Skip to content

Report the release re-cut truth in the dry-run preview #16

Description

@DocksDocks

Goal

release.mjs --dry-run states what a re-cut release does: manifests unchanged, and existing HEAD tagged instead of a release commit.

Mode: plan-and-implement

Research

The engine already owns the re-cut, so no version-ownership question survives.
scripts/lib/plugin-release.mjs:283-289 refuses an already-at-version run only
when releaseTagExists finds the tag, and its comment states the contract: the
manifest number is a proxy for "already released", never the fact.
createGenericPluginReleaseIo.commit at scripts/lib/plugin-release.mjs:395-405
stages the manifests, detects an empty cached diff with git diff --cached --quiet, prints manifests already at this version — tagging existing HEAD, and
returns without committing. The recovery text at
scripts/lib/plugin-release.mjs:346-350 directs the author to re-cut exactly that
number, which is that same path.

Defect 1, the false commit promise. The dry-run tail at
scripts/lib/plugin-release.mjs:316-323 always prints [dry-run] git commit -m "chore(release): <plugin> v<version>", contradicting the commit branch above.
On a re-cut of canonical manifests the real run makes no commit and tags existing
HEAD, so the preview promises an action the release will not take.

Defect 2, a predicate that cannot see what the write does. previewJsonWrite at
scripts/lib/plugin-release.mjs:226-236 compares formattedJson(after) against an
original the caller built with formattedJson(...) of the parsed manifest at
scripts/lib/plugin-release.mjs:292,298,307, because io.readJson at
scripts/lib/plugin-release.mjs:431-433 parses and discards bytes. Both sides are
canonical, so the comparison sees version values only and its
none — formatting drift! label names a state it cannot reach. The consequence is
worse than a wrong label: io.writeJson at
scripts/lib/plugin-release.mjs:524-526 always writes canonical bytes, so a
same-version run against a noncanonical manifest does change the file, does stage
a diff, and therefore does commit, while a preview built on the parsed comparison
promises tag existing HEAD.

The predicate must be the staged object, not the bytes. Comparing raw file text
against formattedJson(after) is still not the executed decision: commit
inspects the index after git add, and a clean filter can map two different byte
strings to one blob, so byte inequality would over-predict a commit. The exact,
read-only equivalent of git add is git hash-object --path <file> --stdin
against git rev-parse --quiet --verify HEAD:<path>. The local git help hash-object OPTIONS text for --path states: "Hash object as if it were located
at the given path. The location of the file does not directly influence the hash
value, but the path is used to determine which Git filters should be applied to
the object before it can be placed in the object database." --stdin is "Read the
object from standard input instead of from a file", and only -w will "Actually
write the object into the object database", which this probe never passes. git help rev-parse documents <rev>:<path> as naming "the blob or tree at the given
path in the tree-ish object named by the part before the colon". Measured in this
repository on a clean tree: the probe over
plugins/plan-lifecycle/.claude-plugin/plugin.json returns
22d8f95eb084dbc40167136f9a85ea34be29b8b5, byte-equal to HEAD: for that path;
one appended line returns e45d1dab8189a61d6f033662683d24493f525e94; and
git cat-file -t on that second identifier fails, so no object was written.

Why the clean-tree gate is part of the same fix.
scripts/lib/plugin-release.mjs:271 gates io.ensureCleanTree() behind !dryRun,
so a dry run never reports the refusal working tree dirty - commit/stash first
that the executed path raises before any manifest is touched. That gate is also
what makes the probe's HEAD comparison exact: on a clean tree the index equals
HEAD, so a blob that matches HEAD stages nothing. When the tree is dirty the
release refuses, so the preview reports that refusal and predicts no landing
rather than comparing anything. ensureCleanTree at
scripts/lib/plugin-release.mjs:415-417 is already in the closed set and is a
read, so the gate stays inside the existing "dry-run must not invoke write,
commit, push, tag, workflow, or GitHub Release IO" assertion at
scripts/tests/ci-plugin-targeting.mjs:893-897.

