@opengsd/gsd-core 1.7.0-rc.3 → 1.7.0-rc.5

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 (115) 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 +20 -0
  4. package/README.ja-JP.md +4 -3
  5. package/README.ko-KR.md +4 -3
  6. package/README.md +4 -3
  7. package/README.pt-BR.md +4 -3
  8. package/README.zh-CN.md +4 -3
  9. package/agents/gsd-doc-classifier.md +105 -0
  10. package/agents/gsd-doc-synthesizer.md +61 -0
  11. package/agents/gsd-ui-checker.md +28 -0
  12. package/bin/install.js +652 -466
  13. package/commands/gsd/map-codebase.md +4 -4
  14. package/commands/gsd/ns-project.md +2 -1
  15. package/commands/gsd/onboard.md +46 -0
  16. package/gsd-core/bin/gsd-tools.cjs +87 -3
  17. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  18. package/gsd-core/bin/lib/audit.cjs +6 -3
  19. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  20. package/gsd-core/bin/lib/capability-registry.cjs +741 -62
  21. package/gsd-core/bin/lib/capability-state.cjs +117 -18
  22. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  23. package/gsd-core/bin/lib/capability-writer.cjs +21 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +136 -0
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +404 -0
  27. package/gsd-core/bin/lib/clusters.cjs +1 -0
  28. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  29. package/gsd-core/bin/lib/commands.cjs +7 -5
  30. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  31. package/gsd-core/bin/lib/config.cjs +96 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  33. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  34. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  35. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  36. package/gsd-core/bin/lib/init-command-router.cjs +4 -0
  37. package/gsd-core/bin/lib/init.cjs +99 -99
  38. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  39. package/gsd-core/bin/lib/install-engine.cjs +159 -16
  40. package/gsd-core/bin/lib/install-profiles.cjs +2 -0
  41. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  42. package/gsd-core/bin/lib/loop-resolver.cjs +75 -18
  43. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  44. package/gsd-core/bin/lib/milestone.cjs +3 -3
  45. package/gsd-core/bin/lib/model-resolver.cjs +69 -4
  46. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  47. package/gsd-core/bin/lib/onboard-projection.cjs +309 -0
  48. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  49. package/gsd-core/bin/lib/phase.cjs +78 -16
  50. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  51. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  53. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  54. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  56. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  57. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  58. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  59. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  60. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  61. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  62. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  63. package/gsd-core/bin/lib/state.cjs +24 -24
  64. package/gsd-core/bin/lib/surface.cjs +81 -30
  65. package/gsd-core/bin/lib/uat.cjs +4 -1
  66. package/gsd-core/bin/lib/validate.cjs +15 -6
  67. package/gsd-core/bin/lib/verify.cjs +33 -37
  68. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  69. package/gsd-core/bin/shared/model-catalog.json +11 -6
  70. package/gsd-core/references/api-coverage.md +104 -0
  71. package/gsd-core/references/gsd-run-resolver.md +8 -0
  72. package/gsd-core/references/model-profiles.md +2 -2
  73. package/gsd-core/references/planning-config.md +2 -0
  74. package/gsd-core/references/specless-probe-fallback.md +172 -0
  75. package/gsd-core/templates/config.json +2 -1
  76. package/gsd-core/templates/project.md +1 -1
  77. package/gsd-core/workflows/audit-fix.md +9 -1
  78. package/gsd-core/workflows/code-review-fix.md +7 -3
  79. package/gsd-core/workflows/code-review.md +4 -1
  80. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  81. package/gsd-core/workflows/do.md +4 -3
  82. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  83. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  84. package/gsd-core/workflows/execute-phase.md +1 -25
  85. package/gsd-core/workflows/help/modes/brief.md +2 -1
  86. package/gsd-core/workflows/help/modes/default.md +2 -1
  87. package/gsd-core/workflows/help/modes/full.md +11 -1
  88. package/gsd-core/workflows/help/modes/topic.md +1 -1
  89. package/gsd-core/workflows/onboard.md +277 -0
  90. package/gsd-core/workflows/plan-phase.md +31 -2
  91. package/gsd-core/workflows/quick.md +22 -2
  92. package/gsd-core/workflows/review.md +59 -13
  93. package/gsd-core/workflows/settings-advanced.md +5 -5
  94. package/gsd-core/workflows/settings.md +2 -2
  95. package/gsd-core/workflows/verify-phase.md +3 -2
  96. package/gsd-core/workflows/verify-work.md +38 -0
  97. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  98. package/hooks/dist/gsd-cursor-stop.js +48 -0
  99. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  100. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  101. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  102. package/hooks/gsd-cursor-pre-tool.js +76 -0
  103. package/hooks/gsd-cursor-stop.js +48 -0
  104. package/hooks/gsd-cursor-subagent-start.js +50 -0
  105. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  106. package/hooks/managed-hooks-registry.cjs +4 -0
  107. package/package.json +2 -1
  108. package/scripts/build-hooks.js +5 -1
  109. package/scripts/gen-golden-install-parity-zcode.cjs +77 -0
  110. package/scripts/lint-phase-id-drift.cjs +150 -0
  111. package/scripts/run-tests.cjs +26 -4
  112. package/scripts/sync-runtime-launcher.cjs +20 -2
  113. package/skills/gsd-map-codebase/SKILL.md +3 -3
  114. package/skills/gsd-ns-project/SKILL.md +1 -0
  115. package/skills/gsd-onboard/SKILL.md +46 -0
