@opengsd/gsd-core 1.18.0 → 1.19.0

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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +2 -2
  5. package/agents/gsd-code-fixer.md +4 -3
  6. package/agents/gsd-verifier.md +1 -1
  7. package/bin/install.js +422 -103
  8. package/gsd-core/bin/gsd-tools.cjs +106 -7
  9. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  10. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  11. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  12. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  13. package/gsd-core/bin/lib/capability-registry.cjs +156 -74
  14. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  15. package/gsd-core/bin/lib/capability-validator.cjs +17 -0
  16. package/gsd-core/bin/lib/config-loader.cjs +10 -0
  17. package/gsd-core/bin/lib/config.cjs +256 -58
  18. package/gsd-core/bin/lib/core-utils.cjs +34 -0
  19. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  20. package/gsd-core/bin/lib/gate-context-drift.cjs +8 -3
  21. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  22. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  23. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  24. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  25. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  26. package/gsd-core/bin/lib/git-info-exclude.cjs +57 -0
  27. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +18 -3
  28. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  29. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  30. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  31. package/gsd-core/bin/lib/init.cjs +65 -1
  32. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  33. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  34. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  35. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  36. package/gsd-core/bin/lib/markdown-sectionizer.cjs +113 -0
  37. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  38. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  39. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  40. package/gsd-core/bin/lib/milestone.cjs +43 -1
  41. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  42. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  43. package/gsd-core/bin/lib/phase-id.cjs +21 -4
  44. package/gsd-core/bin/lib/phase-lifecycle.cjs +37 -4
  45. package/gsd-core/bin/lib/phase.cjs +115 -26
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +3 -1
  47. package/gsd-core/bin/lib/planning-workspace.cjs +18 -0
  48. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  49. package/gsd-core/bin/lib/read-only-git-env.cjs +58 -0
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +35 -2
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +12 -0
  53. package/gsd-core/bin/lib/roadmap-upgrade.cjs +39 -2
  54. package/gsd-core/bin/lib/roadmap.cjs +33 -25
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  58. package/gsd-core/bin/lib/shell-command-projection.cjs +121 -16
  59. package/gsd-core/bin/lib/smart-entry.cjs +13 -0
  60. package/gsd-core/bin/lib/state-contract.cjs +29 -9
  61. package/gsd-core/bin/lib/state-document.cjs +12 -4
  62. package/gsd-core/bin/lib/state-transition.cjs +119 -24
  63. package/gsd-core/bin/lib/state.cjs +50 -28
  64. package/gsd-core/bin/lib/surface.cjs +9 -0
  65. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  66. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  67. package/gsd-core/bin/lib/verification.cjs +96 -9
  68. package/gsd-core/bin/lib/verify.cjs +2 -0
  69. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  72. package/gsd-core/references/git-integration.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  74. package/gsd-core/references/planning-config.md +2 -0
  75. package/gsd-core/references/tdd.md +2 -2
  76. package/gsd-core/templates/DEBUG.md +16 -0
  77. package/gsd-core/templates/config.json +2 -1
  78. package/gsd-core/templates/verification-report.md +1 -1
  79. package/gsd-core/workflows/audit-fix.md +2 -1
  80. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  81. package/gsd-core/workflows/code-review.md +61 -7
  82. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  83. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  84. package/gsd-core/workflows/complete-milestone.md +37 -150
  85. package/gsd-core/workflows/diagnose-issues.md +29 -5
  86. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  87. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  88. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  89. package/gsd-core/workflows/discuss-phase.md +6 -10
  90. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  91. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  92. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  93. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  94. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  95. package/gsd-core/workflows/execute-phase.md +9 -5
  96. package/gsd-core/workflows/health.md +2 -1
  97. package/gsd-core/workflows/import.md +1 -1
  98. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  99. package/gsd-core/workflows/plan-phase.md +13 -13
  100. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  101. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  102. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  103. package/gsd-core/workflows/quick.md +1 -1
  104. package/gsd-core/workflows/review.md +16 -13
  105. package/gsd-core/workflows/section-manifest.json +5 -0
  106. package/gsd-core/workflows/settings.md +1 -0
  107. package/gsd-core/workflows/ship.md +3 -4
  108. package/gsd-core/workflows/spec-phase.md +3 -6
  109. package/gsd-core/workflows/verify-work.md +4 -3
  110. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  111. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  112. package/hooks/dist/gsd-path-export.js +95 -0
  113. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  114. package/hooks/dist/gsd-statusline.js +35 -2
  115. package/hooks/dist/gsd-windsurf-pre-write.js +28 -1
  116. package/hooks/dist/gsd-workflow-guard.js +1 -1
  117. package/hooks/dist/gsd-worktree-path-guard.js +28 -1
  118. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  119. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  120. package/hooks/gsd-agent-isolation-guard.js +11 -1
  121. package/hooks/gsd-cursor-subagent-start.js +6 -2
  122. package/hooks/gsd-path-export.js +95 -0
  123. package/hooks/gsd-secret-read-guard.js +172 -39
  124. package/hooks/gsd-statusline.js +35 -2
  125. package/hooks/gsd-windsurf-pre-write.js +28 -1
  126. package/hooks/gsd-workflow-guard.js +1 -1
  127. package/hooks/gsd-worktree-path-guard.js +28 -1
  128. package/hooks/hooks.json +1 -0
  129. package/hooks/lib/isolation-sentinel.js +44 -0
  130. package/hooks/managed-hooks-registry.cjs +1 -0
  131. package/package.json +13 -6
  132. package/pi/gsd.cjs +1047 -10
  133. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  134. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  135. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  136. package/pi/package-agents/gsd-code-fixer.md +769 -0
  137. package/pi/package-agents/gsd-code-reviewer.md +402 -0
  138. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  139. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  140. package/pi/package-agents/gsd-debugger.md +1274 -0
  141. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  142. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  143. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  144. package/pi/package-agents/gsd-doc-writer.md +626 -0
  145. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  146. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  147. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  148. package/pi/package-agents/gsd-eval-planner.md +149 -0
  149. package/pi/package-agents/gsd-executor.md +894 -0
  150. package/pi/package-agents/gsd-framework-selector.md +159 -0
  151. package/pi/package-agents/gsd-integration-checker.md +476 -0
  152. package/pi/package-agents/gsd-intel-updater.md +338 -0
  153. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  154. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  155. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  156. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  157. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  158. package/pi/package-agents/gsd-planner.md +1003 -0
  159. package/pi/package-agents/gsd-project-researcher.md +611 -0
  160. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  161. package/pi/package-agents/gsd-roadmapper.md +779 -0
  162. package/pi/package-agents/gsd-security-auditor.md +171 -0
  163. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  164. package/pi/package-agents/gsd-ui-checker.md +417 -0
  165. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  166. package/pi/package-agents/gsd-user-profiler.md +174 -0
  167. package/pi/package-agents/gsd-verifier.md +963 -0
  168. package/pi/package-agents/manifest.json +39 -0
  169. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  170. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  171. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  172. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  173. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  174. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  175. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  176. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  177. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  178. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  179. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  180. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  181. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  182. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  183. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  184. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  185. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  186. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  187. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  188. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  189. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  190. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  191. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  192. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  193. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  194. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  195. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  196. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  197. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  198. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  199. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  200. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  201. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  202. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  203. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  204. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  205. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  206. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  207. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  208. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  209. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  210. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  211. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  212. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  213. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  214. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  215. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  216. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  217. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  218. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  219. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  220. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  221. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  222. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  223. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  224. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  225. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  226. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  227. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  228. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  229. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  230. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  231. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  232. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  233. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  234. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  235. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  236. package/pi/package-skills/gsd-update/SKILL.md +51 -0
  237. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  238. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  239. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  240. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  241. package/scripts/build-hooks.js +4 -0
  242. package/scripts/command-contract-helpers.cjs +1 -1
  243. package/scripts/docs-guard-registry.cjs +20 -7
  244. package/scripts/gen-health-docs.cjs +13 -1
  245. package/scripts/gen-pi-package-skills.cjs +217 -0
  246. package/scripts/lib/macos-conformance-tier.generated.cjs +11 -1
  247. package/scripts/lib/platform-conformance-tier.generated.cjs +26 -1
  248. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  249. package/scripts/lint-retired-runtime-name.cjs +5 -0
  250. package/scripts/npm-audit-baseline.cjs +41 -7
  251. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  252. package/scripts/select-docs-guards.cjs +1 -1
  253. package/scripts/self-test-report-policy.cjs +350 -0
  254. package/vscode/package.json +1 -1
  255. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  256. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  257. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -22,6 +22,8 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.locateProgressTable = locateProgressTable;
