2.1.1 — MCP Phase 0: every high-level op returns an envelope (return-not-print)

Internal refactor per docs/MCP-SERVER-SPEC.md §4; CLI output and exit codes
unchanged (wrappers reproduce them from the envelopes):

- echo_ops: capture_op/resolve_op/link_op — duplicate gate and offline queueing
  are data (action: duplicate-gate + candidates; queued: true); the --json
  stdout-swallow hack is deleted (helper chatter -> stderr inside the cores);
  capture_op takes body_text= directly (the MCP path, no temp file).
- echo_recall.recall_op — one structured result backs prose and --json.
- echo_triage.list_op/route_op, echo_reflect.apply_op,
  echo_session.session_end_op — call capture_op internally, count gate outcomes
  from envelopes.
- echo.scope_show_op/scope_set_op/load_op (+ shared _load_gather), and
  echo_doctor.run_op (checks as data).

New eval/test_ops_api.py asserts the contract for every core: envelope shape
AND stdout purity (a stray print would corrupt an MCP response stream). All six
suites green. The 2.2 server build now starts directly at the app layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-28 22:51:33 -05:00
parent 446cd9a0ff
commit e7792003a7
13 changed files with 738 additions and 345 deletions
@@ -37,12 +37,13 @@ LOG_HEADING = {
# ---------------------------------------------------------------- resolve -----
def resolve(mention: str) -> int:
def resolve_op(mention: str) -> dict:
"""Core resolve: mention -> match/candidates dict. No printing (Phase 0 — the same
return value backs the CLI wrapper and the MCP `echo_resolve` tool)."""
index = idx_mod.load()
slug, e = idx_mod.resolve(index, mention)
if e:
print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2))
return 0
return {"match": True, "slug": slug, **e}
# No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo
# memory" for the project "echo") reveals the existing note instead of looking absent.
cands = idx_mod.fuzzy_candidates(index, mention)
@@ -54,21 +55,30 @@ def resolve(mention: str) -> int:
"these before creating a new note; reuse the right path or `echo.py link`.")
else:
out["note"] = "no index entry — derive a path with the right --kind and create it"
print(json.dumps(out, ensure_ascii=False, indent=2))
return out
def resolve(mention: str) -> int:
print(json.dumps(resolve_op(mention), ensure_ascii=False, indent=2))
return 0
# ------------------------------------------------------------------- link -----
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
def link_op(a_path: str, b_path: str) -> dict:
"""Core link: reciprocal `## Related` links, returns the change envelope."""
import echo_output
a_changed, b_changed = links.link_bidirectional(a_path, b_path)
return echo_output.envelope("link", {"a": a_path, "b": b_path,
"a_changed": a_changed, "b_changed": b_changed})
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
env = link_op(a_path, b_path)
if as_json:
import echo_output
env = echo_output.envelope("link", {"a": a_path, "b": b_path,
"a_changed": a_changed, "b_changed": b_changed})
print(json.dumps(env, ensure_ascii=False))
return 0
print(f"ok: linked {a_path} <-> {b_path} "
f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})")
print(f"ok: linked {env['a']} <-> {env['b']} "
f"(added: {'A' if env['a_changed'] else '-'}{'B' if env['b_changed'] else '-'})")
return 0
@@ -176,52 +186,52 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N
echo.cmd_fm(path, "updated", json.dumps(today_s))
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
aliases=None, sources=None, tags=None, date: str | None = None,
domain: str = "business", inbox: bool = False, no_log: bool = False,
as_json: bool = False, dry_run: bool = False, force: bool = False,
merge_into: str | None = None) -> int:
def capture_op(kind: str | None, title: str, file_arg: str | None = None, status_v: str = "",
aliases=None, sources=None, tags=None, date: str | None = None,
domain: str = "business", inbox: bool = False, no_log: bool = False,
dry_run: bool = False, force: bool = False,
merge_into: str | None = None, body_text: str | None = None) -> dict:
"""Core capture (Phase 0): route + frontmatter + index + auto-link + agent-log,
returning an envelope dict — never printing to stdout (helper chatter from the
low-level verbs is redirected to stderr). Special outcomes are DATA, not exit
codes: `duplicate-gate` (ok=false, candidates), `queued:capture` (queued=true),
`dry-run:*`. Raises echo.EchoError on hard failure. `body_text` may be passed
directly (MCP path); otherwise it is read from file_arg/stdin."""
import contextlib
import io
import echo_output
import echo_quality
real_stdout = sys.stdout
def chatter():
# Low-level verbs (cmd_put/cmd_append/cmd_fm) print progress lines; keep
# stdout clean for the envelope by routing them to stderr wholesale.
return contextlib.redirect_stdout(sys.stderr)
def quiet():
# M4: in --json mode, swallow the helper "ok:" chatter so stdout is clean JSON.
return contextlib.redirect_stdout(io.StringIO()) if as_json else contextlib.nullcontext()
def done(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
near=None) -> int:
if as_json:
act = f"dry-run:{action}" if dry else action
data = {"kind": kind, "path": path, "title": title, "links_added": links}
if near:
data["near_duplicates"] = near
env = echo_output.envelope(act, data, ok=ok)
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
elif dry:
print(f"would {action} {kind or '-'} -> {path}")
# non-dry human output is the helper "ok:" lines + the summary printed by the caller.
return 0 if ok else 1
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
if body_text is None:
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
today_s = echo.today()
aliases = [a.strip() for a in (aliases or []) if a.strip()]
sources = [s.strip() for s in (sources or []) if s.strip()]
tags = [t.strip() for t in (tags or []) if t.strip()]
def env_for(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
near=None, **extra) -> dict:
act = f"dry-run:{action}" if dry else action
data = {"kind": kind, "path": path, "title": title, "links_added": links}
if near:
data["near_duplicates"] = near
data.update(extra)
return echo_output.envelope(act, data, ok=ok)
# Unknown home -> defer to the inbox (a single idempotent capture line).
if inbox or not kind:
if dry_run:
return done("inbox", "inbox/captures/inbox.md", dry=True)
return env_for("inbox", "inbox/captures/inbox.md", dry=True)
line = f"- {today_s}: {title}"
if body_text.strip():
line += f"{body_text.strip().splitlines()[0]}"
with quiet():
with chatter():
rc = echo.cmd_append("inbox/captures/inbox.md", line)
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
return env_for("inbox", "inbox/captures/inbox.md", ok=rc == 0)
slug = idx_mod.slugify(title)
# Offline short-circuit (2.1.0). The routed path needs the entity index and several
@@ -235,9 +245,8 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if "unreachable" not in str(exc):
raise
if dry_run:
print("offline: vault unreachable — a real run would queue this capture "
"for replay on the next reachable session.", file=real_stdout)
return 0
return echo_output.envelope("dry-run:queued:capture",
{"kind": kind, "title": title, "queued": True})
import echo_queue
echo_queue.enqueue_capture({
"kind": kind, "title": title, "body_text": body_text, "status_v": status_v,
@@ -245,23 +254,16 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
"domain": domain, "inbox": False, "no_log": no_log,
"force": force, "merge_into": merge_into, "today": today_s,
})
if as_json:
env = echo_output.envelope("queued:capture",
{"kind": kind, "title": title, "queued": True})
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
else:
print(f"queued (offline): capture {kind} '{title}' — will replay through "
"capture on the next reachable session", file=real_stdout)
return 0
return echo_output.envelope("queued:capture",
{"kind": kind, "title": title, "queued": True})
match_slug, existing = idx_mod.resolve(index, title)
# --merge-into: the operator has already identified the canonical entity (e.g. after
# a duplicate-gate stop) — route this capture as an UPDATE to it, whatever the title.
if merge_into and not existing:
match_slug, existing = idx_mod.resolve(index, merge_into)
if not existing:
print(f"echo_ops: --merge-into '{merge_into}' matches no entity in the index "
f"(try `resolve` first)", file=sys.stderr)
return 2
raise echo.EchoError(f"--merge-into '{merge_into}' matches no entity in the "
"index (try `resolve` first)", 2)
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
@@ -276,40 +278,26 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind,
threshold=DUP_GATE)]
if gate_hits:
if as_json:
env = echo_output.envelope("duplicate-gate", {
"kind": kind, "title": title, "candidates": gate_hits,
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
"existing entity, or --force to create anyway"}, ok=False)
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
else:
print(f"STOP: '{title}' likely already exists — not creating a duplicate.",
file=real_stdout)
for c in gate_hits:
print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})",
file=real_stdout)
print(" re-run with --merge-into <slug> to update the existing entity, "
"or --force to create anyway.", file=real_stdout)
return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config)
return echo_output.envelope("duplicate-gate", {
"kind": kind, "title": title, "candidates": gate_hits,
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
"existing entity, or --force to create anyway"}, ok=False)
if dry_run:
if existing_reachable:
return done("update", existing["path"], dry=True)
return env_for("update", existing["path"], dry=True)
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
dry=True, near=near)
if (not as_json and not force
and idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)):
# (in --json mode the near_duplicates field carries this; keep stdout clean)
print("note: a real run would STOP at the duplicate gate — use --merge-into "
"or --force.", file=real_stdout)
return plan
would_gate = (not force
and bool(idx_mod.gate_candidates(index, title, kind=kind,
threshold=DUP_GATE)))
return env_for("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
dry=True, near=near, would_gate=would_gate)
near_dupes: list[str] = []
index_title = title # title to record in the index entry
index_aliases = list(aliases)
with quiet():
with chatter():
if existing_reachable:
# Reuse the matched entity's CANONICAL slug — never re-slug from the mention, or
# two index slugs end up pointing at one note. Keep its title; learn the mention
@@ -374,13 +362,61 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
if not no_log:
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
return env_for(action, path, links=linked, near=near_dupes)
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
aliases=None, sources=None, tags=None, date: str | None = None,
domain: str = "business", inbox: bool = False, no_log: bool = False,
as_json: bool = False, dry_run: bool = False, force: bool = False,
merge_into: str | None = None) -> int:
"""CLI wrapper around capture_op: prints exactly what pre-Phase-0 capture printed
(human prose or the --json envelope) and maps envelope outcomes to exit codes
(76 duplicate-gate, 2 usage-class errors, 0 otherwise)."""
try:
env = capture_op(kind, title, file_arg, status_v=status_v, aliases=aliases,
sources=sources, tags=tags, date=date, domain=domain,
inbox=inbox, no_log=no_log, dry_run=dry_run, force=force,
merge_into=merge_into)
except echo.EchoError as exc:
print(f"echo_ops: {exc}", file=sys.stderr)
return getattr(exc, "code", 1)
action = env.get("action", "")
if as_json:
done(action, path, links=linked, near=near_dupes)
else:
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
if near_dupes:
kind_word = "entity" if len(near_dupes) == 1 else "entities"
print(f"WARNING: similar existing {kind_word} ({', '.join(near_dupes)}) — if this is "
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.",
file=real_stdout)
return 0
print(json.dumps(env, ensure_ascii=False))
if action == "duplicate-gate":
return 76
return 0 if env.get("ok") else 1
if action == "duplicate-gate":
print(f"STOP: '{title}' likely already exists — not creating a duplicate.")
for c in env.get("candidates", []):
print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})")
print(" re-run with --merge-into <slug> to update the existing entity, "
"or --force to create anyway.")
return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config)
if action == "queued:capture":
print(f"queued (offline): capture {kind} '{title}' — will replay through "
"capture on the next reachable session")
return 0
if action == "dry-run:queued:capture":
print("offline: vault unreachable — a real run would queue this capture "
"for replay on the next reachable session.")
return 0
if action.startswith("dry-run:"):
print(f"would {action.split(':', 1)[1]} {kind or '-'} -> {env.get('path')}")
if env.get("would_gate"):
print("note: a real run would STOP at the duplicate gate — use --merge-into "
"or --force.")
return 0
if action == "inbox":
return 0 if env.get("ok") else 1
print(f"ok: {action} {kind} -> {env.get('path')}"
+ (f"; auto-linked {env['links_added']}" if env.get("links_added") else ""))
near = env.get("near_duplicates")
if near:
kind_word = "entity" if len(near) == 1 else "entities"
print(f"WARNING: similar existing {kind_word} ({', '.join(near)}) — if this is "
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.")
return 0 if env.get("ok") else 1