devflow-kit 3.9.0 → 3.10.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 +111 -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/compliance-prompts.js +9 -4
- package/dist/cli/commands/compliance.js +171 -24
- package/dist/cli/commands/curation.js +12 -7
- package/dist/cli/commands/gate-level-prompts.js +39 -25
- package/dist/cli/commands/gates.js +5 -7
- package/dist/cli/commands/init.js +142 -138
- package/dist/cli/commands/install-report.js +34 -0
- package/dist/cli/commands/legacy-sweep.js +64 -0
- package/dist/cli/commands/proxy.js +6 -1
- package/dist/cli/commands/rules.js +29 -12
- package/dist/cli/commands/skills.js +42 -9
- package/dist/cli/commands/tracker.js +9 -3
- package/dist/cli/commands/uninstall.js +277 -51
- 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/curation-variants.js +460 -112
- package/dist/core/flags.js +2 -2
- 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 +11 -2
- package/dist/core/plugins.js +65 -8
- 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 +32 -12
- package/dist/targets/claude-code/compliance-off-files.js +245 -0
- package/dist/targets/claude-code/curation-install.js +570 -100
- package/dist/targets/claude-code/installer.js +467 -62
- package/dist/targets/claude-code/language-stamp.js +6 -0
- package/dist/targets/claude-code/linked-folder.js +182 -0
- package/dist/targets/claude-code/reference-converge.js +118 -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 +231 -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,23 @@
|
|
|
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 { clearLinkForWrite, directoryState, refuseUnrealFolder, removeReportingLink, unlinkLinkAt } from './linked-folder.js';
|
|
13
|
+
import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
|
|
10
14
|
import { TRACKER_AGENT_NAME } from './tracker-install.js';
|
|
11
15
|
import { isTrackerStampedAgent } from './tracker-servers.js';
|
|
12
16
|
import { stampAgentTools } from '../../core/agent-frontmatter.js';
|
|
13
17
|
import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
|
|
14
18
|
import { BUILTIN_OVERRIDE_AGENTS, findForeignAgentOwner } from './agent-type-owner.js';
|
|
15
19
|
import { restampInstalledCommands } from './language-stamp.js';
|
|
20
|
+
import { CHARTER_RELPATH, HANDOFF_SENTINEL_RELPATH, installRenderedCharter, } from './charter-render.js';
|
|
16
21
|
// ---------------------------------------------------------------------------
|
|
17
22
|
// Shadow validation helpers (exported — reused by Step 4 list commands)
|
|
18
23
|
// ---------------------------------------------------------------------------
|
|
@@ -85,13 +90,20 @@ export async function validateRuleShadow(shadowFile) {
|
|
|
85
90
|
* copy failures (EACCES, ENOSPC, etc.) are still isolated
|
|
86
91
|
* (one bad copy does not abort the whole batch).
|
|
87
92
|
* Invalid shadows still warn-and-install-source.
|
|
93
|
+
*
|
|
94
|
+
* A link at the target file is unlinked and replaced by a real file, never written through, and
|
|
95
|
+
* recorded in `links` when given. A link that cannot be removed is recorded as refused and the
|
|
96
|
+
* outcome is `skipped`.
|
|
88
97
|
*/
|
|
89
|
-
export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
98
|
+
export async function installRuleFile(ruleName, devflowDir, rulesTarget, links) {
|
|
90
99
|
const shadowFile = path.join(devflowDir, 'rules', mdFileName(ruleName));
|
|
91
100
|
const targetFile = path.join(rulesTarget, mdFileName(ruleName));
|
|
92
101
|
const ruleSource = path.join(rulesDir(), mdFileName(ruleName));
|
|
102
|
+
// Asked once, so a link that cannot be removed is recorded once.
|
|
103
|
+
let writable;
|
|
104
|
+
const mayWrite = async () => (writable ??= await clearLinkForWrite(targetFile, links));
|
|
93
105
|
const shadowState = await validateRuleShadow(shadowFile);
|
|
94
|
-
if (shadowState === 'valid') {
|
|
106
|
+
if (shadowState === 'valid' && await mayWrite()) {
|
|
95
107
|
try {
|
|
96
108
|
await fs.copyFile(shadowFile, targetFile);
|
|
97
109
|
return 'shadow';
|
|
@@ -121,6 +133,8 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
|
121
133
|
}
|
|
122
134
|
// Copy is isolated: a copy failure degrades to 'skipped' so one
|
|
123
135
|
// bad rule does not abort the entire installAllRules Promise.all batch.
|
|
136
|
+
if (!(await mayWrite()))
|
|
137
|
+
return 'skipped';
|
|
124
138
|
try {
|
|
125
139
|
await fs.copyFile(ruleSource, targetFile);
|
|
126
140
|
return invalidShadowOutcome ?? 'source';
|
|
@@ -138,9 +152,9 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
|
|
|
138
152
|
* `rules --enable` (renders per-rule log lines). One place computes;
|
|
139
153
|
* callers present.
|
|
140
154
|
*/
|
|
141
|
-
export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
|
|
155
|
+
export async function installAllRules(rulesMap, devflowDir, rulesTarget, links) {
|
|
142
156
|
return Promise.all([...rulesMap.keys()].map(async (ruleName) => {
|
|
143
|
-
const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget);
|
|
157
|
+
const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget, links);
|
|
144
158
|
return { ruleName, outcome };
|
|
145
159
|
}));
|
|
146
160
|
}
|
|
@@ -148,18 +162,32 @@ export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
|
|
|
148
162
|
// Directory utilities
|
|
149
163
|
// ---------------------------------------------------------------------------
|
|
150
164
|
/**
|
|
151
|
-
* Recursively copy a directory tree.
|
|
165
|
+
* Recursively copy a directory tree. `exclude` holds absolute SOURCE paths that the copy
|
|
166
|
+
* skips (a file, or a directory with everything below it); omitted, everything is copied.
|
|
167
|
+
*
|
|
168
|
+
* `links` is given when `dest` is inside a Devflow install folder. The copy then checks, with
|
|
169
|
+
* `lstat`, each folder and file it is about to write, and never writes through a link: the link is
|
|
170
|
+
* unlinked and a real folder or file is written in its place (recorded in `links.replacedLinks`),
|
|
171
|
+
* and a link that cannot be removed is skipped with everything below it (recorded in
|
|
172
|
+
* `links.refusedFolders`). Omitted, the copy is the plain one, for a `dest` that is the user's own
|
|
173
|
+
* content.
|
|
152
174
|
*/
|
|
153
|
-
export async function copyDirectory(src, dest) {
|
|
175
|
+
export async function copyDirectory(src, dest, exclude, links) {
|
|
176
|
+
if (links !== undefined && !(await clearLinkForWrite(dest, links)))
|
|
177
|
+
return;
|
|
154
178
|
await fs.mkdir(dest, { recursive: true });
|
|
155
179
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
156
180
|
for (const entry of entries) {
|
|
157
181
|
const srcPath = path.join(src, entry.name);
|
|
158
182
|
const destPath = path.join(dest, entry.name);
|
|
183
|
+
if (exclude?.has(srcPath))
|
|
184
|
+
continue;
|
|
159
185
|
if (entry.isDirectory()) {
|
|
160
|
-
await copyDirectory(srcPath, destPath);
|
|
186
|
+
await copyDirectory(srcPath, destPath, exclude, links);
|
|
161
187
|
}
|
|
162
188
|
else {
|
|
189
|
+
if (links !== undefined && !(await clearLinkForWrite(destPath, links)))
|
|
190
|
+
continue;
|
|
163
191
|
await fs.copyFile(srcPath, destPath);
|
|
164
192
|
}
|
|
165
193
|
}
|
|
@@ -830,7 +858,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
|
|
|
830
858
|
throw new Error(`Generated skill references not found: ${sourceRoot}. ` +
|
|
831
859
|
`The whole generated tree is absent, so none of the ${manifest.length} references the ` +
|
|
832
860
|
`devflow:git agent is instructed to load would be installed. ` +
|
|
833
|
-
`Run \`npm run build:mds\` to regenerate
|
|
861
|
+
`Run \`npm run build:mds\` to regenerate ${sourceRoot} before install ` +
|
|
834
862
|
`(\`npm run build:cli\` alone does not produce it).`);
|
|
835
863
|
}
|
|
836
864
|
/**
|
|
@@ -1073,6 +1101,46 @@ export async function overlayGeneratedReferences(opts) {
|
|
|
1073
1101
|
}
|
|
1074
1102
|
/** The directory inside an installed skill that the reference overlay converges. */
|
|
1075
1103
|
const SKILL_REFERENCES_DIRNAME = 'references';
|
|
1104
|
+
/**
|
|
1105
|
+
* Prove, with `lstat`, that the overlay can run below `gitSkillDir`.
|
|
1106
|
+
*
|
|
1107
|
+
* The overlay stages, prunes and sets modes below the `references` entry, and the skill copy
|
|
1108
|
+
* before it writes there too. `mkdir` and `readdir` follow a link, so a link at either level
|
|
1109
|
+
* would send those writes into another directory. An absent entry is fine: the install creates it.
|
|
1110
|
+
* Fail closed: an entry that cannot be inspected is refused, and a link is never repaired,
|
|
1111
|
+
* because the installer did not create it.
|
|
1112
|
+
*
|
|
1113
|
+
* One answer for every caller that has no pre-clean to replace such a link: the converge, a
|
|
1114
|
+
* partial install, and a full install whose pre-clean could not remove one. `lstat` runs on
|
|
1115
|
+
* `references` only below a real skill directory, since through a linked one it would follow the
|
|
1116
|
+
* link. Never throws.
|
|
1117
|
+
*/
|
|
1118
|
+
export async function referencesHostState(gitSkillDir) {
|
|
1119
|
+
const skill = await directoryState(gitSkillDir);
|
|
1120
|
+
if (skill === 'absent')
|
|
1121
|
+
return { kind: 'absent' };
|
|
1122
|
+
if (skill !== 'real')
|
|
1123
|
+
return { kind: 'refused', path: gitSkillDir };
|
|
1124
|
+
const referencesDir = path.join(gitSkillDir, SKILL_REFERENCES_DIRNAME);
|
|
1125
|
+
const references = await directoryState(referencesDir);
|
|
1126
|
+
return references === 'absent' || references === 'real'
|
|
1127
|
+
? { kind: 'ready' }
|
|
1128
|
+
: { kind: 'refused', path: referencesDir };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* The report of an overlay refused as a whole: one failure per unit, as a refused converged
|
|
1132
|
+
* root reports (D-CONVERGED-ROOT-REAL), each carrying the state the unit was left in.
|
|
1133
|
+
* The classification reads; nothing is written.
|
|
1134
|
+
*/
|
|
1135
|
+
async function refusedOverlayFailures(refusedPath, referencesTarget, manifest) {
|
|
1136
|
+
const error = `${refusedPath} is not a real directory — nothing is copied, staged, pruned or set through it. ` +
|
|
1137
|
+
`Replace it with a real directory and re-run.`;
|
|
1138
|
+
const failures = [];
|
|
1139
|
+
for (const unit of planOverlayUnits(manifest)) {
|
|
1140
|
+
failures.push({ unit: unitRef(unit), state: await classifyUntouchedUnit(unit, referencesTarget), error });
|
|
1141
|
+
}
|
|
1142
|
+
return failures;
|
|
1143
|
+
}
|
|
1076
1144
|
/**
|
|
1077
1145
|
* What inside an installed skill directory the reference overlay owns, and the
|
|
1078
1146
|
* pre-clean must therefore leave standing (D-OVERLAY-OWNERSHIP).
|
|
@@ -1140,10 +1208,29 @@ export function overlayOwnedSkillPaths(manifest) {
|
|
|
1140
1208
|
* An unreadable directory is left alone rather than reported: the caller already
|
|
1141
1209
|
* swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
|
|
1142
1210
|
* read its target has nothing to remove from it.
|
|
1211
|
+
*
|
|
1212
|
+
* The walk never goes through a link. `readdir` follows one, so a link at `dir` itself is
|
|
1213
|
+
* unlinked and nothing is read from it, and below `dir` only a `Dirent` that is a real
|
|
1214
|
+
* directory is entered. The folder a link points to is left as it is. A link that stands where
|
|
1215
|
+
* the pre-clean expects its own folder or file (`dir`, or a path in `keep` or above one) is
|
|
1216
|
+
* returned, so the caller reports it; any other link is removed like the file it stands for.
|
|
1217
|
+
* A link at `dir` that cannot be removed is left in place and nothing is emptied.
|
|
1218
|
+
*
|
|
1219
|
+
* A link at a kept path or above one is returned as `awaitingWrite`, not as replaced: the
|
|
1220
|
+
* converger that owns the path has not written there yet, and a summary that says a real file or
|
|
1221
|
+
* folder stands where the link stood is true only after it has ({@link reportLinksOnceWritten}).
|
|
1222
|
+
* `leaveKeptLinks` is for a kept path whose converger replaces a link itself and reports it, never
|
|
1223
|
+
* writing through it (the tracker agent file): the link then stays for that converger.
|
|
1224
|
+
*
|
|
1225
|
+
* @returns The links at `dir` that this replaced, and the links it removed that a later write replaces.
|
|
1143
1226
|
*/
|
|
1144
|
-
async function emptyDirectoryExcept(dir, keep) {
|
|
1227
|
+
async function emptyDirectoryExcept(dir, keep, options = {}) {
|
|
1228
|
+
const rootLink = await unlinkLinkAt(dir);
|
|
1229
|
+
if (rootLink !== 'no-link')
|
|
1230
|
+
return { replacedLinks: rootLink === 'unlinked' ? [dir] : [], awaitingWrite: [] };
|
|
1145
1231
|
const kept = [...keep];
|
|
1146
1232
|
const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
|
|
1233
|
+
const awaitingWrite = [];
|
|
1147
1234
|
const walk = async (current, rel, depth) => {
|
|
1148
1235
|
let entries;
|
|
1149
1236
|
try {
|
|
@@ -1154,19 +1241,34 @@ async function emptyDirectoryExcept(dir, keep) {
|
|
|
1154
1241
|
}
|
|
1155
1242
|
for (const entry of entries) {
|
|
1156
1243
|
const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
1157
|
-
if (keep.has(entryRel) && !entry.isSymbolicLink())
|
|
1244
|
+
if (keep.has(entryRel) && (!entry.isSymbolicLink() || options.leaveKeptLinks === true))
|
|
1158
1245
|
continue;
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
await walk(path.join(current, entry.name), entryRel, depth + 1);
|
|
1246
|
+
const entryPath = path.join(current, entry.name);
|
|
1247
|
+
const holdsSomethingKept = kept.some(relPath => relPath.startsWith(`${entryRel}/`));
|
|
1248
|
+
if (entry.isDirectory() && depth < maxDepth && holdsSomethingKept) {
|
|
1249
|
+
await walk(entryPath, entryRel, depth + 1);
|
|
1164
1250
|
continue;
|
|
1165
1251
|
}
|
|
1166
|
-
await fs.rm(
|
|
1252
|
+
await fs.rm(entryPath, { recursive: true, force: true });
|
|
1253
|
+
if (entry.isSymbolicLink() && (holdsSomethingKept || keep.has(entryRel)))
|
|
1254
|
+
awaitingWrite.push(entryPath);
|
|
1167
1255
|
}
|
|
1168
1256
|
};
|
|
1169
1257
|
await walk(dir, '', 1);
|
|
1258
|
+
return { replacedLinks: [], awaitingWrite };
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
|
|
1262
|
+
* path. A path that is still absent, or a link again, is not a replacement and is not reported: the
|
|
1263
|
+
* step that should have written there failed or was refused, and it reports that failure itself.
|
|
1264
|
+
* Bounded by `removed`, which holds at most the kept paths of one pre-clean.
|
|
1265
|
+
*/
|
|
1266
|
+
async function reportLinksOnceWritten(report, removed) {
|
|
1267
|
+
for (const linkPath of removed) {
|
|
1268
|
+
const stat = await fs.lstat(linkPath).catch(() => undefined);
|
|
1269
|
+
if (stat !== undefined && !stat.isSymbolicLink())
|
|
1270
|
+
report.replacedLinks.push(linkPath);
|
|
1271
|
+
}
|
|
1170
1272
|
}
|
|
1171
1273
|
// ---------------------------------------------------------------------------
|
|
1172
1274
|
// Script composer
|
|
@@ -1190,6 +1292,9 @@ function collectRelativeImports(source) {
|
|
|
1190
1292
|
* under ~/.devflow/scripts/ at the same relative-to-dist/ paths
|
|
1191
1293
|
* (c) ~/.devflow/scripts/package.json → {"type":"module"}
|
|
1192
1294
|
*
|
|
1295
|
+
* The copy leaves out the orchestrator charter and the implement sentinel. Install the
|
|
1296
|
+
* directory with {@link installScripts}, which writes both after the copy.
|
|
1297
|
+
*
|
|
1193
1298
|
* Frozen externally-referenced paths:
|
|
1194
1299
|
* ~/.devflow/scripts/hooks/run-hook (hook bootstrap entry)
|
|
1195
1300
|
* ~/.devflow/scripts/hud.sh (HUD entry script)
|
|
@@ -1204,9 +1309,19 @@ export async function composeScripts(scriptsTarget) {
|
|
|
1204
1309
|
// mirrored dist/hud/ closure), so a re-run gave package.json an exec bit the first
|
|
1205
1310
|
// run never did and re-init stopped being a no-op on disk (#388 AC-3). Scoping the
|
|
1206
1311
|
// chmod to the copied entries makes the first and every later run agree.
|
|
1312
|
+
//
|
|
1313
|
+
// D-CHARTER-INSTALL-RENDER: the orchestrator charter and the implement sentinel are
|
|
1314
|
+
// NOT part of this verbatim copy. The charter is installed as a render of the packaged
|
|
1315
|
+
// source for the installed plugin set, and the sentinel exists only while implement is
|
|
1316
|
+
// installed; `installRenderedCharter` is the only writer of both, so no copy here can put
|
|
1317
|
+
// the full source charter or a stale sentinel back over a render.
|
|
1207
1318
|
const srcScripts = scriptsDir();
|
|
1319
|
+
const rendered = new Set([
|
|
1320
|
+
path.join(srcScripts, CHARTER_RELPATH),
|
|
1321
|
+
path.join(srcScripts, HANDOFF_SENTINEL_RELPATH),
|
|
1322
|
+
]);
|
|
1208
1323
|
try {
|
|
1209
|
-
await copyDirectory(srcScripts, scriptsTarget);
|
|
1324
|
+
await copyDirectory(srcScripts, scriptsTarget, rendered);
|
|
1210
1325
|
if (process.platform !== 'win32') {
|
|
1211
1326
|
for (const entry of await fs.readdir(srcScripts, { withFileTypes: true })) {
|
|
1212
1327
|
const copied = path.join(scriptsTarget, entry.name);
|
|
@@ -1268,6 +1383,23 @@ export async function composeScripts(scriptsTarget) {
|
|
|
1268
1383
|
}
|
|
1269
1384
|
catch { /* already exists from prior install — leave as-is */ }
|
|
1270
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Install the scripts directory whole: the verbatim copy of {@link composeScripts}, then the orchestrator
|
|
1388
|
+
* charter and the implement sentinel it skips.
|
|
1389
|
+
*
|
|
1390
|
+
* D-CHARTER-INSTALL-RENDER: a tree from `composeScripts` alone has no charter, so every caller outside this
|
|
1391
|
+
* module goes through here and none pairs the two steps by hand. The render is its own step, outside the
|
|
1392
|
+
* copy's catch-all, so its failure handling is the writer's own: a warning, never an abort.
|
|
1393
|
+
*/
|
|
1394
|
+
export async function installScripts(opts) {
|
|
1395
|
+
await composeScripts(opts.scriptsTarget);
|
|
1396
|
+
await installRenderedCharter({
|
|
1397
|
+
scriptsDir: opts.scriptsTarget,
|
|
1398
|
+
plugins: opts.plugins,
|
|
1399
|
+
warn: opts.warn,
|
|
1400
|
+
mode: 'install',
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1271
1403
|
/**
|
|
1272
1404
|
* Install one agent that carries the tracker-server stamp: the shipped source with
|
|
1273
1405
|
* `servers` appended to its `tools:` line, written in one step so no copy of it ever sits on
|
|
@@ -1344,7 +1476,7 @@ async function firstExisting(candidates) {
|
|
|
1344
1476
|
* installed in its place).
|
|
1345
1477
|
*
|
|
1346
1478
|
* The ONE spelling of that decision: the install loop and
|
|
1347
|
-
*
|
|
1479
|
+
* convergeVariantAxes both call it, so a skill the curation toggle installs
|
|
1348
1480
|
* later is shadowed exactly as one the install installed.
|
|
1349
1481
|
*
|
|
1350
1482
|
* The shipped source is stat-checked FIRST, even under a valid shadow, so an
|
|
@@ -1383,8 +1515,8 @@ export async function resolveSkillSource(skillName, devflowDir, packageRoot) {
|
|
|
1383
1515
|
* validateSkillShadow at install time. This only answers "did the user write
|
|
1384
1516
|
* one?", which is what dormancy reporting turns on.
|
|
1385
1517
|
*/
|
|
1386
|
-
async function listShadowedSkills(devflowDir) {
|
|
1387
|
-
const registry = new Set(getAllSkillNames());
|
|
1518
|
+
async function listShadowedSkills(devflowDir, folderWorkflows) {
|
|
1519
|
+
const registry = new Set(getAllSkillNames(folderWorkflows));
|
|
1388
1520
|
let entries;
|
|
1389
1521
|
try {
|
|
1390
1522
|
entries = await fs.readdir(path.join(devflowDir, 'skills'));
|
|
@@ -1432,16 +1564,118 @@ async function removeDeselectedSkills(skillsDir, names) {
|
|
|
1432
1564
|
}
|
|
1433
1565
|
return { removed, failed };
|
|
1434
1566
|
}
|
|
1567
|
+
/** The most variant files a refusal names; the count says how many more there are. */
|
|
1568
|
+
const MAX_REPORTED_MISSING_VARIANTS = 5;
|
|
1569
|
+
/** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
|
|
1570
|
+
function namedVariantFiles(items) {
|
|
1571
|
+
const named = items.slice(0, MAX_REPORTED_MISSING_VARIANTS).join(', ');
|
|
1572
|
+
const more = items.length > MAX_REPORTED_MISSING_VARIANTS ? ` and ${items.length - MAX_REPORTED_MISSING_VARIANTS} more` : '';
|
|
1573
|
+
return `${named}${more}`;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* The source lists the copy reads, most-preferred first. The check in
|
|
1577
|
+
* {@link requireComplianceVariantFiles} and the copy in {@link installCommandsAndAgents}
|
|
1578
|
+
* both take their lists from here, so the check looks at the files the copy takes.
|
|
1579
|
+
* An injected agent list wins as given.
|
|
1580
|
+
*/
|
|
1581
|
+
function copySourceDirs(opts) {
|
|
1582
|
+
const { curation, compliance, packageRoot } = opts;
|
|
1583
|
+
return {
|
|
1584
|
+
commands: commandSourceDirs({ curation, compliance }, packageRoot),
|
|
1585
|
+
agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, { curation, compliance }),
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Refuse a compliance-off install that cannot be trusted to carry the right text, before
|
|
1590
|
+
* any file is written. Compliance on reads nothing and returns.
|
|
1591
|
+
*
|
|
1592
|
+
* With compliance off, the installer must know which compiled prompts carry a compliance
|
|
1593
|
+
* arm: the build records that in dist/variant-axes.json, and the installer reads it here
|
|
1594
|
+
* rather than guess from the files it finds. A prompt with a compliance arm has no safe
|
|
1595
|
+
* fall-through, because its normal file, and on a both-off machine its curation-off file,
|
|
1596
|
+
* carry the compliance text the machine has switched off. So the install is refused when
|
|
1597
|
+
* - the manifest is absent, unreadable or not valid (the build has not run, or only
|
|
1598
|
+
* `build:cli` did); or
|
|
1599
|
+
* - a compliance-armed prompt has no file for the machine's EXACT switches
|
|
1600
|
+
* (requiredComplianceVariantFiles names the file: a both-off machine needs the file
|
|
1601
|
+
* built for both axes, never the curation-off one); or
|
|
1602
|
+
* - the copy would take another file for that prompt: the first existing file of its
|
|
1603
|
+
* kind and name over the copy's own source list (copySourceDirs) is not the required
|
|
1604
|
+
* file. A prompt whose list holds no file at all is left to the copy's own refusal.
|
|
1605
|
+
* A prompt with no compliance arm is not checked: it falls through to its normal file.
|
|
1606
|
+
*
|
|
1607
|
+
* The same refusal shape as {@link requireGeneratedTree}: one throw, before the unit loop,
|
|
1608
|
+
* naming the build step. Only the file reads, so it leaves no partial install behind.
|
|
1609
|
+
*/
|
|
1610
|
+
async function requireComplianceVariantFiles(opts) {
|
|
1611
|
+
const { curation, compliance, packageRoot } = opts;
|
|
1612
|
+
if (compliance)
|
|
1613
|
+
return;
|
|
1614
|
+
const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
|
|
1615
|
+
const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
|
|
1616
|
+
`(\`npm run build:cli\` alone does not produce them).`;
|
|
1617
|
+
let text;
|
|
1618
|
+
try {
|
|
1619
|
+
text = await fs.readFile(manifestFile, 'utf-8');
|
|
1620
|
+
}
|
|
1621
|
+
catch (err) {
|
|
1622
|
+
const reason = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
|
|
1623
|
+
throw new Error(`Variant manifest ${reason}: ${manifestFile}. ` +
|
|
1624
|
+
`With compliance off the installer needs it to find the prompts that carry a compliance arm, so no file was written. ${remedy}`);
|
|
1625
|
+
}
|
|
1626
|
+
const parsed = parseVariantManifest(text);
|
|
1627
|
+
if (!parsed.ok) {
|
|
1628
|
+
throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
|
|
1629
|
+
`No file was written. ${remedy}`);
|
|
1630
|
+
}
|
|
1631
|
+
const sourceDirs = copySourceDirs({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1632
|
+
const machine = `curation ${curation ? 'on' : 'off'} and compliance off`;
|
|
1633
|
+
const missing = [];
|
|
1634
|
+
const shadowed = [];
|
|
1635
|
+
for (const host of parsed.value) {
|
|
1636
|
+
const [rel] = requiredComplianceVariantFiles({ curation, compliance }, [host]);
|
|
1637
|
+
if (rel === undefined)
|
|
1638
|
+
continue;
|
|
1639
|
+
const required = path.join(packageRoot, ...rel.split('/'));
|
|
1640
|
+
let present = false;
|
|
1641
|
+
try {
|
|
1642
|
+
present = (await fs.stat(required)).isFile();
|
|
1643
|
+
}
|
|
1644
|
+
catch { /* absent — reported below */ }
|
|
1645
|
+
if (!present) {
|
|
1646
|
+
missing.push(rel);
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const dirs = host.kind === 'commands' ? sourceDirs.commands : sourceDirs.agents;
|
|
1650
|
+
const taken = await firstExisting(dirs.map(dir => path.join(dir, mdFileName(host.name))));
|
|
1651
|
+
if (taken !== undefined && path.resolve(taken) !== path.resolve(required)) {
|
|
1652
|
+
shadowed.push(`${rel} (the copy would take ${path.relative(packageRoot, taken).split(path.sep).join('/')})`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (missing.length > 0) {
|
|
1656
|
+
throw new Error(`Variant file not found for ${missing.length} prompt(s) that carry a compliance arm, on a machine with ` +
|
|
1657
|
+
`${machine}: ${namedVariantFiles(missing)}. ` +
|
|
1658
|
+
`Another variant of the same prompt would carry the wrong text, so no file was written. ${remedy}`);
|
|
1659
|
+
}
|
|
1660
|
+
if (shadowed.length > 0) {
|
|
1661
|
+
throw new Error(`Another file comes first for ${shadowed.length} prompt(s) that carry a compliance arm, on a machine with ` +
|
|
1662
|
+
`${machine}: ${namedVariantFiles(shadowed)}. ` +
|
|
1663
|
+
`The install would carry the wrong text, so no file was written. ${remedy}`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1435
1666
|
/**
|
|
1436
1667
|
* Write the commands and the agents of `plugins` into `claudeDir`, and nothing else:
|
|
1437
1668
|
* no sweep, no stamp, no skills. The one copy path shared by `installViaFileCopy` and
|
|
1438
1669
|
* the selective uninstall, which rewrites the files of the plugins that stay installed
|
|
1439
1670
|
* after it removed a retired agent.
|
|
1440
1671
|
*
|
|
1441
|
-
* Commands come from dist/commands/{name}.md, or — with
|
|
1442
|
-
*
|
|
1672
|
+
* Commands come from dist/commands/{name}.md, or — with an axis off — the off variant of
|
|
1673
|
+
* the host when it has one, such as dist/curation-off/commands/{name}.md
|
|
1443
1674
|
* (D-LEARNING-VARIANT-INSTALL; commandSourceDirs owns the order). A declared command
|
|
1444
|
-
* with no compiled source file is a hard error, not a skip.
|
|
1675
|
+
* with no compiled source file is a hard error, not a skip. With compliance off the copy
|
|
1676
|
+
* first checks that every compliance-armed prompt has its file for the machine's exact
|
|
1677
|
+
* switches and that the copy takes that file, and refuses before it writes anything
|
|
1678
|
+
* otherwise ({@link requireComplianceVariantFiles}).
|
|
1445
1679
|
*
|
|
1446
1680
|
* Agents resolve dist-first with a src fallback: dist/agents/{name}.md (compiled from an
|
|
1447
1681
|
* .mds generator host) wins over src/assets/agents/{name}.md. A declared agent absent from
|
|
@@ -1458,32 +1692,45 @@ async function removeDeselectedSkills(skillsDir, names) {
|
|
|
1458
1692
|
* known and leaves it alone.
|
|
1459
1693
|
*/
|
|
1460
1694
|
export async function installCommandsAndAgents(opts) {
|
|
1461
|
-
const { plugins, agentsMap, claudeDir, curation, trackerServers, warn } = opts;
|
|
1695
|
+
const { plugins, agentsMap, claudeDir, curation, compliance, trackerServers, warn } = opts;
|
|
1696
|
+
const skip = opts.skip ?? {};
|
|
1697
|
+
const packageRoot = opts.packageRoot ?? getPackageRoot();
|
|
1698
|
+
// Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
|
|
1699
|
+
// this call covers the caller that reaches the copy alone (the selective uninstall).
|
|
1700
|
+
await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1701
|
+
const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
|
|
1702
|
+
curation,
|
|
1703
|
+
compliance,
|
|
1704
|
+
packageRoot,
|
|
1705
|
+
agentSourceDirs: opts.agentSourceDirs,
|
|
1706
|
+
});
|
|
1462
1707
|
const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
|
|
1463
|
-
const
|
|
1708
|
+
const folderWorkflows = opts.folderWorkflows ?? WORKFLOW_FOLDER_NAMES;
|
|
1464
1709
|
const commandsSourceNames = new Set();
|
|
1465
1710
|
for (const plugin of plugins) {
|
|
1466
1711
|
for (const cmd of plugin.commands) {
|
|
1467
1712
|
const name = cmd.startsWith('/') ? cmd.slice(1) : cmd;
|
|
1468
|
-
|
|
1713
|
+
// D-WORKFLOW-FOLDER-INSTALL: a workflow that ships as a skill folder has no command file.
|
|
1714
|
+
if (!folderWorkflows.has(name))
|
|
1715
|
+
commandsSourceNames.add(name);
|
|
1469
1716
|
}
|
|
1470
1717
|
}
|
|
1471
|
-
if (commandsSourceNames.size > 0) {
|
|
1718
|
+
if (commandsSourceNames.size > 0 && skip.commands !== true) {
|
|
1472
1719
|
await fs.mkdir(commandsTarget, { recursive: true });
|
|
1473
1720
|
for (const name of commandsSourceNames) {
|
|
1474
1721
|
const srcFile = await firstExisting(commandDirs.map(dir => path.join(dir, mdFileName(name))));
|
|
1475
1722
|
if (srcFile === undefined) {
|
|
1476
1723
|
// The error names the curation-ON path: that file exists for every declared
|
|
1477
1724
|
// 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(
|
|
1725
|
+
throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(ALL_SWITCHES_ON, packageRoot)[0], mdFileName(name))}. ` +
|
|
1479
1726
|
`Ensure build:mds ran successfully before install.`);
|
|
1480
1727
|
}
|
|
1481
|
-
|
|
1728
|
+
const commandTarget = path.join(commandsTarget, mdFileName(name));
|
|
1729
|
+
if (await clearLinkForWrite(commandTarget, opts.links))
|
|
1730
|
+
await fs.copyFile(srcFile, commandTarget);
|
|
1482
1731
|
}
|
|
1483
1732
|
}
|
|
1484
1733
|
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
1734
|
const allAgentNames = new Set();
|
|
1488
1735
|
for (const plugin of plugins) {
|
|
1489
1736
|
for (const agent of plugin.agents) {
|
|
@@ -1494,7 +1741,7 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1494
1741
|
}
|
|
1495
1742
|
}
|
|
1496
1743
|
}
|
|
1497
|
-
if (allAgentNames.size > 0) {
|
|
1744
|
+
if (allAgentNames.size > 0 && skip.agents !== true) {
|
|
1498
1745
|
await fs.mkdir(agentsTarget, { recursive: true });
|
|
1499
1746
|
for (const agentName of allAgentNames) {
|
|
1500
1747
|
if (await skipsForUserOwnedType(agentName, claudeDir, agentsTarget, warn))
|
|
@@ -1502,11 +1749,13 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1502
1749
|
const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
|
|
1503
1750
|
const srcFile = await firstExisting(candidates);
|
|
1504
1751
|
if (srcFile === undefined) {
|
|
1505
|
-
throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs()[0], mdFileName(agentName))}. ` +
|
|
1752
|
+
throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs(packageRoot)[0], mdFileName(agentName))}. ` +
|
|
1506
1753
|
`Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
|
|
1507
1754
|
`ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
|
|
1508
1755
|
}
|
|
1509
1756
|
const agentTarget = path.join(agentsTarget, mdFileName(agentName));
|
|
1757
|
+
if (!(await clearLinkForWrite(agentTarget, opts.links)))
|
|
1758
|
+
continue;
|
|
1510
1759
|
if (isTrackerStampedAgent(agentName)) {
|
|
1511
1760
|
await writeStampedAgent(srcFile, agentTarget, trackerServers, warn);
|
|
1512
1761
|
}
|
|
@@ -1524,7 +1773,48 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1524
1773
|
* Returns an InstallReport describing which shadows were applied and which were skipped.
|
|
1525
1774
|
*/
|
|
1526
1775
|
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;
|
|
1776
|
+
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, } = options;
|
|
1777
|
+
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
1778
|
+
// D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
|
|
1779
|
+
// empty, and with it every folder step below is a no-op over an empty set.
|
|
1780
|
+
const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
|
|
1781
|
+
const folderWorkflows = workflowFolderNames(folderRegistry);
|
|
1782
|
+
const folderSwitches = { curation, compliance };
|
|
1783
|
+
const folderFiles = new Map();
|
|
1784
|
+
/** The files of one registered folder, read once. A failed read is a reason and never a throw. */
|
|
1785
|
+
const readFolder = async (folder) => {
|
|
1786
|
+
const cached = folderFiles.get(folder.name);
|
|
1787
|
+
if (cached !== undefined)
|
|
1788
|
+
return { ok: true, files: cached };
|
|
1789
|
+
const read = await readWorkflowFolder(folder, folderSwitches, options.workflowFoldersRoot ?? packageRoot);
|
|
1790
|
+
if (read.ok)
|
|
1791
|
+
folderFiles.set(folder.name, read.files);
|
|
1792
|
+
return read;
|
|
1793
|
+
};
|
|
1794
|
+
const readFolders = async (names) => {
|
|
1795
|
+
for (const folder of folderRegistry) {
|
|
1796
|
+
if (!names.has(folder.name))
|
|
1797
|
+
continue;
|
|
1798
|
+
const read = await readFolder(folder);
|
|
1799
|
+
if (!read.ok)
|
|
1800
|
+
throw new Error(read.reason);
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
// The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
|
|
1804
|
+
// must leave the installed tree as it found it.
|
|
1805
|
+
await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: options.agentSourceDirs });
|
|
1806
|
+
// The folders this run installs are read from the exact-key tree before the pre-clean for the
|
|
1807
|
+
// same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
|
|
1808
|
+
await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
|
|
1809
|
+
// The references the machine's compliance switch installs: the manifest, and the ONE source
|
|
1810
|
+
// root they are overlaid from. With compliance off the root is the compliance-off tree, and its
|
|
1811
|
+
// absence refuses the install here, before the pre-clean, so a refused install leaves the
|
|
1812
|
+
// installed tree as it found it and never falls through to the compliance-on references (AC-50).
|
|
1813
|
+
const refsManifest = installedReferenceManifest({ compliance });
|
|
1814
|
+
const refsSourceRoot = skillRefsSourceDir(compliance, packageRoot);
|
|
1815
|
+
if (omitCurationGatedSkills(skillsMap, curation).has(SKILL_REFS_SKILL_NAME)) {
|
|
1816
|
+
await requireGeneratedTree(refsSourceRoot, refsManifest);
|
|
1817
|
+
}
|
|
1528
1818
|
const report = {
|
|
1529
1819
|
shadowedSkills: [],
|
|
1530
1820
|
shadowedRules: [],
|
|
@@ -1534,6 +1824,8 @@ export async function installViaFileCopy(options) {
|
|
|
1534
1824
|
overlaidRefs: [],
|
|
1535
1825
|
unchangedRefs: [],
|
|
1536
1826
|
overlayFailures: [],
|
|
1827
|
+
replacedLinks: [],
|
|
1828
|
+
refusedFolders: [],
|
|
1537
1829
|
removedSkills: [],
|
|
1538
1830
|
dormantShadows: [],
|
|
1539
1831
|
};
|
|
@@ -1551,9 +1843,18 @@ export async function installViaFileCopy(options) {
|
|
|
1551
1843
|
const skillPlan = resolveSkillInstallPlan({
|
|
1552
1844
|
effectivePlugins: options.effectivePlugins ?? plugins,
|
|
1553
1845
|
isPartialInstall,
|
|
1554
|
-
shadowedSkills: await listShadowedSkills(devflowDir),
|
|
1846
|
+
shadowedSkills: await listShadowedSkills(devflowDir, folderWorkflows),
|
|
1847
|
+
folderWorkflows,
|
|
1555
1848
|
});
|
|
1556
1849
|
report.dormantShadows = [...skillPlan.dormantShadows];
|
|
1850
|
+
// D-WORKFLOW-SHADOW-REFUSAL, install half: a folder installs from the build and has no source to
|
|
1851
|
+
// overlay, so a shadow of its name is ignored and kept. The warning says so; it is not dormant.
|
|
1852
|
+
for (const name of skillPlan.ignoredFolderShadows) {
|
|
1853
|
+
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.`);
|
|
1854
|
+
}
|
|
1855
|
+
// Links the pre-clean removed at a path that a later step of this install writes. Each is reported
|
|
1856
|
+
// as replaced only once a real file or folder stands there (reportLinksOnceWritten).
|
|
1857
|
+
const awaitingWrite = [];
|
|
1557
1858
|
// Clean old Devflow files before installing
|
|
1558
1859
|
spinner.message('Cleaning old files...');
|
|
1559
1860
|
if (!isPartialInstall) {
|
|
@@ -1566,7 +1867,7 @@ export async function installViaFileCopy(options) {
|
|
|
1566
1867
|
];
|
|
1567
1868
|
for (const dir of oldDirs) {
|
|
1568
1869
|
try {
|
|
1569
|
-
await
|
|
1870
|
+
report.replacedLinks.push(...await removeReportingLink(dir));
|
|
1570
1871
|
}
|
|
1571
1872
|
catch { /* ignore */ }
|
|
1572
1873
|
}
|
|
@@ -1579,11 +1880,28 @@ export async function installViaFileCopy(options) {
|
|
|
1579
1880
|
// exactly as the unconditional wipe removed it, and the file is still
|
|
1580
1881
|
// converged on this run — drift in it is restored, so preserving it strands
|
|
1581
1882
|
// nothing.
|
|
1883
|
+
//
|
|
1884
|
+
// A link at that file is left standing: the converge replaces it with a real file and reports it
|
|
1885
|
+
// once it has written, so the install does not claim a replacement the converge may not make.
|
|
1582
1886
|
try {
|
|
1583
|
-
await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]));
|
|
1887
|
+
const emptied = await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]), { leaveKeptLinks: true });
|
|
1888
|
+
report.replacedLinks.push(...emptied.replacedLinks);
|
|
1889
|
+
awaitingWrite.push(...emptied.awaitingWrite);
|
|
1584
1890
|
}
|
|
1585
1891
|
catch { /* ignore */ }
|
|
1586
1892
|
}
|
|
1893
|
+
// The commands, agents and rules folders, each proved a real folder (or absent) before any write
|
|
1894
|
+
// below. A full install's pre-clean replaced a link at each of them. A partial install has no
|
|
1895
|
+
// pre-clean, and a link the pre-clean could not remove is still standing, so every write below
|
|
1896
|
+
// would go through it into another directory: the sweeps, the copies and the restamp. A refused
|
|
1897
|
+
// folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
|
|
1898
|
+
// only when the selection has a rule, so only then is it proved.
|
|
1899
|
+
const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
|
|
1900
|
+
const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
|
|
1901
|
+
const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
|
|
1902
|
+
const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
|
|
1903
|
+
const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
|
|
1904
|
+
const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
|
|
1587
1905
|
// Sweep stale devflow:* skill dirs — ungated: runs on every install shape,
|
|
1588
1906
|
// including partial installs, so renamed/deleted skills are pruned promptly.
|
|
1589
1907
|
// knownNames spans ALL plugins (getAllSkillNames) so skills from uninstalled
|
|
@@ -1596,7 +1914,7 @@ export async function installViaFileCopy(options) {
|
|
|
1596
1914
|
// are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
|
|
1597
1915
|
// runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
|
|
1598
1916
|
// then re-materialized by converge, creating a false-orphan report on every install.
|
|
1599
|
-
recordSweep(report, 'skill', await sweepOrphanedAssets(path.join(claudeDir, 'skills'), new Set([...getAllSkillNames(), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null));
|
|
1917
|
+
recordSweep(report, 'skill', await sweepOrphanedAssets(path.join(claudeDir, 'skills'), new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null));
|
|
1600
1918
|
// Pre-clean the prefixed install targets before re-copying so stale content
|
|
1601
1919
|
// never bleeds into a fresh install. Bare pre-namespace dirs at
|
|
1602
1920
|
// ~/.claude/skills/{name} are owned solely by the frozen LEGACY_SKILL_NAMES
|
|
@@ -1632,22 +1950,35 @@ export async function installViaFileCopy(options) {
|
|
|
1632
1950
|
// Everything else in the directory is still emptied, so a stale hand-authored skill
|
|
1633
1951
|
// file — including a reference at the references ROOT, which the overlay may replace
|
|
1634
1952
|
// but never delete (D-OVERLAY-FLAT-UNIT) — does not survive a full install.
|
|
1953
|
+
//
|
|
1954
|
+
// A target that is a link is unlinked and reported in `replacedLinks`, never emptied
|
|
1955
|
+
// through: the folder it points to is not Devflow's, and the install below writes a real
|
|
1956
|
+
// folder in its place.
|
|
1635
1957
|
if (!isPartialInstall) {
|
|
1636
|
-
const overlayOwned = overlayOwnedSkillPaths(
|
|
1958
|
+
const overlayOwned = overlayOwnedSkillPaths(refsManifest);
|
|
1637
1959
|
for (const skill of skillsMap.keys()) {
|
|
1638
1960
|
// Empty the prefixed directory (its contents are re-created during the install
|
|
1639
1961
|
// phase), minus whatever another converger owns inside it.
|
|
1640
1962
|
const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
|
|
1641
1963
|
try {
|
|
1642
1964
|
if (skill === SKILL_REFS_SKILL_NAME) {
|
|
1643
|
-
await emptyDirectoryExcept(target, overlayOwned);
|
|
1965
|
+
const emptied = await emptyDirectoryExcept(target, overlayOwned);
|
|
1966
|
+
report.replacedLinks.push(...emptied.replacedLinks);
|
|
1967
|
+
awaitingWrite.push(...emptied.awaitingWrite);
|
|
1644
1968
|
}
|
|
1645
1969
|
else {
|
|
1646
|
-
await
|
|
1970
|
+
report.replacedLinks.push(...await removeReportingLink(target));
|
|
1647
1971
|
}
|
|
1648
1972
|
}
|
|
1649
1973
|
catch { /* ignore */ }
|
|
1650
1974
|
}
|
|
1975
|
+
// A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
|
|
1976
|
+
// for a skill. A link it cannot remove is refused by the install below.
|
|
1977
|
+
for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
|
|
1978
|
+
const target = path.join(claudeDir, 'skills', prefixSkillName(name));
|
|
1979
|
+
if ((await unlinkLinkAt(target)) === 'unlinked')
|
|
1980
|
+
report.replacedLinks.push(target);
|
|
1981
|
+
}
|
|
1651
1982
|
}
|
|
1652
1983
|
// Remove the skills no selected plugin owns or requires — the deselection half
|
|
1653
1984
|
// of the scoped install. Empty on a partial install by construction
|
|
@@ -1655,7 +1986,7 @@ export async function installViaFileCopy(options) {
|
|
|
1655
1986
|
// (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
|
|
1656
1987
|
// was on disk and is now gone: the plan is registry arithmetic and mostly names
|
|
1657
1988
|
// skills that were never installed.
|
|
1658
|
-
const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), skillPlan.remove);
|
|
1989
|
+
const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), [...skillPlan.remove, ...skillPlan.removeFolders]);
|
|
1659
1990
|
report.removedSkills.push(...deselected.removed);
|
|
1660
1991
|
for (const failure of deselected.failed) {
|
|
1661
1992
|
warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
|
|
@@ -1668,8 +1999,9 @@ export async function installViaFileCopy(options) {
|
|
|
1668
1999
|
// Ungated: runs on every install shape. knownNames spans ALL plugins
|
|
1669
2000
|
// (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
|
|
1670
2001
|
// names absent from the full registry are removed.
|
|
1671
|
-
const
|
|
1672
|
-
|
|
2002
|
+
const agentSweep = agentsRefused
|
|
2003
|
+
? { scanned: 0, removed: [], failed: [] }
|
|
2004
|
+
: await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName);
|
|
1673
2005
|
recordSweep(report, 'agent', agentSweep);
|
|
1674
2006
|
// A partial install writes the selected plugins' commands and agents and leaves every
|
|
1675
2007
|
// other plugin's files alone, which is only safe while those files still name agents
|
|
@@ -1688,26 +2020,37 @@ export async function installViaFileCopy(options) {
|
|
|
1688
2020
|
agentsMap: commandAgentMap,
|
|
1689
2021
|
claudeDir,
|
|
1690
2022
|
curation,
|
|
2023
|
+
compliance,
|
|
2024
|
+
packageRoot,
|
|
1691
2025
|
trackerServers,
|
|
1692
2026
|
agentSourceDirs: options.agentSourceDirs,
|
|
2027
|
+
skip: { commands: commandsRefused, agents: agentsRefused },
|
|
2028
|
+
links: report,
|
|
2029
|
+
folderWorkflows,
|
|
1693
2030
|
warn,
|
|
1694
2031
|
});
|
|
1695
2032
|
// Sweep stale command files — ungated: runs on every install shape.
|
|
1696
2033
|
// knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
|
|
1697
2034
|
// plugins survive a partial run. Only names absent from the full registry are removed.
|
|
1698
|
-
|
|
2035
|
+
// A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
|
|
2036
|
+
if (!commandsRefused) {
|
|
2037
|
+
recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName));
|
|
2038
|
+
}
|
|
1699
2039
|
// D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
|
|
1700
2040
|
// installed /code-review, on every install shape. This is the language gate the command reads
|
|
1701
2041
|
// (no run-time probe of Claude Code's directory), so it runs after the copy and the sweep and
|
|
1702
2042
|
// whether or not code-review's own plugin is in this run: `--plugin=devflow-typescript` copies no
|
|
1703
2043
|
// command, yet must update a code-review an earlier run installed. The copy above wrote the shipped
|
|
1704
2044
|
// `(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
|
-
|
|
2045
|
+
// pre-clean can defeat it. A failure is a warning, never an abort. A refused commands folder is not
|
|
2046
|
+
// restamped: the rewrite would go through the link.
|
|
2047
|
+
if (!commandsRefused) {
|
|
2048
|
+
await restampInstalledCommands({
|
|
2049
|
+
claudeDir,
|
|
2050
|
+
effectivePlugins: options.effectivePlugins ?? plugins,
|
|
2051
|
+
warn,
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
1711
2054
|
// Install skills from ALL plugins (skillsMap covers all plugins, not just selected).
|
|
1712
2055
|
// Resolved from flat src/assets/skills/{name}/ (no per-plugin subdirectory).
|
|
1713
2056
|
// A declared skill whose source directory is absent is a build/packaging failure
|
|
@@ -1716,12 +2059,32 @@ export async function installViaFileCopy(options) {
|
|
|
1716
2059
|
// D-LEARNING-VARIANT-INSTALL: a curation-off machine skips the curation-gated
|
|
1717
2060
|
// skills here. The pre-clean above walked the UNFILTERED map, so on a full
|
|
1718
2061
|
// install a leftover copy is already gone; a partial install leaves removal to
|
|
1719
|
-
//
|
|
2062
|
+
// convergeVariantAxes, which init runs straight after this call.
|
|
1720
2063
|
spinner.message('Installing skills...');
|
|
1721
2064
|
for (const [skillName] of omitCurationGatedSkills(skillsMap, curation)) {
|
|
1722
|
-
const resolved = await resolveSkillSource(skillName, devflowDir);
|
|
1723
2065
|
const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
|
|
1724
|
-
|
|
2066
|
+
// A full install's pre-clean replaced a link at the skill or its references entry. A partial
|
|
2067
|
+
// install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
|
|
2068
|
+
// copy and the overlay below would write through such a link into another directory: the whole
|
|
2069
|
+
// skill is refused instead and listed in `refusedFolders`. The references skill is also reported
|
|
2070
|
+
// like a refused overlay, one failure for each unit.
|
|
2071
|
+
if (skillName === SKILL_REFS_SKILL_NAME) {
|
|
2072
|
+
const host = await referencesHostState(skillTarget);
|
|
2073
|
+
if (host.kind === 'refused') {
|
|
2074
|
+
report.refusedFolders.push(host.path);
|
|
2075
|
+
report.overlayFailures.push(...await refusedOverlayFailures(host.path, path.join(skillTarget, SKILL_REFERENCES_DIRNAME), refsManifest));
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
else if (await refuseUnrealFolder(report, skillTarget)) {
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
// With compliance off the copy leaves out the posting files of COMPLIANCE_OFF_REMOVED_SKILL_FILES.
|
|
2083
|
+
// They are named below the source the copy reads, shadow or shipped, so the posting text is never
|
|
2084
|
+
// on the machine, even when the install stops or a removal is refused later. With compliance on
|
|
2085
|
+
// nothing is excluded.
|
|
2086
|
+
const resolved = await resolveSkillSource(skillName, devflowDir);
|
|
2087
|
+
await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
|
|
1725
2088
|
if (resolved.shadow === 'valid') {
|
|
1726
2089
|
report.shadowedSkills.push(skillName);
|
|
1727
2090
|
}
|
|
@@ -1737,7 +2100,9 @@ export async function installViaFileCopy(options) {
|
|
|
1737
2100
|
referencesTarget: path.join(skillTarget, 'references'),
|
|
1738
2101
|
// Every provider's mechanics (D-INSTALL-ALL-PROVIDERS). The overlay
|
|
1739
2102
|
// converges rather than merges, so a retired generated document is pruned.
|
|
1740
|
-
manifest
|
|
2103
|
+
// The manifest and the source root follow the machine compliance switch together.
|
|
2104
|
+
sourceRoot: refsSourceRoot,
|
|
2105
|
+
manifest: refsManifest,
|
|
1741
2106
|
warn,
|
|
1742
2107
|
});
|
|
1743
2108
|
report.overlaidRefs.push(...overlay.overlaidRefs);
|
|
@@ -1746,13 +2111,47 @@ export async function installViaFileCopy(options) {
|
|
|
1746
2111
|
recordSweep(report, 'reference', overlay.pruned);
|
|
1747
2112
|
}
|
|
1748
2113
|
}
|
|
2114
|
+
// The skill copy and the overlay have written. A link the pre-clean removed at a path they write is
|
|
2115
|
+
// reported now if a real file or folder stands there, and not at all if a unit failed or was refused.
|
|
2116
|
+
await reportLinksOnceWritten(report, awaitingWrite);
|
|
2117
|
+
// With compliance off, the flat skill files that only the posting steps load are removed by
|
|
2118
|
+
// exact name (COMPLIANCE_OFF_REMOVED_SKILL_FILES), on every install shape. The copy above wrote
|
|
2119
|
+
// none of them; this step removes the ones an earlier compliance-on install left, which a partial
|
|
2120
|
+
// install copies over without clearing. A removal that was refused is a reported failure and
|
|
2121
|
+
// never an abort; the compliance converge that init runs next fails closed on the same files.
|
|
2122
|
+
if (!compliance) {
|
|
2123
|
+
const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
|
|
2124
|
+
for (const refusal of removal.refused) {
|
|
2125
|
+
report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
// D-WORKFLOW-FOLDER-INSTALL: the workflow skill folders of the plugins whose commands this run
|
|
2129
|
+
// writes, each from the exact-key tree read above. Before the scripts step, so the hand-off
|
|
2130
|
+
// sentinel that step installs never stands without the folder it names. A widened run may add a
|
|
2131
|
+
// folder the early read did not cover. Each folder is read, installed and reported on its own, and
|
|
2132
|
+
// a read failure is a warning, since the pre-clean has run: one broken folder never hides the next.
|
|
2133
|
+
const foldersToInstall = selectedWorkflowFolders(commandAgentPlugins, folderWorkflows);
|
|
2134
|
+
if (foldersToInstall.size > 0)
|
|
2135
|
+
spinner.message('Installing workflow skills...');
|
|
2136
|
+
for (const folder of folderRegistry) {
|
|
2137
|
+
if (!foldersToInstall.has(folder.name))
|
|
2138
|
+
continue;
|
|
2139
|
+
const read = await readFolder(folder);
|
|
2140
|
+
if (!read.ok) {
|
|
2141
|
+
warn(`workflow skill folder: ${read.reason}`);
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report });
|
|
2145
|
+
if (installed.outcome === 'failed') {
|
|
2146
|
+
warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
1749
2149
|
// Install rules from selected plugins (rulesMap covers selected plugins only).
|
|
1750
2150
|
// Rules are flat .md files resolved from src/assets/rules/{name}.md (no per-plugin subdir).
|
|
1751
2151
|
spinner.message('Installing rules...');
|
|
1752
|
-
|
|
1753
|
-
if (rulesMap.size > 0) {
|
|
2152
|
+
if (rulesMap.size > 0 && !rulesRefused) {
|
|
1754
2153
|
await fs.mkdir(rulesTarget, { recursive: true });
|
|
1755
|
-
const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget);
|
|
2154
|
+
const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget, report);
|
|
1756
2155
|
for (const { ruleName, outcome } of outcomes) {
|
|
1757
2156
|
if (outcome === 'shadow') {
|
|
1758
2157
|
report.shadowedRules.push(ruleName);
|
|
@@ -1769,7 +2168,13 @@ export async function installViaFileCopy(options) {
|
|
|
1769
2168
|
// (no dev tooling, no raw TypeScript).
|
|
1770
2169
|
spinner.message('Installing scripts...');
|
|
1771
2170
|
const scriptsTarget = path.join(devflowDir, 'scripts');
|
|
1772
|
-
|
|
2171
|
+
// The charter and the sentinel render from the EFFECTIVE selection (a partial install
|
|
2172
|
+
// keeps what earlier runs installed).
|
|
2173
|
+
await installScripts({
|
|
2174
|
+
scriptsTarget,
|
|
2175
|
+
plugins: options.effectivePlugins ?? plugins,
|
|
2176
|
+
warn,
|
|
2177
|
+
});
|
|
1773
2178
|
spinner.stop('Components installed via file copy');
|
|
1774
2179
|
return report;
|
|
1775
2180
|
}
|