devflow-kit 2.4.0 → 3.0.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 (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -1,10 +1,13 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { existsSync } from 'fs';
3
3
  import * as path from 'path';
4
- import { DEVFLOW_PLUGINS, SKILL_NAMESPACE, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
5
- import { skillsDir, agentsDir, rulesDir, commandsDir, scriptsDir } from '../../core/assets.js';
6
- import { getPackageRoot } from '../../core/paths.js';
4
+ import { SKILL_NAMESPACE, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan } from '../../core/plugins.js';
5
+ import { skillsDir, agentSourceDirs, rulesDir, commandsDir, scriptsDir, compiledSkillRefsDir } from '../../core/assets.js';
6
+ import { getPackageRoot, isContainedIn } from '../../core/paths.js';
7
7
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
8
+ import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT } from '../../core/mds-variants.js';
9
+ import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
10
+ import { TRACKER_AGENT_NAME } from './tracker-install.js';
8
11
  // ---------------------------------------------------------------------------
9
12
  // Shadow validation helpers (exported — reused by Step 4 list commands)
10
13
  // ---------------------------------------------------------------------------
@@ -157,14 +160,42 @@ export async function copyDirectory(src, dest) {
157
160
  }
158
161
  }
159
162
  /**
160
- * Recursively chmod all files in a directory tree.
163
+ * Recursively chmod all files in a directory tree, bounded by the shared descent bound.
164
+ *
165
+ * `_depth` counts the walked root as 0 and a breach is `_depth > MAX_REFERENCE_SWEEP_DEPTH`
166
+ * — the same comparison every other walk over this same tree already makes
167
+ * (`sweepOrphanedReferences`, the build's `pruneOrphans`, the harness's `walkFiles`). The
168
+ * constant is imported, never re-spelled: one tree, one bound — two walkers each
169
+ * carrying their own literal is how a pair of them comes to disagree.
170
+ *
171
+ * The bound is CONSISTENCY, not an exploit closure. `Dirent.isDirectory()` is lstat-based,
172
+ * so a symlink-to-directory is a leaf to this walk and a symlink loop — the hazard the
173
+ * shared constant's own rationale cites — cannot be entered here in the first place. What
174
+ * the bound buys is that the one walk over `references/` holding no explicit upper bound
175
+ * stops holding the opposite position on a hazard its siblings document, in a codebase
176
+ * whose standing rule is that every loop has one.
177
+ *
178
+ * A breach THROWS rather than returning quietly. A walk that stopped early would leave an
179
+ * unnamed part of the tree on its source modes while the caller believed the whole tree
180
+ * was normalised — the same "converged over ground it never covered" claim the sweep's
181
+ * `failed` channel exists to prevent. The reference overlay — the walk this bound is for,
182
+ * and the only one that crosses a tree the installer does not own — turns the throw into a
183
+ * `warn(...)` line carrying the directory and the bound, through the channel it already
184
+ * has for mode normalisation (see {@link overlayGeneratedReferences}). The other caller,
185
+ * {@link composeScripts}, swallows it with the rest of its copy-and-chmod step; that tree
186
+ * is three levels of shipped assets, so a breach there means the package itself grew a
187
+ * shape no walk in this repo expects, and neither call site aborts an install over it.
161
188
  */
