devflow-kit 3.9.0 → 3.11.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 +142 -0
- package/README.md +1 -1
- package/dist/agents/code.md +6 -6
- package/dist/agents/fix.md +6 -6
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- package/dist/cli/commands/ambient.js +10 -2
- package/dist/cli/commands/backup-session.js +131 -0
- package/dist/cli/commands/compliance-prompts.js +9 -4
- package/dist/cli/commands/compliance.js +184 -24
- package/dist/cli/commands/curation.js +23 -10
- package/dist/cli/commands/gate-level-prompts.js +39 -25
- package/dist/cli/commands/gates.js +5 -7
- package/dist/cli/commands/init.js +169 -184
- package/dist/cli/commands/install-report.js +129 -0
- package/dist/cli/commands/legacy-sweep.js +159 -0
- package/dist/cli/commands/proxy.js +6 -1
- package/dist/cli/commands/rules.js +75 -21
- package/dist/cli/commands/skills.js +42 -9
- package/dist/cli/commands/tracker.js +9 -3
- package/dist/cli/commands/uninstall.js +339 -75
- package/dist/commands/bug-analysis.md +69 -120
- package/dist/commands/dynamic-build.md +23 -23
- package/dist/commands/implement.md +46 -32
- package/dist/commands/release.md +1 -1
- package/dist/commands/resolve.md +35 -31
- package/dist/commands/self-review.md +8 -7
- package/dist/compliance-off/agents/git.md +120 -0
- package/dist/compliance-off/agents/triage.md +180 -0
- package/dist/compliance-off/commands/bug-analysis.md +398 -0
- package/dist/compliance-off/commands/code-review.md +520 -0
- package/dist/compliance-off/commands/dynamic-build.md +1251 -0
- package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
- package/dist/compliance-off/commands/implement.md +736 -0
- package/dist/compliance-off/commands/plan.md +671 -0
- package/dist/compliance-off/commands/release.md +309 -0
- package/dist/compliance-off/commands/resolve.md +742 -0
- package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
- package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
- package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
- package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
- package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
- package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
- package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
- package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
- package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
- package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
- package/dist/core/agent-models.js +19 -4
- package/dist/core/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/compliance.js +14 -0
- package/dist/core/curation-variants.js +460 -112
- package/dist/core/flags.js +2 -2
- package/dist/core/fs-atomic.js +5 -1
- package/dist/core/installed-variants.js +63 -0
- package/dist/core/manifest.js +48 -5
- package/dist/core/mds-variants.js +700 -3
- package/dist/core/orphan-sweep.js +29 -2
- package/dist/core/path-set.js +15 -0
- package/dist/core/plugins.js +68 -10
- 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 +176 -0
- package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
- package/dist/curation-compliance-off/commands/code-review.md +481 -0
- package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
- package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
- package/dist/curation-compliance-off/commands/implement.md +689 -0
- package/dist/curation-compliance-off/commands/plan.md +631 -0
- package/dist/curation-compliance-off/commands/release.md +279 -0
- package/dist/curation-compliance-off/commands/resolve.md +692 -0
- package/dist/curation-off/agents/code.md +6 -6
- package/dist/curation-off/agents/fix.md +6 -6
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/dynamic-build.md +23 -23
- package/dist/curation-off/commands/implement.md +46 -32
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +35 -31
- package/dist/curation-off/commands/self-review.md +8 -7
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- package/dist/targets/claude-code/behavior-install.js +23 -9
- package/dist/targets/claude-code/charter-render.js +374 -0
- package/dist/targets/claude-code/compliance-install.js +62 -17
- package/dist/targets/claude-code/compliance-off-files.js +249 -0
- package/dist/targets/claude-code/curation-install.js +632 -117
- 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 +795 -199
- package/dist/targets/claude-code/language-stamp.js +6 -0
- package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
- package/dist/targets/claude-code/linked-folder.js +176 -0
- package/dist/targets/claude-code/reference-converge.js +131 -0
- 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/tracker-install.js +21 -8
- package/dist/targets/claude-code/tracker-servers.js +20 -6
- package/dist/targets/claude-code/workflow-folder-install.js +251 -0
- package/dist/variant-axes.json +109 -0
- package/package.json +1 -1
- package/src/assets/agents/_partials/_code.mds +6 -6
- package/src/assets/agents/evaluate.md +1 -1
- package/src/assets/agents/git.mds +32 -0
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +2 -0
- package/src/assets/agents/validate.md +10 -10
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +43 -0
- package/src/assets/commands/dynamic-build.mds +109 -15
- package/src/assets/commands/dynamic-tickets.mds +13 -0
- package/src/assets/commands/implement.mds +145 -33
- package/src/assets/commands/plan.mds +19 -0
- package/src/assets/commands/release.mds +50 -1
- package/src/assets/commands/resolve.mds +217 -32
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +23 -0
- package/src/assets/mds/git/_ops_steps.mds +17 -0
- package/src/assets/mds/git/_pr.mds +1 -1
- package/src/assets/mds/git/_references.mds +6 -0
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +42 -0
- package/src/assets/mds/tracker/_jira.mds +43 -0
- package/src/assets/mds/tracker/_linear.mds +43 -0
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +204 -126
- package/src/assets/scripts/hooks/assets/implement-installed +1 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
- package/src/assets/scripts/hooks/preamble +18 -8
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +41 -2
- package/src/assets/scripts/verify-evidence.cjs +408 -56
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
- package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
- package/src/assets/skills/docs-framework/SKILL.md +5 -2
- package/src/assets/skills/git/SKILL.md +3 -3
- package/src/assets/skills/git/references/github-api.md +2 -139
- package/src/assets/skills/git/references/github-posting.md +158 -0
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +1 -1
- package/src/assets/skills/review-methodology/references/report-template.md +1 -2
- package/src/assets/skills/testing/SKILL.md +2 -4
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { SKILL_NAMESPACE, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitCurationGatedSkills } from '../../core/plugins.js';
|
|
5
|
-
import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir } from '../../core/assets.js';
|
|
4
|
+
import { SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitCurationGatedSkills, selectedWorkflowFolders, workflowFolderNames } from '../../core/plugins.js';
|
|
5
|
+
import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir, skillRefsSourceDir } from '../../core/assets.js';
|
|
6
6
|
import { getPackageRoot, isContainedIn } from '../../core/paths.js';
|
|
7
|
+
import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, parseVariantManifest, requiredComplianceVariantFiles, } from '../../core/curation-variants.js';
|
|
7
8
|
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 { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
|
|
9
10
|
import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
|
|
11
|
+
import { complianceOffCopyExclusions, removeComplianceOffFiles } from './compliance-off-files.js';
|
|
12
|
+
import { discardDisplacedFolder } from './folder-swap.js';
|
|
13
|
+
import { clearLinkForWrite, directoryState, refuseUnrealFolder, unlinkLinkAt } from './linked-folder.js';
|
|
14
|
+
import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
|
|
10
15
|
import { TRACKER_AGENT_NAME } from './tracker-install.js';
|
|
11
16
|
import { isTrackerStampedAgent } from './tracker-servers.js';
|
|
12
17
|
import { stampAgentTools } from '../../core/agent-frontmatter.js';
|
|
13
18
|
import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
|
|
14
19
|
import { BUILTIN_OVERRIDE_AGENTS, findForeignAgentOwner } from './agent-type-owner.js';
|
|
15
20
|
import { restampInstalledCommands } from './language-stamp.js';
|
|
21
|
+
import { CHARTER_RELPATH, HANDOFF_SENTINEL_RELPATH, installRenderedCharter, } from './charter-render.js';
|
|
16
22
|
// ---------------------------------------------------------------------------
|
|
17
23
|
// Shadow validation helpers (exported — reused by Step 4 list commands)
|
|
18
24
|
// ---------------------------------------------------------------------------
|
|
@@ -85,13 +91,20 @@ export async function validateRuleShadow(shadowFile) {
|
|
|
85
91
|
* copy failures (EACCES, ENOSPC, etc.) are still isolated
|
|
86
92
|
* (one bad copy does not abort the whole batch).
|
|
87
93
|
* Invalid shadows still warn-and-install-source.
|
|
94
|
+
*
|
|
95
|
+
* A link at the target file is unlinked and replaced by a real file, never written through, and
|
|
96
|
+
* recorded in `links` when given. A link that cannot be removed is recorded as refused and the
|
|
97
|
+
* outcome is `skipped`.
|
|
88
98
|
*/
|
|
89
|
-
export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
99
|
+
export async function installRuleFile(ruleName, devflowDir, rulesTarget, links) {
|
|
90
100
|
const shadowFile = path.join(devflowDir, 'rules', mdFileName(ruleName));
|
|
91
101
|
const targetFile = path.join(rulesTarget, mdFileName(ruleName));
|
|
92
102
|
const ruleSource = path.join(rulesDir(), mdFileName(ruleName));
|
|
103
|
+
// Asked once, so a link that cannot be removed is recorded once.
|
|
104
|
+
let writable;
|
|
105
|
+
const mayWrite = async () => (writable ??= await clearLinkForWrite(targetFile, links));
|
|
93
106
|
const shadowState = await validateRuleShadow(shadowFile);
|
|
94
|
-
if (shadowState === 'valid') {
|
|
107
|
+
if (shadowState === 'valid' && await mayWrite()) {
|
|
95
108
|
try {
|
|
96
109
|
await fs.copyFile(shadowFile, targetFile);
|
|
97
110
|
return 'shadow';
|
|
@@ -121,6 +134,8 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
|
121
134
|
}
|
|
122
135
|
// Copy is isolated: a copy failure degrades to 'skipped' so one
|
|
123
136
|
// bad rule does not abort the entire installAllRules Promise.all batch.
|
|
137
|
+
if (!(await mayWrite()))
|
|
138
|
+
return 'skipped';
|
|
124
139
|
try {
|
|
125
140
|
await fs.copyFile(ruleSource, targetFile);
|
|
126
141
|
return invalidShadowOutcome ?? 'source';
|
|
@@ -138,9 +153,9 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
|
138
153
|
* `rules --enable` (renders per-rule log lines). One place computes;
|
|
139
154
|
* callers present.
|
|
140
155
|
*/
|
|
141
|
-
export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
|
|
156
|
+
export async function installAllRules(rulesMap, devflowDir, rulesTarget, links) {
|
|
142
157
|
return Promise.all([...rulesMap.keys()].map(async (ruleName) => {
|
|
143
|
-
const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget);
|
|
158
|
+
const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget, links);
|
|
144
159
|
return { ruleName, outcome };
|
|
145
160
|
}));
|
|
146
161
|
}
|
|
@@ -148,18 +163,32 @@ export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
|
|
|
148
163
|
// Directory utilities
|
|
149
164
|
// ---------------------------------------------------------------------------
|
|
150
165
|
/**
|
|
151
|
-
* Recursively copy a directory tree.
|
|
166
|
+
* Recursively copy a directory tree. `exclude` holds absolute SOURCE paths that the copy
|
|
167
|
+
* skips (a file, or a directory with everything below it); omitted, everything is copied.
|
|
168
|
+
*
|
|
169
|
+
* `links` is given when `dest` is inside a Devflow install folder. The copy then checks, with
|
|
170
|
+
* `lstat`, each folder and file it is about to write, and never writes through a link: the link is
|
|
171
|
+
* unlinked and a real folder or file is written in its place (recorded in `links.replacedLinks`),
|
|
172
|
+
* and a link that cannot be removed is skipped with everything below it (recorded in
|
|
173
|
+
* `links.refusedFolders`). Omitted, the copy is the plain one, for a `dest` that is the user's own
|
|
174
|
+
* content.
|
|
152
175
|
*/
|
|
153
|
-
export async function copyDirectory(src, dest) {
|
|
176
|
+
export async function copyDirectory(src, dest, exclude, links) {
|
|
177
|
+
if (links !== undefined && !(await clearLinkForWrite(dest, links)))
|
|
178
|
+
return;
|
|
154
179
|
await fs.mkdir(dest, { recursive: true });
|
|
155
180
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
156
181
|
for (const entry of entries) {
|
|
157
182
|
const srcPath = path.join(src, entry.name);
|
|
158
183
|
const destPath = path.join(dest, entry.name);
|
|
184
|
+
if (exclude?.has(srcPath))
|
|
185
|
+
continue;
|
|
159
186
|
if (entry.isDirectory()) {
|
|
160
|
-
await copyDirectory(srcPath, destPath);
|
|
187
|
+
await copyDirectory(srcPath, destPath, exclude, links);
|
|
161
188
|
}
|
|
162
189
|
else {
|
|
190
|
+
if (links !== undefined && !(await clearLinkForWrite(destPath, links)))
|
|
191
|
+
continue;
|
|
163
192
|
await fs.copyFile(srcPath, destPath);
|
|
164
193
|
}
|
|
165
194
|
}
|
|
@@ -190,8 +219,12 @@ export async function copyDirectory(src, dest) {
|
|
|
190
219
|
* {@link composeScripts}, swallows it with the rest of its copy-and-chmod step; that tree
|
|
191
220
|
* is three levels of shipped assets, so a breach there means the package itself grew a
|
|
192
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.
|
|
193
226
|
*/
|
|
194
|
-
export async function chmodRecursive(dir, mode, _depth = 0) {
|
|
227
|
+
export async function chmodRecursive(dir, mode, skip, _depth = 0) {
|
|
195
228
|
if (_depth > MAX_REFERENCE_SWEEP_DEPTH) {
|
|
196
229
|
throw new Error(`chmodRecursive: descent into ${dir} exceeds the bound of ` +
|
|
197
230
|
`${MAX_REFERENCE_SWEEP_DEPTH} levels — that subtree keeps the modes it arrived with.`);
|
|
@@ -199,8 +232,10 @@ export async function chmodRecursive(dir, mode, _depth = 0) {
|
|
|
199
232
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
200
233
|
for (const entry of entries) {
|
|
201
234
|
const fullPath = path.join(dir, entry.name);
|
|
235
|
+
if (skip?.(fullPath) === true)
|
|
236
|
+
continue;
|
|
202
237
|
if (entry.isDirectory()) {
|
|
203
|
-
await chmodRecursive(fullPath, mode, _depth + 1);
|
|
238
|
+
await chmodRecursive(fullPath, mode, skip, _depth + 1);
|
|
204
239
|
}
|
|
205
240
|
else if (entry.isFile()) {
|
|
206
241
|
await fs.chmod(fullPath, mode);
|
|
@@ -406,7 +441,7 @@ const STAGING_TOKEN = `${process.pid}-${Date.now().toString(36)}`;
|
|
|
406
441
|
* arm's basename is `{provider}.{token}.tmp`, which is not dot-prefixed, so a rule keyed
|
|
407
442
|
* on the name would reach the other arms only.
|
|
408
443
|
*/
|
|
409
|
-
function stagingDirFor(referencesTarget, unit) {
|
|
444
|
+
export function stagingDirFor(referencesTarget, unit) {
|
|
410
445
|
return `${sidecarBaseFor(referencesTarget, unit)}.${STAGING_TOKEN}.tmp`;
|
|
411
446
|
}
|
|
412
447
|
/**
|
|
@@ -415,7 +450,7 @@ function stagingDirFor(referencesTarget, unit) {
|
|
|
415
450
|
* Beside the staging tree, and under `tracker/` for the same reason (property 2 of
|
|
416
451
|
* {@link stagingDirFor}): a backup a crash strands is removed by a later run's prune.
|
|
417
452
|
*/
|
|
418
|
-
function backupDirFor(referencesTarget, unit) {
|
|
453
|
+
export function backupDirFor(referencesTarget, unit) {
|
|
419
454
|
return `${sidecarBaseFor(referencesTarget, unit)}.old`;
|
|
420
455
|
}
|
|
421
456
|
/**
|
|
@@ -575,6 +610,10 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
|
|
|
575
610
|
* entries the overlay must never replace or delete (D-OVERLAY-FLAT-UNIT), so its
|
|
576
611
|
* files are exactly the comparison and the neighbours are none of its business.
|
|
577
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
|
+
*
|
|
578
617
|
* What it deliberately does NOT compare is file MODE. D-OVERLAY-MODE-SCOPE normalises the
|
|
579
618
|
* whole references directory on every run regardless of which units promoted, so a unit
|
|
580
619
|
* skipped here still has its modes converged — a skipped promotion can hide byte drift
|
|
@@ -584,9 +623,9 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
|
|
|
584
623
|
* — answers "no". The fallback is always the promotion that was going to happen anyway,
|
|
585
624
|
* so a failure to compare can only cost a write, never correctness.
|
|
586
625
|
*/
|
|
587
|
-
async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir) {
|
|
626
|
+
async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir, backups) {
|
|
588
627
|
const basenameOf = (relPath) => relPath.split('/').slice(-1)[0];
|
|
589
|
-
if (unit.kind !== 'cross-cutting') {
|
|
628
|
+
if (unit.kind !== 'cross-cutting' && !unitStaysInPlace(backups, underRoot(referencesTarget, unitSubdir(unit)))) {
|
|
590
629
|
const owned = new Set(unit.files.map(basenameOf));
|
|
591
630
|
let installed;
|
|
592
631
|
try {
|
|
@@ -631,6 +670,50 @@ async function restoreDisplacedUnit(backup, target) {
|
|
|
631
670
|
return { ok: false, error: String(err) };
|
|
632
671
|
}
|
|
633
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
|
+
}
|
|
634
717
|
/**
|
|
635
718
|
* Promote the flat cross-cutting set — one `rename` per document.
|
|
636
719
|
*
|
|
@@ -645,29 +728,7 @@ async function restoreDisplacedUnit(backup, target) {
|
|
|
645
728
|
* Throws on the first failing rename; the caller reports the state recorded by then.
|
|
646
729
|
*/
|
|
647
730
|
async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record) {
|
|
648
|
-
|
|
649
|
-
// The directory this set lands in, created rather than assumed — {@link promoteDirectoryUnit}
|
|
650
|
-
// does the same for its target's parent. The two flat directories the registry emits today
|
|
651
|
-
// exist by the time promotion runs for reasons that have nothing to do with the unit landing
|
|
652
|
-
// in them: the references root is created by {@link overlayGeneratedReferences}, and
|
|
653
|
-
// `tracker/` as a side effect of this arm's own staging path. A flat set landing anywhere
|
|
654
|
-
// else takes ENOENT on its first rename — a unit reported as failed for where it was asked
|
|
655
|
-
// to land rather than for anything wrong with the documents it carries.
|
|
656
|
-
await fs.mkdir(destDir, { recursive: true });
|
|
657
|
-
for (const [index, relPath] of unit.files.entries()) {
|
|
658
|
-
const basename = relPath.split('/').slice(-1)[0];
|
|
659
|
-
await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
|
|
660
|
-
// Past the first rename the set is mixed, and there is no directory to swap back
|
|
661
|
-
// (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the
|
|
662
|
-
// rest still carry the previous install's. Recorded after each rename so a failure
|
|
663
|
-
// on the next one names both halves instead of claiming the set is untouched.
|
|
664
|
-
record({
|
|
665
|
-
kind: 'partially-refreshed',
|
|
666
|
-
refreshed: unit.files.slice(0, index + 1),
|
|
667
|
-
stale: unit.files.slice(index + 1),
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
731
|
+
await renameDocumentsInto(unit.files, underRoot(referencesTarget, unit.dir), stagingDir, record);
|
|
671
732
|
}
|
|
672
733
|
/**
|
|
673
734
|
* Promote a directory unit (a provider, or `pr/`) — swapped whole, or not at all.
|
|
@@ -677,9 +738,17 @@ async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, recor
|
|
|
677
738
|
* either entirely the previous install or entirely the new one (DR-05, risk P2-g), and a
|
|
678
739
|
* rename that fails half-way restores the previous one rather than leaving it empty.
|
|
679
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
|
+
*
|
|
680
749
|
* Throws once the state it left has been recorded; the caller reports it.
|
|
681
750
|
*/
|
|
682
|
-
async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record) {
|
|
751
|
+
async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups) {
|
|
683
752
|
const target = underRoot(referencesTarget, unitSubdir(unit));
|
|
684
753
|
const backup = backupDirFor(referencesTarget, unit);
|
|
685
754
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -696,8 +765,15 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
696
765
|
// either is converged away by a later run's tracker-subtree prune (both names end
|
|
697
766
|
// in neither `/` nor `.md`, so no manifest entry can collide with them) — but the
|
|
698
767
|
// backup this run is still relying on is exempt from this run's prune, which is
|
|
699
|
-
// what `restore-failed` carries the recovery path for.
|
|
700
|
-
|
|
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
|
+
}
|
|
701
777
|
let displaced = false;
|
|
702
778
|
try {
|
|
703
779
|
await fs.rename(target, backup);
|
|
@@ -722,7 +798,8 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
722
798
|
}
|
|
723
799
|
throw err;
|
|
724
800
|
}
|
|
725
|
-
|
|
801
|
+
if (displaced)
|
|
802
|
+
await discardDisplacedFolder(backups, backup, target).catch(() => undefined);
|
|
726
803
|
}
|
|
727
804
|
/**
|
|
728
805
|
* Promote a fully built staging tree into place, dispatched on what kind of unit it is.
|
|
@@ -745,7 +822,7 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
|
|
|
745
822
|
* that failure selects (previous unit restored, not deleted) is exactly the one worth
|
|
746
823
|
* pinning.
|
|
747
824
|
*/
|
|
748
|
-
export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir) {
|
|
825
|
+
export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir, backups) {
|
|
749
826
|
let state = { kind: 'installed-unchanged' };
|
|
750
827
|
const record = next => { state = next; };
|
|
751
828
|
try {
|
|
@@ -755,7 +832,7 @@ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir)
|
|
|
755
832
|
break;
|
|
756
833
|
case 'provider':
|
|
757
834
|
case 'pr-host':
|
|
758
|
-
await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record);
|
|
835
|
+
await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups);
|
|
759
836
|
break;
|
|
760
837
|
default: {
|
|
761
838
|
const _exhaustive = unit;
|
|
@@ -830,7 +907,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
|
|
|
830
907
|
throw new Error(`Generated skill references not found: ${sourceRoot}. ` +
|
|
831
908
|
`The whole generated tree is absent, so none of the ${manifest.length} references the ` +
|
|
832
909
|
`devflow:git agent is instructed to load would be installed. ` +
|
|
833
|
-
`Run \`npm run build:mds\` to regenerate
|
|
910
|
+
`Run \`npm run build:mds\` to regenerate ${sourceRoot} before install ` +
|
|
834
911
|
`(\`npm run build:cli\` alone does not produce it).`);
|
|
835
912
|
}
|
|
836
913
|
/**
|
|
@@ -872,7 +949,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
|
|
|
872
949
|
* coordinate system as the manifest that decided them and as the stranded-skip failure
|
|
873
950
|
* below, which has always named its subtree from the root.
|
|
874
951
|
*/
|
|
875
|
-
async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures) {
|
|
952
|
+
async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups) {
|
|
876
953
|
const subtreeRoot = underRoot(referencesTarget, subtree);
|
|
877
954
|
const prefix = `${subtree}/`;
|
|
878
955
|
// Checked here rather than trusted from the unit loop: this is the call that follows
|
|
@@ -905,7 +982,7 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
|
|
|
905
982
|
}
|
|
906
983
|
// Keyed by relative path, because `tracker/{provider}/{op}.md` is what distinguishes
|
|
907
984
|
// two providers' identically named files — the reason mdEntryName cannot serve here.
|
|
908
|
-
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));
|
|
909
986
|
return {
|
|
910
987
|
scanned: swept.scanned,
|
|
911
988
|
removed: swept.removed.map(rel => `${prefix}${rel}`),
|
|
@@ -925,12 +1002,12 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
|
|
|
925
1002
|
* `scanned` sums, which keeps it the non-vacuity counter it is everywhere else: a run
|
|
926
1003
|
* that scanned nothing scanned nothing in any subtree.
|
|
927
1004
|
*/
|
|
928
|
-
async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures) {
|
|
1005
|
+
async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures, backups) {
|
|
929
1006
|
let scanned = 0;
|
|
930
1007
|
const removed = [];
|
|
931
1008
|
const failed = [];
|
|
932
1009
|
for (const subtree of CONVERGED_SUBTREES) {
|
|
933
|
-
const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures);
|
|
1010
|
+
const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups);
|
|
934
1011
|
scanned += swept.scanned;
|
|
935
1012
|
removed.push(...swept.removed);
|
|
936
1013
|
failed.push(...swept.failed);
|
|
@@ -1011,6 +1088,10 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1011
1088
|
if (fault !== null)
|
|
1012
1089
|
rootFaults.set(subtree, fault);
|
|
1013
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)));
|
|
1014
1095
|
for (const unit of planOverlayUnits(manifest)) {
|
|
1015
1096
|
const fault = subtreesTouchedBy(opts.referencesTarget, unit)
|
|
1016
1097
|
.map(subtree => rootFaults.get(subtree))
|
|
@@ -1036,19 +1117,19 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1036
1117
|
// installed, so `overlaidRefs` names what this run WROTE (AC-23). The discard is the
|
|
1037
1118
|
// same one both promotion halves perform on their way out; skipping it would leave
|
|
1038
1119
|
// the `.tmp` residue every other path is asserted not to leave.
|
|
1039
|
-
if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir)) {
|
|
1120
|
+
if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir, opts.backups)) {
|
|
1040
1121
|
await fs.rm(built.stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1041
1122
|
unchangedRefs.push(...unit.files);
|
|
1042
1123
|
continue;
|
|
1043
1124
|
}
|
|
1044
|
-
const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir);
|
|
1125
|
+
const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir, opts.backups);
|
|
1045
1126
|
if (!promoted.ok) {
|
|
1046
1127
|
overlayFailures.push({ unit: unitRef(unit), state: promoted.state, error: promoted.error });
|
|
1047
1128
|
continue;
|
|
1048
1129
|
}
|
|
1049
1130
|
overlaidRefs.push(...unit.files);
|
|
1050
1131
|
}
|
|
1051
|
-
const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures);
|
|
1132
|
+
const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures, opts.backups);
|
|
1052
1133
|
// D-OVERLAY-MODE-SCOPE: normalise the WHOLE references directory, not only the files
|
|
1053
1134
|
// this run installed. copyDirectory preserves source modes, so a hand-authored
|
|
1054
1135
|
// reference checked in with an odd mode installs with it; a reference is read-only
|
|
@@ -1059,12 +1140,13 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1059
1140
|
// boundary is stated as "never replace or delete" rather than "never touch": the MODE of
|
|
1060
1141
|
// a hand-authored reference — and of whatever a shadowed skill supplied outside
|
|
1061
1142
|
// `tracker/` — is normalised here. The prove-you-wrote-it rule permits exactly that: the
|
|
1062
|
-
// 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.
|
|
1063
1145
|
//
|
|
1064
1146
|
// It is also the one walk that can breach chmodRecursive's descent bound. The catch is
|
|
1065
1147
|
// that breach's reporting channel, not just an I/O guard (see {@link chmodRecursive}).
|
|
1066
1148
|
try {
|
|
1067
|
-
await chmodRecursive(opts.referencesTarget, 0o644);
|
|
1149
|
+
await chmodRecursive(opts.referencesTarget, 0o644, entry => opts.backups.isKept(entry));
|
|
1068
1150
|
}
|
|
1069
1151
|
catch (err) {
|
|
1070
1152
|
warn(`reference overlay: could not normalise reference file modes — ${String(err)}`);
|
|
@@ -1073,6 +1155,46 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1073
1155
|
}
|
|
1074
1156
|
/** The directory inside an installed skill that the reference overlay converges. */
|
|
1075
1157
|
const SKILL_REFERENCES_DIRNAME = 'references';
|
|
1158
|
+
/**
|
|
1159
|
+
* Prove, with `lstat`, that the overlay can run below `gitSkillDir`.
|
|
1160
|
+
*
|
|
1161
|
+
* The overlay stages, prunes and sets modes below the `references` entry, and the skill copy
|
|
1162
|
+
* before it writes there too. `mkdir` and `readdir` follow a link, so a link at either level
|
|
1163
|
+
* would send those writes into another directory. An absent entry is fine: the install creates it.
|
|
1164
|
+
* Fail closed: an entry that cannot be inspected is refused, and a link is never repaired,
|
|
1165
|
+
* because the installer did not create it.
|
|
1166
|
+
*
|
|
1167
|
+
* One answer for every caller that has no pre-clean to replace such a link: the converge, a
|
|
1168
|
+
* partial install, and a full install whose pre-clean could not remove one. `lstat` runs on
|
|
1169
|
+
* `references` only below a real skill directory, since through a linked one it would follow the
|
|
1170
|
+
* link. Never throws.
|
|
1171
|
+
*/
|
|
1172
|
+
export async function referencesHostState(gitSkillDir) {
|
|
1173
|
+
const skill = await directoryState(gitSkillDir);
|
|
1174
|
+
if (skill === 'absent')
|
|
1175
|
+
return { kind: 'absent' };
|
|
1176
|
+
if (skill !== 'real')
|
|
1177
|
+
return { kind: 'refused', path: gitSkillDir };
|
|
1178
|
+
const referencesDir = path.join(gitSkillDir, SKILL_REFERENCES_DIRNAME);
|
|
1179
|
+
const references = await directoryState(referencesDir);
|
|
1180
|
+
return references === 'absent' || references === 'real'
|
|
1181
|
+
? { kind: 'ready' }
|
|
1182
|
+
: { kind: 'refused', path: referencesDir };
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* The report of an overlay refused as a whole: one failure per unit, as a refused converged
|
|
1186
|
+
* root reports (D-CONVERGED-ROOT-REAL), each carrying the state the unit was left in.
|
|
1187
|
+
* The classification reads; nothing is written.
|
|
1188
|
+
*/
|
|
1189
|
+
async function refusedOverlayFailures(refusedPath, referencesTarget, manifest) {
|
|
1190
|
+
const error = `${refusedPath} is not a real directory — nothing is copied, staged, pruned or set through it. ` +
|
|
1191
|
+
`Replace it with a real directory and re-run.`;
|
|
1192
|
+
const failures = [];
|
|
1193
|
+
for (const unit of planOverlayUnits(manifest)) {
|
|
1194
|
+
failures.push({ unit: unitRef(unit), state: await classifyUntouchedUnit(unit, referencesTarget), error });
|
|
1195
|
+
}
|
|
1196
|
+
return failures;
|
|
1197
|
+
}
|
|
1076
1198
|
/**
|
|
1077
1199
|
* What inside an installed skill directory the reference overlay owns, and the
|
|
1078
1200
|
* pre-clean must therefore leave standing (D-OVERLAY-OWNERSHIP).
|
|
@@ -1119,54 +1241,17 @@ export function overlayOwnedSkillPaths(manifest) {
|
|
|
1119
1241
|
return owned;
|
|
1120
1242
|
}
|
|
1121
1243
|
/**
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
* removed exactly as the unconditional pre-clean would have removed it.
|
|
1127
|
-
*
|
|
1128
|
-
* A kept path is preserved only as a real file or directory. A symlink planted at one is
|
|
1129
|
-
* removed like anything else (the link, never its target): the converger that owns the
|
|
1130
|
-
* path writes and prunes through it, so a link left standing here would hand that
|
|
1131
|
-
* converger someone else's directory (D-CONVERGED-ROOT-REAL). `Dirent` types are
|
|
1132
|
-
* lstat-based, which is what makes the check see the link rather than its target.
|
|
1133
|
-
*
|
|
1134
|
-
* Descent is bounded, and the bound is the `keep` set's own deepest path rather than a
|
|
1135
|
-
* constant: the walk only ever descends INTO a directory that still has a kept
|
|
1136
|
-
* descendant below it, so there is nothing to look for past that depth. A `keep` set of
|
|
1137
|
-
* depth 2 — which is what {@link overlayOwnedSkillPaths} produces for every manifest the
|
|
1138
|
-
* build emits — walks two levels and `fs.rm`s the rest recursively in one call.
|
|
1139
|
-
*
|
|
1140
|
-
* An unreadable directory is left alone rather than reported: the caller already
|
|
1141
|
-
* swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
|
|
1142
|
-
* read its target has nothing to remove from it.
|
|
1244
|
+
* Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
|
|
1245
|
+
* path. A path that is still absent, or a link again, is not a replacement and is not reported: the
|
|
1246
|
+
* step that should have written there failed or was refused, and it reports that failure itself.
|
|
1247
|
+
* Bounded by `removed`, which holds at most the kept paths of one pre-clean.
|
|
1143
1248
|
*/
|
|
1144
|
-
async function
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
entries = await fs.readdir(current, { withFileTypes: true });
|
|
1151
|
-
}
|
|
1152
|
-
catch {
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
for (const entry of entries) {
|
|
1156
|
-
const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
1157
|
-
if (keep.has(entryRel) && !entry.isSymbolicLink())
|
|
1158
|
-
continue;
|
|
1159
|
-
const holdsSomethingKept = entry.isDirectory()
|
|
1160
|
-
&& depth < maxDepth
|
|
1161
|
-
&& kept.some(relPath => relPath.startsWith(`${entryRel}/`));
|
|
1162
|
-
if (holdsSomethingKept) {
|
|
1163
|
-
await walk(path.join(current, entry.name), entryRel, depth + 1);
|
|
1164
|
-
continue;
|
|
1165
|
-
}
|
|
1166
|
-
await fs.rm(path.join(current, entry.name), { recursive: true, force: true });
|
|
1167
|
-
}
|
|
1168
|
-
};
|
|
1169
|
-
await walk(dir, '', 1);
|
|
1249
|
+
async function reportLinksOnceWritten(report, removed) {
|
|
1250
|
+
for (const linkPath of removed) {
|
|
1251
|
+
const stat = await fs.lstat(linkPath).catch(() => undefined);
|
|
1252
|
+
if (stat !== undefined && !stat.isSymbolicLink())
|
|
1253
|
+
report.replacedLinks.push(linkPath);
|
|
1254
|
+
}
|
|
1170
1255
|
}
|
|
1171
1256
|
// ---------------------------------------------------------------------------
|
|
1172
1257
|
// Script composer
|
|
@@ -1190,6 +1275,9 @@ function collectRelativeImports(source) {
|
|
|
1190
1275
|
* under ~/.devflow/scripts/ at the same relative-to-dist/ paths
|
|
1191
1276
|
* (c) ~/.devflow/scripts/package.json → {"type":"module"}
|
|
1192
1277
|
*
|
|
1278
|
+
* The copy leaves out the orchestrator charter and the implement sentinel. Install the
|
|
1279
|
+
* directory with {@link installScripts}, which writes both after the copy.
|
|
1280
|
+
*
|
|
1193
1281
|
* Frozen externally-referenced paths:
|
|
1194
1282
|
* ~/.devflow/scripts/hooks/run-hook (hook bootstrap entry)
|
|
1195
1283
|
* ~/.devflow/scripts/hud.sh (HUD entry script)
|
|
@@ -1204,9 +1292,19 @@ export async function composeScripts(scriptsTarget) {
|
|
|
1204
1292
|
// mirrored dist/hud/ closure), so a re-run gave package.json an exec bit the first
|
|
1205
1293
|
// run never did and re-init stopped being a no-op on disk (#388 AC-3). Scoping the
|
|
1206
1294
|
// chmod to the copied entries makes the first and every later run agree.
|
|
1295
|
+
//
|
|
1296
|
+
// D-CHARTER-INSTALL-RENDER: the orchestrator charter and the implement sentinel are
|
|
1297
|
+
// NOT part of this verbatim copy. The charter is installed as a render of the packaged
|
|
1298
|
+
// source for the installed plugin set, and the sentinel exists only while implement is
|
|
1299
|
+
// installed; `installRenderedCharter` is the only writer of both, so no copy here can put
|
|
1300
|
+
// the full source charter or a stale sentinel back over a render.
|
|
1207
1301
|
const srcScripts = scriptsDir();
|
|
1302
|
+
const rendered = new Set([
|
|
1303
|
+
path.join(srcScripts, CHARTER_RELPATH),
|
|
1304
|
+
path.join(srcScripts, HANDOFF_SENTINEL_RELPATH),
|
|
1305
|
+
]);
|
|
1208
1306
|
try {
|
|
1209
|
-
await copyDirectory(srcScripts, scriptsTarget);
|
|
1307
|
+
await copyDirectory(srcScripts, scriptsTarget, rendered);
|
|
1210
1308
|
if (process.platform !== 'win32') {
|
|
1211
1309
|
for (const entry of await fs.readdir(srcScripts, { withFileTypes: true })) {
|
|
1212
1310
|
const copied = path.join(scriptsTarget, entry.name);
|
|
@@ -1268,6 +1366,23 @@ export async function composeScripts(scriptsTarget) {
|
|
|
1268
1366
|
}
|
|
1269
1367
|
catch { /* already exists from prior install — leave as-is */ }
|
|
1270
1368
|
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Install the scripts directory whole: the verbatim copy of {@link composeScripts}, then the orchestrator
|
|
1371
|
+
* charter and the implement sentinel it skips.
|
|
1372
|
+
*
|
|
1373
|
+
* D-CHARTER-INSTALL-RENDER: a tree from `composeScripts` alone has no charter, so every caller outside this
|
|
1374
|
+
* module goes through here and none pairs the two steps by hand. The render is its own step, outside the
|
|
1375
|
+
* copy's catch-all, so its failure handling is the writer's own: a warning, never an abort.
|
|
1376
|
+
*/
|
|
1377
|
+
export async function installScripts(opts) {
|
|
1378
|
+
await composeScripts(opts.scriptsTarget);
|
|
1379
|
+
await installRenderedCharter({
|
|
1380
|
+
scriptsDir: opts.scriptsTarget,
|
|
1381
|
+
plugins: opts.plugins,
|
|
1382
|
+
warn: opts.warn,
|
|
1383
|
+
mode: 'install',
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1271
1386
|
/**
|
|
1272
1387
|
* Install one agent that carries the tracker-server stamp: the shipped source with
|
|
1273
1388
|
* `servers` appended to its `tools:` line, written in one step so no copy of it ever sits on
|
|
@@ -1344,7 +1459,7 @@ async function firstExisting(candidates) {
|
|
|
1344
1459
|
* installed in its place).
|
|
1345
1460
|
*
|
|
1346
1461
|
* The ONE spelling of that decision: the install loop and
|
|
1347
|
-
*
|
|
1462
|
+
* convergeVariantAxes both call it, so a skill the curation toggle installs
|
|
1348
1463
|
* later is shadowed exactly as one the install installed.
|
|
1349
1464
|
*
|
|
1350
1465
|
* The shipped source is stat-checked FIRST, even under a valid shadow, so an
|
|
@@ -1383,8 +1498,8 @@ export async function resolveSkillSource(skillName, devflowDir, packageRoot) {
|
|
|
1383
1498
|
* validateSkillShadow at install time. This only answers "did the user write
|
|
1384
1499
|
* one?", which is what dormancy reporting turns on.
|
|
1385
1500
|
*/
|
|
1386
|
-
async function listShadowedSkills(devflowDir) {
|
|
1387
|
-
const registry = new Set(getAllSkillNames());
|
|
1501
|
+
async function listShadowedSkills(devflowDir, folderWorkflows) {
|
|
1502
|
+
const registry = new Set(getAllSkillNames(folderWorkflows));
|
|
1388
1503
|
let entries;
|
|
1389
1504
|
try {
|
|
1390
1505
|
entries = await fs.readdir(path.join(devflowDir, 'skills'));
|
|
@@ -1408,29 +1523,274 @@ function recordSweep(report, kind, sweep) {
|
|
|
1408
1523
|
* report which of them were actually there to remove.
|
|
1409
1524
|
*
|
|
1410
1525
|
* `names` is registry arithmetic (the skills no selected plugin owns), so most of
|
|
1411
|
-
* it is routinely absent from disk. Only the deleter can say what it deleted:
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
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.
|
|
1418
1538
|
*/
|
|
1419
|
-
async function removeDeselectedSkills(skillsDir, names) {
|
|
1539
|
+
async function removeDeselectedSkills(skillsDir, names, backups) {
|
|
1420
1540
|
const removed = [];
|
|
1421
1541
|
const failed = [];
|
|
1422
1542
|
for (const name of names) {
|
|
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
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return { removed, failed };
|
|
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
|
+
}
|
|
1696
|
+
/** The most variant files a refusal names; the count says how many more there are. */
|
|
1697
|
+
const MAX_REPORTED_MISSING_VARIANTS = 5;
|
|
1698
|
+
/** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
|
|
1699
|
+
function namedVariantFiles(items) {
|
|
1700
|
+
const named = items.slice(0, MAX_REPORTED_MISSING_VARIANTS).join(', ');
|
|
1701
|
+
const more = items.length > MAX_REPORTED_MISSING_VARIANTS ? ` and ${items.length - MAX_REPORTED_MISSING_VARIANTS} more` : '';
|
|
1702
|
+
return `${named}${more}`;
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* The source lists the copy reads, most-preferred first. The check in
|
|
1706
|
+
* {@link requireComplianceVariantFiles} and the copy in {@link installCommandsAndAgents}
|
|
1707
|
+
* both take their lists from here, so the check looks at the files the copy takes.
|
|
1708
|
+
* An injected agent list wins as given.
|
|
1709
|
+
*/
|
|
1710
|
+
function copySourceDirs(opts) {
|
|
1711
|
+
const { curation, compliance, packageRoot } = opts;
|
|
1712
|
+
return {
|
|
1713
|
+
commands: commandSourceDirs({ curation, compliance }, packageRoot),
|
|
1714
|
+
agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, { curation, compliance }),
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Refuse a compliance-off install that cannot be trusted to carry the right text, before
|
|
1719
|
+
* any file is written. Compliance on reads nothing and returns.
|
|
1720
|
+
*
|
|
1721
|
+
* With compliance off, the installer must know which compiled prompts carry a compliance
|
|
1722
|
+
* arm: the build records that in dist/variant-axes.json, and the installer reads it here
|
|
1723
|
+
* rather than guess from the files it finds. A prompt with a compliance arm has no safe
|
|
1724
|
+
* fall-through, because its normal file, and on a both-off machine its curation-off file,
|
|
1725
|
+
* carry the compliance text the machine has switched off. So the install is refused when
|
|
1726
|
+
* - the manifest is absent, unreadable or not valid (the build has not run, or only
|
|
1727
|
+
* `build:cli` did); or
|
|
1728
|
+
* - a compliance-armed prompt has no file for the machine's EXACT switches
|
|
1729
|
+
* (requiredComplianceVariantFiles names the file: a both-off machine needs the file
|
|
1730
|
+
* built for both axes, never the curation-off one); or
|
|
1731
|
+
* - the copy would take another file for that prompt: the first existing file of its
|
|
1732
|
+
* kind and name over the copy's own source list (copySourceDirs) is not the required
|
|
1733
|
+
* file. A prompt whose list holds no file at all is left to the copy's own refusal.
|
|
1734
|
+
* A prompt with no compliance arm is not checked: it falls through to its normal file.
|
|
1735
|
+
*
|
|
1736
|
+
* The same refusal shape as {@link requireGeneratedTree}: one throw, before the unit loop,
|
|
1737
|
+
* naming the build step. Only the file reads, so it leaves no partial install behind.
|
|
1738
|
+
*/
|
|
1739
|
+
async function requireComplianceVariantFiles(opts) {
|
|
1740
|
+
const { curation, compliance, packageRoot } = opts;
|
|
1741
|
+
if (compliance)
|
|
1742
|
+
return;
|
|
1743
|
+
const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
|
|
1744
|
+
const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
|
|
1745
|
+
`(\`npm run build:cli\` alone does not produce them).`;
|
|
1746
|
+
let text;
|
|
1747
|
+
try {
|
|
1748
|
+
text = await fs.readFile(manifestFile, 'utf-8');
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
const reason = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
|
|
1752
|
+
throw new Error(`Variant manifest ${reason}: ${manifestFile}. ` +
|
|
1753
|
+
`With compliance off the installer needs it to find the prompts that carry a compliance arm, so no file was written. ${remedy}`);
|
|
1754
|
+
}
|
|
1755
|
+
const parsed = parseVariantManifest(text);
|
|
1756
|
+
if (!parsed.ok) {
|
|
1757
|
+
throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
|
|
1758
|
+
`No file was written. ${remedy}`);
|
|
1759
|
+
}
|
|
1760
|
+
const sourceDirs = copySourceDirs({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1761
|
+
const machine = `curation ${curation ? 'on' : 'off'} and compliance off`;
|
|
1762
|
+
const missing = [];
|
|
1763
|
+
const shadowed = [];
|
|
1764
|
+
for (const host of parsed.value) {
|
|
1765
|
+
const [rel] = requiredComplianceVariantFiles({ curation, compliance }, [host]);
|
|
1766
|
+
if (rel === undefined)
|
|
1767
|
+
continue;
|
|
1768
|
+
const required = path.join(packageRoot, ...rel.split('/'));
|
|
1769
|
+
let present = false;
|
|
1423
1770
|
try {
|
|
1424
|
-
await fs.
|
|
1425
|
-
removed.push(name);
|
|
1771
|
+
present = (await fs.stat(required)).isFile();
|
|
1426
1772
|
}
|
|
1427
|
-
catch
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1773
|
+
catch { /* absent — reported below */ }
|
|
1774
|
+
if (!present) {
|
|
1775
|
+
missing.push(rel);
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
const dirs = host.kind === 'commands' ? sourceDirs.commands : sourceDirs.agents;
|
|
1779
|
+
const taken = await firstExisting(dirs.map(dir => path.join(dir, mdFileName(host.name))));
|
|
1780
|
+
if (taken !== undefined && path.resolve(taken) !== path.resolve(required)) {
|
|
1781
|
+
shadowed.push(`${rel} (the copy would take ${path.relative(packageRoot, taken).split(path.sep).join('/')})`);
|
|
1431
1782
|
}
|
|
1432
1783
|
}
|
|
1433
|
-
|
|
1784
|
+
if (missing.length > 0) {
|
|
1785
|
+
throw new Error(`Variant file not found for ${missing.length} prompt(s) that carry a compliance arm, on a machine with ` +
|
|
1786
|
+
`${machine}: ${namedVariantFiles(missing)}. ` +
|
|
1787
|
+
`Another variant of the same prompt would carry the wrong text, so no file was written. ${remedy}`);
|
|
1788
|
+
}
|
|
1789
|
+
if (shadowed.length > 0) {
|
|
1790
|
+
throw new Error(`Another file comes first for ${shadowed.length} prompt(s) that carry a compliance arm, on a machine with ` +
|
|
1791
|
+
`${machine}: ${namedVariantFiles(shadowed)}. ` +
|
|
1792
|
+
`The install would carry the wrong text, so no file was written. ${remedy}`);
|
|
1793
|
+
}
|
|
1434
1794
|
}
|
|
1435
1795
|
/**
|
|
1436
1796
|
* Write the commands and the agents of `plugins` into `claudeDir`, and nothing else:
|
|
@@ -1438,10 +1798,13 @@ async function removeDeselectedSkills(skillsDir, names) {
|
|
|
1438
1798
|
* the selective uninstall, which rewrites the files of the plugins that stay installed
|
|
1439
1799
|
* after it removed a retired agent.
|
|
1440
1800
|
*
|
|
1441
|
-
* Commands come from dist/commands/{name}.md, or — with
|
|
1442
|
-
*
|
|
1801
|
+
* Commands come from dist/commands/{name}.md, or — with an axis off — the off variant of
|
|
1802
|
+
* the host when it has one, such as dist/curation-off/commands/{name}.md
|
|
1443
1803
|
* (D-LEARNING-VARIANT-INSTALL; commandSourceDirs owns the order). A declared command
|
|
1444
|
-
* with no compiled source file is a hard error, not a skip.
|
|
1804
|
+
* with no compiled source file is a hard error, not a skip. With compliance off the copy
|
|
1805
|
+
* first checks that every compliance-armed prompt has its file for the machine's exact
|
|
1806
|
+
* switches and that the copy takes that file, and refuses before it writes anything
|
|
1807
|
+
* otherwise ({@link requireComplianceVariantFiles}).
|
|
1445
1808
|
*
|
|
1446
1809
|
* Agents resolve dist-first with a src fallback: dist/agents/{name}.md (compiled from an
|
|
1447
1810
|
* .mds generator host) wins over src/assets/agents/{name}.md. A declared agent absent from
|
|
@@ -1458,32 +1821,45 @@ async function removeDeselectedSkills(skillsDir, names) {
|
|
|
1458
1821
|
* known and leaves it alone.
|
|
1459
1822
|
*/
|
|
1460
1823
|
export async function installCommandsAndAgents(opts) {
|
|
1461
|
-
const { plugins, agentsMap, claudeDir, curation, trackerServers, warn } = opts;
|
|
1824
|
+
const { plugins, agentsMap, claudeDir, curation, compliance, trackerServers, warn } = opts;
|
|
1825
|
+
const skip = opts.skip ?? {};
|
|
1826
|
+
const packageRoot = opts.packageRoot ?? getPackageRoot();
|
|
1827
|
+
// Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
|
|
1828
|
+
// this call covers the caller that reaches the copy alone (the selective uninstall).
|
|
1829
|
+
await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1830
|
+
const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
|
|
1831
|
+
curation,
|
|
1832
|
+
compliance,
|
|
1833
|
+
packageRoot,
|
|
1834
|
+
agentSourceDirs: opts.agentSourceDirs,
|
|
1835
|
+
});
|
|
1462
1836
|
const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
|
|
1463
|
-
const
|
|
1837
|
+
const folderWorkflows = opts.folderWorkflows ?? WORKFLOW_FOLDER_NAMES;
|
|
1464
1838
|
const commandsSourceNames = new Set();
|
|
1465
1839
|
for (const plugin of plugins) {
|
|
1466
1840
|
for (const cmd of plugin.commands) {
|
|
1467
1841
|
const name = cmd.startsWith('/') ? cmd.slice(1) : cmd;
|
|
1468
|
-
|
|
1842
|
+
// D-WORKFLOW-FOLDER-INSTALL: a workflow that ships as a skill folder has no command file.
|
|
1843
|
+
if (!folderWorkflows.has(name))
|
|
1844
|
+
commandsSourceNames.add(name);
|
|
1469
1845
|
}
|
|
1470
1846
|
}
|
|
1471
|
-
if (commandsSourceNames.size > 0) {
|
|
1847
|
+
if (commandsSourceNames.size > 0 && skip.commands !== true) {
|
|
1472
1848
|
await fs.mkdir(commandsTarget, { recursive: true });
|
|
1473
1849
|
for (const name of commandsSourceNames) {
|
|
1474
1850
|
const srcFile = await firstExisting(commandDirs.map(dir => path.join(dir, mdFileName(name))));
|
|
1475
1851
|
if (srcFile === undefined) {
|
|
1476
1852
|
// The error names the curation-ON path: that file exists for every declared
|
|
1477
1853
|
// command, while a curation-off file exists only for a host with an arm.
|
|
1478
|
-
throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(
|
|
1854
|
+
throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(ALL_SWITCHES_ON, packageRoot)[0], mdFileName(name))}. ` +
|
|
1479
1855
|
`Ensure build:mds ran successfully before install.`);
|
|
1480
1856
|
}
|
|
1481
|
-
|
|
1857
|
+
const commandTarget = path.join(commandsTarget, mdFileName(name));
|
|
1858
|
+
if (await clearLinkForWrite(commandTarget, opts.links))
|
|
1859
|
+
await fs.copyFile(srcFile, commandTarget);
|
|
1482
1860
|
}
|
|
1483
1861
|
}
|
|
1484
1862
|
const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
|
|
1485
|
-
// Curation-aware (D-LEARNING-VARIANT-INSTALL). An injected list wins as given.
|
|
1486
|
-
const agentDirs = opts.agentSourceDirs ?? agentSourceDirs(undefined, curation);
|
|
1487
1863
|
const allAgentNames = new Set();
|
|
1488
1864
|
for (const plugin of plugins) {
|
|
1489
1865
|
for (const agent of plugin.agents) {
|
|
@@ -1494,7 +1870,7 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1494
1870
|
}
|
|
1495
1871
|
}
|
|
1496
1872
|
}
|
|
1497
|
-
if (allAgentNames.size > 0) {
|
|
1873
|
+
if (allAgentNames.size > 0 && skip.agents !== true) {
|
|
1498
1874
|
await fs.mkdir(agentsTarget, { recursive: true });
|
|
1499
1875
|
for (const agentName of allAgentNames) {
|
|
1500
1876
|
if (await skipsForUserOwnedType(agentName, claudeDir, agentsTarget, warn))
|
|
@@ -1502,11 +1878,13 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1502
1878
|
const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
|
|
1503
1879
|
const srcFile = await firstExisting(candidates);
|
|
1504
1880
|
if (srcFile === undefined) {
|
|
1505
|
-
throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs()[0], mdFileName(agentName))}. ` +
|
|
1881
|
+
throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs(packageRoot)[0], mdFileName(agentName))}. ` +
|
|
1506
1882
|
`Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
|
|
1507
1883
|
`ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
|
|
1508
1884
|
}
|
|
1509
1885
|
const agentTarget = path.join(agentsTarget, mdFileName(agentName));
|
|
1886
|
+
if (!(await clearLinkForWrite(agentTarget, opts.links)))
|
|
1887
|
+
continue;
|
|
1510
1888
|
if (isTrackerStampedAgent(agentName)) {
|
|
1511
1889
|
await writeStampedAgent(srcFile, agentTarget, trackerServers, warn);
|
|
1512
1890
|
}
|
|
@@ -1524,7 +1902,48 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1524
1902
|
* Returns an InstallReport describing which shadows were applied and which were skipped.
|
|
1525
1903
|
*/
|
|
1526
1904
|
export async function installViaFileCopy(options) {
|
|
1527
|
-
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, 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;
|
|
1906
|
+
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
1907
|
+
// D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
|
|
1908
|
+
// empty, and with it every folder step below is a no-op over an empty set.
|
|
1909
|
+
const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
|
|
1910
|
+
const folderWorkflows = workflowFolderNames(folderRegistry);
|
|
1911
|
+
const folderSwitches = { curation, compliance };
|
|
1912
|
+
const folderFiles = new Map();
|
|
1913
|
+
/** The files of one registered folder, read once. A failed read is a reason and never a throw. */
|
|
1914
|
+
const readFolder = async (folder) => {
|
|
1915
|
+
const cached = folderFiles.get(folder.name);
|
|
1916
|
+
if (cached !== undefined)
|
|
1917
|
+
return { ok: true, files: cached };
|
|
1918
|
+
const read = await readWorkflowFolder(folder, folderSwitches, options.workflowFoldersRoot ?? packageRoot);
|
|
1919
|
+
if (read.ok)
|
|
1920
|
+
folderFiles.set(folder.name, read.files);
|
|
1921
|
+
return read;
|
|
1922
|
+
};
|
|
1923
|
+
const readFolders = async (names) => {
|
|
1924
|
+
for (const folder of folderRegistry) {
|
|
1925
|
+
if (!names.has(folder.name))
|
|
1926
|
+
continue;
|
|
1927
|
+
const read = await readFolder(folder);
|
|
1928
|
+
if (!read.ok)
|
|
1929
|
+
throw new Error(read.reason);
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
// The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
|
|
1933
|
+
// must leave the installed tree as it found it.
|
|
1934
|
+
await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: options.agentSourceDirs });
|
|
1935
|
+
// The folders this run installs are read from the exact-key tree before the pre-clean for the
|
|
1936
|
+
// same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
|
|
1937
|
+
await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
|
|
1938
|
+
// The references the machine's compliance switch installs: the manifest, and the ONE source
|
|
1939
|
+
// root they are overlaid from. With compliance off the root is the compliance-off tree, and its
|
|
1940
|
+
// absence refuses the install here, before the pre-clean, so a refused install leaves the
|
|
1941
|
+
// installed tree as it found it and never falls through to the compliance-on references (AC-50).
|
|
1942
|
+
const refsManifest = installedReferenceManifest({ compliance });
|
|
1943
|
+
const refsSourceRoot = skillRefsSourceDir(compliance, packageRoot);
|
|
1944
|
+
if (omitCurationGatedSkills(skillsMap, curation).has(SKILL_REFS_SKILL_NAME)) {
|
|
1945
|
+
await requireGeneratedTree(refsSourceRoot, refsManifest);
|
|
1946
|
+
}
|
|
1528
1947
|
const report = {
|
|
1529
1948
|
shadowedSkills: [],
|
|
1530
1949
|
shadowedRules: [],
|
|
@@ -1534,8 +1953,11 @@ export async function installViaFileCopy(options) {
|
|
|
1534
1953
|
overlaidRefs: [],
|
|
1535
1954
|
unchangedRefs: [],
|
|
1536
1955
|
overlayFailures: [],
|
|
1956
|
+
replacedLinks: [],
|
|
1957
|
+
refusedFolders: [],
|
|
1537
1958
|
removedSkills: [],
|
|
1538
1959
|
dormantShadows: [],
|
|
1960
|
+
backups: backups.snapshot(),
|
|
1539
1961
|
};
|
|
1540
1962
|
// The skill decision, made in full before anything is touched — which skills
|
|
1541
1963
|
// this selection installs, which it removes, and which shadows it leaves inert.
|
|
@@ -1551,39 +1973,122 @@ export async function installViaFileCopy(options) {
|
|
|
1551
1973
|
const skillPlan = resolveSkillInstallPlan({
|
|
1552
1974
|
effectivePlugins: options.effectivePlugins ?? plugins,
|
|
1553
1975
|
isPartialInstall,
|
|
1554
|
-
shadowedSkills: await listShadowedSkills(devflowDir),
|
|
1976
|
+
shadowedSkills: await listShadowedSkills(devflowDir, folderWorkflows),
|
|
1977
|
+
folderWorkflows,
|
|
1555
1978
|
});
|
|
1556
1979
|
report.dormantShadows = [...skillPlan.dormantShadows];
|
|
1557
|
-
//
|
|
1558
|
-
|
|
1980
|
+
// D-WORKFLOW-SHADOW-REFUSAL, install half: a folder installs from the build and has no source to
|
|
1981
|
+
// overlay, so a shadow of its name is ignored and kept. The warning says so; it is not dormant.
|
|
1982
|
+
for (const name of skillPlan.ignoredFolderShadows) {
|
|
1983
|
+
warn(`The shadow of skill "${name}" is ignored: it ships as a skill folder from the build, which a shadow cannot replace. The shadow was kept.`);
|
|
1984
|
+
}
|
|
1985
|
+
// Links the pre-clean removed at a path that a later step of this install writes. Each is reported
|
|
1986
|
+
// as replaced only once a real file or folder stands there (reportLinksOnceWritten).
|
|
1987
|
+
const awaitingWrite = [];
|
|
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);
|
|
1559
2013
|
if (!isPartialInstall) {
|
|
1560
2014
|
// Commands and agents are plugin-scoped — only wipe on full install.
|
|
1561
2015
|
// On partial installs the registry-diff sweeps below handle stale entries
|
|
1562
2016
|
// without discarding assets from plugins not included in this run.
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
try {
|
|
1569
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
1570
|
-
}
|
|
1571
|
-
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);
|
|
1572
2022
|
}
|
|
1573
2023
|
// D-TRACKER-AGENT-OWNER, pre-clean half — the same split the reference tree
|
|
1574
2024
|
// needed (D-OVERLAY-OWNERSHIP), for the same reason. The agent directory is
|
|
1575
2025
|
// emptied AROUND the one file `convergeTrackerArtifacts` owns: taking it
|
|
1576
2026
|
// would leave converge with nothing to byte-compare against, so a
|
|
1577
2027
|
// steady-state re-init would re-copy the agent and announce
|
|
1578
|
-
// `tracker agent installed` on every run.
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
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.
|
|
2031
|
+
//
|
|
2032
|
+
// A link at that file is left standing: the converge replaces it with a real file and reports it
|
|
2033
|
+
// once it has written, so the install does not claim a replacement the converge may not make.
|
|
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
|
+
}
|
|
1584
2069
|
}
|
|
1585
|
-
catch { /* ignore */ }
|
|
1586
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);
|
|
2083
|
+
// The commands, agents and rules folders, each proved a real folder (or absent) before any write
|
|
2084
|
+
// below. A full install's pre-clean replaced a link at each of them. A partial install has no
|
|
2085
|
+
// pre-clean, and a link the pre-clean could not remove is still standing, so every write below
|
|
2086
|
+
// would go through it into another directory: the sweeps, the copies and the restamp. A refused
|
|
2087
|
+
// folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
|
|
2088
|
+
// only when the selection has a rule, so only then is it proved.
|
|
2089
|
+
const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
|
|
2090
|
+
const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
|
|
2091
|
+
const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
|
|
1587
2092
|
// Sweep stale devflow:* skill dirs — ungated: runs on every install shape,
|
|
1588
2093
|
// including partial installs, so renamed/deleted skills are pruned promptly.
|
|
1589
2094
|
// knownNames spans ALL plugins (getAllSkillNames) so skills from uninstalled
|
|
@@ -1596,12 +2101,20 @@ export async function installViaFileCopy(options) {
|
|
|
1596
2101
|
// are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
|
|
1597
2102
|
// runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
|
|
1598
2103
|
// then re-materialized by converge, creating a false-orphan report on every install.
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
|
1605
2118
|
// must not be touched here.
|
|
1606
2119
|
//
|
|
1607
2120
|
// The pre-clean is SCOPED to what this run reinstalls and the orphan sweep
|
|
@@ -1632,21 +2145,17 @@ export async function installViaFileCopy(options) {
|
|
|
1632
2145
|
// Everything else in the directory is still emptied, so a stale hand-authored skill
|
|
1633
2146
|
// file — including a reference at the references ROOT, which the overlay may replace
|
|
1634
2147
|
// but never delete (D-OVERLAY-FLAT-UNIT) — does not survive a full install.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
await fs.rm(target, { recursive: true, force: true });
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
catch { /* ignore */ }
|
|
2148
|
+
//
|
|
2149
|
+
// A target that is a link is unlinked and reported in `replacedLinks`, never emptied
|
|
2150
|
+
// through: the folder it points to is not Devflow's, and the install below writes a real
|
|
2151
|
+
// folder in its place.
|
|
2152
|
+
if (!isPartialInstall && !skillsRefused) {
|
|
2153
|
+
// A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
|
|
2154
|
+
// for a skill. A link it cannot remove is refused by the install below.
|
|
2155
|
+
for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
|
|
2156
|
+
const target = skillFolder(name);
|
|
2157
|
+
if ((await unlinkLinkAt(target)) === 'unlinked')
|
|
2158
|
+
report.replacedLinks.push(target);
|
|
1650
2159
|
}
|
|
1651
2160
|
}
|
|
1652
2161
|
// Remove the skills no selected plugin owns or requires — the deselection half
|
|
@@ -1655,10 +2164,12 @@ export async function installViaFileCopy(options) {
|
|
|
1655
2164
|
// (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
|
|
1656
2165
|
// was on disk and is now gone: the plan is registry arithmetic and mostly names
|
|
1657
2166
|
// skills that were never installed.
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
+
}
|
|
1662
2173
|
}
|
|
1663
2174
|
// Retired agents. The sweep runs BEFORE the copy so the copy can see what it found:
|
|
1664
2175
|
// an agent file whose name left the registry means a previous release is installed
|
|
@@ -1668,16 +2179,21 @@ export async function installViaFileCopy(options) {
|
|
|
1668
2179
|
// Ungated: runs on every install shape. knownNames spans ALL plugins
|
|
1669
2180
|
// (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
|
|
1670
2181
|
// names absent from the full registry are removed.
|
|
1671
|
-
|
|
1672
|
-
|
|
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.
|
|
2185
|
+
const agentSweep = agentsRefused
|
|
2186
|
+
? { scanned: 0, removed: [], failed: [] }
|
|
2187
|
+
: await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink');
|
|
1673
2188
|
recordSweep(report, 'agent', agentSweep);
|
|
1674
2189
|
// A partial install writes the selected plugins' commands and agents and leaves every
|
|
1675
2190
|
// other plugin's files alone, which is only safe while those files still name agents
|
|
1676
2191
|
// that exist. After a retired agent was removed they do not, so a partial install
|
|
1677
2192
|
// writes the commands and agents of every plugin the manifest records instead. The
|
|
1678
2193
|
// widening is conditional because an ordinary add-one run has nothing to repair and
|
|
1679
|
-
// must not rewrite files the user did not ask about.
|
|
1680
|
-
|
|
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);
|
|
1681
2197
|
const commandAgentPlugins = widenToInstalled ? (options.effectivePlugins ?? plugins) : plugins;
|
|
1682
2198
|
const commandAgentMap = widenToInstalled ? buildAssetMaps(commandAgentPlugins).agentsMap : agentsMap;
|
|
1683
2199
|
// Install commands and agents (D-TRACKER-AGENT-OWNER and the source-resolution rules
|
|
@@ -1688,26 +2204,37 @@ export async function installViaFileCopy(options) {
|
|
|
1688
2204
|
agentsMap: commandAgentMap,
|
|
1689
2205
|
claudeDir,
|
|
1690
2206
|
curation,
|
|
2207
|
+
compliance,
|
|
2208
|
+
packageRoot,
|
|
1691
2209
|
trackerServers,
|
|
1692
2210
|
agentSourceDirs: options.agentSourceDirs,
|
|
2211
|
+
skip: { commands: commandsRefused, agents: agentsRefused },
|
|
2212
|
+
links: report,
|
|
2213
|
+
folderWorkflows,
|
|
1693
2214
|
warn,
|
|
1694
2215
|
});
|
|
1695
2216
|
// Sweep stale command files — ungated: runs on every install shape.
|
|
1696
2217
|
// knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
|
|
1697
2218
|
// plugins survive a partial run. Only names absent from the full registry are removed.
|
|
1698
|
-
|
|
2219
|
+
// A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
|
|
2220
|
+
if (!commandsRefused) {
|
|
2221
|
+
recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink'));
|
|
2222
|
+
}
|
|
1699
2223
|
// D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
|
|
1700
2224
|
// installed /code-review, on every install shape. This is the language gate the command reads
|
|
1701
2225
|
// (no run-time probe of Claude Code's directory), so it runs after the copy and the sweep and
|
|
1702
2226
|
// whether or not code-review's own plugin is in this run: `--plugin=devflow-typescript` copies no
|
|
1703
2227
|
// command, yet must update a code-review an earlier run installed. The copy above wrote the shipped
|
|
1704
2228
|
// `(none)` line; the rewrite compares against what is on disk now, so neither that copy nor the
|
|
1705
|
-
// pre-clean can defeat it. A failure is a warning, never an abort.
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
2229
|
+
// pre-clean can defeat it. A failure is a warning, never an abort. A refused commands folder is not
|
|
2230
|
+
// restamped: the rewrite would go through the link.
|
|
2231
|
+
if (!commandsRefused) {
|
|
2232
|
+
await restampInstalledCommands({
|
|
2233
|
+
claudeDir,
|
|
2234
|
+
effectivePlugins: options.effectivePlugins ?? plugins,
|
|
2235
|
+
warn,
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
1711
2238
|
// Install skills from ALL plugins (skillsMap covers all plugins, not just selected).
|
|
1712
2239
|
// Resolved from flat src/assets/skills/{name}/ (no per-plugin subdirectory).
|
|
1713
2240
|
// A declared skill whose source directory is absent is a build/packaging failure
|
|
@@ -1716,12 +2243,36 @@ export async function installViaFileCopy(options) {
|
|
|
1716
2243
|
// D-LEARNING-VARIANT-INSTALL: a curation-off machine skips the curation-gated
|
|
1717
2244
|
// skills here. The pre-clean above walked the UNFILTERED map, so on a full
|
|
1718
2245
|
// install a leftover copy is already gone; a partial install leaves removal to
|
|
1719
|
-
//
|
|
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.
|
|
1720
2250
|
spinner.message('Installing skills...');
|
|
1721
|
-
|
|
1722
|
-
|
|
2251
|
+
const skillsToInstall = skillsRefused ? [] : [...omitCurationGatedSkills(skillsMap, curation).keys()];
|
|
2252
|
+
for (const skillName of skillsToInstall) {
|
|
1723
2253
|
const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
|
|
1724
|
-
|
|
2254
|
+
// A full install's pre-clean replaced a link at the skill or its references entry. A partial
|
|
2255
|
+
// install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
|
|
2256
|
+
// copy and the overlay below would write through such a link into another directory: the whole
|
|
2257
|
+
// skill is refused instead and listed in `refusedFolders`. The references skill is also reported
|
|
2258
|
+
// like a refused overlay, one failure for each unit.
|
|
2259
|
+
if (skillName === SKILL_REFS_SKILL_NAME) {
|
|
2260
|
+
const host = await referencesHostState(skillTarget);
|
|
2261
|
+
if (host.kind === 'refused') {
|
|
2262
|
+
report.refusedFolders.push(host.path);
|
|
2263
|
+
report.overlayFailures.push(...await refusedOverlayFailures(host.path, path.join(skillTarget, SKILL_REFERENCES_DIRNAME), refsManifest));
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
else if (await refuseUnrealFolder(report, skillTarget)) {
|
|
2268
|
+
continue;
|
|
2269
|
+
}
|
|
2270
|
+
// With compliance off the copy leaves out the posting files of COMPLIANCE_OFF_REMOVED_SKILL_FILES.
|
|
2271
|
+
// They are named below the source the copy reads, shadow or shipped, so the posting text is never
|
|
2272
|
+
// on the machine, even when the install stops or a removal is refused later. With compliance on
|
|
2273
|
+
// nothing is excluded.
|
|
2274
|
+
const resolved = await resolveSkillSource(skillName, devflowDir);
|
|
2275
|
+
await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
|
|
1725
2276
|
if (resolved.shadow === 'valid') {
|
|
1726
2277
|
report.shadowedSkills.push(skillName);
|
|
1727
2278
|
}
|
|
@@ -1737,8 +2288,11 @@ export async function installViaFileCopy(options) {
|
|
|
1737
2288
|
referencesTarget: path.join(skillTarget, 'references'),
|
|
1738
2289
|
// Every provider's mechanics (D-INSTALL-ALL-PROVIDERS). The overlay
|
|
1739
2290
|
// converges rather than merges, so a retired generated document is pruned.
|
|
1740
|
-
manifest
|
|
2291
|
+
// The manifest and the source root follow the machine compliance switch together.
|
|
2292
|
+
sourceRoot: refsSourceRoot,
|
|
2293
|
+
manifest: refsManifest,
|
|
1741
2294
|
warn,
|
|
2295
|
+
backups,
|
|
1742
2296
|
});
|
|
1743
2297
|
report.overlaidRefs.push(...overlay.overlaidRefs);
|
|
1744
2298
|
report.unchangedRefs.push(...overlay.unchangedRefs);
|
|
@@ -1746,13 +2300,48 @@ export async function installViaFileCopy(options) {
|
|
|
1746
2300
|
recordSweep(report, 'reference', overlay.pruned);
|
|
1747
2301
|
}
|
|
1748
2302
|
}
|
|
2303
|
+
// The skill copy and the overlay have written. A link the pre-clean removed at a path they write is
|
|
2304
|
+
// reported now if a real file or folder stands there, and not at all if a unit failed or was refused.
|
|
2305
|
+
await reportLinksOnceWritten(report, awaitingWrite);
|
|
2306
|
+
// With compliance off, the flat skill files that only the posting steps load are removed by
|
|
2307
|
+
// exact name (COMPLIANCE_OFF_REMOVED_SKILL_FILES), on every install shape. The copy above wrote
|
|
2308
|
+
// none of them; this step removes the ones an earlier compliance-on install left, which a partial
|
|
2309
|
+
// install copies over without clearing. A removal that was refused is a reported failure and
|
|
2310
|
+
// never an abort; the compliance converge that init runs next fails closed on the same files.
|
|
2311
|
+
// A refused skills root is not entered: the removal would reach files in the folder a link points to.
|
|
2312
|
+
if (!compliance && !skillsRefused) {
|
|
2313
|
+
const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
|
|
2314
|
+
for (const refusal of removal.refused) {
|
|
2315
|
+
report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
// D-WORKFLOW-FOLDER-INSTALL: the workflow skill folders of the plugins whose commands this run
|
|
2319
|
+
// writes, each from the exact-key tree read above. Before the scripts step, so the hand-off
|
|
2320
|
+
// sentinel that step installs never stands without the folder it names. A widened run may add a
|
|
2321
|
+
// folder the early read did not cover. Each folder is read, installed and reported on its own, and
|
|
2322
|
+
// a read failure is a warning, since the pre-clean has run: one broken folder never hides the next.
|
|
2323
|
+
const foldersToInstall = selectedWorkflowFolders(commandAgentPlugins, folderWorkflows);
|
|
2324
|
+
if (foldersToInstall.size > 0)
|
|
2325
|
+
spinner.message('Installing workflow skills...');
|
|
2326
|
+
for (const folder of folderRegistry) {
|
|
2327
|
+
if (!foldersToInstall.has(folder.name))
|
|
2328
|
+
continue;
|
|
2329
|
+
const read = await readFolder(folder);
|
|
2330
|
+
if (!read.ok) {
|
|
2331
|
+
warn(`workflow skill folder: ${read.reason}`);
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report, backups });
|
|
2335
|
+
if (installed.outcome === 'failed') {
|
|
2336
|
+
warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
1749
2339
|
// Install rules from selected plugins (rulesMap covers selected plugins only).
|
|
1750
2340
|
// Rules are flat .md files resolved from src/assets/rules/{name}.md (no per-plugin subdir).
|
|
1751
2341
|
spinner.message('Installing rules...');
|
|
1752
|
-
|
|
1753
|
-
if (rulesMap.size > 0) {
|
|
2342
|
+
if (rulesMap.size > 0 && !rulesRefused) {
|
|
1754
2343
|
await fs.mkdir(rulesTarget, { recursive: true });
|
|
1755
|
-
const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget);
|
|
2344
|
+
const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget, report);
|
|
1756
2345
|
for (const { ruleName, outcome } of outcomes) {
|
|
1757
2346
|
if (outcome === 'shadow') {
|
|
1758
2347
|
report.shadowedRules.push(ruleName);
|
|
@@ -1769,8 +2358,15 @@ export async function installViaFileCopy(options) {
|
|
|
1769
2358
|
// (no dev tooling, no raw TypeScript).
|
|
1770
2359
|
spinner.message('Installing scripts...');
|
|
1771
2360
|
const scriptsTarget = path.join(devflowDir, 'scripts');
|
|
1772
|
-
|
|
2361
|
+
// The charter and the sentinel render from the EFFECTIVE selection (a partial install
|
|
2362
|
+
// keeps what earlier runs installed).
|
|
2363
|
+
await installScripts({
|
|
2364
|
+
scriptsTarget,
|
|
2365
|
+
plugins: options.effectivePlugins ?? plugins,
|
|
2366
|
+
warn,
|
|
2367
|
+
});
|
|
1773
2368
|
spinner.stop('Components installed via file copy');
|
|
2369
|
+
report.backups = backups.snapshot();
|
|
1774
2370
|
return report;
|
|
1775
2371
|
}
|
|
1776
2372
|
//# sourceMappingURL=installer.js.map
|