@sabaiway/agent-workflow-kit 10.2.0 → 10.4.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +7 -7
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/agents/executor.md +40 -0
  7. package/references/modes/agents.md +9 -4
  8. package/references/modes/procedures.md +17 -8
  9. package/references/modes/recipes.md +7 -4
  10. package/references/modes/recommendations.md +4 -1
  11. package/references/modes/set-recipe.md +22 -5
  12. package/references/modes/status.md +3 -3
  13. package/references/modes/upgrade.md +7 -5
  14. package/references/shared/composition-handoff.md +1 -1
  15. package/references/shared/deploy-tail.md +2 -2
  16. package/references/templates/agent_rules.md +3 -2
  17. package/references/templates/orchestration.json +1 -1
  18. package/tools/ack-store.mjs +57 -0
  19. package/tools/ack-write.mjs +1 -1
  20. package/tools/autonomy-config.mjs +1 -1
  21. package/tools/carriers.mjs +140 -0
  22. package/tools/cheap-agents-read.mjs +172 -0
  23. package/tools/cheap-agents.mjs +57 -105
  24. package/tools/commands.mjs +3 -3
  25. package/tools/direct-run.mjs +6 -0
  26. package/tools/doc-parity.mjs +8 -0
  27. package/tools/ensure-ops.mjs +18 -9
  28. package/tools/ensure-specs.mjs +3 -4
  29. package/tools/ensure-vocabulary.mjs +5 -2
  30. package/tools/family-registry.mjs +70 -21
  31. package/tools/flow-check.mjs +2 -7
  32. package/tools/inject-methodology.mjs +4 -0
  33. package/tools/lens-region.mjs +4 -1
  34. package/tools/node-evidence.mjs +77 -0
  35. package/tools/orchestration-config.mjs +34 -13
  36. package/tools/procedures.mjs +65 -52
  37. package/tools/recipes.mjs +156 -184
  38. package/tools/recommendations.mjs +145 -78
  39. package/tools/renderers.mjs +36 -7
  40. package/tools/review-state.mjs +10 -11
  41. package/tools/set-recipe.mjs +63 -24
  42. package/tools/spec-adoption.mjs +71 -0
  43. package/tools/spec-check.mjs +2 -2
  44. package/tools/upgrade-runlist.mjs +1 -1
  45. package/tools/view-model.mjs +19 -3
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
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>`
3
+ // turns plain language into explicit `--set <activity>.<slot>=<value>` / `--unset <activity>.<slot>`
4
4
  // ops; the KIT does the deterministic validate → merge → preview → write. The kit ships NO NL parser
5
5
  // (stays dependency-free + deterministic) and performs no `all`-magic — the agent expands "both review"
6
6
  // into explicit per-activity ops (asking if scope is unclear).
@@ -17,15 +17,25 @@
17
17
  // language when narrating. Exit codes: 0 success (an explicit recipe that gracefully degrades is still
18
18
  // 0); 2 usage (bad/duplicate op, --write with zero ops); 1 config error (malformed/unreadable config)
19
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.
20
+ // env / home / detect / surveyVehicle / fs are injectable for host-independent tests.
21
+ //
22
+ // It writes every slot of every activity in the registry (carriers.mjs, via recipes.mjs) — the
23
+ // carrier slots and the `routine.parallel` switch included — and resolves each preview against the
24
+ // SAME readiness the recipes advisor composes: detected backends plus the executor-vehicle survey.
21
25
  //
22
26
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
23
27
 
24
28
  import { readFileSync, lstatSync } from 'node:fs';
25
29
  import { homedir } from 'node:os';
26
- import { detectBackends } from './detect-backends.mjs';
27
30
  import { isDirectRun } from './direct-run.mjs';
28
- import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
31
+ import {
32
+ ACTIVITIES,
33
+ SLOT_RECIPES,
34
+ EXECUTOR_APPLY,
35
+ composeReadiness,
36
+ resolveActivityRecipe,
37
+ composeActiveRecipeLine,
38
+ } from './recipes.mjs';
29
39
  import { loadAutonomy, resolveAutonomy } from './autonomy-config.mjs';
30
40
  import {
31
41
  CONFIG_REL,
@@ -35,6 +45,7 @@ import {
35
45
  parseOp,
36
46
  applySetOps,
37
47
  serializeConfig,
48
+ refreshReadme,
38
49
  CANON_README,
39
50
  } from './orchestration-config.mjs';
40
51
  import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
@@ -50,7 +61,7 @@ const parseArgs = (argv) => {
50
61
  let write = false;
51
62
  let json = false;
52
63
  const takeOp = (kind, tok) => {
53
- if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<recipe>' : ''}`);
64
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<value>' : ''}`);
54
65
  const op = parseOp(kind, tok);