162
- export async function chmodRecursive(dir, mode) {
189
+ export async function chmodRecursive(dir, mode, _depth = 0) {
190
+ if (_depth > MAX_REFERENCE_SWEEP_DEPTH) {
191
+ throw new Error(`chmodRecursive: descent into ${dir} exceeds the bound of ` +
192
+ `${MAX_REFERENCE_SWEEP_DEPTH} levels — that subtree keeps the modes it arrived with.`);
193
+ }
163
194
  const entries = await fs.readdir(dir, { withFileTypes: true });
164
195
  for (const entry of entries) {
165
196
  const fullPath = path.join(dir, entry.name);
166
197
  if (entry.isDirectory()) {
167
- await chmodRecursive(fullPath, mode);
198
+ await chmodRecursive(fullPath, mode, _depth + 1);
168
199
  }
169
200
  else if (entry.isFile()) {
170
201
  await fs.chmod(fullPath, mode);
@@ -172,6 +203,967 @@ export async function chmodRecursive(dir, mode) {
172
203
  }
173
204
  }
174
205
  // ---------------------------------------------------------------------------
206
+ // Generated skill-reference overlay
207
+ // ---------------------------------------------------------------------------
208
+ /**
209
+ * Every sub-path under the references root the prune converges to the manifest.
210
+ *
211
+ * D-CONVERGED-SUBTREES: a directory converges exactly when EVERY file in it is
212
+ * generated — that is the whole rule, and these two directories are the whole set that
213
+ * satisfies it today.
214
+ *
215
+ * `tracker/` ({@link TRACKER_DESTINATION_ROOT}) holds the per-provider mechanics and `pr/`
216
+ * ({@link PR_HOST_DESTINATION_ROOT}) the PR/review host bodies; the build emits both
217
+ * wholesale, so anything inside them the manifest does not name is by construction a
218
+ * leftover — a retired op, a provider the registry dropped, a shadow-supplied file, a
219
+ * staging tree a crashed run stranded — and removing it is the only way the installed
220
+ * tree can equal the manifest. Every install carries both whole
221
+ * (D-INSTALL-ALL-PROVIDERS), so what the prune converges is each directory's CONTENTS. Both entries are the
222
+ * registry's own constants, so a renamed destination root moves the build and the prune
223
+ * together.
224
+ *
225
+ * The references ROOT is the exemption, and the reason is the inverse of the rule: it
226
+ * holds hand-authored documents (`github-api.md`, `violations.md`, …) beside the flat
227
+ * generated ones with no manifest of which names are hand-authored, so a prune there
228
+ * cannot tell a retired generated document from a reference the skill has always shipped
229
+ * (D-OVERLAY-FLAT-UNIT). The root is overlaid and never pruned; the full-install
230
+ * pre-clean is what reaches a stale file there ({@link overlayOwnedSkillPaths}).
231
+ *
232
+ * An explicit list rather than "every top-level directory the manifest names", which
233
+ * would be self-maintaining and is still the wrong rule: a subtree the manifest stops
234
+ * naming ENTIRELY would drop out of a derived set and keep its whole installed tree,
235
+ * which is the retirement case this prune exists for. Listed here, the same subtree
236
+ * converges to empty. The list is also the one thing that lets the pre-clean keep a
237
+ * nested directory whole ({@link overlayOwnedSkillPaths}), so a directory missing from
238
+ * it fails safe — kept file by file, like the root — rather than keeping its stale files
239
+ * forever (avoids PF-074). The cost is one entry per new wholly-generated directory, and
240
+ * that entry is the whole edit: everything downstream reads the list (avoids PF-015).
241
+ */
242
+ const CONVERGED_SUBTREES = [TRACKER_DESTINATION_ROOT, PR_HOST_DESTINATION_ROOT];
243
+ /** Is this top-level directory under the references root one the prune converges? */
244
+ function isConvergedSubtree(top) {
245
+ return CONVERGED_SUBTREES.includes(top);
246
+ }
247
+ /**
248
+ * One spelling of a unit's name, for every message about it.
249
+ *
250
+ * Pure function — the installer owns the unit types, so it owns how they are named,
251
+ * rather than leaving each render site to invent its own wording (avoids PF-013).
252
+ */
253
+ export function overlayUnitLabel(unit) {
254
+ switch (unit.kind) {
255
+ case 'provider':
256
+ return `provider directory "${unit.subdir}"`;
257
+ case 'pr-host':
258
+ return `the PR-host mechanics in "${unit.dir}"`;
259
+ case 'cross-cutting':
260
+ return unit.dir === ''
261
+ ? 'the cross-cutting document set'
262
+ : `the cross-cutting document set in "${unit.dir}"`;
263
+ default: {
264
+ const _exhaustive = unit;
265
+ return _exhaustive;
266
+ }
267
+ }
268
+ }
269
+ /** The identity half of a unit, as the failure report carries it. */
270
+ function unitRef(unit) {
271
+ return unit.kind === 'provider'
272
+ ? { kind: unit.kind, subdir: unit.subdir }
273
+ : { kind: unit.kind, dir: unit.dir };
274
+ }
275
+ /** POSIX sub-path a unit's files land in under a root — `''` for the references root. */
276
+ function unitSubdir(unit) {
277
+ return unit.kind === 'provider' ? unit.subdir : unit.dir;
278
+ }
279
+ /**
280
+ * Is this directory part a PROVIDER directory — a provider's mechanics, swapped whole?
281
+ *
282
+ * `D-OVERLAY-PROVIDER-SHAPE`. Exactly `tracker/{provider}`: the only directory the
283
+ * reference-module registry emits INSIDE `tracker/`, and so the only one there whose
284
+ * whole directory may be renamed into place. (`pr/` is the registry's other emitted
285
+ * directory, and is classified by name in {@link planOverlayUnits}.)
286
+ *
287
+ * The distinction is load-bearing rather than cosmetic, and it is what the previous
288
+ * "any non-empty directory part is a provider" rule got wrong the first time the
289
+ * manifest carried a file directly under `tracker/`. That entry bucketed to the
290
+ * directory part `tracker`, which was then treated as a provider directory — so the
291
+ * unit's atomic swap was a rename of `tracker/` ITSELF, over a directory whose other
292
+ * entries are every provider's mechanics. Its staging sibling (`tracker.{token}.tmp`)
293
+ * also sat OUTSIDE the subtree the prune converges, which is what
294
+ * {@link stagingDirFor}'s second property exists to guarantee.
295
+ */
296
+ function isProviderSubdir(subdir) {
297
+ const segments = subdir.split('/');
298
+ return segments.length === 2 && segments[0] === TRACKER_DESTINATION_ROOT && segments[1] !== '';
299
+ }
300
+ /**
301
+ * Group a manifest into overlay units by the directory each entry lands in.
302
+ *
303
+ * Deterministic order — sorted by directory part — so a failure report and a loud throw
304
+ * are reproducible run to run.
305
+ *
306
+ * Three kinds, decided by the directory part: a `tracker/{provider}` directory
307
+ * ({@link isProviderSubdir}) is a provider unit and `pr/` ({@link PR_HOST_DESTINATION_ROOT})
308
+ * is the PR-host unit — each a directory holding only its own unit's documents, swapped
309
+ * whole. Every other directory holds a FLAT SET — documents that land beside entries this
310
+ * overlay must never replace or delete, promoted one rename at a time. The references
311
+ * root is one such directory (beside the hand-authored references) and `tracker/` is
312
+ * another (beside the provider directories); both take the flat arm, which is why that
313
+ * arm carries the directory it lands in rather than assuming the root.
314
+ *
315
+ * Exported for the one property no arm reading the INSTALLED tree can discriminate:
316
+ * which KIND a directory becomes. The directory parts sort `'' < pr < tracker <
317
+ * tracker/{provider}`, so a `tracker/` entry mis-bucketed as a provider renames the
318
+ * whole subtree into place BEFORE the provider units promote back into it, and the
319
+ * installed tree ends up complete under either rule. The classification itself is the
320
+ * observation that separates them (avoids PF-018).
321
+ */
322
+ export function planOverlayUnits(manifest) {
323
+ const bySubdir = new Map();
324
+ for (const relPath of manifest) {
325
+ const segments = relPath.split('/');
326
+ const subdir = segments.slice(0, -1).join('/');
327
+ const bucket = bySubdir.get(subdir);
328
+ if (bucket === undefined)
329
+ bySubdir.set(subdir, [relPath]);
330
+ else
331
+ bucket.push(relPath);
332
+ }
333
+ return [...bySubdir.entries()]
334
+ .sort(([a], [b]) => a.localeCompare(b))
335
+ .map(([subdir, files]) => {
336
+ if (isProviderSubdir(subdir))
337
+ return { kind: 'provider', subdir, files };
338
+ if (subdir === PR_HOST_DESTINATION_ROOT)
339
+ return { kind: 'pr-host', dir: subdir, files };
340
+ return { kind: 'cross-cutting', dir: subdir, files };
341
+ });
342
+ }
343
+ /** Resolve a POSIX manifest sub-path against a root, spelled for this filesystem. */
344
+ function underRoot(root, posixSubPath) {
345
+ return posixSubPath === '' ? root : path.join(root, ...posixSubPath.split('/'));
346
+ }
347
+ /**
348
+ * Process-unique token every staging directory this run creates carries.
349
+ *
350
+ * `scripts/build-mds.ts` scopes its own staging path to the writing process for the same
351
+ * reason and in the same idiom (`tempPathFor`: `${dest}.${process.pid}.tmp`). A FIXED
352
+ * staging name was the one thing standing between two concurrent `devflow init` runs and a
353
+ * PARTIAL unit promoted into an installed skill: the first thing
354
+ * {@link buildUnitStagingTree} does to its staging path is
355
+ * `fs.rm(stagingDir, { recursive: true, force: true })`, so under one shared name each run
356
+ * deletes the other's half-built tree, and whichever reaches promotion second renames
357
+ * whatever happened to survive into place — defeating the per-unit atomic swap outright.
358
+ *
359
+ * The pid is what makes two live runs disjoint. The timestamp is what makes a REUSED pid
360
+ * disjoint from the run that crashed before it, so a tree stranded by that earlier run is
361
+ * never mistaken for this one's own and adopted mid-build.
362
+ *
363
+ * Fixed for the life of the process so {@link stagingDirFor} stays a pure function of the
364
+ * unit it is asked about: one path per unit, computed once and threaded from the build to
365
+ * whichever promotion half consumes it.
366
+ */
367
+ const STAGING_TOKEN = `${process.pid}-${Date.now().toString(36)}`;
368
+ /**
369
+ * Staging directory for a unit — process-unique, under the subtree the prune converges.
370
+ *
371
+ * Both properties are about a run that is not this one:
372
+ *
373
+ * 1. The basename carries {@link STAGING_TOKEN}, so a concurrent run's staging tree is
374
+ * never the tree this one pre-cleans, builds into, or promotes.
375
+ * 2. Both names resolve under `tracker/`, one of the {@link CONVERGED_SUBTREES}, so a
376
+ * staging tree stranded by a crash
377
+ * between `mkdir` and promotion is removed by the next run's prune. It HAS to be the
378
+ * prune that removes it, because (1) means no later run's pre-clean will ever look at
379
+ * that name again. A staging directory at the references root instead (say
380
+ * `references/.cross-cutting.tmp`) would sit outside that subtree and outside every
381
+ * other convergence this module performs, so a stranded partial copy of the
382
+ * cross-cutting documents would sit inside the installed skill indefinitely — and be
383
+ * mode-normalised by {@link chmodRecursive} on every later install, that being the one
384
+ * part of the overlay which does reach the whole references root.
385
+ *
386
+ * The provider arm inherits the property from its unit: the path is the unit's own
387
+ * installed location plus a suffix, so it is converged exactly when the unit is, and every
388
+ * provider subdir the reference-module registry declares is `tracker/{provider}`. Every
389
+ * other arm is placed under the converged subtree explicitly ({@link sidecarBaseFor}): a
390
+ * flat set has no installed location to hang a suffix on — its documents ARE the
391
+ * directory — and `pr/`'s own sibling (`pr.{token}.tmp`) would sit at the references
392
+ * root, outside every convergence. The name carries the kind and the directory slug, so
393
+ * no two units share one staging path. The cost is that a manifest carrying no provider
394
+ * would create an empty `tracker/` on its way through; the registry never produces one,
395
+ * and an empty directory is not a partial install.
396
+ *
397
+ * No staging name can collide with a manifest entry, and the prune reaches them all for
398
+ * the same reason it reaches the `.old` backups: it converges the `tracker/` subtree
399
+ * against the manifest BY PATH, so anything under it the manifest does not name is
400
+ * removed and no staging name has to be recognised as one. Not by spelling — a provider
401
+ * arm's basename is `{provider}.{token}.tmp`, which is not dot-prefixed, so a rule keyed
402
+ * on the name would reach the other arms only.
403
+ */
404
+ function stagingDirFor(referencesTarget, unit) {
405
+ return `${sidecarBaseFor(referencesTarget, unit)}.${STAGING_TOKEN}.tmp`;
406
+ }
407
+ /**
408
+ * Backup a directory unit is displaced to while its replacement is renamed into place.
409
+ *
410
+ * Beside the staging tree, and under `tracker/` for the same reason (property 2 of
411
+ * {@link stagingDirFor}): a backup a crash strands is removed by a later run's prune.
412
+ */
413
+ function backupDirFor(referencesTarget, unit) {
414
+ return `${sidecarBaseFor(referencesTarget, unit)}.old`;
415
+ }
416
+ /**
417
+ * The path a unit's staging tree and backup are named from, before their suffixes.
418
+ *
419
+ * A provider's own installed location, which already lies under `tracker/`. Every other
420
+ * unit gets a dot-prefixed name directly under `tracker/`, keyed on kind AND directory:
421
+ * with two flat sets (the references root, and the tool-call contract in `tracker/`) a
422
+ * name keyed on the kind alone would give both units the SAME staging path, each deleting
423
+ * the other's half-built tree — the collision STAGING_TOKEN prevents between runs,
424
+ * reproduced within one.
425
+ */
426
+ function sidecarBaseFor(referencesTarget, unit) {
427
+ if (unit.kind === 'provider')
428
+ return underRoot(referencesTarget, unit.subdir);
429
+ const slug = unit.dir === '' ? 'root' : unit.dir.split('/').join('-');
430
+ return path.join(referencesTarget, TRACKER_DESTINATION_ROOT, `.${unit.kind}.${slug}`);
431
+ }
432
+ /**
433
+ * Why a converged subtree's root must not be used — or `null` when it may.
434
+ *
435
+ * D-CONVERGED-ROOT-REAL: every write and every removal the overlay makes under a
436
+ * converged subtree goes through that subtree's ROOT — staging trees are built under
437
+ * `tracker/`, `pr/` is renamed into place, and each prune starts with a `readdir` of its
438
+ * root. The per-entry symlink guards (the prune's lstat-based `isDirectory()`, the
439
+ * build's symlink skip) protect everything BELOW a root and nothing AT it: `readdir` and
440
+ * `mkdir` follow a link at the root itself. So a root planted as a symlink would send the
441
+ * installer's writes and deletions into whatever it points at.
442
+ *
443
+ * Absent is fine — the overlay creates what it converges. Anything else that is not a
444
+ * real directory, including a root that cannot be lstat'd, is refused (fail closed), and
445
+ * the refusal is reported by the caller rather than repaired: the installer does not
446
+ * delete a link it did not create.
447
+ */
448
+ async function convergedRootFault(referencesTarget, subtree) {
449
+ const root = underRoot(referencesTarget, subtree);
450
+ let stat;
451
+ try {
452
+ stat = await fs.lstat(root);
453
+ }
454
+ catch (err) {
455
+ if (err.code === 'ENOENT')
456
+ return null;
457
+ return `references/${subtree} could not be inspected (${String(err)}) — ` +
458
+ `nothing is staged, promoted or pruned through it.`;
459
+ }
460
+ if (stat.isDirectory())
461
+ return null;
462
+ const what = stat.isSymbolicLink() ? 'a symbolic link' : 'not a directory';
463
+ return `references/${subtree} is ${what} — nothing is staged, promoted or pruned ` +
464
+ `through it. Replace it with a real directory and re-run.`;
465
+ }
466
+ /**
467
+ * The {@link CONVERGED_SUBTREES} a unit's build or promotion writes under.
468
+ *
469
+ * Read off the paths the unit actually uses — where its documents land, where it stages,
470
+ * and where a directory unit's backup goes — rather than restated per kind, so a unit
471
+ * that moves its staging moves this answer with it.
472
+ */
473
+ function subtreesTouchedBy(referencesTarget, unit) {
474
+ const paths = [underRoot(referencesTarget, unitSubdir(unit)), stagingDirFor(referencesTarget, unit)];
475
+ if (unit.kind !== 'cross-cutting')
476
+ paths.push(backupDirFor(referencesTarget, unit));
477
+ const tops = new Set(paths.map(p => path.relative(referencesTarget, p).split(path.sep)[0]));
478
+ return CONVERGED_SUBTREES.filter(subtree => tops.has(subtree));
479
+ }
480
+ /**
481
+ * Build one unit's complete replacement tree under a `.tmp` sibling.
482
+ *
483
+ * Returns the staging directory on success, or the rendered cause when the unit must be
484
+ * abandoned. Throws — and only throws — when a manifest entry is ABSENT from the
485
+ * generated tree: that is a build artifact that was never produced, not an I/O
486
+ * degradation, and shipping an installer that silently omits the mechanics the agent is
487
+ * told to load would move the failure to every user's first spawn.
488
+ *
489
+ * Applies PF-011 (build under a `.tmp` sibling, pre-cleaning an orphan from a prior
490
+ * crashed run). Applies PF-009 for everything else: a copy that fails aborts this unit
491
+ * and no other.
492
+ */
493
+ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
494
+ const stagingDir = stagingDirFor(referencesTarget, unit);
495
+ const subdir = unitSubdir(unit);
496
+ const sourceDir = underRoot(sourceRoot, subdir);
497
+ const wanted = new Map(unit.files.map(relPath => [relPath.split('/').slice(-1)[0], relPath]));
498
+ const landed = new Set();
499
+ const discard = async () => {
500
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
501
+ };
502
+ try {
503
+ await fs.rm(stagingDir, { recursive: true, force: true });
504
+ await fs.mkdir(stagingDir, { recursive: true });
505
+ }
506
+ catch (err) {
507
+ return { ok: false, error: String(err) };
508
+ }
509
+ let entries;
510
+ try {
511
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
512
+ }
513
+ catch (err) {
514
+ await discard();
515
+ return { ok: false, error: String(err) };
516
+ }
517
+ for (const entry of entries) {
518
+ const relPath = subdir === '' ? entry.name : `${subdir}/${entry.name}`;
519
+ // Symlinks are skipped, never followed. copyDirectory follows them and preserves
520
+ // source modes, which is why the overlay does its own copying: a link planted in the
521
+ // generated tree would otherwise pull arbitrary bytes into an installed skill.
522
+ if (entry.isSymbolicLink()) {
523
+ warn(`reference overlay: skipping symlink entry "${relPath}" — symlinks are never followed`);
524
+ continue;
525
+ }
526
+ // A nested directory is another unit's business, and a source file the manifest does
527
+ // not name is not installed at all: the overlay converges to the manifest, it does
528
+ // not merge whatever happens to be lying in the generated tree.
529
+ if (!entry.isFile())
530
+ continue;
531
+ if (!wanted.has(entry.name))
532
+ continue;
533
+ try {
534
+ await fs.copyFile(path.join(sourceDir, entry.name), path.join(stagingDir, entry.name));
535
+ landed.add(entry.name);
536
+ }
537
+ catch (err) {
538
+ await discard();
539
+ return { ok: false, error: String(err) };
540
+ }
541
+ }
542
+ for (const [basename, relPath] of wanted) {
543
+ if (landed.has(basename))
544
+ continue;
545
+ await discard();
546
+ throw new Error(`Generated skill reference not found for declared reference "${relPath}": ` +
547
+ `${underRoot(sourceRoot, relPath)}. ` +
548
+ `Run \`npm run build:mds\` to regenerate dist/skills/git/references/ before install.`);
549
+ }
550
+ return { ok: true, stagingDir };
551
+ }
552
+ /**
553
+ * Is this unit's freshly built staging tree already what is installed?
554
+ *
555
+ * Asked once per unit, between the build and the promotion, so a unit that would be
556
+ * promoted onto an identical copy of itself is skipped and reported as unchanged
557
+ * instead. Without it every run writes every unit, `overlaidRefs` is never empty, and
558
+ * every render site downstream — `devflow tracker --set`'s `(unchanged)` line, init's
559
+ * `Tracker assets: +N` summary — can only ever report movement (AC-23).
560
+ *
561
+ * Comparison is against what the PROMOTION would do, not merely against the bytes the
562
+ * manifest names, and the two differ by unit kind:
563
+ *
564
+ * - a DIRECTORY unit (a provider, or `pr/`) is swapped whole
565
+ * ({@link promoteDirectoryUnit}), so an installed entry the manifest no longer names
566
+ * is something this run would REMOVE. Comparing only the manifest's own files would
567
+ * call such a unit unchanged and leave the stray installed — converge-not-merge
568
+ * silently downgraded to a merge.
569
+ * - a CROSS-CUTTING unit is promoted one document at a time into a directory holding
570
+ * entries the overlay must never replace or delete (D-OVERLAY-FLAT-UNIT), so its
571
+ * files are exactly the comparison and the neighbours are none of its business.
572
+ *
573
+ * What it deliberately does NOT compare is file MODE. D-OVERLAY-MODE-SCOPE normalises the
574
+ * whole references directory on every run regardless of which units promoted, so a unit
575
+ * skipped here still has its modes converged — a skipped promotion can hide byte drift
576
+ * from nothing, and mode drift from nothing either.
577
+ *
578
+ * Any error — an absent installed copy, an unreadable one, a directory that is not there
579
+ * — answers "no". The fallback is always the promotion that was going to happen anyway,
580
+ * so a failure to compare can only cost a write, never correctness.
581
+ */
582
+ async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir) {
583
+ const basenameOf = (relPath) => relPath.split('/').slice(-1)[0];
584
+ if (unit.kind !== 'cross-cutting') {
585
+ const owned = new Set(unit.files.map(basenameOf));
586
+ let installed;
587
+ try {
588
+ installed = await fs.readdir(underRoot(referencesTarget, unitSubdir(unit)));
589
+ }
590
+ catch {
591
+ return false;
592
+ }
593
+ if (installed.length !== owned.size)
594
+ return false;
595
+ if (installed.some(name => !owned.has(name)))
596
+ return false;
597
+ }
598
+ for (const relPath of unit.files) {
599
+ try {
600
+ const staged = await fs.readFile(path.join(stagingDir, basenameOf(relPath)));
601
+ const live = await fs.readFile(underRoot(referencesTarget, relPath));
602
+ if (!staged.equals(live))
603
+ return false;
604
+ }
605
+ catch {
606
+ return false;
607
+ }
608
+ }
609
+ return true;
610
+ }
611
+ /**
612
+ * Put a displaced unit back, and say whether it actually went back.
613
+ *
614
+ * A swallowed rename error would make a failed recovery indistinguishable from a
615
+ * successful one: the install would report the previously installed files as unchanged
616
+ * over a directory that no longer exists, and the prune would then delete the
617
+ * backup holding the only copy. What this returns is what the failure state is built
618
+ * from.
619
+ */
620
+ async function restoreDisplacedUnit(backup, target) {
621
+ try {
622
+ await fs.rename(backup, target);
623
+ return { ok: true };
624
+ }
625
+ catch (err) {
626
+ return { ok: false, error: String(err) };
627
+ }
628
+ }
629
+ /**
630
+ * Promote the flat cross-cutting set — one `rename` per document.
631
+ *
632
+ * There is no directory to swap. These documents land in `references/{unit.dir}` — the
633
+ * references root itself when `dir` is empty — beside hand-authored files the overlay must
634
+ * never replace or delete, so the unit is promoted one `rename` per document and a
635
+ * mid-flight failure leaves it part new and part old
636
+ * (D-OVERLAY-FLAT-UNIT, recorded on {@link OverlayUnit}). That is a weaker guarantee than
637
+ * {@link promoteDirectoryUnit}'s whole-directory swap, which is why the recorded state
638
+ * names which documents carry this run's bytes rather than claiming the set is untouched.
639
+ *
640
+ * Throws on the first failing rename; the caller reports the state recorded by then.
641
+ */
642
+ async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record) {
643
+ const destDir = underRoot(referencesTarget, unit.dir);
644
+ // The directory this set lands in, created rather than assumed — {@link promoteDirectoryUnit}
645
+ // does the same for its target's parent. The two flat directories the registry emits today
646
+ // exist by the time promotion runs for reasons that have nothing to do with the unit landing
647
+ // in them: the references root is created by {@link overlayGeneratedReferences}, and
648
+ // `tracker/` as a side effect of this arm's own staging path. A flat set landing anywhere
649
+ // else takes ENOENT on its first rename — a unit reported as failed for where it was asked
650
+ // to land rather than for anything wrong with the documents it carries.
651
+ await fs.mkdir(destDir, { recursive: true });
652
+ for (const [index, relPath] of unit.files.entries()) {
653
+ const basename = relPath.split('/').slice(-1)[0];
654
+ await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
655
+ // Past the first rename the set is mixed, and there is no directory to swap back
656
+ // (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the
657
+ // rest still carry the previous install's. Recorded after each rename so a failure
658
+ // on the next one names both halves instead of claiming the set is untouched.
659
+ record({
660
+ kind: 'partially-refreshed',
661
+ refreshed: unit.files.slice(0, index + 1),
662
+ stale: unit.files.slice(index + 1),
663
+ });
664
+ }
665
+ await fs.rm(stagingDir, { recursive: true, force: true });
666
+ }
667
+ /**
668
+ * Promote a directory unit (a provider, or `pr/`) — swapped whole, or not at all.
669
+ *
670
+ * Displace the installed unit to its `.old` backup ({@link backupDirFor}), rename the
671
+ * staging tree into its place, then drop the backup — so the installed directory is
672
+ * either entirely the previous install or entirely the new one (DR-05, risk P2-g), and a
673
+ * rename that fails half-way restores the previous one rather than leaving it empty.
674
+ *
675
+ * Throws once the state it left has been recorded; the caller reports it.
676
+ */
677
+ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record) {
678
+ const target = underRoot(referencesTarget, unitSubdir(unit));
679
+ const backup = backupDirFor(referencesTarget, unit);
680
+ await fs.mkdir(path.dirname(target), { recursive: true });
681
+ await fs.mkdir(path.dirname(backup), { recursive: true });
682
+ // Move the installed unit ASIDE, never delete it, before the staging tree takes
683
+ // its place. `rm(target)` then `rename(staging, target)` destroys the only copy
684
+ // first: a rename that then fails leaves the unit with NO mechanics at all,
685
+ // while the report — and the summary line init.ts renders from it — still claims
686
+ // the previously installed files were left unchanged. The backup is what makes
687
+ // that claim true, so a failed promotion is recoverable rather than a silent
688
+ // deletion (avoids PF-009: a reported failure must describe the state it left).
689
+ //
690
+ // The `.old` backup is pre-cleaned like the `.tmp` tree. A crash that strands
691
+ // either is converged away by a later run's tracker-subtree prune (both names end
692
+ // in neither `/` nor `.md`, so no manifest entry can collide with them) — but the
693
+ // backup this run is still relying on is exempt from this run's prune, which is
694
+ // what `restore-failed` carries the recovery path for.
695
+ await fs.rm(backup, { recursive: true, force: true });
696
+ let displaced = false;
697
+ try {
698
+ await fs.rename(target, backup);
699
+ displaced = true;
700
+ }
701
+ catch (err) {
702
+ // Nothing installed yet — a first install has no unit to displace, so a failure
703
+ // from here on leaves the unit ABSENT rather than stale.
704
+ if (err.code !== 'ENOENT')
705
+ throw err;
706
+ record({ kind: 'not-installed', absent: unit.files });
707
+ }
708
+ try {
709
+ await fs.rename(stagingDir, target);
710
+ }
711
+ catch (err) {
712
+ if (displaced) {
713
+ const restored = await restoreDisplacedUnit(backup, target);
714
+ if (!restored.ok) {
715
+ record({ kind: 'restore-failed', recoveryPath: backup, restoreError: restored.error });
716
+ }
717
+ }
718
+ throw err;
719
+ }
720
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
721
+ }
722
+ /**
723
+ * Promote a fully built staging tree into place, dispatched on what kind of unit it is.
724
+ *
725
+ * The unit kinds are promoted by two different strategies with two different guarantees,
726
+ * and each half states its own: {@link promoteDirectoryUnit} swaps a directory whole,
727
+ * {@link promoteCrossCuttingUnit} renames the flat set document by document.
728
+ *
729
+ * What they share is the failure shape. A failure reports the state it left rather than a
730
+ * state a failure is assumed to imply: `state` is advanced as the running half passes each
731
+ * point of no return, so the catch describes the filesystem as it now is. That is the whole
732
+ * difference between a report a user can act on and one that names a recovery copy the same
733
+ * run went on to delete. Discarding the staging tree is shared for the same reason — an
734
+ * abandoned unit leaves no `.tmp` residue, whichever half abandoned it.
735
+ *
736
+ * Exported for the sake of ONE property that cannot be driven through
737
+ * {@link overlayGeneratedReferences}: a promotion that fails AFTER the installed unit has
738
+ * been displaced. The overlay builds and promotes in the same breath, so there is no seam
739
+ * at which a real filesystem failure can be injected between the two — and the behaviour
740
+ * that failure selects (previous unit restored, not deleted) is exactly the one worth
741
+ * pinning.
742
+ */
743
+ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir) {
744
+ let state = { kind: 'installed-unchanged' };
745
+ const record = next => { state = next; };
746
+ try {
747
+ switch (unit.kind) {
748
+ case 'cross-cutting':
749
+ await promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record);
750
+ break;
751
+ case 'provider':
752
+ case 'pr-host':
753
+ await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record);
754
+ break;
755
+ default: {
756
+ const _exhaustive = unit;
757
+ void _exhaustive;
758
+ throw new Error('Unknown overlay unit kind');
759
+ }
760
+ }
761
+ return { ok: true };
762
+ }
763
+ catch (err) {
764
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
765
+ return { ok: false, error: String(err), state };
766
+ }
767
+ }
768
+ /**
769
+ * Which "nothing was modified" sentence is true for a unit whose build failed.
770
+ *
771
+ * A build failure touches nothing under the references root, so the unit is left in
772
+ * whatever state it was already in — and those are two different states with two
773
+ * different consequences. Falling back on a working previous install is a deferred
774
+ * refresh; having no copy at all ships an agent whose mechanics pointers resolve to
775
+ * nothing, which is the worse outcome and the one a shared sentence would describe
776
+ * most quietly.
777
+ *
778
+ * One `access` per file, on the failure path only; the loop is bounded by the unit's
779
+ * own manifest slice.
780
+ */
781
+ async function classifyUntouchedUnit(unit, referencesTarget) {
782
+ const absent = [];
783
+ for (const relPath of unit.files) {
784
+ try {
785
+ await fs.access(underRoot(referencesTarget, relPath));
786
+ }
787
+ catch {
788
+ absent.push(relPath);
789
+ }
790
+ }
791
+ return absent.length === unit.files.length
792
+ ? { kind: 'not-installed', absent }
793
+ : { kind: 'installed-unchanged' };
794
+ }
795
+ /**
796
+ * Refuse the whole overlay when the generated tree was never produced.
797
+ *
798
+ * The per-entry throw in {@link buildUnitStagingTree} cannot reach this case. It is
799
+ * raised after a successful `readdir` of a unit's source directory, so when the ROOT is
800
+ * absent — `npm run build:cli` alone, or a build interrupted before it emitted anything —
801
+ * no unit ever gets that far: each one degrades to a reported failure and the install
802
+ * returns success carrying an agent whose mechanics pointers resolve to nothing. That is
803
+ * the outcome the per-entry throw exists to prevent, arriving by the one route it does
804
+ * not cover — and the same root cause the agent resolver in `installViaFileCopy` already
805
+ * throws for, so the two build artifacts are guarded at the same strength.
806
+ *
807
+ * Deliberately ONE `stat` before the unit loop rather than a check inside it (PF-009):
808
+ * the fan-out has no per-item failure isolation, so a per-unit refusal would let one
809
+ * unbuilt provider abort every other unit's install. A unit directory that is absent
810
+ * under a root that exists stays a per-unit report, exactly as today.
811
+ *
812
+ * Only ENOENT refuses. A root that cannot be stat'd for any other reason (EACCES on a
813
+ * parent, a filesystem in a bad way) is an I/O degradation, not a missing build
814
+ * artifact, and belongs to the per-unit reporting path like every other one.
815
+ */
816
+ async function requireGeneratedTree(sourceRoot, manifest) {
817
+ try {
818
+ await fs.stat(sourceRoot);
819
+ return;
820
+ }
821
+ catch (err) {
822
+ if (err.code !== 'ENOENT')
823
+ return;
824
+ }
825
+ throw new Error(`Generated skill references not found: ${sourceRoot}. ` +
826
+ `The whole generated tree is absent, so none of the ${manifest.length} references the ` +
827
+ `devflow:git agent is instructed to load would be installed. ` +
828
+ `Run \`npm run build:mds\` to regenerate dist/skills/git/references/ before install ` +
829
+ `(\`npm run build:cli\` alone does not produce it).`);
830
+ }
831
+ /**
832
+ * Converge ONE subtree to the manifest — unless that would delete a recovery copy this
833
+ * same run just created.
834
+ *
835
+ * A promotion whose restore failed leaves the unit's ONLY surviving copy in its `.old`
836
+ * sibling, which sits inside the subtree this prune converges and which the manifest
837
+ * (rightly) does not name. Pruning it destroys the backup in the same run that reported
838
+ * it as the way back, so the path the warning names is gone before the user reads it.
839
+ *
840
+ * Of the three ways to stop that, this is the one that leaves the SUCCESSFUL path
841
+ * byte-identical — the same call, the same arguments, the same position in the run.
842
+ * Moving the prune ahead of the unit loop would also spare the backup, but it converges
843
+ * a tree the loop has not rebuilt yet: it reports removals the promotion would have made
844
+ * anyway, and it mutates the install before the one throw path that aborts it. Excluding
845
+ * `.old`/`.tmp` names from the walk would mean a new exclusion option on
846
+ * sweepOrphanedReferences, i.e. changing the shape of a module this concern does not own.
847
+ *
848
+ * The skip is scoped to the subtree the recovery copy is IN, which is what makes it a
849
+ * skip rather than a blanket refusal: a backup stranded under `tracker/` says nothing
850
+ * about whether `pr/` can be converged, and a run that stopped converging both would
851
+ * leave orphans behind for a reason that never applied to one of them.
852
+ *
853
+ * The skip is not silent. The unswept subtree is reported through `failed` — the same
854
+ * channel that module uses for its own depth-bound breach — so nothing claims
855
+ * convergence over ground it did not cover (avoids PF-009, PF-015). Orphans under that
856
+ * subtree survive this install and the next one converges them.
857
+ *
858
+ * A subtree whose root is not a real directory ({@link convergedRootFault}) is skipped
859
+ * the same way and through the same channel: a sweep's `readdir` of its root follows a
860
+ * link planted there, so pruning it would delete inside whatever the link points at.
861
+ *
862
+ * Every name this returns is relative to the references ROOT, not to the subtree it
863
+ * swept. The results of the {@link CONVERGED_SUBTREES} sweeps are merged into one
864
+ * {@link SweepResult}, and `stale.md` removed from `pr/` and `stale.md` removed from
865
+ * `tracker/` would otherwise reach the install report as one indistinguishable name —
866
+ * a report naming a file the user cannot find. It also puts the removals in the same
867
+ * coordinate system as the manifest that decided them and as the stranded-skip failure
868
+ * below, which has always named its subtree from the root.
869
+ */
870
+ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures) {
871
+ const subtreeRoot = underRoot(referencesTarget, subtree);
872
+ const prefix = `${subtree}/`;
873
+ // Checked here rather than trusted from the unit loop: this is the call that follows
874
+ // the root, and the root is re-read at the moment it would be followed.
875
+ const fault = await convergedRootFault(referencesTarget, subtree);
876
+ if (fault !== null) {
877
+ return { scanned: 0, removed: [], failed: [{ name: subtree, error: new Error(fault) }] };
878
+ }
879
+ const stranded = [];
880
+ for (const failure of overlayFailures) {
881
+ if (failure.state.kind !== 'restore-failed')
882
+ continue;
883
+ if (!isContainedIn(subtreeRoot, failure.state.recoveryPath))
884
+ continue;
885
+ stranded.push(failure.state.recoveryPath);
886
+ }
887
+ if (stranded.length > 0) {
888
+ return {
889
+ scanned: 0,
890
+ removed: [],
891
+ failed: [{
892
+ name: subtree,
893
+ error: new Error(`${subtree}: the stale-reference prune was skipped — a promotion that ` +
894
+ `could not be rolled back left the only surviving copy of its references in ` +
895
+ `${stranded.join(', ')}, which this prune would delete in the same run that ` +
896
+ `named it as the way back. Orphaned references under ${prefix} ` +
897
+ `survive this install; the next one converges them.`),
898
+ }],
899
+ };
900
+ }
901
+ // Keyed by relative path, because `tracker/{provider}/{op}.md` is what distinguishes
902
+ // two providers' identically named files — the reason mdEntryName cannot serve here.
903
+ const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))));
904
+ return {
905
+ scanned: swept.scanned,
906
+ removed: swept.removed.map(rel => `${prefix}${rel}`),
907
+ failed: swept.failed.map(f => ({ name: `${prefix}${f.name}`, error: f.error })),
908
+ };
909
+ }
910
+ /**
911
+ * Converge every wholly-generated subtree, as one result.
912
+ *
913
+ * One {@link SweepResult} rather than one per subtree because a sweep result is a
914
+ * REPORT, and its three fields already answer the two questions any reader has —
915
+ * "what went" (`removed`, each name rooted at `references/`) and "what was left
916
+ * unconverged, and why" (`failed`). Splitting it per subtree would push that join onto
917
+ * {@link recordSweep} and `devflow tracker --set`'s summary line, neither of which has
918
+ * anything to say about which directory a reference used to live in.
919
+ *
920
+ * `scanned` sums, which keeps it the non-vacuity counter it is everywhere else: a run
921
+ * that scanned nothing scanned nothing in any subtree.
922
+ */
923
+ async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures) {
924
+ let scanned = 0;
925
+ const removed = [];
926
+ const failed = [];
927
+ for (const subtree of CONVERGED_SUBTREES) {
928
+ const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures);
929
+ scanned += swept.scanned;
930
+ removed.push(...swept.removed);
931
+ failed.push(...swept.failed);
932
+ }
933
+ return { scanned, removed, failed };
934
+ }
935
+ /**
936
+ * Converge an installed `devflow:git` references directory onto the generated tree.
937
+ *
938
+ * Converge, not merge — for every wholly-generated subtree ({@link CONVERGED_SUBTREES}).
939
+ * Every unit is rebuilt from the generated sources and swapped in atomically, and anything
940
+ * under one of those subtrees that the manifest does not name is then removed: a shadow
941
+ * that supplies its own file under `tracker/` does not keep it (AC-2.4c), a provider
942
+ * directory the manifest stops listing is gone rather than left to rot (GAP-24), and a
943
+ * retired `pr/{op}.md` goes the same way for the same reason.
944
+ *
945
+ * The references ROOT is overlaid but never pruned, and that is where the guarantee stops.
946
+ * The flat cross-cutting documents land beside hand-authored references with no manifest of
947
+ * which names are hand-authored to prune against (D-OVERLAY-FLAT-UNIT), so a document
948
+ * retired from `GIT_CROSS_CUTTING_DOCS` keeps its installed copy until the skill directory
949
+ * is replaced — the one convergence this module does not deliver, and the scope
950
+ * CHANGELOG.md states for the shipped claim. A prunable flat root needs an allowlist of the
951
+ * hand-authored names, which is a Phase-3 candidate rather than a Phase-2 omission.
952
+ *
953
+ * Runs for a shadowed and a canonical install alike: a user who overrides the git skill
954
+ * must still receive the canonical GitHub mechanics the agent is told to load
955
+ * (AC-2.4a / UAC-28).
956
+ *
957
+ * The prune runs last and yields to two things only — a recovery copy this run itself
958
+ * created and is still relying on, and a converged root that is not a real directory
959
+ * (see {@link prunePreservingRecoveryCopies}); a unit that would stage or land through
960
+ * such a root is refused before it is built (D-CONVERGED-ROOT-REAL). Every unit this run
961
+ * did not refresh reaches `overlayFailures` carrying the state it was actually left in,
962
+ * never a blanket claim that nothing changed.
963
+ *
964
+ * A unit already installed byte-for-byte is neither written nor a failure: it is skipped
965
+ * and named in `unchangedRefs` (see {@link stagedUnitIsAlreadyInstalled}), so
966
+ * `overlaidRefs` is what this run WROTE rather than what it considered. Every run still
967
+ * BUILDS every unit's staging tree, because that comparison is what the convergence is —
968
+ * the saving is the promotion, not the work of deciding.
969
+ *
970
+ * @param opts.referencesTarget - `{claudeDir}/skills/devflow:git/references`.
971
+ * @param opts.sourceRoot - Generated tree; defaults to `compiledSkillRefsDir()`.
972
+ * @param opts.manifest - Manifest to converge to; defaults to the build registries.
973
+ * Injectable so a provider set the GitHub-only build does not produce can be exercised.
974
+ * @param opts.warn - Receives non-fatal notices (skipped symlinks, mode normalisation).
975
+ *
976
+ * @throws on three conditions, each of them a build artifact that was never produced
977
+ * rather than an I/O degradation. Every other failure is reported, never thrown
978
+ * (PF-009), and the three are ordered here as the function reaches them:
979
+ * 1. `opts.manifest` omitted AND the reference-module registry does not expand —
980
+ * raised by {@link generatedReferenceManifest} while resolving the default. A
981
+ * caller that passes its own manifest cannot reach this one.
982
+ * 2. `opts.sourceRoot` (default {@link compiledSkillRefsDir}) does not exist at all —
983
+ * see {@link requireGeneratedTree}. Nothing is installed and nothing is reported;
984
+ * the refusal is the whole outcome.
985
+ * 3. A manifest entry is absent from a source directory that does exist — see
986
+ * {@link buildUnitStagingTree}. Raised mid-loop, so units planned before the
987
+ * failing one may already have been promoted.
988
+ */
989
+ export async function overlayGeneratedReferences(opts) {
990
+ const sourceRoot = opts.sourceRoot ?? compiledSkillRefsDir();
991
+ const manifest = opts.manifest ?? generatedReferenceManifest();
992
+ const warn = opts.warn ?? (() => { });
993
+ const overlaidRefs = [];
994
+ const unchangedRefs = [];
995
+ const overlayFailures = [];
996
+ // Before the target is touched, so a refused overlay leaves the install exactly as it
997
+ // found it rather than a references directory it went on to abandon.
998
+ await requireGeneratedTree(sourceRoot, manifest);
999
+ await fs.mkdir(opts.referencesTarget, { recursive: true });
1000
+ // D-CONVERGED-ROOT-REAL, unit half: a unit whose build or promotion would pass through
1001
+ // a converged root that is not a real directory is refused before it touches anything.
1002
+ // Every unit stages under `tracker/`, so a faulted `tracker/` refuses them all.
1003
+ const rootFaults = new Map();
1004
+ for (const subtree of CONVERGED_SUBTREES) {
1005
+ const fault = await convergedRootFault(opts.referencesTarget, subtree);
1006
+ if (fault !== null)
1007
+ rootFaults.set(subtree, fault);
1008
+ }
1009
+ for (const unit of planOverlayUnits(manifest)) {
1010
+ const fault = subtreesTouchedBy(opts.referencesTarget, unit)
1011
+ .map(subtree => rootFaults.get(subtree))
1012
+ .find((reason) => reason !== undefined);
1013
+ if (fault !== undefined) {
1014
+ overlayFailures.push({
1015
+ unit: unitRef(unit),
1016
+ state: await classifyUntouchedUnit(unit, opts.referencesTarget),
1017
+ error: fault,
1018
+ });
1019
+ continue;
1020
+ }
1021
+ const built = await buildUnitStagingTree(unit, sourceRoot, opts.referencesTarget, warn);
1022
+ if (!built.ok) {
1023
+ overlayFailures.push({
1024
+ unit: unitRef(unit),
1025
+ state: await classifyUntouchedUnit(unit, opts.referencesTarget),
1026
+ error: built.error,
1027
+ });
1028
+ continue;
1029
+ }
1030
+ // Converged already — discard the staging tree rather than promote a copy of what is
1031
+ // installed, so `overlaidRefs` names what this run WROTE (AC-23). The discard is the
1032
+ // same one both promotion halves perform on their way out; skipping it would leave
1033
+ // the `.tmp` residue every other path is asserted not to leave.
1034
+ if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir)) {
1035
+ await fs.rm(built.stagingDir, { recursive: true, force: true }).catch(() => undefined);
1036
+ unchangedRefs.push(...unit.files);
1037
+ continue;
1038
+ }
1039
+ const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir);
1040
+ if (!promoted.ok) {
1041
+ overlayFailures.push({ unit: unitRef(unit), state: promoted.state, error: promoted.error });
1042
+ continue;
1043
+ }
1044
+ overlaidRefs.push(...unit.files);
1045
+ }
1046
+ const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures);
1047
+ // D-OVERLAY-MODE-SCOPE: normalise the WHOLE references directory, not only the files
1048
+ // this run installed. copyDirectory preserves source modes, so a hand-authored
1049
+ // reference checked in with an odd mode installs with it; a reference is read-only
1050
+ // instruction text and 0644 is what every one of them should be. Best-effort: a
1051
+ // filesystem that does not honour mode bits must not fail an install (PF-009).
1052
+ //
1053
+ // This is the one step that reaches a file the overlay does not own, and it is why the
1054
+ // boundary is stated as "never replace or delete" rather than "never touch": the MODE of
1055
+ // a hand-authored reference — and of whatever a shadowed skill supplied outside
1056
+ // `tracker/` — is normalised here. ADR-024 corollary (b) permits exactly that: the
1057
+ // ownership guard protects deletion, not overwrite.
1058
+ //
1059
+ // It is also the one walk that can breach chmodRecursive's descent bound. The catch is
1060
+ // that breach's reporting channel, not just an I/O guard (see {@link chmodRecursive}).
1061
+ try {
1062
+ await chmodRecursive(opts.referencesTarget, 0o644);
1063
+ }
1064
+ catch (err) {
1065
+ warn(`reference overlay: could not normalise reference file modes — ${String(err)}`);
1066
+ }
1067
+ return { overlaidRefs, unchangedRefs, overlayFailures, pruned };
1068
+ }
1069
+ /** The directory inside an installed skill that the reference overlay converges. */
1070
+ const SKILL_REFERENCES_DIRNAME = 'references';
1071
+ /**
1072
+ * What inside an installed skill directory the reference overlay owns, and the
1073
+ * pre-clean must therefore leave standing (D-OVERLAY-OWNERSHIP).
1074
+ *
1075
+ * Derived from the manifest the overlay is about to converge to, never a hand-typed
1076
+ * list: the two would be one edit apart from disagreeing, and the failure is silent —
1077
+ * a name the pre-clean forgot is simply force-promoted again on every run, which is the
1078
+ * defect this split exists to close.
1079
+ *
1080
+ * Paths are skill-relative, and what an entry contributes depends on whether a prune
1081
+ * converges the directory it lands in — the one thing that makes preserving a whole
1082
+ * directory safe:
1083
+ * - an entry under a {@link CONVERGED_SUBTREES} directory (`tracker/jira/setup-task.md`)
1084
+ * contributes that SUBTREE, `references/tracker`. The overlay prunes everything under
1085
+ * it the manifest does not name, so preserving it whole cannot strand an orphan — a
1086
+ * file the manifest lost leaves through {@link prunePreservingRecoveryCopies} on this
1087
+ * same run.
1088
+ * - any other entry contributes only THAT FILE: a flat one (`decision-markers.md`), and
1089
+ * a nested one whose directory no prune converges. The references root holds
1090
+ * hand-authored documents beside the generated ones with no manifest of which is
1091
+ * which (D-OVERLAY-FLAT-UNIT), so the overlay never prunes there and the pre-clean
1092
+ * must keep reaching it; a nested directory missing from the converged list is in
1093
+ * the same position. Preserving either wholesale would make a retired generated
1094
+ * document, and any stale file beside it, permanent.
1095
+ *
1096
+ * Deciding the subtree arm by {@link CONVERGED_SUBTREES} rather than by nesting is what
1097
+ * makes the two lists agree by construction: a directory is kept whole exactly when a
1098
+ * prune owns it, so a fan-out directory registered without a converged entry fails safe
1099
+ * instead of surviving every install (avoids PF-074).
1100
+ *
1101
+ * Pure function (applies ADR-013). Exported for the one property no installed-tree arm
1102
+ * can reach: the build emits no nested directory outside the converged list, so the
1103
+ * fail-safe arm is only observable on a manifest the registry does not produce.
1104
+ */
1105
+ export function overlayOwnedSkillPaths(manifest) {
1106
+ const owned = new Set();
1107
+ for (const relPath of manifest) {
1108
+ const segments = relPath.split('/');
1109
+ if (segments[0] === '')
1110
+ continue;
1111
+ const keptWhole = segments.length > 1 && isConvergedSubtree(segments[0]);
1112
+ owned.add(`${SKILL_REFERENCES_DIRNAME}/${keptWhole ? segments[0] : relPath}`);
1113
+ }
1114
+ return owned;
1115
+ }
1116
+ /**
1117
+ * Empty a directory of everything but the paths another converger owns.
1118
+ *
1119
+ * `fs.rm(dir)` with a hole in it. `keep` holds directory-relative paths, each preserved
1120
+ * whole — a file as itself, a directory with its entire subtree. Everything else is
1121
+ * removed exactly as the unconditional pre-clean would have removed it.
1122
+ *
1123
+ * A kept path is preserved only as a real file or directory. A symlink planted at one is
1124
+ * removed like anything else (the link, never its target): the converger that owns the
1125
+ * path writes and prunes through it, so a link left standing here would hand that
1126
+ * converger someone else's directory (D-CONVERGED-ROOT-REAL). `Dirent` types are
1127
+ * lstat-based, which is what makes the check see the link rather than its target.
1128
+ *
1129
+ * Descent is bounded, and the bound is the `keep` set's own deepest path rather than a
1130
+ * constant: the walk only ever descends INTO a directory that still has a kept
1131
+ * descendant below it, so there is nothing to look for past that depth. A `keep` set of
1132
+ * depth 2 — which is what {@link overlayOwnedSkillPaths} produces for every manifest the
1133
+ * build emits — walks two levels and `fs.rm`s the rest recursively in one call.
1134
+ *
1135
+ * An unreadable directory is left alone rather than reported: the caller already
1136
+ * swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
1137
+ * read its target has nothing to remove from it.
1138
+ */
1139
+ async function emptyDirectoryExcept(dir, keep) {
1140
+ const kept = [...keep];
1141
+ const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
1142
+ const walk = async (current, rel, depth) => {
1143
+ let entries;
1144
+ try {
1145
+ entries = await fs.readdir(current, { withFileTypes: true });
1146
+ }
1147
+ catch {
1148
+ return;
1149
+ }
1150
+ for (const entry of entries) {
1151
+ const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
1152
+ if (keep.has(entryRel) && !entry.isSymbolicLink())
1153
+ continue;
1154
+ const holdsSomethingKept = entry.isDirectory()
1155
+ && depth < maxDepth
1156
+ && kept.some(relPath => relPath.startsWith(`${entryRel}/`));
1157
+ if (holdsSomethingKept) {
1158
+ await walk(path.join(current, entry.name), entryRel, depth + 1);
1159
+ continue;
1160
+ }
1161
+ await fs.rm(path.join(current, entry.name), { recursive: true, force: true });
1162
+ }
1163
+ };
1164
+ await walk(dir, '', 1);
1165
+ }
1166
+ // ---------------------------------------------------------------------------
175
1167
  // Script composer