@@ -45,7 +45,7 @@ const configLoaderMod = require("./config-loader.cjs");
45
45
  const { loadConfig } = configLoaderMod;
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
47
  const installProfilesMod = require("./install-profiles.cjs");
48
- const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
48
+ const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod;
49
49
  // eslint-disable-next-line @typescript-eslint/no-require-imports
50
50
  const surfaceMod = require("./surface.cjs");
51
51
  const { resolveSurface } = surfaceMod;
@@ -282,22 +282,90 @@ function _loadInstalledSkillsManifest(configDir) {
282
282
  }
283
283
  return manifest;
284
284
  }
285
+ /**
286
+ * #1858 — Build a skill dependency manifest from a FLAT commands/gsd-<stem>.md
287
+ * source layout (the Claude local project install shape, where the `gsd-`
288
+ * prefix is baked into each filename at the commands/ level and there is no
289
+ * commands/gsd/ subdir). Strips the `gsd-` prefix so stems match the nested
290
+ * loader's output (gsd-validate-phase.md → validate-phase, same as nested
291
+ * validate-phase.md).
292
+ *
293
+ * Map shape is identical to loadSkillsManifest: each stem maps to its
294
+ * `requires` deps (parsed via the same shared parseRequires) and carries a
295
+ * companion `_calls_agents_<stem>` key (parsed via parseCallsAgents) so the
296
+ * flat and nested paths cannot drift.
297
+ *
298
+ * Returns an empty Map when the parent directory does not exist or contains
299
+ * no gsd-*.md files (so _resolveManifest can use size>0 as the "flat layout
300
+ * present" signal and fall through to the installed-skills branch otherwise).
301
+ */
302
+ function _loadFlatCommandsGsdManifest(commandsParentDir) {
303
+ const manifest = new Map();
304
+ let entries;
305
+ try {
306
+ entries = node_fs_1.default.readdirSync(commandsParentDir, { withFileTypes: true });
307
+ }
308
+ catch {
309
+ return manifest;
310
+ }
311
+ for (const entry of entries) {
312
+ if (!entry.isFile())
313
+ continue;
314
+ if (!entry.name.startsWith('gsd-'))
315
+ continue;
316
+ if (!entry.name.endsWith('.md'))
317
+ continue;
318
+ // Strip 'gsd-' prefix (4 chars) and '.md' suffix (3 chars) → stem.
319
+ const stem = entry.name.slice(4, -3);
320
+ if (!stem)
321
+ continue;
322
+ // Mirror loadSkillsManifest's try/catch structure exactly: wrap read +
323
+ // parse + set together so an unreadable file OR a thrown parser degrades
324
+ // both keys to [] (parity; closes the latent catch-scope drift a reviewer
325
+ // flagged — both parsers are non-throwing today, but the structural
326
+ // match future-proofs the "identical Map shape" contract).
327
+ try {
328
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(commandsParentDir, entry.name), 'utf8');
329
+ manifest.set(stem, parseRequires(content));
330
+ manifest.set(`_calls_agents_${stem}`, parseCallsAgents(content));
331
+ }
332
+ catch {
333
+ manifest.set(stem, []);
334
+ manifest.set(`_calls_agents_${stem}`, []);
335
+ }
336
+ }
337
+ return manifest;
338
+ }
285
339
  /**
286
340
  * Resolve the skill dependency manifest for capability-state resolution.
287
341
  *
288
- * Resolution order (fixes #1160 — installed-runtime capability surface):
289
- * 1. If commandsGsdDir exists, load from source (repo-checkout behavior).
290
- * 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
342
+ * Resolution order:
343
+ * 1. If commandsGsdDir exists, load from the nested source layout
344
+ * (repo-checkout behavior: <repo>/commands/gsd/*.md).
345
+ * 2. #1858 — otherwise, if the flat source layout is present (gsd-<stem>.md
346
+ * files in dirname(commandsGsdDir)), load from there. This is the Claude
347
+ * local project install shape where commands/gsd/ does not exist but
348
+ * commands/gsd-<stem>.md files do.
349
+ * 3. #1160 — otherwise, fall back to installed skills at
350
+ * configDir/skills/gsd-[stem]/SKILL.md.
291
351
  *
292
- * In an installed runtime the commands/gsd source tree is absent; only the
293
- * skills/ layout exists. Returning an empty manifest caused resolveSurface to
294
- * materialise the full-sentinel to an empty Set, making every capability appear
295
- * unsurfaced even when the skill was physically installed.
352
+ * In an installed runtime both source trees are absent; only the skills/
353
+ * layout exists. Returning an empty manifest caused resolveSurface to
354
+ * materialise the full-sentinel to an empty Set, making every skill-bearing
355
+ * capability appear unsurfaced even when the skill was physically installed
356
+ * (#1160) or authored as a flat command file (#1858).
296
357
  */
