@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
@@ -31,7 +31,7 @@ command -v kimi >/dev/null 2>&1 && echo "kimi-code:available" || echo "kimi-code
31
31
  # hosts and defaults come from each lane's descriptor
32
32
  gsd_run review-lane availability || { echo "WARNING: review-lane availability failed or is unsupported by this gsd-tools; treating local lanes as missing" >&2; printf 'ollama:missing\nlm_studio:missing\nllama_cpp:missing\n'; }
33
33
 
34
- # jq prerequisite (#2589). The config/model/budget lookups in this workflow no
34
+ # jq prerequisite (#2589). The config/model lookups in this workflow no
35
35
  # longer need jq — they use the native --raw/--pick flags. But the lanes listed
36
36
  # under "jq-dependent reviewer lanes" below parse structured JSON that gsd-tools
37
37
  # does not emit (OpenAI-compatible /v1/chat/completions responses, opencode's
@@ -245,7 +245,7 @@ Output your review in markdown format.
245
245
 
246
246
  Write to a temp file: `{run_dir}/gsd-review-prompt.md`
247
247
 
248
- Also write individual section files so the budget tool can re-trim per reviewer:
248
+ Append the plan coverage manifest to the prompt:
249
249
 
250
250
  ```bash
251
251
  # #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
@@ -253,38 +253,21 @@ shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
253
253
 
254
254
  RUN_DIR="{run_dir}" # from gather_context
255
255
 
256
- # Write individual section files for per-reviewer budget trimming
257
- # These are always written so reviewers with a budget can invoke prompt-budget
258
- cp "$INSTRUCTIONS_BLOCK_FILE" "${RUN_DIR}/gsd-review-instructions.md"
259
- cp "$ROADMAP_SECTION_FILE" "${RUN_DIR}/gsd-review-roadmap.md"
260
-
261
- # Plan files: copy each PLAN.md to a predictable path named after its source
262
- # plan id (#3959: a bare padded index discards provenance — the budget tool's
263
- # per-plan `### <file>` header then renders a run-dir artifact name no reviewer
264
- # or consensus step can resolve. The plan id keeps the gsd-review-plan-*.md glob
265
- # prepare_trimmed_prompt_for_reviewer consumes.)
266
- for PLAN_FILE in "${PHASE_DIR}"/*-PLAN.md; do
267
- PLAN_BASENAME=$(basename "$PLAN_FILE")
268
- PLAN_ID="${PLAN_BASENAME%-PLAN.md}"
269
- cp "$PLAN_FILE" "${RUN_DIR}/gsd-review-plan-${PLAN_ID}.md"
270
- done
271
-
272
256
  # #3301: plan coverage manifest — tell reviewers exactly which plan ids exist and
273
257
  # how many there are, so a review that silently covers 6 of 7 plans is no longer
274
258
  # indistinguishable from one that covers all 7. The id is the plan file's own
275
259
  # basename with the `-PLAN.md` suffix stripped (e.g. `12.6-01-PLAN.md` ->
276
260
  # `12.6-01`) — NOT the `plan:` frontmatter key, which holds only the bare
277
261
  # in-phase sequence number ("01") and can never reconstruct the phase-qualified
278
- # id reviewers need to cite. The filename is guaranteed present for every copied
279
- # plan, so no plan is ever dropped from the manifest for lacking a key.
262
+ # id reviewers need to cite. The filename is guaranteed present for every plan,
263
+ # so no plan is ever dropped from the manifest for lacking a key.
280
264
  # Count and bullets are both derived directly from the glob loop below, never
281
265
  # from re-splitting an accumulated string: bash word-splits an unquoted `$VAR`
282
266
  # on IFS by default, but zsh does not (no `setopt SH_WORD_SPLIT` here), so a
283
267
  # prior `PLAN_IDS="$PLAN_IDS $id"` + `for x in $PLAN_IDS` round-trip silently
284
268
  # collapsed every id onto one iteration under zsh whenever there were 2+ plans
285
- # (gsd-core#4099). Direct glob iteration (`for f in "${PHASE_DIR}"/*-PLAN.md`,
286
- # same pattern as the copy loop above) is identical under both shells, so this
287
- # never needs word-splitting at all.
269
+ # (gsd-core#4099). Direct glob iteration (`for f in "${PHASE_DIR}"/*-PLAN.md`)
270
+ # is identical under both shells, so this never needs word-splitting at all.
288
271
  PLAN_COUNT=0
289
272
  PLAN_ID_BULLETS=""
290
273
  for PLAN_FILE in "${PHASE_DIR}"/*-PLAN.md; do
@@ -295,10 +278,8 @@ for PLAN_FILE in "${PHASE_DIR}"/*-PLAN.md; do
295
278
  "
296
279
  done
297
280
 
298
- # Named to avoid BOTH existing RUN_DIR globs: `gsd-review-*.md` (reviewer
299
- # reports, invoke_reviewers) and `gsd-review-plan-*.md` (the plan copies just
300
- # above) — a manifest matching either would be picked up as a report or as a
301
- # plan to review.
281
+ # Dot-prefixed so the `gsd-review-*.md` glob over RUN_DIR (reviewer reports,
282
+ # invoke_reviewers) can never pick it up as a report.
302
283
  {
303
284
  echo ""
304
285
  echo "## Plan Coverage Manifest"
@@ -309,33 +290,11 @@ done
309
290
  printf '%s' "$PLAN_ID_BULLETS"
310
291
  } > "${RUN_DIR}/.plans-manifest.md"
311
292
 
312
- # Optional section files (only if content was included in the combined prompt)
313
- if [ -f ".planning/PROJECT.md" ]; then
314
- cp .planning/PROJECT.md "${RUN_DIR}/gsd-review-project.md"
315
- fi
316
- _CTX=( "${PHASE_DIR}"/*-CONTEXT.md )
317
- if [ ${#_CTX[@]} -gt 0 ]; then
318
- cat "${_CTX[@]}" > "${RUN_DIR}/gsd-review-context.md"
319
- fi
320
- _RESEARCH=( "${PHASE_DIR}"/*-RESEARCH.md )
321
- if [ ${#_RESEARCH[@]} -gt 0 ]; then
322
- cat "${_RESEARCH[@]}" > "${RUN_DIR}/gsd-review-research.md"
323
- fi
324
- if [ -f ".planning/REQUIREMENTS.md" ]; then
325
- cp .planning/REQUIREMENTS.md "${RUN_DIR}/gsd-review-requirements.md"
326
- fi
327
-
328
- # #3301: append the manifest to BOTH files reviewers actually read — the
329
- # per-lane budget-trimmed instructions file (descriptor lanes get
330
- # `--instructions-file`) and the full combined prompt (combined-prompt lanes
331
- # read the whole file). The `instructions` fragment is in prompt-budget's
332
- # `minimumFor` floor set and is never trimmed, so this survives per-lane
333
- # budget trimming intact.
334
- cat "${RUN_DIR}/.plans-manifest.md" >> "${RUN_DIR}/gsd-review-instructions.md"
293
+ # #3301: append the manifest to the one prompt file every lane reads.
335
294
  cat "${RUN_DIR}/.plans-manifest.md" >> "${RUN_DIR}/gsd-review-prompt.md"
336
295
  ```
337
296
 
338
- Note: `INSTRUCTIONS_BLOCK_FILE`, `ROADMAP_SECTION_FILE`, and `PHASE_DIR` come from prompt assembly; `RUN_DIR` is the run-scoped dir from `gather_context` (#2358) re-assigned from `{run_dir}` above. Copy the temp files written during prompt assembly to these section paths (or write each section here if the prompt was built inline).
297
+ Note: `PHASE_DIR` comes from prompt assembly; `RUN_DIR` is the run-scoped dir from `gather_context` (#2358) re-assigned from `{run_dir}` above. Every lane receives `gsd-review-prompt.md` whole — it is never trimmed per lane (#4559). A lane whose prompt exceeds its cap is refused in `invoke_reviewers`, not shortened.
339
298
  </step>
340
299
 
341
300
  <step name="invoke_reviewers">
@@ -408,35 +367,6 @@ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
408
367
  # user's intent; here it would fire exactly the requests the default exists to prevent.
409
368
  PARALLEL_LANES=$(gsd_run query config-get review.parallel_lanes --raw 2>/dev/null || echo "false")
410
369
 
411
- # Shared budget-trim helper. Was defined inside the Ollama leg; it is lane-agnostic, so it is
412
- # hoisted here now that any lane may declare a promptBudgetKey. Returns non-zero when the budget
413
- # is too small for the minimum review set (prompt-budget exit 2 / 11).
414
- prepare_trimmed_prompt_for_reviewer() {
415
- REVIEWER_KEY="$1"; REVIEWER_BUDGET="$2"; OUTPUT_PROMPT="$3"; OUTPUT_META="$4"
416
-
417
- PLAN_FILE_ARGS=""
418
- for p in "$RUN_DIR"/gsd-review-plan-*.md; do
419
- [ -f "$p" ] && PLAN_FILE_ARGS="$PLAN_FILE_ARGS --plan-file $p"
420
- done
421
- PROJECT_ARG=""
422
- [ -f "$RUN_DIR/gsd-review-project.md" ] && PROJECT_ARG="--project-file $RUN_DIR/gsd-review-project.md"
423
- CONTEXT_ARG=""
424
- [ -f "$RUN_DIR/gsd-review-context.md" ] && CONTEXT_ARG="--context-file $RUN_DIR/gsd-review-context.md"
425
- RESEARCH_ARG=""
426
- [ -f "$RUN_DIR/gsd-review-research.md" ] && RESEARCH_ARG="--research-file $RUN_DIR/gsd-review-research.md"
427
- REQUIREMENTS_ARG=""
428
- [ -f "$RUN_DIR/gsd-review-requirements.md" ] && REQUIREMENTS_ARG="--requirements-file $RUN_DIR/gsd-review-requirements.md"
429
-
430
- gsd_run query prompt-budget \
431
- --budget "$REVIEWER_BUDGET" \
432
- --instructions-file "$RUN_DIR/gsd-review-instructions.md" \
433
- --roadmap-file "$RUN_DIR/gsd-review-roadmap.md" \
434
- $PLAN_FILE_ARGS $PROJECT_ARG $CONTEXT_ARG $RESEARCH_ARG $REQUIREMENTS_ARG \
435
- --output-prompt "$OUTPUT_PROMPT" \
436
- --output-metadata "$OUTPUT_META"
437
- return $?
438
- }
439
-
440
370
  gsd_run query review-lane plan \
441
371
  --selected "$SELECTED_REVIEWERS" --run-dir "$RUN_DIR" --repo-root "$REPO_ROOT" --json \
442
372
  > "$RUN_DIR/gsd-review-lanes.json"
@@ -453,36 +383,18 @@ run_review_lane() {
453
383
  # `local` is hygiene, not a live fix: each `&`-dispatched call already forks its own subshell, so
454
384
  # concurrent lanes cannot share these today. Scoped anyway so the isolation is a property of this
455
385
  # function rather than of the dispatch mechanism happening to fork.
456
- local SLUG LANE_BUDGET PROMPT_ARG TRIMMED
386
+ local SLUG
457
387
  SLUG="$1"
458
- # Per-lane prompt budget. The lane declares its own `promptBudgetKey`; `plan` resolved it,
459
- # applying #2797's sentinel rule (-1 = unset → fall back to the global budget; 0 legitimately
460
- # means "do not trim this lane"). Trimming itself stays in prompt-budget, which owns it.
461
- LANE_BUDGET=$(gsd_run query review-lane plan --selected "$SLUG" --run-dir "$RUN_DIR" \
462
- --repo-root "$REPO_ROOT" --json 2>/dev/null \
463
- | sed -n 's/.*"promptBudget": *\([0-9-]*\).*/\1/p' | head -1)
464
- PROMPT_ARG=""
465
- if [ -n "$LANE_BUDGET" ] && [ "$LANE_BUDGET" != "null" ] && [ "$LANE_BUDGET" -gt 0 ] 2>/dev/null; then
466
- TRIMMED="$RUN_DIR/gsd-review-prompt-$SLUG.md"
467
- if prepare_trimmed_prompt_for_reviewer "$SLUG" "$LANE_BUDGET" "$TRIMMED" \
468
- "$RUN_DIR/gsd-review-prompt-$SLUG.metadata.json"; then
469
- PROMPT_ARG="--prompt-file $TRIMMED"
470
- else
471
- # A budget too small for the minimum review set drops the lane just as silently as an empty
472
- # response used to (#2605), so leave the skip visible in the review output, not only on stderr.
473
- echo "$SLUG review skipped: prompt budget (${LANE_BUDGET} tokens) too small for the minimum review set." \
474
- > "$RUN_DIR/gsd-review-$SLUG.md"
475
- # Was `continue` when this was a loop body. Inside a function that keyword is not the loop
476
- # control it looks like — `return 0` is what skips this lane and leaves it with no result line.
477
- return 0
478
- fi
479
- fi
388
+ # #4559: no per-lane trimming. Every lane receives the whole prompt; `review-lane invoke` measures it
389
+ # against the lane's cap (its own `promptBudgetKey`, the global `review.max_prompt_tokens`, or the
390
+ # default derived from its declared context window) and REFUSES a lane that is over, writing a
391
+ # `review did not run: prompt exceeds` stub. A trimmed prompt reviewed something other than the plans.
480
392
 
