@opengsd/gsd-core 1.7.0 → 1.9.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 (261) 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 +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -37,7 +37,11 @@ const node_path_1 = __importDefault(require("node:path"));
37
37
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
39
  const installProfiles = require("./install-profiles.cjs");
40
- const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfiles;
40
+ const { readActiveProfile, resolveProfile, loadSkillsManifest,
41
+ // #2322 HIGH-3: shared marker name — single source of truth with the writer
42
+ // (install-profiles.cts stageSkillsForRuntimeAsSkills) so the prune reader
43
+ // below can never drift from what the stage-time writer actually wrote.
44
+ CAPABILITY_SKILL_MARKER, } = installProfiles;
41
45
  const clusters_cjs_1 = require("./clusters.cjs");
42
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
47
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
@@ -379,12 +383,18 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
379
383
  *
380
384
  * Ownership criteria:
381
385
  * - Non-empty prefix (e.g. 'gsd-'): dir name starts with that prefix AND
382
- * appears in the manifest (manifest membership is required). Dirs that match
383
- * the prefix but are NOT in the manifest are treated as user-owned and
386
+ * EITHER appears in the manifest (first-party membership) OR carries the
387
+ * persisted `CAPABILITY_SKILL_MARKER` file (#2322 HIGH-3: a third-party
388
+ * capability skill, self-certifying and independent of current registry
389
+ * state — so an uninstalled/unsurfaced capability's stale skill is still
390
+ * prunable even though it no longer appears in any registry view). Dirs
391
+ * that match the prefix but satisfy NEITHER are treated as user-owned and
384
392
  * preserved — this prevents data loss for user-created gsd-* directories.
385
393
  * A warning is written to stderr when such a dir is encountered.
386
394
  * - Empty prefix (Hermes): dir name appears as a canonical skill stem in the
387
- * manifest. User dirs not in the manifest are preserved.
395
+ * manifest. User dirs not in the manifest are preserved. (Hermes does not
396
+ * yet stage third-party capability skills, so the marker check does not
397
+ * apply on this path.)
388
398
  * - Empty prefix without manifest, or manifest not a Map: conservative; no
389
399
  * dirs are removed.
390
400
  *
@@ -419,18 +429,50 @@ function pruneSkillDirs(skillsDir, retainedNames, prefix, manifest) {
419
429
  // Does not match prefix at all — user-owned, preserve.
420
430
  continue;
421
431
  }
422
- if (!canonicalStems) {
423
- // No manifest available: cannot confirm ownership — preserve conservatively.
432
+ // #2322: an entry in THIS apply's retained set is unambiguously wanted —
433
+ // check that BEFORE the first-party-manifest-membership gate below. The
434
+ // manifest only ever knows gsd-core's own bundled stems; a materialized
435
+ // third-party capability skill (retained via the resolved profile's
436
+ // registry union, #2045/#2322) has no manifest entry at all, so without
437
+ // this early check it fell into the "unknown, preserve with warning"
438
+ // branch on EVERY apply — misreporting a live, GSD-managed capability
439
+ // skill as "user-owned or unknown" noise. This does not change any
440
+ // deletion outcome (a retained entry was always preserved — see the
441
+ // `retainedNames.has(entry)` check further below); it only short-
442
+ // circuits the ambiguous-ownership warning for entries we already know,
443
+ // this apply, are wanted.
444
+ if (retainedNames.has(entry))
424
445
  continue;
425
- }
426
446
  // Finding 1 fix: prefix match is necessary but NOT sufficient.
427
447
  // The dir must also be in the manifest to be considered GSD-owned.
428
448
  // A user-created gsd-* dir that isn't in the manifest is preserved with a warning.