297
358
  function _resolveManifest(commandsGsdDir, configDir) {
298
359
  if (node_fs_1.default.existsSync(commandsGsdDir)) {
299
360
  return loadSkillsManifest(commandsGsdDir);
300
361
  }
362
+ // #1858: flat source layout — gsd-<stem>.md files at dirname(commandsGsdDir).
363
+ // Only claim the flat branch when it actually has gsd-*.md files; otherwise
364
+ // fall through to the installed-skills branch (a commands/ dir with no gsd
365
+ // files must not shadow an installed skills/ tree).
366
+ const flat = _loadFlatCommandsGsdManifest(node_path_1.default.dirname(commandsGsdDir));
367
+ if (flat.size > 0)
368
+ return flat;
301
369
  return _loadInstalledSkillsManifest(configDir);
302
370
  }
303
371
  /**
@@ -334,7 +402,7 @@ function _resolveManifest(commandsGsdDir, configDir) {
334
402
  * @param raw Whether to emit raw JSON (io.output raw mode)
335
403
  * @param _options Reserved for future use
336
404
  */
337
- function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
405
+ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride, runtimeOverride) {
338
406
  const warnings = [];
339
407
  // Resolve runtimeConfigDir using the canonical runtime-homes resolver.
340
408
  // When not provided, the active runtime is detected via the canonical
@@ -350,13 +418,40 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
350
418
  try {
351
419
  // eslint-disable-next-line @typescript-eslint/no-require-imports
352
420
  const runtimeHomes = require('./runtime-homes.cjs');
353
- // eslint-disable-next-line @typescript-eslint/no-require-imports
354
- const runtimeSlash = require('./runtime-slash.cjs');
355
- // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
356
- // resolveRuntime reads config.json directly (no side effects) and returns
357
- // a lowercased canonical runtime name.
358
- const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
359
- resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
421
+ // #2003: an explicit --runtime override bypasses the persisted-runtime
422
+ // fallback (GSD_RUNTIME → config.runtime → 'claude') so, e.g., a repo with
423
+ // persisted runtime:"codex" resolves the Claude config dir when the operator
424
+ // is driving from Claude Code. Canonicalize via runtime-name-policy (handles
425
+ // aliases like codex-app → codex); if canonicalization yields nothing, fall
426
+ // through to the persisted-runtime resolution below. Mirrors the update-
427
+ // context / effort sync precedent (read/diagnostic paths accepting both
428
+ // --config-dir and --runtime).
429
+ if (typeof runtimeOverride === 'string' && runtimeOverride.trim() !== '') {
430
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
431
+ const runtimeNamePolicy = require('./runtime-name-policy.cjs');
432
+ const canonical = runtimeNamePolicy.canonicalizeRuntimeName(runtimeOverride);
433
+ if (canonical) {
434
+ resolvedConfigDir = runtimeHomes.getGlobalConfigDir(canonical);
435
+ }
436
+ else {
437
+ // #2003: unknown runtime override — warn (don't silently ignore the
438
+ // explicit input) and fall through to persisted-runtime resolution.
439
+ // Avoids a silent-wrong-result on this diagnostic command for typos
440
+ // (e.g. "cluade") or runtimes known to runtime-homes but not yet to
441
+ // the alias manifest (e.g. "grok"). The warning surfaces via the
442
+ // `warnings[]` channel consumed by cmdCapabilityState/cmdLoopRenderHooks.
443
+ warnings.push(`--runtime "${runtimeOverride}" is not a known runtime; falling back to auto-detected/persisted runtime resolution`);
444
+ }
445
+ }
446
+ if (!resolvedConfigDir) {
447
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
448
+ const runtimeSlash = require('./runtime-slash.cjs');
449
+ // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
450
+ // resolveRuntime reads config.json directly (no side effects) and returns
451
+ // a lowercased canonical runtime name.
452
+ const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
453
+ resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
454
+ }
360
455
  }
361
456
  catch {
362
457
  // Defensive fallback: use ~/.claude if the canonical resolver throws.
@@ -450,8 +545,11 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
450
545
  capabilities: result.capabilities,
451
546
  };
452
547
  }