481
393
  # One invocation, whatever the lane's transport, prompt channel, output channel or handler.
482
394
  # `--explicit` marks a lane the user NAMED: ADR-2782 D4 — not finding a lane nobody asked for is
483
395
  # normal, failing to run one somebody asked for is an error.
484
396
  gsd_run query review-lane invoke --slug "$SLUG" \
485
- --run-dir "$RUN_DIR" --repo-root "$REPO_ROOT" $PROMPT_ARG $EXPLICIT_FLAG --json \
397
+ --run-dir "$RUN_DIR" --repo-root "$REPO_ROOT" $EXPLICIT_FLAG --json \
486
398
  > "$RUN_DIR/gsd-review-lane-result-$SLUG.json"
487
399
  }
488
400
 
@@ -527,13 +439,17 @@ wait
527
439
 
528
440
  # Aggregate in SELECTED_REVIEWERS order, NOT completion order, so the JSONL a concurrent run
529
441
  # produces is byte-identical to the one a sequential run produces. This is post-join and therefore
530
- # single-threaded, so `>>` here is safe. A lane that was budget-skipped, or that never started,
531
- # leaves no result file and correctly contributes no line.
442
+ # single-threaded, so `>>` here is safe. A lane that never started leaves no result file, and a lane
443
+ # REFUSED for its prompt cap (#4559: reason `prompt_exceeds_context`) is deliberately left out of the
444
+ # aggregate — it did not run, so it must not count as a lane result or feed models:/model_sources:.
445
+ # Its own result file stays in RUN_DIR for the `refused_reviewers` frontmatter.
532
446
  # Rewrapped through unquoted command substitution (gsd-core#4109) — see the
