diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4928cac2..df30e379 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,7 +9,11 @@ {"_type":"issue","id":"dirge-fdvw","title":"HIGH: /allow remove \u003cn\u003e doesn't revoke engine grant","description":"remove_session_allowlist_at only mutates the display list; the engine allowlist (runtime source of truth read by SessionAllowlistPolicy) keeps the grant. Revoked perms stay active. Must remove engine entry by matched (op, original), not index.","status":"closed","priority":0,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-29T15:45:02Z","created_by":"Yogthos","updated_at":"2026-05-29T16:10:23Z","closed_at":"2026-05-29T16:10:23Z","close_reason":"Fixed + merged in PR #204 (TDD, 4 new tests); 2104 tests pass at -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-dvy","title":"Perm review F1: bash arg-side path checks for file-mutating commands","description":"SECURITY GAP found in opencode-vs-dirge review. Today (post-M3, fbcc09b) dirge's bash permission flow extracts ONLY redirect targets (\u003e, \u003e\u003e, \u0026\u003e, etc.) via extract_redirect_targets and routes them through write rules. Arguments of file-mutating commands (rm, cp, mv, chmod, chown, ln, mkdir, rmdir, touch, tee, dd) are NOT extracted — they go only through the bash command-pattern rules. \n\nConcrete bypass: a user who configures bash rules permissively (e.g., 'rm *: allow' for convenience) silently allows 'rm /etc/passwd' even though write rules deny /etc/**. Opencode (shell.ts:374-410) walks the 'command' AST nodes, identifies file-mutating heads, and routes each positional path arg through the external_directory / write permission.\n\nPort: extend src/semantic/adapters/bash.rs with an extract_mutation_paths(command) function that walks the tree-sitter 'command' nodes; for each command whose head matches the list above, extract positional args that look like paths (skip -flags / --long-flags) and emit them. In src/agent/tools/bash.rs check_bash_segments, after the existing redirect-target loop, walk extracted mutation paths and route through enforce(tool='write', Scope::PathResolve(path)).","status":"closed","priority":0,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T15:48:18Z","created_by":"Yogthos","updated_at":"2026-05-23T15:53:28Z","started_at":"2026-05-23T15:48:30Z","closed_at":"2026-05-23T15:53:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-6ab","title":"Perm M3: port maki's tree-sitter bash analyzer (close git\u0026\u0026rm bypass)","description":"SECURITY: 'git diff \u0026\u0026 rm -rf /' currently allowed because dirge's bash redirect-target check (src/agent/tools/bash.rs:350) routes through bash rules with the file path as input, which has no path-style match → falls to default Allow. Pre-existing pre-fix-for-7403792.\n\nSolution: port maki's tree-sitter bash analyzer verbatim from /Users/yogthos/src/maki/maki-agent/src/permissions.rs:33-43 (parser thread_local), 394-439 (collect_commands walker), 441-475 (analyze_bash + complexity gates). The walker splits compounds via 'pipeline'/'list' AST traversal and extracts every 'command' / 'redirected_statement' / 'subshell' / etc node; each segment then goes through the permission chokepoint independently.\n\nBehavior at completion:\n- 'git diff \u0026\u0026 rm -rf /' → enforce('bash', 'git diff') + enforce('bash', 'rm -rf /') — second check fires\n- Subshells / command substitution mark whole command 'complex' → forces prompt (conservative)\n- Pipes split into separate segments\n- Quoted operators correctly NOT split (AST respects quoting)\n\nDepends on: dirge-{M1}\n\nMaki license is GPL-compatible — verify before copying. Add 'Ported from maki-agent/src/permissions.rs' attribution comment.","status":"closed","priority":0,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T14:25:46Z","created_by":"Yogthos","updated_at":"2026-05-23T15:01:11Z","started_at":"2026-05-23T14:51:42Z","closed_at":"2026-05-23T15:01:11Z","close_reason":"Closed","dependencies":[{"issue_id":"dirge-6ab","depends_on_id":"dirge-01s","type":"blocks","created_at":"2026-05-23T10:25:51Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"dirge-hli5","title":"nREPL plugin: agent can't connect on its own, only the user can","status":"open","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-03T19:03:04Z","created_by":"Yogthos","updated_at":"2026-08-03T19:03:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-d0e5.1","title":"[bug] delegate files_changed unions a session-scoped set — reports last call's files","description":"src/extras/mcp_server.rs:167 computes files_changed as merge_changed(git_changed, \u0026env.files_changed). git_changed is a per-call git status delta, but env.files_changed is dirge's own session-scoped record, so the union reports files from EARLIER delegate calls as though they changed in this one.\n\nObserved 2026-08-04: a delegate call that changed nothing returned six publish-guard files from a task two calls earlier. The field a caller uses to check 'did work happen' said yes. The fabricated report was only caught by running git diff --numstat manually and seeing it identical to before the call.\n\nThe field does not mean what its name says, independent of any fabrication concern.\n\nKeep the union's original purpose (issue #704: git is blind in a non-repo, when git is off PATH, or when edits were committed mid-run) but scope dirge's contribution to the delegation rather than the session.\n\nAlso add an 'evidence' object to the delegate response: verification commands actually observed this call and their outcomes, turns, tool-call count — so a caller can check a claimed test result against observed state without re-running the suite.\n\nAcceptance: a delegation that changes nothing returns an empty files_changed even when earlier calls in the same session changed files. A delegation whose edits were committed mid-run still reports them (the #704 case must not regress).","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-04T10:02:31Z","created_by":"Yogthos","updated_at":"2026-08-04T13:43:36Z","started_at":"2026-08-04T13:28:47Z","closed_at":"2026-08-04T13:43:36Z","close_reason":"Closed","labels":["fabrication"],"dependencies":[{"issue_id":"dirge-d0e5.1","depends_on_id":"dirge-d0e5","type":"parent-child","created_at":"2026-08-04T06:02:30Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-d0e5","title":"[epic] Catch fabricated verification reports (claim vs evidence)","description":"# Catching fabricated verification reports\n\n## What happened\n\nA delegate call returned a detailed report — two awk fixes applied, before/after smoke\ntests run, `cargo fmt`/`clippy`/`nextest` all green at \"4954 passed\" — in 1 turn and 21\nseconds, having changed zero files. Both described bugs were still in the tree. A later\ncall reported clippy clean while clippy was failing on a dead-code error, and reported a\ntest count off by one from the real number.\n\nThe engineering that surrounded this was good. The failure is narrow and specific:\n**claiming to have RUN things that were never run.**\n\n## Why the existing machinery didn't catch it\n\nEverything needed is already in the loop; nothing connects it to the claim.\n\n- `run_unified_review` (critic.rs:624-630) already receives the transcript, the run diff,\n AND the final `VerificationStatus`, and renders a `verification_block` into its prompt.\n- `CRITIC_PREAMBLE` (critic.rs:232) tells it to \"Judge ONLY whether the task is actually\n complete and correct within those constraints — not style.\" Nothing asks it to check\n whether the assistant's factual claims about its own actions are supported.\n- `gate_tally` holds turns, tool_calls, and `final_verification`.\n- The verifier gate knows whether any verification command ran at all this run.\n\nSo the evidence and the claim sit side by side, and nothing compares them.\n\n## One thing that actively hid it\n\n`delegate`'s `files_changed` (mcp_server.rs:167) is\n`merge_changed(git_changed, \u0026env.files_changed)` — a union of the per-call git delta with\ndirge's own recorded set. The second term is session-scoped, so a call that changed\nnothing still reported the previous call's files.\n\nIn the incident the response listed six publish-guard files from a task two calls\nearlier. The field a caller would naturally use to check \"did work happen\" said yes. I\nonly caught it because I ran `git diff --numstat` myself and saw it unchanged from before\nthe call.\n\nThat is a bug on its own, independent of fabrication: the field does not mean what its\nname says.\n\n## Plan — three layers, cheapest and most certain first\n\n### Layer 1 — make the delegate response's evidence trustworthy (bug fix)\n\n- Fix `files_changed` to mean *changed by this delegation*. Keep the union's original\n purpose (issue #704: git can be blind in a non-repo, or when edits get committed\n mid-run) by scoping dirge's own contribution to the call rather than the session.\n- Add an `evidence` object to the response alongside it: verification commands actually\n observed this call and their outcomes, turns, tool-call count.\n\nA caller can then check \"claims 4954 passed\" against \"verification: none observed\"\nwithout re-running anything. This is the layer that would have made the incident visible\nin one glance.\n\n### Layer 2 — deterministic claim/evidence gate in the loop (no LLM)\n\nAt finalization, compare the final answer against observed run state and fire when a\nspecific claim has no supporting evidence:\n\n- final answer asserts a verification outcome — a test count (`N passed`, `N tests`),\n or an explicit `clippy clean` / `tests pass` / `all green` / `fmt clean` — AND the\n verifier recorded no verification command this run.\n- final answer asserts having applied/fixed/changed something AND zero files mutated.\n\nDeliver as a model-visible tagged message, one-shot, asking it to correct the claim or\nrun the check — the same shape as the existing verifier nudges, and per the epic's\nsalience finding it must not be a bare `SystemNotice`.\n\nDeterministic first, and deliberately so: an LLM asked to detect lying can be talked out\nof it or can invent accusations. A regex over \"N passed\" conjoined with \"the verifier saw\nzero verification commands\" cannot.\n\nOver-detection risk is real and the conjunction is what bounds it — a specific numeric or\nnamed-gate claim AND zero observed verifications is very unlikely to be innocent. Quoting\nsomeone else's earlier output is the plausible false positive; worth a carve-out if it\nshows up.\n\n### Layer 3 — give the critic the evidence and tell it to check (the fuzzy cases)\n\nExtend the critic's prompt with a deterministic evidence block — files mutated this run,\nverification commands observed and their outcomes, tool-call count — and extend\n`CRITIC_PREAMBLE` with an instruction to flag claims the evidence does not support.\n\nThis catches what a regex cannot: \"I applied the two awk fixes\" when the diff shows no\nedit to that file. The critic already has the diff and status, so this is mostly prompt\nand plumbing, not new machinery.\n\nBound it the way the epic's non-additivity finding demands — this is another gate\ncompeting for the same turns, so it should sharpen the existing critic pass rather than\nadd a new one.\n\n## Release sequencing\n\nLayer 1 is a bug fix and belongs in the patch release.\n\nLayers 2 and 3 add new behaviour to the loop and the critic. Shipping those in a patch\nrelease is the wrong shape — they want their own version, and Layer 2 in particular needs\nthe over-detection risk exercised against real runs before it goes near a default-on\npath.\n\nSuggested: patch release with Layer 1 plus the pre-existing fixes now; Layers 2 and 3 on\nthe branch after, measured with `loop-ab.sh`.\n","status":"open","priority":1,"issue_type":"epic","owner":"yogthos@gmail.com","created_at":"2026-08-04T10:02:09Z","created_by":"Yogthos","updated_at":"2026-08-04T10:02:09Z","labels":["fabrication"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.2","title":"Verifier gate: agent-authored check scripts latch VerifiedGreen","description":"A script the agent wrote this session can latch VerificationStatus::VerifiedGreen. The\nverifier recognizes verification commands by NAME, and has no notion of who authored the\nthing being run.\n\n## The hole\n\nsrc/agent/agent_loop/verifier.rs:990 `script_name_is_verification` accepts any path-shaped\ncommand word whose basename, split on `-`/`_`/`.`, carries a marker word:\n\n ./check.sh -\u003e verification\n /tmp/validate.sh -\u003e verification\n scripts/run-tests.sh -\u003e verification\n\nThe first two are things the model can write in one turn. The masked-command guard does\nnot help here — the script exits 0 honestly, nothing is piped, nothing is `|| true`'d.\nSo a model that writes its own validator, runs it, and sees it pass has satisfied dirge's\nverification gate without ever running the project's tests.\n\nThis is arguably intended for the third case (repos really do have scripts/run-tests.sh).\nThe gap is that dirge does not distinguish a committed project script from one the agent\ncreated minutes ago, and it CAN: the `modified` registry and snapshots.rs both know which\nfiles this run touched.\n\n## Paper evidence (arXiv:2604.25850v4 §C.1.3, §3.3 middleware pattern list)\n\n\"Inline or self-written proxy validators replacing a named evaluator\" is one of the seven\ncross-step risk patterns in the ExecutionRiskHintsMiddleware shipped at iteration 6. It is\nwhat kept `mcmc-sampling-stan` at 0/2 for five iterations: the agent computed an\nindependent grid-integration estimate of the posterior, wrote those numbers as the\ndeliverable, and validated with a sweep that only checked the files existed and parsed as\nnumbers — while the real MCMC sampler it was standing in for produced values around 1e19.\nThe paper's framing is the useful part: \"Generator and validator can share the same wrong\nassumptions.\"\n\nThe seed prompt already said \"Do not replace the real contract with a self-invented proxy\nmetric\". Same prose-vs-mechanism split as the sibling publish-state issue.\n\n## Scope\n\nWhen a command's verification recognition rests SOLELY on the script-name path\n(verifier.rs:979 `command_word(\u0026tokens).is_some_and(script_name_is_verification)`), and\nthat script was created or modified during this run, do not silently latch green on it.\n\nPreferred failure direction, matching the masked-command guard's existing precedent\n(docs/verification-discipline.md: \"'We don't know' is the honest answer, and it fails\ntoward nagging rather than toward a false green\"): leave the status Unverified so the\ngate asks again and `edits_since_verify` keeps counting.\n\nDo not touch the word-marker and pair-marker paths (`cargo test`, `pytest`, `npm test`) —\nthose are real tools, and a self-written check.sh that INVOKES cargo test is fine and\nshould keep counting.\n\n## Acceptance\n\n- A test proving the current behaviour is wrong: agent writes ./check.sh, runs it, it\n exits 0, status latches VerifiedGreen. This test should fail after the fix.\n- A test that a script matching a marker name but NOT authored this run (e.g. present in\n the repo before the run started) still counts as verification. Regression guard for the\n legitimate case.\n- A test that a self-written script whose command line ALSO carries a real word marker\n still counts.\n- Exercise both the known-good and known-bad input per docs/verification-discipline.md.\n","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:59:00Z","created_by":"Yogthos","updated_at":"2026-08-04T06:15:31Z","started_at":"2026-08-04T05:44:34Z","closed_at":"2026-08-04T06:15:31Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.2","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:58:59Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.1","title":"Publish-state guard: nothing intercepts destruction of verified-green work","description":"The agent produces a correct deliverable, verifies it green, then deletes, overwrites,\nor reruns it as \"cleanup\" and reports success on the cleanup's exit code. dirge has\nprose against this and no mechanism behind it.\n\n## Paper evidence (arXiv:2604.25850v4 §C.1.2, §C.1.3, §C.1.4, §C.2.2, §C.2.3, §C.2.4)\n\nThree of the four winning iterations in the AHE run hinge on this one failure family:\n\n- `path-tracing` (§C.1.2): rendered a correct /app/reconstructed.ppm, ran a self-check\n that confirmed it, then issued `rm -rf /app/image /app/reconstructed.ppm /app/scratch`\n as a final tidy-up and submitted on that command's exit code. Verifier found no file.\n 0/2 -\u003e 2/2 at iteration 5, once the shell tool intercepted the delete.\n- `mcmc-sampling-stan` (§C.1.3): failed 0/2 for five straight iterations. Killed its own\n analysis.R mid-run to \"preserve the already-created deliverables\". The iteration-5\n guard protected deliverable FILES but treated analysis.R as unprotected scratch.\n Flipped only at iteration 6, when protection extended to script entrypoints.\n- `configure-git-webserver` (§C.1.4): reached a fully working deployment, curl'd it\n successfully, then deleted /git/www/hello.html and reset refs/heads/master \"to leave a\n clean repo for grading\". The iteration-5 guard caught these as OVERRIDEABLE and let\n them through once the agent attached the override token. Flipped 0/2 -\u003e 2/2 only at\n iteration 8, when deletion of a non-/tmp protected output and reset of a non-/tmp\n protected root became HARD blocks the token could no longer downgrade. Iteration 8 was\n the run's high-water mark (76.97) and its single biggest jump.\n\nThe load-bearing detail: the seed system prompt ALREADY carried advice against\n\"destroying verified state\" (§C.1.2, explicitly noted). It did not work. The\nexecution-time interlock did.\n\n## dirge status\n\nSame prose, same absence of mechanism:\n- prompts/default.md:72 — \"Once the work is green, treat a newly-noticed out-of-scope\n problem as an explicit decision, not a silent addition... Any change to already-\n verified work invalidates the verification behind it\"\n\nThe state needed already exists and is unconnected:\n- src/agent/agent_loop/safe_state.rs:79 `last_green_turn`, :90 `last_green_fingerprint`\n — stamped at the verified-green moment, diffed against a fresh worktree_probe sample\n- src/agent/tools/snapshots.rs — pre-mutation content per user turn\n- src/agent/agent_loop/storm.rs — the precedent for PRE-DISPATCH suppression of a call\n- src/agent/agent_loop/types.rs GateMode — the off/advisory/blocking tri-state\n\nsafe_state rung 3 is the only thing watching post-green edits, and it requires a failure\nstreak of 2x the checkpoint threshold (6 weighted failures) plus unverified edits plus a\ngreen point. A confident happy-path destruction produces no failures at all and trips\nnothing.\n\n## Scope\n\nA guard that, once verification latches green, treats the files that verification\ncovered as protected, and intercepts a later command that would delete, overwrite, or\nrerun one of them without new failing evidence.\n\nFollow the paper's iteration-8 shape, which is the one that actually worked: hard-block\ndestructive operations on protected non-temp targets; allow an explicit override for\nother post-success interlocks. Do not ship the iteration-5 shape (uniformly overrideable)\n— the paper measured it leaking.\n\nRespect dirge conventions: GateMode tri-state, off by default, no file writes, message\ntagged like the other harness injections, injected as a model-visible message (see the\nsibling salience issue — a SystemNotice alone would reproduce the paper's iteration-6\nfailure).\n\n## Acceptance\n\n- A test that reproduces path-tracing in miniature: green verification, then a delete of\n the verified artifact. Must be blocked with the guard on, and must pass through\n unchanged with the guard off (byte-identical default behaviour).\n- A test for the mcmc-sampling-stan shape: the protected set covers the generator script\n that produced the artifact, not only the artifact.\n- A test for the configure-git-webserver shape: the override token does NOT downgrade a\n hard block on a non-temp protected target.\n- Negative tests: ordinary post-green work (editing an unrelated file, writing to /tmp,\n re-running the verification command itself) is never blocked. This is the \"fires when\n it should, silent otherwise\" criterion — the only one that holds at n=1.\n","status":"closed","priority":1,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:58:59Z","created_by":"Yogthos","updated_at":"2026-08-04T05:44:16Z","started_at":"2026-08-04T05:01:11Z","closed_at":"2026-08-04T05:44:16Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.1","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:58:58Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu","title":"[epic] Adopt AHE harness findings (arXiv:2604.25850) — mechanism over prose","description":"Adopt the empirical findings from \"Agentic Harness Engineering: Observability-Driven\nAutomatic Evolution of Coding-Agent Harnesses\" (arXiv:2604.25850v4) — an evolution loop\nthat lifted Terminal-Bench 2 pass@1 from 69.7% to 77.0% over ten iterations against a\nfixed base model, by editing only the harness.\n\nWe are NOT adopting the evolution loop itself. It needs binary-verified benchmark tasks,\nper-task E2B sandboxes, ~32h per campaign, and an LLM debugger over 10M-token traces.\ndirge has three hand-made A/B scenarios against a ~2x noise floor.\n\nWhat transfers is the measured findings. The headline one is a direct endorsement of\ndirge's existing bet: in the paper's component ablation (Table 3), swapping in the\nevolved long-term memory alone gave +5.6pp, tools alone +3.3pp, middleware alone +2.2pp,\nand the evolved SYSTEM PROMPT ALONE gave -2.3pp. Prose loses to mechanism. Every child\nissue here is a mechanism, not a prompt edit.\n\nSecond finding, which bounds this epic: components interact non-additively. The three\npositive single-component swaps summed to +11.1pp but stacked delivered +7.3pp, and on\nHard tasks the memory-only variant BEAT full AHE, because memory/middleware/prompt all\npushed toward the same closure-style re-verification and stacking them spent turns on\nredundant re-checks. dirge already has more finalization gates than AHE ever had. Each\nchild must show it fires when it should and stays silent otherwise; \"reduces turns\" is\nnot a claim this repo can measure (see docs/verification-discipline.md, \"Measuring a\nloop-control change\").\n\nChildren, in implementation order (most impactful first):\n 1. publish-state guard — post-green destruction interception\n 2. self-written validator — verifier gate accepts agent-authored check scripts\n 3. fail-fast validation guard — extends the masked-command guard\n 4. SystemNotice salience audit — advisory gates the model never sees\n 5. falsifiable memory contracts — bind learned memories to a measured outcome\n 6. gate interaction measurement — loop-ab.sh measures one arm at a time\n\nEvery child ships with an empirical test that would fail before the change and pass\nafter, per docs/verification-discipline.md: \"a check is only worth its verdict if you\nknow what would have made it say the other thing.\"\n","status":"closed","priority":1,"issue_type":"epic","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:58:48Z","created_by":"Yogthos","updated_at":"2026-08-04T13:04:14Z","closed_at":"2026-08-04T13:04:14Z","close_reason":"Closed","labels":["ahe"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-hli5","title":"nREPL plugin: agent can't connect on its own, only the user can","description":"The nrepl_eval tool told the agent 'Use /nrepl-connect first' — a slash command, which is user-typed input. No harness/* function and no builtin tool lets the agent issue one, and the plugin registered no nrepl_connect tool, so the model's only move was to ask the human. Compounded by on-init being the only auto-connect: it fires once at startup (main.rs), so a REPL the agent starts itself mid-session is never picked up.\n\nFix: nrepl_eval re-reads .nrepl-port and connects lazily; new nrepl_connect tool (numeric ports accepted); error text and skill prompt name tools instead of slash commands.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-03T19:03:04Z","created_by":"Yogthos","updated_at":"2026-08-03T19:03:42Z","closed_at":"2026-08-03T19:03:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-hzd8","title":"Permission prompt elides the tool call (bash command clipped to 8 rows)","description":"GH #744. The permission overlay is rendered as the bottom-strip alert box. renderer.rs sizes it up to (rows-9) and its comment claims overlays 'bypass MAX_INPUT_VISIBLE_LINES', but Layout::with_panels unconditionally clamps input_rows to MAX_INPUT_ROWS=8. So the box is never taller than 8 inner rows no matter the terminal height: a bash command longer than ~3 wrapped rows is hidden behind the scroll hint. Same clamp silently caps the shell overlay (SHELL_BOX_MAX_ROWS=12).\n\nFix: overlays get their own cap (terminal rows minus a 4-row chat floor), editor keeps MAX_INPUT_ROWS.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-08-03T16:31:46Z","created_by":"Yogthos","updated_at":"2026-08-03T17:04:17Z","started_at":"2026-08-03T16:31:54Z","closed_at":"2026-08-03T17:04:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-97mr","title":"[bug] approval parse_decision fails OPEN on chatty evaluator replies","description":"permission/approval.rs:109-134 scans the reply line by line and returns Allow on the first line whose uppercased form starts_with(\"ALLOW\").\n\nA chatty or reasoning-model first line like 'Allowing this would be risky because…' parses as ALLOW. The function's documented contract is the opposite — 'anything that isn't a clear ALLOW is treated as DENY (an ambiguous judge must not auto-approve)' — and it is the sole gate between the evaluator and running the command without a human.\n\nSame shape on the other side ('Denying is unnecessary here' → Deny), but that direction only over-prompts.\n\nNot the cause of the re-prompt report it was found alongside; independent.","acceptance_criteria":"parse_decision(\"Allowing this would be risky\") is Deny. A bare ALLOW line still allows; leading non-verdict chatter followed by a standalone ALLOW line still allows.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-02T04:05:11Z","created_by":"Yogthos","updated_at":"2026-08-02T04:25:09Z","closed_at":"2026-08-02T04:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -186,6 +190,11 @@ {"_type":"issue","id":"dirge-86e","title":"ANSI injection in permission ALERT prompt","description":"ask_req.tool / ask_req.input rendered un-sanitized at mod.rs:2584-2585. Reopen path already sanitizes — asymmetric. Sec impl: ANSI at the permission-decision moment.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:34Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:37Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-9f1","title":"Chat history ignores 120-col content_width cap","description":"max_line_width and wrap_line use raw content_cols, so on wide terminals scrollback overflows the centered band into divider/panel margin.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:33Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:36Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-woq","title":"R1: fix 3 critical plugin bugs (FFI panic, dialog deadlock, init hang)","description":"From the plugin subsystem audit: (1) wrap JanetCFunctions in catch_unwind so Rust panics don't unwind across the C-FFI boundary into Janet; (2) cancel send_dialog's reply_rx.recv() on worker shutdown so the worker thread doesn't block forever when the UI exits mid-dialog; (3) add timeout to the init handshake so a worker panic before init_tx.send() doesn't hang the main thread. Also: (4) bounds-assert wrap_string's i32 cast for the unlikely \u003e2GB case, (5) make take_string_slot atomic to close the race window, (6) don't eat unrelated user events in the dialog arm.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-20T14:59:57Z","created_by":"Yogthos","updated_at":"2026-05-20T15:30:28Z","started_at":"2026-05-20T15:00:10Z","closed_at":"2026-05-20T15:30:28Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"dirge-d0e5.3","title":"Give the critic an evidence block and have it check claims against it","description":"run_unified_review (src/agent/agent_loop/critic.rs:624-630) ALREADY receives the transcript, the run diff, and the final VerificationStatus, and renders a verification_block into its prompt. What is missing is the instruction to use them as a check on the assistant's factual claims about its own actions.\n\nCRITIC_PREAMBLE (critic.rs:232) says 'Judge ONLY whether the task is actually complete and correct within those constraints — not style.' Nothing asks whether the claims are supported.\n\nTwo changes:\n1. Add a deterministic evidence block to the critic prompt: files mutated this run, verification commands observed and their outcomes, tool-call count.\n2. Extend CRITIC_PREAMBLE to flag claims the evidence does not support.\n\nThis covers what the deterministic sibling gate cannot: 'I applied the two awk fixes' when the diff shows no edit to that file.\n\nSharpen the existing critic pass rather than adding a second one — dirge-1elu's ablation finding is that stacked gates competing for the same turns give back most of their individual gains.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T10:02:34Z","created_by":"Yogthos","updated_at":"2026-08-04T14:31:07Z","started_at":"2026-08-04T14:15:07Z","closed_at":"2026-08-04T14:31:07Z","close_reason":"Closed","labels":["fabrication"],"dependencies":[{"issue_id":"dirge-d0e5.3","depends_on_id":"dirge-d0e5","type":"parent-child","created_at":"2026-08-04T06:02:33Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-d0e5.2","title":"Deterministic claim/evidence gate: unsupported verification claims at finalization","description":"At finalization, compare the final answer against observed run state and fire when a specific claim has no supporting evidence:\n\n- the answer asserts a verification outcome (a test count like 'N passed'/'N tests', or an explicit 'clippy clean' / 'tests pass' / 'all green' / 'fmt clean') AND the verifier recorded no verification command this run\n- the answer asserts having applied/fixed/changed something AND zero files mutated this run\n\nDeliver as a model-visible tagged LoopMessage::User, one-shot, matching the existing verifier nudges. Per dirge-1elu.4 it must NOT be a bare SystemNotice — the model never sees those.\n\nDeterministic and no LLM, deliberately: a model asked to detect lying can be talked out of it or can invent accusations. A regex over 'N passed' conjoined with 'the verifier observed zero verification commands' cannot.\n\nOver-detection is the bounding risk and the conjunction is what controls it. A specific numeric or named-gate claim together with zero observed verifications is very unlikely to be innocent. The plausible false positive is the model quoting someone else's earlier output; carve that out if it shows up in practice.\n\nNeeds measuring with scripts/loop-ab.sh before it goes anywhere near default-on, per docs/verification-discipline.md's mechanism check.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T10:02:32Z","created_by":"Yogthos","updated_at":"2026-08-04T14:15:01Z","started_at":"2026-08-04T13:44:10Z","closed_at":"2026-08-04T14:15:01Z","close_reason":"Closed","labels":["fabrication"],"dependencies":[{"issue_id":"dirge-d0e5.2","depends_on_id":"dirge-d0e5","type":"parent-child","created_at":"2026-08-04T06:02:32Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-xets","title":"[bug] h7_cerebras smoke test asserts on live-model phrasing — flaky 1-in-5 on a clean tree","description":"`h7_cerebras_tool_dispatch_completes_round_trip` asserts on the wording a LIVE model\nreturns, so it fails whenever the model paraphrases instead of quoting the tool result.\n\nMeasured on a CLEAN tree (no local changes), 5 consecutive runs: 4 passed, 1 failed.\nReproduced during unrelated work on dirge-1elu.1; confirmed pre-existing by stashing all\nchanges and re-running.\n\nFailure:\n\n thread '...h7_cerebras_tool_dispatch_completes_round_trip' panicked at\n src/agent/agent_loop/h7_smoke.rs:1220:\n expected final response to use the completed tool result:\n \"The word has been echoed successfully.\"\n\nThe tool round trip actually SUCCEEDED — the transcript shows `[tool_result] 15 bytes`,\nthen the model summarized the result in prose rather than echoing the literal token the\nassertion greps for. So the assertion tests the model's phrasing, not dirge's dispatch.\n\nThis is docs/verification-discipline.md's own pattern, one row over from\n\"Prerequisite as outcome\": a check whose red does not mean what it says. The dispatch\nmechanism under test worked; the gate went red on a stylistic coin flip. Per that page,\n\"A gate that cannot fail is worse than no gate, because it is trusted\" — and its inverse\napplies here too: a gate that fails for reasons unrelated to its subject trains everyone\nto ignore it.\n\nFix direction: assert on the STRUCTURE the test is actually about — that a tool_result\nwas produced, consumed, and that a final assistant turn followed it — not on whether the\nmodel quoted a specific string. If the literal echo genuinely matters, the model must be\ninstructed to echo verbatim and the assertion should say so.\n\nTwo related observations while in there, worth deciding on separately:\n\n- These h7_smoke tests hit live providers on a plain `cargo nextest run --bin dirge`.\n Observed runtimes in the same 5-run sample ranged 0.5s to 62s, i.e. real network\n latency. A default test run should probably not depend on a paid third-party endpoint\n being up and fast; consider gating behind a feature or an env check that SKIPS (not\n fails) when unset.\n- dirge-8kag already tracks a different breakage in this same module (DeepSeek retired\n the deepseek-chat model name). Same root shape: live-provider coupling in the default\n suite. Consider handling both together.\n","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-04T05:35:25Z","created_by":"Yogthos","updated_at":"2026-08-04T13:27:48Z","closed_at":"2026-08-04T13:27:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.4","title":"Advisory gates emit SystemNotice only — the model never sees them","description":"Some harness gates emit a bare LoopEvent::SystemNotice and nothing else. SystemNotice is\na UI/stream event — the model never sees it. Those gates fire, render to the user, and\nchange nothing about what the model does next.\n\n## Paper evidence (arXiv:2604.25850v4 §C.1.4, §C.2.4)\n\nThis is the exact failure AHE measured and fixed at iteration 8, and it is worth reading\nclosely because the WARNING TEXT WAS ALREADY CORRECT:\n\n \"polyglot-c-py and pytorch-model-recovery failed at iteration 7 with a different but\n related symptom: the iteration-6 middleware had already emitted the right warnings\n about clean-layout violation and inline-helper validation, but the warnings were\n appended only to the tool output, and on the very next model turn the agent ignored\n them and published.\"\n\nThe iteration-8 fix (§C.2.4 chg-2) added a `before_model` hook that promotes any risk note\nemitted on the previous step into a FRAMEWORK reminder visible in the next model turn,\n\"so the warning becomes part of the reasoning context rather than text appended after the\ntool output.\" polyglot-c-py, polyglot-rust-c, pytorch-model-recovery and mteb-retrieve all\nflipped via that path. Same detector, same text, different delivery.\n\nThe paper's own summary of the pair: \"chg-1 prevents the destructive shell command itself,\nchg-2 fixes the salience gap of the iteration-6 middleware.\"\n\n## dirge status\n\ndirge has already learned this once. The general mechanism is right:\n- src/agent/agent_loop/run.rs:191 `emit_harness_notices` — messages injected as\n USER-role LoopMessages get MIRRORED to a SystemNotice for headless consumers. Model\n sees the message, user sees the notice. Correct.\n\nBut there are emit sites that send the notice with no accompanying message:\n- src/agent/agent_loop/run.rs:950 — open-issues gate, GateMode::Advisory arm\n- src/agent/agent_loop/run.rs:1013 — untracked-work advisory\n\nAnd GateMode::Advisory is the DEFAULT (src/agent/agent_loop/types.rs, `#[derive(Default)]`\non the Advisory variant), documented as \"surface findings/reminders as a non-blocking\nSystemNotice\". So the default configuration of every advisory gate — code-review,\nopen-issues, and per the doc comment \"any future opt-in finalization gate\" — has the\nsalience gap the paper measured.\n\nNote: the sibling publish-state issue must not be built on a notice-only path, or it\nreproduces iteration 6 rather than iteration 8.\n\n## Scope\n\nAudit every LoopEvent::SystemNotice emit site. For each, decide deliberately: is this\nFYI-for-the-human (a notice alone is right) or is it steering the model (needs a\nmodel-visible message, which then mirrors to a notice for free)?\n\nDo not blanket-convert. A notice that becomes a message costs a turn's attention and\nprompt budget, and the epic's non-additivity finding says stacking closure-style nudges\nhas a real cost — the paper's three positive single-component swaps summed to +11.1pp but\nstacked delivered only +7.3pp. Each conversion needs its own justification.\n\nThen document the distinction so the next gate author picks correctly. GateMode's doc\ncomment currently describes Advisory purely in terms of SystemNotice, which is what\nsteered these sites wrong.\n\n## Acceptance\n\n- An enumeration of every SystemNotice emit site with a per-site verdict (steer vs FYI).\n- For each site converted: a test that the model-visible message is actually present in\n the next request's messages, not merely emitted as an event. Per\n docs/verification-discipline.md's \"Signal never fed\" row — test the function the\n production path calls. A test that constructs the message and asserts on it directly\n would pass while the wiring stays broken.\n- For each site left as a notice: a one-line rationale in the code or the doc.\n- No change to what the human sees in either the TUI or headless output.\n","notes":"Enumeration done (2026-08-04). Four real LoopEvent::SystemNotice emit sites; bridge.rs:161 and message.rs:748 only handle/name the variant.\n\n1. run.rs:199 emit_harness_notices — MIRROR of an already-injected LoopMessage::User. Correct by construction. FYI. No change.\n2. run.rs:950 open-issues gate, GateMode::Advisory arm — NOTICE-ONLY. Text is a steer ('close or defer them when done'). GAP.\n3. run.rs:1013 untracked-work advisory — NOTICE-ONLY. Text is a steer ('add it with write_todo_list and mark it in_progress'). GAP.\n4. run.rs:3090 max_turns truncation — notice-only and CORRECT: the run is ending, there is no next model turn to steer. No change.\n\nCORRECTION to this bead's premise: code-review's Advisory arm does NOT have the gap. run.rs:735-737 is explicit — 'Any finding — even medium/low — re-enters the loop so the model actually sees and acts on it, rather than a display-only notice it never reads.' dirge already learned this for code review. Scope is sites 2 and 3 only.\n\nRoot cause of the two wrong sites: GateMode's doc comment in types.rs describes Advisory as 'surface findings/reminders as a non-blocking SystemNotice. It never re-enters the loop' — but code_review's Advisory arm DOES inject messages. The sites that followed the doc comment literally got it wrong. The doc comment needs fixing as part of this.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:59:02Z","created_by":"Yogthos","updated_at":"2026-08-04T06:15:26Z","started_at":"2026-08-04T05:58:37Z","closed_at":"2026-08-04T06:15:26Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.4","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:59:02Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.3","title":"Masked-command guard misses non-fail-fast multi-step validation blocks","description":"dirge's masked-command guard catches the exit-status-hiding shapes (`| tail`, `|| true`,\n`;`-chained). It does not catch a multi-step validation block whose exit status is\nhonestly 0 while a mismatch scrolled past in the output.\n\n## The shape\n\n diff expected.txt actual.txt\n cmp -s a.bin b.bin\n test -f out/report.json\n echo \"all checks passed\"\n\nEvery assertion runs, one of them prints a mismatch, none of them stops the block, and\nthe exit status belongs to the `echo`. dirge records VerifiedGreen. The existing guard is\nlooking for a pipeline or a `|| true` and finds neither, because there isn't one — the\nstatus is genuinely 0.\n\n## Paper evidence (arXiv:2604.25850v4, evolved harness middleware)\n\nShipped as an execution-risk pattern in the evolved harness\n(experiments/evolved_harness/middleware/execution_risk_hints.py,\n`_looks_like_non_failfast_validation`, priority 46). The note it emits:\n\n \"This validation script chains diff/cmp-style assertions inside a multi-step shell\n block without an explicit fail-fast guard. One hidden mismatch can still be followed by\n a misleading 'passed' line and exit 0.\"\n\nThe detection is conjunctive and deliberately narrow, which is what keeps it from firing\non ordinary work: validation-ish description or command, AND a diff/cmp/test -f/grep -q\nassertion, AND no `set -e` / `|| exit` / `trap ... exit`, AND multi-step (newline, `;`,\nor a loop), AND a success sentinel or echo/printf at the end.\n\nThe same rule reached the evolved system prompt as an explicit line (systemprompt.md:28):\n\"Custom validation scripts must fail fast. Use `set -e` or explicit non-zero exits for\nevery diff/cmp/assertion, and if any expected-vs-actual mismatch appears in the output,\ntreat the validation as failed even if the script later prints `passed` or exits 0.\"\n\n## dirge status\n\ndocs/verification-discipline.md, \"Masked-command guard\" — the closest existing machinery,\nand the right place to extend. Its stated design rule applies directly here: \"A masked\ncommand reporting success is now not recorded at all: the status stays Unverified... 'We\ndon't know' is the honest answer.\" Same treatment fits this shape.\n\nNote the doc's own warning against over-detection: \"Over-detecting would decline good\nverifications and nag forever, which is the same harm pointed the other way.\" Keep the\nconjunction narrow. `\u0026\u0026`-chained assertions are NOT this shape — `\u0026\u0026` short-circuits, so\na failing assertion IS the exit status, exactly as the existing guard already reasons.\n\n## Scope\n\nExtend the masked-command detection to the non-fail-fast multi-step validation block.\nSame failure direction as the existing guard: success is not recorded, failure still is.\n\n## Acceptance\n\n- A test with the exact failing shape above (newline-separated diff + trailing echo,\n exit 0) that currently latches VerifiedGreen and must not after.\n- A test that `diff a b \u0026\u0026 echo passed` STILL latches green — short-circuit means the\n status is honest, and declining it would be the over-detection the doc warns about.\n- A test that a block carrying `set -e` still latches green.\n- A test that an ordinary non-validation multi-step command (a build script, a\n multi-line cargo invocation) is untouched.\n","notes":"Spec deviation (reviewed 2026-08-04): after reading src/agent/agent_loop/verifier.rs:551 masks_failure, the defect is narrower and more certain than this bead assumed. masks_failure handles ';' but not its exact bash synonym '\\n' — the segment splitter feeding is_verification_command already treats '\u0026 | ; \\n' as separators (comment at verifier.rs:839); masks_failure was written against the same grammar and missed one. So the fix is to treat '\\n' as ';' (with a backslash-continuation carve-out), NOT the broader conjunctive pattern matcher from the AHE middleware. The narrow fix subsumes the target shape and is far less likely to over-fire. See scratchpad spec-3.md.\nSame defect found in a SECOND place during review of dirge-1elu.1: publish_guard.rs tokenize()/detect_discard() splits segments on '\u0026\u0026','||',';','|' but treats '\\n' as ordinary whitespace, so 'echo hi\\nrm out.json' returns Pass — the guard is bypassed by a newline. Proven with a failing probe test. Both sites are the same bash-grammar oversight and are being fixed together.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:59:01Z","created_by":"Yogthos","updated_at":"2026-08-04T05:44:17Z","started_at":"2026-08-04T05:38:05Z","closed_at":"2026-08-04T05:44:17Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.3","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:59:01Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-jktn","title":"[bug] 'allow always' on a substitution/subshell command saves a rule that can never fire","description":"SessionAllowlistPolicy::decide (permission/engine/policies.rs:193-200) returns None for any complex command (command/process substitution, subshell, arithmetic expansion) — by design, dirge-g9qj: the inner command is invisible so a broad head grant like 'echo *' must not cover 'echo $(rm -rf ~)'.\n\nThe UI doesn't know that. Pressing 'a' on 'echo $(date)' still renders '-\u003e will allow: echo *' and 'allowed bash echo * (saved to session)', writes the entry to the session allowlist and persists it — and the identical command prompts again on the very next invocation, forever. The affordance lies, and the dead entry accumulates in the saved session.\n\nSame user-visible symptom as dirge-mirm (found alongside it), different root cause: there the pattern was subsumed by an existing rule, here no pattern of that shape is honored at all.\n\nFix: detect the complex case at suggestion time and downgrade to allow-once with an honest reason, reusing the existing placeholder path the empty-input case already takes.","acceptance_criteria":"Pressing 'allow always' on a substitution/subshell command allows once and says why, rather than saving an entry that cannot match.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-02T04:18:18Z","created_by":"Yogthos","updated_at":"2026-08-02T04:25:09Z","closed_at":"2026-08-02T04:25:09Z","close_reason":"Closed","dependencies":[{"issue_id":"dirge-jktn","depends_on_id":"dirge-mirm","type":"discovered-from","created_at":"2026-08-02T00:18:18Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-h3dw","title":"eval_waits_for_an_in_flight_confirm_past_the_base_timeout is still flaky on loaded CI","description":"src/plugin/worker.rs. Failed 'build (all-features)' on PR #740 (run 30720225929), then PASSED on --failed rerun of the SAME commit with no changes. Identical input, different outcome -- a flake by demonstration, not by inference.\n\n assertion `left == right` failed: eval must wait for the confirm answer\n instead of timing out, got Err(\"janet worker did not reply within 1s\")\n left: Err(...) right: Ok(\"true\")\n\nPasses 10/10 locally in isolation, which is the wrong environment: isolation removes exactly the parallel load that causes it.\n\n## This is the second time\n\nFixed once already in #720 (ec88f762), which warmed up the worker, raised CONFIRM_BASE_BUDGET from 100ms to 750ms, and stated 'passes 12/12 with all cores saturated'. It flaked again anyway. The first failure reported 'did not reply within 0s' at a 100ms budget; this one 'within 1s' at 750ms. Same shape, one order of magnitude up.\n\n## Why no constant fixes it\n\nThe timeout extension only engages if `dialog_pending` is already set when the base budget expires. So the test requires the worker to receive Cmd::Eval, start Janet, and enter harness/confirm within CONFIRM_BASE_BUDGET. That latency is unbounded under nextest parallel load on a shared runner -- it is scheduler-dependent, not work-dependent. Raising the budget lowers the failure RATE and cannot reach zero, and every raise also lengthens the test (CONFIRM_ANSWER_DELAY must stay above the budget, so the test's floor is budget + delay; it is already ~1.5-2.2s).\n\nThe test is racing a wall clock against work whose latency has no bound. #720's own commit message says precisely this about the tests it fixed, then fixed this one by picking a bigger number.\n\n## Recommended fix\n\nStop racing. Split the two things the test currently conflates:\n\n1. THE DECISION -- 'when the base budget expires and dialog_pending is set, extend rather than time out.' That is a pure predicate over (elapsed, dialog_pending) and can be unit-tested with no worker, no Janet VM, and no wall clock. This is the pattern the repo already uses for exactly this reason (should_advise_untracked_work, should_nudge_fast_verify, option_select_action).\n2. THE WIRING -- one end-to-end smoke that a confirm round-trips, asserting only the RESULT, never a duration.\n\nThen no test asserts 'the worker got somewhere within N milliseconds', which is the unbounded quantity.\n\n## Why it is worth doing rather than re-raising the budget again\n\nA gate that goes red for reasons unrelated to the change trains people to re-run instead of read -- which is the same failure this repo documents in docs/verification-discipline.md from the other direction. A gate that cannot fail is worse than no gate; a gate that fails at random is how you get there.\n\nFound on PR #740 (capability tier), unrelated to that change -- filed rather than folded in, since a fix touches the Janet worker's concurrency and does not belong in a capability-estimator PR. See also dirge-ntjh for adjacent worker-lifecycle work.","notes":"If the pure-predicate split turns out to need worker internals exposed, an acceptable interim is to gate the test behind an ignore-by-default flag and run it in a serial nextest profile -- but that is second best, since it stops guarding the extension on every CI run.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-01T22:09:01Z","created_by":"Yogthos","updated_at":"2026-08-01T22:33:36Z","closed_at":"2026-08-01T22:33:36Z","close_reason":"Fixed in b6feee2c on branch fix-worker-timeout-flake.\n\nSplit the test into the two things it conflated, as recommended:\n\n1. THE DECISION -\u003e timeout_action(dialog_active, elapsed, ceiling) -\u003e KeepWaiting | GiveUp. A free function, total over three values, no worker/VM/clock. Truth table is exhaustive: the one extending case, both no-dialog cases, past-ceiling with a dialog pending, the strict-\u003c boundary at exactly the ceiling, and a zero ceiling.\n\n2. THE WIRING -\u003e dialog_pending_is_set_while_a_confirm_is_in_flight. Samples the flag at the instant the helper receives the DialogRequest. Clock-free, because DialogPendingGuard::enter() (worker.rs:1654) happens-before tx.send(req) (:1657), so holding the request proves the counter is incremented. Also asserts the guard is released afterwards. This half matters: without it the predicate could be perfectly correct and the security gate still fail open, which was the original dirge-hwzs bug.\n\nVERIFIED BY MUTATION rather than by a green run -- #720 claimed 12/12 under saturation and still flaked, so a passing hammer proves little. Three mutations, each caught:\n - ceiling comparison \u003c -\u003e \u003c= FAILS (boundary case)\n - dialog_active \u0026\u0026 -\u003e || FAILS\n - DialogPendingGuard::enter() deleted FAILS with the fail-open message\nThe old test caught only the third. So this is strictly better coverage as well as deterministic.\n\nAlso 15/15 with all 10 cores saturated, and the end-to-end half drops from ~1.5s to ~0.03s.\n\nNOT changed, deliberately: the other two duration assertions in the file are a different shape. eval_without_a_dialog_still_times_out_at_the_base bounds a give-up expected at ~150ms against a 5s bound (the alternative outcome is the ~630s dialog ceiling, so the bound is nowhere near tight) and load pushes it the SAFE way -- load makes a timeout more likely, not less. The shutdown test starts its clock only after the dialog request has landed, so the unbounded part is already over. The flaky shape is specifically 'require unbounded work to complete inside a tight bound'; neither of those does that.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-a9pc","title":"Release process silently skips channels: 0.20.0 never reached crates.io or the site","description":"v0.20.0 shipped to 4 of 6 distribution channels and nobody noticed until the 0.21.0 release.\n\nMeasured 2026-08-01:\n tag v0.20.0 YES\n GitHub release+binaries YES (workflow, automatic)\n nix/bin.nix YES (workflow, automatic)\n homebrew formula YES (manual, was done)\n crates.io publish NO -- registry goes 0.19.29 -\u003e 0.21.0, no 0.20.0\n site brand-ver NO -- index.html still read v0.19.29, so it had ALSO missed 0.20.0\n\nNet effect: 'cargo install dirge-agent' users never got 0.20.0 (the DS1 agent-loop work). 0.21.0 contains it, so there is no outstanding user-facing gap, but the failure was silent in both directions -- nothing failed, nothing warned, and the two skipped channels are exactly the two that are MANUAL.\n\nThe automated channels (release.yml binaries, the nix bump action) both fired correctly. The manual ones are where it went wrong, and the same shape as the fmt miss in dirge-5mtx.6 FM-6: a checklist that is a habit rather than a gate, where the steps that get skipped are the ones nothing checks.\n\n## Change\n\nEither automate the two manual steps or make skipping them loud:\n - crates.io publish is a natural release.yml job (cargo publish on tag, needs CARGO_REGISTRY_TOKEN as a repo secret). This is the important one -- it is a real distribution channel.\n - the site bump could be a repository_dispatch to dirge-code.github.io from the same workflow, or just dropped in favour of reading the version from the GitHub API at page load.\n - homebrew is currently manual because it needs the release sha256s; it could be a post-release job that reads them back from the release assets.\n\nIf any stay manual, publishing.md should carry a checklist that lists all six and the release should not be considered done until each is ticked -- but automation is better than a longer list, since the list existing is not what failed.","notes":"publishing.md documents crates.io publish (steps 3-5) and the site bump (step 7) but as prose, not a checklist. Homebrew and nix are not mentioned in it at all.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-01T20:06:42Z","created_by":"Yogthos","updated_at":"2026-08-02T00:21:07Z","closed_at":"2026-08-02T00:21:07Z","close_reason":"Landed on branch z85a-failure-threshold-per-poll (d5d195ea).\n\nBoth manual channels automated in release.yml, per the corrected 3-for-3 / 0-for-2 split in the comment:\n - crates-io: cargo publish on tag. Gates on the tag matching Cargo.toml first (a mismatch would put the wrong version on the registry PERMANENTLY, and no later check can undo it). Idempotent -- 'already uploaded' is treated as success so re-running a completed release is clean. needs: build, because publishing is permanent and a tag that does not compile on all five targets should not become an immovable registry entry. Secret: CARGO_REGISTRY_TOKEN.\n - site: rewrites the brand-ver span in dirge-code.github.io, mirroring the homebrew job. Secret: SITE_REPO_TOKEN. (You picked automate-the-bump over dropping the channel via a GitHub API fetch at page load.)\n\nThird job, and the one that actually closes the issue: verify-channels. Automating the two manual steps only converts 'someone forgot' into 'a job silently no-opped' -- both the homebrew and site steps rewrite markup with sed, and an unmatched sed exits 0, after which 'nothing to do' reads as success. verify-channels ignores job status and reads PUBLISHED STATE back: crates.io sparse index, tap formula, site span, nix/bin.nix on main, and every expected release asset with its checksum. Any disagreement with the tag is a red X on the release run.\n\nVerified against real releases rather than assumed (FM-3: a check whose inputs are all defaults cannot discriminate):\n - v0.21.1 -\u003e exit 0, all six channels ok\n - v0.20.0 -\u003e exit 1, names crates.io specifically. That is this bug, reproduced.\n - site sed guard -\u003e exit 1 when the span's class is renamed\n - tag/manifest guard -\u003e exit 1 on v0.99.0 against a 0.21.1 manifest, exit 0 on v0.21.1\n\nOne bug found and fixed in the verification job while writing it: 'gh api ... | grep -q' looks correct and is not. grep -q exits on first match, gh takes SIGPIPE, and pipefail reports the pipeline as failed -- a missing-channel error for a channel that is fine. It only surfaced on index.html, the one input long enough to still be streaming when grep exits; the nix and homebrew files are small enough to complete first. Fixed by fetching to a file, then matching. Worth remembering: it is the same shape as the failures in docs/verification-discipline.md -- a check that reported the wrong thing for a reason unrelated to what it was checking.\n\npublishing.md rewritten. It documented a manual process that no longer exists; it is now a channel table, the two-step release (bump Cargo.toml, push the tag), the secrets, and a by-hand fallback for when the workflow is broken.\n\nNote the new jobs cannot run until CARGO_REGISTRY_TOKEN and SITE_REPO_TOKEN exist as repo secrets on dirge-code/dirge. Without them those jobs fail loudly on the next tag; the binary release is unaffected.","comments":[{"id":"019fbef8-fad0-7543-b296-46f72eac23d4","issue_id":"dirge-a9pc","author":"Yogthos","text":"CORRECTION to the description: Homebrew is NOT manual. Every formula bump back through 0.19.26 is authored by github-actions[bot], including 0.21.0, which landed on its own with sha256s identical to the ones I fetched and verified against the tarballs by hand. I inferred 'manual' from the plain 'dirge 0.20.0' commit subjects without checking the author, and pushed a redundant duplicate commit before noticing (reset, not on the remote).\n\nThat makes the finding SHARPER, not weaker. Corrected channel status for v0.20.0:\n\n tag manual OK\n GitHub release+binaries automated OK\n nix/bin.nix automated OK\n homebrew formula automated OK\n crates.io publish MANUAL SKIPPED\n site brand-ver MANUAL SKIPPED\n\nEvery automated channel fired. Both manual channels were skipped. It is a clean 3-for-3 and 0-for-2 split, so the fix is unambiguous: automate the remaining two rather than write a better checklist. cargo publish on tag in release.yml is the important half -- it is the only skipped channel that is a real distribution path.\n\nThe homebrew workflow is also the working model to copy: it already reads the release assets back after the binary jobs finish, which is exactly the shape a publish job or a site-bump dispatch would need.","created_at":"2026-08-01T20:16:56Z"},{"id":"019fc042-3b32-7cf8-a082-fb2657313562","issue_id":"dirge-a9pc","author":"Yogthos","text":"CONFIRMED IN PRODUCTION on v0.21.2 (released 2026-08-02, run on tag eb136f9d).\n\nAll six channels landed on the first fully-automated release. Verified independently of verify-channels rather than taking its green at face value:\n crates.io 0.21.0 0.21.1 0.21.2 (contiguous — contrast the 0.19.29 -\u003e 0.21.0 gap this issue is about)\n homebrew version \"0.21.2\"\n site brand-ver\"\u003ev0.21.2\n nix on main version = \"0.21.2\"\n release 11 assets (5 archives + 5 checksums + sbom)\n\nThe two jobs that had never run before — crates-io and site — both succeeded unattended. verify-channels emitted all 15 'ok:' lines, so it ran its checks rather than passing vacuously; that mattered to check, since a job that silently no-ops is the exact failure it was written to catch.\n\nSTILL UNEXERCISED: the crates.io sparse-index retry loop. The index had 0.21.2 on the first probe, so the CDN-lag path (10 attempts, 30s apart) has never run. If a future release fails verify-channels on crates.io alone while the publish job succeeded, suspect that loop before suspecting the publish.","created_at":"2026-08-02T02:16:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} @@ -202,7 +211,7 @@ {"_type":"issue","id":"dirge-c0h4","title":"dirge exits 101 (panic) on terminal hangup instead of 129; children may not be reaped","description":"When the terminal goes away, dirge exits with 101 (Rust panic) instead of the intended 128+SIGHUP = 129. Something panics writing to the dead tty before signal.rs's clean teardown runs.\n\nReproduced end-to-end with a Python harness that runs dirge on a real\ncontrolling pty via `pty.fork()`, then closes the master:\n\n PASS phase 1: alive after 4s\n closed pty master (terminal is now gone)\n exited 0.2s after tty close, code 101\n\n0.2s is faster than the 250ms dead-tty watchdog tick, and 101 is rustc's\npanic exit status, so neither the watchdog nor the SIGHUP handler is what\nends the process — a panic beats both.\n\nPre-existing, NOT introduced by the 0.19.24 spin fixes. Verified by\nstashing the input_reader change, rebuilding, and re-running the same\nharness: baseline behaves identically (phase 1 pass, 0.2s, code 101).\n\nWhy it matters beyond the wrong exit code: `src/signal.rs` exists\nspecifically so a terminal hangup reaps the detached child process groups\n(LSP servers, MCP servers, DAP adapters, bash subtrees) via\n`reap_all_groups()` before exiting, since those are `setsid`-detached and\nnever receive the terminal's signal themselves. A panic path likely skips\nthat, which is the exact orphaned-rust-analyzer scenario dirge-6klk was\nfiled for. Worth confirming whether the panic hook reaps.\n\nNext step: capture the panic message and backtrace. Redirecting the\nchild's stderr to a file changes behaviour (it panics immediately, before\nthe tty is even closed — a different path worth understanding on its own),\nso the message has to be captured on the pty itself, before the master is\nclosed, or via the panic hook's log file.\n\nThe harness used is in the 0.19.24 investigation; it can be rebuilt from\nthe description above in a few lines of Python.\n","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-28T02:40:59Z","created_by":"Yogthos","updated_at":"2026-07-28T03:12:05Z","closed_at":"2026-07-28T03:12:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-jiiv","title":"crossterm input reader spins at 100% CPU on a dead tty; poll() never returns","description":"The crossterm input reader can spin at 100% CPU forever when the terminal dies, and dirge cannot interrupt it.\n\nUpstream bug, crossterm 0.29 `UnixInternalEventSource::try_read`\n(event/source/unix/mio.rs). The inner read loop is:\n\n loop {\n match self.tty_fd.read(\u0026mut self.tty_buffer) {\n Ok(read_count) =\u003e { if read_count \u003e 0 { parser.advance(...) } }\n Err(e) =\u003e {\n if e.kind() == WouldBlock { break }\n else if e.kind() == Interrupted { continue }\n // any other error falls through\n }\n };\n if let Some(event) = self.parser.next() { return Ok(Some(event)); }\n }\n\nIt breaks only on WouldBlock and retries on Interrupted. `Ok(0)` (EOF)\nand any other errno (notably EIO) fall through, the parser yields\nnothing, and the loop repeats with no timeout check. So `event::poll()`\nnever returns, even with a 1ms timeout.\n\nWhy it triggers: verified on macOS, an orphaned pty slave reports\nPOLLIN|POLLHUP forever and read() returns 0 forever. A dirge orphaned\ninto the background (parent reparented to launchd) also gets EIO\nreading its controlling tty once its process group is orphaned.\n\nImpact on dirge: src/ui/input_reader.rs drives that call in a loop. Its\n`Err(_) =\u003e break` arm is unreachable because poll() never returns, and\nthe thread never re-checks EVENT_READER_SHUTDOWN. So the reader pins a\ncore, and terminal.rs's join_reader / suspend_tui_for_subprocess wait\nbarriers (~line 774) wedge behind a thread that will never exit.\n\nThis is the second spinner seen alongside the pty_relay tty-EOF bug\nfixed in fix(pty-relay): stop the tty-EOF spin that pinned two cores.\nThat one is fixed; this one is still live.\n\nProposed fix: before calling `event::poll`, probe the same fd crossterm\nuses (isatty(0) ? fd 0 : /dev/tty, opened once at reader start) with a\nzero-timeout libc::poll. Treat POLLHUP|POLLERR|POLLNVAL as fatal and\nbreak out of the reader loop. POLLHUP is not set on a live terminal, so\nit is a safe discriminator and will not cause false exits.\n\nAlso worth reporting upstream to crossterm.\n\nOpen question, not covered by the guard: once the reader exits, the UI\nloop keeps running with no input source. For an orphaned background\ndirge the process still will not exit. Decide separately whether a dead\nterminal should trigger shutdown.\n","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-28T01:37:33Z","created_by":"Yogthos","updated_at":"2026-07-28T02:12:22Z","started_at":"2026-07-28T01:38:06Z","closed_at":"2026-07-28T02:12:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-fhr5","title":"/agent \u003cname\u003e doesn't cross-provider route its pinned model (sibling of GH #711)","description":"Same root cause as dirge-t665 / GH #711, different consumer. src/ui/slash/cmd/agent/switch.rs:32-37 sets session.model to the profile's pinned model via resolve_model_alias (same-client only) and never rebuilds the client, so /agent researcher with model: glm-5.2 on a Codex/ChatGPT client makes every MAIN-session turn 400. It also resets session.provider to the CLI/config default (switch.rs:35), which /model explicitly documents as wrong — the next routing decision then reasons from the wrong active provider.\n\nFix is the same shape as the task path (see resolve_profile_model / resolve_model_route added for #711): route via resolve_model_route, rebuild the client on ModelRoute::Provider, set session.provider to the switched alias, mirror /model's refuse-and-warn for Unroutable.\n\nNeeds one extra piece the task path doesn't: /agent off (clear.rs:20-25) restores model_before_agent but never restores the CLIENT, so a switch-back would leave the restored model on the profile's provider. Needs a provider_before_agent sibling to model_before_agent.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-27T14:24:25Z","created_by":"Yogthos","updated_at":"2026-07-27T16:04:35Z","closed_at":"2026-07-27T16:04:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"dirge-8kag","title":"h7_smoke tests broken: DeepSeek retired deepseek-chat model name","description":"The three h7_smoke scenarios call the live DeepSeek API with model 'deepseek-chat', which the provider retired: 'The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed deepseek-chat.' All three fail on every local 'cargo test'.\n\nPre-existing, not from #717 — verified by stashing and re-running on a clean tree. Invisible in CI (no DeepSeek key there), so it only bites local runs, where it makes a green suite look red and trains people to ignore failures.\n\nFix: update the model name in src/agent/agent_loop/h7_smoke.rs, or gate the scenarios on a reachable provider so they skip rather than fail when unconfigured.","status":"open","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-26T05:51:31Z","created_by":"Yogthos","updated_at":"2026-07-26T05:51:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-8kag","title":"h7_smoke tests broken: DeepSeek retired deepseek-chat model name","description":"The three h7_smoke scenarios call the live DeepSeek API with model 'deepseek-chat', which the provider retired: 'The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed deepseek-chat.' All three fail on every local 'cargo test'.\n\nPre-existing, not from #717 — verified by stashing and re-running on a clean tree. Invisible in CI (no DeepSeek key there), so it only bites local runs, where it makes a green suite look red and trains people to ignore failures.\n\nFix: update the model name in src/agent/agent_loop/h7_smoke.rs, or gate the scenarios on a reachable provider so they skip rather than fail when unconfigured.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-26T05:51:31Z","created_by":"Yogthos","updated_at":"2026-08-04T13:27:48Z","closed_at":"2026-08-04T13:27:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-ytu1","title":"Syntax gate has no pre-edit baseline: already-broken files can't be edited at all","description":"syntax_gate(path, candidate) validates the WHOLE candidate file with no reference to what was on disk before. If the file was already unparseable, every edit is rejected with an error pointing at a region the model never touched, and there is no opt-out (write goes through the same gate).\n\nTwo classes hit this:\n1. Legitimately unparseable files that carry a lisp extension - templates (luminus-template/resources/leiningen/new/luminus/core/src/core.clj has \u003c% %\u003e markers), linter fixtures (clj-kondo/corpus/invalid_literals.clj), REPL transcripts. dirge can never edit them.\n2. A file broken by something outside the edit tools (bash/sed, a partial apply_patch, the user's own WIP). Recovery requires one edit that fixes ALL syntax at once.\n\nRepro: a .clj file with an unclosed form near the top + 40 valid defns after it -\u003e any edit rejected with 'the ( opened at line 3, col 1 is never closed'.\n\nFix direction: compute the gate on the pre-edit content too, and only block when the edit INTRODUCED errors (or increased the error count). Pre-existing breakage should downgrade to a warning appended to the success message. Same shape as the SQL carve-out reasoning already documented in language_for_path.","acceptance_criteria":"Editing an already-broken file succeeds with a warning when the edit does not make it worse; an edit that introduces a new error is still blocked; regression tests both ways","status":"closed","priority":2,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-07-25T22:05:35Z","created_by":"Yogthos","updated_at":"2026-07-25T22:37:28Z","started_at":"2026-07-25T22:24:01Z","closed_at":"2026-07-25T22:37:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-l8ou","title":"Delimiter hint points at the outermost unclosed opener, not the innermost","description":"delimiter_summary (src/semantic/syntax_validator.rs) reports stack[0] - the OUTERMOST unclosed opener. In a lisp that is almost always the enclosing top-level form, i.e. column 1 of a defn, which is nowhere near the actual mistake. The innermost unclosed opener (stack.last()) sits at or next to the dropped closer.\n\nRepro: file with (defn broken [x] / (let [y (inc x)] / (println y -\u003e message says 'the ( opened at line 3, col 1 is never closed' (the defn) when the actionable pointer is (println at line 5, col 5.\n\nEffect: the message tells the model 'do not count by hand - fix this delimiter' while pointing it at the top of the form, so it re-reads the whole form and counts anyway. This is the reported 'Whole-file balance N (unbalanced) yet reader loads' flailing.\n\nFix direction: lead with the innermost unclosed opener (deepest, closest to the error), keep the count, and optionally mention the outermost as context.","acceptance_criteria":"Summary names the innermost unclosed opener's line/col; existing wording tests updated; count unchanged","status":"closed","priority":2,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-07-25T22:05:24Z","created_by":"Yogthos","updated_at":"2026-07-25T22:37:27Z","started_at":"2026-07-25T22:24:00Z","closed_at":"2026-07-25T22:37:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-32k9","title":"ACP does not expose slash commands (gh#714)","description":"Dirge's ACP bridge never announces its slash commands, so editor clients (Zed, etc.) can't offer them. Advertise a curated ACP-appropriate subset via available_commands_update after session/new, and intercept invocations (/help, /clear, /cd, /model, /mode) in run_prompt so they execute locally instead of being fed to the LLM. TUI-only commands are not advertised.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-25T00:49:20Z","created_by":"Yogthos","updated_at":"2026-07-25T01:59:11Z","started_at":"2026-07-25T00:49:37Z","closed_at":"2026-07-25T01:59:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -640,13 +649,16 @@ {"_type":"issue","id":"dirge-ny0","title":"Phase 3: right-side info panel (cwd, MCP, LSP, todos, modified files)","description":"Carve right ~32 cols (auto-hide when terminal narrower than ~100). Sources: cwd from env, MCP from McpClientManager.handles, LSP via new public accessor on LspManager, todos from TODO_LIST mutex, modified files via new shared Arc\u003cMutex\u003cIndexSet\u003cPathBuf\u003e\u003e\u003e populated by Write/Edit/ApplyPatch tools. New /panel on|off toggle. Default on when wide enough.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:08Z","created_by":"Yogthos","updated_at":"2026-05-20T04:21:53Z","started_at":"2026-05-20T04:11:20Z","closed_at":"2026-05-20T04:21:53Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dirge-r2u","title":"Phase 2A: queue user input while agent is running","description":"Remove 'agent is busy' guard at src/ui/mod.rs:663 and :720 for plain text. Push to VecDeque\u003cString\u003e interjection_queue. Show queue count + dim preview above input. Esc/Ctrl-X drops most recent. Ctrl-C still aborts. Slash commands stay gated to current allow-list. On AgentEvent::Done, drain queue and run as next turn.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:07Z","created_by":"Yogthos","updated_at":"2026-05-20T04:04:50Z","started_at":"2026-05-20T03:58:39Z","closed_at":"2026-05-20T04:04:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dirge-sxt","title":"Phase 1: soft-wrap input box instead of horizontal scroll","description":"Replace horizontal scroll logic in src/ui/renderer.rs::draw_bottom (lines ~500-660) with display-column wrap. Drop input_scroll_offset; compute visual rows from logical lines wrapped to visible_width; keep MAX_INPUT_VISIBLE_LINES cap with vertical scroll keeping cursor visible. Move or guard the token counter so it doesn't collide with wrapped text. Add a unit test for cursor (logical -\u003e visual) mapping.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:06Z","created_by":"Yogthos","updated_at":"2026-05-20T03:58:35Z","started_at":"2026-05-20T03:52:14Z","closed_at":"2026-05-20T03:58:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"dirge-eqx0","title":"nREPL plugin: babashka users can't auto-connect (bb prints the port, never writes .nrepl-port)","status":"open","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-03T19:26:16Z","created_by":"Yogthos","updated_at":"2026-08-03T19:26:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.7","title":"Verification-keyed memory expectations need the verifier status plumbed out of run_loop","description":"dirge-1elu.5 shipped MemoryExpectation with only the CommandRan variant. A VerificationGreen variant was built and then removed before merge because it could not fire: ExpectationSignals.final_verification was hardcoded None at both production call sites (ui/run_handlers/done.rs and context_compacted.rs), since the VerifierGate is a run_loop local and is gone by the time either handler runs. SessionDigest carries commands as bare strings with no exit statuses, so the status is not derivable there either.\n\nShipping it inert would have been the 'gate that cannot fail' pattern the parent epic exists to remove, so it was dropped rather than left settable-but-dead.\n\nTo add it back, the run's final VerificationStatus has to reach the post-session pass. Options considered, neither costed:\n- return it from run_agent_loop (currently returns Vec\u003cLoopMessage\u003e; signature change ripples)\n- stash it in a process-global the way tools/snapshots.rs and tools/modified.rs already do, with the concurrency caveat those carry\n\ngate_tally already holds the value (final_verification, gate_tally.rs:118) and emits it on the dirge::gates line, so a third option is reading it back from the tally rather than re-deriving it.\n\nWhichever path: from_wire already treats an unknown expectation string as None, so a DB written by a future version that sets verification_green stays inert on an older binary. That property must survive.","status":"closed","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T07:11:58Z","created_by":"Yogthos","updated_at":"2026-08-04T13:04:09Z","started_at":"2026-08-04T09:48:18Z","closed_at":"2026-08-04T13:04:09Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.7","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T03:11:57Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.6","title":"gate_tally records per-run totals, not gate co-occurrence at a boundary","description":"scripts/loop-ab.sh compares one arm against one control. Nothing measures what happens\nwhen several gates fire in the same run — which is the configuration every real user is\nin, and which the paper measured as the place the ceiling lives.\n\n## Paper evidence (arXiv:2604.25850v4 §4.4.1, Table 3)\n\nThe component ablation swapped ONE evolved layer into the seed at a time, holding the\nother three at seed defaults:\n\n seed 69.7% (Easy 87.5 / Med 78.2 / Hard 51.7)\n + memory only 75.3% (Easy 50.0 / Med 83.6 / Hard 63.3)\n + tool only 73.0% (Easy 75.0 / Med 87.3 / Hard 46.7)\n + middleware only 71.9% (Easy 100 / Med 81.8 / Hard 50.0)\n + system_prompt only 67.4% (Easy 75.0 / Med 78.2 / Hard 46.7)\n AHE full 77.0% (Easy 100 / Med 88.2 / Hard 53.3)\n\nTwo things fall out. The three positive single-component gains sum to +11.1pp; stacked\nthey deliver +7.3pp. And on Hard tasks the MEMORY-ONLY variant (63.3%) beats full AHE\n(53.3%) by a wide margin — adding the other three components made Hard tasks worse.\n\nThe paper's diagnosis: \"memory, middleware, and the system prompt all push toward the same\nclosure-style verification, so stacking them spends turns on redundant re-checks within\nthe long-horizon budget.\" And on why the loop converged there anyway: \"Since the evolve\nagent optimises an aggregate dominated by 55 Medium tasks, it converges to a Medium-heavy\ntrade-off that returns part of the Hard memory effect.\"\n\nNote also the per-difficulty inversions in that table: memory-only takes Easy from 87.5%\nDOWN to 50.0% while taking Hard from 51.7% UP to 63.3%. An aggregate-only comparison\nwould have reported that component as a clean +5.6pp win and hidden both halves.\n\n## Why this matters for dirge specifically\n\ndirge ships more finalization gates and boundary nudges than AHE ever had: the verifier\ngate and its tier escalation, the critic, code review, the goal gate, open issues, the\nstorm breaker, the failure tracker, safe-state abort, the progress/stall monitor, budget\nnotices, the file-touch tracker, the exploration-prologue cap. Several of them push toward\nthe same closure-style re-verification the paper found interfering. This epic adds more.\n\ndocs/verification-discipline.md already states the measurement constraint honestly: an A/A\non recon-real produced 18 vs 36 turns on one model and 15 vs 33 on another, so \"at n\u003c=3\nagainst a ~2x floor, effects justified by 'this reduces turns' are not measurable at any\nsample size worth paying for. Prefer changes whose success criterion is STRUCTURAL — did\nthe mechanism fire when it should, and stay silent otherwise — because those hold at n=1.\"\n\nThat constraint is not an argument against this issue; it shapes it. The measurable\nquestion is not \"does gate X reduce turns\" but \"do gates X and Y fire on the same\nboundary, on the same run, for the same underlying condition.\"\n\n## dirge status\n\nsrc/agent/agent_loop/gate_tally.rs already records which finalization gate fired\n(GateSource) and which boundary nudges fired (BoundaryNudge), and emits per-run counts on\nthe `dirge::gates` target. It is pure instrumentation with no control-flow effect. Its\nmodule doc already names two consumers (the A/B harness and the capability estimator).\n\nWhat it does not record is CO-OCCURRENCE — which gates fired at the same boundary, and in\nwhat order. The counts are per-run totals, so two gates that always fire together and one\nthat never does are indistinguishable from the aggregate.\n\n## Scope\n\nMake gate interaction visible, then look at what it says. Two parts, in order:\n\n1. Extend the tally to record co-occurrence at a boundary, not just per-run totals. Stay\n observation-only — gate_tally has no control-flow effect and must keep none.\n2. Extend scripts/loop-ab.sh to report the co-occurrence, and add a mode that measures\n more than two arms so a \"all gates on\" configuration can be compared against\n \"one gate on\" configurations, which is the shape of the paper's Table 3.\n\nDo not act on the results in this issue. Finding that two gates redundantly fire is a\nseparate decision with its own evidence bar; this issue is about being able to see it at\nall. The paper's own loop got this wrong in the other direction — it optimized an\naggregate and silently gave back the Hard-tier gain.\n\n## Acceptance\n\n- A run in which two gates fire at the same boundary produces a tally line that says so,\n distinguishably from the same two gates firing at different boundaries.\n- loop-ab.sh reports co-occurrence per arm, and its existing mechanism check still holds:\n \"A treatment arm showing zero did not exercise the change, and its deltas are noise.\"\n- Per-model reporting is preserved. The doc is explicit that a single-model result is not\n evidence for a steering change.\n- gate_tally still has zero control-flow effect — a test that the loop behaves\n identically with the tally's new fields populated and ignored.\n","status":"closed","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:59:05Z","created_by":"Yogthos","updated_at":"2026-08-04T06:45:05Z","started_at":"2026-08-04T06:15:38Z","closed_at":"2026-08-04T06:45:05Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.6","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:59:05Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1elu.5","title":"Learned memories carry no falsifiable prediction; effectiveness is self-reported","description":"dirge's post-session learning writes memories and skills with no prediction attached and\nno measured verification that they helped. The `memory mark success|failure` action exists\nbut is agent-discretionary self-report, in-band, and nothing forces it to be called.\n\n## Paper evidence (arXiv:2604.25850v4 §3.3, §4.4.2, Appendix D)\n\nDecision observability is AHE's third pillar and the one that keeps the loop from\ncollapsing into trial-and-error. Every edit ships a manifest entry naming four things:\nfailure evidence, root cause, targeted fix, and PREDICTED IMPACT (expected fixes plus\nat-risk regressions). The next round intersects those predicted sets with the observed\ntask-level deltas to produce a per-edit verdict, and edits whose predictions do not\nmaterialize get rolled back at file granularity. \"Each edit thereby becomes falsifiable by\nthe next evaluation, which replaces rationale-driven self-justification with a measurable\ncontract between rounds.\"\n\nThe calibration result is the part to take seriously, and it cuts both ways (§4.4.2):\n\n- FIX predictions are informative. Cross-iteration precision 33.7%, recall 51.4% — about\n 5x the random baselines of 6.5% and 10.6%. \"each harness edit lands on a real,\n agent-anticipated target rather than on an arbitrary subset of the panel.\"\n- REGRESSION predictions are near-worthless. Precision 11.8%, recall 11.1%, only about 2x\n the random baselines of 5.6% and 5.4%. Appendix D gives the raw counts: across 9 rounds\n the agent issued 43 unique regression predictions and 5 landed, while 40 regressions it\n did not foresee actually occurred.\n\nThe paper's own reading: \"The agent can justify why an edit should help, but it cannot\nreliably name the tasks the same edit is about to break.\" It names closing this gap as\n\"the clearest direction for future self-evolution loops.\"\n\nThe design consequence for dirge is specific: ASK for the fix prediction and act on it.\nDo NOT ask a model to predict what its learned memory will break and then trust the\nanswer — that number is barely above chance, and treating it as a safety signal would be\nworse than having none.\n\n## dirge status\n\nThe substrate is all there and unconnected:\n- src/agent/review.rs — background review at session end, writes memories + skills\n- src/agent/post_session.rs — the ordered orchestrator around it\n- src/agent/session_digest.rs — deterministic ground truth (files touched, commands run,\n where we stopped) with no model call\n- src/extras/memory_db.rs:211 `effectiveness_bonus(kind, success_count, failure_count)`,\n procedural-only, signed and bounded, already feeding salience at :740\n- src/agent/tools/memory.rs:86 the `mark` action\n\nThe missing piece is the contract. A procedural memory today asserts a rule; it does not\nstate what it expects to change, so nothing can ever falsify it. `effectiveness_bonus`\nreads counters that only move when the agent volunteers a `mark`.\n\n## Scope\n\nWhen post-session review writes a PROCEDURAL memory, have it also record a falsifiable\ntrigger: the observable condition under which the memory should have applied. Later\nsessions that meet the trigger settle it against measured signal rather than self-report.\n\nThe measured signal already exists per-run: src/agent/agent_loop/gate_tally.rs emits gate\nand nudge counts plus capability signals on the `dirge::gates` target, and\nscripts/loop-ab.sh already scrapes exactly that line.\n\nDeliberately out of scope: asking for a regression prediction. See the calibration data\nabove.\n\nStart narrow. This is the least-proven item in the epic and the one whose blast radius\ntouches persisted user state, so prefer a shape that is inert when the trigger never\nfires and that cannot corrupt existing memories.\n\n## Acceptance\n\n- A memory written with a trigger, a later session meeting that trigger, and the\n effectiveness counter moving WITHOUT an agent-issued `mark`. This is the whole point;\n if the counter only moves on self-report, nothing changed.\n- A test that a memory whose trigger never fires is left exactly as it was — no decay, no\n phantom failure. Inert by default.\n- Existing memories with no trigger keep working unchanged (migration safety).\n- Per docs/verification-discipline.md \"Signal never fed\": the test must drive the\n PRODUCTION path that records the outcome, not call the recorder directly. That row\n exists because a counter with zero production callers passed its unit test while being\n structurally always 0.\n","status":"closed","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-04T04:59:04Z","created_by":"Yogthos","updated_at":"2026-08-04T07:35:16Z","started_at":"2026-08-04T06:45:11Z","closed_at":"2026-08-04T07:35:16Z","close_reason":"Closed","labels":["ahe"],"dependencies":[{"issue_id":"dirge-1elu.5","depends_on_id":"dirge-1elu","type":"parent-child","created_at":"2026-08-04T00:59:03Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-eqx0","title":"nREPL plugin: babashka users can't auto-connect (bb prints the port, never writes .nrepl-port)","description":"Found while fixing dirge-hli5. .nrepl-port is the plugin's only port-discovery mechanism. clojure -M:nrepl and lein repl :headless write it; 'bb nrepl-server' does NOT — it prints 'Started nREPL server at 127.0.0.1:\u003cport\u003e' to stdout and writes nothing. Verified locally with bb 1.x.\n\nSo babashka users get no auto-connect and must call nrepl_connect with an explicit port (which does work). Not a regression — the plugin never supported bb — and the README comparison table already says 'Port discovery: .nrepl-port only'.\n\nOptions: lsof fallback (what clojure-mcp-light does), or parse a known server log. Neither is obviously right; needs a decision.","status":"open","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-08-03T19:26:16Z","created_by":"Yogthos","updated_at":"2026-08-03T19:26:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-rdwt","title":"[bug] Relay tests write ~126KB of debris into the real ~/.dirge/transient","description":"background.rs:1341 (regression_notify_relays_large_completed_payload) and background.rs:1676 (ui_sink_event_carries_relayed_payload) each build a 5000-line payload, push it through the real relay, and never remove the file. output_relay's own tests clean up after themselves; these two don't.\n\nObserved 50 leftover task-*.txt files (~6 MB) in a developer's ~/.dirge/transient. They are reclaimed only when some later relay write happens to trigger the 24h aged sweep.\n\nAlso actively misleading during investigation: the debris looks like production relay payloads when triaging a subagent-truncation report.","acceptance_criteria":"cargo test leaves no new files under ~/.dirge/transient.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-02T04:05:17Z","created_by":"Yogthos","updated_at":"2026-08-02T04:25:08Z","closed_at":"2026-08-02T04:25:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-jbhz","title":"check_microvm_includes_kvm_check fails on macOS: expects libkrunfw.dylib, gets libkrunfw.5.dylib","description":"src/sandbox/check.rs:490. Fails on macOS under --all-features on clean main (verified by stashing unrelated changes and re-running):\n\n should include libkrunfw.dylib check, got: [\"Hypervisor.framework\",\n \"libkrun.dylib\", \"libkrunfw.5.dylib\", \"gzip\", \"tar\", \"ssh-keygen\",\n \"dirge-microvm-runner\", ...]\n\nThe test looks for an unversioned 'libkrunfw.dylib' entry; the code emits the versioned 'libkrunfw.5.dylib'. Either the check list or the assertion drifted -- worth deciding which is correct rather than just relaxing the assertion, since the string is what a user is told to install.\n\nsandbox::microvm::tests::integration::dyld_fallback_library_path_valid_on_macos fails in the same run and is probably the same drift.\n\nNOT caught by CI because the all-features job runs on ubuntu; see dirge-u35k (no macOS CI coverage). So this is invisible to the gate and only shows up when someone runs the suite locally on a Mac -- which means it reads as 'the suite is red' to anyone doing that, and trains them to ignore it.\n\nFound incidentally while fixing dirge-h3dw; unrelated to that work and not folded in.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-01T22:33:49Z","created_by":"Yogthos","updated_at":"2026-08-02T00:39:27Z","closed_at":"2026-08-02T00:39:27Z","close_reason":"Landed on branch z85a-failure-threshold-per-poll (1138e13f). The issue guessed the second failure was 'probably the same drift' -- it isn't, they have different causes and needed different fixes.\n\nFAILURE 1 (check_microvm_includes_kvm_check) -- real drift, and the CODE was right. check.rs:142 deliberately emits the versioned libkrunfw.5.dylib, with a comment saying why: libkrun carries no LC_RPATH and dlopens libkrunfw by bare versioned name at runtime, so the unversioned libkrunfw.dylib symlink is never consulted by the loader and can exist on a machine where the dlopen still fails. Checking for it would report OK on a broken install. The test's assertion had drifted to the unversioned name.\n\nAnswering the issue's 'decide which is correct rather than just relax the assertion': the check is correct, the assertion was wrong. But the real cause is that the string was spelled out in FOUR places (check.rs:143, microvm/mod.rs:322, microvm/tests.rs:2027, and the wrong one in check.rs:486), so it is now a single LIBKRUNFW_LIB const (plus LIBKRUN_LIB) that all four read. Both are gated on sandbox-microvm, or they are dead_code under default features and -D warnings fails the default build.\n\nDe-duplication alone would make the decision unfalsifiable -- with one const feeding both the check and its assertion, changing it to the unversioned name moves the test with it and nothing objects. So there is also a separate macOS-only test pinning LIBKRUNFW_LIB == 'libkrunfw.5.dylib' with the reason.\n\nFAILURE 2 (dyld_fallback_library_path_valid_on_macos) -- NOT drift. It hard-fails whenever libkrunfw is not installed, which is any Mac that has not run 'brew install libkrun libkrunfw'. That is the 'trains them to ignore red' half of this issue, and it is an environment prerequisite, not a code defect.\n\nWhat the test actually guards is our brew-prefix RESOLUTION, using the installed library as the oracle: formula installed but not found under any prefix we resolve =\u003e resolution broken =\u003e fail. Formula absent =\u003e nothing to resolve to =\u003e skip, matching the ~20 sibling tests in that file that skip on missing virtualization or PTY allocation.\n\nThe precondition asks brew directly ('brew list --formula libkrunfw') rather than looking for the file under a prefix WE resolved. Using our own resolution would make the skip condition identical to the assertion, turning the guard vacuous instead of red -- the FM-3 shape from dirge-5mtx.6.\n\nVerified: macOS 'cargo test --bin dirge --all-features' is now 5158 passed / 0 failed, first time green on this machine. Confirmed with --nocapture that the dyld test SKIPS with its message rather than vacuously passing (libkrunfw is genuinely not installed here). All four clippy configs clean, fmt clean, default suite 4866 passed.\n\nDoes not close dirge-u35k (no macOS CI coverage) -- that is still the reason this class of drift is invisible to the gate.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-z85a","title":"FailureTracker threshold is fixed at construction, so it can never adapt","description":"FAILURE_REFLECTION_THRESHOLD (run.rs:275, value 3) is consumed exactly once, at FailureTracker::new(FAILURE_REFLECTION_THRESHOLD) (run.rs:2053), which runs at run start. At that point the capability estimator is always Nominal by warm-up (MIN_CALLS_FOR_ESTIMATE = 5 tool calls), so deriving the threshold at the construction site is inert by construction -- it would read the neutral tier every time.\n\nThis matters because FAILURE_REFLECTION_THRESHOLD is the single BEST-MATCHED candidate for capability derivation in the whole audit (see dirge-5mtx.7). The estimator is literally built from failure counts and streaks, and this guard fires on consecutive errored results -- signal and trigger are the same thing, which is the test the audit says a derivation must pass. Every other movable constant fails that test.\n\n## Change\n\nFailureTracker needs its threshold to be re-readable per turn rather than baked in at construction: either a set_threshold(u32), or have poll/record take the effective threshold as an argument (mirroring how should_nudge_fast_verify takes the tier). Prefer the argument form -- it keeps the tracker stateless about policy and matches the pattern already used for the tier-derived nudge.\n\nThen derive it: Struggling -\u003e reflect after 1 consecutive failure instead of 3. Strong and Nominal stay at 3, per the one-directional policy (the tier may add support, never remove it).\n\n## Acceptance\n\n- The effective threshold is read per evaluation, not at construction, with a test that changes tier mid-run and observes the guard firing earlier.\n- Nominal and Strong remain bit-identical to today.\n- No A/B claim. Per dirge-5mtx.6 FM-5 the ~2x noise floor makes this unmeasurable; it ships on the structural argument that signal and trigger match, or not at all.","notes":"Found while auditing dirge-5mtx.7 rather than from a failure. Low priority: it only affects Struggling, which is rare (1 of 6 runs at the supported low bound) and n=1.","status":"closed","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-08-01T21:15:21Z","created_by":"Yogthos","updated_at":"2026-08-01T23:24:06Z","closed_at":"2026-08-01T23:24:06Z","close_reason":"Landed on branch z85a-failure-threshold-per-poll (adef66e1).\n\npoll_reflection now takes the tier and derives its threshold per evaluation via CapabilityTier::scale_threshold, mirroring should_nudge_fast_verify. The tracker keeps the base constant; the caller supplies the observed tier.\n\nDeviation from the issue text: Struggling gets 2, not 1. scale_threshold is 2/3, so 3 -\u003e 2, and a threshold of 1 would violate the tracker's own '\u003e= 2' construction invariant and fire the checkpoint on the first errored call -- which contradicts the module's premise (repeated, DISTINCT failures). The derived value is floored at MIN_EFFECTIVE_THRESHOLD = 2 so a future smaller base can't produce it either.\n\nTwo things deliberately left on the base threshold, both inside the same tracker:\n - the permission checkpoint (dirge-iwwq). A denial streak is a policy wall; nothing in CapabilityCounters measures how often the user's rules block a call, so the tier has no standing to pull it forward.\n - safe_state_due's 2x signal. That rung spends one of two hard-capped aborts per run and, in auto mode, restores files on the tree. Firing it sooner is not 'adding support' in the one-directional sense, and there is no evidence for it.\n\nAcceptance:\n - threshold read per poll, with tier_flip_mid_streak_moves_the_guard_both_ways changing tier mid-streak and observing the guard move in BOTH directions (fires at 2 under Struggling; re-arm reverts to the base interval when it flips back)\n - nominal_and_strong_are_bit_identical pins the no-op property\n - no A/B claim, per dirge-5mtx.6 FM-5\n\nVerified red-then-green: neutering effective_threshold to ignore the tier fails exactly the three tier tests, the other 26 pass. Full gate green -- 4866 passed, RUSTFLAGS=-D warnings cargo clippy --all-targets clean, cargo fmt --all --check clean.\n\ndocs/verification-discipline.md now carries the table of what the tier actually derives (two thresholds) and what it deliberately doesn't.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-9m05","title":"clippy -D warnings fails under --all-features and --features dap","description":"CI's clippy job lints three configs (default, windows-default, sandbox-microvm) and ci.yml notes 'Extending coverage to dap / --all-features is still a follow-up'. Measured 2026-08-01: 'RUSTFLAGS=-D warnings cargo clippy --all-features --all-targets' exits 101 with 17 findings, mostly collapsible_if:\n\n 6 src/agent/tools/debug.rs\n 3 src/ui/run_handlers/done.rs\n 2 src/dap/config.rs\n 2 src/agent/tools/graph.rs\n 1 src/ui/tui/scene.rs\n 1 src/plugin/mod_tests.rs\n 1 src/extras/entity_compress.rs\n 1 src/dap/janet_bindings.rs\n\nSame shape as dirge-xw43 (sandbox-microvm, 50 findings): an ungated feature combination accumulates lint debt silently. The fix is both halves — clear the findings AND add the config to the clippy job, since clearing without gating just restarts the clock.\n\nFound while landing dirge-5mtx; not caused by it (all 8 files are untouched by that work). Filed rather than folded in to keep an unrelated 8-file diff out of PR #739.","notes":"Check whether --features dap is also dirty before deciding the job layout; the clippy job already runs three configs sequentially and a fourth/fifth adds CI time. May be worth one --all-features pass instead of per-feature ones.","status":"closed","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-08-01T19:37:25Z","created_by":"Yogthos","updated_at":"2026-08-02T00:33:08Z","closed_at":"2026-08-02T00:33:08Z","close_reason":"Landed on branch z85a-failure-threshold-per-poll (d2b74019). Both halves done, per the issue.\n\nAll 17 findings cleared across the 8 files listed. The collapsible_if ones became let-chains (edition 2024), which is how the loop code already reads -- run.rs:1966 was already written that way. Two needed hand fixes: graph.rs:229 took \u0026PathBuf where \u0026Path does (deref coercion means no caller changed), and entity_compress.rs:587 indexed 'kinds' by a loop counter that existed only to index it.\n\nJob layout, answering the NOTES question: --features dap alone is CLEAN once the all-features findings are fixed, so no separate dap step is warranted. Added ONE '--all-features --all-targets' step instead of per-feature ones -- it subsumes dap, plugin, semantic and the rest for one config's worth of CI time.\n\nKept the three existing narrower configs rather than replacing them with --all-features. --all-features cannot reach a cfg(not(feature = ...)) path, and windows-default is subtractive (--no-default-features), not additive, so it is not a subset of anything --all-features compiles. Dropping them would lose the coverage dirge-xw43 added.\n\nVerified all four configs clean: default, windows-default, sandbox-microvm, all-features. cargo fmt --all --check clean.\n\nTests: default suite 4866 passed. The --all-features suite is the one that actually exercises the changed dap/plugin/graph code -- 5155 passed, 2 failed, and both failures are the known macOS sandbox drift in dirge-jbhz (check_microvm_includes_kvm_check, dyld_fallback_library_path_valid_on_macos), in files this change does not touch.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-5mtx.5","title":"State-derived gate preconditions; unify the nine gate budgets","description":"The largest and last child. Mostly refactoring; the behavioural win is that\nseveral hand-tuned budgets stop being load-bearing.\n\nPaper §A.2 (implicit sequence): attach a readiness PRECONDITION to each step and\nre-evaluate every tick. In a static world it behaves identically to a fixed\nsequence; in a changing one it's reactive and self-recovering, because a step\nwhose precondition stopped holding simply doesn't fire.\n\ndirge's gate predicates evaluate over `new_messages`, which ACCUMULATES across\nre-entries. So a condition that was true once stays true forever, and each gate\nneeds a counter to stop it re-firing. The code diagnoses this itself at\nrun.rs:795-800:\n\n \"`new_messages` accumulates across re-entries, so the triggering\n `write_todo_list` call stays in the list and the condition would hold on\n every later pass. A behavioral nudge that didn't land the first time doesn't\n land on the third identical repeat — it just spends round-trips.\"\n\nThe compensating counters, all separate, all hand-tuned, all threaded through\n`poll_finalization_follow_up`'s argument list (run.rs:536-561 — nine `\u0026mut`\ncounters, already `#[allow(clippy::too_many_arguments)]`):\n\n critic_done, code_review_reacts, goal_reacts, todo_nudges, resume_nudges,\n open_issues_nudges, track_nudges, verify_nudges, plus the verifier's own\n internal one-shot and `last_reviewed_fingerprint` / `last_review_findings`.\n\nSame root cause produces the documented priority inversion at run.rs:528-532:\n\"a red build surfaces the verifier nudge now and the critic runs at the *next*\nfinalization once the build is fixed (the verifier won't fire twice)\". That's a\none-shot budget standing in for reactive re-evaluation.\n\nAnd it produces two already-filed bugs, both of which .5 should fix or close:\n - dirge-q7vw: a judge ERROR records `last_reviewed_fingerprint`, so the retry\n is suppressed and the diff never gets reviewed.\n - dirge-mu46: the dedup skip keys on the diff fingerprint but also skips\n transcript-based completeness re-judgment.\n\n## Change\n\n1. Evaluate gate predicates over the DELTA since the last finalization, not the\n whole accumulated `new_messages`. Most gates want \"did this happen since I\n last looked\", which is what a precondition means.\n2. Introduce one `GateBudget { max: u8, spent: u8 }` (or a small `GateState`\n holding budget + per-gate memo) and a single `GateStates` struct replacing\n the nine `\u0026mut` parameters. Collapses the argument list and puts every\n lifecycle in one place.\n3. With state-derived predicates, relax the budgets that only existed to\n compensate. Some are genuine cost ceilings and MUST stay — MAX_GOAL_REACT\n and MAX_REVIEW_REACT bound LLM calls, MAX_TURNS bounds spend. Distinguish\n the two kinds explicitly in the type: a cost ceiling vs a re-fire guard.\n Getting this wrong in the unsafe direction means an unbounded loop, so a\n budget stays unless the precondition demonstrably subsumes it.\n\n## Risk\n\nHighest-risk child in the epic; it touches every gate's lifecycle. Land it last,\nbehind the A/B harness, and expect to keep more budgets than the theory says are\nneeded. \"Refactored into one place\" is a real win on its own even if few budgets\nactually relax.\n\n## Acceptance\n\n- `poll_finalization_follow_up` takes one state struct, not nine `\u0026mut u8`s;\n the `too_many_arguments` allow comes off.\n- dirge-q7vw and dirge-mu46 have failing tests first, then pass.\n- Every existing gate test passes unchanged — this must be behaviour-preserving\n except where a linked bug says otherwise.\n- Each budget is documented as cost-ceiling or re-fire-guard, and every relaxed\n budget has a test showing the precondition now stops the re-fire.\n- A/B (.1 harness): all scenarios show no regression. This child is not expected\n to improve the numbers; if it moves them, something changed that shouldn't\n have.\n","status":"closed","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-08-01T03:33:46Z","created_by":"Yogthos","updated_at":"2026-08-01T19:31:24Z","closed_at":"2026-08-01T19:31:24Z","close_reason":"Refactor landed (a6573055). poll_finalization_follow_up now takes \u0026mut GateStates + GateInputs instead of 17 positional args; the too_many_arguments allow is off. Behaviour-preserving: both structs are destructured at the top of the fn so gate bodies are byte-identical, and the 30 existing gate tests pass with no assertion changes (only building a GateStates instead of loose locals). Full gate green: fmt --all --check 0, clippy -D warnings 0, 4852 tests.\n\nThe tractable order recorded on the previous attempt turned out to be unnecessary — the destructure trick meant the fn body never changed, so the only work was the 30 call sites, done with a transformer written to ABORT on any shape it did not fully recognize rather than guess. It aborted twice (once on a real parser bug of mine, once correctly flagging that tests seed non-default values like todo_nudges=MAX_TODO_NUDGES that a lenient transform would have silently dropped). That refusal is why this attempt landed and the last one did not.\n\nAcceptance: one state struct + allow removed DONE; q7vw and mu46 fixed earlier (be8738be); existing gate tests pass unchanged DONE; every gate budget documented cost-ceiling vs re-fire-guard on GateStates DONE. Budget relaxation and delta-based predicates stay DROPPED per the descope. The broader constant taxonomy (MAX_RULES_CHARS, MAX_DIFF_BYTES, MAX_SCAVENGE_INPUT, context_manager fold fractions) belongs to dirge-5mtx.7.","dependencies":[{"issue_id":"dirge-5mtx.5","depends_on_id":"dirge-5mtx","type":"parent-child","created_at":"2026-07-31T23:33:45Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-5mtx.5","depends_on_id":"dirge-5mtx.1","type":"blocks","created_at":"2026-07-31T23:33:57Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-5mtx.5","depends_on_id":"dirge-5mtx.7","type":"relates-to","created_at":"2026-08-01T00:09:54Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-5mtx.5","depends_on_id":"dirge-mu46","type":"relates-to","created_at":"2026-07-31T23:33:59Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-5mtx.5","depends_on_id":"dirge-q7vw","type":"relates-to","created_at":"2026-07-31T23:33:58Z","created_by":"Yogthos","metadata":"{}"}],"comments":[{"id":"019fbb83-ebce-728c-8c67-513e0ca05725","issue_id":"dirge-5mtx.5","author":"Yogthos","text":"REVISED by dirge-5mtx.7. Unifying the nine budgets into one struct is still the refactor, but the budgets should end up DERIVED from the capability estimate rather than being constants in a tidier container. Also: this issue must draw the line between capability-tuned thresholds (adaptive) and true invariants that stay fixed — context_manager fold fractions are memory pressure, MAX_RULES_CHARS / MAX_DIFF_BYTES are token budgets, MAX_SCAVENGE_INPUT is a DoS bound. Right now both kinds of constant sit side by side looking identical, which is half of why this is confusing to change.","created_at":"2026-08-01T04:10:13Z"},{"id":"019fbda2-70db-7981-bb9b-98f6394c99a5","issue_id":"dirge-5mtx.5","author":"Yogthos","text":"DESCOPED on the evidence.\n\nOriginal premise: with state-derived preconditions, most of the nine hand-tuned budgets could be relaxed because the precondition would stop the re-fire on its own. That claim cannot be verified. Run-to-run variance on the recon-real scenario is ~2x on turns and tool calls (dirge-5mtx.6, FM-5), so 'this budget can be relaxed without hurting anything' is exactly the kind of statement our A/B cannot distinguish from noise at any sample size we can afford — provider spend is the binding constraint.\n\nAlso, the empirical work found no benefit to point at. Every failure this epic surfaced was a VERIFICATION failure, not a steering one; nothing suggested the budgets were mis-set.\n\nREMAINING SCOPE (pure refactor, no behavioural claim):\n - collapse the nine \u0026mut counters threaded through poll_finalization_follow_up into one GateStates struct, and drop the too_many_arguments allow\n - fix the two linked lifecycle bugs, dirge-q7vw and dirge-mu46, each with a failing test first\n - document every constant as cost-ceiling vs re-fire-guard (dirge-5mtx.7 needs that line drawn anyway)\n\nDROPPED:\n - relaxing any budget\n - evaluating predicates over the delta since last finalization, which was only worth doing to enable the relaxation\n\nPriority drops to P3: it is tidying with two small bug fixes attached, not a behavioural improvement.","created_at":"2026-08-01T14:02:47Z"},{"id":"019fbe86-ed7a-7bea-b8d6-6b49ac99018d","issue_id":"dirge-5mtx.5","author":"Yogthos","text":"Attempted the GateStates collapse and REVERTED it.\n\nThe struct itself was fine — nine \u0026mut params into one place, with the cost-ceiling vs re-fire-guard distinction documented per field. Production code compiled cleanly. What killed it was the 30 test call sites: they pass a mix of named locals and inline `\u0026mut 0u8` defaults and then assert on those locals afterwards, so collapsing the signature means rewriting each site to build a GateStates, call, and copy values back out. My mechanical transformer got most of them and mangled the rest.\n\nJudgement: not worth it. This is tidying with no behavioural claim (already descoped for that reason), and pushing a fragile 30-site rewrite through at the end of a long session risks breaking real coverage for zero user-visible gain. The two bugs attached to this issue — dirge-q7vw and dirge-mu46 — were the valuable part and are fixed and committed (be8738be).\n\nIf it gets picked up later, the tractable order is: add GateStates and a `from_legacy`-style test helper FIRST so existing sites keep compiling, migrate sites in small batches with the suite green between each, then delete the helper. Doing the signature and the sites in one pass is what made it fragile.","created_at":"2026-08-01T18:12:21Z"}],"dependency_count":1,"dependent_count":0,"comment_count":3} -{"_type":"issue","id":"dirge-1c77","title":"[bug] cargo check --no-default-features fails: task.rs references git_worktree unconditionally","description":"Pre-existing on main (verified against origin/main in a clean worktree), not a regression from the RAX branch.\n\nsrc/agent/tools/task.rs:77 calls crate::extras::git_worktree::repo_is_dirty unconditionally, but the module is behind the git-worktree feature. So:\n\n cargo check --no-default-features\n error[E0433]: cannot find `git_worktree` in `extras`\n\nNot covered by CI — the matrix is acp / all-features / dap / default / loop+git-worktree / lsp / mcp / plugin / sandbox-microvm / semantic-bash / windows-default, none of which is bare --no-default-features. Release artifacts build with default features, so shipped binaries are unaffected.\n\nFix is presumably a cfg gate on the call site (or on the containing fn) with a no-op fallback when git-worktree is off. Worth deciding whether --no-default-features is a supported configuration at all; if it is, it should be in the CI matrix so this can't regress again.","status":"open","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-01T02:37:39Z","created_by":"Yogthos","updated_at":"2026-08-01T02:37:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1c77","title":"[bug] cargo check --no-default-features fails: task.rs references git_worktree unconditionally","description":"Pre-existing on main (verified against origin/main in a clean worktree), not a regression from the RAX branch.\n\nsrc/agent/tools/task.rs:77 calls crate::extras::git_worktree::repo_is_dirty unconditionally, but the module is behind the git-worktree feature. So:\n\n cargo check --no-default-features\n error[E0433]: cannot find `git_worktree` in `extras`\n\nNot covered by CI — the matrix is acp / all-features / dap / default / loop+git-worktree / lsp / mcp / plugin / sandbox-microvm / semantic-bash / windows-default, none of which is bare --no-default-features. Release artifacts build with default features, so shipped binaries are unaffected.\n\nFix is presumably a cfg gate on the call site (or on the containing fn) with a no-op fallback when git-worktree is off. Worth deciding whether --no-default-features is a supported configuration at all; if it is, it should be in the CI matrix so this can't regress again.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-08-01T02:37:39Z","created_by":"Yogthos","updated_at":"2026-08-04T13:27:49Z","closed_at":"2026-08-04T13:27:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-8kw8","title":"llmtrim capability: 191 of 299 model_context entries are unreachable (prefix stripped from query but kept in key)","description":"capability::context_window_for (src/llmtrim/capability.rs:156) normalizes the QUERY by dropping a 'provider/' prefix:\n\n let id = model_id.to_ascii_lowercase();\n let id = id.split_once('/').map_or(id.as_str(), |(_, rest)| rest);\n CONTEXT.get(id).copied()\n\nbut CONTEXT is built from the snapshot's keys verbatim (lowercased only, capability.rs:54-67), and 191 of the 299 keys in src/llmtrim/data/model_context.json ARE prefixed ('moonshotai/kimi-k2', 'ai21/jamba-large-1.7', 'amazon/nova-pro-v1', ...). A prefixed key can therefore never be hit: any query is stripped before lookup, so it can only ever match one of the 108 bare keys.\n\nEffect today is a silent miss -\u003e caller default, not a wrong answer, which is why it went unnoticed while the only consumer was the breakdown occupancy view. It matters more now that config::context_window_for_model falls back to this registry (dirge-9jy7): a routed id like 'moonshotai/kimi-k2.5' still resolves to the 128k default rather than its real 262144.\n\nFix: normalize on BOTH sides — store each key under its bare form (and optionally also the prefixed form), or try the full id first and the stripped id second. Watch for collisions when two vendors ship the same bare name; preferring the exact-match-first order avoids silently picking the wrong vendor's window.\n\nWorth a test that asserts every snapshot key is retrievable by at least one query form, so the two normalizations cannot drift apart again.\n\nFound while fixing dirge-9jy7.","status":"open","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-29T03:03:37Z","created_by":"Yogthos","updated_at":"2026-07-29T03:03:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-01tu","title":"Routed-Anthropic cache freeze fires even when the cache stage is off, losing compression with nothing cached","description":"has_automatic_cache_marker (src/llmtrim/cache_zone.rs:111) returns true when routes_to_anthropic(raw) is true — i.e. any 'anthropic/*' router model id — regardless of whether CacheStage will actually run.\n\nCacheStage is gated on config.cache (src/llmtrim/mod.rs:204). config.cache is true only in the 'agent', 'aggressive', and 'cache' presets; it is false in the lossless baseline, which is what 'safe' / 'rag' / 'code' / 'reasoning' and any hand-tuned config without the flag inherit.\n\nSo under those presets, with an 'anthropic/\u003cmodel\u003e' route through OpenRouter:\n- frozen_pointers freezes the system prompt and every message but the newest, so every content-mutating stage (hygiene, serialize, dedup, toolout, skeleton, ngram, jsoncrush, retrieve) skips them;\n- no cache_control marker is ever written, because CacheStage never runs;\n- Anthropic caches nothing without an explicit breakpoint.\n\nNet: the content is neither compressed nor cached. Strictly worse than before the change for that combination.\n\nThe PR notes this and argues it 'errs toward not rewriting cached bytes, which is the direction those presets want anyway'. That reasoning holds when something is actually cached — here nothing is, so the freeze protects a prefix that does not exist.\n\nNot the default path: the runtime default is 'auto' shape-routing, and agent-shaped traffic (dirge's own) routes to the 'agent' preset, which sets cache = true. So this needs an explicitly chosen non-caching preset plus a routed Anthropic model.\n\nFix: gate the routed-Anthropic half of has_automatic_cache_marker on the same config.cache the stage is gated on, so the freeze and the marker are decided together. The top-level-cache_control half is already self-consistent — that marker's presence proves a breakpoint exists.\n\nFound while reviewing #732 for the 0.19.27 release.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-07-28T14:34:07Z","created_by":"Yogthos","updated_at":"2026-07-29T03:17:02Z","closed_at":"2026-07-29T03:17:02Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-h4o3","title":"Drop the crossterm dead-tty workaround once crossterm#1067 ships","description":"Track crossterm-rs/crossterm#1067 and drop our local workaround once it ships.\n\nUpstream bug: crossterm-rs/crossterm#793. `UnixInternalEventSource::try_read`\n(src/event/source/unix/mio.rs) breaks only on `WouldBlock` and retries on\n`Interrupted`. `Ok(0)` (EOF) and every other errno fall through to a parser\nthat yields nothing, and the loop repeats with no timeout check — so\n`event::poll()` never returns once the tty is gone, spinning a core inside\nthe crate. Confirmed on macOS: an orphaned pty secondary reports\nPOLLIN|POLLHUP forever and read() returns 0 forever.\n\nUpstream fix: crossterm-rs/crossterm#1067, \"fix: return an error when the\ntty is gone\" (open as of 2026-07-28, created 2026-07-10). Returns\n`UnexpectedEof` instead of retrying, in that loop plus `read_position_raw`\nand the `use-dev-tty` source.\n\nWhat we shipped instead, in 0.19.24 (src/ui/input_reader.rs):\n- `tty_is_dead(fd)` probe before each poll call\n- `event::poll(Duration::ZERO)` plus our own 1ms sleep, so crossterm holds\n the thread for microseconds rather than a millisecond — this is what\n actually shrinks the trap window, by roughly 1000x\n- a watchdog thread that exits via signal.rs's teardown if the reader is\n trapped anyway\n\nNote we cannot fix this by patching the dependency: `[patch.crates-io]` is\nignored for published crates and `cargo publish` rejects git deps, so\nanyone doing `cargo install dirge-agent` would still get stock crossterm.\nWaiting for the upstream release is the only real path.\n\nWhen #1067 lands and a crossterm release carries it:\n1. Bump the crossterm dependency.\n2. Delete the zero-timeout/own-sleep dance and restore a plain\n `event::poll(1ms)`.\n3. Decide whether to keep the watchdog. It is cheap and also covers cases\n crossterm cannot (an orphaned process that never receives SIGHUP), so\n keeping it is defensible even after the upstream fix.\n4. Keep `tty_is_dead` either way — its unit tests are useful and the probe\n is nearly free.\n\nThe residual window today is microseconds per iteration rather than the\nfull poll duration, so this is not urgent.\n","status":"open","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-07-28T03:18:26Z","created_by":"Yogthos","updated_at":"2026-07-28T03:18:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/scripts/loop-ab-selftest.sh b/scripts/loop-ab-selftest.sh new file mode 100755 index 00000000..ca832e96 --- /dev/null +++ b/scripts/loop-ab-selftest.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# loop-ab-selftest.sh — exercise loop-ab.sh's reporting awk against a synthetic +# results.tsv, with no models, no network, and no builds. +# +# Why this exists: the reporting layer is awk embedded in bash, and three real +# bugs shipped in it at once (dirge-1elu.6) — the arm list collected the model +# column instead of the tag column, the co-occurrence lookup key had its two +# components reversed against the accumulation key, and the boundaries value was +# scraped from a TSV column that `run_arm` never wrote. None of them could fail a +# Rust test, and all three degrade SILENTLY: the report still prints, it just +# says "none" forever. That is the failure mode docs/verification-discipline.md +# calls a gate that cannot fail. +# +# The fixture below is built so each assertion has a known-other-answer: +# control — no boundary events at all +# treatment — two gates co-firing at ONE boundary -> `Verifier+Critic` +# allgates — the same two gates at SEPARATE boundaries -> `Critic, Verifier` +# The treatment/allgates pair is the point. If the report cannot tell those two +# apart, co-occurrence is not being measured, and neither row means anything +# alone. +# +# Usage: scripts/loop-ab-selftest.sh (exit 0 = pass) + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ab="$here/loop-ab.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# Extract the reporting awk program from loop-ab.sh so we test the REAL one +# rather than a copy that can drift out of sync with it. +awk '/^awk -F/{flag=1; next} /^'"'"' "\$WORK\/results.tsv"/{flag=0} flag' "$ab" > "$work/report.awk" +if [ ! -s "$work/report.awk" ]; then + echo "FAIL: could not extract the reporting awk program from $ab" >&2 + exit 1 +fi + +# tag model repeat turns tools err scav storm streak rep_inv rep_tot verify +# first_write correct tally prologue nudges tier boundaries +row() { printf '%s\t%s\t%s\t10\t20\t0\t0\t0\t0\t0\t0\t%s\t3\t%s\t%s\t0\t2\tnominal\t%s\n' "$@"; } +{ + row control m1 1 VerifiedGreen 1 1 none + row control m1 2 VerifiedGreen 1 1 none + row treatment m1 1 VerifiedGreen 1 1 'Verifier+Critic' + row treatment m1 2 VerifiedGreen 1 1 'Verifier+Critic' + row allgates m1 1 VerifiedGreen 1 1 'Verifier;Critic' + row allgates m1 2 VerifiedGreen 1 1 'Verifier;Critic' + row control m3 1 - 0 0 none + row treatment m3 1 - 0 0 none +} > "$work/results.tsv" + +out="$(awk -F'\t' -f "$work/report.awk" "$work/results.tsv" 2>&1)" + +fails=0 +want() { # $1 = description, $2 = grep -E pattern + if printf '%s\n' "$out" | grep -qE "$2"; then + echo " ok $1" + else + echo " FAIL $1 (no line matching: $2)" + fails=$((fails + 1)) + fi +} + +echo "loop-ab.sh reporting self-test:" +# Arms are named by TAG, not by model. The bug printed `co-occurrence m1`. +want "arms are named by tag, not model" '^ co-occurrence (control|treatment|allgates):' +if printf '%s\n' "$out" | grep -qE '^ co-occurrence m[0-9]+:'; then + echo " FAIL an arm was named after a model (tag/model column mix-up)" + fails=$((fails + 1)) +else + echo " ok no arm named after a model" +fi +# The discriminating pair. Neither assertion means anything without the other. +want "co-firing at one boundary reads as one event" '^ co-occurrence treatment: Verifier\+Critic x2$' +want "the same gates at separate boundaries differ" '^ co-occurrence allgates: Critic x2, Verifier x2$' +want "an arm with no events says so" '^ co-occurrence control: none' +# N-arm mode picks up the third arm and compares it against control. +want "N-arm mode compares the extra arm" '^== model: m1 — arm: allgates ==$' +# A missing tally is surfaced, never silently zeroed. +want "a missing tally is reported, not zeroed" '^tally_found +0/1' + +if [ "$fails" -ne 0 ]; then + echo + echo "$fails check(s) failed. Full report:" + printf '%s\n' "$out" + exit 1 +fi +echo "all checks passed" diff --git a/scripts/loop-ab.sh b/scripts/loop-ab.sh index 3ffac7a6..422e0f71 100755 --- a/scripts/loop-ab.sh +++ b/scripts/loop-ab.sh @@ -379,6 +379,7 @@ build_config() { # $1 = cfgdir, $2 = overrides, $3 = model # $WORK/results.tsv as: # tag model repeat turns tool_calls errored scavenged storm # maxstreak repair_invalid repair_total verification first_write correct tally +# prologue_nudges nudges_total capability_tier boundaries run_arm() { # $1 = overrides, $2 = tag, $3 = model local i cfgdir datadir out err logfile ok tally_str local gates_line tally_found turns tool_calls_f errored scavenged storm maxstreak rep_invalid rep_total verification fw @@ -414,6 +415,12 @@ run_arm() { # $1 = overrides, $2 = tag, $3 = model rep_total="$(get_field repair_total_successful "$gates_line")" verification="$(get_field final_verification "$gates_line")" captier="$(get_field capability_tier "$gates_line")" + # dirge-1elu.6: boundary co-occurrence shapes, e.g. `Verifier+Critic;Todo`. + # Absent on a build that predates the field, which reads as `none` — + # distinct from a missing tally line, which is a harness bug and is + # reported as such. + boundaries="$(get_field boundaries "$gates_line")" + boundaries="${boundaries:-none}" # MECHANISM CHECK. Without this an A/B cannot distinguish "the change # helped" from "the change never fired" — the arms differ in config but # nothing confirms the code path under test was reached. Sum of every @@ -432,16 +439,16 @@ run_arm() { # $1 = overrides, $2 = tag, $3 = model tally_found=0 turns=0; tool_calls_f=0; errored=0; scavenged=0; storm=0 maxstreak=0; rep_invalid=0; rep_total=0; verification="-" - nudge_prologue=0; nudges_total=0; captier="-" + nudge_prologue=0; nudges_total=0; captier="-"; boundaries="none" fi fw="$(first_write "$out")" ok="$(check_correct "$out")" - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$2" "$3" "$i" "$turns" "$tool_calls_f" "$errored" "$scavenged" "$storm" \ "$maxstreak" "$rep_invalid" "$rep_total" "$verification" "$fw" "$ok" "$tally_found" \ - "$nudge_prologue" "$nudges_total" "$captier" \ + "$nudge_prologue" "$nudges_total" "$captier" "$boundaries" \ >> "$WORK/results.tsv" if [ "$tally_found" = 1 ]; then tally_str=found; else tally_str=missing; fi @@ -487,6 +494,10 @@ for model in "${MODEL_LIST[@]}"; do run_arm "$ARM_A" control "$model" echo "treatment:" run_arm "$ARM_B" treatment "$model" + for arm in "${ARMS[@]}"; do + echo "${arm%%:*}:" + run_arm "${arm#*:}" "${arm%%:*}" "$model" + done echo done @@ -502,6 +513,7 @@ BEGIN { tiercols["struggling"]=1; tiercols["nominal"]=1; tiercols["strong"]=1 } if (!(key in seen)) { seen[key] = 1 if (!(("m" $2) in mseen)) { mseen["m" $2] = 1; mlist[++nm] = $2 } + if (!(("a" $1) in aseen)) { aseen["a" $1] = 1; alist[++na] = $1 } } n[key]++ # 4..11 are the numeric run metrics; 16 (prologue nudges) and 17 (all @@ -526,6 +538,17 @@ BEGIN { tiercols["struggling"]=1; tiercols["nominal"]=1; tiercols["strong"]=1 } if ($13 == "-") never[key]++ ok[key] += $14 tallyfound[key] += $15 + # dirge-1elu.6: boundary co-occurrence events (col 19). Each event is a + # shape like `Verifier+Critic` (co-firing members joined by `+`, events + # separated by `;` in the run). `none` / `-` means no event fired. + if ($19 != "" && $19 != "none" && $19 != "-") { + split($19, evs, ";") + for (i in evs) { + s = evs[i] + if ((key, s) in evcnt) evcnt[key, s]++ + else { evcnt[key, s] = 1; evlist[key, ++evn[key]] = s } + } + } if ($18 != "" && $18 != "-") tiers[key,$18]++ if ($15 == 0) missing[key]++ green[key] += ($12 == "VerifiedGreen") @@ -585,6 +608,15 @@ function row(name, c, t, d) { else if (d == "worse") wm[name]++ else if (d == "flat") fm[name]++ } +# dirge-1elu.6: like row(), but tallies into bm2/wm2/fm2 so the extra-arm +# comparisons do not disturb the two-arm consistency summary. +function row2(name, c, t, d) { + printf "%-26s %-26s %-26s %s\n", name, c, t, d + if (d == "better") bm2[name]++ + else if (d == "worse") wm2[name]++ + else if (d == "flat") fm2[name]++ + if (!(name in names2)) names2[name] = 1 +} END { for (mi = 1; mi <= nm; mi++) { m = mlist[mi] @@ -621,6 +653,64 @@ END { dir3(green[ck] / n[ck], green[tk] / n[tk], 0, 0.05, ratefloor(ck, tk))) row("capability_tier", tierdist(ck), tierdist(tk), "observed") row("tally_found", sprintf("%d/%d", tallyfound[ck], n[ck]), sprintf("%d/%d", tallyfound[tk], n[tk]), "must be full") + + # dirge-1elu.6: co-occurrence per arm — which gates and nudges fired + # together at one decision point, with counts. An arm whose boundaries + # field is `none` in every run is reported as such (mechanism: nothing + # fired — same discipline as the nudge sums). + for (ai = 1; ai <= na; ai++) { + ak = alist[ai] + kk = ak SUBSEP m + if (evn[kk] > 0) { + desc = "" + for (ei = 1; ei <= evn[kk]; ei++) { + shp = evlist[kk, ei] + desc = desc (desc == "" ? "" : ", ") shp " x" evcnt[kk, shp] + } + printf " co-occurrence %s: %s\n", ak, desc + } else { + printf " co-occurrence %s: none (no boundary events in any run)\n", ak + } + } + + # dirge-1elu.6: N-arm mode — every arm beyond control/treatment gets the + # same comparison against the control arm. Uses row2() so the two-arm + # consistency summary below is not polluted. + for (ai = 1; ai <= na; ai++) { + ak = alist[ai] + if (ak == "control" || ak == "treatment") continue + ek = ak SUBSEP m + # An arm can be absent for THIS model — a launch that failed, or an + # N-arm matrix that is not fully crossed. Say so and move on: every + # rate below divides by n[ek], so proceeding would divide by zero and + # abort the whole report. Reporting the absence rather than skipping + # silently is the same discipline as `tally=missing` — a gap in the + # data is a fact about the run, not a zero. + if (!(ek in n) || n[ek] == 0) { + printf "== model: %s — arm: %s ==\n no runs for this model — arm not comparable\n", m, ak + continue + } + printf "== model: %s — arm: %s ==\n", m, ak + printf "%-26s %-26s %-26s %s\n", "metric", "control", ak, "delta" + row2("turns", spread(ck, 4), spread(ek, 4), dir3(mean(ck, 4), mean(ek, 4), 1, 0.5, noisefloor(ck, 4))) + row2("tool_calls", spread(ck, 5), spread(ek, 5), dir3(mean(ck, 5), mean(ek, 5), 1, 0.5, noisefloor(ck, 5))) + row2("errored_tool_calls", spread(ck, 6), spread(ek, 6), dir3(mean(ck, 6), mean(ek, 6), 1, 0.5, noisefloor(ck, 6))) + row2("scavenged_calls", spread(ck, 7), spread(ek, 7), dir3(mean(ck, 7), mean(ek, 7), 1, 0.5, noisefloor(ck, 7))) + row2("storm_suppressions", spread(ck, 8), spread(ek, 8), dir3(mean(ck, 8), mean(ek, 8), 1, 0.5, noisefloor(ck, 8))) + row2("max_failure_streak", spread(ck, 9), spread(ek, 9), dir3(mean(ck, 9), mean(ek, 9), 1, 0.5, noisefloor(ck, 9))) + row2("repair_invalid", spread(ck, 10), spread(ek, 10), dir3(mean(ck, 10), mean(ek, 10), 1, 0.5, noisefloor(ck, 10))) + row2("repair_total_successful", spread(ck, 11), spread(ek, 11), sprintf("%+.1f", mean(ek, 11) - mean(ck, 11))) + eval2 = (fwn[ek] ? sprintf("%.1f (%.0f..%.0f) [never=%d]", fws[ek] / fwn[ek], fwmin[ek], fwmax[ek], never[ek]) : "- [never=" never[ek] "]") + row2("first_write", cval, eval2, "n/a") + row2("nudges_fired", spread(ck, 17), spread(ek, 17), "mechanism") + row2(" of which prologue", spread(ck, 16), spread(ek, 16), "mechanism") + row2("success_rate", rate(ck), rate(ek), dir3(ok[ck] / n[ck], ok[ek] / n[ek], 0, 0.05, ratefloor(ck, ek))) + row2("green_rate", sprintf("%d/%d (%.0f%%)", green[ck], n[ck], 100 * green[ck] / n[ck]), + sprintf("%d/%d (%.0f%%)", green[ek], n[ek], 100 * green[ek] / n[ek]), + dir3(green[ck] / n[ck], green[ek] / n[ek], 0, 0.05, ratefloor(ck, ek))) + row2("tally_found", sprintf("%d/%d", tallyfound[ck], n[ck]), sprintf("%d/%d", tallyfound[ek], n[ek]), "must be full") + printf "\n" + } printf "\n" } @@ -642,6 +732,23 @@ END { for (k in missing) total_missing += missing[k] if (total_missing > 0) { printf " WARNING: %d run(s) produced no dirge::gates line (missing tally is a harness bug — check DIRGE_LOG/RUST_LOG).\n", total_missing + + # dirge-1elu.6: N-arm consistency across models (only meaningful with + # several models and at least one extra arm). bm2/wm2/fm2 were filled by + # the extra-arm blocks above. + if (na > 2 && nm > 1) { + printf "== summary across models, extra arms ==\n" + for (ai = 1; ai <= na; ai++) { + ak = alist[ai] + if (ak == "control" || ak == "treatment") continue + printf " arm %s (vs control)\n", ak + for (ni2 in names2) { + b = bm2[ni2]; w = wm2[ni2]; f = fm2[ni2] + if (b + w + f == 0) continue + printf " %-26s better %d, worse %d, flat %d of %d models\n", ni2, b, w, f, nm + } + } + } } } ' "$WORK/results.tsv" diff --git a/src/agent/agent_loop/claim_gate.rs b/src/agent/agent_loop/claim_gate.rs new file mode 100644 index 00000000..346301cb --- /dev/null +++ b/src/agent/agent_loop/claim_gate.rs @@ -0,0 +1,360 @@ +//! Deterministic claim/evidence gate (dirge-d0e5.2). +//! +//! At finalization, a model-visible one-shot nudge fires when the final +//! answer makes a SPECIFIC claim the run's evidence does not support: +//! +//! - **Unsupported verification claim** — the answer asserts a test count or +//! named-gate result ("4954 passed", "clippy clean") while the verifier +//! recorded NO build/test command this run. +//! - **Unsupported change claim** — the answer asserts having +//! applied/fixed/changed something while zero files were mutated this run. +//! +//! Deliberately deterministic, no LLM: a pattern over "N passed" conjoined +//! with zero observed verifications cannot be talked out of or invent +//! accusations the way a judging model can. The conjunction is the control — +//! per docs/verification-discipline.md, "Over-detecting would decline good +//! verifications and nag forever, which is the same harm pointed the other +//! way." +//! +//! Carve-outs, deliberately narrow: output the model is QUOTING or +//! attributing to another actor (a pasted CI log, "CI reported", "you said") +//! is not the model's own assertion about this run, so it does not fire. Do +//! not widen them to catch more — a missed fabrication is recoverable; a +//! gate that nags on honest work gets turned off and then catches nothing. + +/// Tag prefixing the model-visible nudge, so it is greppable in transcripts. +pub(crate) const CLAIM_GATE_TAG: &str = "[claim-check]"; + +/// One-shot per run. The nudge exists to correct a claim or actually run the +/// check; a model that ignores it once will not be nagged forever. +pub(crate) const MAX_CLAIM_NUDGES: u8 = 1; + +/// Which unsupported claim fired. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClaimKind { + Verification, + Change, +} + +impl ClaimKind { + /// Body of the nudge (the tag is prefixed by the caller). Asks the model + /// to correct the claim or actually do the work — never a verdict, so it + /// cannot cause a false green. + pub(crate) fn nudge_text(self) -> &'static str { + match self { + ClaimKind::Verification => { + "Your final message asserts a verification result (a test count like \ + \"N passed\" or a named gate like \"clippy clean\"), but no build/test \ + command ran this run, so the claim is unsupported. Either actually run \ + the check and report its real output, or remove the unsupported claim." + } + ClaimKind::Change => { + "Your final message says you changed or fixed something, but no files were \ + mutated this run. Either make the change you claim, or correct the claim \ + so it matches what actually happened." + } + } + } +} + +/// The claims [`scan_final_answer`] found in the final answer text. Evidence +/// is applied separately by [`unsupported_claims`], so the scanner stays a +/// pure function of the text. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Claims { + pub verification_claim: bool, + pub change_claim: bool, +} + +/// Scan the model's final answer for concrete claims about what it ran and +/// what it changed. Quoted/attributed output is stripped first (the +/// carve-outs), so a pasted CI log or a "CI reported …" sentence never +/// counts as the model's own claim. +pub(crate) fn scan_final_answer(text: &str) -> Claims { + let unquoted = strip_quoted(text); + let sentences = split_sentences(&unquoted); + let mut claims = Claims::default(); + for sentence in sentences { + if sentence_attributes_to_another_actor(sentence) { + continue; + } + claims.verification_claim |= claims_verification(sentence); + claims.change_claim |= claims_change(sentence); + } + claims +} + +/// The deterministic conjunction: a claim with no supporting evidence from +/// this run. `ran_verification` — did the verifier observe a build/test +/// command this run. `files_mutated` — how many files the tracker recorded +/// since the run's epoch. +pub(crate) fn unsupported_claims( + claims: &Claims, + ran_verification: bool, + files_mutated: usize, +) -> Option { + if claims.verification_claim && !ran_verification { + return Some(ClaimKind::Verification); + } + if claims.change_claim && files_mutated == 0 { + return Some(ClaimKind::Change); + } + None +} + +/// Drop double-quoted and backtick-quoted spans. A pasted CI log or a +/// user-supplied transcript is someone else's output; the model quoting it is +/// not asserting it as its own run's outcome. +fn strip_quoted(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '"' || c == '`' { + let open = c; + for inner in chars.by_ref() { + if inner == open { + break; + } + } + } else { + out.push(c); + } + } + out +} + +/// Split on sentence boundaries so an attributed sentence can be dropped +/// without silencing a real claim in the same answer ("CI reported 4954 +/// passed. I then fixed the parser."). +fn split_sentences(text: &str) -> Vec<&str> { + text.split(['.', '\n', ';']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect() +} + +/// A claim scoped to another actor or to the past ("CI reported", "you +/// said") is not the model asserting this run's outcome — the carve-out +/// from the spec. +fn sentence_attributes_to_another_actor(sentence: &str) -> bool { + let lower = sentence.to_ascii_lowercase(); + const MARKERS: [&str; 9] = [ + "ci reported", + "ci says", + "ci shows", + "the ci log", + "the log shows", + "the output shows", + "you said", + "you told", + "reported by", + ]; + MARKERS.iter().any(|m| lower.contains(m)) +} + +/// A concrete verification-outcome claim: a test count ("4954 passed", +/// "12 tests passing") or a named gate ("clippy clean", "fmt clean", "all +/// green", "exit 0"). +fn claims_verification(sentence: &str) -> bool { + let lower = sentence.to_ascii_lowercase(); + // Numeric test counts: [test|tests] (passed|passing). + let bytes = lower.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let rest = &lower[i..]; + let after_digits = rest.trim_start(); + if (after_digits.starts_with("passed") + || after_digits.starts_with("passing") + || after_digits.starts_with("tests passed") + || after_digits.starts_with("test passed") + || after_digits.starts_with("tests passing") + || after_digits.starts_with("tests pass")) + && i - start >= 2 + { + return true; + } + continue; + } + i += 1; + } + const GATES: [&str; 10] = [ + "clippy clean", + "clippy is clean", + "fmt clean", + "formatted clean", + "all green", + "all tests pass", + "tests pass", + "tests passing", + "exit 0", + "exit code 0", + ]; + GATES.iter().any(|g| lower.contains(g)) +} + +/// A first-person past-tense change claim ("I fixed the parser", "I've +/// updated the config"). Present/future tense ("I will fix …") does not +/// assert that a change was already applied, so it does not count. +fn claims_change(sentence: &str) -> bool { + let lower = sentence.to_ascii_lowercase(); + const VERBS: [&str; 22] = [ + "fixed", + "applied", + "changed", + "updated", + "added", + "removed", + "implemented", + "created", + "deleted", + "wrote", + "refactored", + "renamed", + "moved", + "replaced", + "patched", + "corrected", + "edited", + "modified", + "rewrote", + "restructured", + "adjusted", + "revised", + ]; + let needs_boundary = |b: &[u8]| b.first().is_none_or(|&c| !c.is_ascii_alphanumeric()); + VERBS.iter().any(|verb| { + for prefix in ["i ", "i've ", "i have "] { + let needle = format!("{prefix}{verb}"); + let bytes = lower.as_bytes(); + let mut idx = 0; + while let Some(rel) = find_subslice(&bytes[idx..], needle.as_bytes()) { + let pos = idx + rel; + let before_ok = pos == 0 || !bytes[pos - 1].is_ascii_alphanumeric(); + let after = pos + needle.len(); + let after_ok = needs_boundary(&bytes[after..]); + if before_ok && after_ok { + return true; + } + idx = pos + needle.len(); + } + } + false + }) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scan(text: &str) -> Claims { + scan_final_answer(text) + } + + fn fires(text: &str, ran_verification: bool, files_mutated: usize) -> Option { + unsupported_claims(&scan(text), ran_verification, files_mutated) + } + + // Spec case 1: "4954 passed" with no verification command → fires. + #[test] + fn verification_claim_without_evidence_fires() { + assert_eq!( + fires("All done. 4954 passed, 0 failed.", false, 3), + Some(ClaimKind::Verification) + ); + assert_eq!( + fires("clippy clean and fmt clean.", false, 3), + Some(ClaimKind::Verification) + ); + } + + // Spec case 2: same claim WITH a verification command → silent. The + // discriminating pair with case 1; neither means anything alone. + #[test] + fn verification_claim_with_evidence_is_silent() { + assert_eq!(fires("All done. 4954 passed, 0 failed.", true, 3), None); + assert_eq!(fires("clippy clean.", true, 3), None); + } + + // Spec case 3: "I fixed the parser" with zero files mutated → fires. + #[test] + fn change_claim_without_evidence_fires() { + assert_eq!( + fires("I fixed the parser.", false, 0), + Some(ClaimKind::Change) + ); + assert_eq!( + fires("I've updated the config.", false, 0), + Some(ClaimKind::Change) + ); + } + + // Spec case 4: same claim with files mutated → silent. + #[test] + fn change_claim_with_evidence_is_silent() { + assert_eq!(fires("I fixed the parser.", false, 2), None); + } + + // Spec case 5: a quoted / attributed claim is someone else's output, not + // the model's own assertion → silent. + #[test] + fn attributed_claim_is_silent() { + assert_eq!(fires("CI reported 4954 passed.", false, 0), None); + assert_eq!(fires("You said the tests pass.", false, 0), None); + assert_eq!( + fires( + "The log shows \"clippy clean\". I fixed the parser.", + false, + 2 + ), + None + ); + } + + // An attributed sentence does not silence a REAL claim in the same + // answer — the conjunction stays honest. + #[test] + fn attributed_sentence_does_not_silence_real_claim() { + assert_eq!( + fires("CI reported 4954 passed. I fixed the parser.", false, 0), + Some(ClaimKind::Change) + ); + } + + // Past tense only: "I will fix" is not an assertion that a change + // happened. + #[test] + fn future_tense_does_not_fire() { + assert_eq!(fires("I will fix the parser next.", false, 0), None); + } + + // A plain summary with no concrete claims never fires. + #[test] + fn no_claims_is_silent() { + assert_eq!( + fires("Here is a summary of what we discussed.", false, 0), + None + ); + } + + // A quoted count ("the transcript says \"4954 passed\"") is quoting, not + // asserting — the carve-out strips the quotes. + #[test] + fn quoted_output_is_silent() { + assert_eq!( + fires("The transcript says \"4954 passed\".", false, 0), + None + ); + } +} diff --git a/src/agent/agent_loop/critic.rs b/src/agent/agent_loop/critic.rs index ad895333..a39fda0b 100644 --- a/src/agent/agent_loop/critic.rs +++ b/src/agent/agent_loop/critic.rs @@ -251,6 +251,11 @@ the latest request and the transcript.\n\ - If the assistant ended by asking the user a question or presenting options and is waiting on their \ decision, that is a CORRECT stopping point, not incompleteness — never tell it to proceed anyway, \ pick a default, or guess. Judge only the work done up to the question.\n\ +- The `--- evidence ... ---` block in the prompt is a factual record of this run. Check the \ +assistant's claims against it: a claim that names a file it changed, a command it ran, or a result \ +it produced, where the evidence shows no such thing, is an UNSUPPORTED claim — flag it with the \ +concrete mismatch. Never invent a mismatch the evidence does not show, and never flag a claim the \ +evidence supports.\n\ - Do NOT invent new requirements, scope, or \"nice to haves\". If you cannot determine correctness from \ the spec and evidence available, ABSTAIN — say what's missing (e.g. no test covering this change, \ unclear acceptance criteria). An abstention is safer than a false pass. If you are unsure whether \ @@ -329,6 +334,55 @@ fn verification_block(verification: Option) -> &'static str } } +/// Deterministic evidence about THIS run, rendered into the critic prompt so +/// the judge can check the assistant's factual claims against what actually +/// happened (dirge-d0e5.3). Complements the aggregate +/// [`VerificationStatus`] block: this names files, commands, and counts, so +/// a claim like "I applied the two awk fixes" is checkable against the file +/// list rather than only against "unverified". +#[derive(Debug, Default, Clone)] +pub struct Evidence { + /// Paths the tracker recorded as mutated since the run's epoch. + pub files_mutated: Vec, + /// Verification commands observed this run, each with whether it failed, + /// latest-first (see [`crate::agent::agent_loop::verifier`]). + pub observed_commands: Vec<(String, bool)>, + /// Tool-result messages in this finalization's message list. + pub tool_calls: usize, +} + +/// Render the evidence block for the critic prompt (dirge-d0e5.3). Empty +/// when there is no evidence to show. The block renders even when the lists +/// are empty — `(none)` is the honest signal that a claim has nothing to +/// stand on, and absence is as informative as presence. +fn evidence_block(evidence: Option<&Evidence>) -> String { + let Some(e) = evidence else { + return String::new(); + }; + let files = if e.files_mutated.is_empty() { + "(none)".to_string() + } else { + e.files_mutated.join(", ") + }; + let commands = if e.observed_commands.is_empty() { + "(none observed)".to_string() + } else { + e.observed_commands + .iter() + .map(|(cmd, failed)| format!("{cmd} — {}", if *failed { "FAILED" } else { "passed" })) + .collect::>() + .join("; ") + }; + format!( + "\n\n--- evidence of what happened this run (check the assistant's claims against this; \ + a claim naming a file, a command, or an outcome that is absent here is UNSUPPORTED) ---\n\ + files mutated: {files}\n\ + verification commands observed: {commands}\n\ + tool calls: {}\n--- end evidence ---", + e.tool_calls + ) +} + /// Classified verdict signal, strongest (most action-forcing) first. Shared by /// the critic and goal parsers so both judge the same surface form the same way /// (dirge-5mtx). @@ -493,6 +547,7 @@ pub fn build_unified_prompt( // silently dropping an unaddressed one is the opposite failure). `None`/blank // = no section. prior_findings: Option<&str>, + evidence: Option<&Evidence>, ) -> String { let rules = strip_compaction_summary(rules).trim(); let rules_block = if rules.is_empty() { @@ -523,8 +578,9 @@ pub fn build_unified_prompt( "{UNIFIED_FORMAT}\n\n\ --- assistant instructions & constraints (judge within these; never demand a \ forbidden/out-of-scope action) ---\n{rules_block}\n--- end instructions ---\n\n\ - --- transcript ---\n{transcript}\n--- end transcript ---{diff_block}{prior_findings_block}{}", - verification_block(verification) + --- transcript ---\n{transcript}\n--- end transcript ---{diff_block}{prior_findings_block}{}{}", + verification_block(verification), + evidence_block(evidence) ) } @@ -628,8 +684,16 @@ pub async fn run_unified_review( diff: Option<&str>, verification: Option, prior_findings: Option<&str>, + evidence: Option<&Evidence>, ) -> ReviewOutcome { - let prompt = build_unified_prompt(rules, transcript, diff, verification, prior_findings); + let prompt = build_unified_prompt( + rules, + transcript, + diff, + verification, + prior_findings, + evidence, + ); let response = run_judge!( judge, prompt, @@ -663,6 +727,63 @@ pub async fn run_unified_review( mod tests { use super::*; + /// dirge-d0e5.3 test 8: the evidence block must reach the critic prompt + /// with REAL values — mutated file names, observed verification commands + /// with outcomes, and the tool-call count — so the judge can check the + /// assistant's factual claims against what actually happened. + #[test] + fn evidence_block_reaches_prompt_with_real_values() { + let evidence = Evidence { + files_mutated: vec!["src/agent/loop.rs".to_string(), "Cargo.toml".to_string()], + observed_commands: vec![ + ("cargo test".to_string(), false), + ("cargo clippy".to_string(), true), + ], + tool_calls: 9, + }; + let p = build_unified_prompt("", "t", None, None, None, Some(&evidence)); + assert!( + p.contains("src/agent/loop.rs"), + "mutated file must be named" + ); + assert!(p.contains("Cargo.toml"), "mutated file must be named"); + assert!(p.contains("cargo test"), "observed command must be named"); + assert!(p.contains("FAILED"), "a failed command must be marked"); + assert!(p.contains("passed"), "a passing command must be marked"); + assert!( + p.contains("tool calls: 9"), + "tool-call count must be present" + ); + assert!( + p.contains("UNSUPPORTED"), + "the block must say what an absent fact means" + ); + // No evidence → no block; empty-but-present evidence renders the + // honest `(none)` markers rather than a gap. + assert_eq!(evidence_block(None), ""); + let empty = build_unified_prompt("", "t", None, None, None, Some(&Evidence::default())); + assert!( + empty.contains("(none)"), + "empty evidence must be marked, not elided" + ); + } + + /// dirge-d0e5.3 test 9: the system preamble must ask the critic to check + /// the assistant's claims against the evidence block. Style mirrors + /// `preamble_is_calibrated_and_constraint_aware`. + #[test] + fn preamble_asks_for_claim_check_against_evidence() { + let lower = CRITIC_PREAMBLE.to_ascii_lowercase(); + assert!( + lower.contains("evidence"), + "preamble must point the critic at the evidence block" + ); + assert!( + lower.contains("unsupported") || lower.contains("does not show"), + "preamble must tell the critic to flag unsupported claims" + ); + } + /// dirge-uw2l.2: the fast-green-only block must name the gap (the full /// suite never ran) while keeping the same calibrated escape hatch as /// the `Unverified` block — it is a nudge, not a hard rule, so a project @@ -695,7 +816,7 @@ mod tests { transcript: &str, verification: Option, ) -> String { - build_unified_prompt(rules, transcript, None, verification, None) + build_unified_prompt(rules, transcript, None, verification, None, None) } #[test] @@ -1080,6 +1201,7 @@ mod tests { None, Some(VerificationStatus::Unverified), None, + None, ) .await; let prompt = seen.lock().unwrap().clone(); @@ -1098,7 +1220,7 @@ mod tests { let judge: CriticFn = Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE\nFINDINGS: none".to_string()) })); assert!( - run_unified_review(&judge, "rules", "did stuff", None, None, None) + run_unified_review(&judge, "rules", "did stuff", None, None, None, None) .await .messages .is_empty() @@ -1205,10 +1327,11 @@ mod tests { Some("@@ -1 +1 @@\n-a\n+b"), None, None, + None, ); assert!(with.contains("diff under review")); assert!(with.contains("+b")); - let without = build_unified_prompt("rules", "did stuff", None, None, None); + let without = build_unified_prompt("rules", "did stuff", None, None, None, None); assert!(!without.contains("diff under review")); // Both carry the combined verdict+findings format contract. assert!(with.contains("FINDINGS:")); @@ -1225,6 +1348,7 @@ mod tests { Some("@@ -1 +1 @@\n-a\n+b"), None, None, + None, ); assert!(!p.contains("earlier review")); assert!(p.contains("diff under review")); @@ -1233,7 +1357,14 @@ mod tests { #[test] fn unified_prompt_omits_prior_findings_section_when_blank() { // A whitespace-only string carries no real findings — still no section. - let p = build_unified_prompt("rules", "did stuff", Some("diff"), None, Some(" \n ")); + let p = build_unified_prompt( + "rules", + "did stuff", + Some("diff"), + None, + Some(" \n "), + None, + ); assert!(!p.contains("earlier review")); } @@ -1245,6 +1376,7 @@ mod tests { Some("diff"), None, Some("- High — sql injection"), + None, ); assert!( p.contains("earlier review"), @@ -1268,7 +1400,7 @@ mod tests { async fn run_unified_review_fails_open_on_error() { let judge: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("provider down") })); assert!( - run_unified_review(&judge, "rules", "did stuff", Some("diff"), None, None) + run_unified_review(&judge, "rules", "did stuff", Some("diff"), None, None, None) .await .messages .is_empty(), @@ -1424,7 +1556,7 @@ mod tests { #[tokio::test] async fn judge_error_is_not_reported_as_judged() { let judge: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("provider down") })); - let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None).await; + let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None, None).await; assert!(!out.judged, "a failed call must not claim to have judged"); assert!( out.messages.is_empty(), @@ -1437,7 +1569,7 @@ mod tests { async fn clean_review_is_reported_as_judged() { let judge: CriticFn = Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE".to_string()) })); - let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None).await; + let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None, None).await; assert!(out.judged, "a real response must count as judged"); assert!( out.messages.is_empty(), @@ -1451,8 +1583,8 @@ mod tests { async fn error_and_clean_review_differ_only_in_judged() { let err: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("down") })); let ok: CriticFn = Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE".to_string()) })); - let a = run_unified_review(&err, "", "t", Some("d"), None, None).await; - let b = run_unified_review(&ok, "", "t", Some("d"), None, None).await; + let a = run_unified_review(&err, "", "t", Some("d"), None, None, None).await; + let b = run_unified_review(&ok, "", "t", Some("d"), None, None, None).await; assert_eq!(a.messages.len(), b.messages.len()); assert_eq!(a.raised_findings, b.raised_findings); assert_ne!(a.judged, b.judged, "only `judged` separates them"); diff --git a/src/agent/agent_loop/gate_state.rs b/src/agent/agent_loop/gate_state.rs index c6346075..91275d2b 100644 --- a/src/agent/agent_loop/gate_state.rs +++ b/src/agent/agent_loop/gate_state.rs @@ -76,6 +76,18 @@ pub struct GateStates { /// **Re-fire guard.** Bounded by `MAX_RESUME_NUDGE`. pub resume_nudges: u8, + /// **Re-fire guard.** Bounded by `claim_gate::MAX_CLAIM_NUDGES` + /// [dirge-d0e5.2]. No LLM call — the deterministic claim/evidence gate. + pub claim_nudges: u8, + + /// **Memo,** not a bound. The run-start [`crate::agent::tools::modified::epoch`] + /// captured when this `GateStates` was constructed, so the claim gate can + /// ask "how many files were mutated THIS run" via + /// [`crate::agent::tools::modified::since`] instead of "ever". Zero is the + /// inert `Default` value (tests that construct directly); the production + /// `run_loop` stamps it at construction. + pub run_epoch: u64, + /// **Re-fire guard.** Bounded by `MAX_OPEN_ISSUES_NUDGES` [dirge-ksjl]. /// The gate reads the issue DB, not a provider. pub open_issues_nudges: u8, diff --git a/src/agent/agent_loop/gate_tally.rs b/src/agent/agent_loop/gate_tally.rs index 6f6a95b9..5c8e91ce 100644 --- a/src/agent/agent_loop/gate_tally.rs +++ b/src/agent/agent_loop/gate_tally.rs @@ -38,6 +38,8 @@ pub enum GateSource { Hook, ResumeAfterFailure, Verifier, + /// Deterministic claim/evidence gate (dirge-d0e5.2). No LLM call. + ClaimGate, Critic, Goal, Todo, @@ -62,6 +64,17 @@ pub enum BoundaryNudge { None, } +/// One member of a boundary co-occurrence event, in the order it was +/// recorded. A boundary is one decision point in the loop — the boundary +/// nudge poll at a turn's start, or the finalization gate poll — and every +/// gate and nudge that fires there becomes one event (dirge-1elu.6). +/// Observation only: nothing in the loop reads these back. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoundaryMember { + Gate(GateSource), + Nudge(BoundaryNudge), +} + impl GateSource { fn index(self) -> usize { match self { @@ -69,11 +82,12 @@ impl GateSource { GateSource::Hook => 1, GateSource::ResumeAfterFailure => 2, GateSource::Verifier => 3, - GateSource::Critic => 4, - GateSource::Goal => 5, - GateSource::Todo => 6, - GateSource::OpenIssues => 7, - GateSource::None => 8, + GateSource::ClaimGate => 4, + GateSource::Critic => 5, + GateSource::Goal => 6, + GateSource::Todo => 7, + GateSource::OpenIssues => 8, + GateSource::None => 9, } } } @@ -99,7 +113,7 @@ impl BoundaryNudge { /// discard. #[derive(Clone, Debug, Default)] pub struct GateTally { - gates: [u32; 9], + gates: [u32; 10], nudges: [u32; 9], turns: u32, tool_calls: u32, @@ -114,7 +128,17 @@ pub struct GateTally { scavenged_calls: u32, hallucinated_tool_names: u32, storm_suppressions: u32, + /// Peak failure streak over the run. max_failure_streak: u32, + /// dirge-1elu.6: completed boundary co-occurrence events, in run order. + /// Each event lists the gates and nudges that fired at one decision + /// point. OBSERVATION ONLY — no loop logic reads this back. + boundaries: Vec>, + /// Members recorded since [`begin_boundary`](Self::begin_boundary), + /// flushed by [`end_boundary`](Self::end_boundary). + open_boundary: Vec, + /// True between `begin_boundary` and `end_boundary`. + in_boundary: bool, } impl GateTally { @@ -122,12 +146,78 @@ impl GateTally { Self::default() } + /// Open a boundary event. Subsequent [`record_gate`](Self::record_gate) + /// / [`record_nudge`](Self::record_nudge) calls that pass a non-`None` + /// variant are attributed to it, in order, until + /// [`end_boundary`](Self::end_boundary) flushes it as one co-occurrence + /// event. A duplicate open while already inside is a no-op. Observation + /// only — the per-gate/per-nudge totals are untouched. + pub fn begin_boundary(&mut self) { + if !self.in_boundary { + self.in_boundary = true; + self.open_boundary.clear(); + } + } + + /// Close the boundary opened by + /// [`begin_boundary`](Self::begin_boundary). A boundary with no members + /// is dropped. Totals are untouched. + pub fn end_boundary(&mut self) { + if self.in_boundary { + self.in_boundary = false; + if !self.open_boundary.is_empty() { + self.boundaries + .push(std::mem::take(&mut self.open_boundary)); + } + } + } + + /// The completed boundary events, in run order (observation surface). + /// + /// Test-only: production scrapes the same data off the `dirge::gates` + /// line via [`Self::boundaries_encoding`], so an ungated accessor here + /// is dead code in a release build. + #[cfg(test)] + pub fn boundaries(&self) -> &[Vec] { + &self.boundaries + } + + /// The events as a scrapeable string: events joined by `;`, co-firing + /// members by `+`, each member its `Debug` name — e.g. + /// `Verifier+Critic;Goal`. `none` when no event fired (a stable + /// placeholder like `capability_tier`). This is exactly the value of the + /// `boundaries=` field on the `dirge::gates` line. + pub fn boundaries_encoding(&self) -> String { + if self.boundaries.is_empty() { + return "none".to_string(); + } + self.boundaries + .iter() + .map(|event| { + event + .iter() + .map(|m| match m { + BoundaryMember::Gate(g) => format!("{g:?}"), + BoundaryMember::Nudge(n) => format!("{n:?}"), + }) + .collect::>() + .join("+") + }) + .collect::>() + .join(";") + } + /// Record a fired gate. The `None` variant ("no gate fired") is a no-op. pub fn record_gate(&mut self, gate: GateSource) { if gate == GateSource::None { return; } self.gates[gate.index()] += 1; + // dirge-1elu.6: also attribute the fire to the open boundary, in + // order. Observation only — the totals above are the authority. + if self.in_boundary { + self.open_boundary.push(BoundaryMember::Gate(gate)); + } } /// Record a fired boundary nudge. The `None` variant is a no-op. @@ -136,6 +226,11 @@ impl GateTally { return; } self.nudges[nudge.index()] += 1; + // dirge-1elu.6: attribute the fire to the open boundary, in order. + // Observation only — the totals above are the authority. + if self.in_boundary { + self.open_boundary.push(BoundaryMember::Nudge(nudge)); + } } pub fn record_turn(&mut self) { @@ -252,9 +347,11 @@ impl GateTally { // Stable placeholder when unset, so the log line keeps one shape and // a scraper never has to cope with a missing field. let capability_tier = self.capability_tier.map_or("none", |t| t.as_str()); + let boundaries = self.boundaries_encoding(); tracing::info!( target: "dirge::gates", capability_tier = %capability_tier, + boundaries = %boundaries, turns = self.turns, tool_calls = self.tool_calls, errored_tool_calls = self.errored_tool_calls, @@ -292,7 +389,7 @@ impl GateTally { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::agent::agent_loop::verifier::VerificationStatus; @@ -452,4 +549,166 @@ mod tests { tally.record_failure_streak(2); assert_eq!(tally.max_failure_streak(), 5); } + // ---- dirge-1elu.6: boundary co-occurrence — observation only --------- + + /// dirge-1elu.6 test 1: gates that fire at the SAME boundary become ONE + /// co-occurrence event, and the tally line says so. + #[test] + fn co_firing_gates_at_one_boundary_are_one_event() { + let mut tally = GateTally::new(); + tally.begin_boundary(); + tally.record_gate(GateSource::Verifier); + tally.record_gate(GateSource::Critic); + tally.end_boundary(); + assert_eq!(tally.boundaries_encoding(), "Verifier+Critic"); + assert_eq!( + tally.boundaries(), + &[vec![ + BoundaryMember::Gate(GateSource::Verifier), + BoundaryMember::Gate(GateSource::Critic), + ]] + ); + let line = capture_emit(&tally); + assert!( + line.contains("boundaries=Verifier+Critic"), + "the tally line must say so: {line}" + ); + } + + /// dirge-1elu.6 test 2: the same gates at DIFFERENT boundaries are two + /// events — co-firing is distinguishable from mere co-presence in a run. + #[test] + fn same_gates_at_different_boundaries_are_distinct_events() { + let mut tally = GateTally::new(); + tally.begin_boundary(); + tally.record_gate(GateSource::Verifier); + tally.end_boundary(); + tally.begin_boundary(); + tally.record_gate(GateSource::Critic); + tally.end_boundary(); + assert_eq!(tally.boundaries_encoding(), "Verifier;Critic"); + assert_ne!(tally.boundaries_encoding(), "Verifier+Critic"); + } + + /// dirge-1elu.6 test 3: boundary bookkeeping leaves the existing per-run + /// totals byte-identical — a bracketed and an unbracketed tally emit the + /// same line except for the new `boundaries=` field. + #[test] + fn boundary_bracketing_leaves_per_run_totals_identical() { + let mut bracketed = GateTally::new(); + let mut plain = GateTally::new(); + for (gate, nudge) in [ + (GateSource::Verifier, BoundaryNudge::TrackWork), + (GateSource::Critic, BoundaryNudge::SafeState), + (GateSource::Todo, BoundaryNudge::FastVerify), + ] { + bracketed.begin_boundary(); + bracketed.record_gate(gate); + bracketed.record_nudge(nudge); + bracketed.end_boundary(); + plain.record_gate(gate); + plain.record_nudge(nudge); + } + bracketed.record_failure_streak(4); + plain.record_failure_streak(4); + + // Every pre-existing read surface is unchanged by the bracketing. + assert_eq!( + bracketed.gate_count(GateSource::Verifier), + plain.gate_count(GateSource::Verifier) + ); + assert_eq!( + bracketed.nudge_count(BoundaryNudge::TrackWork), + plain.nudge_count(BoundaryNudge::TrackWork) + ); + assert_eq!(bracketed.max_failure_streak(), plain.max_failure_streak()); + assert_eq!(bracketed.turns(), plain.turns()); + assert_eq!(bracketed.tool_calls(), plain.tool_calls()); + + // The emitted lines differ ONLY in the boundaries field. + let b = strip_field(&capture_emit(&bracketed), "boundaries"); + let p = strip_field(&capture_emit(&plain), "boundaries"); + assert_eq!(b, p, "pre-existing fields must be byte-identical"); + + assert_eq!( + bracketed.boundaries_encoding(), + "Verifier+TrackWork;Critic+SafeState;Todo+FastVerify" + ); + assert_eq!(plain.boundaries_encoding(), "none"); + } + + /// dirge-1elu.6: a boundary that closes with nothing recorded drops no + /// event, and recording outside a boundary never pollutes the events. + #[test] + fn empty_and_unbracketed_recording_produce_no_events() { + let mut tally = GateTally::new(); + tally.begin_boundary(); + tally.end_boundary(); // nothing fired at this boundary + tally.record_gate(GateSource::Verifier); // not inside a boundary + assert_eq!(tally.boundaries_encoding(), "none"); + assert!(tally.boundaries().is_empty()); + } + + // ---- tracing capture helpers ---------------------------------------- + + /// The `dirge::gates` line for a tally, rendered by a real subscriber + /// (the fmt layer), so the tests assert on the actual emit output. + fn capture_emit(tally: &GateTally) -> String { + let (cap, _guard) = field_capture(); + tally.emit(); + cap.snapshot() + } + + /// A capture writer + subscriber for the `dirge::gates` line. Shared with + /// run_tests (dirge-1elu.6 test 4). Renders through the real fmt layer, + /// so `%field` values appear exactly as they do in production logs. + #[derive(Clone, Default)] + pub(crate) struct FieldCapture { + buf: std::sync::Arc>>, + } + + impl FieldCapture { + pub(crate) fn snapshot(&self) -> String { + String::from_utf8_lossy(&self.buf.lock().unwrap()).to_string() + } + } + + impl std::io::Write for FieldCapture { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.buf.lock().unwrap().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl tracing_subscriber::fmt::MakeWriter<'_> for FieldCapture { + type Writer = Self; + fn make_writer(&self) -> Self::Writer { + self.clone() + } + } + + /// A fresh capture subscriber; the guard keeps it installed for the + /// caller's scope. + pub(crate) fn field_capture() -> (FieldCapture, tracing::subscriber::DefaultGuard) { + let cap = FieldCapture::default(); + let sub = tracing_subscriber::fmt() + .with_writer(cap.clone()) + .without_time() + .with_ansi(false) + .finish(); + let guard = tracing::subscriber::set_default(sub); + (cap, guard) + } + + /// Drop one key=value pair from a rendered line, for byte-comparison. + fn strip_field(line: &str, key: &str) -> String { + let prefix = format!("{key}="); + line.split_whitespace() + .filter(|tok| !tok.starts_with(&prefix)) + .collect::>() + .join(" ") + } } diff --git a/src/agent/agent_loop/h7_smoke.rs b/src/agent/agent_loop/h7_smoke.rs index ccad7fc4..eed0bca2 100644 --- a/src/agent/agent_loop/h7_smoke.rs +++ b/src/agent/agent_loop/h7_smoke.rs @@ -55,10 +55,10 @@ fn detect_provider() -> Option<&'static str> { /// cheap / fast models so smoke tests don't burn budget. fn default_model(provider: &str) -> &'static str { match provider { - "deepseek" => "deepseek-chat", + "deepseek" => "deepseek-v4-flash", "anthropic" => "claude-haiku-4-5-20251001", "openai" => "gpt-4o-mini", - "openrouter" => "deepseek/deepseek-chat", + "openrouter" => "deepseek/deepseek-v4-flash", _ => "gpt-4o-mini", } } @@ -139,21 +139,56 @@ async fn drain_to_done( /// people to ignore a red suite. (GLM answers an exhausted 5-hour window /// with HTTP 429 code 1308; `classify_error` already recognizes it, so /// this reuses that classifier rather than matching provider strings -/// here.) Genuine failures — [`ErrorKind::Other`], a wrong answer, a -/// missing tool call — still fail as before. +/// here.) A provider that rejects the request because the model name +/// doesn't exist (retired upstream) is the same prerequisite class — keyed +/// on the provider's own rejection wording via +/// [`model_rejected_by_provider`], never on what the test asserts. +/// Genuine failures — [`ErrorKind::Other`], a wrong answer, a missing tool +/// call — still fail as before. fn provider_unavailable(events: &[AgentEvent]) -> Option { use crate::agent::recovery::{ErrorKind, classify_error}; events.iter().find_map(|e| match e { - AgentEvent::Error(msg) => match classify_error(msg) { - ErrorKind::UsageCap | ErrorKind::RateLimit | ErrorKind::Auth | ErrorKind::Network => { - Some(msg.to_string()) + AgentEvent::Error(msg) => { + let text = msg.to_string(); + if model_rejected_by_provider(&text) { + return Some(text); } - _ => None, - }, + match classify_error(&text) { + ErrorKind::UsageCap + | ErrorKind::RateLimit + | ErrorKind::Auth + | ErrorKind::Network => Some(text), + _ => None, + } + } _ => None, }) } +/// True when the provider itself rejected the request because the model +/// name/id doesn't exist — retired upstream or never valid. That is a +/// PREREQUISITE that isn't met, not a finding about dirge's loop: no code +/// change can make a retired model name work, so failing the suite for it +/// trains people to ignore a red suite (the same class of harm as a quota +/// ceiling). +/// +/// Keyed on the provider's OWN rejection wording — the provider telling us +/// the model doesn't exist — never on what the test asserts, so a response +/// missing the expected content still fails. Deliberately narrower than a +/// generic 4xx: a bare `400 Bad Request` with no model-name wording is our +/// bug and must still fail. +fn model_rejected_by_provider(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("model") + && (lower.contains("does not exist") + || lower.contains("doesn't exist") + || lower.contains("not found") + || lower.contains("unknown model") + || lower.contains("unsupported model") + || lower.contains("model names are") + || lower.contains("model not supported")) +} + /// Skip-guard wrapper: prints the standard `[skipped]` line and returns /// true when the provider was unavailable. Every scenario calls this /// immediately after draining, BEFORE asserting on the response — an @@ -163,7 +198,9 @@ fn skip_if_provider_unavailable(events: &[AgentEvent]) -> bool { match provider_unavailable(events) { Some(msg) => { let brief: String = msg.chars().take(160).collect(); - eprintln!("[skipped] provider unavailable (quota/rate-limit/auth/network): {brief}"); + eprintln!( + "[skipped] provider unavailable (quota/rate-limit/auth/network/model-not-found): {brief}" + ); true } None => false, @@ -283,6 +320,8 @@ async fn h7_scenario_1_simple_text() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -377,6 +416,8 @@ async fn h7_scenario_2_turn_boundaries() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -505,6 +546,8 @@ async fn h7_scenario_5_auth_error_surfaces() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -686,6 +729,8 @@ async fn h7_scenario_3_tool_dispatch() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -727,8 +772,18 @@ async fn h7_scenario_3_tool_dispatch() { ); let final_resp = response.unwrap_or_default(); assert!( - final_resp.to_lowercase().contains("pineapple"), - "expected final response to reference 'pineapple'; got: {final_resp:?}" + !final_resp.trim().is_empty(), + "expected a final assistant turn after the tool round trip; got an empty response" + ); + let last_tool_result = events + .iter() + .rposition(|e| matches!(e, AgentEvent::ToolResult { .. })); + let done_pos = events + .iter() + .position(|e| matches!(e, AgentEvent::Done { .. })); + assert!( + last_tool_result.is_some_and(|tr| done_pos.is_some_and(|done| tr < done)), + "expected the final assistant turn to follow the completed tool result" ); } @@ -818,6 +873,8 @@ async fn h7_glm_scenario_1_simple_text() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -968,6 +1025,8 @@ async fn h7_glm_scenario_3_tool_dispatch() { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -994,8 +1053,18 @@ async fn h7_glm_scenario_3_tool_dispatch() { assert_eq!(tool_calls, tool_results, "call/result count mismatch"); let final_resp = response.unwrap_or_default(); assert!( - final_resp.to_lowercase().contains("pineapple"), - "expected 'pineapple' in final response; got: {final_resp:?}" + !final_resp.trim().is_empty(), + "expected a final assistant turn after the tool round trip; got an empty response" + ); + let last_tool_result = events + .iter() + .rposition(|e| matches!(e, AgentEvent::ToolResult { .. })); + let done_pos = events + .iter() + .position(|e| matches!(e, AgentEvent::Done { .. })); + assert!( + last_tool_result.is_some_and(|tr| done_pos.is_some_and(|done| tr < done)), + "expected the final assistant turn to follow the completed tool result" ); } @@ -1060,6 +1129,8 @@ fn cerebras_spawn_config( open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -1211,8 +1282,18 @@ async fn h7_cerebras_tool_dispatch_completes_round_trip() { assert_eq!(tool_calls, tool_results, "every tool call must complete"); let final_response = response.unwrap_or_default(); assert!( - final_response.to_ascii_lowercase().contains("pineapple"), - "expected final response to use the completed tool result: {final_response:?}", + !final_response.trim().is_empty(), + "expected a final assistant turn after the tool round trip; got an empty response" + ); + let last_tool_result = events + .iter() + .rposition(|e| matches!(e, AgentEvent::ToolResult { .. })); + let done_pos = events + .iter() + .position(|e| matches!(e, AgentEvent::Done { .. })); + assert!( + last_tool_result.is_some_and(|tr| done_pos.is_some_and(|done| tr < done)), + "expected the final assistant turn to follow the completed tool result" ); } @@ -1257,6 +1338,28 @@ mod skip_guard_tests { ); } + /// A provider that rejects the request because the model name doesn't + /// exist (retired upstream or typo'd) is a prerequisite miss, not a + /// dirge defect — it must skip, not fail. + #[test] + fn model_rejection_skips() { + assert!( + provider_unavailable(&err( + "The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed deepseek-chat." + )) + .is_some(), + "a retired deepseek model name must skip" + ); + assert!( + provider_unavailable(&err("The model `gpt-oss-120b` does not exist")).is_some(), + "an unknown model id must skip" + ); + assert!( + provider_unavailable(&err("Model not found: unknown-model")).is_some(), + "a model-not-found response must skip" + ); + } + /// The guard must NOT swallow a real defect. An unclassified provider /// error, or a run that simply produced no Error event, still fails — /// otherwise the smoke tests would be green no matter what broke. diff --git a/src/agent/agent_loop/integration.rs b/src/agent/agent_loop/integration.rs index a1be138e..de7c1f07 100644 --- a/src/agent/agent_loop/integration.rs +++ b/src/agent/agent_loop/integration.rs @@ -465,9 +465,15 @@ pub struct LoopSpawnConfig { /// Forwarded to `LoopConfig.safe_state_abort_mode`. Default `Off` /// (opt-in; off is byte-identical to the loop without the rung). pub safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode, + /// Forwarded to `LoopConfig.publish_guard_mode`. Default `Off` + /// (opt-in; off is byte-identical to the loop without the guard). + pub publish_guard_mode: crate::agent::agent_loop::types::GateMode, /// Active session id forwarded to `LoopConfig.session_id` for /// session-scoped gate queries. `None` in sub-runners. + /// Forwarded to `LoopConfig.claim_gate_mode`. Default `Off` + /// (dirge-d0e5.2; the gate is opt-in and off is byte-identical). + pub claim_gate_mode: crate::agent::agent_loop::types::GateMode, pub session_id: Option, /// Goal gate's judge callback, threaded into `LoopConfig.goal_fn`. @@ -540,6 +546,8 @@ impl LoopSpawnConfig { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -628,6 +636,8 @@ pub fn spawn_loop_runner(cfg: LoopSpawnConfig) -> LoopRunner { open_issues_gate_mode: cfg.open_issues_gate_mode, verification_tiers_mode: cfg.verification_tiers_mode, safe_state_abort_mode: cfg.safe_state_abort_mode, + publish_guard_mode: cfg.publish_guard_mode, + claim_gate_mode: cfg.claim_gate_mode, session_id: cfg.session_id.clone(), goal_fn: cfg.goal_fn.clone(), goal: cfg.goal.clone(), diff --git a/src/agent/agent_loop/integration_tests.rs b/src/agent/agent_loop/integration_tests.rs index 5e8d1c48..a0929c05 100644 --- a/src/agent/agent_loop/integration_tests.rs +++ b/src/agent/agent_loop/integration_tests.rs @@ -94,6 +94,8 @@ fn build_config() -> LoopConfig { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, diff --git a/src/agent/agent_loop/mod.rs b/src/agent/agent_loop/mod.rs index 5b5d9370..4a7a4019 100644 --- a/src/agent/agent_loop/mod.rs +++ b/src/agent/agent_loop/mod.rs @@ -24,6 +24,7 @@ pub mod activity; pub mod bridge; pub mod capability; +pub mod claim_gate; pub mod code_review; pub mod context_depth; pub mod context_manager; @@ -46,6 +47,7 @@ pub mod plugin_hooks; #[cfg(all(test, feature = "plugin"))] mod plugin_hooks_tests; pub mod progress; +pub mod publish_guard; pub mod reflexion; pub mod residual; pub mod result; diff --git a/src/agent/agent_loop/plugin_hooks_tests.rs b/src/agent/agent_loop/plugin_hooks_tests.rs index d43e8e5a..44ae7192 100644 --- a/src/agent/agent_loop/plugin_hooks_tests.rs +++ b/src/agent/agent_loop/plugin_hooks_tests.rs @@ -108,6 +108,8 @@ fn build_config() -> LoopConfig { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, diff --git a/src/agent/agent_loop/publish_guard.rs b/src/agent/agent_loop/publish_guard.rs new file mode 100644 index 00000000..0d155161 --- /dev/null +++ b/src/agent/agent_loop/publish_guard.rs @@ -0,0 +1,1051 @@ +//! Publish-state guard — the interlock behind prompts/default.md's "don't +//! destroy verified work" advice (dirge-1elu.1). +//! +//! The seed prompt for the AHE run already carried that advice — it was the +//! same prose dirge has today — and the paper measured that it did not work: +//! three of the four winning iterations were execution-time interlocks, not +//! better wording (arXiv:2604.25850v4 §C.1.2, §C.1.3, §C.1.4, §C.2.2, §C.2.3, +//! §C.2.4). `path-tracing` verified its output, then `rm -rf`'d it as a +//! tidy-up and reported success on the delete's exit code; `mcmc-sampling-stan` +//! killed its own generator script; `configure-git-webserver` reset a working +//! deployment "to leave a clean repo for grading". The load-bearing shape was +//! iteration 8's: a HARD block with no override token on non-/tmp protected +//! targets. The overrideable iteration-5 shape leaked — the agent attached the +//! token and the delete went through. +//! +//! This guard arms at the moment the loop observes fresh-green, with the SAME +//! `worktree_probe::TreeFingerprint` the safe-state rung stamps — one source +//! of truth for "what this run changed at the green moment". The protected set +//! is the fingerprint's keys: every file differing from HEAD at green, +//! including `bash`-mutated files the snapshot registry never sees and the +//! generator-script case (`gen.py` and its `out.json` both differ from HEAD, +//! so both are protected — no script-name extraction needed). A later +//! fresh-green REPLACES the set; going stale (an edit after green) does not +//! clear it — previously verified work is still work worth not destroying. +//! +//! Only operations that DISCARD verified work are intercepted: `rm`/`rm -rf` +//! naming a protected path or a directory containing one, `find -delete` +//! where `` contains one, the discarding git verbs (`reset --hard`, +//! `checkout -f`, `checkout -- `, `clean -f`, `stash`/`push`), and +//! `> ` / `truncate` on one. Ordinary modification of a protected +//! file — `write`, `edit`, `sed -i`, appends — is deliberately NOT blocked: +//! the paper's one-shot setting froze the deliverable after verification, but +//! dirge's interactive session treats continuing to edit verified code as +//! ordinary work, and blocking it would nag on every edit-test-edit cycle +//! (docs/verification-discipline.md's over-detection failure). The transferable +//! core is *discarding* verified work, not *modifying* it. +//! +//! Anything under a temp dir is never blocked (the paper carved out `/tmp` +//! too); paths not in the protected set are never blocked; before any green +//! has latched nothing is ever blocked. `Off` (the default) is byte-identical +//! to the loop without the guard. `advisory` injects a model-visible warning +//! naming the protected paths, bounded at 2 per run; `blocking` suppresses the +//! call pre-dispatch (storm's mechanism) and returns an error result naming +//! the paths and suggesting a scratch copy under /tmp. There is no override +//! token: the paper measured the overrideable guard leaking, and `advisory` / +//! `off` are the escape hatches — the user's to set. +//! +//! Self-contained — no rig/LLM state. Owned as a local in `run_loop`. + +use super::tools::ToolCall; +use super::types::GateMode; +use super::worktree_probe::TreeFingerprint; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +/// Advisory-mode ceiling: at most this many model-visible warnings per run. +/// Matches the tier-ceiling convention (two safe-state aborts, two nudges…). +pub const MAX_PUBLISH_ADVISORIES: u8 = 2; + +/// Display tag prefixing every message the guard injects. The UI keys on this +/// to attribute the message to the system; [`emit_harness_notices`] +/// (run.rs) mirrors tagged user messages to a SystemNotice for headless +/// consumers, so a `--print` run surfaces the guard's warning too. +pub const PUBLISH_GUARD_TAG: &str = "[publish-guard]"; + +/// Outcome of `PublishGuard::inspect` for one tool call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PublishVerdict { + /// Nothing at risk — let the call through. Also the verdict for `off` + /// mode, before any green, and after the advisory budget is spent. + Pass, + /// The command would discard verified work. In `blocking` mode + /// `block == true` (suppress the call); in `advisory` mode `block == + /// false` (let it run, but inject a warning first). + Hit { + block: bool, + /// Protected repo-relative paths the command would discard, sorted. + protected: Vec, + /// Short human reason naming the operation, e.g. `rm -rf out.json`. + reason: String, + }, +} + +/// Per-run state for the publish guard. Owned as a local in `run_loop`, +/// persists across the outer (turn) loop so a green point from an earlier +/// turn still protects its files later. +#[derive(Debug, Default)] +pub struct PublishGuard { + /// Repo-relative paths protected by the most recent fresh-green. `None` + /// until a green has been seen this run. + protected: Option>, + /// Repo root the fingerprint was taken against, for resolving absolute + /// paths in commands. `None` when the tree wasn't a git work tree. + repo_root: Option, + /// Advisory warnings already injected this run. Bounded by + /// [`MAX_PUBLISH_ADVISORIES`]; once spent, advisory-mode hits are silent. + advisories_emitted: u8, +} + +impl PublishGuard { + pub fn new() -> Self { + Self::default() + } + + /// Arm (or re-arm) the protected set from the fingerprint taken at the + /// same green moment the safe-state rung stamps. A later fresh-green + /// REPLACES the set; going stale never clears it. + pub fn arm(&mut self, fp: Option, repo_root: Option) { + self.protected = fp.map(|f| f.into_keys().collect()); + self.repo_root = repo_root; + } + + /// Pre-dispatch decision for one tool call (dirge-1elu.1). `Off` is a + /// pure pass-through; `blocking` hard-blocks without an override; + /// `advisory` warns (bounded) and lets the call run. + pub fn inspect(&mut self, mode: GateMode, call: &ToolCall) -> PublishVerdict { + if mode == GateMode::Off { + return PublishVerdict::Pass; + } + let Some(protected) = self.protected.as_ref() else { + return PublishVerdict::Pass; + }; + if protected.is_empty() { + return PublishVerdict::Pass; + } + let Some(discard) = detect_discard(call, protected, self.repo_root.as_deref()) else { + return PublishVerdict::Pass; + }; + match mode { + GateMode::Blocking => PublishVerdict::Hit { + block: true, + protected: discard.protected, + reason: discard.reason, + }, + GateMode::Advisory => { + if self.advisories_emitted >= MAX_PUBLISH_ADVISORIES { + return PublishVerdict::Pass; + } + self.advisories_emitted += 1; + PublishVerdict::Hit { + block: false, + protected: discard.protected, + reason: discard.reason, + } + } + GateMode::Off => unreachable!("guarded above"), + } + } +} + +/// A detected discard of protected work. +struct Discard { + /// The protected paths at risk (repo-relative, sorted, deduped). + protected: Vec, + /// Short reason, e.g. `git reset --hard`. + reason: String, +} + +/// Parse one tool call for a command that would discard verified work. +/// Only `bash` calls are inspected — git, rm, find and friends all flow +/// through the shell. The dedicated `verify` gate never appears here, and a +/// bare re-run of a test command discards nothing, so re-verification is +/// never caught. +fn detect_discard( + call: &ToolCall, + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + if call.name != "bash" { + return None; + } + let command = call.arguments.get("command")?.as_str()?; + // Tokenize once, then split into segments on the shell separators + // (`&&`, `||`, `;`, `|`). Checking each segment independently means + // `a && rm out.json` still catches the rm, and a quoted `|` (inside + // `'...'`/`"..."`) arrives as one token and never splits. + let tokens = tokenize(command); + let mut seg: Vec<&String> = Vec::new(); + let check = |seg: &[&String]| -> Option { check_segment(seg, protected, repo_root) }; + for tok in &tokens { + if matches!(tok.as_str(), "&&" | "||" | ";" | "|" | "\n") { + if let Some(d) = check(&seg) { + return Some(d); + } + seg.clear(); + } else { + seg.push(tok); + } + } + check(&seg) +} + +/// Check a single command segment (one argv vector). Leading wrapper words +/// (`sudo`, `command`, `nohup`) are unwrapped so `sudo rm …` is still seen +/// as an rm. +fn check_segment( + tokens: &[&String], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let mut rest = tokens; + while matches!( + rest.first().map(|t| t.as_str()), + Some("sudo" | "command" | "nohup") + ) { + rest = &rest[1..]; + } + let argv: Vec<&str> = rest.iter().map(|s| s.as_str()).collect(); + let (first, args) = argv.split_first()?; + let cmd = basename(first); + let command_specific = match cmd { + "rm" => rm_discard(args, protected, repo_root), + "git" => git_discard(args, protected, repo_root), + "find" => find_discard(args, protected, repo_root), + "truncate" => truncate_discard(args, protected, repo_root), + _ => None, + }; + if command_specific.is_some() { + return command_specific; + } + // General pass: a `> target` / `>| target` redirect truncates `target`, + // discarding its verified content. `>>` (append) is ordinary modification + // and never blocks. + redirect_discard(&argv, protected, repo_root) +} + +/// `rm [flags] path...` — block when any path is protected or an ancestor +/// directory of one. +fn rm_discard( + args: &[&str], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let mut paths = Vec::new(); + let mut after_ddash = false; + for &a in args { + if after_ddash { + paths.push(a); + } else if a == "--" { + after_ddash = true; + } else if a.starts_with('-') { + // flag (including combined `-rf`, `-v`…) + } else { + paths.push(a); + } + } + paths_discard(&paths, "rm", protected, repo_root) +} + +/// The discarding git verbs. Anything not in this set (a plain branch +/// checkout, `stash pop`, `reset --soft`, …) touches the working tree +/// without discarding it and is never blocked. +fn git_discard( + args: &[&str], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let &sub = args.first()?; + match sub { + "reset" => { + if args.contains(&"--hard") { + all_discard("git reset --hard", protected) + } else { + None + } + } + "checkout" => { + let force = args.iter().any(|a| *a == "-f" || *a == "--force"); + let paths: Vec<&str> = match args.iter().position(|a| *a == "--") { + Some(i) => args[i + 1..].to_vec(), + None => Vec::new(), + }; + if paths.is_empty() { + // `checkout -f ` discards the whole working tree. + if force { + all_discard("git checkout -f", protected) + } else { + None + } + } else { + paths_discard(&paths, "git checkout --", protected, repo_root) + } + } + "clean" => { + // `-f`/`-fd`/`-fx` remove untracked files — which may be the + // verified output. A dry run (`-n`, or a flag containing `n`) + // deletes nothing. `-d` alone does nothing without `-f`. + let destructive = args.iter().any(|a| { + (*a == "-f" || *a == "--force" || a.starts_with("-f")) && !a.contains('n') + }); + if destructive { + all_discard("git clean -f", protected) + } else { + None + } + } + "stash" => match args.get(1).copied() { + // bare `git stash` / `git stash push` / `save` pull the working + // tree's changes out of it. apply/pop/list/show/drop/clear leave + // the working tree alone (or restore into it). + None | Some("push" | "save") => all_discard("git stash", protected), + _ => None, + }, + _ => None, + } +} + +/// `find … -delete` (or `-exec rm`) — block when a starting point is +/// protected or contains one. +fn find_discard( + args: &[&str], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let deletes = args.contains(&"-delete") + || args.iter().position(|a| *a == "-exec").is_some_and(|i| { + args.get(i + 1) + .copied() + .is_some_and(|n| n == "rm" || n.ends_with("/rm")) + }); + if !deletes { + return None; + } + // The starting points are the leading non-flag args; `find` without one + // defaults to `.`. + let mut starts: Vec<&str> = args + .iter() + .take_while(|a| !a.starts_with('-')) + .copied() + .collect(); + if starts.is_empty() { + starts.push("."); + } + paths_discard(&starts, "find -delete", protected, repo_root) +} + +/// `truncate [-s SIZE] file...` — truncating a protected path discards its +/// verified content. +fn truncate_discard( + args: &[&str], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let paths: Vec<&str> = args + .iter() + .filter(|a| !a.starts_with('-')) + .copied() + .collect(); + paths_discard(&paths, "truncate", protected, repo_root) +} + +/// `> target` / `>| target` redirect anywhere in the argv: the shell +/// truncates `target` first, discarding its verified content. +fn redirect_discard( + tokens: &[&str], + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let mut i = 0; + while i < tokens.len() { + if tokens[i] == ">" || tokens[i] == ">|" { + if let Some(target) = tokens.get(i + 1).copied() + && let Some(rel) = resolve_repo_relative(target, repo_root) + && let Some(hit) = at_risk(&rel, protected) + { + return Some(Discard { + protected: hit, + reason: format!("`> {target}`"), + }); + } + i += 2; + } else { + i += 1; + } + } + None +} + +/// Block when the whole tree's protected set is at risk — used by the +/// all-working-tree git verbs, which discard everything at once. +fn all_discard(what: &str, protected: &BTreeSet) -> Option { + if protected.is_empty() { + return None; + } + Some(Discard { + protected: protected.iter().cloned().collect(), + reason: what.to_string(), + }) +} + +/// Match command paths against the protected set. Returns the sorted list of +/// protected paths the command would discard, or `None` if none is at risk. +fn paths_discard( + paths: &[&str], + what: &str, + protected: &BTreeSet, + repo_root: Option<&Path>, +) -> Option { + let mut hits = BTreeSet::new(); + for &p in paths { + if let Some(rel) = resolve_repo_relative(p, repo_root) + && let Some(hit) = at_risk(&rel, protected) + { + hits.extend(hit); + } else if p.contains(['*', '?', '[']) { + // Shell glob: conservative match — `*` may cross separators, so + // `rm -rf *` / `/app/*` / `dir/*` all cover protected paths they + // can expand to. A glob that matches nothing protected is pass. + if let Some(rel) = resolve_repo_relative(p, repo_root) + && let Some(hit) = glob_risk(&rel, protected) + { + hits.extend(hit); + } + } + } + if hits.is_empty() { + return None; + } + Some(Discard { + protected: hits.into_iter().collect(), + reason: format!("`{what}`"), + }) +} + +/// Resolve a command-line path to the repo-relative form used by the +/// protected set. Absolute paths outside the repo root (e.g. `/tmp/x`) +/// resolve to `None` and can never match. Lexical only — no filesystem +/// access, so `.`/`..`/trailing-slash handling never depends on cwd state. +fn resolve_repo_relative(candidate: &str, repo_root: Option<&Path>) -> Option { + let p = Path::new(candidate); + let rel = if p.is_absolute() { + PathBuf::from(p.strip_prefix(repo_root?).ok()?) + } else { + p.to_path_buf() + }; + Some(normalize(&rel)) +} + +/// Lexically clean a relative path: drop `.`, resolve `..`, drop trailing +/// slashes. `.` collapses to the empty path, which is an ancestor of +/// everything (so `rm -rf .` in the repo root is caught). +fn normalize(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + Component::Normal(c) => out.push(c), + Component::RootDir | Component::Prefix(_) => out.push(comp.as_os_str()), + } + } + out +} + +/// The protected paths that `rel` is or contains — i.e. the protected paths a +/// command targeting `rel` would discard. `None` when `rel` matches nothing. +fn at_risk(rel: &Path, protected: &BTreeSet) -> Option> { + let hit: Vec = protected + .iter() + .filter(|p| *p == rel || p.starts_with(rel)) + .cloned() + .collect(); + if hit.is_empty() { None } else { Some(hit) } +} + +/// Glob variant of [`at_risk`]: which protected paths does the pattern cover? +/// `*` matches any run of characters including `/`; `?` matches any single +/// character. Errs toward blocking only when a protected path genuinely fits +/// the pattern. +fn glob_risk(pattern: &Path, protected: &BTreeSet) -> Option> { + let pat = pattern.to_string_lossy(); + let hit: Vec = protected + .iter() + .filter(|p| glob_match(&pat, &p.to_string_lossy())) + .cloned() + .collect(); + if hit.is_empty() { None } else { Some(hit) } +} + +/// Match `pattern` (with `*`/`?`) against `text`. `*` matches any sequence +/// including `/`; `?` matches any single character including `/`. +fn glob_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + // dp[i][j]: pattern[..i] matches text[..j] + let mut dp = vec![vec![false; t.len() + 1]; p.len() + 1]; + dp[0][0] = true; + for i in 1..=p.len() { + dp[i][0] = dp[i - 1][0] && p[i - 1] == '*'; + } + for i in 1..=p.len() { + for j in 1..=t.len() { + dp[i][j] = match p[i - 1] { + '*' => dp[i - 1][j] || dp[i][j - 1], + '?' => dp[i - 1][j - 1], + c => dp[i - 1][j - 1] && c == t[j - 1], + }; + } + } + dp[p.len()][t.len()] +} + +/// Last path component of a command word, so `/bin/rm` and `rm` both match. +fn basename(word: &str) -> &str { + word.rsplit('/').next().unwrap_or(word) +} + +/// Whitespace tokenizer honoring single/double quotes and backslash escapes, +/// so `rm 'out file.json'` and `rm out\ file.json` are single tokens and +/// quoted `>`s aren't redirects. Unquoted `;` / `&` / `|` (and `&&`, `||`) +/// become standalone separator tokens — `rm out.json; echo done` splits like +/// the shell does — except when glued to a `>` (`2>&1`, `>&`, `>|`), which +/// stays one token. +fn tokenize(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut chars = s.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + while let Some(c) = chars.next() { + if in_single { + if c == '\'' { + in_single = false; + } else { + cur.push(c); + } + } else if in_double { + match c { + '"' => in_double = false, + '\\' => { + if let Some(&n) = chars.peek() { + chars.next(); + cur.push(n); + } + } + _ => cur.push(c), + } + } else { + match c { + '\'' => in_single = true, + '"' => in_double = true, + '\\' => { + if let Some(&n) = chars.peek() { + chars.next(); + cur.push(n); + } + } + // A newline between commands is an exact synonym for `;` — but + // NOT when escaped: the `\\` arm above already consumed a + // backslash-newline pair into the current token, so a line + // continuation never reaches here. + '\n' => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + out.push("\n".to_string()); + } + c if c.is_whitespace() => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + ';' => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + out.push(";".to_string()); + } + '&' if !cur.ends_with('>') => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + if chars.peek() == Some(&'&') { + chars.next(); + out.push("&&".to_string()); + } else { + out.push("&".to_string()); + } + } + '|' if !cur.ends_with('>') => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + if chars.peek() == Some(&'|') { + chars.next(); + out.push("||".to_string()); + } else { + out.push("|".to_string()); + } + } + _ => cur.push(c), + } + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::agent_loop::worktree_probe::TreeFingerprint; + + /// A bash tool call carrying `command`. + fn bash(command: &str) -> ToolCall { + ToolCall { + id: "call_1".to_string(), + name: "bash".to_string(), + arguments: serde_json::json!({ "command": command }), + } + } + + /// A non-bash tool call (write/edit/verify must never be inspected). + fn tool(name: &str) -> ToolCall { + ToolCall { + id: "call_1".to_string(), + name: name.to_string(), + arguments: serde_json::json!({ "path": "out.json", "content": "x" }), + } + } + + /// Guard armed on `paths` as if a green had just latched over them, with + /// an optional repo root for absolute-path resolution. + fn armed(paths: &[&str], repo_root: Option<&str>) -> PublishGuard { + let mut g = PublishGuard::new(); + let fp: TreeFingerprint = paths + .iter() + .map(|p| (PathBuf::from(p), "abc123".to_string())) + .collect(); + g.arm(Some(fp), repo_root.map(PathBuf::from)); + g + } + + fn assert_blocked(g: &mut PublishGuard, command: &str, expect: &str) { + match g.inspect(GateMode::Blocking, &bash(command)) { + PublishVerdict::Hit { + block: true, + protected, + reason, + } => { + assert!( + protected.iter().any(|p| p == Path::new(expect)), + "reason={reason}: expected {expect} in {protected:?}" + ); + } + other => panic!("expected a block for `{command}`, got {other:?}"), + } + } + + fn assert_passes(g: &mut PublishGuard, command: &str) { + assert_eq!( + g.inspect(GateMode::Blocking, &bash(command)), + PublishVerdict::Pass, + "`{command}` must pass untouched" + ); + } + + // ---- Positive: the guard fires when it should ---------------------- + + #[test] + fn git_reset_hard_after_green_is_blocked_and_warned() { + // Test 1: green latches, then `git reset --hard`. + let mut g = armed(&["src/a.rs"], None); + assert_blocked(&mut g, "git reset --hard", "src/a.rs"); + assert_blocked(&mut g, "git reset --hard HEAD~1", "src/a.rs"); + + let mut g = armed(&["src/a.rs"], None); + match g.inspect(GateMode::Advisory, &bash("git reset --hard")) { + PublishVerdict::Hit { + block: false, + protected, + .. + } => { + assert_eq!(protected, vec![PathBuf::from("src/a.rs")]); + } + other => panic!("advisory must warn, not block: {other:?}"), + } + // The budget is 2: the second hit still warns, the third is silent. + assert_eq!( + g.inspect(GateMode::Advisory, &bash("git reset --hard")), + PublishVerdict::Hit { + block: false, + protected: vec![PathBuf::from("src/a.rs")], + reason: "git reset --hard".to_string(), + } + ); + assert_eq!( + g.inspect(GateMode::Advisory, &bash("git reset --hard")), + PublishVerdict::Pass, + "once the advisory budget is spent the guard is silent" + ); + } + + #[test] + fn rm_of_verified_output_is_blocked() { + // Test 2: green latches on a diff containing `out.json`, then rm it. + let mut g = armed(&["out.json"], None); + assert_blocked(&mut g, "rm out.json", "out.json"); + assert_blocked(&mut g, "rm -rf out.json", "out.json"); + assert_blocked(&mut g, "rm -f -- out.json", "out.json"); + assert_blocked(&mut g, "/bin/rm out.json", "out.json"); + } + + #[test] + fn rm_of_generator_script_is_blocked() { + // Test 3 (mcmc-sampling-stan shape): the protected set covers the + // generator script, not only its artifact. + let mut g = armed(&["gen.py", "out.json"], None); + assert_blocked(&mut g, "rm gen.py", "gen.py"); + assert_blocked(&mut g, "rm -rf gen.py", "gen.py"); + } + + #[test] + fn no_override_downgrades_a_hard_block() { + // Test 4 (configure-git-webserver shape): the iteration-5 guard let a + // cleanup through once the agent attached its override token. This + // guard has no token — every argument vector still blocks. + let mut g = armed(&["out.json"], None); + for command in [ + "rm -rf out.json", + "rm -rf --no-preserve-root out.json", + "yes | rm -rf out.json", + "sudo rm -rf out.json", + "rm -rf out.json || true", + "rm -rf out.json 2>/dev/null", + ] { + assert_eq!( + g.inspect(GateMode::Blocking, &bash(command)), + { + // `yes | rm` and `sudo rm` are piped — the guard parses + // per `|`-free segments; only the rm segment is checked. + let _ = command; + PublishVerdict::Hit { + block: true, + protected: vec![PathBuf::from("out.json")], + reason: "`rm`".to_string(), + } + }, + "no argument vector may pass `{command}`" + ); + } + // Advisory mode warns but never blocks — and never silently enables + // a third path: after the budget the call still runs. + let mut g = armed(&["out.json"], None); + match g.inspect(GateMode::Advisory, &bash("rm -rf out.json")) { + PublishVerdict::Hit { block: false, .. } => {} + other => panic!("advisory warns, does not block: {other:?}"), + } + } + + // ---- Negative: silent when it should be (the criterion that holds + // ---- at n=1) ------------------------------------------------------ + + #[test] + fn off_mode_passes_everything() { + // Test 5: byte-identical default. + let mut g = armed(&["out.json", "src/a.rs"], None); + for command in [ + "git reset --hard", + "rm -rf out.json", + "find . -delete", + "git checkout -f", + "git clean -fd", + "git stash", + "echo x > out.json", + "truncate -s 0 out.json", + ] { + assert_eq!( + g.inspect(GateMode::Off, &bash(command)), + PublishVerdict::Pass, + "off mode must pass `{command}`" + ); + } + } + + #[test] + fn editing_a_protected_file_is_never_blocked() { + // Test 6: the paper's rewrite-block deliberately not ported. + let mut g = armed(&["out.json"], None); + for command in [ + "sed -i 's/a/b/' out.json", + "sed -i -e 's/a/b/' -e 's/c/d/' out.json", + "echo extra >> out.json", + "touch out.json", + "chmod +x out.json", + ] { + assert_passes(&mut g, command); + } + // write/edit tools are not bash — never inspected at all. + assert_passes(&mut g, "true"); + assert_eq!( + g.inspect(GateMode::Blocking, &tool("write")), + PublishVerdict::Pass + ); + assert_eq!( + g.inspect(GateMode::Blocking, &tool("edit")), + PublishVerdict::Pass + ); + } + + #[test] + fn temp_paths_are_never_blocked() { + // Test 7: the paper carved out /tmp too. + let mut g = armed(&["out.json"], Some("/repo")); + for command in [ + "rm /tmp/scratch.txt", + "rm -rf /tmp/scratch", + "find /tmp -delete", + "echo x > /tmp/out.json", + "truncate -s 0 /tmp/out.json", + "git -C /tmp/other reset --hard", + ] { + assert_passes(&mut g, command); + } + } + + #[test] + fn rm_of_unprotected_file_is_never_blocked() { + // Test 8: paths not in the protected set. + let mut g = armed(&["src/a.rs"], None); + assert_passes(&mut g, "rm unrelated.txt"); + assert_passes(&mut g, "rm -rf notes/"); + assert_passes(&mut g, "rm -rf src/b.rs"); // b.rs differs from HEAD? no — only a.rs is protected + } + + #[test] + fn nothing_is_blocked_before_any_green() { + // Test 9: no green latched yet. + let mut g = PublishGuard::new(); + for command in [ + "rm -rf out.json", + "git reset --hard", + "find . -delete", + "echo x > out.json", + ] { + assert_passes(&mut g, command); + } + } + + #[test] + fn rerunning_verification_is_never_blocked() { + // Test 10: a re-run of the verification command discards nothing. + let mut g = armed(&["out.json"], None); + for command in [ + "cargo test", + "cargo nextest run --bin dirge", + "make check", + "pytest", + "npm test", + ] { + assert_passes(&mut g, command); + } + assert_eq!( + g.inspect(GateMode::Blocking, &tool("verify")), + PublishVerdict::Pass + ); + } + + // ---- Parsing robustness -------------------------------------------- + + #[test] + fn rm_of_directory_containing_protected_is_blocked() { + let mut g = armed(&["src/gen.rs", "out.json"], None); + assert_blocked(&mut g, "rm -rf src", "src/gen.rs"); + assert_blocked(&mut g, "rm -rf .", "src/gen.rs"); + assert_blocked(&mut g, "rm -r src/", "src/gen.rs"); + } + + #[test] + fn find_delete_blocked_when_dir_contains_protected() { + let mut g = armed(&["src/a.rs"], None); + assert_blocked(&mut g, "find . -delete", "src/a.rs"); + assert_blocked(&mut g, "find src -name '*.rs' -delete", "src/a.rs"); + assert_blocked(&mut g, "find src -exec rm {} \\;", "src/a.rs"); + assert_passes(&mut g, "find . -name '*.tmp' -print"); // no -delete + assert_passes(&mut g, "find /tmp -delete"); // outside the repo + let mut g = armed(&["src/a.rs"], None); + assert_passes(&mut g, "find build -delete"); // build/ not protected + } + + #[test] + fn git_checkout_forms() { + let mut g = armed(&["src/a.rs"], None); + assert_blocked(&mut g, "git checkout -- src/a.rs", "src/a.rs"); + assert_blocked(&mut g, "git checkout -- .", "src/a.rs"); + assert_blocked(&mut g, "git checkout -f", "src/a.rs"); + assert_blocked(&mut g, "git checkout -f feature", "src/a.rs"); + assert_passes(&mut g, "git checkout main"); // plain checkout discards nothing + assert_passes(&mut g, "git checkout -b fix"); // new branch + assert_passes(&mut g, "git checkout -- README.md"); // not protected + } + + #[test] + fn git_stash_and_clean_forms() { + let mut g = armed(&["out.json"], None); + assert_blocked(&mut g, "git stash", "out.json"); + assert_blocked(&mut g, "git stash push -m wip", "out.json"); + assert_blocked(&mut g, "git stash save wip", "out.json"); + assert_passes(&mut g, "git stash pop"); + assert_passes(&mut g, "git stash apply"); + assert_passes(&mut g, "git stash list"); + assert_passes(&mut g, "git stash drop"); + assert_blocked(&mut g, "git clean -fd", "out.json"); + assert_blocked(&mut g, "git clean -fdx", "out.json"); + assert_passes(&mut g, "git clean -n"); // dry run + assert_passes(&mut g, "git reset"); // mixed reset leaves the tree alone + assert_passes(&mut g, "git reset --soft HEAD~1"); + } + + #[test] + fn redirect_truncates_but_appends_do_not() { + let mut g = armed(&["out.json"], None); + assert_blocked(&mut g, "echo x > out.json", "out.json"); + assert_blocked(&mut g, "cat gen.py >| out.json", "out.json"); + assert_blocked(&mut g, "truncate -s 0 out.json", "out.json"); + assert_blocked(&mut g, "truncate out.json", "out.json"); + assert_passes(&mut g, "echo x >> out.json"); // append is ordinary work + assert_passes(&mut g, "echo x > README.md"); // not protected + assert_passes(&mut g, "cmd > /tmp/log 2>&1"); + assert_passes(&mut g, "echo '> out.json'"); // quoted — not a redirect + } + + #[test] + fn globs_cover_protected_paths_but_only_those() { + let mut g = armed(&["out.json"], Some("/repo")); + assert_blocked(&mut g, "rm -rf *", "out.json"); + assert_blocked(&mut g, "rm -rf out*", "out.json"); + assert_blocked(&mut g, "rm -rf /repo/out.*", "out.json"); + assert_passes(&mut g, "rm -rf target/*"); // nothing protected under target/ + let mut g = armed(&["src/a.rs"], Some("/repo")); + assert_passes(&mut g, "rm -rf *.json"); // matches no protected path + } + + #[test] + fn absolute_and_relative_paths_resolve_against_the_repo() { + let mut g = armed(&["out.json"], Some("/repo")); + assert_blocked(&mut g, "rm -rf /repo/out.json", "out.json"); + assert_blocked(&mut g, "rm -rf /repo/sub/../out.json", "out.json"); + assert_blocked(&mut g, "rm -rf ./out.json", "out.json"); + assert_passes(&mut g, "rm -rf /elsewhere/out.json"); // outside the repo + assert_passes(&mut g, "rm -rf /tmp/out.json"); + // Unknown repo root: absolute paths can't be matched — conservative pass. + let mut g = armed(&["out.json"], None); + assert_passes(&mut g, "rm -rf /repo/out.json"); + } + + #[test] + fn chained_commands_are_checked_independently() { + let mut g = armed(&["out.json"], None); + assert_blocked(&mut g, "true && rm out.json", "out.json"); + assert_blocked(&mut g, "rm out.json; echo done", "out.json"); + assert_blocked(&mut g, "cd /tmp && rm out.json", "out.json"); // relative inside the repo + assert_passes(&mut g, "rm /tmp/x && echo ok"); + } + + #[test] + fn backslash_continuation_is_not_a_separator() { + // `echo hi \rm out.json` is ONE command line — rm is an + // argument to echo, so the guard must not see an rm. The tokenizer's + // `\\` arm consumes the escaped newline into the token. + let mut g = armed(&["out.json"], Some("/repo")); + assert_passes( + &mut g, + "echo hi \\ +rm out.json", + ); + // A continuation after `&&` still leaves the discard in its own + // segment: `rm out.json && \echo done` runs rm first. + let mut g = armed(&["out.json"], None); + assert_blocked( + &mut g, + "rm out.json && \\ +echo done", + "out.json", + ); + } + + #[test] + fn quoting_and_escapes_are_respected() { + let mut g = armed(&["out file.json"], None); + assert_blocked(&mut g, "rm 'out file.json'", "out file.json"); + assert_blocked(&mut g, "rm \"out file.json\"", "out file.json"); + assert_blocked(&mut g, "rm out\\ file.json", "out file.json"); + assert_passes(&mut g, "rm out_file.json"); // different file + } + + #[test] + fn advisory_budget_is_bounded_at_two_per_run() { + let mut g = armed(&["a.json", "b.json", "c.json"], None); + for expected in [true, true, false] { + let v = g.inspect(GateMode::Advisory, &bash("rm a.json")); + let warned = matches!(v, PublishVerdict::Hit { block: false, .. }); + assert_eq!( + warned, expected, + "advisory #{} budget", + g.advisories_emitted + ); + } + // Blocking mode is NOT budgeted — every discard is a hard block. + let mut g = armed(&["a.json"], None); + for _ in 0..5 { + assert!(matches!( + g.inspect(GateMode::Blocking, &bash("rm a.json")), + PublishVerdict::Hit { block: true, .. } + )); + } + } + + #[test] + fn rearm_replaces_the_protected_set() { + let mut g = armed(&["a.rs"], None); + assert_blocked(&mut g, "rm a.rs", "a.rs"); + assert_passes(&mut g, "rm b.rs"); + let fp: TreeFingerprint = [(PathBuf::from("b.rs"), "h".to_string())] + .into_iter() + .collect(); + g.arm(Some(fp), None); + assert_passes(&mut g, "rm a.rs"); // stale set gone + assert_blocked(&mut g, "rm b.rs", "b.rs"); + } + + #[test] + fn stale_after_green_edits_does_not_disarm() { + // Going stale (an edit lands after green) keeps protecting the + // previously verified work. + let mut g = armed(&["out.json"], None); + assert_blocked(&mut g, "rm out.json", "out.json"); + } + + #[test] + fn empty_fingerprint_never_arms() { + let mut g = PublishGuard::new(); + g.arm(Some(TreeFingerprint::new()), None); + assert_passes(&mut g, "rm -rf out.json"); + g.arm(None, None); + assert_passes(&mut g, "git reset --hard"); + } + #[test] + fn newline_separated_rm_is_caught() { + let mut g = armed(&["out.json"], Some("/repo")); + let v = g.inspect(GateMode::Blocking, &bash("echo hi\nrm out.json")); + assert!( + matches!(v, PublishVerdict::Hit { .. }), + "newline-separated rm of a protected file must be caught, got {v:?}" + ); + } +} diff --git a/src/agent/agent_loop/run.rs b/src/agent/agent_loop/run.rs index 7384c72b..75ed51b9 100644 --- a/src/agent/agent_loop/run.rs +++ b/src/agent/agent_loop/run.rs @@ -168,6 +168,7 @@ const HARNESS_TAGS: &[&str] = &[ super::progress::STALL_TAG, super::progress::BUDGET_TAG, super::safe_state::SAFE_STATE_TAG, + super::publish_guard::PUBLISH_GUARD_TAG, ]; /// The harness tag `text` carries, if any. @@ -295,6 +296,10 @@ enum FollowUpSource { ResumeAfterFailure, /// Verifier gate: code was edited but nothing was run to check it. Verifier, + /// Deterministic claim/evidence gate (dirge-d0e5.2): the final answer + /// claimed a verification result or a change the run's evidence does not + /// support. No LLM call. + ClaimGate, /// Unified finalization judge (dirge-8v98): completeness verdict + diff /// findings in one call. One-shot (Off/Advisory) or persistent up to /// [`super::code_review::MAX_REVIEW_REACT`] (Blocking). @@ -318,6 +323,7 @@ impl From for GateSource { FollowUpSource::Hook => GateSource::Hook, FollowUpSource::ResumeAfterFailure => GateSource::ResumeAfterFailure, FollowUpSource::Verifier => GateSource::Verifier, + FollowUpSource::ClaimGate => GateSource::ClaimGate, FollowUpSource::Critic => GateSource::Critic, FollowUpSource::Goal => GateSource::Goal, FollowUpSource::Todo => GateSource::Todo, @@ -603,7 +609,10 @@ async fn poll_finalization_follow_up( new_messages: &[LoopMessage], gates: &mut GateStates, inputs: GateInputs<'_>, - emit: &mpsc::Sender, + // dirge-1elu.4: all follow-up delivery now flows through the returned + // messages — the loop mirrors them to SystemNotice with + // emit_harness_notices — so no gate emits on this channel anymore. + _emit: &mpsc::Sender, ) -> (Vec, FollowUpSource) { // Destructured rather than accessed through `gates.` / `inputs.` so the // gate bodies below read exactly as they did when these were seventeen @@ -620,6 +629,8 @@ async fn poll_finalization_follow_up( resume_nudges, open_issues_nudges, track_nudges, + claim_nudges, + run_epoch, } = gates; let GateInputs { code_review_baseline, @@ -715,6 +726,53 @@ async fn poll_finalization_follow_up( return (msgs, FollowUpSource::Verifier); } } + + // 2.5 Claim/evidence gate (dirge-d0e5.2) — deterministic, no LLM. Fires + // once when the final answer asserts a verification result or a change + // the run's evidence does not support: a test count / named-gate claim + // ("4954 passed", "clippy clean") with NO verification command + // observed this run, or a first-person "I fixed …" claim with zero + // files mutated. Sits AFTER the verifier gate so the more actionable + // "actually run the check" nudge wins when both would fire; the claim + // gate is the backstop for a model that finalizes while still claiming + // an unrun result. Off by default and byte-identical when off. + if config.claim_gate_mode != GateMode::Off + && *claim_nudges < super::claim_gate::MAX_CLAIM_NUDGES + && let Some(LoopMessage::Assistant(last)) = new_messages.last() + { + { + let answer: String = last + .content + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let claims = super::claim_gate::scan_final_answer(&answer); + let ran_verification = config + .verifier + .as_ref() + .is_some_and(|v| v.ran_verification()); + let files_mutated = crate::agent::tools::modified::since(*run_epoch).len(); + if let Some(kind) = + super::claim_gate::unsupported_claims(&claims, ran_verification, files_mutated) + { + *claim_nudges += 1; + return ( + vec![LoopMessage::User(super::message::UserMessage { + content: vec![super::message::UserPart::text(format!( + "{} {}", + super::claim_gate::CLAIM_GATE_TAG, + kind.nudge_text() + ))], + })], + FollowUpSource::ClaimGate, + ); + } + } + } // 3. Unified finalization judge (dirge-8v98) — ONE judge call that both // judges completeness (the old F6 critic) AND reviews the run's diff for // defects (the old diff-aware reviewer), returning a single consolidated @@ -819,6 +877,21 @@ async fn poll_finalization_follow_up( .verifier .as_ref() .map(|v| v.status(config.verification_tiers_mode)); + let evidence = super::critic::Evidence { + files_mutated: crate::agent::tools::modified::since(*run_epoch) + .iter() + .map(|p| p.display().to_string()) + .collect(), + observed_commands: config + .verifier + .as_ref() + .map(|v| v.observed_commands()) + .unwrap_or_default(), + tool_calls: new_messages + .iter() + .filter(|m| matches!(m, super::message::LoopMessage::ToolResult(_))) + .count(), + }; let outcome = super::critic::run_unified_review( judge, system_prompt, @@ -826,6 +899,7 @@ async fn poll_finalization_follow_up( diff_owned.as_deref(), verification, last_review_findings.as_deref(), + Some(&evidence), ) .await; let msgs = outcome.messages; @@ -942,18 +1016,25 @@ async fn poll_finalization_follow_up( if count > 0 { match open_issues_gate_mode { GateMode::Advisory => { - // One-shot notice (fires at most once per run), then fall - // through — does not re-enter the loop. + // One-shot (fires at most once per run), then re-enter + // ONCE with a model-visible, tagged message. The text is + // an imperative aimed at the model — a display-only + // SystemNotice it never sees would change nothing + // (dirge-1elu.4, arXiv:2604.25850v4 §C.1.4/§C.2.4). The + // loop's emit_harness_notices mirror renders the same + // SystemNotice the old path emitted directly, so what the + // human sees is unchanged. if *open_issues_nudges == 0 { *open_issues_nudges += 1; - let _ = emit - .send(LoopEvent::SystemNotice { - content: format!( - "{count} issue(s) from this session are still open — \ - close or defer them when done." + return ( + vec![LoopMessage::User(super::message::UserMessage::text( + format!( + "{OPEN_ISSUES_NUDGE_TAG} {count} issue(s) from this session \ + are still open — close or defer them when done." ), - }) - .await; + ))], + FollowUpSource::OpenIssues, + ); } } GateMode::Blocking => { @@ -1002,6 +1083,13 @@ async fn poll_finalization_follow_up( } // dirge-track: file-edits-without-todos advisory — fires at most once per // run when the model edited files this turn but has no active todo tracked. + // The boundary nudge (poll_boundary_nudge → build_early_track_work_reminder) + // shares the same `track_nudges` budget, so only one of the two can ever + // fire; this finalization-time copy catches runs that finalize without + // passing a boundary. The text is an imperative aimed at the model, so it + // must be a model-visible tagged message (dirge-1elu.4) — the loop's + // emit_harness_notices mirror emits the SystemNotice the old path emitted + // directly, keeping what the human sees unchanged. if should_advise_untracked_work( session_id, *track_nudges, @@ -1009,11 +1097,16 @@ async fn poll_finalization_follow_up( turn_made_file_edits(new_messages), ) { *track_nudges += 1; - let _ = emit - .send(LoopEvent::SystemNotice { - content: "You modified files this turn but have no active todo. If this task isn't finished, add it with write_todo_list and mark it in_progress so it stays your tracked priority (and gets closed when done).".to_string(), - }) - .await; + return ( + vec![LoopMessage::User(super::message::UserMessage::text( + format!( + "{TRACK_WORK_TAG} You modified files this turn but have no active todo. If this \ + task isn't finished, add it with write_todo_list and mark it in_progress so it \ + stays your tracked priority (and gets closed when done)." + ), + ))], + FollowUpSource::Todo, + ); } (Vec::new(), FollowUpSource::None) } @@ -1988,6 +2081,7 @@ pub(crate) fn poll_boundary_nudge( tally.record_nudge(BoundaryNudge::ProgressBudget); return Some((msg, BoundaryNudge::ProgressBudget)); } + tally.end_boundary(); None } @@ -2000,12 +2094,16 @@ fn finish_tally( capability: &super::capability::CapabilityEstimator, ) { tally.set_capability_tier(Some(capability.tier())); - tally.set_verification( - config - .verifier - .as_ref() - .map(|v| v.status(config.verification_tiers_mode)), - ); + let verification = config + .verifier + .as_ref() + .map(|v| v.status(config.verification_tiers_mode)); + tally.set_verification(verification); + // dirge-1elu.7: hand this run's status to the post-session pass, which + // runs on the UI side and cannot see the loop's verifier. Recorded + // unconditionally — a `None` here CLEARS any prior entry, so a run that + // verified nothing never inherits an earlier run's green. + super::verifier::record_run_verification(config.session_id.as_deref(), verification); tally.set_repairs(Some(config.repair_stats.snapshot())); tally.emit(); } @@ -2109,10 +2207,20 @@ pub async fn run_loop( // checkpoint threshold. let mut safe_state = super::safe_state::SafeStateEngine::new(); + // dirge-1elu.1: publish-state guard. Off (the default) is byte-identical + // to the loop without the guard — inspect() short-circuits before any + // work. Arms at the fresh-green instant from the SAME fingerprint the + // safe-state rung stamps; persists across turns so verified work from an + // earlier turn stays protected. + let mut publish_guard = super::publish_guard::PublishGuard::new(); + // dirge-5mtx.5: every finalization gate's re-fire state, in one place. Each // field is labelled cost-ceiling or re-fire-guard on `GateStates` itself — // that distinction is what decides which are safe to relax. - let mut gates = GateStates::default(); + let mut gates = GateStates { + run_epoch: crate::agent::tools::modified::epoch(), + ..Default::default() + }; // dirge-1g3v: snapshot the working-tree diff at run start so the reviewer // can tell what THIS run changed. Without a baseline it diffed the whole @@ -2569,7 +2677,7 @@ pub async fn run_loop( let mut storm_give_up_tools: Option> = None; if !tool_calls.is_empty() { let original_count = tool_calls.len(); - let (surviving_calls, storm_report) = guards.inspect_calls(&tool_calls); + let (mut surviving_calls, storm_report) = guards.inspect_calls(&tool_calls); for _ in 0..storm_report.storms_broken { tally.record_storm_suppression(); } @@ -2643,6 +2751,80 @@ pub async fn run_loop( storm_give_up_tools = Some(tool_calls.iter().map(|c| c.name.clone()).collect()); } + // dirge-1elu.1: publish-state guard — pre-dispatch, after the + // storm breaker. `blocking` drops the call (an error result + // naming the protected paths is backfilled below); `advisory` + // lets the call run but injects a tagged, model-visible + // warning, bounded at 2 per run then silent. Off passes + // everything through untouched. + let mut blocked: Vec<( + super::tools::ToolCall, + super::publish_guard::PublishVerdict, + )> = Vec::new(); + let mut warned: Vec<( + super::tools::ToolCall, + super::publish_guard::PublishVerdict, + )> = Vec::new(); + if config.publish_guard_mode != super::types::GateMode::Off { + for call in &surviving_calls { + match publish_guard.inspect(config.publish_guard_mode, call) { + super::publish_guard::PublishVerdict::Pass => {} + v @ super::publish_guard::PublishVerdict::Hit { + block: true, .. + } => { + blocked.push((call.clone(), v)); + } + v @ super::publish_guard::PublishVerdict::Hit { + block: false, .. + } => { + warned.push((call.clone(), v)); + } + } + } + if !blocked.is_empty() { + let blocked_ids: std::collections::HashSet<&str> = + blocked.iter().map(|(c, _)| c.id.as_str()).collect(); + surviving_calls.retain(|c| !blocked_ids.contains(c.id.as_str())); + } + // Advisory warnings are model-visible User messages tagged + // like the other harness injections, so emit_harness_notices + // mirrors them to a SystemNotice for headless consumers. + if !warned.is_empty() { + let mut body = format!( + "{} Advisory: a command in this batch would discard verified-green work. \ + It is being allowed to run (advisory mode) — make a scratch copy under \ + /tmp if you meant to clean up:\n", + super::publish_guard::PUBLISH_GUARD_TAG + ); + for (call, verdict) in &warned { + if let super::publish_guard::PublishVerdict::Hit { + protected, + reason, + .. + } = verdict + { + let command = call + .arguments + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let paths = protected + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + body.push_str(&format!( + "- `{command}` ({reason}) discards: {paths}\n" + )); + } + } + let msg = LoopMessage::User(super::message::UserMessage::text(body)); + emit_harness_notices(emit, std::slice::from_ref(&msg)).await; + current_context.messages.push(loop_message_to_value(&msg)); + new_messages.push(msg); + } + } + // Dispatch surviving calls through the unified dispatch. // `execute_tool_calls` takes pre-extracted tool calls. if !surviving_calls.is_empty() { @@ -2694,6 +2876,45 @@ pub async fn run_loop( } } + // dirge-1elu.1: publish-blocked calls return an error result, + // so history stays well-formed and the model sees WHY the call + // was suppressed and what to do instead. Because these ids are + // already covered, the dirge-tc4r backfill below synthesizes + // nothing for them. + for (call, verdict) in &blocked { + if let super::publish_guard::PublishVerdict::Hit { + protected, reason, .. + } = verdict + { + let command = call + .arguments + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let paths = protected + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + let text = format!( + "{} The command `{command}` ({reason}) would discard verified-green work \ + ({paths}) and was blocked. Make a scratch copy under /tmp if you meant to \ + clean up — the verified work is not recoverable once discarded.", + super::publish_guard::PUBLISH_GUARD_TAG + ); + let tr = ToolResultMessage { + tool_call_id: call.id.clone(), + tool_name: call.name.clone(), + content: vec![ContentBlock::Text { text }], + details: Value::Null, + is_error: true, + }; + current_context.messages.push(tool_result_to_value(&tr)); + new_messages.push(LoopMessage::ToolResult(tr.clone())); + tool_results.push(tr); + } + } + // dirge-tc4r: guarantee a result for EVERY tool_call_id in // the assistant message. Partial storm suppression and a // cancelled/interrupted batch both append fewer results @@ -2769,17 +2990,25 @@ pub async fn run_loop( // supersedes. Every input it reads — the guards, the verifier, the // reflexion log — is already current at this point: tool results // were recorded above. + // dirge-1elu.1: the publish-state guard and the safe-state rung + // arm from ONE fingerprint, taken at the fresh-green instant — a + // single git sample is the source of truth for "what this run + // changed at green". A later fresh-green replaces the protected + // set; going stale (an edit after green) never clears it. + let fresh_green = config.verifier.as_ref().is_some_and(|v| v.is_fresh_green()); + if fresh_green + && (config.publish_guard_mode != super::types::GateMode::Off + || config.safe_state_abort_mode == super::types::SafeStateMode::Auto) + { + let repo = safe_state_repo(&config); + let fp = repo.as_deref().and_then(super::worktree_probe::fingerprint); + publish_guard.arm(fp.clone(), repo); + safe_state.set_green_fingerprint(fp); + } + let safe_state_msg = if config.safe_state_abort_mode != super::types::SafeStateMode::Off { let excerpts = guards.recent_excerpts(); - let fresh_green = config.verifier.as_ref().is_some_and(|v| v.is_fresh_green()); - if fresh_green && config.safe_state_abort_mode == super::types::SafeStateMode::Auto - { - let fp = safe_state_repo(&config) - .as_deref() - .and_then(super::worktree_probe::fingerprint); - safe_state.set_green_fingerprint(fp); - } let green_fp = safe_state.green_fingerprint().cloned(); let repo = safe_state_repo(&config); safe_state.decide( @@ -3086,6 +3315,11 @@ pub async fn run_loop( // color) rather than a `MessageStart { User }` — the // latter rendered with the `` prefix as if the user // had typed it. + // + // dirge-1elu.4 audit (site 4): deliberately notice-only and + // never a steering message — the run is ending, there is no + // next model turn to read one. The transcript entry below is + // a record of truncation for callers, not a steer. let _ = emit .send(LoopEvent::SystemNotice { content: notice.clone(), @@ -3142,7 +3376,11 @@ pub async fn run_loop( emit, ) .await; + // dirge-1elu.6: one finalization boundary — the source gate (and + // any future co-firing gate) becomes one co-occurrence event. + tally.begin_boundary(); tally.record_gate(source.into()); + tally.end_boundary(); if !follow_up.is_empty() { tracing::trace!(target: "dirge::loop", ?source, "finalization follow-up interjected"); emit_harness_notices(emit, &follow_up).await; diff --git a/src/agent/agent_loop/run_tests.rs b/src/agent/agent_loop/run_tests.rs index a90b0974..99481d72 100644 --- a/src/agent/agent_loop/run_tests.rs +++ b/src/agent/agent_loop/run_tests.rs @@ -144,6 +144,8 @@ fn build_config() -> LoopConfig { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -1566,6 +1568,233 @@ async fn test_full_loop_with_tool_then_final_text() { assert!(kinds.contains(&"tool_execution_end")); } +/// Recording stand-in for the real `bash` tool: same name and `command` arg +/// shape, but just records the command and returns success. The loop's +/// verifier plumbing (tools.rs:632) keys on the `bash` name + `command` arg, +/// so a recorded pass latches fresh-green exactly as a real pass would — +/// which is what lets a loop-level publish-guard test arm through the real +/// green path. +#[derive(Debug)] +struct RecBashTool { + executed: std::sync::Arc>>, +} + +impl RecBashTool { + fn new() -> Self { + Self { + executed: std::sync::Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl LoopTool for RecBashTool { + fn name(&self) -> &str { + "bash" + } + + fn description(&self) -> &str { + "record-only bash" + } + + fn label(&self) -> &str { + "bash" + } + + fn parameters(&self) -> &Value { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + EMPTY.get_or_init( + || serde_json::json!({"type":"object","properties":{"command":{"type":"string"}}}), + ) + } + + fn execute<'a>( + &'a self, + _id: &'a str, + args: Value, + _signal: AbortSignal, + _on_update: LoopToolUpdate, + ) -> Pin> + Send + 'a>> + { + let executed = self.executed.clone(); + Box::pin(async move { + let command = args + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + executed.lock().unwrap().push(command); + Ok(super::super::LoopToolResult { + content: vec![serde_json::json!({"type":"text","text":"ok"})], + details: args, + terminate: None, + }) + }) + } +} + +/// A throwaway git work tree whose only untracked file is `out.json` — the +/// shape the fingerprint sees at green: every file differing from HEAD, +/// including bash-mutated / untracked output. +fn temp_git_worktree() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "dirge-pubg-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let _ = std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&dir) + .output(); + std::fs::write(dir.join("out.json"), "verified content").unwrap(); + dir +} + +fn flat_text(messages: &[LoopMessage]) -> String { + let mut out = String::new(); + for m in messages { + match m { + LoopMessage::User(u) => out.push_str(&u.text_joined()), + LoopMessage::Assistant(a) => { + for b in &a.content { + if let ContentBlock::Text { text } = b { + out.push_str(text); + } + } + } + LoopMessage::ToolResult(t) => { + for b in &t.content { + if let ContentBlock::Text { text } = b { + out.push_str(text); + } + } + } + _ => {} + } + out.push('\n'); + } + out +} + +#[tokio::test] +async fn publish_guard_blocks_destructive_bash_after_real_green() { + let rec_bash = std::sync::Arc::new(RecBashTool::new()); + let repo = temp_git_worktree(); + let mut ctx = empty_context(); + ctx.tools.push(rec_bash.clone()); + let mut cfg = build_config(); + cfg.publish_guard_mode = crate::agent::agent_loop::types::GateMode::Blocking; + cfg.verifier = Some(crate::agent::agent_loop::verifier::VerifierGate::new()); + cfg.code_review_repo = Some(repo.clone()); + + let factory = canned_factory(vec![ + tool_use_response( + "call-1", + "bash", + serde_json::json!({"command": "make check"}), + ), + tool_use_response( + "call-2", + "bash", + serde_json::json!({"command": "rm out.json"}), + ), + text_response("done"), + ]); + + let (tx, mut _rx) = mpsc::channel::(128); + let messages = run_agent_loop( + vec![user("verify and clean up")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, // summarize_fn — test default + None, // memory_provider — test default + ) + .await; + drop(tx); + + // The green-making `make check` ran; the destructive `rm` never did — + // it was suppressed pre-dispatch, which is the whole point of the guard. + let executed = rec_bash.executed.lock().unwrap().clone(); + assert_eq!( + executed, + vec!["make check".to_string()], + "rm must not dispatch" + ); + + // The model sees an error result explaining the block, tagged like the + // other harness injections. + let text = flat_text(&messages); + assert!( + text.contains("[publish-guard]"), + "blocked result must carry the harness tag: {text}" + ); + assert!( + text.contains("rm out.json") && text.contains("out.json"), + "blocked result must name the command and the protected path: {text}" + ); + assert!( + text.contains("verified-green") || text.contains("verified"), + "blocked result must say why: {text}" + ); + + let _ = std::fs::remove_dir_all(&repo); +} + +#[tokio::test] +async fn publish_guard_off_is_byte_identical_default() { + let rec_bash = std::sync::Arc::new(RecBashTool::new()); + let repo = temp_git_worktree(); + let mut ctx = empty_context(); + ctx.tools.push(rec_bash.clone()); + let mut cfg = build_config(); + cfg.publish_guard_mode = crate::agent::agent_loop::types::GateMode::Off; + cfg.verifier = Some(crate::agent::agent_loop::verifier::VerifierGate::new()); + cfg.code_review_repo = Some(repo.clone()); + + let factory = canned_factory(vec![ + tool_use_response( + "call-1", + "bash", + serde_json::json!({"command": "make check"}), + ), + tool_use_response( + "call-2", + "bash", + serde_json::json!({"command": "rm out.json"}), + ), + text_response("done"), + ]); + + let (tx, mut _rx) = mpsc::channel::(128); + let messages = run_agent_loop( + vec![user("verify and clean up")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, // summarize_fn — test default + None, // memory_provider — test default + ) + .await; + drop(tx); + + // Off mode is a pure pass-through: both commands executed. + let executed = rec_bash.executed.lock().unwrap().clone(); + assert_eq!( + executed, + vec!["make check".to_string(), "rm out.json".to_string()] + ); + // No guard message anywhere. + assert!(!flat_text(&messages).contains("[publish-guard]")); + + let _ = std::fs::remove_dir_all(&repo); +} + /// Port of pi test "should use prepareNextTurn snapshot before /// continuing" (agent-loop.test.ts:897). The hook returns a /// snapshot mutating `context`; subsequent turn observes the @@ -4510,6 +4739,125 @@ async fn todo_gate_skips_readonly_turn_even_with_unfinished_todos() { assert_eq!(gates.todo_nudges, 0, "todo budget untouched"); } +/// dirge-d0e5.2 spec case 7: the claim gate's nudge is a model-visible +/// `LoopMessage::User` in the messages the finalization poll produces — not +/// merely an emitted event. A verification claim ("4954 passed") with no +/// verification command observed this run must fire. +#[tokio::test] +async fn claim_gate_fires_model_visible_nudge_on_unsupported_verification_claim() { + let mut config = build_config(); + config.claim_gate_mode = GateMode::Advisory; + let epoch = crate::agent::tools::modified::epoch(); + let mut gates = GateStates { + run_epoch: epoch, + ..Default::default() + }; + let (emit, _emit_rx) = tokio::sync::mpsc::channel(64); + let new_messages = vec![assistant_text("All done. 4954 passed, 0 failed.")]; + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &new_messages, + &mut gates, + GateInputs::default(), + &emit, + ) + .await; + + assert_eq!(source, FollowUpSource::ClaimGate); + assert_eq!(msgs.len(), 1, "exactly one nudge"); + let LoopMessage::User(user) = &msgs[0] else { + panic!( + "claim nudge must be a model-visible User message; got {:?}", + msgs[0] + ); + }; + let text: String = user + .content + .iter() + .filter_map(|b| match b { + crate::agent::agent_loop::message::UserPart::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + assert!( + text.contains(crate::agent::agent_loop::claim_gate::CLAIM_GATE_TAG), + "nudge must carry the claim-check tag; got: {text}" + ); + assert_eq!(gates.claim_nudges, 1, "one-shot budget spent"); +} + +/// The discriminating pair (spec case 2): the SAME verification claim with a +/// verification command actually observed this run is silent. +#[tokio::test] +async fn claim_gate_is_silent_when_verification_ran() { + let mut config = build_config(); + config.claim_gate_mode = GateMode::Advisory; + let verifier = crate::agent::agent_loop::verifier::VerifierGate::new(); + verifier.record_outcome( + "bash", + &serde_json::json!({ "command": "cargo test" }), + &crate::agent::agent_loop::result::LoopToolResult { + content: vec![serde_json::json!({ "type": "text", "text": "ok" })], + details: serde_json::json!(null), + terminate: None, + }, + false, + ); + config.verifier = Some(verifier); + let epoch = crate::agent::tools::modified::epoch(); + let mut gates = GateStates { + run_epoch: epoch, + ..Default::default() + }; + let (emit, _emit_rx) = tokio::sync::mpsc::channel(64); + let new_messages = vec![assistant_text("All done. 4954 passed, 0 failed.")]; + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &new_messages, + &mut gates, + GateInputs::default(), + &emit, + ) + .await; + + assert_eq!(source, FollowUpSource::None, "evidence satisfied → silent"); + assert!(msgs.is_empty()); + assert_eq!(gates.claim_nudges, 0, "no budget spent"); +} + +/// Spec case 6: `off` mode is byte-identical — nothing fires even for a claim +/// with zero evidence. +#[tokio::test] +async fn claim_gate_off_mode_is_silent() { + let config = build_config(); // claim_gate_mode defaults to Off + let epoch = crate::agent::tools::modified::epoch(); + let mut gates = GateStates { + run_epoch: epoch, + ..Default::default() + }; + let (emit, _emit_rx) = tokio::sync::mpsc::channel(64); + let new_messages = vec![assistant_text("All done. 4954 passed, 0 failed.")]; + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &new_messages, + &mut gates, + GateInputs::default(), + &emit, + ) + .await; + + assert_eq!(source, FollowUpSource::None, "off mode must not fire"); + assert!(msgs.is_empty()); + assert_eq!(gates.claim_nudges, 0, "no budget spent"); +} + /// A turn that made a file edit with unfinished todos still fires the nudge — /// the precondition only narrows the gate, it doesn't disable it. #[tokio::test] @@ -5009,10 +5357,15 @@ async fn open_issues_gate_blocking_has_bound() { let _ = std::fs::remove_dir_all(&dir); } - -/// Zero open session issues → inert (FollowUpSource::None). +/// dirge-1elu.4 (site 2): open-issues gate in Advisory mode must inject a +/// model-visible, tagged User message — not a display-only SystemNotice the +/// model never sees. Driven through the production path: the messages come +/// out of `poll_finalization_follow_up` (the function the loop calls), and +/// the SystemNotice comes out of `emit_harness_notices` (the mirror the loop +/// runs on the returned messages). #[tokio::test] -async fn open_issues_gate_zero_open_session_issues_is_inert() { +async fn open_issues_gate_advisory_injects_model_visible_message() { + use crate::agent::agent_loop::run::OPEN_ISSUES_NUDGE_TAG; let config = build_config(); let mut gates = GateStates { critic_done: true, @@ -5023,22 +5376,24 @@ async fn open_issues_gate_zero_open_session_issues_is_inert() { open_issues_nudges: 0, ..Default::default() }; - let (review_emit, _review_emit_rx) = tokio::sync::mpsc::channel(64); + let (review_emit, mut review_emit_rx) = tokio::sync::mpsc::channel(64); - let dir = temp_dir("open-issues-zero"); + let dir = temp_dir("open-issues-advisory"); let db_path = dir.join("state.db"); - let _store = crate::extras::issue_db::IssueStore::open_at(&db_path).unwrap(); - // No issues for this session. - let sid = "open-issues-zero-sess"; + let store = crate::extras::issue_db::IssueStore::open_at(&db_path).unwrap(); + let sid = "open-issues-advisory-sess"; + store + .create("close the telemetry wiring", "", None, Some(sid), None) + .unwrap(); let (msgs, source) = poll_finalization_follow_up( &config, "sys", - &[], + &[assistant_calling("edit")], &mut gates, GateInputs { code_review_baseline: None, - open_issues_gate_mode: GateMode::Blocking, + open_issues_gate_mode: GateMode::Advisory, issue_db_path: Some(db_path.as_path()), session_id: Some(sid), }, @@ -5046,15 +5401,41 @@ async fn open_issues_gate_zero_open_session_issues_is_inert() { ) .await; - assert!(msgs.is_empty(), "zero open issues should be inert"); - assert_eq!(source, FollowUpSource::None); + assert_eq!(source, FollowUpSource::OpenIssues); + assert_eq!(gates.open_issues_nudges, 1, "one-shot budget spent"); + assert_eq!(msgs.len(), 1); + let content = match &msgs[0] { + LoopMessage::User(u) => u.text_joined(), + _ => panic!("advisory must inject a User message, got {:?}", msgs[0]), + }; + assert!( + content.starts_with(OPEN_ISSUES_NUDGE_TAG), + "expected [open-issues] tag, got: {content}" + ); + assert!( + content.contains("close or defer them when done"), + "the model must see the imperative: {content}" + ); + + // The human-visible side is unchanged: the loop mirrors the tagged User + // message to a SystemNotice — exactly what the old path emitted directly. + emit_harness_notices(&review_emit, &msgs).await; + match review_emit_rx.recv().await { + Some(LoopEvent::SystemNotice { content }) => assert!( + content.contains("close or defer them when done"), + "SystemNotice must carry the reminder text: {content}" + ), + other => panic!("expected a SystemNotice, got {other:?}"), + } let _ = std::fs::remove_dir_all(&dir); } -/// Missing db → inert (fail-open). +/// dirge-1elu.4 (test 3, site 2): the conversion must not change frequency — +/// the advisory stays one-shot; a finalization after the budget is spent +/// produces nothing. #[tokio::test] -async fn open_issues_gate_missing_db_is_inert() { +async fn open_issues_advisory_is_still_one_shot() { let config = build_config(); let mut gates = GateStates { critic_done: true, @@ -5062,34 +5443,208 @@ async fn open_issues_gate_missing_db_is_inert() { goal_reacts: 0u8, todo_nudges: MAX_TODO_NUDGES, resume_nudges: 0, - open_issues_nudges: 0, + open_issues_nudges: 1, // budget already spent this run + track_nudges: MAX_TRACK_NUDGES, // keep the untracked-work advisory silent too ..Default::default() }; let (review_emit, _review_emit_rx) = tokio::sync::mpsc::channel(64); + let dir = temp_dir("open-issues-advisory-once"); + let db_path = dir.join("state.db"); + let store = crate::extras::issue_db::IssueStore::open_at(&db_path).unwrap(); + store + .create("still open", "", None, Some("advisory-once-sess"), None) + .unwrap(); + let (msgs, source) = poll_finalization_follow_up( &config, "sys", - &[], + &[assistant_calling("edit")], &mut gates, GateInputs { code_review_baseline: None, - open_issues_gate_mode: GateMode::Blocking, + open_issues_gate_mode: GateMode::Advisory, + issue_db_path: Some(db_path.as_path()), + session_id: Some("advisory-once-sess"), + }, + &review_emit, + ) + .await; + + assert!(msgs.is_empty(), "spent budget must stay silent: {msgs:?}"); + assert_eq!(source, FollowUpSource::None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// dirge-1elu.4 (site 3): the file-edits-without-todos advisory must inject a +/// model-visible, tagged User message — not a display-only SystemNotice. +/// Shares the `[track]` tag with the boundary nudge. Driven through the +/// production path (`poll_finalization_follow_up` + the `emit_harness_notices` +/// mirror the loop runs). +#[tokio::test] +async fn untracked_work_advisory_injects_model_visible_message() { + use crate::agent::agent_loop::run::TRACK_WORK_TAG; + // The advisory requires an EMPTY active-todo list; parallel tests may + // have left items in the process-global board, so clear it first. + crate::agent::tools::todo::TODO_LIST.lock().unwrap().clear(); + let config = build_config(); + let mut gates = GateStates { + critic_done: true, + code_review_reacts: 0u8, + goal_reacts: 0u8, + todo_nudges: MAX_TODO_NUDGES, + resume_nudges: 0, + open_issues_nudges: MAX_OPEN_ISSUES_NUDGES, // keep site 2 from preempting + track_nudges: 0, + ..Default::default() + }; + let (review_emit, mut review_emit_rx) = tokio::sync::mpsc::channel(64); + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &[assistant_calling("edit")], + &mut gates, + GateInputs { + code_review_baseline: None, + open_issues_gate_mode: GateMode::Off, issue_db_path: None, - session_id: Some("some-sess"), + session_id: Some("untracked-advisory-sess"), }, &review_emit, ) .await; - assert!(msgs.is_empty(), "missing db should be inert (fail-open)"); + assert_eq!(source, FollowUpSource::Todo); + assert_eq!(gates.track_nudges, 1, "one-shot budget spent"); + assert_eq!(msgs.len(), 1); + let content = match &msgs[0] { + LoopMessage::User(u) => u.text_joined(), + _ => panic!("advisory must inject a User message, got {:?}", msgs[0]), + }; + assert!( + content.starts_with(TRACK_WORK_TAG), + "expected [track] tag, got: {content}" + ); + assert!( + content.contains("write_todo_list"), + "the model must see the imperative: {content}" + ); + + // The human-visible side is unchanged: the mirror emits the SystemNotice. + emit_harness_notices(&review_emit, &msgs).await; + match review_emit_rx.recv().await { + Some(LoopEvent::SystemNotice { content }) => assert!( + content.contains("write_todo_list"), + "SystemNotice must carry the reminder: {content}" + ), + other => panic!("expected a SystemNotice, got {other:?}"), + } +} + +/// dirge-1elu.4 (test 3, site 3): the conversion must not change frequency — +/// a finalization after the one-shot budget is spent produces nothing. +#[tokio::test] +async fn untracked_work_advisory_is_still_one_shot() { + crate::agent::tools::todo::TODO_LIST.lock().unwrap().clear(); + let config = build_config(); + let mut gates = GateStates { + critic_done: true, + code_review_reacts: 0u8, + goal_reacts: 0u8, + todo_nudges: MAX_TODO_NUDGES, + resume_nudges: 0, + open_issues_nudges: MAX_OPEN_ISSUES_NUDGES, + track_nudges: 1, // budget already spent this run + ..Default::default() + }; + let (review_emit, _review_emit_rx) = tokio::sync::mpsc::channel(64); + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &[assistant_calling("edit")], + &mut gates, + GateInputs { + code_review_baseline: None, + open_issues_gate_mode: GateMode::Off, + issue_db_path: None, + session_id: Some("untracked-once-sess"), + }, + &review_emit, + ) + .await; + + assert!(msgs.is_empty(), "spent budget must stay silent: {msgs:?}"); assert_eq!(source, FollowUpSource::None); } -/// Advisory mode emits a SystemNotice when issues are open but does -/// NOT re-enter the loop. +/// dirge-1elu.4 (test 5 / site 4): max_turns truncation stays notice-only — +/// the run is ending, there is no next model turn to steer, so it must NOT +/// become a steering message. Regression guard against converting it: the +/// notice goes to the user, the transcript records the truncation, and the +/// factory is called exactly once (the notice does not re-enter the loop). #[tokio::test] -async fn open_issues_gate_advisory_emits_notice_but_does_not_reenter() { +async fn max_turns_truncation_stays_notice_only() { + use crate::agent::agent_loop::run::MAX_TURNS_NOTICE_PREFIX; + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let calls2 = calls.clone(); + let factory: StreamFn = std::sync::Arc::new(move |_ctx, _opts| { + calls2.fetch_add(1, Ordering::SeqCst); + let msg = tool_use_response("call-1", "bash", serde_json::json!({"command": "true"})); + let reason = msg.stop_reason; + Box::pin(futures::stream::iter(vec![StreamEvent::Done { + reason, + message: msg, + usage: None, + }])) + }); + let mut ctx = empty_context(); + ctx.tools.push(std::sync::Arc::new(RecBashTool::new())); + let mut cfg = build_config(); + cfg.max_turns = Some(1); + + let (tx, mut rx) = mpsc::channel::(256); + let messages = run_agent_loop( + vec![user("start")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, // summarize_fn — test default + None, // memory_provider — test default + ) + .await; + drop(tx); + + // Exactly one model turn: the notice did not re-enter the loop. + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "truncation must not steer another turn" + ); + // The notice reached the user as a SystemNotice. + let events = drain(&mut rx).await; + assert!( + events.iter().any(|e| matches!( + e, + LoopEvent::SystemNotice { content } + if content.starts_with(MAX_TURNS_NOTICE_PREFIX) + )), + "expected the max_turns SystemNotice among {events:?}" + ); + // The transcript records the truncation (contract nicety) — a record, + // not a steer. + assert!( + flat_text(&messages).contains(MAX_TURNS_NOTICE_PREFIX), + "truncation notice should be recorded in the returned transcript" + ); +} + +/// Zero open session issues → inert (FollowUpSource::None). +#[tokio::test] +async fn open_issues_gate_zero_open_session_issues_is_inert() { let config = build_config(); let mut gates = GateStates { critic_done: true, @@ -5100,25 +5655,22 @@ async fn open_issues_gate_advisory_emits_notice_but_does_not_reenter() { open_issues_nudges: 0, ..Default::default() }; - let (review_emit, mut review_emit_rx) = tokio::sync::mpsc::channel(64); + let (review_emit, _review_emit_rx) = tokio::sync::mpsc::channel(64); - let dir = temp_dir("open-issues-advisory"); + let dir = temp_dir("open-issues-zero"); let db_path = dir.join("state.db"); - let store = crate::extras::issue_db::IssueStore::open_at(&db_path).unwrap(); - let sid = "open-issues-advisory-sess"; - store - .create("wire up telemetry", "", None, Some(sid), None) - .unwrap(); + let _store = crate::extras::issue_db::IssueStore::open_at(&db_path).unwrap(); + // No issues for this session. + let sid = "open-issues-zero-sess"; - // dirge-g2ex: open-issues gate now requires the turn to have made file edits. let (msgs, source) = poll_finalization_follow_up( &config, "sys", - &[assistant_calling("edit")], + &[], &mut gates, GateInputs { code_review_baseline: None, - open_issues_gate_mode: GateMode::Advisory, + open_issues_gate_mode: GateMode::Blocking, issue_db_path: Some(db_path.as_path()), session_id: Some(sid), }, @@ -5126,25 +5678,46 @@ async fn open_issues_gate_advisory_emits_notice_but_does_not_reenter() { ) .await; - // Advisory does NOT re-enter (returns empty messages). - assert!(msgs.is_empty(), "advisory should not re-enter"); + assert!(msgs.is_empty(), "zero open issues should be inert"); assert_eq!(source, FollowUpSource::None); - assert_eq!(gates.open_issues_nudges, 1, "counts the advisory"); - - // Check that a SystemNotice was emitted. - match review_emit_rx.try_recv() { - Ok(crate::agent::agent_loop::message::LoopEvent::SystemNotice { content }) => { - assert!( - content.contains("issue(s) from this session are still open"), - "{content}" - ); - } - other => panic!("expected SystemNotice, got {other:?}"), - } let _ = std::fs::remove_dir_all(&dir); } +/// Missing db → inert (fail-open). +#[tokio::test] +async fn open_issues_gate_missing_db_is_inert() { + let config = build_config(); + let mut gates = GateStates { + critic_done: true, + code_review_reacts: 0u8, + goal_reacts: 0u8, + todo_nudges: MAX_TODO_NUDGES, + resume_nudges: 0, + open_issues_nudges: 0, + ..Default::default() + }; + let (review_emit, _review_emit_rx) = tokio::sync::mpsc::channel(64); + + let (msgs, source) = poll_finalization_follow_up( + &config, + "sys", + &[], + &mut gates, + GateInputs { + code_review_baseline: None, + open_issues_gate_mode: GateMode::Blocking, + issue_db_path: None, + session_id: Some("some-sess"), + }, + &review_emit, + ) + .await; + + assert!(msgs.is_empty(), "missing db should be inert (fail-open)"); + assert_eq!(source, FollowUpSource::None); +} + // ── Blocking review dedupe (dirge-9b2k): skip re-reviewing an unchanged diff ─ // // The unified finalization judge is stateless, so a Blocking run that persists @@ -6967,3 +7540,126 @@ fn hallucinated_names_accumulate_across_calls() { assert_eq!(tally.errored_tool_calls(), 3); assert_eq!(tally.tool_calls(), 3); } + +// dirge-1elu.6 test 4: the tally's boundary recording is observation only. +// Two identical runs of a scenario where a finalization gate (the goal +// judge) fires must produce identical messages and turn counts — the new +// bookkeeping changes nothing — and each run's `dirge::gates` line must +// carry the populated `boundaries=` field (recorded, and ignored). +#[tokio::test] +async fn boundary_recording_does_not_change_loop_output() { + use crate::agent::agent_loop::critic::CriticFn; + use crate::agent::agent_loop::gate_tally::tests::field_capture; + + let run_once = || async { + let (cap, _guard) = field_capture(); + let mut ctx = empty_context(); + ctx.tools.push(std::sync::Arc::new(RecBashTool::new())); + let mut cfg = build_config(); + cfg.goal = Some("all tests pass and committed".into()); + cfg.goal_fn = Some(std::sync::Arc::new(|_p| { + Box::pin(async { Ok("GOAL: UNMET\n- tests still failing".to_string()) }) + })); + let factory = canned_factory(vec![text_response("done")]); + let (tx, _rx) = tokio::sync::mpsc::channel(128); + let msgs = run_agent_loop( + vec![user("task")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, + None, + ) + .await; + drop(tx); + (msgs, cap.snapshot()) + }; + + let (msgs_a, log_a) = run_once().await; + let (msgs_b, log_b) = run_once().await; + + assert_eq!( + flat_text(&msgs_a), + flat_text(&msgs_b), + "boundary recording must not change the loop's output" + ); + let gates_a: Vec<&str> = log_a + .lines() + .filter(|l| l.contains("dirge::gates")) + .collect(); + assert!( + !gates_a.is_empty(), + "the run must emit a dirge::gates line: {log_a}" + ); + assert!( + gates_a.iter().any(|l| l.contains("boundaries=")), + "the tally line must carry the populated boundaries= field: {gates_a:?}" + ); + assert!( + gates_a.iter().any(|l| l.contains("Goal")), + "the goal gate must have fired and been recorded: {gates_a:?}" + ); + let gates_b: Vec<&str> = log_b + .lines() + .filter(|l| l.contains("dirge::gates")) + .collect(); + assert!( + !gates_b.is_empty(), + "the second run must also emit a dirge::gates line: {log_b}" + ); +} + +/// dirge-1elu.7: the PRODUCTION path for the run-status handoff. A loop that +/// edits code and runs a passing check must leave its status in the +/// session-keyed slot that the post-session pass reads. +/// +/// This is the half a direct `record`/`take` unit test cannot prove: those +/// exercise the slot, this exercises the WIRING from `finish_tally` into it. +/// Without this, the slot could work perfectly while nothing ever wrote to it +/// (docs/verification-discipline.md, "Signal never fed"). +#[tokio::test] +async fn finish_tally_hands_the_run_status_to_the_session_slot() { + let repo = temp_git_worktree(); + let mut ctx = empty_context(); + ctx.tools.push(std::sync::Arc::new(RecBashTool::new())); + let mut cfg = build_config(); + cfg.session_id = Some("s-production-handoff".to_string()); + cfg.verifier = Some(crate::agent::agent_loop::verifier::VerifierGate::new()); + cfg.code_review_repo = Some(repo.clone()); + + // Nothing left over from an earlier test in this process. + let _ = crate::agent::agent_loop::verifier::take_run_verification("s-production-handoff"); + + let factory = canned_factory(vec![ + tool_use_response( + "call-1", + "bash", + serde_json::json!({"command": "make check"}), + ), + text_response("done"), + ]); + + let (tx, mut _rx) = mpsc::channel::(128); + let _ = run_agent_loop( + vec![user("check it")], + ctx, + cfg, + AbortSignal::new(), + &tx, + &factory, + None, + None, + ) + .await; + drop(tx); + + let status = crate::agent::agent_loop::verifier::take_run_verification("s-production-handoff"); + assert!( + status.is_some(), + "the run must hand its verification status to the session slot; got None" + ); + + let _ = std::fs::remove_dir_all(&repo); +} diff --git a/src/agent/agent_loop/steering.rs b/src/agent/agent_loop/steering.rs index e7cfa294..bfbc1eea 100644 --- a/src/agent/agent_loop/steering.rs +++ b/src/agent/agent_loop/steering.rs @@ -394,6 +394,8 @@ mod tests { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, diff --git a/src/agent/agent_loop/tools_tests.rs b/src/agent/agent_loop/tools_tests.rs index 24e7d469..8abe04a1 100644 --- a/src/agent/agent_loop/tools_tests.rs +++ b/src/agent/agent_loop/tools_tests.rs @@ -244,6 +244,8 @@ fn build_config() -> LoopConfig { open_issues_gate_mode: crate::agent::agent_loop::types::GateMode::Off, verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, diff --git a/src/agent/agent_loop/types.rs b/src/agent/agent_loop/types.rs index 3806129f..ac0c9881 100644 --- a/src/agent/agent_loop/types.rs +++ b/src/agent/agent_loop/types.rs @@ -143,10 +143,21 @@ pub struct TurnUpdate { /// (dirge-ksjl), and any future opt-in finalization gates that need an /// on/off/nagging toggle. /// +/// Advisory vs Blocking is a POLICY choice, not a delivery mechanism: both +/// may inject a model-visible `LoopMessage::User` (tagged, so the TUI +/// attributes it to the system and `emit_harness_notices` mirrors it to a +/// `SystemNotice`). The difference is how hard the gate pushes back. +/// /// - `Off` — the gate is not armed: zero cost. -/// - `Advisory` *(default)* — surface findings/reminders as a non-blocking -/// `SystemNotice`. It never re-enters the loop and never spends a react -/// budget, so a tight debug loop is never held up waiting on it. +/// - `Advisory` *(default)* — non-blocking and one-shot: fires at most once +/// per run (per its own budget), never spends a react budget, and never +/// repeatedly re-enters the loop on the same finding, so a tight debug +/// loop is never held up waiting on it. Whether the one shot is a +/// model-visible message is a SEPARATE decision: if the gate's text is an +/// imperative steering what the model does next, it must be a tagged +/// `LoopMessage::User` — a display-only `SystemNotice` the model never +/// sees changes nothing (dirge-1elu.4, arXiv:2604.25850v4 §C.1.4/§C.2.4). +/// Only FYI-for-the-human text belongs in a bare notice. /// - `Blocking` — await the gate and re-enter the loop on relevant /// findings, bounded by a per-gate react cap. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -578,6 +589,22 @@ pub struct LoopConfig { /// writes. Set by `build_agent` from `Config::resolve_safe_state_abort_mode`. pub safe_state_abort_mode: SafeStateMode, + /// How the publish-state guard engages (dirge-1elu.1). `Off` *(default)* + /// is byte-identical to the loop without the guard. `Advisory` injects a + /// model-visible warning (bounded at 2 per run) when a command would + /// discard verified-green work; `Blocking` suppresses the call pre-dispatch + /// and returns an error naming the protected paths. Set by `build_agent` + /// from `Config::resolve_publish_guard_mode`. + pub publish_guard_mode: GateMode, + + /// dirge-d0e5.2: the deterministic claim/evidence gate's engagement mode + /// (`off`/`advisory`/`blocking`). `off` *(default)* is byte-identical to + /// the loop without the gate. Both `advisory` and `blocking` deliver the + /// same one-shot model-visible nudge — the gate only ever speaks, it + /// cannot block finalization, so the tri-state is really "on or off" with + /// room for a future hard mode. Set from `Config::resolve_claim_gate_mode`. + pub claim_gate_mode: GateMode, + /// Active session id for the open-issues gate and tools that need /// session-scoping. `None` in review/curator sub-runners and most /// tests — the gate is inert without it. @@ -766,6 +793,7 @@ impl std::fmt::Debug for LoopConfig { .field("open_issues_gate_mode", &self.open_issues_gate_mode) .field("verification_tiers_mode", &self.verification_tiers_mode) .field("safe_state_abort_mode", &self.safe_state_abort_mode) + .field("publish_guard_mode", &self.publish_guard_mode) .field("progress", &self.progress.is_some()) .field("session_id", &self.session_id) .field("goal_fn", &self.goal_fn.as_ref().map(|_| "")) @@ -819,6 +847,8 @@ impl Clone for LoopConfig { open_issues_gate_mode: self.open_issues_gate_mode, verification_tiers_mode: self.verification_tiers_mode, safe_state_abort_mode: self.safe_state_abort_mode, + publish_guard_mode: self.publish_guard_mode, + claim_gate_mode: self.claim_gate_mode, progress: self.progress.clone(), session_id: self.session_id.clone(), goal_fn: self.goal_fn.clone(), @@ -881,6 +911,8 @@ impl LoopConfig { open_issues_gate_mode: GateMode::Off, verification_tiers_mode: GateMode::Off, safe_state_abort_mode: SafeStateMode::Off, + publish_guard_mode: GateMode::Off, + claim_gate_mode: GateMode::Off, progress: None, session_id: None, goal_fn: None, diff --git a/src/agent/agent_loop/verifier.rs b/src/agent/agent_loop/verifier.rs index 95a9b9e6..56d813cc 100644 --- a/src/agent/agent_loop/verifier.rs +++ b/src/agent/agent_loop/verifier.rs @@ -124,6 +124,107 @@ pub enum VerificationTier { Slow, } +/// Cross-boundary handoff of a run's final [`VerificationStatus`] +/// (dirge-1elu.7). +/// +/// # Why a process-global, and why keyed by session +/// +/// The status is computed inside `run_loop` from a [`VerifierGate`] the loop +/// owns; the consumer is the post-session pass in `ui::run_handlers::done`, +/// which runs on the UI side off an `AgentEvent`. Nothing carries a value +/// between those two points today, and the obvious routes all cost more than +/// the signal is worth: +/// +/// - returning it from `run_agent_loop` does not reach `handle_done` at all, +/// which fires on the EVENT, not the return; +/// - carrying it on `AgentEnd` -> `AgentEvent::Done` puts a verifier-internal +/// type into the UI/stream event contract and touches ~15 construction sites +/// across subagents, plan runtime, and background review, none of which have +/// anything to do with verification; +/// - "persist it on the session" does not avoid the problem, it relocates it: +/// the session is owned by the UI, so the value still has to travel first. +/// +/// So this is the same shape as [`crate::agent::tools::modified`] and +/// `crate::agent::tools::snapshots`, which are process-globals for the same +/// structural reason. The difference is the key: those are single-slot, and a +/// single slot here would let a subagent or MCP-delegate run, which share the +/// process, hand its status to the wrong session. Keying by session id makes a +/// wrong-session read impossible by construction rather than by convention. +/// +/// Staleness is handled by taking on read AND recording unconditionally +/// (including `None`): a run that verified nothing clears its entry rather +/// than letting an earlier green be re-read. +mod run_status { + use super::VerificationStatus; + use crate::sync_util::LockExt; + use std::collections::HashMap; + use std::sync::{LazyLock, Mutex}; + + /// Bound on retained entries. Only a session that never reads its own + /// status leaks one, so this is a backstop, not a working limit. + const MAX_RETAINED: usize = 64; + + static LAST_RUN: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + /// Record the status a run finished with. Called once per run from + /// `finish_tally`. Recording `None` REMOVES any prior entry: a run that + /// ran no verification must not leave an earlier run's green behind. + pub fn record(session_id: Option<&str>, status: Option) { + let Some(id) = session_id else { + return; + }; + let mut map = LAST_RUN.lock_ignore_poison(); + match status { + Some(s) => { + if map.len() >= MAX_RETAINED && !map.contains_key(id) { + map.clear(); + } + map.insert(id.to_string(), s); + } + None => { + map.remove(id); + } + } + } + + /// Take this session's status, clearing it. Consuming, so a later run that + /// recorded nothing cannot re-read a stale value. + pub fn take(session_id: &str) -> Option { + LAST_RUN.lock_ignore_poison().remove(session_id) + } +} + +pub use run_status::{record as record_run_verification, take as take_run_verification}; + +/// Cap on the verification commands kept for the critic's evidence block +/// (latest-first; dirge-d0e5.3). +const MAX_OBSERVED_COMMANDS: usize = 6; + +/// Slack subtracted from the captured run start before comparing a script's +/// mtime against it (dirge-1elu.2). +/// +/// Linux updates filesystem timestamps from a clock the kernel caches at +/// timer-tick granularity, so a file written microseconds AFTER +/// `SystemTime::now()` can land an mtime marginally BEFORE it. macOS's +/// finer-grained clock hides this, which is why it only ever surfaced on +/// Linux CI: a script the agent had just written read as pre-existing and +/// latched a green it should have declined. +/// +/// One second is far beyond any tick granularity and far below the interval +/// that would sweep in genuinely pre-existing files. The failure direction is +/// the safe one either way: erring toward "authored this run" declines a green +/// and asks again, which is the direction this whole gate already prefers. +const MTIME_SLACK: std::time::Duration = std::time::Duration::from_secs(1); + +/// The run-start instant to compare mtimes against, backdated by +/// [`MTIME_SLACK`]. +fn run_start_marker() -> std::time::SystemTime { + std::time::SystemTime::now() + .checked_sub(MTIME_SLACK) + .unwrap_or_else(std::time::SystemTime::now) +} + /// Per-run verifier gate. See module docs. #[derive(Debug)] pub struct VerifierGate { @@ -167,6 +268,16 @@ struct Inner { /// The project gate has been seen PASS this run. A failing gate run /// must not set this — the red dominates downstream. ran_project_gate: bool, + /// When this gate was built — the run start. A script file whose mtime + /// is at-or-after this is treated as authored by the agent THIS run + /// (dirge-1elu.2). `None` only in the derived default; every + /// constructor stamps it. + run_started_at: Option, + /// The most recent verification commands and whether each failed, + /// latest-first. Feeds the critic's evidence block (dirge-d0e5.3) so the + /// judge can check claims against the commands that actually ran; capped + /// to keep the prompt block small. + observed_commands: std::collections::VecDeque<(String, bool)>, } impl Inner { @@ -194,7 +305,10 @@ impl VerifierGate { #[allow(dead_code)] pub fn new() -> Arc { Arc::new(Self { - inner: Mutex::new(Inner::default()), + inner: Mutex::new(Inner { + run_started_at: Some(run_start_marker()), + ..Inner::default() + }), }) } @@ -220,6 +334,7 @@ impl VerifierGate { inner: Mutex::new(Inner { project_gate: project_gate.as_deref().and_then(gate_signature), ci_commands, + run_started_at: Some(run_start_marker()), ..Inner::default() }), }) @@ -273,8 +388,31 @@ impl VerifierGate { if masks_failure(command) && !failed { return; } + // dirge-1elu.2: decline a green that rests SOLELY on a + // script-name match when the script was authored THIS + // run. A self-written validator proves nothing — the + // generator and the validator can share the same wrong + // assumptions — so leave `ran_verification` false, keep + // `edits_since_verify` counting, and let the gate ask + // again. A self-authored script that reports FAILURE is + // still trustworthy in that direction: the red is + // recorded below. A command that also carries a real + // word marker (`./check.sh && cargo test`) stays + // recognized — that's a wrapper, not a proxy. + if !failed + && is_script_name_only(command) + && script_name_paths(command) + .iter() + .any(|p| script_is_agent_authored(command, p, inner.run_started_at)) + { + return; + } inner.ran_verification = true; inner.verification_failed = failed; + inner + .observed_commands + .push_front((command.to_string(), failed)); + inner.observed_commands.truncate(MAX_OBSERVED_COMMANDS); // Any verification attempt clears the mid-run counter — // the model did go and check, whatever the outcome. inner.edits_since_verify = 0; @@ -349,6 +487,23 @@ impl VerifierGate { /// Code edits since the last verification command of any tier. Feeds /// the mid-run nudge; never mutates the gate (same read-only contract /// as [`VerifierGate::status`]). + pub fn ran_verification(&self) -> bool { + self.inner.lock_ignore_poison().ran_verification + } + + /// Verification commands observed this run, latest-first, each with + /// whether it failed. Empty when no build/test command ran (or when + /// masking or a self-authored script declined the green). Feeds the + /// critic's evidence block (dirge-d0e5.3). + pub fn observed_commands(&self) -> Vec<(String, bool)> { + self.inner + .lock_ignore_poison() + .observed_commands + .iter() + .cloned() + .collect() + } + pub fn edits_since_verify(&self) -> u32 { self.inner.lock_ignore_poison().edits_since_verify } @@ -565,6 +720,24 @@ fn masks_failure(command: &str) -> bool { } return true; } + b'\n' => { + // A newline between commands is an exact synonym for `;` + // (dirge-1elu.3): the exit status belongs to the LAST + // command, so an earlier failure is discarded. Only masks + // when something follows — a trailing newline does not. + // + // A backslash immediately before the newline is a line + // continuation, not a separator — `cargo test && \ + // echo done` short-circuits and its status is honest. + if i > 0 && bytes[i - 1] == b'\\' { + i += 1; + continue; + } + if command[i + 1..].trim().is_empty() { + return false; + } + return true; + } b'&' => { // `&&` is fine (short-circuits); a lone `&` backgrounds. if bytes.get(i + 1) == Some(&b'&') { @@ -779,11 +952,40 @@ fn cargo_tier(args: &[&str]) -> VerificationTier { /// signature, so matching is robust to env prefixes and flag placement /// without being string-identical. #[derive(Debug, Clone, PartialEq, Eq)] -struct GateSignature { +pub(crate) struct GateSignature { program: String, subcommand: Option, } +impl GateSignature { + /// dirge-1elu.5: wire form for the `command_ran:` memory expectation. + /// ` ` — a program is a single shell word, so a + /// first-space split is unambiguous. + pub(crate) fn to_wire(&self) -> String { + match &self.subcommand { + Some(s) => format!("{} {}", self.program, s), + None => self.program.clone(), + } + } + + pub(crate) fn from_wire(s: &str) -> Option { + let mut it = s.splitn(2, ' '); + let program = it.next()?.trim().to_string(); + if program.is_empty() { + return None; + } + let subcommand = it + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from); + Some(GateSignature { + program, + subcommand, + }) + } +} + /// Tokenize a shell command keeping quoted values as ONE word /// (`RUSTFLAGS="-D warnings"` is a single env assignment, not three /// words). Quote characters are dropped; `None` when the segment has no @@ -854,7 +1056,7 @@ fn gate_signatures(command: &str) -> Vec { /// The single signature of a command treated as a gate SPECIFICATION (the /// `verification_command` config value). A spec naming a chain is taken by /// its last segment: `cargo fmt && cargo clippy` specifies the clippy gate. -fn gate_signature(command: &str) -> Option { +pub(crate) fn gate_signature(command: &str) -> Option { gate_signatures(command).pop() } @@ -949,13 +1151,24 @@ fn js_runner_tier(args: &[&str]) -> VerificationTier { /// Two-word markers whose leading word isn't a marker on its own. const PAIR_MARKERS: &[(&str, &str)] = &[("go", "vet"), ("go", "run"), ("go", "test")]; -fn segment_is_verification(segment: &str) -> bool { +/// Why a segment counted as verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SegmentMatch { + /// No marker and no script-name match. + None, + /// A word or pair marker matched (`cargo test`, `pytest`, `make check`). + Word, + /// Only the path-shaped command word matched (`./check.sh`). + ScriptName, +} + +fn segment_match(segment: &str) -> SegmentMatch { let tokens: Vec = segment .split_whitespace() .map(|t| t.to_ascii_lowercase()) .collect(); if tokens.iter().any(|t| NON_VERIFY.contains(&t.as_str())) { - return false; + return SegmentMatch::None; } // Whole-word markers. A `--check`-style flag is its dash-stripped // word, so `prettier --check .` and `cmake --build` register. @@ -963,20 +1176,120 @@ fn segment_is_verification(segment: &str) -> bool { .iter() .any(|t| WORD_MARKERS.contains(&t.trim_start_matches('-'))) { - return true; + return SegmentMatch::Word; } if tokens .windows(2) .any(|w| PAIR_MARKERS.contains(&(w[0].as_str(), w[1].as_str()))) { - return true; + return SegmentMatch::Word; } // The command word may be a path to a repo script: match markers // inside its basename, split on `-`/`_`/`.`, accepting a plural form, // so `./run-tests.sh` and `scripts/lint.sh` register. Only the // executed word gets this treatment — an argument like `ls tests/` // must not count (dirge-eg37). - command_word(&tokens).is_some_and(script_name_is_verification) + if command_word(&tokens).is_some_and(script_name_is_verification) { + return SegmentMatch::ScriptName; + } + SegmentMatch::None +} + +/// Behavioural wrapper for the call sites that only need the bool. +fn segment_is_verification(segment: &str) -> bool { + !matches!(segment_match(segment), SegmentMatch::None) +} + +/// True when a command is recognized as verification ONLY through the +/// script-name path — no word/pair marker appears in any segment +/// (dirge-1elu.2). A command that also carries a real marker stays +/// recognized regardless of provenance: a self-written `check.sh` that +/// invokes `cargo test` is a wrapper, not a proxy. +fn is_script_name_only(command: &str) -> bool { + let mut script = false; + for seg in command.split(['&', '|', ';', '\n']) { + match segment_match(seg) { + SegmentMatch::Word => return false, + SegmentMatch::ScriptName => script = true, + SegmentMatch::None => {} + } + } + script +} + +/// The path-shaped command words of the segments that matched only by +/// script name, in their ORIGINAL case — paths are case-sensitive, so the +/// lowercased tokens used for marker matching can't be reused here. +fn script_name_paths(command: &str) -> Vec { + command + .split(['&', '|', ';', '\n']) + .filter(|seg| matches!(segment_match(seg), SegmentMatch::ScriptName)) + .filter_map(|seg| { + let tokens: Vec = seg.split_whitespace().map(str::to_string).collect(); + command_word(&tokens).map(std::path::PathBuf::from) + }) + .collect() +} + +/// Did the agent author `path` THIS run? Two OR'd sources — the +/// modified-files registry (write/edit/apply_patch) and the file's mtime +/// at-or-after run start (a bash heredoc the registry never sees). If +/// neither can answer (missing file, unreadable mtime, no run start), +/// treat it as NOT authored — degrade toward today's behaviour, never +/// toward a new false nag (dirge-1elu.2). +/// Resolve a command's script path the way bash would: fold the command's +/// leading `cd`/`pushd` segments onto the run cwd — the SAME lexical folder +/// the permission layer uses (`fold_cd_dirs`), not a second implementation +/// (dirge-1elu.2 follow-up). `cd sub && ./check.sh` resolves `./check.sh` +/// against `sub/`. Without the semantic-bash feature the path resolves +/// against the run cwd exactly as before. +#[cfg(feature = "semantic-bash")] +fn resolve_script_path(command: &str, path: &std::path::Path) -> std::path::PathBuf { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let segments: Vec = command + .split(['&', '|', ';', '\n']) + .map(str::to_string) + .collect(); + let effective = + crate::agent::tools::bash::check::fold_cd_dirs(&cwd.to_string_lossy(), &segments); + if path.is_absolute() { + path.to_path_buf() + } else { + std::path::Path::new(&effective).join(path) + } +} + +/// Non-semantic-bash builds keep today's behaviour: no cd folding. +#[cfg(not(feature = "semantic-bash"))] +fn resolve_script_path(_command: &str, path: &std::path::Path) -> std::path::PathBuf { + path.to_path_buf() +} + +fn script_is_agent_authored( + command: &str, + path: &std::path::Path, + run_started_at: Option, +) -> bool { + // Registry entries are canonical absolute paths, so a relative command + // word (`./check.sh`) must canonicalize against the effective run cwd to + // match — which is also the cwd bash inherits, since the tool spawns + // without a `current_dir` override. `cd`/`pushd` segments are folded in + // first (dirge-1elu.2 follow-up). The raw spelling is checked too, for + // entries recorded before canonicalization succeeded. + let resolved = resolve_script_path(command, path); + let canonical = crate::permission::path::canonical_or_self(&resolved); + let in_registry = crate::agent::tools::modified::MODIFIED_FILES + .lock() + .is_ok_and(|s| s.contains_key(&canonical) || s.contains_key(&resolved)); + if in_registry { + return true; + } + let Some(run_start) = run_started_at else { + return false; + }; + std::fs::metadata(&canonical) + .and_then(|m| m.modified()) + .is_ok_and(|mtime| mtime >= run_start) } /// First token that isn't a `VAR=value` environment prefix. @@ -2406,6 +2719,7 @@ mod tests { "cargo test; echo done", "cargo test &", "cargo clippy 2>&1 | head -20", + "cargo test\necho done", ] { assert!(masks_failure(cmd), "should be detected as masking: {cmd}"); } @@ -2423,6 +2737,8 @@ mod tests { "cargo test 2>&1", "RUSTFLAGS=\"-D warnings\" cargo clippy --all-targets", "cargo test;", + "cargo test\n", + "cargo test && \\\necho done", ] { assert!(!masks_failure(cmd), "must not be flagged: {cmd}"); } @@ -2448,6 +2764,24 @@ mod tests { } } + /// The bug, end to end (dirge-1elu.3): a newline-chained validation + /// block whose exit status belongs to a trailing `echo` must NOT latch + /// green — every assertion may have failed while the status is honestly + /// 0. Same treatment as the `;` shape: success is not recorded. + #[test] + fn newline_chained_validation_block_does_not_latch_green() { + let cmd = "diff expected.txt actual.txt\ncmp -s a.bin b.bin\ntest -f out/report.json\necho \"all checks passed\""; + assert!(masks_failure(cmd), "the block's status is the echo's"); + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path":"src/a.rs"}), &ok_result(), false); + g.record_outcome("bash", &json!({"command": cmd}), &ok_result(), false); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "a masked success proves nothing and must not read as green" + ); + } + /// A masked command that still reports FAILURE is trustworthy in that /// direction — something in the chain genuinely failed — so the red is /// recorded. Declining it would let a real failure go unreported. @@ -2464,6 +2798,23 @@ mod tests { assert_eq!(g.status(GateMode::Off), VerificationStatus::VerifiedRed); } + /// A masked multi-line command that still reports FAILURE is + /// trustworthy in that direction — something in the chain genuinely + /// failed — so the red is recorded (dirge-1elu.3, same asymmetry as the + /// `;` shape). + #[test] + fn masked_newline_failure_is_still_recorded_as_red() { + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path":"src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command":"cargo test\necho done"}), + &ok_result(), + true, + ); + assert_eq!(g.status(GateMode::Off), VerificationStatus::VerifiedRed); + } + /// An unmasked command is unaffected — the common path stays green. #[test] fn unmasked_success_still_latches_green() { @@ -2505,4 +2856,419 @@ mod tests { "a masked check did not verify anything, so the counter must stand" ); } + + // ── dirge-1elu.2: decline green from agent-authored scripts ──────────── + // A model can write its own validator, run it, watch it exit 0, and + // satisfy the gate without ever running the project's tests. A script + // authored THIS RUN proves nothing (generator and validator can share + // the same wrong assumptions), so the green is declined — except a + // self-authored script reporting FAILURE, which is still trustworthy + // in that direction. + + static SCRIPT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + /// A unique temp script path (per test) so parallel tests never share + /// a file. The basename carries a marker word so the command registers + /// as verification via the script-name path. + fn script_path(marker: &str) -> std::path::PathBuf { + let n = SCRIPT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!("dirge-elu2-{}-{n}-{marker}.sh", std::process::id())) + } + + /// Pin a file's mtime to before this gate's run start, so the + /// modified-files registry is the ONLY source that can answer + /// "authored". + fn predate(file: &std::path::Path) { + std::fs::File::open(file) + .unwrap() + .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(3600)) + .unwrap(); + } + + /// The bug, reproduced: the agent writes `check.sh` THIS run (in the + /// modified-files registry) and runs it; exit 0 must NOT read as green. + #[test] + fn agent_authored_script_does_not_latch_green() { + let script = script_path("check"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + predate(&script); // only the registry can answer "authored" + crate::agent::tools::modified::mark_modified(&script); + + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "a script authored this run proves nothing — must not read as green" + ); + let _ = std::fs::remove_file(&script); + } + + /// The bash-authored path: the file is only detectable by mtime — a + /// `cat > check.sh` heredoc the registry never sees. + #[test] + fn bash_authored_script_does_not_latch_green() { + let script = script_path("validate"); + let g = VerifierGate::new(); // gate FIRST: mtime must be >= run start + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + assert!( + crate::agent::tools::modified::MODIFIED_FILES + .lock() + .is_ok_and(|s| !s.contains_key(&script)) + ); + + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "a script created by bash this run must not read as green" + ); + let _ = std::fs::remove_file(&script); + } + + /// Over-detection guard: a legitimate repo script that PREDATES the + /// run (old mtime, not in the registry) still counts. + #[test] + fn pre_existing_repo_script_still_latches_green() { + let script = script_path("run-tests"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + predate(&script); + let g = VerifierGate::new(); // gate AFTER: the script predates the run + + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::VerifiedGreen, + "a repo script that predates the run must still count" + ); + let _ = std::fs::remove_file(&script); + } + + /// Wrapper, not proxy: a self-authored script whose command line ALSO + /// carries a real word marker stays recognized regardless of who wrote + /// it. + #[test] + fn self_authored_script_with_real_marker_still_counts() { + let script = script_path("check"); + std::fs::write(&script, "#!/bin/sh\ncargo test\nexit 0\n").unwrap(); + predate(&script); + crate::agent::tools::modified::mark_modified(&script); + + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + let command = format!("{} && cargo test", script.to_string_lossy()); + g.record_outcome("bash", &json!({"command": command}), &ok_result(), false); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::VerifiedGreen, + "a real word marker is present — wrapper, not proxy" + ); + let _ = std::fs::remove_file(&script); + } + + /// The asymmetry: a self-authored script reporting FAILURE is still + /// trustworthy in that direction — record the red. + #[test] + fn agent_authored_script_failure_is_still_red() { + let script = script_path("check"); + std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap(); + crate::agent::tools::modified::mark_modified(&script); + + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + true, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::VerifiedRed, + "a self-authored script reporting failure is trustworthy" + ); + let _ = std::fs::remove_file(&script); + } + + /// Unknown provenance: the path doesn't exist on disk and is not in the + /// registry — behave exactly as today. + #[test] + fn script_with_unknown_provenance_behaves_as_today() { + let script = script_path("check"); // never written to disk + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::VerifiedGreen, + "can't tell who wrote it — degrade toward today's behaviour" + ); + } + + /// The same-call heredoc+exec shape: `cat > check.sh <<'EOF'` writes the + /// script and a later line of the SAME bash call runs it. The newline + /// split lands the exec word in its own segment, so it must be caught + /// like any other script-name run — the mtime witness (gate first) does + /// the detecting. + #[test] + fn heredoc_then_execute_in_one_bash_call_is_caught() { + let script = script_path("check"); + let g = VerifierGate::new(); // gate FIRST: mtime is the only witness + let command = format!( + "cat > {} <<'EOF'\nexit 0\nEOF\n{}", + script.to_string_lossy(), + script.to_string_lossy() + ); + // The tool call wrote the file; the registry never sees it. + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome("bash", &json!({"command": command}), &ok_result(), false); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "heredoc-write + same-call execute must not read as green" + ); + let _ = std::fs::remove_file(&script); + } + + /// A RELATIVE script word (`../check.sh`) resolves against the run's + /// cwd — the same cwd bash inherits — so the registry entry, recorded + /// under the same relative spelling, must match. Pins the + /// canonicalize-against-run-cwd behaviour. + #[test] + fn relative_script_path_resolves_against_the_run_cwd() { + let rel = std::path::Path::new("..").join(format!( + "dirge-elu2-{}-{}.sh", + std::process::id(), + SCRIPT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::write(&rel, "#!/bin/sh\nexit 0\n").unwrap(); + predate(&rel); // only the registry can answer "authored" + crate::agent::tools::modified::mark_modified(&rel); + + let g = VerifierGate::new(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": rel.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "a relative-path script authored this run must not read as green" + ); + let _ = std::fs::remove_file(&rel); + } + + /// The accepted over-detection trade: a pre-existing script whose mtime + /// is refreshed DURING the run (touch, chmod, a build regenerating it) + /// is indistinguishable from one bash created this run, so its green is + /// declined too. Fail-safe direction — a missed decline would let a + /// proxy validator through, which is the worse failure. + #[test] + fn touched_during_run_script_is_treated_as_authored() { + let script = script_path("run-tests"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + let g = VerifierGate::new(); // run starts + predate(&script); // the script predates the run… + // …but something touches it mid-run (touch / build regen). + std::fs::File::open(&script) + .unwrap() + .set_modified(std::time::SystemTime::now()) + .unwrap(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + g.record_outcome( + "bash", + &json!({"command": script.to_string_lossy().to_string()}), + &ok_result(), + false, + ); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "mtime at-or-after run start reads as authored this run" + ); + let _ = std::fs::remove_file(&script); + } + + /// dirge-1elu.2 follow-up (cd-fold): `cd && ./check.sh` resolves the + /// script against the cd'd directory — a proxy validator authored this + /// run under that directory must NOT latch green. The run cwd alone would + /// miss it (under-detection, the failure direction that matters). + #[test] + fn cd_folded_script_authored_this_run_does_not_latch_green() { + let dir = std::env::temp_dir().join(format!( + "dirge-elu2-cd-{}-{}", + std::process::id(), + SCRIPT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("check.sh"); + let g = VerifierGate::new(); // gate FIRST: mtime is the only witness + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + let command = format!("cd {} && ./check.sh", dir.to_string_lossy()); + g.record_outcome("bash", &json!({"command": command}), &ok_result(), false); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::Unverified, + "a cd'd-into proxy validator authored this run must not read as green" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Over-detection guard for the cd-fold: the same shape with a script + /// that predates the run still latches green. + #[test] + fn cd_folded_pre_existing_script_still_latches_green() { + let dir = std::env::temp_dir().join(format!( + "dirge-elu2-cd2-{}-{}", + std::process::id(), + SCRIPT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("run-tests.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + predate(&script); + let g = VerifierGate::new(); // gate AFTER: the script predates the run + g.record_outcome("edit", &json!({"path": "src/a.rs"}), &ok_result(), false); + let command = format!("cd {} && ./run-tests.sh", dir.to_string_lossy()); + g.record_outcome("bash", &json!({"command": command}), &ok_result(), false); + assert_eq!( + g.status(GateMode::Off), + VerificationStatus::VerifiedGreen, + "a pre-existing repo script behind a cd must still count" + ); + let _ = std::fs::remove_dir_all(&dir); + } + // ── dirge-1elu.7: session-keyed run-status handoff ────────────── + + /// The producer/consumer pair actually connects, and the value that + /// comes out is the one that went in. + #[test] + fn run_status_round_trips_for_its_own_session() { + record_run_verification( + Some("s-round-trip"), + Some(VerificationStatus::VerifiedGreen), + ); + assert_eq!( + take_run_verification("s-round-trip"), + Some(VerificationStatus::VerifiedGreen) + ); + } + + /// The property the whole session-keyed design exists for: a subagent or + /// MCP-delegate run sharing the process must not hand its status to a + /// different session. + #[test] + fn run_status_never_leaks_across_sessions() { + record_run_verification(Some("s-owner"), Some(VerificationStatus::VerifiedRed)); + assert_eq!(take_run_verification("s-other"), None); + // The owner's entry is untouched by the foreign read. + assert_eq!( + take_run_verification("s-owner"), + Some(VerificationStatus::VerifiedRed) + ); + } + + /// Taking is consuming, so a second post-session pass in the same session + /// reads "could not tell" rather than re-crediting a stale green. + #[test] + fn run_status_is_taken_not_copied() { + record_run_verification(Some("s-once"), Some(VerificationStatus::VerifiedGreen)); + assert!(take_run_verification("s-once").is_some()); + assert_eq!(take_run_verification("s-once"), None); + } + + /// A run that verified nothing CLEARS its slot. Without this a later run + /// with no verification would inherit the previous run's green — the + /// stale-read failure this handoff exists to avoid. + #[test] + fn recording_none_clears_a_prior_status() { + record_run_verification(Some("s-clear"), Some(VerificationStatus::VerifiedGreen)); + record_run_verification(Some("s-clear"), None); + assert_eq!(take_run_verification("s-clear"), None); + } + + /// No session id (headless paths that never set one) records nothing + /// rather than colliding on a shared empty key. + #[test] + fn missing_session_id_records_nothing() { + record_run_verification(None, Some(VerificationStatus::VerifiedGreen)); + assert_eq!(take_run_verification(""), None); + } + /// dirge-1elu.2: the run-start marker is backdated, so a script whose + /// mtime lands marginally BEFORE `SystemTime::now()` still reads as + /// authored this run. + /// + /// Linux sets filesystem timestamps from a clock the kernel caches at + /// timer-tick granularity, so a file written microseconds after the gate + /// was constructed can carry an earlier mtime. Without the slack that + /// script reads as pre-existing and latches a green it should decline — + /// which is exactly what happened on Linux CI while macOS stayed green. + /// + /// Simulated deterministically rather than by racing a real clock: the + /// script's mtime is compared against a marker taken AFTER it, which is + /// the same ordering the coarse-clock case produces. + #[test] + fn mtime_marginally_before_run_start_still_reads_as_authored() { + let dir = std::env::temp_dir().join(format!( + "dirge-elu2-slack-{}-{}", + std::process::id(), + SCRIPT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("check.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + + let mtime = std::fs::metadata(&script).unwrap().modified().unwrap(); + + // The coarse-clock case: the run "started" slightly AFTER the file's + // mtime. Without backdating this reads as pre-existing and latches a + // green; with MTIME_SLACK it correctly reads as authored this run. + let skewed_now = mtime + std::time::Duration::from_millis(500); + assert!( + script_is_agent_authored("./check.sh", &script, Some(skewed_now - MTIME_SLACK)), + "an mtime marginally before run start must still read as authored" + ); + + // Bounded: a run that started well after the file was written still + // treats it as pre-existing, so the slack can't sweep in real repo + // scripts. + let much_later = mtime + MTIME_SLACK + std::time::Duration::from_secs(60); + assert!( + !script_is_agent_authored("./check.sh", &script, Some(much_later - MTIME_SLACK)), + "the slack must not sweep in genuinely pre-existing files" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/agent/post_session.rs b/src/agent/post_session.rs index 02fbd4cd..303faaba 100644 --- a/src/agent/post_session.rs +++ b/src/agent/post_session.rs @@ -112,6 +112,11 @@ pub fn spawn_post_session( digest: crate::agent::session_digest::SessionDigest, base: String, graduation_enabled: bool, + // dirge-1elu.5: deterministic signals the expectation-settle stage + // evaluates procedural-memory expectations against. Built at the call + // site from the digest (commands) and whatever verification status the + // caller can still see; no LLM is involved in the evaluation path. + signals: crate::extras::memory_db::ExpectationSignals, ) { tokio::spawn(async move { // dirge-ba0m: at most one orchestrator in flight per @@ -153,6 +158,10 @@ pub fn spawn_post_session( "skills-curator", Box::pin(stage_skills_curator(agent.clone(), paths.clone())), ), + ( + "expectation-settle", + Box::pin(stage_expectation_settle(paths.clone(), signals)), + ), ( "memory-curator", Box::pin(stage_memory_curator( @@ -287,6 +296,54 @@ async fn stage_memory_curator(agent: AnyAgent, paths: ProjectPaths, graduation_e } } +/// dirge-1elu.5: deterministic settle of this run's memory expectations. +/// No LLM: reads the frozen session-start snapshot, evaluates each +/// procedural memory that carries an expectation against the session's +/// deterministic signals (digest commands + finalization verifier status), +/// and moves only the bounded signed effectiveness counters. "Could not +/// tell" moves NEITHER counter; nothing is ever deleted. Runs after +/// background-review (whose writes are session-derived, not snapshot) and +/// before the curators, preserving the strict ordering the orchestrator +/// guarantees. +async fn stage_expectation_settle( + paths: ProjectPaths, + signals: crate::extras::memory_db::ExpectationSignals, +) { + let store = match crate::extras::memory_db::SqliteMemoryStore::load(&paths) { + Ok(store) => store, + Err(e) => { + tracing::debug!( + target: "dirge::expectation_settle", + error = %e, + "expectation settle skipped — memory store unavailable", + ); + return; + } + }; + match tokio::task::spawn_blocking(move || store.settle_expectations(&signals)).await { + Ok(Ok(summary)) => { + tracing::info!( + target: "dirge::expectation_settle", + considered = summary.considered, + met = summary.met, + not_met = summary.not_met, + not_observable = summary.not_observable, + "memory expectations settled (met→success, not-met→failure, not-observable→no move)", + ); + } + Ok(Err(e)) => { + tracing::warn!( + target: "dirge::expectation_settle", + error = %e, + "memory expectation settle failed", + ); + } + Err(_) => { + tracing::debug!(target: "dirge::expectation_settle", "expectation settle task panicked"); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agent/tools/bash/check.rs b/src/agent/tools/bash/check.rs index bc448f18..d99d1a33 100644 --- a/src/agent/tools/bash/check.rs +++ b/src/agent/tools/bash/check.rs @@ -84,7 +84,7 @@ fn normalize_lexical(p: &std::path::Path) -> std::path::PathBuf { /// against. Best-effort, quote-trimming; conservatively applies ALL `cd`s /// in the compound (so the effective dir is the last one). #[cfg(feature = "semantic-bash")] -fn fold_cd_dirs(base: &str, segments: &[String]) -> String { +pub(crate) fn fold_cd_dirs(base: &str, segments: &[String]) -> String { let mut dir = std::path::PathBuf::from(base); for seg in segments { let mut it = seg.split_whitespace(); diff --git a/src/agent/tools/modified.rs b/src/agent/tools/modified.rs index d14f5384..52967a41 100644 --- a/src/agent/tools/modified.rs +++ b/src/agent/tools/modified.rs @@ -4,13 +4,16 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{LazyLock, Mutex}; -use indexmap::IndexSet; +use indexmap::IndexMap; /// Monotonic version counter bumped on every `mark_modified` / /// `clear_modified` call. Lets the info-panel build path skip the /// O(N) clone-and-strip work when the underlying set hasn't changed /// — `recent(256)` previously locked + cloned 256 PathBufs on every -/// keystroke during streaming (review #6). +/// keystroke during streaming (review #6). Doubles as the epoch +/// source for [`epoch`] / [`since`]: every mark stores the counter +/// value it was made at, so "what changed since I started" is a +/// version comparison, not a set diff. static VERSION: AtomicU64 = AtomicU64::new(0); /// Current version. Panel-side code remembers this and re-snapshots @@ -19,6 +22,13 @@ pub fn version() -> u64 { VERSION.load(Ordering::Acquire) } +/// Snapshot the counter for a later [`since`] query. Capture BEFORE the +/// unit of work whose delta you want: entries marked after the capture +/// carry a strictly greater version (see [`since`]). +pub fn epoch() -> u64 { + version() +} + /// How many DISTINCT files have been mutated. The progress monitor /// (dirge-uw2l.3) reads this at turn boundaries: an increase means new /// ground was broken, while a flat count across turns means the run is @@ -32,10 +42,15 @@ pub fn count() -> usize { /// reads this to show a short tail of touched paths so the user has a /// running record of what the agent has been doing. /// -/// `LazyLock` because `IndexSet::new()` is not `const`. The cost is one +/// `LazyLock` because `IndexMap::new()` is not `const`. The cost is one /// extra atomic on first access. -pub static MODIFIED_FILES: LazyLock>> = - LazyLock::new(|| Mutex::new(IndexSet::new())); +/// +/// The value is the [`VERSION`] the path was marked at. `IndexMap` +/// preserves insertion order like the old `IndexSet` (re-insert moves +/// the entry to the end) while letting [`since`] answer "marked after +/// this epoch" per entry. +pub static MODIFIED_FILES: LazyLock>> = + LazyLock::new(|| Mutex::new(IndexMap::new())); /// Record that `path` was modified by a write/edit/apply_patch tool call. /// Maximum entries retained in the modified-files set. Older entries @@ -50,16 +65,19 @@ const MAX_MODIFIED: usize = 256; pub fn mark_modified(path: &Path) { let canonical = crate::permission::path::canonical_or_self(path); let mut set = MODIFIED_FILES.lock_ignore_poison(); - // IndexSet preserves insertion order and dedups; we want the most-recent - // touch to surface at the end, so re-insert moves the entry. + // IndexMap preserves insertion order and dedups; we want the most-recent + // touch to surface at the end, so re-insert moves the entry. The stored + // version is the counter AFTER this mark, so it is strictly greater than + // any epoch captured before the mark — a re-touched path reappears in a + // `since(epoch)` delta that already reported it once. set.shift_remove(&canonical); // Cap the set BEFORE inserting so we always have room for the // freshest entry. Oldest (front) gets evicted. while set.len() >= MAX_MODIFIED { set.shift_remove_index(0); } - set.insert(canonical); - VERSION.fetch_add(1, Ordering::Release); + let version = VERSION.fetch_add(1, Ordering::Release) + 1; + set.insert(canonical, version); } /// Clear the tracked list. Hooked into /clear so the panel resets along @@ -76,7 +94,21 @@ pub fn recent(n: usize) -> Vec { let set = MODIFIED_FILES.lock_ignore_poison(); let len = set.len(); let start = len.saturating_sub(n); - set.iter().skip(start).cloned().collect() + set.iter().skip(start).map(|(p, _)| p.clone()).collect() +} + +/// Files marked AFTER `epoch` was captured — the delta for the unit of +/// work that captured it at its start. A path RE-touched after the +/// capture carries its newest mark's version, so it appears here too: +/// a naive snapshot-and-diff of the set would silently drop it +/// (`IndexSet`/`IndexMap` keep a re-inserted entry at its original +/// position), the false negative dirge-d0e5.1 guards against. +pub fn since(epoch: u64) -> Vec { + let set = MODIFIED_FILES.lock_ignore_poison(); + set.iter() + .filter(|(_, v)| **v > epoch) + .map(|(p, _)| p.clone()) + .collect() } #[cfg(test)] @@ -191,4 +223,78 @@ mod tests { assert_eq!(recent(10).len(), 0); }); } + + /// dirge-d0e5.1: `since(epoch)` is the per-unit-of-work delta. A + /// delegation's child process rehydrates the session's CUMULATIVE + /// registry (session/rehydrate.rs replays `mark_modified`) before its + /// run starts; capturing the epoch at run start must exclude every + /// replayed file — a delegation that changes nothing reports nothing, + /// even when earlier delegations in the same session changed files. + #[test] + fn since_scopes_delta_to_marks_after_epoch() { + with_isolated(|| { + let dir = std::env::temp_dir().join("dirge-modified-test-since"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.txt"); + let b = dir.join("b.txt"); + std::fs::write(&a, "x").unwrap(); + std::fs::write(&b, "x").unwrap(); + + // Prior delegations' files replayed into the registry. + mark_modified(&a); + mark_modified(&b); + let epoch = epoch(); + + // This delegation changes NOTHING → empty delta. + assert!( + since(epoch).is_empty(), + "a delegation that changes nothing must report no files, got {:?}", + since(epoch) + ); + + // A file touched after the capture appears in the delta. + mark_modified(&a); + let delta = since(epoch); + assert!( + delta.iter().any(|p| p.ends_with("a.txt")), + "touched-after-capture file must appear: {delta:?}" + ); + assert!( + !delta.iter().any(|p| p.ends_with("b.txt")), + "untouched-after-capture file must not appear: {delta:?}" + ); + }); + } + + /// dirge-d0e5.1: a file touched in delegation 1 and touched AGAIN in + /// delegation 2 appears in BOTH deltas. The naive fix — snapshot the + /// set, diff afterwards — fails this: a re-inserted path keeps its + /// original position, so it silently vanishes from the second diff. + /// The per-entry version makes the second touch a new, higher version. + #[test] + fn since_reports_a_path_touched_again_in_a_later_delta() { + with_isolated(|| { + let dir = std::env::temp_dir().join("dirge-modified-test-since-remark"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.txt"); + std::fs::write(&a, "x").unwrap(); + + // Delegation 1 touches a. + let e1 = epoch(); + mark_modified(&a); + assert!( + since(e1).iter().any(|p| p.ends_with("a.txt")), + "delegation 1 must report the file it touched" + ); + + // Delegation 2 touches the SAME file again. + let e2 = epoch(); + mark_modified(&a); + assert!( + since(e2).iter().any(|p| p.ends_with("a.txt")), + "delegation 2 must report the file it re-touched, got {:?}", + since(e2) + ); + }); + } } diff --git a/src/agent/tools/task.rs b/src/agent/tools/task.rs index 30372bf2..a8910954 100644 --- a/src/agent/tools/task.rs +++ b/src/agent/tools/task.rs @@ -74,7 +74,18 @@ fn current_repo_is_dirty() -> Result { .map_err(|e| format!("failed to resolve current directory: {e}"))? .canonicalize() .map_err(|e| format!("failed to canonicalize repository: {e}"))?; - crate::extras::git_worktree::repo_is_dirty(&repo) + #[cfg(feature = "git-worktree")] + { + crate::extras::git_worktree::repo_is_dirty(&repo) + } + #[cfg(not(feature = "git-worktree"))] + { + // No worktree isolation in this build, so no dirty-check machinery is + // compiled in and there is no worktree to clobber. The caller treats + // `Ok(false)` exactly like `Err(_)` — "no uncommitted work to lose". + let _ = repo; + Ok(false) + } } #[cfg(feature = "git-worktree")] @@ -1141,6 +1152,7 @@ impl TaskTool { .unwrap_or(SUBAGENT_DEFAULT_PREAMBLE) .to_string() }; + #[cfg_attr(not(feature = "git-worktree"), allow(unused_variables))] let runner = if let Some((info, main_git_dir, base_commit)) = rooted_worktree_for_task.as_ref() { diff --git a/src/agent/tools/write.rs b/src/agent/tools/write.rs index 192406ba..18fb7e85 100644 --- a/src/agent/tools/write.rs +++ b/src/agent/tools/write.rs @@ -142,6 +142,7 @@ impl Tool for WriteTool { let was_creation = !path.exists(); // Only a REPAIR rewrites the model's bytes; a pre-existing-error note // means the text went out verbatim, so there is nothing to verify. + #[cfg_attr(not(feature = "lsp"), allow(unused_variables))] let was_repaired = syntax_note .as_ref() .is_some_and(crate::agent::tools::GateNote::is_repair); diff --git a/src/config/mod.rs b/src/config/mod.rs index 2758eae4..790de1c5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -985,6 +985,23 @@ pub struct Config { /// rather than silently doing something other than what its name says. /// See [`resolve_safe_state_abort_mode`](Self::resolve_safe_state_abort_mode). pub safe_state_abort: Option, + /// How the publish-state guard engages (dirge-1elu.1): `off` / `advisory` + /// / `blocking` (case-insensitive, trimmed). `off` *(default)* is + /// byte-identical to the loop without the guard. `advisory` injects a + /// model-visible warning (bounded at 2 per run) when a command would + /// discard verified-green work but lets the call run; `blocking` suppresses + /// the call pre-dispatch and returns an error naming the protected paths — + /// with no override token (the paper's overrideable iteration-5 guard + /// leaked). See [`resolve_publish_guard_mode`](Self::resolve_publish_guard_mode). + pub publish_guard: Option, + /// How the deterministic claim/evidence gate engages (dirge-d0e5.2): + /// `off` / `advisory` / `blocking` (case-insensitive, trimmed). `off` + /// *(default)* is byte-identical to the loop without the gate. Either + /// non-`off` mode delivers the same one-shot model-visible nudge when + /// the final answer claims a verification result or a change the run's + /// evidence does not support. See + /// [`resolve_claim_gate_mode`](Self::resolve_claim_gate_mode). + pub claim_gate: Option, /// How the ingestion-time injection scanner handles untrusted tool /// results (read, MCP, websearch). One of `off` / `advisory` / `block` /// (case-insensitive, trimmed). `advisory` *(default)* fences positive @@ -1376,6 +1393,59 @@ impl Config { }) } + /// Resolve the publish-state guard's engagement mode from + /// [`publish_guard`](Self::publish_guard): `off`/`advisory`/`blocking`, + /// parsed case-insensitively and trimmed. `None` and an empty value + /// resolve to `Off` (opt-in — the guard intercepts commands, which is + /// intrusive). An unrecognized non-empty value also resolves to `Off` + /// but logs a warning, so a typo never silently arms a destructive + /// interlock (dirge-1elu.1). + pub fn resolve_publish_guard_mode(&self) -> crate::agent::agent_loop::types::GateMode { + use crate::agent::agent_loop::types::GateMode; + let Some(raw) = self.publish_guard.as_deref() else { + return GateMode::Off; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return GateMode::Off; + } + GateMode::from_wire(trimmed).unwrap_or_else(|| { + tracing::warn!( + target: "dirge::config", + value = trimmed, + "unrecognized `publish_guard` value; falling back to `off` \ + (valid: off | advisory | blocking)" + ); + GateMode::Off + }) + } + + /// Resolve the claim gate's engagement mode from + /// [`claim_gate`](Self::claim_gate): `off`/`advisory`/`blocking`, + /// parsed case-insensitively and trimmed. `None` and an empty value + /// resolve to `Off` (opt-in — the gate nags the model, so it must be + /// switched on explicitly; dirge-d0e5.2). An unrecognized non-empty + /// value also resolves to `Off` but logs a warning. + pub fn resolve_claim_gate_mode(&self) -> crate::agent::agent_loop::types::GateMode { + use crate::agent::agent_loop::types::GateMode; + let Some(raw) = self.claim_gate.as_deref() else { + return GateMode::Off; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return GateMode::Off; + } + GateMode::from_wire(trimmed).unwrap_or_else(|| { + tracing::warn!( + target: "dirge::config", + value = trimmed, + "unrecognized `claim_gate` value; falling back to `off` \ + (valid: off | advisory | blocking)" + ); + GateMode::Off + }) + } + /// Resolve the ingestion-time injection scan mode from /// [`injection_scan`](Self::injection_scan): `off`/`advisory`/`block`, /// parsed case-insensitively and trimmed. `None` and an empty value @@ -2233,6 +2303,51 @@ mod tests { assert_eq!(cfg.resolve_safe_state_abort_mode(), SafeStateMode::Off); } + #[test] + fn resolve_claim_gate_mode_each_string_and_default() { + use crate::agent::agent_loop::types::GateMode; + for raw in ["off", "advisory", "blocking"] { + let cfg = Config::deserialize(serde_json::json!({ "claim_gate": raw })).unwrap(); + assert_eq!(cfg.resolve_claim_gate_mode().as_str(), raw); + } + // Absent key and an empty value both resolve to Off (opt-in). + let absent = Config::deserialize(serde_json::json!({})).unwrap(); + assert_eq!(absent.resolve_claim_gate_mode(), GateMode::Off); + let empty = Config::deserialize(serde_json::json!({ "claim_gate": "" })).unwrap(); + assert_eq!(empty.resolve_claim_gate_mode(), GateMode::Off); + } + + #[test] + fn resolve_publish_guard_mode_each_string_and_default() { + use crate::agent::agent_loop::types::GateMode; + + let mk = |raw: &str| { + Config::deserialize(serde_json::json!({ "publish_guard": raw })) + .unwrap() + .resolve_publish_guard_mode() + }; + assert_eq!(mk("off"), GateMode::Off); + assert_eq!(mk("OFF"), GateMode::Off); + assert_eq!(mk(" Blocking "), GateMode::Blocking); + assert_eq!(mk("advisory"), GateMode::Advisory); + + // Absent / empty → default Off (opt-in — the guard intercepts + // commands, so it stays dark until asked for). + let cfg: Config = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(cfg.resolve_publish_guard_mode(), GateMode::Off); + let cfg: Config = serde_json::from_str(r#"{"publish_guard":" "}"#).unwrap(); + assert_eq!(cfg.resolve_publish_guard_mode(), GateMode::Off); + } + + #[test] + fn resolve_publish_guard_mode_unknown_defaults_off() { + use crate::agent::agent_loop::types::GateMode; + + // A typo must never silently arm a destructive interlock. + let cfg: Config = serde_json::from_str(r#"{"publish_guard":"nuclear"}"#).unwrap(); + assert_eq!(cfg.resolve_publish_guard_mode(), GateMode::Off); + } + #[test] fn resolve_open_issues_gate_mode_each_string_and_default() { use crate::agent::agent_loop::types::GateMode; diff --git a/src/extras/mcp_server.rs b/src/extras/mcp_server.rs index 4b8d3aca..3390afeb 100644 --- a/src/extras/mcp_server.rs +++ b/src/extras/mcp_server.rs @@ -172,6 +172,10 @@ impl DirgeMcp { "files_changed": files_changed, "turns": env.turns, "duration_ms": env.duration_ms, + // dirge-d0e5.1: observed evidence from the child run — + // verification status, turns, tool calls — so a caller + // can check a claimed test result against it. + "evidence": env.evidence, }); Ok(tool_json(&result)) } @@ -254,6 +258,12 @@ struct Envelope { /// git-independent (issue #704); the git-status diff is only a /// supplement for anything the tools didn't record. files_changed: Vec, + /// dirge-d0e5.1: what the child run actually did — turns, tool-call + /// count, and the verification status observed (or `observed: false` + /// when no verification command ran at all). Forwarded verbatim so a + /// delegate caller can check a claimed test result against observed + /// state without re-running the suite. + evidence: serde_json::Value, } /// SIGKILL a whole process group on drop. Mirrors @@ -448,6 +458,13 @@ async fn run_delegation( .collect() }) .unwrap_or_default(), + evidence: env_val.get("evidence").cloned().unwrap_or_else(|| { + serde_json::json!({ + "turns": 0, + "tool_calls": 0, + "verification": { "observed": false, "status": null }, + }) + }), }) } diff --git a/src/extras/memory_db.rs b/src/extras/memory_db.rs index bde50023..14862b51 100644 --- a/src/extras/memory_db.rs +++ b/src/extras/memory_db.rs @@ -27,6 +27,7 @@ //! markdown store minted a fresh id on every replace, so any //! consolidation reset an entry's age tracking to zero. +use crate::agent::agent_loop::verifier::{GateSignature, VerificationStatus, gate_signature}; #[allow(unused_imports)] use crate::sync_util::LockExt; use std::collections::HashMap; @@ -324,6 +325,142 @@ struct ActiveRow { failure_count: i64, /// dirge-fa10: truth-likelihood in [0,1]. See [`DEFAULT_CONFIDENCE`]. confidence: f64, + /// dirge-1elu.5: falsifiable expectation this procedural memory carries, + /// if any. NULL for every pre-existing memory — the migration backfills + /// nothing, and an expectation is only ever attached explicitly. + expectation: Option, +} + +/// dirge-1elu.5: a falsifiable expectation a procedural memory may carry. +/// Ask what the memory expects to IMPROVE, expressed as a trigger/behavior +/// pair: `when` is the observable situation under which the memory should +/// have applied, `expect` is the behavior it prescribes. Both are command +/// signatures, evaluated deterministically in the post-session pass against +/// the run's digest commands — never by an LLM, never by the model's +/// discretionary `mark`. Met → success, unmet → NotMet → failure, situation +/// never arose → NotObservable → neither counter (conflating "could not +/// tell" with "did not work" decays honest memories toward eviction). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemoryExpectation { + /// The run's final verification was GREEN (`VerifiedGreen`) — the + /// observable condition under which the memory should have applied. + /// Judged by the same three cases as `CommandRan`: green → success, + /// observable-but-not-green (VerifiedRed) → failure, no verification + /// (no verifier, or Unverified/NoCodeEdited) → neither counter. + /// Falsifiable because the middle case genuinely moves `failure_count`. + VerificationGreen, + /// `when` (the situation) and `expect` (the rule), e.g. "run cargo fmt + /// before commit" → when=`git commit`, expect=`cargo fmt`. A session + /// that committed without formatting is a real, measured failure. + /// Ordering within a session's command list is NOT considered — presence + /// of both signatures is enough (the digest's command order is + /// first-seen, not a reliable timeline). + CommandRan { + when: GateSignature, + expect: GateSignature, + }, +} + +impl MemoryExpectation { + /// Wire form for the `expectation` column: `command_ran:|`. + pub fn as_wire(&self) -> String { + match self { + MemoryExpectation::VerificationGreen => "verification_green".to_string(), + MemoryExpectation::CommandRan { when, expect } => { + format!("command_ran:{}|{}", when.to_wire(), expect.to_wire()) + } + } + } + + /// Parse the wire form. Unknown or unparseable strings — including the + /// old single-signature form `command_ran:cargo fmt` — return `None`: + /// the memory reads as "no expectation" and is inert, never + /// half-parsed into something that then mis-evaluates (migration + /// safety: a future version's expectations are never mis-evaluated). + pub fn from_wire(s: &str) -> Option { + if s == "verification_green" { + return Some(MemoryExpectation::VerificationGreen); + } + let rest = s.strip_prefix("command_ran:")?; + let (when, expect) = rest.split_once('|')?; + if when.is_empty() || expect.is_empty() { + return None; + } + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire(when)?, + expect: GateSignature::from_wire(expect)?, + }) + } + + /// Deterministic evaluation. No LLM; a pure function of the signals. + pub fn evaluate(&self, signals: &ExpectationSignals) -> ExpectationVerdict { + match self { + MemoryExpectation::VerificationGreen => match signals.final_verification { + // Green → the rule was followed. + Some(VerificationStatus::VerifiedGreen) => ExpectationVerdict::Met, + // Observable but NOT green → the rule was not followed. This + // is the falsifiability case: it genuinely moves failure. + Some(VerificationStatus::VerifiedRed) => ExpectationVerdict::NotMet, + // No verification ran (no verifier configured, or the run + // ended Unverified / with nothing to verify) — "could not + // tell", which moves NEITHER counter. + _ => ExpectationVerdict::NotObservable, + }, + MemoryExpectation::CommandRan { when, expect } => { + let when_ran = signals + .commands + .iter() + .any(|c| gate_signature(c).as_ref() == Some(when)); + let expect_ran = signals + .commands + .iter() + .any(|c| gate_signature(c).as_ref() == Some(expect)); + match (when_ran, expect_ran) { + (false, _) => ExpectationVerdict::NotObservable, + (true, true) => ExpectationVerdict::Met, + (true, false) => ExpectationVerdict::NotMet, + } + } + } + } +} + +/// Deterministic inputs the settle pass evaluates expectations against. +/// Built at the post-session spawn site from the session digest (commands). +/// No LLM in this path (dirge-1elu.5). +#[derive(Debug, Clone, Default)] +pub struct ExpectationSignals { + /// The run's final verification status, as produced by the verifier + /// gate at the end of the run (dirge-1elu.7). `None` when no verifier + /// produced a status this run — the "could not tell" case, which moves + /// NEITHER counter. The idle-path spawn site passes `None` because the + /// gate is a run_loop local by then; the compaction site passes the + /// live status. + pub final_verification: Option, + /// Command strings recorded this run, matched with `gate_signature`. + pub commands: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpectationVerdict { + /// The situation arose AND the rule was followed → success. + Met, + /// The situation arose AND the rule was NOT followed → failure. + NotMet, + /// The situation never arose this run ("could not tell") — moves + /// NEITHER counter. Conflating this with NotMet decays honest memories + /// toward eviction. + NotObservable, +} + +/// What one settle pass did, for the `dirge::expectation_settle` log line +/// and tests. +#[derive(Debug, Clone, Copy, Default)] +pub struct SettleSummary { + pub considered: usize, + pub met: usize, + pub not_met: usize, + pub not_observable: usize, } /// What a budget compaction did: `demoted` hot entries moved to the @@ -384,6 +521,7 @@ pub struct CurationEntry { /// injection. /// One frozen-snapshot entry (active at load time, passed the /// render-time threat scan). +#[derive(Clone)] struct SnapshotEntry { target: String, kind: String, @@ -657,7 +795,7 @@ impl SqliteMemoryStore { ) -> Result, String> { let sql = format!( "SELECT id, uid, kind, content, salience, status, tier, last_used_at, - success_count, failure_count, confidence + success_count, failure_count, confidence, expectation FROM memories WHERE target = ?1 AND {extra_where} ORDER BY id" ); let mut stmt = conn @@ -677,6 +815,10 @@ impl SqliteMemoryStore { success_count: row.get(8)?, failure_count: row.get(9)?, confidence: row.get(10)?, + expectation: row + .get(11) + .ok() + .and_then(|s: String| MemoryExpectation::from_wire(&s)), }) }) .map_err(|e| format!("Failed to query entries: {e}"))? @@ -1350,6 +1492,129 @@ impl SqliteMemoryStore { })) } + /// dirge-1elu.5: attach (or clear) a falsifiable expectation to a + /// procedural memory, matched by substring like `replace`/`remove`. + /// Procedural-only; `None` clears. Never backfills — existing memories + /// carry no expectation until this (or a future review pass) sets one, + /// which is what keeps the migration inert. + /// + /// Deliberately has no production caller yet: the review pass attaches + /// expectations (its own LLM decision), and today the capability is + /// exercised by tests and the future review path. Evaluation — the + /// settle — is the always-on side. + #[allow(dead_code)] + pub fn set_expectation( + &self, + target: &str, + old_text: &str, + expectation: Option, + ) -> Result<(), String> { + let conn = self.conn.lock_ignore_poison(); + let rows = Self::active_rows(&conn, target)?; + let idx = find_unique_match(&rows, old_text)?; + let row = &rows[idx]; + if row.kind != "procedural" { + return Err(format!( + "Expectations are procedural-only; entry {} is `{}`.", + row.uid, row.kind + )); + } + let wire = expectation.as_ref().map(MemoryExpectation::as_wire); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE memories SET expectation = ?1, updated_at = ?2 WHERE id = ?3", + params![wire, now, row.id], + ) + .map_err(|e| format!("Failed to set expectation: {e}"))?; + Ok(()) + } + + /// Row lookup by uid — the frozen snapshot's key. Includes the + /// expectation column (dirge-1elu.5). + fn row_by_uid(conn: &Connection, uid: &str) -> Result, String> { + let sql = "SELECT id, uid, kind, content, salience, status, tier, last_used_at, + success_count, failure_count, confidence, expectation + FROM memories WHERE uid = ?1"; + let mut stmt = conn + .prepare(sql) + .map_err(|e| format!("Failed to prepare query: {e}"))?; + let mut rows = stmt + .query_map(params![uid], |row| { + Ok(ActiveRow { + id: row.get(0)?, + uid: row.get(1)?, + kind: row.get(2)?, + content: row.get(3)?, + salience: row.get(4)?, + status: row.get(5)?, + tier: row.get(6)?, + last_used_at: row.get(7)?, + success_count: row.get(8)?, + failure_count: row.get(9)?, + confidence: row.get(10)?, + expectation: row + .get(11) + .ok() + .and_then(|s: String| MemoryExpectation::from_wire(&s)), + }) + }) + .map_err(|e| format!("Failed to query entry: {e}"))?; + rows.next() + .transpose() + .map_err(|e| format!("Failed to read entry: {e}")) + } + + /// dirge-1elu.5: evaluate this run's memory expectations against + /// deterministic signals. Considered = memories in the FROZEN + /// session-start snapshot, kind `procedural`, carrying an expectation. + /// Met → `success_count += 1`; NotMet → `failure_count += 1`; + /// NotObservable → NEITHER ("could not + /// tell" is not "did not work" — conflating them decays honest + /// memories toward eviction). Never + /// deletes; never touches any other column. This is the ONLY counter + /// mover besides the model's `mark` action — the production path for + /// expectation evaluation, with no LLM and no agent `mark` in it. + pub fn settle_expectations( + &self, + signals: &ExpectationSignals, + ) -> Result { + let conn = self.conn.lock_ignore_poison(); + let mut summary = SettleSummary::default(); + let now = chrono::Utc::now().to_rfc3339(); + let snapshot: Vec = self.snapshot.lock_ignore_poison().clone(); + for entry in snapshot.iter().filter(|e| e.kind == "procedural") { + let Some(row) = Self::row_by_uid(&conn, &entry.uid)? else { + continue; + }; + let Some(exp) = row.expectation else { + continue; + }; + summary.considered += 1; + match exp.evaluate(signals) { + ExpectationVerdict::Met => { + conn.execute( + "UPDATE memories SET success_count = success_count + 1, \ + updated_at = ?1 WHERE id = ?2", + params![now, row.id], + ) + .map_err(|e| format!("Failed to record expectation success: {e}"))?; + summary.met += 1; + } + ExpectationVerdict::NotMet => { + conn.execute( + "UPDATE memories SET failure_count = failure_count + 1, \ + updated_at = ?1 WHERE id = ?2", + params![now, row.id], + ) + .map_err(|e| format!("Failed to record expectation failure: {e}"))?; + summary.not_met += 1; + } + ExpectationVerdict::NotObservable => summary.not_observable += 1, + } + } + Ok(summary) + } + /// Record that a procedural playbook succeeded or failed in /// practice (dirge-zygq). Matches the entry by uid or unique /// substring (same rules as `replace`/`remove`), bumps the matching @@ -4859,4 +5124,350 @@ mod tests { .unwrap(); assert!(store.is_graduated("idempotent-hash").unwrap()); } + + // ── dirge-1elu.5: falsifiable expectations, settled in the post-session pass ── + // Evaluation is deterministic (no LLM) and only ever moves the bounded + // signed effectiveness counters; it never deletes. `settle_expectations` + // is the EXACT method the post-session expectation-settle stage calls — + // these tests drive the production path, not a recorder helper. + + /// Case 1 (+ the spec's case 5, which is this same path): the situation + /// arose and the rule was followed — success increments, failure does + /// not — evaluated through the production settle, with NO agent `mark` + /// call anywhere. + #[test] + fn expectation_met_increments_success_through_production_settle() { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry( + "memory", + "run cargo fmt before commit", + Some(MemoryKind::Procedural), + ) + .unwrap(); + store + .set_expectation( + "memory", + "run cargo fmt before commit", + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo fmt").unwrap(), + }), + ) + .unwrap(); + store.refresh_snapshot().unwrap(); + let uid = store.snapshot.lock_ignore_poison()[0].uid.clone(); + + let signals = ExpectationSignals { + final_verification: None, + commands: vec![ + "cargo fmt --check".to_string(), + "git commit -m x".to_string(), + ], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!( + (summary.considered, summary.met, summary.not_met), + (1, 1, 0) + ); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!( + row.success_count, 1, + "met expectation bumps success — no mark call" + ); + assert_eq!(row.failure_count, 0); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Case 2 — the one that was missing all along: the situation arose and + /// the rule was NOT followed. A session that committed without + /// formatting is a real, measured failure of this memory. + #[test] + fn when_ran_but_expect_did_not_is_a_failure() { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry( + "memory", + "run cargo fmt before commit", + Some(MemoryKind::Procedural), + ) + .unwrap(); + store + .set_expectation( + "memory", + "run cargo fmt before commit", + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo fmt").unwrap(), + }), + ) + .unwrap(); + store.refresh_snapshot().unwrap(); + let uid = store.snapshot.lock_ignore_poison()[0].uid.clone(); + + // The situation arose (git commit ran) and the rule did not + // (cargo fmt never ran) — a measured failure. + let signals = ExpectationSignals { + final_verification: None, + commands: vec!["git commit -m x".to_string()], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!( + (summary.considered, summary.met, summary.not_met), + (1, 0, 1) + ); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!(row.failure_count, 1, "unmet expectation bumps failure"); + assert_eq!(row.success_count, 0); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Case 3: the situation never arose this run — NEITHER counter moves. + #[test] + fn when_never_ran_moves_neither_counter() { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry( + "memory", + "run cargo fmt before commit", + Some(MemoryKind::Procedural), + ) + .unwrap(); + store + .set_expectation( + "memory", + "run cargo fmt before commit", + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo fmt").unwrap(), + }), + ) + .unwrap(); + store.refresh_snapshot().unwrap(); + let uid = store.snapshot.lock_ignore_poison()[0].uid.clone(); + + let signals = ExpectationSignals { + final_verification: None, + commands: vec!["cargo check".to_string()], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!( + ( + summary.considered, + summary.met, + summary.not_met, + summary.not_observable + ), + (1, 0, 0, 1) + ); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!(row.success_count, 0, "not-observable is not a success"); + assert_eq!(row.failure_count, 0, "not-observable is not a failure"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Case 4 — migration safety for the wire form: the OLD single-signature + /// form `command_ran:cargo fmt` reads as None (inert), never half-parsed + /// into something that then mis-evaluates. Unknown strings too. + #[test] + fn old_single_signature_wire_form_reads_as_none() { + assert_eq!(MemoryExpectation::from_wire("command_ran:cargo fmt"), None); + assert_eq!(MemoryExpectation::from_wire("command_ran:cargo fmt|"), None); + assert_eq!( + MemoryExpectation::from_wire("command_ran:|git commit"), + None + ); + assert_eq!(MemoryExpectation::from_wire(""), None); + // The restored variant round-trips; the OLD single-signature form + // stays None (inert, never half-parsed). + assert_eq!( + MemoryExpectation::from_wire("verification_green"), + Some(MemoryExpectation::VerificationGreen) + ); + assert_eq!( + MemoryExpectation::VerificationGreen.as_wire(), + "verification_green" + ); + assert_eq!( + MemoryExpectation::from_wire("command_ran:git commit|cargo fmt"), + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo fmt").unwrap(), + }) + ); + } + + /// A memory with NO expectation is never evaluated — inertness for + /// everything the migration didn't touch. + #[test] + fn memory_without_expectation_is_untouched() { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry( + "memory", + "no expectation here", + Some(MemoryKind::Procedural), + ) + .unwrap(); + store.refresh_snapshot().unwrap(); + + let signals = ExpectationSignals { + final_verification: None, + commands: vec!["git commit -m x".to_string()], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!( + summary.considered, 0, + "no expectation ⇒ not even considered" + ); + assert_eq!((summary.met, summary.not_met), (0, 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A memory NOT in the frozen session-start snapshot is not evaluated. + #[test] + fn expectation_outside_frozen_snapshot_is_not_evaluated() { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry( + "memory", + "added after snapshot froze", + Some(MemoryKind::Procedural), + ) + .unwrap(); + store + .set_expectation( + "memory", + "added after snapshot froze", + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo fmt").unwrap(), + }), + ) + .unwrap(); + // NOTE: refresh_snapshot deliberately NOT called — the snapshot is + // still the empty frozen set from load time. + + let signals = ExpectationSignals { + final_verification: None, + commands: vec!["git commit -m x".to_string(), "cargo fmt".to_string()], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!(summary.considered, 0, "not injected ⇒ not evaluated"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The three-case discipline for the VerificationGreen arm, driven + /// through the production settle: green → Met → success_count; an + /// observable run that did NOT reach green → NotMet → failure_count; + /// no verification ran → NotObservable → NEITHER counter. + #[test] + fn verification_green_settle_is_three_case() { + for (status, expect_met, expect_not_met, expect_success, expect_failure) in [ + ( + Some(VerificationStatus::VerifiedGreen), + 1usize, + 0usize, + 1i64, + 0i64, + ), + (Some(VerificationStatus::VerifiedRed), 0, 1, 0, 1), + (None, 0, 0, 0, 0), + ] { + let (paths, dir) = temp_project(); + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry("memory", "run checks", Some(MemoryKind::Procedural)) + .unwrap(); + store + .set_expectation( + "memory", + "run checks", + Some(MemoryExpectation::VerificationGreen), + ) + .unwrap(); + store.refresh_snapshot().unwrap(); + let uid = store.snapshot.lock_ignore_poison()[0].uid.clone(); + + let signals = ExpectationSignals { + final_verification: status, + commands: vec![], + }; + let summary = store.settle_expectations(&signals).unwrap(); + assert_eq!(summary.met, expect_met, "status={status:?}"); + assert_eq!(summary.not_met, expect_not_met, "status={status:?}"); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!(row.success_count, expect_success, "status={status:?}"); + assert_eq!(row.failure_count, expect_failure, "status={status:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + } + + /// Migration safety, checked hardest: a DB that predates the expectation + /// column opens, migrates in place, keeps its data, and backfills NO + /// default expectation onto existing memories. + #[test] + fn older_db_without_expectation_column_migrates_in_place() { + let (paths, dir) = temp_project(); + { + let store = SqliteMemoryStore::load(&paths).unwrap(); + store + .add_entry("memory", "legacy playbook", Some(MemoryKind::Procedural)) + .unwrap(); + } + // Rewind to a v15-era schema: drop the column, pin user_version. + let conn = rusqlite::Connection::open(paths.session_db_path()).unwrap(); + conn.execute_batch( + "ALTER TABLE memories DROP COLUMN expectation;\nPRAGMA user_version = 15;", + ) + .unwrap(); + drop(conn); + + let store = SqliteMemoryStore::load(&paths).unwrap(); + let uid = store.snapshot.lock_ignore_poison()[0].uid.clone(); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!( + row.expectation, None, + "migrated row keeps NULL — nothing backfilled" + ); + assert_eq!(row.success_count, 0, "legacy counters untouched"); + + // And the capability is live on the migrated DB — attach works. + store + .set_expectation( + "memory", + "legacy playbook", + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo test").unwrap(), + }), + ) + .unwrap(); + let row = SqliteMemoryStore::row_by_uid(&store.conn.lock_ignore_poison(), &uid) + .unwrap() + .unwrap(); + assert_eq!( + row.expectation, + Some(MemoryExpectation::CommandRan { + when: GateSignature::from_wire("git commit").unwrap(), + expect: GateSignature::from_wire("cargo test").unwrap(), + }) + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/extras/session_db.rs b/src/extras/session_db.rs index cf4de624..db1f39ea 100644 --- a/src/extras/session_db.rs +++ b/src/extras/session_db.rs @@ -28,9 +28,9 @@ use regex::Regex; // caps at v13 so the user_version pragma never outruns the tables that // actually exist. #[cfg(feature = "experimental-graph-search")] -pub(crate) const SCHEMA_VERSION: u32 = 15; +pub(crate) const SCHEMA_VERSION: u32 = 16; #[cfg(not(feature = "experimental-graph-search"))] -pub(crate) const SCHEMA_VERSION: u32 = 13; +pub(crate) const SCHEMA_VERSION: u32 = 14; /// Thread-safe snapshot of the most recent `SessionDb::open()` failure. /// Port of Hermes's `_last_init_error` (hermes_state.py:66-67). @@ -343,6 +343,12 @@ impl SessionDb { self.run_migration_v15()?; } + // dirge-1elu.5: expectation column on memories. Ungated — applies to + // every build; adds a nullable column and backfills nothing. + if current < 16 { + self.run_migration_v16()?; + } + if current < SCHEMA_VERSION { self.conn .pragma_update(None, "user_version", SCHEMA_VERSION) @@ -999,6 +1005,24 @@ impl SessionDb { Ok(()) } + /// dirge-1elu.5: optional falsifiable `expectation` on memories. A + /// column added, NOTHING backfilled — existing rows stay NULL, so an + /// older DB opens and behaves exactly as before. Idempotent (guarded by + /// a column-existence check, like the v9/v11 column drops). + fn run_migration_v16(&self) -> Result<(), String> { + let has_expectation: bool = self + .conn + .prepare("SELECT 1 FROM pragma_table_info('memories') WHERE name = 'expectation'") + .and_then(|mut s| s.exists([])) + .unwrap_or(false); + if !has_expectation { + self.conn + .execute_batch("ALTER TABLE memories ADD COLUMN expectation TEXT;") + .map_err(|e| format!("Migration v16 failed: {e}"))?; + } + Ok(()) + } + /// v15: add schema_version column to entities for PRISM memory schema /// (#393). Defaults to 'generic' — PRISM-typed relations (HAS_FACET, /// DERIVED_FROM, etc.) only apply when schema_version is 'prism'. diff --git a/src/provider/build.rs b/src/provider/build.rs index 10b744af..d1ec8173 100644 --- a/src/provider/build.rs +++ b/src/provider/build.rs @@ -547,6 +547,11 @@ pub async fn build_agent( // dirge-uw2l.4: safe-state abort rung (off by default; advisory adds a // third failure-ladder rung that re-plans from the last verified-green // tree). See resolve_safe_state_abort_mode. + // dirge-1elu.1: publish-state guard (off by default; blocking + // intercepts commands that would discard verified-green work). + agent = agent.with_publish_guard_mode(cfg.resolve_publish_guard_mode()); + // dirge-d0e5.2: deterministic claim/evidence gate (off by default). + agent = agent.with_claim_gate_mode(cfg.resolve_claim_gate_mode()); agent = agent.with_safe_state_abort_mode(cfg.resolve_safe_state_abort_mode()); agent = agent.with_session_id(session_id); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 2f88ef23..d97eaa98 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -146,6 +146,14 @@ pub struct AnyAgent { /// forwarded to `LoopConfig.safe_state_abort_mode`. Defaults to `Off` /// (dirge-uw2l.4; the rung is opt-in and off is byte-identical). safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode, + /// Set by `build_agent` from `Config::resolve_publish_guard_mode`; + /// forwarded to `LoopConfig.publish_guard_mode`. Defaults to `Off` + /// (dirge-1elu.1; the guard is opt-in and off is byte-identical). + publish_guard_mode: crate::agent::agent_loop::types::GateMode, + /// Set by `build_agent` from `Config::resolve_claim_gate_mode`; + /// forwarded to `LoopConfig.claim_gate_mode`. Defaults to `Off` + /// (dirge-d0e5.2; the gate is opt-in and off is byte-identical). + claim_gate_mode: crate::agent::agent_loop::types::GateMode, /// Active session id forwarded to `LoopConfig.session_id` for the /// open-issues gate and session-scoped tools. `None` in sub-runners. session_id: Option, @@ -351,6 +359,8 @@ impl AnyAgent { verification_tiers_mode: crate::agent::agent_loop::types::GateMode::Off, verification_command: None, safe_state_abort_mode: crate::agent::agent_loop::types::SafeStateMode::Off, + publish_guard_mode: crate::agent::agent_loop::types::GateMode::Off, + claim_gate_mode: crate::agent::agent_loop::types::GateMode::Off, session_id: None, goal_fn: None, goal: None, @@ -579,6 +589,21 @@ impl AnyAgent { self } + /// dirge-1elu.1: set the publish-state guard mode. + pub fn with_publish_guard_mode( + mut self, + mode: crate::agent::agent_loop::types::GateMode, + ) -> Self { + self.publish_guard_mode = mode; + self + } + + /// dirge-d0e5.2: set the claim gate mode. + pub fn with_claim_gate_mode(mut self, mode: crate::agent::agent_loop::types::GateMode) -> Self { + self.claim_gate_mode = mode; + self + } + /// dirge-uw2l.4: set the safe-state abort rung mode. pub fn with_safe_state_abort_mode( mut self, diff --git a/src/provider/run.rs b/src/provider/run.rs index 03641ef3..ba83766c 100644 --- a/src/provider/run.rs +++ b/src/provider/run.rs @@ -41,6 +41,7 @@ pub(crate) fn headless_result_json( result: &str, session_id: &str, files_changed: &[String], + evidence: &serde_json::Value, ) -> serde_json::Value { let (subtype, is_error) = match end { RunEnd::Completed => ("success", false), @@ -65,24 +66,32 @@ pub(crate) fn headless_result_json( // project isn't a git repo, git isn't on PATH, or the edits were // committed mid-run — see dirge MCP `delegate` (issue #704). "files_changed": files_changed, + // dirge-d0e5.1: what this run actually did — turns, tool calls, + // and the verification status observed (or `observed: false` when + // no verification command ran at all). Lets a caller check a + // claimed test result against observed state without re-running. + "evidence": evidence, "total_cost_usd": 0.0, }) } -/// Files the run modified — snapshot the process-global modified-files -/// tracker (write/edit/edit_lines/edit_minified/apply_patch/bash all mark -/// it) and project each to a `cwd`-relative string when it lives under the -/// working dir, else its absolute path. Sorted + deduped. Empty when -/// nothing was touched. The tracker is authoritative and git-independent, -/// which is the point: a git-status diff silently reports nothing when the -/// project isn't a repo (issue #704). -fn headless_files_changed() -> Vec { +/// Files the run modified — snapshot the modified-files tracker for +/// entries marked AFTER `since_epoch` (captured at run start, see +/// [`run_print`]) and project each to a `cwd`-relative string when it +/// lives under the working dir, else its absolute path. Sorted + +/// deduped. Empty when nothing was touched. The tracker is +/// authoritative and git-independent, which is the point: a git-status +/// diff silently reports nothing when the project isn't a repo, when +/// git is off PATH, or when the run committed its edits mid-way (issue +/// #704). Scoping to the epoch keeps the delta per-run rather than +/// session-cumulative (dirge-d0e5.1). +fn headless_files_changed(since_epoch: u64) -> Vec { // Canonicalize cwd to match the tracker, which stores canonical paths; // a raw cwd wouldn't strip on symlinked dirs (e.g. macOS /tmp). let cwd = std::env::current_dir() .ok() .map(|c| crate::permission::path::canonical_or_self(&c)); - let mut v: Vec = crate::agent::tools::modified::recent(usize::MAX) + let mut v: Vec = crate::agent::tools::modified::since(since_epoch) .into_iter() .map(|p| { cwd.as_ref() @@ -97,6 +106,40 @@ fn headless_files_changed() -> Vec { v } +/// Evidence of what THIS run actually did, so a caller can check a +/// claimed result against observed state without re-running anything +/// (dirge-d0e5.1). Turns and tool-call count are counted by the headless +/// driver itself; the verification status is the loop's own final read, +/// handed across the session boundary by `finish_tally` — +/// `take_run_verification` CONSUMES it, so a run that verified nothing +/// reports `observed: false` rather than inheriting an earlier run's +/// green. `None` session id never recorded anything (`record` keys on +/// the id), so it reads as no verification observed. +fn run_evidence( + num_turns: u32, + tool_calls: &[crate::session::ToolCallEntry], + session_id: Option<&str>, +) -> serde_json::Value { + use crate::agent::agent_loop::verifier::{VerificationStatus, take_run_verification}; + let verification = match take_run_verification(session_id.unwrap_or("")) { + Some(status) => { + let observed = matches!( + status, + VerificationStatus::VerifiedGreen + | VerificationStatus::VerifiedRed + | VerificationStatus::FastGreenOnly + ); + serde_json::json!({ "observed": observed, "status": format!("{status:?}") }) + } + None => serde_json::json!({ "observed": false, "status": serde_json::Value::Null }), + }; + serde_json::json!({ + "turns": num_turns, + "tool_calls": tool_calls.len(), + "verification": verification, + }) +} + /// Build a stream-json `assistant` event for one turn (dirge-kuqp). /// The text block carries the turn's streamed text (omitted when /// empty so a tool-only turn doesn't emit a stray empty block), @@ -185,6 +228,14 @@ impl AnyAgent { let agent = self.clone().with_max_turns(Some(max_turns)); let start_instant = std::time::Instant::now(); let session_id = runner::uuid_v4_simple(); + // dirge-d0e5.1: scope this run's `files_changed` to files touched + // DURING this delegation, not since the session began. The child + // process rehydrates the session's cumulative registry before + // run_print starts (session/rehydrate.rs replays `mark_modified`), + // so capturing the epoch here excludes every replayed file — a + // delegation that changes nothing reports an empty delta even when + // earlier delegations in the same session changed files. + let since_epoch = crate::agent::tools::modified::epoch(); let mut num_turns: u32 = 0; let suppress_inline = !matches!(output_format, crate::cli::OutputFormat::Text); @@ -479,7 +530,8 @@ impl AnyAgent { num_turns, &full_response, &session_id, - &headless_files_changed(), + &headless_files_changed(since_epoch), + &run_evidence(num_turns, &tool_calls, self.session_id.as_deref()), ); match output_format { @@ -559,24 +611,56 @@ mod tests { /// — `--print` consumers parse this JSON, not stderr. #[test] fn result_envelope_reflects_run_end() { - let ok = headless_result_json(RunEnd::Completed, 10, 2, "answer", "sid", &[]); + let ok = headless_result_json( + RunEnd::Completed, + 10, + 2, + "answer", + "sid", + &[], + &serde_json::json!({}), + ); assert_eq!(ok["subtype"], "success"); assert_eq!(ok["is_error"], false); assert_eq!(ok["result"], "answer"); - let capped = headless_result_json(RunEnd::Truncated, 10, 100, "partial", "sid", &[]); + let capped = headless_result_json( + RunEnd::Truncated, + 10, + 100, + "partial", + "sid", + &[], + &serde_json::json!({}), + ); assert_eq!(capped["subtype"], "error_max_turns"); assert_eq!(capped["is_error"], true); assert_eq!(capped["result"], "partial", "partial text still delivered"); - let died = headless_result_json(RunEnd::Incomplete, 10, 1, "fragment", "sid", &[]); + let died = headless_result_json( + RunEnd::Incomplete, + 10, + 1, + "fragment", + "sid", + &[], + &serde_json::json!({}), + ); assert_eq!(died["subtype"], "error"); assert_eq!(died["is_error"], true); // dirge-u6zc: a usage-cap pause is a distinct, resumable outcome — // its own subtype so a wrapper can re-run after the reset, not the // generic "error" it'd share with a runner death. - let capped = headless_result_json(RunEnd::UsageCapped, 10, 5, "partial", "sid", &[]); + let capped = headless_result_json( + RunEnd::UsageCapped, + 10, + 5, + "partial", + "sid", + &[], + &serde_json::json!({}), + ); assert_eq!(capped["subtype"], "error_usage_cap"); assert_eq!(capped["is_error"], true); assert_eq!(capped["result"], "partial", "partial work still delivered"); @@ -588,17 +672,90 @@ mod tests { /// run touched nothing; never absent. #[test] fn result_envelope_carries_files_changed() { - let none = headless_result_json(RunEnd::Completed, 1, 1, "ok", "sid", &[]); + let none = headless_result_json( + RunEnd::Completed, + 1, + 1, + "ok", + "sid", + &[], + &serde_json::json!({}), + ); assert_eq!(none["files_changed"], serde_json::json!([])); let files = ["src/a.rs".to_string(), "tests/b.rs".to_string()]; - let env = headless_result_json(RunEnd::Completed, 1, 1, "ok", "sid", &files); + let env = headless_result_json( + RunEnd::Completed, + 1, + 1, + "ok", + "sid", + &files, + &serde_json::json!({}), + ); assert_eq!( env["files_changed"], serde_json::json!(["src/a.rs", "tests/b.rs"]) ); } + /// dirge-d0e5.1: `headless_files_changed` reports the tracker's + /// per-run delta — files marked after the run's epoch capture — and + /// nothing else. This is the #704 property (the tracker reports + /// edited files with no git involved, so a run that committed its + /// edits mid-way still reports them) scoped to the run rather than + /// the session. + #[test] + fn files_changed_reports_only_the_runs_epoch_delta() { + let dir = std::env::temp_dir().join("dirge-d0e5-headless"); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.rs"); + let b = dir.join("b.rs"); + std::fs::write(&a, "x").unwrap(); + std::fs::write(&b, "x").unwrap(); + + // Files the SESSION already had (replayed into the registry by + // rehydration before the run started). + crate::agent::tools::modified::mark_modified(&a); + crate::agent::tools::modified::mark_modified(&b); + let epoch = crate::agent::tools::modified::epoch(); + + // This run changes nothing → empty delta (spec acceptance 1). + let changed = headless_files_changed(epoch); + assert!( + changed.is_empty(), + "a delegation that changes nothing must report no files, got {changed:?}" + ); + + // This run touches a (via the tracker — no git involved) → only a. + crate::agent::tools::modified::mark_modified(&a); + let changed = headless_files_changed(epoch); + assert!( + changed.iter().any(|p| p.ends_with("a.rs")), + "the run must report the file it touched: {changed:?}" + ); + assert!( + !changed.iter().any(|p| p.ends_with("b.rs")), + "a session file untouched this run must not appear: {changed:?}" + ); + crate::agent::tools::modified::clear_modified(); + } + + /// dirge-d0e5.1: the envelope carries the evidence object. When no + /// verification status was recorded the evidence reports no + /// verification observed — the field a caller checks against a + /// claimed test result. + #[test] + fn result_envelope_carries_evidence() { + let ev = serde_json::json!({ + "turns": 3, + "tool_calls": 7, + "verification": { "observed": false, "status": null }, + }); + let env = headless_result_json(RunEnd::Completed, 1, 3, "ok", "sid", &[], &ev); + assert_eq!(env["evidence"], ev); + } + /// dirge-kuqp: a turn's assistant event carries its streamed text /// as a `text` block followed by one `tool_use` block per call, /// matching the Claude Code stream-json shape consumers parse. diff --git a/src/provider/spawn.rs b/src/provider/spawn.rs index c3aa9691..b66e82ba 100644 --- a/src/provider/spawn.rs +++ b/src/provider/spawn.rs @@ -277,6 +277,8 @@ impl AnyAgent { cfg.open_issues_gate_mode = self.open_issues_gate_mode; cfg.verification_tiers_mode = self.verification_tiers_mode; cfg.safe_state_abort_mode = self.safe_state_abort_mode; + cfg.publish_guard_mode = self.publish_guard_mode; + cfg.claim_gate_mode = self.claim_gate_mode; cfg.session_id = self.session_id.clone(); cfg.goal_fn = self.goal_fn.clone(); // dirge-5mtx.3: classify judge. No consumer in run.rs yet — diff --git a/src/ui/permission_ui.rs b/src/ui/permission_ui.rs index cff656c0..1bd31cf6 100644 --- a/src/ui/permission_ui.rs +++ b/src/ui/permission_ui.rs @@ -168,10 +168,12 @@ pub(crate) fn is_placeholder_pattern(p: &str) -> bool { /// cover `echo $(rm -rf ~)`). Offering "allow always" anyway saved an entry /// that could never match, told the user it was saved, and then re-prompted /// on the very next identical command. +#[cfg_attr(not(feature = "semantic"), allow(unused_variables))] pub(crate) fn allow_always_downgrade_reason(tool: &str, input: &str) -> Option<&'static str> { if input.trim().is_empty() { return Some("can't derive a useful pattern from empty input"); } + #[cfg(feature = "semantic")] if tool == "bash" && crate::semantic::adapters::bash::command_is_complex(input) { return Some( "commands with shell substitution or a subshell are never covered by a saved rule \ @@ -203,6 +205,7 @@ fn segment_already_allowed(segment: &str) -> bool { /// manufacture a phantom segment. Falls back to the coarse separator split /// when that parser is unavailable or declines to decompose the command. fn bash_segments(command: &str) -> Vec { + #[cfg(feature = "semantic")] if let Ok((segments, complex)) = crate::semantic::adapters::bash::parse_bash_segments_full(command) && !complex diff --git a/src/ui/run_handlers/context_compacted.rs b/src/ui/run_handlers/context_compacted.rs index 624ee41c..692aaa71 100644 --- a/src/ui/run_handlers/context_compacted.rs +++ b/src/ui/run_handlers/context_compacted.rs @@ -240,12 +240,26 @@ pub(crate) async fn handle_context_compacted( // build the digest on-thread, defer its git subprocess to the task. let base = crate::agent::review::build_transcript(ctx.session); let digest = crate::agent::session_digest::SessionDigest::from_session(ctx.session); + // dirge-1elu.7: deterministic expectation signals. The verifier gate + // is a run_loop local and `Config` carries no verifier, so the run + // hands its final status over through the session-keyed slot in + // `verifier::run_status`. Compaction fires MID-run, so the slot is + // usually still empty here — that reads as "could not tell" (neither + // counter moves), never as a fake green or red. Command expectations + // evaluate from the digest either way. + let expectation_commands = digest.commands.clone(); + let final_verification = + crate::agent::agent_loop::verifier::take_run_verification(ctx.session.id.as_ref()); crate::agent::post_session::spawn_post_session( agent.clone(), paths, digest, base, ctx.cfg.memory_graduation.unwrap_or(true), + crate::extras::memory_db::ExpectationSignals { + final_verification, + commands: expectation_commands, + }, ); } Ok(()) diff --git a/src/ui/run_handlers/done.rs b/src/ui/run_handlers/done.rs index 7537600f..0ff3193c 100644 --- a/src/ui/run_handlers/done.rs +++ b/src/ui/run_handlers/done.rs @@ -658,12 +658,25 @@ pub(crate) fn finalize_idle_turn( // skills curator, then memory curator, strictly ordered inside ONE detached // task so a skill the review creates is flushed before the curator reads it // and the three runners never fire concurrently. Fire-and-forget. + // dirge-1elu.7: deterministic expectation signals. The verifier gate is a + // run_loop local and is gone by the time this idle path runs, so the run + // hands its final status over through the session-keyed slot in + // `verifier::run_status`. Taking it is consuming: a later run that + // verified nothing finds an empty slot and reads as "could not tell" + // (NEITHER counter moves) rather than inheriting an earlier green. + let expectation_commands = digest.commands.clone(); + let final_verification = + crate::agent::agent_loop::verifier::take_run_verification(session.id.as_ref()); crate::agent::post_session::spawn_post_session( agent.clone(), paths, digest, base, graduation_enabled, + crate::extras::memory_db::ExpectationSignals { + final_verification, + commands: expectation_commands, + }, ); // Drain the interjection queue: concatenate all queued messages into one