The two external commands the release runs. The option reference at
https://git-scm.com/docs/git-diff states --quiet "Disable all output of the
program. Implies --exit-code." and --exit-code "exits with 1 if there were
differences and 0 means no differences", and the local git help diff DESCRIPTION
for the --cached form states it views "the changes you staged for the next
commit relative to the named ", defaulting to HEAD; so the status-0 test
in commit means exactly "nothing staged relative to HEAD". The tag step is the
pinned CLI at package.json:20, @anthropic-ai/claude-code 2.1.234, whose own
claude plugin tag --help states "Create a {name}--v{version} git tag for a
plugin release, validating that plugin.json and any enclosing marketplace entry
agree", --push "Push the tag to --remote after creating it", and -f, --force
"Skip the dirty-working-tree and tag-already-exists checks", matching
https://code.claude.com/docs/en/plugins-reference. createTag at
scripts/lib/plugin-release.mjs:409-413 passes only --push and --message, so
the release never bypasses that dirty-tree check and the tag stays
manifest-derived.

Measured exposure. All eight release-managed manifests are byte-canonical today
(.claude-plugin/marketplace.json, .agents/plugins/marketplace.json, and both
plugin.json files for docks, plan-lifecycle, and effect-kit), so the hole
is latent rather than live. Nothing enforces that state: no javascriptQuality
path in scripts/lib/plugins.mjs:56-59,78,97-100 covers a manifest or either
marketplace catalog, so one hand edit makes the preview wrong with no failing
check. Correct-by-construction beats an unenforced invariant.

Cost of the correct predicate. IO_KEYS at scripts/lib/plugin-release.mjs:5-20
is a frozen closed set of fourteen operations, and validateIo at
scripts/lib/plugin-release.mjs:108-115 holds callers to exactly that set, so the
probe adds a fifteenth key, wouldStageChange. The cost is one stub, not a sweep:
GENERIC_RELEASE_IO_KEYS at scripts/tests/ci-plugin-targeting.mjs:34-36 derives
from the production adapter and the single fixture builder asserts its own keys
against it at scripts/tests/ci-plugin-targeting.mjs:813-817. That same builder
makes the hard cases mutation-free: a manifest that would stage a change is a
wouldStageChange return value and a dirty tree is an ensureCleanTree return
value, so no repository file is rewritten and no restore is needed.
scripts/AGENTS.md:29 describes the adapter as an exact closed adapter and the
release runbook sits at scripts/AGENTS.md:171-180; neither mentions the re-cut
contract, which is the documentation half of the same defect.

Durable fix versus patch-over. A documentation sentence alone leaves the output
lying. Deleting the unreachable label alone leaves the noncanonical case
mispredicted. The durable fix makes preview and execution decide on the same
question — would git add stage anything — and share one reason constant, so the
two cannot drift.

Steps

