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
@@ -59,7 +59,9 @@ def seed(path: str, source: Path, dry_run: bool) -> None:
if dry_run:
print(f"bootstrap: would seed {path} <- {source.relative_to(SKILL_DIR)}")
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)
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
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):
owner/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json
(env CHORUS_OWNER / CHORUS_BASE / CHORUS_KEY override per field).
Config (group/member/endpoint/key are machine-local — see chorus_config; the plugin ships none):
group/member/endpoint/key resolved by chorus_config from ~/.claude/chorus-memory/config.json
(env CHORUS_GROUP / CHORUS_MEMBER / CHORUS_BASE / CHORUS_KEY override per field).
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_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
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 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 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) ·
78 config-required · 2 usage · 1 other HTTP/transport error.
@@ -80,7 +80,7 @@ for _stream in (sys.stdout, sys.stderr):
pass
# 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,
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
# are missing (see _require_endpoint / request).
@@ -89,7 +89,8 @@ import chorus_config # noqa: E402
_CFG = chorus_config.load()
BASE = _CFG["endpoint"]
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"
LOCK_TTL = int(os.environ.get("CHORUS_LOCK_TTL", "900"))
# 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)."""
if not chorus_config.is_configured(_CFG):
raise ChorusError(
"chorus: NOT CONFIGURED — no usable CHORUS key file on this machine. Ask the "
f"operator for their config (owner/endpoint/key) and install it at "
"chorus: NOT CONFIGURED — no usable CHORUS config on this machine. Ask the "
f"member for their config (group/member/endpoint/key) and install it at "
f"{chorus_config.config_path()}: `chorus.py config import <file>`, or "
"`chorus.py config set --owner ... --endpoint ... --key ...` "
"(or set CHORUS_BASE / CHORUS_KEY).", 2)
"`chorus.py config set --group ... --member ... --endpoint ... --key ...` "
"(or set CHORUS_BASE / CHORUS_KEY / CHORUS_MEMBER).", 2)
def _new_connection() -> http.client.HTTPConnection:
@@ -613,10 +614,10 @@ def cmd_load() -> int:
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()}")
print("ACTION: ask the operator for their chorus-memory config file (it holds the")
print(" vault owner, endpoint, and API key), then install it with ONE of:")
print("ACTION: ask the member for their chorus-memory config file (it holds the")
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 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.)")
return 78 # distinct: configuration required
targets = [
@@ -694,17 +695,17 @@ def cmd_load() -> 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()
if args.action == "init":
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)")
return 0
if args.action == "import":
if not args.path:
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
try:
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)")
return 0
if args.action == "set":
if args.owner 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)
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 --group / --member / --endpoint / --key",
file=sys.stderr)
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)")
return 0
# show — never prints the key in the clear
@@ -725,7 +731,8 @@ def cmd_config(args) -> int:
key = cfg["key"]
redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
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)
print(f" {field:9} {shown or '(unset)'} [{src}]")
return 0
@@ -752,10 +759,11 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("delete").add_argument("path")
sub.add_parser("lock").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("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.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
p.add_argument("text", nargs="?")
@@ -31,10 +31,13 @@ import chorus_index as idx_mod # noqa: E402
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."""
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
@@ -1,12 +1,15 @@
#!/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
machine, a machine-local JSON config supplies them:
CHORUS is GROUP memory: one shared vault, many members. Every machine therefore
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
{
"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",
"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
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
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:
If the artifact carries both a baked endpoint AND key (a per-user build), those
baked values are used as-is and CANNOT be shadowed by env vars or a config file.
This is the default path: a delivered per-user plugin "just works" with no setup,
and a stale CHORUS_* env var or a leftover/placeholder ~/.claude config.json can
never override or break it.
If the artifact carries a baked endpoint AND key AND member (a per-member build),
those baked values are used as-is and CANNOT be shadowed by env vars or a config
file. This is the default path: a delivered per-member plugin "just works" with no
setup, and a stale CHORUS_* env var or a leftover/placeholder ~/.claude config.json
can never override or break it.
Otherwise (nothing baked — the generic published plugin), fall back per-field,
first hit wins:
owner env CHORUS_OWNER -> config "owner"
endpoint env CHORUS_BASE -> config "endpoint"
key env CHORUS_KEY -> config "key"
group env CHORUS_GROUP -> config "group"
member env CHORUS_MEMBER -> config "member"
endpoint env CHORUS_BASE -> config "endpoint"
key env CHORUS_KEY -> config "key"
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
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
source — the committed plugin ships zero credentials. `build.py --bake-key`
substitutes a specific user's owner/endpoint/key into them at package time,
producing a per-user artifact that needs no config file. This is how the plugin
substitutes one member's group/member/endpoint/key into them at package time,
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
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.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
# 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.
DEFAULT_OWNER = ""
DEFAULT_GROUP = ""
DEFAULT_MEMBER = ""
DEFAULT_BASE = ""
DEFAULT_KEY = ""
MEMBER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
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",
"key": "<obsidian-local-rest-api-bearer-token>",
}
@@ -86,31 +99,34 @@ def _from_file() -> dict:
def baked_complete() -> bool:
"""True when the artifact carries a usable baked endpoint AND key — i.e. a
per-user `--bake-key` build. When True, the baked values win unconditionally."""
return bool(DEFAULT_BASE and DEFAULT_KEY)
"""True when the artifact carries a usable baked endpoint AND key AND member —
i.e. a per-member `--bake-key` build. When True, the baked values win
unconditionally."""
return bool(DEFAULT_BASE and DEFAULT_KEY and DEFAULT_MEMBER)
def load() -> dict:
"""Return {owner, endpoint, key}. A complete baked set wins unconditionally;
otherwise env overrides the file, missing -> "".
"""Return {group, member, endpoint, key}. A complete baked set wins
unconditionally; otherwise env overrides the file, missing -> "".
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
even when nothing is configured yet."""
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key()/
resolve_member(), which raise a helpful error. This keeps `--help`, `config`, and
`doctor` usable even when nothing is configured yet."""
f = _from_file()
if baked_complete():
# Per-user artifact: baked creds are authoritative and must not be shadowed
# by a stale env var or a leftover/placeholder config file. Owner is purely
# descriptive, so it may still fall back when not baked.
# Per-member artifact: baked values are authoritative and must not be
# shadowed by a stale env var or a leftover/placeholder config file. Group
# is purely descriptive, so it may still fall back when not baked.
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("/"),
"key": DEFAULT_KEY,
}
endpoint = (os.environ.get("CHORUS_BASE") or f.get("endpoint") or "").rstrip("/")
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,
"key": os.environ.get("CHORUS_KEY") or f.get("key") or "",
}
@@ -120,7 +136,7 @@ def _missing(field: str, env: str) -> RuntimeError:
return RuntimeError(
f"chorus: no {field} configured. Set {env}, or create {config_path()} "
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
def resolve_owner() -> str:
"""The owner display name. Descriptive only, so an empty value is tolerated."""
return load()["owner"]
def resolve_member() -> str:
"""This machine's member id — required for attribution (author: stamping)."""
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:
"""True only when endpoint AND key are present and NOT the untouched template
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
configured, which is what triggers the first-run "ask for the key file" prompt."""
"""True only when endpoint, key, AND member are present and NOT the untouched
template placeholders — so a freshly `config init`-ed (but unedited) file reads
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()
ep, key = c.get("endpoint", ""), c.get("key", "")
if not ep or not key:
return False
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
return False
return True
return member_valid(c.get("member", ""))
def source(field: str) -> str:
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
# A complete baked set wins unconditionally (see load()): endpoint/key always
# come from the artifact, and owner too whenever it was baked.
if baked_complete() and (field in ("endpoint", "key") or baked):
baked = {"group": DEFAULT_GROUP, "member": DEFAULT_MEMBER,
"endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
# A complete baked set wins unconditionally (see load()): member/endpoint/key
# 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)"
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):
return f"{env} env"
if config_path().exists() and _from_file().get(field):
@@ -184,11 +217,12 @@ def _secure(p: Path) -> None:
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."""
cur = _from_file()
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("/"),
"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})")
if not isinstance(data, dict) or not is_configured(data):
raise RuntimeError(
'config import: file must be JSON with a real "endpoint" and "key" '
'(and ideally "owner") — not the template placeholders.')
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
'config import: file must be JSON with a real "endpoint", "key", and '
'"member" (kebab-case slug; and ideally "group") — not the template '
'placeholders.')
return write_config(data.get("group", ""), data["member"], data["endpoint"], data["key"])
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]}",
f"running {sys.version_info.major}.{sys.version_info.minor}")
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key
# nothing else can run, so report it clearly before touching the network.
# A still-placeholder `config init` template counts as not configured.
# 2. machine-local config (group/member/endpoint/key). Without a usable
# endpoint+key+member nothing else can run, so report it clearly before
# 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"]
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
member_real = chorus_config.member_valid(cfg["member"])
line(ep_real, "endpoint configured",
f"[{chorus_config.source('endpoint')}]" if ep_real
else (f"placeholder — edit {chorus_config.config_path()}" if cfg["endpoint"]
@@ -50,12 +52,16 @@ def run() -> int:
line(key_real, "API key configured",
f"[{chorus_config.source('key')}]" if key_real
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `chorus.py config`"))
line(bool(cfg["owner"]), "vault owner set",
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
line(member_real, "member id configured",
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):
print("\ndoctor: not configured — ask the operator for their key file and install it "
"(`chorus.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
"then re-run.")
print("\ndoctor: not configured — ask the member for their config file and install it "
"(`chorus.py config import <file>`, or "
"`config set --group … --member … --endpoint … --key …`), then re-run.")
return 1
# 3. reachability + auth + marker, in one GET of the bootstrap marker
@@ -47,7 +47,7 @@ def main() -> int:
if rc == 78:
context = ("[chorus-memory] CHORUS is NOT CONFIGURED on this machine. Before "
"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.")
elif rc == 0 and text.strip():
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:
# 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 += ["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)
if body_text.strip():
out += body_text.strip() + "\n"
@@ -63,6 +63,16 @@ def _key(method: str, url: str, data: bytes | None) -> str:
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
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).
@@ -75,6 +85,7 @@ def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key:
"method": method.upper(), "url": url,
"body_b64": base64.b64encode(data).decode() if data else None,
"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).
}
with outbox_path().open("a", encoding="utf-8") as fh:
@@ -76,10 +76,12 @@ def main() -> int:
check("H5 classify/preview/apply wired",
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
# defaults). Env wins per field; the file fills the rest; missing required -> raises.
# Config — group/member/endpoint/key resolve from env -> machine-local config.json
# (no baked defaults). Env wins per field; the file fills the rest; missing
# required -> raises.
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:
for k in saved_env:
os.environ.pop(k, None)
@@ -92,23 +94,40 @@ def main() -> int:
p2, created2 = cfg.init_template()
check("config init is idempotent (no clobber)", created2 is False)
# 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()
check("config file round-trips owner/endpoint/key",
loaded == {"owner": "Owner Name",
check("config file round-trips group/member/endpoint/key",
loaded == {"group": "Group Name",
"member": "test-member",
"endpoint": "https://obsidian.example.com", # trailing / stripped
"key": "file-key-xyz"})
check("configured with valid member", cfg.is_configured(loaded))
# env overrides the file, per field.
os.environ["CHORUS_KEY"] = "env-key-123"
check("env CHORUS_KEY overrides config", cfg.resolve_key() == "env-key-123")
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.
cfg.write_config("Owner Name", "", "")
cfg.write_config("Group Name", "test-member", "", "")
try:
cfg.resolve_key()
check("missing key raises", False)
except RuntimeError:
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:
for k, v in saved_env.items():
if v is None:
@@ -196,6 +196,13 @@ def main() -> int:
if not fm_field_populated(raw, fm, field):
what = f"missing {field}" if field not in fm else f"empty {field}"
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"))
if created and updated and updated < 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",
"missing-frontmatter": "Missing required frontmatter field",
"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",
"future-date": "updated date is in the future",
"source-notes-wikilink": "Wikilink in source_notes (must be plain paths)",