533
447
  # dispatch loop above for why a bare `$DISPATCH_SLUGS` collapses under zsh.
534
448
  for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
535
449
  LANE_RESULT="$RUN_DIR/gsd-review-lane-result-$SLUG.json"
536
- if [ -f "$LANE_RESULT" ]; then
450
+ # The refusal is the PARSED `reason` field — a failed lane whose `detail` merely mentions the
451
+ # string is a real lane result and must stay in the aggregate.
452
+ if [ -f "$LANE_RESULT" ] && ! node -e 'try{process.exit(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).reason==="prompt_exceeds_context"?0:1)}catch{process.exit(1)}' "$LANE_RESULT"; then
537
453
  cat "$LANE_RESULT" >> "$RUN_DIR/gsd-review-lane-results.jsonl"
538
454
  fi
539
455
  done
@@ -546,7 +462,9 @@ review: it keeps its "failed or returned empty output" header (#2494/#2605/#2794
546
462
 
547
463
  A lane that will not run reports a typed reason rather than an empty file — `missing_binary`,
548
464
  `probe_failed`, `probe_timeout`, `missing_required_binary`, `host_unreachable`,
549
- `egress_host_changed`, `unknown_handler`, `budget_too_small`. **`egress_host_changed` means the lane
465
+ `egress_host_changed`, `unknown_handler`, `prompt_exceeds_context` (the prompt is over
466
+ the lane's cap — refused, never trimmed; its review file carries a `review did not run: prompt exceeds` stub).
467
+ **`egress_host_changed` means the lane
550
468
  was consented to send plans to one destination and `.planning/config.json` now names another; it is
551
469
  blocked, not silently redirected** (ADR-2782 D5).
552
470
 
@@ -563,7 +481,7 @@ Display progress:
563
481
  **#3352 (ADR-3473 §8.5): no artifact from failed inputs.** Before rendering anything, gate on
564
482
  whether any lane actually produced a result — "every lane failed" is exactly "the aggregate
565
483
  JSONL has zero lines" (§`invoke_reviewers`'s aggregation loop already builds this file as a
566
- byproduct; a lane that never started or was budget-skipped contributes no line either way).
484
+ byproduct; a lane that never started or was refused for its prompt cap contributes no line either way).
567
485
 
