@opengsd/gsd-core 1.7.0 → 1.8.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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
@@ -15,6 +15,17 @@
15
15
  * inside a fenced code block) is ignored — this is the exact failure mode that
16
16
  * issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
17
17
  * its regex at byte 0 of the document, which provides this guarantee.
18
+ *
19
+ * #2348 staleness signal: whether a *-VERIFICATION.md is stale (a summary newer
20
+ * than it) is decided from git commit time when a file is committed AND clean,
21
+ * and from filesystem mtime otherwise. mtimes are assigned at checkout time and
22
+ * are not preserved by `git clone` / `cp -R`, and any unrelated `touch` /
23
+ * reformat / editor-save re-stales a valid report — so a committed phase could
24
+ * read `passed` on one machine and `stale` on a fresh clone purely from checkout
25
+ * order. Git commit time is content-tied and clone-stable; mtime is retained
26
+ * only for uncommitted or working-tree-dirty files, where it is the true
27
+ * last-changed signal. Both are real wall-clock change times, so the comparison
28
+ * is sound even when one file uses each.
18
29
  */
19
30
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
31
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -29,6 +40,7 @@ const phaseId = require("./phase-id.cjs");
29
40
  const frontmatterMod = require("./frontmatter.cjs");
30
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
31
42
  const scanPhasePlans = require("./plan-scan.cjs");
43
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
32
44
  const { output, error } = io;
33
45
  const { extractPhaseToken } = phaseId;
34
46
  const { extractFrontmatter } = frontmatterMod;
@@ -87,6 +99,79 @@ const VERIFICATION_ROUTING_TABLE = {
87
99
  next_command: '/gsd:execute-phase',
88
100
  },
89
101
  };