453
- function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
454
- const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
548
+ function cmdCapabilityState(cwd, runtimeConfigDir, raw, options = {}) {
549
+ // #2003: thread an explicit --runtime override so the config-dir resolution
550
+ // bypasses the persisted-runtime fallback (GSD_RUNTIME → config.runtime).
551
+ const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined;
552
+ const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, undefined, runtimeOverride);
455
553
  for (const warning of result.warnings) {
456
554
  coreError(`capability state: ${warning}`);
457
555
  }
@@ -491,6 +589,7 @@ module.exports = {
491
589
  // Exported for tests
492
590
  _resolveCommandsGsdDir,
493
591
  _loadInstalledSkillsManifest,
592
+ _loadFlatCommandsGsdManifest,
494
593
  _resolveManifest,
495
594
  _isSafePropKey,
496
595
  };
@@ -709,7 +709,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
709
709
  // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
710
710
  // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
711
711
  // host exposes no extension surface (engine owns the bus, e.g. VS Code).
712
- const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
712
+ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
713
713
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
714
714
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
715
715
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
@@ -77,8 +77,17 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
77
77
  const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
78
78
  const resolvedConfigDir = before.runtimeConfigDir;
79
79
  // ── Load registry ─────────────────────────────────────────────────────────
80
+ // Issue #2045 (DEFECT 2): validate against the COMPOSED overlay-aware registry
81
+ // (first-party ∪ accepted overlays), mirroring capability-state.cts:547-551.
82
+ // The frozen capability-registry.cjs only knows first-party ids, so a third-
83
+ // party cap failed the membership check below → "unknown capability" even
84
+ // though resolveCapabilityRuntimeState (the `before` snapshot, line 150) already
85
+ // knew about it. loadRegistry is non-throwing and first-party-wins, so a
86
+ // malformed overlay is skipped (never crashes the writer); a truly-unknown id
87
+ // is STILL rejected because it is absent from the composed capabilities map.
80
88
  // eslint-disable-next-line @typescript-eslint/no-require-imports
81
- const registry = require('./capability-registry.cjs');
89
+ const { loadRegistry } = require('./capability-loader.cjs');
90
+ const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
82
91
  const capabilitiesMap = (registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
83
92
  ? registry['capabilities']
84
93
  : {});
@@ -250,8 +259,18 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
250
259
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
251
260
  const commandsGsdDir = _resolveCommandsGsdDir();
252
261
  const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
262
+ // #1575: applySurface now accepts opts.resolveAttribution so surface-path
263
+ // agents get the same Co-Authored-By trailer as the install path. The
264
+ // resolver is not threaded here yet — the CLI command handler does not have
265
+ // access to getCommitAttribution (which lives in bin/install.js). Until that
266
+ // is refactored into a shared module, surface-path agents for descriptor-
267
+ // driven runtimes will lack the Co-Authored-By trailer that install adds.
268
+ // Parity is proven when resolveAttribution IS provided (see
269
+ // tests/issue-1575-agent-descriptor-parity.test.cjs).
253
270
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
254
- applySurface(resolvedConfigDir, layout, manifest, undefined, registry);
271
+ applySurface(resolvedConfigDir, layout, manifest, undefined, registry, opts?.materialize?.resolveAttribution
272
+ ? { resolveAttribution: opts.materialize.resolveAttribution }
273
+ : undefined);
255
274
  }
