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.
- package/CHANGELOG.md +156 -0
- package/README.md +86 -18
- package/dist/agents/git.md +824 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +23 -1
- package/dist/cli/commands/init-seed.js +24 -26
- package/dist/cli/commands/init.js +502 -71
- package/dist/cli/commands/install-report.js +205 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +27 -37
- package/dist/cli/commands/learning.js +37 -30
- package/dist/cli/commands/memory.js +79 -69
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/security.js +76 -16
- package/dist/cli/commands/skills.js +53 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +405 -0
- package/dist/cli/commands/uninstall.js +211 -65
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +22 -4
- package/dist/commands/code-review.md +44 -15
- package/dist/commands/debug.md +20 -6
- package/dist/commands/dynamic-build.md +289 -67
- package/dist/commands/dynamic-plan.md +60 -21
- package/dist/commands/dynamic-profile.md +1 -1
- package/dist/commands/dynamic-tickets.md +58 -8
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +241 -53
- package/dist/commands/plan.md +88 -17
- package/dist/commands/release.md +64 -17
- package/dist/commands/resolve.md +138 -58
- package/dist/commands/self-review.md +2 -2
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/evidence-policy.js +147 -0
- package/dist/core/feature-config.js +130 -64
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +4 -4
- package/dist/core/manifest.js +33 -7
- package/dist/core/mds-variants.js +861 -0
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +357 -9
- package/dist/core/project-paths.js +1 -1
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/tracker.js +407 -0
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/installer.js +1213 -31
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +196 -74
- package/dist/targets/claude-code/tracker-install.js +161 -0
- package/package.json +4 -3
- package/src/assets/agents/code.md +42 -4
- package/src/assets/agents/design.md +1 -1
- package/src/assets/agents/git.mds +827 -0
- package/src/assets/agents/knowledge.md +1 -1
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +467 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_engine.mds +11 -9
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_knowledge.mds +2 -2
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +1 -1
- package/src/assets/commands/_partials/_publication.mds +3 -1
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +15 -5
- package/src/assets/commands/code-review.mds +34 -14
- package/src/assets/commands/debug.mds +11 -4
- package/src/assets/commands/dynamic-build.mds +227 -41
- package/src/assets/commands/dynamic-plan.mds +35 -13
- package/src/assets/commands/dynamic-tickets.mds +47 -5
- package/src/assets/commands/implement.mds +206 -52
- package/src/assets/commands/plan.mds +70 -17
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/resolve.mds +126 -56
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +299 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +14 -9
- package/src/assets/scripts/hooks/capture-prompt +6 -2
- package/src/assets/scripts/hooks/capture-question +6 -2
- package/src/assets/scripts/hooks/capture-turn +6 -2
- package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
- package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +223 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
- package/src/assets/scripts/hooks/memory-worker +15 -8
- package/src/assets/scripts/hooks/pre-compact-memory +12 -8
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +68 -24
- package/src/assets/scripts/hooks/session-start-context +355 -8
- package/src/assets/scripts/hooks/session-start-memory +12 -8
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +2 -0
- package/src/assets/skills/docs-framework/SKILL.md +5 -3
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- 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 {
|
|
5
|
-
import { skillsDir,
|
|
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
|
-
*
|
|
264
|
-
*
|
|
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
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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)
|
|
368
|
-
//
|
|
369
|
-
//
|
|
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
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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).
|