@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -19,7 +19,7 @@ const configLoaderMod = require("./config-loader.cjs");
19
19
  const { loadConfig } = configLoaderMod;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const phaseIdMod = require("./phase-id.cjs");
22
- const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
22
+ const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const roadmapParserMod = require("./roadmap-parser.cjs");
25
25
  const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
@@ -123,25 +123,38 @@ function _stateHolderVerifiedLive(lockPath) {
123
123
  return pid !== null && _stateLockIsPidAlive(pid);
124
124
  }
125
125
  /**
126
- * Parse the lock body to its recorded pid, or null when the body is empty / non-numeric
127
- * / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal
128
- * promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
129
- * fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
130
- * in acquireStateLock reads the pid directly (PR #1532 review, window a).
126
+ * Read + classify the lock body at `lockPath`. See `LockBodyStatus` for the
127
+ * three-way distinction the steal decision in `acquireStateLock` relies on.
131
128
  */
132
- function _stateLockBodyPid(lockPath) {
129
+ function _stateLockBodyStatus(lockPath) {
133
130
  let body;
134
131
  try {
135
132
  body = node_fs_1.default.readFileSync(lockPath, 'utf-8');
136
133
  }
137
134
  catch {
138
- return null; // unreadable body → cannot verify
135
+ return { kind: 'unreadable' };
139
136
  }
140
137
  const trimmed = body.trim();
141
138
  const pid = parseInt(trimmed, 10);
142
139
  if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== trimmed)
143
- return null;
144
- return pid;
140
+ return { kind: 'empty' };
141
+ return { kind: 'pid', pid };
142
+ }
143
+ /**
144
+ * Parse the lock body to its recorded pid, or null when the body is empty / non-numeric
145
+ * / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal
146
+ * promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
147
+ * fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
148
+ * in acquireStateLock reads the pid directly (PR #1532 review, window a).
149
+ *
150
+ * NOTE: this collapses "genuinely empty" and "unreadable" to the same `null` —
151
+ * that is fine for `_stateHolderVerifiedLive` (both mean "not verified-live"
152
+ * either way), but the STEAL-TIMING decision must not make that same
153
+ * collapse (#3057 B2) and reads `_stateLockBodyStatus` directly instead.
154
+ */
155
+ function _stateLockBodyPid(lockPath) {
156
+ const status = _stateLockBodyStatus(lockPath);
157
+ return status.kind === 'pid' ? status.pid : null;
145
158
  }
146
159
  // Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
147
160
  let _stateStealSeq = 0;
@@ -160,7 +173,8 @@ const STOP_H2_H3 = (lv) => lv === 2 || lv === 3;
160
173
  const STOP_H2_ONLY = (lv) => lv === 2;
161
174
  function cmdStateLoad(cwd, raw) {
162
175
  const config = loadConfig(cwd);
163
- const planDir = planningPaths(cwd).planning;
176
+ const paths = planningPaths(cwd);
177
+ const planDir = paths.planning;
164
178
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planDir, 'STATE.md')) || '';
165
179
  const configExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'config.json'));
166
180
  const roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'ROADMAP.md'));
@@ -173,10 +187,12 @@ function cmdStateLoad(cwd, raw) {
173
187
  config_exists: configExists,
174
188
  // #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
175
189
  // spawned subagent's own cwd may differ from the orchestrator's.
176
- // debug.md has no init.* call of its own; it reads this field from
177
- // `state load` to build debug_file_path for its gsd-debug-session-manager
178
- // spawns instead of hardcoding '.planning/debug/{slug}.md'.
179
- debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(node_path_1.default.join(planDir, 'debug')),
190
+ // #3149: debug.md now has its own `init.debug` entry point and reads this
191
+ // field from there, not from `state load`. This stays on the state.load
192
+ // bundle regardless: it is a shipped query surface with its own test anchor
193
+ // (tests/state.test.cjs), so narrowing it would break unseen consumers for
194
+ // no gain (Hyrum's Law). Both emit the SAME `planningPaths(cwd).debug`.
195
+ debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(paths.debug),
180
196
  };