55
66
  const key = `${op.activity}.${op.slot}`;
56
67
  if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
@@ -74,12 +85,24 @@ const parseArgs = (argv) => {
74
85
 
75
86
  // ── effective-recipe resolution per op (degradation honesty) ────────────────────────
76
87
 
88
+ // The readiness EVERY resolution here runs against: the detected bridges plus the executor-vehicle
89
+ // survey, composed by the one helper the recipes CLI uses. Detection is a SECONDARY input — a bridge
90
+ // detector throw must NOT block the write (the config write is readiness-independent) and must not
91
+ // cost the CARRIER either: the hook warns, the bridge half floors at not-ready, the vehicle survives.
92
+ const composeReadinessOrWarn = (cwd, deps, warnings) =>
93
+ composeReadiness(cwd, {
94
+ ...deps,
95
+ onDetectError: (err) => warnings.push(
96
+ `backend detection failed (${(err && err.message) || err}) — treating every bridge as not ready; recipes needing a bridge degrade to solo (the executor vehicle is unaffected).`,
97
+ ),
98
+ });
99
+
77
100
  // A single op's before/after value + the effective recipe it resolves to here (vs live readiness).
78
101
  // `to` is null for an unset (falls to the computed default). degradedFrom/reason carry the honesty.
79
- const resolveOp = (op, current, after, detection) => {
102
+ const resolveOp = (op, current, after, readiness) => {
80
103
  const from = current?.[op.activity]?.[op.slot] ?? null;
81
104
  const to = after?.[op.activity]?.[op.slot] ?? null;
82
- const r = resolveActivityRecipe({ config: after ?? {}, readiness: detection, activity: op.activity, slot: op.slot });
105
+ const r = resolveActivityRecipe({ config: after ?? {}, readiness, activity: op.activity, slot: op.slot });
83
106
  return { activity: op.activity, slot: op.slot, from, to, effective: r.recipe, degradedFrom: r.degradedFrom, reason: r.reason };
84
107
  };
85
108
 
@@ -127,19 +150,40 @@ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine
127
150
  activeLine: activeLine ?? null,
128
151
  });
129
152
 
153
+ // The writable surface, rendered FROM the registry — never re-typed as literals, so an activity, a
154
+ // slot or an accepted value added to the table shows up in the help (and in the doc that quotes it).
155
+ const ACTIVITY_LINES = Object.entries(ACTIVITIES)
156
+ .map(([activity, def]) => ` ${activity} → ${Object.keys(def.slots).join(', ')}`)
157
+ .join('\n');
158
+
159
+ const VALUE_LINES = Object.entries(SLOT_RECIPES)
160
+ .map(([slotType, values]) => ` ${slotType} slots accept ${values.join(' | ')}`)
161
+ .join('\n');
162
+
163
+ const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
164
+ .flatMap(([activity, def]) => Object.keys(def.slots).map((slot) => `${activity}.${slot}`))
165
+ .join(', ');
166
+
130
167
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
131
168
 
132
169
  Usage:
133
- node set-recipe.mjs [--set <activity>.<slot>=<recipe>]... [--unset <activity>.<slot>]... [--write] [--json]
170
+ node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]... [--write] [--json]
134
171
 
135
- --set <activity>.<slot>=<recipe> pin a recipe (fully-qualified; e.g. plan-authoring.review=council)
172
+ --set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
136
173
  --unset <activity>.<slot> return a slot to its computed default
