diff --git a/README.md b/README.md index f893f2b..8da4772 100644 --- a/README.md +++ b/README.md @@ -352,4 +352,5 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the | **0.5.0** | Self-bootstrap + control-logic-in-plugin. Plugin becomes the single source of truth: bundled `scaffold/` (8 templates, 3 anchor seeds, thin vault README, marker) bootstraps an empty vault with no external/local-path dependency. New `operating-contract.md` (principles + safety from the old in-vault `CLAUDE.md`); `bootstrap.md` rewritten as a portable bootstrap/repair/migrate manifest. Cold-start probe moved from `/vault/BOOTSTRAP.md` to `_agent/echo-vault.md` (carries `schema_version`). Live vault migrated to data-only. | | **0.5.1** | Routing-doc consistency pass: decision-mirror heading unified to `## Key Decisions`; stale `Current status` PATCH examples corrected to `Status`; vault-layout inline project example refreshed to the real template. All 17 `projects/active/` notes normalized losslessly to the canonical template heading set; `android-mqtt-shell` moved to `incubating/` (was broken `status: upcoming` in active). Plugin repackaged (21 files). | | **0.6.0** | Schema 2. **#8 Inbox auto-fire:** the Loading procedure adds an inbox-depth GET and a load-time *Reconcile* step (inbox triage + scope-drift), so triage self-fires. **#10 Routing:** `reviews/` retired — weekly/monthly/quarterly/annual rollups fold into `journal/{weekly,monthly,quarterly,annual}/`, vault-health moves to `_agent/health/`; new `references/routing-map.md` is the complete audited endpoint→logic map. **Recs:** heartbeat pointer operationalized (read first at load, written at session end); new `scripts/vault-lint.sh` mechanically checks vault invariants. Dead refs pruned (`archive/`, `_agent/outputs/`, `resources/source-material`). Migration `1 → 2` in `bootstrap.md`. | +| **0.7.1** | **Scope-drift fix.** Scope is the most churn-prone state (several sessions/day) and had no freshness signal, so sessions silently ran under stale scope (same failure class as #8). Added a `scope_updated:` frontmatter timestamp (maintained automatically), an `echo.sh scope show` / `scope set` command (atomic switch: archive prior → replace → stamp), and a `vault-lint.sh` **drift check** (flags when ≥ `SCOPE_STALE_SESSIONS`, default 3, session logs are dated after `scope_updated`) — making drift mechanically *evaluable* via `/echo-health`. Tightened the SKILL load-reconcile to *state and confirm* scope every session and switch before working. (Also fixed a bash nested-quote parse bug found while building `scope`, where `show` could fall through into `set`.) | | **0.7.0** | Schema 2 (unchanged layout). Hardening pass — gave the prose-and-curl skill an executable spine. **S2** `scripts/echo.sh`: one validated client wrapping every verb with auth, HTTP-status checking (failed writes exit non-zero instead of looking like success), one bounded retry on 5xx, read-back-verified PUT, and idempotent `append`. **S3** `scripts/routing.json`: canonical machine-readable route manifest; `vault-lint.sh` enforces it (flags unknown/retired paths). **S4** deterministic `scripts/bootstrap.sh` + `scripts/migrate.sh` (idempotent, dry-run, probe-before-write; fixes the old CWD-relative `@scaffold/...` empty-body bug). **S5** cooperative advisory lock (`_agent/locks/vault.lock`) + documented multi-writer model. **M1/M2/M5** linter rewrite: real YAML parsing, injected clock (`ECHO_TODAY`), exits `3` (not "clean") on an un-bootstrapped vault, plus routing-membership + frontmatter-integrity checks. **M3** status-check guidance throughout. **M4** four slash commands (`/echo-load`, `/echo-save`, `/echo-triage`, `/echo-health`). Added a credential-free A/B `eval/` harness (mock REST API + fault injection): isolates a **−76% generated-token** I/O layer and **4 → 0 silent write failures** vs 0.6. | diff --git a/echo-memory-0.7.1.plugin b/echo-memory-0.7.1.plugin new file mode 100644 index 0000000..07f501f Binary files /dev/null and b/echo-memory-0.7.1.plugin differ diff --git a/echo-memory.plugin b/echo-memory.plugin index 54b3557..07f501f 100644 Binary files a/echo-memory.plugin and b/echo-memory.plugin differ diff --git a/echo-memory.plugin.src/.claude-plugin/plugin.json b/echo-memory.plugin.src/.claude-plugin/plugin.json index ae71cfa..a0e65ed 100644 --- a/echo-memory.plugin.src/.claude-plugin/plugin.json +++ b/echo-memory.plugin.src/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "echo-memory", - "version": "0.7.0", + "version": "0.7.1", "description": "Persistent memory via the ECHO Obsidian vault over the Local REST API \u2014 no MCP server. Self-bootstrapping: the plugin carries the full vault scaffold and all control logic, so it stands up an empty vault and ports to any other. Ships a validated API client (echo.sh), a machine-readable routing manifest the linter enforces, deterministic bootstrap/migrate scripts, and /echo-load|save|triage|health commands. Reads/writes notes across Claude/CoWork sessions. Jason's personal memory vault.", "author": { "name": "Jason" diff --git a/echo-memory.plugin.src/skills/echo-memory/SKILL.md b/echo-memory.plugin.src/skills/echo-memory/SKILL.md index 720bfaf..1868b0b 100644 --- a/echo-memory.plugin.src/skills/echo-memory/SKILL.md +++ b/echo-memory.plugin.src/skills/echo-memory/SKILL.md @@ -106,7 +106,7 @@ Do not read every session log — older sessions are reachable via `POST /search **Reconcile at load (do this every cold start, after the batch returns).** The batch already fetched everything needed for a cheap self-check — run it before diving into the work so memory maintains itself instead of drifting: 1. **Inbox depth (Inbox Triage).** If `inbox/captures/inbox.md` (GET #6) holds dated capture lines older than ~7 days that were never routed, surface the count once and offer to triage — see **Inbox Triage** below. This is the load-time trigger that makes triage self-firing rather than something you only run when asked. -2. **Scope drift.** Compare `## Scope` in `current-context.md` (GET #3) against what Jason just asked for. If they diverge, follow **Scope Switching** to record the prior scope and set the new one. +2. **Scope drift (state it, don't just check it).** Scope is the most churn-prone state — Jason runs several sessions a day across different topics, so the recorded `## Scope` is frequently stale at load. **Silently working under a stale scope is the default failure mode.** To prevent it, at load read the active scope and its freshness in one call — `echo.sh scope show` (prints `## Scope`, `scope_updated`, and how many sessions have been logged since) — and form a one-line judgment: *does this session's request match the recorded scope?* If it diverges, switch **before** doing the work via `echo.sh scope set ""` (see **Scope Switching**). If `scope show` reports several sessions logged since the last switch, treat the recorded scope as suspect and confirm with Jason rather than trusting it. Keep the reconcile to a single short line to Jason (e.g. "3 inbox captures from last week are still un-routed — triage now?"); don't let it crowd out the actual request. @@ -307,16 +307,22 @@ curl -s -X POST \ ## Scope Switching (`current-context.md`) -`_agent/context/current-context.md` tracks a single active scope. Jason routinely shifts scope within a day (echo plugin → MPM brand → WISP docs). +`_agent/context/current-context.md` tracks a single active scope. Jason routinely shifts scope within a day (echo plugin → MPM brand → WISP docs), so this is high-churn — switch deliberately, every time the work changes topic. -When scope changes: +**Preferred — one command:** -1. PATCH `prepend` a dated bullet to `## Scope History` capturing the **prior** scope (one line: `- 2026-06-06: `). If `## Scope History` doesn't exist yet, POST the heading first, same pattern as the daily-note Agent Log. -2. PATCH `replace` `## Scope` with the new scope. -3. PATCH the frontmatter `updated:` field. +```bash +"$ECHO" scope set "" +``` + +`scope set` does the whole switch atomically and correctly: it archives the **prior** scope to `## Scope History` (dated, truncated), replaces `## Scope` with the new text, and stamps the `scope_updated:` frontmatter timestamp. That timestamp is the **freshness signal** — it's what `echo.sh scope show` and the `vault-lint.sh` drift check read to tell whether the recorded scope still reflects current work. Always switch through `scope set` so `scope_updated` stays honest; a hand-edited `## Scope` that skips the stamp reintroduces silent drift. + +**Manual fallback** (only if `echo.sh` is unavailable): PATCH `prepend` the prior scope to `## Scope History`, PATCH `replace` `## Scope`, then PATCH the frontmatter `scope_updated:` (and `updated:`) to today. Note `scope_updated` must already exist in frontmatter — a `PATCH replace` on a missing field returns `400 invalid-target`; run `bootstrap.sh` repair to add it. This keeps a rolling trail of recent scopes in one file instead of spawning separate stash notes. Trim Scope History to the last ~10 entries when it grows past that. +**Drift backstop:** `vault-lint.sh` flags when ≥ `SCOPE_STALE_SESSIONS` (default 3) session logs are dated after `scope_updated` — i.e. work happened without a scope switch. It's advisory (surfaced in Vault Health / `/echo-health`), the mechanical safety net under the load-time judgment above. + ## Journal Rollups (the journal is one continuum) The journal is a single append-only chronological stream. Rollups are just coarser-grained journal entries over the same timeline, so they **all live under `journal/`** — there is no separate `reviews/` tree. One place to read the whole time-series story, daily through annual. diff --git a/echo-memory.plugin.src/skills/echo-memory/scaffold/anchors/current-context.seed.md b/echo-memory.plugin.src/skills/echo-memory/scaffold/anchors/current-context.seed.md index e8d6474..5c6e56d 100644 --- a/echo-memory.plugin.src/skills/echo-memory/scaffold/anchors/current-context.seed.md +++ b/echo-memory.plugin.src/skills/echo-memory/scaffold/anchors/current-context.seed.md @@ -7,6 +7,7 @@ tags: [agent, context] agent_written: true source_notes: [] scope: +scope_updated: {{DATE}} refresh_strategy: on-demand --- diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/echo.sh b/echo-memory.plugin.src/skills/echo-memory/scripts/echo.sh index b9acece..9be3f41 100755 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/echo.sh +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/echo.sh @@ -27,6 +27,8 @@ # echo.sh delete # DELETE (destructive; explicit use only) # echo.sh lock # acquire advisory lock (exit 75 if held by someone else & fresh) # echo.sh unlock # release advisory lock if owned by +# echo.sh scope show # print active scope, its freshness, and sessions-since +# echo.sh scope set "" # switch scope atomically (history + replace + stamp scope_updated) # # Exit codes: 0 ok · 44 not-found(404) · 75 lock-held · 2 usage · 1 other HTTP/transport error. @@ -194,6 +196,50 @@ case "$cmd" in [ "$HTTP" = "404" ] || _check "unlock" echo "ok: unlocked" ;; + scope) + # scope show | scope set "" + # 'set' archives the prior scope to ## Scope History, replaces ## Scope, and stamps + # the scope_updated freshness timestamp — one command instead of three error-prone PATCHes. + sub="${1:-show}"; shift || true + ccpath="_agent/context/current-context.md" + case "$sub" in + show) + _curl GET "$(_vault_url "$ccpath")"; _check "scope show" + cur="$RESP" + echo "── Active scope ──" + awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' "$cur" + su="$(sed -n 's/^scope_updated:[[:space:]]*//p' "$cur" | head -1)" + su="${su//\"/}" + echo "scope_updated: ${su:-}" + _curl GET "$(_vault_url "_agent/sessions/")" + if [ "$HTTP" = "200" ] && [ -n "$su" ]; then + n="$(python3 -c "import json,sys;f=json.load(open('$RESP'))['files'];print(sum(1 for x in f if x.endswith('.md') and x[:10]>'$su'))" 2>/dev/null || echo '?')" + echo "sessions logged since: ${n}" + fi ;; + set) + [ $# -ge 1 ] || die "scope set needs the new scope text" + new="$1" + _curl GET "$(_vault_url "$ccpath")"; _check "scope set(read)" + prior="$(awk '/^## Scope[[:space:]]*$/{f=1;next} /^## /{if(f)exit} f' "$RESP" \ + | tr '\n' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | cut -c1-140)" + BODY="$(mktemp)"; printf -- '- %s: %s\n' "$(_today)" "${prior:-(prior scope)}" > "$BODY" + _curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: prepend' -H 'Target-Type: heading' \ + -H 'Target: Current Context::Scope History' -H 'Content-Type: text/markdown' --data-binary @"$BODY" + _check "scope set(history)" + BODY="$(mktemp)"; printf '%s\n' "$new" > "$BODY" + _curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: replace' -H 'Target-Type: heading' \ + -H 'Target: Current Context::Scope' -H 'Content-Type: text/markdown' --data-binary @"$BODY" + _check "scope set(replace)" + BODY="$(mktemp)"; printf '"%s"' "$(_today)" > "$BODY" + _curl PATCH "$(_vault_url "$ccpath")" -H 'Operation: replace' -H 'Target-Type: frontmatter' \ + -H 'Target: scope_updated' -H 'Content-Type: application/json' --data-binary @"$BODY" + if [ "${HTTP:-000}" -ge 400 ]; then + die "scope set: body switched, but scope_updated frontmatter is missing (run bootstrap.sh repair to add it) [HTTP $HTTP]" + fi + echo "ok: scope switched (prior archived to Scope History; scope_updated=$(_today))" ;; + *) die "scope: use 'show' or 'set \"\"'" ;; + esac ;; + ""|-h|--help|help) usage ;; - *) die "unknown command '$cmd' (try: get map ls search put post append patch fm bump delete lock unlock)" ;; + *) die "unknown command '$cmd' (try: get map ls search put post append patch fm bump delete lock unlock scope)" ;; esac diff --git a/echo-memory.plugin.src/skills/echo-memory/scripts/vault-lint.sh b/echo-memory.plugin.src/skills/echo-memory/scripts/vault-lint.sh index d9a5f7f..3301c16 100755 --- a/echo-memory.plugin.src/skills/echo-memory/scripts/vault-lint.sh +++ b/echo-memory.plugin.src/skills/echo-memory/scripts/vault-lint.sh @@ -28,9 +28,11 @@ ECHO_BASE="${ECHO_BASE:-https://echoapi.alwisp.com}" ECHO_KEY="${ECHO_KEY:-241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab}" STALE_DAYS="${STALE_DAYS:-30}" INBOX_DAYS="${INBOX_DAYS:-14}" +SCOPE_STALE_SESSIONS="${SCOPE_STALE_SESSIONS:-3}" ECHO_TODAY="${ECHO_TODAY:-$(date +%Y-%m-%d)}" ECHO_BASE="$ECHO_BASE" ECHO_KEY="$ECHO_KEY" STALE_DAYS="$STALE_DAYS" INBOX_DAYS="$INBOX_DAYS" \ +SCOPE_STALE_SESSIONS="$SCOPE_STALE_SESSIONS" \ ECHO_TODAY="$ECHO_TODAY" ROUTING_JSON="$SCRIPT_DIR/routing.json" \ python3 - <<'PY' import os, sys, json, re, datetime, urllib.request, urllib.error @@ -39,6 +41,7 @@ BASE = os.environ["ECHO_BASE"].rstrip("/") KEY = os.environ["ECHO_KEY"] STALE_DAYS = int(os.environ["STALE_DAYS"]) INBOX_DAYS = int(os.environ["INBOX_DAYS"]) +SCOPE_STALE_SESSIONS = int(os.environ["SCOPE_STALE_SESSIONS"]) TODAY = datetime.date.fromisoformat(os.environ["ECHO_TODAY"]) ROUTING_JSON = os.environ["ROUTING_JSON"] LIFECYCLES = ["active", "incubating", "on-hold", "archived"] @@ -260,6 +263,26 @@ for line in inbox.splitlines(): if d and (TODAY - d).days > INBOX_DAYS: flag("aging-inbox", f"inbox capture {d} ({(TODAY-d).days}d): {line.strip()[:80]}") +# ---- Scope freshness (drift detector) ---------------------------------------- +# Scope is the most churn-prone state (Jason runs several sessions/day across topics). +# It has no natural staleness signal, so drift is otherwise invisible. Rule: if N+ session +# logs are dated AFTER current-context's scope_updated, the recorded scope may no longer +# reflect current work — surface it for a human glance (advisory, like every health finding). +cc = get("_agent/context/current-context.md") +if cc is not None: + _, ccfm = parse_fm(cc) + su = parse_date(ccfm.get("scope_updated")) + if su is None: + flag("scope-no-timestamp", + "_agent/context/current-context.md: no scope_updated frontmatter — scope drift cannot be detected; add it (bootstrap.sh repair) and switch scope via `echo.sh scope set`") + else: + since = [p for p in all_files + if (m := re.match(r"^_agent/sessions/(\d{4}-\d{2}-\d{2})", p)) + and (d := parse_date(m.group(1))) and d > su] + if len(since) >= SCOPE_STALE_SESSIONS: + flag("scope-stale", + f"scope set {su}; {len(since)} session(s) logged since without a switch — confirm it still reflects current work (or run `echo.sh scope set`)") + # ---- Report ------------------------------------------------------------------ if not violations: print("vault-lint: clean — all invariants hold.") @@ -283,6 +306,8 @@ labels = { "future-date": "updated date is in the future", "source-notes-wikilink": "Wikilink in source_notes (must be plain paths)", "routing-manifest": "routing.json problem", + "scope-no-timestamp": "current-context has no scope_updated (drift undetectable)", + "scope-stale": f"Scope may have drifted (>= {SCOPE_STALE_SESSIONS} sessions since last switch)", } for check, msgs in by.items(): print(f"## {labels.get(check, check)}")