@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
@@ -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.
@@ -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');
@@ -93,6 +96,29 @@ function cmdAuditUat(cwd, raw) {
93
96
  }
94
97
  }
95
98
  }
99
+ // Process deferred-items.md (#2287) — the SCOPE BOUNDARY convention
100
+ // (agents/gsd-executor.md) has the executor log out-of-scope discoveries
101
+ // to this file; nothing previously read it back. Surface every
102
+ // UNRESOLVED entry (see parseDeferredItems for the resolved/unresolved
103
+ // parsing rule) as a 'deferred'-typed result, keeping deferred-items.md
104
+ // itself the single source of truth — no duplicate pending-todo entry
105
+ // required.
106
+ const deferredFile = 'deferred-items.md';
107
+ if (files.includes(deferredFile)) {
108
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, deferredFile), 'utf-8');
109
+ const items = parseDeferredItems(content);
110
+ if (items.length > 0) {
111
+ results.push({
112
+ phase: phaseNum,
113
+ phase_dir: dir,
114
+ file: deferredFile,
115
+ file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, deferredFile))),
116
+ type: 'deferred',
117
+ status: 'unresolved',
118
+ items,
119
+ });
120
+ }
121
+ }
96
122
  }
97
123
  // Compute summary
98
124
  const summary = {
@@ -127,7 +153,9 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
127
153
  if (currentTest.complete) {
128
154
  error('UAT session is already complete; no pending checkpoint to render');
129
155
  }
130
- const checkpoint = buildCheckpoint(currentTest);
156
+ const config = loadConfig(cwd);
157
+ const responseLanguage = typeof config.response_language === 'string' ? config.response_language : undefined;
158
+ const checkpoint = buildCheckpoint(currentTest, responseLanguage);
131
159
  output({
132
160
  file_path: toPosixPath(node_path_1.default.relative(cwd, resolvedPath)),
133
161
  test_number: currentTest.number,
@@ -240,11 +268,111 @@ function parseExpectedFromTestBlock(block) {
240
268
  const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
241
269
  return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
242
270
  }
243
- // ─── buildCheckpoint ──────────────────────────────────────────────────────────
244
- function buildCheckpoint(currentTest) {
271
+ const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
272
+ const CHECKPOINT_FRAMES = {
273
+ english: {
274
+ banner: 'CHECKPOINT: Verification Required',
275
+ instruction: 'Type `pass` or describe what\'s wrong.',
276
+ },
277
+ spanish: {
278
+ banner: 'PUNTO DE CONTROL: Verificación requerida',
279
+ instruction: 'Escribe `pass` o describe qué está mal.',
280
+ },
281
+ french: {
282
+ banner: 'POINT DE CONTRÔLE : Vérification requise',
283
+ instruction: 'Tapez `pass` ou décrivez ce qui ne va pas.',
284
+ },
285
+ german: {
286
+ banner: 'KONTROLLPUNKT: Überprüfung erforderlich',
287
+ instruction: 'Gib `pass` ein oder beschreibe, was nicht stimmt.',
288
+ },
289
+ portuguese: {
290
+ banner: 'PONTO DE VERIFICAÇÃO: Verificação necessária',
291
+ instruction: 'Digite `pass` ou descreva o que está errado.',
292
+ },
293
+ japanese: {
294
+ banner: 'チェックポイント: 検証が必要です',
295
+ instruction: '`pass` と入力するか、問題点を説明してください。',
296
+ },
297
+ chinese: {
298
+ banner: '检查点:需要验证',
299
+ instruction: '输入 `pass` 或描述问题所在。',
300
+ },
301
+ korean: {
302
+ banner: '체크포인트: 검증 필요',
303
+ instruction: '`pass`를 입력하거나 문제를 설명하세요.',
304
+ },
305
+ italian: {
306
+ banner: 'PUNTO DI CONTROLLO: Verifica richiesta',
307
+ instruction: 'Digita `pass` o descrivi cosa non va.',
308
+ },
309
+ };
310
+ // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key.
311
+ const CHECKPOINT_LANGUAGE_ALIASES = {
312
+ english: 'english', en: 'english', 'en-us': 'english', 'en-gb': 'english',
313
+ spanish: 'spanish', es: 'spanish', 'español': 'spanish', espanol: 'spanish', castellano: 'spanish',
314
+ french: 'french', fr: 'french', 'français': 'french', francais: 'french',
315
+ german: 'german', de: 'german', deutsch: 'german',
316
+ portuguese: 'portuguese', pt: 'portuguese', 'pt-br': 'portuguese', 'português': 'portuguese', portugues: 'portuguese', 'brazilian portuguese': 'portuguese',
317
+ japanese: 'japanese', ja: 'japanese', '日本語': 'japanese',
318
+ chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese',
319
+ korean: 'korean', ko: 'korean', '한국어': 'korean',
320
+ italian: 'italian', it: 'italian', italiano: 'italian',
321
+ };
322
+ function resolveCheckpointFrame(responseLanguage) {
323
+ if (!responseLanguage)
324
+ return CHECKPOINT_FRAMES.english;
325
+ const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()];
326
+ return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
327
+ }
328
+ // Approximate East Asian Width ranges (Unicode property values W and F) — the
329
+ // CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each
330
+ // matching code point at 2 terminal/display columns, not 1. Padding computed
331
+ // from `.length` (UTF-16 code units) undercounts these by one column per
332
+ // wide character, visually misaligning the box's right border (#2402 review
333
+ // medium finding). Latin-script frames (English/Spanish/French/German/
334
+ // Portuguese/Italian) contain no wide code points, so displayWidth === length
335
+ // for them — no behavior change there.
336
+ function isWideCodePoint(codePoint) {
337
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
338
+ codePoint === 0x2329 || codePoint === 0x232a ||
339
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
340
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
341
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
342
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
343
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
344
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
345
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
346
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
347
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
348
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
349
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
350
+ );
351
+ }
352
+ // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
353
+ // are measured once, not as two surrogate units.
354
+ function displayWidth(text) {
355
+ let width = 0;
356
+ for (const ch of text) {
357
+ width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
358
+ }
359
+ return width;
360
+ }
361
+ // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
362
+ // that overflows the box (a longer translated string) is left unpadded rather
363
+ // than truncated — a slightly ragged border beats losing text.
364
+ function checkpointBoxLine(text) {
365
+ const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
366
+ const content = ` ${text}`;
367
+ const padLength = innerWidth - displayWidth(content);
368
+ const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
369
+ return `║${padded}║`;
370
+ }
371
+ function buildCheckpoint(currentTest, responseLanguage) {
372
+ const frame = resolveCheckpointFrame(responseLanguage);
245
373
  return [
246
374
  '╔══════════════════════════════════════════════════════════════╗',
247
- '║ CHECKPOINT: Verification Required ║',
375
+ checkpointBoxLine(frame.banner),
248
376
  '╚══════════════════════════════════════════════════════════════╝',
249
377
  '',
250
378
  `**Test ${currentTest.number}: ${currentTest.name}**`,
@@ -252,7 +380,7 @@ function buildCheckpoint(currentTest) {
252
380
  currentTest.expected,
253
381
  '',
254
382
  '──────────────────────────────────────────────────────────────',
255
- 'Type `pass` or describe what\'s wrong.',
383
+ frame.instruction,
256
384
  '──────────────────────────────────────────────────────────────',
257
385
  ].join('\n');
258
386
  }
@@ -286,12 +414,238 @@ function parseUatItems(content) {
286
414
  items.push(item);
287
415
  }
288
416
  }
417
+ items.push(...parseGapsItems(content));
289
418
  return items;
290
419
  }
420
+ // ─── parseGapsItems ───────────────────────────────────────────────────────────
421
+ /**
422
+ * Extract unresolved entries from a UAT file's `## Gaps` section (#2286).
423
+ *
424
+ * `## Gaps` records open findings as a YAML-lite bullet list (see
425
+ * `templates/UAT.md`'s `## Gaps` block: `- truth: "..."` followed by indented
426
+ * continuation lines `status:` / `reason:` / `severity:` / `test:` / etc.,
427
+ * and — for `artifacts:` / `missing:` — a further-nested `- ` sub-list).
428
+ * `parseUatItems`'s `### N.` test-block regex never looks at this section at
429
+ * all, so a UAT file whose only outstanding findings live in `## Gaps` was
430
+ * silently invisible — the false-negative this fix addresses.
431
+ *
432
+ * Reuses the existing `collectSection` seam (already used elsewhere in this
433
+ * file for `## Current Test` / `## Tests`) to locate the section. Field
434
+ * extraction is deliberately NOT done via `iterateBullets`: that seam folds
435
+ * every continuation line onto ONE space-joined `text` string per bullet,
436
+ * which erases line boundaries — a `key:` scan against that flattened text
437
+ * matches the FIRST `key:`-shaped substring anywhere, including one that
438
+ * happens to appear inside an EARLIER field's own quoted free-text value
439
+ * (e.g. `truth: "The status: resolved workflow should trigger"` — a real
440
+ * `status: failed` on the next line would never be reached, silently
441
+ * DROPPING a genuinely open gap — the exact false-negative class #2286
442
+ * exists to fix, so the fix must not reintroduce it). `splitGapsEntries` /
443
+ * `extractGapEntryFields` below instead walk the section PER LINE and only
444
+ * recognise a field at the START of its own (trimmed) line, so a `key:`
445
+ * embedded inside another field's quoted value can never be mistaken for a
446
+ * field declaration.
447
+ *
448
+ * Every entry whose `status` is present and NOT `resolved` (case-insensitive)
449
+ * is surfaced — mirroring the "ignore passing/resolved" convention already
450
+ * used for `### N.` test blocks (`result: pass` is never surfaced) and the
451
+ * VERIFICATION table-row PASS/resolved skip (`hasPassResult`, below). An
452
+ * entry with NO parseable `status:` field is surfaced too, as `result:
453
+ * 'unknown'` — #2286 is a false-NEGATIVE bug, and a `## Gaps` entry only
454
+ * exists to record an outstanding finding (a template-conformant RESOLVED
455
+ * entry always carries an explicit `status: resolved`); a garbled or
456
+ * non-conformant entry is far more likely to be an unresolved finding whose
457
+ * `status:` line failed to parse than a genuinely resolved one, so the
458
+ * fail-safe direction is to surface it rather than silently drop it.
459
+ */
460
+ function parseGapsItems(content) {
461
+ const gapsSection = collectSection(content, (h) => /^gaps$/i.test(h.text) && h.level === 2, { levelBounded: true });
462
+ if (!gapsSection)
463
+ return [];
464
+ const items = [];
465
+ for (const entryLines of splitGapsEntries(gapsSection.body)) {
466
+ const fields = extractGapEntryFields(entryLines);
467
+ const rawStatus = fields.status;
468
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
469
+ continue;
470
+ // Fail-safe: missing/garbled status surfaces as 'unknown' rather than
471
+ // being dropped (see doc comment above).
472
+ const status = rawStatus || 'unknown';
473
+ const truth = fields.truth;
474
+ const reason = fields.reason;
475
+ const testNum = fields.test;
476
+ const item = {
477
+ name: truth || rawGapEntryText(entryLines),
478
+ result: status,
479
+ category: categorizeItem(status, reason, undefined),
480
+ };
481
+ if (testNum && /^\d+$/.test(testNum))
482
+ item.test = parseInt(testNum, 10);
483
+ if (reason)
484
+ item.reason = reason;
485
+ items.push(item);
486
+ }
487
+ return items;
488
+ }
489
+ // ─── parseDeferredItems ────────────────────────────────────────────────────────
490
+ /**
491
+ * Extract unresolved entries from a phase directory's `deferred-items.md`
492
+ * (#2287) — the SCOPE BOUNDARY convention `agents/gsd-executor.md` instructs
493
+ * the executor to follow: "Log out-of-scope discoveries to `deferred-items.md`
494
+ * in the phase directory". Nothing previously read this file back, so a
495
+ * deferred entry was permanently invisible outside the phase directory.
496
+ *
497
+ * The writer convention (unchanged by this fix, per the issue's stated
498
+ * out-of-scope) emits a plain bullet list, typically under a `## Deferred
499
+ * Items` heading (see the issue's own reproduction fixture), one entry per
500
+ * top-level `- ` line with optional indented continuation lines. There is no
501
+ * mandated heading text, so if no `## Deferred Items`-shaped level-2 heading
502
+ * is found, the WHOLE file is scanned as the entry list — fail-safe, so an
503
+ * agent writing a differently-headed (or headless) deferred-items.md still
504
+ * has its entries surfaced rather than silently skipped.
505
+ *
506
+ * Reuses the same per-line field/entry-splitting seams as `parseGapsItems`
507
+ * (`splitGapsEntries`, `extractGapEntryFields`, `rawGapEntryText`) — an entry
508
+ * is RESOLVED only when it carries an explicit `status: resolved` field
509
+ * (case-insensitive), mirroring the established Gaps convention so a human or
510
+ * follow-up agent can mark a deferred item done in place, keeping
511
+ * `deferred-items.md` the single source of truth (no duplicate
512
+ * `.planning/todos/pending/*.md` entry required). Every other entry —
513
+ * including one with no `status:` field at all — is UNRESOLVED and is
514
+ * surfaced.
515
+ */
516
+ function parseDeferredItems(content) {
517
+ const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
518
+ const sectionBody = deferredSection ? deferredSection.body : content;
519
+ const items = [];
520
+ for (const entryLines of splitGapsEntries(sectionBody)) {
521
+ const fields = extractGapEntryFields(entryLines);
522
+ const rawStatus = fields.status;
523
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
524
+ continue;
525
+ const text = rawGapEntryText(entryLines);
526
+ if (!text)
527
+ continue;
528
+ items.push({
529
+ name: text,
530
+ result: 'unresolved',
531
+ category: 'deferred',
532
+ });
533
+ }
534
+ return items;
535
+ }
536
+ /**
537
+ * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
538
+ * `- ` bullet openers.
539
+ *
540
+ * The indentation of the FIRST bullet line encountered establishes the
541
+ * "top-level" indent for the whole section; any subsequent `- `-opening line
542
+ * at that same indent (or shallower) starts a NEW entry, while everything
543
+ * more deeply indented — field continuation lines (` status: ...`) AND
544
+ * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
545
+ * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
546
+ * items from being mis-split into spurious standalone entries (#2286 review
547
+ * LOW finding).
548
+ *
549
+ * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
550
+ * the template emits) are discarded. An empty/whitespace-only section body
551
+ * (heading present, no bullets) returns `[]`.
552
+ */
553
+ function splitGapsEntries(sectionBody) {
554
+ const lines = sectionBody.split('\n');
555
+ const entries = [];
556
+ let current = null;
557
+ let baseIndent = null;
558
+ for (const rawLine of lines) {
559
+ const line = rawLine.replace(/\r$/, '');
560
+ const bulletMatch = line.match(/^(\s*)-\s/);
561
+ if (bulletMatch) {
562
+ const indent = bulletMatch[1].length;
563
+ if (baseIndent === null)
564
+ baseIndent = indent;
565
+ if (indent <= baseIndent) {
566
+ if (current)
567
+ entries.push(current);
568
+ current = [line];
569
+ continue;
570
+ }
571
+ }
572
+ if (current)
573
+ current.push(line);
574
+ // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
575
+ }
576
+ if (current)
577
+ entries.push(current);
578
+ return entries;
579
+ }
580
+ /**
581
+ * Extract `key: value` fields from one Gaps entry's lines, anchored to the
582
+ * START of each (bullet-marker-stripped, trimmed) line — never scanning the
583
+ * REST of a line, so a colon-bearing phrase inside a quoted `truth`/`reason`
584
+ * value is never misread as a field declaration (see `parseGapsItems`'s doc
585
+ * comment for the false-negative this specifically guards against).
586
+ *
587
+ * Recognises a double-quoted value (`truth: "..."`, stripped of its wrapping
588
+ * quotes — the value may itself contain any character, including `:`) or a
589
+ * bare value (`status: open`, `test: 2`, `artifacts: []`) taken verbatim.
590
+ * The FIRST occurrence of a given key wins (top-level fields always precede
591
+ * any nested sub-list content in the template's field ordering); later
592
+ * `key:`-shaped nested-list content is captured, if it parses as one, but
593
+ * never overrides an already-seen top-level field.
594
+ */
595
+ function extractGapEntryFields(entryLines) {
596
+ const fields = {};
597
+ const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
598
+ entryLines.forEach((rawLine, idx) => {
599
+ const line = rawLine.replace(/\r$/, '');
600
+ // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
601
+ // a later line belongs to a nested sub-list and is handled by
602
+ // `splitGapsEntries` already folding it in — it is not itself a field
603
+ // line unless it independently matches `key: value` after stripping.
604
+ const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
605
+ const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
606
+ const m = fieldLineRe.exec(content);
607
+ if (!m)
608
+ return;
609
+ const key = m[1];
610
+ let value = m[2].trim();
611
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
612
+ value = value.slice(1, -1);
613
+ }
614
+ if (!(key in fields))
615
+ fields[key] = value;
616
+ });
617
+ return fields;
618
+ }
619
+ /** Fallback display text for a Gaps entry with no parseable `truth:` field. */
620
+ function rawGapEntryText(entryLines) {
621
+ return entryLines
622
+ .map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
623
+ .join(' ')
624
+ .trim();
625
+ }
291
626
  // ─── parseVerificationItems ───────────────────────────────────────────────────