# Id Task Files Depends Effect Status Done when
1 stage_probe Add wouldStageChange to the closed IO set, implemented as git hash-object --path <file> --stdin compared with git rev-parse --quiet --verify HEAD:<path>, never passing -w scripts/lib/plugin-release.mjs local done IO_KEYS holds fifteen keys, the probe returns true only when the staged blob would differ from HEAD, and no other operation changed
2 preview_truth Report each manifest as unchanged, formatting-only, or a version write from the probe result and the version transition, and return whether it would stage a change scripts/lib/plugin-release.mjs 1 local done A canonical re-cut dry run prints unchanged (already at 0.6.0) per manifest and a staging fixture prints the formatting-only label
3 clean_gate Consult ensureCleanTree during the dry run, print the refusal the executed path raises, and suppress the landing prediction when the tree is dirty scripts/lib/plugin-release.mjs local done A dirty-tree dry run prints the working tree dirty refusal and no git commit, git push, or plugin tag line
4 predict_commit Branch the dry-run tail on the union of the probe results, printing the shared re-cut reason in the would-tag tense when nothing would stage scripts/lib/plugin-release.mjs 2, 3 local done A canonical re-cut dry run prints no git commit line while a staging re-cut and a real bump both print one
5 contract_tests Extend the generic release module contract: add wouldStageChange to the fixture, pin all three preview labels, both clean-tree tail branches, the dirty-tree suppression, and the closed-set refusal of a fixture missing the probe scripts/tests/ci-plugin-targeting.mjs 1, 2, 3, 4 local done The suite exits 0, reverting any of the four production behaviours fails it, and no assertion writes a repository file
6 docs_recut Record the re-cut contract and the staging probe where the adapter and the release runbook are described scripts/AGENTS.md 1 local done The runbook states that the tag is the fact and that manifests already at the version tag existing HEAD
7 verify_local Run the full gate for shared release infrastructure and prove every acceptance row against the working tree scripts/lib/plugin-release.mjs, scripts/tests/ci-plugin-targeting.mjs, scripts/AGENTS.md 5, 6 local done The gate exits 0 and every acceptance row passes
8 default_coverage Invoke the fake-adapter release contract from the default suite path, because it was reachable only through the clean-tree flag gate and therefore never ran under the gate or CI scripts/tests/ci-plugin-targeting.mjs 5 local done Both the default and --unit runs print the contract line, and reverting the dirty-tree comparison or the commit-prediction branch fails them
9 adapter_coverage Assert the adapter’s two fail-loud git predicates against real git with hermetic fixtures: a directory that is not a repository, a required clean filter that cannot run, a path absent from HEAD, and the committed-bytes truth table scripts/tests/ci-plugin-targeting.mjs 5, 8 local done Deleting either the git status throw or the git hash-object throw fails the default suite
10 verify_final Re-run the full gate and the gated safety scenario after the coverage steps, and tighten the harness so only the three exact read-only argument vectors reach real git scripts/tests/ci-plugin-targeting.mjs 8, 9 local done The gate exits 0, the safety scenario exits 0 from a clean clone, and the harness denies -w, index, and ref writes
11 review_repairs Repair the reproduced round-1 review findings: isolate the real-git fixtures from ambient Git configuration and widen the dirty-tree negative list to every line the unblocked tail prints scripts/tests/ci-plugin-targeting.mjs 10 local done Each repair fails the suite or the scenario when reverted, and the gate exits 0
12 fail_closed_shim Make the release shim fail closed: stub the selected-plugin gate argv green so the dry run spawns no descendant carrying the real PATH, leave the three exact read-only git shapes as the only route to git, and pin the whole refusal matrix on the default suite path scripts/tests/ci-plugin-targeting.mjs 11 local done Every other argv exits 97, each relaxation of the shim fails the default suite, and the safety scenario exits 0 from a clean clone with identical object names, refs, and status
13 closed_tag_check Move the already-released check into the closed adapter as tagPublished, because step:default_coverage put a direct git ls-remote origin call on the default suite path and made the gate fail whenever origin is unreachable scripts/lib/plugin-release.mjs, scripts/tests/ci-plugin-targeting.mjs 8, 12 local done IO_KEYS holds sixteen keys, the executed behaviour is unchanged, the default suite passes with an unreachable origin, and the same-version consult plus the already-released refusal are asserted
14 ambient_config Make the test process the isolation boundary for real git: drop inherited GIT_CONFIG_KEY_<n> pairs, which override every configuration file including a fixture --local setting, around the adapter contracts, and add the same zero pair count to the shim matrix scripts/tests/ci-plugin-targeting.mjs 8, 9, 12 local done The suite exits 0 under injected hostile pairs and under a hostile global configuration file, and removing either the zero pair count or the process assignment fails it

Acceptance

