devflow-kit 3.8.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 +231 -0
- package/README.md +6 -6
- package/dist/agents/code.md +10 -10
- package/dist/agents/fix.md +7 -7
- package/dist/agents/git.md +2 -2
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- package/dist/agents/skim.md +1 -1
- package/dist/agents/triage.md +7 -0
- 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 +79 -23
- package/dist/cli/commands/gate-level-prompts.js +39 -23
- package/dist/cli/commands/gates.js +6 -7
- package/dist/cli/commands/init.js +150 -145
- package/dist/cli/commands/install-report.js +34 -0
- package/dist/cli/commands/legacy-sweep.js +64 -0
- package/dist/cli/commands/memory.js +59 -12
- 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 +354 -60
- package/dist/commands/bug-analysis.md +71 -122
- package/dist/commands/code-review.md +11 -11
- package/dist/commands/debug.md +2 -2
- package/dist/commands/dynamic-build.md +25 -25
- package/dist/commands/dynamic-plan.md +2 -2
- package/dist/commands/dynamic-profile.md +2 -2
- package/dist/commands/dynamic-tickets.md +2 -2
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +48 -34
- package/dist/commands/plan.md +2 -2
- package/dist/commands/release.md +3 -3
- package/dist/commands/research.md +2 -2
- package/dist/commands/resolve.md +50 -46
- package/dist/commands/self-review.md +10 -9
- 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/area-pause-notice.js +44 -0
- package/dist/core/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/curation-move-skip.js +4 -5
- package/dist/core/curation-queue-cleanup.js +6 -5
- package/dist/core/curation-store.js +1 -0
- package/dist/core/curation-variants.js +460 -112
- package/dist/core/evidence-policy.js +10 -8
- 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 +69 -12
- package/dist/core/tracked-areas.js +138 -14
- 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 +10 -10
- package/dist/curation-off/agents/fix.md +7 -7
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/agents/triage.md +7 -0
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/code-review.md +11 -11
- package/dist/curation-off/commands/debug.md +2 -2
- package/dist/curation-off/commands/dynamic-build.md +25 -25
- package/dist/curation-off/commands/explore.md +2 -2
- package/dist/curation-off/commands/implement.md +48 -34
- package/dist/curation-off/commands/plan.md +2 -2
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +50 -46
- package/dist/curation-off/commands/self-review.md +10 -9
- package/dist/hud/components/curation-counts.js +27 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- 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/code.mds +4 -4
- package/src/assets/agents/evaluate.md +7 -13
- package/src/assets/agents/fix.mds +1 -1
- package/src/assets/agents/git.mds +34 -2
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/skim.mds +1 -1
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +9 -0
- package/src/assets/agents/validate.md +27 -34
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_decisions.mds +10 -8
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/_partials/_settings.mds +2 -2
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +52 -9
- 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 +229 -44
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +29 -1
- package/src/assets/mds/git/_ops_pr.mds +20 -10
- package/src/assets/mds/git/_ops_steps.mds +27 -0
- package/src/assets/mds/git/_pr.mds +15 -15
- package/src/assets/mds/git/_references.mds +12 -7
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +43 -4
- package/src/assets/mds/tracker/_jira.mds +44 -4
- package/src/assets/mds/tracker/_linear.mds +44 -4
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +493 -118
- 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/background-memory-update +79 -1
- package/src/assets/scripts/hooks/json-helper.cjs +44 -2
- package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +11 -0
- package/src/assets/scripts/hooks/pre-compact-memory +11 -0
- package/src/assets/scripts/hooks/preamble +18 -8
- package/src/assets/scripts/hooks/queue-append +218 -1
- package/src/assets/scripts/hooks/session-start-context +89 -37
- package/src/assets/scripts/hooks/session-start-memory +39 -0
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +46 -4
- package/src/assets/scripts/resolve-settings.cjs +244 -30
- package/src/assets/scripts/verify-evidence.cjs +708 -124
- package/src/assets/skills/agent-references/SKILL.md +7 -1
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
- 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/commands/resolve-levels.md +3 -2
- package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- 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,28 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Prompt-variant installer for the Claude Code target: the curation and compliance switches.
|
|
3
3
|
*
|
|
4
|
-
* D-LEARNING-VARIANT-INSTALL: the install follows the machine's
|
|
5
|
-
* (`features.curation` in ~/.devflow/manifest.json) and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* D-LEARNING-VARIANT-INSTALL: the install follows the machine's two switches
|
|
5
|
+
* (`features.curation` and `features.compliance` in ~/.devflow/manifest.json) and
|
|
6
|
+
* nothing else. Each command and agent that has an arm for a switch that is off is
|
|
7
|
+
* installed from the build tree of the machine's exact switch combination
|
|
8
|
+
* (dist/{curation-off,compliance-off,curation-compliance-off}/{commands,agents}, built by
|
|
9
|
+
* D-LEARNING-VARIANTS), and the apply-decisions skill is not installed when curation is
|
|
10
|
+
* off, so the decisions text reaches no prompt and the skill has no reader. With a switch on,
|
|
11
|
+
* its on files are installed. A compliance-off machine carries no posting step in any prompt
|
|
12
|
+
* that has a compliance arm. Three pieces carry it:
|
|
11
13
|
*
|
|
12
14
|
* - the source order (src/core/assets.ts commandSourceDirs / agentSourceDirs):
|
|
13
|
-
*
|
|
15
|
+
* an off combination takes its own tree, and only the all-on combination takes the
|
|
14
16
|
* normal order, so a host with no arm installs its one file as before;
|
|
15
17
|
* - the skill condition (CURATION_GATED_SKILLS in src/core/plugins.ts):
|
|
16
18
|
* installViaFileCopy leaves the skill out when curation is off;
|
|
17
|
-
* - {@link
|
|
18
|
-
* ALREADY-installed tree match
|
|
19
|
-
* file copy and by `devflow curation
|
|
20
|
-
* written ({@link
|
|
19
|
+
* - {@link convergeVariantAxes}, below: the one converge that makes an
|
|
20
|
+
* ALREADY-installed tree match both switches, run by `devflow init` after the
|
|
21
|
+
* file copy and by `devflow curation` and `devflow compliance` after the switch is
|
|
22
|
+
* written ({@link applyVariantToggle}).
|
|
21
23
|
*
|
|
22
24
|
* The converge, decided in turn:
|
|
23
|
-
* - Its roster is the files that exist under
|
|
24
|
-
*
|
|
25
|
-
* no edit to this file.
|
|
25
|
+
* - Its roster is the files that exist under the off trees, joined with the hosts the build
|
|
26
|
+
* manifest (dist/variant-axes.json) says carry an arm, not a list typed here: the build owns
|
|
27
|
+
* which hosts have an arm, so a new arm is converged with no edit to this file.
|
|
28
|
+
* - It fails closed on the posting question. With compliance off, an installed
|
|
29
|
+
* compliance-armed prompt that could not be converged is named in `posting`, and a build
|
|
30
|
+
* manifest that cannot name those hosts leaves `posting` unknown. Callers decide the cost:
|
|
31
|
+
* `devflow compliance` exits non-zero, init and curation warn.
|
|
32
|
+
* - After a clean converge, {@link applyVariantToggle} and init record which variants are
|
|
33
|
+
* installed (`installedVariants` in the manifest, see src/core/installed-variants.ts).
|
|
26
34
|
* - It rewrites only files that are ALREADY installed. A `--plugin` install
|
|
27
35
|
* carries one plugin's files; a toggle must not add another plugin's. For the
|
|
28
36
|
* same reason the skill is installed on a switch-on only when the machine's
|
|
@@ -47,13 +55,14 @@
|
|
|
47
55
|
* applied again the moment curation comes back on.
|
|
48
56
|
* - Version skew: the variants in this package were built with this version.
|
|
49
57
|
* When the manifest records another version, the installed files are not the
|
|
50
|
-
* ones this package would rewrite, so {@link
|
|
51
|
-
* converge and says to run `devflow init`.
|
|
58
|
+
* ones this package would rewrite, so {@link applyVariantToggle} skips the
|
|
59
|
+
* converge and says to run `devflow init`. For a compliance-off toggle the message also
|
|
60
|
+
* says that posting steps remain installed until then.
|
|
52
61
|
* - An agent's installed `model:` and `effort:` are carried into the variant before
|
|
53
62
|
* the byte comparison (D-AGENT-OVERRIDE-CARRY, see {@link withInstalledState}),
|
|
54
63
|
* so the overrides `devflow agents` and the proxy put there are never written
|
|
55
64
|
* back to the shipped values and a no-op converge writes no agent.
|
|
56
|
-
* {@link
|
|
65
|
+
* {@link applyVariantToggle} still runs reapplyAgentMapping straight after a
|
|
57
66
|
* converge that rewrote an agent: it reads agent-models.json and is the authority.
|
|
58
67
|
*
|
|
59
68
|
* D-LEARNING-PRELOAD-MACHINE-ONLY (amended): the apply-decisions preload follows
|
|
@@ -69,17 +78,24 @@
|
|
|
69
78
|
*/
|
|
70
79
|
import { promises as fs } from 'fs';
|
|
71
80
|
import * as path from 'path';
|
|
72
|
-
import { agentSourceDirs, commandSourceDirs, curationOffDir, } from '../../core/assets.js';
|
|
81
|
+
import { agentSourceDirs, bothOffDir, commandSourceDirs, complianceOffDir, curationOffDir, } from '../../core/assets.js';
|
|
73
82
|
import { carryStampedTools } from '../../core/agent-frontmatter.js';
|
|
74
83
|
import { carryAgentOverrides, reapplyAgentMapping } from '../../core/agent-models.js';
|
|
75
84
|
import { describeEchoedValue } from '../../core/echo-value.js';
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
85
|
+
import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineVariantKey, parseVariantManifest, variantOffRelPath, } from '../../core/curation-variants.js';
|
|
86
|
+
import { readInstalledVariants } from '../../core/installed-variants.js';
|
|
87
|
+
import { readManifest, writeManifest } from '../../core/manifest.js';
|
|
88
|
+
import { withManifestLock } from '../../core/manifest-lock.js';
|
|
78
89
|
import { getPackageRoot } from '../../core/paths.js';
|
|
79
90
|
import { isProxyEnabled } from '../../core/proxy-state.js';
|
|
80
|
-
import {
|
|
91
|
+
import { WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
|
|
92
|
+
import { DEVFLOW_PLUGINS, CURATION_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, selectedWorkflowFolders, skillsOf, workflowFolderNames, } from '../../core/plugins.js';
|
|
93
|
+
import { convergeFromManifest } from './compliance-install.js';
|
|
81
94
|
import { copyDirectory, resolveSkillSource } from './installer.js';
|
|
82
95
|
import { LANGUAGE_STAMPED_COMMANDS, stampForConverge } from './language-stamp.js';
|
|
96
|
+
import { entryIsLink, newLinkReport, refuseUnrealFolder, refusedFolderMessage, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
|
|
97
|
+
import { convergeReferences } from './reference-converge.js';
|
|
98
|
+
import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
|
|
83
99
|
// ── Bounds ─────────────────────────────────────────────────────────────────
|
|
84
100
|
/**
|
|
85
101
|
* The most variant files one kind may carry. The shipped roster is 14 commands and
|
|
@@ -92,38 +108,113 @@ const MAX_SKILL_TREE_DEPTH = 4;
|
|
|
92
108
|
/** A roster entry is a flat, lowercase, hyphenated markdown basename. */
|
|
93
109
|
const ROSTER_NAME_RE = /^[a-z0-9][a-z0-9-]*\.md$/;
|
|
94
110
|
const KINDS = ['commands', 'agents'];
|
|
111
|
+
const POSTING_CLEAR = { kind: 'clear' };
|
|
95
112
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
96
113
|
function installedFile(claudeDir, kind, fileName) {
|
|
97
114
|
return path.join(claudeDir, kind, 'devflow', fileName);
|
|
98
115
|
}
|
|
99
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* The both-axes-ON source directories for a kind, most-preferred first: the files a host
|
|
118
|
+
* with no off variant for the machine installs.
|
|
119
|
+
*/
|
|
100
120
|
function onSourceDirs(kind, root) {
|
|
101
|
-
return kind === 'commands' ? commandSourceDirs(
|
|
121
|
+
return kind === 'commands' ? commandSourceDirs(ALL_SWITCHES_ON, root) : agentSourceDirs(root, ALL_SWITCHES_ON);
|
|
122
|
+
}
|
|
123
|
+
/** The roster-shaped file names directly under `dir`, `'absent'` when it does not exist and `'unreadable'` (warned) when it cannot be read. */
|
|
124
|
+
async function listVariantDir(dir, kind, warn) {
|
|
125
|
+
try {
|
|
126
|
+
return (await fs.readdir(dir)).filter(entry => ROSTER_NAME_RE.test(entry));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err.code === 'ENOENT')
|
|
130
|
+
return 'absent';
|
|
131
|
+
warn(`prompt variants: cannot read the ${kind} variants under ${dir} — ${String(err)}`);
|
|
132
|
+
return 'unreadable';
|
|
133
|
+
}
|
|
102
134
|
}
|
|
103
135
|
/**
|
|
104
|
-
* The
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* only `build:cli` did), so the caller can say so instead of reporting a converge
|
|
108
|
-
* that had nothing to do.
|
|
136
|
+
* The compiled prompts the build says carry an arm, read from its variant manifest. An error names the
|
|
137
|
+
* cause in a line a person can act on. Compliance on never reads it: only a compliance-off machine needs
|
|
138
|
+
* the manifest to know which prompts it must not leave unconverged.
|
|
109
139
|
*/
|
|
110
|
-
async function
|
|
111
|
-
|
|
140
|
+
async function readVariantHosts(root) {
|
|
141
|
+
const file = path.join(root, ...VARIANT_MANIFEST_REL_PATH.split('/'));
|
|
142
|
+
let text;
|
|
112
143
|
try {
|
|
113
|
-
|
|
144
|
+
text = await fs.readFile(file, 'utf-8');
|
|
114
145
|
}
|
|
115
146
|
catch (err) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
147
|
+
const cause = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
|
|
148
|
+
return { ok: false, reason: `the variant manifest ${cause}: ${file}` };
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseVariantManifest(text);
|
|
151
|
+
if (!parsed.ok)
|
|
152
|
+
return { ok: false, reason: `the variant manifest is not valid: ${file} (${describeVariantManifestParseError(parsed.error)})` };
|
|
153
|
+
const hosts = new Map();
|
|
154
|
+
for (const host of parsed.value)
|
|
155
|
+
hosts.set(`${host.kind}/${host.name}.md`, { file: `${host.name}.md`, axes: host.axes });
|
|
156
|
+
return { ok: true, hosts };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The roster of one kind: the prompts that differ on an axis, sorted by file name.
|
|
160
|
+
*
|
|
161
|
+
* The files under the three off trees name them (the build owns which hosts have an arm), and a host the
|
|
162
|
+
* build manifest lists is added, so a tree removed from the package still leaves its hosts in the roster.
|
|
163
|
+
* An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it
|
|
164
|
+
* (a curation arm has a file under curation-off or both-off, a compliance arm under compliance-off or
|
|
165
|
+
* both-off).
|
|
166
|
+
*
|
|
167
|
+
* `present` is false when no tree holds this kind and the manifest lists none (the build has not run, or
|
|
168
|
+
* only `build:cli` did), so the caller can say so instead of reporting a converge that had nothing to do.
|
|
169
|
+
* `incomplete` says the roster was read in part, already warned: a tree could not be listed
|
|
170
|
+
* (`unreadable`), or the roster passed {@link MAX_CURATION_ROSTER} and the hosts after it were left out
|
|
171
|
+
* (`truncated`). `refused` lists the manifest hosts whose name {@link ROSTER_NAME_RE} does not hold (the
|
|
172
|
+
* build accepts a wider name), already warned: they are left out of `hosts` and the caller fails closed.
|
|
173
|
+
*/
|
|
174
|
+
async function readRoster(kind, root, manifestHosts, warn) {
|
|
175
|
+
const listings = {
|
|
176
|
+
curation: await listVariantDir(curationOffDir(kind, root), kind, warn),
|
|
177
|
+
compliance: await listVariantDir(complianceOffDir(kind, root), kind, warn),
|
|
178
|
+
both: await listVariantDir(bothOffDir(kind, root), kind, warn),
|
|
179
|
+
};
|
|
180
|
+
const inferred = new Map();
|
|
181
|
+
const note = (names, axes) => {
|
|
182
|
+
if (typeof names === 'string')
|
|
183
|
+
return;
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
const arms = inferred.get(name) ?? { curation: false, compliance: false };
|
|
186
|
+
inferred.set(name, {
|
|
187
|
+
curation: arms.curation || axes.includes('curation'),
|
|
188
|
+
compliance: arms.compliance || axes.includes('compliance'),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
note(listings.curation, ['curation']);
|
|
193
|
+
note(listings.compliance, ['compliance']);
|
|
194
|
+
note(listings.both, ['curation', 'compliance']);
|
|
195
|
+
const files = new Map();
|
|
196
|
+
const refused = [];
|
|
197
|
+
for (const [file, axes] of inferred)
|
|
198
|
+
files.set(file, { file, axes });
|
|
199
|
+
for (const [key, host] of manifestHosts ?? []) {
|
|
200
|
+
if (!key.startsWith(`${kind}/`) || !(host.axes.curation || host.axes.compliance))
|
|
201
|
+
continue;
|
|
202
|
+
if (ROSTER_NAME_RE.test(host.file)) {
|
|
203
|
+
files.set(host.file, host);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
warn(`prompt variants: the build manifest lists ${kind}/${host.file}, a name the converge cannot hold; it was not converged`);
|
|
207
|
+
refused.push(host);
|
|
208
|
+
}
|
|
120
209
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
210
|
+
const hosts = [...files.values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
211
|
+
const present = hosts.length > 0 || Object.values(listings).some(listing => typeof listing !== 'string');
|
|
212
|
+
if (hosts.length > MAX_CURATION_ROSTER) {
|
|
213
|
+
warn(`prompt variants: ${hosts.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
|
|
214
|
+
return { hosts: hosts.slice(0, MAX_CURATION_ROSTER), present: true, incomplete: 'truncated', refused };
|
|
125
215
|
}
|
|
126
|
-
|
|
216
|
+
const unreadable = Object.values(listings).some(listing => listing === 'unreadable');
|
|
217
|
+
return { hosts, present, incomplete: unreadable ? 'unreadable' : null, refused };
|
|
127
218
|
}
|
|
128
219
|
async function readIfPresent(file) {
|
|
129
220
|
try {
|
|
@@ -183,29 +274,38 @@ function withInstalledState(kind, fileName, source, installed, focuses) {
|
|
|
183
274
|
: source;
|
|
184
275
|
}
|
|
185
276
|
/**
|
|
186
|
-
* Converge ONE installed prompt onto the variant the
|
|
277
|
+
* Converge ONE installed prompt onto the variant the machine's switches want.
|
|
278
|
+
*
|
|
279
|
+
* The file is the one built for the machine's exact switches (`machineVariantKey`): an off
|
|
280
|
+
* variant is read from its own tree and no other, so a host with a compliance arm on a
|
|
281
|
+
* compliance-off machine never falls through to a file that carries the posting steps. A host
|
|
282
|
+
* the machine needs no off variant for comes from the normal source order.
|
|
187
283
|
*
|
|
188
284
|
* Never throws: every failure is a warning and the outcome `failed`, so the next
|
|
189
285
|
* file is still tried.
|
|
190
286
|
*/
|
|
191
|
-
async function convergeFile(kind,
|
|
192
|
-
const { claudeDir,
|
|
287
|
+
async function convergeFile(kind, host, opts) {
|
|
288
|
+
const { claudeDir, switches, warn, root, focuses, folderRefused, links } = opts;
|
|
289
|
+
const fileName = host.file;
|
|
193
290
|
const target = installedFile(claudeDir, kind, fileName);
|
|
194
291
|
const installed = await readIfPresent(target);
|
|
195
292
|
if (installed === 'absent')
|
|
196
293
|
return 'not-installed';
|
|
294
|
+
// Reading through a link is harmless and tells a prompt that is there from one that is not. It is
|
|
295
|
+
// left as it is: the folder is reported once, by the caller.
|
|
296
|
+
if (folderRefused)
|
|
297
|
+
return 'failed';
|
|
197
298
|
if (installed === 'unreadable') {
|
|
198
|
-
warn(`
|
|
299
|
+
warn(`prompt variants: cannot read the installed ${kind}/${fileName} (${target}) — left as it is`);
|
|
199
300
|
return 'failed';
|
|
200
301
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const candidates = curation
|
|
302
|
+
const key = machineVariantKey(switches, host.axes);
|
|
303
|
+
const candidates = key === 'on'
|
|
204
304
|
? onSourceDirs(kind, root).map(dir => path.join(dir, fileName))
|
|
205
|
-
: [path.join(
|
|
305
|
+
: [path.join(root, ...variantOffRelPath(key, kind, fileName).split('/'))];
|
|
206
306
|
const source = await firstReadable(candidates);
|
|
207
307
|
if (source === undefined) {
|
|
208
|
-
warn(`
|
|
308
|
+
warn(`prompt variants: no source for ${kind}/${fileName} (searched: ${candidates.join(', ')}) — left as it is`);
|
|
209
309
|
return 'failed';
|
|
210
310
|
}
|
|
211
311
|
const wanted = withInstalledState(kind, fileName, source.bytes, installed, focuses);
|
|
@@ -215,11 +315,11 @@ async function convergeFile(kind, fileName, opts) {
|
|
|
215
315
|
return 'unchanged';
|
|
216
316
|
try {
|
|
217
317
|
// Prompts are UTF-8 text; the atomic writer takes a string and preserves the
|
|
218
|
-
// target's permission mode.
|
|
219
|
-
await
|
|
318
|
+
// target's permission mode. A link at the target is replaced by the rename, never followed.
|
|
319
|
+
await writeFileReplacingLink(target, wanted.toString('utf-8'), links);
|
|
220
320
|
}
|
|
221
321
|
catch (err) {
|
|
222
|
-
warn(`
|
|
322
|
+
warn(`prompt variants: could not rewrite ${kind}/${fileName} (${target}) — ${String(err)}`);
|
|
223
323
|
return 'failed';
|
|
224
324
|
}
|
|
225
325
|
return 'rewritten';
|
|
@@ -286,14 +386,18 @@ async function pathExists(p) {
|
|
|
286
386
|
*
|
|
287
387
|
* Staged under `claudeDir` itself, not under `skills/`: a staging directory a
|
|
288
388
|
* crash leaves behind must not look like a second skill to Claude Code.
|
|
389
|
+
*
|
|
390
|
+
* A target that is a link is moved aside as the link itself, never followed, so what it points to is
|
|
391
|
+
* left as it was; the link is recorded in `links` once the real folder stands in its place.
|
|
289
392
|
*/
|
|
290
|
-
async function swapInSkillDirectory(claudeDir, sourceDir, target) {
|
|
393
|
+
async function swapInSkillDirectory(claudeDir, sourceDir, target, links) {
|
|
291
394
|
const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${Date.now().toString(36)}.tmp`);
|
|
292
395
|
const backup = `${staging}.old`;
|
|
293
396
|
try {
|
|
294
397
|
await copyDirectory(sourceDir, staging);
|
|
295
398
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
296
|
-
const
|
|
399
|
+
const wasLink = await entryIsLink(target);
|
|
400
|
+
const hadTarget = wasLink || await pathExists(target);
|
|
297
401
|
if (hadTarget)
|
|
298
402
|
await fs.rename(target, backup);
|
|
299
403
|
try {
|
|
@@ -304,14 +408,16 @@ async function swapInSkillDirectory(claudeDir, sourceDir, target) {
|
|
|
304
408
|
await fs.rename(backup, target);
|
|
305
409
|
throw err;
|
|
306
410
|
}
|
|
411
|
+
if (wasLink)
|
|
412
|
+
links.replacedLinks.push(target);
|
|
307
413
|
}
|
|
308
414
|
finally {
|
|
309
415
|
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
310
416
|
await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
|
|
311
417
|
}
|
|
312
418
|
}
|
|
313
|
-
/** Converge the curation-gated skill directories. Never throws. */
|
|
314
|
-
async function convergeSkills(opts) {
|
|
419
|
+
/** Converge the curation-gated skill directories. A skill folder that was a link and was swapped is recorded in `links`. Never throws. */
|
|
420
|
+
async function convergeSkills(opts, links) {
|
|
315
421
|
const { claudeDir, devflowDir, curation, plugins, warn } = opts;
|
|
316
422
|
const selected = skillsOf(plugins);
|
|
317
423
|
let state = 'unchanged';
|
|
@@ -334,7 +440,7 @@ async function convergeSkills(opts) {
|
|
|
334
440
|
const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
|
|
335
441
|
if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
|
|
336
442
|
continue;
|
|
337
|
-
await swapInSkillDirectory(claudeDir, resolved.dir, target);
|
|
443
|
+
await swapInSkillDirectory(claudeDir, resolved.dir, target, links);
|
|
338
444
|
state = 'installed';
|
|
339
445
|
}
|
|
340
446
|
catch (err) {
|
|
@@ -344,65 +450,310 @@ async function convergeSkills(opts) {
|
|
|
344
450
|
}
|
|
345
451
|
return state;
|
|
346
452
|
}
|
|
347
|
-
|
|
453
|
+
/** The result of a converge that did not run: nothing changed and, with compliance off, nothing is proven. */
|
|
454
|
+
function notRun(switches, reason) {
|
|
455
|
+
return {
|
|
456
|
+
converged: false,
|
|
457
|
+
commandsRewritten: [],
|
|
458
|
+
agentsRewritten: [],
|
|
459
|
+
unchanged: 0,
|
|
460
|
+
notInstalled: 0,
|
|
461
|
+
posting: switches.compliance ? POSTING_CLEAR : { kind: 'unknown', reason },
|
|
462
|
+
replacedLinks: [],
|
|
463
|
+
refusedFolders: [],
|
|
464
|
+
};
|
|
465
|
+
}
|
|
348
466
|
/**
|
|
349
|
-
* Make the installed commands
|
|
350
|
-
*
|
|
467
|
+
* Make the installed commands and agents match the machine's two switches: the per-kind
|
|
468
|
+
* converge of the prompts.
|
|
469
|
+
*
|
|
470
|
+
* Each roster host is rewritten to the file built for the machine's exact switches (see
|
|
471
|
+
* {@link convergeFile}). With compliance off the converge also answers the fail-closed
|
|
472
|
+
* question: the build manifest names the hosts that carry a compliance arm, and an installed one
|
|
473
|
+
* that could not be converged is listed in `posting` as still posting. A compliance-off machine whose
|
|
474
|
+
* build manifest is unusable answers `unknown`, because it cannot name those hosts.
|
|
351
475
|
*
|
|
352
|
-
*
|
|
353
|
-
* not
|
|
354
|
-
*
|
|
476
|
+
* A kind's `devflow` folder that is a link, or not a folder, is refused: nothing is written in it, the
|
|
477
|
+
* installed prompts there count as not converged, and the folder is named in `refusedFolders` and in
|
|
478
|
+
* a warning. A prompt that is a link in a real folder is replaced by a real file, and named in
|
|
479
|
+
* `replacedLinks` and in a warning.
|
|
480
|
+
*
|
|
481
|
+
* Never throws. A caller reads `converged` and `posting`; it does not catch.
|
|
355
482
|
*/
|
|
356
|
-
export async function
|
|
357
|
-
const { claudeDir,
|
|
358
|
-
const empty = {
|
|
359
|
-
converged: false, commandsRewritten: [], agentsRewritten: [], unchanged: 0, notInstalled: 0, skill: 'unchanged',
|
|
360
|
-
};
|
|
483
|
+
export async function convergePromptVariants(opts) {
|
|
484
|
+
const { claudeDir, switches, warn } = opts;
|
|
361
485
|
// Precondition, asserted in production code: a relative claudeDir would resolve
|
|
362
486
|
// the install targets somewhere unexpected.
|
|
363
487
|
if (!path.isAbsolute(claudeDir)) {
|
|
364
|
-
warn(`
|
|
365
|
-
return
|
|
488
|
+
warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
|
|
489
|
+
return notRun(switches, 'the Claude directory is not an absolute path');
|
|
366
490
|
}
|
|
367
491
|
const root = opts.packageRoot ?? getPackageRoot();
|
|
368
492
|
const focuses = installedLanguageFocuses(opts.plugins);
|
|
369
493
|
let failed = false;
|
|
494
|
+
let unknownReason = null;
|
|
495
|
+
let manifestHosts = null;
|
|
496
|
+
if (!switches.compliance) {
|
|
497
|
+
const manifest = await readVariantHosts(root);
|
|
498
|
+
if (manifest.ok) {
|
|
499
|
+
manifestHosts = manifest.hosts;
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
warn(`prompt variants: ${manifest.reason} — the installed prompts that still post cannot be named. Run \`npm run build:mds\`, then \`devflow init\`.`);
|
|
503
|
+
unknownReason = manifest.reason;
|
|
504
|
+
failed = true;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
370
507
|
let unchanged = 0;
|
|
371
508
|
let notInstalled = 0;
|
|
509
|
+
const stillPosting = [];
|
|
372
510
|
const rewritten = { commands: [], agents: [] };
|
|
511
|
+
const links = newLinkReport();
|
|
373
512
|
for (const kind of KINDS) {
|
|
374
|
-
|
|
513
|
+
// A link at the folder sends every write below it into another place, so the folder is proven first.
|
|
514
|
+
const folderRefused = await refuseUnrealFolder(links, path.join(claudeDir, kind, 'devflow'));
|
|
515
|
+
if (folderRefused)
|
|
516
|
+
failed = true;
|
|
517
|
+
const roster = await readRoster(kind, root, manifestHosts, warn);
|
|
518
|
+
// A roster read in part is a failed converge. Past the bound, a host left out may carry a
|
|
519
|
+
// compliance arm, so a compliance-off machine can no longer name every prompt that still posts.
|
|
520
|
+
if (roster.incomplete !== null)
|
|
521
|
+
failed = true;
|
|
522
|
+
if (roster.incomplete === 'truncated' && !switches.compliance && unknownReason === null) {
|
|
523
|
+
unknownReason = `more than ${MAX_CURATION_ROSTER} ${kind} carry a variant, and the ones past the bound were not converged`;
|
|
524
|
+
}
|
|
525
|
+
// A manifest host the roster cannot hold was never converged. If it carries a compliance arm, it may still post.
|
|
526
|
+
if (roster.refused.length > 0)
|
|
527
|
+
failed = true;
|
|
528
|
+
const refusedPosting = roster.refused.find(host => host.axes.compliance);
|
|
529
|
+
if (refusedPosting !== undefined && !switches.compliance && unknownReason === null) {
|
|
530
|
+
unknownReason = `the build manifest lists ${kind}/${refusedPosting.file}, a name the converge cannot hold, so it was not converged`;
|
|
531
|
+
}
|
|
375
532
|
if (!roster.present) {
|
|
376
|
-
warn(`
|
|
533
|
+
warn(`prompt variants: no ${kind} variants under ${curationOffDir(kind, root)} — run \`npm run build:mds\`; installed ${kind} left as they are`);
|
|
377
534
|
failed = true;
|
|
378
535
|
continue;
|
|
379
536
|
}
|
|
380
|
-
for (const
|
|
381
|
-
const outcome = await convergeFile(kind,
|
|
537
|
+
for (const host of roster.hosts) {
|
|
538
|
+
const outcome = await convergeFile(kind, host, { claudeDir, switches, warn, root, focuses, folderRefused, links });
|
|
382
539
|
if (outcome === 'rewritten')
|
|
383
|
-
rewritten[kind].push(
|
|
540
|
+
rewritten[kind].push(host.file);
|
|
384
541
|
else if (outcome === 'unchanged')
|
|
385
542
|
unchanged++;
|
|
386
543
|
else if (outcome === 'not-installed')
|
|
387
544
|
notInstalled++;
|
|
388
|
-
else
|
|
545
|
+
else {
|
|
389
546
|
failed = true;
|
|
547
|
+
if (!switches.compliance && host.axes.compliance)
|
|
548
|
+
stillPosting.push(installedFile(claudeDir, kind, host.file));
|
|
549
|
+
}
|
|
390
550
|
}
|
|
391
551
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
552
|
+
let posting = POSTING_CLEAR;
|
|
553
|
+
if (unknownReason !== null)
|
|
554
|
+
posting = { kind: 'unknown', reason: unknownReason };
|
|
555
|
+
else if (stillPosting.length > 0)
|
|
556
|
+
posting = { kind: 'left', files: stillPosting };
|
|
557
|
+
warnLinkReport(links, warn);
|
|
397
558
|
return {
|
|
398
559
|
converged: !failed,
|
|
399
560
|
commandsRewritten: rewritten.commands,
|
|
400
561
|
agentsRewritten: rewritten.agents,
|
|
401
562
|
unchanged,
|
|
402
563
|
notInstalled,
|
|
564
|
+
posting,
|
|
565
|
+
replacedLinks: links.replacedLinks,
|
|
566
|
+
refusedFolders: links.refusedFolders,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Run one per-kind converge and fold an unexpected throw into its fallback result, so the
|
|
571
|
+
* composed converge never throws and a failed kind never skips the next.
|
|
572
|
+
*/
|
|
573
|
+
async function attempt(label, warn, fallback, run) {
|
|
574
|
+
try {
|
|
575
|
+
return await run();
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
warn(`${label}: the converge failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
579
|
+
return fallback;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Converge the workflow skill folders of the selected plugins onto the machine's switches.
|
|
584
|
+
*
|
|
585
|
+
* D-WORKFLOW-FOLDER-INSTALL: a folder is complete under every variant key, so a switch of either axis
|
|
586
|
+
* moves it to the folder of the new exact key (the same read and the same swap as the install), and a
|
|
587
|
+
* folder that already holds that key's files is left alone. A switch to compliance off writes the
|
|
588
|
+
* compliance-off folder, which leaves out the files its file list omits, so none of them stays. A
|
|
589
|
+
* workflow whose plugin is not selected is not installed here and is not removed either: a full
|
|
590
|
+
* install owns the removal. Never throws.
|
|
591
|
+
*/
|
|
592
|
+
async function convergeWorkflowFolders(opts) {
|
|
593
|
+
const { claudeDir, switches, plugins, folders, root, warn } = opts;
|
|
594
|
+
const installed = [];
|
|
595
|
+
const unchanged = [];
|
|
596
|
+
const failures = [];
|
|
597
|
+
const stalled = [];
|
|
598
|
+
const owned = selectedWorkflowFolders(plugins, workflowFolderNames(folders));
|
|
599
|
+
for (const folder of folders) {
|
|
600
|
+
if (!owned.has(folder.name))
|
|
601
|
+
continue;
|
|
602
|
+
const target = path.join(claudeDir, 'skills', prefixSkillName(folder.name));
|
|
603
|
+
const fail = (message) => {
|
|
604
|
+
warn(`workflow folders: ${message}`);
|
|
605
|
+
failures.push(message);
|
|
606
|
+
stalled.push(target);
|
|
607
|
+
};
|
|
608
|
+
const read = await readWorkflowFolder(folder, switches, root);
|
|
609
|
+
if (!read.ok) {
|
|
610
|
+
fail(read.reason);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const links = newLinkReport();
|
|
614
|
+
const result = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links });
|
|
615
|
+
switch (result.outcome) {
|
|
616
|
+
case 'installed':
|
|
617
|
+
installed.push(folder.name);
|
|
618
|
+
break;
|
|
619
|
+
case 'unchanged':
|
|
620
|
+
unchanged.push(folder.name);
|
|
621
|
+
break;
|
|
622
|
+
case 'refused':
|
|
623
|
+
fail(refusedFolderMessage(links.refusedFolders[0] ?? target));
|
|
624
|
+
break;
|
|
625
|
+
case 'failed':
|
|
626
|
+
fail(`could not converge ${prefixSkillName(folder.name)} (${result.reason ?? 'unknown error'})`);
|
|
627
|
+
break;
|
|
628
|
+
default: {
|
|
629
|
+
const unhandled = result.outcome;
|
|
630
|
+
fail(`unhandled folder outcome ${String(unhandled)}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { converged: failures.length === 0, installed, unchanged, failures, stalled };
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The posting answer of the workflow-folder converge. Compliance on is clear. With compliance off, a
|
|
638
|
+
* folder that was not converged still holds the text of the previous key, which may post, so it is named.
|
|
639
|
+
* A converge that did not finish and names no folder (it threw) cannot say which folders post, so it is
|
|
640
|
+
* `unknown` and never `clear`.
|
|
641
|
+
*/
|
|
642
|
+
export function workflowFoldersPosting(folders, compliance) {
|
|
643
|
+
if (compliance)
|
|
644
|
+
return POSTING_CLEAR;
|
|
645
|
+
if (folders.stalled.length > 0)
|
|
646
|
+
return { kind: 'left', files: folders.stalled };
|
|
647
|
+
if (!folders.converged)
|
|
648
|
+
return { kind: 'unknown', reason: `the workflow folders: ${folders.failures.join('; ')}` };
|
|
649
|
+
return POSTING_CLEAR;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The one converge for both switches: it makes the installed tree match the machine's curation
|
|
653
|
+
* and compliance switches. `devflow init`, `devflow curation` and `devflow compliance` all call it.
|
|
654
|
+
*
|
|
655
|
+
* It composes five per-kind converges, each a result and never a throw:
|
|
656
|
+
* - {@link convergePromptVariants}: the commands and agents, for both axes;
|
|
657
|
+
* - the apply-decisions skill, which follows the curation switch;
|
|
658
|
+
* - {@link convergeFromManifest}: the compliance skill and rule, the one place the manifest
|
|
659
|
+
* becomes compliance options;
|
|
660
|
+
* - {@link convergeReferences}: the `devflow:git` references, which follow the compliance switch
|
|
661
|
+
* (the overlay from the one source root of the switch, then the removal list or the restore);
|
|
662
|
+
* - the workflow skill folders, which follow both switches (the folder of the exact key).
|
|
663
|
+
* A failed kind is a warning and `converged: false`, and the next kind still runs. A compliance-off
|
|
664
|
+
* machine that still has a prompt or a reference file that posts, or cannot name one, is `posting`
|
|
665
|
+
* and never `clear`: the caller decides what that costs (the compliance command exits non-zero,
|
|
666
|
+
* init and curation warn).
|
|
667
|
+
*
|
|
668
|
+
* Never throws.
|
|
669
|
+
*/
|
|
670
|
+
export async function convergeVariantAxes(opts) {
|
|
671
|
+
const { claudeDir, devflowDir, manifest, plugins, warn, packageRoot } = opts;
|
|
672
|
+
const switches = { curation: manifest.features.curation, compliance: manifest.features.compliance.enabled };
|
|
673
|
+
// Precondition, asserted in production code: a relative claudeDir would resolve
|
|
674
|
+
// every install target somewhere unexpected, the skill removal included.
|
|
675
|
+
if (!path.isAbsolute(claudeDir)) {
|
|
676
|
+
warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
|
|
677
|
+
return {
|
|
678
|
+
...notRun(switches, 'the Claude directory is not an absolute path'),
|
|
679
|
+
skill: 'unchanged',
|
|
680
|
+
compliance: { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] },
|
|
681
|
+
references: {
|
|
682
|
+
converged: false,
|
|
683
|
+
rewritten: [],
|
|
684
|
+
removed: [],
|
|
685
|
+
restored: [],
|
|
686
|
+
failures: ['the Claude directory is not an absolute path'],
|
|
687
|
+
stillPosting: [],
|
|
688
|
+
unproven: !switches.compliance,
|
|
689
|
+
},
|
|
690
|
+
workflowFolders: { converged: false, installed: [], unchanged: [], failures: ['the Claude directory is not an absolute path'], stalled: [] },
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
|
|
694
|
+
const skillLinks = newLinkReport();
|
|
695
|
+
const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot }, skillLinks));
|
|
696
|
+
warnLinkReport(skillLinks, warn);
|
|
697
|
+
const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn }));
|
|
698
|
+
const references = await attempt('reference converge', warn, {
|
|
699
|
+
converged: false,
|
|
700
|
+
rewritten: [],
|
|
701
|
+
removed: [],
|
|
702
|
+
restored: [],
|
|
703
|
+
failures: ['the reference converge failed'],
|
|
704
|
+
stillPosting: [],
|
|
705
|
+
unproven: !switches.compliance,
|
|
706
|
+
}, () => convergeReferences({ claudeDir, devflowDir, compliance: switches.compliance, warn, packageRoot }));
|
|
707
|
+
const workflowFolders = await attempt('workflow folders', warn, { converged: false, installed: [], unchanged: [], failures: ['the workflow folder converge failed'], stalled: [] }, () => convergeWorkflowFolders({
|
|
708
|
+
claudeDir,
|
|
709
|
+
switches,
|
|
710
|
+
plugins,
|
|
711
|
+
folders: opts.workflowFolders ?? WORKFLOW_FOLDERS,
|
|
712
|
+
root: opts.workflowFoldersRoot ?? packageRoot ?? getPackageRoot(),
|
|
713
|
+
warn,
|
|
714
|
+
}));
|
|
715
|
+
return {
|
|
716
|
+
converged: prompts.converged && skill !== 'failed' && compliance.converged && references.converged && workflowFolders.converged,
|
|
717
|
+
commandsRewritten: prompts.commandsRewritten,
|
|
718
|
+
agentsRewritten: prompts.agentsRewritten,
|
|
719
|
+
unchanged: prompts.unchanged,
|
|
720
|
+
notInstalled: prompts.notInstalled,
|
|
403
721
|
skill,
|
|
722
|
+
compliance,
|
|
723
|
+
references,
|
|
724
|
+
workflowFolders,
|
|
725
|
+
posting: mergePosting(mergePosting(prompts.posting, referencesPosting(references)), workflowFoldersPosting(workflowFolders, switches.compliance)),
|
|
726
|
+
replacedLinks: [...prompts.replacedLinks, ...skillLinks.replacedLinks, ...compliance.replacedLinks],
|
|
727
|
+
refusedFolders: [...prompts.refusedFolders, ...compliance.refusedFolders],
|
|
404
728
|
};
|
|
405
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* The posting answer of the references converge. Compliance on, and a converge that finished, are clear.
|
|
732
|
+
* A failure that left the overlay unproven cannot name the files that post, so it is `unknown`; a failure
|
|
733
|
+
* that only left named files in place is `left`.
|
|
734
|
+
*/
|
|
735
|
+
function referencesPosting(references) {
|
|
736
|
+
if (references.unproven)
|
|
737
|
+
return { kind: 'unknown', reason: `the installed references: ${references.failures.join('; ')}` };
|
|
738
|
+
if (references.stillPosting.length > 0)
|
|
739
|
+
return { kind: 'left', files: references.stillPosting };
|
|
740
|
+
return POSTING_CLEAR;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Join the posting answers of two kinds. `unknown` wins, because a file list that leaves a kind out
|
|
744
|
+
* would claim more than was proven; two unknowns keep both reasons; two lists join.
|
|
745
|
+
*/
|
|
746
|
+
function mergePosting(a, b) {
|
|
747
|
+
if (a.kind === 'unknown' && b.kind === 'unknown')
|
|
748
|
+
return { kind: 'unknown', reason: `${a.reason}; ${b.reason}` };
|
|
749
|
+
if (a.kind === 'unknown')
|
|
750
|
+
return a;
|
|
751
|
+
if (b.kind === 'unknown')
|
|
752
|
+
return b;
|
|
753
|
+
if (a.kind === 'left' && b.kind === 'left')
|
|
754
|
+
return { kind: 'left', files: [...a.files, ...b.files] };
|
|
755
|
+
return a.kind === 'left' ? a : b;
|
|
756
|
+
}
|
|
406
757
|
// ── The toggle ─────────────────────────────────────────────────────────────
|
|
407
758
|
/** The running package's version, or null when its package.json cannot be read or holds none. */
|
|
408
759
|
export async function readRunningVersion(packageRoot = getPackageRoot()) {
|
|
@@ -420,18 +771,57 @@ export async function readRunningVersion(packageRoot = getPackageRoot()) {
|
|
|
420
771
|
* The caller prints it only on a toggle: a run that moved nothing says nothing.
|
|
421
772
|
*/
|
|
422
773
|
export function describeCurationConverge(result, curation) {
|
|
774
|
+
return describeVariantConverge(result, 'curation', curation);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* One line for what a converge changed on an axis, or null when it changed nothing: the prompts
|
|
778
|
+
* rewritten for the variant the axis now holds and, for curation, the skill it installed or removed.
|
|
779
|
+
*/
|
|
780
|
+
export function describeVariantConverge(result, axis, enabled) {
|
|
423
781
|
const parts = [];
|
|
424
782
|
const prompts = result.commandsRewritten.length + result.agentsRewritten.length;
|
|
425
783
|
if (prompts > 0) {
|
|
426
784
|
parts.push(`${result.commandsRewritten.length} command(s) and ${result.agentsRewritten.length} agent(s) ` +
|
|
427
|
-
`rewritten for the
|
|
785
|
+
`rewritten for the ${axis}-${enabled ? 'on' : 'off'} variant`);
|
|
428
786
|
}
|
|
429
787
|
if (result.skill === 'installed')
|
|
430
788
|
parts.push('the apply-decisions skill installed');
|
|
431
789
|
if (result.skill === 'removed')
|
|
432
790
|
parts.push('the apply-decisions skill removed');
|
|
791
|
+
const references = result.references;
|
|
792
|
+
if (references !== undefined) {
|
|
793
|
+
const changed = references.rewritten.length + references.restored.length;
|
|
794
|
+
if (changed > 0)
|
|
795
|
+
parts.push(`${changed} reference file(s) written`);
|
|
796
|
+
if (references.removed.length > 0)
|
|
797
|
+
parts.push(`${references.removed.length} reference file(s) removed`);
|
|
798
|
+
}
|
|
433
799
|
return parts.length === 0 ? null : parts.join('; ');
|
|
434
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* The lines that say a compliance-off machine may still hold a prompt that posts, or none when
|
|
803
|
+
* the converge proved it clear. The files are the installed ones that could not be converged.
|
|
804
|
+
*/
|
|
805
|
+
export function describePostingLeft(posting) {
|
|
806
|
+
switch (posting.kind) {
|
|
807
|
+
case 'clear':
|
|
808
|
+
return [];
|
|
809
|
+
case 'left':
|
|
810
|
+
return [
|
|
811
|
+
`${posting.files.length} installed file(s) still post: ${posting.files.join(', ')}`,
|
|
812
|
+
'Run `devflow init` to install the compliance-off files. If init cannot remove one, delete it by hand.',
|
|
813
|
+
];
|
|
814
|
+
case 'unknown':
|
|
815
|
+
return [
|
|
816
|
+
`The installed files that still post cannot be named: ${posting.reason}`,
|
|
817
|
+
'Run `devflow init` to install the compliance-off files.',
|
|
818
|
+
];
|
|
819
|
+
default: {
|
|
820
|
+
const unhandled = posting;
|
|
821
|
+
return [`unhandled posting state ${JSON.stringify(unhandled)}`];
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
435
825
|
/**
|
|
436
826
|
* Whether the version the manifest records is the version of the running package.
|
|
437
827
|
* Pure. An unknown running version (null) never matches: the installed files are then
|
|
@@ -448,10 +838,27 @@ export function installedVersionMatches(installedVersion, runningVersion) {
|
|
|
448
838
|
* `describeEchoedValue`.
|
|
449
839
|
*/
|
|
450
840
|
export function curationToggleSkew(installedVersion, runningVersion, curation) {
|
|
841
|
+
return variantToggleSkew(installedVersion, runningVersion, 'curation', curation);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The version-skew check for a toggle of either axis. A compliance-off toggle also says that posting
|
|
845
|
+
* steps remain installed, because that is the unsafe side of the mismatch: the installed prompts are
|
|
846
|
+
* the ones that carry them.
|
|
847
|
+
*/
|
|
848
|
+
export function variantToggleSkew(installedVersion, runningVersion, axis, enabled) {
|
|
451
849
|
if (installedVersionMatches(installedVersion, runningVersion))
|
|
452
850
|
return null;
|
|
453
|
-
|
|
454
|
-
`so the
|
|
851
|
+
const skew = `The installed prompts come from devflow ${describeEchoedValue(installedVersion)} and this CLI is ${runningVersion ?? 'of unknown version'}, ` +
|
|
852
|
+
`so the ${axis}-${enabled ? 'on' : 'off'} variants were not applied`;
|
|
853
|
+
return axis === 'compliance' && !enabled
|
|
854
|
+
? `${skew}, and posting steps remain installed until devflow init. Run \`devflow init\` to apply them.`
|
|
855
|
+
: `${skew}. Run \`devflow init\` to apply them.`;
|
|
856
|
+
}
|
|
857
|
+
/** The line for a toggle that found no readable install manifest. */
|
|
858
|
+
function unreadableManifestMessage(axis, enabled) {
|
|
859
|
+
return axis === 'compliance' && !enabled
|
|
860
|
+
? 'No readable install manifest, so the compliance-off variants were not applied, and posting steps remain installed until devflow init. Run `devflow init`.'
|
|
861
|
+
: `No readable install manifest, so the ${axis} variants were not applied. Run \`devflow init\`.`;
|
|
455
862
|
}
|
|
456
863
|
/**
|
|
457
864
|
* The version-skew line for a command that writes curation data, or null when the
|
|
@@ -474,9 +881,66 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
|
|
|
474
881
|
`${running ?? 'of unknown version'}. Run \`devflow init\` first. Nothing was written.`);
|
|
475
882
|
}
|
|
476
883
|
/**
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
884
|
+
* Record the variants the installed prompts now hold in the manifest, after a converge that
|
|
885
|
+
* finished clean (AC-29). A record that already says so is left alone, so a toggle to the value
|
|
886
|
+
* already installed writes nothing. A failed write is a warning: the status line then reads
|
|
887
|
+
* the record as it stood, which says `mismatch` or `unknown` and names `devflow init`.
|
|
888
|
+
*
|
|
889
|
+
* The read and the write run under the manifest lock, as the switch writers do, so a switch that
|
|
890
|
+
* another devflow run writes in between is never overwritten with the older copy read here. The
|
|
891
|
+
* record is written only when the manifest's switches are still the ones the run converged to: if
|
|
892
|
+
* they changed during the run, nothing is recorded, the record that stood is removed in the same
|
|
893
|
+
* locked write (the status then reads unknown) and a warning says so. A lock that cannot be
|
|
894
|
+
* taken, or is lost before the write, withholds the record with a warning.
|
|
895
|
+
*/
|
|
896
|
+
async function recordInstalledVariants(devflowDir, switches, warn) {
|
|
897
|
+
try {
|
|
898
|
+
const locked = await withManifestLock(devflowDir, {}, async (lock) => {
|
|
899
|
+
const manifest = await readManifest(devflowDir);
|
|
900
|
+
if (manifest === null)
|
|
901
|
+
return 'no-manifest';
|
|
902
|
+
if (manifest.features.curation !== switches.curation || manifest.features.compliance.enabled !== switches.compliance) {
|
|
903
|
+
// The record names switches that no longer hold: remove it, so the status reads unknown.
|
|
904
|
+
if (!Object.hasOwn(manifest, 'installedVariants'))
|
|
905
|
+
return 'switches-changed';
|
|
906
|
+
if (!(await lock.stillHeld()))
|
|
907
|
+
return 'lock-lost';
|
|
908
|
+
const { installedVariants: _stale, ...unrecorded } = manifest;
|
|
909
|
+
await writeManifest(devflowDir, unrecorded);
|
|
910
|
+
return 'switches-changed';
|
|
911
|
+
}
|
|
912
|
+
const recorded = readInstalledVariants(manifest.installedVariants);
|
|
913
|
+
if (recorded !== null && recorded.curation === switches.curation && recorded.compliance === switches.compliance)
|
|
914
|
+
return 'current';
|
|
915
|
+
if (!(await lock.stillHeld()))
|
|
916
|
+
return 'lock-lost';
|
|
917
|
+
await writeManifest(devflowDir, { ...manifest, installedVariants: { curation: switches.curation, compliance: switches.compliance } });
|
|
918
|
+
return 'written';
|
|
919
|
+
});
|
|
920
|
+
if (!locked.ok || locked.value === 'lock-lost') {
|
|
921
|
+
warn('install record: the install manifest lock could not be held, so the installed variants were not recorded. Run `devflow init`.');
|
|
922
|
+
}
|
|
923
|
+
else if (locked.value === 'no-manifest') {
|
|
924
|
+
warn('install record: no readable install manifest, so the installed variants were not recorded. Run `devflow init`.');
|
|
925
|
+
}
|
|
926
|
+
else if (locked.value === 'switches-changed') {
|
|
927
|
+
warn('install record: the switches changed during the run, so the installed variants were not recorded. Run `devflow init`.');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
warn(`install record: could not record the installed variants — ${String(err)}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* What `devflow curation --enable/--disable` and `devflow compliance` do after the switch is
|
|
936
|
+
* written: converge the installed tree onto both switches with the one converge, record the
|
|
937
|
+
* installed variants when it finished clean, then reapply the saved agent model and effort mapping.
|
|
938
|
+
*
|
|
939
|
+
* The switch of the toggled axis is the value the caller just set. The other axis and the
|
|
940
|
+
* compliance frameworks come from the manifest, which the caller has already written.
|
|
941
|
+
*
|
|
942
|
+
* A version skew keeps the check this toggle has always had: the installed prompts are not the
|
|
943
|
+
* ones this package would rewrite, so nothing is converged and the outcome is `skipped`.
|
|
480
944
|
*
|
|
481
945
|
* The converge carries each installed agent's model and effort into the variant it
|
|
482
946
|
* writes (D-AGENT-OVERRIDE-CARRY), so the overrides are already on the file and this
|
|
@@ -487,28 +951,34 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
|
|
|
487
951
|
*
|
|
488
952
|
* Never throws.
|
|
489
953
|
*/
|
|
490
|
-
export async function
|
|
491
|
-
const { claudeDir, devflowDir,
|
|
954
|
+
export async function applyVariantToggle(opts) {
|
|
955
|
+
const { claudeDir, devflowDir, axis, enabled, runningVersion, warn } = opts;
|
|
492
956
|
const manifest = await readManifest(devflowDir);
|
|
493
957
|
if (manifest === null) {
|
|
494
|
-
return {
|
|
495
|
-
kind: 'skipped',
|
|
496
|
-
reason: 'no-manifest',
|
|
497
|
-
message: 'No readable install manifest, so the curation variants were not applied. Run `devflow init`.',
|
|
498
|
-
};
|
|
958
|
+
return { kind: 'skipped', reason: 'no-manifest', message: unreadableManifestMessage(axis, enabled) };
|
|
499
959
|
}
|
|
500
|
-
const skew =
|
|
960
|
+
const skew = variantToggleSkew(manifest.version, runningVersion, axis, enabled);
|
|
501
961
|
if (skew !== null)
|
|
502
962
|
return { kind: 'skipped', reason: 'version-skew', message: skew };
|
|
503
963
|
const selection = new Set(manifest.plugins);
|
|
504
|
-
const
|
|
964
|
+
const state = {
|
|
965
|
+
features: {
|
|
966
|
+
curation: axis === 'curation' ? enabled : manifest.features.curation,
|
|
967
|
+
compliance: axis === 'compliance' ? { ...manifest.features.compliance, enabled } : manifest.features.compliance,
|
|
968
|
+
rules: manifest.features.rules,
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
const result = await convergeVariantAxes({
|
|
505
972
|
claudeDir,
|
|
506
973
|
devflowDir,
|
|
507
|
-
|
|
974
|
+
manifest: state,
|
|
508
975
|
plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
|
|
509
976
|
warn,
|
|
510
977
|
packageRoot: opts.packageRoot,
|
|
511
978
|
});
|
|
979
|
+
if (result.converged) {
|
|
980
|
+
await recordInstalledVariants(devflowDir, { curation: state.features.curation, compliance: state.features.compliance.enabled }, warn);
|
|
981
|
+
}
|
|
512
982
|
let agentMappingReapplied = false;
|
|
513
983
|
if (result.agentsRewritten.length > 0) {
|
|
514
984
|
try {
|
|
@@ -521,7 +991,7 @@ export async function applyCurationToggle(opts) {
|
|
|
521
991
|
agentMappingReapplied = true;
|
|
522
992
|
}
|
|
523
993
|
catch (err) {
|
|
524
|
-
warn(`
|
|
994
|
+
warn(`prompt variants: could not reapply the agent model mapping — ${String(err)}`);
|
|
525
995
|
}
|
|
526
996
|
}
|
|
527
997
|
return { kind: 'converged', result, agentMappingReapplied };
|