@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6

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 (113) 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/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -26,6 +26,7 @@ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
26
26
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
27
27
  const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
28
28
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
29
+ const installProfiles = require("./install-profiles.cjs");
29
30
  const { processAttribution } = runtimeArtifactConversion;
30
31
  // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
31
32
  // test stubs that monkeypatch the module's exports are seen at call time.
@@ -51,6 +52,25 @@ const { getDirName } = runtimeNamePolicy;
51
52
  */
52
53
  const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
53
54
  // ---------------------------------------------------------------------------
55
+ // Host-behavior helpers
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Host-specific install behaviors declared on the runtime descriptor
59
+ * (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
60
+ * Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
61
+ * {} for runtimes that declare none or if the registry fails to load, so
62
+ * every behavior branch degrades to the generic path by default.
63
+ */
64
+ function _hostBehaviors(runtime) {
65
+ try {
66
+ const reg = require('./capability-registry.cjs');
67
+ return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
54
74
  // Conversion helpers
55
75
  // ---------------------------------------------------------------------------
56
76
  /**
@@ -82,6 +102,17 @@ function convertClaudeCommandToOpencodeSkill(content, skillName) {
82
102
  function convertClaudeCommandToKiloSkill(content, skillName) {
83
103
  return runtimeArtifactConversion.convertClaudeCommandToKiloSkill(content, skillName);
84
104
  }
105
+ /**
106
+ * Converter-name registry for the OpenCode-family combined skills installer
107
+ * (ADR-1239 / #2093). Maps the `converter` string declared on each runtime's
108
+ * artifactLayout skills-kind descriptor (capabilities/<runtime>/capability.json)
109
+ * to the actual conversion function, so `installOpencodeFamilySkills` dispatches
110
+ * off the descriptor instead of a `frontmatterDialect === 'kilo'` runtime check.
111
+ */
112
+ const SKILLS_CONVERTER_REGISTRY = {
113
+ convertClaudeCommandToOpencodeSkill,
114
+ convertClaudeCommandToKiloSkill,
115
+ };
85
116
  // ---------------------------------------------------------------------------
86
117
  // User-artifact preservation helpers
87
118
  // ---------------------------------------------------------------------------
@@ -231,14 +262,20 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
231
262
  if (configDir === undefined) {
232
263
  throw new Error('_copyStaged: configDir (install root) is required to confine writes — refusing to write');
233
264
  }
265
+ // The install root is normally configDir, but a kind may declare an alternate
266
+ // `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
267
+ // that case this defense-in-depth check must confine against the resolved
268
+ // alternate root instead, matching the upstream gate's own root selection in
269
+ // createRuntimeArtifactInstallPlan.
270
+ const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
234
271
  // Strict-subpath + NUL containment via the canonical gate (shared with the
235
272
  // layout-driven install plan); throws if destDir escapes the install root.
236
- // destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
237
- const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
238
- // Symlink-escape guard: reject if any path component between configDir and
239
- // destDir is a symlink that would redirect writes outside configDir.
240
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), resolvedDest)) {
241
- throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`);
273
+ // destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
274
+ const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
275
+ // Symlink-escape guard: reject if any path component between the install root and
276
+ // destDir is a symlink that would redirect writes outside the install root.
277
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest)) {
278
+ throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`);
242
279
  }
243
280
  // Use the validated absolute path for the actual writes below.
244
281
  destDir = resolvedDest;
@@ -276,9 +313,13 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
276
313
  let destName;
277
314
  if (kind.kind === 'agents') {
278
315
  // Agent files already carry the gsd- prefix in the source dir.
279
- // #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
280
- destName = runtime === 'copilot'
281
- ? entry.name.replace(/\.md$/, '.agent.md')
316
+ // #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
317
+ // hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
318
+ // every other runtime's descriptor leaves this unset, so destName falls
319
+ // back to entry.name unchanged (byte-parity, #1575 origin comment).
320
+ const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
321
+ destName = _agentExt
322
+ ? entry.name.replace(/\.md$/, _agentExt)
282
323
  : entry.name;
283
324
  }