292
627
  function parseVerificationItems(content, status) {
293
628
  const items = [];
294
629
  if (status === 'human_needed') {
630
+ // #2286: the frontmatter's structured `human_verification:` YAML array
631
+ // (extractFrontmatter) is the PRIMARY source of truth when present and
632
+ // non-empty — it fully bypasses the body-shape scan below, so a file
633
+ // whose frontmatter declares the array doesn't require any particular
634
+ // `## Human Verification` body shape at all. An absent or empty array
635
+ // (length 0) falls back to the body scan unchanged.
636
+ const frontmatter = extractFrontmatter(content);
637
+ const humanVerification = frontmatter.human_verification;
638
+ if (Array.isArray(humanVerification) && humanVerification.length > 0) {
639
+ humanVerification.forEach((entry, idx) => {
640
+ items.push({
641
+ test: idx + 1,
642
+ name: normalizeHumanVerificationEntry(entry),
643
+ result: 'human_needed',
644
+ category: 'human_uat',
645
+ });
646
+ });
647
+ return items;
648
+ }
295
649
  // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
296
650
  const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
297
651
  if (hvSection) {
@@ -376,11 +730,71 @@ function parseVerificationItems(content, status) {
376
730
  });
377
731
  }
378
732
  }
733
+ // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
734
+ // shape (the canonical form emitted by `templates/verification-report.md`
735
+ // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
736
+ // **Why human:** ...`), which the table/bullet/numbered per-line scan
737
+ // above never recognises (a `###`-prefixed line matches none of those
738
+ // three patterns). Uses the same `tokenizeHeadings` seam
739
+ // `parseFirstPendingTest` already uses for `### N.` sub-headings,
740
+ // applied here to the Human Verification section body. Runs in
741
+ // addition to (a union with) the scan above — the two shapes don't
742
+ // collide, so this only adds items a `###` heading page would have
743
+ // silently produced zero for.
744
+ const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
745
+ for (let i = 0; i < hvSubHeadings.length; i += 1) {
746
+ const current = hvSubHeadings[i];
747
+ const next = hvSubHeadings[i + 1];
748
+ const block = next
749
+ ? hvSection.body.slice(current.offset, next.offset)
750
+ : hvSection.body.slice(current.offset);
751
+ const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
752
+ // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
753
+ // a genuine verification item from an unrelated numbered heading.
754
+ if (!/^\s*\*\*/.test(bodyAfterHeading))
755
+ continue;
756
+ const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
757
+ if (!headingParts)
758
+ continue;
759
+ items.push({
760
+ test: parseInt(headingParts[1], 10),
761
+ name: headingParts[2].trim(),
762
+ result: 'human_needed',
763
+ category: 'human_uat',
764
+ });
765
+ }
379
766
  }
