@opengsd/gsd-core 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -270,7 +270,32 @@ if [ "$AUTO_MODE" = "true" ]; then
270
270
  # It is re-derived there from the final REVIEW.md's status, which is exactly the condition the
271
271
  # `break` below fires on. A variable set here and read nowhere would just be a decoy.
272
272
 
273
- while [ $ITERATION -lt $MAX_ITERATIONS ]; do
273
+ # #4100: the loop used to stop only on a re-review returning `status: clean`. A review whose
274
+ # remaining findings are all OUTSIDE the fix scope (Info, or necessity findings without --all)
275
+ # never reaches clean, so the loop re-spawned the fixer with nothing to fix until the cap and then
276
+ # printed the degradation warning over a run with no work left. The fixer is the scope authority:
277
+ # its REVIEW-FIX.md `findings_in_scope` is read strictly — only a frontmatter value of 0 proves
278
+ # zero scope; a missing report or an unusable value keeps the old behaviour.
279
+ _gsd_fix_scope_empty() {
280
+ [ -f "$1" ] || return 1
281
+ _gsd_n=$(FIX_REPORT_PATH="$1" node -e "
282
+ const fs = require('fs');
283
+ try {
284
+ const content = fs.readFileSync(process.env.FIX_REPORT_PATH, 'utf-8');
285
+ const m = content.replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
286
+ const v = m && m[1].match(/^findings_in_scope:[ \t]*([0-9]+)[ \t]*$/m);
287
+ process.stdout.write(v ? String(Number(v[1])) : '');
288
+ } catch (e) {}
289
+ " 2>/dev/null || true)
290
+ [ "$_gsd_n" = "0" ]
291
+ }
292
+ NO_SCOPE_LEFT=false
293
+ if _gsd_fix_scope_empty "${FIX_REPORT_PATH}"; then
294
+ NO_SCOPE_LEFT=true
295
+ echo "No findings in fix scope (${FIX_SCOPE}) — skipping the re-review loop. Out-of-scope findings remain in ${REVIEW_PATH}."
296
+ fi
297
+
298
+ while [ "$NO_SCOPE_LEFT" = false ] && [ $ITERATION -lt $MAX_ITERATIONS ]; do
274
299
  ITERATION=$((ITERATION + 1))
275
300
 
276
301
  echo ""
@@ -361,6 +386,12 @@ ${AGENT_SKILLS_FIXER}")
361
386
  echo "Warning: Iteration ${ITERATION} fixer failed to produce fix report. Stopping auto-loop."
362
387
  break
363
388
  fi
389
+ if _gsd_fix_scope_empty "${FIX_REPORT_PATH}"; then
390
+ NO_SCOPE_LEFT=true
391
+ echo ""
392
+ echo "✓ No in-scope findings remain after iteration ${ITERATION} (scope: ${FIX_SCOPE}). Out-of-scope findings remain in ${REVIEW_PATH}."
393
+ break
394
+ fi
364
395
  done
365
396
 
366
397
  # After loop completes. The iteration COUNTER alone does not distinguish degradation from success:
@@ -369,7 +400,7 @@ ${AGENT_SKILLS_FIXER}")
369
400
  # every finding was fixed — telling the operator the opposite of what happened, on the one path
370
401
  # where the cap and convergence coincide. Convergence is re-derived from the review the loop left
371
402
  # behind, the same signal cleanup_iteration_backups reads, so the two cannot disagree.
372
- if [ $ITERATION -ge $MAX_ITERATIONS ]; then
403
+ if [ "$NO_SCOPE_LEFT" = false ] && [ $ITERATION -ge $MAX_ITERATIONS ]; then
373
404
  LOOP_END_STATUS=$(REVIEW_PATH="${REVIEW_PATH}" node -e "
374
405
  const fs = require('fs');
375
406
  try {
@@ -491,8 +522,9 @@ which is the one distinction #3829 exists to make.
491
522
  Convergence is re-derived here rather than carried. Shell state does not survive the loop's fence, so
492
523
  a flag set there would be gone by this step — and once nothing in the loop consumed it, keeping it
493
524
  would have left a variable set in one place and read in none. The loop breaks on exactly one
494
- condition, a re-review returning clean, and that leaves `REVIEW.md` at `status: clean`: the final
495
- review's status IS the converged case. Hitting `MAX_ITERATIONS` leaves the last re-review non-clean,
525
+ condition that counts as convergence, a re-review returning clean, and that leaves `REVIEW.md` at
526
+ `status: clean`: the final review's status IS the converged case. Its other stop, nothing left in
527
+ fix scope (#4100), leaves out-of-scope findings in a non-clean review, so the backups are kept. Hitting `MAX_ITERATIONS` leaves the last re-review non-clean,
496
528
  and the backups are kept.
497
529
 
498
530
  ```bash
@@ -586,7 +618,16 @@ echo ""
586
618
 
587
619
  If status is "all_fixed":
588
620
  ```bash
589
- if [ "$FIX_STATUS" = "all_fixed" ]; then
621
+ if [ "${FINDINGS_IN_SCOPE:-}" = "0" ]; then
622
+ # #4100: a vacuous all_fixed (nothing was in scope) must not claim the review is resolved.
623
+ echo "○ No findings were in fix scope (${FIX_SCOPE}). Out-of-scope findings (Info, and necessity unless --all) remain in ${REVIEW_PATH}."
624
+ echo ""
625
+ if [ "${FIX_SCOPE}" != "all" ]; then
626
+ echo "Next step:"
627
+ echo " /gsd:code-review ${PHASE_NUMBER} --fix --all — Include them in the fix scope"
628
+ echo ""
629
+ fi
630
+ elif [ "$FIX_STATUS" = "all_fixed" ]; then
590
631
  echo "✓ All issues resolved."
591
632
  echo ""
592
633
  echo "Full report: ${FIX_REPORT_PATH}"
@@ -932,6 +932,8 @@ CRITICAL=$(echo "$FRONTMATTER" | grep -E "^[[:space:]]*(critical|blocker):" | he
932
932
  WARNING=$(echo "$FRONTMATTER" | grep "warning:" | head -1 | cut -d: -f2 | xargs)
933
933
  INFO=$(echo "$FRONTMATTER" | grep "info:" | head -1 | cut -d: -f2 | xargs)
934
934
  TOTAL=$(echo "$FRONTMATTER" | grep "total:" | head -1 | cut -d: -f2 | xargs)
935
+ # #4100: necessity is a SUBSET of warning+info, never part of total. Absent at quick depth.
936
+ NECESSITY=$(echo "$FRONTMATTER" | grep -E "^[[:space:]]*necessity:" | head -1 | cut -d: -f2 | sed 's/#.*//' | xargs)
935
937
  ```
936
938
 
937
939
  Display inline summary to user:
@@ -950,6 +952,7 @@ Display inline summary to user:
950
952
  Critical: ${CRITICAL}
951
953
  Warning: ${WARNING}
952
954
  Info: ${INFO}
955
+ Necessity: ${NECESSITY:-not assessed} (of the above; skipped by --fix unless --all)
953
956
 
954
957
  ---
955
958
  Total: ${TOTAL}
@@ -958,7 +958,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
958
958
  if [ "${TEST_EXIT}" -eq 0 ]; then
959
959
  # Update ROADMAP plan progress for each completed plan in this wave
960
960
  for plan_id in {completed_plan_ids}; do
961
- gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}" "${plan_id}" "complete"
961
+ PROGRESS=$(gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}" "${plan_id}" "complete") && REASON=$(jq -er '.reason//""' <<<"$PROGRESS") && [ "$REASON" != missing_phase_details ] || { echo "ERROR: ROADMAP update-plan-progress did not complete for ${PHASE_NUMBER}-${plan_id} (missing_phase_details decline, failed query, or jq not installed). STOP: skip this plan's completion steps." >&2; exit 1; }
962
962
  done
963
963
 
964
964
  # Only commit tracking files if they actually changed
@@ -504,7 +504,7 @@ IS_WORKTREE=$([ -f .git ] && echo "true" || echo "false")
504
504
 
505
505
  if [ "$IS_WORKTREE" != "true" ]; then
506
506
  # use_worktrees: false → this handler is the sole post-plan sync point (#2661)
507
- gsd_run query roadmap.update-plan-progress "${PHASE}"
507
+ PROGRESS=$(gsd_run query roadmap.update-plan-progress "${PHASE}") && REASON=$(jq -er '.reason//""' <<<"$PROGRESS") && [ "$REASON" != missing_phase_details ] || { echo "ERROR: ROADMAP update-plan-progress did not complete for ${PHASE}-${PLAN} (missing_phase_details decline, failed query, or jq not installed). STOP: skip this plan's completion steps." >&2; exit 1; }
508
508
  fi
509
509
  ```
510
510
  Counts PLAN vs SUMMARY files on disk. Updates progress table row with correct count and status (`In Progress` or `Complete` with date).
@@ -103,23 +103,27 @@ PLANNER_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get planner.stall_thresho
103
103
  # a reliability risk this fix cannot afford: a malformed config value would
104
104
  # abort the stall-watcher itself with a bash syntax error, silently defeating
105
105
  # the exact hang-recovery this issue is about. Reject anything that is not a
106
- # bare non-negative integer before it is ever used, so a bad config value
107
- # degrades to the safe default instead of crashing the watcher.
108
- [[ "$PLANNER_STALL_INTERVAL_MINUTES" =~ ^[0-9]+$ ]] || PLANNER_STALL_INTERVAL_MINUTES=5
109
- [[ "$PLANNER_STALL_THRESHOLD_MINUTES" =~ ^[0-9]+$ ]] || PLANNER_STALL_THRESHOLD_MINUTES=10
106
+ # bare positive integer before it is ever used, so a bad config value
107
+ # degrades to the safe default instead of crashing the watcher. Zero is
108
+ # rejected too (#3923): an interval of 0 makes every gsd_stall_watch cycle a
109
+ # zero-length sleep, and a threshold of 0 declares a healthy agent stalled on
110
+ # the first check. At most 9 digits, so `* 60` can never overflow.
111
+ [[ "$PLANNER_STALL_INTERVAL_MINUTES" =~ ^[1-9][0-9]{0,8}$ ]] || PLANNER_STALL_INTERVAL_MINUTES=5
112
+ [[ "$PLANNER_STALL_THRESHOLD_MINUTES" =~ ^[1-9][0-9]{0,8}$ ]] || PLANNER_STALL_THRESHOLD_MINUTES=10
110
113
 
111
114
  # gsd_stall_should_recover — pure decision function, no IO, no sleeping. Given how
112
115
  # long the orchestrator has been waiting plus two liveness signals (a completion
113
116
  # marker found in the agent's output file, and fresh on-disk artifact activity),
114
117
  # decides whether to keep waiting, treat the wait as satisfied, or auto-surface the
115
118
  # existing accept/retry/stop recovery menu (9a/11a). Never kills or retries anything
116
- # itself — it only classifies. Re-validates both numeric args as bare non-negative
117
- # integers (defense in depth — safe to call with any input, not just the resolved
118
- # config globals above) before either ever reaches arithmetic expansion.
119
+ # itself — it only classifies. Re-validates both numeric args as bounded bare
120
+ # non-negative integers (defense in depth — safe to call with any input, not just
121
+ # the resolved config globals above, which are always >= 1) before either ever
122
+ # reaches arithmetic expansion. It accepts 0 so callers can test boundaries.
119
123
  gsd_stall_should_recover() {
120
124
  local elapsed_seconds="$1" threshold_minutes="$2" marker_found="$3" artifact_fresh="$4"
121
- [[ "$elapsed_seconds" =~ ^[0-9]+$ ]] || elapsed_seconds=0
122
- [[ "$threshold_minutes" =~ ^[0-9]+$ ]] || threshold_minutes=10
125
+ [[ "$elapsed_seconds" =~ ^[0-9]{1,15}$ ]] || elapsed_seconds=0
126
+ [[ "$threshold_minutes" =~ ^[0-9]{1,9}$ ]] || threshold_minutes=10
123
127
  local threshold_seconds=$(( threshold_minutes * 60 ))
124
128
  if [ "$marker_found" = "true" ]; then
125
129
  echo "marker_received"; return 0
@@ -1441,7 +1441,7 @@ This step is derived entirely from existing PLAN frontmatter — no extra LLM pa
1441
1441
  gsd_run query roadmap.annotate-dependencies "${PHASE_NUMBER}"
1442
1442
  ```
1443
1443
 
1444
- This operation is idempotent: if wave headers or cross-cutting constraints already exist in the ROADMAP phase section, the command returns without modifying the file. Skip this step if `plan_count` is 0.
1444
+ Skip this step if `plan_count` is 0. Re-running regenerates the annotation from current PLAN frontmatter; an unchanged result writes nothing, and an unrecognised checklist row leaves ROADMAP.md untouched and reports `skipped: "unrecognised_checklist_row"`. The `**Cross-cutting constraints:**` header line ends in an HTML-comment marker; that block is machine-owned and the bullets directly under it are regenerated. Put hand-written bullets after a blank line. Bullets kept from a block written before the marker existed are reported as `stale_legacy_constraints`.
1445
1445
 
1446
1446
  ## 13d. Commit Plans if commit_docs is true
1447
1447
 
@@ -90,6 +90,8 @@ jq dependency). Write dirty repo names to a temp file so the list survives acros
90
90
  subsequent command executions:
91
91
 
92
92
  ```bash
93
+ # #5548: drop inherited repo-location vars (set inside git hooks) so cwd, not GIT_DIR, picks ROOT.
94
+ unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_OBJECT_DIRECTORY GIT_DIR GIT_WORK_TREE GIT_IMPLICIT_WORK_TREE GIT_GRAFT_FILE GIT_INDEX_FILE GIT_NO_REPLACE_OBJECTS GIT_REPLACE_REF_BASE GIT_PREFIX GIT_SHALLOW_FILE GIT_COMMON_DIR
93
95
  ROOT=$(git rev-parse --show-toplevel)
94
96
  DIRTY_FILE=$(mktemp)
95
97
 
@@ -10,6 +10,21 @@ After a GSD update wipes and reinstalls files, this workflow merges user's previ
10
10
 
11
11
  <process>
12
12
 
13
+ ## Step 0: Parse interactive mode (#4435)
14
+
15
+ Read the arguments passed after `--reapply`:
16
+
17
+ - no `--interactive` → `INTERACTIVE_MODE=off` (the default; Step 4 runs exactly as before)
18
+ - bare `--interactive` or `--interactive=exceptions` → `INTERACTIVE_MODE=exceptions`
19
+ - `--interactive=all` → `INTERACTIVE_MODE=all`
20
+ - any other `--interactive=<value>` → STOP with: `unknown --interactive value '<value>' (expected exceptions or all)`. Do not reapply anything.
21
+
22
+ When the mode is not `off`, tell the user once, before the first prompt: on the first interactive run
23
+ for this install, `--interactive=all` is recommended, because the recommendations have not yet been
24
+ checked against this install's customizations.
25
+
26
+ **Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in the arguments OR `workflow.text_mode` is `true`. When TEXT_MODE is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes where `AskUserQuestion` is not available.
27
+
13
28
  ## Step 1: Detect backed-up patches
14
29
 
15
30
  Check for local patches directory:
@@ -250,6 +265,75 @@ merge paths below, unchanged. If the classifier cannot run at all (e.g. `GSD_HOM
250
265
  unset), fall back to merging every file as before; never report `Incorporated` without the
251
266
  classifier's structured confirmation.
252
267
 
268
+ ### Step 4 interactive review (#4435) — only when `INTERACTIVE_MODE` is not `off`
269
+
270
+ Verbatim matching cannot see an edit that upstream adopted in different words, an edit whose
271
+ surrounding stock text upstream rewrote, or an edit whose anchor lines upstream deleted. Interactive
272
+ mode shows each user edit (hunk) and lets the USER decide. Present and gate: the tool recommends,
273
+ the user decides, and nothing is retired without the user's answer.
274
+
275
+ ```bash
276
+ DECISIONS_FILE="$(mktemp "${TMPDIR:-/tmp}/gsd-reapply-decisions.XXXXXX")"
277
+ HUNKS_ARGS=(
278
+ --patches-dir "$PATCHES_DIR"
279
+ --config-dir "$CONFIG_DIR"
280
+ )
281
+ if [ -d "$PRISTINE_DIR" ]; then
282
+ HUNKS_ARGS+=(--pristine-dir "$PRISTINE_DIR")
283
+ fi
284
+ HUNKS_ARGS+=(--hunks --json)
285
+
286
+ # Informational: exits 0. Each hunk carries hunk_hash, kind, stock_lines, user_lines,
287
+ # recommendation (keep | retire | review), reasons, and exception (true/false).
288
+ HUNKS_OUTPUT="$(node "${GSD_HOME}/gsd-core/bin/verify-reapply-patches.cjs" "${HUNKS_ARGS[@]}")"
289
+ ```
290
+
291
+ The decisions file lives in a temp directory, never inside `gsd-local-patches/`, because the
292
+ Step 5a gate treats every file in that directory as a backed-up patch.
293
+
294
+ For each file in `HUNKS_OUTPUT.files`, for each hunk:
295
+
296
+ - `INTERACTIVE_MODE=all`: present EVERY hunk.
297
+ - `INTERACTIVE_MODE=exceptions`: present only hunks with `exception: true`. Every other hunk is kept
298
+ without asking. Print one summary line for each:
299
+ `kept without review: {file} hunk {hunk_id}: "{first user line, truncated to 60 chars}"`.
300
+
301
+ Presenting a hunk means asking the user one question (`AskUserQuestion`, or under TEXT_MODE a
302
+ plain-text numbered list the user answers by number) that shows:
303
+
304
+ - **Stock text:** `stock_lines` (or "(none — your lines were inserted)" when empty)
305
+ - **Your text:** `user_lines` (or "(none — you deleted the stock text)" when empty)
306
+ - **Recommendation:** `recommendation`, with `reasons` explained in plain words:
307
+ `already_upstream` (the shipped file already has it), `partially_upstream` (only some of it is
308
+ there), `region_changed_upstream` (upstream also changed this spot, for example by rewording it),
309
+ `anchor_orphaned` (the lines it sat between are gone), `baseline_unconfirmed` (no verified
310
+ pre-update copy, so read the whole file), `diff_too_large` (the file changed too much to split it
311
+ into separate edits)
312
+ - Options: **Keep** (graft it), **Retire** (drop it; keep upstream's text), **Rewrite** (the user
313
+ supplies replacement text, which is applied instead)
314
+
315
+ Write a decision entry only from the user's answer to that hunk's question. Never write `retire` or
316
+ `rewrite` for a hunk the user was not asked about, and never pick an answer on the user's behalf,
317
+ even when the recommendation is `retire`. A hunk kept without review in `exceptions` mode is
318
+ recorded as `keep`. Record each answer in `DECISIONS_FILE` as JSON:
319
+
320
+ ```json
321
+ { "decisions": [ { "file": "<file>", "hunk_hash": "<hunk_hash>", "disposition": "keep|retire|rewrite" } ] }
322
+ ```
323
+
324
+ A `rewrite` entry must also carry `"replacement_lines": [...]` — exactly the lines the user supplied.
325
+ The Step 5a gate then requires those lines to be present in the merged file, so a rewrite is verified
326
+ like any kept edit. `keep` and `retire` entries never carry `replacement_lines`.
327
+
328
+ Shell variables do not survive between separate command invocations. Write `INTERACTIVE_MODE` and the
329
+ `DECISIONS_FILE` path down when you set them, and set both again in the same command that runs the
330
+ Step 5a verifier.
331
+
332
+ Then merge each file with the paths below, applying only `keep` hunks and the user's replacement
333
+ text for `rewrite` hunks. A file whose hunks were all retired is left exactly as installed and
334
+ reported `Incorporated` ("Retired by user decision"). A file with some hunks retired is reported
335
+ `Merged` with "{N} hunk(s) retired by user decision".
336
+
253
337
  For each file in `backup-meta.json`:
254
338
 
255
339
  1. **Read the backed-up version** (user's modified copy from `gsd-local-patches/`)
@@ -352,6 +436,11 @@ VERIFY_ARGS=(
352
436
  if [ -d "$PRISTINE_DIR" ]; then
353
437
  VERIFY_ARGS+=(--pristine-dir "$PRISTINE_DIR")
354
438
  fi
439
+ # #4435: hunks the user retired or rewrote are excused only through the recorded,
440
+ # hash-bound decisions. A decision whose hunk no longer matches exits 2 (stale).
441
+ if [ "$INTERACTIVE_MODE" != "off" ] && [ -s "$DECISIONS_FILE" ]; then
442
+ VERIFY_ARGS+=(--decisions "$DECISIONS_FILE")
443
+ fi
355
444
  VERIFY_ARGS+=(--json)
356
445
 
357
446
  # Capture stdout (the structured JSON report) separately from stderr so that
@@ -456,6 +545,18 @@ Then re-run /gsd:update --reapply to re-verify.
456
545
 
457
546
  Do not proceed to cleanup until the verifier exits 0.
458
547
 
548
+ **If `VERIFY_STATUS` is 2 and stderr names a `stale decision`** (#4435), a recorded answer no longer
549
+ matches its hunk because the backup or baseline changed after the user saw it. STOP, discard
550
+ `DECISIONS_FILE`, and re-run `/gsd:update --reapply --interactive` so the user decides on the current
551
+ text. Never edit the decisions file to make it match.
552
+
553
+ When decisions were passed, the JSON report carries `decisions.retired` — every hunk excused by a
554
+ user `retire` or `rewrite` decision. Before Step 6, show the user this list (file, hunk id,
555
+ disposition) and ask them to confirm that each one is a decision they made. The gate can prove that a
556
+ decision matches the exact text that was shown, but not who made it, so the user's confirmation of
557
+ this list is the final check. If the user disowns any entry, restore that hunk from the backup and
558
+ re-run Step 5a without it. List each confirmed entry in the Step 7 report.
559
+
459
560
  **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.
460
561
 
461
562
  ### 5b: Hunk Verification Table review (advisory gate, #1999)
@@ -493,6 +594,8 @@ Do not proceed to cleanup until both gates (5a and 5b) pass.
493
594
 
494
595
  ## Step 6: Cleanup option
495
596
 
597
+ If `DECISIONS_FILE` was created (#4435), delete it now: `rm -f "$DECISIONS_FILE"`.
598
+
496
599
  Ask user:
497
600
  - "Keep patch backups for reference?" → preserve `gsd-local-patches/`
498
601
  - "Clean up patch backups?" → remove `gsd-local-patches/` directory
@@ -520,6 +623,7 @@ Ask user:
520
623
  - [ ] User modifications identified and merged into new version
521
624
  - [ ] Superseded customizations classified `Incorporated` by the deterministic pre-flight classifier (pristine-confirmed) and not re-grafted
522
625
  - [ ] Conflicts surfaced to user with both versions shown
626
+ - [ ] With `--interactive`, every presented hunk was decided by the user (never by the agent), retired/rewritten hunks were passed to the Step 5a gate through the hash-bound decisions file, and each retirement is listed in the report
523
627
  - [ ] Status reported for each file with summary of what was preserved
524
628
  - [ ] Post-merge verification checks each file for dropped hunks and warns if content appears missing
525
629
  </success_criteria>