@sabaiway/agent-workflow-kit 1.12.0 → 1.14.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.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -17,8 +17,20 @@ import {
17
17
  AGENTS_MD_CAP,
18
18
  START_MARKER,
19
19
  END_MARKER,
20
+ ORCH_START_MARKER,
21
+ ORCH_END_MARKER,
22
+ ORCHESTRATION_DESCRIPTOR,
23
+ findMarkerSlot,
24
+ extractMarkerSlot,
25
+ reconcileMarkerSlot,
26
+ methodologyProceduresHint,
27
+ PROCEDURES_POINTER,
20
28
  } from './inject-methodology.mjs';
21
29
 
30
+ // Read the orchestration slot's content from a reconciled entry point.
31
+ const extractOrch = (text) => extractMarkerSlot(text, ORCHESTRATION_DESCRIPTOR);
32
+ const hasOrchSlot = (text) => findMarkerSlot(text, ORCHESTRATION_DESCRIPTOR).state === 'ok';
33
+
22
34
  const HERE = dirname(fileURLToPath(import.meta.url));
23
35
  const SCRIPT = join(HERE, 'inject-methodology.mjs');
24
36
 
@@ -29,6 +41,9 @@ const SCRIPT = join(HERE, 'inject-methodology.mjs');
29
41
  // Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
30
42
  const FRAGMENT =
31
43
  '> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
44
+ // The SECOND bounded fragment (Plan 4) — distinct content so a test can tell which slot got which.
45
+ const ORCH_FRAGMENT =
46
+ '> **Orchestration recipes (test fixture)** — Solo / Reviewed / Council / Delegated; pick one with `/agent-workflow-kit recipes`.\n';
32
47
 
33
48
  // Temp dirs created by the fixtures below — cleaned up once after the whole file.
34
49
  const tmpDirs = [];
@@ -36,8 +51,9 @@ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })))
36
51
 
37
52
  // A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
38
53
  // whose metadata.version matches it (the validator's authoritative version source when there is no
39
- // package.json) + the live fragment at references/methodology-slot.md. detectEngine accepts it.
40
- const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
54
+ // package.json) + BOTH live fragments (methodology + orchestration). detectEngine accepts it; pass
55
+ // `orchFragment = null` to model an OLDER engine (<1.2.0) that ships no orchestration fragment.
56
+ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0', orchFragment = ORCH_FRAGMENT) => {
41
57
  const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
42
58
  tmpDirs.push(dir);
43
59
  const manifest = {
@@ -54,6 +70,7 @@ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
54
70
  writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
55
71
  mkdirSync(join(dir, 'references'), { recursive: true });
56
72
  writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
73
+ if (orchFragment != null) writeFileSync(join(dir, 'references', 'orchestration-slot.md'), orchFragment);
57
74
  return dir;
58
75
  };
59
76
 
@@ -66,6 +83,11 @@ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR:
66
83
  const wrap = (inner) =>
67
84
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
68
85
 
