1
0
forked from jason/echo

Phase 2: identity plumbing — group/member config, author: attribution

Config schema {owner,endpoint,key} -> {group, member, endpoint, key}:
- member (kebab-case slug, validated) is REQUIRED — who this machine
  writes as; group is the descriptive team name. CHORUS_GROUP/
  CHORUS_MEMBER env, config set --group/--member, doctor + config show
  both with sources, NOT-CONFIGURED (78) without a valid member.
- capture stamps author: <member> on every note (_build_note); bootstrap
  gains {{MEMBER}} substitution and stamps the seeded anchors; all 8
  scaffold templates + canonical frontmatter docs gain author:.
- New missing-author lint check: agent_written notes must carry author:
  (bootstrap marker exempt — plugin-owned).
- Lock owner is now <member>-<client>-<pid> (auto_owner); offline-queue
  records carry a member field for audit.
- build.py --bake-key bakes group/member/endpoint/key (--group/--member;
  member required + slug-validated) for per-member artifacts.
- Manifest -> 2.0.0-alpha.2; rebuilt chorus-memory.plugin.

Verified: 25/25 unit (+config/member checks), scaffold suite (+6 new
member/config assertions), routing-sync, 4 mock e2e (+capture-stamps-
author), run_eval metrics unchanged, +8 phase-2 smoke checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:00:59 -05:00
parent d920f8821f
commit b3bc5272d5
35 changed files with 304 additions and 144 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## 2.0.0-alpha.2 — Phase 2 (identity plumbing)
Every write is now attributed to a member. Vault layout unchanged (that's Phase 3).
### Added
- **Config schema is now `{group, member, endpoint, key}`** (was `{owner, endpoint,
key}`). `member` is REQUIRED — a kebab-case slug (`^[a-z0-9][a-z0-9-]{0,31}$`)
identifying who this machine writes as; `group` is the descriptive display name for
the team sharing the vault. Env: `CHORUS_GROUP` / `CHORUS_MEMBER`. `config set`
gains `--group`/`--member` (with slug validation); `config`/`doctor` report both
with sources; an unconfigured member fails loud (`NOT CONFIGURED`, exit 78).
- **`author:` frontmatter on every agent write.** `capture` (`_build_note`) stamps
the configured member; bootstrap stamps seeded anchors via a new `{{MEMBER}}`
substitution; all 8 scaffold templates gain an `author:` line; the canonical
frontmatter block (vault-layout.md, SKILL.md) documents it.
- **`missing-author` lint check** — `/chorus-health` flags any `agent_written: true`
note without an `author:` (the plugin-owned bootstrap marker is exempt).
- **Member-attributed concurrency**: the advisory-lock owner id is now
`<member>-<client>-<pid>` (`auto_owner`), and offline-queue outbox records carry
a `member` field for audit.
- **Per-member baked builds**: `build.py --bake-key` now bakes group/member/endpoint/
key (`--group`/`--member` flags; member required + slug-validated), so each
delivered artifact knows who it writes as.
### Verified
- All suites green (25/25 unit incl. new config/member-validation checks, scaffold,
routing-sync, 4 mock e2e incl. a capture-stamps-author assertion, run_eval metrics
unchanged) plus 8 end-to-end smoke checks (bootstrap stamping, lint flagging,
doctor reporting, NOT-CONFIGURED without member, slug rejection).
## 2.0.0-alpha.1 — CHORUS fork, Phase 1 (mechanical rename) ## 2.0.0-alpha.1 — CHORUS fork, Phase 1 (mechanical rename)
CHORUS is a group-based fork of ECHO (`jason/echo`, v1.5.1, schema 4 — preserved at the CHORUS is a group-based fork of ECHO (`jason/echo`, v1.5.1, schema 4 — preserved at the
+10 -7
View File
@@ -186,13 +186,16 @@ fresh vaults on fresh machines for new users, so no ECHO back-compat: legacy
`_agent/echo-vault.md` markers are all ignored. Shims briefly existed and were `_agent/echo-vault.md` markers are all ignored. Shims briefly existed and were
removed.)* removed.)*
### Phase 2 — Identity plumbing (12 days) ### Phase 2 — Identity plumbing (12 days) — DONE
`member` in config (+ `CHORUS_MEMBER`, baked-tier support, `config set --member`), `member` in config (+ `CHORUS_MEMBER`, baked-tier support, `config set --member`
`MEMBER` global in `chorus.py`, `author:` stamping in `_build_note`, canonical with slug validation; `owner``group`; member REQUIRED for is_configured),
frontmatter + `KIND_REQUIRED_FM` + lint + sweep backfill, member-aware lock `MEMBER` global in `chorus.py`, `author:` stamping in `_build_note` + bootstrap
owner and queue entries, `doctor` reports resolved member. Vault layout `{{MEMBER}}` anchor stamping + templates, canonical frontmatter docs + a
untouched. **Deliverable:** every new write is attributed; single-user behavior `missing-author` lint check (agent_written notes must carry `author:`; sweep
otherwise identical. does NOT backfill — attribution can't be guessed), member-aware lock owner
(`<member>-<client>-<pid>`) and queue records, `doctor` reports member/group.
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)
Schema 5. New `LEAVES` + seeds (`members/<id>/…`, `group-profile.md`), Schema 5. New `LEAVES` + seeds (`members/<id>/…`, `group-profile.md`),
+1 -1
View File
@@ -1,4 +1,4 @@
# chorus-memory — v2.0.0-alpha.1 # chorus-memory — v2.0.0-alpha.2
> **CHORUS is a group-based fork of [ECHO](https://git.alwisp.com/jason/echo)** — one shared > **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 > Obsidian vault serving a group of members instead of a single operator. The conversion
+22 -16
View File
@@ -52,7 +52,8 @@ MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must
# (it has silently regressed past the limit before — fail the build now) # (it has silently regressed past the limit before — fail the build now)
# field name -> the constant assigned in chorus_config.py # field name -> the constant assigned in chorus_config.py
_CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"} _CONSTS = {"group": "DEFAULT_GROUP", "member": "DEFAULT_MEMBER",
"endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"}
def _const_re(const: str) -> re.Pattern: def _const_re(const: str) -> re.Pattern:
@@ -113,8 +114,8 @@ def build(out: Path, files: list[Path], bake: dict | None, strip_key: bool) -> i
def bake_values(args) -> dict: def bake_values(args) -> dict:
"""Resolve owner/endpoint/key for --bake-key from --owner/--endpoint/--key, """Resolve group/member/endpoint/key for --bake-key from the CLI flags,
else --from <config.json>, else CHORUS_OWNER/CHORUS_BASE/CHORUS_KEY env.""" else --from <config.json>, else CHORUS_GROUP/CHORUS_MEMBER/CHORUS_BASE/CHORUS_KEY env."""
src = {} src = {}
if args.from_file: if args.from_file:
fp = Path(args.from_file).expanduser() fp = Path(args.from_file).expanduser()
@@ -127,28 +128,33 @@ def bake_values(args) -> dict:
if isinstance(loaded, dict): if isinstance(loaded, dict):
src = loaded src = loaded
vals = { vals = {
"owner": args.owner or src.get("owner") or os.environ.get("CHORUS_OWNER") or "", "group": args.group or src.get("group") or os.environ.get("CHORUS_GROUP") or "",
"member": args.member or src.get("member") or os.environ.get("CHORUS_MEMBER") or "",
"endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("CHORUS_BASE") or "").rstrip("/"), "endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("CHORUS_BASE") or "").rstrip("/"),
"key": args.key or src.get("key") or os.environ.get("CHORUS_KEY") or "", "key": args.key or src.get("key") or os.environ.get("CHORUS_KEY") or "",
} }
missing = [f for f in ("endpoint", "key") if not vals[f]] missing = [f for f in ("endpoint", "key", "member") if not vals[f]]
if missing: if missing:
raise RuntimeError( raise RuntimeError(
"build: --bake-key needs a real endpoint and key — missing " "build: --bake-key needs a real endpoint, key, and member — missing "
+ ", ".join(missing) + ". Provide --from <config.json>, the --owner/" + ", ".join(missing) + ". Provide --from <config.json>, the --group/--member/"
"--endpoint/--key flags, or set CHORUS_OWNER/CHORUS_BASE/CHORUS_KEY.") "--endpoint/--key flags, or set CHORUS_GROUP/CHORUS_MEMBER/CHORUS_BASE/CHORUS_KEY.")
if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<"): if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<") \
or vals["member"].startswith("<"):
raise RuntimeError("build: --bake-key got the template placeholders, not real values.") raise RuntimeError("build: --bake-key got the template placeholders, not real values.")
if not re.match(r"^[a-z0-9][a-z0-9-]{0,31}$", vals["member"]):
raise RuntimeError(f"build: baked member must be a kebab-case slug, got {vals['member']!r}")
return vals return vals
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Build the chorus-memory .plugin artifact") ap = argparse.ArgumentParser(description="Build the chorus-memory .plugin artifact")
ap.add_argument("--bake-key", action="store_true", ap.add_argument("--bake-key", action="store_true",
help="bake a user's owner/endpoint/key into the artifact (secret-bearing — never commit)") help="bake a member's group/member/endpoint/key into the artifact (secret-bearing — never commit)")
ap.add_argument("--from", dest="from_file", metavar="FILE", ap.add_argument("--from", dest="from_file", metavar="FILE",
help="config.json to read owner/endpoint/key from when baking") help="config.json to read group/member/endpoint/key from when baking")
ap.add_argument("--owner", help="baked owner (overrides --from / env)") ap.add_argument("--group", help="baked group name (overrides --from / env)")
ap.add_argument("--member", help="baked member id (kebab-case; overrides --from / env)")
ap.add_argument("--endpoint", help="baked endpoint (overrides --from / env)") ap.add_argument("--endpoint", help="baked endpoint (overrides --from / env)")
ap.add_argument("--key", help="baked key (overrides --from / env)") ap.add_argument("--key", help="baked key (overrides --from / env)")
ap.add_argument("--label", help="suffix for the artifact name, e.g. --label alice") ap.add_argument("--label", help="suffix for the artifact name, e.g. --label alice")
@@ -206,12 +212,12 @@ def main(argv: list[str] | None = None) -> int:
print(f"built {out.name} ({len(files)} entries, {size:,} bytes)") print(f"built {out.name} ({len(files)} entries, {size:,} bytes)")
if bake: if bake:
who = bake.get("owner") or "(no owner)" who = bake.get("member") or "(no member)"
print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.") print(f"note: --bake-key baked credentials for member '{who}' into {CONFIG_PY}.")
print("WARNING: this artifact carries a vault key — deliver it directly to that one " print("WARNING: this artifact carries a vault key — deliver it directly to that one "
"user and NEVER commit, push, or publish it (dist/ is gitignored).") "member and NEVER commit, push, or publish it (dist/ is gitignored).")
elif args.strip_key: elif args.strip_key:
print("note: --strip-key set — artifact is token-free; user configures owner/endpoint/key at runtime.") print("note: --strip-key set — artifact is token-free; member configures group/member/endpoint/key at runtime.")
elif token_present(SRC / "skills" / "chorus-memory" / "scripts" / "chorus_config.py"): elif token_present(SRC / "skills" / "chorus-memory" / "scripts" / "chorus_config.py"):
print("WARNING: a DEFAULT_* constant in chorus_config.py is non-empty — this artifact carries a " print("WARNING: a DEFAULT_* constant in chorus_config.py is non-empty — this artifact carries a "
"secret. Do not commit it. Build from a clean source tree, or use --strip-key.") "secret. Do not commit it. Build from a clean source tree, or use --strip-key.")
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"owner": "Full Name — how the agent should refer to the vault owner (third person)", "group": "Group Name — the team/group sharing this vault",
"member": "<your-member-id — kebab-case slug, e.g. jason>",
"endpoint": "https://your-obsidian-rest-endpoint.example.com", "endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>" "key": "<obsidian-local-rest-api-bearer-token>"
} }
Binary file not shown.
@@ -1,6 +1,6 @@
{ {
"name": "chorus-memory", "name": "chorus-memory",
"version": "2.0.0-alpha.1", "version": "2.0.0-alpha.2",
"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.", "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": { "author": {
"name": "Jason Stedwell" "name": "Jason Stedwell"
+7 -4
View File
@@ -20,26 +20,29 @@ Architected by **Jason Stedwell**.
## Configuration ## Configuration
The plugin ships **no** owner, endpoint, or key. On each machine it installs on, provide a machine-local JSON config: The plugin ships **no** group, member, endpoint, or key. On each machine it installs on, provide a machine-local JSON config:
``` ```
~/.claude/chorus-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $CHORUS_CONFIG) ~/.claude/chorus-memory/config.json (honors $CLAUDE_CONFIG_DIR; file path overridable with $CHORUS_CONFIG)
{ {
"owner": "Full Name — how the agent refers to the vault owner (third person)", "group": "Group Name — the team/group sharing this vault",
"member": "<your-member-id — kebab-case slug, e.g. jason>",
"endpoint": "https://your-obsidian-rest-endpoint.example.com", "endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>" "key": "<obsidian-local-rest-api-bearer-token>"
} }
``` ```
`member` is **required** — it is stamped as `author:` on every agent write, so the shared vault always knows which member's session wrote what.
Set it up on a fresh install by copying the bundled template and filling it in, or via the client: Set it up on a fresh install by copying the bundled template and filling it in, or via the client:
```bash ```bash
python3 skills/chorus-memory/scripts/chorus.py config init # writes the template if absent → edit it python3 skills/chorus-memory/scripts/chorus.py config init # writes the template if absent → edit it
python3 skills/chorus-memory/scripts/chorus.py config set --owner "…" --endpoint "https://…" --key "…" python3 skills/chorus-memory/scripts/chorus.py config set --group "…" --member "your-id" --endpoint "https://…" --key "…"
python3 skills/chorus-memory/scripts/chorus.py config # show resolved config (key redacted) + source python3 skills/chorus-memory/scripts/chorus.py config # show resolved config (key redacted) + source
``` ```
Per field, an environment variable overrides the file: `CHORUS_OWNER`, `CHORUS_BASE` (endpoint), `CHORUS_KEY`. `config init` writes the template (shown above) when the file is absent; a copy also ships at the repo root (`chorus-memory.config.template.json`). The config is **never committed** and **never written into a vault note**; the bearer key stays out of the plugin tree entirely. Per field, an environment variable overrides the file: `CHORUS_GROUP`, `CHORUS_MEMBER`, `CHORUS_BASE` (endpoint), `CHORUS_KEY`. `config init` writes the template (shown above) when the file is absent; a copy also ships at the repo root (`chorus-memory.config.template.json`). The config is **never committed** and **never written into a vault note**; the bearer key stays out of the plugin tree entirely.
## Vault layout (root-addressed) ## Vault layout (root-addressed)
@@ -7,18 +7,19 @@ description: Use the CHORUS Obsidian vault as the operator's persistent memory a
Use the **CHORUS** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered. Use the **CHORUS** Obsidian vault as persistent memory. Read context accumulated across sessions; write things the operator asks to be remembered.
The vault belongs to a single **owner**, configured per machine (the `owner` field in `~/.claude/chorus-memory/config.json`) — this is that person's personal memory substrate. Write memory in third person about the owner ("the operator prefers X", not "I prefer X") so the vault stays readable by humans and other agents. The vault is shared **group memory**: one vault, many members. Each machine is configured with the **group** (who shares the vault) and this machine's **member id** (who is writing — stamped as `author:` on every agent write), in `~/.claude/chorus-memory/config.json`. Write memory in third person about people ("Jason prefers X", not "I prefer X") so the vault stays readable by every member and agent.
## API Configuration ## API Configuration
The owner, endpoint, and API key are **machine-local** — the committed plugin ships none of them. `chorus.py` (via `chorus_config`) resolves each field from `~/.claude/chorus-memory/config.json`, with an environment override per field (`CHORUS_OWNER`, `CHORUS_BASE`, `CHORUS_KEY`). Never paste the key into a vault note or a doc. The group, member, endpoint, and API key are **machine-local** — the committed plugin ships none of them. `chorus.py` (via `chorus_config`) resolves each field from `~/.claude/chorus-memory/config.json`, with an environment override per field (`CHORUS_GROUP`, `CHORUS_MEMBER`, `CHORUS_BASE`, `CHORUS_KEY`). Never paste the key into a vault note or a doc.
Exception — **per-user baked builds:** a `build.py --bake-key` artifact carries the owner/endpoint/key inside the plugin. When a *complete* baked set is present it is **authoritative** — it wins over env vars and the config file and can't be shadowed (so a baked install just works, even on a machine with a stale or placeholder config). The committed source stays credential-free. Exception — **per-member baked builds:** a `build.py --bake-key` artifact carries the group/member/endpoint/key inside the plugin. When a *complete* baked set is present it is **authoritative** — it wins over env vars and the config file and can't be shadowed (so a baked install just works, even on a machine with a stale or placeholder config). The committed source stays credential-free.
``` ```
endpoint = <config "endpoint" / $CHORUS_BASE> endpoint = <config "endpoint" / $CHORUS_BASE>
key = <config "key" / $CHORUS_KEY — resolved at runtime, never stored in the plugin> key = <config "key" / $CHORUS_KEY — resolved at runtime, never stored in the plugin>
owner = <config "owner" / $CHORUS_OWNER — how to refer to the vault owner, third person> member = <config "member" / $CHORUS_MEMBER — REQUIRED: this member's kebab-case id, stamped as author: on writes>
group = <config "group" / $CHORUS_GROUP — display name for the group sharing the vault>
``` ```
### First run — set up the machine if it isn't configured ### First run — set up the machine if it isn't configured
@@ -26,10 +27,10 @@ owner = <config "owner" / $CHORUS_OWNER — how to refer to the vault owner,
The config file is **per-machine** and is **not** shipped with the plugin, so a fresh install starts unconfigured. Before doing memory work, make sure this machine is set up: The config file is **per-machine** and is **not** shipped with the plugin, so a fresh install starts unconfigured. Before doing memory work, make sure this machine is set up:
1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`chorus.py load` prints a banner and exits `78`; other verbs exit `2`; `/chorus-doctor` reports it red). A still-placeholder `config init` file (unedited) also counts as not configured. 1. **Detect.** Any vault op on an unconfigured machine fails with `NOT CONFIGURED` (`chorus.py load` prints a banner and exits `78`; other verbs exit `2`; `/chorus-doctor` reports it red). A still-placeholder `config init` file (unedited) also counts as not configured.
2. **Ask the operator** — this is the one setup step that needs the human. Say plainly that CHORUS isn't configured on this machine and ask them to **provide their chorus-memory config file** (it holds the vault `owner`, `endpoint`, and API `key`), or to paste those three values. Don't invent or guess them, and don't proceed with memory until it's set. 2. **Ask the member** — this is the one setup step that needs the human. Say plainly that CHORUS isn't configured on this machine and ask them to **provide their chorus-memory config file** (it holds the `group`, their `member` id, the `endpoint`, and the API `key`), or to paste those values. Don't invent or guess them — especially not the member id, which attributes every write — and don't proceed with memory until it's set.
3. **Install what they give you:** 3. **Install what they give you:**
- They hand you a file → `python3 "$CHORUS" config import <path-to-their-file>` (validates it and writes `~/.claude/chorus-memory/config.json`). - They hand you a file → `python3 "$CHORUS" config import <path-to-their-file>` (validates it and writes `~/.claude/chorus-memory/config.json`).
- They paste values → `python3 "$CHORUS" config set --owner "…" --endpoint "https://…" --key "…"`. - They paste values → `python3 "$CHORUS" config set --group "…" --member "their-id" --endpoint "https://…" --key "…"`.
- Inspect with `python3 "$CHORUS" config` (key redacted); then re-run `load`. - Inspect with `python3 "$CHORUS" config` (key redacted); then re-run `load`.
If the vault is simply unreachable (network/`502`) *after* config exists, that's the **Vault Unreachable** path below, not a setup problem — don't re-ask for the key. If the vault is simply unreachable (network/`502`) *after* config exists, that's the **Vault Unreachable** path below, not a setup problem — don't re-ask for the key.
@@ -88,7 +89,7 @@ python3 "$CHORUS" link <pathA> <pathB> [--json] # add reciprocal [[Relate
python3 "$CHORUS" triage --list --json # structured inbox listing (see Inbox Triage) python3 "$CHORUS" triage --list --json # structured inbox listing (see Inbox Triage)
``` ```
- **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind``person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown. - **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/author/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind``person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown.
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `CHORUS_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Two precision rules (1.5.1) keep it from over-firing: the gate only blocks on a **same-kind** candidate (a decision or meeting named after a project/person is intentional naming — those surface as warnings only), and a **single shared token blocks only when it's unique to that entity** (a vault-common word like a project family name can't gate everything that mentions it; multi-token overlaps still block). Resolve a gate stop deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before. - **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `CHORUS_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Two precision rules (1.5.1) keep it from over-firing: the gate only blocks on a **same-kind** candidate (a decision or meeting named after a project/person is intentional naming — those surface as warnings only), and a **single shared token blocks only when it's unique to that entity** (a vault-common word like a project family name can't gate everything that mentions it; multi-token overlaps still block). Resolve a gate stop deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
- **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "chorus memory" surfaces the project `chorus`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes. - **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "chorus memory" surfaces the project `chorus`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes.
- **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose. - **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose.
@@ -214,7 +215,7 @@ Write when the operator:
- Says "remember that", "save this", "log this", "add to memory", "note that" - Says "remember that", "save this", "log this", "add to memory", "note that"
- Finishes a meaningful working session future sessions should pick up - Finishes a meaningful working session future sessions should pick up
Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true`. Write in third person about the operator. Every note carries the canonical frontmatter (see below). Agent-generated notes set `agent_written: true` and `author: <member>` (your configured member id).
**Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$CHORUS" capture "<title>" <bodyfile> --kind <kind>` — it does the search-first, path derivation, frontmatter, indexing, auto-linking, and Agent-Log line in one call (see **High-level memory ops**). Drop to the lower-level `put`/`patch`/`append` verbs below only for shapes `capture` doesn't model (a project `## Status` replacement, an ADR mirror, a daily-note edit) or to inspect/repair. **Default write path: `capture`.** For a routed memory (a fact about a person/company/project/concept/etc.), prefer `python3 "$CHORUS" capture "<title>" <bodyfile> --kind <kind>` — it does the search-first, path derivation, frontmatter, indexing, auto-linking, and Agent-Log line in one call (see **High-level memory ops**). Drop to the lower-level `put`/`patch`/`append` verbs below only for shapes `capture` doesn't model (a project `## Status` replacement, an ADR mirror, a daily-note edit) or to inspect/repair.
@@ -269,7 +270,7 @@ python3 "$CHORUS" fm <path> status active
python3 "$CHORUS" search <terms...> python3 "$CHORUS" search <terms...>
``` ```
Every PUT body needs the canonical YAML frontmatter (see `references/vault-layout.md`); set `agent_written: true` and `source_notes`. Bump `updated:` on substance, not on heartbeat — adding an Agent Log line, an inbox capture, or a timestamped Observations bullet is not a meaningful content change. Raw curl mechanics for every verb live in `references/api-reference.md`. Every PUT body needs the canonical YAML frontmatter (see `references/vault-layout.md`); set `agent_written: true`, `author: <your-member-id>`, and `source_notes`. Bump `updated:` on substance, not on heartbeat — adding an Agent Log line, an inbox capture, or a timestamped Observations bullet is not a meaningful content change. Raw curl mechanics for every verb live in `references/api-reference.md`.
## Scope Switching (`current-context.md`) ## Scope Switching (`current-context.md`)
@@ -431,7 +432,7 @@ If the API returns a connection error, timeout, or `502`, tell the operator once
- 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 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.
- **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. - **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`. - Every memory file has canonical YAML frontmatter — see `references/vault-layout.md`.
- Set `agent_written: true` on agent-generated notes and list `source_notes` (plain relative paths, not links). - Set `agent_written: true` and `author: <your-member-id>` on agent-generated notes and list `source_notes` (plain relative paths, not links).
- **`created:` is the earliest known date the entity was tracked anywhere in the vault, not "today".** When merging notes (e.g. promoting `on-hold/``active/`), preserve the earliest `created:` and only bump `updated:`. - **`created:` is the earliest known date the entity was tracked anywhere in the vault, not "today".** When merging notes (e.g. promoting `on-hold/``active/`), preserve the earliest `created:` and only bump `updated:`.
- **`source_notes` lists the note(s) that *triggered* or *supplied content for* this one** — e.g. the session log that produced a project update, or the daily note where a captured fact originated. It is a *backward* link to inputs. Forward links (this note → other notes it references) belong in the `## Related` section in the note body, never in frontmatter. - **`source_notes` lists the note(s) that *triggered* or *supplied content for* this one** — e.g. the session log that produced a project update, or the daily note where a captured fact originated. It is a *backward* link to inputs. Forward links (this note → other notes it references) belong in the `## Related` section in the note body, never in frontmatter.
- **Never put `[[wikilinks]]` in frontmatter** — YAML parses them as nested lists and the links break in Obsidian's reading view. Put all cross-references in a `## Related` section in the note **body** as a bulleted list of `[[links]]`. - **Never put `[[wikilinks]]` in frontmatter** — YAML parses them as nested lists and the links break in Obsidian's reading view. Put all cross-references in a `## Related` section in the note **body** as a bulleted list of `[[links]]`.
@@ -73,6 +73,9 @@ tags: [] # REQUIRED (non-empty) on entity notes — capture seeds the ki
aliases: [] # optional — other names this entity is called (Obsidian-native). Folded aliases: [] # optional — other names this entity is called (Obsidian-native). Folded
# into the entity index so a shortened/expanded name resolves to this note. # into the entity index so a shortened/expanded name resolves to this note.
agent_written: false agent_written: false
author: # member id of the session that wrote this note — REQUIRED when
# agent_written is true (capture stamps the configured member;
# /chorus-health flags missing-author)
source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]] source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
--- ---
``` ```
@@ -80,9 +83,12 @@ source_notes: [] # plain relative paths as strings — NEVER [[wikilinks]]
`aliases:` is the **durable, Obsidian-native home for alternate names** of an entity (e.g. the project `chorus` is also "chorus-memory" / "chorus plugin"). `capture` auto-derives the obvious case/punctuation variants of the title and writes them here; `sweep` folds frontmatter aliases back into the entity index, so they survive an index rebuild. List plain strings, never `[[wikilinks]]`. `aliases:` is the **durable, Obsidian-native home for alternate names** of an entity (e.g. the project `chorus` is also "chorus-memory" / "chorus plugin"). `capture` auto-derives the obvious case/punctuation variants of the title and writes them here; `sweep` folds frontmatter aliases back into the entity index, so they survive an index rebuild. List plain strings, never `[[wikilinks]]`.
`agent_written: true` + a populated `source_notes` is the key signal separating `agent_written: true` + a populated `source_notes` is the key signal separating
agent-managed content from human-authored content. When appending with POST, do agent-managed content from human-authored content. `author:` records **which
not rewrite frontmatterthe append goes after existing content. To change member's session** wrote the note — CHORUS is group memory, so every agent write
`updated:` or `status:`, use PATCH with `Target-Type: frontmatter`. is attributed (`capture` stamps it from the machine's configured `member`; when
writing a note by hand with PUT, set it to your member id). When appending with
POST, do not rewrite frontmatter — the append goes after existing content. To
change `updated:` or `status:`, use PATCH with `Target-Type: frontmatter`.
**Frontmatter field semantics:** **Frontmatter field semantics:**
@@ -5,6 +5,7 @@ created: {{DATE}}
updated: {{DATE}} updated: {{DATE}}
tags: [agent, context] tags: [agent, context]
agent_written: true agent_written: true
author: {{MEMBER}}
source_notes: [] source_notes: []
scope: scope:
scope_updated: {{DATE}} scope_updated: {{DATE}}
@@ -5,6 +5,7 @@ created: {{DATE}}
updated: {{DATE}} updated: {{DATE}}
tags: [agent, semantic-memory, operator] tags: [agent, semantic-memory, operator]
agent_written: true agent_written: true
author: {{MEMBER}}
source_notes: [] source_notes: []
confidence: low confidence: low
last_reviewed: {{DATE}} last_reviewed: {{DATE}}
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}} updated: {{date:YYYY-MM-DD}}
tags: [agent, context] tags: [agent, context]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
scope: scope:
refresh_strategy: on-demand refresh_strategy: on-demand
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}} updated: {{date:YYYY-MM-DD}}
tags: [agent, semantic-memory] tags: [agent, semantic-memory]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
confidence: high confidence: high
last_reviewed: {{date:YYYY-MM-DD}} last_reviewed: {{date:YYYY-MM-DD}}
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DDTHH:mm}}
updated: {{date:YYYY-MM-DDTHH:mm}} updated: {{date:YYYY-MM-DDTHH:mm}}
tags: [agent, session] tags: [agent, session]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
session_date: {{date:YYYY-MM-DD}} session_date: {{date:YYYY-MM-DD}}
client: claude-code client: claude-code
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DDTHH:mm}}
updated: {{date:YYYY-MM-DDTHH:mm}} updated: {{date:YYYY-MM-DDTHH:mm}}
tags: [agent, working-memory] tags: [agent, working-memory]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
expires_after: 48h expires_after: 48h
--- ---
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}} updated: {{date:YYYY-MM-DD}}
tags: [decision] tags: [decision]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
decision_date: {{date:YYYY-MM-DD}} decision_date: {{date:YYYY-MM-DD}}
impact: medium impact: medium
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}} updated: {{date:YYYY-MM-DD}}
tags: [daily, journal] tags: [daily, journal]
agent_written: false agent_written: false
author:
source_notes: [] source_notes: []
--- ---
@@ -5,6 +5,7 @@ created: {{date:gggg-[W]WW}}
updated: {{date:gggg-[W]WW}} updated: {{date:gggg-[W]WW}}
tags: [review, weekly] tags: [review, weekly]
agent_written: true agent_written: true
author:
source_notes: [] source_notes: []
period: weekly period: weekly
--- ---
@@ -5,6 +5,7 @@ created: {{date:YYYY-MM-DD}}
updated: {{date:YYYY-MM-DD}} updated: {{date:YYYY-MM-DD}}
tags: [project] tags: [project]
agent_written: false agent_written: false
author:
source_notes: [] source_notes: []
owner: owner:
review_cycle: weekly review_cycle: weekly
@@ -59,7 +59,9 @@ def seed(path: str, source: Path, dry_run: bool) -> None:
if dry_run: if dry_run:
print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}") print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}")
return return
text = source.read_text(encoding="utf-8").replace("{{DATE}}", chorus.today()) text = (source.read_text(encoding="utf-8")
.replace("{{DATE}}", chorus.today())
.replace("{{MEMBER}}", chorus.MEMBER or ""))
put_text(path, text) put_text(path, text)
print(f"bootstrap: seeded {path}") print(f"bootstrap: seeded {path}")
@@ -19,11 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts. instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
Config (owner/endpoint/key are machine-local — see chorus_config; the plugin ships none): Config (group/member/endpoint/key are machine-local — see chorus_config; the plugin ships none):
owner/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json group/member/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json
(env CHORUS_OWNER / CHORUS_BASE / CHORUS_KEY override per field). (env CHORUS_GROUP / CHORUS_MEMBER / CHORUS_BASE / CHORUS_KEY override per field).
Set up a machine with `chorus.py config init` then edit, or Set up a machine with `chorus.py config init` then edit, or
`chorus.py config set --owner ... --endpoint ... --key ...`. `chorus.py config set --group ... --member ... --endpoint ... --key ...`.
CHORUS_VERIFY default 1 — read-back verify after a PUT CHORUS_VERIFY default 1 — read-back verify after a PUT
CHORUS_LOCK_TTL default 900 — seconds before an advisory lock is considered stale CHORUS_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
CHORUS_TIMEOUT default 30 — per-request socket timeout (seconds) CHORUS_TIMEOUT default 30 — per-request socket timeout (seconds)
@@ -48,7 +48,7 @@ Usage:
chorus.py unlock <owner-id> # release advisory lock if owned by <owner-id> chorus.py unlock <owner-id> # release advisory lock if owned by <owner-id>
chorus.py scope show # print active scope, its freshness, and sessions-since chorus.py scope show # print active scope, its freshness, and sessions-since
chorus.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated) chorus.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
chorus.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file) chorus.py config [show|init|set|import] # machine-local group/member/endpoint/key (config import <file> adopts a provided file)
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 76 duplicate-gate (capture) · Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 76 duplicate-gate (capture) ·
78 config-required · 2 usage · 1 other HTTP/transport error. 78 config-required · 2 usage · 1 other HTTP/transport error.
@@ -80,7 +80,7 @@ for _stream in (sys.stdout, sys.stderr):
pass pass
# Owner / endpoint / key are machine-local and resolved by chorus_config from # Owner / endpoint / key are machine-local and resolved by chorus_config from
# ~/.claude/chorus-memory/config.json (env CHORUS_OWNER / CHORUS_BASE / CHORUS_KEY override). # ~/.claude/chorus-memory/config.json (env CHORUS_GROUP / CHORUS_MEMBER / CHORUS_BASE / CHORUS_KEY override).
# The plugin ships none of them; load() returns "" for anything unset so --help, # The plugin ships none of them; load() returns "" for anything unset so --help,
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key # `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
# are missing (see _require_endpoint / request). # are missing (see _require_endpoint / request).
@@ -89,7 +89,8 @@ import chorus_config # noqa: E402
_CFG = chorus_config.load() _CFG = chorus_config.load()
BASE = _CFG["endpoint"] BASE = _CFG["endpoint"]
KEY = _CFG["key"] KEY = _CFG["key"]
OWNER = _CFG["owner"] GROUP = _CFG["group"]
MEMBER = _CFG["member"] # this machine's member id — stamped as author: on writes
VERIFY = os.environ.get("CHORUS_VERIFY", "1") != "0" VERIFY = os.environ.get("CHORUS_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("CHORUS_LOCK_TTL", "900")) LOCK_TTL = int(os.environ.get("CHORUS_LOCK_TTL", "900"))
# Per-request socket timeout. A single stuck file fails fast instead of stalling a # Per-request socket timeout. A single stuck file fails fast instead of stalling a
@@ -141,11 +142,11 @@ def _require_config() -> None:
untouched `config init` template (placeholder endpoint/key).""" untouched `config init` template (placeholder endpoint/key)."""
if not chorus_config.is_configured(_CFG): if not chorus_config.is_configured(_CFG):
raise ChorusError( raise ChorusError(
"chorus: NOT CONFIGURED — no usable CHORUS key file on this machine. Ask the " "chorus: NOT CONFIGURED — no usable CHORUS config on this machine. Ask the "
f"operator for their config (owner/endpoint/key) and install it at " f"member for their config (group/member/endpoint/key) and install it at "
f"{chorus_config.config_path()}: `chorus.py config import <file>`, or " f"{chorus_config.config_path()}: `chorus.py config import <file>`, or "
"`chorus.py config set --owner ... --endpoint ... --key ...` " "`chorus.py config set --group ... --member ... --endpoint ... --key ...` "
"(or set CHORUS_BASE / CHORUS_KEY).", 2) "(or set CHORUS_BASE / CHORUS_KEY / CHORUS_MEMBER).", 2)
def _new_connection() -> http.client.HTTPConnection: def _new_connection() -> http.client.HTTPConnection:
@@ -613,10 +614,10 @@ def cmd_load() -> int:
if not chorus_config.is_configured(_CFG): if not chorus_config.is_configured(_CFG):
print("chorus: NOT CONFIGURED — this machine has no usable CHORUS key file yet.") print("chorus: NOT CONFIGURED — this machine has no usable CHORUS key file yet.")
print(f"Expected at: {chorus_config.config_path()}") print(f"Expected at: {chorus_config.config_path()}")
print("ACTION: ask the operator for their chorus-memory config file (it holds the") print("ACTION: ask the member for their chorus-memory config file (it holds the")
print(" vault owner, endpoint, and API key), then install it with ONE of:") print(" group, member id, endpoint, and API key), then install it with ONE of:")
print(" python3 chorus.py config import <path-to-their-file>") print(" python3 chorus.py config import <path-to-their-file>")
print(" python3 chorus.py config set --owner \"\" --endpoint \"https://…\" --key \"\"") print(" python3 chorus.py config set --group \"\" --member \"\" --endpoint \"https://…\" --key \"\"")
print("Then re-run load. (Memory is unavailable until configured.)") print("Then re-run load. (Memory is unavailable until configured.)")
return 78 # distinct: configuration required return 78 # distinct: configuration required
targets = [ targets = [
@@ -694,17 +695,17 @@ def cmd_load() -> int:
def cmd_config(args) -> int: def cmd_config(args) -> int:
"""show | init | set the machine-local owner/endpoint/key config.""" """show | init | set the machine-local group/member/endpoint/key config."""
path = chorus_config.config_path() path = chorus_config.config_path()
if args.action == "init": if args.action == "init":
p, created = chorus_config.init_template() p, created = chorus_config.init_template()
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key" print(f"ok: wrote config template to {p} — edit it with your group/member/endpoint/key"
if created else f"config already exists at {p} (left unchanged)") if created else f"config already exists at {p} (left unchanged)")
return 0 return 0
if args.action == "import": if args.action == "import":
if not args.path: if not args.path:
print("config import: pass the path to a config file " print("config import: pass the path to a config file "
"(JSON with owner/endpoint/key)", file=sys.stderr) "(JSON with group/member/endpoint/key)", file=sys.stderr)
return 2 return 2
try: try:
p = chorus_config.import_file(args.path) p = chorus_config.import_file(args.path)
@@ -714,10 +715,15 @@ def cmd_config(args) -> int:
print(f"ok: imported config to {p} (chmod 600 best-effort)") print(f"ok: imported config to {p} (chmod 600 best-effort)")
return 0 return 0
if args.action == "set": if args.action == "set":
if args.owner is None and args.endpoint is None and args.key is None: if args.group is None and args.member is None and args.endpoint is None and args.key is None:
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr) print("config set: pass at least one of --group / --member / --endpoint / --key",
file=sys.stderr)
return 2 return 2
p = chorus_config.write_config(args.owner, args.endpoint, args.key) if args.member is not None and not chorus_config.member_valid(args.member):
print(f"config set: --member must be a kebab-case slug "
f"(^[a-z0-9][a-z0-9-]{{0,31}}$), got: {args.member!r}", file=sys.stderr)
return 2
p = chorus_config.write_config(args.group, args.member, args.endpoint, args.key)
print(f"ok: updated {p} (chmod 600 best-effort)") print(f"ok: updated {p} (chmod 600 best-effort)")
return 0 return 0
# show — never prints the key in the clear # show — never prints the key in the clear
@@ -725,7 +731,8 @@ def cmd_config(args) -> int:
key = cfg["key"] key = cfg["key"]
redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "") redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
print(f"config file: {path}" + ("" if path.exists() else " (absent)")) print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)): for field, shown in (("group", cfg["group"]), ("member", cfg["member"]),
("endpoint", cfg["endpoint"]), ("key", redacted)):
src = chorus_config.source(field) src = chorus_config.source(field)
print(f" {field:9} {shown or '(unset)'} [{src}]") print(f" {field:9} {shown or '(unset)'} [{src}]")
return 0 return 0
@@ -752,10 +759,11 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("delete").add_argument("path") sub.add_parser("delete").add_argument("path")
sub.add_parser("lock").add_argument("owner") sub.add_parser("lock").add_argument("owner")
sub.add_parser("unlock").add_argument("owner") sub.add_parser("unlock").add_argument("owner")
p = sub.add_parser("config") # machine-local owner/endpoint/key (see chorus_config) p = sub.add_parser("config") # machine-local group/member/endpoint/key (see chorus_config)
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"]) p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
p.add_argument("path", nargs="?") # for `config import <path>` p.add_argument("path", nargs="?") # for `config import <path>`
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key") p.add_argument("--group"); p.add_argument("--member")
p.add_argument("--endpoint"); p.add_argument("--key")
p = sub.add_parser("scope") p = sub.add_parser("scope")
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]) p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
p.add_argument("text", nargs="?") p.add_argument("text", nargs="?")
@@ -31,10 +31,13 @@ import chorus_index as idx_mod # noqa: E402
def auto_owner() -> str: def auto_owner() -> str:
"""A stable-per-process owner id: client tag + pid. Avoids Math.random/time-based """A stable-per-process owner id: member + client tag + pid, so lock contention
is attributable to a person, not just a process. Avoids Math.random/time-based
ids; pid is unique enough for cooperative locking and is reproducible in tests.""" ids; pid is unique enough for cooperative locking and is reproducible in tests."""
tag = os.environ.get("CHORUS_CLIENT", "cc") tag = os.environ.get("CHORUS_CLIENT", "cc")
return f"{tag}-{os.getpid()}" member = getattr(chorus, "MEMBER", "") or ""
prefix = f"{member}-" if member else ""
return f"{prefix}{tag}-{os.getpid()}"
@contextlib.contextmanager @contextlib.contextmanager
@@ -1,12 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""chorus_config.py — resolve the vault owner, endpoint, and API key for CHORUS. """chorus_config.py — resolve the group, member, endpoint, and API key for CHORUS.
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each CHORUS is GROUP memory: one shared vault, many members. Every machine therefore
machine, a machine-local JSON config supplies them: supplies four values — who the group is, who *this* member is, and how to reach
the vault. The plugin ships NONE of them. On each machine, a machine-local JSON
config supplies them:
~/.claude/chorus-memory/config.json ~/.claude/chorus-memory/config.json
{ {
"owner": "Full Name (how the agent refers to the vault owner, third person)", "group": "Display name for the group/team that shares the vault",
"member": "this-members-id (kebab-case slug, e.g. \"jason\" — stamped as author: on writes)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com", "endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>" "key": "<obsidian-local-rest-api-bearer-token>"
} }
@@ -14,48 +17,58 @@ machine, a machine-local JSON config supplies them:
This file is per-machine: it must be created on each fresh install, is never This file is per-machine: it must be created on each fresh install, is never
committed, and is never written into a vault note. To create it, run committed, and is never written into a vault note. To create it, run
`python3 chorus.py config init` (writes a placeholder template when absent) and edit `python3 chorus.py config init` (writes a placeholder template when absent) and edit
it, or `python3 chorus.py config set --owner ... --endpoint ... --key ...`. it, or `python3 chorus.py config set --group ... --member ... --endpoint ... --key ...`.
Resolution — a COMPLETE baked credential set wins unconditionally: Resolution — a COMPLETE baked credential set wins unconditionally:
If the artifact carries both a baked endpoint AND key (a per-user build), those If the artifact carries a baked endpoint AND key AND member (a per-member build),
baked values are used as-is and CANNOT be shadowed by env vars or a config file. those baked values are used as-is and CANNOT be shadowed by env vars or a config
This is the default path: a delivered per-user plugin "just works" with no setup, file. This is the default path: a delivered per-member plugin "just works" with no
and a stale CHORUS_* env var or a leftover/placeholder ~/.claude config.json can setup, and a stale CHORUS_* env var or a leftover/placeholder ~/.claude config.json
never override or break it. can never override or break it.
Otherwise (nothing baked — the generic published plugin), fall back per-field, Otherwise (nothing baked — the generic published plugin), fall back per-field,
first hit wins: first hit wins:
owner env CHORUS_OWNER -> config "owner" group env CHORUS_GROUP -> config "group"
member env CHORUS_MEMBER -> config "member"
endpoint env CHORUS_BASE -> config "endpoint" endpoint env CHORUS_BASE -> config "endpoint"
key env CHORUS_KEY -> config "key" key env CHORUS_KEY -> config "key"
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
file path itself can be overridden with $CHORUS_CONFIG. Pure stdlib only. file path itself can be overridden with $CHORUS_CONFIG. Pure stdlib only.
MEMBER IDs are kebab-case slugs (^[a-z0-9][a-z0-9-]{0,31}$): they are stamped into
`author:` frontmatter on every agent write and (from schema 5) become path segments
(`_agent/members/<member>/…`), so they must be filesystem- and wikilink-safe.
BAKED CREDENTIALS (the default tier): the DEFAULT_* constants below are EMPTY in BAKED CREDENTIALS (the default tier): the DEFAULT_* constants below are EMPTY in
source — the committed plugin ships zero credentials. `build.py --bake-key` source — the committed plugin ships zero credentials. `build.py --bake-key`
substitutes a specific user's owner/endpoint/key into them at package time, substitutes one member's group/member/endpoint/key into them at package time,
producing a per-user artifact that needs no config file. This is how the plugin producing a per-member artifact that needs no config file. This is how the plugin
works in the CoWork sandbox, where the user's ~/.claude is not bridged: the plugin works in the CoWork sandbox, where the user's ~/.claude is not bridged: the plugin
itself (mounted read-only every session) carries the values. A baked artifact is itself (mounted read-only every session) carries the values. A baked artifact is
secret-bearing — deliver it directly to that one user, NEVER commit or publish it. secret-bearing — deliver it directly to that one member, NEVER commit or publish it.
On a normal host the empty defaults mean the env/config-file path takes over. On a normal host the empty defaults mean the env/config-file path takes over.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re
from pathlib import Path from pathlib import Path
# Build-time injection targets — MUST stay empty string literals in source so the # Build-time injection targets — MUST stay empty string literals in source so the
# committed tree ships no secret. `build.py --bake-key` rewrites these lines. # committed tree ships no secret. `build.py --bake-key` rewrites these lines.
DEFAULT_OWNER = "" DEFAULT_GROUP = ""
DEFAULT_MEMBER = ""
DEFAULT_BASE = "" DEFAULT_BASE = ""
DEFAULT_KEY = "" DEFAULT_KEY = ""
MEMBER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
TEMPLATE = { TEMPLATE = {
"owner": "Full Name — how the agent should refer to the vault owner (third person)", "group": "Group Name — the team/group sharing this vault",
"member": "<your-member-id — kebab-case slug, e.g. jason>",
"endpoint": "https://your-obsidian-rest-endpoint.example.com", "endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>", "key": "<obsidian-local-rest-api-bearer-token>",
} }
@@ -86,31 +99,34 @@ def _from_file() -> dict:
def baked_complete() -> bool: def baked_complete() -> bool:
"""True when the artifact carries a usable baked endpoint AND key — i.e. a """True when the artifact carries a usable baked endpoint AND key AND member —
per-user `--bake-key` build. When True, the baked values win unconditionally.""" i.e. a per-member `--bake-key` build. When True, the baked values win
return bool(DEFAULT_BASE and DEFAULT_KEY) unconditionally."""
return bool(DEFAULT_BASE and DEFAULT_KEY and DEFAULT_MEMBER)
def load() -> dict: def load() -> dict:
"""Return {owner, endpoint, key}. A complete baked set wins unconditionally; """Return {group, member, endpoint, key}. A complete baked set wins
otherwise env overrides the file, missing -> "". unconditionally; otherwise env overrides the file, missing -> "".
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(), Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key()/
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable resolve_member(), which raise a helpful error. This keeps `--help`, `config`, and
even when nothing is configured yet.""" `doctor` usable even when nothing is configured yet."""
f = _from_file() f = _from_file()
if baked_complete(): if baked_complete():
# Per-user artifact: baked creds are authoritative and must not be shadowed # Per-member artifact: baked values are authoritative and must not be
# by a stale env var or a leftover/placeholder config file. Owner is purely # shadowed by a stale env var or a leftover/placeholder config file. Group
# descriptive, so it may still fall back when not baked. # is purely descriptive, so it may still fall back when not baked.
return { return {
"owner": DEFAULT_OWNER or os.environ.get("CHORUS_OWNER") or f.get("owner") or "", "group": DEFAULT_GROUP or os.environ.get("CHORUS_GROUP") or f.get("group") or "",
"member": DEFAULT_MEMBER,
"endpoint": DEFAULT_BASE.rstrip("/"), "endpoint": DEFAULT_BASE.rstrip("/"),
"key": DEFAULT_KEY, "key": DEFAULT_KEY,
} }
endpoint = (os.environ.get("CHORUS_BASE") or f.get("endpoint") or "").rstrip("/") endpoint = (os.environ.get("CHORUS_BASE") or f.get("endpoint") or "").rstrip("/")
return { return {
"owner": os.environ.get("CHORUS_OWNER") or f.get("owner") or "", "group": os.environ.get("CHORUS_GROUP") or f.get("group") or "",
"member": os.environ.get("CHORUS_MEMBER") or f.get("member") or "",
"endpoint": endpoint, "endpoint": endpoint,
"key": os.environ.get("CHORUS_KEY") or f.get("key") or "", "key": os.environ.get("CHORUS_KEY") or f.get("key") or "",
} }
@@ -120,7 +136,7 @@ def _missing(field: str, env: str) -> RuntimeError:
return RuntimeError( return RuntimeError(
f"chorus: no {field} configured. Set {env}, or create {config_path()} " f"chorus: no {field} configured. Set {env}, or create {config_path()} "
f"(run `chorus.py config init` to scaffold it, then edit). " f"(run `chorus.py config init` to scaffold it, then edit). "
f'Expected JSON keys: "owner", "endpoint", "key".' f'Expected JSON keys: "group", "member", "endpoint", "key".'
) )
@@ -138,32 +154,49 @@ def resolve_key() -> str:
return k return k
def resolve_owner() -> str: def resolve_member() -> str:
"""The owner display name. Descriptive only, so an empty value is tolerated.""" """This machine's member id — required for attribution (author: stamping)."""
return load()["owner"] m = load()["member"]
if not member_valid(m):
raise _missing("member", "CHORUS_MEMBER")
return m
def resolve_group() -> str:
"""The group display name. Descriptive only, so an empty value is tolerated."""
return load()["group"]
def member_valid(member: str) -> bool:
"""True when the member id is a usable kebab-case slug (not empty, not the
template placeholder). Member ids become author: stamps and path segments."""
return bool(member) and bool(MEMBER_RE.match(member))
def is_configured(cfg: dict | None = None) -> bool: def is_configured(cfg: dict | None = None) -> bool:
"""True only when endpoint AND key are present and NOT the untouched template """True only when endpoint, key, AND member are present and NOT the untouched
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet template placeholders — so a freshly `config init`-ed (but unedited) file reads
configured, which is what triggers the first-run "ask for the key file" prompt.""" as not yet configured, which is what triggers the first-run "ask for the config
file" prompt. Member is required: group memory without attribution is broken."""
c = cfg if cfg is not None else load() c = cfg if cfg is not None else load()
ep, key = c.get("endpoint", ""), c.get("key", "") ep, key = c.get("endpoint", ""), c.get("key", "")
if not ep or not key: if not ep or not key:
return False return False
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"): if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
return False return False
return True return member_valid(c.get("member", ""))
def source(field: str) -> str: def source(field: str) -> str:
"""Where a field is currently coming from — for `config`/`doctor` reporting.""" """Where a field is currently coming from — for `config`/`doctor` reporting."""
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field] baked = {"group": DEFAULT_GROUP, "member": DEFAULT_MEMBER,
# A complete baked set wins unconditionally (see load()): endpoint/key always "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
# come from the artifact, and owner too whenever it was baked. # A complete baked set wins unconditionally (see load()): member/endpoint/key
if baked_complete() and (field in ("endpoint", "key") or baked): # always come from the artifact, and group too whenever it was baked.
if baked_complete() and (field in ("member", "endpoint", "key") or baked):
return "baked-in (plugin)" return "baked-in (plugin)"
env = {"owner": "CHORUS_OWNER", "endpoint": "CHORUS_BASE", "key": "CHORUS_KEY"}[field] env = {"group": "CHORUS_GROUP", "member": "CHORUS_MEMBER",
"endpoint": "CHORUS_BASE", "key": "CHORUS_KEY"}[field]
if os.environ.get(env): if os.environ.get(env):
return f"{env} env" return f"{env} env"
if config_path().exists() and _from_file().get(field): if config_path().exists() and _from_file().get(field):
@@ -184,11 +217,12 @@ def _secure(p: Path) -> None:
pass pass
def write_config(owner: str, endpoint: str, key: str) -> Path: def write_config(group: str, member: str, endpoint: str, key: str) -> Path:
"""Persist a filled-in config (merging over any existing values), chmod 0600.""" """Persist a filled-in config (merging over any existing values), chmod 0600."""
cur = _from_file() cur = _from_file()
merged = { merged = {
"owner": owner if owner is not None else cur.get("owner", ""), "group": group if group is not None else cur.get("group", ""),
"member": member if member is not None else cur.get("member", ""),
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"), "endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
"key": key if key is not None else cur.get("key", ""), "key": key if key is not None else cur.get("key", ""),
} }
@@ -211,9 +245,10 @@ def import_file(src) -> Path:
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})") raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
if not isinstance(data, dict) or not is_configured(data): if not isinstance(data, dict) or not is_configured(data):
raise RuntimeError( raise RuntimeError(
'config import: file must be JSON with a real "endpoint" and "key" ' 'config import: file must be JSON with a real "endpoint", "key", and '
'(and ideally "owner") — not the template placeholders.') '"member" (kebab-case slug; and ideally "group") — not the template '
return write_config(data.get("owner", ""), data["endpoint"], data["key"]) 'placeholders.')
return write_config(data.get("group", ""), data["member"], data["endpoint"], data["key"])
def init_template() -> tuple[Path, bool]: def init_template() -> tuple[Path, bool]:
@@ -38,11 +38,13 @@ def run() -> int:
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}", line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
f"running {sys.version_info.major}.{sys.version_info.minor}") f"running {sys.version_info.major}.{sys.version_info.minor}")
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key # 2. machine-local config (group/member/endpoint/key). Without a usable
# nothing else can run, so report it clearly before touching the network. # endpoint+key+member nothing else can run, so report it clearly before
# A still-placeholder `config init` template counts as not configured. # touching the network. A still-placeholder `config init` template counts
# as not configured.
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"] ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<") key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
member_real = chorus_config.member_valid(cfg["member"])
line(ep_real, "endpoint configured", line(ep_real, "endpoint configured",
f"[{chorus_config.source('endpoint')}]" if ep_real f"[{chorus_config.source('endpoint')}]" if ep_real
else (f"placeholder — edit {chorus_config.config_path()}" if cfg["endpoint"] else (f"placeholder — edit {chorus_config.config_path()}" if cfg["endpoint"]
@@ -50,12 +52,16 @@ def run() -> int:
line(key_real, "API key configured", line(key_real, "API key configured",
f"[{chorus_config.source('key')}]" if key_real f"[{chorus_config.source('key')}]" if key_real
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `chorus.py config`")) else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `chorus.py config`"))
line(bool(cfg["owner"]), "vault owner set", line(member_real, "member id configured",
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes") f"{cfg['member']} [{chorus_config.source('member')}]" if member_real
else (f"invalid — must be a kebab-case slug, got {cfg['member']!r}" if cfg["member"]
else "missing — required for attribution (author: stamping)"))
line(bool(cfg["group"]), "group name set",
cfg["group"] if cfg["group"] else "optional, but recommended (describes who shares the vault)")
if not chorus_config.is_configured(cfg): if not chorus_config.is_configured(cfg):
print("\ndoctor: not configured — ask the operator for their key file and install it " print("\ndoctor: not configured — ask the member for their config file and install it "
"(`chorus.py config import <file>`, or `config set --owner … --endpoint … --key …`), " "(`chorus.py config import <file>`, or "
"then re-run.") "`config set --group … --member … --endpoint … --key …`), then re-run.")
return 1 return 1
# 3. reachability + auth + marker, in one GET of the bootstrap marker # 3. reachability + auth + marker, in one GET of the bootstrap marker
@@ -47,7 +47,7 @@ def main() -> int:
if rc == 78: if rc == 78:
context = ("[chorus-memory] CHORUS is NOT CONFIGURED on this machine. Before " context = ("[chorus-memory] CHORUS is NOT CONFIGURED on this machine. Before "
"doing memory work, ask the operator for their chorus-memory config " "doing memory work, ask the operator for their chorus-memory config "
"(owner/endpoint/key) and install it via `chorus.py config import " "(group/member/endpoint/key) and install it via `chorus.py config import "
"<file>` — see the skill's First-run section.") "<file>` — see the skill's First-run section.")
elif rc == 0 and text.strip(): elif rc == 0 and text.strip():
if len(text) > MAX_CONTEXT: if len(text) > MAX_CONTEXT:
@@ -126,7 +126,13 @@ def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
if aliases: if aliases:
# Obsidian-native, durable home for aliases; sweep folds these back into the index. # Obsidian-native, durable home for aliases; sweep folds these back into the index.
fm.append("aliases: [" + ", ".join(json.dumps(a) for a in aliases) + "]") fm.append("aliases: [" + ", ".join(json.dumps(a) for a in aliases) + "]")
fm += ["agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""] fm.append("agent_written: true")
# Group attribution: every agent-written note records WHICH member's session
# created it. chorus.MEMBER is required config (is_configured), so this is
# only absent in unconfigured/mock edge cases.
if chorus.MEMBER:
fm.append(f"author: {chorus.MEMBER}")
fm += [f"source_notes: {src}", "---", "", f"# {title}", ""]
out = "\n".join(fm) out = "\n".join(fm)
if body_text.strip(): if body_text.strip():
out += body_text.strip() + "\n" out += body_text.strip() + "\n"
@@ -63,6 +63,16 @@ def _key(method: str, url: str, data: bytes | None) -> str:
return h.hexdigest() return h.hexdigest()
def _member() -> str:
"""The configured member id, for outbox attribution. Never raises — the queue
must keep working (that's its whole job) even half-configured."""
try:
import chorus_config
return chorus_config.load().get("member", "")
except Exception:
return ""
# --------------------------------------------------------------------- outbox # --------------------------------------------------------------------- outbox
def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key: str | None = None) -> None: def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key: str | None = None) -> None:
"""Append one intended write to the outbox (NDJSON; base64 body for binary safety). """Append one intended write to the outbox (NDJSON; base64 body for binary safety).
@@ -75,6 +85,7 @@ def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key:
"method": method.upper(), "url": url, "method": method.upper(), "url": url,
"body_b64": base64.b64encode(data).decode() if data else None, "body_b64": base64.b64encode(data).decode() if data else None,
"headers": headers or {}, "idem_key": idem_key, "headers": headers or {}, "idem_key": idem_key,
"member": _member(), # audit: whose session queued this write
# No wall-clock: replay order is file order, not a timestamp (keeps this testable). # No wall-clock: replay order is file order, not a timestamp (keeps this testable).
} }
with outbox_path().open("a", encoding="utf-8") as fh: with outbox_path().open("a", encoding="utf-8") as fh:
@@ -76,10 +76,12 @@ def main() -> int:
check("H5 classify/preview/apply wired", check("H5 classify/preview/apply wired",
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply"))) all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
# Config — owner/endpoint/key resolve from env -> machine-local config.json (no baked # Config — group/member/endpoint/key resolve from env -> machine-local config.json
# defaults). Env wins per field; the file fills the rest; missing required -> raises. # (no baked defaults). Env wins per field; the file fills the rest; missing
# required -> raises.
import chorus_config as cfg import chorus_config as cfg
saved_env = {k: os.environ.get(k) for k in ("CHORUS_KEY", "CHORUS_BASE", "CHORUS_OWNER", "CHORUS_CONFIG")} saved_env = {k: os.environ.get(k) for k in
("CHORUS_KEY", "CHORUS_BASE", "CHORUS_GROUP", "CHORUS_MEMBER", "CHORUS_CONFIG")}
try: try:
for k in saved_env: for k in saved_env:
os.environ.pop(k, None) os.environ.pop(k, None)
@@ -92,23 +94,40 @@ def main() -> int:
p2, created2 = cfg.init_template() p2, created2 = cfg.init_template()
check("config init is idempotent (no clobber)", created2 is False) check("config init is idempotent (no clobber)", created2 is False)
# write_config persists a filled-in config; load() reads it back. # write_config persists a filled-in config; load() reads it back.
cfg.write_config("Owner Name", "https://obsidian.example.com/", "file-key-xyz") cfg.write_config("Group Name", "test-member", "https://obsidian.example.com/", "file-key-xyz")
loaded = cfg.load() loaded = cfg.load()
check("config file round-trips owner/endpoint/key", check("config file round-trips group/member/endpoint/key",
loaded == {"owner": "Owner Name", loaded == {"group": "Group Name",
"member": "test-member",
"endpoint": "https://obsidian.example.com", # trailing / stripped "endpoint": "https://obsidian.example.com", # trailing / stripped
"key": "file-key-xyz"}) "key": "file-key-xyz"})
check("configured with valid member", cfg.is_configured(loaded))
# env overrides the file, per field. # env overrides the file, per field.
os.environ["CHORUS_KEY"] = "env-key-123" os.environ["CHORUS_KEY"] = "env-key-123"
check("env CHORUS_KEY overrides config", cfg.resolve_key() == "env-key-123") check("env CHORUS_KEY overrides config", cfg.resolve_key() == "env-key-123")
os.environ.pop("CHORUS_KEY", None) os.environ.pop("CHORUS_KEY", None)
os.environ["CHORUS_MEMBER"] = "env-member"
check("env CHORUS_MEMBER overrides config", cfg.resolve_member() == "env-member")
os.environ.pop("CHORUS_MEMBER", None)
# member validation: bad slugs and placeholders are not configured.
check("member_valid rejects bad slugs",
not cfg.member_valid("Jason S") and not cfg.member_valid("<your-member-id>")
and cfg.member_valid("jason"))
cfg.write_config("Group Name", "Not A Slug", "https://obsidian.example.com/", "k")
check("invalid member -> not configured", not cfg.is_configured(cfg.load()))
# missing required field raises a helpful error. # missing required field raises a helpful error.
cfg.write_config("Owner Name", "", "") cfg.write_config("Group Name", "test-member", "", "")
try: try:
cfg.resolve_key() cfg.resolve_key()
check("missing key raises", False) check("missing key raises", False)
except RuntimeError: except RuntimeError:
check("missing key raises", True) check("missing key raises", True)
cfg.write_config("Group Name", "", "https://obsidian.example.com/", "k")
try:
cfg.resolve_member()
check("missing member raises", False)
except RuntimeError:
check("missing member raises", True)
finally: finally:
for k, v in saved_env.items(): for k, v in saved_env.items():
if v is None: if v is None:
@@ -196,6 +196,13 @@ def main() -> int:
if not fm_field_populated(raw, fm, field): if not fm_field_populated(raw, fm, field):
what = f"missing {field}" if field not in fm else f"empty {field}" what = f"missing {field}" if field not in fm else f"empty {field}"
flag("incomplete-frontmatter", f"{path}: {what}") flag("incomplete-frontmatter", f"{path}: {what}")
# Group attribution: every agent-written note must say WHICH member's
# session wrote it (capture stamps `author:` from the configured member).
# The bootstrap marker is plugin-owned, not member content — exempt.
if fm and path != "_agent/chorus-vault.md" \
and str(fm.get("agent_written", "")).strip().lower() == "true" \
and not str(fm.get("author", "")).strip():
flag("missing-author", f"{path}: agent_written note has no author")
created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated")) created, updated = parse_date(fm.get("created")), parse_date(fm.get("updated"))
if created and updated and updated < created: if created and updated and updated < created:
flag("date-order", f"{path}: updated {updated} is before created {created}") flag("date-order", f"{path}: updated {updated} is before created {created}")
@@ -333,6 +340,7 @@ def main() -> int:
"retired-path": "Write to a retired/dead path", "retired-path": "Write to a retired/dead path",
"missing-frontmatter": "Missing required frontmatter field", "missing-frontmatter": "Missing required frontmatter field",
"incomplete-frontmatter": "Incomplete entity frontmatter (status/tags) — sweep.py --apply backfills", "incomplete-frontmatter": "Incomplete entity frontmatter (status/tags) — sweep.py --apply backfills",
"missing-author": "agent_written note lacks author: (member attribution)",
"date-order": "updated earlier than created", "date-order": "updated earlier than created",
"future-date": "updated date is in the future", "future-date": "updated date is in the future",
"source-notes-wikilink": "Wikilink in source_notes (must be plain paths)", "source-notes-wikilink": "Wikilink in source_notes (must be plain paths)",
+1 -1
View File
@@ -76,7 +76,7 @@ class Harness:
return None if body == "<<MISSING>>" else body return None if body == "<<MISSING>>" else body
def run(self, script, *args, env_extra=None, base=None): def run(self, script, *args, env_extra=None, base=None):
env = dict(os.environ, CHORUS_BASE=base or self.base, CHORUS_KEY=KEY, env = dict(os.environ, CHORUS_BASE=base or self.base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member",
CHORUS_VERIFY="1", CHORUS_TODAY=TODAY, CHORUS_STATE_DIR=self.state_dir, CHORUS_VERIFY="1", CHORUS_TODAY=TODAY, CHORUS_STATE_DIR=self.state_dir,
**(env_extra or {})) **(env_extra or {}))
return subprocess.run([sys.executable, str(script), *args], return subprocess.run([sys.executable, str(script), *args],
+7 -5
View File
@@ -52,7 +52,7 @@ class Harness:
return None if body == "<<MISSING>>" else body return None if body == "<<MISSING>>" else body
def chorus(self, *args): def chorus(self, *args):
env = dict(os.environ, CHORUS_BASE=self.base, CHORUS_KEY=KEY, CHORUS_VERIFY="1", env = dict(os.environ, CHORUS_BASE=self.base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_VERIFY="1",
CHORUS_TODAY="2026-06-21") CHORUS_TODAY="2026-06-21")
return subprocess.run([sys.executable, str(args[0]), *args[1:]], return subprocess.run([sys.executable, str(args[0]), *args[1:]],
capture_output=True, text=True, env=env) capture_output=True, text=True, env=env)
@@ -80,6 +80,8 @@ def main():
note = h.ground("resources/people/bob-smith.md") note = h.ground("resources/people/bob-smith.md")
check("capture person creates note", note is not None and "type: person" in note, r.stderr) check("capture person creates note", note is not None and "type: person" in note, r.stderr)
check("capture stamps agent_written", note and "agent_written: true" in note) check("capture stamps agent_written", note and "agent_written: true" in note)
check("capture stamps author with configured member",
note and "author: eval-member" in note)
idx = h.ground("_agent/index/entities.json") idx = h.ground("_agent/index/entities.json")
check("index records entity", idx and "bob-smith" in idx and '"bob"' in idx) check("index records entity", idx and "bob-smith" in idx and '"bob"' in idx)
@@ -91,7 +93,7 @@ def main():
# 3. capture a company whose body mentions Bob Smith -> auto bidirectional link # 3. capture a company whose body mentions Bob Smith -> auto bidirectional link
r = subprocess.run([sys.executable, str(CHORUS), "capture", "MPM", "--kind", "company", "-"], r = subprocess.run([sys.executable, str(CHORUS), "capture", "MPM", "--kind", "company", "-"],
input="Bob Smith is the principal here.\n", capture_output=True, text=True, input="Bob Smith is the principal here.\n", capture_output=True, text=True,
env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21")) env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_TODAY="2026-06-21"))
mpm = h.ground("resources/companies/mpm.md") mpm = h.ground("resources/companies/mpm.md")
bob = h.ground("resources/people/bob-smith.md") bob = h.ground("resources/people/bob-smith.md")
check("auto-link forward (mpm -> bob)", mpm and "[[resources/people/bob-smith]]" in mpm, r.stdout + r.stderr) check("auto-link forward (mpm -> bob)", mpm and "[[resources/people/bob-smith]]" in mpm, r.stdout + r.stderr)
@@ -140,7 +142,7 @@ def main():
'{"schema":1,"updated":"2026-06-21","entities":{"ext-entity":' '{"schema":1,"updated":"2026-06-21","entities":{"ext-entity":'
'{"path":"resources/concepts/ext-entity.md","kind":"concept","title":"Ext",' '{"path":"resources/concepts/ext-entity.md","kind":"concept","title":"Ext",'
'"aliases":[],"last_seen":"2026-06-21"}}}') '"aliases":[],"last_seen":"2026-06-21"}}}')
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21", PYTHONPATH=str(SCRIPTS)) env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_TODAY="2026-06-21", PYTHONPATH=str(SCRIPTS))
snippet = ("import chorus_index as ix, chorus_concurrency as c; " snippet = ("import chorus_index as ix, chorus_concurrency as c; "
"c.atomic_index_update(lambda d: ix.upsert(d,'mine'," "c.atomic_index_update(lambda d: ix.upsert(d,'mine',"
"'resources/concepts/mine.md','concept','Mine'))") "'resources/concepts/mine.md','concept','Mine'))")
@@ -184,7 +186,7 @@ def main():
r = subprocess.run([sys.executable, str(CHORUS), "capture", "Bob Smith", "-", "--kind", "person"], r = subprocess.run([sys.executable, str(CHORUS), "capture", "Bob Smith", "-", "--kind", "person"],
input="met at expo\nsecond line survives\nthird line too\n", input="met at expo\nsecond line survives\nthird line too\n",
capture_output=True, text=True, capture_output=True, text=True,
env=dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_TODAY="2026-06-21")) 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 "" 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: met at expo" in bob3, r.stdout + r.stderr)
check("v1.5 update keeps EVERY body line", check("v1.5 update keeps EVERY body line",
@@ -304,7 +306,7 @@ def main():
transcript = tdir / "t.jsonl" transcript = tdir / "t.jsonl"
turns = [json.dumps({"type": "user", "message": {"content": f"user turn {i}"}}) for i in range(6)] turns = [json.dumps({"type": "user", "message": {"content": f"user turn {i}"}}) for i in range(6)]
transcript.write_text("\n".join(turns), encoding="utf-8") transcript.write_text("\n".join(turns), encoding="utf-8")
hook_env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_STATE_DIR=str(tdir)) hook_env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_STATE_DIR=str(tdir))
payload = json.dumps({"session_id": "s1", "transcript_path": str(transcript), payload = json.dumps({"session_id": "s1", "transcript_path": str(transcript),
"stop_hook_active": False}) "stop_hook_active": False})
HOOK_STOP = SCRIPTS / "chorus_hook_stop.py" HOOK_STOP = SCRIPTS / "chorus_hook_stop.py"
+1 -1
View File
@@ -59,7 +59,7 @@ def main():
state = tempfile.mkdtemp() state = tempfile.mkdtemp()
def chorus(base, *args): def chorus(base, *args):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_STATE_DIR=state, env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_STATE_DIR=state,
CHORUS_VERIFY="0", CHORUS_TODAY="2026-06-22") CHORUS_VERIFY="0", CHORUS_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env) return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env)
+1 -1
View File
@@ -59,7 +59,7 @@ def main():
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def chorus(*args): def chorus(*args):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_VERIFY="0") env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_VERIFY="0")
return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env) return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env)
def ground(path): def ground(path):
+1 -1
View File
@@ -51,7 +51,7 @@ def main():
return getattr(e, "code", 0), "" return getattr(e, "code", 0), ""
def chorus(*args, stdin=None): def chorus(*args, stdin=None):
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_VERIFY="1", CHORUS_TODAY="2026-06-22") env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_VERIFY="1", CHORUS_TODAY="2026-06-22")
return subprocess.run([sys.executable, str(CHORUS), *args], input=stdin, return subprocess.run([sys.executable, str(CHORUS), *args], input=stdin,
capture_output=True, text=True, env=env) capture_output=True, text=True, env=env)