@opengsd/gsd-core 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
@@ -12,10 +12,62 @@ const node_path_1 = require("node:path");
12
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
13
13
  const coreUtils = require("./core-utils.cjs");
14
14
  const { countMatchedSummaries } = coreUtils;
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const frontmatterMod = require("./frontmatter.cjs");
17
+ const { extractFrontmatter } = frontmatterMod;
15
18
  // Excluded derivative files
16
19
  const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
17
20
  const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
18
21
  const PLAN_REVIEW_RE = /-PLAN-REVIEW\.md$/i;
22
+ // #2349: a plan's frontmatter always sits at byte 0 and closes well before the
23
+ // body, so only a bounded prefix is ever needed to read the `status` marker.
24
+ // Capping the read keeps scanPhasePlans — which loops over every phase directory
25
+ // on hot paths (state sync/validate, roadmap progress) — from slurping a
26
+ // pathologically large committed plan file into memory just to inspect one key.
27
+ const PLAN_FRONTMATTER_READ_CAP = 64 * 1024;
28
+ /**
29
+ * #2349: a plan whose frontmatter declares `status: superseded` was deliberately
30
+ * reassigned or never executed — its work moved to a later plan, so it can never
31
+ * gain a matching `*-SUMMARY.md`. Like a retired phase (#1514, one level up), such
32
+ * a plan must be excluded from BOTH the plan and summary counts; otherwise a phase
33
+ * with a deliberately-unexecuted plan reads `completed: false` forever, pinning the
34
+ * milestone below 100%. Reading only the frontmatter `status` key is the same seam
35
+ * verify.cts / phase.cts already use for plan metadata; a plan without the marker is
36
+ * counted exactly as before.
37
+ *
38
+ * This is the only path in scanPhasePlans that opens file *contents* (the rest is
39
+ * filename matching), so it is hardened accordingly: `statSync().isFile()` rejects
40
+ * anything that is not a regular file — a directory, socket, or a symlink resolving
41
+ * to a device such as `/dev/zero` (a git-committable DoS vector; cf. #2378/#2383) —
42
+ * BEFORE any open, and the read is bounded to a fixed prefix. Fail-safe throughout:
43
+ * a non-regular or unreadable plan is treated as a normal (counted) plan, never
44
+ * silently dropped.
45
+ */
46
+ function isPlanSuperseded(planFullPath) {
47
+ let content;
48
+ try {
49
+ const st = (0, node_fs_1.statSync)(planFullPath); // follows symlinks → resolves to the target's real type
50
+ if (!st.isFile())
51
+ return false;
52
+ const length = Math.min(st.size, PLAN_FRONTMATTER_READ_CAP);
53
+ if (length === 0)
54
+ return false;
55
+ const fd = (0, node_fs_1.openSync)(planFullPath, 'r');
56
+ try {
57
+ const buf = Buffer.allocUnsafe(length);
58
+ const bytesRead = (0, node_fs_1.readSync)(fd, buf, 0, length, 0);
59
+ content = buf.toString('utf8', 0, bytesRead);
60
+ }
61
+ finally {
62
+ (0, node_fs_1.closeSync)(fd);
63
+ }
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ const status = extractFrontmatter(content)['status'];
69
+ return typeof status === 'string' && status.trim().toLowerCase() === 'superseded';
70
+ }
19
71
  function isRootPlanFile(fileName) {
20
72
  if (PLAN_OUTLINE_RE.test(fileName))
21
73
  return false;
@@ -75,7 +127,16 @@ function scanPhasePlans(phaseDir) {
75
127
  }
76
128
  catch { /* ignore unreadable nested layout */ }
77
129
  }
78
- const planFiles = rootPlanFiles.concat(nestedPlanFiles);
130
+ const allPlanFiles = rootPlanFiles.concat(nestedPlanFiles);
131
+ // #2349: drop plans explicitly marked `status: superseded` from the plan set
132
+ // BEFORE counting, so they inflate neither the denominator (planCount) nor,
133
+ // via countMatchedSummaries below, the numerator (summaryCount). Plans without
134
+ // the marker are untouched, so behaviour is byte-for-behaviour identical for
135
+ // every existing phase — only a phase carrying the new marker changes.
136
+ const supersededPlanFiles = allPlanFiles.filter((f) => isPlanSuperseded((0, node_path_1.join)(phaseDir, f)));
137
+ const planFiles = supersededPlanFiles.length === 0
138
+ ? allPlanFiles
139
+ : allPlanFiles.filter((f) => !supersededPlanFiles.includes(f));
79
140
  const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
80
141
  const planCount = planFiles.length;
81
142
  // Count only summaries that are the PLAN→SUMMARY partner of an existing plan
@@ -87,7 +148,14 @@ function scanPhasePlans(phaseDir) {
87
148
  return {
88
149
  planCount,
89
150
  summaryCount,
90
- completed: planCount > 0 && summaryCount >= planCount,
151
+ // #2349: gate completion on whether the phase had ANY plans on disk
152
+ // (allPlanFiles), NOT on the post-exclusion planCount. A phase whose plans
153
+ // were ALL marked superseded has planCount 0, but it is NOT an unplanned
154
+ // empty phase — there is simply no remaining work, so it must read complete
155
+ // (0 >= 0) rather than being pinned below 100% forever, which is the very
156
+ // failure this fix removes. A genuinely empty phase (no plans authored)
157
+ // still has allPlanFiles.length 0 and stays not-completed, exactly as before.
158
+ completed: allPlanFiles.length > 0 && summaryCount >= planCount,
91
159
  hasNestedPlans,
92
160
  planFiles,
93
161
  summaryFiles,
@@ -511,12 +511,15 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
511
511
  return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
512
512
  }
513
513
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
514
- // #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
515
- // — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
514
+ // #2043: milestone-prefixed sub-phase components must be zero-padded — so a
515
+ // single-digit slug word after the phase
516
516
  // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
517
- // the milestone as a bogus "46-6" id.
517
+ // the milestone as a bogus "46-6" id. #2232: the continuation width is exactly 2
518
+ // (PHASE_CONTINUATION_SEGMENT_SOURCE), so a year-leading slug word (dir
519
+ // "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id.
520
+ // Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling).
518
521
  const numericRe = roadmapUsesHyphenedIds
519
- ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
522
+ ? new RegExp(`^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Za-z]?(?:\\.\\d+)*)`)
520
523
  // 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.
521
524
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
522
525
  function isDirInMilestone(dirName) {
@@ -170,9 +170,19 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
170
170
  if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum)))