137
174
  --write apply the change (default: preview only — writes nothing)
138
175
  --json machine-readable output
139
176
  --help, -h this help
140
177
 
141
- Activities/slots: plan-authoring review; plan-execution → execute, review
142
- Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
178
+ Activities and their slots:
179
+ ${ACTIVITY_LINES}
180
+
181
+ Accepted values per slot type:
182
+ ${VALUE_LINES}
183
+
184
+ A carrier slot set to subagent needs the executor vehicle placed in this project — ${EXECUTOR_APPLY};
185
+ without it the slot resolves to solo with the reason stated. routine.parallel is a flag, not a
186
+ recipe: it never degrades.
143
187
 
144
188
  Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
145
189
  Config writer only: it NEVER runs a backend and NEVER commits. Hand-editing the file stays fully supported.
@@ -152,7 +196,7 @@ Exit codes: 0 success (an explicit recipe that gracefully degrades is still 0);
152
196
 
153
197
  export const main = (argv, ctx = {}) => {
154
198
  const cwd = ctx.cwd ?? process.cwd();
155
- const detect = ctx.detect ?? detectBackends;
199
+ const readinessDeps = { detect: ctx.detect, surveyVehicle: ctx.surveyVehicle };
156
200
  const readFile = ctx.readFileSync ?? readFileSync;
157
201
  const lstat = ctx.lstatSync ?? lstatSync;
158
202
  const writeConfig = ctx.writeConfig ?? writeConfigFs;
@@ -170,23 +214,18 @@ export const main = (argv, ctx = {}) => {
170
214
  return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], warnings: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
171
215
  }
172
216
  const shown = current == null ? `(no ${CONFIG_REL} yet — computed defaults apply)` : serializeConfig(current).replace(/\n$/, '');
173
- const hint = `\nPass --set <activity>.<slot>=<recipe> (preview) then --write to apply. Activities/slots: plan-authoring.review, plan-execution.execute, plan-execution.review.`;
217
+ const hint = `\nPass --set <activity>.<slot>=<value> (preview) then --write to apply. Activities/slots: ${QUALIFIED_SLOTS}.`;
174
218
  return { code: 0, stdout: `${source === 'none' ? '' : `${CONFIG_REL}:\n`}${shown}${hint}`, stderr: '' };
175
219
  }
176
220
 
177
- const after = applySetOps(current, ops, { seedReadme: CANON_README });
221
+ // The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
222
+ // is replaced by the current one on a touched write, while a customized note stays untouched.
223
+ const after = refreshReadme(applySetOps(current, ops, { seedReadme: CANON_README })).config;
178
224
 
179
- // Detection is a SECONDARY input — it only refines the EFFECTIVE recipe note. A throw must NOT block
180
- // the write (the config write is readiness-independent): treat all backends as not-ready, warn, exit 0.
181
225
  const warnings = [];
182
- let detection = [];
183
- try {
184
- detection = detect();
185
- } catch (err) {
186
- warnings.push(`backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`);
187
- }
226
+ const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
188
227
 
189
- const resolved = ops.map((op) => resolveOp(op, current, after, detection));
228
+ const resolved = ops.map((op) => resolveOp(op, current, after, readiness));
190
229
  const changed = resolved.filter((e) => e.from !== e.to);
191
230
  const unchanged = resolved.filter((e) => e.from === e.to);
192
231
  const noop = changed.length === 0;
@@ -220,7 +259,7 @@ export const main = (argv, ctx = {}) => {
220
259
  return { error: (err && err.message) || String(err) };
221
260
  }
222
261
  })();
223
- const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection, autonomyFacts);
262
+ const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, readiness, autonomyFacts);
224
263
  const stdout = json
225
264
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
226
265
  : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
