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