86
+ // An entry point carrying BOTH reconciled slots (the orchestration pair sits right under the
87
+ // methodology pair, as the descriptor anchors it) — the deployed shape after a dual-slot reconcile.
88
+ const wrapDual = (methInner, orchInner) =>
89
+ `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${methInner}${END_MARKER}\n${ORCH_START_MARKER}${orchInner}${ORCH_END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
90
+
69
91
  // The exact Session-Protocols line both deployed templates carry — the slot anchor.
70
92
  const ANCHOR_LINE =
71
93
  'Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**';
@@ -401,26 +423,32 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
401
423
  }
402
424
  };
403
425
 
404
- it('markerless legacy (with anchor) → slot inserted + filled from the live engine fragment (exit 0)', () => {
426
+ it('markerless legacy (with anchor) → BOTH slots inserted + filled from their own live engine fragments (exit 0)', () => {
405
427
  withTempAgents(legacyWithAnchor(), (agents) => {
406
428
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
407
429
  const out = readFileSync(agents, 'utf8');
408
430
  assert.equal(findSlot(out).state, 'ok');
409
- assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
431
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'methodology slot filled from the methodology fragment');
432
+ assert.ok(hasOrchSlot(out), 'the orchestration slot was inserted below the methodology pair');
433
+ assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim(), 'orchestration slot filled from the orchestration fragment');
410
434
  });
411
435
  });
412
436
 
413
- it('present empty slot → filled from the live engine fragment (exit 0)', () => {
437
+ it('present empty methodology slot → BOTH slots filled; each from its OWN engine fragment (exit 0)', () => {
414
438
  withTempAgents(wrap('\n'), (agents) => {
415
439
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
416
- assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
440
+ const out = readFileSync(agents, 'utf8');
441
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
442
+ assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim());
443
+ // Per-slot fragment sourcing: the two slots carry DIFFERENT content (not both the methodology one).
444
+ assert.notEqual(extractSlot(out).trim(), extractOrch(out).trim());
417
445
  });
418
446
  });
419
447
 
420
- it('filled/customized slot → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
421
- const custom = wrap('\nuser notes\n');
448
+ it('BOTH slots already filled → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
449
+ const custom = wrapDual('\nuser meth notes\n', '\nuser orch notes\n');
422
450
  withTempAgents(custom, (agents) => {
423
- // Engine pointed at a path that does not exist — a filled slot must NOT require it.
451
+ // Engine pointed at a path that does not exist — a fully-filled entry point must NOT require it.
424
452
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
425
453
  assert.equal(readFileSync(agents, 'utf8'), custom);
426
454
  });
@@ -444,7 +472,7 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
444
472
  });
445
473
  });
446
474
 
447
- it('explicit [fragment.md] override fills from that file and skips engine resolution (engine absent)', () => {
475
+ it('explicit [fragment.md] override fills methodology ONLY and skips both engine + the orchestration slot', () => {
448
476
  const override = '> custom override fragment line\n';
449
477
  const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
450
478
  tmpDirs.push(fdir);
@@ -452,7 +480,98 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
452
480
  writeFileSync(fpath, override);
453
481
  withTempAgents(wrap('\n'), (agents) => {
454
482
  execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
455
- assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), override.trim());
483
+ const out = readFileSync(agents, 'utf8');
484
+ assert.equal(extractSlot(out).trim(), override.trim(), 'methodology filled from the explicit fragment');
485
+ assert.ok(!hasOrchSlot(out), 'an explicit single-file override binds methodology only — no orchestration slot added');
486
+ });
487
+ });
488
+
489
+ it('dual-block cap → orchestration pointer is SOFT-skipped (reported), the file is byte-unchanged (exit 0)', () => {
490
+ // A 100-line entry point with a FILLED methodology slot + an EMPTY orchestration slot: filling the
491
+ // orchestration slot would push it to 101 > 100, so it is skipped loudly while methodology stays.
492
+ const head = [
493
+ '# AGENTS.md',
494
+ '',
495
+ '## Session Protocols',
496
+ '',
497
+ 'Read it before any code change.',
498
+ '',
499
+ START_MARKER,
500
+ FRAGMENT.trim(),
501
+ END_MARKER,
502
+ ORCH_START_MARKER,
503
+ ORCH_END_MARKER,
504
+ ];
505
+ const pad = Array.from({ length: AGENTS_MD_CAP - head.length }, (_, i) => `pad line ${i}`);
506
+ const atCap = [...head, ...pad].join('\n') + '\n';
507
+ withTempAgents(atCap, (agents) => {
508
+ const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(ENGINE) });
509
+ assert.match(out, /skipped/i, 'the orchestration cap-skip is reported, not silent');
510
+ assert.equal(readFileSync(agents, 'utf8'), atCap, 'file byte-unchanged — the orchestration pointer was withheld, methodology already present');
511
+ });
512
+ });
513
+
514
+ it('malformed orchestration pair → hard STOP (nonzero), file byte-unchanged (methodology fine)', () => {
515
+ const malformedOrch =
516
+ `# AGENTS.md\n\nintro line.\n\n${START_MARKER}\n${FRAGMENT.trim()}\n${END_MARKER}\n` +
517
+ `${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n`;
518
+ withTempAgents(malformedOrch, (agents) => {
519
+ assert.throws(() =>
520
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) }),
521
+ );
522
+ assert.equal(readFileSync(agents, 'utf8'), malformedOrch, 'no partial write on an orchestration STOP');
523
+ });
524
+ });
525
+
526
+ it('engine too old (no orchestration fragment) → methodology filled, orchestration SOFT-skipped (exit 0, not a regression)', () => {
527
+ // A VALID engine that ships methodology-slot.md but NOT orchestration-slot.md (i.e. <1.2.0). The
528
+ // methodology fill must NOT be discarded — only the recipes pointer is withheld, reported, exit 0.
529
+ const oldEngine = makeEngineFixture(FRAGMENT, '1.0.0', null);
530
+ withTempAgents(wrap('\n'), (agents) => {
531
+ const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(oldEngine) });
532
+ const text = readFileSync(agents, 'utf8');
533
+ assert.equal(extractSlot(text).trim(), FRAGMENT.trim(), 'methodology pointer filled (not discarded by the too-old engine)');
534
+ assert.match(out, /skipped/i, 'the orchestration skip is reported (not silent)');
535
+ assert.match(out, /too old/i, 'the skip names the too-old-engine reason');
536
+ assert.ok(!hasOrchSlot(text), 'a too-old engine adds no orchestration pointer');
537
+ });
538
+ });
539
+
540
+ it('orchestration fragment PRESENT but unreadable → hard STOP (a corrupt engine is NOT mislabeled "too old")', () => {
541
+ if (process.getuid && process.getuid() === 0) return; // root bypasses 0o000 perms — can't restrict
542
+ const corruptEngine = makeEngineFixture(FRAGMENT, '1.2.0', '> orchestration line\n'); // both fragments present
543
+ const orchPath = join(corruptEngine, 'references', 'orchestration-slot.md');
544
+ chmodSync(orchPath, 0o000);
545
+ let restricted = false;
546
+ try {
547
+ readFileSync(orchPath, 'utf8');
548
+ } catch {
549
+ restricted = true;
550
+ }
551
+ if (!restricted) {
552
+ chmodSync(orchPath, 0o644); // exotic FS / perms ignored → can't exercise this path here
553
+ return;
554
+ }
555
+ try {
556
+ withTempAgents(wrap('\n'), (agents) => {
557
+ assert.throws(() =>
558
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(corruptEngine) }),
559
+ );
560
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), '', 'no partial write on a corrupt-fragment STOP');
561
+ });
562
+ } finally {
563
+ chmodSync(orchPath, 0o644); // restore so the after() cleanup can remove the fixture
564
+ }
565
+ });
566
+
567
+ it('fully absent engine + a fill needed → hard STOP (methodology fragment also unavailable)', () => {
568
+ // Distinct from the too-old case: a fully absent engine cannot supply EITHER fragment, so the
569
+ // methodology slot fill itself STOPs first — the dual-slot reconcile never silently no-ops.
570
+ withTempAgents(wrap('\n'), (agents) => {
571
+ assert.throws(() =>
572
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
573
+ );
574
+ assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write when the engine is fully absent');
456
575
  });
457
576
  });
458
577
 
@@ -498,3 +617,73 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
498
617
  });
499
618
  });
500
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
+ }