176
1168
  // ---------------------------------------------------------------------------
177
1169
  /** Matches relative ES import/export specifiers and dynamic import() calls. */
@@ -200,11 +1192,24 @@ function collectRelativeImports(source) {
200
1192
  export async function composeScripts(scriptsTarget) {
201
1193
  await fs.mkdir(scriptsTarget, { recursive: true });
202
1194
  // (a) src/assets/scripts/ verbatim
1195
+ //
1196
+ // D-SCRIPTS-EXEC-SCOPE: only what this step copied is made executable — each of the
1197
+ // source tree's top-level entries, at its destination. A chmod over the whole target
1198
+ // would also reach what (b) and (c) wrote there on an earlier run (package.json, the
1199
+ // mirrored dist/hud/ closure), so a re-run gave package.json an exec bit the first
1200
+ // run never did and re-init stopped being a no-op on disk (#388 AC-3). Scoping the
1201
+ // chmod to the copied entries makes the first and every later run agree.
203
1202
  const srcScripts = scriptsDir();
204
1203
  try {
205
1204
  await copyDirectory(srcScripts, scriptsTarget);
206
1205
  if (process.platform !== 'win32') {
207
- await chmodRecursive(scriptsTarget, 0o755);
1206
+ for (const entry of await fs.readdir(srcScripts, { withFileTypes: true })) {
1207
+ const copied = path.join(scriptsTarget, entry.name);
1208
+ if (entry.isDirectory())
1209
+ await chmodRecursive(copied, 0o755);
1210
+ else if (entry.isFile())
1211
+ await fs.chmod(copied, 0o755);
1212
+ }
208
1213
  }
209
1214
  }
210
1215
  catch { /* scripts dir may not exist yet during development */ }
@@ -258,10 +1263,48 @@ export async function composeScripts(scriptsTarget) {
258
1263
  }
259
1264
  catch { /* already exists from prior install — leave as-is */ }
260
1265
  }
1266
+ /**
1267
+ * First path in `candidates` that exists on disk, or undefined when none do.
1268
+ * Bounded by candidates.length. The fs.access rejection is the existence probe,
1269
+ * not a failure: callers decide what an exhausted candidate list means.
1270
+ */
1271
+ async function firstExisting(candidates) {
1272
+ for (const candidate of candidates) {
1273
+ try {
1274
+ await fs.access(candidate);
1275
+ return candidate;
1276
+ }
1277
+ catch { /* not here — try the next directory in preference order */ }
1278
+ }
1279
+ return undefined;
1280
+ }
1281
+ /**
1282
+ * Registry skills that have a shadow directory under `~/.devflow/skills/`.
1283
+ *
1284
+ * One readdir of the SHADOW tree, intersected with the registry. Deliberately
1285
+ * not a readdir of the installed skills directory: that tree is the thing being
1286
+ * converged, and reading it to decide what to remove is how a directory a user
1287
+ * put there by hand becomes a deselection (applies ADR-024).
1288
+ *
1289
+ * Whether a shadow is VALID is a separate question, answered per skill by
1290
+ * validateSkillShadow at install time. This only answers "did the user write
1291
+ * one?", which is what dormancy reporting turns on.
1292
+ */
1293
+ async function listShadowedSkills(devflowDir) {
1294
+ const registry = new Set(getAllSkillNames());
1295
+ let entries;
1296
+ try {
1297
+ entries = await fs.readdir(path.join(devflowDir, 'skills'));
1298
+ }
1299
+ catch {
1300
+ return [];
1301
+ }
1302
+ return entries.filter(name => registry.has(name));
1303
+ }
261
1304
  /**
262
1305
  * Records the result of a single orphan-sweep run into the install report.
263
- * Extracted from the thrice-repeated inline block to keep each call-site a
264
- * one-liner and ensure the kind tag is always populated. (F14)
1306
+ * Shared by every sweep-recording call site so each stays a one-liner and
1307
+ * the kind tag is always populated. (F14)
265
1308
  */
266
1309
  function recordSweep(report, kind, sweep) {
267
1310
  report.sweptOrphans.push(...sweep.removed.map(name => ({ kind, name })));
@@ -275,14 +1318,36 @@ function recordSweep(report, kind, sweep) {
275
1318
  * Returns an InstallReport describing which shadows were applied and which were skipped.
276
1319
  */
277
1320
  export async function installViaFileCopy(options) {
278
- const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, } = options;
1321
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, warn = () => { }, } = options;
279
1322
  const report = {
280
1323
  shadowedSkills: [],
281
1324
  shadowedRules: [],
282
1325
  skippedShadows: [],
283
1326
  sweptOrphans: [],
284
1327
  sweepFailures: [],
1328
+ overlaidRefs: [],
1329
+ unchangedRefs: [],
1330
+ overlayFailures: [],
1331
+ removedSkills: [],
1332
+ dormantShadows: [],
285
1333
  };
1334
+ // The skill decision, made in full before anything is touched — which skills
1335
+ // this selection installs, which it removes, and which shadows it leaves inert.
1336
+ // Pure and registry-driven: the removal set is `skillsOf(all) \ skillsOf(selected)
1337
+ // \ FEATURE_OWNED`, never a readdir of the installed skills directory, so an
1338
+ // unrelated `devflow:` directory a user put there by hand is not swept as a
1339
+ // deselection (applies ADR-024).
1340
+ //
1341
+ // Computed BEFORE shadows are resolved: a shadow is applied only to a skill the
1342
+ // selection installs, so the install set is the question that has to be settled
1343
+ // first. Resolving shadows first would mean probing shadow directories for
1344
+ // skills this run is about to remove.
1345
+ const skillPlan = resolveSkillInstallPlan({
1346
+ effectivePlugins: options.effectivePlugins ?? plugins,
1347
+ isPartialInstall,
1348
+ shadowedSkills: await listShadowedSkills(devflowDir),
1349
+ });
1350
+ report.dormantShadows = [...skillPlan.dormantShadows];
286
1351
  // Clean old Devflow files before installing
287
1352
  spinner.message('Cleaning old files...');
288
1353
  if (!isPartialInstall) {
@@ -291,7 +1356,6 @@ export async function installViaFileCopy(options) {
291
1356
  // without discarding assets from plugins not included in this run.
292
1357
  const oldDirs = [
293
1358
  path.join(claudeDir, 'commands', 'devflow'),
294
- path.join(claudeDir, 'agents', 'devflow'),
295
1359
  path.join(claudeDir, 'rules', 'devflow'),
296
1360
  ];
297
1361
  for (const dir of oldDirs) {
@@ -300,6 +1364,19 @@ export async function installViaFileCopy(options) {
300
1364
  }
301
1365
  catch { /* ignore */ }
302
1366
  }
1367
+ // D-TRACKER-AGENT-OWNER, pre-clean half — the same split the reference tree
1368
+ // needed (D-OVERLAY-OWNERSHIP), for the same reason. The agent directory is
1369
+ // emptied AROUND the one file `convergeTrackerArtifacts` owns: taking it
1370
+ // would leave converge with nothing to byte-compare against, so a
1371
+ // steady-state re-init would re-copy the agent and announce
1372
+ // `tracker agent installed` on every run. Everything else is removed
1373
+ // exactly as the unconditional wipe removed it, and the file is still
1374
+ // converged on this run — drift in it is restored, so preserving it strands
1375
+ // nothing.
1376
+ try {
1377
+ await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]));
1378
+ }
1379
+ catch { /* ignore */ }
303
1380
  }