568
486
  ```bash
569
487
  RUN_DIR="{run_dir}"
@@ -575,7 +493,7 @@ TOTAL_LANE_FAILURE="false"
575
493
  ALL_LANES_SKIPPED="false"
576
494
  if [ "${LANE_LINES:-0}" -eq 0 ]; then
577
495
  # Zero lines means every dispatched lane left no result JSON — either every one
578
- # was budget-skipped (N5: a skip is not a failure) or every one actually failed
496
+ # was refused for its prompt cap (N5: a refusal is not a failure) or every one actually failed
579
497
  # to run. Re-derive the dispatched-slug set the same way invoke_reviewers did
580
498
  # (SELECTED_REVIEWERS is a shell block boundary — recompute, do not assume the
581
499
  # earlier step's local DISPATCH_SLUGS variable survived into this block).
@@ -586,10 +504,10 @@ if [ "${LANE_LINES:-0}" -eq 0 ]; then
586
504
  esac
587
505
  DISPATCH_SLUGS="$DISPATCH_SLUGS $SLUG"
588
506
  done
589
- # Distinguish by whether every dispatched slug's stub markdown says "skipped":
590
- # a skip stub always does (see run_review_lane's budget branch, which writes
591
- # this exact text before returning without ever invoking the lane); a real
592
- # failure stub does not. If a slug has no stub at all, it is not a skip.
507
+ # Distinguish by whether every dispatched slug's stub markdown carries the refusal marker:
508
+ # `review-lane invoke` writes `review did not run: prompt exceeds ...` (#4559) before returning
509
+ # without ever probing or spawning the lane; a real failure stub does not. If a slug has no
510
+ # stub at all, it is not a refusal.
593
511
  DISPATCHED_COUNT=0
594
512
  SKIPPED_COUNT=0
595
513
  # Rewrapped through unquoted command substitution (gsd-core#4109): a bare
@@ -598,7 +516,7 @@ if [ "${LANE_LINES:-0}" -eq 0 ]; then
598
516
  for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
599
517
  DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1))
600
518
  STUB="$RUN_DIR/gsd-review-$SLUG.md"
601
- if [ -f "$STUB" ] && grep -q "review skipped: prompt budget" "$STUB" 2>/dev/null; then
519
+ if [ -f "$STUB" ] && grep -q "review did not run: prompt exceeds" "$STUB" 2>/dev/null; then
602
520
  SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
603
521
  fi
604
522
  done
@@ -611,15 +529,16 @@ fi
611
529
  ```
612
530
 
613
531
  - **If `ALL_LANES_SKIPPED=true`:** do NOT write `REVIEWS.md` and do NOT run the commit below —
614
- there is nothing to review. Report to the user that every selected lane was budget-skipped
615
- (not a failure) and stop; do not proceed to `present_results`' summary claiming a review ran.
532
+ there is nothing to review. Report to the user that every selected lane refused its prompt cap
533
+ (not a failure; name the cap and the `review.context_window_tokens.<slug>` /
534
+ `review.max_prompt_tokens_per_reviewer.<slug>` keys that raise it) and stop; do not proceed to `present_results`' summary claiming a review ran.
616
535
  - **If `TOTAL_LANE_FAILURE=true`:** do NOT write `REVIEWS.md` and do NOT run the commit below.
617
536
  Report the total lane failure to the user (name the lanes that were dispatched and point at
618
537
  their `.err`/stub files preserved under `.review-diagnostics/` by `present_results`) and stop.
619
538
  - **Otherwise** (at least one lane produced a result — R1, unchanged): proceed exactly as below.
620
539
 
621
540
  **#3301: plan coverage check.** For each dispatched lane that produced a *real* review (not a
622
- stub, not budget-skipped, not empty), check whether its output mentions every plan id from
541
+ stub, not refused for its prompt cap, not empty), check whether its output mentions every plan id from
623
542
  `.plans-manifest.md` — the same manifest `build_prompt` gave the reviewer, so the expected-id list
624
543
  here can never diverge from what the reviewer was actually told. This is diagnostic only: it never
625
544
  blocks the workflow, never fails a lane, and never changes the `TOTAL_LANE_FAILURE`/
@@ -673,7 +592,7 @@ for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do
673
592
  REVIEW_FILE="$RUN_DIR/gsd-review-$SLUG.md"
674
593
  [ -f "$REVIEW_FILE" ] || continue
675
594
  [ -s "$REVIEW_FILE" ] || continue
