@opengsd/gsd-core 1.7.0-rc.4 → 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 (84) 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 +28 -0
  7. package/bin/install.js +624 -456
  8. package/gsd-core/bin/gsd-tools.cjs +40 -1
  9. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  10. package/gsd-core/bin/lib/audit.cjs +6 -3
  11. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  12. package/gsd-core/bin/lib/capability-registry.cjs +325 -64
  13. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  15. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  16. package/gsd-core/bin/lib/commands.cjs +7 -5
  17. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  18. package/gsd-core/bin/lib/config.cjs +96 -0
  19. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  20. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  21. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  22. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  23. package/gsd-core/bin/lib/init.cjs +74 -30
  24. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  25. package/gsd-core/bin/lib/install-engine.cjs +151 -11
  26. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  27. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  28. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  29. package/gsd-core/bin/lib/milestone.cjs +3 -3
  30. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  31. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  32. package/gsd-core/bin/lib/phase.cjs +78 -16
  33. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  34. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  35. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  36. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  37. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  38. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  39. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  41. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  42. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  43. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  44. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  45. package/gsd-core/bin/lib/state.cjs +24 -24
  46. package/gsd-core/bin/lib/surface.cjs +30 -0
  47. package/gsd-core/bin/lib/uat.cjs +4 -1
  48. package/gsd-core/bin/lib/validate.cjs +15 -6
  49. package/gsd-core/bin/lib/verify.cjs +33 -37
  50. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  51. package/gsd-core/bin/shared/model-catalog.json +6 -6
  52. package/gsd-core/references/api-coverage.md +104 -0
  53. package/gsd-core/references/model-profiles.md +2 -2
  54. package/gsd-core/references/planning-config.md +2 -0
  55. package/gsd-core/references/specless-probe-fallback.md +172 -0
  56. package/gsd-core/templates/config.json +2 -1
  57. package/gsd-core/workflows/audit-fix.md +9 -1
  58. package/gsd-core/workflows/code-review-fix.md +7 -3
  59. package/gsd-core/workflows/code-review.md +4 -1
  60. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  61. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  62. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  63. package/gsd-core/workflows/execute-phase.md +1 -25
  64. package/gsd-core/workflows/plan-phase.md +31 -2
  65. package/gsd-core/workflows/quick.md +2 -2
  66. package/gsd-core/workflows/review.md +59 -13
  67. package/gsd-core/workflows/settings-advanced.md +5 -5
  68. package/gsd-core/workflows/settings.md +2 -2
  69. package/gsd-core/workflows/verify-phase.md +3 -2
  70. package/gsd-core/workflows/verify-work.md +38 -0
  71. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  72. package/hooks/dist/gsd-cursor-stop.js +48 -0
  73. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  74. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  75. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  76. package/hooks/gsd-cursor-pre-tool.js +76 -0
  77. package/hooks/gsd-cursor-stop.js +48 -0
  78. package/hooks/gsd-cursor-subagent-start.js +50 -0
  79. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  80. package/hooks/managed-hooks-registry.cjs +4 -0
  81. package/package.json +2 -1
  82. package/scripts/build-hooks.js +5 -1
  83. package/scripts/lint-phase-id-drift.cjs +150 -0
  84. package/scripts/run-tests.cjs +21 -1
@@ -22,7 +22,10 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const node_path_1 = __importDefault(require("node:path"));
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const phaseIdModule = require("./phase-id.cjs");
25
- const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdModule;
25
+ const { escapeRegex, phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
26
+ // #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
27
+ // the lookup-source ordering); imported here rather than defined locally.
28
+ roadmapPhaseLookupSources, } = phaseIdModule;
26
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
30
  const planningWorkspace = require("./planning-workspace.cjs");
28
31
  const { planningDir } = planningWorkspace;
@@ -33,7 +36,7 @@ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
33
36
  * Strip shipped milestone content wrapped in <details> blocks.
34
37
  */
