Skip to content

C9 — Plan-Mode hook, one graph default kind, a write-free savings panel (PLN-2027 PR 8, FR5) - #24

Merged
wongk merged 7 commits into
mainfrom
campaign-prd-739-20260915-c9
Sep 17, 2026
Merged

wongk merged 7 commits into
mainfrom
campaign-prd-739-20260915-c9

Conversation

@wongk

@wongk wongk commented Sep 17, 2026

Copy link
Copy Markdown

C9 — Post-review fixes: Plan-Mode hook, graph default kind, savings panel, plus the GA re-run residue

Last chunk of the PRD-739 campaign. Lands PLN-2027 · PR 8 in full, plus the two operator-added findings from the GA re-run. No later chunk depends on this one.

What it lands

PLN-2027 · PR 8, item 1 — Plan Mode auto-allows nothing the broker calls a writer.
search and context are out of the hook's _READ_ONLY_TOOLS (both write). The hook also stopped auto-allowing every graph call: it now reads tool_input and allows graph only for the kinds the broker actually runs, and never with enable.

PLN-2027 · PR 8, item 2 — one declaration of graph's default kind.
The plan says the default is declared twice. It was declared three times — broker_refusal, _op_graph, tool_graph. All three now read broker_policy.GRAPH_DEFAULT_KIND. mcp_server.py (upstream-owned) changes by one import line and two signature defaults.

PLN-2027 · PR 8, item 3 — the savings panel is reachable without a write.
statusline_segment takes read_only, and the broker runs it only with read_only=true. This is the plan's preferred write-free path, not the pre-approved fallback of advertising the tool under both profiles.

Operator-added (GA re-run) — (a) the free-tier file cap is public in inclusion and no longer imported as a private symbol from pro; (b) code_coverage_check's exclusion_source now names the rules that actually fired.

Acceptance ledger

12 criteria · 7 met · 5 met differently · 0 not met

Met differently, each with its reason in the outcome ledger:

  • "the hook's list and the broker allow-list agree" is tested as containment in one direction, not set equality — the two sets legitimately differ (web_fetch is hook-allowed and broker-denied for network reach, not writes; PRD-739 Open Question 2 leaves outbound network undecided). A set-equality test would fail on a correct difference.
  • make typecheck clean from a fresh cache → mypy --strict on the 9 changed files; engine.py's 7 errors reproduced on origin/main in a scratch worktree.
  • Full suite diffed against a main baseline → not run locally (10-minute cap; CI runs the lane sharded).
  • Both operator-added items are added scope, not plan criteria.

⚠️ Behavior change a consumer can see