284
325
  else if (namespacedByDir) {
@@ -414,7 +455,7 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
414
455
  // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
415
456
  // created skills/gsd-dev-preferences/ skill dir.
416
457
  let savedLegacyArtifacts = null;
417
- if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
458
+ if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
418
459
  if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
419
460
  savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
420
461
  node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
@@ -471,7 +512,8 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
471
512
  // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
472
513
  // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
473
514
  // (which would redirect to skills/) conflicts with the test contract for local installs.
474
- const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
515
+ const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
516
+ const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && scope === 'global');
475
517
  if (isLegacyCommandsGsd) {
476
518
  const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
477
519
  if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
@@ -519,6 +561,15 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
519
561
  * @param resolveAttribution injection: (runtime) => attribution string | undefined
520
562
  */
521
563
  function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
564
+ // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
565
+ // the dedicated combined commands+skills+plugin orchestrator instead of the
566
+ // generic layout-driven loop below, mirroring the bespoke install path that
567
+ // previously lived inline in bin/install.js.
568
+ const behaviors = _hostBehaviors(runtime);
569
+ if (behaviors.combinedFamilyInstall) {
570
+ installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
571
+ return;
572
+ }
522
573
  // Legacy cleanup before layout-driven writes
523
574
  _runLegacyInstallMigrations(runtime, configDir, scope);
524
575
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
@@ -543,10 +594,16 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
543
594
  throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
544
595
  const dest = item.destDir;
545
596
  // Symlink-escape guard: reject before mkdir if dest (or any component
546
- // between configDir and dest) is a symlink pointing outside configDir.
547
- // mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
548
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), dest)) {
549
- throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`);
597
+ // between the install root and dest) is a symlink pointing outside that
598
+ // root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
599
+ // call. The install root is normally configDir, but a kind may declare
600
+ // an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
601
+ // $HOME/.agents) — in that case the guard must check against the
602
+ // resolved alternate root instead, matching assertDestWithinConfigHome's
603
+ // own root selection in createRuntimeArtifactInstallPlan.
604
+ const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
605
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest)) {
606
+ throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`);
550
607
  }
551
608
  node_fs_1.default.mkdirSync(dest, { recursive: true });
552
609
  if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
@@ -610,6 +667,18 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
610
667
  const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
611
668
  _removeHermesBareStemDirs(nestedGsdDirForCleanup);
612
669
  }
670
+ // Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
671
+ // outside the OpenCode/Kilo combined-family install (e.g. pi, whose
672
+ // artifactLayout is empty and which never sets combinedFamilyInstall) still
673
+ // need their declared hostBehaviors.nativePlugin file copied into configDir.
674
+ // findInstallSourceRoot resolves the repo/package root independent of
675
+ // configDir contents (marker check, then a walk-up from __dirname), so this
676
+ // is safe even when configDir has no .gsd-source marker (artifactLayout: []).
677
+ if (behaviors.nativePlugin) {
678
+ const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
679
+ const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
680
+ _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
681
+ }
613
682
  }
614
683
  // ---------------------------------------------------------------------------
615
684
  // installOpencodeFamilySkills
@@ -638,9 +707,16 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
638
707
  const rawDir = rawCommandsDir;
639
708
  if (!rawDir || !node_fs_1.default.existsSync(rawDir))
640
709
  return 0;
641
- const converter = runtime === 'kilo'
642
- ? convertClaudeCommandToKiloSkill
643
- : convertClaudeCommandToOpencodeSkill;
710
+ // #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
711
+ // string (capabilities/<runtime>/capability.json artifactLayout) via the
712
+ // SKILLS_CONVERTER_REGISTRY, instead of a `frontmatterDialect === 'kilo'`
713
+ // runtime check. Fail loud if the descriptor names an unregistered converter
714
+ // (mirrors the converter=null throw in runtime-artifact-layout.cts).
715
+ const converterName = skillsKindEntry.converter;
716
+ const converter = converterName ? SKILLS_CONVERTER_REGISTRY[converterName] : undefined;
717
+ if (!converter) {
718
+ throw new TypeError(`installOpencodeFamilySkills: unknown skills converter '${String(converterName)}' for runtime '${runtime}'`);
719
+ }
644
720
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
645
721
  // Symlink-escape guard: reject if any path component between targetDir and
646
722
  // dest is a symlink that would redirect writes outside the config root.
@@ -686,6 +762,136 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
686
762
  return count;
687
763
  }
688
764
  // ---------------------------------------------------------------------------