ID Command Expected
a1 node scripts/release.mjs --plugin plan-lifecycle 0.6.0 --dry-run exits 0 and prints unchanged (already at 0.6.0) for all three manifests
a2 node scripts/release.mjs --plugin plan-lifecycle 0.6.0 --dry-run prints the re-cut prediction naming existing HEAD and no line containing git commit
a3 node scripts/release.mjs --plugin plan-lifecycle patch --dry-run exits 0, prints the 0.6.1 version write per manifest, and still prints the git commit line
a4 node scripts/tests/ci-plugin-targeting.mjs a fixture whose wouldStageChange returns true at the same version prints the formatting-only label and predicts the release commit
a5 node scripts/tests/ci-plugin-targeting.mjs a fixture whose ensureCleanTree returns false prints the working tree dirty refusal and no commit, push, or tag prediction
a6 node scripts/tests/ci-plugin-targeting.mjs exits 0 and still asserts the dry run invokes no writeJson, commit, push, createTag, waitForTagCi, or createRelease
a7 Delete the union branch from the dry-run tail, then run the suite exits 1 naming the missing re-cut prediction
a8 Make wouldStageChange always return false, then run the suite exits 1 naming the mispredicted staging case
a9 Drop the ensureCleanTree consult from the dry-run path, then run the suite exits 1 naming the unreported dirty-tree refusal
a10 Remove wouldStageChange from the fixture IO, then run the suite exits 1 through the closed-set validation, not through a type error
a11 git diff -- scripts/lib/plugin-release.mjs, read the commit operation the executed re-cut sentence is byte-identical to its pre-change text and no other executed behaviour changed
a12 git count-objects -v and git status --porcelain around a same-version dry run both unchanged: the probe writes no object and no tracked file
a13 grep -n 'already at this version' scripts/AGENTS.md the release runbook states the re-cut contract
a14 node scripts/ci.mjs exits 0
a15 node scripts/tests/ci-plugin-targeting.mjs and the same file with --unit both print generic release module contract and dry-run manifest previews passed, so the contracts run in the modes the gate and GitHub Actions actually schedule
a16 Delete the dirty-tree early return from previewJsonWrites, then run the suite exits 1 naming the declined per-manifest comparison
a17 Force the dry-run tail to always predict a commit, then run the suite exits 1 naming the unchanged re-cut prediction
a18 node scripts/tests/ci-plugin-targeting.mjs an adapter on a directory that is not a repository refuses with git status --porcelain failed, and one whose required clean filter cannot run refuses with git hash-object failed for
a19 Delete the ensureCleanTree status throw, then run the suite exits 1 on a missing expected exception
a20 Replace the probe’s git hash-object throw with return true, then run the suite exits 1 on a missing expected exception
a21 node scripts/tests/ci-plugin-targeting.mjs --dry-run-release-safety from a clean clone carrying these bytes exits 0 with Docks release dry-run left repository bytes and refs unchanged, and the harness passes only the three exact read-only argument vectors while denying -w, index, and ref writes
a22 Inject commit.gpgSign=true through GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, then run the suite exited 1 before the fixture isolation and 0 after, so the isolation is load-bearing rather than decorative
a23 Delete the final process.exit(97) from the generated shim, then run the default suite exits 1 naming a refused argv the shim permitted, so the fail-closed exit is load-bearing
a24 node scripts/tests/ci-plugin-targeting.mjs the dirty-tree negative list rejects every line the unblocked tail can print, including wait for tag-CI, gh release create, and the re-cut sentence
a25 node scripts/tests/ci-plugin-targeting.mjs prints release shim refuses every argv but the read-only three and the stubbed gate, permits only the three read-only git shapes and the stubbed gate argv, refuses nine other argvs with 97, and logs every attempt
a26 Restore Node passthrough for the gate argv, then run the default suite exits 1 with a module-not-found status, because the permitted gate row names a script that does not exist and only a stub can accept it
a27 Loosen the shape match to the subcommand only, then run the default suite the first failure names git status, not git hash-object -w --stdin, so the write rejection still refuses the write form when the shapes are widened
a28 node scripts/tests/ci-plugin-targeting.mjs --dry-run-release-safety from a clean clone carrying the final bytes exits 0, and git cat-file --batch-all-objects --batch-check digest, show-ref, and status --porcelain are identical before and after
a29 Loosen the shape match to the subcommand only, then run the default suite exits 1 on the near-miss git status, so the permitted vectors are matched argument for argument
a30 git remote set-url origin /nonexistent/repo.git in a clone of these bytes, then node scripts/tests/ci-plugin-targeting.mjs exits 0, so the default suite no longer depends on reaching origin
a31 Restore the direct releaseTagExists call in place of io.tagPublished, then run the suite with an unreachable origin exits 1 with cannot reach origin to check whether plan-lifecycle--v0.6.0 is already released, which is the failure the gate hit
a32 Drop the io.tagPublished consult, then make the fixture ignore its option, running the suite after each exits 1 naming the missing same-version consult, then exits 1 naming the missing already-released refusal
a33 Export GIT_CONFIG_COUNT=3 with pairs setting core.attributesFile to a file mapping *.json to a required clean filter that exits 3, then run node scripts/tests/ci-plugin-targeting.mjs exits 0 with the shim matrix line last
a34 Repeat a33 with the same hostility supplied as a GIT_CONFIG_GLOBAL file instead of pairs exits 0 with the shim matrix line last
a35 Under the a33 environment, delete GIT_CONFIG_COUNT: '0' from the isolation object, then delete Object.assign(process.env, isolation), running the suite after each and restoring after both exit 1 naming the fixture setup failure from the external filter, and the restored bytes exit 0
a36 Run git help config and read the GIT_CONFIG_COUNT entry states that the pairs override values in configuration files
a37 Run gh run list --commit <pushed head> --json conclusion for each pushed round every run reports success, and no run is missing