171
171
  return null;
172
172
  const roadmapPath = planningPaths(cwd).roadmap;
173
- if (!node_fs_1.default.existsSync(roadmapPath))
174
- return null;
175
- const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
173
+ // Read directly rather than gating on fs.existsSync: existsSync returns false
174
+ // on EACCES/EIO too, which would mask an UNREADABLE roadmap as "missing" and
175
+ // let a blocking gate certify empty scope (#2365 review). Honor the documented
176
+ // contract — null only when genuinely absent (ENOENT), otherwise throw.
177
+ let rawContent;
178
+ try {
179
+ rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
180
+ }
181
+ catch (err) {
182
+ if (err?.code === 'ENOENT')
183
+ return null;
184
+ throw err;
185
+ }
176
186
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
177
187
  const fullContent = stripShippedMilestones(rawContent);
178
188
  // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
@@ -868,7 +868,13 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
868
868
  description = toSingleLine(description);
869
869
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
870
870
  const adapter = getCursorSkillAdapterHeader(skillName);
871
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
871
+ // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/'
872
+ // menu (it defaults to true). Cursor also writes a commands/ surface (#785),
873
+ // and surfacing both duplicated every /gsd-* entry. This mirrors the #789
874
+ // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills
875
+ // stay model-invocable background knowledge. (user-invocable:false hides from
876
+ // '/' while keeping model invocation — distinct from disable-model-invocation.)
877
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`;
872
878
  }
873
879
  /**
874
880
  * Convert a Claude Code command to a Cursor 1.6 slash command (#785).
@@ -227,8 +227,12 @@ function kimiAgentsKind(destSubpath, prefix, configDir) {
227
227
  * arg so scope-aware converters (antigravity, copilot) can choose
228
228
  * between global home paths and workspace-relative paths without
229
229
  * colliding with the `runtime` string at position 3.
230
+ * @param capabilityRegistry #2322: optional capability registry — captured in the
231
+ * stage() closure so third-party capability skills are bound to
232
+ * their declaring capId at staging time. Absent -> stage() stages
233
+ * nothing third-party (fail closed).
230
234
  */
231
- function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false, scope = 'global') {
235
+ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false, scope = 'global', capabilityRegistry) {
232
236
  return {
233
237
  kind: 'skills',
234
238
  destSubpath,
@@ -247,7 +251,7 @@ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nest
247
251
  : [];
248
252
  const isGlobal = scope === 'global';
249
253
  const wrappedConverter = (content, skillName) => realConverter(content, skillName, runtime, cmdNames, isGlobal);
250
- return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
254
+ return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);
251
255
  },
252
256
  };
253
257
  }
@@ -285,7 +289,7 @@ function getRegistry() {
285
289
  * Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
286
290
  * matching builder function. Mirrors the hand-built calls in the old switch.
287
291
  */
288
- function dispatchKindEntry(entry, runtime, configDir, scope) {
292
+ function dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry) {
289
293
  const { kind, destSubpath, prefix, nesting, converter } = entry;
290
294
  const nested = nesting === 'nested';
291
295
  let result;
@@ -304,7 +308,7 @@ function dispatchKindEntry(entry, runtime, configDir, scope) {
304
308
  if (converter == null) {
305
309
  throw new TypeError(`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`);
306
310
  }
307
- result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
311
+ result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope, capabilityRegistry);
308
312
  break;
309
313
  case 'kimi-agents':
310
314
  result = kimiAgentsKind(destSubpath, prefix, configDir);
@@ -322,11 +326,21 @@ function dispatchKindEntry(entry, runtime, configDir, scope) {
322
326
  *
323
327
  * ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
324
328
  * instead of a hardcoded switch statement.
329
+ *
330
+ * @param capabilityRegistry #2322: optional — when the caller has a composed
331
+ * capability registry in scope (e.g. capability-writer.cts's `capability set`
332
+ * path, or a fresh install's registry-aware profile resolution), pass it here
333
+ * so the skills kind's stage() closure can materialize installed third-party
334
+ * capability skills bound to their declaring capId. Both call paths (surface
335
+ * apply AND the installer) must pass their registry here — resolveProfile's
336
+ * own `'*'` (full profile) short-circuit never carries a registry, so if it
337
+ * is not threaded in at layout-build time a `full`-profile install stages no
338
+ * third-party capability skills regardless of registration (#2322 blocker 2).
325
339
  */
326
- function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') {
327
- return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope);
340
+ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global', capabilityRegistry) {
341
+ return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope, capabilityRegistry);
328
342
  }
329
- function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir, scope = 'global') {
343
+ function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir, scope = 'global', capabilityRegistry) {
330
344
  if (typeof configDir !== 'string' || configDir === '') {
331
345
  throw new TypeError('configDir must be a non-empty string');
332
346
  }
@@ -338,7 +352,7 @@ function resolveRuntimeArtifactLayoutFromRegistry(registry, runtime, configDir,
338
352
  throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
339
353
  }
340
354
  const entries = desc[scope] ?? [];
341
- const kinds = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope));
355
+ const kinds = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry));
342
356
  return { runtime, configDir, scope, kinds };
343
357
  }
344
358
  module.exports = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot };
@@ -309,6 +309,22 @@ function normalizeNodePath(execPath, opts) {
309
309
  if (existsSync(shim))
310
310
  return shim;
311
311
  }
312
+ // volta pins a concrete node image at <VOLTA_HOME>/tools/image/node/<ver>/bin/node
313
+ // (Windows: <VOLTA_HOME>/tools/image/node/<ver>/node.exe — volta's own layout
314
+ // puts node.exe at the image root, no bin/). `volta uninstall node@<ver>` prunes
315
+ // that image, so a baked hook command 404s — the same ephemeral-path failure
316
+ // #977 fixed for fnm and #1619 for mise. The stable alias is the shim
317
+ // <VOLTA_HOME>/bin/node, a symlink to volta-shim that always resolves to the
318
+ // active pin. Derive <VOLTA_HOME> from execPath rather than the env so a custom
319
+ // VOLTA_HOME and the Windows %LOCALAPPDATA%\Volta default both work (#2185's
320
+ // reasoning), and only rewrite when the shim exists — otherwise fall back to
321
+ // the raw execPath unchanged.
322
+ const voltaMatch = normalizedForMatch.match(/^(.*)\/tools\/image\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/);
323
+ if (voltaMatch) {
324
+ const shim = `${voltaMatch[1]}/bin/node${voltaMatch[2] || ''}`;
325
+ if (existsSync(shim))
326
+ return shim;
327
+ }
312
328
  return execPath;
313
329
  }
314
330
  function resolveNodeRunner(opts) {
@@ -52,6 +52,9 @@ const { planningPaths } = planningWorkspace;
52
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
53
  const frontmatter = require("./frontmatter.cjs");
54
54
  const { extractFrontmatter } = frontmatter;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
56
+ const phaseLifecycle = require("./phase-lifecycle.cjs");
57
+ const { deriveProgressFromRoadmap } = phaseLifecycle;
55
58
  // eslint-disable-next-line @typescript-eslint/no-require-imports
56
59
  const stateDocument = require("./state-document.cjs");
57
60
  const { stateExtractField } = stateDocument;
@@ -245,6 +248,8 @@ function detectSignals(cwd, now = Date.now) {
245
248
  has_git: git.has_git,
246
249
  verify_failed: false,
247
250
  stale_activity: false,
251
+ roadmap_total_phases: null,
252
+ roadmap_completed_phases: null,
248
253
  };
249
254
  if (!hasPlanning)
250
255
  return empty;
@@ -291,6 +296,24 @@ function detectSignals(cwd, now = Date.now) {
291
296
  // STATUS: marker on the current phase's summary/verify artifact.
292
297
  const verifyFailed = /\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') ||
293
298
  detectVerifyFailed(cwd, currentPhaseRaw);
299
+ // #2427: derive global phase counts from ROADMAP.md's Progress table. These
300
+ // are preferred over STATE.md's cached milestone-scoped `total_phases` (which
301
+ // goes stale when phases are appended after a milestone switch) for the
302
+ // completion check. Null when ROADMAP.md is absent or has no parseable
303
+ // Progress table — isComplete falls back to the legacy comparison in that case.
304
+ let roadmapTotalPhases = null;
305
+ let roadmapCompletedPhases = null;
306
+ if (hasRoadmap) {
307
+ try {
308
+ const roadmapContent = node_fs_1.default.readFileSync(paths.roadmap, 'utf8');
309
+ const derived = deriveProgressFromRoadmap(roadmapContent);
310
+ roadmapTotalPhases = derived.totalPhases;
311
+ roadmapCompletedPhases = derived.completedPhases;
312
+ }
313
+ catch {
314
+ /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
315
+ }
316
+ }
294
317
  return {
295
318
  current_phase: parseIntOrNull(currentPhaseRaw),
296
319
  total_phases: parseIntOrNull(totalPhasesRaw),
@@ -305,14 +328,56 @@ function detectSignals(cwd, now = Date.now) {
305
328
  has_git: git.has_git,
306
329
  verify_failed: verifyFailed,
307
330
  stale_activity: staleActivity,
331
+ roadmap_total_phases: roadmapTotalPhases,
332
+ roadmap_completed_phases: roadmapCompletedPhases,
308
333
  };
309
334
  }
310
335
  // ─── Situation classification ─────────────────────────────────────────────────
311
- /** True when the workflow has fully completed all phases. */
336
+ /**
337
+ * True when the workflow has fully completed all phases.
338
+ *
339
+ * #2427: completion is grounded in ROADMAP.md's Progress table (global,
340
+ * authoritative, never stale) when available, with a legacy fallback to
341
+ * STATE.md's cached `total_phases` when the roadmap has no parseable Progress
342
+ * table (e.g. a fresh project or a non-standard roadmap layout). The status
343
+ * regex was tightened to require milestone-level completion language
344
+ * (`milestone complete` / `all phases complete` / `complete(d)`) and no longer
345
+ * matches per-phase messages like "Phase X shipped — PR #N" that falsely
346
+ * satisfied the pre-fix alternation (`\bcomplete(d)?|done|shipped\b`).
347
+ */
312
348
  function isComplete(s) {
313
- if (s.total_phases === null || s.current_phase === null)
314
- return false;
315
- return s.current_phase >= s.total_phases && /\bcomplete(d)?|done|shipped\b/i.test(s.status);
349
+ // Prefer ROADMAP-derived counts (global, authoritative) over STATE.md's
350
+ // cached milestone-scoped total_phases (stale-prone). Fall back to legacy
351
+ // when the roadmap has no Progress table.
352
+ if (s.roadmap_total_phases !== null && s.roadmap_completed_phases !== null) {
353
+ if (s.roadmap_total_phases === 0)
354
+ return false;
355
+ if (s.roadmap_completed_phases < s.roadmap_total_phases)
356
+ return false;
357
+ }
358
+ else {
359
+ // Legacy path: STATE.md comparison. Still subject to the two-scale bug,
360
+ // but only fires when ROADMAP.md is absent or has no Progress table.
361
+ if (s.total_phases === null || s.current_phase === null)
362
+ return false;
363
+ if (s.current_phase < s.total_phases)
364
+ return false;
365
+ }
366
+ // Status regex: require milestone-level completion language. The pre-fix
367
+ // regex matched any "shipped" / "done" substring (per-phase language).
368
+ // Tightened to match:
369
+ // - "milestone complete" (ADR-2207 terminal status — usually written as
370
+ // "<version> milestone complete", e.g. "v1.0 milestone complete"; the
371
+ // substring match handles both forms)
372
+ // - "all phases complete" (ADR-2207 intermediate terminal)
373
+ // - "complete" / "completed" (legacy short form — STATE.md milestone
374
+ // status is a single value, not a per-phase log)
375
+ // Intentionally does NOT match "done" alone even though normalizeStateStatus
376
+ // (state-document.cts) treats "done" as "completed" — in the milestone
377
+ // status field, "done" is per-phase noise (e.g. "Phase X done"), not a
378
+ // milestone-completion signal. Mirrors workstream-inventory-builder.cts's
379
+ // terminal pattern \bmilestone\s+complete\b.
380
+ return /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.test(s.status);
316
381
  }
317
382
  /** Idle-stranded: clean tree, committed work not shipped, optionally stale. */
318
383
  function isIdleStranded(s) {
@@ -153,11 +153,14 @@ function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
153
153
  return false;
154
154
  const existing = existingProgress;
155
155
  const derived = derivedProgress;
156
- // total_phases is intentionally excluded from the ratchet: it must always
157
- // take the freshly derived value so it can correct downward (#1446).
158
- // Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
156
+ // total_phases (#1446) and total_plans (#2440) are intentionally excluded
157
+ // from the ratchet: both must always take the freshly derived value so they
158
+ // can correct in BOTH directions. total_plans legitimately moves up (a new
159
+ // phase adds plans) and down (milestone reorganization removes phases).
160
+ // Ratcheting it freezes stale values. Only completed_phases and
161
+ // completed_plans keep ratchet behaviour — they are monotonic (once a
162
+ // phase/plan is complete, it stays complete).
159
163
  return (existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
160
- existingProgressExceedsDerived(existing, derived, 'total_plans') ||
161
164
  existingProgressExceedsDerived(existing, derived, 'completed_plans'));
162
165
  }
163
166
  function normalizeProgressNumbers(progress) {
@@ -100,7 +100,28 @@ function applyStatePreservation(input) {
100
100
  !resync &&
101
101
  preFm &&
102
102
  preFm['progress']) {
103
- postFm['progress'] = preFm['progress'];
103
+ // #2440: when the caller opts in (deriveProgressKeys), total_plans and
104
+ // total_phases always take the derived (post-sync) value even under !resync.
105
+ // This is used by cmdStatePlannedPhase where total_plans must correct upward
106
+ // after plans are added. For body-only writes (state.update/patch without
107
+ // the flag), the wholesale restore preserves everything as before — the
108
+ // #3242 Bug A protection stays fully in force.
109
+ if (input.deriveProgressKeys && postFm['progress']) {
110
+ const curated = preFm['progress'];
111
+ const derived = (postFm['progress'] ?? {});
112
+ const merged = { ...derived };
113
+ if (curated) {
114
+ for (const [key, value] of Object.entries(curated)) {
115
+ if (key !== 'total_plans' && key !== 'total_phases') {
116
+ merged[key] = value;
117
+ }
118
+ }
119
+ }
120
+ postFm['progress'] = merged;
121
+ }
122
+ else {
123
+ postFm['progress'] = preFm['progress'];
124
+ }
104
125
  mutated = true;
105
126
  }
106
127
  // status — #1230 body-delta heuristic. Table: preserve-when-unchanged.
@@ -170,6 +170,12 @@ function cmdStateLoad(cwd, raw) {
170
170
  state_exists: stateExists,
171
171
  roadmap_exists: roadmapExists,
172
172
  config_exists: configExists,
173
+ // #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
174
+ // spawned subagent's own cwd may differ from the orchestrator's.
175
+ // debug.md has no init.* call of its own; it reads this field from
176
+ // `state load` to build debug_file_path for its gsd-debug-session-manager
177
+ // spawns instead of hardcoding '.planning/debug/{slug}.md'.
178
+ debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(node_path_1.default.join(planDir, 'debug')),
173
179
  };
174
180
  // For --raw, output a condensed key=value format
175
181
  if (raw) {
@@ -1003,6 +1009,15 @@ function cmdStateRecordSession(cwd, options, raw) {
1003
1009
  // duplicate section alongside an existing one.
1004
1010
  const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
1005
1011
  const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
1012
+ // Track whether the chosen branch's rewrite actually matched. The detector
1013
+ // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
1014
+ // tolerant ($ under /m treats \r as a line terminator); the writer regexes
1015
+ // below must be too. If a writer regex silently fails to match (line-ending
1016
+ // mismatch, unexpected heading shape, ...), do NOT report success — the
1017
+ // caller would believe fields were persisted that were silently dropped
1018
+ // (#2450). The append branch always sets rewriteMatched=true (it always
1019
+ // mutates content).
1020
+ let rewriteMatched = false;
1006
1021
  if (existingCanonicalSession) {
1007
1022
  // Normalize in place: replace the ENTIRE BODY of the existing ## Session
1008
1023
  // section (heading + all content up to the next ## heading or EOF) with
@@ -1010,7 +1025,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1010
1025
  // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
1011
1026
  // which correctly stops at the next section boundary without consuming it.
1012
1027
  // A trailing blank line is added so the next ## heading keeps its spacing.
1013
- content = content.replace(/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m, [
1028
+ //
1029
+ // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
1030
+ // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
1031
+ // updated.push(...) reported success — #2450. The detector regex on the
1032
+ // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
1033
+ // asymmetry armed the bug.
1034
+ const canonicalReplacement = [
1014
1035
  '## Session',
1015
1036
  '',
1016
1037
  `**Last session:** ${now}`,
@@ -1018,7 +1039,11 @@ function cmdStateRecordSession(cwd, options, raw) {
1018
1039
  `**Resume file:** ${resumeValue}`,
1019
1040
  '',
1020
1041
  '',
1021
- ].join('\n'));
1042
+ ].join('\n');
1043
+ content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
1044
+ rewriteMatched = true;
1045
+ return canonicalReplacement;
1046
+ });
1022
1047
  }
1023
1048
  else if (existingSessionContinuity) {
1024
1049
  // #1101: a `## Session Continuity` section already exists (bootstrap
