@opengsd/gsd-core 1.4.4 → 1.5.0-rc.2

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 (197) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-code-fixer.md +3 -2
  4. package/agents/gsd-debug-session-manager.md +2 -1
  5. package/agents/gsd-debugger.md +4 -3
  6. package/agents/gsd-executor.md +17 -16
  7. package/agents/gsd-intel-updater.md +38 -41
  8. package/agents/gsd-phase-researcher.md +8 -8
  9. package/agents/gsd-plan-checker.md +23 -13
  10. package/agents/gsd-planner.md +32 -188
  11. package/agents/gsd-project-researcher.md +5 -4
  12. package/agents/gsd-research-synthesizer.md +2 -1
  13. package/agents/gsd-ui-researcher.md +2 -1
  14. package/agents/gsd-verifier.md +12 -11
  15. package/bin/install.js +965 -1486
  16. package/commands/gsd/autonomous.md +5 -1
  17. package/commands/gsd/ns-manage.md +8 -1
  18. package/commands/gsd/ns-project.md +5 -0
  19. package/commands/gsd/ns-review.md +4 -1
  20. package/commands/gsd/ns-workflow.md +7 -1
  21. package/commands/gsd/plan-review-convergence.md +5 -4
  22. package/commands/gsd/surface.md +12 -5
  23. package/gemini-extension.json +1 -1
  24. package/gsd-core/bin/gsd-tools.cjs +198 -101
  25. package/gsd-core/bin/gsd_run +20 -0
  26. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  27. package/gsd-core/bin/lib/capability-registry.cjs +2234 -0
  28. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  29. package/gsd-core/bin/lib/check-command-router.cjs +133 -2
  30. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  31. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  32. package/gsd-core/bin/lib/configuration.cjs +4 -34
  33. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  34. package/gsd-core/bin/lib/core.cjs +107 -1817
  35. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  36. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  37. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  38. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  39. package/gsd-core/bin/lib/init.cjs +58 -12
  40. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  41. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  42. package/gsd-core/bin/lib/intel.cjs +3 -3
  43. package/gsd-core/bin/lib/io.cjs +222 -0
  44. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  45. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  46. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  47. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  48. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  49. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  50. package/gsd-core/bin/lib/phase.cjs +17 -0
  51. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  52. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  54. package/gsd-core/bin/lib/roadmap.cjs +44 -1
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +92 -95
  56. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +68 -29
  57. package/gsd-core/bin/lib/runtime-homes.cjs +163 -87
  58. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  59. package/gsd-core/bin/lib/runtime-name-policy.cjs +2 -1
  60. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  61. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  62. package/gsd-core/bin/lib/state-document.cjs +8 -0
  63. package/gsd-core/bin/lib/state.cjs +114 -2
  64. package/gsd-core/bin/lib/surface.cjs +66 -14
  65. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  66. package/gsd-core/bin/lib/update-context.cjs +4 -1
  67. package/gsd-core/bin/lib/verify.cjs +104 -1
  68. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  69. package/gsd-core/bin/shared/model-catalog.json +11 -6
  70. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -1
  71. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  72. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  73. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  74. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  75. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  76. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  77. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  78. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  79. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  80. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  81. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  82. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  83. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  84. package/gsd-core/references/edge-probe.md +261 -0
  85. package/gsd-core/references/planner-antipatterns.md +41 -0
  86. package/gsd-core/references/planner-guidance.md +186 -0
  87. package/gsd-core/references/planner-reviews.md +5 -2
  88. package/gsd-core/templates/phase-prompt.md +7 -7
  89. package/gsd-core/templates/project.md +19 -2
  90. package/gsd-core/templates/spec.md +12 -0
  91. package/gsd-core/templates/summary-complex.md +1 -0
  92. package/gsd-core/templates/summary-minimal.md +1 -0
  93. package/gsd-core/templates/summary-standard.md +1 -0
  94. package/gsd-core/templates/summary.md +1 -0
  95. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  96. package/gsd-core/workflows/add-backlog.md +1 -1
  97. package/gsd-core/workflows/add-phase.md +1 -1
  98. package/gsd-core/workflows/add-tests.md +1 -1
  99. package/gsd-core/workflows/add-todo.md +1 -1
  100. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  101. package/gsd-core/workflows/audit-fix.md +1 -1
  102. package/gsd-core/workflows/audit-milestone.md +1 -1
  103. package/gsd-core/workflows/audit-uat.md +1 -1
  104. package/gsd-core/workflows/autonomous.md +111 -51
  105. package/gsd-core/workflows/check-todos.md +1 -1
  106. package/gsd-core/workflows/cleanup.md +1 -1
  107. package/gsd-core/workflows/code-review-fix.md +6 -4
  108. package/gsd-core/workflows/code-review.md +53 -17
  109. package/gsd-core/workflows/complete-milestone.md +11 -5
  110. package/gsd-core/workflows/debug.md +1 -1
  111. package/gsd-core/workflows/diagnose-issues.md +1 -1
  112. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  113. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  114. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  115. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  116. package/gsd-core/workflows/discuss-phase.md +8 -1
  117. package/gsd-core/workflows/do.md +1 -1
  118. package/gsd-core/workflows/docs-update.md +1 -1
  119. package/gsd-core/workflows/edit-phase.md +1 -1
  120. package/gsd-core/workflows/eval-review.md +4 -1
  121. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  122. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  123. package/gsd-core/workflows/execute-phase.md +8 -1
  124. package/gsd-core/workflows/execute-plan.md +1 -1
  125. package/gsd-core/workflows/explore.md +1 -1
  126. package/gsd-core/workflows/extract-learnings.md +1 -1
  127. package/gsd-core/workflows/forensics.md +1 -1
  128. package/gsd-core/workflows/graduation.md +1 -1
  129. package/gsd-core/workflows/health.md +1 -1
  130. package/gsd-core/workflows/help/modes/full.md +1 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/ingest-docs.md +1 -1
  133. package/gsd-core/workflows/insert-phase.md +1 -1
  134. package/gsd-core/workflows/list-workspaces.md +1 -1
  135. package/gsd-core/workflows/manager.md +1 -1
  136. package/gsd-core/workflows/map-codebase.md +1 -1
  137. package/gsd-core/workflows/milestone-summary.md +1 -1
  138. package/gsd-core/workflows/mvp-phase.md +1 -1
  139. package/gsd-core/workflows/new-milestone.md +9 -1
  140. package/gsd-core/workflows/new-project.md +9 -1
  141. package/gsd-core/workflows/new-workspace.md +1 -1
  142. package/gsd-core/workflows/next.md +1 -1
  143. package/gsd-core/workflows/pause-work.md +1 -1
  144. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  145. package/gsd-core/workflows/plan-phase.md +65 -28
  146. package/gsd-core/workflows/plan-review-convergence.md +60 -33
  147. package/gsd-core/workflows/plant-seed.md +1 -1
  148. package/gsd-core/workflows/profile-user.md +1 -1
  149. package/gsd-core/workflows/progress.md +1 -1
  150. package/gsd-core/workflows/quick.md +2 -2
  151. package/gsd-core/workflows/remove-phase.md +1 -1
  152. package/gsd-core/workflows/remove-workspace.md +1 -1
  153. package/gsd-core/workflows/resume-project.md +1 -1
  154. package/gsd-core/workflows/review.md +1 -1
  155. package/gsd-core/workflows/scan.md +1 -1
  156. package/gsd-core/workflows/secure-phase.md +1 -1
  157. package/gsd-core/workflows/settings-advanced.md +7 -7
  158. package/gsd-core/workflows/settings-integrations.md +1 -1
  159. package/gsd-core/workflows/settings.md +2 -2
  160. package/gsd-core/workflows/ship.md +8 -1
  161. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  162. package/gsd-core/workflows/sketch.md +1 -1
  163. package/gsd-core/workflows/spec-phase.md +130 -1
  164. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  165. package/gsd-core/workflows/spike.md +1 -1
  166. package/gsd-core/workflows/stats.md +1 -1
  167. package/gsd-core/workflows/thread.md +1 -1
  168. package/gsd-core/workflows/transition.md +1 -1
  169. package/gsd-core/workflows/ui-phase.md +1 -1
  170. package/gsd-core/workflows/ui-review.md +1 -1
  171. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  172. package/gsd-core/workflows/update.md +2 -2
  173. package/gsd-core/workflows/validate-phase.md +1 -1
  174. package/gsd-core/workflows/verify-phase.md +1 -1
  175. package/gsd-core/workflows/verify-work.md +8 -1
  176. package/package.json +11 -3
  177. package/scripts/base64-scan.sh +1 -1
  178. package/scripts/changeset/cli.cjs +8 -1
  179. package/scripts/changeset/lint.cjs +38 -2
  180. package/scripts/ci-test-scope.cjs +21 -10
  181. package/scripts/gen-capability-registry.cjs +2293 -0
  182. package/scripts/gen-loop-host-contract.cjs +471 -0
  183. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  184. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  185. package/scripts/lint-regression-test-names.cjs +117 -0
  186. package/scripts/lint-test-file-count.allowlist.json +25 -4
  187. package/scripts/lint-windows-test-portability.cjs +178 -0
  188. package/scripts/prompt-injection-scan.sh +4 -4
  189. package/scripts/research-profiles.cjs +10 -10
  190. package/scripts/run-tests.cjs +133 -29
  191. package/scripts/secret-scan.sh +3 -3
  192. package/scripts/sync-next-version.cjs +133 -0
  193. package/scripts/sync-runtime-launcher.cjs +21 -5
  194. package/scripts/update-size-baseline.cjs +68 -0
  195. package/scripts/workflow-policy.cjs +42 -9
  196. package/scripts/workflow-size.cjs +90 -0
  197. package/scripts/run-cross-platform-tests.cjs +0 -67