429
- if (!canonicalStems.has(entry.slice(prefix.length))) {
449
+ const stem = entry.slice(prefix.length);
450
+ if (canonicalStems && canonicalStems.has(stem)) {
451
+ isGsdOwned = true;
452
+ }
453
+ else if (node_fs_1.default.existsSync(node_path_1.default.join(entryPath, CAPABILITY_SKILL_MARKER))) {
454
+ // #2322 HIGH-3: not a first-party stem, but self-certified as a
455
+ // GSD-managed THIRD-PARTY capability skill via the persisted marker
456
+ // (written by install-profiles.cts stageSkillsForRuntimeAsSkills at
457
+ // stage time). Without this, an orphaned capability skill — its
458
+ // owning capability uninstalled/unsurfaced and no longer appearing in
459
+ // ANY registry view — had no manifest entry at all and fell into the
460
+ // "unknown, preserve with warning" branch below FOREVER: uninstalling
461
+ // a malicious capability never actually removed its already-staged
462
+ // instructions from the agent's context. The marker makes ownership
463
+ // self-certifying at prune time, independent of current registry
464
+ // state (or even of whether a manifest was supplied at all).
465
+ isGsdOwned = true;
466
+ }
467
+ else if (!canonicalStems) {
468
+ // No manifest available and no capability marker: cannot confirm
469
+ // ownership — preserve conservatively (silent).
470
+ continue;
471
+ }
472
+ else {
430
473
  process.stderr.write(`[gsd] Warning: ${entry} matches GSD prefix '${prefix}' but is not in the manifest — preserving (user-owned or unknown)\n`);
431
474
  continue;
432
475
  }
433
- isGsdOwned = true;
434
476
  }
