* test(#4709): assert no shipped surface mints a retired runtime id Extends the #1928 removal guard to the surfaces it structurally could not reach. Its own docblock scopes it to the installer CLI contract and the runtime-name-policy exports; it spawns the installer and inspects module exports, and never reads gsd-core/workflows/**, commands/** or skills/**. Four structural assertions, all RED on next: - every RUNTIME= assignment must name a canonical runtime - the runtime->model-tier table must name only model-catalog runtimes - runtime selection menus must offer only canonical runtimes - config-set runtime / model_profile_overrides examples must be canonical Structural, not textual: each asserts the literal is canonical or the runtime exists as a catalog key, never that the string "gemini" is absent. That string is load-bearing across Antigravity's real on-disk contract, so a fifth test pins that contract from the descriptor (not from a resolved path, which would read $ANTIGRAVITY_CONFIG_DIR and the real $HOME -- the #4312 defect class). An over-broad gemini -> antigravity replacement fails there rather than ships. The menu assertion is scoped by the nearest preceding `question:` matching /runtime/i, because the same file carries a provider menu (anthropic, openai) and a budget menu (high, medium, low) whose labels are single lowercase tokens too and name neither a runtime nor anything the policy should judge. Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4709): stop minting the retired gemini runtime id in workflow text #1928 removed the gemini runtime after Google sunset Gemini CLI on 2026-06-18, but the removal stopped at the installer boundary. Runtime-loaded workflow text kept assigning the id, and the name policy's unknown-id fallbacks then applied a default designed for a never-known FUTURE runtime to an id GSD itself retired: getRuntimeLabel('gemini') is 'Claude Code', getProjectInstructionFile('gemini') is 'AGENTS.md', getGlobalConfigDir('gemini') is ~/.claude. A stale id produced a plausible wrong answer instead of an error. Those fallbacks are DELIBERATE and are left untouched here -- four docblocks document them, src/runtime-name-policy.cts:220-222 calls the label default "the always-safe default, fail-closed", and an existing test in this very suite pins getProjectInstructionFile('gemini') === 'AGENTS.md'. This commit removes the REACHABILITY of the retired id instead: - new-project.md, ingest-docs.md: the runtime-detection cascade mapped /.gemini/ and $GEMINI_CONFIG_DIR to RUNTIME=gemini. Both now map /.gemini/antigravity{,-ide,-cli}/ and $ANTIGRAVITY_CONFIG_DIR to RUNTIME=antigravity, the documented successor. ingest-docs.md was not in the original report; the new structural test found it. - settings-advanced.md: dropped the `gemini` row from the runtime->model-tier table. The model catalog has no gemini runtime (runtimeTierDefaults has 18 keys, none of them gemini), so the row advertised built-in defaults for a runtime whose config key is ignored. Its three model IDs were copied from the `google` PROVIDER preset -- a provider axis rendered as a runtime axis. - settings-advanced.md: removed the `gemini` / "Gemini CLI." runtime menu option and its group listing, so no menu offers a runtime GSD cannot install. - settings-advanced.md: repointed the config examples from `runtime gemini` to `runtime antigravity`, which ships no built-in tier defaults and is therefore the case those overrides actually exist for. - reapply-patches.md: $GEMINI_CONFIG_DIR -> $ANTIGRAVITY_CONFIG_DIR, ~/.gemini/gsd-local-patches -> ~/.gemini/antigravity/gsd-local-patches, and the local scan's bare .gemini -> .agents (Antigravity's localConfigDir). This file is hand-written, so `npm run sync:launcher` never reached it. - update.md: bare ~/.gemini and ./.gemini as GSD config dirs -> the real ~/.gemini/antigravity and ./.agents. Antigravity's own Gemini-family surfaces are untouched by design: ~/.gemini as its configHome parent, ~/.gemini/config for global skills/agents (#3738), hookEvents "gemini", GEMINI.md as its projectInstructionFile, the ~/.gemini/antigravity{,-ide,-cli} ambiguity probes (#1441), and every gemini-* model ID. The launcher's own GEMINI_CONFIG_DIR arm is left to #4632, which absorbed #4347 for it. Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#4709): record the Gemini -> Antigravity migration research Primary-source research note behind #4709: the sense taxonomy that separates a runtime-axis `gemini` (stale) from Antigravity's on-disk contract, Google's model IDs, and release history (all load-bearing); the PRESERVE table; the guard-gap analysis; and the per-file inventory with file:line citations. Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4709): keep the legacy patches probe, and stop tripping two lint gates Three review findings, fixed inline. 1. Dropping the global ~/.gemini/gsd-local-patches probe was a regression: a pre-#1928 Gemini CLI install put patches there, and a stranded patches dir is still the user's work. Restored as an explicitly-labelled legacy arm probed AFTER Antigravity, so a live install always wins. This is a directory probe, not a runtime home -- it assigns no runtime id, so it does not reintroduce the defect this PR closes. The $GEMINI_CONFIG_DIR env probe is deliberately NOT restored: that names a runtime config home, which tests/declarative-reference-antigravity.test.cjs:307 pins as ignored. 2. The comment added in (1) originally contained the literal string that the new structural test matches, so the test flagged its own fix's comment as a mint. Reworded. The test was right; a comment in shipped workflow text is as readable to a matcher as code is. 3. docs/research/gemini-to-antigravity-migration.md used the colon slash-form inside a quoted manifest description. lint-docs-command-form rejects it: docs are never passed through the install-time converters, so the colon form names a command no runtime registers. Normalised to the hyphen form. Also verified, rather than assumed: the local scan's .agents entry is unambiguous. Antigravity is the ONLY runtime declaring localConfigDir '.agents' across all 19 capability manifests; grok and codex use ~/.agents as a GLOBAL home, and this scan is local (./$dir). Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4709): retire Gemini CLI from the PR templates, and close two review gaps Adversarial review findings, all fixed inline. 1. All three .github/PULL_REQUEST_TEMPLATE/*.md still offered "Gemini CLI" under "Runtimes tested", and none offered Antigravity. #1928's follow-up dropped Gemini CLI from .github/ISSUE_TEMPLATE/*.yml but missed the PR templates, so every contributor opening a fix/feature/enhancement PR has been asked for two releases which runtime they tested and offered a retired one. Now Antigravity. Guarded by a new assertion: runtime checklist labels in the PR templates must appear in the runtime label table. Proven non-vacuous by reverting one template line and watching the probe report the offender. 2. The #4709 scanning corpus excluded agents/, which also ships runtime-loaded markdown including .compact.md variants. Widened: 318 -> 382 files (+64), zero new offenders, so the gap was coverage rather than a live defect. 3. gsd-core/workflows/sync-skills.md said "grok and gemini have no dedicated installer flag — they alias the codex and claude skills roots respectively." The gemini half is wrong twice over: the runtime is retired, and it never aliased claude -- canonicalizeRuntimeName returns null for it and the caller's fail-closed default merely happens to be claude. Describing that as designed aliasing is exactly the confusion this issue is about. Reduced to grok, which genuinely does alias the codex skills root. 4. The changeset said the runtime was removed in 1.11. It shipped in 1.8.0 (CHANGELOG.md:1023 is the enclosing release heading for the #1928 entry at :1124). Corrected. Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4709): follow the corrected sync-skills prose, and refresh the compact baseline Three GREEN-run failures, all caused by this PR's own edits. 1. tests/sync-skills-cross-runtime-refuse.test.cjs pinned the literal phrase "grok and gemini have no dedicated installer flag" — a test REQUIRING shipped text to name a runtime retired in 1.8.0, which is the exact class #4709 exists to remove. The assertion and its rationale comment now track the corrected prose ("grok has no dedicated installer flag"), and the docblock's runtime list drops gemini. The remaining assertions in that file — the guard's exit, the installer pointer, the $DEST reference, guard-before-copy ordering — are untouched, so #3025's contract is otherwise intact. 2. tests/fixtures/compact-content-benchmark-baseline.json drifted because the new-project.md edits changed its compacted size (split "new-project": off 14279 -> 14308, on 12335 -> 12364; aggregate off 107411 -> 107440). Refreshed with `node scripts/benchmark-compact-content.cjs --write`, which is that script's own documented remedy. 3. emitted-attribution reported four grown workflow files with no acknowledgment. Acked below as commit trailers per ADR-3942, which moved the acknowledgment out of tests/emitted-drift-acks/*.json fragments and into the PR's own commit range (read with three-dot base...head). Exactly the four files the gate named are acked — settings-advanced.md and sync-skills.md shrank and are deliberately absent, since a trailer no delta consumed is a staleAcks error. Refs #4709 Emitted-Drift-Ack-Growth: ingest-docs.md — the runtime-detection cascade now names Antigravity's three real directories (/.gemini/antigravity{,-ide,-cli}/) and $ANTIGRAVITY_CONFIG_DIR in place of the single retired /.gemini/ arm and $GEMINI_CONFIG_DIR; three correct paths cost more bytes than the one wrong path they replace. Emitted-Drift-Ack-Growth: new-project.md — same runtime-detection correction as ingest-docs.md, plus dropping "gemini/" from the two GEMINI.md instruction-file sentences so the prose stops contradicting getProjectInstructionFile, which returns AGENTS.md for that retired id. Emitted-Drift-Ack-Growth: reapply-patches.md — restores the legacy ~/.gemini/gsd-local-patches probe as an explicitly-labelled arm after an adversarial-review finding that dropping it stranded a pre-#1928 user's patches, and repoints the env/global probes at Antigravity; the four-line comment is load-bearing, since a bare retired-runtime path with no explanation is exactly what the next reader would delete. Emitted-Drift-Ack-Growth: update.md — bare ~/.gemini and ./.gemini as GSD config dirs are replaced by the real ~/.gemini/antigravity and ./.agents, which are longer strings; no content was added beyond the corrected paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4709): backfill the changeset PR number pr: 0 -> 4711, now that the PR exists. Never guessed ahead of the number. Refs #4709 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
26 KiB
@~/.claude/gsd-core/references/response-language-directive.md
Reapply Local Patches Workflow
Invoked by /gsd:update --reapply (commands/gsd/update.md).
After a GSD update wipes and reinstalls files, this workflow merges user's previously saved local modifications back into the new version. Uses three-way comparison (pristine baseline, user-modified backup, newly installed version) to reliably distinguish user customizations from version drift.
Critical invariant: Every file in gsd-local-patches/ was backed up because the installer's hash comparison detected it was modified. The workflow must NEVER conclude "no custom content" for any backed-up file — that is a logical contradiction. When in doubt, classify as CONFLICT requiring user review, not SKIP.
Step 1: Detect backed-up patches
Check for local patches directory:
expand_home() {
case "$1" in
"~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
*) printf '%s\n' "$1" ;;
esac
}
PATCHES_DIR=""
# Env overrides first — covers custom config directories used with --config-dir
if [ -n "$KILO_CONFIG_DIR" ]; then
candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
elif [ -n "$KILO_CONFIG" ]; then
candidate="$(dirname "$(expand_home "$KILO_CONFIG")")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
elif [ -n "$XDG_CONFIG_HOME" ]; then
candidate="$(expand_home "$XDG_CONFIG_HOME")/kilo/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
fi
if [ -z "$PATCHES_DIR" ] && [ -n "$OPENCODE_CONFIG_DIR" ]; then
candidate="$(expand_home "$OPENCODE_CONFIG_DIR")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
elif [ -z "$PATCHES_DIR" ] && [ -n "$OPENCODE_CONFIG" ]; then
candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
elif [ -z "$PATCHES_DIR" ] && [ -n "$XDG_CONFIG_HOME" ]; then
candidate="$(expand_home "$XDG_CONFIG_HOME")/opencode/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
fi
if [ -z "$PATCHES_DIR" ] && [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then
candidate="$(expand_home "$ANTIGRAVITY_CONFIG_DIR")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
fi
if [ -z "$PATCHES_DIR" ] && [ -n "$CODEX_HOME" ]; then
candidate="$(expand_home "$CODEX_HOME")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
fi
if [ -z "$PATCHES_DIR" ] && [ -n "$CLAUDE_CONFIG_DIR" ]; then
candidate="$(expand_home "$CLAUDE_CONFIG_DIR")/gsd-local-patches"
if [ -d "$candidate" ]; then
PATCHES_DIR="$candidate"
fi
fi
# Global install — detect runtime config directory defaults
if [ -z "$PATCHES_DIR" ]; then
if [ -d "$HOME/.config/kilo/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.config/kilo/gsd-local-patches"
elif [ -d "$HOME/.config/opencode/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.config/opencode/gsd-local-patches"
elif [ -d "$HOME/.opencode/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.opencode/gsd-local-patches"
elif [ -d "$HOME/.gemini/antigravity/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.gemini/antigravity/gsd-local-patches"
# Legacy: a pre-#1928 Gemini CLI install put patches at ~/.gemini/gsd-local-patches.
# That runtime is retired, but a stranded patches dir is still the user's work — probed
# AFTER Antigravity so a live install always wins. This is a directory probe, not a
# runtime home: nothing here assigns the retired runtime id.
elif [ -d "$HOME/.gemini/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.gemini/gsd-local-patches"
elif [ -d "$HOME/.codex/gsd-local-patches" ]; then
PATCHES_DIR="$HOME/.codex/gsd-local-patches"
else
PATCHES_DIR="$HOME/.claude/gsd-local-patches"
fi
fi
# Local install fallback — check all runtime directories
if [ ! -d "$PATCHES_DIR" ]; then
for dir in .config/kilo .kilo .config/opencode .opencode .agents .codex .claude; do
if [ -d "./$dir/gsd-local-patches" ]; then
PATCHES_DIR="./$dir/gsd-local-patches"
break
fi
done
fi
Read backup-meta.json from the patches directory.
If no patches found:
No local patches found. Nothing to reapply.
Local patches are automatically saved when you run /gsd:update
after modifying any GSD workflow, command, or agent files.
Exit.
Step 2: Determine baseline for three-way comparison
The quality of the merge depends on having a pristine baseline — the original unmodified version of each file from the pre-update GSD release. This enables three-way comparison:
- Pristine baseline (original GSD file before any user edits)
- User's version (backed up in
gsd-local-patches/) - New version (freshly installed after update)
Check for baseline sources in priority order:
Option A: Pristine hash from backup-meta.json + git history (most reliable)
If the config directory is a git repository:
CONFIG_DIR=$(dirname "$PATCHES_DIR")
if git -C "$CONFIG_DIR" rev-parse --git-dir >/dev/null 2>&1; then
HAS_GIT=true
fi
When HAS_GIT=true, use the pristine_hashes recorded in backup-meta.json to locate the correct baseline commit. For each file, iterate commits that touched it and find the one whose blob SHA-256 matches the recorded pristine hash:
# Get the expected pristine SHA-256 from backup-meta.json
PRISTINE_HASH=$(jq -r ".pristine_hashes[\"${file_path}\"] // empty" "$PATCHES_DIR/backup-meta.json")
BASELINE_COMMIT=""
if [ -n "$PRISTINE_HASH" ]; then
# Walk commits that touched this file, pick the one matching the pristine hash
while IFS= read -r commit_hash; do
blob_hash=$(git -C "$CONFIG_DIR" show "${commit_hash}:${file_path}" 2>/dev/null | sha256sum | cut -d' ' -f1)
if [ "$blob_hash" = "$PRISTINE_HASH" ]; then
BASELINE_COMMIT="$commit_hash"
break
fi
done < <(git -C "$CONFIG_DIR" log --format="%H" -- "${file_path}")
fi
# Fallback: if no pristine hash in backup-meta (older installer), use first-add commit
if [ -z "$BASELINE_COMMIT" ]; then
BASELINE_COMMIT=$(git -C "$CONFIG_DIR" log --diff-filter=A --format="%H" -- "${file_path}" | tail -1)
fi
Extract the pristine version from the matched commit:
git -C "$CONFIG_DIR" show "${BASELINE_COMMIT}:${file_path}"
Why this matters: git log --diff-filter=A returns the commit that first added the file, which is the wrong baseline on repos that have been through multiple GSD update cycles. The pristine_hashes field in backup-meta.json records the SHA-256 of the file as it existed in the pre-update GSD release — matching against it finds the correct baseline regardless of how many updates have occurred.
Option B: Pristine snapshot directory
Check if a gsd-pristine/ directory exists alongside gsd-local-patches/:
PRISTINE_DIR="$CONFIG_DIR/gsd-pristine"
If it exists, the installer saved pristine copies at install time. Use these as the baseline. Both the deterministic verifier and the installer's preserve-check resolve each file's snapshot at its canonical path first and, when that misses, by the SHA-256 recorded in pristine_hashes — so a snapshot stored at a legacy path (for example, without the gsd-core/ prefix an earlier release dropped) is still found and, on the next update, relocated to its canonical path (#4145). When no snapshot resolves under gsd-pristine/, the deterministic verifier additionally falls back to Option A's git-history walk itself (read-only, same recorded-hash match), which is what keeps Step 5a coverage non-zero on multi-version updates where the hash-validated regeneration had nothing it could promote (#4135).
Option C: No baseline available (two-way fallback)
If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with strengthened heuristics (see Step 3).
Step 3: Show patch summary
## Local Patches to Reapply
**Backed up from:** v{from_version}
**Current version:** {read VERSION file}
**Files modified:** {count}
**Merge strategy:** {three-way (git) | three-way (pristine) | two-way (enhanced)}
| # | File | Status |
|---|------|--------|
| 1 | {file_path} | Pending |
| 2 | {file_path} | Pending |
Step 4: Merge each file
Step 4 pre-flight: classify superseded customizations (#4136)
Before merging anything, run the deterministic classifier. It computes, per backed-up file,
whether the user's added lines (diff of the backup against the hash-validated pristine
baseline) are ALREADY present verbatim in the newly installed version — i.e. upstream
adopted the customization. That is the only ground on which the Incorporated status
below is valid. Never conclude Incorporated from a signature line, a heading, or general
resemblance: the classifier excludes structural/trivial lines and requires every
significant user-added line to be present, because a false Incorporated silently retires
a live customization.
PRISTINE_DIR="${CONFIG_DIR}/gsd-pristine"
# Build args as a bash array so paths with spaces survive expansion intact.
CLASSIFY_ARGS=(
--patches-dir "$PATCHES_DIR"
--config-dir "$CONFIG_DIR"
)
if [ -d "$PRISTINE_DIR" ]; then
CLASSIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
fi
CLASSIFY_ARGS+=(--classify --json)
# Informational: exits 0. The binding gate is Step 5a's post-merge verification run.
CLASSIFY_OUTPUT="$(node "${GSD_HOME}/gsd-core/bin/verify-reapply-patches.cjs" "${CLASSIFY_ARGS[@]}")"
INCORPORATED_FILES="$(echo "$CLASSIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.incorporated_files||[]).forEach(f=>process.stdout.write(f+'\n'))")"
For every file in INCORPORATED_FILES:
- Do NOT merge it. Do NOT re-apply its diff. Leave the newly installed file exactly as shipped — the user's customization is already in it. Re-grafting the diff onto a version that already contains it duplicates the content (both copies run; a stale graft can contradict a richer native upstream step that replaced it).
- Report the per-file status
Incorporated— "Already in upstream v{version}" — in the Step 3 summary and the Step 7 report. - No Hunk Verification Table rows are required for these files (nothing was merged); the classifier's structured output is the evidence. If EVERY backed-up file is Incorporated, still emit the table with a header row and a single note line naming the incorporated files, so the Step 5b absent-table halt is not tripped. Step 5a passes these files without special-casing — every user-added line is present in the untouched install.
- This is what ends the re-graft cycle: because the installed file stays identical to what
the release ships, the next update's hash comparison no longer flags it as modified, and
the file drops out of
gsd-local-patches/on the next cycle.
Files NOT in INCORPORATED_FILES — reported needs_merge (with the lines still missing
from the new version) or unknown (no confirmable pristine baseline) — take the normal
merge paths below, unchanged. If the classifier cannot run at all (e.g. GSD_HOME
unset), fall back to merging every file as before; never report Incorporated without the
classifier's structured confirmation.
For each file in backup-meta.json:
- Read the backed-up version (user's modified copy from
gsd-local-patches/) - Read the newly installed version (current file after update)
- If available, read the pristine baseline (from git history or
gsd-pristine/)
Three-way merge (when baseline is available)
Compare the three versions to isolate changes:
- User changes = diff(pristine → user's version) — these are the customizations to preserve
- Upstream changes = diff(pristine → new version) — these are version updates to accept
Merge rules:
- Sections changed only by user → apply user's version
- Sections changed only by upstream → accept upstream version
- Sections changed by both → flag as CONFLICT, show both, ask user
- Sections unchanged by either → use new version (identical to all three)
- User-added content already present verbatim in the new version → already incorporated
upstream — do NOT re-apply it (the file-level outcome is
Incorporatedwhen ALL user-added content is thus present, as determined by the Step 4 pre-flight classifier)
Two-way merge (fallback when no baseline)
When no pristine baseline is available, use these strengthened heuristics:
CRITICAL RULE: Every file in this backup directory was explicitly detected as modified by the installer's SHA-256 hash comparison. "No custom content" is never a valid conclusion.
For each file: a. Read both versions completely b. Identify ALL differences, then classify each as:
- Mechanical drift — path substitutions (e.g.
/Users/xxx/.claude/→$HOME/.claude/), variable additions (${GSD_WS},${AGENT_SKILLS_*}), error handling additions (|| true) - User customization — added steps/sections, removed sections, reordered content, changed behavior, added frontmatter fields, modified instructions
c. If ANY differences remain after filtering out mechanical drift → those are user customizations. Merge them. d. If ALL differences appear to be mechanical drift → still flag as CONFLICT. The installer's hash check already proved this file was modified. Ask the user: "This file appears to only have path/variable differences. Were there intentional customizations?" Do NOT silently skip.
Git-enhanced two-way merge
When the config directory is a git repo but the pristine install commit can't be found, use commit history to identify user changes:
# Find non-update commits that touched this file
git -C "$CONFIG_DIR" log --oneline --no-merges -- "{file_path}" | grep -v "gsd:update\|gsd-update\|GSD update\|gsd-install"
Each matching commit represents an intentional user modification. Use the commit messages and diffs to understand what was changed and why.
- Write merged result to the installed location
Post-merge verification
After writing each merged file, verify that user modifications survived the merge:
-
Line-count check: Count lines in the backup and the merged result. If the merged result has fewer lines than the backup minus the expected upstream removals, flag for review.
-
Hunk presence check: For each user-added section identified during diff analysis, search the merged output for at least the first significant line (non-blank, non-comment) of each addition. Missing signature lines indicate a dropped hunk.
-
Report warnings inline (do not block):
⚠ Potential dropped content in {file_path}: - Missing hunk near line {N}: "{first_line_preview}..." ({line_count} lines) - Backup available: {patches_dir}/{file_path} -
Produce a Hunk Verification Table — one row per hunk per file. This table is mandatory output and must be produced before Step 5 can proceed. Format:
file hunk_id signature_line line_count verified {file_path} {N} {first_significant_line} {count} yes {file_path} {N} {first_significant_line} {count} no hunk_id— sequential integer per file (1, 2, 3…)signature_line— first non-blank, non-comment line of the user-added sectionline_count— total lines in the hunkverified—yesif the signature_line is present in the merged output,nootherwise
-
Track verification status — add to per-file report:
Merged (verified)vsMerged (⚠ {N} hunks may be missing) -
Report status per file:
Merged— user modifications applied cleanly (show summary of what was preserved)Conflict— user reviewed and chose resolutionIncorporated— user's modification was already adopted upstream (only valid when pristine baseline confirms this — determined exclusively by the Step 4 pre-flight classifier'sincorporated_files, never by inspection)
Never report Skipped — no custom content. If a file is in the backup, it has custom content.
Step 5: Hunk Verification Gate
Two layered gates. Both must pass before proceeding to cleanup.
5a: Deterministic verifier (binding gate, #2969)
Run the deterministic verifier script. Do NOT rely solely on the free-text verified: yes/no Hunk Verification Table from Step 4 — bug #2969 traced repeated false-positive verified: yes reports to that table being filled in without an actual content-presence check. The script performs the check structurally and exits non-zero on any miss.
Run the verifier as a child process (the gsd-tools binary directory is not required — the script ships under gsd-core/bin/ in the source repo and is installed to ${GSD_HOME}/gsd-core/bin/):
PRISTINE_DIR="${CONFIG_DIR}/gsd-pristine"
# Build args as a bash array so paths with spaces survive expansion intact
# (string-concat + unquoted expansion would split incorrectly on whitespace).
VERIFY_ARGS=(
--patches-dir "$PATCHES_DIR"
--config-dir "$CONFIG_DIR"
)
if [ -d "$PRISTINE_DIR" ]; then
VERIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
fi
VERIFY_ARGS+=(--json)
# Capture stdout (the structured JSON report) separately from stderr so that
# Node warnings, deprecation notices, or stack traces do not corrupt the
# JSON parse downstream. Stderr is preserved on the controlling terminal
# for operator visibility.
VERIFY_OUTPUT="$(node "${GSD_HOME}/gsd-core/bin/verify-reapply-patches.cjs" "${VERIFY_ARGS[@]}")"
VERIFY_STATUS=$?
Step 5a: drift check — even when VERIFY_STATUS is 0, the report may signal that one or more files were skipped due to pristine-snapshot drift (Bug #3657) or a missing baseline (Bug #934). Parse the JSON and check:
DRIFTED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.drifted||0))")"
DRIFTED_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.drifted_files||[]).forEach(f=>process.stdout.write(f+'\n'))")"
NO_BASELINE_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.no_baseline||0))")"
NO_BASELINE_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.no_baseline_files||[]).forEach(f=>process.stdout.write(f+'\n'))")"
BASELINE_COVERED="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.baseline_covered||0))")"
CHECKED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.checked||0))")"
Baseline coverage headline (#4135) — BEFORE any pass/fail statement, print the coverage N-of-M line. A run where 12 of 13 files were not diff-verified must never present the same way as a fully-verified one:
Baseline coverage: {BASELINE_COVERED} of {CHECKED_COUNT} file(s) verified against a pristine
baseline ({CHECKED_COUNT - BASELINE_COVERED} unverified)
The verifier also reports this on every run (JSON field baseline_covered; the human summary
leads with the same line). Operators who want the gate itself to fail on low coverage (cautious
environments) pass the opt-in strict flag when invoking the verifier:
--min-baseline-coverage <fraction between 0 and 1> — below the threshold the verifier exits
with code 3 (a real content failure still exits 1 and outranks it). Without the flag the
default advisory posture is unchanged.
If NO_BASELINE_COUNT is greater than 0, emit an advisory warning (non-blocking — the gate still exits 0 for these files). Do NOT halt:
ADVISORY: {NO_BASELINE_COUNT} file(s) could not be diff-verified because no pristine
baseline exists on disk despite a hash being recorded in backup-meta.json (Bug #934:
the installer discarded the only pristine candidate because it was from a newer release).
These files were skipped rather than false-failed; their user customisations may or
may not have survived the merge.
Unverified files:
{each path in NO_BASELINE_FILES, one per line, indented two spaces}
Recommended: manually inspect each file above and confirm your customisations survived.
If DRIFTED_COUNT is greater than 0, STOP and report to the user, then set DRIFT_DETECTED=true and halt — do not proceed to 5b or cleanup:
HALT: {DRIFTED_COUNT} file(s) were skipped by the deterministic verifier because the
gsd-pristine/ snapshot on disk does not match the hash recorded in backup-meta.json
(pristine drift — the snapshot was refreshed to a newer GSD version after the backup
was captured). These files were NOT diff-verified; their user customisations may or
may not have survived the merge.
Drifted files:
{each path in DRIFTED_FILES, one per line, indented two spaces}
Resolve before re-running:
(a) Re-anchor the pristine snapshot to the version recorded in backup-meta.json, or
(b) Restore the affected file(s) from backup and re-merge manually:
cp {patches_dir}/{file} {installed_path} # then re-apply customisations
(c) If the upstream changes are acceptable, update the backup-meta.json
pristine_hashes entry for each drifted file to the current on-disk hash, then
re-run /gsd:update --reapply to re-verify with the refreshed baseline.
Then re-run /gsd:update --reapply to re-verify.
DRIFT_DETECTED=true
# Abort — subsequent steps must not execute when drift is unresolved.
exit 1
If VERIFY_STATUS is non-zero, STOP and report to the user, parsing the JSON output:
ERROR: {failures} file(s) failed deterministic post-merge verification (#2969 gate).
The verifier compared user-added lines (computed from the diff between
the backup and the pristine baseline) against the merged installed file.
Lines listed below are present in the backup but absent from the merged result.
For each failed file:
{file}
missing: {first significant missing line, up to 5 per file}
backup: {patches_dir}/{file}
Resolve before proceeding:
(a) Re-merge the missing content into the installed file by hand, or
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
Then re-run /gsd:update --reapply to re-verify.
Do not proceed to cleanup until the verifier exits 0.
Only when VERIFY_STATUS is 0 (or when all files had zero significant user-added lines, which the verifier reports as Failures: 0) may execution continue to gate 5b.
5b: Hunk Verification Table review (advisory gate, #1999)
The Hunk Verification Table produced in Step 4 must also be reviewed before proceeding. This is advisory after the script gate but is preserved as a defense-in-depth check — if the script ever has a bug or the pristine baseline is unavailable, the table-based gate still catches obvious regressions.
If the Hunk Verification Table is absent (Step 4 silently produced nothing), STOP and report:
ERROR: Hunk Verification Table is missing — Step 4 did not produce it.
The deterministic verifier (5a) may still have passed, but a missing table
means post-merge verification was not fully completed. Rerun
/gsd:update --reapply to retry with full verification.
A missing table absent from the workflow output cannot bypass this gate.
If any row in the Hunk Verification Table shows verified: no, STOP and report:
ERROR: {N} hunk(s) failed Step 5b verification — content may have been dropped during merge.
Unverified hunks:
{file} hunk {hunk_id}: signature line "{signature_line}" not found in merged output
The backup is preserved at: {patches_dir}/{file}
Review the merged file manually, then either:
(a) Re-merge the missing content by hand, or
(b) Restore from backup: cp {patches_dir}/{file} {installed_path}
Do not proceed to cleanup until both gates (5a and 5b) pass.
Why both gates? 5a (the script) is the binding gate — it does the actual substring check structurally and cannot be shortcut by the LLM. 5b (the table review) is the advisory gate — it provides a redundant safety net via the Step 4 prose summary, ensuring that even a script regression or absent pristine baseline cannot silently allow a verified: no row to slip past, nor can a missing table go unnoticed. Layered gates favour false-positive halts (recoverable) over silent successes on lost content (unrecoverable).
Step 6: Cleanup option
Ask user:
- "Keep patch backups for reference?" → preserve
gsd-local-patches/ - "Clean up patch backups?" → remove
gsd-local-patches/directory
Step 7: Report
## Patches Reapplied
| # | File | Result | User Changes Preserved |
|---|------|--------|----------------------|
| 1 | {file_path} | Merged | Added step X, modified section Y |
| 2 | {file_path} | Incorporated | Already in upstream v{version} |
| 3 | {file_path} | Conflict resolved | User chose: keep custom section |
{count} file(s) updated. Your local modifications are active again.
<success_criteria>
- All backed-up patches processed — zero files left unhandled
- No file classified as "no custom content" or "SKIP" — every backed-up file is definitionally modified
- Three-way merge used when pristine baseline available (git history or gsd-pristine/)
- User modifications identified and merged into new version
- Superseded customizations classified
Incorporatedby the deterministic pre-flight classifier (pristine-confirmed) and not re-grafted - Conflicts surfaced to user with both versions shown
- Status reported for each file with summary of what was preserved
- Post-merge verification checks each file for dropped hunks and warns if content appears missing </success_criteria>