Do not touch

The executed behaviour of createGenericPluginReleaseIo.commit: only its message
may move behind a shared constant, byte-identical. io.readJson keeps parsing and
io.writeJson keeps writing canonical bytes; the probe is additive and never
passes -w, so the object database is never written. The scripts/release.mjs
entry point, the descriptors in scripts/lib/plugins.mjs, the tag-exists guard,
every plugin manifest and version, and the whole plugins/ payload stay
unchanged. No new command-line flag is added, and no manifest is reformatted by
this plan or by its tests.

Open questions

None blocking. Releasing plan-lifecycle 0.6.0 stays the user's authorization and
is not part of this plan.

One pre-existing gap is recorded rather than fixed here, because closing it
changes CI topology rather than release reporting. Nothing schedules
node scripts/tests/ci-plugin-targeting.mjs --dry-run-release-safety: neither
scripts/ci.mjs nor any workflow passes that flag, so its real-git shim scenario
runs only when an author passes it from a clean checkout. This plan therefore put
every predicate it depends on into the default suite path instead, and the
flag-gated scenario stays supplementary evidence. Scheduling it needs a named
follow-up plan.

Review

Plan review - 2026-08-21

Plan-review: repair

  • [research_gap] scripts/lib/plugin-release.mjs:271 - .git/docks-review/plan-16.md:41,76,88 treats rawText !== formattedJson(after) as exactly equivalent to the executed commit decision, but dry runs skip the whole-tree cleanliness gate while real releases refuse any dirty path before reaching commit; commit then compares the post-git add index with HEAD, so pre-staged state and Git clean/CRLF conversion can also disagree with raw bytes, and a4's working-tree rewrite cannot honestly predict a commit - call the existing read-only ensureCleanTree operation during dry run, report the real refusal and suppress commit/tag prediction for any dirty tree rather than refusing the preview outright, use Git-normalized/index semantics for the clean-tree forecast (or suppress it when conversion is active), revise a1-a4 and their contract tests accordingly, and make a4 restore saved exact bytes in finally
  • [research_gap] .git/docks-review/plan-16.md:5 - the load-bearing claims about git diff --cached --quiet and claude plugin tag --push cite only repository prose; current official Git documentation says --cached compares the staged index with HEAD and --quiet returns 0 only for no differences, while current Claude documentation says plugin tag creates the manifest-derived release tag and --push pushes it, but those sources are absent from ## Research - cite the official git-diff/git-add and Claude Plugins reference/plugin-dependencies pages and bind the Claude claim to the pinned 2.1.234 CLI

Disposition, finding 1: reproduced, and the fix went further than the first
amendment. Both halves are now closed. The dirty-tree half is step:clean_gate:
the dry run consults ensureCleanTree, prints the executed refusal, and predicts
no landing, pinned by a5 and a9. The conversion half is not documented as a caveat
but removed: the predicate is no longer raw bytes but git hash-object --path <file> --stdin against HEAD:<path>, which applies the same filters git add
applies, so no byte-level over-prediction remains. The working-tree rewrite is
deleted rather than wrapped in finally; the fixture supplies both hard cases as
return values, and a12 proves no tracked file and no object is written.

Disposition, finding 2: reproduced and fixed. ## Research now cites the local
git help diff DESCRIPTION for the --cached form, the --quiet and
--exit-code clauses from https://git-scm.com/docs/git-diff, the --path,
--stdin, and -w clauses from git help hash-object, the <rev>:<path> clause
from git help rev-parse, and the pinned 2.1.234 claude plugin tag --help
output alongside https://code.claude.com/docs/en/plugins-reference, and records
that createTag passes only --push and --message, so the CLI's own dirty-tree
check is never bypassed. git-add is cited by equivalence rather than by page: the
plan adds no staging behaviour, and the probe reproduces staging semantics through
the documented filter path.

Code review - 2026-08-21