676
- grep -q "review skipped: prompt budget" "$REVIEW_FILE" 2>/dev/null && continue
595
+ grep -q "review did not run: prompt exceeds" "$REVIEW_FILE" 2>/dev/null && continue
677
596
  grep -q "failed or returned empty output" "$REVIEW_FILE" 2>/dev/null && continue
678
597
 
679
598
  node -e '
@@ -696,7 +615,7 @@ done
696
615
 
697
616
  Each `${RUN_DIR}/.plan-coverage-<slug>.json` carries `{complete, missing_ids, total}` for one
698
617
  graded lane. Collect these into a `plan_coverage` frontmatter block — **only** when at least one
699
- graded lane has `complete: false` (mirrors the existing `trimmed_reviewers` precedent: present
618
+ graded lane has `complete: false` (mirrors the `refused_reviewers` precedent: present
700
619
  only when there is something to report):
701
620
 
702
621
  ```yaml
@@ -714,7 +633,7 @@ heading and before the end of the first exact `<!-- gsd:plan-revision-conflicts:
714
633
  as `{preserved_plan_revision_conflict_entries}`. Ignore identical headings or delimiters in reviewer
715
634
  output: reviewers do not own blocking state. Restore the captured bytes at the explicit slot below.
716
635
 
717
- After all reviewers complete, collect trim metadata files written during the run. For each reviewer that was trimmed (i.e. a `.metadata.json` file exists and `hardFailed` or `omitted` is non-empty, or `projectMdShrunk` is true, or `planTruncationPct > 0`), include a `trimmed_reviewers` block in the frontmatter. Omit the key entirely if no reviewer was trimmed.
636
+ After all reviewers complete, collect the lanes that were refused for their prompt cap (#4559): each dispatched lane whose `${RUN_DIR}/gsd-review-lane-result-<slug>.json` has `"reason": "prompt_exceeds_context"`. For each, include a `refused_reviewers` entry in the frontmatter with `estimated_tokens`, `cap` and `cap_source` copied from that file's `refusal` object (`estimatedTokens`, `cap`, `capSource`). Omit the key entirely if no lane was refused. A refused lane's section in the body renders its `review did not run: prompt exceeds` stub, never an empty section.
718
637
 
719
638
  **Reviewer instances (#1517, optional):** when instances ran, frontmatter records their
720
639
  names, each gets its own `## <Adapter> Review (<instance>)` section, and ≥2 same-cli
@@ -746,16 +665,11 @@ models: # resolved model per reviewer; `unknown` when not reco
746
665
  model_sources: # how each value above was determined
747
666
  codex: "banner"
748
667
  antigravity: "unknown"
749
- trimmed_reviewers: # only present if at least one reviewer was trimmed
668
+ refused_reviewers: # only present if at least one lane was refused for its prompt cap (#4559)
750
669
  ollama:
751
- budget: 6000
752
- effective_budget: 5400
753
- estimated_tokens: 5380
754
- omitted: [context, research]
755
- project_md_shrunk: true
756
- plan_truncation_pct: 22
757
- hard_failed: false
758
- note_injected: true
670
+ estimated_tokens: 61000
671
+ cap: 6000
672
+ cap_source: lane # lane | global | context-window
759
673
  plan_coverage: # only present if at least one graded lane is incomplete (#3301)
760
674
  ollama:
761
675
  total: 7
@@ -819,7 +733,7 @@ present as complete. Report instead:
819
733
  ```
820
734
  ### GSD ► REVIEW FAILED
821
735
 
822
- Phase {N}: every selected reviewer lane {failed to produce a result|was budget-skipped} — no
736
+ Phase {N}: every selected reviewer lane {failed to produce a result|refused its prompt cap} — no
823
737
  REVIEWS.md was written.
824
738
 
825
739
  {If the preserve+cleanup block below reports success: "Diagnostics preserved:
@@ -852,11 +766,9 @@ step names only `{padded_phase}-REVIEWS.md` explicitly, never a directory glob,
852
766
  `.review-diagnostics/` is never swept into it.
853
767
 
854
768
  **#4097: preserve lane OUTPUT, never the run's own input copies.** `RUN_DIR` holds not only
855
- lane outputs — prompt assembly (the `gather_context`/section-copy step above) also writes the
856
- run's assembled INPUTS there under the same `gsd-review-` prefix: the combined prompt, the
857
- instructions/roadmap sections, a copy of every plan under review, the project/context/research/
858
- requirements sections, and the per-lane trimmed prompts. Those are byte-identical duplicates of
859
- files already committed under `.planning/`; sweeping them into `.review-diagnostics/` buries
769
+ lane outputs — prompt assembly also writes the run's assembled INPUT there under the same
770
+ `gsd-review-` prefix: the combined prompt (`gsd-review-prompt.md`). It duplicates files already
771
+ committed under `.planning/`; sweeping them into `.review-diagnostics/` buries
860
772
  the actual evidence under plan duplicates and grows the phase directory on every run. The
861
773
  exclusion list below is CLOSED and owned here: this workflow itself writes every input
862
774
  basename at prompt-assembly time, so a future input file CANNOT silently join the evidence
@@ -882,8 +794,10 @@ DIAG_DIR="{phase_dir}/.review-diagnostics"
882
794
  # the closed input set this workflow itself writes — direct glob iteration with a `case`
883
795
  # filter, no string accumulator, identical under bash and zsh (#4099/#4109), and the
884
796
  # `nullglob` set at the top of this fence keeps an empty RUN_DIR an empty array (#2962).
885
- # `gsd-review-prompt*` deliberately covers BOTH the combined prompt (`gsd-review-prompt.md`)
886
- # and the per-lane trimmed prompts (`gsd-review-prompt-<slug>.md`).
797
+ # `gsd-review-prompt*` is the one input file this workflow now writes (#4559 removed the
798
+ # per-lane trimmed prompts and the section copies). The section-copy names stay excluded:
799
+ # a run dir reused from an earlier GSD version can still hold them, and they are inputs,
800
+ # not lane evidence.
887
801
  _DIAG_MD=()
888
802
  for f in "$RUN_DIR"/gsd-review-*.md; do
889
803
  case "$(basename "$f")" in
@@ -908,9 +822,8 @@ if [ ${#_DIAG_MD[@]} -gt 0 ] || [ ${#_DIAG_ERR[@]} -gt 0 ]; then
908
822
  # unique per run BY CONSTRUCTION — uniqueness never depends on the clock. The
909
823
  # UTC stamp is only a sort key in front of it, and is omitted entirely if `date`
910
824
  # fails. Lane basenames are unchanged INSIDE the subdirectory, so evidence still
911
- # correlates back to its lane, and nothing inside $RUN_DIR is renamed (both
912
- # `prepare_trimmed_prompt_for_reviewer` and the lane invocation resolver depend
913
- # on those exact basenames).
825
+ # correlates back to its lane, and nothing inside $RUN_DIR is renamed (the
826
+ # lane invocation resolver depends on those exact basenames).
914
827
  _DIAG_STAMP="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
915
828
  _DIAG_RUN_DIR="$DIAG_DIR/${_DIAG_STAMP:+${_DIAG_STAMP}-}$(basename "$RUN_DIR")"
916
829
  # `mkdir -p` still creates $DIAG_DIR itself, so a plain FILE sitting at
@@ -181,7 +181,7 @@ gsd_run query commit "docs(phase-${PHASE}): add/update security threat verificat
181
181
  GSD > PHASE {N} THREAT-SECURE
182
182
  threats_open: 0 — no blocking threats remain (threats_open: 0).
183
183
  ▶ /gsd:validate-phase {N} validate test coverage
184
- ▶ /gsd:verify-work {N} run UAT
184
+ ▶ /gsd:verify-work {N} run UAT; once UAT and verification pass it marks the phase complete (ROADMAP + STATE); if it refuses on stale verification, re-run /gsd:execute-phase {N} then verify again
185
185
  ```
186
186
 
187
187
  Display `/clear` reminder.
@@ -1,8 +1,7 @@
1
1
  Apply response_language to all user-facing prose — narration between tool calls, status updates, progress notes, and findings included; preserve code, paths, and identifiers.
2
2
 
3
3
  <purpose>
4
- Interactive configuration of GSD power-user knobs — plan bounce, planner stall detection, node
5
- repair, subagent timeouts, inline plan threshold, cross-AI execution, base branch, branch templates, response language,
4
+ Interactive configuration of GSD power-user knobs — plan bounce, planner stall detection and timing, node repair, subagent timeouts, inline plan threshold, cross-AI execution, base branch, branch templates, response language,
6
5
  context window, gitignored search, graphify build timeout, runtime model tier overrides, and
7
6
  model policy configuration (provider + budget → canonical tier mapping, or manual model ID
8
7
  assignment per cost tier).
@@ -50,6 +49,8 @@ shown in parentheses:
50
49
 
51
50
  Planning Tuning:
52
51
  - `planner.stall_detection_enabled` (default: `true`)
52
+ - `planner.stall_threshold_minutes` (default: `10`)
53
+ - `planner.stall_detect_interval_minutes` (default: `5`)
53
54
  - `workflow.plan_bounce` (default: `false`)
54
55
  - `workflow.plan_bounce_passes` (default: `2`)
55
56
  - `workflow.plan_bounce_script` (default: `null`)
@@ -106,10 +107,10 @@ in `$ARGUMENTS` OR `text_mode` is true in config. When `TEXT_MODE=true`, replace
106
107
  choice number or free-text value.
107
108
 
108
109
  **Numeric-input validation.** For any numeric field (`*_passes`, `*_budget`, `*_timeout`,
109
- `*_threshold`, `context_window`, `graphify.build_timeout`), if the user types a value that
110
+ `*_threshold`, `*_minutes`, `context_window`, `graphify.build_timeout`), if the user types a value that
110
111
  is not a non-negative integer, the workflow MUST reject it, state which value was invalid,
111
112
  and re-prompt that single field. The minimum accepted value is field-specific and is stated
112
- in each field's prompt below — `workflow.plan_bounce_passes` and `workflow.max_discuss_passes`
113
+ in each field's prompt below — the `*_passes` fields and the `planner.stall_*_minutes` fields
113
114
  require `>= 1`; all other numeric fields accept `>= 0`. An empty input means "keep current"
114
115
  — the existing value is retained. Non-numeric input is never silently coerced.
115
116
 
@@ -127,7 +128,9 @@ stored verbatim as a string.
127
128
 
128
129
  ### Section 1 — Planning Tuning
129
130
 
130
- Before offering the disabled choice, display this warning verbatim:
131
+ Asked as four calls, 1a–1d (`AskUserQuestion` takes at most 4 questions per call).
132
+
133
+ **1a — Planner watchdog.** Before offering the disabled choice, display this warning verbatim:
131
134
 
132
135
  `Warning: disabling planner stall detection gives up bounded automatic recovery if the runtime loses an agent completion handoff. Planning still waits through the runtime-native completion mechanism, but you may need to interrupt and use the filesystem fallback.`
133
136
 
@@ -141,7 +144,40 @@ AskUserQuestion([
141
144
  { label: "Yes (default: true)", description: "Poll for completion markers and plan-file activity, with bounded recovery." },
142
145
  { label: "No (false)", description: "Await runtime-native completion without polling. Gives up bounded automatic recovery if completion handoff is lost." }
143
146
  ]
147
+ }
148
+ ])
149
+ ```
150
+
151
+ **1b — Stall timing.** Ask only when `planner.stall_detection_enabled` is effectively `true` after 1a (anything but boolean `false`); otherwise skip 1b.
152
+ `planner.stall_threshold_minutes`: idle time before plan-phase offers recovery; raise it for slow models. `planner.stall_detect_interval_minutes`: check cadence. Both integer `>= 1`.
153
+
154
+ ```text
155
+ AskUserQuestion([
156
+ {
157
+ question: "Planner stall threshold (minutes)? (current: <value or 10>)",
158
+ header: "Stall Threshold",
159
+ multiSelect: false,
160
+ options: [
161
+ { label: "Keep current", description: "Leave the threshold unchanged." },
162
+ { label: "Enter minutes", description: "Integer >= 1. Default: 10." }
163
+ ]
144
164
  },
165
+ {
166
+ question: "Planner stall check interval (minutes)? (current: <value or 5>)",
167
+ header: "Stall Interval",
168
+ multiSelect: false,
169
+ options: [
170
+ { label: "Keep current", description: "Leave the interval unchanged." },
171
+ { label: "Enter minutes", description: "Integer >= 1. Default: 5." }
172
+ ]
173
+ }
174
+ ])
175
+ ```
176
+
177
+ **1c — Plan bounce.**
178
+
179
+ ```text
180
+ AskUserQuestion([
145
181
  {
146
182
  question: "Run external plan-bounce validator against generated PLAN.md? (current: <value or false>)",
147
183
  header: "Plan Bounce",
@@ -169,7 +205,14 @@ AskUserQuestion([
169
205
  { label: "Clear (null)", description: "Unset the script path." },
170
206
  { label: "Enter path", description: "Type an absolute or repo-relative path. Receives PLAN.md path as first argument." }
171
207
  ]
172
- },
208
+ }
209
+ ])
210
+ ```
211
+
212
+ **1d — Timeouts.**
213
+
214
+ ```text
215
+ AskUserQuestion([
173
216
  {
174
217
  question: "Subagent timeout (milliseconds)? (current: <value or 300000>)",
175
218
  header: "Subagent Timeout",
@@ -180,12 +223,12 @@ AskUserQuestion([
180
223
  ]
181
224
  },
182
225
  {
183
- question: "Inline plan threshold — tasks allowed inline before splitting to PLAN.md? (current: <value or 3>)",
226
+ question: "Inline plan threshold — plans with this many tasks or fewer execute inline instead of spawning a subagent? (current: <value or 2>)",
184
227
  header: "Inline Plan Threshold",
185
228
  multiSelect: false,
186
229
  options: [
187
230
  { label: "Keep current", description: "Leave threshold unchanged." },
188
- { label: "Enter number", description: "Integer count. Non-numeric rejected. Default: 3" }
231
+ { label: "Enter number", description: "Integer >= 0. Non-numeric rejected. Default: 2. Set 0 to always spawn a subagent." }
189
232
  ]
190
233
  }
191
234
  ])
@@ -535,7 +578,9 @@ anything not listed in Sections 1–8 MUST survive the update):
535
578
  ...existing_config,