@@ -0,0 +1,71 @@
1
+ // spec-adoption.mjs — which of the four adoption states the feature-spec store is in, and whether the layer
2
+ // was declined. Contract: docs/ai/specs/kit/spec-adoption.md. The census and the per-document read are
3
+ // spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
4
+
5
+ import { join } from 'node:path';
6
+ import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
7
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
8
+ import { probe as probePath, realpath as realpathOf, list as listDir } from './spec-check-cli.mjs';
9
+ import { walkStore, readClosure } from './spec-check.mjs';
10
+ import { ACKS_SPEC_ADOPTION_KEY, factFingerprint, readAckValue } from './ack-store.mjs';
11
+
12
+ export const ADOPTION = Object.freeze({
13
+ NOT_ADOPTED: 'not-adopted',
14
+ ADOPTING: 'adopting',
15
+ ADOPTED: 'adopted',
16
+ UNREADABLE: 'unreadable',
17
+ });
18
+ export const ADOPTION_STATES = Object.freeze(Object.values(ADOPTION));
19
+
20
+ export const STORE_DIR_REL = SPEC_SCHEMA.storePrefix.slice(0, -1);
21
+ export const SPEC_ADOPTION_LANE = 'spec-adoption';
22
+ export const DECLINE_FACT = `spec-adoption:declined:${SPEC_SCHEMA.storePrefix}`;
23
+
24
+ const CONTRACT_KIND = 'spec';
25
+ const LIVE = 'live';
26
+ const DRAFT = 'draft';
27
+ const RETIRED = 'retired';
28
+
29
+ const DEFAULT_IO = Object.freeze({ read: readRegularFileNoFollow, probe: probePath, realpath: realpathOf, list: listDir });
30
+
31
+ const verdict = (state, counts = { live: 0, draft: 0, retired: 0 }, reason = null) => Object.freeze({ state, ...counts, reason });
32
+
33
+ // surveySpecAdoption(root, deps) -> { state, live, draft, retired, reason }. `deps.io` overrides the four
34
+ // IO primitives (tests); every other answer comes from the store bytes through the one reader.
35
+ export const surveySpecAdoption = (root, deps = {}) => {
36
+ const io = { ...DEFAULT_IO, ...(deps.io ?? {}) };
37
+ const dirState = io.probe(join(root, STORE_DIR_REL));
38
+ if (dirState === 'absent') return verdict(ADOPTION.NOT_ADOPTED);
39
+ if (dirState !== 'dir') return verdict(ADOPTION.UNREADABLE, undefined, `${STORE_DIR_REL} is ${dirState === 'file' ? 'a file' : dirState}, not a directory`);
40
+ const rootReal = io.realpath(root);
41
+ if (rootReal === null) return verdict(ADOPTION.UNREADABLE, undefined, 'the project root does not resolve');
42
+ const findings = [];
43
+ const ctx = { io, at: (rel) => (rel === '' ? root : `${root}/${rel}`), rootReal, add: (rule, path, message) => findings.push({ rule, path, message }) };
44
+ const closure = walkStore(ctx).map((path) => ({ path, roles: ['present'] }));
45
+ const docs = findings.length === 0 ? readClosure(closure, ctx) : new Map();
46
+ if (findings.length > 0) return verdict(ADOPTION.UNREADABLE, undefined, `${findings[0].path}: ${findings[0].message}`);
47
+ const counts = { live: 0, draft: 0, retired: 0 };
48
+ for (const doc of docs.values()) {
49
+ if (doc.verdict?.kind !== CONTRACT_KIND) continue;
50
+ if (doc.verdict.status === LIVE) counts.live += 1;
51
+ else if (doc.verdict.status === DRAFT) counts.draft += 1;
52
+ else if (doc.verdict.status === RETIRED) counts.retired += 1;
53
+ }
54
+ return verdict(counts.live > 0 ? ADOPTION.ADOPTED : ADOPTION.ADOPTING, counts);
55
+ };
56
+
57
+ export const declineFingerprint = () => factFingerprint(DECLINE_FACT);
58
+
59
+ // True when the store's decline is recorded; the guarded reader's refusals propagate to the caller.
60
+ export const readDeclineAck = (root, deps = {}) => readAckValue(root, deps, ACKS_SPEC_ADOPTION_KEY) === declineFingerprint();
61
+
62
+ const plural = (n, noun) => `${n} ${noun}`;
63
+
64
+ // The one status line body, per state (the caller prefixes its own label).
65
+ export const describeAdoption = ({ state, live, draft, reason }, { declined = false } = {}) => {
66
+ const suffix = declined && state !== ADOPTION.ADOPTED ? ' — declined' : '';
67
+ if (state === ADOPTION.NOT_ADOPTED) return `not adopted${suffix}`;
68
+ if (state === ADOPTION.ADOPTING) return `adopting (${plural(draft, 'draft')})${suffix}`;
69
+ if (state === ADOPTION.ADOPTED) return `adopted (${plural(live, 'live')}, ${plural(draft, 'draft')})`;
70
+ return `could not be read — ${reason}`;
71
+ };
@@ -87,7 +87,7 @@ const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], docume
87
87
  // Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