380
767
  }
381
768
  // gaps_found items are already handled by plan-phase --gaps pipeline
382
769
  return items;
383
770
  }
771
+ /**
772
+ * Normalize a single `human_verification:` frontmatter array entry (#2286)
773
+ * into a display-ready name.
774
+ *
775
+ * #2286 review (LOW finding): `extractFrontmatter`'s generic array-item
776
+ * parser (`src/frontmatter.cts`, the `line.trim().startsWith('- ')` branch)
777
+ * has NO notion of nested key/value objects — regardless of whether the
778
+ * source YAML was authored as `- test: "..."` (an implied-but-unsupported
779
+ * shorthand) or `- "plain string"`, it ALWAYS pushes the raw post-`- ` text
780
+ * (with only a single layer of wrapping quotes stripped) as a plain string.
781
+ * There is therefore no reliable signal here to distinguish a genuine
782
+ * `key: value`-shaped pseudo-field from a legitimate plain string that
783
+ * itself happens to start with a word and a colon (e.g. `"Confirm: the
784
+ * button responds"`). A prior version of this function stripped a leading
785
+ * `word:` prefix on the assumption it was always a flattened nested-object
786
+ * key — that assumption is false, and it silently truncated real plain-string
787
+ * content. No such stripping is applied: any residual wrapping-quote noise
788
+ * left by `extractFrontmatter`'s own (anchor-only) quote handling is cleaned
789
+ * up, and everything else is preserved verbatim.
790
+ */
791
+ function normalizeHumanVerificationEntry(raw) {
792
+ if (typeof raw !== 'string') {
793
+ return raw === null || raw === undefined ? '' : JSON.stringify(raw);
794
+ }
795
+ const s = raw.trim().replace(/^["']+|["']+$/g, '').trim();
796
+ return s || raw.trim();
797
+ }
384
798
  // ─── categorizeItem ───────────────────────────────────────────────────────────
385
799
  function categorizeItem(result, reason, blockedBy) {
386
800
  if (result === 'blocked' || blockedBy) {
@@ -418,4 +832,5 @@ module.exports = {
418
832
  cmdRenderCheckpoint,
419
833
  parseCurrentTest,
420
834
  buildCheckpoint,
835
+ parseDeferredItems,
421
836
  };
@@ -39,28 +39,32 @@ exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
39
39
  exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
40
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
41
  const phaseIdMod = require("./phase-id.cjs");
42
- const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
42
+ const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, } = phaseIdMod;
43
43
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
44
44
  // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
45
45
  // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
46
46
  exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
47
47
  // Extracts the full phase token from a directory name, including milestone-prefixed
48
48
  // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
49
- // #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a
49
+ // #2043: a *continuation* sub-phase segment must be zero-padded, so a
50
50
  // single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
51
- // NOT absorbed — it captures "46", not "46-6". The first component stays "\d+"
51
+ // NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
52
+ // exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
53
+ // "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
54
+ // The first component stays "\d+"
52
55
  // (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
53
56
  // milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
54
57
  // "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
55
58
  // that starts with a digit terminate the token.
56
- exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
59
+ exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
57
60
  exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
58
61
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
59
62
  function canonicalPlanStem(stem) {
60
- // #2043: the plan component (after the phase number) must be zero-padded
61
- // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
62
- // for a "46-6" phase/plan pair.
63
- const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
63
+ // #2043: the plan component (after the phase number) must be zero-padded,
64
+ // so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
65
+ // for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
66
+ // slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
67
+ const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'));
64
68
  return m ? m[1] : stem;
65
69
  }
66
70
  // ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────