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:
@@ -72,36 +72,34 @@ def validate(bundle: dict) -> tuple[str, str]:
|
||||
return path, line
|
||||
|
||||
|
||||
def session_end(bundle: dict, apply: bool = False) -> int:
|
||||
def session_end_op(bundle: dict, apply: bool = False) -> dict:
|
||||
"""Core session-end (Phase 0): validate -> plan -> (apply). Returns an envelope
|
||||
(plan rows + per-step results); never prints to stdout — helper chatter from the
|
||||
underlying verbs routes to stderr. Raises echo.EchoError(2) on a bad bundle,
|
||||
BEFORE any write."""
|
||||
import contextlib
|
||||
import echo_output
|
||||
import echo_reflect
|
||||
path, line = validate(bundle)
|
||||
scope = str(bundle.get("scope") or "").strip()
|
||||
proposals = bundle.get("reflect") or []
|
||||
|
||||
valid, errors = echo_reflect.validate(proposals)
|
||||
for e in errors:
|
||||
print(f"skip: {e}", file=sys.stderr)
|
||||
|
||||
print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):")
|
||||
print(f" 1. session log -> {path}")
|
||||
print(f" 2. agent-log line: {line}")
|
||||
rows: list[dict] = []
|
||||
if valid:
|
||||
echo_reflect.classify(valid)
|
||||
print(f" 3. reflect: {len(valid)} proposal(s)")
|
||||
print(echo_reflect.preview(valid))
|
||||
else:
|
||||
print(" 3. reflect: (none)")
|
||||
print(f" 4. scope set: {scope!r}" if scope else " 4. scope: (unchanged)")
|
||||
print(f" 5. heartbeat -> {path} @ <now> (written LAST — the commit marker)")
|
||||
|
||||
rows = [{"action": p.get("_action"), "kind": p.get("kind"),
|
||||
"title": p["title"], "path": p.get("_path")} for p in valid]
|
||||
data = {"path": path, "agent_log_line": line, "scope": scope or None,
|
||||
"reflect_rows": rows, "reflect_errors": errors,
|
||||
"dry_run": not apply, "steps": {}}
|
||||
if not apply:
|
||||
print("\nsession-end: dry-run — re-run with --apply to write.")
|
||||
return 0
|
||||
return echo_output.envelope("session-end", data)
|
||||
|
||||
import echo_concurrency
|
||||
import echo_ops
|
||||
steps: dict[str, str] = {}
|
||||
with echo_concurrency.vault_lock():
|
||||
with echo_concurrency.vault_lock(), contextlib.redirect_stdout(sys.stderr):
|
||||
# 1. The session log itself. A hard failure here aborts the whole bundle —
|
||||
# nothing after it (including the heartbeat) runs, so orientation state
|
||||
# can never point at a log that was never written.
|
||||
@@ -116,14 +114,13 @@ def session_end(bundle: dict, apply: bool = False) -> int:
|
||||
for p in valid:
|
||||
if p.get("_action") == "error":
|
||||
continue
|
||||
bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None
|
||||
rc = echo_ops.capture(
|
||||
p.get("kind"), p["title"], bodyfile,
|
||||
env = echo_ops.capture_op(
|
||||
p.get("kind"), p["title"], body_text=p.get("body") or "",
|
||||
aliases=p.get("aliases") or [], sources=p.get("sources") or [],
|
||||
tags=p.get("tags") or [], date=p.get("date"),
|
||||
domain=p.get("domain", "business"),
|
||||
inbox=bool(p.get("inbox")) or not p.get("kind"))
|
||||
gated += 1 if rc == 76 else 0
|
||||
gated += 1 if env.get("action") == "duplicate-gate" else 0
|
||||
steps["reflect"] = f"{len(valid) - gated}/{len(valid)} applied" \
|
||||
+ (f", {gated} gated (re-propose with --merge-into)" if gated else "")
|
||||
else:
|
||||
@@ -139,6 +136,30 @@ def session_end(bundle: dict, apply: bool = False) -> int:
|
||||
echo.temp_file(f"{path} @ {echo.now_iso()}\n".encode("utf-8")))
|
||||
steps["heartbeat"] = "ok"
|
||||
|
||||
data["steps"] = steps
|
||||
return echo_output.envelope("session-end", data)
|
||||
|
||||
|
||||
def session_end(bundle: dict, apply: bool = False) -> int:
|
||||
"""CLI wrapper: same plan/summary text as before; envelope logic in session_end_op."""
|
||||
env = session_end_op(bundle, apply=apply)
|
||||
for e in env["reflect_errors"]:
|
||||
print(f"skip: {e}", file=sys.stderr)
|
||||
print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):")
|
||||
print(f" 1. session log -> {env['path']}")
|
||||
print(f" 2. agent-log line: {env['agent_log_line']}")
|
||||
rows = env["reflect_rows"]
|
||||
if rows:
|
||||
print(f" 3. reflect: {len(rows)} proposal(s)")
|
||||
print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} "
|
||||
f"| {r['title']} -> {r['path'] or '?'}" for r in rows))
|
||||
else:
|
||||
print(" 3. reflect: (none)")
|
||||
print(f" 4. scope set: {env['scope']!r}" if env["scope"] else " 4. scope: (unchanged)")
|
||||
print(f" 5. heartbeat -> {env['path']} @ <now> (written LAST — the commit marker)")
|
||||
if env["dry_run"]:
|
||||
print("\nsession-end: dry-run — re-run with --apply to write.")
|
||||
return 0
|
||||
print("\nsession-end: done — "
|
||||
+ "; ".join(f"{k}: {v}" for k, v in steps.items()))
|
||||
+ "; ".join(f"{k}: {v}" for k, v in env["steps"].items()))
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user