765
+ // installOpencodeFamilyCommands
766
+ // ---------------------------------------------------------------------------
767
+ /**
768
+ * Install the flattened commands surface for an OpenCode-family runtime
769
+ * (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
770
+ * per-runtime frontmatter conversion and path-prefix/attribution rewrites.
771
+ *
772
+ * Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
773
+ * #2087), except attribution is resolved via the injected
774
+ * `resolveAttribution` callback instead of a module-level getCommitAttribution.
775
+ *
776
+ * @param runtime - 'opencode' or 'kilo'
777
+ * @param destDir - destination directory for flattened commands (recurses with the same destDir)
778
+ * @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
779
+ * @param pathPrefix - computed config-path prefix for body rewrites
780
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
781
+ * @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
782
+ */
783
+ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, resolveAttribution = () => undefined, prefix = 'gsd') {
784
+ if (!node_fs_1.default.existsSync(srcDir))
785
+ return;
786
+ // Remove old gsd-*.md files before copying new ones
787
+ if (node_fs_1.default.existsSync(destDir)) {
788
+ for (const file of node_fs_1.default.readdirSync(destDir)) {
789
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md'))
790
+ node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
791
+ }
792
+ }
793
+ else {
794
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
795
+ }
796
+ for (const entry of node_fs_1.default.readdirSync(srcDir, { withFileTypes: true })) {
797
+ const srcPath = node_path_1.default.join(srcDir, entry.name);
798
+ if (entry.isDirectory()) {
799
+ installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
800
+ }
801
+ else if (entry.name.endsWith('.md')) {
802
+ const baseName = entry.name.replace('.md', '');
803
+ const destName = `${prefix}-${baseName}.md`;
804
+ let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
805
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
806
+ content = processAttribution(content, resolveAttribution(runtime));
807
+ // #2093: this commands-kind entry's descriptor `converter` field is
808
+ // intentionally `null` (see capabilities/{kilo,opencode}/capability.json —
809
+ // the flattened-command writer above applies its own path/attribution
810
+ // rewrites and has no per-file converter slot to key on), so there is no
811
+ // descriptor string to dispatch through here. `frontmatterDialect` is the
812
+ // documented, intentional dispatch key for frontmatter-shape selection —
813
+ // it is itself descriptor-driven (not a `runtime === 'kilo'` check), so it
814
+ // already satisfies the fold-to-descriptor requirement. Only the SKILLS
815
+ // converter site above (installOpencodeFamilySkills) has a real
816
+ // `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
817
+ content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
818
+ ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
819
+ : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
820
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, destName), content);
821
+ }
822
+ }
823
+ }
824
+ // ---------------------------------------------------------------------------
825
+ // _installNativePluginIfDeclared
826
+ // ---------------------------------------------------------------------------
827
+ /**
828
+ * Copy a runtime's declared native-extension/plugin file (hostBehaviors.nativePlugin)
829
+ * into its resolved config dir, when the runtime descriptor declares one.
830
+ *
831
+ * Extracted (ADR-1239 / #2102 Stage 1) from the body previously inlined in
832
+ * installOpencodeFamilyArtifacts so a runtime that is NOT part of the
833
+ * OpenCode/Kilo combined-family install (e.g. pi, whose artifactLayout is
834
+ * empty and which never sets combinedFamilyInstall) can still get its
835
+ * nativePlugin file staged via the generic installRuntimeArtifacts branch.
836
+ * Behavior for opencode/kilo is unchanged — same source resolution, same
837
+ * mkdir + copyFileSync call, same silent no-op when the source is missing.
838
+ *
839
+ * @param runtime - canonical runtime id (only used for the assertDestWithinConfigHome guard)
840
+ * @param configDir - resolved runtime config directory
841
+ * @param behaviors - the runtime's hostBehaviors descriptor
842
+ * @param src - repo/package root (two levels up from the commands/gsd source dir)
843
+ */
844
+ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
845
+ const np = behaviors.nativePlugin;
846
+ if (np && np.source) {
847
+ const pluginSrc = node_path_1.default.join(src, np.source);
848
+ if (node_fs_1.default.existsSync(pluginSrc)) {
849
+ const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
850
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
851
+ node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
852
+ }
853
+ }
854
+ }
855
+ // ---------------------------------------------------------------------------
856
+ // installOpencodeFamilyArtifacts
857
+ // ---------------------------------------------------------------------------
858
+ /**
859
+ * Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087,
860
+ * #2093). Stages the flattened commands surface + skills surface + (any
861
+ * runtime whose hostBehaviors declares `nativePlugin` — OpenCode and, since
862
+ * #2093, Kilo) native plugin adapter, mirroring the bespoke `else if (isOpencode ||
863
+ * isKilo)` block previously inlined in bin/install.js.
864
+ *
865
+ * @param runtime - 'opencode' or 'kilo'
866
+ * @param configDir - resolved runtime config directory
867
+ * @param scope - install scope ('global' | 'local')
868
+ * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
869
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
870
+ * @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
871
+ */
872
+ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
873
+ const isGlobal = scope === 'global';
874
+ // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
875
+ // (via the .gsd-source marker or a walk-up from __dirname) — every other
876
+ // call site in runtime-artifact-layout.cts feeds its return value straight
877
+ // into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
878
+ // root (needed below for the native plugin source) is two levels up.
879
+ const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
880
+ const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
881
+ const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
882
+ const pathPrefix = runtimeArtifactConversion._computePathPrefix({
883
+ isGlobal,
884
+ isOpencode: behaviors.skipHomePrefixSubstitution === true,
885
+ isWindowsHost: process.platform === 'win32',
886
+ resolvedTarget: node_path_1.default.resolve(configDir).replace(/\\/g, '/'),
887
+ homeDir: node_os_1.default.homedir().replace(/\\/g, '/'),
888
+ });
889
+ const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
890
+ installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
891
+ installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
892
+ _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
893
+ }
894
+ // ---------------------------------------------------------------------------
689
895
  // uninstallRuntimeArtifacts