@@ -1029,6 +1054,8 @@ function cmdStateRecordSession(cwd, options, raw) {
1029
1054
  // (e.g. prose like "Next recommended action"). Fields already updated in
1030
1055
  // place above (needs* false) are not re-inserted. A function replacement
1031
1056
  // is used so `$`-bearing caller values are inserted literally (#3454).
1057
+ //
1058
+ // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
1032
1059
  const linesToInsert = [];
1033
1060
  if (needsLastSession)
1034
1061
  linesToInsert.push(`**Last session:** ${now}`);
@@ -1040,8 +1067,18 @@ function cmdStateRecordSession(cwd, options, raw) {
1040
1067
  // Case-insensitive to match the `existingSessionContinuity` detection
1041
1068
  // above (#1101 review F3) — otherwise a lowercase heading would detect
1042
1069
  // but no-op the insert while still reporting the fields as updated.
1043
- content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
1070
+ content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
1071
+ rewriteMatched = true;
1072
+ return heading + linesToInsert.join('\n') + '\n';
1073
+ });
1044
1074
  }
1075
+ // No `else` branch: if linesToInsert.length === 0 the outer guard at
1076
+ // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
1077
+ // || needsLastSession)) could not have fired, so this whole block is
1078
+ // unreachable. Leaving `rewriteMatched = false` here is the fail-loud
1079
+ // posture — a future change to the outer guard or needs* computation
1080
+ // that makes this branch reachable will surface as a missing
1081
+ // updated[] entry (silent recorded:false) rather than re-arming #2450.
1045
1082
  }
