@sabaiway/agent-workflow-kit 1.17.0 → 1.19.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 +55 -0
- package/README.md +13 -7
- package/SKILL.md +44 -8
- package/bin/install.mjs +128 -33
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/orchestration.json +8 -3
- package/tools/commands.mjs +7 -0
- package/tools/family-members.mjs +67 -0
- package/tools/family-registry.mjs +133 -161
- package/tools/inject-methodology.mjs +90 -16
- package/tools/labels.mjs +81 -0
- package/tools/orchestration-config.mjs +297 -0
- package/tools/orchestration-write.mjs +97 -0
- package/tools/presentation.mjs +55 -0
- package/tools/procedures.mjs +17 -102
- package/tools/renderers.mjs +119 -0
- package/tools/set-recipe.mjs +217 -0
- package/tools/setup-backends.mjs +71 -5
- package/tools/surface.mjs +75 -0
- package/tools/view-model.mjs +89 -0
package/tools/procedures.mjs
CHANGED
|
@@ -17,48 +17,36 @@
|
|
|
17
17
|
// installed engine is absent / invalid / too old to ship references/procedures.md).
|
|
18
18
|
|
|
19
19
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
20
|
import { homedir } from 'node:os';
|
|
22
21
|
import { pathToFileURL } from 'node:url';
|
|
23
22
|
import { detectBackends } from './detect-backends.mjs';
|
|
24
|
-
import { ACTIVITIES,
|
|
23
|
+
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
25
24
|
import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
25
|
+
// The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
|
|
26
|
+
// is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
|
|
27
|
+
// (orchestration-write.mjs) — so "the read-only advisor can never reach a writer" is STRUCTURALLY true
|
|
28
|
+
// (an import-split test pins it). CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs,
|
|
29
|
+
// historically) keep their import site working.
|
|
30
|
+
import { CONFIG_REL, fail, loadConfig, assertSlotRecipe } from './orchestration-config.mjs';
|
|
31
|
+
export { CONFIG_REL };
|
|
34
32
|
|
|
35
33
|
// ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
|
|
36
34
|
|
|
37
35
|
// Parse the activity's --override <slot>=<recipe> tokens into a { slot: recipe } map, validating each
|
|
38
|
-
// against the
|
|
39
|
-
//
|
|
40
|
-
// slot
|
|
41
|
-
//
|
|
42
|
-
|
|
36
|
+
// against the SHARED slot/recipe validity table (assertSlotRecipe — the SAME accept/reject the set-recipe
|
|
37
|
+
// op parser uses, drift-guarded). Every malformed token is a USAGE error (exit 2): a bare `<recipe>` (no
|
|
38
|
+
// slot), an unknown slot for the activity, an invalid recipe-for-slot, or a duplicate slot. (An override
|
|
39
|
+
// naming a recipe whose backend merely is not `ready` is NOT a usage error — it degrades loudly at
|
|
40
|
+
// resolution time, exit 0.) The `--override` grammar stays activity-SCOPED (the activity comes from the
|
|
41
|
+
// CLI arg), unlike the fully-qualified `--set <activity>.<slot>=<recipe>` the writer takes.
|
|
42
|
+
const parseOverrides = (tokens, activity) => {
|
|
43
43
|
const overrides = {};
|
|
44
44
|
for (const tok of tokens) {
|
|
45
45
|
const eq = tok.indexOf('=');
|
|
46
46
|
if (eq <= 0) throw fail(2, `--override must be <slot>=<recipe> (got "${tok}")`);
|
|
47
47
|
const slot = tok.slice(0, eq);
|
|
48
48
|
const recipe = tok.slice(eq + 1);
|
|
49
|
-
|
|
50
|
-
if (!slotType) {
|
|
51
|
-
throw fail(
|
|
52
|
-
2,
|
|
53
|
-
`--override: unknown slot "${slot}" for activity "${activity}" (${activity} slots: ${Object.keys(activityDef.slots).join(', ')})`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
if (!(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
|
|
57
|
-
throw fail(
|
|
58
|
-
2,
|
|
59
|
-
`--override: invalid recipe "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
49
|
+
assertSlotRecipe(activity, slot, recipe); // shared validity (unknown slot / invalid recipe → exit 2)
|
|
62
50
|
if (slot in overrides) throw fail(2, `--override: duplicate override for slot "${slot}"`);
|
|
63
51
|
overrides[slot] = recipe;
|
|
64
52
|
}
|
|
@@ -94,80 +82,7 @@ const parseArgs = (argv) => {
|
|
|
94
82
|
if (!activity) throw fail(2, `missing <activity> (known: ${KNOWN_ACTIVITIES()})`);
|
|
95
83
|
const activityDef = ACTIVITIES[activity];
|
|
96
84
|
if (!activityDef) throw fail(2, `unknown activity "${activity}" (known: ${KNOWN_ACTIVITIES()})`);
|
|
97
|
-
return { activity, overrides: parseOverrides(overrideTokens, activity
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
// ── config IO + validation (config errors → exit 1) ────────────────────────────────
|
|
101
|
-
|
|
102
|
-
// Validate a parsed orchestration.json object against the §2.0 schema. Strict: an unknown top-level
|
|
103
|
-
// activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
|
|
104
|
-
// optional. An optional "_README" string key is allowed + ignored (self-documentation). Never a silent
|
|
105
|
-
// fallback — every rejection is a loud `path: reason`.
|
|
106
|
-
const validateConfig = (config) => {
|
|
107
|
-
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
108
|
-
throw fail(1, `${CONFIG_REL}: must be a JSON object of activity → { slot: recipe }`);
|
|
109
|
-
}
|
|
110
|
-
for (const [key, val] of Object.entries(config)) {
|
|
111
|
-
if (key === '_README') {
|
|
112
|
-
if (typeof val !== 'string') throw fail(1, `${CONFIG_REL}: "_README" must be a string`);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
const activityDef = ACTIVITIES[key];
|
|
116
|
-
if (!activityDef) {
|
|
117
|
-
throw fail(1, `${CONFIG_REL}: unknown activity "${key}" (known: ${KNOWN_ACTIVITIES()})`);
|
|
118
|
-
}
|
|
119
|
-
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
|
|
120
|
-
throw fail(1, `${CONFIG_REL}: activity "${key}" must be a JSON object of slot → recipe`);
|
|
121
|
-
}
|
|
122
|
-
for (const [slot, recipe] of Object.entries(val)) {
|
|
123
|
-
const slotType = activityDef.slots[slot];
|
|
124
|
-
if (!slotType) {
|
|
125
|
-
throw fail(
|
|
126
|
-
1,
|
|
127
|
-
`${CONFIG_REL}: unknown slot "${slot}" for activity "${key}" (${key} slots: ${Object.keys(activityDef.slots).join(', ')})`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
|
|
131
|
-
throw fail(
|
|
132
|
-
1,
|
|
133
|
-
`${CONFIG_REL}: invalid recipe "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return config;
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// Load + validate the config from <cwd>/docs/ai/orchestration.json. Absent FILE → computed defaults
|
|
142
|
-
// (NOT an error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable →
|
|
143
|
-
// loud `path: reason` (exit 1). The resolver receives the parsed+validated object (§2.2 IO/resolver split).
|
|
144
|
-
// Exported so the read-only status settings-survey (family-registry.mjs) reuses ONE config reader —
|
|
145
|
-
// same strict-JSON + loud-on-malformed contract, no second drifting implementation (Plan 3.1).
|
|
146
|
-
export const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
|
|
147
|
-
const full = join(cwd, CONFIG_REL);
|
|
148
|
-
// Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
|
|
149
|
-
// unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT
|
|
150
|
-
// follow the link, so a dangling symlink reads as "present" here and its later read failure surfaces
|
|
151
|
-
// loudly — never silently treated as absent (no-silent-failures Hard Constraint).
|
|
152
|
-
try {
|
|
153
|
-
lstat(full);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
|
|
156
|
-
throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
157
|
-
}
|
|
158
|
-
let raw;
|
|
159
|
-
try {
|
|
160
|
-
raw = readFile(full, 'utf8');
|
|
161
|
-
} catch (err) {
|
|
162
|
-
throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
163
|
-
}
|
|
164
|
-
let parsed;
|
|
165
|
-
try {
|
|
166
|
-
parsed = JSON.parse(raw);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
throw fail(1, `${CONFIG_REL}: malformed JSON (${err.message})`);
|
|
169
|
-
}
|
|
170
|
-
return { config: validateConfig(parsed), source: CONFIG_REL };
|
|
85
|
+
return { activity, overrides: parseOverrides(overrideTokens, activity), json };
|
|
171
86
|
};
|
|
172
87
|
|
|
173
88
|
// ── engine canon: live read + per-activity section extraction (engine errors → exit 1) ──
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// renderers.mjs — the direct-CLI presenters (plain + ansi) for the four status blocks. Plan §4.2/§4.5.
|
|
2
|
+
//
|
|
3
|
+
// Input: the ViewModel (view-model.mjs, built from the no-leak envelope) + a resolved surface
|
|
4
|
+
// (surface.mjs: { mode, width, color, ascii }). Output: a single string. `mode` selects plain vs ansi;
|
|
5
|
+
// the JSON mode is handled by the caller (it prints the envelope, not a render). The renderers do
|
|
6
|
+
// LAYOUT + GLYPHS only — phrasing + headline counts already live in the ViewModel.
|
|
7
|
+
//
|
|
8
|
+
// Pure, no side effects, Node >= 18.
|
|
9
|
+
|
|
10
|
+
import { BLOCK_TITLES, SETTINGS_LABELS, glyphsFor, NO_DEPLOYMENT } from './presentation.mjs';
|
|
11
|
+
|
|
12
|
+
const MEMBER_COL = 20;
|
|
13
|
+
const VERSION_COL = 12;
|
|
14
|
+
const READINESS_COL = 14;
|
|
15
|
+
const STAMP_COL = 26;
|
|
16
|
+
const SETTINGS_COL = 14;
|
|
17
|
+
|
|
18
|
+
const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
|
|
19
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
20
|
+
export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
|
|
21
|
+
|
|
22
|
+
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
23
|
+
// A styled span only when color is on — so color:false emits ZERO SGR (plain output, byte-clean).
|
|
24
|
+
const heading = (text, color) => (color ? `${SGR.bold}${text}${SGR.reset}` : text);
|
|
25
|
+
|
|
26
|
+
const renderMembers = (vm, { color, glyph }) => {
|
|
27
|
+
const lines = [heading(BLOCK_TITLES.members, color), ''];
|
|
28
|
+
for (const m of vm.members) {
|
|
29
|
+
const ver = m.version ? `v${m.version}` : '—';
|
|
30
|
+
const tail = m.statePhrase == null ? '' : m.statePhrase; // installed → the version IS the info
|
|
31
|
+
lines.push(` ${pad(m.display, MEMBER_COL)}${pad(ver, VERSION_COL)}${tail}`.trimEnd());
|
|
32
|
+
for (const note of m.notes) lines.push(` ${glyph.note} ${note}`); // verbatim caveats (INV-3)
|
|
33
|
+
}
|
|
34
|
+
// refresh.behind drives a HEADLINE COUNT only — the recovery command stays in the verbatim notes
|
|
35
|
+
// above (the direct CLI never dedupes, never re-prints it). Omitted when nothing is behind.
|
|
36
|
+
if (vm.headline.behind > 0) {
|
|
37
|
+
lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above).`);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const renderBridges = (vm, { glyph, color }) => {
|
|
43
|
+
if (!vm.bridges) return [];
|
|
44
|
+
const lines = ['', heading(BLOCK_TITLES.bridges, color), ''];
|
|
45
|
+
for (const b of vm.bridges) {
|
|
46
|
+
const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
|
|
47
|
+
lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const renderProject = (vm, { color }) => {
|
|
53
|
+
const p = vm.project;
|
|
54
|
+
if (!p) return [];
|
|
55
|
+
const lines = ['', heading(BLOCK_TITLES.project(p.dir), color), ''];
|
|
56
|
+
if (!p.deployed) {
|
|
57
|
+
lines.push(` ${NO_DEPLOYMENT}`);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
|
|
61
|
+
lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
|
|
62
|
+
if (p.visibility) {
|
|
63
|
+
const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
|
|
64
|
+
lines.push(` ${pad('visibility', STAMP_COL)}${v}`);
|
|
65
|
+
}
|
|
66
|
+
return lines;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const renderSettings = (vm, { color, glyph }) => {
|
|
70
|
+
const s = vm.project?.settings;
|
|
71
|
+
if (!s) return [];
|
|
72
|
+
const lines = ['', heading(BLOCK_TITLES.settings, color)];
|
|
73
|
+
// recipes — the effective recipe per slot, or a loud error; a detector floor adds a sub-line.
|
|
74
|
+
if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
|
|
75
|
+
else if (s.recipes) {
|
|
76
|
+
const joined = s.recipes.pairs.map((p) => `${p.key}=${p.recipe}`).join(' · ') || '—';
|
|
77
|
+
lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}${joined}`);
|
|
78
|
+
if (s.recipes.detectError) {
|
|
79
|
+
lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// attribution — effective includeCoAuthoredBy; a real local override is called out.
|
|
83
|
+
if (s.attribution?.error) lines.push(` ${pad(SETTINGS_LABELS.attribution, SETTINGS_COL)}error: ${s.attribution.error}`);
|
|
84
|
+
else if (s.attribution) {
|
|
85
|
+
const override = s.attribution.override ? ' (local override)' : '';
|
|
86
|
+
lines.push(` ${pad(SETTINGS_LABELS.attribution, SETTINGS_COL)}includeCoAuthoredBy effective=${String(s.attribution.effective)}${override}`);
|
|
87
|
+
}
|
|
88
|
+
// velocity — effective defaultMode + per-source allow counts.
|
|
89
|
+
if (s.velocity?.error) lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}error: ${s.velocity.error}`);
|
|
90
|
+
else if (s.velocity) {
|
|
91
|
+
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
92
|
+
}
|
|
93
|
+
return lines;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Pad each NON-empty line UP to the surface width (by VISIBLE length, so SGR codes don't count); empty
|
|
97
|
+
// separator lines stay empty. `width` is a MIN target for a consistent block, NOT a hard max: a long
|
|
98
|
+
// content line — a verbatim caveat `↳`, a long recipe row — is left INTACT and is NEVER truncated,
|
|
99
|
+
// because truncating could hide part of a recovery command (a no-silent-failure violation). The
|
|
100
|
+
// terminal soft-wraps an over-width line; data integrity wins over a perfectly rectangular block.
|
|
101
|
+
const padLineTo = (line, width) => {
|
|
102
|
+
if (line === '') return '';
|
|
103
|
+
const vis = visibleLength(line);
|
|
104
|
+
return vis < width ? line + ' '.repeat(width - vis) : line;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const render = (vm, surface = {}) => {
|
|
108
|
+
const glyph = glyphsFor(Boolean(surface.ascii));
|
|
109
|
+
const color = surface.mode === 'ansi' && Boolean(surface.color); // color applies in ansi mode only
|
|
110
|
+
const ctx = { color, glyph };
|
|
111
|
+
const lines = [
|
|
112
|
+
...renderMembers(vm, ctx),
|
|
113
|
+
...renderBridges(vm, ctx),
|
|
114
|
+
...renderProject(vm, ctx),
|
|
115
|
+
...renderSettings(vm, ctx),
|
|
116
|
+
];
|
|
117
|
+
if (surface.mode === 'ansi') return lines.map((l) => padLineTo(l, surface.width ?? 80)).join('\n');
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// set-recipe.mjs — the WRITER for docs/ai/orchestration.json. The division of labor (AD-025): the AGENT
|
|
3
|
+
// turns plain language into explicit `--set <activity>.<slot>=<recipe>` / `--unset <activity>.<slot>`
|
|
4
|
+
// ops; the KIT does the deterministic validate → merge → preview → write. The kit ships NO NL parser
|
|
5
|
+
// (stays dependency-free + deterministic) and performs no `all`-magic — the agent expands "both review"
|
|
6
|
+
// into explicit per-activity ops (asking if scope is unclear).
|
|
7
|
+
//
|
|
8
|
+
// Posture: PREVIEW BY DEFAULT (dry-run); `--write` applies. It NEVER commits and NEVER runs a backend.
|
|
9
|
+
// It previews current→proposed for the CHANGED slots only, resolves the effective recipe vs LIVE backend
|
|
10
|
+
// readiness (degradation honesty on BOTH the preview and the --write path), and writes only via the
|
|
11
|
+
// hardened writeConfig (deployment gate; exclusive-create tmp+rename; symlink/TOCTOU-safe; last-writer-
|
|
12
|
+
// wins). A no-op set never writes and never spuriously seeds the _README. `--unset` returns a slot to its
|
|
13
|
+
// computed default, so reverting needs no hand-edit either. Hand-edit stays first-class — this is an
|
|
14
|
+
// OFFERED convenience, never a lock.
|
|
15
|
+
//
|
|
16
|
+
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes to the user's
|
|
17
|
+
// language when narrating. Exit codes: 0 success (an explicit recipe that gracefully degrades is still
|
|
18
|
+
// 0); 2 usage (bad/duplicate op, --write with zero ops); 1 config error (malformed/unreadable config)
|
|
19
|
+
// or a write STOP (no deployment / symlinked leaf). main(argv, ctx) → { code, stdout, stderr }; cwd /
|
|
20
|
+
// env / home / detect / fs are injectable for host-independent tests.
|
|
21
|
+
//
|
|
22
|
+
// Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
23
|
+
|
|
24
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { pathToFileURL } from 'node:url';
|
|
27
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
28
|
+
import { resolveActivityRecipe } from './recipes.mjs';
|
|
29
|
+
import {
|
|
30
|
+
CONFIG_REL,
|
|
31
|
+
fail,
|
|
32
|
+
loadConfig,
|
|
33
|
+
validateConfig,
|
|
34
|
+
parseOp,
|
|
35
|
+
applySetOps,
|
|
36
|
+
serializeConfig,
|
|
37
|
+
CANON_README,
|
|
38
|
+
} from './orchestration-config.mjs';
|
|
39
|
+
import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
|
|
40
|
+
|
|
41
|
+
// ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
// Parse argv → { ops, write, json }. `--set`/`--unset` take a fully-qualified token (parseOp validates
|
|
44
|
+
// it). A duplicate op for the same activity.slot, a `--write` with zero ops, an unknown flag, or a bad
|
|
45
|
+
// token → exit 2. `--set=<tok>` / `--unset=<tok>` inline forms are accepted too.
|
|
46
|
+
const parseArgs = (argv) => {
|
|
47
|
+
const ops = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
let write = false;
|
|
50
|
+
let json = false;
|
|
51
|
+
const takeOp = (kind, tok) => {
|
|
52
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<recipe>' : ''}`);
|
|
53
|
+
const op = parseOp(kind, tok);
|
|
54
|
+
const key = `${op.activity}.${op.slot}`;
|
|
55
|
+
if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
|
|
56
|
+
seen.add(key);
|
|
57
|
+
ops.push(op);
|
|
58
|
+
};
|
|
59
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
60
|
+
const a = argv[i];
|
|
61
|
+
if (a === '--json') json = true;
|
|
62
|
+
else if (a === '--write') write = true;
|
|
63
|
+
else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
|
|
64
|
+
else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
|
|
65
|
+
else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
|
|
66
|
+
else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
|
|
67
|
+
else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
|
|
68
|
+
else throw fail(2, `unexpected argument: ${a}`);
|
|
69
|
+
}
|
|
70
|
+
if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset (a bare --write is a no-op)');
|
|
71
|
+
return { ops, write, json };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── effective-recipe resolution per op (degradation honesty) ────────────────────────
|
|
75
|
+
|
|
76
|
+
// A single op's before/after value + the effective recipe it resolves to here (vs live readiness).
|
|
77
|
+
// `to` is null for an unset (falls to the computed default). degradedFrom/reason carry the honesty.
|
|
78
|
+
const resolveOp = (op, current, after, detection) => {
|
|
79
|
+
const from = current?.[op.activity]?.[op.slot] ?? null;
|
|
80
|
+
const to = after?.[op.activity]?.[op.slot] ?? null;
|
|
81
|
+
const r = resolveActivityRecipe({ config: after ?? {}, readiness: detection, activity: op.activity, slot: op.slot });
|
|
82
|
+
return { activity: op.activity, slot: op.slot, from, to, effective: r.recipe, degradedFrom: r.degradedFrom, reason: r.reason };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ── rendering (ENGLISH; the agent localizes) ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const valueLabel = (v) => (v == null ? '(computed default)' : v);
|
|
88
|
+
|
|
89
|
+
const effectiveLine = (e) =>
|
|
90
|
+
e.degradedFrom
|
|
91
|
+
? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
|
|
92
|
+
: `effective here: ${e.effective}`;
|
|
93
|
+
|
|
94
|
+
const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody }) => {
|
|
95
|
+
const lines = [];
|
|
96
|
+
if (wrote) lines.push(`wrote ${CONFIG_REL}`);
|
|
97
|
+
else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
|
|
98
|
+
for (const e of changed) {
|
|
99
|
+
lines.push(` ${e.activity}.${e.slot}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
|
|
100
|
+
lines.push(` ↳ ${effectiveLine(e)}`);
|
|
101
|
+
}
|
|
102
|
+
for (const e of unchanged) lines.push(` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
|
|
103
|
+
for (const w of warnings) lines.push(` ⚠ ${w}`);
|
|
104
|
+
if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
|
|
105
|
+
if (!wrote) {
|
|
106
|
+
if (!changed.length) lines.push(' no changes — nothing to write.');
|
|
107
|
+
else if (willWrite) lines.push('', `would write ${CONFIG_REL} — re-run with --write to apply.`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const buildJson = ({ changed, unchanged, warnings, writtenPath, noop }) => ({
|
|
113
|
+
changed: changed.map((e) => ({ activity: e.activity, slot: e.slot, from: e.from, to: e.to, effective: e.effective, degradedFrom: e.degradedFrom ?? null, reason: e.reason ?? null })),
|
|
114
|
+
unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
|
|
115
|
+
writtenPath: writtenPath ?? null,
|
|
116
|
+
noop,
|
|
117
|
+
warnings,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
|
|
121
|
+
|
|
122
|
+
Usage:
|
|
123
|
+
node set-recipe.mjs [--set <activity>.<slot>=<recipe>]... [--unset <activity>.<slot>]... [--write] [--json]
|
|
124
|
+
|
|
125
|
+
--set <activity>.<slot>=<recipe> pin a recipe (fully-qualified; e.g. plan-authoring.review=council)
|
|
126
|
+
--unset <activity>.<slot> return a slot to its computed default
|
|
127
|
+
--write apply the change (default: preview only — writes nothing)
|
|
128
|
+
--json machine-readable output
|
|
129
|
+
--help, -h this help
|
|
130
|
+
|
|
131
|
+
Activities/slots: plan-authoring → review; plan-execution → execute, review
|
|
132
|
+
Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
|
|
133
|
+
|
|
134
|
+
Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
|
|
135
|
+
Config writer only: it NEVER runs a backend and NEVER commits. Hand-editing the file stays fully supported.
|
|
136
|
+
|
|
137
|
+
Exit codes: 0 success (an explicit recipe that gracefully degrades is still 0);
|
|
138
|
+
2 usage (bad/duplicate op, or --write with no ops);
|
|
139
|
+
1 config error (malformed/unreadable config) or a write STOP (no deployment / symlinked leaf).`;
|
|
140
|
+
|
|
141
|
+
// ── main ────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export const main = (argv, ctx = {}) => {
|
|
144
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
145
|
+
const detect = ctx.detect ?? detectBackends;
|
|
146
|
+
const readFile = ctx.readFileSync ?? readFileSync;
|
|
147
|
+
const lstat = ctx.lstatSync ?? lstatSync;
|
|
148
|
+
const writeConfig = ctx.writeConfig ?? writeConfigFs;
|
|
149
|
+
try {
|
|
150
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
151
|
+
const { ops, write, json } = parseArgs(argv);
|
|
152
|
+
|
|
153
|
+
// Load the current config first (loadConfig throws fail(1) loud on malformed/unreadable — a write
|
|
154
|
+
// never clobbers an unparseable file; the message points the agent at the parse error to help fix it).
|
|
155
|
+
const { config: current, source } = loadConfig(cwd, readFile, lstat);
|
|
156
|
+
|
|
157
|
+
// No ops + no --write → show the current config + a hint (read-only; nothing changes).
|
|
158
|
+
if (ops.length === 0) {
|
|
159
|
+
if (json) {
|
|
160
|
+
return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], warnings: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
|
|
161
|
+
}
|
|
162
|
+
const shown = current == null ? `(no ${CONFIG_REL} yet — computed defaults apply)` : serializeConfig(current).replace(/\n$/, '');
|
|
163
|
+
const hint = `\nPass --set <activity>.<slot>=<recipe> (preview) then --write to apply. Activities/slots: plan-authoring.review, plan-execution.execute, plan-execution.review.`;
|
|
164
|
+
return { code: 0, stdout: `${source === 'none' ? '' : `${CONFIG_REL}:\n`}${shown}${hint}`, stderr: '' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const after = applySetOps(current, ops, { seedReadme: CANON_README });
|
|
168
|
+
|
|
169
|
+
// Detection is a SECONDARY input — it only refines the EFFECTIVE recipe note. A throw must NOT block
|
|
170
|
+
// the write (the config write is readiness-independent): treat all backends as not-ready, warn, exit 0.
|
|
171
|
+
const warnings = [];
|
|
172
|
+
let detection = [];
|
|
173
|
+
try {
|
|
174
|
+
detection = detect();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
warnings.push(`backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const resolved = ops.map((op) => resolveOp(op, current, after, detection));
|
|
180
|
+
const changed = resolved.filter((e) => e.from !== e.to);
|
|
181
|
+
const unchanged = resolved.filter((e) => e.from === e.to);
|
|
182
|
+
const noop = changed.length === 0;
|
|
183
|
+
|
|
184
|
+
if (!write) {
|
|
185
|
+
const stdout = json
|
|
186
|
+
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop }), null, 2)
|
|
187
|
+
: formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false });
|
|
188
|
+
return { code: 0, stdout, stderr: '' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --write. A no-op never writes (idempotent; never re-seeds the _README).
|
|
192
|
+
if (noop) {
|
|
193
|
+
const stdout = json
|
|
194
|
+
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop: true }), null, 2)
|
|
195
|
+
: formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false });
|
|
196
|
+
return { code: 0, stdout, stderr: '' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
validateConfig(after); // defensive re-validate immediately before the write
|
|
200
|
+
const { writtenPath } = writeConfig(cwd, after, ctx);
|
|
201
|
+
const fileBody = serializeConfig(after);
|
|
202
|
+
const stdout = json
|
|
203
|
+
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false }), null, 2)
|
|
204
|
+
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody });
|
|
205
|
+
return { code: 0, stdout, stderr: '' };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
212
|
+
if (isDirectRun) {
|
|
213
|
+
const r = main(process.argv.slice(2));
|
|
214
|
+
if (r.stdout) console.log(r.stdout);
|
|
215
|
+
if (r.stderr) console.error(r.stderr);
|
|
216
|
+
process.exit(r.code);
|
|
217
|
+
}
|
package/tools/setup-backends.mjs
CHANGED
|
@@ -24,9 +24,9 @@ import {
|
|
|
24
24
|
import { join, resolve, relative, dirname, isAbsolute } from 'node:path';
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
26
|
import os from 'node:os';
|
|
27
|
-
import { KNOWN_BACKENDS, detectBackend, resolveDir, guideFor } from './detect-backends.mjs';
|
|
27
|
+
import { KNOWN_BACKENDS, detectBackend, detectBackends, resolveDir, guideFor, READY } from './detect-backends.mjs';
|
|
28
28
|
import { copyTreeRefresh, linkManaged } from './fs-safe.mjs';
|
|
29
|
-
import { validateManifest, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
29
|
+
import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
30
30
|
|
|
31
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
32
|
// bridges/ ships beside tools/ in both the repo and the installed kit, so this resolves in both.
|
|
@@ -315,9 +315,18 @@ export const planFor = (backend, deps = {}) => {
|
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
let links;
|
|
318
|
+
let version = null;
|
|
319
|
+
let priorVersion = null;
|
|
318
320
|
try {
|
|
319
321
|
const fs = fsDeps(deps);
|
|
320
|
-
const
|
|
322
|
+
const readVersion = deps.readVersion ?? readAuthoritativeVersion;
|
|
323
|
+
const bundledManifest = readBundledManifest(place.bundleDir, deps);
|
|
324
|
+
// The bundled bridge version (what setup will place/refresh) + the prior installed version (only on
|
|
325
|
+
// a refresh — a place has no prior). readAuthoritativeVersion is the SAME source `status` reads, so
|
|
326
|
+
// setup never invents a second version reader. A place / absent-prior shows no arrow (never "vnull").
|
|
327
|
+
version = typeof bundledManifest.version === 'string' ? bundledManifest.version : null;
|
|
328
|
+
priorVersion = place.action === 'refresh' ? readVersion(skillDir).version ?? null : null;
|
|
329
|
+
const derived = deriveLinks(bundledManifest, skillDir);
|
|
321
330
|
// Preflight the BUNDLE sources (what we will copy → link). After place, the skill source IS the
|
|
322
331
|
// bundle source, so checking it here makes a dry-run faithfully predict linkWrappers instead of
|
|
323
332
|
// reporting "ok" and then throwing at apply time.
|
|
@@ -347,7 +356,7 @@ export const planFor = (backend, deps = {}) => {
|
|
|
347
356
|
const onPath = bindirOnPath(bindir, deps.getenv ?? process.env, platform);
|
|
348
357
|
const bindirHint = onPath ? null : `add ${bindir} to PATH to use the wrappers: export PATH="${bindir}:$PATH" (persist in ~/.bashrc / ~/.zshrc)`;
|
|
349
358
|
|
|
350
|
-
return { name: entry.name, skillDir, bindir, platform, place, links, guides, bindirHint, outcome: 'ok' };
|
|
359
|
+
return { name: entry.name, skillDir, bindir, platform, place, links, guides, bindirHint, outcome: 'ok', version, priorVersion };
|
|
351
360
|
};
|
|
352
361
|
|
|
353
362
|
// Perform an `ok` plan's deterministic steps. Re-derives + re-checks inside the primitives (no trust
|
|
@@ -361,6 +370,16 @@ const applyBackend = (plan, deps) => {
|
|
|
361
370
|
|
|
362
371
|
// ── formatting ─────────────────────────────────────────────────────────────────
|
|
363
372
|
|
|
373
|
+
// The bridge version label for the skill line: place / absent-prior → "(vX)" (no arrow, never
|
|
374
|
+
// "vnull → vX"); refresh with a DIFFERENT prior → "(vOld → vNew)"; refresh equal / null prior → "(vX)".
|
|
375
|
+
const versionLabel = (plan) => {
|
|
376
|
+
if (!plan.version) return '';
|
|
377
|
+
if (plan.place?.action === 'refresh' && plan.priorVersion && plan.priorVersion !== plan.version) {
|
|
378
|
+
return ` (v${plan.priorVersion} → v${plan.version})`;
|
|
379
|
+
}
|
|
380
|
+
return ` (v${plan.version})`;
|
|
381
|
+
};
|
|
382
|
+
|
|
364
383
|
const formatBackend = (plan, applied) => {
|
|
365
384
|
const lines = [` ${plan.name} → ${plan.outcome}`];
|
|
366
385
|
if (plan.outcome === 'unsupported') {
|
|
@@ -372,7 +391,7 @@ const formatBackend = (plan, applied) => {
|
|
|
372
391
|
return lines.join('\n');
|
|
373
392
|
}
|
|
374
393
|
const placeVerb = applied ? { place: 'placed', refresh: 'refreshed' } : { place: 'will place', refresh: 'will refresh' };
|
|
375
|
-
lines.push(` • skill: ${placeVerb[plan.place.action]} → ${plan.skillDir}`);
|
|
394
|
+
lines.push(` • skill: ${placeVerb[plan.place.action]}${versionLabel(plan)} → ${plan.skillDir}`);
|
|
376
395
|
for (const l of plan.links) {
|
|
377
396
|
const verb = l.dstState === 'ours' ? 'already linked' : applied ? 'linked' : 'will link';
|
|
378
397
|
lines.push(` • wrapper ${l.cmd}: ${verb} → ${l.dst}`);
|
|
@@ -382,6 +401,41 @@ const formatBackend = (plan, applied) => {
|
|
|
382
401
|
return lines.join('\n');
|
|
383
402
|
};
|
|
384
403
|
|
|
404
|
+
// ── close-the-loop: surface versions + a proactive recipe offer ───────────────────
|
|
405
|
+
|
|
406
|
+
// The closing pointer: setup surfaces only the bridge it touched; the full family + deployment version
|
|
407
|
+
// view lives in the read-only status mode.
|
|
408
|
+
const STATUS_POINTER = 'Full family + deployment versions: /agent-workflow-kit status';
|
|
409
|
+
|
|
410
|
+
// Count the review-capable backends that are READY right now (both bridges provide review, so a READY
|
|
411
|
+
// count ≥1 unlocks Reviewed, ≥2 unlocks Council). Uses the MULTI-backend detector. A detection failure
|
|
412
|
+
// → null: we can't tell, so we make NO offer (never a false one). Read-only.
|
|
413
|
+
const reviewReadyCount = (deps) => {
|
|
414
|
+
const detectAll = deps.detectAll ?? detectBackends;
|
|
415
|
+
try {
|
|
416
|
+
return detectAll(deps).filter((d) => d.readiness === READY).length;
|
|
417
|
+
} catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// After a successful setup, if a review backend NEWLY became ready (the pre-apply readiness in planFor
|
|
423
|
+
// is stale — re-detect AFTER apply), offer to set the review recipe — for BOTH planning AND execution
|
|
424
|
+
// review (planning review is a core goal; never offer only plan-execution). Council when ≥2 are ready,
|
|
425
|
+
// else Reviewed (with a one-reviewer alternative noted). English; the agent localizes when it narrates.
|
|
426
|
+
export const proactiveReviewOffer = (before, after) => {
|
|
427
|
+
if (before == null || after == null || after <= before) return null;
|
|
428
|
+
const depth = after >= 2 ? 'council' : 'reviewed';
|
|
429
|
+
const lines = [
|
|
430
|
+
'',
|
|
431
|
+
`A review backend is now ready (${after} ready). To have your plans reviewed, set the review recipe (preview first; I'll write it for you — or edit docs/ai/orchestration.json yourself):`,
|
|
432
|
+
` /agent-workflow-kit set-recipe --set plan-authoring.review=${depth}`,
|
|
433
|
+
` /agent-workflow-kit set-recipe --set plan-execution.review=${depth}`,
|
|
434
|
+
];
|
|
435
|
+
if (depth === 'council') lines.push(' (or =reviewed for a single reviewer)');
|
|
436
|
+
return lines.join('\n');
|
|
437
|
+
};
|
|
438
|
+
|
|
385
439
|
// ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
386
440
|
|
|
387
441
|
const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run] [--help]
|
|
@@ -448,12 +502,16 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
448
502
|
|
|
449
503
|
const runDeps = { ...deps, bindir: args.bindir ?? deps.bindir };
|
|
450
504
|
log(args.dryRun ? 'agent-workflow backend setup — DRY RUN (no changes)' : 'agent-workflow backend setup (link-only)');
|
|
505
|
+
// Snapshot review readiness BEFORE applying, so we can offer the recipe only on a true readiness flip.
|
|
506
|
+
const reviewBefore = args.dryRun ? null : reviewReadyCount(runDeps);
|
|
451
507
|
let worst = 0;
|
|
508
|
+
let appliedOk = false;
|
|
452
509
|
for (const name of targets) {
|
|
453
510
|
let plan = planFor(name, runDeps);
|
|
454
511
|
if (!args.dryRun && plan.outcome === 'ok') {
|
|
455
512
|
try {
|
|
456
513
|
applyBackend(plan, runDeps);
|
|
514
|
+
appliedOk = true;
|
|
457
515
|
} catch (err) {
|
|
458
516
|
plan = { ...plan, outcome: err.code === SETUP_STOP ? 'stop' : 'error', reason: err.message };
|
|
459
517
|
}
|
|
@@ -461,6 +519,14 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
461
519
|
log(formatBackend(plan, !args.dryRun));
|
|
462
520
|
if (plan.outcome === 'stop' || plan.outcome === 'error') worst = 1;
|
|
463
521
|
}
|
|
522
|
+
log('');
|
|
523
|
+
log(STATUS_POINTER);
|
|
524
|
+
// Re-detect AFTER apply (pre-apply readiness is stale): a review backend that just became ready earns
|
|
525
|
+
// a proactive set-recipe offer. Only after a real apply (never on a dry-run or a no-op run).
|
|
526
|
+
if (!args.dryRun && appliedOk) {
|
|
527
|
+
const offer = proactiveReviewOffer(reviewBefore, reviewReadyCount(runDeps));
|
|
528
|
+
if (offer) log(offer);
|
|
529
|
+
}
|
|
464
530
|
return worst;
|
|
465
531
|
};
|
|
466
532
|
|