35
38
  function stripShippedMilestones(content) {
36
- return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
39
+ return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
37
40
  }
38
41
  /**
39
42
  * Extract the current milestone section from ROADMAP.md by positive lookup.
@@ -80,10 +83,9 @@ function extractCurrentMilestone(content, cwd) {
80
83
  const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
81
84
  const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
82
85
  const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
83
- const preamble = content.slice(0, preambleCutoff)
84
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
85
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
86
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
86
+ const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content.slice(0, preambleCutoff), 'details')
87
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
88
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
87
89
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
88
90
  return preamble + content.slice(detailsOpenIdx, detailsEnd);
89
91
  }
@@ -150,10 +152,9 @@ function extractCurrentMilestone(content, cwd) {
150
152
  const detailsStart = detailsMatch.index ?? 0;
151
153
  detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
152
154
  }
153
- const preamble = beforeMilestones
154
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
155
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
156
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
+ const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details')
156
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
157
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
157
158
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
158
159
  return detailsSection
159
160
  ? preamble + currentSection + '\n' + detailsSection
@@ -174,7 +175,7 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
174
175
  }
175
176
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
176
177
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
177
- const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
179
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
179
180
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
180
181
  if (headingIndex === -1)
@@ -197,21 +198,6 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
197
198
  section,
198
199
  };
199
200
  }
200
- function roadmapPhaseLookupSources(phaseNum) {
201
- const sources = [];
202
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
203
- if (exactSource)
204
- sources.push(exactSource);
205
- const numericSource = phaseMarkdownRegexSource(phaseNum);
206
- // Source order matters: the bare numeric source is tried before the
207
- // prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
208
- // preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
209
- // both exist in the same ROADMAP. The prefix-tolerant form is the fallback
210
- // that handles the drifted-only case.
211
- sources.push(numericSource);
212
- sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
213
- return [...new Set(sources)];
214
- }
215
201
  function getRoadmapPhaseInternal(cwd, phaseNum) {
216
202
  if (!phaseNum)
217
203
  return null;
@@ -323,7 +309,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
323
309
  throw new Error('missing');
324
310
  let roadmap = extractCurrentMilestone(roadmapContent, cwd);
325
311
  const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
326
- const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
312
+ const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
327
313
  if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
328
314
  console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
329
315
  'The project has phase_id_convention set to "milestone-prefixed" in config.json but the ' +
@@ -378,8 +364,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
378
364
  }
379
365
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
380
366
  // T4 seam migration: phase headings inside fences are excluded automatically.
381
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
382
- const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
367
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
368
+ const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
383
369
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
384
370
  if (h.level < 2 || h.level > 4)
385
371
  continue;
@@ -401,8 +387,13 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
401
387
  return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
402
388
  }
403
389
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
390
+ // #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
391
+ // — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
392
+ // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
393
+ // the milestone as a bogus "46-6" id.
404
394
  const numericRe = roadmapUsesHyphenedIds
405
- ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
395
+ ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
396
+ // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
406
397
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
407
398
  function isDirInMilestone(dirName) {
408
399
  const m2 = dirName.match(numericRe);
@@ -19,16 +19,16 @@ const planningWorkspace = require("./planning-workspace.cjs");
19
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
20
  const phaseIdMod = require("./phase-id.cjs");
21
21
  const { planningDir } = planningWorkspace;
22
- const { stripProjectCodePrefix } = phaseIdMod;
22
+ const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
23
23
  // ─── Regex helpers ────────────────────────────────────────────────────────────
24
24
  // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
25
25
  // Captures: (hashes)(spaces)(phase-number)(rest-of-line)
26
- const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
26
+ const LEGACY_PHASE_HEADING_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, 'i');
27
27
  // Matches already-migrated phase headings: ### Phase M-NN: Name
28
- const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
28
+ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
29
29
  // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
30
30
  // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
31
- const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
31
+ const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
32
32
  // ─── Pure computation helpers ─────────────────────────────────────────────────
33
33
  /**
34
34
  * Parse the ROADMAP.md content and build a list of phase entries with their
@@ -106,7 +106,7 @@ function extractPhaseNumFromDir(dirName) {
106
106
  const stripped = stripProjectCodePrefix(dirName);
107
107
  // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
108
108
  // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
109
- const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
109
+ const m = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-|$)`, 'i'));
110
110
  return m ? m[1] : null;
111
111
  }
112
112
  /**
@@ -119,7 +119,7 @@ function buildNewDirName(oldDirName, newId, projectCode) {
119
119
  // Strip existing project_code prefix
120
120
  const stripped = stripProjectCodePrefix(oldDirName);
121
121
  // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
122
- const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
122
+ const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i'));
123
123
  const slug = slugMatch ? slugMatch[1] : stripped;
124
124
  // Build M-NN prefix (zero-pad both parts)
125
125
  const [milestoneStr, subStr] = newId.split('-');
@@ -256,7 +256,7 @@ function computeMigrationPlan(cwd, options = {}) {
256
256
  continue;
257
257
  // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
258
258
  const oldLine = lines[entry.lineIndex];
259
- const newLine = oldLine.replace(/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i, `$1${mapping.newId}$2`);
259
+ const newLine = oldLine.replace(new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), `$1${mapping.newId}$2`);
260
260
  if (newLine !== oldLine) {
261
261
  roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
262
262
  }
@@ -274,7 +274,7 @@ function computeMigrationPlan(cwd, options = {}) {
274
274
  if (roadmapEdits.some(e => e.lineIndex === i))
275
275
  continue;
276
276
  // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
277
- const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
277
+ const checklistMatch = line.match(new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i'));
278
278
  if (checklistMatch) {
279
279
  const legacyNum = checklistMatch[2];
280
280
  const cIntPart = parseInt(legacyNum, 10);
@@ -300,7 +300,7 @@ function computeMigrationPlan(cwd, options = {}) {
300
300
  newId = found.newId;
301
301
  }
302
302
  if (newId) {
303
- const newLine = line.replace(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i, `$1${newId}$2`);
303
+ const newLine = line.replace(new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'), `$1${newId}$2`);
304
304
  if (newLine !== line) {
305
305
  roadmapEdits.push({ lineIndex: i, from: line, to: newLine });
306
306
  }
@@ -17,7 +17,7 @@ const ioMod = require("./io.cjs");
17
17
  const { output, error } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const phaseIdMod = require("./phase-id.cjs");
20
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
20
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources } = phaseIdMod;
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
@@ -102,7 +102,7 @@ function countPhasePlansAndSummaries(phaseDir) {
102
102
  */