@@ -28,11 +28,12 @@ const FALLBACK_ALIASES = {
28
28
  copilot: ['copilot', 'copilot-cli', 'github-copilot'],
29
29
  antigravity: ['antigravity', 'antigravity-cli', 'antigravity-agent'],
30
30
  cursor: ['cursor', 'cursor-cli', 'cursor-nightly'],
31
- windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next'],
31
+ windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next', 'devin-desktop'],
32
32
  augment: ['augment', 'augment-code', 'augment-cli'],
33
33
  trae: ['trae', 'trae-cli'],
34
34
  qwen: ['qwen', 'qwen-code', 'qwen-cli'],
35
35
  hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
36
+ kimi: ['kimi'],
36
37
  codebuddy: ['codebuddy', 'codebuddy-cli'],
37
38
  cline: ['cline', 'cline-cli'],
38
39
  };
@@ -61,8 +61,13 @@ function formatGsdSlash(commandName, runtime) {
61
61
  const tail = wsMatch && wsMatch[2] ? wsMatch[2] : '';
62
62
  const runtimeText = (typeof runtime === 'string' && runtime ? runtime : 'claude').toLowerCase();
63
63
  const rt = (0, runtime_name_policy_cjs_1.canonicalizeRuntimeName)(runtimeText) || runtimeText;
64
- if (rt === 'codex') {
65
- // Codex skills are invoked as $gsd-<cmd> (shell-var syntax). The command
64
+ // Descriptor-driven: look up commandStyle from the capability registry.
65
+ // Mirrors the lazy-require pattern from runtime-homes.cts §getGlobalConfigDir.
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
67
+ const { runtimes } = require('./capability-registry.cjs');
68
+ const style = runtimes[rt]?.runtime?.commandStyle;
69
+ if (style === 'shell-var') {
70
+ // shell-var runtimes (currently: codex) use $gsd-<cmd> syntax. The command
66
71
  // token is lowercased because shell-var identifiers are conventionally
67
72
  // lowercase; matches the convertCodexSlash() projection in bin/install.js.
68
73
  return `$gsd-${token.toLowerCase()}${tail}`;
@@ -232,6 +232,19 @@ function isManagedHookCommand(commandText, opts = {}) {
232
232
  const managedBasenames = managedHookCommandSurfaceSet(surface, includeLegacyAliases);
233
233
  if (!managedBasenames || managedBasenames.size === 0)
234
234
  return false;
235
+ // args-form check: the managed hook filename may appear in args[] rather than
236
+ // in command when a windowless launcher wraps the Node invocation. (#976)
237
+ // Only treat as managed when an arg basename matches the managed hook set —
238
+ // prevents false-positives for non-GSD entries that happen to share a path segment.
239
+ if (Array.isArray(opts.args) && opts.args.length > 0) {
240
+ for (const arg of opts.args) {
241
+ if (typeof arg !== 'string')
242
+ continue;
243
+ const argBasename = arg.replace(/\\/g, '/').split('/').pop() || '';
244
+ if (isManagedHookBasename(argBasename, { surface }))
245
+ return true;
246
+ }
247
+ }
235
248
  const normalizedCommand = commandText.replace(/\\/g, '/');
236
249
  if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
237
250
  const normalizedHooksDir = `${node_path_1.default.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
@@ -176,6 +176,14 @@ exports.KNOWN_STATUS_PATTERNS = [
176
176
  /^Phase\s+\d+\s+complete/i,
177
177
  /^Verifying Phase\s+\d+/i,
178
178
  /^Phase complete/i,
179
+ // #1070: LLM executors (e.g. OpenCode) may write "Complete ✓" or bare "Complete"
180
+ // when finishing a phase. Only bare terminal markers yield to the next phase's
181
+ // "Ready to execute" during planned-phase. The pattern is anchored at both ends
182
+ // so that statuses with trailing prose (e.g. "Complete but needs manual QA",
183
+ // "Complete — ready for verification") are NOT matched and are preserved as
184
+ // executor-authored values. Only exact forms like "Complete", "Complete ✓",
185
+ // "Complete✓", or "Complete ☑ " (trailing whitespace) match.
186
+ /^Complete\s*[✓✔✅☑]?\s*$/i,
179
187
  ];
180
188
  /**
181
189
  * Returns true when the given value is a known template default for the field,
@@ -607,6 +607,7 @@ function cmdStateRecordSession(cwd, options, raw) {
607
607
  }
608
608
  const now = clock_cjs_1.realClock.nowIso();
609
609
  const updated = [];
610
+ let sessionCreated = false;
610
611
  readModifyWriteStateMd(statePath, (content) => {
611
612
  // Update Last session / Last Date
612
613
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
@@ -661,10 +662,80 @@ function cmdStateRecordSession(cwd, options, raw) {
661
662
  }
662
663
  }
663
664
  }
665
+ // Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
666
+ // --resume-file but the body lacks the canonical labels (in-place replace
667
+ // returned a miss), persist the values durably. Mirrors the DWIM pattern used
668
+ // by add-decision, add-blocker, and record-metric. Never silently drop
669
+ // caller-supplied values.
670
+ //
671
+ // Guard: only act when the caller actually supplied a value. When no
672
+ // --stopped-at / --resume-file are given and the body already had no session
673
+ // labels (nothing was updated), we return recorded:false — the existing
674
+ // behaviour for a no-op call that didn't supply any values.
675
+ //
676
+ // Correctness invariant: both buildStateFrontmatter and cmdStateSnapshot read
677
+ // only the FIRST `## Session` block (via a /##\s*Session\s*\n…/i regex).
678
+ // If we blindly append a second `## Session` block when one already exists, the
679
+ // newly-written Stopped at / Resume file end up in the second (invisible) block.
680
+ // Fix: when a `## Session` heading already exists, normalize THAT block in place
681
+ // (insert / replace canonical bold-label lines within the existing section).
682
+ // Only append a brand-new section when NO `## Session` heading exists at all.
683
+ const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
684
+ const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
685
+ const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
686
+ const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
687
+ if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
688
+ const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
689
+ ? options.resume_file
690
+ : 'None';
691
+ const stoppedAtValue = options.stopped_at || 'None';
692
+ // Determine whether a ## Session heading already exists in the body.
693
+ const existingSessionHeading = /^## Session\s*$/im.test(content);
694
+ if (existingSessionHeading) {
695
+ // Normalize in place: replace the ENTIRE BODY of the existing ## Session
696
+ // section (heading + all content up to the next ## heading or EOF) with
697
+ // canonical bold-label lines. The negative-lookahead per-line pattern
698
+ // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
699
+ // which correctly stops at the next section boundary without consuming it.
700
+ // A trailing blank line is added so the next ## heading keeps its spacing.
701
+ content = content.replace(/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m, [
702
+ '## Session',
703
+ '',
704
+ `**Last session:** ${now}`,
705
+ `**Stopped at:** ${stoppedAtValue}`,
706
+ `**Resume file:** ${resumeValue}`,
707
+ '',
708
+ '',
709
+ ].join('\n'));
710
+ }
711
+ else {
712
+ // No ## Session heading exists at all — append a new canonical section.
713
+ const scaffold = [
714
+ '',
715
+ '## Session',
716
+ '',
717
+ `**Last session:** ${now}`,
718
+ `**Stopped at:** ${stoppedAtValue}`,
719
+ `**Resume file:** ${resumeValue}`,
720
+ '',
721
+ ].join('\n');
722
+ content = content.trimEnd() + '\n' + scaffold;
723
+ }
724
+ sessionCreated = true;
725
+ if (needsLastSession)
726
+ updated.push('Last session');
727
+ if (needsStoppedAt)
728
+ updated.push('Stopped At');
729
+ if (needsResumeFile)
730
+ updated.push('Resume File');
731
+ }
664
732
  return content;
665
733
  }, cwd);
666
734
  if (updated.length > 0) {
667
- output({ recorded: true, updated }, raw, 'true');
735
+ const result = { recorded: true, updated };
736
+ if (sessionCreated)
737
+ result['created'] = true;
738
+ output(result, raw, 'true');
668
739
  }
669
740
  else {
670
741
  output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
@@ -748,8 +819,12 @@ function cmdStateSnapshot(cwd, raw) {
748
819
  const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
749
820
  if (sessionMatch) {
750
821
  const sessionSection = sessionMatch[1];
822
+ // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
823
+ // (the form written by the DWIM auto-create / normalize path added for #944).
751
824
  const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
752
- || sessionSection.match(/^Last Date:\s*(.+)/im);
825
+ || sessionSection.match(/^Last Date:\s*(.+)/im)
826
+ || sessionSection.match(/\*\*Last session:\*\*\s*(.+)/i)
827
+ || sessionSection.match(/^Last session:\s*(.+)/im);
753
828
  const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
754
829
  || sessionSection.match(/^Stopped At:\s*(.+)/im);
755
830
  const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
@@ -974,12 +1049,39 @@ function syncStateFrontmatter(content, cwd) {
974
1049
  if (derivedFm['status'] === 'unknown' && existingFm['status'] && existingFm['status'] !== 'unknown') {
975
1050
  derivedFm['status'] = existingFm['status'];
976
1051
  }
1052
+ // Bug #948: preserve `milestone_name` / `milestone` when the derived value
1053
+ // is the template placeholder 'milestone'. getMilestoneInfo returns the
1054
+ // literal string 'milestone' when it cannot match the version from the roadmap
1055
+ // (e.g. no ROADMAP.md, roadmap lacks the heading for the stored version, or the
1056
+ // milestone version read from STATE.md itself triggers the lookup before the
1057
+ // file is fully written). A placeholder must never overwrite a real name that the
1058
+ // existing frontmatter already holds; only an empty derived value falls through
1059
+ // to this guard (the primary #905 preserve path below handles that).
1060
+ const MILESTONE_NAME_PLACEHOLDER = 'milestone';
1061
+ if (derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
1062
+ existingFm['milestone_name'] &&
1063
+ existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER) {
1064
+ derivedFm['milestone_name'] = existingFm['milestone_name'];
1065
+ // Keep the stored milestone version consistent with the preserved name.
1066
+ if (existingFm['milestone']) {
1067
+ derivedFm['milestone'] = existingFm['milestone'];
1068
+ }
1069
+ }
977
1070
  // Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
978
1071
  // from body annotations (Current Phase:, Current Plan:, etc.). When those
979
1072
  // annotations are absent — e.g. after an agent or tool rewrites the body —
980
1073
  // buildStateFrontmatter returns no value for those keys. Mirror the same
981
1074
  // fallback pattern used in cmdStateJson so the existing frontmatter values
982
1075
  // survive every writeStateMd call.
1076
+ //
1077
+ // For stopped_at / paused_at: the original #905 "fall back when derived is
1078
+ // absent" rule is preserved here. The stale-body-overwrites-frontmatter
1079
+ // scenario from #948 is prevented by the no-op guard in
1080
+ // readModifyWriteStateMd: when the transform produces no change the file is
1081
+ // never written, so syncStateFrontmatter never even runs. Attempting to
1082
+ // "always prefer frontmatter" here breaks legitimate callers like phase.complete
1083
+ // that intentionally write a new stopped_at value to the body and expect
1084
+ // syncStateFrontmatter to pick it up.
983
1085
  if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
984
1086
  derivedFm['stopped_at'] = existingFm['stopped_at'];
985
1087
  }
@@ -1155,6 +1257,16 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1155
1257
  // restore it when resync is false.
1156
1258
  const preFm = resync ? null : extractFrontmatter(content);
1157
1259
  const modified = transformFn(content);
1260
+ // Bug #948: no-op guard — if the transform produced no change, do NOT write
1261
+ // the file. An unconditional write would bump `last_updated`, reset
1262
+ // `milestone_name` to the template placeholder, and resurrect stale
1263
+ // body-derived `stopped_at` values via syncStateFrontmatter. Skipping the
1264
+ // write when content is unchanged is safe because every caller that mutates
1265
+ // content already returns the mutated string, and callers that detect a
1266
+ // no-op explicitly return the original content unchanged.
1267
+ if (modified === content) {
1268
+ return;
1269
+ }
1158
1270
  let synced = syncStateFrontmatter(modified, cwd);
1159
1271
  if (!resync && preFm && preFm['progress']) {
1160
1272
  // Re-apply the curated progress block that syncStateFrontmatter just
@@ -12,11 +12,18 @@
12
12
  * Exports:
13
13
  * readSurface(runtimeConfigDir)
14
14
  * writeSurface(runtimeConfigDir, surfaceState)
15
- * resolveSurface(runtimeConfigDir, manifest, clusterMap)
16
- * applySurface(runtimeConfigDir, layout, manifest, clusterMap)
17
- * listSurface(runtimeConfigDir, manifest, clusterMap)
15
+ * resolveSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
16
+ * applySurface(runtimeConfigDir, layout, manifest, clusterMap?, registry?)
17
+ * listSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
18
18
  * pruneSkillDirs(skillsDir, retainedNames, prefix, manifest)
19
19
  *
20
+ * The optional `registry` param (ADR-857 phase 4c) accepts the capability-registry
21
+ * object. When present, capability clusters are merged into the effective cluster
22
+ * map and the registry is threaded into resolveProfile so capability-contributed
23
+ * skills participate in the base set and disable-ability. Absent or undefined
24
+ * leaves behaviour identical to the pre-registry path (no-op for current registry
25
+ * where UI=full and the full profile returns '*' regardless).
26
+ *
20
27
  * ADR-457 build-at-publish: the hand-written bin/lib/surface.cjs collapsed
21
28
  * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
22
29
  * from the prior hand-written .cjs; only types are added.
@@ -86,10 +93,11 @@ function clustersToSkills(clusterNames, clusterMap) {
86
93
  const result = new Set();
87
94
  for (const name of clusterNames) {
88
95
  const members = clusterMap[name];
89
- if (members) {
90
- for (const s of members)
91
- result.add(s);
92
- }
96
+ // FIX 5: guard against non-iterable members — malformed registry must never throw
97
+ if (!Array.isArray(members))
98
+ continue;
99
+ for (const s of members)
100
+ result.add(s);
93
101
  }
94
102
  return result;
95
103
  }
@@ -132,19 +140,63 @@ function normalizeSkillManifest(runtimeConfigDir, manifest) {
132
140
  * 2. Remove skills in disabled clusters
133
141
  * 3. Add explicitAdds (and their transitive closure)
134
142
  * 4. Remove explicitRemoves (only the stem itself, no cascade)
143
+ *
144
+ * ADR-857 phase 4c: optional registry param. When present:
145
+ * - capability clusters are merged into the effective cluster map so
146
+ * capability-owned skill groups are disable-able.
147
+ * - registry is threaded into resolveProfile so capability skills
148
+ * participate in the base skill set and their requires: chains expand.
135
149
  */
136
- function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
137
- const cm = clusterMap || clusters_cjs_1.CLUSTERS;
150
+ function resolveSurface(runtimeConfigDir, manifest, clusterMap, registry) {
151
+ // Merge capability clusters into the cluster map when registry is provided.
152
+ // The ADR-857 phase 4a HARD gate guarantees that when a capId matches a CLUSTERS
153
+ // key, the values are EQUAL — so the spread is idempotent for matching names.
154
+ // Defense-in-depth: if a capId collides with a hand-authored CLUSTERS key AND
155
+ // the values DIFFER (future drift bypassing the gate), prefer the hand-authored
156
+ // value so disable behavior is never silently changed by a stale registry entry.
157
+ // Also guard: skip entries whose value is not a string[] (malformed registry).
158
+ let cm = clusterMap || clusters_cjs_1.CLUSTERS;
159
+ if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
160
+ const baseCm = cm;
161
+ const capClusters = registry.capabilityClusters;
162
+ const merged = { ...baseCm };
163
+ for (const capId of Object.keys(capClusters)) {
164
+ const val = capClusters[capId];
165
+ // FIX 5: skip malformed (non-array) entries — never throw on bad registry
166
+ if (!Array.isArray(val))
167
+ continue;
168
+ // FIX 4: if the capId matches an existing cluster key, only override when
169
+ // the values are identical (guaranteed by 4a gate). If they differ, the
170
+ // hand-authored value wins — prefer known-correct disable behavior over
171
+ // a potentially stale registry entry.
172
+ if (Object.prototype.hasOwnProperty.call(baseCm, capId)) {
173
+ const existing = baseCm[capId];
174
+ if (!Array.isArray(existing)) {
175
+ merged[capId] = val;
176
+ continue;
177
+ }
178
+ // Values differ → hand-authored wins (skip the override)
179
+ if (existing.length !== val.length || existing.some((v, i) => v !== val[i]))
180
+ continue;
181
+ }
182
+ // Prototype-pollution guard (parity with _capabilitySkillsForMode in install-profiles.cts)
183
+ if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype')
184
+ continue;
185
+ merged[capId] = val;
186
+ }
187
+ cm = merged;
188
+ }
138
189
  const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
139
190
  const surface = readSurface(runtimeConfigDir);
140
191
  // Determine base profile name: from surface state or from .gsd-profile marker
141
192
  const baseProfileName = (surface && surface.baseProfile)
142
193
  ? surface.baseProfile
143
194
  : (readActiveProfile(runtimeConfigDir) || 'full');
144
- // Resolve base profile
195
+ // Resolve base profile — thread registry so capability skills are included.
145
196
  const baseResolved = resolveProfile({
146
197
  modes: baseProfileName.split(',').map((s) => s.trim()),
147
198
  manifest: skillManifest,
199
+ registry,
148
200
  });
149
201
  // If full, we need to enumerate all skills from the manifest
150
202
  let skills;
@@ -205,12 +257,12 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
205
257
  * Re-stage the active surface using the resolved layout.
206
258
  * Iterates layout.kinds and syncs each artifact kind to its destination.
207
259
  */
208
- function applySurface(runtimeConfigDir, layout, manifest, clusterMap) {
260
+ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry) {
209
261
  if (node_path_1.default.resolve(runtimeConfigDir) !== node_path_1.default.resolve(layout.configDir)) {
210
262
  throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
211
263
  }
212
264
  const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
213
- const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap);
265
+ const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
214
266
  // Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
215
267
  // so SKILL.md bodies reference the install target (pathPrefix), not the
216
268
  // converter's default ~/.claude paths (#813). Computed lazily so command-only
@@ -409,9 +461,9 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest) {
409
461
  * Token cost = sum of description lengths ÷ 4 (mirrors audit script).
410
462
  * Descriptions are read from the install source (findInstallSourceRoot).
411
463
  */
412
- function listSurface(runtimeConfigDir, manifest, clusterMap) {
464
+ function listSurface(runtimeConfigDir, manifest, clusterMap, registry) {
413
465
  const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
414
- const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap);
466
+ const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap, registry);
415
467
  // All known stems from manifest (exclude _calls_agents_ meta keys)
416
468
  const allStems = [];
417
469
  for (const [key] of skillManifest) {