690
896
  // ---------------------------------------------------------------------------
691
897
  /**
@@ -740,6 +946,10 @@ module.exports = {
740
946
  installRuntimeArtifacts,
741
947
  uninstallRuntimeArtifacts,
742
948
  installOpencodeFamilySkills,
949
+ installOpencodeFamilyCommands,
950
+ installOpencodeFamilyArtifacts,
951
+ _installNativePluginIfDeclared,
952
+ _hostBehaviors,
743
953
  _copyStaged,
744
954
  hasExistingSymlinkBetween,
745
955
  preserveUserArtifacts,
@@ -37,7 +37,14 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
37
37
  'hooks/gsd-config-reload.js',
38
38
  'hooks/gsd-context-monitor.js',
39
39
  'hooks/gsd-cursor-post-tool.js',
40
+ 'hooks/gsd-cursor-pre-tool.js',
40
41
  'hooks/gsd-cursor-session-start.js',
42
+ 'hooks/gsd-cursor-stop.js',
43
+ 'hooks/gsd-cursor-subagent-start.js',
44
+ 'hooks/gsd-cursor-subagent-stop.js',
45
+ // Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
46
+ 'hooks/gsd-windsurf-pre-write.js',
47
+ 'hooks/gsd-windsurf-pre-command.js',
41
48
  'hooks/gsd-ensure-canonical-path.js',
42
49
  'hooks/gsd-graphify-update.sh',
43
50
  'hooks/gsd-phase-boundary.sh',
@@ -401,6 +401,25 @@ function renderLoopHooks(resolved) {
401
401
  * Missing <capId> value → coreError + non-zero exit.
402
402
  * Unknown/inactive capId → `false` (not an error).
403
403
  */
