1
0
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:
2026-07-21 15:16:13 -05:00
parent b3bc5272d5
commit 33a2d73a22
31 changed files with 449 additions and 184 deletions
+47
View File
@@ -1,5 +1,52 @@
# Changelog
## 2.0.0-alpha.3 — Phase 3 (per-member namespacing — schema 5)
The core of the group conversion: one shared vault, per-member namespaces for all
churn-prone state. A CHORUS vault now bootstraps multi-member from empty.
### Added — vault layout (schema 5)
- **`_agent/members/<member>/`** — each member's `preferences.md`,
`current-context.md` (scope + history), `heartbeat.md` (last-session pointer,
written at session end), and `inbox.md`. Members never contend on each other's
churn-prone files.
- **`_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md`** — member-namespaced
session logs (no same-minute collisions, filterable per member).
- **`_agent/memory/semantic/group-profile.md`** — shared group facts, conventions,
and a `## Members` roster (members are rostered automatically at onboarding).
- **`inbox/captures/group.md`** — shared inbox for unowned member-tagged items;
whoever triages first routes them.
### Added — behavior
- **Self-onboarding**: `chorus.py load` detects a bootstrapped vault that lacks THIS
member's namespace and stands it up automatically (anchors + sessions dir +
roster line). New members install their baked plugin and just start.
- `load` is now 8 orientation reads: marker → group profile → my preferences /
context / heartbeat → today's shared daily note → my inbox → group inbox; the
heartbeat fallback lists MY sessions dir.
- `scope show/set` operates on the member's own current-context; drift counts only
that member's sessions.
- **Member-tagged shared writes**: daily `## Agent Log` lines and entity-update
dated blocks are written as `- YYYY-MM-DD [member]: …`; `capture --inbox` lands
in the member's own inbox; triage lists BOTH inboxes (tagged mine/group) and
writes member-attributed audit lines.
- `bootstrap.py` seeds group anchors + the configured member's namespace
(`member_bootstrap()` is reusable); the marker is schema 5; `migrate.py` gains a
4→5 step for alpha vaults (moves pre-5 anchors into the configured member's
namespace; no ECHO adoption path).
- `routing.json`: new `member-anchors`, `inbox-group`, member-segmented sessions
routes; the old single-user anchor paths (shared inbox, operator-preferences,
current-context, heartbeat, flat sessions) are **retired**. `vault_lint` runs
aging-inbox and scope-drift checks per member across all members found in the
vault. Docs (SKILL, vault-layout, routing-map, bootstrap) updated to match.
### Verified
- All suites green (25/25 unit, scaffold, routing-sync ×36 routes, 4 mock e2e,
run_eval metrics unchanged) plus a 19-check two-member end-to-end smoke:
jason bootstraps → alice self-onboards via load → interleaved member-tagged
daily log → cross-member entity update attributed → independent scopes →
triage separates personal vs group items → lint clean.
## 2.0.0-alpha.2 — Phase 2 (identity plumbing)
Every write is now attributed to a member. Vault layout unchanged (that's Phase 3).
+1 -1
View File
@@ -197,7 +197,7 @@ does NOT backfill — attribution can't be guessed), member-aware lock owner
Vault layout untouched. **Deliverable:** every new write is attributed;
single-user behavior otherwise identical.
### Phase 3 — Per-member namespacing (35 days — the core)
### Phase 3 — Per-member namespacing (35 days — the core) — DONE
Schema 5. New `LEAVES` + seeds (`members/<id>/…`, `group-profile.md`),
member-parameterized bootstrap (bootstraps *your* member subtree on first load
if the vault exists but your namespace doesn't — new members self-onboard),
+3 -3
View File
@@ -1,10 +1,10 @@
# chorus-memory — v2.0.0-alpha.2
# chorus-memory — v2.0.0-alpha.3
> **CHORUS is a group-based fork of [ECHO](https://git.alwisp.com/jason/echo)** — one shared
> Obsidian vault serving a group of members instead of a single operator. The conversion
> plan and settled design decisions live in [CHORUS-PLAN.md](CHORUS-PLAN.md). The pre-fork
> ECHO v1.5.1 state is preserved at the `echo-fork-point` tag. **Phase 1 (mechanical
> rename) is done**; the body of this README below the fold still describes the
> ECHO v1.5.1 state is preserved at the `echo-fork-point` tag. **Phases 13 (through per-member
> namespacing) are done**; the body of this README below the fold still describes the
> inherited single-operator behavior and is rewritten in Phase 5.
> Deployment endpoint: `https://chorusapi.mpm.to`. CHORUS is a **clean break** from
> ECHO — fresh vaults on fresh machines; it carries no ECHO back-compat and never
Binary file not shown.
@@ -1,6 +1,6 @@
{
"name": "chorus-memory",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.3",
"description": "Group-based persistent memory via the shared CHORUS Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /chorus-* commands.",
"author": {
"name": "Jason Stedwell"
@@ -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.
@@ -29,7 +29,7 @@ For a vault large enough that even the concurrent pass approaches the tool timeo
# Read any file by vault path
curl -s \
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/_agent/context/current-context.md"
"$CHORUS_BASE/vault/_agent/members/<member>/current-context.md"
```
Returns raw file content (text/markdown). On 404, the file does not exist.
@@ -38,7 +38,7 @@ Returns raw file content (text/markdown). On 404, the file does not exist.
# Read a specific heading's content only
curl -s \
-H "Authorization: Bearer $CHORUS_KEY" \
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md/heading/Operator"
"$CHORUS_BASE/vault/_agent/members/<member>/preferences.md/heading/Operator"
```
Nested headings: separate levels with `::` (URL-encode spaces as `%20`):
@@ -74,7 +74,7 @@ curl -s -X POST \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_entry.md \
"$CHORUS_BASE/vault/inbox/captures/inbox.md"
"$CHORUS_BASE/vault/_agent/members/<member>/inbox.md"
```
---
@@ -116,7 +116,7 @@ curl -s -X PUT \
### Append under a heading
**Heading targets must be the FULL heading path, `::`-delimited from the top-level heading.** A bare subheading name returns `400 invalid-target` (errorCode 40080). Example: `## Fact / Pattern` nested under `# Operator Preferences``Target: Operator Preferences::Fact / Pattern`. Percent-encode non-ASCII characters (e.g. `H%C3%A9llo`); spaces are fine.
**Heading targets must be the FULL heading path, `::`-delimited from the top-level heading.** A bare subheading name returns `400 invalid-target` (errorCode 40080). Example: `## Fact / Pattern` nested under `# Operator Preferences``Target: Preferences::Fact / Pattern`. Percent-encode non-ASCII characters (e.g. `H%C3%A9llo`); spaces are fine.
```bash
cat > /tmp/obs_patch.md << 'OBSEOF'
@@ -127,10 +127,10 @@ curl -s -X PATCH \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Operation: append" \
-H "Target-Type: heading" \
-H "Target: Operator Preferences::Fact / Pattern" \
-H "Target: Preferences::Fact / Pattern" \
-H "Content-Type: text/markdown" \
--data-binary @/tmp/obs_patch.md \
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md"
"$CHORUS_BASE/vault/_agent/members/<member>/preferences.md"
```
### Discover heading / block / frontmatter targets
@@ -141,7 +141,7 @@ When unsure of the exact heading path, GET the note with the document-map Accept
curl -s \
-H "Authorization: Bearer $CHORUS_KEY" \
-H "Accept: application/vnd.olrapi.document-map+json" \
"$CHORUS_BASE/vault/_agent/memory/semantic/operator-preferences.md"
"$CHORUS_BASE/vault/_agent/members/<member>/preferences.md"
```
Returns `{ "headings": [...], "blocks": [...], "frontmatterFields": [...] }`. Copy the heading string verbatim into `Target`.
@@ -211,7 +211,7 @@ Only on explicit operator request. Deletion is destructive.
- Path separators (`/`) in the vault path are **literal** — do not encode them.
- Spaces in filenames or heading targets in a URL: use `%20`.
- Nested heading levels in a URL path: use `%3A%3A` for `::`.
- Heading text in the `Target:` header: the **full heading path** joined by `::` (e.g. `Operator Preferences::Fact / Pattern`), no `#`; spaces are fine; percent-encode non-ASCII.
- Heading text in the `Target:` header: the **full heading path** joined by `::` (e.g. `Preferences::Fact / Pattern`), no `#`; spaces are fine; percent-encode non-ASCII.
---
@@ -219,14 +219,14 @@ Only on explicit operator request. Deletion is destructive.
| Situation | Vault path | Method |
|-----------|-----------|--------|
| Quick capture / unsorted | `inbox/captures/inbox.md` (date-prefixed line) | POST |
| Quick capture / unsorted | `_agent/members/<member>/inbox.md` (date-prefixed line) | POST |
| Raw imported material | `inbox/imports/` | PUT |
| Operator preference / durable fact | `_agent/memory/semantic/operator-preferences.md` | PATCH |
| Operator preference / durable fact | `_agent/members/<member>/preferences.md` | PATCH |
| Other durable facts, patterns | `_agent/memory/semantic/<slug>.md` | PUT |
| Event record (what happened) | `_agent/memory/episodic/<slug>.md` | PUT |
| Short-lived, time-boxed state | `_agent/memory/working/<slug>.md` | PUT |
| Task-scoped context / focus | `_agent/context/current-context.md` | PATCH / PUT |
| Working-session log | `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
| Task-scoped context / focus | `_agent/members/<member>/current-context.md` | PATCH / PUT |
| Working-session log | `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md` | PUT |
| Long-running project state | `projects/<lifecycle>/<slug>.md` (lifecycle: `incubating``active``on-hold`/`archived`; folder and `status:` MUST agree) | 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` (mirror only into an existing project note's `## Key Decisions`; otherwise skip) | PUT |
@@ -238,7 +238,7 @@ Only on explicit operator request. Deletion is destructive.
| Daily activity / Agent Log | `journal/daily/YYYY-MM-DD.md` | POST / PATCH |
| Journal rollup | `journal/{weekly/YYYY-Www,monthly/YYYY-MM,quarterly/YYYY-Qn,annual/YYYY}.md` (weekly = opt-in on first session of a new ISO week; monthly = offered with Vault Health; quarterly/annual = manual) | PUT |
| Vault-health audit (agent self-maintenance) | `_agent/health/YYYY-MM-vault-health.md` (monthly; NOT a journal entry) | PUT |
| Session-end orientation pointer | `_agent/heartbeat/last-session.md` (one line, overwritten each session end) | PUT |
| Session-end orientation pointer | `_agent/members/<member>/heartbeat.md` (one line, overwritten each session end) | PUT |
| Bootstrap marker (plugin-owned) | `_agent/chorus-vault.md` (`schema_version`, bootstrap date) — the "is this vault set up?" probe | GET / PUT |
**Slug rules:** kebab-case, ASCII, ~40 chars max. Every file carries canonical frontmatter (see `vault-layout.md`).
@@ -43,7 +43,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "$AUTH" "$BASE/vault/_agent/chorus-vau
Idempotent and additive. **Never overwrite an existing file** — GET-probe each path and skip any that already returns `200`. The marker is written **last**, so the vault is only flagged "set up" after every piece is in place.
Throughout, `{{DATE}}` means today's date (`YYYY-MM-DD`), resolved from the conversation's `currentDate`. Substitute it before PUTting any scaffold file or anchor seed.
Throughout, `{{DATE}}` means today's date (`YYYY-MM-DD`), resolved from the conversation's `currentDate`, and `{{MEMBER}}` means this machine's configured member id. Substitute both before PUTting any scaffold file or anchor seed.
### 1. Folder tree
@@ -57,10 +57,13 @@ areas/business areas/personal areas/learning areas/systems
resources/concepts resources/references resources/people resources/companies resources/meetings
decisions/by-date
_agent/context _agent/memory/working _agent/memory/episodic _agent/memory/semantic
_agent/sessions _agent/health _agent/templates _agent/heartbeat
_agent/sessions _agent/health _agent/templates _agent/members
_agent/skills/active _agent/skills/archived
```
Plus, per member (created by `bootstrap.py` for the configured member, and by `chorus.py load`'s
self-onboarding for any later member): `_agent/sessions/<member>` (→ README).
> `decisions/by-project/` is intentionally **not** created — it is retired. A project-relevant decision is mirrored as a `[[wikilink]]` under that project's `## Key Decisions` heading instead.
>
> `reviews/` is **not** created — it is retired. Journal rollups (weekly/monthly/quarterly/annual) live under `journal/`; the monthly vault-health audit lives under `_agent/health/`.
@@ -101,15 +104,28 @@ curl -s -X PUT -H "$AUTH" -H "Content-Type: text/markdown" \
### 3. Anchor seeds (only if absent — no fabricated facts)
Substitute `{{DATE}}` → today, then PUT each:
Substitute `{{DATE}}` → today and `{{MEMBER}}` → the configured member, then PUT each.
**Shared group anchors** (once per vault):
| Scaffold file | Vault path |
|---|---|
| `scaffold/anchors/operator-preferences.seed.md` | `_agent/memory/semantic/operator-preferences.md` |
| `scaffold/anchors/current-context.seed.md` | `_agent/context/current-context.md` |
| `scaffold/anchors/inbox.seed.md` | `inbox/captures/inbox.md` |
| `scaffold/anchors/group-profile.seed.md` | `_agent/memory/semantic/group-profile.md` |
| `scaffold/anchors/group-inbox.seed.md` | `inbox/captures/group.md` |
The operator-preferences seed is deliberately empty — **do not invent preferences.** Real facts accrue through use.
**Member anchors** (once per member — `bootstrap.py member_bootstrap()`, also invoked by
`chorus.py load`'s self-onboarding; a roster line `- <member> (joined YYYY-MM-DD)` is appended
idempotently under the group profile's `## Members`):
| Scaffold file | Vault path |
|---|---|
| `scaffold/anchors/member-preferences.seed.md` | `_agent/members/<member>/preferences.md` |
| `scaffold/anchors/member-context.seed.md` | `_agent/members/<member>/current-context.md` |
| `scaffold/anchors/member-inbox.seed.md` | `_agent/members/<member>/inbox.md` |
`_agent/members/<member>/heartbeat.md` is deliberately NOT seeded — it appears at the first
session end. The preferences seed is deliberately empty — **do not invent preferences.** Real
facts accrue through use.
### 4. Vault README (human signpost)
@@ -39,7 +39,7 @@ Assume clients may operate without filesystem access, through the Obsidian Local
The vault is a **shared** substrate — Claude Code, CoWork, and other REST API clients may operate on it concurrently. The REST API offers no transactions, so writers coordinate cooperatively:
- Single-line, overwrite-style files (`_agent/heartbeat/last-session.md`, `current-context.md::Scope`) and append targets (`inbox.md`, `## Agent Log`) assume **one writer at a time**. Two sessions writing at once can clobber or duplicate.
- Single-line, overwrite-style files (`_agent/members/<member>/heartbeat.md`, `current-context.md::Scope`) and append targets (`inbox.md`, `## Agent Log`) assume **one writer at a time**. Two sessions writing at once can clobber or duplicate.
- Before a burst of writes in a session that may overlap another, take the advisory lock (`chorus.py lock <id>``_agent/locks/vault.lock`) and release it at session end. The lock is read-back-confirmed and cooperative with a TTL (stale locks are reclaimable); it is a courtesy, not a hard mutex.
- Idempotent append (read-before-POST, whole-line match, via `chorus.py append`) is the second line of defense against duplicate lines from retries or overlapping sessions.
- Status-check every write. A write that returns `>= 400` did **not** land — surface it rather than assuming success.
@@ -17,8 +17,8 @@ Views of the same truth: `scripts/routing.json` is the **machine-readable canoni
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `inbox/captures/inbox.md` | "Save this, sort later" — destination genuinely unknown at capture time | One date-prefixed line | The only path whose contract is *deferred routing*; everything else here has a known home | POST |
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs `captures` which holds single lines | PUT |
| `inbox/captures/group.md` | Unowned "someone should look at this" capture for the whole group | One date-prefixed **member-tagged** line (`- YYYY-MM-DD [member]: …`) | Shared deferred routing — whoever triages first routes it. Personal captures go to your own `_agent/members/<member>/inbox.md` | POST |
| `inbox/imports/<slug>.md` | Raw external material dropped in wholesale (export, paste, dump) | The raw artifact, unedited | Holds un-triaged *bulk*, vs captures which hold single lines | PUT |
| `inbox/processing-log/YYYY-MM-DD.md` | An inbox item is routed to its real home | One line: `<original> → <destination path>` | Audit trail of moves — never holds memory itself, only the record of routing | POST |
Captures and imports are temporary by contract. Triage drains them into the homes below and logs the move; the original is left until the operator okays deletion.
@@ -79,14 +79,16 @@ Lifecycle folders; `status:` frontmatter MUST equal the folder name (the linter
| Path | Trigger | What lands | Distinct because | Method |
|------|---------|------------|------------------|--------|
| `_agent/chorus-vault.md` | Bootstrap / schema migration only | Marker: `schema_version`, bootstrap date | Plugin-owned probe; never hand-edited | GET / PUT |
| `_agent/context/current-context.md` | Active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Single *live* scope pointer, vs episodic which is a *past* record | PATCH / PUT |
| `_agent/memory/semantic/operator-preferences.md` | A preference/pattern about the operator | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The one curated profile; distinct from ad-hoc semantic notes | PATCH |
| `_agent/memory/semantic/<slug>.md` | A durable fact/pattern that isn't an operator-preference | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
| `_agent/members/<member>/current-context.md` | THIS member's active scope changes; task focus shifts | `## Scope`, `## Scope History`, priorities | Per-member live scope pointer — members work on different things concurrently | PATCH / PUT |
| `_agent/members/<member>/preferences.md` | A preference/pattern about THIS member | Append under `## Observations`; promote to `## Fact / Pattern` when stable | The member's curated profile; only their own sessions edit it (group-visible by design) | PATCH |
| `_agent/members/<member>/heartbeat.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | Per-member O(1) orientation pointer read first at load; overwritten, never grows | PUT |
| `_agent/members/<member>/inbox.md` | THIS member's "save this, sort later" capture | One date-prefixed line | Personal deferred routing — no cross-member append contention | POST |
| `_agent/context/<slug>.md` | Shared task bundles / reading lists for the group | Context bundle | Task-scoped shared context; the live scope itself is per member (`members/`) | PATCH / PUT |
| `_agent/memory/semantic/<slug>.md` | A durable group fact/pattern (incl. `group-profile.md`) | Semantic note | Timeless fact, vs episodic (time-stamped event) and working (transient) | PUT |
| `_agent/memory/episodic/<slug>.md` | A record of *what happened, when* | Episodic note | Anchored to an event in time; not a standing fact | PUT |
| `_agent/memory/working/<slug>.md` | Short-lived state needed only for the current effort | Working note | Explicitly transient/time-boxed; safe to go stale | PUT |
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record; the unit loading Step 4 scans | PUT |
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT the operator's work narrative, so not in `journal/` | PUT |
| `_agent/heartbeat/last-session.md` | End of every session, after the session log is written | One line: `<session-log-path> @ <ISO-timestamp>` | O(1) orientation pointer read first at load (Step 4); overwritten, never grows | PUT |
| `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md` | A substantive session ends (decisions/artifacts/commitments) | Session log (see template) | Per-session record, namespaced by member; the unit loading's heartbeat fallback scans | PUT |
| `_agent/health/YYYY-MM-vault-health.md` | First substantive session of a month (Vault Health pass) | Health-audit findings | Agent self-maintenance about vault integrity — NOT a work narrative, so not in `journal/` | PUT |
| `_agent/templates/` | Bootstrap only (seeded from plugin masters) | Canonical note templates | Holds templates, not memory; never a runtime routing target | PUT (seed) |
| `_agent/skills/active/<slug>.md` | The operator authors/installs a skill and wants it catalogued | Skill capability entry | Catalogs a *capability*, vs `projects/` which tracks the *build effort* | PUT |
| `_agent/skills/archived/<slug>.md` | A catalogued skill is retired | Skill entry (moved from `active/`) | Terminal state of the skill catalog | PUT |
@@ -112,6 +114,11 @@ Listed so they are recognised as dead and never recreated. Any one of these appe
| Path | Status | Where it went instead |
|------|--------|-----------------------|
| `inbox/captures/inbox.md` (single shared inbox) | Retired in schema 5 | Personal captures → `_agent/members/<member>/inbox.md`; unowned items → `inbox/captures/group.md` |
| `_agent/memory/semantic/operator-preferences.md` | Retired in schema 5 | Per-member `_agent/members/<member>/preferences.md` |
| `_agent/context/current-context.md` (single shared scope) | Retired in schema 5 | Per-member `_agent/members/<member>/current-context.md` |
| `_agent/heartbeat/` (single shared pointer) | Retired in schema 5 | Per-member `_agent/members/<member>/heartbeat.md` |
| `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md` (flat, unattributed) | Retired in schema 5 | Member-namespaced `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md` |
| `reviews/` (weekly/monthly/quarterly/annual) | Retired in schema 2 | Journal rollups → `journal/{weekly,monthly,quarterly,annual}/`; vault-health → `_agent/health/` |
| `decisions/by-project/` | Retired in schema 1 | ADR mirrored as a `[[wikilink]]` under the project's `## Key Decisions` |
| `archive/` (top-level) | Never existed | Project archival → `projects/archived/`; skill archival → `_agent/skills/archived/` |
@@ -1,6 +1,6 @@
# Session Log Template
Session logs go in: `_agent/sessions/YYYY-MM-DD-HHMM-<slug>.md`
Session logs go in: `_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md`
**Filename format is canonical and not optional.** The four-digit local-time HHMM component is what makes session filenames lex-sort in true chronological order — the loading procedure depends on it. Before PUT-ing a new session log, validate the filename matches `^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$`. Legacy session logs without HHMM exist in the vault; do not edit their names, but every new write must use the full form.
@@ -8,7 +8,8 @@
/vault/
├── README.md ← thin human signpost (NOT read for routing)
├── inbox/
│ ├── captures/ ← quick captures (inbox.md), date-prefixed lines
│ ├── captures/ ← group.md — unowned group-wide captures (member-tagged lines);
│ │ personal captures live in _agent/members/<member>/inbox.md
│ ├── imports/ ← raw imported material
│ └── processing-log/
├── journal/ ← one append-only time-series stream; rollups are coarser journal entries, NOT a separate reviews/ tree
@@ -37,23 +38,28 @@
│ (reviews/ is retired — journal rollups live under journal/; vault-health audits under _agent/health/)
└── _agent/
├── chorus-vault.md ← bootstrap marker: schema_version + bootstrap date (plugin-owned; the "is this vault set up?" probe)
├── context/ ← current-context.md and task bundles
├── members/ ← PER-MEMBER namespaces (schema 5) — each member's churn-prone state
│ └── <member>/
│ ├── preferences.md ← this member's profile/preferences (group-visible)
│ ├── current-context.md ← this member's active scope + Scope History
│ ├── heartbeat.md ← this member's last-session pointer (written at session end)
│ └── inbox.md ← this member's quick captures
├── context/ ← shared task bundles / reading lists (scope itself is per member)
├── memory/
│ ├── working/ ← transient, time-boxed
│ ├── episodic/ ← what happened, when
│ └── semantic/ ← durable facts/patterns (operator-preferences.md)
├── sessions/ ← YYYY-MM-DD-HHMM-<slug>.md
│ └── semantic/ ← durable group facts/patterns (group-profile.md)
├── sessions/ ← <member>/YYYY-MM-DD-HHMM-<slug>.md (member-namespaced session logs)
├── health/ ← YYYY-MM-vault-health.md (monthly self-maintenance audit; NOT a journal entry)
├── templates/ ← canonical note templates
├── skills/ ← active / archived
├── heartbeat/ ← single-line pointers (e.g. last-session.md → most-recent session log path)
├── index/ ← entities.json — machine-maintained slug→{path,kind,title,aliases} registry
└── locks/ ← vault.lock — cooperative advisory multi-writer lock
```
**Entity index:** `_agent/index/entities.json` is a machine-maintained registry mapping each entity slug to its `{path, kind, title, aliases, last_seen}`. It makes routing/resolve an O(1), alias-aware lookup and supplies the name→path map for cross-linking and recall. `resolve` matches slug/title/alias exactly; when nothing matches exactly it falls back to **fuzzy candidates** — entities sharing a distinctive token with the mention — so a shortened name ("chorus memory" → the project `chorus`) surfaces the existing note instead of spawning a duplicate. Aliases are auto-derived from titles, learned from mentions on update, and re-folded from note frontmatter on `sweep`. It is rebuilt automatically by `chorus.py capture` and by `sweep.py`; do not hand-edit. The only JSON file in the vault.
**Heartbeat:** `_agent/heartbeat/last-session.md` is a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) the **session-logging procedure writes (PUT, overwrite) at session end** and the **loading procedure reads first (Step 4)** as an O(1) shortcut to the latest session log. It is a hint, not a source of truth — fall back to the `sessions/` directory listing if it's missing or stale. Because it's PUT-overwritten, it never grows or duplicates.
**Heartbeat:** `_agent/members/<member>/heartbeat.md` is a one-line pointer (`<session-log-path> @ <ISO-timestamp>`) the **session-logging procedure writes (PUT, overwrite) at session end** and the **loading procedure reads first (Step 4)** as an O(1) shortcut to the latest session log. It is a hint, not a source of truth — fall back to the `sessions/` directory listing if it's missing or stale. Because it's PUT-overwritten, it never grows or duplicates.
**Slug rules:** kebab-case, ASCII only, truncate to ~40 chars.
@@ -0,0 +1,3 @@
# Inbox — Group
Unowned "someone should look at this" captures for the whole group, as date-prefixed **member-tagged** lines (`- {{DATE}} [member]: <thing>`), one per line, via idempotent append. Any member's triage may route these; the processing log records who routed what. Personal captures belong in your own `_agent/members/<you>/inbox.md`.
@@ -0,0 +1,28 @@
---
type: semantic-memory
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, group, profile]
agent_written: true
author: {{MEMBER}}
source_notes: []
---
# Group Profile
<!-- Shared facts about the group itself — what the team is, how it works, and
durable group-wide conventions. Read by every member's cold-start load.
Facts about ONE member belong in their _agent/members/<id>/preferences.md;
facts about a person as a person belong in resources/people/. -->
## Group
<!-- One paragraph: what this group is and what the vault is for. -->
## Members
<!-- One line per member, appended automatically at onboarding: `- <member-id> (joined YYYY-MM-DD)` -->
## Conventions
<!-- Durable group-wide working agreements. -->
## Related
@@ -1,3 +0,0 @@
# Inbox — Captures
Quick captures land here as date-prefixed lines (`- {{DATE}}: <thing>`), one per line, via POST. Triage routes durable items to their proper home (see the chorus-memory skill's Inbox Triage). Don't delete originals on triage — the processing log is the audit trail.
@@ -0,0 +1,3 @@
# Inbox — {{MEMBER}}
{{MEMBER}}'s quick captures land here as date-prefixed lines (`- {{DATE}}: <thing>`), one per line, via POST. Triage routes durable items to their proper home (see the chorus-memory skill's Inbox Triage). Don't delete originals on triage — the processing log is the audit trail. Items for the whole group belong in `inbox/captures/group.md`.
@@ -0,0 +1,35 @@
---
type: semantic-memory
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, member, preferences]
agent_written: true
author: {{MEMBER}}
source_notes: []
confidence: low
last_reviewed: {{DATE}}
---
# Preferences
<!-- This member's preferences — how the agent should work FOR {{MEMBER}}.
Group-visible by design (one vault, one key); truly private material belongs
in a personal vault, not CHORUS. Only {{MEMBER}}'s sessions should edit this
file. -->
## Member
<!-- One paragraph: who {{MEMBER}} is (role, focus). Leave for them to confirm; do not fabricate. -->
## Fact / Pattern
<!-- Promoted, deduped, timeless rules. No date prefix. Add only when a rule is stable. -->
## Observations
<!-- Timestamped raw observations. Default landing zone for new evidence: `- {{DATE}}: ...` -->
## Evidence
## Recommendation or Implication
## Review Notes
Seeded empty at member onboarding ({{DATE}}). Raise confidence once preferences are confirmed through day-to-day use.
@@ -1,32 +0,0 @@
---
type: semantic-memory
status: active
created: {{DATE}}
updated: {{DATE}}
tags: [agent, semantic-memory, operator]
agent_written: true
author: {{MEMBER}}
source_notes: []
confidence: low
last_reviewed: {{DATE}}
---
# Operator Preferences
## Operator
<!-- One paragraph: who the operator is. Leave for the operator to confirm; do not fabricate. -->
## Fact / Pattern
<!-- Promoted, deduped, timeless rules. No date prefix. Add only when a rule is stable. -->
## Observations
<!-- Timestamped raw observations. Default landing zone for new evidence: `- {{DATE}}: ...` -->
## Evidence
## Recommendation or Implication
## Review Notes
Seeded empty at bootstrap ({{DATE}}). Raise confidence once preferences are confirmed through day-to-day use.
## Related
@@ -6,7 +6,7 @@ updated: {{DATE}}
tags: [agent, system, marker]
agent_written: true
source_notes: []
schema_version: 4
schema_version: 5
bootstrapped: {{DATE}}
managed_by: chorus-memory-plugin
---
@@ -1,15 +1,23 @@
#!/usr/bin/env python3
"""bootstrap.py — stand up (or repair) an CHORUS vault deterministically.
"""bootstrap.py — stand up (or repair) a CHORUS vault deterministically.
Idempotent and additive: every write is probe-before-write and NEVER overwrites
an existing file. The marker (_agent/chorus-vault.md) is written LAST, so the vault
is only flagged "set up" once every piece is in place. Re-running is the repair
path (it fills in only what is missing). Cross-platform: pure Python via chorus.py.
CHORUS is group memory, so bootstrap has two layers:
* the SHARED vault (folder tree, templates, group profile, group inbox, marker)
— created once, repaired by anyone;
* the MEMBER subtree (_agent/members/<member>/ anchors + _agent/sessions/<member>/)
— created per member. `member_bootstrap()` is reusable: `chorus.py load` calls
it to SELF-ONBOARD a new member whose vault exists but whose namespace doesn't.
Usage:
bootstrap.py [--dry-run]
Env: CHORUS_BASE, CHORUS_KEY (via chorus.py), CHORUS_TODAY (YYYY-MM-DD for {{DATE}}).
Env: CHORUS_BASE, CHORUS_KEY, CHORUS_MEMBER (via chorus.py),
CHORUS_TODAY (YYYY-MM-DD for {{DATE}}).
"""
from __future__ import annotations
@@ -36,10 +44,13 @@ LEAVES = [
"decisions/by-date",
"_agent/context", "_agent/memory/working", "_agent/memory/episodic",
"_agent/memory/semantic", "_agent/sessions", "_agent/health", "_agent/templates",
"_agent/heartbeat", "_agent/skills/active", "_agent/skills/archived", "_agent/locks",
"_agent/members", "_agent/skills/active", "_agent/skills/archived", "_agent/locks",
"_agent/index",
]
GROUP_PROFILE_PATH = "_agent/memory/semantic/group-profile.md"
GROUP_INBOX_PATH = "inbox/captures/group.md"
def exists(path: str) -> bool:
status, _ = chorus.request("GET", chorus.vault_url(path))
@@ -78,8 +89,43 @@ def leaf_readme(folder: str, dry_run: bool) -> None:
print(f"bootstrap: readme {path}")
def register_member(dry_run: bool) -> None:
"""Idempotently add this member to the group profile's ## Members roster."""
line = f"- {chorus.MEMBER} (joined {chorus.today()})"
if dry_run:
print(f"bootstrap: would roster {chorus.MEMBER} in {GROUP_PROFILE_PATH}")
return
status, body = chorus.request("GET", chorus.vault_url(GROUP_PROFILE_PATH))
if status != 200:
return # no profile yet (dry-run orderings) — the roster line is best-effort
if any(ln.strip().startswith(f"- {chorus.MEMBER} ") for ln in body.decode(errors="replace").splitlines()):
return
chorus.request("PATCH", chorus.vault_url(GROUP_PROFILE_PATH),
data=chorus.normalize_patch_body((line + "\n").encode(), "append", "heading"),
headers={"Operation": "append", "Target-Type": "heading",
"Target": "Group Profile::Members", "Content-Type": "text/markdown"})
print(f"bootstrap: rostered {chorus.MEMBER} in group profile")
def member_bootstrap(dry_run: bool = False) -> None:
"""Stand up (or repair) the CONFIGURED member's namespace: leaf READMEs for
_agent/members/<m>/ and _agent/sessions/<m>/, the three member anchors, and
the group-profile roster line. Idempotent; called by main() and by
`chorus.py load` when it detects an onboarded vault without this member."""
if not chorus.MEMBER:
print("bootstrap: no member configured — cannot create a member namespace.", file=sys.stderr)
return
mdir = f"_agent/members/{chorus.MEMBER}"
leaf_readme(f"_agent/sessions/{chorus.MEMBER}", dry_run)
seed(f"{mdir}/preferences.md", SCAFFOLD / "anchors" / "member-preferences.seed.md", dry_run)
seed(f"{mdir}/current-context.md", SCAFFOLD / "anchors" / "member-context.seed.md", dry_run)
seed(f"{mdir}/inbox.md", SCAFFOLD / "anchors" / "member-inbox.seed.md", dry_run)
# heartbeat.md is deliberately NOT seeded — it appears at the first session end.
register_member(dry_run)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Bootstrap or repair an CHORUS vault")
parser = argparse.ArgumentParser(description="Bootstrap or repair a CHORUS vault")
parser.add_argument("--dry-run", "-n", action="store_true")
args = parser.parse_args(argv)
@@ -104,14 +150,15 @@ def main(argv: list[str] | None = None) -> int:
for source in sorted(templates.rglob("*.md")):
seed(source.relative_to(templates).as_posix(), source, args.dry_run)
seed("_agent/memory/semantic/operator-preferences.md", SCAFFOLD / "anchors" / "operator-preferences.seed.md", args.dry_run)
seed("_agent/context/current-context.md", SCAFFOLD / "anchors" / "current-context.seed.md", args.dry_run)
seed("inbox/captures/inbox.md", SCAFFOLD / "anchors" / "inbox.seed.md", args.dry_run)
# Shared group anchors, then this member's namespace.
seed(GROUP_PROFILE_PATH, SCAFFOLD / "anchors" / "group-profile.seed.md", args.dry_run)
seed(GROUP_INBOX_PATH, SCAFFOLD / "anchors" / "group-inbox.seed.md", args.dry_run)
member_bootstrap(args.dry_run)
seed("README.md", SCAFFOLD / "README.vault.md", args.dry_run)
seed(chorus.MARKER_PATH, SCAFFOLD / "chorus-vault.md", args.dry_run) # marker LAST
print(f"bootstrap: done ({'DRY-RUN ' if args.dry_run else ''}{chorus.today()}).")
print("bootstrap: next: create today's daily note + a bootstrap session log + heartbeat (see SKILL.md).")
print("bootstrap: next: create today's daily note + a bootstrap session log + your heartbeat (see SKILL.md).")
return 0
@@ -31,7 +31,7 @@ Config (group/member/endpoint/key are machine-local — see chorus_config; the p
CHORUS_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate)
Usage:
chorus.py load # cold-start: the 6 orientation reads in one call
chorus.py load # cold-start: the 8 orientation reads in one call
chorus.py get <path> # print file contents (404 -> exit 44)
chorus.py map <path> # document-map JSON (headings/blocks/frontmatter)
chorus.py ls <dir> # directory listing JSON
@@ -103,6 +103,22 @@ LOCK_PATH = "_agent/locks/vault.lock"
MARKER_PATH = "_agent/chorus-vault.md"
# --- member/group path helpers (schema 5) -------------------------------------
# Per-member churn-prone state lives under _agent/members/<member>/ so members
# never contend on each other's scope/heartbeat/preferences/inbox. Shared
# knowledge (projects, areas, resources, journal, semantic memory) stays communal.
GROUP_PROFILE_PATH = "_agent/memory/semantic/group-profile.md"
GROUP_INBOX_PATH = "inbox/captures/group.md"
def member_path(name: str, member: str | None = None) -> str:
"""A file in a member's namespace, e.g. member_path('preferences.md')."""
return f"_agent/members/{member or MEMBER}/{name}"
def member_sessions_dir(member: str | None = None) -> str:
return f"_agent/sessions/{member or MEMBER}"
class ChorusError(RuntimeError):
def __init__(self, message: str, code: int = 1) -> None:
@@ -553,7 +569,9 @@ def extract_heading(markdown: str, heading: str) -> str:
def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
path = "_agent/context/current-context.md"
# Scope is PER MEMBER (schema 5): each member's active scope lives in their
# own namespace, so members working on different things never clobber it.
path = member_path("current-context.md")
status, body = request("GET", vault_url(path))
check(status, body, f"scope {subcommand}")
current = body.decode(errors="replace")
@@ -566,9 +584,9 @@ def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
break
sessions_since = None
# Count session logs dated after scope_updated (the drift signal).
# Count MY session logs dated after scope_updated (the drift signal).
if scope_updated:
status, body = request("GET", vault_url("_agent/sessions/"))
status, body = request("GET", vault_url(member_sessions_dir() + "/"))
if status == 200:
try:
files = json.loads(body).get("files", [])
@@ -609,8 +627,11 @@ def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -
def cmd_load() -> int:
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
daily note and the inbox are normal (printed as absent, not errors)."""
"""Cold-start orientation: the canonical 8 reads in one call — the marker, the
GROUP profile, then MY member state (preferences/context/heartbeat/inbox), the
shared daily note, and the group inbox. 404s on today's daily note and the
inboxes are normal (printed as absent, not errors). If the vault is
bootstrapped but MY namespace is missing, self-onboard it."""
if not chorus_config.is_configured(_CFG):
print("chorus: NOT CONFIGURED — this machine has no usable CHORUS key file yet.")
print(f"Expected at: {chorus_config.config_path()}")
@@ -621,12 +642,14 @@ def cmd_load() -> int:
print("Then re-run load. (Memory is unavailable until configured.)")
return 78 # distinct: configuration required
targets = [
("marker", MARKER_PATH),
("preferences", "_agent/memory/semantic/operator-preferences.md"),
("context", "_agent/context/current-context.md"),
("heartbeat", "_agent/heartbeat/last-session.md"),
("today", f"journal/daily/{today()}.md"),
("inbox", "inbox/captures/inbox.md"),
("marker", MARKER_PATH),
("group-profile", GROUP_PROFILE_PATH),
("my-preferences", member_path("preferences.md")),
("my-context", member_path("current-context.md")),
("my-heartbeat", member_path("heartbeat.md")),
("today", f"journal/daily/{today()}.md"),
("my-inbox", member_path("inbox.md")),
("group-inbox", GROUP_INBOX_PATH),
]
import chorus_queue
# H2: sync any writes queued during a prior outage, best-effort and quiet on empty.
@@ -639,6 +662,7 @@ def cmd_load() -> int:
marker_missing = False
heartbeat_absent = False
prefs_absent = False
offline = False
for label, path in targets:
status, body = request("GET", vault_url(path))
@@ -654,8 +678,10 @@ def cmd_load() -> int:
print("(absent — fine)")
if label == "marker":
marker_missing = True
if label == "heartbeat":
if label == "my-heartbeat":
heartbeat_absent = True
if label == "my-preferences":
prefs_absent = True
elif status == 0: # vault unreachable -> degrade to last-known-good cache
offline = True
cached = chorus_queue.cache_get(path)
@@ -671,10 +697,23 @@ def cmd_load() -> int:
print(f"===== {label}: {path} (HTTP {status}) =====")
print(f"(error HTTP {status}: {body.decode(errors='replace')[:200]})")
print()
# M3: heartbeat pointer missing/stale -> fall back to the recent sessions listing
# Self-onboarding: the vault is bootstrapped but THIS member has no namespace
# yet (fresh member, existing group). Stand up their subtree right now so the
# session starts with real anchors instead of 404s.
if prefs_absent and not marker_missing and not offline:
print(f"===== self-onboarding: member '{MEMBER}' is new to this vault =====")
try:
import bootstrap
bootstrap.member_bootstrap(dry_run=False)
print(f"(created _agent/members/{MEMBER}/ anchors + {member_sessions_dir()}/ — "
"ask the member to confirm their preferences when convenient)")
except Exception as exc: # noqa: BLE001 — onboarding must never kill a load
print(f"(self-onboarding failed: {exc} — run bootstrap.py manually)")
print()
# M3: heartbeat pointer missing/stale -> fall back to MY recent sessions listing
# (matches the documented loading procedure) so orientation works without the pointer.
if heartbeat_absent and not offline:
st, body = request("GET", vault_url("_agent/sessions/"))
st, body = request("GET", vault_url(member_sessions_dir() + "/"))
if st == 200:
try:
files = sorted((f for f in json.loads(body).get("files", []) if f.endswith(".md")),
@@ -682,9 +721,9 @@ def cmd_load() -> int:
except json.JSONDecodeError:
files = []
if files:
print("===== recent sessions (heartbeat absent — fallback) =====")
print("===== my recent sessions (heartbeat absent — fallback) =====")
for f in files[:5]:
print(f" _agent/sessions/{f}")
print(f" {member_sessions_dir()}/{f}")
print()
if offline:
print("NOTE: vault unreachable — context above is last-known-good cache; writes this "
@@ -146,7 +146,10 @@ def _dated_block(today_s: str, body_text: str) -> tuple[str, str]:
a markdown continuation. Returns (bullet, full_block) — the bullet alone is the
idempotency key (same first-line-per-day semantics as before)."""
lines = [ln.rstrip() for ln in body_text.strip().splitlines()]
bullet = f"- {today_s}: {lines[0] if lines else '(update)'}"
# Member-tagged: shared entities are updated by many members' sessions, so
# each dated block records who added it.
who = f" [{chorus.MEMBER}]" if chorus.MEMBER else ""
bullet = f"- {today_s}{who}: {lines[0] if lines else '(update)'}"
block = bullet
for ln in lines[1:]:
block += "\n" + (f" {ln}" if ln else "")
@@ -207,16 +210,19 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
sources = [s.strip() for s in (sources or []) if s.strip()]
tags = [t.strip() for t in (tags or []) if t.strip()]
# Unknown home -> defer to the inbox (a single idempotent capture line).
# Unknown home -> defer to MY inbox (a single idempotent capture line).
# (Group-wide "someone should look at this" items go to inbox/captures/group.md
# as member-tagged lines via a plain append — see the skill's Inbox Triage.)
if inbox or not kind:
inbox_path = chorus.member_path("inbox.md")
if dry_run:
return done("inbox", "inbox/captures/inbox.md", dry=True)
return done("inbox", inbox_path, dry=True)
line = f"- {today_s}: {title}"
if body_text.strip():
line += f"{body_text.strip().splitlines()[0]}"
with quiet():
rc = chorus.cmd_append("inbox/captures/inbox.md", line)
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
rc = chorus.cmd_append(inbox_path, line)
return done("inbox", inbox_path, ok=rc == 0)
slug = idx_mod.slugify(title)
index = idx_mod.load()
@@ -339,7 +345,10 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
print(f"chorus_ops: recall-index update skipped ({exc})", file=sys.stderr)
if not no_log:
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
# The daily Agent Log is the GROUP's shared timeline — every line is
# member-tagged so interleaved activity stays attributable.
who = f" [{chorus.MEMBER}]" if chorus.MEMBER else ""
ensure_daily_log(f"- {today_s}{who}: {action} {kind} [[{links.link_token(path)}]]")
if as_json:
done(action, path, links=linked, near=near_dupes)
@@ -28,7 +28,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import chorus # noqa: E402
INBOX_PATH = "inbox/captures/inbox.md"
# Triage covers BOTH inboxes: this member's personal captures and the shared
# group inbox (unowned items — whoever triages first routes them).
INBOX_PATH = chorus.member_path("inbox.md")
GROUP_INBOX_PATH = chorus.GROUP_INBOX_PATH
LOG_DIR = "inbox/processing-log"
_CAPTURE_LINE = re.compile(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\s*:?\s*(.*\S)\s*$")
@@ -55,26 +58,33 @@ def parse_inbox(text: str) -> list[dict]:
return items
def list_inbox(as_json: bool = False) -> int:
status, body = chorus.request("GET", chorus.vault_url(INBOX_PATH))
def _read_inbox(path: str) -> list[dict]:
status, body = chorus.request("GET", chorus.vault_url(path))
if status == 404:
items = []
else:
chorus.check(status, body, f"triage list {INBOX_PATH}")
items = parse_inbox(body.decode(errors="replace"))
return []
chorus.check(status, body, f"triage list {path}")
items = parse_inbox(body.decode(errors="replace"))
for it in items:
it["source"] = path
return items
def list_inbox(as_json: bool = False) -> int:
items = _read_inbox(INBOX_PATH) + _read_inbox(GROUP_INBOX_PATH)
if as_json:
import chorus_output
env = chorus_output.envelope("triage-list", {"path": INBOX_PATH, "items": items,
"count": len(items)})
env = chorus_output.envelope("triage-list", {"paths": [INBOX_PATH, GROUP_INBOX_PATH],
"items": items, "count": len(items)})
print(json.dumps(env, ensure_ascii=False))
return 0
if not items:
print("triage: inbox is empty — nothing to route.")
print("triage: both inboxes are empty — nothing to route.")
return 0
print(f"triage: {len(items)} capture(s) in {INBOX_PATH}")
print(f"triage: {len(items)} capture(s) across {INBOX_PATH} + {GROUP_INBOX_PATH}")
for it in items:
age = f"{it['age_days']}d" if it["age_days"] is not None else "?"
print(f" [{age:>4}] {it['date']}: {it['text']}")
tag = "group" if it["source"] == GROUP_INBOX_PATH else "mine"
print(f" [{age:>4}][{tag:>5}] {it['date']}: {it['text']}")
print("\nBuild a proposals JSON (reflect schema + optional \"line\") and run "
"`chorus.py triage <file>` to preview, `--apply` to route.")
return 0
@@ -124,8 +134,9 @@ def apply(proposals: list[dict], confirm: bool = False) -> int:
continue
applied += 1
original = (p.get("line") or p["title"]).strip()
who = f"[{chorus.MEMBER}] " if chorus.MEMBER else ""
chorus.cmd_append(f"{LOG_DIR}/{today_s}.md",
f"- {original}{p.get('_path', '?')}")
f"- {who}{original}{p.get('_path', '?')}")
print(f"triage: routed {applied}/{len(valid)} item(s); audit in {LOG_DIR}/{today_s}.md. "
"Originals kept in the inbox (deletion is explicit-only)."
+ (f" {gated} stopped at the duplicate gate — re-propose with the existing "
@@ -25,7 +25,7 @@ sys.path.insert(0, str(SCRIPT_DIR))
import chorus # noqa: E402
CURRENT_SCHEMA = 4
CURRENT_SCHEMA = 5
def get_text(path: str) -> str | None:
@@ -146,6 +146,35 @@ def main(argv: list[str] | None = None) -> int:
print("migrate: [3->4] _agent/index/ already exists (schema 3); no moves needed.")
print("migrate: [3->4] then run `sweep.py --apply` to build _agent/index/recall-index.json.")
if start < 5:
# Schema 5 = per-member namespacing. This step exists for alpha CHORUS
# vaults stood up before Phase 3 — the pre-5 single-user anchors move into
# the CONFIGURED member's namespace (there is no ECHO adoption path).
print("migrate: [4->5] per-member namespacing — move single-user anchors into "
f"_agent/members/{chorus.MEMBER or '<member>'}/")
if not chorus.MEMBER:
print("migrate: [4->5] ERROR: no member configured — set CHORUS_MEMBER first.",
file=sys.stderr)
return 2
m = chorus.MEMBER
moves = [
("_agent/memory/semantic/operator-preferences.md", f"_agent/members/{m}/preferences.md"),
("_agent/context/current-context.md", f"_agent/members/{m}/current-context.md"),
("_agent/heartbeat/last-session.md", f"_agent/members/{m}/heartbeat.md"),
("inbox/captures/inbox.md", f"_agent/members/{m}/inbox.md"),
]
for src, dst in moves:
if get_text(src) is not None:
do_or_show(args.apply, f"move {src} -> {dst}",
lambda s=src, d=dst: move(s, d))
for filename in list_files("_agent/sessions"):
if filename.endswith(".md") and filename != "README.md":
dst = f"_agent/sessions/{m}/{filename}"
do_or_show(args.apply, f"move _agent/sessions/{filename} -> {dst}",
lambda f=filename, d=dst: move(f"_agent/sessions/{f}", d))
print("migrate: [4->5] then run `bootstrap.py` to seed the group profile, group "
"inbox, and any missing member anchors, and `sweep.py --apply` to reindex.")
do_or_show(args.apply, f"set _agent/chorus-vault.md schema_version -> {CURRENT_SCHEMA}",
lambda: chorus.cmd_fm("_agent/chorus-vault.md", "schema_version", str(CURRENT_SCHEMA)))
if args.apply:
@@ -2,7 +2,7 @@
"$comment": "CANONICAL machine-readable routing manifest for the CHORUS vault. This is the single source of truth for 'what paths may be written to'. The human-readable tables in SKILL.md, references/routing-map.md, and references/api-reference.md are DERIVED views of this file — when they disagree, this file wins. vault_lint.py consumes it to enforce the core rule (if a vault path matches no route here, and is not retired, nothing should be written to it); check_routing.py consumes it to verify the human routing docs stay in sync with this manifest. Patterns are Python regexes matched against vault-root-relative paths (no leading slash, no /vault/ prefix).",
"schema_version": 2,
"routes": [
{ "id": "inbox-captures", "pattern": "^inbox/captures/inbox\\.md$", "method": "POST", "trigger": "Destination unknown at capture time", "distinct_because": "Only path whose contract is deferred routing" },
{ "id": "inbox-group", "pattern": "^inbox/captures/group\\.md$", "method": "POST", "trigger": "Unowned group-wide capture (member-tagged line)", "distinct_because": "Shared deferred routing — any member's triage may route it" },
{ "id": "inbox-imports", "pattern": "^inbox/imports/[^/]+\\.md$", "method": "PUT", "trigger": "Raw external material dropped wholesale", "distinct_because": "Bulk un-triaged material vs single-line captures" },
{ "id": "inbox-processing-log", "pattern": "^inbox/processing-log/\\d{4}-\\d{2}-\\d{2}\\.md$", "method": "POST", "trigger": "An inbox item is routed to its real home", "distinct_because": "Audit trail of moves, not memory itself" },
@@ -31,13 +31,13 @@
{ "id": "decisions-template", "pattern": "^decisions/decision-template\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Template, not a decision" },
{ "id": "agent-marker", "pattern": "^_agent/chorus-vault\\.md$", "method": "PUT", "trigger": "Bootstrap / schema migration only", "distinct_because": "Plugin-owned probe; never hand-edited" },
{ "id": "agent-context", "pattern": "^_agent/context/[^/]+\\.md$", "method": "PATCH", "trigger": "Active scope changes / task bundles", "distinct_because": "Single live scope pointer + bundles" },
{ "id": "agent-semantic", "pattern": "^_agent/memory/semantic/[^/]+\\.md$", "method": "PUT", "trigger": "A durable fact/pattern (incl. operator-preferences.md)", "distinct_because": "Timeless fact" },
{ "id": "member-anchors", "pattern": "^_agent/members/[a-z0-9-]+/(preferences|current-context|heartbeat|inbox)\\.md$", "method": "PATCH", "trigger": "A member's own preferences / scope / heartbeat / captures", "distinct_because": "Per-member namespace — only that member's sessions write here" },
{ "id": "agent-context", "pattern": "^_agent/context/(?!current-context\\.md$)[^/]+\\.md$", "method": "PATCH", "trigger": "Shared task bundles / reading lists", "distinct_because": "Task-scoped context shared by the group (scope itself is per member)" },
{ "id": "agent-semantic", "pattern": "^_agent/memory/semantic/(?!operator-preferences\\.md$)[^/]+\\.md$", "method": "PUT", "trigger": "A durable fact/pattern (incl. group-profile.md)", "distinct_because": "Timeless fact shared by the group" },
{ "id": "agent-episodic", "pattern": "^_agent/memory/episodic/[^/]+\\.md$", "method": "PUT", "trigger": "A record of what happened, when", "distinct_because": "Anchored to an event in time" },
{ "id": "agent-working", "pattern": "^_agent/memory/working/[^/]+\\.md$", "method": "PUT", "trigger": "Short-lived state for the current effort", "distinct_because": "Explicitly transient" },
{ "id": "agent-sessions", "pattern": "^_agent/sessions/\\d{4}-\\d{2}-\\d{2}(-\\d{4})?-[^/]+\\.md$", "method": "PUT", "trigger": "A substantive session ends", "distinct_because": "Per-session record (new ones require HHMM)" },
{ "id": "agent-sessions", "pattern": "^_agent/sessions/[a-z0-9-]+/\\d{4}-\\d{2}-\\d{2}-\\d{4}-[^/]+\\.md$", "method": "PUT", "trigger": "A substantive session ends", "distinct_because": "Per-session record, namespaced by member (HHMM required)" },
{ "id": "agent-health", "pattern": "^_agent/health/\\d{4}-\\d{2}-vault-health\\.md$", "method": "PUT", "trigger": "First substantive session of a month", "distinct_because": "Vault integrity, not work narrative" },
{ "id": "agent-heartbeat", "pattern": "^_agent/heartbeat/[^/]+\\.md$", "method": "PUT", "trigger": "End of every session", "distinct_because": "O(1) orientation pointer; overwritten, never grows" },
{ "id": "agent-templates", "pattern": "^_agent/templates/.+\\.md$", "method": "PUT", "trigger": "Bootstrap seed only", "distinct_because": "Holds templates, not memory" },
{ "id": "agent-skills-active", "pattern": "^_agent/skills/active/[^/]+\\.md$", "method": "PUT", "trigger": "A skill/plugin catalogued as a capability", "distinct_because": "Catalogs a capability vs the build effort" },
{ "id": "agent-skills-archived","pattern": "^_agent/skills/archived/[^/]+\\.md$", "method": "PUT", "trigger": "A catalogued skill is retired", "distinct_because": "Terminal state of the skill catalog" },
@@ -47,6 +47,11 @@
{ "id": "leaf-readme", "pattern": "^(.+/)?README\\.md$", "method": "PUT", "trigger": "Bootstrap leaf signpost / vault root README", "distinct_because": "Human signpost, not read for routing" }
],
"retired": [
{ "pattern": "^inbox/captures/inbox\\.md$", "retired_in_schema": 5, "replacement": "_agent/members/<member>/inbox.md (personal) or inbox/captures/group.md (unowned)" },
{ "pattern": "^_agent/memory/semantic/operator-preferences\\.md$", "retired_in_schema": 5, "replacement": "_agent/members/<member>/preferences.md" },
{ "pattern": "^_agent/context/current-context\\.md$", "retired_in_schema": 5, "replacement": "_agent/members/<member>/current-context.md" },
{ "pattern": "^_agent/heartbeat/", "retired_in_schema": 5, "replacement": "_agent/members/<member>/heartbeat.md" },
{ "pattern": "^_agent/sessions/\\d{4}-\\d{2}-\\d{2}(-\\d{4})?-[^/]+\\.md$", "retired_in_schema": 5, "replacement": "_agent/sessions/<member>/YYYY-MM-DD-HHMM-<slug>.md" },
{ "pattern": "^reviews/", "retired_in_schema": 2, "replacement": "journal/{weekly,monthly,quarterly,annual}/ and _agent/health/" },
{ "pattern": "^decisions/by-project/", "retired_in_schema": 1, "replacement": "[[wikilink]] under the project's ## Key Decisions" },
{ "pattern": "^archive/", "retired_in_schema": 0, "replacement": "projects/archived/ and _agent/skills/archived/" },
@@ -15,7 +15,7 @@ backfill what older vaults don't have yet:
4. **symmetrize cross-links** — for every `## Related` link A -> B, ensure the
reciprocal B -> A exists (it only adds the missing direction; it never invents
new links from body text), and
5. stamp the marker `schema_version` to the current schema (4).
5. stamp the marker `schema_version` to the current schema (5).
Dry-run by default; pass --apply to write. READ-ONLY without --apply.
Cross-platform: pure Python via chorus.py.
@@ -39,7 +39,7 @@ import chorus_index as idx_mod # noqa: E402
import chorus_links as links # noqa: E402
import chorus_recall # noqa: E402
CURRENT_SCHEMA = 4
CURRENT_SCHEMA = 5
TEMPLATE_RE = re.compile(r"(^|/)(templates/|.*-template\.md$)")
SKIP_BASENAMES = {"README.md"}
@@ -242,31 +242,43 @@ def main() -> int:
if count > 1:
flag("duplicate-agent-log", f"{path}: {count} '## Agent Log' headings")
# Inbox: captures aging past INBOX_DAYS
inbox = get("inbox/captures/inbox.md") or ""
for line in inbox.splitlines():
match = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
date = parse_date(match.group(1)) if match else None
if date and (TODAY - date).days > INBOX_DAYS:
flag("aging-inbox", f"inbox capture {date} ({(TODAY - date).days}d): {line.strip()[:80]}")
# Members roster (schema 5): every per-member check below runs across ALL
# members found in the vault, not just the configured one.
members = [
m for m in re.findall(r"^_agent/members/([a-z0-9-]+)/", "\n".join(all_files), re.M)
]
members = sorted(set(members))
# Scope freshness (drift detector)
context = get("_agent/context/current-context.md")
if context is not None:
# Inboxes: captures aging past INBOX_DAYS — each member's inbox + the group inbox
inboxes = [(f"_agent/members/{m}/inbox.md", m) for m in members]
inboxes.append(("inbox/captures/group.md", "group"))
for inbox_path, who in inboxes:
for line in (get(inbox_path) or "").splitlines():
match = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
date = parse_date(match.group(1)) if match else None
if date and (TODAY - date).days > INBOX_DAYS:
flag("aging-inbox", f"[{who}] capture {date} ({(TODAY - date).days}d): {line.strip()[:80]}")
# Scope freshness (drift detector) — per member, against THAT member's sessions
for m in members:
ctx_path = f"_agent/members/{m}/current-context.md"
context = get(ctx_path)
if context is None:
continue
_, fm = parse_frontmatter(context)
scope_updated = parse_date(fm.get("scope_updated"))
if scope_updated is None:
flag("scope-no-timestamp",
"_agent/context/current-context.md: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.py) and switch scope via `chorus.py scope set`")
f"{ctx_path}: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.py) and switch scope via `chorus.py scope set`")
else:
since = [
path for path in all_files
if (m := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", path))
and (d := parse_date(m.group(1))) and d > scope_updated
if (mt := re.match(rf"^_agent/sessions/{re.escape(m)}/(\d{{4}}-\d{{2}}-\d{{2}})", path))
and (d := parse_date(mt.group(1))) and d > scope_updated
]
if len(since) >= SCOPE_STALE_SESSIONS:
flag("scope-stale",
f"scope set {scope_updated}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `chorus.py scope set`)")
f"[{m}] scope set {scope_updated}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `chorus.py scope set`)")
# ---- Graph health: broken wikilinks, orphans, index drift ----------------
# (md_files / md_set were built up top; reuse the shared `texts` cache.)
+5 -5
View File
@@ -287,9 +287,9 @@ def eval_write_safety(h):
# replayed append -> whole-line idempotency, no duplicate
ops += 1
h.run(CHORUS, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
h.run(CHORUS, "append", "inbox/captures/inbox.md", f"- {TODAY}: replay me")
n = (h.ground("inbox/captures/inbox.md") or "").count("replay me")
h.run(CHORUS, "append", "_agent/members/eval-member/inbox.md", f"- {TODAY}: replay me")
h.run(CHORUS, "append", "_agent/members/eval-member/inbox.md", f"- {TODAY}: replay me")
n = (h.ground("_agent/members/eval-member/inbox.md") or "").count("replay me")
if n != 1:
silent += 1
@@ -303,13 +303,13 @@ def eval_durability(h, dead_base):
h.seed("_agent/chorus-vault.md", "---\nschema_version: 4\n---\n# marker\n")
queued_ok = 0
for i in range(3):
r = h.run(CHORUS, "append", "inbox/captures/inbox.md",
r = h.run(CHORUS, "append", "_agent/members/eval-member/inbox.md",
f"- {TODAY}: offline capture {i}", base=dead_base)
queued_ok += int(r.returncode == 0 and "queued" in r.stdout)
# vault returns: flush replays; a second flush must not duplicate
h.run(CHORUS, "flush")
h.run(CHORUS, "flush")
inbox = h.ground("inbox/captures/inbox.md") or ""
inbox = h.ground("_agent/members/eval-member/inbox.md") or ""
landed = sum(1 for i in range(3) if f"offline capture {i}" in inbox)
dupes = sum(inbox.count(f"offline capture {i}") - 1
for i in range(3) if f"offline capture {i}" in inbox)
+5 -5
View File
@@ -121,14 +121,14 @@ def main():
# 6. inbox capture (unknown home)
h.chorus(CHORUS, "capture", "stray thought", "--inbox")
inbox = h.ground("inbox/captures/inbox.md")
inbox = h.ground("_agent/members/eval-member/inbox.md")
check("inbox capture lands", inbox and "stray thought" in inbox)
# 7. sweep --apply rebuilds the index, symmetrizes, stamps schema 3
r = h.chorus(SWEEP, "--apply")
marker = h.ground("_agent/chorus-vault.md")
check("sweep runs clean", r.returncode == 0, r.stdout + r.stderr)
check("sweep stamps schema 4", marker and "schema_version=4" in marker.replace(": ", "="), marker)
check("sweep stamps schema 5", marker and "schema_version=5" in marker.replace(": ", "="), marker)
idx2 = h.ground("_agent/index/entities.json")
check("sweep rebuilt index has all entities",
idx2 and all(s in idx2 for s in ["bob-smith", "mpm", "alpha", "beta"]))
@@ -188,7 +188,7 @@ def main():
capture_output=True, text=True,
env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_TODAY="2026-06-21"))
bob3 = h.ground("resources/people/bob-smith.md") or ""
check("v1.5 update keeps the dated bullet", "- 2026-06-21: met at expo" in bob3, r.stdout + r.stderr)
check("v1.5 update keeps the dated bullet", "- 2026-06-21 [eval-member]: met at expo" in bob3, r.stdout + r.stderr)
check("v1.5 update keeps EVERY body line",
" second line survives" in bob3 and " third line too" in bob3, bob3)
@@ -259,7 +259,7 @@ def main():
and primary[0].get("status") == "active", json.dumps(primary[:1]))
# 15. one-tap triage: structured list, then route with an audit trail.
h.seed("inbox/captures/inbox.md",
h.seed("_agent/members/eval-member/inbox.md",
"- 2026-06-01: prefers uv over pip\n- 2026-06-20: try the new espresso place\n")
r = h.chorus(CHORUS, "triage", "--list", "--json")
try:
@@ -282,7 +282,7 @@ def main():
check("v1.5 triage writes the processing-log audit line",
"prefers uv over pip" in plog and "_agent/memory/semantic/prefers-uv-over-pip.md" in plog, plog)
check("v1.5 triage keeps the original capture",
"prefers uv over pip" in (h.ground("inbox/captures/inbox.md") or ""))
"prefers uv over pip" in (h.ground("_agent/members/eval-member/inbox.md") or ""))
# 16. lint flags incomplete entity frontmatter; sweep backfills it.
h.seed("resources/companies/driftco.md",
+1 -1
View File
@@ -89,7 +89,7 @@ def main():
(ground("resources/companies/acme-corp.md") or "").find("type: company") >= 0)
check("apply creates the semantic note",
ground("_agent/memory/semantic/use-uv-not-pip.md") is not None)
check("apply routes the inbox proposal", "half-formed idea" in (ground("inbox/captures/inbox.md") or ""))
check("apply routes the inbox proposal", "half-formed idea" in (ground("_agent/members/eval-member/inbox.md") or ""))
check("apply still skips the low-confidence proposal",
ground("resources/concepts/maybe-relevant.md") is None)