536
579
  "planner": {
537
580
  ...existing_planner,
538
- "stall_detection_enabled": <new|existing>
581
+ "stall_detection_enabled": <new|existing>,
582
+ "stall_threshold_minutes": <new|existing>,
583
+ "stall_detect_interval_minutes": <new|existing>
539
584
  },
540
585
  "workflow": {
541
586
  ...existing_workflow,
@@ -772,6 +817,8 @@ Display:
772
817
  | Setting | Value |
773
818
  |--------------------------------------------|-------|
774
819
  | planner.stall_detection_enabled | {true/false} |
820
+ | planner.stall_threshold_minutes | {minutes} |
821
+ | planner.stall_detect_interval_minutes | {minutes} |
775
822
  | workflow.plan_bounce | {on/off} |
776
823
  | workflow.plan_bounce_passes | {n} |
777
824
  | workflow.plan_bounce_script | {path/null} |
@@ -826,6 +873,7 @@ UI/AI phase gates), use /gsd:settings.
826
873
  - [ ] Eight sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime/Output, Runtime Model Tiers, Model Policy)
827
874
  - [ ] Every field pre-selected to its current value (or documented default if absent)
828
875
  - [ ] Disabling planner stall detection shows the bounded-recovery warning before writing `false`
876
+ - [ ] Stall threshold/interval asked only while stall detection is on; no call exceeds 4 questions
829
877
  - [ ] Numeric inputs validated — non-numeric rejected and re-prompted