102
+ /** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
103
+ function toPosix(p) {
104
+ return p.replace(/\\/g, '/');
105
+ }
106
+ /**
107
+ * Match a git-emitted (repo-root-relative) path back to the caller's
108
+ * phaseDir-relative request by exact match or `/`-bounded suffix — precise
109
+ * enough that a root file and a nested `plans/` file can never collide (a plain
110
+ * basename match could). Returns the original caller-form file string, or null.
111
+ */
112
+ function matchRequestedFile(gitPath, requested, requestedPosix) {
113
+ const g = toPosix(gitPath);
114
+ for (let i = 0; i < requested.length; i++) {
115
+ const want = requestedPosix[i];
116
+ if (g === want || g.endsWith('/' + want))
117
+ return requested[i];
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Parse `git log --format=%ct --name-only` output into file → most-recent commit
123
+ * time (ms). Output is reverse-chronological, so a file's FIRST appearance
124
+ * top-down is its latest commit. `%ct` headers are pure digits; path lines
125
+ * contain a `.` (the `.md` extension) — so the two are unambiguous.
126
+ */
127
+ function parseCommitTimes(stdout, requested, requestedPosix) {
128
+ const out = new Map();
129
+ let currentCt = null;
130
+ for (const line of stdout.split('\n')) {
131
+ if (line.length === 0)
132
+ continue;
133
+ if (/^\d+$/.test(line)) {
134
+ currentCt = Number.parseInt(line, 10);
135
+ continue;
136
+ }
137
+ if (currentCt === null)
138
+ continue;
139
+ const rel = matchRequestedFile(line, requested, requestedPosix);
140
+ if (rel !== null && !out.has(rel))
141
+ out.set(rel, currentCt * 1000);
142
+ }
143
+ return out;
144
+ }
145
+ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_command_projection_cjs_1.execGit) {
146
+ if (files.length === 0)
147
+ return new Map();
148
+ const requestedPosix = files.map(toPosix);
149
+ const logRes = execGitFn(['log', '--first-parent', '--format=%ct', '--name-only', '--', ...files], {
150
+ cwd: phaseDir,
151
+ });
152
+ if (logRes.error || logRes.exitCode !== 0 || logRes.stdout.length === 0)
153
+ return new Map();
154
+ const commitTimes = parseCommitTimes(logRes.stdout, files, requestedPosix);
155
+ if (commitTimes.size === 0)
156
+ return commitTimes;
157
+ // Drop dirty files (working tree ≠ HEAD) so their mtime is used instead. If the
158
+ // dirty-check itself is INCONCLUSIVE (git diff errored / non-zero — as opposed
159
+ // to "ran and reported no dirty files"), we cannot prove any file is clean, so
160
+ // fail SAFE: discard the commit times and let every file fall back to mtime,
161
+ // the same direction as a git-log failure. Trusting possibly-stale commit times
162
+ // here would silently mask a real edit (false "not stale"). (#2348)
163
+ const diffRes = execGitFn(['diff', '--name-only', 'HEAD', '--', ...files], { cwd: phaseDir });
164
+ if (diffRes.error || diffRes.exitCode !== 0)
165
+ return new Map();
166
+ for (const line of diffRes.stdout.split('\n')) {
167
+ if (line.length === 0)
168
+ continue;
169
+ const rel = matchRequestedFile(line, files, requestedPosix);
170
+ if (rel !== null)
171
+ commitTimes.delete(rel);
172
+ }
173
+ return commitTimes;
174
+ }
90
175
  /**
91
176
  * Build a 'missing' result from the routing table.
92
177
  * Used for two early-return paths: no *-VERIFICATION.md file found, and
@@ -100,7 +185,7 @@ function missingResult() {
100
185
  next_command: route.next_command,
101
186
  };
102
187
  }
103
- function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
188
+ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default, phaseCleanCommitTimesMs = defaultPhaseCleanCommitTimesMs) {
104
189
  // FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
105
190
  // unreadable dir; broken symlink; file->dir swap) must degrade to "not stale" rather
106
191
  // than throw uncaught into callers that are NOT under the planning lock
@@ -112,23 +197,31 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
112
197
  const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0];
113
198
  if (!verificationFile)
114
199
  return null;
115
- const verificationMtimeMs = fsImpl.statSync(node_path_1.default.join(phaseDir, verificationFile)).mtimeMs;
116
- let newestStaleSummary = null;
117
- const summaryFiles = scanPhasePlans(phaseDir).summaryFiles;
118
- for (const summaryFile of summaryFiles.sort()) {
119
- const summaryMtimeMs = fsImpl.statSync(node_path_1.default.join(phaseDir, summaryFile)).mtimeMs;
120
- if (summaryMtimeMs <= verificationMtimeMs)
121
- continue;
122
- if (!newestStaleSummary || summaryMtimeMs > newestStaleSummary.mtimeMs) {
123
- newestStaleSummary = { summaryFile, mtimeMs: summaryMtimeMs };
200
+ const summaryFiles = scanPhasePlans(phaseDir).summaryFiles
201
+ .slice()
202
+ .sort();
203
+ // No summary can be newer than the verification → never stale. Return before
204
+ // touching git so a phase with no summaries costs zero subprocesses. (#2348)
205
+ if (summaryFiles.length === 0)
206
+ return null;
207
+ // Each file's effective "last changed" time = its commit time when committed
208
+ // AND clean (content-tied and clone-stable), else its filesystem mtime (the
209
+ // uncommitted working-tree edit). Both are real wall-clock change times, so
210
+ // comparing a clean file's commit time against a dirty file's mtime is sound.
211
+ // One resolver call = two git subprocesses for the whole phase. (#2348)
212
+ const cleanCommitMs = phaseCleanCommitTimesMs(phaseDir, [verificationFile, ...summaryFiles]);
213
+ const effectiveTimeMs = (file) => cleanCommitMs.has(file)
214
+ ? cleanCommitMs.get(file)
215
+ : fsImpl.statSync(node_path_1.default.join(phaseDir, file)).mtimeMs;
216
+ const verificationTimeMs = effectiveTimeMs(verificationFile);
217
+ for (const summaryFile of summaryFiles) {
218
+ // The caller only needs whether the phase is stale, not which summary —
219
+ // the first stale summary (in sorted order) is enough. Short-circuit.
220
+ if (effectiveTimeMs(summaryFile) > verificationTimeMs) {
221
+ return { verificationFile, summaryFile };
124
222
  }
125
223
  }
126
- if (!newestStaleSummary)
127
- return null;
128
- return {
129
- verificationFile,
130
- summaryFile: newestStaleSummary.summaryFile,
131
- };
224
+ return null;
132
225
  }
133
226
  catch {
134
227
  return null;
@@ -151,6 +244,7 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
151
244
  */
152
245
  function readVerificationStatus(phaseDir, opts = {}) {
153
246
  const fsImpl = opts.fs ?? node_fs_1.default;
247
+ const phaseCleanCommitTimesMs = opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
154
248
  // Phase token for the gaps_found command
155
249
  const baseName = node_path_1.default.basename(phaseDir);
156
250
  const phaseToken = extractPhaseToken(baseName);
@@ -200,7 +294,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
200
294
  next_command: `/gsd:plan-phase ${phaseNumber} --gaps`,
201
295
  };
202
296
  }