exclusion_source no longer returns the fixed string "git-ignore + unrecognised-file-type". It now lists the rules that fired, in EXCLUSION_RULES order joined by " + ", and is "" when nothing was excluded. The field could not both agree with the per-path rule fields (the operator's instruction) and keep its old value.

This invalidates PLN-1677 N0 — any capture or assertion of that literal string. PLN-1677 is outside this campaign and is not touched here.

Review

/code-review:deep --base origin/mainAPPROVED (cr-95289), scope proven at 3269d8b6. 5/5 reviewers ran. 2 verified MEDIUM, 1 rejected, 0 BLOCKING, 0 HIGH, no coverage gaps.

Both MEDIUMs were real, and both are fixed:

  1. read_only=true still wrote. On the segment path with no resolvable host session, the sidecar path was resolved before the guard and fell through to the workspace store dir, creating <workspace>/.lemoncrow/ and its .gitignore — contradicting the contract this chunk adds. Resolve found a second instance the review did not name: the workspace-bridge session lookup had the same side effect, so a Codex/OpenCode host would still have written after the first guard was in place. Both fixed; paths.py gained a non-creating workspace_store_dir() that resolve_workspace_store_dir() now delegates its layout to, keeping the self-ignore side effect attached to every writer.
  2. The hook crashed on a malformed kind. tool_input.get("kind", …) in frozenset hashes raw model input, so {"kind": []} raised TypeError out of main() and exited 1 — breaking the hook's documented fail-open. It now mirrors the broker's own isinstance(kind, str) check.

Re-review gate fired (RERUN: 3 prod files changed (> 2)) → /code-review:deep --since-last-reviewAPPROVED, 0 findings at every severity, scope proven at f7b87366. Note for the reviewer: route classified the 156-LOC incremental diff as Small and took the fast path, so the Design Critic and Impact Analyzer did not spawn. The orchestrator checked the paths.py blast radius mechanically instead — exactly one call site moved to the non-creating form (a pure read inside try/except); every writer of session_state.json still uses the creating form.

1 finding declined (not pushed back): the Design Critic's pass-through-variable observation on the fold/read_only thread. Its verifier rejected the anchor (evidence_not_found), and resolve declined it on the merits anyway — the recommended shape would duplicate the report/meter composition, and re-plumbing the exact flag that carries the write-free contract, for no behavior change, is the churn most likely to reintroduce the write. Full reasoning verbatim in C9.resolve.json.

Verification

check result
pytest -q tests/integrations/test_mcp_read_allow_hook.py tests/gateway/test_cap_tools_list_gate.py tests/core/test_savings_aggregate.py pass (109)
pytest -q tests/infra/code_intel/test_inclusion_layering.py tests/infra/code_intel/test_coverage.py pass
ruff check --no-cache src benchmarks tests scripts integrations pass
black --check src tests pass
mypy --strict on the 9 changed source files pass (engine.py's 7 errors pre-exist on main)
blast-radius suites for paths.py (test_paths, test_host_session_bridge, test_statusline_script, test_project_knowledge_store, test_context_dedup) pass (45)
full suite diffed against a main baseline not run locally — 10-minute cap; CI owns it

Counterfactuals were proven per fix, not in aggregate: with both production fixes reverted, the no-session segment case, the bridge case and both hook cases fail (4 failed / 94 passed).

Independent orchestrator probes (outside the chunk's own tests): through the broker with read_only=true, nothing is written under either the LemonCrow root or the workspace root across 15 host/format combinations — no session id, Codex bridge host, Claude with a session, and OpenCode/Codex with a live stamped bridge file, in segment, markdown and json. The hook exits 0 with no decision for {"kind": []} and {"kind": {"a": 1}}, stays silent for pr_risk, and allows dead_code and the default. workspace_store_dir() creates nothing; resolve_workspace_store_dir() still writes .lemoncrow/.gitignore and returns main's path shape.

Notes for the reviewer

  • "Writes nothing" is a tool-level property. Through the JSON-RPC dispatcher, every tool call — including the already-allowed read — writes per-call accounting (live_savings_events.jsonl, tool_token_ledger.json, outcomes.json, a throttled sidecar refresh). That is pre-existing and not attributable to this tool.
  • segment with read_only=true returns "" until something else has written the sidecar. markdown and json always return the panel, and their totals can trail the newest unfolded ledger rows — that is the freshness this route trades away to avoid the write.
  • PR 8 is filed under FR5, but FR5 names only relations, code_query, code_coverage_check and code_changes, all already advertised. statusline_segment is not in FR5's list — the fallback branch that would have extended the advertised surface was not taken, so nothing here extends it.
  • paths.py entered this chunk's file set during resolve and adds one public name (workspace_store_dir) to __all__. Additive: no signature, return value or side effect of the existing function changed.
  • make lint's baseline is 0, not the 9 PLN-2027 §0 claims (recorded by C7). Every ruff finding here would have been this chunk's own; there were none.

Artifacts

  • Plan: PLN-2027 (PR 8) — stays APPROVED until this merges
  • Root: PRD-739 — untouched, per campaign doctrine
  • Campaign state, both review runs and every phase JSON: .closedloop-ai/campaigns/prd-739-20260915/

What is still owed after this

Nothing in this campaign — C9 is the last chunk. Operator-owned items carried forward: PRD-739 Open Question 4 still gates FR15 on a probe the operator waived; PLN-2027 §1's FR12 row and §0's 9-finding lint baseline are both stale; PLN-2027 is still DRAFT; and PLN-1677 N0 needs the exclusion_source update above.

wongk and others added 7 commits September 17, 2026 12:10
…d graph has one default kind (PLN-2027 PR 8)

PR 8 items 1 and 2.

The Plan-Mode hook no longer auto-allows `search` or `context`, which the
broker classifies as writers, and auto-allows `graph` only for the kinds the
broker runs. The hook stays standard-library-only, so a repo-side drift test
pins the direction (hook-allowed stays inside broker-read-only, web_fetch
aside), not equality.

graph's default kind was declared in broker_refusal, _op_graph and
tool_graph. All three now read broker_policy.GRAPH_DEFAULT_KIND.

Co-Authored-By: Claude <noreply@anthropic.com>
…ion_source names the rules that fired

Operator-directed scope from the PRD-739 GA re-run, not a PLN-2027 PR 8 item.

- FREE_TIER_MAX_FILES moves from engine._FREE_TIER_MAX_FILES to
  infra/code_intel/inclusion.py. The engine and coverage both read it there
  at call time, so infra no longer imports a private pro symbol and one
  monkeypatch still caps both. coverage's call-time iter_source_files reach
  stays.
- code_coverage_check's exclusion_source was a fixed string that named
  git-ignore when no path used it and left out skipped-directory when a path
  did. It now joins the rules behind this report's excluded verdicts, in
  EXCLUSION_RULES order, and is empty when nothing was excluded.

Co-Authored-By: Claude <noreply@anthropic.com>
…ker without a write (PLN-2027 PR 8)

PR 8 item 3, the write-free path (the plan's preferred option).

statusline_segment gains read_only. With it, segment returns the sidecar as
it stands, and markdown/json build the report with fold=False: the savings
aggregate is read as it stands, never folded, persisted or refreshed in the
background, and the window cache is not written. fold threads through
build_savings_report, compute_usage_meter (the second write the canary
found), aggregate_window_savings and _read_historical_savings[_many].

The broker allow-lists statusline_segment and refuses any call without
read_only=true. The four SKILL.md copies (source edited, hosts regenerated by
scripts/sync_agent_context.py) name the broker route.

Co-Authored-By: Claude <noreply@anthropic.com>
@wongk
wongk merged commit 91a92b1 into main Sep 17, 2026
9 checks passed
@wongk
wongk deleted the campaign-prd-739-20260915-c9 branch September 17, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant