@sabaiway/agent-workflow-kit 1.13.0 → 1.15.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.
@@ -18,6 +18,7 @@ import {
18
18
  } from './family-registry.mjs';
19
19
  import { VALID, INVALID, UNSUPPORTED } from './manifest/validate.mjs';
20
20
  import { START_MARKER } from './hide-footprint.mjs';
21
+ import { ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
  const REPO_ROOT = resolve(__dirname, '../..'); // agent-workflow-kit/tools → repo root
@@ -110,55 +111,86 @@ describe('surveyFamily', () => {
110
111
  ? { result: VALID, name: 'agent-workflow-engine', kind: 'methodology-engine', available: true }
111
112
  : { result: VALID, name: 'x', kind: 'x', available: true };
112
113
 
113
- // The caveat mirrors the reconcile: it reads the orchestration fragment (readEngineFragment), so an
114
- // absent / non-file / unreadable fragment all surface; a current readable fragment does not.
114
+ // The caveats mirror the consumers: each reads a live engine fragment (readEngineFragment) the
115
+ // recipes pointer (orchestration-slot.md, engine >= 1.2.0) AND the activity-procedures canon
116
+ // (procedures.md, engine >= 1.3.0). An absent / non-file / unreadable fragment surfaces as a DISTINCT
117
+ // caveat in `row.caveats` (an array, so missing BOTH surfaces BOTH); a current readable
118
+ // fragment never gets one. Helpers below model each fragment's on-disk state independently.
115
119
  const engineDeps = (over) => ({
116
120
  exists: () => true, // SKILL.md marker present (classifyMember)
117
121
  stat: () => ({ isFile: () => true }),
118
122
  getenv: {},
119
123
  home: '/home/test',
120
124
  validate: engineValidate,
121
- readVersion: () => ({ version: '1.2.0' }),
125
+ readVersion: () => ({ version: '1.3.0' }),
122
126
  ...over,
123
127
  });
128
+ // statType where each fragment is independently a readable file ('file') or absent (null); the engine
129
+ // dir + everything else is a 'dir'. readFileSync returns content for a present fragment.
130
+ // Pin the mock to the AUTHORITATIVE engine-source constants (mirrors engine-source.test.mjs), so a
131
+ // fragment-path rename follows here instead of silently passing against the old basename.
132
+ const fragmentStat = ({ orch = 'file', proc = 'file' }) => (p) => {
133
+ const s = String(p);
134
+ if (s.endsWith(ORCHESTRATION_FRAGMENT_REL)) return orch;
135
+ if (s.endsWith(PROCEDURES_FRAGMENT_REL)) return proc;
136
+ return 'dir';
137
+ };
138
+ const caveatsOf = (rows) => rows.find((r) => r.kind === 'methodology-engine').caveats ?? [];
124
139
 
125
- it('an OK engine MISSING the orchestration fragment gets a plain caveat', () => {
126
- const rows = surveyFamily(engineDeps({
127
- readVersion: () => ({ version: '1.1.0' }),
128
- statType: (p) => (String(p).endsWith('orchestration-slot.md') ? null : 'dir'), // fragment ABSENT
129
- }));
140
+ it('a current engine WITH BOTH fragments readable carries NO caveat', () => {
141
+ const rows = surveyFamily(engineDeps({ statType: fragmentStat({}), readFileSync: () => '> a bounded fragment' }));
130
142
  const engine = rows.find((r) => r.kind === 'methodology-engine');
131
143
  assert.equal(engine.manifestState, OK);
132
- assert.ok(engine.caveat, 'an engine without the recipes fragment carries a caveat');
133
- assert.match(engine.caveat, /recipes pointer|too old|incomplete/i);
144
+ assert.equal(engine.caveats, undefined, 'no caveats when both live fragments are present + readable');
134
145
  });
135
146
 
136
- it('a current engine WITH a readable orchestration fragment carries NO caveat', () => {
147
+ it('an OK engine MISSING the orchestration fragment gets the recipes caveat (only)', () => {
137
148
  const rows = surveyFamily(engineDeps({
138
- statType: (p) => (String(p).endsWith('orchestration-slot.md') ? 'file' : 'dir'),
139
- readFileSync: () => '> orchestration recipes pointer', // present + readable
149
+ readVersion: () => ({ version: '1.2.0' }),
150
+ statType: fragmentStat({ orch: null }), // recipes fragment ABSENT, procedures present
151
+ readFileSync: () => '> a bounded fragment',
140
152
  }));
141
- const engine = rows.find((r) => r.kind === 'methodology-engine');
142
- assert.equal(engine.manifestState, OK);
143
- assert.ok(!engine.caveat);
153
+ const caveats = caveatsOf(rows);
154
+ assert.equal(caveats.length, 1);
155
+ assert.match(caveats[0], /recipes pointer/i);
156
+ });
157
+
158
+ it('an OK engine MISSING the procedures canon gets the activity-procedures caveat (only)', () => {
159
+ // The realistic post-release case: an engine at 1.2.0 ships the recipes pointer but not procedures.md.
160
+ const rows = surveyFamily(engineDeps({
161
+ readVersion: () => ({ version: '1.2.0' }),
162
+ statType: fragmentStat({ proc: null }), // procedures canon ABSENT, recipes present
163
+ readFileSync: () => '> a bounded fragment',
164
+ }));
165
+ const caveats = caveatsOf(rows);
166
+ assert.equal(caveats.length, 1);
167
+ assert.match(caveats[0], /activity-procedures|procedures canon/i);
144
168
  });
145
169
 
146
- it('a broken engine whose orchestration "fragment" is a DIRECTORY is NOT a false "ok"', () => {
170
+ it('an engine MISSING BOTH fragments surfaces BOTH caveats (neither overwrites the other)', () => {
147
171
  const rows = surveyFamily(engineDeps({
148
- statType: () => 'dir', // orchestration path is a directory, not a file
172
+ readVersion: () => ({ version: '1.1.0' }),
173
+ statType: fragmentStat({ orch: null, proc: null }),
149
174
  }));
150
- assert.ok(rows.find((r) => r.kind === 'methodology-engine').caveat, 'a non-file fragment is caveated');
175
+ const caveats = caveatsOf(rows);
176
+ assert.equal(caveats.length, 2, 'both missing fragments are reported');
177
+ assert.ok(caveats.some((c) => /recipes pointer/i.test(c)));
178
+ assert.ok(caveats.some((c) => /activity-procedures|procedures canon/i.test(c)));
151
179
  });
152
180
 
153
- it('a current engine whose orchestration fragment is PRESENT but UNREADABLE is NOT a false "ok"', () => {
181
+ it('a broken engine whose fragments are DIRECTORIES is NOT a false "ok"', () => {
182
+ const rows = surveyFamily(engineDeps({ statType: () => 'dir' })); // every fragment path is a dir
183
+ assert.equal(caveatsOf(rows).length, 2, 'non-file fragments are caveated');
184
+ });
185
+
186
+ it('a fragment PRESENT but UNREADABLE is NOT a false "ok" (mirrors the consumer STOP)', () => {
154
187
  const rows = surveyFamily(engineDeps({
155
- statType: (p) => (String(p).endsWith('orchestration-slot.md') ? 'file' : 'dir'), // present as a file
188
+ statType: fragmentStat({}), // both present as files
156
189
  readFileSync: () => {
157
190
  throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); // but unreadable
158
191
  },
159
192
  }));
160
- const engine = rows.find((r) => r.kind === 'methodology-engine');
161
- assert.ok(engine.caveat, 'an unreadable fragment is caveated (mirrors the reconcile STOP), not reported clean');
193
+ assert.equal(caveatsOf(rows).length, 2, 'unreadable fragments are caveated, not reported clean');
162
194
  });
163
195
  });
164
196
 
@@ -201,6 +201,22 @@ export const ensureSlot = (text) => ensureMarkerSlot(text, METHODOLOGY_DESCRIPTO
201
201
  export const reconcileSlot = (text, fragment, opts) => reconcileMarkerSlot(text, METHODOLOGY_DESCRIPTOR, fragment, opts);
202
202
  export const slotNeedsFill = (text) => markerSlotNeedsFill(text, METHODOLOGY_DESCRIPTOR);
203
203
 
204
+ // The routing token the methodology pointer should carry so NL like "write a plan" auto-discovers the
205
+ // activity procedures. A deployment whose methodology slot was filled (legacy / customized) BEFORE this
206
+ // clause existed will NOT auto-receive it — reconcile preserves a filled slot verbatim (AD-019 §3.1a).
207
+ export const PROCEDURES_POINTER = '/agent-workflow-kit procedures';
208
+
209
+ // Read-only upgrade advisory (NO mutation): when the methodology slot is present + FILLED but lacks the
210
+ // procedures route, return a one-line note the upgrade flow surfaces — add it for auto-discovery; the
211
+ // feature is reachable now via the explicit command. Returns null for an absent / empty / malformed slot
212
+ // or one that already routes to procedures. Pure; never edits the file.
213
+ export const methodologyProceduresHint = (text) => {
214
+ const content = extractSlot(text);
215
+ if (content == null || content.trim() === '') return null; // only a FILLED methodology slot
216
+ if (content.includes(PROCEDURES_POINTER)) return null; // already routes to the procedures advisor
217
+ return `the methodology pointer has no procedures route — add "${PROCEDURES_POINTER} <activity>" for auto-discovery; the activity procedures are reachable now via ${PROCEDURES_POINTER}.`;
218
+ };
219
+
204
220
  // A cap-refusal is a SOFT, reported skip (distinct from a malformed/anchor STOP) — keyed off the
205
221
  // stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
206
222
  // orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
@@ -307,6 +323,13 @@ const main = async (argv) => {
307
323
  'reconciled-filled': 'filled the empty workflow-methodology pointer',
308
324
  'present-filled': 'workflow-methodology pointer already present',
309
325
  }[methResult.status];
326
+ // Read-only upgrade advisory (AD-019 §3.1a): a pre-existing FILLED methodology pointer that predates
327
+ // the procedures clause won't be re-rendered (reconcile preserves it verbatim), so surface a hint to
328
+ // add the procedures route. No mutation — purely a reported note appended to the success report.
329
+ const proceduresNote = methResult.status === 'present-filled' ? methodologyProceduresHint(afterMeth) : null;
330
+ const reportNote = () => {
331
+ if (proceduresNote) console.log(`[inject-methodology] note: ${proceduresNote}`);
332
+ };
310
333
 
311
334
  // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
312
335
  if (explicitFragmentArg) {
@@ -361,10 +384,12 @@ const main = async (argv) => {
361
384
  // Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
362
385
  if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
363
386
  else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
387
+ reportNote();
364
388
  return;
365
389
  }
366
390
  await writeAtomic(finalText);
367
391
  console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
392
+ reportNote();
368
393
  return;
369
394
  }
370
395
 
@@ -22,6 +22,9 @@ import {
22
22
  ORCHESTRATION_DESCRIPTOR,
23
23
  findMarkerSlot,
24
24
  extractMarkerSlot,
25
+ reconcileMarkerSlot,
26
+ methodologyProceduresHint,
27
+ PROCEDURES_POINTER,
25
28
  } from './inject-methodology.mjs';
26
29
 
27
30
  // Read the orchestration slot's content from a reconciled entry point.
@@ -614,3 +617,73 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
614
617
  });
615
618
  });
616
619
  });
620
+
621
+ // AD-019 §3.1a — the read-only upgrade advisory: a FILLED methodology pointer lacking the procedures
622
+ // route gets a hint (it can't be auto-re-rendered, reconcile preserves a filled slot verbatim); a slot
623
+ // that already routes to procedures, or an empty / absent / malformed slot, is silent. NO mutation.
624
+ describe('methodologyProceduresHint — read-only upgrade advisory (§3.1a)', () => {
625
+ it('a filled methodology slot WITHOUT the procedures route → a hint naming the procedures command', () => {
626
+ const entry = wrap('\n> methodology notes the user wrote, no procedures route here\n');
627
+ const hint = methodologyProceduresHint(entry);
628
+ assert.ok(hint, 'a filled-without-clause slot yields a hint');
629
+ assert.match(hint, new RegExp(PROCEDURES_POINTER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
630
+ });
631
+
632
+ it('a filled methodology slot that ALREADY routes to procedures → silent (null)', () => {
633
+ const entry = wrap(`\n> methodology — see ${PROCEDURES_POINTER} <activity> for the steps\n`);
634
+ assert.equal(methodologyProceduresHint(entry), null);
635
+ });
636
+
637
+ it('an empty methodology slot → silent (null) — only a filled slot is advised', () => {
638
+ assert.equal(methodologyProceduresHint(wrap('\n')), null);
639
+ });
640
+
641
+ it('an absent / malformed methodology slot → silent (null)', () => {
642
+ assert.equal(methodologyProceduresHint('# AGENTS.md\n\nno slot here\n'), null);
643
+ assert.equal(methodologyProceduresHint(`${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), null);
644
+ });
645
+
646
+ it('is read-only: a pre-filled-without-clause deployment is reported, not mutated, on reconcile (engine absent)', () => {
647
+ // A dual-filled entry point whose methodology lacks the procedures route: reconcile is a zero-diff
648
+ // no-op (filled slots preserved) yet still surfaces the hint on stdout — never rewrites the file.
649
+ const custom = wrapDual('\nuser meth notes, no procedures route\n', '\nuser orch notes\n');
650
+ const dir = mkdtempSync(join(tmpdir(), 'reconcile-hint-'));
651
+ tmpDirs.push(dir);
652
+ const agents = join(dir, 'AGENTS.md');
653
+ writeFileSync(agents, custom);
654
+ const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(NO_ENGINE) });
655
+ assert.equal(readFileSync(agents, 'utf8'), custom, 'the file is byte-unchanged (read-only advisory)');
656
+ assert.match(out, /note:.*procedures/i, 'the upgrade flow surfaces the procedures hint');
657
+ });
658
+ });
659
+
660
+ // §3.2 (kit) — the REAL extended engine fragments must keep the dual-fill ≤ 100 on BOTH deployed
661
+ // templates. The methodology fragment grew a procedures clause (still ONE line, so no extra line), but
662
+ // pin it against the REAL fragments + REAL templates so a future fragment edit that DID add a line is
663
+ // caught here, not in the field.
664
+ describe('real-fragment dual-fill ≤ cap on both deployed templates (§3.2)', () => {
665
+ const ENGINE_REFS = join(HERE, '..', '..', 'agent-workflow-engine', 'references');
666
+ const realMeth = readFileSync(join(ENGINE_REFS, 'methodology-slot.md'), 'utf8');
667
+ const realOrch = readFileSync(join(ENGINE_REFS, 'orchestration-slot.md'), 'utf8');
668
+ const lineCount = (t) => t.split('\n').length - (t.endsWith('\n') ? 1 : 0);
669
+ const TEMPLATES = {
670
+ kit: join(HERE, '..', 'references', 'templates', 'AGENTS.md'),
671
+ memory: join(HERE, '..', '..', 'agent-workflow-memory', 'references', 'templates', 'AGENTS.md'),
672
+ };
673
+
674
+ it('the real methodology fragment carries the procedures route (auto-discovery clause)', () => {
675
+ assert.match(realMeth, new RegExp(PROCEDURES_POINTER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
676
+ assert.equal(lineCount(realMeth), 1, 'the methodology fragment stays exactly one content line');
677
+ });
678
+
679
+ for (const [name, path] of Object.entries(TEMPLATES)) {
680
+ it(`${name} template: filling BOTH real fragments stays ≤ ${AGENTS_MD_CAP} lines`, () => {
681
+ const template = readFileSync(path, 'utf8');
682
+ const meth = reconcileSlot(template, realMeth, { maxLines: AGENTS_MD_CAP });
683
+ assert.equal(meth.status, 'reconciled-filled', `${name}: methodology slot fills`);
684
+ const both = reconcileMarkerSlot(meth.text, ORCHESTRATION_DESCRIPTOR, realOrch, { maxLines: AGENTS_MD_CAP });
685
+ assert.equal(both.status, 'reconciled-filled', `${name}: orchestration slot fills`);
686
+ assert.ok(lineCount(both.text) <= AGENTS_MD_CAP, `${name}: dual-filled entry point is ${lineCount(both.text)} lines (cap ${AGENTS_MD_CAP})`);
687
+ });
688
+ }
689
+ });
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ // Activity-procedures advisor — the read-only `/agent-workflow-kit procedures <activity>` surface.
3
+ //
4
+ // It composes the AD-018 orchestration recipes into NAMED activities: it reads the canonical procedure
5
+ // steps LIVE from the installed agent-workflow-engine (references/procedures.md — AD-016 live read, no
6
+ // bundled mirror), reads the per-project, hand-edited config (docs/ai/orchestration.json), runs the
7
+ // read-only backend detector, and prints the activity's steps VERBATIM + the resolved effective recipe
8
+ // per slot (default = Reviewed-when-a-backend-is-ready, Council on request, slot-aware incl. Delegated).
9
+ //
10
+ // Invariants (mirror recipes.mjs): pure-where-possible, READ-ONLY (never writes, never commits, never
11
+ // runs a subscription CLI). The deterministic resolution lives in the kit (resolveActivityRecipe), not
12
+ // in the agent. Dependency-free, Node >= 18.
13
+ //
14
+ // Exit codes: 0 success (an unsatisfiable explicit override degrades LOUDLY but still exits 0 — it is a
15
+ // valid request that gracefully degraded); 2 usage (unknown <activity> / bad --override syntax);
16
+ // 1 config error (malformed / schema-invalid / unreadable orchestration.json) or engine error (the
17
+ // installed engine is absent / invalid / too old to ship references/procedures.md).
18
+
19
+ import { readFileSync, lstatSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { detectBackends } from './detect-backends.mjs';
24
+ import { ACTIVITIES, SLOT_RECIPES, resolveActivityRecipe } from './recipes.mjs';
25
+ 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 });
34
+
35
+ // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
36
+
37
+ // 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) => {
43
+ const overrides = {};
44
+ for (const tok of tokens) {
45
+ const eq = tok.indexOf('=');
46
+ if (eq <= 0) throw fail(2, `--override must be <slot>=<recipe> (got "${tok}")`);
47
+ const slot = tok.slice(0, eq);
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
+ }
62
+ if (slot in overrides) throw fail(2, `--override: duplicate override for slot "${slot}"`);
63
+ overrides[slot] = recipe;
64
+ }
65
+ return overrides;
66
+ };
67
+
68
+ const KNOWN_ACTIVITIES = () => Object.keys(ACTIVITIES).join(', ');
69
+
70
+ // Parse argv → { activity, overrides, json }. Unknown activity / bad flags / bad --override → exit 2.
71
+ const parseArgs = (argv) => {
72
+ let activity;
73
+ let json = false;
74
+ const overrideTokens = [];
75
+ for (let i = 0; i < argv.length; i += 1) {
76
+ const a = argv[i];
77
+ if (a === '--json') {
78
+ json = true;
79
+ } else if (a === '--override') {
80
+ const tok = argv[i + 1];
81
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, '--override requires <slot>=<recipe>');
82
+ overrideTokens.push(tok);
83
+ i += 1;
84
+ } else if (a.startsWith('--override=')) {
85
+ overrideTokens.push(a.slice('--override='.length));
86
+ } else if (a.startsWith('-')) {
87
+ throw fail(2, `unknown flag: ${a}`);
88
+ } else if (activity === undefined) {
89
+ activity = a;
90
+ } else {
91
+ throw fail(2, `unexpected argument: ${a}`);
92
+ }
93
+ }
94
+ if (!activity) throw fail(2, `missing <activity> (known: ${KNOWN_ACTIVITIES()})`);
95
+ const activityDef = ACTIVITIES[activity];
96
+ 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
+ const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
145
+ const full = join(cwd, CONFIG_REL);
146
+ // Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
147
+ // unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT
148
+ // follow the link, so a dangling symlink reads as "present" here and its later read failure surfaces
149
+ // loudly — never silently treated as absent (no-silent-failures Hard Constraint).
150
+ try {
151
+ lstat(full);
152
+ } catch (err) {
153
+ if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
154
+ throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
155
+ }
156
+ let raw;
157
+ try {
158
+ raw = readFile(full, 'utf8');
159
+ } catch (err) {
160
+ throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
161
+ }
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(raw);
165
+ } catch (err) {
166
+ throw fail(1, `${CONFIG_REL}: malformed JSON (${err.message})`);
167
+ }
168
+ return { config: validateConfig(parsed), source: CONFIG_REL };
169
+ };
170
+
171
+ // ── engine canon: live read + per-activity section extraction (engine errors → exit 1) ──
172
+
173
+ // Read the activity-procedures canon LIVE from the installed engine. A failure (engine absent / invalid
174
+ // / too old to ship references/procedures.md) is surfaced loudly with the resolver's message + an
175
+ // upgrade hint — never a cryptic fs error.
176
+ const readProceduresCanon = (env, home) => {
177
+ const { dir, source } = resolveEngineDir({ env, home });
178
+ try {
179
+ return readEngineFragment(dir, { source, rel: PROCEDURES_FRAGMENT_REL });
180
+ } catch (err) {
181
+ throw fail(
182
+ 1,
183
+ `${err.message}\n (the activity-procedures canon needs agent-workflow-engine shipping references/procedures.md — upgrade the engine if it is installed but older.)`,
184
+ );
185
+ }
186
+ };
187
+
188
+ // Extract a `## <activity>` section (its heading → the next `## ` heading or EOF) and return it
189
+ // VERBATIM (trailing blank lines trimmed). The kit prints this string; it never parses the steps.
190
+ export const extractSection = (text, activity) => {
191
+ const lines = text.split('\n');
192
+ const start = lines.findIndex((l) => l.trim() === `## ${activity}`);
193
+ if (start === -1) {
194
+ throw fail(
195
+ 1,
196
+ `the installed engine's procedures.md has no "## ${activity}" section — upgrade the engine (it predates this activity).`,
197
+ );
198
+ }
199
+ let end = lines.length;
200
+ for (let i = start + 1; i < lines.length; i += 1) {
201
+ if (/^## /.test(lines[i])) {
202
+ end = i;
203
+ break;
204
+ }
205
+ }
206
+ return lines.slice(start, end).join('\n').replace(/[\r\n]+$/, ''); // trim trailing blank lines (LF or CRLF)
207
+ };
208
+
209
+ // ── resolution + rendering ─────────────────────────────────────────────────────────
210
+
211
+ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
212
+ Object.keys(ACTIVITIES[activity].slots).map((slot) => ({
213
+ slot,
214
+ ...resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] }),
215
+ }));
216
+
217
+ // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
218
+ // graceful config/default degradation is reported as a per-slot reason, not a warning.
219
+ const collectWarnings = (slots) =>
220
+ slots
221
+ .filter((s) => s.overrideUnsatisfied)
222
+ .map(
223
+ (s) =>
224
+ `override "${s.slot}=${s.degradedFrom}" could not be satisfied here — degraded to ${s.recipe} (${s.reason}). Tell the user.`,
225
+ );
226
+
227
+ const SOURCE_LABEL = {
228
+ default: 'computed default',
229
+ config: `from ${CONFIG_REL}`,
230
+ override: 'from --override',
231
+ };
232
+
233
+ const formatHuman = ({ activity, section, slots, warnings }) => {
234
+ const lines = [
235
+ section,
236
+ '',
237
+ `resolved recipes for "${activity}" (read-only — the orchestrator runs the recipe via the bridge skills and owns any commit; a backend never commits):`,
238
+ ];
239
+ for (const s of slots) {
240
+ const arrow = s.degradedFrom ? ` (requested ${s.degradedFrom} → degraded)` : '';
241
+ lines.push(` ${s.slot}: ${s.recipe} — ${SOURCE_LABEL[s.source]}${arrow}`);
242
+ if (s.reason) lines.push(` ↳ ${s.reason}`);
243
+ }
244
+ if (warnings.length) {
245
+ lines.push('', 'warnings:');
246
+ for (const w of warnings) lines.push(` ⚠ ${w}`);
247
+ }
248
+ return lines.join('\n');
249
+ };
250
+
251
+ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
252
+ activity,
253
+ section,
254
+ slots: Object.fromEntries(
255
+ slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason }]),
256
+ ),
257
+ configSource,
258
+ warnings,
259
+ });
260
+
261
+ const HELP = `procedures — read-only activity-procedures advisor for the agent-workflow family.
262
+
263
+ Usage:
264
+ node procedures.mjs <activity> [--override <slot>=<recipe>]... [--json]
265
+
266
+ Activities: ${Object.keys(ACTIVITIES).join(', ')}
267
+ Slots: plan-authoring → review; plan-execution → execute, review
268
+ Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
269
+
270
+ Reads the activity's procedure steps LIVE from the installed agent-workflow-engine
271
+ (references/procedures.md), resolves the effective recipe per slot from
272
+ ${CONFIG_REL} + the read-only backend detector, and prints both. A per-run
273
+ --override <slot>=<recipe> (repeatable) overrides the configured/default recipe for that slot.
274
+ Read-only: never writes, never commits, never runs a subscription CLI.
275
+
276
+ Exit codes: 0 success (an unsatisfiable override degrades loudly, still 0);
277
+ 2 usage (unknown activity / bad --override); 1 config or engine error.`;
278
+
279
+ // ── main ───────────────────────────────────────────────────────────────────────────
280
+
281
+ // main(argv, ctx) → { code, stdout, stderr }. Pure I/O at the edges (cwd / env / home / detect are
282
+ // injectable for host-independent tests); never calls process.exit itself — the direct-run guard does.
283
+ export const main = (argv, ctx = {}) => {
284
+ const cwd = ctx.cwd ?? process.cwd();
285
+ const env = ctx.env ?? process.env;
286
+ const home = ctx.home ?? homedir();
287
+ const detect = ctx.detect ?? detectBackends;
288
+ const readFile = ctx.readFileSync ?? readFileSync;
289
+ const lstat = ctx.lstatSync ?? lstatSync;
290
+ try {
291
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
292
+ const { activity, overrides, json } = parseArgs(argv);
293
+ const { config, source: configSource } = loadConfig(cwd, readFile, lstat);
294
+ const section = extractSection(readProceduresCanon(env, home), activity);
295
+ // Backend detection is a SECONDARY input — it only refines the recipe. A corrupt / unreadable backend
296
+ // must NOT fail activity resolution as a config/engine error (exit 1, outside the contract): treat all
297
+ // backends as not-ready (resolution floors at Solo) and surface the failure as a loud warning, exit 0.
298
+ const detectWarnings = [];
299
+ let detection = [];
300
+ try {
301
+ detection = detect();
302
+ } catch (err) {
303
+ detectWarnings.push(
304
+ `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`,
305
+ );
306
+ }
307
+ const slots = resolveAllSlots({ activity, config, detection, overrides });
308
+ const warnings = [...detectWarnings, ...collectWarnings(slots)];
309
+ const stdout = json
310
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings }), null, 2)
311
+ : formatHuman({ activity, section, slots, warnings });
312
+ return { code: 0, stdout, stderr: '' };
313
+ } catch (err) {
314
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `procedures: ${err.message}` };
315
+ }
316
+ };
317
+
318
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
319
+ if (isDirectRun) {
320
+ const r = main(process.argv.slice(2));
321
+ if (r.stdout) console.log(r.stdout);
322
+ if (r.stderr) console.error(r.stderr);
323
+ process.exit(r.code);
324
+ }