25
+ exports.normalizeMilestoneToken = normalizeMilestoneToken;
26
+ exports.milestoneVersionOf = milestoneVersionOf;
25
27
  exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
26
28
  exports.clampPercentFromFraction = clampPercentFromFraction;
27
29
  exports.clampPercent = clampPercent;
@@ -68,6 +70,28 @@ function locateProgressTable(roadmapContent) {
68
70
  }
69
71
  return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
70
72
  }
73
+ /**
74
+ * #5165: a milestone token compared by identity, not spelling — `v1.1`, `V1.1`
75
+ * and ` 1.1 ` name one milestone. A hand-decorated Progress cell
76
+ * (`**v1.1**`, `` `v1.1` ``, `v1.1 (current)`) still names its first
77
+ * whitespace-separated token. Absent/blank tokens normalize to `''`.
78
+ */
79
+ function normalizeMilestoneToken(value) {
80
+ // `[v1.1](ROADMAP.md#v11)` names its link text.
81
+ const unlinked = (milestoneVersionOf(value) ?? '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
82
+ const first = unlinked.split(/\s+/)[0] ?? '';
83
+ return first.replace(/^[*_`]+|[*_`:,;]+$/g, '').replace(/^v/i, '').toLowerCase();
84
+ }
85
+ /**
86
+ * #5165: a STATE.md `milestone:` frontmatter value as a trimmed version string,
87
+ * or null when it is absent, blank or not scalar text.
88
+ */
89
+ function milestoneVersionOf(value) {
90
+ if (typeof value !== 'string' && typeof value !== 'number')
91
+ return null;
92
+ const text = String(value).trim();
93
+ return text === '' ? null : text;
94
+ }
71
95
  /**
72
96
  * Derive completed_phases, total_phases, and total_plans from ROADMAP content.
73
97
  * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
@@ -82,7 +106,7 @@ function locateProgressTable(roadmapContent) {
82
106
  * 5-column milestone-grouped variant that inserts a `Milestone` column ahead
83
107
  * of them).
84
108
  */
85
- function deriveProgressFromRoadmap(roadmapContent) {
109
+ function deriveProgressFromRoadmap(roadmapContent, milestoneFilter) {
86
110
  let completedPhases = null;
87
111
  let totalPhases = null;
88
112
  let totalPlans = null;
@@ -95,10 +119,19 @@ function deriveProgressFromRoadmap(roadmapContent) {
95
119
  // `RoadmapProgress` contract (nulls = absent) is unchanged.
96
120
  const table = locateProgressTable(roadmapContent);
97
121
  if (table) {
98
- const allRows = table.rows;
122
+ // #5165: the milestone-grouped Progress table (templates/roadmap.md) keeps
123
+ // every milestone's rows, shipped ones included. When the caller names the
124
+ // milestone and the table carries a `Milestone` column, count only that
125
+ // milestone's rows. A table without the column (or a caller without a
126
+ // milestone) keeps the whole-table behavior; a named milestone that matches
127
+ // no row yields nulls (nothing countable) rather than a foreign count.
128
+ const wantMilestone = normalizeMilestoneToken(milestoneFilter);
129
+ const scopedRows = wantMilestone !== '' && table.columns.includes('Milestone')
130
+ ? table.rows.filter((r) => normalizeMilestoneToken(r['Milestone']) === wantMilestone)
131
+ : table.rows;
99
132
  // Data rows only (exclude sentinel phases 0 and 999.x).
100
133
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
101
- const dataRows = allRows.filter((r) => {
134
+ const dataRows = scopedRows.filter((r) => {
102
135
  const phase = (r['Phase'] ?? '').trim();
103
136
  return /^\d/.test(phase) && !isSentinelPhaseId(phase);
104
137
  });
@@ -110,7 +143,7 @@ function deriveProgressFromRoadmap(roadmapContent) {
110
143
  const completed = dataRows.filter((r) => (0, phase_status_cjs_1.parseRoadmapStatusCell)(r['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE).length;
111
144
  completedPhases = completed > 0 ? completed : null;
112
145
  let totalPlansSum = 0;
113
- for (const r of allRows) {
146
+ for (const r of scopedRows) {
114
147
  const cell = (r['Plans Complete'] ?? '').trim();
115
148
  const m = /(\d+)\s*\/\s*(\d+)/.exec(cell);
116
149
  if (m)
@@ -54,13 +54,13 @@ const planningScopeMod = require("./planning-scope.cjs");
54
54
  const { SCOPE } = planningScopeMod;
55
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
56
56
  const roadmapParserMod = require("./roadmap-parser.cjs");
57
- const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
57
+ const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editOutsideShippedBlocks, findMilestoneScopeHeadingLines } = roadmapParserMod;
58
58
  // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
59
59
  // (pure computation, no I/O — no cycle on this path) for the intent-first
60
60
  // progress counters the phase-complete transaction passes downstream.
61
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
62
62
  const phaseLifecycleMod = require("./phase-lifecycle.cjs");
63
- const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
63
+ const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent, milestoneVersionOf, locateProgressTable, normalizeMilestoneToken } = phaseLifecycleMod;
64
64
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
65
65
  const planningWorkspace = require("./planning-workspace.cjs");
66
66
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -1225,7 +1225,7 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
1225
1225
  timeout: 10_000,
1226
1226
  windowsHide: true,
1227
1227
  maxBuffer: 4 * 1024 * 1024,
1228
- });
1228
+ }); // allow-index-refresh: lists worktrees from .git metadata, never opens the index
1229
1229
  }
1230
1230
  catch {
1231
1231
  return; // not a git repo / git unavailable — unchanged behavior
@@ -1577,6 +1577,21 @@ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
1577
1577
  }
1578
1578
  return decimalSet;
1579
1579
  }
1580
+ /**
1581
+ * #5599: true when `token` (a heading/bullet phase number such as "13.1.2") is
1582
+ * a STRICT descendant of `ancestor` ("13"). Compared segment-wise so "130" is
1583
+ * not inside "13", and digit segments compare numerically so a zero-padded
1584
+ * "05.1" matches "5".
1585
+ */
1586
+ function isPhaseTokenWithin(token, ancestor) {
1587
+ const t = token.split('.');
1588
+ const a = ancestor.split('.');
1589
+ if (t.length <= a.length)
1590
+ return false;
1591
+ // Zero padding is not significant ("05" = "5", "03A" = "3A"); case is not either.
1592
+ const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
1593
+ return a.every((segment, i) => canon(segment) === canon(t[i]));
1594
+ }
1580
1595
  function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
1581
1596
  if (!afterPhase || !description) {
1582
1597
  error('after-phase and description required for phase insert');
@@ -1638,6 +1653,11 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1638
1653
  const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
1639
1654
  _decimalPhase = `${parentBase}.${siblingNextDecimal}`;
1640
1655
  }
1656
+ // #5599: the family whose subtree the new entry must land at the end of —
1657
+ // the parent for a sibling allocation, the target itself for a nested one.
1658
+ const subtreeRoot = allocation === 'sibling' && lastDotIndex !== -1
1659
+ ? normalizedBase.slice(0, lastDotIndex)
1660
+ : normalizedBase;
1641
1661
  const insertConfig = loadConfig(cwd);
1642
1662
  const projectCode = insertConfig.project_code || '';
1643
1663
  const pfx = projectCode ? `${projectCode}-` : '';
@@ -1675,13 +1695,17 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1675
1695
  }
1676
1696
  const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
1677
1697
  const afterBullet = rawContent.slice(bulletLineEnd);
1678
- const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
1679
- let insertIdx;
1680
- if (nextBulletMatch) {
1681
- insertIdx = bulletLineEnd + nextBulletMatch.index;
1682
- }
1683
- else {
1684
- insertIdx = bulletLineEnd;
1698
+ // #5599: land after the last bullet inside the allocation family's
1699
+ // subtree, not before the first following bullet of any number (which is
1700
+ // the target's own later decimal child when one exists).
1701
+ const bulletRe = /\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+(?<tok>\d[^\s:*]*)[^\r\n]*/gi;
1702
+ let insertIdx = bulletLineEnd;
1703
+ for (const m of afterBullet.matchAll(bulletRe)) {
1704
+ if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
1705
+ insertIdx = bulletLineEnd + m.index;
1706
+ break;
1707
+ }
1708
+ insertIdx = bulletLineEnd + m.index + m[0].length;
1685
1709
  }
1686
1710
  updatedContent =
1687
1711
  rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
@@ -1723,13 +1747,19 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
1723
1747
  // preserved exactly as the prior literal had it — a pre-existing,
1724
1748
  // deliberately-unchanged asymmetry with the other sites in this file.
1725
1749
  const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
1726
- const nextPhaseMatch = afterHeader.match(new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}\\d[\\d.]*`, 'i'));
1727
- let insertIdx;
1728
- if (nextPhaseMatch) {
1729
- insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
1730
- }
1731
- else {
1732
- insertIdx = rawContent.length;
1750
+ // #5599: the boundary is the first following heading OUTSIDE the
1751
+ // allocation family's subtree. Taking the first heading of any number
1752
+ // spliced the new section above the target's own later decimal children
1753
+ // (13, 13.2, 13.1, 14); the allocator already guarantees the new number
1754
+ // exceeds every existing descendant, so "after the last descendant" is
1755
+ // the numerically correct position.
1756
+ const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
1757
+ let insertIdx = rawContent.length;
1758
+ for (const m of afterHeader.matchAll(nextPhaseRe)) {
1759
+ if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
1760
+ insertIdx = headerIdx + headerMatch[0].length + m.index;
1761
+ break;
1762
+ }
1733
1763
  }
1734
1764
  updatedContent =
1735
1765
  rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
@@ -3451,6 +3481,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3451
3481
  const runPhaseCompleteTransaction = () => {
3452
3482
  const writes = [];
3453
3483
  let roadmapContent = null;
3484
+ // #5165: the phase was ALREADY complete in the ROADMAP this transaction
3485
+ // started from (read before any mutation below): its checklist box was
3486
+ // checked, or its Progress-table row read Complete.
3487
+ let phaseWasAlreadyComplete = false;
3454
3488
  if (node_fs_1.default.existsSync(roadmapPath)) {
3455
3489
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
3456
3490
  roadmapContent = originalRoadmapContent;
@@ -3488,6 +3522,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3488
3522
  // its own `- [ ] Phase N: …` entries inside a fenced code block, but it
3489
3523
  // is a real (and correct) behavioural difference on pathological input.
3490
3524
  const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
3525
+ // #5165: pre-mutation read of the same checklist line, checked this time.
3526
+ const checkedBoxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[xX](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`);
3527
+ const originalMilestoneScope = extractCurrentMilestone(originalRoadmapContent, cwd);
3528
+ phaseWasAlreadyComplete = originalMilestoneScope
3529
+ .split('\n')
3530
+ .some((line) => checkedBoxPattern.test(line));
3491
3531
  // Progress table row: update Plans Complete/Status/Completed columns BY
3492
3532
  // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
3493
3533
  // markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
@@ -3504,6 +3544,22 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3504
3544
  const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
3505
3545
  const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
3506
3546
  const dateShape = /^\d{4}-\d{2}-\d{2}$/;
3547
+ // #5165: a ROADMAP whose checklist box was never ticked (or has none) but
3548
+ // whose Progress-table row already reads Complete is just as complete. A
3549
+ // milestone-grouped table (a `Milestone` column) is read only for the
3550
+ // milestone ROADMAP binds, so a shipped milestone's same-numbered phase
3551
+ // cannot count.
3552
+ if (!phaseWasAlreadyComplete) {
3553
+ const progressTable = locateProgressTable(originalMilestoneScope);
3554
+ if (progressTable !== null) {
3555
+ const wantMilestone = progressTable.columns.includes('Milestone')
3556
+ ? normalizeMilestoneToken(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
3557
+ : null;
3558
+ phaseWasAlreadyComplete = progressTable.rows.some((row) => rowMatch(row)
3559
+ && (0, phase_status_cjs_1.parseRoadmapStatusCell)(row['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
3560
+ && (wantMilestone === null || (wantMilestone !== '' && normalizeMilestoneToken(row['Milestone']) === wantMilestone)));
3561
+ }
3562
+ }
3507
3563
  /**
3508
3564
  * Within `text` (already scoped to one milestone window by the
3509
3565
  * caller), scope further to the `## Progress` heading section (up to
@@ -3641,7 +3697,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3641
3697
  const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
3642
3698
  if (plansResult.ok)
3643
3699
  text = plansResult.value;
3644
- const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
3700
+ // #5165: a Status cell that already reads Complete is left untouched —
3701
+ // re-padding it rewrote a cell the re-run had no business editing.
3702
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', (current) => (0, phase_status_cjs_1.parseRoadmapStatusCell)(current) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
3703
+ ? current
3704
+ : ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
3645
3705
  if (statusResult.ok)
3646
3706
  text = statusResult.value;
3647
3707
  // Preserve only a valid ISO date (#1161: idempotent; self-heal
@@ -3667,12 +3727,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3667
3727
  const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
3668
3728
  if (!planId)
3669
3729
  continue;
3670
- const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
3730
+ // #5542: only a summary paired with a live (non-superseded) plan
3731
+ // flips a row — the same pairing rule the counts use.
3732
+ if (!coreUtilsMod.summaryPairsWithAnyPlan(phaseInfo['plans'] ?? [], summaryFile))
3733
+ continue;
3671
3734
  // ADR-5057 §6 (Phase 13, #5217): the per-plan flip is a bullet
3672
3735
  // mutation, so it goes through `updateBullet` like the phase
3673
3736
  // checkbox flip above — first matching bullet only, never a
3674
3737
  // fenced line, never a mid-line occurrence.
3675
- const planCheckboxPattern = new RegExp(`^(\\s*-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
3738
+ const planCheckboxPattern = coreUtilsMod.planRowPattern(planId, { anchored: true });
3676
3739
  b = (0, markdown_sectionizer_cjs_1.updateBullet)(b, (_bulletText, rawLine) => planCheckboxPattern.test(rawLine), (rawLine) => rawLine.replace(planCheckboxPattern, '$1x$2'));
3677
3740
  }
3678
3741
  return b;
@@ -3694,7 +3757,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
3694
3757
  }
3695
3758
  }
3696
3759
  else {
3697
- roadmapContent = mutateMilestonePhase(roadmapContent);
3760
+ // #5543: the active milestone could not be resolved (no STATE
3761
+ // `milestone:`, no marker). Writing the whole document would let
3762
+ // `withPhaseSection` pick the first `### Phase N:` in document order,
3763
+ // which can sit inside a shipped `<details>` block. Hide the shipped
3764
+ // blocks from the mutation and restore them untouched; a ROADMAP with
3765
+ // no shipped block is mutated whole, as before.
3766
+ roadmapContent = editOutsideShippedBlocks(roadmapContent, mutateMilestonePhase);
3698
3767
  }
3699
3768
  writes.push({
3700
3769
  filePath: roadmapPath,
@@ -4288,6 +4357,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4288
4357
  }
4289
4358
  if (node_fs_1.default.existsSync(statePath)) {
4290
4359
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
4360
+ // #5165: the milestone STATE asserts (ROADMAP-bound identity first,
4361
+ // else STATE's own frontmatter) scopes the post-completion progress
4362
+ // derivation to that milestone's Progress-table rows.
4363
+ const stateFm = frontmatterMod.extractFrontmatter(originalStateContent, statePath);
4364
+ const storedMilestone = roadmapParserMod.getMilestoneInfo(cwd).value?.version
4365
+ ?? milestoneVersionOf(stateFm['milestone']);
4366
+ // #5165: re-completing a phase that is already complete in ROADMAP.md
4367
+ // while STATE has moved past it is a no-op for STATE's position —
4368
+ // the transition, the metrics row and the authoritative current-phase
4369
+ // pin would all rewind it. A first-time completion (box unchecked, or
4370
+ // STATE not past N, #2028 out-of-order) still transitions.
4371
+ const stateAlreadyPast = phaseWasAlreadyComplete
4372
+ && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
4291
4373
  let stateContent = originalStateContent;
4292
4374
  // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
4293
4375
  // shape/name, Status, Current Plan, Last Activity + Description, and
@@ -4302,8 +4384,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4302
4384
  // NOT go through readModifyWriteStateMd because STATE.md is
4303
4385
  // committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
4304
4386
  // #3374 / #3469).
4305
- const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
4306
- phaseDisplayNameFromSlug(nextPhaseName);
4387
+ const nextPhaseDisplayName = stateAlreadyPast
4388
+ ? null
4389
+ : phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
4390
+ phaseDisplayNameFromSlug(nextPhaseName);
4307
4391
  // #4824: classify the SELECTED next phase from disk (the same ladder
4308
4392
  // roadmap analyze uses) so the Status / Current Plan / Stopped-at
4309
4393
  // handoff does not say "ready to plan" for a phase already planned or
@@ -4324,7 +4408,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4324
4408
  /* best-effort: an unreadable next phase keeps `Ready to plan` */
4325
4409
  }
4326
4410
  }
4327
- const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
4411
+ const completeResult = stateAlreadyPast ? { content: stateContent } : (0, state_transition_cjs_1.transitionCore)(stateContent, {
4328
4412
  kind: 'completePhase',
4329
4413
  phaseNum,
4330
4414
  nextPhaseNum,
@@ -4333,6 +4417,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4333
4417
  planCount,
4334
4418
  summaryCount,
4335
4419
  nextPhaseDisk,
4420
+ // #5165: the body counter and the frontmatter progress below count the
4421
+ // SAME milestone's Progress rows.
4422
+ milestone: storedMilestone,
4336
4423
  }, {
4337
4424
  clock: clock_cjs_1.realClock,
4338
4425
  // #4901: the body counter derives from the same current-milestone scope
@@ -4343,7 +4430,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4343
4430
  sourcePath: statePath,
4344
4431
  });
4345
4432
  stateContent = completeResult.content;
4346
- stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
4433
+ if (!stateAlreadyPast) {
4434
+ stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
4435
+ }
4347
4436
  // #2736: the transition holds the next phase's exact display name in
4348
4437
  // the intent; pass it as authoritative so the sync's prose
4349
4438
  // re-derivation cannot rewrite current_phase_name to the name's own
@@ -4376,7 +4465,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
4376
4465
  ? extractCurrentMilestone(roadmapContent, cwd)
4377
4466
  : null;
4378
4467
  const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
4379
- ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
4468
+ ? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope, storedMilestone)
4380
4469
  : null;
4381
4470
  const authoritativeProgress = postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
4382
4471
  ? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
@@ -129,7 +129,9 @@ function buildPhaseSnapshot(phasesDir, dir, convention) {
129
129
  dir,
130
130
  complete: completionResult.value.complete,
131
131
  verificationStatus: completionResult.value.verification.status,
132
- verificationStatusError: statusError ? { file: statusError.file, message: statusError.message } : null,
132
+ verificationStatusError: statusError
133
+ ? { file: statusError.file, message: statusError.message, rawStatus: statusError.rawStatus }
134
+ : null,
133
135
  planCount: scanResult.planCount,
134
136
  summaryCount: scanResult.summaryCount,
135
137
  scope: worstScope(completionResult.scope, scanResult.scope),
@@ -630,6 +630,22 @@ function findContextMdIn(absDirOrFiles) {
630
630
  return { file: null, files: [], scope: SCOPE.UNREADABLE };
631
631
  }
632
632
  }
633
+ /**
634
+ * Is `name` the phase's requirements SPEC (#5629)? A plain `NN-SPEC.md`: the `-AI-SPEC.md` (AI design
635
+ * contract) and `-UI-SPEC.md` (UI design contract) variants have other purposes and never lock requirements.
636
+ * The one definition behind the init payloads' `has_spec` / `spec_path`, the discuss and plan-phase workflows
637
+ * (which read those fields) and the context-drift gate, so they cannot disagree about which file is "the SPEC".
638
+ */
639
+ function isPhaseSpecFile(name) {
640
+ return name.endsWith('-SPEC.md') && !name.endsWith('-AI-SPEC.md') && !name.endsWith('-UI-SPEC.md');
641
+ }
642
+ /**
643
+ * The phase SPEC in an already-read directory listing, or `null`. The lexically first match, so the answer
644
+ * does not depend on the filesystem's readdir order. The caller's array is not reordered.
645
+ */
646
+ function findPhaseSpecIn(files) {
647
+ return files.filter(isPhaseSpecFile).sort()[0] ?? null;
648
+ }
633
649
  module.exports = {
634
650
  worktreesOptedOut,
635
651
  readScopedConfigValue,
@@ -655,6 +671,8 @@ module.exports = {
655
671
  diagnoseUnresolvedActiveWorkstream,
656
672
  describeUnresolvedWorkstreamReason,
657
673
  findContextMdIn,
674
+ isPhaseSpecFile,
675
+ findPhaseSpecIn,
658
676
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
659
677
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
660
678
  _setLockProbes(probes) {
@@ -34,6 +34,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
34
34
  const node_path_1 = __importDefault(require("node:path"));
35
35
  const node_crypto_1 = __importDefault(require("node:crypto"));
36
36
  const node_child_process_1 = require("node:child_process");
37
+ const read_only_git_env_cjs_1 = require("./read-only-git-env.cjs");
37
38
  /**
38
39
  * SHA-256 hex digest of a file's raw bytes. Byte-for-byte the same digest
39
40
  * install.js fileHash() records into manifests and backup-meta.json.
@@ -144,13 +145,20 @@ function isCleanRelativePosixPath(relPath) {
144
145
  }
145
146
  function gitExec(gitDir, args) {
146
147
  return (0, node_child_process_1.execFileSync)('git', args, {
148
+ // windowsHide (#685): a console-window flash per git call would spam the
149
+ // user on Windows for what is a background, read-only history walk. Kept
150
+ // high in the options so the completeness guard's 400-char scan finds it.
151
+ windowsHide: true,
147
152
  cwd: gitDir,
148
153
  encoding: 'utf8',
154
+ // #5048: every call here is a history/objects walk that never opens the
155
+ // index, so the variable is inert today — set anyway so a future `status` /
156
+ // `diff` caller inherits it. The block is owned by
157
+ // src/read-only-git-env.cts and shared with every other read-only seam.
158
+ // Parity: tests/git-optional-locks-parity.test.cjs.
159
+ env: (0, read_only_git_env_cjs_1.readOnlyGitEnv)(),
149
160
  timeout: GIT_SUBPROCESS_TIMEOUT_MS,
150
161
  maxBuffer: GIT_MAX_BUFFER_BYTES,
151
- // windowsHide (#685): a console-window flash per git call would spam the
152
- // user on Windows for what is a background, read-only history walk.
153
- windowsHide: true,
154
162
  // stderr is discarded: "file absent in commit" is an expected walk outcome,
155
163
  // not operator-visible diagnostics.
156
164
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * read-only-git-env.cts — the single owner of the read-only git env block
4
+ * (#5048). CONTEXT.md / OS Shell Projection seam.
5
+ *
6
+ * By default `git status` refreshes the index and takes an *optional*
7
+ * `.git/index.lock` to write the refreshed copy back. That is a write the caller
8
+ * never asked for, and it turns a pure read into a contender for the lock a
9
+ * real `git add` / `git commit` needs — so a read-only statusline render can
10
+ * make a concurrent commit fail with
11
+ * `Unable to create '.git/index.lock': File exists`. `GIT_OPTIONAL_LOCKS=0`
12
+ * tells git to skip only those *optional* index operations. Nothing a read
13
+ * needs is lost: `git status` reads the index, it just doesn't need to rewrite
14
+ * it.
15
+ *
16
+ * ## Why this module exists
17
+ *
18
+ * The block used to be hand-written at every seam that spawns git for a read.
19
+ * Six copies of `{ ...process.env, GIT_OPTIONAL_LOCKS: '0' }` is six places to
20
+ * forget when a seam is added, and a probe that checks each seam for the literal
21
+ * value passes even when a seam stopped consulting the owner. One owner makes
22
+ * the drift structural instead: the parity test patches THIS function to a
23
+ * sentinel and asserts every seam's spawn carries the sentinel, so a seam that
24
+ * hand-writes the variable again fails red rather than passing quietly.
25
+ * (tests/git-optional-locks-parity.test.cjs, "#5048 every seam reads the shared
26
+ * owner".)
27
+ *
28
+ * ## Why the hook scripts require the built artifact
29
+ *
30
+ * `hooks/*.js` are staged as standalone files at install time, so they reach
31
+ * compiled `src/` modules through `../gsd-core/bin/lib/*.cjs` rather than a
32
+ * sibling hooks/-tree file — the same rule hooks/lib/git-cmd.js follows for
33
+ * token-scanner. They call `readOnlyGitEnv()` per spawn rather than snapshotting
34
+ * the result at module load, so a later `process.env` mutation is visible and the
35
+ * two behave identically.
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.readOnlyGitEnv = readOnlyGitEnv;
39
+ /**
40
+ * Build the env for a read-only git spawn.
41
+ *
42
+ * @param base Environment to spread, `process.env` by default. Callers that
43
+ * layer extra git variables on top (GIT_TERMINAL_PROMPT, a caller's own
44
+ * overrides) pass them in `base`; callers that must let a caller-supplied
45
+ * override win spread the result and apply their own keys afterwards.
46
+ * @returns A fresh object each call — never the cached `process.env`.
47
+ */
48
+ function readOnlyGitEnv(base = process.env) {
49
+ const env = {};
50
+ for (const [key, value] of Object.entries(base)) {
51
+ // process.env carries undefined-valued keys on some platforms; spawn()
52
+ // rejects a non-string value rather than ignoring it.
53
+ if (value !== undefined)
54
+ env[key] = value;
55
+ }
56
+ env.GIT_OPTIONAL_LOCKS = '0';
57
+ return env;
58
+ }
@@ -58,6 +58,7 @@ exports.DOCS_PARITY_VIOLATION = exports.LANE_SLUG_RE = exports.PARITY_VIOLATION
58
58
  exports.mergeReviewerLanes = mergeReviewerLanes;
59
59
  exports.checkReviewerLaneParity = checkReviewerLaneParity;
60
60
  exports.checkReviewerDocsParity = checkReviewerDocsParity;
61
+ const text_lines_cjs_1 = require("./text-lines.cjs");
61
62
  /**
62
63
  * Argv placeholders (Phase 5b, #2799).
63
64
  *
@@ -576,6 +577,7 @@ exports.PARITY_VIOLATION = Object.freeze({
576
577
  REGISTRY_LANE_UNDECLARED: 'registry_lane_undeclared',
577
578
  DESCRIPTOR_LANE_NOT_IN_REGISTRY: 'descriptor_lane_not_in_registry',
578
579
  BESPOKE_LEG_PRESENT: 'bespoke_leg_present',
580
+ AVAILABILITY_FALLBACK_MISMATCH: 'availability_fallback_mismatch',
579
581
  DUPLICATE_SLUG: 'duplicate_slug',
580
582
  DUPLICATE_FLAG: 'duplicate_flag',
581
583
  DUPLICATE_SECTION: 'duplicate_section',
@@ -607,6 +609,10 @@ const LEG_MARKER_RE = /<!--\s*reviewer-lane:\s*([a-z0-9_-]+)\s*-->/g;
607
609
  * design principle of this module.
608
610
  */
609
611
  exports.LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
612
+ /** The workflow's availability call: `gsd_run review-lane availability ...` at command position. */
613
+ const AVAILABILITY_CALL_RE = /^\s*gsd_run review-lane availability(?:\s|$)/;
614
+ /** One `<slug>:missing` row in the printf fallback; slug is LANE_SLUG_RE's grammar capped at 32 chars. */
615
+ const AVAILABILITY_FALLBACK_ROW_RE = /(?:'|\\n)([a-z0-9][a-z0-9_-]{0,31}):missing/g;
610
616
  /** Bounds of the step a bespoke leg marker could appear inside. */
611
617
  const INVOKE_STEP_RE = /<step name="invoke_reviewers">([\s\S]*?)<\/step>/;
612
618
  function sliceStep(workflowText, re) {
@@ -627,8 +633,10 @@ function countOccurrences(haystack, re) {
627
633
  }
628
634
  /**
629
635
  * Bidirectional parity across three surfaces: the descriptor, the roster
630
- * (`KNOWN_REVIEWER_SLUGS`), and the generated capability **registry** — plus one anti-parity
631
- * assertion against the workflow.
636
+ * (`KNOWN_REVIEWER_SLUGS`), and the generated capability **registry** — plus two assertions
637
+ * against the workflow: the anti-parity check, and the availability-fallback check (the workflow
638
+ * makes the availability call, has a fallback row per lane, and the fallback slug set equals the
639
+ * openai-http lanes).
632
640
  *
633
641
  * **Re-pointed by Phase 5b (#2799).** Through Phase 5a this function also required a literal
634
642
  * `<!-- reviewer-lane: <slug> -->` per lane inside `invoke_reviewers` and a literal
@@ -671,6 +679,7 @@ function checkReviewerLaneParity(input) {
671
679
  const seenSlug = new Set();
672
680
  const seenFlag = new Set();
673
681
  const seenSection = new Set();
682
+ const httpSlugs = new Set();
674
683
  // The declared parameter type says `ReviewerLane[]`, but this function is a
675
684
  // trust boundary — narrow from `unknown` rather than believing the annotation.
676
685
  const rawLanes = Array.isArray(descriptor) ? descriptor : [];
@@ -689,6 +698,8 @@ function checkReviewerLaneParity(input) {
689
698
  if (seenSlug.has(slug))
690
699
  add(exports.PARITY_VIOLATION.DUPLICATE_SLUG, slug);
691
700
  seenSlug.add(slug);
701
+ if (lane.transport === 'openai-http')
702
+ httpSlugs.add(slug);
692
703
  const flags = Array.isArray(lane.flags) ? lane.flags : [];
693
704
  for (const flag of flags) {
694
705
  if (typeof flag !== 'string')
@@ -740,6 +751,28 @@ function checkReviewerLaneParity(input) {
740
751
  for (const slug of markerSlugs.keys()) {
741
752
  add(exports.PARITY_VIOLATION.BESPOKE_LEG_PRESENT, slug);
742
753
  }
754
+ // --- the availability call's `||` fallback must name exactly the openai-http lanes ---
755
+ //
756
+ // review.md prints these rows only when an older gsd-tools lacks `review-lane availability`, so
757
+ // they are a second hand-kept copy of the roster. Checked only when the call is present.
758
+ // Anchored to the command position so a comment or prose mention cannot shadow the real call;
759
+ // a missing call is itself a violation (otherwise deleting it would silently skip this check).
760
+ const availabilityLine = (0, text_lines_cjs_1.splitLines)(workflowText).find((line) => AVAILABILITY_CALL_RE.test(line));
761
+ if (availabilityLine === undefined) {
762
+ add(exports.PARITY_VIOLATION.AVAILABILITY_FALLBACK_MISMATCH, 'no-call');
763
+ }
764
+ else {
765
+ if (!availabilityLine.includes('review-lane availability ||')) {
766
+ add(exports.PARITY_VIOLATION.AVAILABILITY_FALLBACK_MISMATCH, 'no-fallback');
767
+ }
768
+ const fallback = new Set(Array.from(availabilityLine.matchAll(AVAILABILITY_FALLBACK_ROW_RE), (m) => m[1]));
769
+ for (const slug of fallback)
770
+ if (!httpSlugs.has(slug))
771
+ add(exports.PARITY_VIOLATION.AVAILABILITY_FALLBACK_MISMATCH, slug);
772
+ for (const slug of httpSlugs)
773
+ if (!fallback.has(slug))
774
+ add(exports.PARITY_VIOLATION.AVAILABILITY_FALLBACK_MISMATCH, slug);
775
+ }
743
776
  return { ok: violations.length === 0, violations };
744
777
  }
745
778
  /* ------------------------------------------------------------------ *
@@ -32,6 +32,7 @@ exports.parseModelBanner = parseModelBanner;
32
32
  exports.parseTranscriptModel = parseTranscriptModel;
33
33
  exports.checkEgressHost = checkEgressHost;
34
34
  exports.probeLane = probeLane;
35
+ exports.probeHttpLanes = probeHttpLanes;
35
36
  exports.writeReviewOrStub = writeReviewOrStub;
36
37
  exports.emptyOutputDiagnosis = emptyOutputDiagnosis;
37
38
  exports.handleOpencodeOutput = handleOpencodeOutput;
@@ -318,14 +319,18 @@ async function probeLane(plan, deps) {
318
319
  // Only a STRING config value names a host. `String(unknown)` would turn an object into the
319
320
  // literal '[object Object]' and probe that as a URL — a nonsense destination reported as
320
321
  // "unreachable" rather than as the misconfiguration it is.
322
+ // For openai-http, `plan.host` is already resolved by `resolveLanePlan` (config → default,
323
+ // unset sentinels like "null" handled) — probe the destination the run would use.
321
324
  const configured = deps.configGet(probe.hostConfigKey);
322
- const base = (0, review_lane_invocation_cjs_1.normalizeHost)((typeof configured === 'string' ? configured : '') ||
323
- (plan.transport === 'openai-http' ? plan.host : ''));
325
+ const base = (0, review_lane_invocation_cjs_1.normalizeHost)((plan.transport === 'openai-http' ? plan.host : '') || (typeof configured === 'string' ? configured : ''));
324
326
  if (!base) {
325
327
  return { available: false, reason: review_lane_invocation_cjs_1.LANE_UNAVAILABLE.HOST_UNREACHABLE, detail: 'no host resolved' };
326
328
  }
327
- const r = await deps.httpJson(`${base}${probe.path}`, { method: 'GET', timeoutMs: probe.timeoutMs });
328
- return r.ok
329
+ // D7: reachable ⇒ available. Any HTTP answer (401 behind an auth proxy, 404, 3xx) proves a
330
+ // server is there; only a transport failure (status 0) does not. Redirects are not followed:
331
+ // a probe must not issue a request to a host the descriptor never named.
332
+ const r = await deps.httpJson(`${base}${probe.path}`, { method: 'GET', timeoutMs: probe.timeoutMs, probe: true });
333
+ return r.status > 0
329
334
  ? { available: true }
330
335
  : {
331
336
  available: false,
@@ -341,6 +346,25 @@ async function probeLane(plan, deps) {
341
346
  };
342
347
  }
343
348
  }
349
+ /**
350
+ * `<slug>:available|missing` rows for the openai-http lanes in `lanes` (all, or those in `selected`).
351
+ * Prerequisite binaries are not folded in: the caller reports `jq` separately. An unresolvable lane
352
+ * reads `missing` and its reason is returned in `warnings` rather than swallowed.
353
+ */
354
+ async function probeHttpLanes(lanes, selected, resolve, deps) {
355
+ // Probes run concurrently (each has its own timeout); Promise.all keeps descriptor order, and
356
+ // warnings are collected from the results afterwards so their order never depends on resolve timing.
357
+ const results = await Promise.all(lanes
358
+ .filter((l) => l.transport === 'openai-http' && (selected.length === 0 || selected.includes(l.slug)))
359
+ .map(async (lane) => {
360
+ const r = resolve(lane);
361
+ const up = r.ok && (await probeLane({ ...r.plan, requiresBinaries: [] }, deps)).available;
362
+ return { row: `${lane.slug}:${up ? 'available' : 'missing'}`, warning: r.ok ? null : `${lane.slug}: ${r.detail}` };
363
+ }));
364
+ const rows = results.map((x) => x.row);
365
+ const warnings = results.flatMap((x) => (x.warning === null ? [] : [x.warning]));
366
+ return { rows, warnings };
367
+ }
344
368
  /* ------------------------------------------------------------------ *
345
369
  * Empty-output policy (#2494 / #2605 / #2794)
346
370
  * ------------------------------------------------------------------ */
@@ -940,6 +964,11 @@ async function runOpenAiCompatible(plan, promptText, deps) {
940
964
  let model = plan.model;
941
965
  if (!model && plan.modelsUrl) {
942
966
  const listed = await deps.httpJson(plan.modelsUrl, { method: 'GET', timeoutMs: 2_000 });
967
+ // A redirect is a lane failure, not "discovery found nothing" (#5433): the host answered with
968
+ // somewhere else to go, so the configured URL is wrong and the completions request would be too.
969
+ if (listed.status >= 300 && listed.status < 400 && listed.error) {
970
+ return { review: '', rawBody: listed.error, model: recordedModel(plan.fallbackModel, exports.MODEL_SOURCE.REQUESTED) };
971
+ }
943
972
  if (listed.ok) {
944
973
  try {
945
974
  const parsed = JSON.parse(listed.body);