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
@@ -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.)