103
103
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
104
104
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
105
- const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
105
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
106
106
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
107
107
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
108
108
  const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
@@ -174,23 +174,18 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
174
174
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
175
175
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
176
176
  const fullContent = stripShippedMilestones(rawContent);
177
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
178
- if (exactSource) {
179
- const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
180
- if (exactMilestone && !exactMilestone.error)
181
- return exactMilestone.section ?? null;
182
- const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
183
- if (exactFull && !exactFull.error)
184
- return exactFull.section ?? null;
177
+ // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
178
+ // prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
179
+ // bare-number query resolves a drifted project-code-prefixed heading.
180
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
181
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
182
+ if (milestoneResult && !milestoneResult.error)
183
+ return milestoneResult.section ?? null;
184
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
185
+ if (fullResult && !fullResult.error)
186
+ return fullResult.section ?? null;
185
187
  }
186
- const escapedPhase = phaseMarkdownRegexSource(phaseNum);
187
- const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
188
- const result = (milestoneResult && !milestoneResult.error)
189
- ? milestoneResult
190
- : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
191
- if (!result || result.error)
192
- return null;
193
- return result.section ?? null;
188
+ return null;
194
189
  }
195
190
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
196
191
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
@@ -206,46 +201,35 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
206
201
  try {
207
202
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
208
203
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
209
- // #3599 two-pass: when the caller passes a project-code-prefixed ID like
210
- // `PROJ-42`, try the exact-prefixed heading first (`### Phase PROJ-42:`).
211
- // If no match, fall back to the #3537 padding-tolerant numeric form so
212
- // a `CK-01` query still resolves to `### Phase 1:`. Doing this at the
213
- // call site (instead of inside phaseMarkdownRegexSource) avoids the
214
- // alternation-order ambiguity where a bare `### Phase 42:` heading in
215
- // the same document would intercept the match for a `PROJ-42` query.
216
204
  const fullContent = stripShippedMilestones(rawContent);
217
- const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
218
- if (exactSource) {
219
- const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
220
- if (exactMilestone && !exactMilestone.error) {
221
- output(exactMilestone, raw, exactMilestone.section);
205
+ // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
206
+ // prefix-tolerant) so all three roadmap resolvers share one contract and a
207
+ // bare-number query resolves a drifted `### Phase AB-29:` heading. This
208
+ // preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior
209
+ // (both now encoded in roadmapPhaseLookupSources' ordering). A clean match
210
+ // (milestone or full, any source) wins immediately; a malformed_roadmap
211
+ // (checklist-only) candidate is surfaced only if no source finds a real
212
+ // heading — so a milestone checklist never blocks a full-roadmap header.
213
+ let malformed = null;
214
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
215
+ const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
216
+ if (milestoneResult && !milestoneResult.error) {
217
+ output(milestoneResult, raw, milestoneResult.section);
222
218
  return;
223
219
  }
224
- const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
225
- if (exactFull && !exactFull.error) {
226
- output(exactFull, raw, exactFull.section);
220
+ const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
221
+ if (fullResult && !fullResult.error) {
222
+ output(fullResult, raw, fullResult.section);
227
223
  return;
228
224
  }
225
+ if (!malformed)
226
+ malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
229
227
  }
230
- // #3537: padding-tolerant fragment so callers passing `02.7` still match
231
- // un-padded ROADMAP prose (`### Phase 2.7:`).
232
- const escapedPhase = phaseMarkdownRegexSource(phaseNum);
233
- // Search the current milestone slice first, then fall back to full roadmap.
234
- // A malformed_roadmap result (checklist-only) from the milestone should not
235
- // block finding a full header match in the wider roadmap content.
236
- const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
237
- const result = (milestoneResult && !milestoneResult.error)
238
- ? milestoneResult
239
- : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
240
- if (!result) {
241
- output({ found: false, phase_number: phaseNum }, raw, '');
242
- return;
243
- }
244
- if (result.error) {
245
- output(result, raw, '');
228
+ if (malformed) {
229
+ output(malformed, raw, '');
246
230
  return;
247
231
  }
248
- output(result, raw, result.section);
232
+ output({ found: false, phase_number: phaseNum }, raw, '');
249
233
  }
250
234
  catch (e) {
251
235
  error('Failed to read ROADMAP.md: ' + e.message);
@@ -262,8 +246,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
262
246
  const content = extractCurrentMilestone(rawContent, cwd);
263
247
  const phasesDir = planningPaths(cwd).phases;
264
248
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
265
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
266
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
249
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
250
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
251
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
267
252
  const phases = [];
268
253
  let match;
269
254
  // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
@@ -296,7 +281,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
296
281
  const restOfContent = content.slice(sectionStart);
297
282
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
298
283
  // recognised as section boundaries.
299
- const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
284
+ const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i);
300
285
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
301
286
  const section = content.slice(sectionStart, sectionEnd);
302
287
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -383,6 +368,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
383
368
  // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
384
369
  // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
385
370
  // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
371
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
386
372
  const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
387
373
  const checklistPhases = new Set();
388
374
  let checklistMatch;
@@ -473,7 +459,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
473
459
  // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
474
460
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
475
461
  // `Plans: N plans` — plain text header
476
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
462
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
477
463
  const planCountText = isComplete
478
464
  ? `${summaryCount}/${planCount} plans complete`
479
465
  : `${summaryCount}/${planCount} plans executed`;
@@ -530,8 +516,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
530
516
  //
531
517
  // Pattern A: anchor to bare `Plans:` header (preferred).
532
518
  // Pattern B: fallback to bold summary when no bare header exists.
533
- const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
534
- const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
519
+ const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
520
+ const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
535
521
  const sortedMissing = [...missingPlans].sort();
536
522
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
537
523
  const inserter = (match) => `${match}\n${newRows}`;
@@ -2277,7 +2277,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
2277
2277
  const resolvedTarget = node_path_1.default.resolve(configDir).replace(/\\/g, '/');
2278
2278
  const homeDir = homedir().replace(/\\/g, '/');
2279
2279
  const isGlobal = scope === 'global';
2280
- const isOpencode = runtime === 'opencode';
2280
+ const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
2281
2281
  const isWindowsHost = platform === 'win32';
2282
2282
  const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
2283
2283
  const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2290,10 +2290,12 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
2290
2290
  * attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace
2291
2291
  * (single copy+rewrite owner).
2292
2292
  *
2293
- * @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam
2294
- * API for command bodies. No production caller today (install rewrites commands via
2295
- * copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for
2296
- * API symmetry + test coverage.
2293
+ * @internal — symmetric companion to rewriteStagedSkillBodies; the deep-seam API for
2294
+ * command bodies. Production callers: applySurface (surface.cts) and the install path
2295
+ * in createRuntimeArtifactInstallPlan (runtime-artifact-install-plan.cts) — both keep
2296
+ * the returned temp dir alive until they have copied its contents out, then clean it up
2297
+ * in their own finally. (A test that treats this as a throwaway shared-tmp path will
2298
+ * race those live temp dirs under --test-concurrency; see #1575/#2090.)
2297
2299
  *
2298
2300
  * @returns {string} path to the temp dir (caller is responsible for cleanup)
2299
2301
  */
@@ -2304,7 +2306,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
2304
2306
  const resolvedTarget = node_path_1.default.resolve(configDir).replace(/\\/g, '/');
2305
2307
  const homeDir = homedir().replace(/\\/g, '/');
2306
2308
  const isGlobal = scope === 'global';
2307
- const isOpencode = runtime === 'opencode';
2309
+ const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
2308
2310
  const isWindowsHost = platform === 'win32';
2309
2311
  const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
2310
2312
  const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2425,6 +2427,11 @@ module.exports = {
2425
2427
  neutralizeAgentReferences,
2426
2428
  convertClaudeCommandToOpencodeSkill,
2427
2429
  convertClaudeCommandToKiloSkill,
2430
+ // #2087 — opencode/kilo command-frontmatter converters, exported so the
2431
+ // layout-driven `convertedCommandsKind` can resolve them by name (routes the
2432
+ // opencode/kilo command install through the engine instead of the bespoke path).
2433
+ convertClaudeToOpencodeFrontmatter,
2434
+ convertClaudeToKiloFrontmatter,
2428
2435
  readGsdCommandNames,
2429
2436
  transformContentToHyphen,
2430
2437
  // #1383: version resolver (exported for regression test of the Codex
@@ -111,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) {
111
111
  items.push({
112
112
  kind: kind.kind,
113
113
  sourceDir,
114
- destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
114
+ destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
115
115
  });
116
116
  }
117
117
  return { ok: true, plan: { items, cleanupDirs } };
@@ -120,7 +120,7 @@ function createRuntimeArtifactUninstallPlan(layout) {
120
120
  return {
121
121
  items: layout.kinds.map((kind) => ({
122
122
  kind: kind.kind,
123
- destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
123
+ destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
124
124
  })),
125
125
  };
126
126
  }
@@ -278,27 +278,34 @@ function getRegistry() {
278
278
  function dispatchKindEntry(entry, runtime, configDir, scope) {
279
279
  const { kind, destSubpath, prefix, nesting, converter } = entry;
280
280
  const nested = nesting === 'nested';
281
+ let result;
281
282
  switch (kind) {
282
283
  case 'commands':
283
- if (converter == null) {
284
- return commandsKind(destSubpath, prefix, configDir);
285
- }
286
- return convertedCommandsKind(destSubpath, prefix, converter, configDir);
284
+ result = converter == null
285
+ ? commandsKind(destSubpath, prefix, configDir)
286
+ : convertedCommandsKind(destSubpath, prefix, converter, configDir);
287
+ break;
287
288
  case 'agents':
288
- if (converter == null) {
289
- return agentsKind(destSubpath, prefix, configDir);
290
- }
291
- return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
289
+ result = converter == null
290
+ ? agentsKind(destSubpath, prefix, configDir)
291
+ : convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
292
+ break;
292
293
  case 'skills':
293
294
  if (converter == null) {
294
295
  throw new TypeError(`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`);
295
296
  }
296
- return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
297
+ result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
298
+ break;
297
299
  case 'kimi-agents':
298
- return kimiAgentsKind(destSubpath, prefix, configDir);
300
+ result = kimiAgentsKind(destSubpath, prefix, configDir);
301
+ break;
299
302
  default:
300
303
  throw new TypeError(`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`);
301
304
  }
305
+ if (typeof entry.home === 'string' && entry.home !== '') {
306
+ result.home = node_path_1.default.join(node_os_1.default.homedir(), entry.home);
307
+ }
308
+ return result;
302
309
  }
303
310
  /**
304
311
  * Resolve the artifact layout for a given runtime and config directory.
@@ -296,6 +296,14 @@ function getGlobalSkillsBase(runtime) {
296
296
  const runtimeEntry = getRegistry().runtimes[runtime];
297
297
  const descriptor = runtimeEntry?.runtime;
298
298
  const globalSkillsKind = descriptor?.artifactLayout?.global?.find((entry) => entry.kind === 'skills');
299
+ // ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
300
+ // → $HOME/.agents/skills, independent of $CODEX_HOME) so the reported skills
301
+ // root matches where the installer actually writes (the artifact layout /
302
+ // _resolveSkillsRootDir). Without this, `--skills-root` and the sync-skills
303
+ // workflow would look under configHome/skills while skills live under ~/.agents.
304
+ if (globalSkillsKind?.home && globalSkillsKind?.destSubpath) {
305
+ return node_path_1.default.join(node_os_1.default.homedir(), globalSkillsKind.home, globalSkillsKind.destSubpath);
306
+ }
299
307
  if (descriptor?.configHome && globalSkillsKind?.destSubpath) {
300
308
  return resolveSkillsBaseFromDescriptor(descriptor.configHome, { env: process.env, home: node_os_1.default.homedir(), existsSync: node_fs_1.default.existsSync }, globalSkillsKind.destSubpath);
301
309
  }
@@ -28,6 +28,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
28
28
  const node_fs_1 = __importDefault(require("node:fs"));
29
29
  const node_path_1 = __importDefault(require("node:path"));
30
30
  const node_os_1 = __importDefault(require("node:os"));
31
+ const imperative_hook_bus_cjs_1 = require("./host-integration-adapters/imperative-hook-bus.cjs");
31
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
33
  const shellCmdProjection = require("./shell-command-projection.cjs");
33
34
  const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
@@ -57,7 +58,18 @@ const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
57
58
  // ---------------------------------------------------------------------------
58
59
  const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
59
60
  const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
61
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
62
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
63
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
64
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
60
65
  const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
66
+ // The full set of Cursor hook events GSD manages — sourced from the adapter
67
+ // (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary
68
+ // stays closed and first-party. Used by reconcileCursorHooksJson (the
69
+ // reconciliation scope is always the full set). The install path
70
+ // (writeCursorHooksJson) resolves a descriptor-driven subset via
71
+ // resolveManagedHookEvents(opts.managedHookEvents).
72
+ const CURSOR_MANAGED_EVENTS = imperative_hook_bus_cjs_1.CURSOR_HOOK_EVENTS;
61
73
  // ---------------------------------------------------------------------------
62
74
  // Cline / AGENTS.md constants
63
75
  // ---------------------------------------------------------------------------
@@ -819,9 +831,8 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
819
831
  parsed = {};
820
832
  const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
821
833
  if (!hasNestedHooksObject) {
822
- const eventKeys = ['sessionStart', 'postToolUse'];
823
834
  const lifted = {};
824
- for (const k of eventKeys) {
835
+ for (const k of CURSOR_MANAGED_EVENTS) {
825
836
  if (Array.isArray(parsed[k])) {
826
837
  lifted[k] = parsed[k];
827
838
  delete parsed[k];
@@ -832,9 +843,8 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
832
843
  if (!parsed['version'])
833
844
  parsed['version'] = 1;
834
845
  const hookTable = parsed['hooks'];
835
- const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
836
846
  const entries = managedEntries || {};
837
- for (const event of MANAGED_EVENTS) {
847
+ for (const event of CURSOR_MANAGED_EVENTS) {
838
848
  const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
839
849
  const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
840
850
  const newEntry = entries[event] || null;
@@ -862,7 +872,11 @@ function writeCursorHooksJson(targetDir, src, opts) {
862
872
  opts = opts || {};
863
873
  const hooksDir = node_path_1.default.join(targetDir, 'hooks');
864
874
  node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
865
- const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
875
+ // Descriptor-driven event resolution (#2089): the managed event set comes
876
+ // from the host descriptor's hostBehaviors.managedHookEvents via the pure
877
+ // adapter (resolveManagedHookEvents), NOT a hardcoded constant.
878
+ const events = (0, imperative_hook_bus_cjs_1.resolveManagedHookEvents)(opts.managedHookEvents);
879
+ const hookScripts = (0, imperative_hook_bus_cjs_1.resolveHookScripts)(events);
866
880
  const srcHooksDir = node_path_1.default.join(src, 'hooks');
867
881
  const installedScripts = new Set();
868
882
  for (const script of hookScripts) {
@@ -880,27 +894,17 @@ function writeCursorHooksJson(targetDir, src, opts) {
880
894
  }
881
895
  }
882
896
  const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
883
- const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
884
- ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
885
- : null;
886
- const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
887
- ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
888
- : null;
889
- const managedEntries = {};
890
- if (sessionStartCmd) {
891
- managedEntries['sessionStart'] = {
892
- type: 'command',
893
- command: sessionStartCmd,
894
- [GSD_CURSOR_HOOK_MARKER]: true,
895
- };
896
- }
897
- if (postToolCmd) {
898
- managedEntries['postToolUse'] = {
899
- type: 'command',
900
- command: postToolCmd,
901
- [GSD_CURSOR_HOOK_MARKER]: true,
902
- };
897
+ const commands = {};
898
+ for (const ev of events) {
899
+ const script = imperative_hook_bus_cjs_1.CURSOR_EVENT_SCRIPT_MAP[ev];
900
+ if (script && installedScripts.has(script)) {
901
+ commands[ev] = buildHookCommand(targetDir, script, hookOpts);
902
+ }
903
+ else {
904
+ commands[ev] = null;
905
+ }
903
906
  }
907
+ const managedEntries = (0, imperative_hook_bus_cjs_1.buildHookBusEntries)(events, commands);
904
908
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
905
909
  const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
906
910
  return { hooksJsonPath, changed: result.changed };
@@ -1449,6 +1453,10 @@ module.exports = {
1449
1453
  removeCursorHooksJson,
1450
1454
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
1451
1455
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
1456
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
1457
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
1458
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
1459
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
1452
1460
  GSD_CURSOR_HOOK_MARKER,
1453
1461
  // Copilot
1454
1462
  buildCopilotHookConfig,