Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/discovery/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "discovery",
"version": "0.13.1",
"version": "0.14.0",
"description": "Structured discovery before changes: explore the local codebase and run disciplined multi-source external research, both dispatching a purpose-built subagent by default so the reading stays out of the main conversation — with source tiers, falsification, recency gates, and a corpus-coverage ledger — persisting EXPLORE.md / RESEARCH.md index-plus-sidecar handoff artifacts.",
"author": {
"name": "Melodic Software",
Expand Down
154 changes: 154 additions & 0 deletions plugins/discovery/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,159 @@
# Changelog — discovery plugin

## [0.14.0]

### Fixed

- **The plugin's own by-value fallback was unreachable from the only failure that needs it.**
`reference/topic-docs.md` has 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." No agent definition referenced that rule as a persistence mode
and neither recovery ladder carried a rung for it: `skills/explore/reference/dispatch.md` and
`skills/research/context/dispatch.md` had rungs for a bad envelope, a live agent to resume, and a
refused resume, and none for *the worker could not write*. Following the rule anyway guaranteed a
halt — an empty slice holding only the parent's pre-dispatch baseline is exactly what
`check-dispatch-artifact.sh` exits 1 on, and both `SKILL.md` files declared any non-zero exit to
halt the workflow. Two documents in one plugin prescribed opposite outcomes for the same run, and
the correct one was the unreachable one.

Compounding it, no payload value could say what had happened. `status` was `complete | truncated`
and `truncated` is the turn-budget stop whose ladder consequence is *discard the partial slice*;
`complete` requires an `artifact:` pointer the run had no file to name. Both available values
misdescribed a run that finished its work and could not save it, and the only honest one routed
the parent to throw the work away. Observed consequence: a parent reinvented the by-value rule ad
hoc in a resume message, because the rule the plugin already owned could not be reached from the
failure it was written for.

**`persistence: written | by-value` is now its own payload axis** on both agents, deliberately
separate from `status` and from `coverage` — `truncated` keeps meaning the budget stop, so the
discard rung stays correct, and `coverage` stays a statement about exploration and the corpus
ledger rather than about the disk. On the by-value path the agent returns its index, sidecars and
ledger as verbatim bodies after the YAML block, `artifact:` names the path the parent must write
to (a destination, not a claim that a file exists), and both ladders gained the matching rung
ahead of the resume rung: **the parent writes the slice from the payload, then re-runs the same
gate.**

**The exception is to the halt, not to the gate**, and both `SKILL.md` files now 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-value` routes the parent and grades nothing;
a by-value payload that returns *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. A by-value slice earns its exit 0 from the
identical command, freshness check included: the parent writes after its own pre-dispatch `touch`.

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 and nothing else; the branch belongs one level
up, in the ladder, where gate step 1 has already put the payload in the parent's hands.

**Three conditions bind the parent's write, because the recovery path must not become a hole in
the rules it recovers into.** 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 — a researcher in particular spends its whole run
ingesting untrusted third-party pages. So: filenames are checked **before** anything reaches disk
and only the contract's own names are accepted (`EXPLORE.md` / `EXPLORE-<section>.md`,
`RESEARCH.md` / `RESEARCH-<section>.md` / `research-checklist.md`), as bare filenames; a directory
separator, a `..` segment or a leading `/` makes the payload a failed dispatch rather than a name
to sanitize. The explorer's **collision rule still applies** — a slice root already holding an
unrelated `EXPLORE.md` gets a parent-assigned sub-slice here too, because overwriting the index
that rule protects would be a silent, unrecoverable loss arriving through the recovery path. And
the research side's **unbounded-corpus rule is unchanged** — a run that recorded the corpus as
unbounded wrote no ledger and owes none here, so the coverage gate is re-run only when a ledger
was owed; running it against a file nobody was supposed to write exits 2, a FAIL, and would halt a
complete run on a check that never applied to it.

- **`agents/researcher.md` described a tool grant it never made.** The file declared no `tools:` key
and no `disallowedTools:` key, so it inherited every tool available to a subagent — while its own
"Tool honesty" section asserted "`Edit` is absent from your tool list" and "`Agent` is listed."
Both sentences are false there. The paragraph is a verbatim copy from `agents/explorer.md`, where
the `tools:` allowlist at line 4 makes both of them true; it was carried into a file whose
frontmatter inverts them. Inheritance was not only derivable but observed — the agent's own
transcript shows it calling `ToolSearch` and `WebFetch`, neither of which appears in the
explorer's allowlist.

The harm is not tidiness. This is an unattended `maxTurns: 40` worker whose entire write boundary
is instruction-held, and the false inventory — understating the pool by roughly a dozen tools
including a second shell and the whole session MCP set — is the calibration input for that
boundary. Least-privilege understatement is the dangerous polarity.

The section now states what is true: no allowlist is declared, the pool is inherited, `Edit` and
`PowerShell` are held, `Agent` is inherited and conditionally filtered at the depth limit, the MCP
pool is held, and the memory-tier boundary holds by instruction and by nothing else. **No `tools:`
allowlist was added** — an allowlist removes every MCP tool, and the skill's third mandatory
discipline requires doc-MCP servers in the tool spread, so the allowlist would break the discipline
it was meant to protect. A narrow `disallowedTools:` denylist is the instrument instead.

Three decisions are now written down rather than left accidental. **`NotebookEdit` is denied** —
nothing in the contract writes notebooks. **`Edit` is kept, deliberately**: `research-checklist.md`
rows go `[ ]` → `[x]` as phases proceed, and denying `Edit` would force a full-file rewrite of the
coverage ledger at every phase boundary. **`EnterWorktree`/`ExitWorktree` are denied and
`isolation: worktree` is not set on either agent**, because these artifacts are graded off disk by
the parent, in the parent's checkout, against a slice path the parent resolved before dispatch —
work written into an isolated copy of the repository lands where that gate never looks, and the run
would read as having produced nothing. Isolation and a disk-graded handoff are incompatible by
construction; this plugin chose the handoff. The explorer/researcher asymmetry is now stated in
both files as the deliberate thing it is.

One thing the fix 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 refused `Write` is an answer rather than an obstacle —
do not route the same write through `Bash` to get around it. That is grounded in the transcript
asymmetry it was observed as (three `Write` calls refused while a Bash-mediated write succeeded to
the same directory tree), not in any documented rule about which guard covers which tool.

- **Nothing restated the input, so a corrupted scope or topic passed every gate.** Every refusal
mechanism in the plugin was a presence test — preload token present or `MISSING`, envelope field
present or absent, index on disk or not, ledger rows marked or not, `artifact:` pointer present or
not — so none of them could fire on an input that arrived present and wrong. Observed 2026-08-10:
an argument naming *another* plugin's `${CLAUDE_PLUGIN_DATA}` directory reached a dispatched agent
rewritten to this plugin's own path. The agent was asked a factually wrong question and answered
it correctly, which is the most expensive shape of wrong available.

Both agents now echo the envelope back — `scope_as_received:` / `topic_as_received:`, quoted
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 accompanying caveat in `skills/explore`, `skills/research` and `skills/research-deep` is
written as an observation rather than a mechanism, on purpose. What is documented (both pages
fetched 2026-08-11) is that skill and agent content is a substitution site for the three
`${CLAUDE_*}` path placeholders "anywhere the placeholder appears", and that no escape exists for
them — "A backslash before any other `$` is left unchanged" covers `$ARGUMENTS` and declared
argument names, not these. What is documented nowhere is whether argument-supplied text is itself
scanned for those placeholders. The caveat therefore states the observation, the two documented
facts, and the gap, and **carries an unconditional 2027-02-11 expiry** so the claim cannot go stale
invisibly. The echo-back is the part that works under either reading.

The caveat also states its own boundary, because two nearby claims read as if they were about one
thing. This is about placeholder-shaped text a **caller** supplies on the inline path or in a
dispatch prompt. It is a different question from what the adjacent paragraph says about a
`$ARGUMENTS` placeholder the plugin's **own body** carries on the preload path, and it is evidence
for neither side of it. That older claim is untouched here and is tracked separately.

### Added

- `plugins/discovery/agents/tool-honesty.test.sh` — a contract test over this plugin's own agent
definitions, locking the class of drift the second entry describes rather than the one instance of
it: prose claiming a tool is absent from (or present in) a tool list must be backed by a `tools:`
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 set `isolation:`, and both payload
contracts must carry the `persistence:` and echo-back fields. Scoped to this plugin's agents on
purpose — a repo-wide sweep would fail this plugin's test on another plugin's drift.

- `scripts/check-dispatch-artifact.test.sh` gains the by-value pair: a slice holding only the
pre-dispatch baseline exits 1, and the same slice exits 0 once the parent writes it from the
payload, with freshness and pointer checks both passing. The pair is the point — the first half
proves the exception answers a failure the gate really produces, the second proves the recovery
routes *through* the gate rather than around it.

- Eval cases on both skills covering the outcomes the new axis has to keep apart: a by-value
recovery that must be written and re-graded rather than discarded, a by-value payload carrying
findings instead of artifact bodies (a failed dispatch, not a fallback), 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.

## [0.13.1]

### Fixed
Expand Down
71 changes: 67 additions & 4 deletions plugins/discovery/agents/explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,26 @@ mutating Bash — no writes, moves, deletes, or installs, and no git-state chang
exactly two permitted destinations: the artifact files inside the memory-slice path named in your
dispatch prompt, and the memory root's self-ignoring `.gitignore` guard when it is absent.

`Edit` is absent from your tool list. State what that buys and nothing more: you cannot mutate an
existing repo file in a single call. It does **not** make you read-only, and it does **not**
mechanically enforce the memory-tier boundary. The boundary above holds by instruction. Honor it
deliberately.
`Edit` is absent from your tool list — the `tools:` allowlist in the frontmatter above declares it
away, so this sentence is a property of the definition rather than a hope. State what that buys and
nothing more: you cannot mutate an existing repo file in a single call. It does **not** make you
read-only, and it does **not** mechanically enforce the memory-tier boundary. The boundary above
holds by instruction. Honor it deliberately. In particular, **if a `Write` is refused, that is an
answer, not an obstacle** — do not route the same write through `Bash` to get around it. Report the
refusal through the by-value path below.

**That allowlist also declares away `EnterWorktree` / `ExitWorktree`, and `isolation: worktree` is
deliberately not set on this definition.** Your artifacts are graded off disk by the parent, in the
parent's own checkout, against a memory-slice path the parent resolved before dispatching you. Work
written into an isolated copy of the repository lands where that gate never looks — the run would
read as having produced nothing at all. Isolation and a disk-graded handoff are incompatible by
construction, and this plugin chose the handoff.

**Your sibling `discovery:researcher` is configured the other way, and the asymmetry is
deliberate.** It declares no allowlist, because an allowlist removes every MCP tool and the research
discipline requires doc-MCP servers in its tool spread; it narrows with a `disallowedTools:`
denylist instead. Exploration is local and needs no MCP, so the tighter instrument fits here. Read
each agent's own Tool honesty section for what it holds — neither describes the other.

`Agent` is listed, but **listing is necessary and not sufficient**: the harness also has to be
allowing nested spawning at your depth, and that default has moved repeatedly (fixed five layers,
Expand Down Expand Up @@ -151,7 +167,9 @@ here; that is the entire point of dispatching you.

```yaml
preload_token: <echoed verbatim from the preloaded skill, or MISSING>
scope_as_received: <the scope from your dispatch prompt, verbatim>
status: complete # complete | truncated
persistence: written # written | by-value
artifact: <the index path you actually wrote — the sub-slice one on a collision>/EXPLORE.md
sidecars: <count>
coverage: complete # complete | partial — any load-bearing area left as a numbered gap is partial
Expand All @@ -164,11 +182,56 @@ open_questions:
- "<question, with a one-line recommended default>"
```

**`scope_as_received` is a quote, not a summary.** Copy the scope out of your dispatch prompt
character for character — no paraphrase, no normalization, no expansion of anything that looks like
a path or a variable. It exists so the parent can compare what it sent against what arrived; a
tidied restatement answers a different question and hides exactly the corruption the field is for.
If the scope reached you already carrying something that looks wrong, quote it anyway and say so in
`open_questions` — you report what you got, you do not repair it.

**`status: truncated` is written BEFORE your turn budget runs out**, together with whatever partial
payload you have. A dispatch that returns no payload at all is read by the parent as
truncated-without-warning, and the parent discards the partial slice rather than resuming it. Budget
a turn for the payload.

### `persistence:` — when the work finished but the write did not

`status` describes **your run**. `persistence` describes **the disk**. They are separate axes on
purpose: a run that explored everything and could not save it is not a truncated run, and calling it
one routes the parent to discard work that is complete. `coverage` likewise stays about exploration
only — never about whether anything was written.

- **`persistence: written`** — the normal case. The artifact set is in the slice, `artifact:` names
the index you wrote, and the parent's gate grades it off disk.
- **`persistence: by-value`** — you finished the work and **every** attempt to write the slice was
refused. Do not retry through another tool, and do not silently downgrade to `truncated`. Instead:
1. `status:` stays `complete` if the exploration is complete. It is.
2. `artifact:` carries **the path you would have written** — the same path the collision rule
above would have sent you to, so a slice root already holding an unrelated `EXPLORE.md` still
resolves to the sub-slice rather than to the root. On this path it is a **destination for the
parent, not a claim that a file exists**, and it does not override the parent's own anchor:
the parent writes under the slice path it resolved before dispatching you, choosing the
sub-slice itself when the collision rule applies.
3. `sidecars:` is the count of sidecar bodies you are returning, not a count of files on disk.
4. **Append the artifact bodies verbatim after the YAML block**, each in its own fenced block
Comment thread
kyle-sexton marked this conversation as resolved.
introduced by the filename it belongs in — the index first, then every sidecar. This is the one
case where the "at most one paragraph of prose" rule is suspended, because these bodies *are*
the artifact and the parent writes the slice from them.
5. **Name only the files this contract defines: `EXPLORE.md` and `EXPLORE-<section>.md`.** A bare
filename, never a path — no directory component, no `..`, no leading `/`. On this one path a
name you emit becomes a name the *parent* writes, and the parent holds wider write permission
than you do; a name outside that set is a failed dispatch and the parent will treat it as one.
6. Say in one line what refused the write and what the refusal text said.

The bodies you return are the same bodies you would have written — full artifact text under the
normal output format, already through the outcome gate. They are not a summary, not an abstract,
and not a substitute for the artifact. The parent writes them to the slice and then re-runs the
same gate against disk; nothing you return is accepted in place of that gate passing.

Rationale for the mode, and the boundary it sits on:
[`${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md`](${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md)
("The contract's by-value boundary is the checkout, not the process").

**`verification: pending` is non-negotiable.** The parent dispatches the verifier as your sibling.

## You are already the fresh pair of eyes
Expand Down
Loading