435
477
  else if (canonicalStems) {
436
478
  // Hermes: GSD-owned iff the directory name appears in the canonical manifest.
@@ -194,9 +194,10 @@ function evaluateUatPassed(phaseFullDir, opts) {
194
194
  // ── Process UAT files ──────────────────────────────────────────────────────
195
195
  for (const file of uatFileNames) {
196
196
  uatFiles.push(file);
197
+ const uatFilePath = node_path_1.default.join(phaseFullDir, file);
197
198
  let raw = '';
198
199
  try {
199
- raw = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
200
+ raw = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
200
201
  }
201
202
  catch {
202
203
  blockers.push(`${file}: could not read file`);
@@ -210,7 +211,7 @@ function evaluateUatPassed(phaseFullDir, opts) {
210
211
  if (unterminatedFence || unterminatedComment) {
211
212
  blockers.push(`${file}: malformed markdown (unterminated fence or comment)`);
212
213
  }
213
- const fm = extractFrontmatter(raw);
214
+ const fm = extractFrontmatter(raw, uatFilePath);
214
215
  // File-level frontmatter status check
215
216
  if (fm['status'] && BLOCKING_UAT_FM_STATUSES.has(fm['status'])) {
216
217
  blockers.push(`${file}: frontmatter status=${fm['status']}`);
@@ -240,15 +241,16 @@ function evaluateUatPassed(phaseFullDir, opts) {
240
241
  let hasPassingVerification = false;
241
242
  for (const file of verFileNames) {
242
243
  verificationFiles.push(file);
244
+ const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
243
245
  let raw = '';
244
246
  try {
245
- raw = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
247
+ raw = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
246
248
  }
247
249
  catch {
248
250
  blockers.push(`${file}: could not read verification file`);
249
251
  continue;
250
252
  }
251
- const vfm = extractFrontmatter(raw);
253
+ const vfm = extractFrontmatter(raw, verificationFilePath);
252
254
  const vStatus = vfm['status'];
253
255
  if (vStatus && BLOCKING_VERIFICATION_FM_STATUSES.has(vStatus)) {
254
256
  blockers.push(`${file}: verification status=${vStatus}`);
@@ -39,6 +39,9 @@ const { extractFrontmatter } = frontmatter;
39
39
  const phaseIdMod = require("./phase-id.cjs");
40
40
  const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
41
41
  const security_cjs_1 = require("./security.cjs");
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
43
+ const configLoader = require("./config-loader.cjs");
44
+ const { loadConfig } = configLoader;
42
45
  // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
43
46
  function cmdAuditUat(cwd, raw) {
44
47
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -60,7 +63,8 @@ function cmdAuditUat(cwd, raw) {
60
63
  const files = node_fs_1.default.readdirSync(phaseDir);
61
64
  // Process UAT files
62
65
  for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
63
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, file), 'utf-8');
66
+ const uatFilePath = node_path_1.default.join(phaseDir, file);
67
+ const content = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
64
68
  const items = parseUatItems(content);
65
69
  if (items.length > 0) {
66
70
  results.push({
@@ -69,17 +73,18 @@ function cmdAuditUat(cwd, raw) {
69
73
  file,
70
74
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
71
75
  type: 'uat',
72
- status: (extractFrontmatter(content).status || 'unknown'),
76
+ status: (extractFrontmatter(content, uatFilePath).status || 'unknown'),
73
77
  items,
74
78
  });
75
79
  }
76
80
  }
77
81
  // Process VERIFICATION files
78
82
  for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
79
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, file), 'utf-8');
80
- const status = extractFrontmatter(content).status || 'unknown';
83
+ const verificationFilePath = node_path_1.default.join(phaseDir, file);
84
+ const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
85
+ const status = extractFrontmatter(content, verificationFilePath).status || 'unknown';
81
86
  if (status === 'human_needed' || status === 'gaps_found') {
82
- const items = parseVerificationItems(content, status);
87
+ const items = parseVerificationItems(content, status, verificationFilePath);
83
88
  if (items.length > 0) {
84
89
  results.push({
85
90
  phase: phaseNum,
@@ -93,6 +98,29 @@ function cmdAuditUat(cwd, raw) {
93
98
  }
94
99
  }
95
100
  }
101
+ // Process deferred-items.md (#2287) — the SCOPE BOUNDARY convention
102
+ // (agents/gsd-executor.md) has the executor log out-of-scope discoveries
103
+ // to this file; nothing previously read it back. Surface every
104
+ // UNRESOLVED entry (see parseDeferredItems for the resolved/unresolved
105
+ // parsing rule) as a 'deferred'-typed result, keeping deferred-items.md
106
+ // itself the single source of truth — no duplicate pending-todo entry
107
+ // required.
108
+ const deferredFile = 'deferred-items.md';
109
+ if (files.includes(deferredFile)) {
110
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, deferredFile), 'utf-8');
111
+ const items = parseDeferredItems(content);
112
+ if (items.length > 0) {
113
+ results.push({
114
+ phase: phaseNum,
115
+ phase_dir: dir,
116
+ file: deferredFile,
117
+ file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, deferredFile))),
118
+ type: 'deferred',
119
+ status: 'unresolved',
120
+ items,
121
+ });
122
+ }
123
+ }
96
124
  }
97
125
  // Compute summary
98
126
  const summary = {
@@ -127,7 +155,9 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
127
155
  if (currentTest.complete) {
128
156
  error('UAT session is already complete; no pending checkpoint to render');
129
157
  }
130
- const checkpoint = buildCheckpoint(currentTest);
158
+ const config = loadConfig(cwd);
159
+ const responseLanguage = typeof config.response_language === 'string' ? config.response_language : undefined;
160
+ const checkpoint = buildCheckpoint(currentTest, responseLanguage);
131
161
  output({
132
162
  file_path: toPosixPath(node_path_1.default.relative(cwd, resolvedPath)),
133
163
  test_number: currentTest.number,
@@ -240,11 +270,111 @@ function parseExpectedFromTestBlock(block) {
240
270
  const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
241
271
  return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
242
272
  }
243
- // ─── buildCheckpoint ──────────────────────────────────────────────────────────
244
- function buildCheckpoint(currentTest) {
273
+ const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
274
+ const CHECKPOINT_FRAMES = {
275
+ english: {
276
+ banner: 'CHECKPOINT: Verification Required',
277
+ instruction: 'Type `pass` or describe what\'s wrong.',
278
+ },
279
+ spanish: {
280
+ banner: 'PUNTO DE CONTROL: Verificación requerida',
281
+ instruction: 'Escribe `pass` o describe qué está mal.',
282
+ },
283
+ french: {
284
+ banner: 'POINT DE CONTRÔLE : Vérification requise',
285
+ instruction: 'Tapez `pass` ou décrivez ce qui ne va pas.',
286
+ },
287
+ german: {
288
+ banner: 'KONTROLLPUNKT: Überprüfung erforderlich',
289
+ instruction: 'Gib `pass` ein oder beschreibe, was nicht stimmt.',
290
+ },
291
+ portuguese: {
292
+ banner: 'PONTO DE VERIFICAÇÃO: Verificação necessária',
293
+ instruction: 'Digite `pass` ou descreva o que está errado.',
294
+ },
295
+ japanese: {
296
+ banner: 'チェックポイント: 検証が必要です',
297
+ instruction: '`pass` と入力するか、問題点を説明してください。',
298
+ },
299
+ chinese: {
300
+ banner: '检查点:需要验证',
301
+ instruction: '输入 `pass` 或描述问题所在。',
302
+ },
303
+ korean: {
304
+ banner: '체크포인트: 검증 필요',
305
+ instruction: '`pass`를 입력하거나 문제를 설명하세요.',
306
+ },
307
+ italian: {
308
+ banner: 'PUNTO DI CONTROLLO: Verifica richiesta',
309
+ instruction: 'Digita `pass` o descrivi cosa non va.',
310
+ },
311
+ };
312
+ // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key.
313
+ const CHECKPOINT_LANGUAGE_ALIASES = {
314
+ english: 'english', en: 'english', 'en-us': 'english', 'en-gb': 'english',
315
+ spanish: 'spanish', es: 'spanish', 'español': 'spanish', espanol: 'spanish', castellano: 'spanish',
316
+ french: 'french', fr: 'french', 'français': 'french', francais: 'french',
317
+ german: 'german', de: 'german', deutsch: 'german',
318
+ portuguese: 'portuguese', pt: 'portuguese', 'pt-br': 'portuguese', 'português': 'portuguese', portugues: 'portuguese', 'brazilian portuguese': 'portuguese',
319
+ japanese: 'japanese', ja: 'japanese', '日本語': 'japanese',
320
+ chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese',
321
+ korean: 'korean', ko: 'korean', '한국어': 'korean',
322
+ italian: 'italian', it: 'italian', italiano: 'italian',
323
+ };
324
+ function resolveCheckpointFrame(responseLanguage) {
325
+ if (!responseLanguage)
326
+ return CHECKPOINT_FRAMES.english;
327
+ const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()];
328
+ return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
329
+ }
330
+ // Approximate East Asian Width ranges (Unicode property values W and F) — the
331
+ // CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each
332
+ // matching code point at 2 terminal/display columns, not 1. Padding computed
333
+ // from `.length` (UTF-16 code units) undercounts these by one column per
334
+ // wide character, visually misaligning the box's right border (#2402 review
335
+ // medium finding). Latin-script frames (English/Spanish/French/German/
336
+ // Portuguese/Italian) contain no wide code points, so displayWidth === length
337
+ // for them — no behavior change there.
338
+ function isWideCodePoint(codePoint) {
339
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
340
+ codePoint === 0x2329 || codePoint === 0x232a ||
341
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
342
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
343
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
344
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
345
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
346
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
347
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
348
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
349
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
350
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
351
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
352
+ );
353
+ }
354
+ // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
355
+ // are measured once, not as two surrogate units.
356
+ function displayWidth(text) {
357
+ let width = 0;
358
+ for (const ch of text) {
359
+ width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
360
+ }
361
+ return width;
362
+ }
363
+ // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
364
+ // that overflows the box (a longer translated string) is left unpadded rather
365
+ // than truncated — a slightly ragged border beats losing text.
366
+ function checkpointBoxLine(text) {
367
+ const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
368
+ const content = ` ${text}`;
369
+ const padLength = innerWidth - displayWidth(content);
370
+ const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
371
+ return `║${padded}║`;
372
+ }
373
+ function buildCheckpoint(currentTest, responseLanguage) {
374
+ const frame = resolveCheckpointFrame(responseLanguage);
245
375
  return [
246
376
  '╔══════════════════════════════════════════════════════════════╗',
247
- '║ CHECKPOINT: Verification Required ║',
377
+ checkpointBoxLine(frame.banner),
248
378
  '╚══════════════════════════════════════════════════════════════╝',
249
379
  '',
250
380
  `**Test ${currentTest.number}: ${currentTest.name}**`,
@@ -252,7 +382,7 @@ function buildCheckpoint(currentTest) {
252
382
  currentTest.expected,
253
383
  '',
254
384
  '──────────────────────────────────────────────────────────────',
255
- 'Type `pass` or describe what\'s wrong.',
385
+ frame.instruction,
256
386
  '──────────────────────────────────────────────────────────────',
257
387
  ].join('\n');
258
388
  }
@@ -286,12 +416,238 @@ function parseUatItems(content) {
286
416
  items.push(item);
287
417
  }
288
418
  }
419
+ items.push(...parseGapsItems(content));
289
420
  return items;
290
421
  }
422
+ // ─── parseGapsItems ───────────────────────────────────────────────────────────
423
+ /**
424
+ * Extract unresolved entries from a UAT file's `## Gaps` section (#2286).
425
+ *
426
+ * `## Gaps` records open findings as a YAML-lite bullet list (see
427
+ * `templates/UAT.md`'s `## Gaps` block: `- truth: "..."` followed by indented
428
+ * continuation lines `status:` / `reason:` / `severity:` / `test:` / etc.,
429
+ * and — for `artifacts:` / `missing:` — a further-nested `- ` sub-list).
430
+ * `parseUatItems`'s `### N.` test-block regex never looks at this section at
431
+ * all, so a UAT file whose only outstanding findings live in `## Gaps` was
432
+ * silently invisible — the false-negative this fix addresses.
433
+ *
434
+ * Reuses the existing `collectSection` seam (already used elsewhere in this
435
+ * file for `## Current Test` / `## Tests`) to locate the section. Field
436
+ * extraction is deliberately NOT done via `iterateBullets`: that seam folds
437
+ * every continuation line onto ONE space-joined `text` string per bullet,
438
+ * which erases line boundaries — a `key:` scan against that flattened text
439
+ * matches the FIRST `key:`-shaped substring anywhere, including one that
440
+ * happens to appear inside an EARLIER field's own quoted free-text value
441
+ * (e.g. `truth: "The status: resolved workflow should trigger"` — a real
442
+ * `status: failed` on the next line would never be reached, silently
443
+ * DROPPING a genuinely open gap — the exact false-negative class #2286
444
+ * exists to fix, so the fix must not reintroduce it). `splitGapsEntries` /
445
+ * `extractGapEntryFields` below instead walk the section PER LINE and only
446
+ * recognise a field at the START of its own (trimmed) line, so a `key:`
447
+ * embedded inside another field's quoted value can never be mistaken for a
448
+ * field declaration.
449
+ *
450
+ * Every entry whose `status` is present and NOT `resolved` (case-insensitive)
451
+ * is surfaced — mirroring the "ignore passing/resolved" convention already
452
+ * used for `### N.` test blocks (`result: pass` is never surfaced) and the
453
+ * VERIFICATION table-row PASS/resolved skip (`hasPassResult`, below). An
454
+ * entry with NO parseable `status:` field is surfaced too, as `result:
455
+ * 'unknown'` — #2286 is a false-NEGATIVE bug, and a `## Gaps` entry only
456
+ * exists to record an outstanding finding (a template-conformant RESOLVED
457
+ * entry always carries an explicit `status: resolved`); a garbled or
458
+ * non-conformant entry is far more likely to be an unresolved finding whose
459
+ * `status:` line failed to parse than a genuinely resolved one, so the
460
+ * fail-safe direction is to surface it rather than silently drop it.
461
+ */
462
+ function parseGapsItems(content) {
463
+ const gapsSection = collectSection(content, (h) => /^gaps$/i.test(h.text) && h.level === 2, { levelBounded: true });
464
+ if (!gapsSection)
465
+ return [];
466
+ const items = [];
467
+ for (const entryLines of splitGapsEntries(gapsSection.body)) {
468
+ const fields = extractGapEntryFields(entryLines);
469
+ const rawStatus = fields.status;
470
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
471
+ continue;
472
+ // Fail-safe: missing/garbled status surfaces as 'unknown' rather than
473
+ // being dropped (see doc comment above).
474
+ const status = rawStatus || 'unknown';
475
+ const truth = fields.truth;
476
+ const reason = fields.reason;
477
+ const testNum = fields.test;
478
+ const item = {
479
+ name: truth || rawGapEntryText(entryLines),
480
+ result: status,
481
+ category: categorizeItem(status, reason, undefined),
482
+ };
483
+ if (testNum && /^\d+$/.test(testNum))
484
+ item.test = parseInt(testNum, 10);
485
+ if (reason)
486
+ item.reason = reason;
487
+ items.push(item);
488
+ }
489
+ return items;
490
+ }
491
+ // ─── parseDeferredItems ────────────────────────────────────────────────────────
492
+ /**
493
+ * Extract unresolved entries from a phase directory's `deferred-items.md`
494
+ * (#2287) — the SCOPE BOUNDARY convention `agents/gsd-executor.md` instructs
495
+ * the executor to follow: "Log out-of-scope discoveries to `deferred-items.md`
496
+ * in the phase directory". Nothing previously read this file back, so a
497
+ * deferred entry was permanently invisible outside the phase directory.
498
+ *
499
+ * The writer convention (unchanged by this fix, per the issue's stated
500
+ * out-of-scope) emits a plain bullet list, typically under a `## Deferred
501
+ * Items` heading (see the issue's own reproduction fixture), one entry per
502
+ * top-level `- ` line with optional indented continuation lines. There is no
503
+ * mandated heading text, so if no `## Deferred Items`-shaped level-2 heading
504
+ * is found, the WHOLE file is scanned as the entry list — fail-safe, so an
505
+ * agent writing a differently-headed (or headless) deferred-items.md still
506
+ * has its entries surfaced rather than silently skipped.
507
+ *
508
+ * Reuses the same per-line field/entry-splitting seams as `parseGapsItems`
509
+ * (`splitGapsEntries`, `extractGapEntryFields`, `rawGapEntryText`) — an entry
510
+ * is RESOLVED only when it carries an explicit `status: resolved` field
511
+ * (case-insensitive), mirroring the established Gaps convention so a human or
512
+ * follow-up agent can mark a deferred item done in place, keeping
513
+ * `deferred-items.md` the single source of truth (no duplicate
514
+ * `.planning/todos/pending/*.md` entry required). Every other entry —
515
+ * including one with no `status:` field at all — is UNRESOLVED and is
516
+ * surfaced.
517
+ */
518
+ function parseDeferredItems(content) {
519
+ const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
520
+ const sectionBody = deferredSection ? deferredSection.body : content;
521
+ const items = [];
522
+ for (const entryLines of splitGapsEntries(sectionBody)) {
523
+ const fields = extractGapEntryFields(entryLines);
524
+ const rawStatus = fields.status;
525
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
526
+ continue;
527
+ const text = rawGapEntryText(entryLines);
528
+ if (!text)
529
+ continue;
530
+ items.push({
531
+ name: text,
532
+ result: 'unresolved',
533
+ category: 'deferred',
534
+ });
535
+ }
536
+ return items;
537
+ }
538
+ /**
539
+ * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
540
+ * `- ` bullet openers.
541
+ *
542
+ * The indentation of the FIRST bullet line encountered establishes the
543
+ * "top-level" indent for the whole section; any subsequent `- `-opening line
544
+ * at that same indent (or shallower) starts a NEW entry, while everything
545
+ * more deeply indented — field continuation lines (` status: ...`) AND
546
+ * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
547
+ * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
548
+ * items from being mis-split into spurious standalone entries (#2286 review
549
+ * LOW finding).
550
+ *
551
+ * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
552
+ * the template emits) are discarded. An empty/whitespace-only section body
553
+ * (heading present, no bullets) returns `[]`.
554
+ */
555
+ function splitGapsEntries(sectionBody) {
556
+ const lines = sectionBody.split('\n');
557
+ const entries = [];
558
+ let current = null;
559
+ let baseIndent = null;
560
+ for (const rawLine of lines) {
561
+ const line = rawLine.replace(/\r$/, '');
562
+ const bulletMatch = line.match(/^(\s*)-\s/);
563
+ if (bulletMatch) {
564
+ const indent = bulletMatch[1].length;
565
+ if (baseIndent === null)
566
+ baseIndent = indent;
567
+ if (indent <= baseIndent) {
568
+ if (current)
569
+ entries.push(current);
570
+ current = [line];
571
+ continue;
572
+ }
573
+ }
574
+ if (current)
575
+ current.push(line);
576
+ // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
577
+ }
578
+ if (current)
579
+ entries.push(current);
580
+ return entries;
581
+ }
582
+ /**
583
+ * Extract `key: value` fields from one Gaps entry's lines, anchored to the
584
+ * START of each (bullet-marker-stripped, trimmed) line — never scanning the
585
+ * REST of a line, so a colon-bearing phrase inside a quoted `truth`/`reason`
586
+ * value is never misread as a field declaration (see `parseGapsItems`'s doc
587
+ * comment for the false-negative this specifically guards against).
588
+ *
589
+ * Recognises a double-quoted value (`truth: "..."`, stripped of its wrapping
590
+ * quotes — the value may itself contain any character, including `:`) or a
591
+ * bare value (`status: open`, `test: 2`, `artifacts: []`) taken verbatim.
592
+ * The FIRST occurrence of a given key wins (top-level fields always precede
593
+ * any nested sub-list content in the template's field ordering); later
594
+ * `key:`-shaped nested-list content is captured, if it parses as one, but
595
+ * never overrides an already-seen top-level field.
596
+ */
597
+ function extractGapEntryFields(entryLines) {
598
+ const fields = {};
599
+ const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
600
+ entryLines.forEach((rawLine, idx) => {
601
+ const line = rawLine.replace(/\r$/, '');
602
+ // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
603
+ // a later line belongs to a nested sub-list and is handled by
604
+ // `splitGapsEntries` already folding it in — it is not itself a field
605
+ // line unless it independently matches `key: value` after stripping.
606
+ const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
607
+ const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
608
+ const m = fieldLineRe.exec(content);
609
+ if (!m)
610
+ return;
611
+ const key = m[1];
612
+ let value = m[2].trim();
613
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
614
+ value = value.slice(1, -1);
615
+ }
616
+ if (!(key in fields))
617
+ fields[key] = value;
618
+ });
619
+ return fields;
620
+ }
621
+ /** Fallback display text for a Gaps entry with no parseable `truth:` field. */
622
+ function rawGapEntryText(entryLines) {
623
+ return entryLines
624
+ .map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
625
+ .join(' ')
626
+ .trim();
627
+ }
291
628
  // ─── parseVerificationItems ───────────────────────────────────────────────────
292
- function parseVerificationItems(content, status) {
629
+ function parseVerificationItems(content, status, sourcePath) {
293
630
  const items = [];
294
631
  if (status === 'human_needed') {
632
+ // #2286: the frontmatter's structured `human_verification:` YAML array
633
+ // (extractFrontmatter) is the PRIMARY source of truth when present and
634
+ // non-empty — it fully bypasses the body-shape scan below, so a file
635
+ // whose frontmatter declares the array doesn't require any particular
636
+ // `## Human Verification` body shape at all. An absent or empty array
637
+ // (length 0) falls back to the body scan unchanged.
638
+ const frontmatter = extractFrontmatter(content, sourcePath);
639
+ const humanVerification = frontmatter.human_verification;
640
+ if (Array.isArray(humanVerification) && humanVerification.length > 0) {
641
+ humanVerification.forEach((entry, idx) => {
642
+ items.push({
643
+ test: idx + 1,
644
+ name: normalizeHumanVerificationEntry(entry),
645
+ result: 'human_needed',
646
+ category: 'human_uat',
647
+ });
648
+ });
649
+ return items;
650
+ }
295
651
  // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
296
652
  const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
297
653
  if (hvSection) {
@@ -376,11 +732,71 @@ function parseVerificationItems(content, status) {
376
732
  });
377
733
  }
378
734
  }
735
+ // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
736
+ // shape (the canonical form emitted by `templates/verification-report.md`
737
+ // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
738
+ // **Why human:** ...`), which the table/bullet/numbered per-line scan
739
+ // above never recognises (a `###`-prefixed line matches none of those
740
+ // three patterns). Uses the same `tokenizeHeadings` seam
741
+ // `parseFirstPendingTest` already uses for `### N.` sub-headings,
742
+ // applied here to the Human Verification section body. Runs in
743
+ // addition to (a union with) the scan above — the two shapes don't
744
+ // collide, so this only adds items a `###` heading page would have
745
+ // silently produced zero for.
746
+ const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
747
+ for (let i = 0; i < hvSubHeadings.length; i += 1) {
748
+ const current = hvSubHeadings[i];
749
+ const next = hvSubHeadings[i + 1];
750
+ const block = next
751
+ ? hvSection.body.slice(current.offset, next.offset)
752
+ : hvSection.body.slice(current.offset);
753
+ const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
754
+ // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
755
+ // a genuine verification item from an unrelated numbered heading.
756
+ if (!/^\s*\*\*/.test(bodyAfterHeading))
757
+ continue;
758
+ const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
759
+ if (!headingParts)
760
+ continue;
761
+ items.push({
762
+ test: parseInt(headingParts[1], 10),
763
+ name: headingParts[2].trim(),
764
+ result: 'human_needed',
765
+ category: 'human_uat',
766
+ });
767
+ }
379
768
  }
380
769
  }
381
770
  // gaps_found items are already handled by plan-phase --gaps pipeline
382
771
  return items;
383
772
  }
773
+ /**
774
+ * Normalize a single `human_verification:` frontmatter array entry (#2286)
775
+ * into a display-ready name.
776
+ *
777
+ * #2286 review (LOW finding): `extractFrontmatter`'s generic array-item
778
+ * parser (`src/frontmatter.cts`, the `line.trim().startsWith('- ')` branch)
779
+ * has NO notion of nested key/value objects — regardless of whether the
780
+ * source YAML was authored as `- test: "..."` (an implied-but-unsupported
781
+ * shorthand) or `- "plain string"`, it ALWAYS pushes the raw post-`- ` text
782
+ * (with only a single layer of wrapping quotes stripped) as a plain string.
783
+ * There is therefore no reliable signal here to distinguish a genuine
784
+ * `key: value`-shaped pseudo-field from a legitimate plain string that
785
+ * itself happens to start with a word and a colon (e.g. `"Confirm: the
786
+ * button responds"`). A prior version of this function stripped a leading
787
+ * `word:` prefix on the assumption it was always a flattened nested-object
788
+ * key — that assumption is false, and it silently truncated real plain-string
789
+ * content. No such stripping is applied: any residual wrapping-quote noise
790
+ * left by `extractFrontmatter`'s own (anchor-only) quote handling is cleaned
791
+ * up, and everything else is preserved verbatim.
792
+ */
793
+ function normalizeHumanVerificationEntry(raw) {
794
+ if (typeof raw !== 'string') {
795
+ return raw === null || raw === undefined ? '' : JSON.stringify(raw);
796
+ }
797
+ const s = raw.trim().replace(/^["']+|["']+$/g, '').trim();
798
+ return s || raw.trim();
799
+ }
384
800
  // ─── categorizeItem ───────────────────────────────────────────────────────────
385
801
  function categorizeItem(result, reason, blockedBy) {
386
802
  if (result === 'blocked' || blockedBy) {
@@ -418,4 +834,5 @@ module.exports = {
418
834
  cmdRenderCheckpoint,
419
835
  parseCurrentTest,
420
836
  buildCheckpoint,
837
+ parseDeferredItems,
421
838
  };