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