304
1381
  // Sweep stale devflow:* skill dirs — ungated: runs on every install shape,
305
1382
  // including partial installs, so renamed/deleted skills are pruned promptly.
@@ -317,21 +1394,67 @@ export async function installViaFileCopy(options) {
317
1394
  // Pre-clean the prefixed install targets before re-copying so stale content
318
1395
  // never bleeds into a fresh install. Bare pre-namespace dirs at
319
1396
  // ~/.claude/skills/{name} are owned solely by the frozen LEGACY_SKILL_NAMES
320
- // pass in init.ts (runs immediately after this call, init.ts:1149). A bare
321
- // dir whose name matches a current registry skill is by construction foreign
322
- // to Devflow and must not be touched here (avoids PF-012).
323
- const allSkills = new Set();
324
- for (const plugin of DEVFLOW_PLUGINS) {
325
- for (const skill of plugin.skills) {
326
- allSkills.add(skill);
1397
+ // pass in init.ts (runs immediately after this call). A bare dir whose name
1398
+ // matches a current registry skill is by construction foreign to Devflow and
1399
+ // must not be touched here (avoids PF-012).
1400
+ //
1401
+ // The pre-clean is SCOPED to what this run reinstalls and the orphan sweep
1402
+ // above is UNSCOPED (the full registry). The opposite scoping is deliberate,
1403
+ // not an inconsistency waiting to be simplified away:
1404
+ // - the sweep removes names the registry no longer has at all, which is true
1405
+ // regardless of selection, so a partial install must still prune them;
1406
+ // - the pre-clean empties a directory this run is about to rewrite, so
1407
+ // widening it past the install set would delete a selected plugin's skill
1408
+ // and never put it back.
1409
+ // Gated on a full install for the same reason: `--plugin=X` rewrites X's
1410
+ // skills only, and a pre-clean over the whole registry would wipe every other
1411
+ // plugin's skills on an add-one run.
1412
+ //
1413
+ // ONE skill is pre-cleaned around a hole rather than emptied: the skill hosting the
1414
+ // generated references, whose overlay-owned subtree belongs to
1415
+ // {@link overlayGeneratedReferences} and to nothing else (D-OVERLAY-OWNERSHIP). The
1416
+ // two mechanisms are not alternatives — the overlay is a CONVERGER and the pre-clean
1417
+ // is not, so handing it the subtree loses what the converger is for:
1418
+ // - the overlay compares each unit against what is installed and skips the ones
1419
+ // already correct ({@link stagedUnitIsAlreadyInstalled}). A pre-clean that deletes
1420
+ // the installed copy first leaves it nothing to compare against, so every unit is
1421
+ // force-promoted and a re-init that changed nothing still reports the whole
1422
+ // manifest as written (QA S2);
1423
+ // - the overlay PRUNES every wholly-generated subtree (`tracker/`, `pr/`) down to the
1424
+ // manifest and swaps each unit atomically, so drift and orphans under those subtrees
1425
+ // are converged away without the pre-clean reaching them at all.
1426
+ // Everything else in the directory is still emptied, so a stale hand-authored skill
1427
+ // file — including a reference at the references ROOT, which the overlay may replace
1428
+ // but never delete (D-OVERLAY-FLAT-UNIT) — does not survive a full install.
1429
+ if (!isPartialInstall) {
1430
+ const overlayOwned = overlayOwnedSkillPaths(installedReferenceManifest());
1431
+ for (const skill of skillsMap.keys()) {
1432
+ // Empty the prefixed directory (its contents are re-created during the install
1433
+ // phase), minus whatever another converger owns inside it.
1434
+ const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
1435
+ try {
1436
+ if (skill === SKILL_REFS_SKILL_NAME) {
1437
+ await emptyDirectoryExcept(target, overlayOwned);
1438
+ }
1439
+ else {
1440
+ await fs.rm(target, { recursive: true, force: true });
1441
+ }
1442
+ }
1443
+ catch { /* ignore */ }
327
1444
  }
328
1445
  }
329
- for (const skill of allSkills) {
330
- // Remove prefixed directory (will be re-created during install phase)
1446
+ // Remove the skills no selected plugin owns or requires — the deselection half
1447
+ // of the scoped install. Empty on a partial install by construction
1448
+ // (resolveSkillInstallPlan gates it), so `--plugin=X` adds and never subtracts
1449
+ // (AC-22). Failures are per-item and non-fatal (applies PF-009).
1450
+ for (const skill of skillPlan.remove) {
331
1451
  try {
332
1452
  await fs.rm(path.join(claudeDir, 'skills', prefixSkillName(skill)), { recursive: true, force: true });
1453
+ report.removedSkills.push(skill);
1454
+ }
1455
+ catch (err) {
1456
+ warn(`Could not remove deselected skill "${prefixSkillName(skill)}" — ${String(err)}`);
333
1457
  }
334
- catch { /* ignore */ }
335
1458
  }
336
1459
  // Install commands from selected plugins using registry-driven lookup.
337
1460
  // Source: dist/commands/{name}.md (single lookup directory for all commands).
@@ -364,14 +1487,30 @@ export async function installViaFileCopy(options) {
364
1487
  // knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
365
1488
  // plugins survive a partial run. Only names absent from the full registry are removed.
366
1489
  recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames()), mdEntryName));
367
- // Install agents (deduplicated) from flat src/assets/agents/{name}.md.
368
- // A declared agent whose source file is absent is a build/packaging failure
369
- // and throws rather than silently skipping (matches command pattern).
1490
+ // Install agents (deduplicated), resolved dist-first with a src fallback:
1491
+ // dist/agents/{name}.md (compiled from an .mds generator host) wins over
1492
+ // src/assets/agents/{name}.md. A declared agent absent from BOTH is a
1493
+ // build/packaging failure and throws rather than silently skipping (matches
1494
+ // command pattern); the message names the build step as well as the tree.
1495
+ //
1496
+ // D-TRACKER-AGENT-OWNER: every declared agent but ONE. The Tracker agent is
1497
+ // converged by `convergeTrackerArtifacts` alone (plan A3), which runs after this
1498
+ // function in init and reports whether this run wrote it. Copying it here too
1499
+ // would make every install do the work twice, and a fresh install would never
1500
+ // report `installed` because converge would find this loop's byte-identical copy
1501
+ // already in place.
1502
+ //
1503
+ // The name is skipped from the COPY set only. It stays declared in
1504
+ // `devflow-core-skills.agents`, so the sweep below — which keys on the full
1505
+ // registry via getAllAgentNames() — still treats a converged tracker.md as known
1506
+ // and leaves it alone.
370
1507
  const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
371
- const aDir = agentsDir();
1508
+ const agentDirs = options.agentSourceDirs ?? agentSourceDirs();
372
1509
  const allAgentNames = new Set();
373
1510
  for (const plugin of plugins) {
374
1511
  for (const agent of plugin.agents) {
1512
+ if (agent === TRACKER_AGENT_NAME)
1513
+ continue;
375
1514
  if (!allAgentNames.has(agent) && agentsMap.get(agent) === plugin.name) {
376
1515
  allAgentNames.add(agent);
377
1516
  }
@@ -380,13 +1519,12 @@ export async function installViaFileCopy(options) {
380
1519
  if (allAgentNames.size > 0) {
381
1520
  await fs.mkdir(agentsTarget, { recursive: true });
382
1521
  for (const agentName of allAgentNames) {
383
- const srcFile = path.join(aDir, mdFileName(agentName));
384
- try {
385
- await fs.access(srcFile);
386
- }
387
- catch {
388
- throw new Error(`Agent source not found for declared agent "${agentName}": ${srcFile}. ` +
389
- `Ensure the agent file exists in src/assets/agents/.`);
1522
+ const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
1523
+ const srcFile = await firstExisting(candidates);
1524
+ if (srcFile === undefined) {
1525
+ throw new Error(`Agent source not found for declared agent "${agentName}": ${candidates[0]}. ` +
1526
+ `Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
1527
+ `ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
390
1528
  }
391
1529
  await fs.copyFile(srcFile, path.join(agentsTarget, mdFileName(agentName)));
392
1530
  }
@@ -427,6 +1565,23 @@ export async function installViaFileCopy(options) {
427
1565
  else {
428
1566
  await copyDirectory(skillSource, skillTarget);
429
1567
  }
1568
+ // Converge the generated references onto the skill that was just installed. One call
1569
+ // site downstream of all three branches above, so a shadowed devflow:git receives the
1570
+ // canonical GitHub mechanics exactly as a canonical install does — AC-2.4a / UAC-28,
1571
+ // which is a release blocker, not merely an acceptance criterion.
1572
+ if (skillName === SKILL_REFS_SKILL_NAME) {
1573
+ const overlay = await overlayGeneratedReferences({
1574
+ referencesTarget: path.join(skillTarget, 'references'),
1575
+ // Every provider's mechanics (D-INSTALL-ALL-PROVIDERS). The overlay
1576
+ // converges rather than merges, so a retired generated document is pruned.
1577
+ manifest: installedReferenceManifest(),
1578
+ warn,
1579
+ });
1580
+ report.overlaidRefs.push(...overlay.overlaidRefs);
1581
+ report.unchangedRefs.push(...overlay.unchangedRefs);
1582
+ report.overlayFailures.push(...overlay.overlayFailures);
1583
+ recordSweep(report, 'reference', overlay.pruned);
1584
+ }
430
1585
  }
431
1586
  // Install rules from selected plugins (rulesMap covers selected plugins only).
432
1587
  // Rules are flat .md files resolved from src/assets/rules/{name}.md (no per-plugin subdir).