@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.
@@ -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, SLOT_RECIPES, resolveActivityRecipe } from './recipes.mjs';
23
+ import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
25
24
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
-
27
- // The hand-edited, per-project config (strict JSON). cwd-relative the error prefix uses this rel path
28
- // so a user sees a path they can open, never an absolute temp/host path.
29
- export const CONFIG_REL = 'docs/ai/orchestration.json';
30
-
31
- // A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config|engine).
32
- // Avoids a class (project rule) while letting main() map a throw to the right code in one place.
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 activity's slots + SLOT_RECIPES. Every malformed token is a USAGE error (exit 2): a bare
39
- // `<recipe>` (no slot), an unknown slot for the activity, an invalid recipe-for-slot, or a duplicate
40
- // slot. (An override naming a recipe whose backend merely is not `ready` is NOT a usage error it
41
- // degrades loudly at resolution time, exit 0.)
42
- const parseOverrides = (tokens, activity, activityDef) => {
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
- const slotType = activityDef.slots[slot];
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, activityDef), json };
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
+ }
@@ -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 derived = deriveLinks(readBundledManifest(place.bundleDir, deps), skillDir);
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