Fourth review round on #646.
1. GraphKey returned early on an empty repo prefix, before filepath.FromSlash.
A standalone index carries no prefix but its keys still use native
separators, so a '/'-spelled forge or git path missed them on Windows and
get_pr_impact / suggest_reviewers(number=...) silently lost changed symbols
and reviewer signals. Only GraphKeyedPath returns unchanged now;
RepoRelativePath always converts, then takes the prefix when one applies.
2. CODEOWNERS received a graph key in ids mode.
relForRepo only strips an absolute repo root, so repo-a/pkg/auth/login.go
reached MatchFile with the prefix still on and a root-anchored rule such as
/pkg/auth/ matched nothing. matched_files reported the same spelling back to
the caller. Both now go through the new analysis.RepoRelPath, GraphKey's
inverse: it strips the prefix only for a GraphKeyedPath, because that is the
one domain that carries it by construction.
3. Tests.
- Empty-prefix multi-segment cases for GraphKey and JoinFileNodes. The
previous a.go fixture has no separator, so it cannot fail either way.
- The ids CODEOWNERS fixture is now root-anchored (/pkg/auth/). The
unanchored rule also matched the graph-keyed spelling and masked the bug.
- TestJoinHunksToSymbolsPrefixedDeleteAndRename covers the two hunk-less
change kinds against a prefix-shadowed graph: the delete's Path and the
rename's PreviousPath must resolve the nested old-side key and never the
same-named shadow. Their existing tests use an empty prefix and so did not
bind this contract.
- The Windows selector gains PrefixedDeleteAndRename.
Each fix is sabotage-verified separately: forcing the ids branch back to a
raw graph key leaves only the CODEOWNERS-less reviewers, passing the graph
key to MatchFile drops the codeowner signal entirely, and declaring the
vanished path GraphKeyedPath makes the delete/rename test miss both old-side
symbols.
Four fixtures needed the same correction as TestChangedSymbolsForFiles did
earlier: prToolsTestServer, conflictsTestServer and conflictsBudgetServer
hard-coded '/'-spelled graph keys while supplying the same strings as forge
paths, which describes an index a Windows daemon never writes. They now store
native and supply the forge spelling, which is the shape under test.
Windows: internal/mcp 27 -> 24 against main, failing test names diffed,
newly-broken set empty. internal/analysis passes clean. internal/review keeps
its four pre-existing failures. Lint: 6 staticcheck findings, pre-existing.
Third review round on #646.
1. suggest_reviewers(ids=...) double-prefixed graph-keyed paths.
resolveReviewerChangeset's three sources do not share a vocabulary: ids
returns graph node FilePaths, base (git) and number (forge) return
repo-relative paths. Forcing analysis.RepoRelativePath on all three
looked up repo-a/pkg/auth/login.go as repo-a/repo-a/pkg/auth/login.go, so
the ownership and co-change signals disappeared while CODEOWNERS - which
matches the repo-relative spelling - still answered and the tool looked
healthy.
The resolver now returns the domain with the files and the two lookups
use it. That also gives GraphKeyedPath its first production caller.
My round-two audit missed this: I read the call site's comment, which
says the paths are repo-relative, without walking all three producers
feeding it. The comment was true for two of them.
2. File risk stripped the prefix after filepath.Clean.
fromGraphKey called cleanPath first, and cleanPath ends in filepath.Clean,
so on Windows repo-a/repo-a\widget.go became repo-a\repo-a\widget.go and
the '/'-joined prefix no longer matched. Changed-symbol impact stayed on a
graph-prefixed row while findings and ChangedFiles produced a second
repo-relative row for the same file. Both domains now normalize through
graphpath.Norm before the strip and leave in the documented repo-relative
'/' spelling.
This also fixes three pre-existing Windows failures in internal/review:
TestRankFileRiskUsesImpact, TestRankFileRiskNormalizesRepoPrefix and
TestRankFileRiskExemptsTestFilesFromCoverageDebt. The package goes from
seven failures to four; the remaining four are untouched by this change.
3. Windows coverage.
The selector gains GraphKey, JoinFileNodes, ChangedSymbolsForFiles, both
prefix-shadow end-to-end tests, the new ids test and RankFileRisk, and
./internal/review joins the package list.
Tests. The new prefixed-graph ids test asserts all three reviewer signals
survive; the existing ids tests use an unprefixed graph where both domains
coincide, which is why they could not see the regression. Both
prefix-shadow end-to-end tests now require exactly one FileRisk row equal
to the changed file - the previous NotEqual(shadow) assertion passed even
when both erroneous rows were present.
Each fix is sabotage-verified separately: restoring the ids branch to
RepoRelativePath leaves only the CODEOWNERS reviewer, and restoring
cleanPath-before-strip produces exactly the two rows described above.
Also audited, and clean: every other JoinFileNodes / GraphKey caller.
changedSymbolsForFiles is fed only by forge PR file lists through
resolvePRFiles and prImpactForNumber, so its repo-relative domain is
correct. The only two remaining repoPrefix strips both normalize first and
both have an established domain.
A recovery returning rows the page already ranked was the recovery agreeing
with itself, and it terminalized sessions onto the same wrong siblings. A
result must now contribute at least one declaration the frozen baseline
lacks, aligned by file or task anchors; a pure subset spends its allowance
and re-arms recovery. Feeding only novel rows to the alignment test also
closes the door where a task-cited query terminalized on the strength of
the query alone.
The answer-shape instruction sat in a trailing sentence agents ignore; it
now leads the page: two lines asking for the strongest candidates as bare
qualified identifiers and an explicit statement when unconfirmed. Displayed
identities drop synthetic spellings — constructor rows render as prose and
positional suffixes are trimmed only when they match the row's own line —
while IDs and every machine field stay exact.
A successful recovery whose handler recorded no graph-backed identity
stamped answer_ready, so an obedient agent stopped on an unproven page.
A recovery now terminalizes only when its rows land on the retained page
by symbol, by file, or by the task's anchor terms; an uncorroborated
success keeps what it surfaced and re-arms recovery, bounded by a
two-allowance ledger, and the spent ledger closes with the provisional
page that says so. Corroboration is judged against the page frozen when
recovery was armed, so a second recovery cannot corroborate itself. The
refinement directive names up to three ranked candidates and phrases the
miss as non-terminal instead of prescribing a single symbol that may be
wrong.
Second review round on #646.
1. JoinFileNodes resolved the wrong file.
It tried the raw key first and the prefixed key second, so a git-relative
"<prefix>/<rel>" resolved against the same-named top-level file — and when
that shadow did not exist the HasPrefix branch returned nil, never trying
the real key "<prefix>/<prefix>/<rel>". That contaminated
DiffResult.ChangedSymbols, which review and review_pack derive impact,
classification, risk, receipts and the verdict from.
The domain now travels with the path, as analysis.PathDomain, and a
repo-relative path is prefixed unconditionally. All five production call
sites were audited and every one is repo-relative: joinHunksToSymbols
(hunks and vanished paths), changedSymbolsForFiles (forge file list) and
suggest_reviewers (its own comment already said so). Nothing depended on
the mixed contract.
GraphKey also converts to native separators. The key's shape is
"<prefix>/" + the remainder as the indexing machine spells it (see
internal/graphpath), so a '/'-spelled git path missed every file below the
repo root on Windows — the shadow fixture proved it, resolving 0 nodes
before and 4 after.
JoinFilePath collapsed into GraphKey: with the domain explicit it was the
same function, and probing the store to pick between two candidate keys is
what made it ambiguous.
2. File risk repeated the collision.
rankFileRisk ran one normalizer over every input, stripping the repo
prefix from graph-keyed symbol paths and from repo-relative findings and
ChangedFiles alike, so a legitimate "repo-a/pkg/widget.go" became
"pkg/widget.go" and the risk landed on the shadow. Split by domain:
fromGraphKey strips, fromRepoRel does not.
3. Tests.
End-to-end review and review_pack tests drive MapGitDiff, JoinFileNodes,
rankFileRisk and the pack gates over a repo whose tree carries a top-level
directory named like the repo prefix. Both files carry the flagged source
at the base commit and only the nested one is edited — inside the function,
so the hunk overlaps a symbol — so a wrong-target join still produces
findings and only the attributed path separates the outcomes.
Fixture paths are deliberately flat: a nested spelling differs by
separator on Windows and would mask the defect there.
Existing tests that encoded the old tolerance were rewritten rather than
deleted: TestJoinFileNodes and TestChangedSymbolsForFiles_RepoPrefixJoin
now assert the shadow resolves to the nested file, and their fixtures
build graph keys the way the indexer does instead of hard-coding the
'/'-joined form.
changedPathDomain folded into analysis.PathDomain so one vocabulary spans
both packages.
Windows: internal/mcp 27 -> 24 against main, failing test names diffed,
newly-broken set empty. The seven internal/review failures are unchanged
and pre-existing (identical with this change stashed). Lint: 6 staticcheck
findings, also pre-existing.
Addresses the review on #646.
reviewChangedGraphPaths inferred "this path is already graph-keyed" from
strings.HasPrefix(f, repoPrefix+"/"). The two domains overlap, so that is
not recoverable by inspection: in a repo whose tree carries a top-level
directory named like the repo prefix, `repo-a/pkg/widget.go` is a valid
git-relative path AND a valid graph key for a different file. The
inference skips the real key `repo-a/repo-a/pkg/widget.go`, so the
changed file is never scanned - and when a same-named `pkg/widget.go`
exists, that unchanged shadow is scanned in its place.
Make the domain explicit and travel with the data:
changedPathsRepoRelative git's spelling, relative to the working tree
changedPathsGraphKeyed the graph's "<prefix>/<rel>" node key
All four production callers pass DiffResult.ChangedFiles, which MapGitDiff
documents as keeping "the diff-relative paths (callers re-join them with
git pathspecs)", so they pass changedPathsRepoRelative and the prefix is
now applied unconditionally. Only the test covering a caller that already
holds node keys passes changedPathsGraphKeyed.
The Norm-based join and output normalization from the first revision are
unchanged; this only replaces the inference.
Regression test: a fixture carrying both pkg/widget.go and
repo-a/pkg/widget.go, only the nested path marked changed, asserting every
match reports the changed target. Both files carry the detector fixture,
so a wrong-target scan still returns matches and only the reported path
separates the outcomes - restoring the HasPrefix inference fails it with
"pkg/widget.go" is the unchanged shadow.
The ci.yml selector hunk is dropped: #652 removes that job outright.
reviewRulepackMatches builds the changed-file key correctly and then
looks it up with filepath.Clean, which rewrites the one separator a graph
path keeps as '/': the repo prefix. Probed on windows/amd64:
changed map key "repo-a/pkg\widget.go"
target.GraphPath "repo-a/pkg\widget.go" <- identical
filepath.Clean(GraphPath) "repo-a\pkg\widget.go" <- prefix slash lost
hit false
So `review` narrowed the rulepack to an empty target set and reported
zero findings on code its own detector bundle flags — the same false
clean the join was written to fix, reintroduced by the Clean.
Compare in graphpath.Norm form on both sides instead. Norm is the
canonical comparison spelling for a path that is "<prefix>/" plus a
native remainder, and it is filepath.ToSlash, so this is the identity on
POSIX.
Second half, same contract: reviewRepoRelPath feeds the `.gortex.yaml`
rule globs, rankFileRisk's row keys and the forge comment API, all of
which speak '/'. It returned `pkg\widget.go` on Windows, which matches no
glob and anchors no comment. Normalize there too.
Whole package on windows before/after: 27 -> 24 failures, exactly the
three rulepack tests flip, nothing else changes state. Each half is
separately load-bearing: reverting the join fails all three, reverting
the output normalization fails the two that assert m.File.
graphpath.Norm is deliberately a no-op on POSIX — a backslash is an
ordinary filename byte there — so no test can bind this on the
linux/macos matrix. The three names join the existing windows
native-separator step, whose package list already carries internal/mcp.
resolveSymbolID returned early on a nil multiIndexer, before the
graphRelID rung. Only the cwd rung needs the multi-repo index — it maps a
directory to a repo prefix — so the early return also discarded the path
anchoring that works without one.
Measured on windows/amd64 with the move/inline fixture, which builds its
Server through NewServer(...) with no MultiRepoOptions:
multiIndexer == nil: true
GetNode("pkga/a.go::Foo"): false
resolveSymbolID("pkga/a.go::Foo") "pkga/a.go::Foo" <- unchanged
graphRelID("pkga/a.go::Foo") "pkga\a.go::Foo"
GetNode(graphRelID): true <- resolves
graphRelPath/graphPathSpelling already reconcile the spelling, and their
doc-comment names this exact failure: a forward-slash path is "the
spelling every agent writes", and without the rewrite it "missed every
node below the repo root on Windows". The rung was simply unreachable
for a server built this way.
Scope the guard to the cwd rung. The change is additive: the graphRelID
result is still gated on GetNode(rel) != nil, so it can only turn a miss
into a hit, never redirect a resolvable id.
Whole package on windows before/after: 40 -> 27 failures. Diffing the
failing test names, exactly the 13 TestMoveSymbol_* / TestInlineSymbol_*
cases flip and nothing else changes state.
The regression test uses an absolute-path id rather than the Windows
separator, so it exercises the rung on every platform and the
linux/macos matrix protects the fix without a new windows CI step.
TestFeedbackDir, TestParseDiffGitPaths and TestParseDiffLinesNewSide all
compare against a '/'-spelled path while the value under test is a native
filesystem path. On POSIX the two coincide, so the linux/macos matrix has
never seen it; on windows/amd64 all three fail:
"\home\user\.cache\gortex\5887dcec1741_latest"
does not contain ".cache/gortex"
parseDiffGitPaths("diff --git a/pkg/foo.go b/pkg/foo.go")
= "pkg\foo.go", want "pkg/foo.go"
diffmap_test.go:63: expected new-side lines for pkg/foo.go
The production values are correct in each case and are left alone:
- FeedbackDir is filepath.Join(cacheDir, key) and feeds os.Open, so it
must carry native separators.
- cleanDiffPath is filepath.Clean, and JoinFileNodes looks a diff path
up as repoPrefix + "/" + path. Indexed paths are repoPrefix + '/' +
the rest in native separators (see internal/graphpath), so the
cleaned native form is exactly what the join needs. Making diff paths
slash-canonical would break that lookup on Windows.
So the fix is on the assertion side: build the expectation with
filepath.Join / filepath.Clean instead of a literal. On POSIX both are
identity for these inputs, so the assertions keep exactly the strength
they had on the matrix that already runs them.
Fold the three into the existing native-separator windows step rather
than adding one: internal/analysis, internal/mcp, internal/resolver and
internal/graph/store_sqlite are already in that step's package list, and
internal/persistence is already compiled by the sidecar step above it.
TestCachedConsentResolver raced a real 20ms deadline: it primed the cache,
wrote a second consent file to disk, and then asserted the cached value had
not yet expired. On a loaded runner the SaveConsent write plus scheduling
delay between those two calls can exceed 20ms, the cache re-reads, and the
"within TTL the cached value should persist" assertion fails — which is what
took down the ubuntu leg of CI on main while macOS passed.
Give CachedConsentResolver an unexported constructor that takes a clock and
have the test step it across the TTL boundary (ttl-1, then exactly ttl), so
the boundary under test is asserted exactly instead of approximately. The
exported constructor keeps its signature and gains a small test covering the
real-clock wiring. Drops a 30ms sleep from the suite as a side effect.
A sendOpens=false session still evicts to keep the cache bounded, but the
counter sat inside the lifecycle gate, so doc_evictions read zero exactly
where cache churn is the only signal left. Count the eviction where it
happens; didOpens / peak stay honestly zero for a lifecycle-off pass.
confirmGroups was computed unconditionally but the noHeavy path never
reads it (that path deliberately builds from the raw target list); move
the grouping into the sweep branch. And normalizeOpenDocs stopped being
open-docs-specific the moment the heavy override borrowed it — rename to
normalizeOnOff for the shared on/off vocabulary.
The definition-rebind fallback fans out across maxParallel grouped by
call-site file — the serial loop the comment described is what this
branch replaced.
The mode that changes what C# enrichment produces was the one knob with no
docs presence. Add a section to the enrichment cost model beside its
open-docs sibling: what NoHeavyRequests skips (references confirm,
incomingCalls, references-add, on-demand find_usages/get_callers
confirmation), what replaces it (definition-at-call-site confirms, the
dispatch synthesizer), the GORTEX_LSP_HEAVY override and why it is
env-only, and the warning that heavy=on is only safe on a csharp-ls build
carrying the upstream FindReferences leak fix. Also bring the
definition-rebind phase description up to date with the declared-member
verdict: add-not-rebind for devirtualization guesses.
The add-not-rebind verdict for dispatched call sites lives in the shared
definition-rebind pass, so it changes edge topology for every server, not
just the heavy-opt-out ones — but every behavior test for the arm set
noHeavyRequests. Cover the default path: references sweep runs and answers
empty, definition answers the declared member, and the site must keep its
devirtualization guess while gaining the compiler-proven edge (the old
behavior rebound the impl edge instead). The dispatched-call fixture moves
into a shared helper for both tests.
goreleaser ran `go mod tidy` as a before-hook inside the release container.
It took v0.63.8 down: the hook spent 4m49s walking the module graph and then
failed when proxy.golang.org returned HTTP/2 INTERNAL_ERROR on six unrelated
fetches. `go mod tidy` resolves the test dependencies of dependencies too, so
it touches far more of the network than a build needs, on the one path where
a flake costs a release.
The hook was also unsound in a quieter way. It MUTATES go.mod/go.sum, so a
drifted module set would have been silently rewritten inside the container
and the published binaries built against something no CI job had ever
compiled. A release-time rewrite is not a check.
Drop it — the config already skips tests here on the reasoning that the tag
is on a green commit, and the same argument applies.
Nothing verified module tidiness before this: no workflow, no Makefile
target. So the `lint` job gains a `go mod tidy` + `git diff --exit-code`
step, which fails the PR that introduces drift instead of papering over it
at tag time. main is already tidy, so the gate is green on landing.
Verified with the real toolchain, not by reading it:
* `goreleaser build --snapshot` with a deliberately failing before-hook
aborts at "running before hooks" in 0s, so hooks do execute in that mode;
with `hooks: []` the stage never appears and the run goes straight from
snapshotting to building. No default hook takes its place.
* `goreleaser check` validates the config.
* The CI gate passes on main unchanged, and fails with exit 1 (naming
go.mod) against a commit carrying a deliberately untidy require line.
newPlanLockFixture opens an on-disk Store and never closes it. Windows
refuses to unlink an open file, so plan_lock.sqlite survives the test and
the enclosing t.TempDir() cleanup fails.
Measured on windows/amd64, go1.26.6, whole package before the change:
--- FAIL: TestSweepPlanLocks
--- FAIL: TestAdjacencyPlanLocksDuringBulkLoad
--- FAIL: TestSweepPlanLockReceiverRebindBatch
--- FAIL: TestPreparedStatementPlansNeverScanBigTables
--- FAIL: TestSweepWarnPlanLocks
testing.go:1464: TempDir RemoveAll cleanup: unlinkat
...\plan_lock.sqlite: The process cannot access the file because
it is being used by another process.
Five failures, one cause, zero failing assertions — the fixture is the
only holder. After the change the package is ok, 147s, no failures.
Register the close with t.Cleanup after t.TempDir() so LIFO ordering
closes the database before the directory is removed. This is already the
convention at 160 of the 165 Open() sites in the package's tests.
Guard it on the windows runner. Every assertion in these tests is
platform-neutral: on linux/macos an unlinked-but-open file is removed
normally, so the whole set passes with or without the Close and the
matrix cannot protect the fixture from losing it again. Same reasoning,
and the same shape, as the sidecar-handle step added in #415.
The generated cask used `pre_install` / `post_install`. Those are formula
DSL methods; a cask has no such stanzas, so brew rejected the file at load
time with
Error: Cask 'gortex' definition is invalid: undefined method 'pre_install'
Every brew command that touches the cask parses it, so `brew install` and
`brew upgrade` both failed for every macOS user on both arches, starting
with v0.63.7. Closes#639.
Two further defects made a straight rename insufficient:
* The cask DSL's `system_command` is `SystemCommand.run!`, which raises on
a non-zero exit. `gortex daemon status` exits 1 exactly when no daemon
is reachable, so `next unless status.success?` could never run — brew
would have aborted the install outright on any machine without a live
daemon. The probe now passes `must_succeed: false`.
* `preflight` was the wrong hook for stopping the daemon. On upgrade brew
unlinks the old cask's binary (`start_upgrade` -> `uninstall_artifacts`)
before installing the new cask's artifacts, so by the time a preflight
block runs there is no gortex on disk to ask. The stop half could never
fire on the path it was written for.
`postflight` handles both halves instead: after the new binary is linked,
probe for a daemon and, only if one answers, `daemon restart` — which stops
the old process (blocking until it exits, releasing the store lock) before
starting the new one, so a store migration still runs alone. A fresh install
and CI have no daemon answering and skip it.
The cask body moves out of the release.yml heredoc into
.github/homebrew/gortex.rb.tmpl, rendered by scripts/render-cask.sh. That
makes it reviewable as Ruby, removes the shell-expansion hazards of an
unquoted heredoc, and — the point — lets a real brew load it before
publication. The renderer also refuses a malformed sha256, a version with a
leading "v", or a placeholder that survived substitution. Rendered output is
byte-identical to the published cask apart from the hook block.
Nothing could have caught this: the file was valid Ruby, valid YAML, and the
tap has no CI, so the first machine to evaluate the cask was a user's.
scripts/validate-cask.sh renders the template with dummy values and loads it
through a real brew. It runs on every PR that touches the cask
(.github/workflows/homebrew-cask.yml) and again in build-darwin as the last
gate before the release job pushes to the tap. Both are macOS-only —
Homebrew on Linux cannot load casks. Verified by reintroducing `pre_install`
in the template: the validator fails with the exact error users reported.
The `note` shipped on every connectivity_health response ended with
"genuinely unreachable code that is safe to remove", and the two
doc-comments feeding it said the same ("the code is unused and can be
removed", "a real finding to act on (delete it)").
That contradicts the shared vocabulary this analyzer says it stays in
lockstep with. ClassifyZeroEdge reaches the opposite conclusion from the
same zero-incoming shape: likely_unused is "evidence of no callers, not
proof: confirm with a text search for the symbol name before removing
it", and coverage_incomplete is "not as proof the symbol is unused or
safe to remove". FindDeadCode's own contract claims no more than "zero
incoming calls or references" — the removal verdict was added on top of
it, and only here.
The hedge is load-bearing, not stylistic: an ambiguous member call is
deliberately left unresolved rather than misattributed to a same-name
candidate, and a name-only match never earns a usage edge. Both leave a
live symbol with the exact zero-incoming shape dead_code reports on, so
a reader who trusts the note deletes working code. Measured on a 2.5k-file
Go repository, three of four dead_code rows were refuted by grep; two were
methods reached through a nested field selector.
Reword all three sites to state the signal and withhold the verdict. The
isolated-vs-dead-code distinction the note exists to draw is unchanged.
The new test pins the property rather than the prose: it builds the
dead-code shape, asserts the shared caveat for that same symbol still says
"not proof", then asserts the note keeps naming dead_code and its INCOMING
signal while carrying no removal verdict. Restoring the old sentence fails
it on the "safe to remove" assertion.
Review follow-up on the dispatch arg guard. Rejecting by default broke
callers that work today: first-party surfaces inject undeclared keys
into arbitrary tools (the CLI pins format into every legacy-surface
frame, gortex call sets it for every non-facade tool, the HTTP bridge
merges ?format=), and generic layers honor max_bytes / max_tokens /
fields on every list-shaped response without any schema declaring them.
- Default is now warn: the handler runs and the result carries an
_ignored_options rider naming the unknown keys and the valid options.
GORTEX_TOOL_ARG_GUARD=reject restores the hard refusal; the off
vocabulary aligns with the repo's boolean-env idiom (0/false/off/no).
- Response-shaping keys (format, fields, max_bytes, max_tokens, cursor)
are exempt in both modes - dispatch demonstrably honors them, so
flagging them is noise at best and a broken CLI at worst.
- The rider skips error results and is mirrored into structuredContent
when the result carries a structured map - Content alone is invisible
to structured readers.
- read_file declares its handler-honored max_chars; the subagent hook
stops passing compact to smart_context (never a declared or honored
option - the call worked only because unknown keys used to vanish).
- The RawInputSchema branch is gone: 0 shipped tools use raw schemas
and the #597 stamp closes only structured ones, so it guarded a
population of zero.
- End-to-end pins: the CLI's format-pinned frames for audit_health /
verify_change / get_test_targets survive guarded dispatch untouched,
and the #597 read_file(line_range) shape still refuses under reject
while warning under the default.
Both sides re-based the agent preset ceiling: main for the edit_file /
write_file receipt + idempotency options, this branch for the #597
additionalProperties:false stamp. Stack the two — measured on the merged
tree and re-based with ~300-400 bytes of slack. The core pre-diet
baseline moves for the same reason: main's growth had eaten most of the
diet slack and the stamp is contract, not creep.