181
197
  // For --raw, output a condensed key=value format
182
198
  if (raw) {
@@ -276,7 +292,7 @@ function cmdStatePatch(cwd, patches, raw) {
276
292
  // and the resync-progress decision stay in this adapter.
277
293
  let results = { updated: [], failed: [] };
278
294
  readModifyWriteStateMd(statePath, (content) => {
279
- const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
295
+ const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
280
296
  results = result.data ?? results;
281
297
  return result.content;
282
298
  }, cwd, { resync: shouldResync });
@@ -308,7 +324,7 @@ function cmdStateUpdate(cwd, field, value) {
308
324
  // Preserve curated progress for body-only updates, but allow fields that
309
325
  // directly project into progress.* frontmatter to rebuild after mutation.
310
326
  readModifyWriteStateMd(statePath, (content) => {
311
- const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
327
+ const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
312
328
  updated = result.data?.updated === true;
313
329
  return result.content;
314
330
  }, cwd, { resync: shouldResync });
@@ -359,7 +375,6 @@ function cmdStateAdvancePlan(cwd, raw) {
359
375
  const intent = { kind: 'advancePlan' };
360
376
  const deps = {
361
377
  clock: clock_cjs_1.realClock,
362
- progressProvider: () => null,
363
378
  sourcePath: statePath,
364
379
  };
365
380
  let resultData;
@@ -1152,6 +1167,29 @@ function matchSessionSection(body) {
1152
1167
  ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
1153
1168
  return section ? section.body : null;
1154
1169
  }
1170
+ /**
1171
+ * Match the "Current Position" section body from a STATE.md body. #2956: this
1172
+ * is the Phase analogue of matchSessionSection. `Phase` canonically lives under
1173
+ * `## Current Position` (gsd-core/templates/state.md), so — like Stopped At /
1174
+ * Paused At under `## Session` — it must be extracted from THAT section, not
1175
+ * from the first `Phase:` / `**Phase:**` line anywhere in the body. Without the
1176
+ * scope, a historical `Phase:` line in an archive section silently overwrites
1177
+ * `current_phase` on every write, and because `current_phase` is routing input
1178
+ * for gsd-progress / --next the rewind routes work to the wrong phase.
1179
+ *
1180
+ * Level-flexible: the canonical template uses an h2 `## Current Position`, the
1181
+ * bootstrap template an h3 `### Current Position` (templates/state.md). Both
1182
+ * must match — mirroring how matchSessionSection recognises `## Session` and
1183
+ * `## Session Continuity`. Exact 'current position' text match (case-insensitive)
1184
+ * excludes unrelated headings. Built on the same `collectSection` seam as
1185
+ * matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
1186
+ * Returns the section body, or null (caller falls back to full-body search).
1187
+ */
1188
+ function matchCurrentPositionSection(body) {
1189
+ const isCurrentPosition = (h) => (h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
1190
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isCurrentPosition, { levelBounded: true });
1191
+ return section ? section.body : null;
1192
+ }
1155
1193
  /**
1156
1194
  * #2567: prevent a stale archive "Last activity:" line from overwriting a
1157
1195
  * newer frontmatter value. `stateExtractField` matches the first body
@@ -1183,6 +1221,14 @@ function preferNewerLastActivity(existingFm, derivedFm) {
1183
1221
  derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1184
1222
  }
1185
1223
  }
1224
+ else if (derDate === exDate) {
1225
+ // #3052: same-date — frontmatter is authoritative for this date, so
1226
+ // preserve its last_activity_desc rather than letting the derived body
1227
+ // prose (which may be stale) overwrite it.
1228
+ if (existingFm['last_activity_desc'] !== undefined) {
1229
+ derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1230
+ }
1231
+ }
1186
1232
  }
1187
1233
  function parseProsePhaseField(value) {
1188
1234
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
@@ -1234,7 +1280,14 @@ function cmdStateSnapshot(cwd, raw) {
1234
1280
  return null;
1235
1281
  };
1236
1282
  // Extract basic fields — frontmatter keys take precedence over body
1237
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(body, 'Phase'));
1283
+ // #2956: scope `Phase` extraction to ## Current Position so a historical
1284
+ // Phase: / **Phase:** line in an archive section cannot overwrite the current
1285
+ // value. Phase canonically lives in ## Current Position (templates/state.md),
1286
+ // so it is scopeable exactly like Stopped At under ## Session. Fall back to
1287
+ // full-body search only when no ## Current Position section exists, so files
1288
+ // with no section heading keep their current behaviour.
1289
+ const currentPositionScope = matchCurrentPositionSection(body) ?? body;
1290
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
1238
1291
  const currentPhase = fmScalar('current_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase.phase;
1239
1292
  const currentPhaseName = fmScalar('current_phase_name') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase Name') ?? prosePhase.name;
1240
1293
  const totalPhasesRaw = fmScalar('total_phases') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases');
@@ -1246,7 +1299,12 @@ function cmdStateSnapshot(cwd, raw) {
1246
1299
  const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1247
1300
  const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity;
1248
1301
  const lastActivityDesc = fmScalar('last_activity_desc') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description') ?? proseLastActivity.description;
1249
- const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Paused At');
1302
+ // #2956: Paused At canonically lives in ## Session (see the comment above
1303
+ // preferNewerLastActivity and the write seam in buildStateFrontmatter). The
1304
+ // write seam already scopes it to ## Session; this read seam must agree, so a
1305
+ // stale "Paused At:" in a Session Continuity Archive cannot win here either.
1306
+ const sessionScope = matchSessionSection(body) ?? body;
1307
+ const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
1250
1308
  // Parse numeric fields
1251
1309
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1252
1310
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
@@ -1324,25 +1382,11 @@ function cmdStateSnapshot(cwd, raw) {
1324
1382
  output(result, raw, undefined);
1325
1383
  }
1326
1384
  // ─── State Frontmatter Sync ──────────────────────────────────────────────────
1327
- /**
1328
- * Canonical key for matching a ROADMAP phase token against an on-disk phase
1329
- * directory: normalizePhaseName collapses padding/case, strips the project-code
1330
- * prefix, and handles decimals/letter-suffixes/milestone-prefixed IDs, so
1331
- * "Phase 4"/"Phase 04"/dir "04-delta" and "Phase PROJ-42"/dir "PROJ-42-foo"
1332
- * each map to one key. For a directory, extract its phase token first.
1333
- *
1334
- * Stripping the project-code prefix is GSD's canonical phase identity (a
1335
- * project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat
1336
- * `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a
1337
- * bare ROADMAP token). A consistent project uses one scheme, so a bare numeric
1338
- * and a same-suffix project-code phase never coexist in one milestone.
1339
- */
1340
- function phaseKeyFromToken(token) {
1341
- return normalizePhaseName(token).toUpperCase();
1342
- }
1343
- function phaseKeyFromDir(dir) {
1344
- return phaseKeyFromToken(extractPhaseToken(dir));
1345
- }
1385
+ // `phaseKeyFromToken` / `phaseKeyFromDir` — the canonical key for matching a
1386
+ // ROADMAP phase token against an on-disk phase directory — moved to the
1387
+ // phase-id owner module in #2562 so every consumer derives BOTH sides of a
1388
+ // phase comparison from the same function (see phase-id.cts). Imported at the
1389
+ // top of this file; call sites below are unchanged.
1346
1390
  /**
1347
1391
  * Extract the set of retired/folded phase keys from a ROADMAP milestone scope
1348
1392
  * (#1514). A retired phase is struck through with GFM strikethrough,
@@ -1386,8 +1430,15 @@ function extractRetiredPhaseNumbers(scope) {
1386
1430
  * a YAML frontmatter object. Allows hooks and scripts to read state
1387
1431
  * reliably via `state json` instead of fragile regex parsing.
1388
1432
  */
1389
- function buildStateFrontmatter(bodyContent, cwd) {
1390
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(bodyContent, 'Phase'));
1433
+ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1434
+ // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1435
+ // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1436
+ // below). Phase canonically lives in ## Current Position (templates/state.md);
1437
+ // without the scope, a historical Phase: / **Phase:** line in an archive
1438
+ // section overwrites current_phase here, and the next read surfaces it. Fall
1439
+ // back to full-body search when no ## Current Position section exists.
1440
+ const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
1441
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
1391
1442
  const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
1392
1443
  const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
1393
1444
  const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
@@ -1453,7 +1504,10 @@ function buildStateFrontmatter(bodyContent, cwd) {
1453
1504
  }
1454
1505
  }
1455
1506
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
1456
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1507
+ // #3017: scope the milestone filter to the STORED milestone when available,
1508
+ // so a state.* write doesn't auto-derive (and mis-bind) to a different
1509
+ // milestone's heading and clobber the stored value + progress counts.
1510
+ const isDirInMilestone = getMilestonePhaseFilter(cwd, storedMilestone ?? undefined);
1457
1511
  const allMatchingDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1458
1512
  .filter(e => e.isDirectory()).map(e => e.name)
1459
1513
  .filter(isDirInMilestone);
@@ -1633,7 +1687,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1633
1687
  // derivable here without widening the signature (#1882).
1634
1688
  const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
1635
1689
  const body = stripFrontmatter(content);
1636
- const derivedFm = buildStateFrontmatter(body, cwd);
1690
+ // #3017: pass the stored milestone from the existing frontmatter so
1691
+ // buildStateFrontmatter scopes its disk scan to the correct milestone
1692
+ // instead of auto-deriving (and potentially mis-binding).
1693
+ const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
1694
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone);
1637
1695
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
1638
1696
  // This prevents a missing Status: field in the body from overwriting a
1639
1697
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -1849,17 +1907,22 @@ function acquireStateLock(statePath, clock) {
1849
1907
  if (err.code !== 'EEXIST')
1850
1908
  throw err; // propagate — silent bypass causes lost updates
1851
1909
  // Liveness-gated steal (audit M1) + steal-safety (PR #1532 review). The steal
1852
- // decision is three-way on the lock body:
1910
+ // decision is four-way on the lock body (#3057 B2 added the fourth):
1853
1911
  // - VERIFIED-LIVE holder (parseable pid that signals alive): NEVER stolen until
1854
1912
  // its age crosses the absolute deadman ceiling (the pid-reuse backstop) —
1855
1913
  // nuking a slow-but-live writer's lock causes lost updates (#3711 / #500/#905/
1856
1914
  // #1230 family).
1857
1915
  // - COMPLETE DEAD pid (parseable pid, not alive): stolen PROMPTLY regardless of
1858
1916
  // age — a crashed holder left a full body.
1859
- // - EMPTY / unparseable body: liveness is unknowable. While FRESH (age <=
1860
- // freshCreateFloorMs) it is a lock still mid-creation (O_EXCL done, pid not yet
1861
- // written) and is NOT stolen (window a); only once aged past the floor is it a
1862
- // genuine orphan and stealable.
1917
+ // - UNREADABLE body (I/O fault reading the file): NOT the same as empty — we
1918
+ // have no evidence this is a fresh create window, only that we could not read
1919
+ // it. Held to the SAME conservative ceiling as a verified-live holder rather
1920
+ // than the short fresh-create floor, so a transient read fault can never rob
1921
+ // an active holder the way stealing at 1s would.
1922
+ // - EMPTY / unparseable body (body WAS read, and holds no valid pid): liveness is
1923
+ // unknowable. While FRESH (age <= freshCreateFloorMs) it is a lock still
1924
+ // mid-creation (O_EXCL done, pid not yet written) and is NOT stolen (window a);
1925
+ // only once aged past the floor is it a genuine orphan and stealable.
1863
1926
  // The steal itself is an ATOMIC rename-then-recreate (only one racer can rename the
1864
1927
  // inode) guarded by an identity re-confirm, so a racer that recreates a fresh lock
1865
1928
  // in the decision→steal gap never has its replacement deleted (window b). Mirrors
@@ -1867,7 +1930,8 @@ function acquireStateLock(statePath, clock) {
1867
1930
  try {
1868
1931
  const stat = node_fs_1.default.statSync(lockPath);
1869
1932
  const ageMs = clock.now() - stat.mtimeMs;
1870
- const bodyPid = _stateLockBodyPid(lockPath);
1933
+ const bodyStatus = _stateLockBodyStatus(lockPath);
1934
+ const bodyPid = bodyStatus.kind === 'pid' ? bodyStatus.pid : null;
1871
1935
  const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid);
1872
1936
  let steal;
1873
1937
  if (holderLive) {
@@ -1876,6 +1940,9 @@ function acquireStateLock(statePath, clock) {
1876
1940
  else if (bodyPid !== null) {
1877
1941
  steal = true; // complete dead pid → prompt steal
1878
1942
  }
1943
+ else if (bodyStatus.kind === 'unreadable') {
1944
+ steal = ageMs > deadmanCeilingMs; // I/O fault ≠ known-fresh — do not grant the short floor
1945
+ }
1879
1946
  else {
1880
1947
  steal = ageMs > freshCreateFloorMs; // empty/garbage → protect the create window
1881
1948
  }
@@ -2064,7 +2131,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2064
2131
  // content already returns the mutated string, and callers that detect a
2065
2132
  // no-op explicitly return the original content unchanged.
2066
2133
  if (modified === content) {
2067
- return;
2134
+ return false;
2068
2135
  }
2069
2136
  let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm);
2070
2137
  // Post-transform body source fields used for the delta comparison (#1230).
@@ -2115,6 +2182,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2115
2182
  synced = `---\n${yamlStr}\n---\n\n${body}`;
2116
2183
  }
2117
2184
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2185
+ return true;
2118
2186
  }
2119
2187
  finally {
2120
2188
  releaseStateLock(lockPath);
@@ -2193,7 +2261,6 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2193
2261
  };
2194
2262
  const deps = {
2195
2263
  clock: clock_cjs_1.realClock,
2196
- progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
2197
2264
  sourcePath: statePath,
2198
2265
  };
2199
2266
  // #2736: the transition holds the exact display name; without this the
@@ -2462,7 +2529,6 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2462
2529
  };
2463
2530
  const deps = {
2464
2531
  clock: clock_cjs_1.realClock,
2465
- progressProvider: () => null,
2466
2532
  sourcePath: statePath,
2467
2533
  };
2468
2534
  let updated = [];
@@ -2496,7 +2562,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2496
2562
  // milestoneSwitch rebuilds frontmatter directly and must not run the
2497
2563
  // steady-state syncStateFrontmatter post-sync.
2498
2564
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
2499
- const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null, sourcePath: statePath };
2565
+ const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
2500
2566
  const lockPath = acquireStateLock(statePath);
2501
2567
  try {
2502
2568
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
@@ -2719,7 +2785,7 @@ function cmdStateSync(cwd, options, raw) {
2719
2785
  const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2720
2786
  percent = p !== null ? p : 0;
2721
2787
  }
2722
- const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
2788
+ const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock });
2723
2789
  modified = syncResult.content;