404
+ // #2009: a capability id surfaced inside the runnable `gsd capability remove <id>`
405
+ // remediation must match the canonical kebab-case id shape (identical to
406
+ // capability-consent.cts / capability-ledger.cts) before it is embedded — a raw
407
+ // overlay directory name is attacker-controlled and can carry shell/markdown
408
+ // metacharacters (backticks, ';', '|', '$()'). An id that fails this check is
409
+ // withheld and no runnable command is rendered for it.
410
+ const LOAD_FAIL_CAP_ID_RE = /^[a-z][a-z0-9-]*$/;
411
+ // #2009: neutralize control chars, newlines, and backticks from a third-party
412
+ // load-failure reason so a malicious manifest cannot break out of the warning
413
+ // line or inject markdown / prompt content into the surfaced message.
414
+ function sanitizeLoadFailReason(reason) {
415
+ const cleaned = String(reason)
416
+ // Strip C0 control chars, DEL, and backticks; collapse remaining whitespace.
417
+ .replace(/[\x00-\x1F\x7F`]/g, ' ')
418
+ .replace(/\s+/g, " ")
419
+ .trim()
420
+ .slice(0, 300);
421
+ return cleaned || '(no reason given)';
422
+ }
404
423
  function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
405
424
  if (!point) {
406
425
  coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
@@ -456,26 +475,54 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
456
475
  coreError(msg);
457
476
  return;
458
477
  }
459
- // ── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
460
- // For every skipped overlay capability that declared a gate at this point,
461
- // inject a synthetic BLOCKING gate into the resolved output so the loop HALTS
462
- // rather than silently proceeding as if the gate had passed. step/contribution
463
- // overlays that were skipped are left open (skip-open is correct for them).
478
+ // ── ADR-1244 D2: load-failed capability gates FAIL OPEN with a loud warning ────
479
+ // Decision (#2009): a capability that failed to LOAD must not block the loop.
480
+ // The prior behavior injected a BLOCKING synthetic gate (blocking:true,
481
+ // onError:'halt') at every point where the skipped cap declared a gate, so a
482
+ // single incompatible capability halted every ship:pre / verify:post
483
+ // project-wide for a load error unrelated to what the gate checked — with no
484
+ // remediation surfaced. We now fail OPEN: no gate is injected (the loop proceeds
485
+ // and `--active-cap <failed-cap>` correctly reports it inactive), and a loud
486
+ // warning is emitted instead — to STDERR (which the operator, or the agent
487
+ // running the command, actually sees regardless of how the host workflow
488
+ // consumes stdout) AND in the envelope's `warnings` channel for structured
489
+ // consumers. The warning names the load reason and the exact
490
+ // `gsd capability remove <id>` remediation so the operator can clear the broken
491
+ // capability. blockedGates is still recorded by the loader; only the consequence
492
+ // changes from block to warn. step/contribution overlays were already skip-open.
493
+ //
494
+ // The gate injection was dropped rather than made non-blocking because no host
495
+ // workflow generically surfaces an arbitrary gate's message at ship:pre /
496
+ // verify:post (consumers dispatch on specific capIds / ref.skills), and the
497
+ // generic gate consumers expect an object-shaped `check`, not a prose string —
498
+ // so an injected advisory gate would be silently dropped or mis-dispatched. A
499
+ // stderr warning is the channel that is actually surfaced. (See #2009 review.)
464
500
  const overlayMeta = registry['_overlay'];
501
+ const loadFailWarnings = [];
465
502
  if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) {
466
503
  for (const blocked of overlayMeta.blockedGates) {
467
- if (blocked.point === point) {
468
- const syntheticGate = {
469
- capId: blocked.capId,
470
- kind: 'gate',
471
- blocking: true,
472
- onError: 'halt',
473
- check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`,
474
- };
475
- resolved.activeHooks.push(syntheticGate);
476
- }
504
+ if (blocked.point !== point)
505
+ continue;
506
+ // Security (#2009 review): capId/reason come from a third-party manifest or
507
+ // directory name. Validate capId before embedding it in the runnable
508
+ // remediation command; withhold it (no runnable command) if it is not a
509
+ // canonical id. Strip control chars/backticks from reason.
510
+ const idValid = LOAD_FAIL_CAP_ID_RE.test(String(blocked.capId));
511
+ const capLabel = idValid
512
+ ? `"${blocked.capId}"`
513
+ : 'with an invalid id (withheld) under .gsd/capabilities/';
514
+ const remediation = idValid
515
+ ? `Run \`gsd capability remove ${blocked.capId}\` to remove it, or fix the load error.`
516
+ : 'Remove the offending capability directory under .gsd/capabilities/, or fix the load error.';
517
+ loadFailWarnings.push(`capability ${capLabel} failed to load (${sanitizeLoadFailReason(blocked.reason)}); ` +
518
+ `its gate at ${point} is SKIPPED and NOT enforced (failing open). ${remediation}`);
477
519
  }
478
520
  }
521
+ // Emit loudly to stderr in EVERY output mode (including --active-cap), so a
522
+ // skipped gate is never silently invisible to the operator/agent.
523
+ for (const w of loadFailWarnings) {
524
+ process.stderr.write(`gsd: warning — ${w}\n`);
525
+ }
479
526
  // --active-cap mode: print exactly 'true' or 'false' with no envelope