88
88
  // bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
89
89
  // read, so a directory that resolves outside the root is never opened through.
90
- const readClosure = (closure, ctx) => {
90
+ export const readClosure = (closure, ctx) => {
91
91
  const { io, at, rootReal, add } = ctx;
92
92
  const docs = new Map();
93
93
  for (const { path, roles } of closure) {
@@ -247,7 +247,7 @@ const judgeListing = (doc, docs, add) => {
247
247
  // FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
248
248
  // store would be the one answer this lane must never give. A directory is contained BEFORE it is
249
249
  // listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
250
- const walkStore = (ctx) => {
250
+ export const walkStore = (ctx) => {
251
251
  const { io, at, rootReal, add } = ctx;
252
252
  const found = [];
253
253
  const stack = [STORE_DIR];
@@ -44,7 +44,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
44
44
  'customized-preserved',
45
45
  'malformed-preserved',
46
46
  'already-present',
47
- 'skipped-no-node',
47
+ 'skipped-no-node-evidence',
48
48
  'old-adr-layout-migration-instructed',
49
49
  'failed',
50
50
  ],
@@ -54,7 +54,11 @@ const recipesVm = (r) => {
54
54
  if (r.error) return { error: r.error };
55
55
  const pairs = [];
56
56
  for (const [activity, slots] of Object.entries(r.activities ?? {})) {
57
- for (const [slot, v] of Object.entries(slots)) pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe });
57
+ // source + degradedFrom ride along: an effective recipe alone cannot tell a chosen value from a
58
+ // computed default, nor a degrade from a configuration that really names the resolved recipe.
59
+ for (const [slot, v] of Object.entries(slots)) {
60
+ pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe, source: v.source ?? null, degradedFrom: v.degradedFrom ?? null });
61
+ }
58
62
  }
59
63
  return { pairs, detectError: r.detectError ?? null };
60
64
  };
@@ -76,8 +80,18 @@ const velocityVm = (v) => {
76
80
 
77
81
  const agentsVm = (a) => {
78
82
  if (!a) return null;
79
- if (a.error) return { error: a.error };
80
- return { bundled: a.bundled ?? 0, placed: a.placed ?? 0 };
83
+ if (a.error) return { error: a.error, executor: a.executor ?? null, executorReason: a.executorReason ?? null };
84
+ const bundled = a.bundled ?? 0;
85
+ return {
86
+ bundled,
87
+ placed: a.placed ?? 0,
88
+ // The envelope counts the read-only vehicles; an envelope predating the executor field bundled
89
+ // only read-only ones.
90
+ readOnly: a.readOnly ?? (a.executor == null ? bundled : Math.max(bundled - 1, 0)),
91
+ // null = an envelope predating the field (unknown), never a state.
92
+ executor: a.executor ?? null,
93
+ executorReason: a.executorReason ?? null,
94
+ };
81
95
  };
82
96
 
83
97
  const hookVm = (h) => {
@@ -114,6 +128,8 @@ const projectVm = (p) =>
114
128
  deployed: p.deployed,
115
129
  docsAi: p.docsAi,
116
130
  adrLayout: p.adrLayout ?? null,
131
+ // null = an envelope predating the field (unknown), never a state.
132
+ specs: p.specs ?? null,
117
133
  deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
118
134
  visibility: visibilityVm(p.visibility),
119
135
  settings: settingsVm(p.settings),