830
878
  - [ ] Branch-template inputs validated — non-default must contain a placeholder
831
879
  - [ ] Null-allowed fields accept an empty input as a clear
@@ -73,6 +73,18 @@ Verify the work is ready to ship:
73
73
  ```
74
74
  Present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the command to run next. These two are message text only — the block/allow decision has already been made from `$STATUS`, so a concurrent write between the reads cannot change the gate's verdict. The query already covers every status in its closed set, so no per-status arm is needed.
75
75
 
76
+ 1a. **Phase marked complete?** (#5221) Verified is not complete: `/gsd:verify-work` completes the phase.
77
+ ```bash
78
+ ANALYZE=$(gsd_run query roadmap.analyze ${GSD_WS:+--ws=${GSD_WS##* }}) || { echo "roadmap.analyze failed — see the error above" >&2; exit 1; }
79
+ if [[ "$ANALYZE" == @file:* ]]; then ANALYZE=$(cat "${ANALYZE#@file:}"); fi
80
+ # roadmap_record: phase -> every ROADMAP record of it reads complete. Fail closed: only true ships.
81
+ VERDICT=$(jq -er --arg p "$PHASE_NUMBER" 'def n:split(".")|map(sub("^0+(?=[0-9])";""))|join(".");if .error=="ROADMAP.md not found" then "skip" elif has("roadmap_record")|not then error("no roadmap_record") else .roadmap_record as $r|($p|n) as $k|if $r==null then "unreadable" elif ($r|type)!="object" then error("bad roadmap_record") elif ($r|has($k)|not) then "unrecorded" elif $r[$k] then "complete" else "incomplete" end end' <<<"$ANALYZE") || { echo "roadmap.analyze output unparseable" >&2; exit 1; }
82
+ [ "$VERDICT" != unreadable ] || { echo "ROADMAP_TABLE_UNREADABLE: the ROADMAP.md progress table does not parse (e.g. a row with the wrong cell count); fix it, then re-run /gsd:ship" >&2; exit 1; }
83
+ [ "$VERDICT" != unrecorded ] || { echo "PHASE_NOT_RECORDED: ROADMAP.md has no checklist entry or progress-table row for phase ${PHASE_NUMBER}; add one, run /gsd:verify-work ${PHASE_NUMBER}, then re-run /gsd:ship" >&2; exit 1; }
84
+ [ "$VERDICT" != incomplete ] || { echo "PHASE_NOT_COMPLETE: Run /gsd:verify-work ${PHASE_NUMBER} (completes the phase), then re-run /gsd:ship" >&2; [[ $PHASE_NUMBER =~ ^[A-Za-z]?[0-9] ]] || echo "Custom-id phase: the phase tools cannot find its directory yet, so after verifying, tick its ROADMAP.md checklist entry by hand" >&2; exit 1; }
85
+ ```
86
+ Only a phase whose every ROADMAP record (checklist entry, progress-table Status) reads complete ships; a phase with no record blocks. A missing ROADMAP.md skips the check; an analyze failure stops. This reads the record to catch a missed write-back — it is not a completion verdict (ADR-3180 §7.4, Amendment 10; step 1 already holds the disk verdict). `roadmap_record` keys use `foldPhaseId`; `def n` above must fold the same way.
87
+
76
88
  2. **Clean working tree?**
77
89
  ```bash
78
90
  git status --short
@@ -422,7 +422,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
422
422
  return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
423
423
  }
424
424
 
425
- const sentinel = readSentinel(cwd, { clock });
425
+ const sentinel = readSentinel(cwd, { clock, dispatchIds });
426
426
  // Hoisted so the "sentinel was present/fresh but did not apply" case below
427
427
  // (#4594 row 15 — Postel's-Law finding) can distinguish itself from
428
428
  // "absent"/"stale" without re-deriving applicability.
@@ -512,7 +512,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
512
512
  // Hoisted (readSentinel never throws) so the "present, fresh, but did not
513
513
  // apply" case (#4594 row 15) can be reported on every deny path below
514
514
  // instead of silently discarded.
515
- const sentinel = readSentinel(root, { clock });
515
+ const sentinel = readSentinel(root, { clock, dispatchIds });
516
516
  const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
517
517
  const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
518
518
 
@@ -81,6 +81,12 @@ case "$COMMAND" in
81
81
  *) exit 0 ;;
82
82
  esac
83
83
 
84
+ # #5548: inherited repo-location vars (GIT_DIR, GIT_INDEX_FILE, ...) would override
85
+ # cwd for every git call below. Drop git's own list minus the config-injection vars.
86
+ for v in $(git rev-parse --local-env-vars 2>/dev/null); do
87
+ case "$v" in GIT_CONFIG|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT) ;; *) unset "$v" ;; esac
88
+ done
89
+
84
90
  # Gate 3 — inside git repo
85
91
  git rev-parse --git-dir >/dev/null 2>&1 || exit 0
86
92
 
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  // monkeypatching childProcess.execFileSync.
19
19
  const childProcess = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
21
+ const { gitEnv } = require('./lib/git-probe.js');
21
22
 
22
23
  // This hook's build-seam outer catch (require.main guard just below) has
23
24
  // always exited 0 (fail open — the statusline renders on EVERY prompt, so a
@@ -934,7 +935,7 @@ function readGitStatus(dir) {
934
935
  // or untracked files; overflow still degrades safely to segment-absent via
935
936
  // the catch below.
936
937
  return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
937
- { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv() });
938
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv(gitEnv()) });
938
939
  } catch (e) {
939
940
  return null;
940
941
  }
@@ -1029,7 +1030,7 @@ function readStateHeadCommits(root, stamp) {
1029
1030
  try {
1030
1031
  return childProcess.execFileSync('git', // allow-index-refresh: rev-list walks the commit graph and never opens the index
1031
1032
  ['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
1032
- { encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1033
+ { env: gitEnv(), encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1033
1034
  stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
1034
1035
  } catch (e) {
1035
1036
  return null;