480
527
  if (activeCapId !== undefined) {
481
528
  const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
@@ -488,8 +535,12 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
488
535
  activeHooks: resolved.activeHooks,
489
536
  rendered,
490
537
  };
491
- if (state.warnings && state.warnings.length > 0) {
492
- envelope.warnings = state.warnings;
538
+ // Surface capability-state warnings and the #2009 load-failure fail-open
539
+ // warnings together in the structured `warnings` channel (in addition to the
540
+ // stderr emission above, which is the channel host workflows actually see).
541
+ const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings];
542
+ if (combinedWarnings.length > 0) {
543
+ envelope.warnings = combinedWarnings;
493
544
  }
494
545
  coreOutput(envelope, raw);
495
546
  }
@@ -17,6 +17,7 @@ exports.collectSections = collectSections;
17
17
  exports.collectSection = collectSection;
18
18
  exports.iterateBullets = iterateBullets;
19
19
  exports.extractTaggedBlocks = extractTaggedBlocks;
20
+ exports.stripTaggedBlocks = stripTaggedBlocks;
20
21
  exports.replaceSection = replaceSection;
21
22
  // ─── stripFencedCode ──────────────────────────────────────────────────────────
22
23
  /**
@@ -407,25 +408,26 @@ function iterateBullets(sectionText) {
407
408
  * fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
408
409
  * block and should be excluded, the caller should apply `stripFencedCode` first.
409
410
  *
410
- * **Nested tags are NOT supported.** The underlying regex uses a non-greedy
411
- * `[\s\S]*?` match, which means it closes at the FIRST `</tagName>` encountered.
412
- * Given `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns
413
- * `['<x>inner']` — the inner `<x>` is captured as literal text, and the second
414
- * `</x>` is left unmatched (or matched as a second block with empty inner text
415
- * if another `<x>` follows). Callers that need to handle nested tags must
416
- * pre-process the input or use a proper XML/HTML parser.
411
+ * **Nested tags are NOT supported.** The body scan terminates at the NEXT
412
+ * opening of the same tag (the ReDoS-safe boundary, #2128). Given
413
+ * `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns `['inner']`
414
+ * — the well-formed inner block; the unterminated outer `<x>` is skipped.
415
+ * Callers that need true nesting must use a proper XML/HTML parser.
416
+ *
417
+ * `allowAttributes` (default `false`): when `true`, the opening tag may carry
418
+ * bounded attributes (`<tag foo="x">`) — needed for `<task type="…">` blocks.
419
+ * Leave `false` for tags that must match exactly (e.g. `<decisions>`), and never
420
+ * enable it for a tag where an attributed form is semantically distinct.
417
421
  *
418
422
  * Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
419
423
  * so tier T1 can drop its own copy (tracked duplication until T1 lands).
420
424
  */
