forked from jason/echo
Phase 3: per-member namespacing — schema 5 (the core group conversion)
One shared vault, per-member namespaces for all churn-prone state:
- _agent/members/<member>/{preferences,current-context,heartbeat,inbox}.md
+ _agent/sessions/<member>/ session logs; shared group-profile.md
(with auto-rostered ## Members) and inbox/captures/group.md.
- Self-onboarding: load detects a bootstrapped vault lacking THIS
member's namespace and seeds it (bootstrap.member_bootstrap()).
- load = 8 reads (marker, group profile, my prefs/context/heartbeat,
shared daily, my inbox, group inbox); heartbeat fallback lists my
sessions dir; scope show/set is per member.
- Member-tagged shared writes: daily Agent Log lines and entity-update
dated blocks are '- YYYY-MM-DD [member]: …'; capture --inbox targets
my inbox; triage lists both inboxes and writes attributed audit lines.
- routing.json: member-anchors / inbox-group / member-segmented session
routes; single-user anchor paths retired (schema 5). vault_lint runs
aging-inbox + scope-drift per member. migrate.py 4->5 for alpha vaults.
- Scaffold: member-* seeds ({{MEMBER}}-stamped), group-profile/group-inbox
seeds, marker schema 5. Docs (SKILL, vault-layout, routing-map,
bootstrap) synced; manifest -> 2.0.0-alpha.3; rebuilt plugin (81 files).
Verified: all suites green (25/25 unit, scaffold, routing-sync 36 routes,
4 mock e2e, run_eval unchanged) + 19-check two-member smoke: bootstrap ->
self-onboard -> interleaved tagged daily log -> cross-member update
attribution -> independent scopes -> mine/group triage -> lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,7 +64,7 @@ All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/`. **Invok
|
||||
```bash
|
||||
CHORUS="${CLAUDE_PLUGIN_ROOT}/skills/chorus-memory/scripts/chorus.py" # call as: python3 "$CHORUS" <cmd> ...
|
||||
[ -f "$CHORUS" ] || CHORUS=$(ls /sessions/*/mnt/.remote-plugins/*/skills/chorus-memory/scripts/chorus.py 2>/dev/null | head -1) # CoWork sandbox fallback
|
||||
python3 "$CHORUS" load # cold-start: the 6 orientation reads in one call
|
||||
python3 "$CHORUS" load # cold-start: the 8 orientation reads in one call
|
||||
python3 "$CHORUS" get <path> # 404 -> exit 44
|
||||
python3 "$CHORUS" ls <dir> ; python3 "$CHORUS" map <path> # listing / document-map
|
||||
python3 "$CHORUS" search <terms...>
|
||||
@@ -101,7 +101,7 @@ The index is maintained automatically by `capture`; `sweep.py` rebuilds it and b
|
||||
|
||||
### Concurrency — the vault is shared, so coordinate writes
|
||||
|
||||
CHORUS is read/written by multiple clients (Claude Code **and** CoWork sessions). The single-line files (`heartbeat/last-session.md`, `current-context.md::Scope`, `inbox.md`) assume a single writer at a time. Before a burst of writes in a session that may overlap another, take the **advisory lock**, and release it at session end:
|
||||
CHORUS is read/written by **every member's** clients (Claude Code **and** CoWork sessions). The schema-5 member namespaces remove most contention by design — your scope/heartbeat/preferences/inbox are yours alone. What remains genuinely shared-write: the daily note's `## Agent Log` (member-tagged idempotent appends make interleaving safe), the group inbox, shared entity notes, and the machine indexes. Before a burst of writes that may overlap another member's session, take the **advisory lock**, and release it at session end:
|
||||
|
||||
```bash
|
||||
python3 "$CHORUS" lock "cc-<session-id>" # exit 75 if another session holds a fresh lock, or you lost the race
|
||||
@@ -141,22 +141,24 @@ Load at the start of any substantive conversation — anything beyond a single q
|
||||
|
||||
### Loading procedure
|
||||
|
||||
**Run `python3 "$CHORUS" load`** — one call that performs the six orientation reads below and prints each labeled section (404s on today's note / inbox show as absent, not errors; an absent marker is flagged). This replaces issuing six separate GETs by hand. The table documents what `load` reads and why:
|
||||
**Run `python3 "$CHORUS" load`** — one call that performs the eight orientation reads below and prints each labeled section (404s on today's note / inboxes show as absent, not errors; an absent marker is flagged). If the vault is set up but YOUR member namespace isn't, `load` **self-onboards** it (seeds your `_agent/members/<member>/` anchors and sessions folder, and adds you to the group-profile roster). The table documents what `load` reads and why:
|
||||
|
||||
| # | GET | Notes |
|
||||
|---|-----|-------|
|
||||
| 1 | `/vault/_agent/chorus-vault.md` | The bootstrap marker. 404 → vault not set up; follow `references/bootstrap.md`. 200 → check its `schema_version` and migrate if older. |
|
||||
| 2 | `/vault/_agent/memory/semantic/operator-preferences.md` | the operator's profile |
|
||||
| 3 | `/vault/_agent/context/current-context.md` | Active scope + Scope History |
|
||||
| 4 | `/vault/_agent/heartbeat/last-session.md` → then `/vault/_agent/sessions/` | **Read the heartbeat first** — a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) written at the end of the previous session. It's an O(1) jump to the latest log, so you can skip or shortcut the full listing. Fall back to the `sessions/` listing only if the pointer is missing or looks stale; then pick the ~5 most recent by reverse lex sort (filenames `YYYY-MM-DD-HHMM-<slug>.md`, so lex == chrono) and read only the relevant ones. |
|
||||
| 5 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's note; 404 is fine — it's created on first agent activity |
|
||||
| 6 | `/vault/inbox/captures/inbox.md` | Inbox depth probe — feeds the load-time reconcile below. 404 is fine (empty inbox). |
|
||||
| 2 | `/vault/_agent/memory/semantic/group-profile.md` | Shared facts about the group — what the team is, member roster, conventions |
|
||||
| 3 | `/vault/_agent/members/<member>/preferences.md` | THIS member's profile/preferences |
|
||||
| 4 | `/vault/_agent/members/<member>/current-context.md` | THIS member's active scope + Scope History |
|
||||
| 5 | `/vault/_agent/members/<member>/heartbeat.md` → then `/vault/_agent/sessions/<member>/` | **Read the heartbeat first** — a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) written at the end of the member's previous session. It's an O(1) jump to their latest log. Fall back to the member's `sessions/` listing only if the pointer is missing or stale; then pick the ~5 most recent by reverse lex sort (filenames `YYYY-MM-DD-HHMM-<slug>.md`, so lex == chrono) and read only the relevant ones. |
|
||||
| 6 | `/vault/journal/daily/YYYY-MM-DD.md` | Today's SHARED note (the whole group's timeline); 404 is fine — created on first agent activity |
|
||||
| 7 | `/vault/_agent/members/<member>/inbox.md` | MY inbox depth probe — feeds the load-time reconcile below. 404 is fine (empty inbox). |
|
||||
| 8 | `/vault/inbox/captures/group.md` | GROUP inbox depth probe — unowned items any member may triage. 404 is fine. |
|
||||
|
||||
Do not read every session log — older sessions are reachable via `POST /search/simple/?query=...` when needed.
|
||||
|
||||
**Reconcile at load (do this every cold start, after the batch returns).** The batch already fetched everything needed for a cheap self-check — run it before diving into the work so memory maintains itself instead of drifting:
|
||||
|
||||
1. **Inbox depth (Inbox Triage).** If `inbox/captures/inbox.md` (GET #6) holds dated capture lines older than ~7 days that were never routed, surface the count once and offer to triage — see **Inbox Triage** below. This is the load-time trigger that makes triage self-firing rather than something you only run when asked.
|
||||
1. **Inbox depth (Inbox Triage).** If your inbox (GET #7) or the group inbox (GET #8) holds dated capture lines older than ~7 days that were never routed, surface the count once and offer to triage — see **Inbox Triage** below. This is the load-time trigger that makes triage self-firing rather than something you only run when asked.
|
||||
2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — the operator runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `python3 "$CHORUS" scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `python3 "$CHORUS" scope set "<new scope>"` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with the operator rather than trusting it.
|
||||
|
||||
Keep the reconcile to a single short line to the operator (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request.
|
||||
@@ -173,9 +175,9 @@ Do NOT narrate this loading. Reading memory is expected behavior.
|
||||
|
||||
## Inbox Triage (one-tap: `chorus.py triage`)
|
||||
|
||||
`inbox/captures/inbox.md` is the catch-all for "save this somewhere — figure out where later." Without triage it grows forever and durable facts get stranded there.
|
||||
There are TWO inboxes: `_agent/members/<member>/inbox.md` (YOUR catch-all for "save this somewhere — figure out where later") and `inbox/captures/group.md` (unowned group-wide items, as member-tagged lines `- YYYY-MM-DD [<member>]: …` — whoever triages first routes them). `triage --list` covers both. Without triage they grow forever and durable facts get stranded.
|
||||
|
||||
At the start of a substantive session, check the inbox. If it holds lines older than ~7 days that haven't been routed elsewhere, surface them once:
|
||||
At the start of a substantive session, check both inboxes (`load` probes them). If either holds lines older than ~7 days that haven't been routed elsewhere, surface them once:
|
||||
|
||||
> "Three captures from last week are still in the inbox — want to route them now or leave them?"
|
||||
|
||||
@@ -189,7 +191,7 @@ python3 "$CHORUS" triage proposals.json # dry-run: classify + previe
|
||||
python3 "$CHORUS" triage proposals.json --apply # route via capture + log each move
|
||||
```
|
||||
|
||||
Routing targets are the usual homes (preference/pattern → `operator-preferences.md::Observations` via a `semantic` proposal or a direct PATCH; project idea → `project` with `--status incubating`; durable fact → `semantic`; person fact → `person`). `--apply` records each move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original line> → <destination path>`) automatically. Originals are **never deleted** unless the operator explicitly asks — the processing log is the audit trail.
|
||||
Routing targets are the usual homes (a preference/pattern about YOU → your own `_agent/members/<member>/preferences.md` under `Preferences::Observations` via a direct PATCH; project idea → `project` with `--status incubating`; durable group fact → `semantic`; person fact → `person`). Never route another member's preference into your own preferences file — put it in theirs only if their own session confirms it, else `resources/people/`. `--apply` records each move in `inbox/processing-log/YYYY-MM-DD.md` (`- <original line> → <destination path>`) automatically. Originals are **never deleted** unless the operator explicitly asks — the processing log is the audit trail.
|
||||
|
||||
## Project Lifecycle
|
||||
|
||||
@@ -239,7 +241,7 @@ Only after the search comes back empty (or you've decided to merge) is it safe t
|
||||
|
||||
### Before you append — read first (idempotency)
|
||||
|
||||
`chorus.py append` is **whole-line idempotent**: it GETs the file and skips the POST when the exact line already exists, so a retry, replay, or overlapping session can't grow duplicate lines. Use it for single-line additions to `inbox/captures/inbox.md`, a daily note's `## Agent Log`, or a `## Fact / Pattern` / `## Observations` / `## Log` heading. (A raw POST is **not** idempotent — if you must use curl, GET first and whole-line-match the entry yourself; see `references/api-reference.md`.) This does **not** apply to PUT or PATCH `replace`, which overwrite by design.
|
||||
`chorus.py append` is **whole-line idempotent**: it GETs the file and skips the POST when the exact line already exists, so a retry, replay, or overlapping session can't grow duplicate lines. Use it for single-line additions to `_agent/members/<member>/inbox.md`, a daily note's `## Agent Log`, or a `## Fact / Pattern` / `## Observations` / `## Log` heading. (A raw POST is **not** idempotent — if you must use curl, GET first and whole-line-match the entry yourself; see `references/api-reference.md`.) This does **not** apply to PUT or PATCH `replace`, which overwrite by design.
|
||||
|
||||
### Append, patch, put — via the client
|
||||
|
||||
@@ -247,14 +249,14 @@ Write multi-line bodies to a file first (use the Write tool — cross-platform,
|
||||
|
||||
```bash
|
||||
# additive line (idempotent). Anchor the date on the conversation's currentDate.
|
||||
python3 "$CHORUS" append inbox/captures/inbox.md "- <currentDate>: <entry>"
|
||||
python3 "$CHORUS" append _agent/members/<member>/inbox.md "- <currentDate>: <entry>"
|
||||
|
||||
# targeted heading append/replace. The heading Target is the FULL `::`-delimited path
|
||||
# from the H1 — a bare subheading returns 400 invalid-target and the write is LOST.
|
||||
# If unsure of the exact path, GET the document map first and copy the heading verbatim:
|
||||
python3 "$CHORUS" map _agent/memory/semantic/operator-preferences.md
|
||||
python3 "$CHORUS" patch _agent/memory/semantic/operator-preferences.md \
|
||||
append heading "Operator Preferences::Fact / Pattern" <bodyfile>
|
||||
python3 "$CHORUS" map _agent/members/<member>/preferences.md
|
||||
python3 "$CHORUS" patch _agent/members/<member>/preferences.md \
|
||||
append heading "Preferences::Fact / Pattern" <bodyfile>
|
||||
# `replace` (instead of `append`) overwrites a section entirely (e.g. a project's "Name::Status").
|
||||
|
||||
# create/overwrite a whole file (read-back verified; intermediate dirs auto-created)
|
||||
@@ -274,7 +276,7 @@ Every PUT body needs the canonical YAML frontmatter (see `references/vault-layou
|
||||
|
||||
## Scope Switching (`current-context.md`)
|
||||
|
||||
`_agent/context/current-context.md` tracks a single active scope. The operator routinely shifts scope within a day (one project → another → docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
`_agent/members/<member>/current-context.md` tracks a single active scope. The operator routinely shifts scope within a day (one project → another → docs), so this is high-churn — switch deliberately, every time the work changes topic.
|
||||
|
||||
**Preferred — one command:**
|
||||
|
||||
@@ -321,7 +323,7 @@ CHORUS_TODAY=<currentDate> python3 "$LINT"
|
||||
Exit codes: `0` clean · `1` violations · `2` unreachable · `3` not bootstrapped. Checks (the linter asserts each and prints violations):
|
||||
|
||||
1. **Stale active projects** — for each note in `projects/active/`, check `updated:` >30 days. Likely belongs in `on-hold/`.
|
||||
2. **Unprocessed inbox** — GET `inbox/captures/inbox.md`. List items older than 14 days that never moved through the triage protocol.
|
||||
2. **Unprocessed inbox** — GET `_agent/members/<member>/inbox.md`. List items older than 14 days that never moved through the triage protocol.
|
||||
3. **Duplicate slugs across lifecycle folders** — any slug appearing in more than one of `active/`, `incubating/`, `on-hold/`, `archived/` is broken state.
|
||||
4. **Folder ↔ `status:` mismatch** — any `projects/<lifecycle>/` note whose `status:` frontmatter disagrees with its folder.
|
||||
5. **Wikilinks in frontmatter** — any `[[...]]` inside a YAML frontmatter block (breaks Obsidian reading view), swept across all folders.
|
||||
@@ -350,7 +352,7 @@ The pass is cheap and pays for itself by catching drift before it requires a reo
|
||||
|
||||
## Daily Note — Agent Log
|
||||
|
||||
After substantive activity, write a one-line entry to today's daily note's `## Agent Log` heading. The daily-note **template** (`journal/templates/daily-note-template.md`) defines this heading, but ad-hoc daily notes created without the template don't have it — so PATCH can fail with `invalid-target` if the note exists but lacks the heading.
|
||||
After substantive activity, write a one-line entry to today's daily note's `## Agent Log` heading. The daily note is the **group's shared timeline** — every member's sessions append to the same heading — so **every line is member-tagged**: `- YYYY-MM-DD [<member>]: <what happened>`. (`capture` writes its log line in this form automatically.) The daily-note **template** (`journal/templates/daily-note-template.md`) defines this heading, but ad-hoc daily notes created without the template don't have it — so PATCH can fail with `invalid-target` if the note exists but lacks the heading.
|
||||
|
||||
**Procedure (resilient)** — all dates are the conversation's `currentDate`, not the machine clock:
|
||||
|
||||
@@ -367,13 +369,14 @@ python3 "$CHORUS" patch journal/daily/<currentDate>.md append heading "<currentD
|
||||
|
||||
| Situation | File / path | Method |
|
||||
|-----------|-------------|--------|
|
||||
| Unsure where it goes / quick capture | `inbox/captures/inbox.md` (date-prefixed line) | POST |
|
||||
| Operator preference or durable fact | `_agent/memory/semantic/operator-preferences.md` (append under the right heading) | PATCH |
|
||||
| Other durable facts / patterns | `_agent/memory/semantic/<slug>.md` | PUT |
|
||||
| Unsure where it goes / quick capture (mine) | `_agent/members/<member>/inbox.md` (date-prefixed line) | POST |
|
||||
| Unowned group-wide capture ("someone should look at this") | `inbox/captures/group.md` (member-tagged line `- YYYY-MM-DD [<member>]: …`) | POST |
|
||||
| MY preference or personal pattern | `_agent/members/<member>/preferences.md` (append under the right heading) | PATCH |
|
||||
| Durable group facts / patterns (incl. `group-profile.md`) | `_agent/memory/semantic/<slug>.md` | PUT |
|
||||
| What happened (event record) | `_agent/memory/episodic/<slug>.md` | PUT |
|
||||
| Short-lived working state | `_agent/memory/working/<slug>.md` | PUT |
|
||||
| Task-scoped context / focus | `_agent/context/current-context.md` | PATCH / PUT |
|
||||
| Working session ended with substance | `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
|
||||
| Task-scoped context / focus | `_agent/members/<member>/current-context.md` | PATCH / PUT |
|
||||
| Working session ended with substance | `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
|
||||
| Long-running project state | `projects/<lifecycle>/<slug>.md` (see Project Lifecycle) | PUT + PATCH |
|
||||
| Ongoing area of responsibility (standing domain, no end state) | `areas/<domain>/<slug>.md` (domain: `business`/`personal`/`learning`/`systems`) | PUT |
|
||||
| Non-obvious decision (ADR) | `decisions/by-date/YYYY-MM-DD-<slug>.md` (see mirror note below) | PUT |
|
||||
@@ -386,7 +389,7 @@ python3 "$CHORUS" patch journal/daily/<currentDate>.md append heading "<currentD
|
||||
| Weekly / monthly rollup | `journal/weekly/YYYY-Www.md` · `journal/monthly/YYYY-MM.md` — see **Journal Rollups** | PUT |
|
||||
| Quarterly / annual review | `journal/quarterly/YYYY-Qn.md` · `journal/annual/YYYY.md` (manual / on request) | PUT |
|
||||
| Vault-health audit (agent self-maintenance) | `_agent/health/YYYY-MM-vault-health.md` — see **Vault Health** | PUT |
|
||||
| Session-end orientation pointer | `_agent/heartbeat/last-session.md` (one line: `<session-log-path> @ <ISO-timestamp>`) | PUT |
|
||||
| Session-end orientation pointer | `_agent/members/<member>/heartbeat.md` (one line: `<session-log-path> @ <ISO-timestamp>`) | PUT |
|
||||
|
||||
> **The complete, audited routing map lives in `references/routing-map.md`** — every write destination with its trigger, what lands there, and why it's distinct from its neighbors. This table is the quick-reference; the map is the authority. If a path isn't in the map, it shouldn't be written to.
|
||||
|
||||
@@ -404,7 +407,7 @@ Never delete files unless the operator explicitly asks. Memory is append-friendl
|
||||
|
||||
At the end of substantive conversations (ones that produced decisions, artifacts, or commitments), create a session log. Ask once if unsure: "Want me to log a session note for this?"
|
||||
|
||||
**Filename format is canonical: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`.** Always include the four-digit local-time HHMM component — it makes filenames lexically sort in true chronological order, which is what Step 3 of loading relies on. Older session logs without the HHMM part exist; leave them alone, but every new one must use the full form.
|
||||
**Filename format is canonical: `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md`.** Always include the four-digit local-time HHMM component — it makes filenames lexically sort in true chronological order, which is what Step 3 of loading relies on. Older session logs without the HHMM part exist; leave them alone, but every new one must use the full form.
|
||||
|
||||
Write the body (see `references/session-log-template.md`) to a file with the Write tool, then:
|
||||
|
||||
@@ -417,7 +420,7 @@ Then add a one-line entry to today's daily note via the **Daily Note — Agent L
|
||||
Finally, update the heartbeat pointer so the next session can orient in one read (this closes the loop with loading-procedure Step 4 — a pointer nobody writes is a pointer nobody can read). It is a single line, `<session-log-path> @ <ISO-timestamp>`; write it to a file and PUT it:
|
||||
|
||||
```bash
|
||||
python3 "$CHORUS" put _agent/heartbeat/last-session.md <bodyfile>
|
||||
python3 "$CHORUS" put _agent/members/<member>/heartbeat.md <bodyfile>
|
||||
```
|
||||
|
||||
`last-session.md` is overwritten (PUT) each session end — never appended, so it can't grow or duplicate.
|
||||
@@ -429,7 +432,7 @@ If the API returns a connection error, timeout, or `502`, tell the operator once
|
||||
## Style Rules
|
||||
|
||||
- Write in third person about the operator: "the operator prefers X", not "I prefer X".
|
||||
- This vault holds only its own owner's memory. Do not cross-write between distinct vaults/owners — another person's vault and preferences belong in their own vault, not here.
|
||||
- This vault holds the GROUP's shared memory — every member reads and writes it, and everything in it is visible to the whole group. Do not edit another member's `_agent/members/<id>/` namespace (their preferences/scope/heartbeat/inbox belong to their sessions); facts about them as a person go in `resources/people/`. Do not cross-write with vaults outside this group.
|
||||
- **Anchor relative dates on the conversation's `currentDate`** before writing. "Today" → `currentDate`. "Thursday" / "next week" → resolve to an absolute `YYYY-MM-DD`. Never guess from training-data knowledge of the current year.
|
||||
- Every memory file has canonical YAML frontmatter — see `references/vault-layout.md`.
|
||||
- Set `agent_written: true` and `author: <your-member-id>` on agent-generated notes and list `source_notes` (plain relative paths, not links).
|
||||
@@ -442,7 +445,7 @@ If the API returns a connection error, timeout, or `502`, tell the operator once
|
||||
|
||||
## operator-preferences.md — Rules vs Observations
|
||||
|
||||
`_agent/memory/semantic/operator-preferences.md` separates two kinds of content:
|
||||
`_agent/members/<member>/preferences.md` separates two kinds of content:
|
||||
|
||||
- `## Fact / Pattern` — **promoted, deduped rules.** No date prefix. These are timeless: "the operator prefers concise communication." Append here only when a rule is stable.
|
||||
- `## Observations` — **timestamped raw observations.** Date-prefixed: `- 2026-06-06: the operator chose X over Y because Z.` This is where new evidence goes by default.
|
||||
|
||||
Reference in New Issue
Block a user