Code-review: fixes-required

  • [MEDIUM bug] scripts/tests/ci-plugin-targeting.mjs:326 - the shim unconditionally
    executes any node argv with the real PATH, so a nested Node child can run
    mutating git outside the whitelist and the call log; the snapshot does not count
    objects, so this can falsely certify safety.
  • [LOW bug] scripts/tests/ci-plugin-targeting.mjs:2508 - the real-git fixtures
    inherit global and system Git configuration, so commit.gpgSign, core.hooksPath,
    or global attributes can fail or alter supposedly hermetic fixtures.
  • [MEDIUM spec] scripts/lib/plugin-release.mjs:239 - the dirty-tree return runs before
    versionChanged, so a dirty patch run reports not compared for a known version
    write instead of one of the three contract labels.
  • [LOW spec] scripts/tests/ci-plugin-targeting.mjs:1031 - the dirty-tree forbidden list
    covers add, commit, push, and tag only, so moving the workflow or release forecast
    above the blocked return would still pass.

Disposition, finding 1: reproduced and repaired, with a different fix than proposed.
Restricting the Node passthrough was rejected: the scenario's purpose is to run a real
plugin gate, which legitimately spawns many Node children with varying argv, so an
exact-vector allowlist there would make the harness brittle without closing the hole.
The hole was detection, not passthrough, so gitSnapshot now records
git count-objects -v. An object write is the only mutation a nested child could make
that neither status nor refs reveal. Proven by a23: injecting one hash-object -w into
that exact branch fails the scenario at the snapshot comparison, counts 2845 to 2846,
while the unmutated gate leaves the count byte-identical. Superseded in round 2: the
residual path itself is gone, so detection is no longer the defence and the object
count was reverted.

Disposition, finding 2: reproduced and repaired. Every fixture git spawn now runs with
GIT_CONFIG_GLOBAL at the null device, GIT_CONFIG_NOSYSTEM=1, and prompts disabled,
each initialized repository sets local core.hooksPath and core.attributesFile to the
null device, and the commit invocations disable signing. process.env is never mutated.
The fixture whose purpose is a required-but-broken clean filter keeps its repository-local
filter. Proven by a22: a hostile injected config failed the suite before the change and
passes after it.

Disposition, finding 3: reproduced, and rejected on the merits. Printing
would write version on a dirty tree would reinstate the exact defect this plan deletes.
The executed release refuses at ensureCleanTree before touching any manifest, so on a
dirty tree no version write happens and predicting one is false. Line 239 is a refusal
state, not a comparison state: the three labels describe the outcome of a staging
comparison, and not compared (working tree dirty) is the honest report of a comparison
that was not run, followed by the refusal and the blocked closer. No acceptance row asks
for version labels on a dirty tree, and a5 and a16 pin the refusal behaviour.

Disposition, finding 4: reproduced and repaired. One list now serves both the dirty-tree
assertion and the two failure-path assertions, and it holds every line the unblocked tail
can print: git add, git commit, git push, plugin tag, wait for tag-CI,
gh release create, and the re-cut sentence (a24).

Code review round 2 - 2026-08-21

Code-review: fixes-required

  • [MEDIUM bug] scripts/tests/ci-plugin-targeting.mjs:303,330 - count-objects -v
    detects an injected loose-object mutation but does not close the unrestricted
    nested-Node path: a child can run git config --local or change index metadata
    while status, refs, object counts, and the manifest snapshots stay identical, and
    the physical counters can also move under a concurrent repack with no object
    identity change.
  • [MEDIUM spec] scripts/tests/ci-plugin-targeting.mjs:303,2521-2548 - step:review_repairs
    and the record claim the residual Node path is covered and every fixture Git spawn is
    isolated, but the object-count snapshot misses non-object mutations and the fixture
    environment reaches only the setup spawns, not the adapter's own.

Disposition, finding 1: reproduced and repaired, by deleting the path rather than by
detecting writes on it. The scenario needed a descendant only to run the selected-plugin
gate, which the shim now stubs green for the exact argv
node <repo>/scripts/ci.mjs -q --plugin <name>; the gate itself is covered by its own
CI job. With no descendant, the three read-only shapes are the only way any git command
runs at all and every other argv exits 97, so safety is structural instead of a snapshot
allowlist that must grow with each new mutation class. The object count was therefore
reverted, which also removes the repack-invariance defect the finding names. The refusal
matrix is pinned on the default suite path, not behind the flag (a25), and each
relaxation of it fails that suite (a23, a26, a27).

