@sabaiway/agent-workflow-kit 1.17.0 → 1.18.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.
@@ -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