fix(discovery): make the by-value fallback reachable, declare the researcher's real tool grant, and echo the input back - #2222
Conversation
…earcher's real tool grant, and echo the input back
Three defects on one plugin, one release.
The plugin's own by-value fallback (reference/topic-docs.md) was referenced by
no agent and by neither recovery ladder, and following it guaranteed exit 1 from
check-dispatch-artifact.sh — which both SKILL.md files declared halts the
workflow. No payload value could say "work complete, persistence failed" either:
`truncated` is the budget stop whose ladder rung is discard, and `complete`
needs an artifact pointer there is no file for. Both available values
misdescribed the run and the honest one threw the work away.
`persistence: written | by-value` is now its own payload axis on both agents,
with the matching rung ahead of the resume rung in both ladders: the parent
writes the slice from the payload's verbatim bodies, then re-runs the same gate.
The exception is to the halt, not to the gate — the workflow proceeds only on a
subsequent exit 0, and a by-value payload returning findings instead of artifact
bodies is a failed dispatch, not a fallback.
agents/researcher.md declared no tools: and no disallowedTools:, so it inherited
everything, while its Tool-honesty prose — copied verbatim from explorer.md,
where the allowlist makes it true — asserted `Edit` is absent and `Agent` is
listed. Both false there, on an unattended maxTurns: 40 worker whose write
boundary is instruction-held and calibrated against that false inventory. The
section now states the inherited pool. No allowlist was added (it would strip
the MCP tools the research discipline requires); a narrow denylist is used
instead. NotebookEdit denied, Edit deliberately kept (the coverage ledger is
edited row by row), EnterWorktree/ExitWorktree denied and isolation: worktree
declined on both agents — an isolated checkout puts the artifact where the
parent's disk-graded gate cannot see it.
Every refusal mechanism in the plugin was a presence test, so none could fire on
an input that arrived present and wrong — observed 2026-08-10, an argument
naming another plugin's ${CLAUDE_PLUGIN_DATA} reached the agent rewritten to
this plugin's own path. Both agents now echo back scope_as_received /
topic_as_received and both gates compare it against the envelope the parent
wrote. The accompanying caveat is observation-shaped rather than
mechanism-shaped, carries an unconditional expiry, and states its own boundary
against the separate preload-substitution claim it sits beside.
Closes #2203
Closes #2204
Closes #2205
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 5m 40s —— View job Review completeTodo list
Scope note
What I checkedI read every changed file in full (not just the diff hunks) and traced the cross-file contract end to end: the new I traced the recovery-ladder logic by hand: the "by-value" rung is reachable (exit 1 + I hand-verified the new I was not able to execute FindingsOne low-severity nit, posted inline: No correctness bugs found. The cross-document contract (agent payload ↔ |
|
Claude finished @kyle-sexton's task in 3m 17s —— View job Security Review — PR #2222Todo list
Scope: reviewed only the 14 files changed in this PR ( FindingsIMPORTANT — new by-value recovery path writes agent-supplied filenames with no path-traversal check (confidence: medium) This PR introduces a new "
The base directory is anchored safely (it's the parent's own pre-dispatch-resolved slice path, not something the agent controls). But the individual filenames (" Why this matters here specifically: This is a genuinely new trust-boundary shift introduced by this PR: previously the only write path was the subagent's own (already instruction-only-bounded) Suggested mitigation: add an explicit instruction (mirroring the new Not flagged (reviewed, no issue found)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 456a82c24d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The repo's exec-bit hygiene gate requires every `*.sh` under `plugins/` to be mode 100755; the new `agents/tool-honesty.test.sh` landed at 100644. Both sibling tests under `scripts/` are already 755, so this is conformance, not a new convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 44s —— View job Security Review — PR #2222Todo list
What changed since the last reviewThe only commit added since the previous security review ( Scope: only the 14 files changed in this PR. No GitHub Actions workflow files are touched, so no FindingsIMPORTANT — new by-value recovery path instructs the parent to write agent-supplied filenames with no path-traversal / allow-list check (confidence: medium) Confirmed still present, unchanged, at current HEAD. This PR adds a
The base directory is anchored safely (the parent's own pre-dispatch-resolved slice path — not agent-controlled). But the individual filenames ( Why this matters specifically here: This is a genuine new trust-boundary shift: previously the only write path was the subagent's own instruction-bounded Suggested mitigation: add an explicit instruction (mirroring the Not flagged (reviewed, no security issue)
|
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Claude finished @kyle-sexton's task in 5m 7s —— View job Code Review — PR #2222 (correctness / architecture / maintainability lane)Per this repo's Todo list
Findings (posted as inline comments)I independently traced both issues the automated Codex review flagged on the
Both are genuine logic gaps in the new contract text, not stylistic nits — verified by reading the actual script behavior and the conflicting prose myself rather than restating the bot's comments. Details and suggested fixes are in the inline comments. Test coverage note (not filed inline — no single line to anchor it to)Neither What looks solid
|
All four are in text this PR added, and three of them are the same shape the PR exists to fix — a new rule that contradicts an existing one two files away. 1. The explorer's collision rule (an unrelated EXPLORE.md already in the slice root goes to a sub-slice) was contradicted by the by-value text, on both sides: the agent was told artifact: is "the index path from your dispatch envelope" unconditionally, and the parent was told to write into "the memory-slice path resolved before dispatch" unconditionally. A recovery into an occupied slice root would have overwritten the index the collision rule exists to protect — a silent, unrecoverable loss arriving through the recovery path. Both sides now apply the rule; the parent assigns the sub-slice, as it assigns every other one. 2. Phase 0 writes no research-checklist.md for an unbounded corpus, but the by-value instructions listed it unconditionally among the bodies to append and the ladder re-ran check-coverage-complete.sh "over the ledger the payload supplied" unconditionally. A missing ledger exits 2, which SKILL.md states is a FAIL, so a legitimately complete unbounded-corpus run would have halted on a check that never applied to it. The ledger is now returned and graded only when one was owed, with the standing unbounded-corpus rule cited unchanged. 3. Security: the by-value rung is the only place in this contract where a filename the worker produced becomes a write the parent performs, and the parent holds wider write permission than the sandboxed worker. The researcher's whole run is spent ingesting untrusted third-party pages, so a payload it emits is not a trusted source of paths. Both ladders now check filenames BEFORE anything reaches disk, accept only the contract's own names as bare filenames, and treat a directory separator, a `..` segment or a leading `/` as a failed dispatch rather than a name to sanitize. Both agents are told the same rule from their side, and told why. 4. Evals: the unbounded-corpus + by-value combination that (2) falls into was uncovered — eval 13 uses a bounded corpus. Added it, plus the collision case and a filename-rejection case on both skills. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 54s —— View job Security Review — PR #2222Todo list
What changed since the last security reviewThe previous two security reviews (on Previously reported finding: RESOLVEDThe path-traversal / filename allow-list gap is fixed on both the agent side and the parent side, symmetrically for
I consider this adequately mitigated for a contract expressed as agent instructions (i.e., there's no way to mechanically enforce this in a system with no code path here, only prose) — the fix follows the same shape as the Rest of the diff (fresh review)No other file changed between the last review and current HEAD carries security-relevant content:
ConclusionNo outstanding or new security issues. The previously reported IMPORTANT finding (by-value recovery path writing agent-supplied filenames with no traversal check) has been fixed with a filename allow-list enforced on both the agent-instruction side and, more importantly, the parent-instruction side, backed by eval coverage. |
Every `tools:`-bearing agent in this repo quotes its comma-separated value; the docs' own example is unquoted and YAML parses both the same, so this is consistency, not correctness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 35s —— View job Security Review — PR #2222 (re-run at HEAD
|
|
Claude finished @kyle-sexton's task in 2m 48s —— View job Code Review — PR #2222 (correctness / architecture / maintainability lane)Todo list
Per What changed since the last review roundTwo commits landed since the prior code-review pass on this PR: All four are correctly fixed, and consistently across every file that needed it:
I independently re-read the full text of
New findingsNone. I did not find any new correctness or design issues introduced by these two commits, and no regressions in the areas the fix touched. Not independently verifiedI do not have shell-execution approval in this sandboxed session ( |
…uence resume before discard, and un-invert the research hub (#2311) ## Summary Six filed issues against `discovery`, one plugin, one release: **0.14.0 → 0.15.0**. Every one is the same shape — an obligation or an explanation carried in several files that drifted apart, or a harness behavior asserted without a source. **#2270 — the `$ARGUMENTS`-on-preload claim, retired rather than inverted.** Verified at HEAD before touching it: the empty-string rendering was asserted at the 5 core sites the issue names, weakly at `explore/SKILL.md:161`, **and at four more the issue did not count** — `skills/explore/evals/evals.json:69,73` and `skills/research/evals/evals.json:124,128`, where it had become a *grading criterion*. 12 grep hits in total. Re-checked against raw markdown (not a summarized fetch) on 2026-08-11: the skills page scopes the placeholder to "All arguments passed **when invoking** the skill" and says preload "work[s] differently: the full skill content is injected at startup"; the sub-agents page says only "The full content of each listed skill is injected into the subagent's context at startup". **Neither covers argument substitution on that path in either direction.** So this ships the rule that holds whichever way the harness renders it — *do not rely on seeing an unfilled slot; a topic that did not arrive in the dispatch prompt is a parent-envelope failure the agent reports rather than repairs* — and records the doc status once. Recorded as **unsupported, not false**; the opposite is not asserted either. The `${CLAUDE_…}`-caller caveat #2222 separated from this is kept separate. Same issue's `F6`: three files stated three different write boundaries. Reconciled to one statement in `reference/topic-docs.md` with a `scratch-` naming prefix and a cleanup owner. The researcher's **session** scratch dir is kept as a distinct, harness-owned place rather than merged — they were never the same location. `reference/artifact-protocol.md` is byte-identical across four plugins and is not touched. **#2272 — a contradiction, resolved by sequencing.** 5 sites, not 3: the three discard statements plus `explore/reference/dispatch.md:133`, which back-referenced the discard rule to justify its own. **Resume first; decide about the slice from what the resume returns**, sourced against the sub-agents page ("Resumed subagents retain their full conversation history … picks up exactly where it stopped"). The discard is not removed — it is sequenced, and stays mandatory once the resume is refused. `truncated` still means the turn-budget stop, so #2203's `persistence:` axis is not reopened. **#2268 — envelope delivery.** `Memory root` becomes a row in the parent-obligation table and the envelope becomes one labelled template, reproduced from `research-deep`'s existing literal block so the two cannot drift. Memory root is the one *degradable* field (derive + flag, not halt). Per the issue's constraint, **write capability is not asserted as a flag**: it is not probeable pre-dispatch, the parent's own `mkdir`/`touch` proves only the parent, and an agent-side probe would corrupt the freshness baseline — the question is routed to `persistence: by-value`, which already answers it. **#2269 — portability.** The baseline command now has one home carrying POSIX **and** PowerShell forms; the two monorepo pointers become `${CLAUDE_PLUGIN_ROOT}` form. **#2267 — the gate's grants and step 1.** `allowed-tools` pairing **does not apply here**, on three sourced legs from the skills page (raw markdown, 2026-08-11): `${CLAUDE_PLUGIN_ROOT}` is not substituted in `allowed-tools` rules (inert grant); `${CLAUDE_SKILL_DIR}` is "the skill's subdirectory within the plugin, **not the plugin root**", where these scripts live because one gate serves both families; and the grant "clears when you send your next message" while the parent runs the gate a turn later. `bash` is not in the docs' stripped-wrapper list, so a covering rule would be interpreter-led — this repo's `permission-rule-hygiene` anti-pattern 1. Following #2225's precedent means shipping the **rationale plus a test**, not an inert grant: both skills now state *a gate that could not run is a FAIL, never a skip*, and the operator-setup path the docs prescribe is recorded. `explorer` `maxTurns` 30 → 40 **on parity grounds only** — explicitly not offered as the cause of any past bare-prose return, since `evidence-2.md` supersedes that reading. "Budget a turn for the payload" is replaced by emitting the payload early, because an agent cannot observe its own remaining budget. **#2271 — the inversion, re-measured at HEAD and reduced.** `research/SKILL.md` was **6,629 words vs `context/discipline.md`'s 5,087** (confirmed unchanged since `9b34a82a`). This PR **shrinks** it: **6,629 → 5,026 words, 242 → 232 lines**, now 210 words *below* the spoke. The two densest lines moved rather than compressed — the fetch-log spec to `context/artifact-shape.md`, criterion 9's elaboration into `context/discipline.md`'s existing artifact-ladder section. The description gains its missing `research-deep` boundary clause. ### New surface `reference/parent-contract.md` — the parent's **cross-family** contract. The plugin had two family-specific parent-side spokes and no home for what is identical across both; that absence is why five statements existed in 2–6 copies each. Each existing spoke keeps its family-specific half and points here. ### Not fixed, deliberately **#2267 `B-F8`** (three consecutive releases fixed assertions that could not fail) is left unchecked. It is a process observation about past releases, not a defect at HEAD, and inventing a mechanism for it would be the silently-checked row this batch is named for. The repo already owns this class in `scripts/check-discriminating-test-skips.sh`. It did, however, catch this PR's own test. A first revision asserted that each agent *"points at"* the write boundary — which passed at the merge-base too, because both agents already linked `topic-docs.md` for an unrelated reason. Two vacuous `ok`s, inside a test written to pin non-vacuity. Those assertions now key on the restatements being **gone**. ### Two corrections made to this PR's own claims Recorded because the rest of the batch is about exactly this. 1. **A false mechanism in the first CHANGELOG draft.** It said criterion 9's elaboration was "folded into `context/discipline.md`'s existing artifact-ladder section". `git diff --numstat` says `discipline.md` gained **2 lines** — the Tier-3 exception, nothing else. What actually happened is that criterion 9's cell was **compressed to a pointer at text `discipline.md` already carried** ("A probe locates a rung; it does not grade one", the exhaustive-surface rule, the `unresolved` default, all in "Primary-source-first protocol" before this PR): the hub was restating a spoke rather than owning anything. Three distinct operations — one move to `artifact-shape.md` (+644 words), one move to `discipline.md` (+149), one compress-to-existing — are now stated as three. 2. **A count that was not executed.** "12 grep hits" was arithmetic, not output. The union over all five patterns at `a0abaf81` is **10 lines across 7 files**, and one of those patterns (`reaches a preloaded skill`, in both `evals.json` grading criteria) had no assertion pinning it. It has one now. Two pointers created in the trimming pass were also grep-verified against their targets rather than assumed: `discipline.md`'s "Recency gate" does carry the stable-project carve-out and the 30/14/90 windows, and "Corpus enumeration" does carry both the exhaustive-surface table and the criterion-drift reasoning. ### Explore/research parity The trimming pass initially hit `research/SKILL.md` only, which left three verbatim-shared gate sentences compressed on one side and not the other. This plugin's 0.12.0 rationale treats that parity as a value (#2268 cites it), so the same compressions were applied to `explore/SKILL.md`: **4,154 → 3,977 words.** `explore/SKILL.md` is *also* hub-inverted against its own 2,007-word `reference/dispatch.md`; that is unfiled and out of this PR's scope, and is reported rather than fixed. ## Test plan New: `plugins/discovery/scripts/contract.test.sh` — 25 assertions, discovered automatically by `scripts/run-plugin-tests.sh`, which globs `plugins/**/*.test.sh`. **Fail-before — the shipped test run against a detached worktree at the merge-base `a0abaf81`. 23 of 25 fail:** ```console $ bash plugins/discovery/scripts/contract.test.sh FAIL - no file asserts $ARGUMENTS substitutes to the empty string — 3 hit(s) FAIL - no file asserts $ARGUMENTS reaches a preloaded body as the empty string — 2 hit(s) FAIL - no evals entry grades against "empty under preload" — 2 hit(s) FAIL - no file asserts $ARGUMENTS is empty under dispatch — 1 hit(s) FAIL - no evals criterion asserts what does or does not reach a preloaded skill — 4 hit(s) FAIL - no monorepo-path pointer to the agent definitions — 2 hit(s) FAIL - the pre-dispatch baseline command has exactly one home — found in: <6 files> FAIL - the baseline home states a PowerShell form — no match for /New-Item/ in reference/parent-contract.md FAIL - no file prescribes discard-instead-of-resume — 3 hit(s) FAIL - no file back-references a discard-rather-than-resume rule — 1 hit(s) FAIL - the ordering is stated once, in the parent contract — no match for /Resume first/ in reference/parent-contract.md FAIL - the research parent-obligation table carries a Memory root row — no match for /^\| Memory root \|/ in skills/research/context/dispatch.md FAIL - the parent contract ships a literal envelope template — no match for /Memory root:/ in reference/parent-contract.md ok - no Bash permission rule is written with the non-substituting ${CLAUDE_PLUGIN_ROOT} ok - neither skill declares allowed-tools (the un-run case is stated instead) FAIL - the un-run case is stated — no match for /could not run/ in reference/parent-contract.md FAIL - explorer maxTurns (30) >= researcher maxTurns (40) FAIL - research/SKILL.md (6629 words) is NOT smaller than context/discipline.md (5087 words) FAIL - the research description carries a boundary against research-deep — no match for /^description:.*research-deep/ in skills/research/SKILL.md FAIL - the write boundary names a scratch prefix — no match for /scratch-/ in reference/topic-docs.md FAIL - the write boundary assigns a cleanup owner — no match for /[Cc]leanup/ in reference/topic-docs.md FAIL - no agent restates the write boundary as a closed two-destination list — 1 hit(s) FAIL - no agent restates the write boundary as a single destination — 1 hit(s) FAIL - agents/explorer.md defers to the single write boundary — no match for /single write boundary/ in agents/explorer.md FAIL - agents/researcher.md defers to the single write boundary — no match for /single write boundary/ in agents/researcher.md 23 contract assertion(s) failed. ``` `<6 files>` is elided for width; the real output names `skills/explore/evals/evals.json`, `skills/explore/reference/dispatch.md`, `skills/explore/SKILL.md`, `skills/research/evals/evals.json`, `skills/research/SKILL.md`, `skills/research-deep/SKILL.md`. The two `ok`s at the merge-base are the deliberate **no-grant guards** — they pin a decision (this plugin ships no `allowed-tools` rule, and no rule is written with the non-substituting `${CLAUDE_PLUGIN_ROOT}`) rather than a fix, so they correctly hold on both sides. **Pass-after, at the tip — all 25:** ```console $ bash plugins/discovery/scripts/contract.test.sh ok - no file asserts $ARGUMENTS substitutes to the empty string ok - no file asserts $ARGUMENTS reaches a preloaded body as the empty string ok - no evals entry grades against "empty under preload" ok - no file asserts $ARGUMENTS is empty under dispatch ok - no evals criterion asserts what does or does not reach a preloaded skill ok - no monorepo-path pointer to the agent definitions ok - the pre-dispatch baseline command has exactly one home ok - the baseline home states a PowerShell form ok - no file prescribes discard-instead-of-resume ok - no file back-references a discard-rather-than-resume rule ok - the ordering is stated once, in the parent contract ok - the research parent-obligation table carries a Memory root row ok - the parent contract ships a literal envelope template ok - no Bash permission rule is written with the non-substituting ${CLAUDE_PLUGIN_ROOT} ok - neither skill declares allowed-tools (the un-run case is stated instead) ok - the un-run case is stated ok - explorer maxTurns (40) >= researcher maxTurns (40) ok - research/SKILL.md (5026 words) is smaller than context/discipline.md (5236 words) ok - the research description carries a boundary against research-deep ok - the write boundary names a scratch prefix ok - the write boundary assigns a cleanup owner ok - no agent restates the write boundary as a closed two-destination list ok - no agent restates the write boundary as a single destination ok - agents/explorer.md defers to the single write boundary ok - agents/researcher.md defers to the single write boundary All contract assertions passed. EXIT=0 ``` **No regression in the two existing suites:** ```console $ bash plugins/discovery/scripts/check-dispatch-artifact.test.sh # exit 0 all tests passed $ bash plugins/discovery/scripts/check-coverage-complete.test.sh # exit 0 All checks passed. ``` **Repo gates run locally:** ```console $ bash scripts/check-changelog-parity.sh --check Every versioned plugin has a CHANGELOG.md (or a stale-guarded baseline entry), and none documents a version above its manifest. $ bash scripts/check-changelog-parity.sh --check-order All 75 changelog(s) read newest-first with no duplicate versions. $ bash scripts/check-changelog-parity.sh --check-bump origin/main Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. $ npx markdownlint-cli2 --config .markdownlint-cli2.jsonc "plugins/discovery/**/*.md" Linting: 19 files Summary: 0 issues in 0 files $ bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/discovery/skills CHECK-LISTING-BUDGET: OK — aggregate 2739/8000 chars within budget. ``` Both `evals.json` files re-parsed with `json.load` after the wording sweep — valid. ## Related Closes #2267 Closes #2268 Closes #2269 Closes #2270 Closes #2271 Closes #2272 Inbox items: `20260810-225904-discovery-dispatch-persistence-contract`, `2026-08-10-plugin-quality-audit-four-components` (023241Z), `20260811-021645-plugin-audit-four-components-and-guard-deadlock-ownership`. Ledgers: `.work/handoff-inbox-batch-4/ledgers/I1-discovery-dispatch.md`, `ledgers/I7-four-components-023241Z.md`, `ledgers/I9-021645-four-components.md`. Adjacent, not reopened: #2203 / #2222 (the `persistence:` axis), #2225 (the paired body+rule precedent this follows), #2237 / #1824 (`${CLAUDE_SKILL_DIR}` substitution scope), #1414 (no Windows runner, which is why #2269's class survives green checks). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Summary
Three verified HIGH defects in
discovery, shipped as one release (0.13.1→0.14.0— new payload fields, a new gate step, and a new frontmatter key are behavior).1. The by-value fallback was unreachable, and no payload value could reach it
reference/topic-docs.md:34-39has said since the 2.0.0 contract that "a worker dispatched into its own checkout (worktree or background session) returns findings by value instead, and the parent writes the memory slice." Nothing referenced it as a persistence mode: neither agent, and neither recovery ladder —skills/explore/reference/dispatch.md:78-99andskills/research/context/dispatch.md:142-172had rungs for a bad envelope, a live agent, and a refused resume, and none for the worker could not write. Following the rule guaranteed a halt: an empty slice holding only the pre-dispatch baseline is exactly whatcheck-dispatch-artifact.sh:248-251exits 1 on, andskills/explore/SKILL.md:59/skills/research/SKILL.md:60declared any non-zero exit to halt the workflow. Two documents in one plugin, opposite outcomes for the same run, and the correct one unreachable.No payload value could describe it either.
statuswascomplete | truncated;truncatedis the turn-budget stop whose ladder consequence is discard the partial slice, andcompleterequires anartifact:pointer the run had no file to name. Both values misdescribed a run that finished and could not save, and the honest one routed the parent to throw good work away.Fixed:
persistence: written | by-valueis now its own payload axis on both agents —truncatedkeeps meaning the budget stop so the discard rung stays correct, andcoveragestays about exploration and the corpus ledger rather than the disk. On the by-value path the agent returns index, sidecars and ledger as verbatim bodies,artifact:names the path the parent must write to (a destination, not a claim a file exists), and both ladders gained the rung ahead of the resume rung: the parent writes the slice, then re-runs the same gate.The exception is to the halt, not to the gate, and both
SKILL.mdfiles say so in those terms. The workflow proceeds only on a subsequent exit 0 — for research, from both the artifact gate and the coverage-ledger gate.persistence: by-valueroutes the parent and grades nothing; a by-value payload returning findings instead of artifact bodies is a failed dispatch, not a fallback, because a claim the gate is invited to accept on the agent's word is the same laundering the source-tier discipline refuses everywhere else.This also closes the seam where exit 1 read identically for "never ran" and "ran well, could not persist". The script is right to grade disk state only; the branch belongs one level up, where gate step 1 has already put the payload in the parent's hands.
2.
agents/researcher.mddescribed a tool grant it never madeNo
tools:key and nodisallowedTools:key, so it inherited every subagent tool — while its own Tool-honesty section asserted "Editis absent from your tool list" and "Agentis listed." The paragraph is a verbatim copy fromagents/explorer.md, where the allowlist at line 4 makes both sentences true; it was carried into a file whose frontmatter inverts them. Inheritance was observed, not merely derived — the agent's transcript showsToolSearchandWebFetchcalls, neither in the explorer's allowlist. This is an unattendedmaxTurns: 40worker whose write boundary is instruction-held, and the false inventory was the calibration input for that boundary.Fixed: the section states the inherited pool truthfully. No
tools:allowlist was added — an allowlist removes every MCP tool ("The subagent can't edit files, write files, or use any MCP tools"), and mandatory discipline #3 requires doc-MCP servers in the tool spread, so the allowlist would break the discipline it was meant to protect. A narrowdisallowedTools:denylist is used instead.Three decisions are now written down rather than accidental:
NotebookEditdenied — nothing in the contract writes notebooks.Editdeliberately kept —research-checklist.mdrows go[ ]→[x]per phase, which isEdit-shaped; denying it forces a full-file ledger rewrite at every phase boundary.EnterWorktree/ExitWorktreedenied,isolation: worktreedeclined on both agents — these artifacts are graded off disk by the parent, in the parent's checkout, against a slice path resolved before dispatch. Work written into an isolated copy lands where that gate never looks, so the run reads as having produced nothing. Isolation and a disk-graded handoff are incompatible by construction; the plugin chose the handoff. The explorer/researcher asymmetry is now stated as deliberate in both files.One thing this does not claim. It does not make the write boundary enforceable — you cannot deny "Bash writing a file" without denying
Bash, which the research discipline needs. Both agents instead gain an explicit instruction that a refusedWriteis an answer, not an obstacle, and must not be routed throughBash. That is grounded in the transcript asymmetry it was observed as (threeWritecalls refused while a Bash-mediated write succeeded to the same directory tree), not in any documented rule about which guard covers which tool.3. Nothing restated the input, so a corrupted scope/topic passed every gate
Every refusal mechanism was a presence test — token present or
MISSING, envelope field present or absent, index on disk or not, ledger rows marked or not,artifact:present or not — so none could fire on an input that arrived present and wrong. Observed 2026-08-10: an argument naming another plugin's${CLAUDE_PLUGIN_DATA}reached a dispatched agent rewritten to this plugin's own path. The agent was asked a factually wrong question and answered it correctly.Fixed: both agents echo
scope_as_received:/topic_as_received:verbatim (explicitly not paraphrased or normalized), and both acceptance gates compare it against the envelope the parent wrote. A mismatch is a failed dispatch even when the artifact is complete and every mechanical check exits 0; a payload lacking the field is an out-of-date agent definition, not a pass.The caveat in
explore,researchandresearch-deepis observation-shaped, not mechanism-shaped: it states the observation, the two documented facts (skill and agent content is a substitution site for the three${CLAUDE_*}placeholders "anywhere the placeholder appears"; no escape exists — "A backslash before any other$is left unchanged"), and the gap (no page states whether argument-supplied text is itself scanned). It carries an unconditional 2027-02-11 expiry.research-deepgets the caveat only — it has no agent and no payload contract of its own.Cross-ledger adjudications honored
F2, I9D-F2, I7B-F2). Implemented once; all three ledgers' evidence is folded into fix(discovery): agents/researcher.md declares no tools and its Tool-honesty prose describes a grant it never made #2204.F1+F3are one change, and I9's constraint survives it: the value of a third outcome is routing, not acceptance. The payload carries no research fallback the gate then grades; the gate is unchanged and still authoritative.RKD-11says discovery "handles the write block well." I1F1governs: that path was graded unreachable by direct inspection and reproduced empirically. Fixed as a HIGH.B-F5's evidence (the exit-1 rung dead-ends on a nonexistent index) merged into fix(discovery): the by-value fallback is unreachable and no payload value expresses "complete but unpersisted" #2203 rather than filed separately.F4isrationale_falsifiedand is not implemented here. Its framing is not reused: the Bash-evasion caution in both agents argues from the transcript asymmetry, never from the worktrees page's isolation check list, which is scoped to a session already isolated in a worktree reaching a main checkout — neither of which the observed run had.The
$ARGUMENTSconflict, adjudicatedThe coordinator flagged an unadjudicated tension between I9
D-F6(the "$ARGUMENTSempties on the preload path" claim, asserted at 5 sites, unsupported by current docs) and I7B-F1(this PR's change 3). They are not incompatible — they are about different things, and the appearance of conflict comes from both being described as "$ARGUMENTShandling":D-F6/ I1F8concern a$ARGUMENTSplaceholder the plugin's own body carries, on the preload path.B-F1concerns placeholder-shaped text a caller supplies, on the invocation/inline path and in the dispatch prompt the parent writes.Both can hold: the skills page treats preload as a distinct path ("Subagents with preloaded skills work differently: the full skill content is injected at startup"). On doc support,
B-F1's remediation wins outright — its confirmed halves are the plugins-reference substitution-domain row and the skills-page escape rule, and its unconfirmed half (ordering) does not change the fix, which is correct either way.D-F6's claim is documented nowhere and its nearest analogue (thecontext: forkwalkthrough, showing\$ARGUMENTSarriving unsubstituted) points the other way.So this PR implements
B-F1and leavesD-F6's five sites untouched —D-F6/F8areDOC_ONLYand not this lane's rows. To avoid leaving the tension implicit in the tree, each new caveat now states its own boundary: it names the path it covers and says explicitly that it is a different question from the adjacent preload claim and is evidence for neither side of it. Verified mechanically: this diff adds zero new sites restating the empties-on-preload claim (still exactly the 5 pre-existing ones). The echo-back design does not depend on that claim being true or false.Test plan
Both new tests fail before the change and pass after.
1. The by-value gate pair —
scripts/check-dispatch-artifact.test.sh, extended. Asserts the empty by-value slice exits 1 and that the same slice exits 0 once the parent writes it from the payload, with freshness and pointer checks both passing. The pair proves the exception routes through the gate rather than around it. Runs for both artifact families.2. The agent-contract lock —
plugins/discovery/agents/tool-honesty.test.sh, new. Locks the class rather than the instance: prose claiming a tool is absent from (or present in) a tool list must be backed by atools:key that actually omits (or lists) it; every agent must declare its posture in frontmatter rather than leaving the prose as the only inventory; neither agent may setisolation:; both payload contracts must carrypersistence:and the echo-back field. Scoped to this plugin's agents on purpose — a repo-wide sweep would fail this plugin's test on another plugin's drift.Against HEAD (
origin/main) agent definitions, i.e. before the change:After the change:
3. Every test in the plugin
4. Lint, format, changelog parity, JSON validity
Eval cases added on both skills covering the outcomes the new axis must keep apart: a by-value recovery that is written and re-graded rather than discarded; a by-value payload carrying findings instead of artifact bodies (a failed dispatch); an echo-back mismatch that fails a dispatch whose artifact is otherwise perfect; a by-value payload naming a file outside the contract (rejected before anything is written); an explore-side recovery into a slice root already holding an unrelated index; and a research-side recovery of an unbounded corpus, which owes no ledger and must not be graded against one.
Re-run green after every follow-up commit:
tool-honesty.test.sh,check-dispatch-artifact.test.sh,check-coverage-complete.test.sh,markdownlint-cli2over all 18 plugin markdown files,shellcheck --rcfile=.shellcheckrc,shfmt -d,check-shell-portability.sh origin/main, and eval-JSON validity with unique ids.Security note
Revised after review — my first version of this section was wrong, and the correction is in the diff. I wrote "no trust surface widens." One does. The by-value rung is the only place in this contract where a filename the worker produced becomes a write the parent performs, and the parent holds wider write permission than the sandboxed worker — while the researcher spends its entire run ingesting untrusted third-party pages. A payload it emits is not a trusted source of paths, and the first draft anchored the directory safely while leaving the filenames unchecked.
Fixed in
33e368f: both ladders check filenames before anything reaches disk, accept only the contract's own names as bare filenames (EXPLORE.md/EXPLORE-<section>.md;RESEARCH.md/RESEARCH-<section>.md/research-checklist.md), confirm the resolved write path stays directly inside the destination directory, and treat a directory separator, a..segment or a leading/as a failed dispatch rather than a name to sanitize. Both agents carry the same rule from their side, with the reason. Eval cases on both skills assert nothing is written — including the well-formed bodies — when one name is out of contract.Otherwise: the only grant-shaped change is a
disallowedTools:denylist, which narrows the researcher's inherited pool. No new hook, no new external read, no new script invocation. And the honest residual, stated in the diff rather than only here: the memory-tier write boundary was already instruction-held and remains so — this PR stops the file from understating what it holds, which is the direction that matters for least privilege.Review follow-ups (all in this PR)
Four gaps found by review, all in text this PR added, and three of them the same shape the PR exists to close — a new rule contradicting an existing one two files away. All seven review threads are answered and resolved.
33e368f). The explorer's rule sends a slice root already holding an unrelatedEXPLORE.mdto a sub-slice. The by-value text contradicted it on both sides — the agent was toldartifact:is the envelope path unconditionally, the parent to write into the resolved slice path unconditionally — which would have overwritten the index that rule protects, silently and unrecoverably, through the recovery path. All three sites now apply the rule; the parent assigns the sub-slice, as it assigns every other one.33e368f). Phase 0 writes noresearch-checklist.mdfor an unbounded corpus, but the by-value instructions listed it unconditionally and the ladder re-rancheck-coverage-complete.sh"over the ledger the payload supplied" unconditionally. A missing ledger exits 2, whichSKILL.mdstates is a FAIL — so a legitimately complete unbounded-corpus run would have halted on a check that never applied to it. The ledger is now returned and graded only when one was owed, citing the standing rule unchanged.33e368f) — see the security note above.33e368f). The unbounded-corpus + by-value combination that (2) falls into was uncovered (eval 13 uses a bounded corpus). Added, plus the collision case and a filename-rejection case on both skills.Plus one nit:
disallowedToolsis now quoted (789799a), matching every othertools:-bearing agent in the repo. The docs' own example is unquoted and YAML parses both identically, so this is consistency, not correctness.Related
Closes #2203
Closes #2204
Closes #2205
Inbox items:
20260810-225904-discovery-dispatch-persistence-contract(I1F1,F2,F3, withF10closing as a side effect) ·2026-08-10-plugin-quality-audit-four-components021645Z (I9D-F1deferred here,D-F2merged) · four-components 023241Z (I7B-F1implemented,B-F2/B-F5merged as duplicates).Evidence sourced from the packets'
evidence-1.md, which governs overevidence.mdon conflict.