2724
2790
  const coreChanges = syncResult.data?.changes ?? [];
2725
2791
  changes.push(...coreChanges);
@@ -2787,7 +2853,7 @@ function cmdStatePrune(cwd, options, raw) {
2787
2853
  // This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
2788
2854
  // fallback above), dry-run, and STATE-ARCHIVE.md writes.
2789
2855
  const runPruneCore = (content) => {
2790
- const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
2856
+ const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock });
2791
2857
  return {
2792
2858
  newContent: result.content,
2793
2859
  archivedSections: (result.data?.archivedSections) ?? [],
@@ -2866,11 +2932,20 @@ function cmdStateRebuild(cwd, options, raw) {
2866
2932
  // is the same canonical source `buildStateFrontmatter` consults; the Leaky-
2867
2933
  // Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
2868
2934
  // testable without this dep — here we provide it.
2935
+ //
2936
+ // #3057 B1: a missing `.planning/phases/` directory is genuinely "nothing
2937
+ // to reconcile" (`ok:true, phases: []`) — but a `readdirSync`/`statSync`
2938
+ // THROW on a directory that DOES exist (permission fault, corrupted
2939
+ // mount, etc.) is a real scan failure (`ok:false`). The old implementation
2940
+ // returned `null` for both, so `state rebuild` could report success while
2941
+ // by-phase-table reconciliation silently never ran. Per-entry stat
2942
+ // failures (an individual phase dir vanishing mid-scan) still `continue`
2943
+ // past that one entry — that is not a whole-scan failure.
2869
2944
  const phaseInventoryProvider = () => {
2870
2945
  try {
2871
2946
  const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
2872
2947
  if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
2873
- return null;
2948
+ return { ok: true, phases: [] };
2874
2949
  const entries = node_fs_1.default.readdirSync(phasesDir);
2875
2950
  const records = [];
2876
2951
  for (const entry of entries) {
@@ -2893,14 +2968,13 @@ function cmdStateRebuild(cwd, options, raw) {
2893
2968
  const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
2894
2969
  records.push({ number: m[1], name: m[2], planCount, summaryCount });
2895
2970
  }
2896
- return records;
2971
+ return { ok: true, phases: records };
2897
2972
  }
2898
- catch {
2899
- return null;
2973
+ catch (err) {
2974
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
2900
2975
  }
2901
2976
  };
2902
2977
  const deps = {
2903
- progressProvider: () => null,
2904
2978
  clock: clock_cjs_1.realClock,
2905
2979
  phaseInventoryProvider,
2906
2980
  // Without this, `state rebuild --dry-run` reported a truncated STATE.md anonymously: the
@@ -2919,18 +2993,25 @@ function cmdStateRebuild(cwd, options, raw) {
2919
2993
  process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
2920
2994
  }
2921
2995
  };
2996
+ const scanFailureNote = (reason) => 'Nothing rebuilt: the phase-inventory disk scan failed, so by-phase-table reconciliation did not run' +
2997
+ (reason ? ` (${reason})` : '');
2922
2998
  if (dryRun) {
2923
2999
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2924
3000
  const result = runRebuild(content);
2925
3001
  const data = (result.data ?? {});
2926
3002
  emitVerboseLog(data.log);
2927
3003
  const mutated = data.mutated === true;
3004
+ const scanFailed = data.phase_inventory_scan_failed === true;
2928
3005
  emit({
2929
3006
  rebuilt: false,
2930
3007
  dry_run: true,
2931
3008
  mutations: Array.isArray(data.log) ? data.log.length : 0,
2932
3009
  mutated,
2933
- note: mutated ? 'Run without --dry-run to apply changes' : 'Nothing to rebuild',
3010
+ phase_inventory_scan_failed: scanFailed,
3011
+ phase_inventory_scan_reason: scanFailed ? data.phase_inventory_scan_reason : undefined,
3012
+ note: mutated
3013
+ ? 'Run without --dry-run to apply changes'
3014
+ : scanFailed ? scanFailureNote(data.phase_inventory_scan_reason) : 'Nothing to rebuild',
2934
3015
  }, raw, mutated ? 'true' : 'false');
2935
3016
  return;
2936
3017
  }
@@ -2939,18 +3020,26 @@ function cmdStateRebuild(cwd, options, raw) {
2939
3020
  // to STATE.md by rebuildCore itself, per ADR-1817 §3).
2940
3021
  let capturedLog = [];
2941
3022
  let capturedMutated = false;
3023
+ let capturedScanFailed = false;
3024
+ let capturedScanReason;
2942
3025
  readModifyWriteStateMd(statePath, (content) => {
2943
3026
  const result = runRebuild(content);
2944
3027
  const data = (result.data ?? {});
2945
3028
  capturedLog = Array.isArray(data.log) ? data.log : [];
2946
3029
  capturedMutated = data.mutated === true;
3030
+ capturedScanFailed = data.phase_inventory_scan_failed === true;
3031
+ capturedScanReason = data.phase_inventory_scan_reason;
2947
3032
  return result.content;
2948
3033
  }, cwd);
2949
3034
  emitVerboseLog(capturedLog);
2950
3035
  emit({
2951
3036
  rebuilt: capturedMutated,
2952
3037
  mutations: capturedLog.length,
2953
- note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : 'Nothing to rebuild',
3038
+ phase_inventory_scan_failed: capturedScanFailed,
3039
+ phase_inventory_scan_reason: capturedScanFailed ? capturedScanReason : undefined,
3040
+ note: capturedMutated
3041
+ ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail'
3042
+ : capturedScanFailed ? scanFailureNote(capturedScanReason) : 'Nothing to rebuild',
2954
3043
  }, raw, capturedMutated ? 'true' : 'false');
2955
3044
  }
2956
3045
  /**
@@ -50,6 +50,8 @@ const { findInstallSourceRoot } = runtimeArtifactLayout;
50
50
  const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
51
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports
52
52
  const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
54
+ const retiredArtifactCleanup = require("./retired-artifact-cleanup.cjs");
53
55
  const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
54
56
  const SURFACE_FILE_NAME = '.gsd-surface.json';
55
57
  /**
@@ -302,6 +304,9 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
302
304
  }
303
305
  const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
304
306
  const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
307
+ // Profile toggles must converge retired surfaces too. Once a kind disappears
308
+ // from artifactLayout there is no normal sync pass left to prune it (#2644).
309
+ retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(layout.runtime, layout.configDir);
305
310
  // #1575: agents kind now mirrors createRuntimeArtifactInstallPlan — build
306
311
  // agentCtx (pathPrefix + attribution) and pass it to kind.stage() so
307
312
  // stageAgentsForRuntimeWithConverter applies the full inline-loop pipeline
@@ -360,7 +365,13 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
360
365
  tempDirsToClean.push(rewritten);
361
366
  }
362
367
  }
363
- const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
368
+ // #2911: honor kind.home as a FALLBACK-preferred override (e.g. Codex
369
+ // skills -> $HOME/.agents), never a blanket replacement — kinds without
370
+ // a `home` must keep resolving against layout.configDir. This must stay
371
+ // in lockstep with _copyStaged's root selection in src/install-engine.cts;
372
+ // the parity test in tests/runtime-artifact-layout-surface.test.cjs
373
+ // enforces that the two writers never diverge again.
374
+ const dest = assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath);
364
375
  _syncGsdDir(staged, dest, kind, skillManifest, layout.runtime);
365
376
  }
366
377
  }
@@ -186,6 +186,8 @@ function evaluateUatPassed(phaseFullDir, opts) {
186
186
  blockers,
187
187
  no_uat_artifacts,
188
188
  policy: { require_verification: requireVerification },
189
+ // readVerificationStatus was never reached on this early-return path.
190
+ verification_stale_check_indeterminate: false,
189
191
  };
190
192
  }
191
193
  // Filter UAT and VERIFICATION files using the same filter as cmdPhaseComplete
@@ -263,8 +265,15 @@ function evaluateUatPassed(phaseFullDir, opts) {
263
265
  // (handled by the requireVerification policy check below if needed)
264
266
  }
265
267
  // ── Policy: requireVerification ───────────────────────────────────────────
268
+ // #3057 B3: routing here is UNCHANGED — an indeterminate staleness check
269
+ // still falls through to the same `verificationStatus !== 'passed'` branch
270
+ // it always did (the pre-existing fail-open contract). `verificationStaleCheckIndeterminate`
271
+ // only records the fact for the report below; it never itself gates `blockers`.
272
+ let verificationStaleCheckIndeterminate = false;
266
273
  if (requireVerification) {
267
- const verificationStatus = readVerificationStatus(phaseFullDir).status;
274
+ const verificationResult = readVerificationStatus(phaseFullDir);
275
+ const verificationStatus = verificationResult.status;
276
+ verificationStaleCheckIndeterminate = verificationResult.staleCheckIndeterminate === true;
268
277
  if (verificationStatus === 'stale') {
269
278
  blockers.push('policy: verification status=stale');
270
279
  }
@@ -288,6 +297,7 @@ function evaluateUatPassed(phaseFullDir, opts) {
288
297
  policy: {
289
298
  require_verification: requireVerification,
290
299
  },
300
+ verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
291
301
  };
292
302
  }
293
303
  module.exports = {