Disposition, finding 2: reproduced and repaired. The four missing repository-local pins
were added to the same configureFixtureRepo helper, so core.autocrlf, core.eol,
core.safecrlf, and commit.gpgSign are fixed per fixture rather than per spawn, which
is what reaches the adapter's own invocations: repository-local configuration outranks
global and system configuration. Proven load-bearing by a hostile global config, which
failed at the adapter's git hash-object before the change and exits 0 after it (a22).
The record claims are corrected above: step:review_repairs no longer asserts detection
of the residual path, and step:fail_closed_shim states what replaced it.

Code review round 3 - cancelled before dispatch (superseded below)

Round 3 was called off before any reviewer ran. The trigger rule says re-review
follows only a CRITICAL or HIGH fix; the round-two verdict carried MEDIUM
findings only, both dispositioned above with reproduced repairs. A third full
round had no contract basis and would have restarted the loop this plan exists
to end. Remote evidence for the push step landed in its place: Actions run
32490889283 for head 1fccf71 completed success on all five jobs.

Code review round 3 - 2026-08-21

Code-review: pass

  • [MEDIUM spec] scripts/lib/plugin-release.mjs:441 - on a clean checkout with stale stat data, the dry-run git status probe may refresh .git/index, so the no-index-write guarantee is false even though status, refs, manifests, and object names stay unchanged - run the probe with GIT_OPTIONAL_LOCKS=0 and pin index bytes in the safety snapshot.

Disposition: pass. The single MEDIUM advisory is recorded as a follow-up;
advisories never trigger a re-review and do not block archive. The landed PR #17
bytes are unchanged since review (head 1fccf71, merged as dcb28ab).

Verification Results

All acceptance rows, a1 through a37, pass on the implemented bytes: the remote Actions run 32490889283 reports success for head 1fccf71.

Reporting truth, from a clean clone of these bytes at
~/.local/share/agent-worktrees/docks/dry-run-safety, because the working tree
that produced them is dirty and the release refuses a dirty tree. A re-cut dry run
of plan-lifecycle 0.6.0 prints unchanged (already at 0.6.0) for all three
manifests and manifests already at this version — tagging existing HEAD, with no
git commit line (a1, a2). The same command as a patch bump prints
would write version → 0.6.1 per manifest and does print the commit line (a3).
Around a same-version dry run, git count-objects -v and git status --porcelain
are byte-identical, so the probe writes no object and no tracked file (a12).

Executed behaviour unchanged. The only diff inside commit moves its sentence
behind RECUT_TAG_REASON, whose value is the pre-change string byte for byte; the
staging call, the git diff --cached --quiet test, and the early return are
untouched (a11). scripts/AGENTS.md:187 states the re-cut contract (a13).

Fixture contracts. The suite pins all three preview labels, the dirty-tree
refusal with no landing prediction, both tail branches, and the closed-set refusal
of a fixture missing the probe (a4, a5, a6, a10). It exits 0 in the default mode
that GitHub Actions runs and in the --unit mode the local gate runs, and the
contract line prints in both (a15).

Mutation evidence. Every production behaviour this plan adds was proven
load-bearing by reverting it and observing the suite fail, then restoring the file
and confirming a green run with cmp -s. Reverting the union branch, the probe
result, the clean-tree consult, or the fixture key each fails (a7, a8, a9, a10).
The first matrix run also exposed a real hole rather than a test defect: two
mutations passed, because the fake-adapter contracts and the dirty-tree comparison
were reachable only from the flag-gated scenario, which nothing schedules. That is
what step:default_coverage and step:adapter_coverage fix, and re-running the
same mutations against the closed default path now fails as required (a16, a17,
a19, a20). The adapter's two fail-loud predicates are asserted against real git
with hermetic fixtures — a directory that is not a repository, a required clean
filter that cannot run, a path absent from HEAD, and the committed-bytes truth
table (a18).

Harness safety, as repaired twice. The shim is now the only route to git: it permits
three exact read-only argument vectors, refuses -w, index writes, and ref writes, and
stubs the one Node argv the release makes - the selected-plugin gate - green rather than
executing it, so no descendant runs with the real PATH. Every other argv exits 97. That
matrix is asserted on the default suite path, where nine refused argvs include a
subcommand near miss, and every attempt is logged so refusals stay observable (a25).
Deleting the fail-closed exit, restoring the Node passthrough, dropping the -w clause,
or loosening the shape match each fails that suite (a23, a26, a27). The scenario re-run
from a clean clone of the final bytes exits 0 with Docks release dry-run left repository bytes and refs unchanged, and the object-name digest, refs, and status are byte-identical
before and after, which also proves the exact vectors still match every invocation the
engine makes (a21, a28). The real-git fixtures no longer inherit ambient Git
configuration (a22).

