devflow-kit 3.12.1 → 3.13.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 +16 -0
- package/dist/agents/architect.md +10 -9
- package/dist/agents/code.md +6 -6
- package/dist/agents/design.md +7 -7
- package/dist/agents/harden.md +6 -8
- package/dist/agents/review.md +5 -6
- package/dist/cli/commands/init-seed.js +5 -1
- package/dist/cli/commands/init.js +27 -19
- package/dist/cli/commands/tdd.js +230 -0
- package/dist/cli/commands/uninstall.js +2 -2
- package/dist/cli.js +2 -0
- package/dist/commands/implement.md +1 -1
- package/dist/commands/plan.md +23 -61
- package/dist/compliance-off/commands/implement.md +1 -1
- package/dist/compliance-off/commands/plan.md +23 -61
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +1 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +1 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +1 -0
- package/dist/compliance-tdd-off/agents/triage.md +173 -0
- package/dist/core/assets.js +40 -33
- package/dist/core/curation-variants.js +191 -76
- package/dist/core/feature-switch.js +3 -2
- package/dist/core/installed-variants.js +45 -14
- package/dist/core/manifest.js +3 -0
- package/dist/core/mds-variants.js +13 -6
- package/dist/core/plugins.js +38 -9
- package/dist/curation-compliance-off/commands/implement.md +1 -1
- package/dist/curation-compliance-off/commands/plan.md +23 -61
- package/dist/curation-compliance-tdd-off/agents/triage.md +170 -0
- package/dist/curation-off/agents/architect.md +10 -9
- package/dist/curation-off/agents/code.md +6 -6
- package/dist/curation-off/agents/design.md +7 -7
- package/dist/curation-off/agents/harden.md +6 -8
- package/dist/curation-off/agents/review.md +5 -6
- package/dist/curation-off/commands/implement.md +1 -1
- package/dist/curation-off/commands/plan.md +23 -61
- package/dist/curation-tdd-off/agents/code.md +173 -0
- package/dist/curation-tdd-off/agents/triage.md +171 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +1 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +1 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +1 -0
- package/dist/targets/claude-code/agent-type-owner.js +20 -15
- package/dist/targets/claude-code/curation-install.js +120 -99
- package/dist/targets/claude-code/installer.js +26 -29
- package/dist/targets/claude-code/shipped-set.js +2 -3
- package/dist/tdd-off/agents/code.md +176 -0
- package/dist/tdd-off/agents/triage.md +174 -0
- package/dist/variant-axes.json +54 -27
- package/package.json +1 -1
- package/src/assets/agents/_partials/_design.mds +7 -7
- package/src/assets/agents/architect.mds +3 -2
- package/src/assets/agents/code.mds +19 -7
- package/src/assets/agents/harden.mds +6 -8
- package/src/assets/agents/plan.md +63 -0
- package/src/assets/agents/review.mds +5 -6
- package/src/assets/agents/triage.mds +4 -0
- package/src/assets/commands/implement.mds +1 -1
- package/src/assets/commands/plan.mds +22 -68
- package/src/assets/mds/git/_ops_steps.mds +1 -0
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +3 -3
- package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -9
- package/src/assets/skills/design-review/SKILL.md +9 -12
- package/src/assets/skills/design-review/references/anti-patterns.md +4 -1
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/references/patterns.md +3 -0
- package/src/assets/skills/patterns/SKILL.md +2 -4
- package/src/assets/skills/quality-gates/SKILL.md +2 -2
- package/src/assets/skills/quality-gates/references/patterns.md +3 -3
- package/src/assets/skills/quality-gates/references/report-template.md +2 -2
- package/src/assets/skills/quality-gates/references/violations.md +5 -5
- package/src/assets/skills/test-driven-development/SKILL.md +5 -5
- package/src/assets/skills/test-driven-development/references/rationalization-prevention.md +1 -1
- package/src/assets/skills/testing/SKILL.md +9 -9
- package/src/assets/skills/testing/references/report-template.md +1 -1
- package/src/assets/skills/testing/references/violations.md +1 -1
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prompt-variant installer for the Claude Code target: the curation and
|
|
2
|
+
* Prompt-variant installer for the Claude Code target: the curation, compliance and tdd switches.
|
|
3
3
|
*
|
|
4
|
-
* D-LEARNING-VARIANT-INSTALL: the install follows the machine's
|
|
5
|
-
* (`features.curation` and `features.
|
|
6
|
-
* nothing else. Each command and agent that has an arm for a switch that is off is
|
|
4
|
+
* D-LEARNING-VARIANT-INSTALL: the install follows the machine's switches
|
|
5
|
+
* (`features.curation`, `features.compliance` and `features.tdd` in ~/.devflow/manifest.json)
|
|
6
|
+
* and nothing else. Each command and agent that has an arm for a switch that is off is
|
|
7
7
|
* installed from the build tree of the machine's exact switch combination
|
|
8
|
-
* (dist/{curation-off,compliance-off,
|
|
9
|
-
* D-LEARNING-VARIANTS), and
|
|
10
|
-
*
|
|
8
|
+
* (the trees of OFF_OUTPUT_DIRS, such as dist/{curation-off,compliance-off,tdd-off}/{commands,agents},
|
|
9
|
+
* built by D-LEARNING-VARIANTS), and a skill that an off switch gates is not installed (the
|
|
10
|
+
* apply-decisions skill with curation off, the test-driven-development skill with tdd off), so
|
|
11
|
+
* the text reaches no prompt and the skill has no reader. With a switch on,
|
|
11
12
|
* its on files are installed. A compliance-off machine carries no posting step in any prompt
|
|
12
13
|
* that has a compliance arm. Three pieces carry it:
|
|
13
14
|
*
|
|
14
15
|
* - the source order (src/core/assets.ts commandSourceDirs / agentSourceDirs):
|
|
15
16
|
* an off combination takes its own tree, and only the all-on combination takes the
|
|
16
17
|
* normal order, so a host with no arm installs its one file as before;
|
|
17
|
-
* - the skill condition (
|
|
18
|
-
* installViaFileCopy leaves the
|
|
18
|
+
* - the skill condition (AXIS_GATED_SKILLS in src/core/plugins.ts):
|
|
19
|
+
* installViaFileCopy leaves out the skills an off switch gates;
|
|
19
20
|
* - {@link convergeVariantAxes}, below: the one converge that makes an
|
|
20
|
-
* ALREADY-installed tree match
|
|
21
|
-
* file copy and by `devflow curation` and `devflow
|
|
22
|
-
* written ({@link applyVariantToggle}).
|
|
21
|
+
* ALREADY-installed tree match every switch, run by `devflow init` after the
|
|
22
|
+
* file copy and by `devflow curation`, `devflow compliance` and `devflow tdd` after
|
|
23
|
+
* the switch is written ({@link applyVariantToggle}).
|
|
23
24
|
*
|
|
24
25
|
* The converge, decided in turn:
|
|
25
26
|
* - Its roster is the files that exist under the off trees, joined with the hosts the build
|
|
@@ -78,18 +79,18 @@
|
|
|
78
79
|
*/
|
|
79
80
|
import { promises as fs } from 'fs';
|
|
80
81
|
import * as path from 'path';
|
|
81
|
-
import { agentSourceDirs,
|
|
82
|
+
import { agentSourceDirs, commandSourceDirs, curationOffDir, variantOffDir, } from '../../core/assets.js';
|
|
82
83
|
import { carryStampedTools } from '../../core/agent-frontmatter.js';
|
|
83
84
|
import { carryAgentOverrides, reapplyAgentMapping } from '../../core/agent-models.js';
|
|
84
85
|
import { describeEchoedValue } from '../../core/echo-value.js';
|
|
85
|
-
import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineVariantKey, parseVariantManifest, variantOffRelPath, } from '../../core/curation-variants.js';
|
|
86
|
+
import { ALL_SWITCHES_ON, OFF_VARIANT_KEYS, VARIANT_AXES, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineSwitches, machineVariantKey, offAxesOf, parseVariantManifest, perAxis, sameSwitches, variantOffRelPath, } from '../../core/curation-variants.js';
|
|
86
87
|
import { readInstalledVariants } from '../../core/installed-variants.js';
|
|
87
88
|
import { readManifest, writeManifest } from '../../core/manifest.js';
|
|
88
89
|
import { withManifestLock } from '../../core/manifest-lock.js';
|
|
89
90
|
import { getPackageRoot } from '../../core/paths.js';
|
|
90
91
|
import { isProxyEnabled } from '../../core/proxy-state.js';
|
|
91
92
|
import { WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
|
|
92
|
-
import { DEVFLOW_PLUGINS,
|
|
93
|
+
import { DEVFLOW_PLUGINS, AXIS_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, selectedWorkflowFolders, skillsOf, workflowFolderNames, } from '../../core/plugins.js';
|
|
93
94
|
import { convergeFromManifest } from './compliance-install.js';
|
|
94
95
|
import { swapFolderIn } from './folder-swap.js';
|
|
95
96
|
import { copyDirectory, resolveSkillSource } from './installer.js';
|
|
@@ -159,11 +160,11 @@ async function readVariantHosts(root) {
|
|
|
159
160
|
/**
|
|
160
161
|
* The roster of one kind: the prompts that differ on an axis, sorted by file name.
|
|
161
162
|
*
|
|
162
|
-
* The files under the
|
|
163
|
+
* The files under the off trees name them (the build owns which hosts have an arm), and a host the
|
|
163
164
|
* build manifest lists is added, so a tree removed from the package still leaves its hosts in the roster.
|
|
164
|
-
* An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it
|
|
165
|
-
*
|
|
166
|
-
* both-off
|
|
165
|
+
* An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it:
|
|
166
|
+
* a file in a tree has an arm on each axis its tree turns off (offAxesOf), so a curation arm has a file under
|
|
167
|
+
* curation-off or both-off, a compliance arm under compliance-off or both-off, and so on for the other trees.
|
|
167
168
|
*
|
|
168
169
|
* `present` is false when no tree holds this kind and the manifest lists none (the build has not run, or
|
|
169
170
|
* only `build:cli` did), so the caller can say so instead of reporting a converge that had nothing to do.
|
|
@@ -173,32 +174,26 @@ async function readVariantHosts(root) {
|
|
|
173
174
|
* build accepts a wider name), already warned: they are left out of `hosts` and the caller fails closed.
|
|
174
175
|
*/
|
|
175
176
|
async function readRoster(kind, root, manifestHosts, warn) {
|
|
176
|
-
const listings =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
};
|
|
177
|
+
const listings = new Map();
|
|
178
|
+
for (const key of OFF_VARIANT_KEYS) {
|
|
179
|
+
listings.set(key, await listVariantDir(variantOffDir(key, kind, root), kind, warn));
|
|
180
|
+
}
|
|
181
181
|
const inferred = new Map();
|
|
182
|
-
const
|
|
182
|
+
for (const [key, names] of listings) {
|
|
183
183
|
if (typeof names === 'string')
|
|
184
|
-
|
|
184
|
+
continue;
|
|
185
|
+
const turnedOff = offAxesOf(key);
|
|
185
186
|
for (const name of names) {
|
|
186
|
-
const arms = inferred.get(name) ??
|
|
187
|
-
inferred.set(name,
|
|
188
|
-
curation: arms.curation || axes.includes('curation'),
|
|
189
|
-
compliance: arms.compliance || axes.includes('compliance'),
|
|
190
|
-
});
|
|
187
|
+
const arms = inferred.get(name) ?? perAxis(() => false);
|
|
188
|
+
inferred.set(name, perAxis(axis => arms[axis] || turnedOff.includes(axis)));
|
|
191
189
|
}
|
|
192
|
-
}
|
|
193
|
-
note(listings.curation, ['curation']);
|
|
194
|
-
note(listings.compliance, ['compliance']);
|
|
195
|
-
note(listings.both, ['curation', 'compliance']);
|
|
190
|
+
}
|
|
196
191
|
const files = new Map();
|
|
197
192
|
const refused = [];
|
|
198
193
|
for (const [file, axes] of inferred)
|
|
199
194
|
files.set(file, { file, axes });
|
|
200
195
|
for (const [key, host] of manifestHosts ?? []) {
|
|
201
|
-
if (!key.startsWith(`${kind}/`) || !(
|
|
196
|
+
if (!key.startsWith(`${kind}/`) || !VARIANT_AXES.some(axis => host.axes[axis]))
|
|
202
197
|
continue;
|
|
203
198
|
if (ROSTER_NAME_RE.test(host.file)) {
|
|
204
199
|
files.set(host.file, host);
|
|
@@ -209,12 +204,13 @@ async function readRoster(kind, root, manifestHosts, warn) {
|
|
|
209
204
|
}
|
|
210
205
|
}
|
|
211
206
|
const hosts = [...files.values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
212
|
-
const
|
|
207
|
+
const listed = [...listings.values()];
|
|
208
|
+
const present = hosts.length > 0 || listed.some(listing => typeof listing !== 'string');
|
|
213
209
|
if (hosts.length > MAX_CURATION_ROSTER) {
|
|
214
210
|
warn(`prompt variants: ${hosts.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
|
|
215
211
|
return { hosts: hosts.slice(0, MAX_CURATION_ROSTER), present: true, incomplete: 'truncated', refused };
|
|
216
212
|
}
|
|
217
|
-
const unreadable =
|
|
213
|
+
const unreadable = listed.some(listing => listing === 'unreadable');
|
|
218
214
|
return { hosts, present, incomplete: unreadable ? 'unreadable' : null, refused };
|
|
219
215
|
}
|
|
220
216
|
async function readIfPresent(file) {
|
|
@@ -450,48 +446,54 @@ async function removeSkillFolder(target, backups) {
|
|
|
450
446
|
}
|
|
451
447
|
}
|
|
452
448
|
}
|
|
453
|
-
/**
|
|
449
|
+
/**
|
|
450
|
+
* Converge the gated skill directories of every axis (AXIS_GATED_SKILLS): the state each axis'
|
|
451
|
+
* skills were left in. An axis that gates no skill is `unchanged`. A skill folder that was a link
|
|
452
|
+
* and was swapped is recorded in `links`. Never throws.
|
|
453
|
+
*/
|
|
454
454
|
async function convergeSkills(opts, links) {
|
|
455
|
-
const { claudeDir, devflowDir,
|
|
455
|
+
const { claudeDir, devflowDir, switches, plugins, warn } = opts;
|
|
456
456
|
const selected = skillsOf(plugins);
|
|
457
|
-
|
|
457
|
+
const states = perAxis(() => 'unchanged');
|
|
458
458
|
// A skills folder that is a link or not a folder is refused once. The removal and the swap below
|
|
459
459
|
// would reach the folder the link points to: `directoryState` of a skill folder follows the link
|
|
460
460
|
// above it. The refusal is recorded in `links`, and the converge is not clean.
|
|
461
461
|
if (await refuseUnrealFolder(links, path.join(claudeDir, 'skills')))
|
|
462
|
-
return 'failed';
|
|
463
|
-
// Every gated skill is converged: a failure on one must not skip the next.
|
|
464
|
-
for (const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
462
|
+
return perAxis(() => 'failed');
|
|
463
|
+
// Every gated skill of every axis is converged: a failure on one must not skip the next.
|
|
464
|
+
for (const axis of VARIANT_AXES) {
|
|
465
|
+
for (const skill of AXIS_GATED_SKILLS[axis]) {
|
|
466
|
+
const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
|
|
467
|
+
try {
|
|
468
|
+
if (!switches[axis]) {
|
|
469
|
+
const removal = await removeSkillFolder(target, opts.backups);
|
|
470
|
+
if (removal === 'removed') {
|
|
471
|
+
states[axis] = 'removed';
|
|
472
|
+
}
|
|
473
|
+
else if (removal === 'kept') {
|
|
474
|
+
warn(`${axis} variants: the ${prefixSkillName(skill)} skill folder (${target}) still holds a file that could not be moved aside, so it was not removed`);
|
|
475
|
+
states[axis] = 'failed';
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
471
478
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
479
|
+
if (!selected.has(skill)) {
|
|
480
|
+
if (states[axis] === 'unchanged')
|
|
481
|
+
states[axis] = 'not-selected';
|
|
482
|
+
continue;
|
|
475
483
|
}
|
|
476
|
-
|
|
484
|
+
const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
|
|
485
|
+
if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
|
|
486
|
+
continue;
|
|
487
|
+
await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
|
|
488
|
+
states[axis] = 'installed';
|
|
477
489
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
continue;
|
|
490
|
+
catch (err) {
|
|
491
|
+
warn(`${axis} variants: could not converge the ${prefixSkillName(skill)} skill (${target}) — ${String(err)}`);
|
|
492
|
+
states[axis] = 'failed';
|
|
482
493
|
}
|
|
483
|
-
const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
|
|
484
|
-
if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
|
|
485
|
-
continue;
|
|
486
|
-
await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
|
|
487
|
-
state = 'installed';
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
warn(`curation variants: could not converge the ${prefixSkillName(skill)} skill (${target}) — ${String(err)}`);
|
|
491
|
-
state = 'failed';
|
|
492
494
|
}
|
|
493
495
|
}
|
|
494
|
-
return
|
|
496
|
+
return states;
|
|
495
497
|
}
|
|
496
498
|
/** The result of a converge that did not run: nothing changed and, with compliance off, nothing is proven. */
|
|
497
499
|
function notRun(switches, reason) {
|
|
@@ -507,7 +509,7 @@ function notRun(switches, reason) {
|
|
|
507
509
|
};
|
|
508
510
|
}
|
|
509
511
|
/**
|
|
510
|
-
* Make the installed commands and agents match the machine's
|
|
512
|
+
* Make the installed commands and agents match the machine's switches: the per-kind
|
|
511
513
|
* converge of the prompts.
|
|
512
514
|
*
|
|
513
515
|
* Each roster host is rewritten to the file built for the machine's exact switches (see
|
|
@@ -692,17 +694,20 @@ export function workflowFoldersPosting(folders, compliance) {
|
|
|
692
694
|
return POSTING_CLEAR;
|
|
693
695
|
}
|
|
694
696
|
/**
|
|
695
|
-
* The one converge for
|
|
696
|
-
* and
|
|
697
|
+
* The one converge for every switch: it makes the installed tree match the machine's curation,
|
|
698
|
+
* compliance and tdd switches. `devflow init`, `devflow curation`, `devflow compliance` and
|
|
699
|
+
* `devflow tdd` all call it.
|
|
697
700
|
*
|
|
698
701
|
* It composes five per-kind converges, each a result and never a throw:
|
|
699
|
-
* - {@link convergePromptVariants}: the commands and agents, for
|
|
700
|
-
* - the apply-decisions
|
|
702
|
+
* - {@link convergePromptVariants}: the commands and agents, for every axis;
|
|
703
|
+
* - the gated skills: apply-decisions follows the curation switch and test-driven-development
|
|
704
|
+
* the tdd switch;
|
|
701
705
|
* - {@link convergeFromManifest}: the compliance skill and rule, the one place the manifest
|
|
702
706
|
* becomes compliance options;
|
|
703
707
|
* - {@link convergeReferences}: the `devflow:git` references, which follow the compliance switch
|
|
704
708
|
* (the overlay from the one source root of the switch, then the removal list or the restore);
|
|
705
|
-
* - the workflow skill folders, which follow
|
|
709
|
+
* - the workflow skill folders, which follow the curation and compliance switches (the folder of
|
|
710
|
+
* the exact key; a folder has no tdd arm).
|
|
706
711
|
* A failed kind is a warning and `converged: false`, and the next kind still runs. A compliance-off
|
|
707
712
|
* machine that still has a prompt or a reference file that posts, or cannot name one, is `posting`
|
|
708
713
|
* and never `clear`: the caller decides what that costs (the compliance command exits non-zero,
|
|
@@ -711,8 +716,7 @@ export function workflowFoldersPosting(folders, compliance) {
|
|
|
711
716
|
* Never throws.
|
|
712
717
|
*/
|
|
713
718
|
export async function convergeVariantAxes(opts) {
|
|
714
|
-
const { claudeDir, devflowDir,
|
|
715
|
-
const switches = { curation: manifest.features.curation, compliance: manifest.features.compliance.enabled };
|
|
719
|
+
const { claudeDir, devflowDir, switches, frameworks, rulesEnabled, plugins, warn, packageRoot, backups } = opts;
|
|
716
720
|
// Precondition, asserted in production code: a relative claudeDir would resolve
|
|
717
721
|
// every install target somewhere unexpected, the skill removal included.
|
|
718
722
|
if (!path.isAbsolute(claudeDir)) {
|
|
@@ -720,6 +724,7 @@ export async function convergeVariantAxes(opts) {
|
|
|
720
724
|
return {
|
|
721
725
|
...notRun(switches, 'the Claude directory is not an absolute path'),
|
|
722
726
|
skill: 'unchanged',
|
|
727
|
+
tddSkill: 'unchanged',
|
|
723
728
|
compliance: { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] },
|
|
724
729
|
references: {
|
|
725
730
|
converged: false,
|
|
@@ -735,9 +740,16 @@ export async function convergeVariantAxes(opts) {
|
|
|
735
740
|
}
|
|
736
741
|
const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
|
|
737
742
|
const skillLinks = newLinkReport();
|
|
738
|
-
const
|
|
743
|
+
const skills = await attempt('gated skills', warn, perAxis(() => 'failed'), () => convergeSkills({ claudeDir, devflowDir, switches, plugins, warn, packageRoot, backups }, skillLinks));
|
|
744
|
+
const { curation: skill, tdd: tddSkill } = skills;
|
|
739
745
|
warnLinkReport(skillLinks, warn);
|
|
740
|
-
const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({
|
|
746
|
+
const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({
|
|
747
|
+
claudeDir,
|
|
748
|
+
devflowDir,
|
|
749
|
+
manifest: { features: { compliance: { enabled: switches.compliance, frameworks }, rules: rulesEnabled } },
|
|
750
|
+
warn,
|
|
751
|
+
backups,
|
|
752
|
+
}));
|
|
741
753
|
const references = await attempt('reference converge', warn, {
|
|
742
754
|
converged: false,
|
|
743
755
|
rewritten: [],
|
|
@@ -757,12 +769,17 @@ export async function convergeVariantAxes(opts) {
|
|
|
757
769
|
backups,
|
|
758
770
|
}));
|
|
759
771
|
return {
|
|
760
|
-
converged: prompts.converged &&
|
|
772
|
+
converged: prompts.converged &&
|
|
773
|
+
VARIANT_AXES.every(axis => skills[axis] !== 'failed') &&
|
|
774
|
+
compliance.converged &&
|
|
775
|
+
references.converged &&
|
|
776
|
+
workflowFolders.converged,
|
|
761
777
|
commandsRewritten: prompts.commandsRewritten,
|
|
762
778
|
agentsRewritten: prompts.agentsRewritten,
|
|
763
779
|
unchanged: prompts.unchanged,
|
|
764
780
|
notInstalled: prompts.notInstalled,
|
|
765
781
|
skill,
|
|
782
|
+
tddSkill,
|
|
766
783
|
compliance,
|
|
767
784
|
references,
|
|
768
785
|
workflowFolders,
|
|
@@ -819,7 +836,8 @@ export function describeCurationConverge(result, curation) {
|
|
|
819
836
|
}
|
|
820
837
|
/**
|
|
821
838
|
* One line for what a converge changed on an axis, or null when it changed nothing: the prompts
|
|
822
|
-
* rewritten for the variant the axis now holds and
|
|
839
|
+
* rewritten for the variant the axis now holds and the gated skills it installed or removed
|
|
840
|
+
* (apply-decisions with curation, test-driven-development with tdd).
|
|
823
841
|
*/
|
|
824
842
|
export function describeVariantConverge(result, axis, enabled) {
|
|
825
843
|
const parts = [];
|
|
@@ -828,10 +846,16 @@ export function describeVariantConverge(result, axis, enabled) {
|
|
|
828
846
|
parts.push(`${result.commandsRewritten.length} command(s) and ${result.agentsRewritten.length} agent(s) ` +
|
|
829
847
|
`rewritten for the ${axis}-${enabled ? 'on' : 'off'} variant`);
|
|
830
848
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
849
|
+
const skillStates = [
|
|
850
|
+
[AXIS_GATED_SKILLS.curation[0], result.skill],
|
|
851
|
+
[AXIS_GATED_SKILLS.tdd[0], result.tddSkill],
|
|
852
|
+
];
|
|
853
|
+
for (const [skill, state] of skillStates) {
|
|
854
|
+
if (state === 'installed')
|
|
855
|
+
parts.push(`the ${skill} skill installed`);
|
|
856
|
+
if (state === 'removed')
|
|
857
|
+
parts.push(`the ${skill} skill removed`);
|
|
858
|
+
}
|
|
835
859
|
const references = result.references;
|
|
836
860
|
if (references !== undefined) {
|
|
837
861
|
const changed = references.rewritten.length + references.restored.length;
|
|
@@ -943,7 +967,7 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
|
|
|
943
967
|
const manifest = await readManifest(devflowDir);
|
|
944
968
|
if (manifest === null)
|
|
945
969
|
return 'no-manifest';
|
|
946
|
-
if (manifest.features
|
|
970
|
+
if (!sameSwitches(machineSwitches(manifest.features), switches)) {
|
|
947
971
|
// The record names switches that no longer hold: remove it, so the status reads unknown.
|
|
948
972
|
if (!Object.hasOwn(manifest, 'installedVariants'))
|
|
949
973
|
return 'switches-changed';
|
|
@@ -954,11 +978,11 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
|
|
|
954
978
|
return 'switches-changed';
|
|
955
979
|
}
|
|
956
980
|
const recorded = readInstalledVariants(manifest.installedVariants);
|
|
957
|
-
if (recorded !== null && recorded
|
|
981
|
+
if (recorded !== null && sameSwitches(recorded, switches))
|
|
958
982
|
return 'current';
|
|
959
983
|
if (!(await lock.stillHeld()))
|
|
960
984
|
return 'lock-lost';
|
|
961
|
-
await writeManifest(devflowDir, { ...manifest, installedVariants:
|
|
985
|
+
await writeManifest(devflowDir, { ...manifest, installedVariants: perAxis(axis => switches[axis]) });
|
|
962
986
|
return 'written';
|
|
963
987
|
});
|
|
964
988
|
if (!locked.ok || locked.value === 'lock-lost') {
|
|
@@ -976,11 +1000,11 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
|
|
|
976
1000
|
}
|
|
977
1001
|
}
|
|
978
1002
|
/**
|
|
979
|
-
* What `devflow curation
|
|
980
|
-
* written: converge the installed tree onto
|
|
1003
|
+
* What `devflow curation`, `devflow compliance` and `devflow tdd` do after the switch is
|
|
1004
|
+
* written: converge the installed tree onto every switch with the one converge, record the
|
|
981
1005
|
* installed variants when it finished clean, then reapply the saved agent model and effort mapping.
|
|
982
1006
|
*
|
|
983
|
-
* The switch of the toggled axis is the value the caller just set. The other
|
|
1007
|
+
* The switch of the toggled axis is the value the caller just set. The other axes and the
|
|
984
1008
|
* compliance frameworks come from the manifest, which the caller has already written.
|
|
985
1009
|
*
|
|
986
1010
|
* A version skew keeps the check this toggle has always had: the installed prompts are not the
|
|
@@ -1005,24 +1029,21 @@ export async function applyVariantToggle(opts) {
|
|
|
1005
1029
|
if (skew !== null)
|
|
1006
1030
|
return { kind: 'skipped', reason: 'version-skew', message: skew };
|
|
1007
1031
|
const selection = new Set(manifest.plugins);
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
curation: axis === 'curation' ? enabled : manifest.features.curation,
|
|
1011
|
-
compliance: axis === 'compliance' ? { ...manifest.features.compliance, enabled } : manifest.features.compliance,
|
|
1012
|
-
rules: manifest.features.rules,
|
|
1013
|
-
},
|
|
1014
|
-
};
|
|
1032
|
+
// The toggled axis takes the value the caller just set; every other axis is the manifest's.
|
|
1033
|
+
const switches = { ...machineSwitches(manifest.features), [axis]: enabled };
|
|
1015
1034
|
const result = await convergeVariantAxes({
|
|
1016
1035
|
claudeDir,
|
|
1017
1036
|
devflowDir,
|
|
1018
|
-
|
|
1037
|
+
switches,
|
|
1038
|
+
frameworks: manifest.features.compliance.frameworks,
|
|
1039
|
+
rulesEnabled: manifest.features.rules,
|
|
1019
1040
|
plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
|
|
1020
1041
|
warn,
|
|
1021
1042
|
packageRoot: opts.packageRoot,
|
|
1022
1043
|
backups: opts.backups,
|
|
1023
1044
|
});
|
|
1024
1045
|
if (result.converged) {
|
|
1025
|
-
await recordInstalledVariants(devflowDir,
|
|
1046
|
+
await recordInstalledVariants(devflowDir, switches, warn);
|
|
1026
1047
|
}
|
|
1027
1048
|
let agentMappingReapplied = false;
|
|
1028
1049
|
if (result.agentsRewritten.length > 0) {
|
|
@@ -1,10 +1,10 @@
|
|
|
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, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan,
|
|
4
|
+
import { SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitGatedSkills, selectedWorkflowFolders, workflowFolderNames } from '../../core/plugins.js';
|
|
5
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
|
+
import { ALL_SWITCHES_ON, VARIANT_AXES, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, parseVariantManifest, requiredComplianceVariantFiles, } from '../../core/curation-variants.js';
|
|
8
8
|
import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
|
|
9
9
|
import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
|
|
10
10
|
import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
|
|
@@ -1404,7 +1404,7 @@ async function writeStampedAgent(srcFile, target, servers, warn) {
|
|
|
1404
1404
|
}
|
|
1405
1405
|
/**
|
|
1406
1406
|
* D-EXPLORE-NO-CLOBBER, install half: true when `agentName` takes over a built-in
|
|
1407
|
-
* agent type (Explore) and the user already owns an agent of that type under
|
|
1407
|
+
* agent type (Explore, Plan) and the user already owns an agent of that type under
|
|
1408
1408
|
* `{claudeDir}/agents`, so devflow's copy must not be installed.
|
|
1409
1409
|
*
|
|
1410
1410
|
* The user's file is only ever read. A copy of devflow's own left by an earlier install
|
|
@@ -1708,10 +1708,10 @@ function namedVariantFiles(items) {
|
|
|
1708
1708
|
* An injected agent list wins as given.
|
|
1709
1709
|
*/
|
|
1710
1710
|
function copySourceDirs(opts) {
|
|
1711
|
-
const {
|
|
1711
|
+
const { switches, packageRoot } = opts;
|
|
1712
1712
|
return {
|
|
1713
|
-
commands: commandSourceDirs(
|
|
1714
|
-
agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot,
|
|
1713
|
+
commands: commandSourceDirs(switches, packageRoot),
|
|
1714
|
+
agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, switches),
|
|
1715
1715
|
};
|
|
1716
1716
|
}
|
|
1717
1717
|
/**
|
|
@@ -1737,8 +1737,8 @@ function copySourceDirs(opts) {
|
|
|
1737
1737
|
* naming the build step. Only the file reads, so it leaves no partial install behind.
|
|
1738
1738
|
*/
|
|
1739
1739
|
async function requireComplianceVariantFiles(opts) {
|
|
1740
|
-
const {
|
|
1741
|
-
if (compliance)
|
|
1740
|
+
const { switches, packageRoot } = opts;
|
|
1741
|
+
if (switches.compliance)
|
|
1742
1742
|
return;
|
|
1743
1743
|
const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
|
|
1744
1744
|
const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
|
|
@@ -1757,12 +1757,12 @@ async function requireComplianceVariantFiles(opts) {
|
|
|
1757
1757
|
throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
|
|
1758
1758
|
`No file was written. ${remedy}`);
|
|
1759
1759
|
}
|
|
1760
|
-
const sourceDirs = copySourceDirs({
|
|
1761
|
-
const machine =
|
|
1760
|
+
const sourceDirs = copySourceDirs({ switches, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1761
|
+
const machine = VARIANT_AXES.map(axis => `${axis} ${switches[axis] ? 'on' : 'off'}`).join(', ');
|
|
1762
1762
|
const missing = [];
|
|
1763
1763
|
const shadowed = [];
|
|
1764
1764
|
for (const host of parsed.value) {
|
|
1765
|
-
const [rel] = requiredComplianceVariantFiles(
|
|
1765
|
+
const [rel] = requiredComplianceVariantFiles(switches, [host]);
|
|
1766
1766
|
if (rel === undefined)
|
|
1767
1767
|
continue;
|
|
1768
1768
|
const required = path.join(packageRoot, ...rel.split('/'));
|
|
@@ -1821,15 +1821,14 @@ async function requireComplianceVariantFiles(opts) {
|
|
|
1821
1821
|
* known and leaves it alone.
|
|
1822
1822
|
*/
|
|
1823
1823
|
export async function installCommandsAndAgents(opts) {
|
|
1824
|
-
const { plugins, agentsMap, claudeDir,
|
|
1824
|
+
const { plugins, agentsMap, claudeDir, switches, trackerServers, warn } = opts;
|
|
1825
1825
|
const skip = opts.skip ?? {};
|
|
1826
1826
|
const packageRoot = opts.packageRoot ?? getPackageRoot();
|
|
1827
1827
|
// Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
|
|
1828
1828
|
// this call covers the caller that reaches the copy alone (the selective uninstall).
|
|
1829
|
-
await requireComplianceVariantFiles({
|
|
1829
|
+
await requireComplianceVariantFiles({ switches, packageRoot, agentSourceDirs: opts.agentSourceDirs });
|
|
1830
1830
|
const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
|
|
1831
|
-
|
|
1832
|
-
compliance,
|
|
1831
|
+
switches,
|
|
1833
1832
|
packageRoot,
|
|
1834
1833
|
agentSourceDirs: opts.agentSourceDirs,
|
|
1835
1834
|
});
|
|
@@ -1902,20 +1901,19 @@ export async function installCommandsAndAgents(opts) {
|
|
|
1902
1901
|
* Returns an InstallReport describing which shadows were applied and which were skipped.
|
|
1903
1902
|
*/
|
|
1904
1903
|
export async function installViaFileCopy(options) {
|
|
1905
|
-
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner,
|
|
1904
|
+
const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, switches, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
|
|
1906
1905
|
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
1907
1906
|
// D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
|
|
1908
1907
|
// empty, and with it every folder step below is a no-op over an empty set.
|
|
1909
1908
|
const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
|
|
1910
1909
|
const folderWorkflows = workflowFolderNames(folderRegistry);
|
|
1911
|
-
const folderSwitches = { curation, compliance };
|
|
1912
1910
|
const folderFiles = new Map();
|
|
1913
1911
|
/** The files of one registered folder, read once. A failed read is a reason and never a throw. */
|
|
1914
1912
|
const readFolder = async (folder) => {
|
|
1915
1913
|
const cached = folderFiles.get(folder.name);
|
|
1916
1914
|
if (cached !== undefined)
|
|
1917
1915
|
return { ok: true, files: cached };
|
|
1918
|
-
const read = await readWorkflowFolder(folder,
|
|
1916
|
+
const read = await readWorkflowFolder(folder, switches, options.workflowFoldersRoot ?? packageRoot);
|
|
1919
1917
|
if (read.ok)
|
|
1920
1918
|
folderFiles.set(folder.name, read.files);
|
|
1921
1919
|
return read;
|
|
@@ -1931,7 +1929,7 @@ export async function installViaFileCopy(options) {
|
|
|
1931
1929
|
};
|
|
1932
1930
|
// The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
|
|
1933
1931
|
// must leave the installed tree as it found it.
|
|
1934
|
-
await requireComplianceVariantFiles({
|
|
1932
|
+
await requireComplianceVariantFiles({ switches, packageRoot, agentSourceDirs: options.agentSourceDirs });
|
|
1935
1933
|
// The folders this run installs are read from the exact-key tree before the pre-clean for the
|
|
1936
1934
|
// same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
|
|
1937
1935
|
await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
|
|
@@ -1939,9 +1937,9 @@ export async function installViaFileCopy(options) {
|
|
|
1939
1937
|
// root they are overlaid from. With compliance off the root is the compliance-off tree, and its
|
|
1940
1938
|
// absence refuses the install here, before the pre-clean, so a refused install leaves the
|
|
1941
1939
|
// 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 (
|
|
1940
|
+
const refsManifest = installedReferenceManifest({ compliance: switches.compliance });
|
|
1941
|
+
const refsSourceRoot = skillRefsSourceDir(switches.compliance, packageRoot);
|
|
1942
|
+
if (omitGatedSkills(skillsMap, switches).has(SKILL_REFS_SKILL_NAME)) {
|
|
1945
1943
|
await requireGeneratedTree(refsSourceRoot, refsManifest);
|
|
1946
1944
|
}
|
|
1947
1945
|
const report = {
|
|
@@ -2203,8 +2201,7 @@ export async function installViaFileCopy(options) {
|
|
|
2203
2201
|
plugins: commandAgentPlugins,
|
|
2204
2202
|
agentsMap: commandAgentMap,
|
|
2205
2203
|
claudeDir,
|
|
2206
|
-
|
|
2207
|
-
compliance,
|
|
2204
|
+
switches,
|
|
2208
2205
|
packageRoot,
|
|
2209
2206
|
trackerServers,
|
|
2210
2207
|
agentSourceDirs: options.agentSourceDirs,
|
|
@@ -2240,15 +2237,15 @@ export async function installViaFileCopy(options) {
|
|
|
2240
2237
|
// A declared skill whose source directory is absent is a build/packaging failure
|
|
2241
2238
|
// and throws rather than silently skipping (matches command pattern).
|
|
2242
2239
|
//
|
|
2243
|
-
// D-LEARNING-VARIANT-INSTALL: a
|
|
2244
|
-
//
|
|
2240
|
+
// D-LEARNING-VARIANT-INSTALL: a machine with an axis off skips the skills that axis
|
|
2241
|
+
// gates here (omitGatedSkills). The pre-clean above walked the UNFILTERED map, so on a full
|
|
2245
2242
|
// install a leftover copy is already gone; a partial install leaves removal to
|
|
2246
2243
|
// convergeVariantAxes, which init runs straight after this call.
|
|
2247
2244
|
//
|
|
2248
2245
|
// A refused skills root (a link or not a folder) gets no skill: every copy, and the overlay
|
|
2249
2246
|
// below it, would write into the folder the link points to.
|
|
2250
2247
|
spinner.message('Installing skills...');
|
|
2251
|
-
const skillsToInstall = skillsRefused ? [] : [...
|
|
2248
|
+
const skillsToInstall = skillsRefused ? [] : [...omitGatedSkills(skillsMap, switches).keys()];
|
|
2252
2249
|
for (const skillName of skillsToInstall) {
|
|
2253
2250
|
const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
|
|
2254
2251
|
// A full install's pre-clean replaced a link at the skill or its references entry. A partial
|
|
@@ -2272,7 +2269,7 @@ export async function installViaFileCopy(options) {
|
|
|
2272
2269
|
// on the machine, even when the install stops or a removal is refused later. With compliance on
|
|
2273
2270
|
// nothing is excluded.
|
|
2274
2271
|
const resolved = await resolveSkillSource(skillName, devflowDir);
|
|
2275
|
-
await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
|
|
2272
|
+
await copyDirectory(resolved.dir, skillTarget, switches.compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
|
|
2276
2273
|
if (resolved.shadow === 'valid') {
|
|
2277
2274
|
report.shadowedSkills.push(skillName);
|
|
2278
2275
|
}
|
|
@@ -2309,7 +2306,7 @@ export async function installViaFileCopy(options) {
|
|
|
2309
2306
|
// install copies over without clearing. A removal that was refused is a reported failure and
|
|
2310
2307
|
// never an abort; the compliance converge that init runs next fails closed on the same files.
|
|
2311
2308
|
// A refused skills root is not entered: the removal would reach files in the folder a link points to.
|
|
2312
|
-
if (!compliance && !skillsRefused) {
|
|
2309
|
+
if (!switches.compliance && !skillsRefused) {
|
|
2313
2310
|
const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
|
|
2314
2311
|
for (const refusal of removal.refused) {
|
|
2315
2312
|
report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
|
|
@@ -24,8 +24,7 @@ import * as path from 'path';
|
|
|
24
24
|
import { skillsDir } from '../../core/assets.js';
|
|
25
25
|
import { complianceSkillFiles } from '../../core/compliance.js';
|
|
26
26
|
import { COMPLIANCE_OFF_REMOVED_SKILL_FILES } from '../../core/compliance-off-files.js';
|
|
27
|
-
import {
|
|
28
|
-
import { SKILL_REFS_SKILL_NAME, installedReferenceManifest, workflowFolderFiles, } from '../../core/mds-variants.js';
|
|
27
|
+
import { FOLDER_VARIANT_KEYS, SKILL_REFS_SKILL_NAME, installedReferenceManifest, workflowFolderFiles, } from '../../core/mds-variants.js';
|
|
29
28
|
import { createPathSet } from '../../core/path-set.js';
|
|
30
29
|
import { FEATURE_OWNED_RULES, getAllAgentNames, getAllCommandNames, getAllRuleNames, getAllSkillNames, prefixSkillName, } from '../../core/plugins.js';
|
|
31
30
|
import { validateSkillShadow } from './installer.js';
|
|
@@ -69,7 +68,7 @@ export function shippedPaths(inputs) {
|
|
|
69
68
|
for (const file of complianceSkillFiles())
|
|
70
69
|
paths.add(skillPath('compliance', file));
|
|
71
70
|
for (const folder of inputs.workflowFolders) {
|
|
72
|
-
for (const key of
|
|
71
|
+
for (const key of FOLDER_VARIANT_KEYS) {
|
|
73
72
|
for (const file of workflowFolderFiles(folder, key))
|
|
74
73
|
paths.add(skillPath(folder.name, file));
|
|
75
74
|
}
|