1046
1083
  else {
1047
1084
  // No session heading exists at all — append a new canonical section.
@@ -1055,14 +1092,30 @@ function cmdStateRecordSession(cwd, options, raw) {
1055
1092
  '',
1056
1093
  ].join('\n');
1057
1094
  content = content.trimEnd() + '\n' + scaffold;
1095
+ rewriteMatched = true;
1096
+ }
1097
+ // #2450 defensive invariant: only report sessionCreated/updated when the
1098
+ // chosen branch's rewrite actually mutated content. Unreachable when the
1099
+ // writer regexes above stay in sync with the CRLF-tolerant detector —
1100
+ // but unreachable-defensive is the right posture for a silent-success
1101
+ // gate. A no-op replace here means a future line-ending or shape drift
1102
+ // between detector and writer; fail to record rather than claim success.
1103
+ //
1104
+ // Scope limitation (not a regression of this fix): the gate covers only
1105
+ // the section-rewrite block. The earlier in-place stateReplaceField
1106
+ // successes at :1081/:1083/:1089/:1101/:1108/:1114 push to `updated`
1107
+ // unconditionally — those represent fields that DID land on disk via
1108
+ // same-line replace (CRLF-agnostic seam), so unconditional push is
1109
+ // correct. The class-defect防御 here is for the INSERT path only.
1110
+ if (rewriteMatched) {
1111
+ sessionCreated = true;
1112
+ if (needsLastSession)
1113
+ updated.push('Last session');
1114
+ if (needsStoppedAt)
1115
+ updated.push('Stopped At');
1116
+ if (needsResumeFile)
1117
+ updated.push('Resume File');
1058
1118
  }
1059
- sessionCreated = true;
1060
- if (needsLastSession)
1061
- updated.push('Last session');
1062
- if (needsStoppedAt)
1063
- updated.push('Stopped At');
1064
- if (needsResumeFile)
1065
- updated.push('Resume File');
1066
1119
  }
1067
1120
  return content;
1068
1121
  }, cwd);
@@ -1970,6 +2023,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1970
2023
  const postFm = extractFrontmatter(synced);
1971
2024
  const preservation = applyStatePreservation({
1972
2025
  preFm, postFm, preFmSnapshot, resync,
2026
+ deriveProgressKeys: options?.deriveProgressKeys === true,
1973
2027
  preBodyStatus, postBodyStatus,
1974
2028
  preBodyStoppedAt, postBodyStoppedAt,
1975
2029
  preBodyPhaseSource, postBodyPhaseSource,
@@ -2313,7 +2367,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2313
2367
  const result = transitionCore(content, intent, deps);
2314
2368
  updated = result.updated;
2315
2369
  return result.content;
2316
- }, cwd, { resync: false });
2370
+ }, cwd, { resync: false, deriveProgressKeys: true });
2317
2371
  output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2318
2372
  }
2319
2373
  /**