Shim mutation evidence, including a hole it exposed in this test and a false claim it
exposed in this record. Each relaxation was applied one at a time in a disposable clone at
~/.local/share/agent-worktrees/docks/shim-mutations, run, and restored. Deleting the
fail-closed process.exit(97) fails with git hash-object -w --stdin must be refused
(a23). Restoring the Node passthrough was invisible at first, because the permitted gate
row named the real scripts/ci.mjs, which an executed gate also loads; the row now names
a path that does not exist, and the mutation then fails with a module-not-found status
(a26). Loosening the shape match to the subcommand fails with git status must be refused, and that same run is the evidence for the -w rejection: the write form stays
refused under the widened match, which is the only condition where that clause can act
(a27, a29).

An intermediate repair was withdrawn rather than kept. A refused row
git hash-object --path -w --stdin was added to pin the -w clause directly, and the
claim that it enables a write is false: --path consumes -w as its path operand.
Measured in a throwaway repository, that argv returns
975fbec8256d3e8a3797e7a3611380f27c49f4ac and git cat-file -t on it fails, while
hash-object -w --stdin writes a blob that resolves. The row was removed, and the
clause is documented as defence in depth with the widened-match run as its evidence
instead of a mutation that proves nothing.

Harness scope, restated honestly. The gate is stubbed, so this scenario no longer proves
anything about scripts/ci.mjs; that program is covered by its own CI job and by the
local gate. What the scenario proves is narrower and now airtight: the release itself
reaches git only through vectors that cannot mutate a repository.

Hermeticity, a defect this plan created and closed. step:default_coverage moved the
fake-adapter contract onto the default suite path, which was the right fix for coverage and
carried an unnoticed cost: the same-version cases reached
git ls-remote --tags origin through a module-private function outside the closed set, so
node scripts/ci.mjs needed network. It failed exactly that way during this session. The
operation is now the sixteenth closed-set key, tagPublished, with the production adapter
keeping the local-tag-then-origin order and the refusal text byte for byte, and the fixture
stubbing it false. The default suite passes against an unreachable origin (a30), restoring
the direct call reproduces the gate failure (a31), and dropping either the consult or the
fixture override fails the suite (a32).

Ambient configuration. Adding the real-git fixtures put developer configuration
inside the suite. git help config documents that GIT_CONFIG_KEY_<n> and
GIT_CONFIG_VALUE_<n> pairs override values in configuration files (a36), so the
earlier file-level isolation was incomplete: a hostile pair still reached the
production adapter, which spawns git with this process's environment and must keep
doing so, because in production it has to predict what the operator's own git add
would stage. Directly, that hostility makes the permitted shape
git hash-object --path plugins/docks/.claude-plugin/plugin.json --stdin exit 128,
while the isolated environment exits 0. The boundary is therefore the test process:
testReleaseAdapterGitContracts assigns the isolation keys, delegates, and restores
every prior value in finally, and the shim matrix carries the same zero pair count.
The suite exits 0 under injected pairs (a33) and under a hostile global file (a34),
and removing either the zero pair count or the process assignment fails it (a35).

Landing evidence. Landing sits outside the six phases, so the branch, commits,
push, pull request, and merge belong to the user under
docks:commit-discipline; no Steps row exists for them. The user authorized
this plan's landing in session before the first round was pushed. The recorded
result: head 1fccf71 sits on fix/release-dry-run-truth, and Actions run
32490889283 for that head completed success across all five jobs - shard
resolution, targeting contracts, repo and core shards, and validate - so row
a37 is met.

Round 3 cancelled by contract. The round-two verdict carried MEDIUM
findings only, and the plan-manager rule re-reviews only after a CRITICAL or
HIGH fix, so a third full round had no trigger; it was called off before any
reviewer ran. Remote evidence for the push effect landed instead: Actions run
32490889283 for head 1fccf71 completed success across all five jobs - shard
resolution, targeting contracts, repo and core shards, and validate - so row
a37 is met by the landing evidence above.

Gate. node scripts/ci.mjs exits 0 on the final bytes (a14). The full gate is the
correct scope: this working tree changes files outside every plugin root.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions