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:
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user