devflow-kit 3.10.0 → 3.12.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 +74 -0
- package/dist/agents/architect.md +87 -0
- package/dist/agents/code.md +21 -25
- package/dist/agents/debug.md +21 -49
- package/dist/agents/design.md +9 -43
- package/dist/agents/fix.md +15 -29
- package/dist/agents/git.md +19 -22
- package/dist/agents/harden.md +14 -19
- package/dist/agents/knowledge.md +11 -19
- package/dist/agents/refactor.md +9 -14
- package/dist/agents/research.md +11 -34
- package/dist/agents/review.md +22 -52
- package/dist/agents/skim.md +7 -17
- package/dist/agents/triage.md +7 -14
- package/dist/cli/commands/backup-session.js +131 -0
- package/dist/cli/commands/compliance.js +16 -3
- package/dist/cli/commands/curation.js +12 -4
- package/dist/cli/commands/init.js +36 -55
- package/dist/cli/commands/install-report.js +95 -0
- package/dist/cli/commands/legacy-sweep.js +140 -45
- package/dist/cli/commands/rules.js +50 -13
- package/dist/cli/commands/uninstall.js +62 -24
- package/dist/commands/bug-analysis.md +31 -62
- package/dist/commands/code-review.md +38 -87
- package/dist/commands/debug.md +32 -82
- package/dist/commands/dynamic-build.md +155 -211
- package/dist/commands/dynamic-plan.md +71 -93
- package/dist/commands/dynamic-profile.md +7 -155
- package/dist/commands/dynamic-tickets.md +65 -186
- package/dist/commands/explore.md +27 -70
- package/dist/commands/implement.md +98 -186
- package/dist/commands/plan.md +60 -107
- package/dist/commands/release.md +25 -91
- package/dist/commands/research.md +34 -103
- package/dist/commands/resolve.md +64 -128
- package/dist/commands/self-review.md +35 -99
- package/dist/compliance-off/agents/git.md +15 -18
- package/dist/compliance-off/agents/triage.md +7 -14
- package/dist/compliance-off/commands/bug-analysis.md +31 -62
- package/dist/compliance-off/commands/code-review.md +34 -82
- package/dist/compliance-off/commands/dynamic-build.md +149 -205
- package/dist/compliance-off/commands/dynamic-tickets.md +65 -186
- package/dist/compliance-off/commands/implement.md +94 -180
- package/dist/compliance-off/commands/plan.md +60 -107
- package/dist/compliance-off/commands/release.md +25 -90
- package/dist/compliance-off/commands/resolve.md +54 -128
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +3 -3
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -3
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -3
- package/dist/core/compliance.js +14 -0
- package/dist/core/fs-atomic.js +5 -1
- package/dist/core/orphan-sweep.js +20 -2
- package/dist/core/path-set.js +15 -0
- package/dist/core/plugins.js +6 -5
- package/dist/core/reference-sweep.js +42 -25
- package/dist/core/retired-shipped-names.js +278 -0
- package/dist/curation-compliance-off/agents/triage.md +7 -13
- package/dist/curation-compliance-off/commands/bug-analysis.md +21 -41
- package/dist/curation-compliance-off/commands/code-review.md +25 -62
- package/dist/curation-compliance-off/commands/dynamic-build.md +142 -194
- package/dist/curation-compliance-off/commands/dynamic-tickets.md +54 -173
- package/dist/curation-compliance-off/commands/implement.md +86 -161
- package/dist/curation-compliance-off/commands/plan.md +51 -86
- package/dist/curation-compliance-off/commands/release.md +17 -78
- package/dist/curation-compliance-off/commands/resolve.md +45 -108
- package/dist/curation-off/agents/architect.md +81 -0
- package/dist/curation-off/agents/code.md +20 -23
- package/dist/curation-off/agents/debug.md +19 -47
- package/dist/curation-off/agents/design.md +8 -41
- package/dist/curation-off/agents/fix.md +14 -27
- package/dist/curation-off/agents/harden.md +13 -18
- package/dist/curation-off/agents/knowledge.md +9 -17
- package/dist/curation-off/agents/refactor.md +8 -12
- package/dist/curation-off/agents/research.md +9 -32
- package/dist/curation-off/agents/review.md +11 -40
- package/dist/curation-off/agents/skim.md +7 -17
- package/dist/curation-off/agents/triage.md +7 -13
- package/dist/curation-off/commands/bug-analysis.md +21 -41
- package/dist/curation-off/commands/code-review.md +29 -67
- package/dist/curation-off/commands/debug.md +24 -63
- package/dist/curation-off/commands/dynamic-build.md +148 -200
- package/dist/curation-off/commands/dynamic-plan.md +60 -80
- package/dist/curation-off/commands/dynamic-tickets.md +54 -173
- package/dist/curation-off/commands/explore.md +18 -50
- package/dist/curation-off/commands/implement.md +90 -167
- package/dist/curation-off/commands/plan.md +51 -86
- package/dist/curation-off/commands/release.md +17 -79
- package/dist/curation-off/commands/research.md +23 -81
- package/dist/curation-off/commands/resolve.md +55 -108
- package/dist/curation-off/commands/self-review.md +26 -78
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -1
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +3 -3
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -3
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -3
- package/dist/targets/claude-code/compliance-install.js +38 -13
- package/dist/targets/claude-code/compliance-off-files.js +5 -1
- package/dist/targets/claude-code/curation-install.js +77 -32
- package/dist/targets/claude-code/folder-swap.js +142 -0
- package/dist/targets/claude-code/install-backup.js +780 -0
- package/dist/targets/claude-code/installer.js +383 -192
- package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
- package/dist/targets/claude-code/linked-folder.js +5 -11
- package/dist/targets/claude-code/reference-converge.js +15 -2
- package/dist/targets/claude-code/rules-folder.js +34 -0
- package/dist/targets/claude-code/shipped-set.js +160 -0
- package/dist/targets/claude-code/workflow-folder-install.js +32 -12
- package/dist/variant-axes.json +5 -1
- package/package.json +1 -1
- package/src/assets/agents/_partials/_code.mds +3 -5
- package/src/assets/agents/_partials/_design.mds +86 -0
- package/src/assets/agents/architect.mds +41 -0
- package/src/assets/agents/code.mds +18 -20
- package/src/assets/agents/curate.md +26 -35
- package/src/assets/agents/debug.mds +21 -49
- package/src/assets/agents/design.mds +11 -114
- package/src/assets/agents/evaluate.md +6 -16
- package/src/assets/agents/explore.md +1 -2
- package/src/assets/agents/fix.mds +12 -24
- package/src/assets/agents/git.mds +20 -23
- package/src/assets/agents/harden.mds +14 -19
- package/src/assets/agents/knowledge.mds +10 -38
- package/src/assets/agents/polish.md +13 -39
- package/src/assets/agents/refactor.mds +6 -9
- package/src/assets/agents/research.mds +11 -34
- package/src/assets/agents/review.mds +12 -84
- package/src/assets/agents/skim.mds +7 -17
- package/src/assets/agents/synthesize.md +3 -21
- package/src/assets/agents/test.md +31 -32
- package/src/assets/agents/tracker.md +116 -229
- package/src/assets/agents/triage.mds +7 -26
- package/src/assets/agents/validate.md +1 -3
- package/src/assets/commands/_partials/_compliance.mds +2 -2
- package/src/assets/commands/_partials/_decisions.mds +17 -24
- package/src/assets/commands/_partials/_docs_root.mds +31 -10
- package/src/assets/commands/_partials/_engine.mds +34 -56
- package/src/assets/commands/_partials/_evidence_policy.mds +1 -1
- package/src/assets/commands/_partials/_factory.mds +10 -104
- package/src/assets/commands/_partials/_knowledge.mds +47 -51
- package/src/assets/commands/_partials/_plan_contract.mds +2 -2
- package/src/assets/commands/_partials/_preamble.mds +21 -21
- package/src/assets/commands/_partials/_publication.mds +3 -3
- package/src/assets/commands/_partials/_roster.mds +12 -13
- package/src/assets/commands/_partials/_settings.mds +6 -3
- package/src/assets/commands/_partials/_ticket_template.mds +3 -7
- package/src/assets/commands/_partials/_tracker.mds +10 -5
- package/src/assets/commands/_partials/_wave.mds +23 -32
- package/src/assets/commands/bug-analysis.mds +3 -7
- package/src/assets/commands/code-review.mds +12 -33
- package/src/assets/commands/debug.mds +5 -47
- package/src/assets/commands/dynamic-build.mds +48 -70
- package/src/assets/commands/dynamic-plan.mds +23 -42
- package/src/assets/commands/dynamic-profile.mds +7 -57
- package/src/assets/commands/dynamic-tickets.mds +9 -29
- package/src/assets/commands/explore.mds +5 -44
- package/src/assets/commands/implement.mds +75 -117
- package/src/assets/commands/plan.mds +27 -57
- package/src/assets/commands/release.mds +4 -62
- package/src/assets/commands/research.mds +7 -53
- package/src/assets/commands/resolve.mds +40 -87
- package/src/assets/commands/self-review.mds +5 -45
- package/src/assets/mds/git/_ops_pr.mds +1 -0
- package/src/assets/mds/git/_ops_steps.mds +4 -4
- package/src/assets/mds/git/_pr.mds +1 -1
- package/src/assets/rules/engineering.md +1 -1
- package/src/assets/rules/quality.md +2 -2
- package/src/assets/scripts/ci-wait.cjs +150 -18
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +3 -3
- package/src/assets/scripts/hooks/preamble +2 -3
- package/src/assets/scripts/pr-evidence.cjs +1 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +14 -13
- package/src/assets/scripts/resolve-root.cjs +186 -0
- package/src/assets/scripts/resolve-settings.cjs +14 -9
- package/src/assets/scripts/verify-evidence.cjs +355 -22
- package/src/assets/skills/accessibility/SKILL.md +28 -95
- package/src/assets/skills/agent-references/SKILL.md +2 -4
- package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +1 -1
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +4 -7
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +1 -1
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +2 -2
- package/src/assets/skills/apply-decisions/SKILL.md +5 -6
- package/src/assets/skills/apply-feature-knowledge/SKILL.md +3 -13
- package/src/assets/skills/architecture/SKILL.md +29 -105
- package/src/assets/skills/boundary-validation/SKILL.md +27 -78
- package/src/assets/skills/complexity/SKILL.md +8 -104
- package/src/assets/skills/compliance/SKILL.md +9 -67
- package/src/assets/skills/consistency/SKILL.md +7 -107
- package/src/assets/skills/database/SKILL.md +7 -72
- package/src/assets/skills/dependencies/SKILL.md +9 -109
- package/src/assets/skills/dependencies/references/detection.md +10 -0
- package/src/assets/skills/dependency-research/SKILL.md +9 -63
- package/src/assets/skills/design-review/SKILL.md +1 -35
- package/src/assets/skills/docs-framework/SKILL.md +4 -47
- package/src/assets/skills/documentation/SKILL.md +7 -71
- package/src/assets/skills/documentation/references/patterns.md +1 -1
- package/src/assets/skills/documentation/references/violations.md +1 -1
- package/src/assets/skills/feature-knowledge/SKILL.md +19 -222
- package/src/assets/skills/feature-knowledge/references/new-kb.md +128 -0
- package/src/assets/skills/gap-analysis/SKILL.md +1 -31
- package/src/assets/skills/git/SKILL.md +2 -22
- package/src/assets/skills/go/SKILL.md +24 -113
- package/src/assets/skills/java/SKILL.md +12 -98
- package/src/assets/skills/patterns/SKILL.md +8 -93
- package/src/assets/skills/performance/SKILL.md +29 -88
- package/src/assets/skills/python/SKILL.md +22 -120
- package/src/assets/skills/qa/SKILL.md +16 -54
- package/src/assets/skills/qa/references/browser-testing.md +0 -24
- package/src/assets/skills/qa/references/sources.md +2 -2
- package/src/assets/skills/quality-gates/SKILL.md +13 -100
- package/src/assets/skills/react/SKILL.md +33 -95
- package/src/assets/skills/regression/SKILL.md +8 -73
- package/src/assets/skills/reliability/SKILL.md +11 -120
- package/src/assets/skills/research-codebase/SKILL.md +7 -49
- package/src/assets/skills/research-competitor/SKILL.md +4 -39
- package/src/assets/skills/research-external/SKILL.md +5 -43
- package/src/assets/skills/research-market/SKILL.md +5 -43
- package/src/assets/skills/research-technology/SKILL.md +8 -68
- package/src/assets/skills/review-methodology/SKILL.md +10 -56
- package/src/assets/skills/rust/SKILL.md +19 -109
- package/src/assets/skills/security/SKILL.md +39 -66
- package/src/assets/skills/software-design/SKILL.md +20 -105
- package/src/assets/skills/software-design/references/patterns.md +84 -2
- package/src/assets/skills/software-design/references/violations.md +1 -1
- package/src/assets/skills/test-driven-development/SKILL.md +32 -107
- package/src/assets/skills/test-driven-development/references/rationalization-prevention.md +33 -1
- package/src/assets/skills/testing/SKILL.md +35 -53
- package/src/assets/skills/typescript/SKILL.md +23 -102
- package/src/assets/skills/ui-design/SKILL.md +26 -74
- package/src/assets/skills/worktree-support/SKILL.md +4 -10
- package/dist/curation-off/commands/dynamic-profile.md +0 -214
|
@@ -9,7 +9,8 @@ import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-
|
|
|
9
9
|
import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
|
|
10
10
|
import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
|
|
11
11
|
import { complianceOffCopyExclusions, removeComplianceOffFiles } from './compliance-off-files.js';
|
|
12
|
-
import {
|
|
12
|
+
import { discardDisplacedFolder } from './folder-swap.js';
|
|
13
|
+
import { clearLinkForWrite, directoryState, refuseUnrealFolder, unlinkLinkAt } from './linked-folder.js';
|
|
13
14
|
import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
|
|
14
15
|
import { TRACKER_AGENT_NAME } from './tracker-install.js';
|
|
15
16
|
import { isTrackerStampedAgent } from './tracker-servers.js';
|
|
@@ -218,8 +219,12 @@ export async function copyDirectory(src, dest, exclude, links) {
|
|
|
218
219
|
* {@link composeScripts}, swallows it with the rest of its copy-and-chmod step; that tree
|
|
219
220
|
* is three levels of shipped assets, so a breach there means the package itself grew a
|
|
220
221
|
* shape no walk in this repo expects, and neither call site aborts an install over it.
|
|
222
|
+
*
|
|
223
|
+
* `skip` names the entries the walk leaves alone: a path it accepts keeps its mode, and a folder
|
|
224
|
+
* it accepts is not entered. The reference overlay passes the backup session's `isKept`, so a file
|
|
225
|
+
* of the user that stays because its move failed keeps the mode the user gave it.
|
|
221
226
|
*/
|
|
222
|
-
export async function chmodRecursive(dir, mode, _depth = 0) {
|
|
227
|
+
export async function chmodRecursive(dir, mode, skip, _depth = 0) {
|
|
223
228
|
if (_depth > MAX_REFERENCE_SWEEP_DEPTH) {
|
|
224
229
|
throw new Error(`chmodRecursive: descent into ${dir} exceeds the bound of ` +
|
|
225
230
|
`${MAX_REFERENCE_SWEEP_DEPTH} levels — that subtree keeps the modes it arrived with.`);
|
|
@@ -227,8 +232,10 @@ export async function chmodRecursive(dir, mode, _depth = 0) {
|
|
|
227
232
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
228
233
|
for (const entry of entries) {
|
|
229
234
|
const fullPath = path.join(dir, entry.name);
|
|
235
|
+
if (skip?.(fullPath) === true)
|
|
236
|
+
continue;
|
|
230
237
|
if (entry.isDirectory()) {
|
|
231
|
-
await chmodRecursive(fullPath, mode, _depth + 1);
|
|
238
|
+
await chmodRecursive(fullPath, mode, skip, _depth + 1);
|
|
232
239
|
}
|
|
233
240
|
else if (entry.isFile()) {
|
|
234
241
|
await fs.chmod(fullPath, mode);
|
|
@@ -434,7 +441,7 @@ const STAGING_TOKEN = `${process.pid}-${Date.now().toString(36)}`;
|
|
|
434
441
|
* arm's basename is `{provider}.{token}.tmp`, which is not dot-prefixed, so a rule keyed
|
|
435
442
|
* on the name would reach the other arms only.
|
|
436
443
|
*/
|
|
437
|
-
function stagingDirFor(referencesTarget, unit) {
|
|
444
|
+
export function stagingDirFor(referencesTarget, unit) {
|
|
438
445
|
return `${sidecarBaseFor(referencesTarget, unit)}.${STAGING_TOKEN}.tmp`;
|
|
439
446
|
}
|
|
440
447
|
/**
|
|
@@ -443,7 +450,7 @@ function stagingDirFor(referencesTarget, unit) {
|
|
|
443
450
|
* Beside the staging tree, and under `tracker/` for the same reason (property 2 of
|
|
444
451
|
* {@link stagingDirFor}): a backup a crash strands is removed by a later run's prune.
|
|
445
452
|
*/
|
|
446
|
-
function backupDirFor(referencesTarget, unit) {
|
|
453
|
+
export function backupDirFor(referencesTarget, unit) {
|
|
447
454
|
return `${sidecarBaseFor(referencesTarget, unit)}.old`;
|
|
448
455
|
}
|
|
449
456
|
/**
|
|
@@ -603,6 +610,10 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
|
|
|
603
610
|
* entries the overlay must never replace or delete (D-OVERLAY-FLAT-UNIT), so its
|
|
604
611
|
* files are exactly the comparison and the neighbours are none of its business.
|
|
605
612
|
*
|
|
613
|
+
* A directory unit that {@link unitStaysInPlace} is promoted file by file, like a flat set,
|
|
614
|
+
* so a file of the user's that stays in its folder is no sign that the unit is stale: its
|
|
615
|
+
* files are exactly the comparison there too.
|
|
616
|
+
*
|
|
606
617
|
* What it deliberately does NOT compare is file MODE. D-OVERLAY-MODE-SCOPE normalises the
|
|
607
618
|
* whole references directory on every run regardless of which units promoted, so a unit
|
|
608
619
|
* skipped here still has its modes converged — a skipped promotion can hide byte drift
|
|
@@ -612,9 +623,9 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
|
|
|
612
623
|
* — answers "no". The fallback is always the promotion that was going to happen anyway,
|
|
613
624
|
* so a failure to compare can only cost a write, never correctness.
|
|
614
625
|
*/
|
|
615
|
-
async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir) {
|
|
626
|
+
async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir, backups) {
|
|
616
627
|
const basenameOf = (relPath) => relPath.split('/').slice(-1)[0];
|
|
617
|
-
if (unit.kind !== 'cross-cutting') {
|
|
628
|
+
if (unit.kind !== 'cross-cutting' && !unitStaysInPlace(backups, underRoot(referencesTarget, unitSubdir(unit)))) {
|
|
618
629
|
const owned = new Set(unit.files.map(basenameOf));
|
|
619
630
|
let installed;
|
|
620
631
|
try {
|
|
@@ -659,6 +670,50 @@ async function restoreDisplacedUnit(backup, target) {
|
|
|
659
670
|
return { ok: false, error: String(err) };
|
|
660
671
|
}
|
|
661
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Does a directory unit's folder hold an entry that must stay where it is?
|
|
675
|
+
*
|
|
676
|
+
* True when the backup session kept an entry at or below the folder (a move that failed, a
|
|
677
|
+
* folder it could not scan), or kept the folder or a folder above it (one over the entry
|
|
678
|
+
* cap, or a root it refused). Such a folder is never swapped out: a swap sets the whole
|
|
679
|
+
* folder aside, and what the session kept in it would go with it. The unit is written into
|
|
680
|
+
* the folder file by file instead, and the prune that follows leaves the kept entries alone.
|
|
681
|
+
*/
|
|
682
|
+
function unitStaysInPlace(backups, unitDir) {
|
|
683
|
+
return backups.isKept(unitDir) || backups.keptUnder(unitDir).length > 0;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Rename the documents a staging tree holds into `destDir`, one `rename` each, and drop the
|
|
687
|
+
* staging tree. Shared by the two promotions that write into a folder that stays in place.
|
|
688
|
+
*
|
|
689
|
+
* The directory is created rather than assumed — {@link promoteDirectoryUnit} does the same
|
|
690
|
+
* for its target's parent. The two flat directories the registry emits today exist by the time
|
|
691
|
+
* promotion runs for reasons that have nothing to do with the unit landing in them: the
|
|
692
|
+
* references root is created by {@link overlayGeneratedReferences}, and `tracker/` as a side
|
|
693
|
+
* effect of the flat arm's own staging path. A flat set landing anywhere else takes ENOENT on
|
|
694
|
+
* its first rename — a unit reported as failed for where it was asked to land rather than for
|
|
695
|
+
* anything wrong with the documents it carries.
|
|
696
|
+
*
|
|
697
|
+
* Past the first rename the set is mixed, and there is no directory to swap back
|
|
698
|
+
* (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the rest still
|
|
699
|
+
* carry the previous install's. The state is recorded after each rename so a failure on the
|
|
700
|
+
* next one names both halves instead of claiming the set is untouched.
|
|
701
|
+
*
|
|
702
|
+
* Throws on the first failing rename; the caller reports the state recorded by then.
|
|
703
|
+
*/
|
|
704
|
+
async function renameDocumentsInto(files, destDir, stagingDir, record) {
|
|
705
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
706
|
+
for (const [index, relPath] of files.entries()) {
|
|
707
|
+
const basename = relPath.split('/').slice(-1)[0];
|
|
708
|
+
await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
|
|
709
|
+
record({
|
|
710
|
+
kind: 'partially-refreshed',
|
|
711
|
+
refreshed: files.slice(0, index + 1),
|
|
712
|
+
stale: files.slice(index + 1),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
716
|
+
}
|
|
662
717
|
/**
|
|
663
718
|
* Promote the flat cross-cutting set — one `rename` per document.
|
|
664
719
|
*
|
|
@@ -673,29 +728,7 @@ async function restoreDisplacedUnit(backup, target) {
|
|
|
673
728
|
* Throws on the first failing rename; the caller reports the state recorded by then.
|
|
674
729
|
*/
|
|
675
730
|
async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record) {
|
|
676
|
-
|
|
677
|
-
// The directory this set lands in, created rather than assumed — {@link promoteDirectoryUnit}
|
|
678
|
-
// does the same for its target's parent. The two flat directories the registry emits today
|
|
679
|
-
// exist by the time promotion runs for reasons that have nothing to do with the unit landing
|
|
680
|
-
// in them: the references root is created by {@link overlayGeneratedReferences}, and
|
|
681
|
-
// `tracker/` as a side effect of this arm's own staging path. A flat set landing anywhere
|
|
682
|
-
// else takes ENOENT on its first rename — a unit reported as failed for where it was asked
|
|
683
|
-
// to land rather than for anything wrong with the documents it carries.
|
|
684
|
-
await fs.mkdir(destDir, { recursive: true });
|
|
685
|
-
for (const [index, relPath] of unit.files.entries()) {
|
|
686
|
-
const basename = relPath.split('/').slice(-1)[0];
|
|
687
|
-
await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
|
|
688
|
-
// Past the first rename the set is mixed, and there is no directory to swap back
|
|
689
|
-
// (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the
|
|
690
|
-
// rest still carry the previous install's. Recorded after each rename so a failure
|
|
691
|
-
// on the next one names both halves instead of claiming the set is untouched.
|
|
692
|
-
record({
|
|
693
|
-
kind: 'partially-refreshed',
|
|
694
|
-
refreshed: unit.files.slice(0, index + 1),
|
|
695
|
-
stale: unit.files.slice(index + 1),
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
731
|
+
await renameDocumentsInto(unit.files, underRoot(referencesTarget, unit.dir), stagingDir, record);
|
|
699
732
|
}
|
|
700
733
|
/**
|
|
701
734
|
* Promote a directory unit (a provider, or `pr/`) — swapped whole, or not at all.
|
|
@@ -705,9 +738,17 @@ async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, recor
|
|
|
705
738
|
* either entirely the previous install or entirely the new one (DR-05, risk P2-g), and a
|
|
706
739
|
* rename that fails half-way restores the previous one rather than leaving it empty.
|
|
707
740
|
*
|
|
741
|
+
* A folder that holds an entry which must stay where it is ({@link unitStaysInPlace}), or
|
|
742
|
+
* that cannot be set aside because an earlier `.old` folder still stands beside it, is not
|
|
743
|
+
* swapped: the unit's files are renamed into it one by one, as a flat set is
|
|
744
|
+
* ({@link renameDocumentsInto}), and the prune that follows converges the rest. Nothing here
|
|
745
|
+
* removes a folder with a recursive `rm`: a displaced folder goes through the backup
|
|
746
|
+
* session, which moves what devflow did not ship, removes what it shipped by the list the
|
|
747
|
+
* scan made and ends with a plain `rmdir` ({@link discardDisplacedFolder}).
|
|
748
|
+
*
|
|
708
749
|
* Throws once the state it left has been recorded; the caller reports it.
|
|
709
750
|
*/
|
|
710
|
-
async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record) {
|
|
751
|
+
async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups) {
|
|
711
752
|
const target = underRoot(referencesTarget, unitSubdir(unit));
|
|
712
753
|
const backup = backupDirFor(referencesTarget, unit);
|
|
713
754
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -724,8 +765,15 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
724
765
|
// either is converged away by a later run's tracker-subtree prune (both names end
|
|
725
766
|
// in neither `/` nor `.md`, so no manifest entry can collide with them) — but the
|
|
726
767
|
// backup this run is still relying on is exempt from this run's prune, which is
|
|
727
|
-
// what `restore-failed` carries the recovery path for.
|
|
728
|
-
|
|
768
|
+
// what `restore-failed` carries the recovery path for. A file of the user's in a
|
|
769
|
+
// stranded `.old` moves to the backup folder first; one that cannot move keeps the
|
|
770
|
+
// folder standing.
|
|
771
|
+
await discardDisplacedFolder(backups, backup, target);
|
|
772
|
+
if ((await directoryState(target)) === 'real'
|
|
773
|
+
&& (unitStaysInPlace(backups, target) || (await directoryState(backup)) !== 'absent')) {
|
|
774
|
+
await renameDocumentsInto(unit.files, target, stagingDir, record);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
729
777
|
let displaced = false;
|
|
730
778
|
try {
|
|
731
779
|
await fs.rename(target, backup);
|
|
@@ -750,7 +798,8 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
750
798
|
}
|
|
751
799
|
throw err;
|
|
752
800
|
}
|
|
753
|
-
|
|
801
|
+
if (displaced)
|
|
802
|
+
await discardDisplacedFolder(backups, backup, target).catch(() => undefined);
|
|
754
803
|
}
|
|
755
804
|
/**
|
|
756
805
|
* Promote a fully built staging tree into place, dispatched on what kind of unit it is.
|
|
@@ -773,7 +822,7 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
773
822
|
* that failure selects (previous unit restored, not deleted) is exactly the one worth
|
|
774
823
|
* pinning.
|
|
775
824
|
*/
|
|
776
|
-
export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir) {
|
|
825
|
+
export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir, backups) {
|
|
777
826
|
let state = { kind: 'installed-unchanged' };
|
|
778
827
|
const record = next => { state = next; };
|
|
779
828
|
try {
|
|
@@ -783,7 +832,7 @@ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir)
|
|
|
783
832
|
break;
|
|
784
833
|
case 'provider':
|
|
785
834
|
case 'pr-host':
|
|
786
|
-
await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record);
|
|
835
|
+
await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups);
|
|
787
836
|
break;
|
|
788
837
|
default: {
|
|
789
838
|
const _exhaustive = unit;
|
|
@@ -900,7 +949,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
|
|
|
900
949
|
* coordinate system as the manifest that decided them and as the stranded-skip failure
|
|
901
950
|
* below, which has always named its subtree from the root.
|
|
902
951
|
*/
|
|
903
|
-
async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures) {
|
|
952
|
+
async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups) {
|
|
904
953
|
const subtreeRoot = underRoot(referencesTarget, subtree);
|
|
905
954
|
const prefix = `${subtree}/`;
|
|
906
955
|
// Checked here rather than trusted from the unit loop: this is the call that follows
|
|
@@ -933,7 +982,7 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
|
|
|
933
982
|
}
|
|
934
983
|
// Keyed by relative path, because `tracker/{provider}/{op}.md` is what distinguishes
|
|
935
984
|
// two providers' identically named files — the reason mdEntryName cannot serve here.
|
|
936
|
-
const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))));
|
|
985
|
+
const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))), referenceSweepDelegate(backups));
|
|
937
986
|
return {
|
|
938
987
|
scanned: swept.scanned,
|
|
939
988
|
removed: swept.removed.map(rel => `${prefix}${rel}`),
|
|
@@ -953,12 +1002,12 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
|
|
|
953
1002
|
* `scanned` sums, which keeps it the non-vacuity counter it is everywhere else: a run
|
|
954
1003
|
* that scanned nothing scanned nothing in any subtree.
|
|
955
1004
|
*/
|
|
956
|
-
async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures) {
|
|
1005
|
+
async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures, backups) {
|
|
957
1006
|
let scanned = 0;
|
|
958
1007
|
const removed = [];
|
|
959
1008
|
const failed = [];
|
|
960
1009
|
for (const subtree of CONVERGED_SUBTREES) {
|
|
961
|
-
const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures);
|
|
1010
|
+
const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups);
|
|
962
1011
|
scanned += swept.scanned;
|
|
963
1012
|
removed.push(...swept.removed);
|
|
964
1013
|
failed.push(...swept.failed);
|
|
@@ -1039,6 +1088,10 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1039
1088
|
if (fault !== null)
|
|
1040
1089
|
rootFaults.set(subtree, fault);
|
|
1041
1090
|
}
|
|
1091
|
+
// A unit swap deletes the folder it displaces, and the prune below sweeps what the manifest does
|
|
1092
|
+
// not name. What the user put in a converged subtree therefore moves aside first, before either.
|
|
1093
|
+
// A root that is not a real folder is not scanned; the unit refusal and the prune skip it too.
|
|
1094
|
+
await opts.backups.prepass(CONVERGED_SUBTREES.filter(subtree => !rootFaults.has(subtree)).map(subtree => underRoot(opts.referencesTarget, subtree)));
|
|
1042
1095
|
for (const unit of planOverlayUnits(manifest)) {
|
|
1043
1096
|
const fault = subtreesTouchedBy(opts.referencesTarget, unit)
|
|
1044
1097
|
.map(subtree => rootFaults.get(subtree))
|
|
@@ -1064,19 +1117,19 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1064
1117
|
// installed, so `overlaidRefs` names what this run WROTE (AC-23). The discard is the
|
|
1065
1118
|
// same one both promotion halves perform on their way out; skipping it would leave
|
|
1066
1119
|
// the `.tmp` residue every other path is asserted not to leave.
|
|
1067
|
-
if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir)) {
|
|
1120
|
+
if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir, opts.backups)) {
|
|
1068
1121
|
await fs.rm(built.stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1069
1122
|
unchangedRefs.push(...unit.files);
|
|
1070
1123
|
continue;
|
|
1071
1124
|
}
|
|
1072
|
-
const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir);
|
|
1125
|
+
const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir, opts.backups);
|
|
1073
1126
|
if (!promoted.ok) {
|
|
1074
1127
|
overlayFailures.push({ unit: unitRef(unit), state: promoted.state, error: promoted.error });
|
|
1075
1128
|
continue;
|
|
1076
1129
|
}
|
|
1077
1130
|
overlaidRefs.push(...unit.files);
|
|
1078
1131
|
}
|
|
1079
|
-
const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures);
|
|
1132
|
+
const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures, opts.backups);
|
|
1080
1133
|
// D-OVERLAY-MODE-SCOPE: normalise the WHOLE references directory, not only the files
|
|
1081
1134
|
// this run installed. copyDirectory preserves source modes, so a hand-authored
|
|
1082
1135
|
// reference checked in with an odd mode installs with it; a reference is read-only
|
|
@@ -1087,12 +1140,13 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1087
1140
|
// boundary is stated as "never replace or delete" rather than "never touch": the MODE of
|
|
1088
1141
|
// a hand-authored reference — and of whatever a shadowed skill supplied outside
|
|
1089
1142
|
// `tracker/` — is normalised here. The prove-you-wrote-it rule permits exactly that: the
|
|
1090
|
-
// ownership guard protects deletion, not overwrite.
|
|
1143
|
+
// ownership guard protects deletion, not overwrite. A file of the user that the backup session
|
|
1144
|
+
// kept in place (its move failed) is the exception: its mode stays as the user set it.
|
|
1091
1145
|
//
|
|
1092
1146
|
// It is also the one walk that can breach chmodRecursive's descent bound. The catch is
|
|
1093
1147
|
// that breach's reporting channel, not just an I/O guard (see {@link chmodRecursive}).
|
|
1094
1148
|
try {
|
|
1095
|
-
await chmodRecursive(opts.referencesTarget, 0o644);
|
|
1149
|
+
await chmodRecursive(opts.referencesTarget, 0o644, entry => opts.backups.isKept(entry));
|
|
1096
1150
|
}
|
|
1097
1151
|
catch (err) {
|
|
1098
1152
|
warn(`reference overlay: could not normalise reference file modes — ${String(err)}`);
|
|
@@ -1186,77 +1240,6 @@ export function overlayOwnedSkillPaths(manifest) {
|
|
|
1186
1240
|
}
|
|
1187
1241
|
return owned;
|
|
1188
1242
|
}
|
|
1189
|
-
/**
|
|
1190
|
-
* Empty a directory of everything but the paths another converger owns.
|
|
1191
|
-
*
|
|
1192
|
-
* `fs.rm(dir)` with a hole in it. `keep` holds directory-relative paths, each preserved
|
|
1193
|
-
* whole — a file as itself, a directory with its entire subtree. Everything else is
|
|
1194
|
-
* removed exactly as the unconditional pre-clean would have removed it.
|
|
1195
|
-
*
|
|
1196
|
-
* A kept path is preserved only as a real file or directory. A symlink planted at one is
|
|
1197
|
-
* removed like anything else (the link, never its target): the converger that owns the
|
|
1198
|
-
* path writes and prunes through it, so a link left standing here would hand that
|
|
1199
|
-
* converger someone else's directory (D-CONVERGED-ROOT-REAL). `Dirent` types are
|
|
1200
|
-
* lstat-based, which is what makes the check see the link rather than its target.
|
|
1201
|
-
*
|
|
1202
|
-
* Descent is bounded, and the bound is the `keep` set's own deepest path rather than a
|
|
1203
|
-
* constant: the walk only ever descends INTO a directory that still has a kept
|
|
1204
|
-
* descendant below it, so there is nothing to look for past that depth. A `keep` set of
|
|
1205
|
-
* depth 2 — which is what {@link overlayOwnedSkillPaths} produces for every manifest the
|
|
1206
|
-
* build emits — walks two levels and `fs.rm`s the rest recursively in one call.
|
|
1207
|
-
*
|
|
1208
|
-
* An unreadable directory is left alone rather than reported: the caller already
|
|
1209
|
-
* swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
|
|
1210
|
-
* read its target has nothing to remove from it.
|
|
1211
|
-
*
|
|
1212
|
-
* The walk never goes through a link. `readdir` follows one, so a link at `dir` itself is
|
|
1213
|
-
* unlinked and nothing is read from it, and below `dir` only a `Dirent` that is a real
|
|
1214
|
-
* directory is entered. The folder a link points to is left as it is. A link that stands where
|
|
1215
|
-
* the pre-clean expects its own folder or file (`dir`, or a path in `keep` or above one) is
|
|
1216
|
-
* returned, so the caller reports it; any other link is removed like the file it stands for.
|
|
1217
|
-
* A link at `dir` that cannot be removed is left in place and nothing is emptied.
|
|
1218
|
-
*
|
|
1219
|
-
* A link at a kept path or above one is returned as `awaitingWrite`, not as replaced: the
|
|
1220
|
-
* converger that owns the path has not written there yet, and a summary that says a real file or
|
|
1221
|
-
* folder stands where the link stood is true only after it has ({@link reportLinksOnceWritten}).
|
|
1222
|
-
* `leaveKeptLinks` is for a kept path whose converger replaces a link itself and reports it, never
|
|
1223
|
-
* writing through it (the tracker agent file): the link then stays for that converger.
|
|
1224
|
-
*
|
|
1225
|
-
* @returns The links at `dir` that this replaced, and the links it removed that a later write replaces.
|
|
1226
|
-
*/
|
|
1227
|
-
async function emptyDirectoryExcept(dir, keep, options = {}) {
|
|
1228
|
-
const rootLink = await unlinkLinkAt(dir);
|
|
1229
|
-
if (rootLink !== 'no-link')
|
|
1230
|
-
return { replacedLinks: rootLink === 'unlinked' ? [dir] : [], awaitingWrite: [] };
|
|
1231
|
-
const kept = [...keep];
|
|
1232
|
-
const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
|
|
1233
|
-
const awaitingWrite = [];
|
|
1234
|
-
const walk = async (current, rel, depth) => {
|
|
1235
|
-
let entries;
|
|
1236
|
-
try {
|
|
1237
|
-
entries = await fs.readdir(current, { withFileTypes: true });
|
|
1238
|
-
}
|
|
1239
|
-
catch {
|
|
1240
|
-
return;
|
|
1241
|
-
}
|
|
1242
|
-
for (const entry of entries) {
|
|
1243
|
-
const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
1244
|
-
if (keep.has(entryRel) && (!entry.isSymbolicLink() || options.leaveKeptLinks === true))
|
|
1245
|
-
continue;
|
|
1246
|
-
const entryPath = path.join(current, entry.name);
|
|
1247
|
-
const holdsSomethingKept = kept.some(relPath => relPath.startsWith(`${entryRel}/`));
|
|
1248
|
-
if (entry.isDirectory() && depth < maxDepth && holdsSomethingKept) {
|
|
1249
|
-
await walk(entryPath, entryRel, depth + 1);
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
await fs.rm(entryPath, { recursive: true, force: true });
|
|
1253
|
-
if (entry.isSymbolicLink() && (holdsSomethingKept || keep.has(entryRel)))
|
|
1254
|
-
awaitingWrite.push(entryPath);
|
|
1255
|
-
}
|
|
1256
|
-
};
|
|
1257
|
-
await walk(dir, '', 1);
|
|
1258
|
-
return { replacedLinks: [], awaitingWrite };
|
|
1259
|
-
}
|
|
1260
1243
|
/**
|
|
1261
1244
|
* Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
|
|
1262
1245
|
* path. A path that is still absent, or a link again, is not a replacement and is not reported: the
|
|
@@ -1540,30 +1523,176 @@ function recordSweep(report, kind, sweep) {
|
|
|
1540
1523
|
* report which of them were actually there to remove.
|
|
1541
1524
|
*
|
|
1542
1525
|
* `names` is registry arithmetic (the skills no selected plugin owns), so most of
|
|
1543
|
-
* it is routinely absent from disk. Only the deleter can say what it deleted:
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
*
|
|
1526
|
+
* it is routinely absent from disk. Only the deleter can say what it deleted: an
|
|
1527
|
+
* absent folder is "nothing to remove", neither a removal nor a failure. Reporting
|
|
1528
|
+
* the plan instead would tell the user about deletions that never happened.
|
|
1529
|
+
*
|
|
1530
|
+
* A folder goes by the entries the backup session listed, then a plain `rmdir`. The
|
|
1531
|
+
* pre-pass has moved every file devflow did not ship, so a folder that still holds
|
|
1532
|
+
* an entry (a move that failed) stays whole and is not reported as removed. A link
|
|
1533
|
+
* at the folder is unlinked and its target is left as it is; a path that is not a
|
|
1534
|
+
* folder is a failure and stays.
|
|
1535
|
+
*
|
|
1536
|
+
* Per-item failure isolation, never throws: a failure is recorded in `failed`
|
|
1537
|
+
* and the remaining names are still attempted.
|
|
1550
1538
|
*/
|
|
1551
|
-
async function removeDeselectedSkills(skillsDir, names) {
|
|
1539
|
+
async function removeDeselectedSkills(skillsDir, names, backups) {
|
|
1552
1540
|
const removed = [];
|
|
1553
1541
|
const failed = [];
|
|
1554
1542
|
for (const name of names) {
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1543
|
+
const folder = path.join(skillsDir, prefixSkillName(name));
|
|
1544
|
+
const state = await directoryState(folder);
|
|
1545
|
+
switch (state) {
|
|
1546
|
+
case 'absent':
|
|
1547
|
+
break;
|
|
1548
|
+
case 'link':
|
|
1549
|
+
if ((await unlinkLinkAt(folder)) === 'unlinked')
|
|
1550
|
+
removed.push(name);
|
|
1551
|
+
else
|
|
1552
|
+
failed.push({ name, error: new Error('a link that could not be removed') });
|
|
1553
|
+
break;
|
|
1554
|
+
case 'unusable':
|
|
1555
|
+
failed.push({ name, error: new Error('not a folder, so nothing was removed') });
|
|
1556
|
+
break;
|
|
1557
|
+
case 'real': {
|
|
1558
|
+
const result = await backups.removeListed(folder);
|
|
1559
|
+
if (result.folderRemoved)
|
|
1560
|
+
removed.push(name);
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
default: {
|
|
1564
|
+
const unreachable = state;
|
|
1565
|
+
void unreachable;
|
|
1566
|
+
}
|
|
1563
1567
|
}
|
|
1564
1568
|
}
|
|
1565
1569
|
return { removed, failed };
|
|
1566
1570
|
}
|
|
1571
|
+
/** The kind of the entry at `entryPath`, read with `lstat` so a link is a link; null when it cannot be read. */
|
|
1572
|
+
async function entryKindAt(entryPath) {
|
|
1573
|
+
try {
|
|
1574
|
+
const stat = await fs.lstat(entryPath);
|
|
1575
|
+
if (stat.isSymbolicLink())
|
|
1576
|
+
return 'link';
|
|
1577
|
+
if (stat.isDirectory())
|
|
1578
|
+
return 'directory';
|
|
1579
|
+
return stat.isFile() ? 'file' : 'special';
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* The `mayRemove` check of a flat registry-diff sweep. It admits a file or link that the backup
|
|
1587
|
+
* session classifies as devflow's own (a retired or superseded file, a leftover of an interrupted
|
|
1588
|
+
* write) and nothing else. An unknown entry was moved by the pre-pass or stays where it is, and a
|
|
1589
|
+
* folder is never admitted: the sweep removes what this check admits with `unlink`, so an entry
|
|
1590
|
+
* that turns into a folder after the check is not removed. An entry the session kept, or one in a
|
|
1591
|
+
* folder it kept (over the entry cap, or one it could not scan), is not admitted either: the run
|
|
1592
|
+
* said it would leave that folder whole.
|
|
1593
|
+
*/
|
|
1594
|
+
function removesOnlyDevflowFiles(backups) {
|
|
1595
|
+
return async (entryPath) => {
|
|
1596
|
+
if (backups.isKept(entryPath))
|
|
1597
|
+
return false;
|
|
1598
|
+
const kind = await entryKindAt(entryPath);
|
|
1599
|
+
if (kind === null || kind === 'directory')
|
|
1600
|
+
return false;
|
|
1601
|
+
return backups.classifyAt(entryPath, kind) !== 'unknown';
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* The delegate the reference prune acts through: it decides, per entry the manifest does not name,
|
|
1606
|
+
* between keeping a file of the user's and removing one of devflow's own.
|
|
1607
|
+
*
|
|
1608
|
+
* An unknown entry moves whole to the backup folder, with one rename. An entry the session
|
|
1609
|
+
* classifies as devflow's own (a retired or superseded reference, a leftover of an interrupted
|
|
1610
|
+
* write) is removed: a file or link by `unlink`, a folder by the files the session lists and a plain
|
|
1611
|
+
* `rmdir`, after its unknown entries moved. A folder is never removed recursively. A failed move is
|
|
1612
|
+
* in the session's record and the entry stays; a failed unlink reaches the sweep as a failure.
|
|
1613
|
+
*
|
|
1614
|
+
* An entry the session kept stays as it is, with no second attempt: a move that failed earlier in
|
|
1615
|
+
* the run, or a folder over the entry cap or one the scan could not read, and everything in it.
|
|
1616
|
+
*/
|
|
1617
|
+
export function referenceSweepDelegate(backups) {
|
|
1618
|
+
return {
|
|
1619
|
+
async dispose(entry, isDirectory) {
|
|
1620
|
+
if (backups.isKept(entry))
|
|
1621
|
+
return { outcome: 'kept' };
|
|
1622
|
+
const kind = isDirectory ? 'directory' : (await entryKindAt(entry)) ?? 'file';
|
|
1623
|
+
if (backups.classifyAt(entry, kind) === 'unknown') {
|
|
1624
|
+
const moved = await backups.moveWhole(entry);
|
|
1625
|
+
return { outcome: moved.status === 'moved' ? 'moved' : 'kept' };
|
|
1626
|
+
}
|
|
1627
|
+
if (kind === 'directory') {
|
|
1628
|
+
await backups.prepass([entry]);
|
|
1629
|
+
const removal = await backups.removeListed(entry);
|
|
1630
|
+
return { outcome: removal.folderRemoved ? 'removed' : 'kept' };
|
|
1631
|
+
}
|
|
1632
|
+
try {
|
|
1633
|
+
await fs.unlink(entry);
|
|
1634
|
+
return { outcome: 'removed' };
|
|
1635
|
+
}
|
|
1636
|
+
catch (err) {
|
|
1637
|
+
if (err.code === 'ENOENT')
|
|
1638
|
+
return { outcome: 'kept' };
|
|
1639
|
+
return { outcome: 'failed', error: err };
|
|
1640
|
+
}
|
|
1641
|
+
},
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Whether the run may move and remove below `root`: it is a real folder. An absent root has nothing
|
|
1646
|
+
* to scan. A link at a root the pre-clean replaces (`replaceLink`) is unlinked and reported, as the
|
|
1647
|
+
* pre-clean always did, and its target is not walked. A link that stays, or a path that is not a
|
|
1648
|
+
* folder, is a refused root: no move and no removal below it, and `refuseUnrealFolder` names it
|
|
1649
|
+
* later in the run.
|
|
1650
|
+
*/
|
|
1651
|
+
async function mayChangeBelow(root, report, replaceLink) {
|
|
1652
|
+
const state = await directoryState(root);
|
|
1653
|
+
if (state === 'real')
|
|
1654
|
+
return true;
|
|
1655
|
+
if (state === 'link' && replaceLink && (await unlinkLinkAt(root)) === 'unlinked')
|
|
1656
|
+
report.replacedLinks.push(root);
|
|
1657
|
+
return false;
|
|
1658
|
+
}
|
|
1659
|
+
/** Whether `relative` (a path below a skill folder) is an overlay-owned path or lies below one. */
|
|
1660
|
+
function isUnderOverlayOwnedPath(relative, owned) {
|
|
1661
|
+
for (const ownedPath of owned) {
|
|
1662
|
+
if (relative === ownedPath || relative.startsWith(`${ownedPath}/`))
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Whether the top-level entry that `relative` starts with is a `devflow:*` skill folder outside the
|
|
1669
|
+
* registry: the folders the skill registry-diff sweep would remove.
|
|
1670
|
+
*/
|
|
1671
|
+
function isUnregisteredSkillFolder(relative, registry) {
|
|
1672
|
+
const top = relative.split('/', 1)[0];
|
|
1673
|
+
return top.startsWith(SKILL_NAMESPACE) && !registry.has(unprefixSkillName(top));
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Unlink a link that stands at an overlay-owned path or above one, inside the references skill
|
|
1677
|
+
* `skillDir`. The overlay writes and prunes through those paths, so a link left there would hand it
|
|
1678
|
+
* another folder. Each unlinked link is returned for {@link reportLinksOnceWritten}, since it is
|
|
1679
|
+
* replaced only once the overlay has written a real entry. Bounded by the owned set.
|
|
1680
|
+
*/
|
|
1681
|
+
async function unlinkOverlayOwnedLinks(skillDir, owned) {
|
|
1682
|
+
const paths = new Set();
|
|
1683
|
+
for (const rel of owned) {
|
|
1684
|
+
const segments = rel.split('/');
|
|
1685
|
+
for (let depth = 1; depth <= segments.length; depth++)
|
|
1686
|
+
paths.add(segments.slice(0, depth).join('/'));
|
|
1687
|
+
}
|
|
1688
|
+
const unlinked = [];
|
|
1689
|
+
for (const rel of [...paths].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : 1))) {
|
|
1690
|
+
const entry = path.join(skillDir, ...rel.split('/'));
|
|
1691
|
+
if ((await unlinkLinkAt(entry)) === 'unlinked')
|
|
1692
|
+
unlinked.push(entry);
|
|
1693
|
+
}
|
|
1694
|
+
return unlinked;
|
|
1695
|
+
}
|
|
1567
1696
|
/** The most variant files a refusal names; the count says how many more there are. */
|
|
1568
1697
|
const MAX_REPORTED_MISSING_VARIANTS = 5;
|
|
1569
1698
|
/** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
|
|
@@ -1773,7 +1902,7 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1773
1902
|
* Returns an InstallReport describing which shadows were applied and which were skipped.
|
|
1774
1903
|
*/
|
|
1775
1904
|
export async function installViaFileCopy(options) {
|
|
1776
|
-
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, } = options;
|
|
1905
|
+
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
|
|
1777
1906
|
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
1778
1907
|
// D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
|
|
1779
1908
|
// empty, and with it every folder step below is a no-op over an empty set.
|
|
@@ -1828,6 +1957,7 @@ export async function installViaFileCopy(options) {
|
|
|
1828
1957
|
refusedFolders: [],
|
|
1829
1958
|
removedSkills: [],
|
|
1830
1959
|
dormantShadows: [],
|
|
1960
|
+
backups: backups.snapshot(),
|
|
1831
1961
|
};
|
|
1832
1962
|
// The skill decision, made in full before anything is touched — which skills
|
|
1833
1963
|
// this selection installs, which it removes, and which shadows it leaves inert.
|
|
@@ -1855,50 +1985,107 @@ export async function installViaFileCopy(options) {
|
|
|
1855
1985
|
// Links the pre-clean removed at a path that a later step of this install writes. Each is reported
|
|
1856
1986
|
// as replaced only once a real file or folder stands there (reportLinksOnceWritten).
|
|
1857
1987
|
const awaitingWrite = [];
|
|
1858
|
-
|
|
1859
|
-
|
|
1988
|
+
const skillsRoot = path.join(claudeDir, 'skills');
|
|
1989
|
+
const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
|
|
1990
|
+
const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
|
|
1991
|
+
const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
|
|
1992
|
+
const skillFolder = (name) => path.join(skillsRoot, prefixSkillName(name));
|
|
1993
|
+
const registrySkills = new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]);
|
|
1994
|
+
const overlayOwned = overlayOwnedSkillPaths(refsManifest);
|
|
1995
|
+
const trackerAgentFile = mdFileName(TRACKER_AGENT_NAME);
|
|
1996
|
+
// Every root this run would change is checked first, with the same link checks the install uses
|
|
1997
|
+
// for its writes, and before the first move or removal. A refused root (a link that stays, or a
|
|
1998
|
+
// path that is not a folder) and everything below it get no move and no removal; the other roots
|
|
1999
|
+
// go on. A full install replaces a link at a root, as its pre-clean always did, and does not walk
|
|
2000
|
+
// the folder the link points to.
|
|
2001
|
+
const wipedFolders = [];
|
|
2002
|
+
const prepassTargets = [];
|
|
2003
|
+
const skillsRootState = await directoryState(skillsRoot);
|
|
2004
|
+
const mayChangeSkills = skillsRootState === 'real';
|
|
2005
|
+
// A skills root that is a link or not a folder is refused once, for every step below it: the
|
|
2006
|
+
// pre-pass, the sweeps, the skill copies and the overlay, the exact-name removals and the
|
|
2007
|
+
// replacement of a link at a workflow folder. Each of them would move, remove or write inside the
|
|
2008
|
+
// folder the link points to. The refusal is named in `refusedFolders`, and the install summary says
|
|
2009
|
+
// that nothing was written there. An absent root is fine: the skill copies create it.
|
|
2010
|
+
const skillsRefused = skillsRootState === 'link' || skillsRootState === 'unusable';
|
|
2011
|
+
if (skillsRefused)
|
|
2012
|
+
report.refusedFolders.push(skillsRoot);
|
|
1860
2013
|
if (!isPartialInstall) {
|
|
1861
2014
|
// Commands and agents are plugin-scoped — only wipe on full install.
|
|
1862
2015
|
// On partial installs the registry-diff sweeps below handle stale entries
|
|
1863
2016
|
// without discarding assets from plugins not included in this run.
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
try {
|
|
1870
|
-
report.replacedLinks.push(...await removeReportingLink(dir));
|
|
1871
|
-
}
|
|
1872
|
-
catch { /* ignore */ }
|
|
2017
|
+
for (const dir of [commandsTarget, rulesTarget]) {
|
|
2018
|
+
if (!(await mayChangeBelow(dir, report, true)))
|
|
2019
|
+
continue;
|
|
2020
|
+
prepassTargets.push({ folder: dir });
|
|
2021
|
+
wipedFolders.push(dir);
|
|
1873
2022
|
}
|
|
1874
2023
|
// D-TRACKER-AGENT-OWNER, pre-clean half — the same split the reference tree
|
|
1875
2024
|
// needed (D-OVERLAY-OWNERSHIP), for the same reason. The agent directory is
|
|
1876
2025
|
// emptied AROUND the one file `convergeTrackerArtifacts` owns: taking it
|
|
1877
2026
|
// would leave converge with nothing to byte-compare against, so a
|
|
1878
2027
|
// steady-state re-init would re-copy the agent and announce
|
|
1879
|
-
// `tracker agent installed` on every run.
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
1882
|
-
// nothing.
|
|
2028
|
+
// `tracker agent installed` on every run. The scan leaves that file out, and
|
|
2029
|
+
// the file is still converged on this run — drift in it is restored, so
|
|
2030
|
+
// preserving it strands nothing.
|
|
1883
2031
|
//
|
|
1884
2032
|
// A link at that file is left standing: the converge replaces it with a real file and reports it
|
|
1885
2033
|
// once it has written, so the install does not claim a replacement the converge may not make.
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2034
|
+
if (await mayChangeBelow(agentsTarget, report, true)) {
|
|
2035
|
+
prepassTargets.push({ folder: agentsTarget, include: relative => relative !== trackerAgentFile });
|
|
2036
|
+
wipedFolders.push(agentsTarget);
|
|
2037
|
+
}
|
|
2038
|
+
// The skill folders this run reinstalls, and the ones it removes. The references skill is
|
|
2039
|
+
// scanned around the paths the overlay owns (D-OVERLAY-OWNERSHIP): the overlay converges them
|
|
2040
|
+
// and moves what the user put in them, so the scan leaves them out.
|
|
2041
|
+
if (mayChangeSkills) {
|
|
2042
|
+
for (const skill of skillsMap.keys()) {
|
|
2043
|
+
const target = skillFolder(skill);
|
|
2044
|
+
if (!(await mayChangeBelow(target, report, true)))
|
|
2045
|
+
continue;
|
|
2046
|
+
if (skill === SKILL_REFS_SKILL_NAME) {
|
|
2047
|
+
awaitingWrite.push(...await unlinkOverlayOwnedLinks(target, overlayOwned));
|
|
2048
|
+
prepassTargets.push({ folder: target, include: relative => !isUnderOverlayOwnedPath(relative, overlayOwned) });
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
prepassTargets.push({ folder: target });
|
|
2052
|
+
}
|
|
2053
|
+
wipedFolders.push(target);
|
|
2054
|
+
}
|
|
2055
|
+
for (const name of [...skillPlan.remove, ...skillPlan.removeFolders]) {
|
|
2056
|
+
const target = skillFolder(name);
|
|
2057
|
+
if ((await directoryState(target)) === 'real')
|
|
2058
|
+
prepassTargets.push({ folder: target });
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
else {
|
|
2063
|
+
// Only what the registry-diff sweeps below would remove: the unknown .md files of the commands
|
|
2064
|
+
// and agents folders. Files of other kinds stay, as they always did on a partial install.
|
|
2065
|
+
for (const dir of [commandsTarget, agentsTarget]) {
|
|
2066
|
+
if (await mayChangeBelow(dir, report, false)) {
|
|
2067
|
+
prepassTargets.push({ folder: dir, include: relative => mdEntryName(relative) !== null });
|
|
2068
|
+
}
|
|
1890
2069
|
}
|
|
1891
|
-
catch { /* ignore */ }
|
|
1892
2070
|
}
|
|
2071
|
+
// The devflow:* folders outside the registry, on every shape: a retired folder goes by its listed
|
|
2072
|
+
// files and anything else moves whole.
|
|
2073
|
+
if (mayChangeSkills) {
|
|
2074
|
+
prepassTargets.push({ folder: skillsRoot, include: relative => isUnregisteredSkillFolder(relative, registrySkills) });
|
|
2075
|
+
}
|
|
2076
|
+
const prepass = await backups.prepass(prepassTargets);
|
|
2077
|
+
const movedFromAgents = prepass.moved.some(unit => isContainedIn(agentsTarget, unit.original));
|
|
2078
|
+
// Clean old Devflow files before installing: each folder loses the entries the pre-pass listed as
|
|
2079
|
+
// devflow's own, then a plain rmdir. A folder that still holds a file the pre-pass could not move stays.
|
|
2080
|
+
spinner.message('Cleaning old files...');
|
|
2081
|
+
for (const folder of wipedFolders)
|
|
2082
|
+
await backups.removeListed(folder);
|
|
1893
2083
|
// The commands, agents and rules folders, each proved a real folder (or absent) before any write
|
|
1894
2084
|
// below. A full install's pre-clean replaced a link at each of them. A partial install has no
|
|
1895
2085
|
// pre-clean, and a link the pre-clean could not remove is still standing, so every write below
|
|
1896
2086
|
// would go through it into another directory: the sweeps, the copies and the restamp. A refused
|
|
1897
2087
|
// folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
|
|
1898
2088
|
// only when the selection has a rule, so only then is it proved.
|
|
1899
|
-
const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
|
|
1900
|
-
const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
|
|
1901
|
-
const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
|
|
1902
2089
|
const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
|
|
1903
2090
|
const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
|
|
1904
2091
|
const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
|
|
@@ -1914,12 +2101,20 @@ export async function installViaFileCopy(options) {
|
|
|
1914
2101
|
// are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
|
|
1915
2102
|
// runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
|
|
1916
2103
|
// then re-materialized by converge, creating a false-orphan report on every install.
|
|
1917
|
-
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
|
|
1922
|
-
|
|
2104
|
+
//
|
|
2105
|
+
// The pre-pass has already moved every devflow:* folder outside the registry that holds a file of
|
|
2106
|
+
// the user's, so this sweep removes nothing itself: a retired folder goes by the files the session
|
|
2107
|
+
// listed, and a folder the pre-pass could not move stays where it is.
|
|
2108
|
+
if (mayChangeSkills) {
|
|
2109
|
+
recordSweep(report, 'skill', await sweepOrphanedAssets(skillsRoot, registrySkills, (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null, async (entryPath) => {
|
|
2110
|
+
await backups.removeListed(entryPath);
|
|
2111
|
+
return false;
|
|
2112
|
+
}));
|
|
2113
|
+
}
|
|
2114
|
+
// The prefixed install targets were pre-cleaned above, before re-copying, so stale content never
|
|
2115
|
+
// bleeds into a fresh install. Bare pre-namespace dirs at ~/.claude/skills/{name} are owned
|
|
2116
|
+
// solely by the frozen LEGACY_SKILL_NAMES pass in init.ts (runs immediately after this call). A
|
|
2117
|
+
// bare dir whose name matches a current registry skill is by construction foreign to Devflow and
|
|
1923
2118
|
// must not be touched here.
|
|
1924
2119
|
//
|
|
1925
2120
|
// The pre-clean is SCOPED to what this run reinstalls and the orphan sweep
|
|
@@ -1954,28 +2149,11 @@ export async function installViaFileCopy(options) {
|
|
|
1954
2149
|
// A target that is a link is unlinked and reported in `replacedLinks`, never emptied
|
|
1955
2150
|
// through: the folder it points to is not Devflow's, and the install below writes a real
|
|
1956
2151
|
// folder in its place.
|
|
1957
|
-
if (!isPartialInstall) {
|
|
1958
|
-
const overlayOwned = overlayOwnedSkillPaths(refsManifest);
|
|
1959
|
-
for (const skill of skillsMap.keys()) {
|
|
1960
|
-
// Empty the prefixed directory (its contents are re-created during the install
|
|
1961
|
-
// phase), minus whatever another converger owns inside it.
|
|
1962
|
-
const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
|
|
1963
|
-
try {
|
|
1964
|
-
if (skill === SKILL_REFS_SKILL_NAME) {
|
|
1965
|
-
const emptied = await emptyDirectoryExcept(target, overlayOwned);
|
|
1966
|
-
report.replacedLinks.push(...emptied.replacedLinks);
|
|
1967
|
-
awaitingWrite.push(...emptied.awaitingWrite);
|
|
1968
|
-
}
|
|
1969
|
-
else {
|
|
1970
|
-
report.replacedLinks.push(...await removeReportingLink(target));
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
catch { /* ignore */ }
|
|
1974
|
-
}
|
|
2152
|
+
if (!isPartialInstall && !skillsRefused) {
|
|
1975
2153
|
// A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
|
|
1976
2154
|
// for a skill. A link it cannot remove is refused by the install below.
|
|
1977
2155
|
for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
|
|
1978
|
-
const target =
|
|
2156
|
+
const target = skillFolder(name);
|
|
1979
2157
|
if ((await unlinkLinkAt(target)) === 'unlinked')
|
|
1980
2158
|
report.replacedLinks.push(target);
|
|
1981
2159
|
}
|
|
@@ -1986,10 +2164,12 @@ export async function installViaFileCopy(options) {
|
|
|
1986
2164
|
// (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
|
|
1987
2165
|
// was on disk and is now gone: the plan is registry arithmetic and mostly names
|
|
1988
2166
|
// skills that were never installed.
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2167
|
+
if (mayChangeSkills) {
|
|
2168
|
+
const deselected = await removeDeselectedSkills(skillsRoot, [...skillPlan.remove, ...skillPlan.removeFolders], backups);
|
|
2169
|
+
report.removedSkills.push(...deselected.removed);
|
|
2170
|
+
for (const failure of deselected.failed) {
|
|
2171
|
+
warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
|
|
2172
|
+
}
|
|
1993
2173
|
}
|
|
1994
2174
|
// Retired agents. The sweep runs BEFORE the copy so the copy can see what it found:
|
|
1995
2175
|
// an agent file whose name left the registry means a previous release is installed
|
|
@@ -1999,17 +2179,21 @@ export async function installViaFileCopy(options) {
|
|
|
1999
2179
|
// Ungated: runs on every install shape. knownNames spans ALL plugins
|
|
2000
2180
|
// (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
|
|
2001
2181
|
// names absent from the full registry are removed.
|
|
2182
|
+
//
|
|
2183
|
+
// The sweep removes only what the backup session classifies as devflow's own (a retired name, a
|
|
2184
|
+
// leftover of an interrupted write): an unknown file was moved by the pre-pass or stays.
|
|
2002
2185
|
const agentSweep = agentsRefused
|
|
2003
2186
|
? { scanned: 0, removed: [], failed: [] }
|
|
2004
|
-
: await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName);
|
|
2187
|
+
: await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink');
|
|
2005
2188
|
recordSweep(report, 'agent', agentSweep);
|
|
2006
2189
|
// A partial install writes the selected plugins' commands and agents and leaves every
|
|
2007
2190
|
// other plugin's files alone, which is only safe while those files still name agents
|
|
2008
2191
|
// that exist. After a retired agent was removed they do not, so a partial install
|
|
2009
2192
|
// writes the commands and agents of every plugin the manifest records instead. The
|
|
2010
2193
|
// widening is conditional because an ordinary add-one run has nothing to repair and
|
|
2011
|
-
// must not rewrite files the user did not ask about.
|
|
2012
|
-
|
|
2194
|
+
// must not rewrite files the user did not ask about. An agent file the pre-pass moved out of the
|
|
2195
|
+
// folder is the same sign: it came from an earlier release, so the commands may still name it.
|
|
2196
|
+
const widenToInstalled = isPartialInstall && (agentSweep.removed.length > 0 || movedFromAgents);
|
|
2013
2197
|
const commandAgentPlugins = widenToInstalled ? (options.effectivePlugins ?? plugins) : plugins;
|
|
2014
2198
|
const commandAgentMap = widenToInstalled ? buildAssetMaps(commandAgentPlugins).agentsMap : agentsMap;
|
|
2015
2199
|
// Install commands and agents (D-TRACKER-AGENT-OWNER and the source-resolution rules
|
|
@@ -2034,7 +2218,7 @@ export async function installViaFileCopy(options) {
|
|
|
2034
2218
|
// plugins survive a partial run. Only names absent from the full registry are removed.
|
|
2035
2219
|
// A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
|
|
2036
2220
|
if (!commandsRefused) {
|
|
2037
|
-
recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName));
|
|
2221
|
+
recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink'));
|
|
2038
2222
|
}
|
|
2039
2223
|
// D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
|
|
2040
2224
|
// installed /code-review, on every install shape. This is the language gate the command reads
|
|
@@ -2060,8 +2244,12 @@ export async function installViaFileCopy(options) {
|
|
|
2060
2244
|
// skills here. The pre-clean above walked the UNFILTERED map, so on a full
|
|
2061
2245
|
// install a leftover copy is already gone; a partial install leaves removal to
|
|
2062
2246
|
// convergeVariantAxes, which init runs straight after this call.
|
|
2247
|
+
//
|
|
2248
|
+
// A refused skills root (a link or not a folder) gets no skill: every copy, and the overlay
|
|
2249
|
+
// below it, would write into the folder the link points to.
|
|
2063
2250
|
spinner.message('Installing skills...');
|
|
2064
|
-
|
|
2251
|
+
const skillsToInstall = skillsRefused ? [] : [...omitCurationGatedSkills(skillsMap, curation).keys()];
|
|
2252
|
+
for (const skillName of skillsToInstall) {
|
|
2065
2253
|
const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
|
|
2066
2254
|
// A full install's pre-clean replaced a link at the skill or its references entry. A partial
|
|
2067
2255
|
// install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
|
|
@@ -2104,6 +2292,7 @@ export async function installViaFileCopy(options) {
|
|
|
2104
2292
|
sourceRoot: refsSourceRoot,
|
|
2105
2293
|
manifest: refsManifest,
|
|
2106
2294
|
warn,
|
|
2295
|
+
backups,
|
|
2107
2296
|
});
|
|
2108
2297
|
report.overlaidRefs.push(...overlay.overlaidRefs);
|
|
2109
2298
|
report.unchangedRefs.push(...overlay.unchangedRefs);
|
|
@@ -2119,7 +2308,8 @@ export async function installViaFileCopy(options) {
|
|
|
2119
2308
|
// none of them; this step removes the ones an earlier compliance-on install left, which a partial
|
|
2120
2309
|
// install copies over without clearing. A removal that was refused is a reported failure and
|
|
2121
2310
|
// never an abort; the compliance converge that init runs next fails closed on the same files.
|
|
2122
|
-
|
|
2311
|
+
// A refused skills root is not entered: the removal would reach files in the folder a link points to.
|
|
2312
|
+
if (!compliance && !skillsRefused) {
|
|
2123
2313
|
const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
|
|
2124
2314
|
for (const refusal of removal.refused) {
|
|
2125
2315
|
report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
|
|
@@ -2141,7 +2331,7 @@ export async function installViaFileCopy(options) {
|
|
|
2141
2331
|
warn(`workflow skill folder: ${read.reason}`);
|
|
2142
2332
|
continue;
|
|
2143
2333
|
}
|
|
2144
|
-
const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report });
|
|
2334
|
+
const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report, backups });
|
|
2145
2335
|
if (installed.outcome === 'failed') {
|
|
2146
2336
|
warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
|
|
2147
2337
|
}
|
|
@@ -2176,6 +2366,7 @@ export async function installViaFileCopy(options) {
|
|
|
2176
2366
|
warn,
|
|
2177
2367
|
});
|
|
2178
2368
|
spinner.stop('Components installed via file copy');
|
|
2369
|
+
report.backups = backups.snapshot();
|
|
2179
2370
|
return report;
|
|
2180
2371
|
}
|
|
2181
2372
|
//# sourceMappingURL=installer.js.map
|