256
275
  catch (err) {
257
276
  const msg = err instanceof Error ? err.message : String(err);
@@ -37,6 +37,9 @@ const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
37
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
38
  const gatePredicateEval = require("./gate-predicate-evaluator.cjs");
39
39
  const { evaluatePredicate } = gatePredicateEval;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const apiCoverageMod = require("./api-coverage.cjs");
42
+ const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod;
40
43
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
41
44
  // ─── Helpers ──────────────────────────────────────────────────────────────────
42
45
  function normalizePhrase(text) {
@@ -131,10 +134,13 @@ function loadPlanContents(phaseDir) {
131
134
  }
132
135
  }
133
136
  const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
134
- const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
137
+ const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
135
138
  function stripCommentsAndFences(text) {
136
139
  // HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
137
- const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
140
+ // Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
141
+ // so downstream tags are preserved (unlike a `(?:-->|$)` fallback, which would
142
+ // wipe to EOF and fail-close the decision-coverage gate).
143
+ const htmlStripped = text.replace(/<!--(?:(?!<!--)[\s\S])*?-->/g, ' ');
138
144
  // Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
139
145
  // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
140
146
  return (0, markdown_sectionizer_cjs_1.stripFencedCode)(htmlStripped).text;
@@ -876,6 +882,233 @@ function cmdCheckPredicate(projectDir, args, raw) {
876
882
  }
877
883
  output(result, raw, undefined);
878
884
  }
885
+ // ─── api-coverage-verify-pre ──────────────────────────────────────────────────
886
+ /**
887
+ * api-coverage.verify-pre: BLOCKING seal-time gate for the ai-integration
888
+ * capability (#1562). Enforces "Full API Coverage by Default — Opt Out, Never
889
+ * Opt In." A phase that integrates an external API/SDK/service may not seal
890
+ * until a COVERAGE.md matrix enumerates the surface and every non-integrated
891
+ * capability is an explicit, reasoned opt-out.
892
+ *
893
+ * Contract (two touch points composed into one check):
894
+ * 1. If COVERAGE.md exists in the phase dir → validate it (acceptance #2).
895
+ * Block on any validation error (empty matrix, OPT-OUT without reason,
896
+ * duplicate/empty capability).
897
+ * 2. If COVERAGE.md is absent → run detectApiIntegration over the phase scope
898
+ * (PLAN.md body, then ROADMAP phase section as fallback). If a strong
899
+ * external-API-integration signal is detected → BLOCK ("integration
900
+ * detected without coverage matrix"). If no signal → PASS (treat as a
901
+ * non-API phase; acceptance #4 — low false positives).
902
+ *
903
+ * The detector is the FALLBACK for the "nobody decided / forgot the matrix"
904
+ * case; the primary path is the plan:pre contribution prompting COVERAGE.md.
905
+ *
906
+ * Args: check api-coverage.verify-pre <phase-dir>
907
+ * Emits the uniform gate contract: { block, passed, message, ...details }.
908
+ */
909
+ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
910
+ const phaseArg = typeof args[2] === 'string' ? args[2] : '';
911
+ if (!phaseArg) {
912
+ error('api-coverage.verify-pre requires a phase argument: check api-coverage.verify-pre <phase-dir-or-token>', ERROR_REASON.SDK_MISSING_ARG);
913
+ return;
914
+ }
915
+ const pDir = planningDir(projectDir);
916
+ const phasesRoot = node_path_1.default.join(pDir, 'phases');
917
+ // SECURITY (path traversal): the phase argument is taken ONLY as a phase
918
+ // token — its basename — and resolved by findPhaseInternal strictly under
919
+ // .planning/phases/ (or a milestone archive). The raw arg is never used as a
920
+ // path, so `..`, absolute paths, and arbitrary directories cannot reach a
921
+ // file read. Mirrors cmdVerifySchemaDrift's token-match approach.
922
+ let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
923
+ // A token like ".." or "." carries no phase identity → unresolvable.
924
+ if (token === '.' || token === '..')
925
+ token = '';
926
+ // Not a GSD project (no phases tree at all) → fail-open: nothing to gate.
927
+ if (!node_fs_1.default.existsSync(phasesRoot)) {
928
+ output({
929
+ block: false,
930
+ passed: true,
931
+ coverage_present: false,
932
+ detected: false,
933
+ message: 'api-coverage: no .planning/phases directory; gate skipped (not a GSD project layout)',
934
+ }, raw, undefined);
935
+ return;
936
+ }
937
+ // Resolve the phase dir under the contained phases root.
938
+ let resolvedDir = null;
939
+ let phaseNumber = '';
940
+ if (token) {
941
+ const found = findPhaseInternal(projectDir, token);
942
+ if (found && found.directory) {
943
+ resolvedDir = found.directory;
944
+ phaseNumber = found.phase_number || '';
945
+ }
946
+ }
947
+ if (!resolvedDir) {
948
+ // The phases tree EXISTS but THIS phase could not be resolved. For a
949
+ // BLOCKING gate, fail-closed: a missing phase dir must not silently bypass
950
+ // the coverage requirement. (Distinguished from "no .planning at all"
951
+ // above, which is a genuine non-GSD-project → pass.)
952
+ output({
953
+ block: true,
954
+ passed: false,
955
+ coverage_present: false,
956
+ detected: false,
957
+ phase_lookup_failed: true,
958
+ message: `api-coverage: could not resolve phase "${phaseArg}" under .planning/phases/. ` +
959
+ 'Resolve the phase directory (or produce COVERAGE.md) before sealing.',
960
+ }, raw, undefined);
961
+ return;
962
+ }
963
+ // Defense-in-depth: the resolved dir must be inside the phases root (or a
964
+ // milestone archive under .planning/milestones).
965
+ const milestonesRoot = node_path_1.default.join(pDir, 'milestones');
966
+ if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) {
967
+ output({
968
+ block: true,
969
+ passed: false,
970
+ coverage_present: false,
971
+ detected: false,
972
+ message: 'api-coverage: resolved phase dir escapes .planning/ — refusing to evaluate',
973
+ }, raw, undefined);
974
+ return;
975
+ }
976
+ // (1) locate COVERAGE.md — prefer the exact name, then a single *-COVERAGE.md.
977
+ let coverageFile = '';
978
+ let suffixed = [];
979
+ try {
980
+ const entries = node_fs_1.default.readdirSync(resolvedDir, { withFileTypes: true });
981
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
982
+ const exact = files.find((f) => /^COVERAGE\.md$/i.test(f));
983
+ if (exact) {
984
+ coverageFile = exact;
985
+ }
986
+ else {
987
+ suffixed = files.filter((f) => /-COVERAGE\.md$/i.test(f)).sort();
988
+ if (suffixed.length === 1)
989
+ coverageFile = suffixed[0];
990
+ }
991
+ }
992
+ catch {
993
+ // readdir failure → treat as no matrix readable; fall through to detection.
994
+ }
995
+ if (coverageFile) {
996
+ let matrixText;
997
+ try {
998
+ matrixText = node_fs_1.default.readFileSync(node_path_1.default.join(resolvedDir, coverageFile), 'utf8');
999
+ }
1000
+ catch {
1001
+ // COVERAGE.md exists but is unreadable (EACCES/EIO/encoding). Fail-closed
1002
+ // with a useful message rather than a raw throw.
1003
+ output({
1004
+ block: true,
1005
+ passed: false,
1006
+ coverage_present: true,
1007
+ message: `api-coverage: COVERAGE.md exists but is unreadable — fix file permissions/encoding before sealing`,
1008
+ }, raw, undefined);
1009
+ return;
1010
+ }
1011
+ const v = validateCoverageMatrix(matrixText);
1012
+ if (v.valid) {
1013
+ output({
1014
+ block: false,
1015
+ passed: true,
1016
+ coverage_present: true,
1017
+ matrix: coverageFile,
1018
+ counts: v.counts,
1019
+ message: `api-coverage: matrix present (${v.counts.surface} capabilities, ${v.counts.optout} opt-out)`,
1020
+ }, raw, undefined);
1021
+ return;
1022
+ }
1023
+ // Fixed-template message (no raw cell content echoed into the LLM-facing
1024
+ // message). The structured `errors` array is safe (row-indexed, no cell
1025
+ // values) and travels as data for tooling that wants detail.
1026
+ output({
1027
+ block: true,
1028
+ passed: false,
1029
+ coverage_present: true,
1030
+ matrix: coverageFile,
1031
+ error_count: v.errors.length,
1032
+ errors: v.errors,
1033
+ message: `api-coverage: COVERAGE.md has ${v.errors.length} problem(s) — fix the matrix (every capability INTEGRATE or OPT-OUT with a reason) before sealing`,
1034
+ }, raw, undefined);
1035
+ return;
1036
+ }
1037
+ if (suffixed.length > 1) {
1038
+ output({
1039
+ block: true,
1040
+ passed: false,
1041
+ coverage_present: false,
1042
+ message: `api-coverage: multiple *-COVERAGE.md files found (${suffixed.length}) — consolidate into one COVERAGE.md before sealing`,
1043
+ }, raw, undefined);
1044
+ return;
1045
+ }
1046
+ // (2) no matrix — detect whether this phase integrates an external API.
1047
+ const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1048
+ const detection = detectApiIntegration(scopeText);
1049
+ if (detection.detected) {
1050
+ // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the
1051
+ // gate output cannot relay injected PLAN.md instructions to the orchestrator.
1052
+ const signals = detection.signals.map((s) => ({ verb: s.verb, noun: s.noun }));
1053
+ output({
1054
+ block: true,
1055
+ passed: false,
1056
+ coverage_present: false,
1057
+ detected: true,
1058
+ signals,
1059
+ message: 'api-coverage: external-API integration detected without a coverage matrix. ' +
1060
+ 'Produce COVERAGE.md enumerating the API surface (every capability INTEGRATE or ' +
1061
+ 'OPT-OUT with a reason) before sealing. Full coverage is the default.',
1062
+ }, raw, undefined);
1063
+ return;
1064
+ }
1065
+ output({
1066
+ block: false,
1067
+ passed: true,
1068
+ coverage_present: false,
1069
+ detected: false,
1070
+ message: 'api-coverage: no external-API integration detected; coverage matrix not required',
1071
+ }, raw, undefined);
1072
+ }
1073
+ /**
1074
+ * Read the phase-scope text used for API-integration detection. Uses the
1075
+ * resolved plan files (PLAN.md bodies — the planner's own words about what the
1076
+ * phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the
1077
+ * whole roadmap, which would cross-contaminate sibling phases). Strips nothing
1078
+ * here — detectApiIntegration strips fenced code itself.
1079
+ */
1080
+ function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1081
+ const chunks = [];
1082
+ try {
1083
+ const entries = node_fs_1.default.readdirSync(phaseDir, { withFileTypes: true });
1084
+ const plans = entries
1085
+ .filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name))
1086
+ .map((e) => e.name)
1087
+ .sort();
1088
+ for (const p of plans) {
1089
+ chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1090
+ }
1091
+ }
1092
+ catch {
1093
+ // ignore — fall through to roadmap
1094
+ }
1095
+ if (chunks.join('').trim().length > 0)
1096
+ return chunks.join('\n\n');
1097
+ // Fallback: ONLY this phase's ROADMAP section (not the whole file, which
1098
+ // would pollute detection with sibling-phase prose). Best-effort; absence or
1099
+ // an unresolvable section is non-fatal (detector returns not-detected).
1100
+ if (phaseNumber) {
1101
+ try {
1102
+ const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber);
1103
+ if (section)
1104
+ return section;
1105
+ }
1106
+ catch {
1107
+ // ignore
1108
+ }
1109
+ }
1110
+ return '';
1111
+ }
879
1112
  function routeCheckCommand({ args, cwd, raw }) {
880
1113
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
881
1114
  // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
@@ -905,6 +1138,12 @@ function routeCheckCommand({ args, cwd, raw }) {
905
1138
  cmdGapAnalysisPlanPost(cwd, args, raw);
906
1139
  return;
907
1140
  }
1141
+ if (subcommand === 'api-coverage-verify-pre') {
1142
+ // ai-integration capability blocking gate at verify:pre (#1562). Dot-to-
1143
+ // hyphen normalization means query "api-coverage.verify-pre" routes here.
1144
+ cmdApiCoverageVerifyPre(cwd, args, raw);
1145
+ return;
1146
+ }
908
1147
  if (subcommand === 'tdd-review-checkpoint') {
909
1148
  cmdTddReviewCheckpoint(cwd, args, raw);
910
1149
  return;
@@ -945,7 +1184,7 @@ function routeCheckCommand({ args, cwd, raw }) {
945
1184
  (0, prohibition_enforcement_cjs_1.routeProhibitionEnforcement)(args, raw);
946
1185
  return;
947
1186
  }
948
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1187
+ error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
949
1188
  }
950
1189
  module.exports = {
951
1190
  routeCheckCommand,
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * Claude orchestration command router — CLI dispatcher for
4
+ * `gsd-tools claude-orchestration <subcommand>`.
5
+ *
6
+ * #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module.
7
+ * Lets execute-phase (or any orchestrator) invoke the Workflow-backend
8
+ * detection and the Workflow-script emitter through the standard capability
9
+ * command surface (ADR-959) instead of a bare `require()`.
10
+ *
11
+ * Router signature: { args, cwd, raw, error } — identical to the other host
12
+ * routers; discovered by dispatchCapabilityCommand via the registry's
13
+ * commandFamilies index.
14
+ *
15
+ * Subcommands:
16
+ * detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
17
+ * Resolves whether the Workflow backend should activate. `--runtime`
18
+ * defaults to the GSD_RUNTIME env var (or 'unknown'). Reads the
19
+ * `claude_orchestration.*` keys from .planning/config.json. Emits
20
+ * { available, backend, reason }.
21
+ *
22
+ * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
23
+ * Reads a wave/plan manifest JSON file and emits the generated Workflow
24
+ * script + summary. The manifest shape matches emitWorkflowScript's input:
25
+ * { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ const node_fs_1 = __importDefault(require("node:fs"));
31
+ const node_path_1 = __importDefault(require("node:path"));
32
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
33
+ const io = require("./io.cjs");
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const core = require("./claude-orchestration.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const configLoader = require("./config-loader.cjs");
38
+ const { output } = io;
39
+ const { detectWorkflowBackend, emitWorkflowScript } = core;
40
+ const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
41
+ function usage(error) {
42
+ error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
43
+ ' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
44
+ ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]');
45
+ }
46
+ function argValue(args, flag) {
47
+ const i = args.indexOf(flag);
48
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
49
+ }
50
+ /**
51
+ * Detect whether the Workflow backend should activate for the current/given
52
+ * runtime. Reads `claude_orchestration.*` from the project config; runtime and
53
+ * SDK version come from flags (the orchestrator already knows these) or env.
54
+ */
55
+ function cmdDetectBackend(args, cwd, raw) {
56
+ const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
57
+ const agentSdkVersion = argValue(args, '--agent-sdk-version');
58
+ const noNested = args.includes('--no-nested-dispatch');
59
+ const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
60
+ // Resolve the claude_orchestration.* slice from the project config (federated
61
+ // keys are merged by loadConfig as a nested object). A config read failure
62
+ // degrades to inline — it must not break the core loop.
63
+ let claudeSlice = {};
64
+ try {
65
+ const loaded = configLoader.loadConfig(cwd);
66
+ const slice = loaded['claude_orchestration'];
67
+ if (slice && typeof slice === 'object' && !Array.isArray(slice)) {
68
+ claudeSlice = slice;
69
+ }
70
+ }
71
+ catch {
72
+ claudeSlice = {};
73
+ }
74
+ // Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
75
+ const flatConfig = {};
76
+ for (const k of Object.keys(claudeSlice)) {
77
+ flatConfig['claude_orchestration.' + k] = claudeSlice[k];
78
+ }
79
+ const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
80
+ output(result, raw);
81
+ }
82
+ /**
83
+ * Emit a Workflow script from a wave/plan manifest file.
84
+ */
85
+ function cmdEmitWorkflow(args, _cwd, raw, error) {
86
+ const wavesPath = argValue(args, '--waves');
87
+ const runId = argValue(args, '--run-id');
88
+ const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
89
+ const budgetRaw = argValue(args, '--budget');
90
+ if (!wavesPath) {
91
+ error('emit-workflow requires --waves <path>');
92
+ return;
93
+ }
94
+ if (!runId) {
95
+ error('emit-workflow requires --run-id <id>');
96
+ return;
97
+ }
98
+ let waves;
99
+ try {
100
+ const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
101
+ const parsed = JSON.parse(content);
102
+ waves = parsed['waves'];
103
+ }
104
+ catch (e) {
105
+ error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
106
+ return;
107
+ }
108
+ const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
109
+ const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
110
+ const result = emitWorkflowScript({
111
+ phaseDir,
112
+ runId,
113
+ waves: waves,
114
+ budgetTokens: budget,
115
+ });
116
+ if (!result.ok) {
117
+ error('emit-workflow: ' + result.reason);
118
+ return;
119
+ }
120
+ output({ script: result.script, summary: result.summary }, raw);
121
+ }
122
+ function routeClaudeOrchestrationCommand(opts) {
123
+ const { args, cwd, raw, error } = opts;
124
+ // args[0] is the family ('claude-orchestration'); the subcommand is args[1].
125
+ const subcommand = args[1];
126
+ if (subcommand === 'detect-backend') {
127
+ cmdDetectBackend(args, cwd, raw);
128
+ }
129
+ else if (subcommand === 'emit-workflow') {
130
+ cmdEmitWorkflow(args, cwd, raw, error);
131
+ }
132
+ else {
133
+ usage(error);
134
+ }
135
+ }
136
+ module.exports = { routeClaudeOrchestrationCommand };