421
- function extractTaggedBlocks(content, tagName) {
425
+ function extractTaggedBlocks(content, tagName, allowAttributes = false) {
422
426
  if (typeof content !== 'string' || content.length === 0)
423
427
  return [];
424
428
  if (typeof tagName !== 'string' || tagName.length === 0)
425
429
  return [];
426
- // Escape the tag name for safe interpolation into a RegExp.
427
- const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
428
- const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
430
+ const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
429
431
  const results = [];
430
432
  let match;
431
433
  while ((match = pattern.exec(content)) !== null) {
@@ -433,6 +435,43 @@ function extractTaggedBlocks(content, tagName) {
433
435
  }
434
436
  return results;
435
437
  }
438
+ /**
439
+ * Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
440
+ * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
441
+ *
442
+ * Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open)
443
+ * instead of lazily rescanning the whole remaining document for a `</tag>` that
444
+ * may never appear — so a document full of unclosed `<tag>` openings scans
445
+ * LINEARLY, not quadratically (#2128). Group 1 is the block body.
446
+ *
447
+ * `allowAttributes`: when `true`, the opener accepts bounded attributes
448
+ * (`<tag foo="x">`) and the body boundary is `<tag` followed by a space or `>`.
449
+ * When `false`, the opener is the EXACT `<tag>` and the boundary is exact `<tag>`,
450
+ * so an attributed `<tag foo>` is neither an opener nor a boundary — it is body
451
+ * content. That exact form is load-bearing for `<details>` stripping: `<details
452
+ * open>` marks the ACTIVE milestone and must be preserved, not stripped (#557).
453
+ */
454
+ function taggedBlockPattern(tagName, flags, allowAttributes) {
455
+ const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
456
+ const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`;
457
+ const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`;
458
+ return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)</${esc}>`, flags);
459
+ }
460
+ /**
461
+ * Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
462
+ * from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
463
+ * hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes`
464
+ * defaults to `false` so `<details open>` (active milestone) is preserved (#557);
465
+ * case-insensitive by default (matching the `<details>` strip call sites), pass
466
+ * `caseSensitive` to force exact-case matching.
467
+ */
468
+ function stripTaggedBlocks(content, tagName, allowAttributes = false, caseSensitive = false) {
469
+ if (typeof content !== 'string' || content.length === 0)
470
+ return '';
471
+ if (typeof tagName !== 'string' || tagName.length === 0)
472
+ return content;
473
+ return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), '');
474
+ }
436
475
  // ─── replaceSection ───────────────────────────────────────────────────────────
437
476
  /**
438
477
  * Splice `newBody` in place of a section's body and return the resulting
@@ -5,8 +5,16 @@
5
5
  * so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
6
6
  * Hermes) can drive GSD with NO bespoke plugin:
7
7
  *
8
- * - point 1 (command): tool `gsd_invoke_command` → the command-routing hub
9
- * (`createHub`/`dispatch`, src/command-routing-hub.cts).
8
+ * - point 1 (command): tool `gsd_invoke_command` → `dispatchGsdCommand`
9
+ * (src/shell-command-projection.cts), a bounded subprocess-shim to
10
+ * gsd-tools.cjs. #2102 Stage 2: `commandRoutingHub.createHub()` called
11
+ * with no args here always hit `if(!_cjsRegistry) return
12
+ * makeUnknownCommand()` — every dispatch was UnknownCommand. No
13
+ * fully-populated hub factory exists anywhere in gsd-core (every
14
+ * createHub() caller builds a single-family hub for its own narrow
15
+ * purpose), so the fix routes through the SAME shared dispatch helper
16
+ * the pi extension uses (pi/gsd.cjs), mirroring the SUBPROCESS-REUSE
17
+ * precedent already established for the OpenCode/Kilo hook bridge.
10
18
  * - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the
11
19
  * Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
12
20
  *
@@ -24,9 +32,10 @@ exports.SERVER_NAME = exports.PROTOCOL_VERSION = void 0;
24
32
  exports.handleMessage = handleMessage;
25
33
  exports.runServer = runServer;
26
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const commandRoutingHub = require("./command-routing-hub.cjs");
28
- // eslint-disable-next-line @typescript-eslint/no-require-imports
29
35
  const stateIo = require("./state-io.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const shellCommandProjection = require("./shell-command-projection.cjs");
38
+ const { dispatchGsdCommand } = shellCommandProjection;
30
39
  exports.PROTOCOL_VERSION = '2024-11-05';
31
40
  exports.SERVER_NAME = 'gsd-core';
32
41
  const SERVER_VERSION = '1.7.0';
@@ -94,9 +103,11 @@ function callTool(name, args, ctx) {
94
103
  if (!family || !subcommand) {
95
104
  return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
96
105
  }
97
- const hub = commandRoutingHub.createHub();
98
- const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined });
99
- return { content: [{ type: 'text', text: JSON.stringify(res) }] };
106
+ const res = dispatchGsdCommand({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd });
107
+ if (!res.ok) {
108
+ return { isError: true, content: [{ type: 'text', text: res.stderr || res.stdout || `dispatch failed (exit ${res.code})` }] };
109
+ }
110
+ return { content: [{ type: 'text', text: res.stdout }] };
100
111
  }
101
112
  if (name === 'gsd_read_state') {
102
113
  const p = asString(a.path);
@@ -26,7 +26,7 @@ const ioMod = require("./io.cjs");
26
26
  const { output, error } = ioMod;
27
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
28
  const phaseIdMod = require("./phase-id.cjs");
29
- const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
29
+ const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
31
  const roadmapParserMod = require("./roadmap-parser.cjs");
32
32
  const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
@@ -156,8 +156,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
156
156
  if (stateVersion && stateVersion === version) {
157
157
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
158
158
  const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
159
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
160
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
159
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
160
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
161
161
  const noDirectoryPhases = [];
162
162
  let pm;
163
163
  const phaseDirEntries = (() => {