203
- const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl);
297
+ const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs);
204
298
  if (staleVerification) {
205
299
  const entry = VERIFICATION_ROUTING_TABLE['stale'];
206
300
  return {
@@ -251,6 +345,7 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
251
345
  module.exports = {
252
346
  VERIFIER_STATUSES,
253
347
  VERIFICATION_ROUTING_TABLE,
348
+ defaultPhaseCleanCommitTimesMs,
254
349
  findStaleVerificationSummary,
255
350
  readVerificationStatus,
256
351
  cmdVerificationStatus,
@@ -31,6 +31,7 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
31
31
  const schema_detect_cjs_1 = require("./schema-detect.cjs");
32
32
  const artifacts_cjs_1 = require("./artifacts.cjs");
33
33
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
34
+ const model_catalog_cjs_1 = require("./model-catalog.cjs");
34
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
35
36
  const agentInstallCheck = require("./agent-install-check.cjs");
36
37
  const { checkAgentsInstalled } = agentInstallCheck;
@@ -42,7 +43,7 @@ const configLoaderMod = require("./config-loader.cjs");
42
43
  const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
43
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
44
45
  const phaseIdMod = require("./phase-id.cjs");
45
- const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
46
+ const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, extractPhaseToken, comparePhaseNum } = phaseIdMod;
46
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
48
  const phaseLocatorMod = require("./phase-locator.cjs");
48
49
  const { findPhaseInternal } = phaseLocatorMod;
@@ -52,6 +53,9 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
52
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
54
  const worktreeSafetyMod = require("./worktree-safety.cjs");
54
55
  const { inspectWorktreeHealth } = worktreeSafetyMod;
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
57
+ const commandsMod = require("./commands.cjs");
58
+ const { determinePhaseStatus } = commandsMod;
55
59
  const { planningDir, planningRoot } = planningWorkspace;
56
60
  const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
57
61
  const { writeStateMd } = stateMod;
@@ -516,6 +520,131 @@ function scanFileWideNegativeGateConflict(content) {
516
520
  // This detector is warn-only: it never sets valid=false.
517
521
  return { warnings, valid: true };
518
522
  }
523
+ /**
524
+ * Single pass over `<task ...>…</task>` blocks. The body pattern is
525
+ * ReDoS-safe stop-at-next-open (mirrors `taggedBlockPattern` in
526
+ * markdown-sectionizer.cts): bounded attributes (`[^>]{0,1000}`) and a body
527
+ * boundary that terminates at the NEXT `<task[\s>]` opening, so a document
528
+ * full of unclosed `<task>` openings scans linearly. Captures both the
529
+ * attribute string (group 1, so the `type=` selector is not lost the way it is
530
+ * with `extractTaggedBlocks`) and the body (group 2).
531
+ */
532
+ const PLAN_TASK_BLOCK_RE = /<task(\s[^>]{0,1000})?>((?:(?!<task[\s>])[\s\S])*?)<\/task>/g;
533
+ /**
534
+ * Extract one `PlanTaskInfo` per `<task …>…</task>` block in `content`.
535
+ *
536
+ * Why a dedicated regex instead of `extractTaggedBlocks('task', true)`:
537
+ * `extractTaggedBlocks` discards the opening tag, so the task's `type=`
538
+ * attribute (which selects the validation branch) is lost. This helper
539
+ * captures both the attribute string and the body in one pass, then reuses
540
+ * `extractTaggedBlocks` on the body for sub-element extraction.
541
+ */
542
+ function extractPlanTaskInfos(content) {
543
+ const infos = [];
544
+ if (typeof content !== 'string' || content.length === 0)
545
+ return infos;
546
+ PLAN_TASK_BLOCK_RE.lastIndex = 0;
547
+ let match;
548
+ while ((match = PLAN_TASK_BLOCK_RE.exec(content)) !== null) {
549
+ const attrs = match[1] ?? '';
550
+ const body = match[2] ?? '';
551
+ const typeMatch = attrs.match(/\btype\s*=\s*["']?([\w:-]+)/i);
552
+ const type = typeMatch ? typeMatch[1].toLowerCase() : '';
553
+ const nameArr = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(body, 'name');
554
+ const hasName = nameArr.length > 0;
555
+ const name = hasName ? nameArr[0].trim() : '';
556
+ infos.push({
557
+ name,
558
+ type,
559
+ hasName,
560
+ hasFiles: /<files>/.test(body),
561
+ hasAction: /<action>/.test(body),
562
+ hasVerify: /<verify>/.test(body),
563
+ hasDone: /<done>/.test(body),
564
+ hasWhatBuilt: /<what-built>/.test(body),
565
+ hasHowToVerify: /<how-to-verify>/.test(body),
566
+ hasDecision: /<decision>/.test(body),
567
+ hasOptions: /<options>/.test(body),
568
+ hasInstructions: /<instructions>/.test(body),
569
+ hasVerification: /<verification>/.test(body),
570
+ hasResumeSignal: /<resume-signal>/.test(body),
571
+ });
572
+ // Guard against zero-length matches looping forever.
573
+ if (match.index === PLAN_TASK_BLOCK_RE.lastIndex) {
574
+ PLAN_TASK_BLOCK_RE.lastIndex++;
575
+ }
576
+ }
577
+ return infos;
578
+ }
579
+ function isCheckpointType(type) {
580
+ return type.startsWith('checkpoint:');
581
+ }
582
+ /**
583
+ * Validate one plan task's structure against its type-specific canonical field
584
+ * set (per `gsd-core/references/checkpoints.md`):
585
+ * - `checkpoint:human-verify` requires `<what-built>` / `<how-to-verify>` /
586
+ * `<resume-signal>` (the "checkpoint triple").
587
+ * - `checkpoint:decision` requires `<decision>` / `<options>` /
588
+ * `<resume-signal>`.
589
+ * - `checkpoint:human-action` requires `<action>` / `<instructions>` /
590
+ * `<verification>` / `<resume-signal>`.
591
+ * - Unknown `checkpoint:*` subtypes require only the universal
592
+ * `<resume-signal>` (forward-compat — newer checkpoint types registered
593
+ * in the reference don't need a verifier change to pass structure
594
+ * validation).
595
+ * - All other types (`auto`, `tracer`, `manual`, bare `<task>`, …) keep the
596
+ * historical `<action>` / `<verify>` / `<done>` / `<files>` requirements.
597
+ */
598
+ function validatePlanTaskStructure(task) {
599
+ const errors = [];
600
+ const warnings = [];
601
+ const taskName = task.hasName ? task.name : 'unnamed';
602
+ if (!task.hasName) {
603
+ errors.push('Task missing <name> element');
604
+ }
605
+ if (isCheckpointType(task.type)) {
606
+ if (!task.hasResumeSignal) {
607
+ errors.push(`Task '${taskName}' missing <resume-signal>`);
608
+ }
609
+ switch (task.type) {
610
+ case 'checkpoint:human-verify':
611
+ if (!task.hasWhatBuilt)
612
+ errors.push(`Task '${taskName}' missing <what-built>`);
613
+ if (!task.hasHowToVerify)
614
+ errors.push(`Task '${taskName}' missing <how-to-verify>`);
615
+ break;
616
+ case 'checkpoint:decision':
617
+ if (!task.hasDecision)
618
+ errors.push(`Task '${taskName}' missing <decision>`);
619
+ if (!task.hasOptions)
620
+ errors.push(`Task '${taskName}' missing <options>`);
621
+ break;
622
+ case 'checkpoint:human-action':
623
+ if (!task.hasAction)
624
+ errors.push(`Task '${taskName}' missing <action>`);
625
+ if (!task.hasInstructions)
626
+ errors.push(`Task '${taskName}' missing <instructions>`);
627
+ if (!task.hasVerification)
628
+ errors.push(`Task '${taskName}' missing <verification>`);
629
+ break;
630
+ default:
631
+ // Unknown checkpoint:* subtype: <resume-signal> is the only universal
632
+ // requirement (forward-compat).
633
+ break;
634
+ }
635
+ }
636
+ else {
637
+ if (!task.hasAction)
638
+ errors.push(`Task '${taskName}' missing <action>`);
639
+ if (!task.hasVerify)
640
+ warnings.push(`Task '${taskName}' missing <verify>`);
641
+ if (!task.hasDone)
642
+ warnings.push(`Task '${taskName}' missing <done>`);
643
+ if (!task.hasFiles)
644
+ warnings.push(`Task '${taskName}' missing <files>`);
645
+ }
646
+ return { errors, warnings };
647
+ }
519
648
  function cmdVerifyPlanStructure(cwd, filePath, raw) {
520
649
  if (!filePath) {
521
650
  error('file path required');
@@ -534,25 +663,20 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
534
663
  if (fm[field] === undefined)
535
664
  errors.push(`Missing required frontmatter field: ${field}`);
536
665
  }
666
+ const extractedTasks = extractPlanTaskInfos(content);
537
667
  const tasks = [];
538
- for (const taskContent of (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(content, 'task', true)) {
539
- const nameArr = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(taskContent, 'name');
540
- const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed';
541
- const hasFiles = /<files>/.test(taskContent);
542
- const hasAction = /<action>/.test(taskContent);
543
- const hasVerify = /<verify>/.test(taskContent);
544
- const hasDone = /<done>/.test(taskContent);
545
- if (nameArr.length === 0)
546
- errors.push('Task missing <name> element');
547
- if (!hasAction)
548
- errors.push(`Task '${taskName}' missing <action>`);
549
- if (!hasVerify)
550
- warnings.push(`Task '${taskName}' missing <verify>`);
551
- if (!hasDone)
552
- warnings.push(`Task '${taskName}' missing <done>`);
553
- if (!hasFiles)
554
- warnings.push(`Task '${taskName}' missing <files>`);
555
- tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
668
+ for (const task of extractedTasks) {
669
+ const verdict = validatePlanTaskStructure(task);
670
+ errors.push(...verdict.errors);
671
+ warnings.push(...verdict.warnings);
672
+ tasks.push({
673
+ name: task.hasName ? task.name : 'unnamed',
674
+ type: task.type,
675
+ hasFiles: task.hasFiles,
676
+ hasAction: task.hasAction,
677
+ hasVerify: task.hasVerify,
678
+ hasDone: task.hasDone,
679
+ });
556
680
  }
557
681
  if (tasks.length === 0)
558
682
  warnings.push('No <task> elements found');
@@ -567,6 +691,24 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
567
691
  if (hasCheckpoints && fm['autonomous'] !== 'false' && String(fm['autonomous']) !== 'false') {
568
692
  errors.push('Has checkpoint tasks but autonomous is not false');
569
693
  }
694
+ // #1951: a decision rated one-way is supposed to be confirmed before it is
695
+ // walked through. Warn (never error — <reversibility> stays additive) when a
696
+ // one-way rating has no checkpoint:decision anywhere ahead of it in the plan,
697
+ // which is the planner emitting the rating but skipping the gate.
698
+ const decisionCheckpointOffsets = [];
699
+ for (const m of content.matchAll(/<task\s+type=["']?checkpoint:decision/g)) {
700
+ if (m.index !== undefined)
701
+ decisionCheckpointOffsets.push(m.index);
702
+ }
703
+ for (const m of content.matchAll(/<reversibility\s[^>]*rating=["']?one-way/g)) {
704
+ const at = m.index;
705
+ if (at === undefined)
706
+ continue;
707
+ if (!decisionCheckpointOffsets.some((offset) => offset < at)) {
708
+ warnings.push('Task rated <reversibility rating="one-way"> has no preceding checkpoint:decision — '
709
+ + 'a one-way door must be confirmed before the agent walks through it');
710
+ }
711
+ }
570
712
  const echoScan = scanNegativeGrepCommentEcho(content);
571
713
  errors.push(...echoScan.errors);
572
714
  warnings.push(...echoScan.warnings);
@@ -1207,9 +1349,22 @@ function cmdValidateHealth(cwd, options, raw) {
1207
1349
  try {
1208
1350
  const rawCfg = node_fs_1.default.readFileSync(configPath, 'utf-8');
1209
1351
  const parsed = JSON.parse(rawCfg);
1210
- const validProfiles = ['quality', 'balanced', 'budget', 'inherit'];
1211
- if (parsed['model_profile'] && !validProfiles.includes(parsed['model_profile'])) {
1212
- addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed['model_profile']}"`, `Valid values: ${validProfiles.join(', ')}`);
1352
+ if (parsed['model_profile'] && !model_catalog_cjs_1.VALID_PROFILES.includes(parsed['model_profile'])) {
1353
+ addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed['model_profile']}"`, `Valid values: ${model_catalog_cjs_1.VALID_PROFILES.join(', ')}`);
1354
+ }
1355
+ const configModels = parsed['models'];
1356
+ if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) {
1357
+ for (const [phaseType, tierValue] of Object.entries(configModels)) {
1358
+ if (!model_catalog_cjs_1.VALID_PHASE_TYPES.has(phaseType)) {
1359
+ addIssue('warning', 'W022', `config.json: models has an unknown phase type "${phaseType}" which will be ignored`, `Valid phase types: ${[...model_catalog_cjs_1.VALID_PHASE_TYPES].join(', ')}`);
1360
+ }
1361
+ else if (typeof tierValue !== 'string' || !model_catalog_cjs_1.VALID_TIERS.has(tierValue)) {
1362
+ addIssue('warning', 'W022', `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`, `Valid tiers: ${[...model_catalog_cjs_1.VALID_TIERS].join(', ')}`);
1363
+ }
1364
+ }
1365
+ }
1366
+ else if (configModels !== undefined && configModels !== null) {
1367
+ addIssue('warning', 'W022', `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`, `Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults`);
1213
1368
  }
1214
1369
  }
1215
1370
  catch (err) {
@@ -1260,6 +1415,49 @@ function cmdValidateHealth(cwd, options, raw) {
1260
1415
  addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
1261
1416
  }
1262
1417
  }
1418
+ // W023 (#2408): detect two or more real on-disk phase directories that
1419
+ // normalize to the same phase key (e.g. `05-real/` + `05-real-stray/`).
1420
+ // The collision silently breaks /gsd-stats status accuracy (now folded by
1421
+ // precedence — see commands.cts foldPhaseStatus) and forces an operator
1422
+ // decision. Wording is neutral — never guesses which directory is "real".
1423
+ {
1424
+ const groups = new Map();
1425
+ for (const e of phaseDirEntries) {
1426
+ // extractPhaseToken never returns empty — for unparseable dir names it
1427
+ // falls back to the dir name itself. Two distinct unparseable names
1428
+ // therefore normalize to distinct keys and cannot false-positive here;
1429
+ // only dirs whose tokens collapse to the same key (e.g. `05-real` and
1430
+ // `05-real-stray` → token `05`) produce a collision group.
1431
+ const token = extractPhaseToken(e.name);
1432
+ const key = normalizePhaseName(token);
1433
+ const list = groups.get(key);
1434
+ if (list)
1435
+ list.push(e.name);
1436
+ else
1437
+ groups.set(key, [e.name]);
1438
+ }
1439
+ for (const [key, dirs] of groups) {
1440
+ if (dirs.length < 2)
1441
+ continue;
1442
+ // Compute each dir's status independently so the warning is informative.
1443
+ // Sort by phase id for stable output regardless of readdir order; tie-
1444
+ // break on the dir name itself so two dirs sharing the same phase token
1445
+ // (the collision case itself) still sort deterministically (V8's stable
1446
+ // sort would otherwise fall back to non-portable fs.readdirSync order).
1447
+ const described = dirs
1448
+ .slice()
1449
+ .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
1450
+ .map((d) => {
1451
+ const files = phaseDirFiles.get(d) || [];
1452
+ const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1453
+ const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1454
+ const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, d), 'Not Started');
1455
+ return `${d} (${status})`;
1456
+ })
1457
+ .join(', ');
1458
+ addIssue('warning', 'W023', `Phase directories collide on normalized key "${key}": ${described}`, 'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key');
1459
+ }
1460
+ }
1263
1461
  for (const e of phaseDirEntries) {
1264
1462
  const phaseFiles = phaseDirFiles.get(e.name) || [];
1265
1463
  const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
@@ -72,6 +72,7 @@
72
72
  "statusline.show_last_command",
73
73
  "statusline.context_position",
74
74
  "statusline.show_context_tokens",
75
+ "statusline.state_format",
75
76
  "statusline.show_git",
76
77
  "workflow.max_discuss_passes",
77
78
  "features.thinking_partner",
@@ -148,8 +149,8 @@
148
149
  },
149
150
  {
150
151
  "topLevel": "dynamic_routing",
151
- "source": "^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$",
152
- "description": "dynamic_routing.<enabled|escalate_on_failure|max_escalations|tier_models.<light|standard|heavy>>"
152
+ "source": "^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|provider_escalation|tier_models\\.(light|standard|heavy))$",
153
+ "description": "dynamic_routing.<enabled|escalate_on_failure|max_escalations|provider_escalation|tier_models.<light|standard|heavy>>"
153
154
  },
154
155
  {
155
156
  "topLevel": "model_overrides",
@@ -24,13 +24,26 @@ treated as an external-API integration when **either**:
24
24
  1. a `COVERAGE.md` matrix is present in the phase directory (the planner produced
25
25
  one at `plan:pre`), **or**
26
26
  2. the phase scope shows a strong external-API-integration signal (an integration
27
- verb co-occurring with an external-API noun, or an explicit `<Service>
28
- API|SDK|REST|GraphQL` surface) and no matrix yet exists.
29
-
30
- Non-API phases (refactors, bug fixes, internal-only work, features that merely
31
- *mention* an existing internal API) do **not** fire the gate — the trigger
32
- requires a compound signal, so a bare word like "api" in "the public API of
33
- UserController" is intentionally ignored.
27
+ verb and an external-API noun **in the same clause**, or an explicit
28
+ `<Service> API|SDK|REST|GraphQL` surface naming a real service) and no matrix
29
+ yet exists.
30
+
31
+ The detector is deliberately **fail-closed**: it leans toward firing, because a
32
+ false positive is dismissed by a one-line `COVERAGE.md` "no external API
33
+ integration" declaration, whereas a false *negative* silently lets a real
34
+ external-API phase past this blocking gate — strictly worse. So it suppresses
35
+ only prose that is unambiguously not external integration. A bare word like
36
+ "api" in "the public API of UserController" is ignored (no integration verb +
37
+ named service); the clause boundary is the whole relationship test, so an
38
+ integration verb and an API noun in **different** clauses do not pair. Since
39
+ #2365 the detector also excludes non-prose spans before matching: fenced code
40
+ blocks, inline `` `code` `` spans, and path-shaped tokens (a first-party
41
+ `src/app/api/profile/route.ts` route is a file path, not an external API, while
42
+ an external host like `api.stripe.com/v1` still counts). In the
43
+ `<Service> API` surface position it rejects capitalized sentence starters
44
+ ("The API"), locality/protocol descriptors ("Internal API", "REST API"),
45
+ compound modifiers ("Resolver-only API"), and first-party-qualified services
46
+ ("internal Payments API") — a real vendor name is none of these.
34
47
 
35
48
  ## The two touch points
36
49
 
@@ -70,6 +83,23 @@ must be non-empty and unique; every decision must be `INTEGRATE` or `OPT-OUT`;
70
83
  every `OPT-OUT` must have a reason. Violations block the seal with a precise
71
84
  error.
72
85
 
86
+ ### Declaring "no external API integration" (#2365)
87
+
88
+ A phase that integrates no external API/SDK/service — but was still asked for a
89
+ matrix (e.g. the detector over-fired, or a team wants the decision on record) —
90
+ declares it instead of fabricating a row:
91
+
92
+ ```markdown
93
+ No external API integration: UI-only phase, no third-party surface.
94
+ ```
95
+
96
+ The reason is **required**, exactly like an `OPT-OUT` reason — the declaration
97
+ is a reasoned decision, not a bypass. A `COVERAGE.md` containing both the
98
+ declaration and coverage rows is contradictory and blocks the seal. When the
99
+ detector still finds integration signals in the phase scope, the declaration
100
+ wins (it is the human overrule for a fallible detector) but the gate output
101
+ surfaces the overridden signals so the contradiction is visible, not silent.
102
+
73
103
  ## A second integration against the same need
74
104
 
75
105
  A second platform for an existing capability (e.g. adding YouTube alongside
@@ -474,7 +474,7 @@ npm run dev &
474
474
  DEV_SERVER_PID=$!
475
475
 
476
476
  # Wait for ready (max 30s) — uses fetch() for cross-platform compatibility
477
- timeout 30 bash -c 'until node -e "fetch(\"http://localhost:3000\").then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))" 2>/dev/null; do sleep 1; done'
477
+ gsd_run run-with-timeout 30 -- bash -c 'until node -e "fetch(\"http://localhost:3000\").then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))" 2>/dev/null; do sleep 1; done'
478
478
  ```
479
479
 
480
480
  **Port conflicts:** Kill stale process (`lsof -ti:3000 | xargs kill`) or use alternate port (`--port 3001`).
@@ -97,6 +97,19 @@ Checklist of frequent bug patterns to scan before forming hypotheses. Ordered by
97
97
  3. **Each checked pattern is a hypothesis candidate** — verify or eliminate with evidence
98
98
  4. **If no pattern matches**, proceed to open-ended investigation
99
99
 
100
+ ### Pattern categories → bug taxonomy (Phase 1.75)
101
+
102
+ The categories here feed bug-class classification (see `debugger-bug-taxonomy.md`):
103
+
104
+ | Pattern category | Typical bug_class |
105
+ |---|---|
106
+ | Null / Undefined, Off-by-One, State, Import, Type, Regex, Error Handling, Scope | Bohrbug (deterministic) |
107
+ | Async / Timing (intermittent, leaked timer, init order) | Heisenbug / Concurrency |
108
+ | Environment / Config (works-here-not-there) | Heisenbug / Mandelbug (or config-as-root-cause) |
109
+ | Data Shape / API Contract | Bohrbug (or Mandelbug if volume-dependent) |
110
+
111
+ The taxonomy routes the investigation technique (SBFL + bisect for Bohrbugs; record-replay/stability for Heisenbugs; atomicity/order/deadlock checklist for Concurrency).
112
+
100
113
  ### Symptom-to-Category Quick Map
101
114
 
102
115
  | Symptom | Check First |