@sabaiway/agent-workflow-kit 1.12.0 → 1.13.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.
- package/CHANGELOG.md +23 -0
- package/README.md +7 -3
- package/SKILL.md +70 -37
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +27 -1
- package/tools/family-registry.test.mjs +58 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
|
@@ -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,17 @@ 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,
|
|
20
25
|
} from './inject-methodology.mjs';
|
|
21
26
|
|
|
27
|
+
// Read the orchestration slot's content from a reconciled entry point.
|
|
28
|
+
const extractOrch = (text) => extractMarkerSlot(text, ORCHESTRATION_DESCRIPTOR);
|
|
29
|
+
const hasOrchSlot = (text) => findMarkerSlot(text, ORCHESTRATION_DESCRIPTOR).state === 'ok';
|
|
30
|
+
|
|
22
31
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
32
|
const SCRIPT = join(HERE, 'inject-methodology.mjs');
|
|
24
33
|
|
|
@@ -29,6 +38,9 @@ const SCRIPT = join(HERE, 'inject-methodology.mjs');
|
|
|
29
38
|
// Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
|
|
30
39
|
const FRAGMENT =
|
|
31
40
|
'> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
|
|
41
|
+
// The SECOND bounded fragment (Plan 4) — distinct content so a test can tell which slot got which.
|
|
42
|
+
const ORCH_FRAGMENT =
|
|
43
|
+
'> **Orchestration recipes (test fixture)** — Solo / Reviewed / Council / Delegated; pick one with `/agent-workflow-kit recipes`.\n';
|
|
32
44
|
|
|
33
45
|
// Temp dirs created by the fixtures below — cleaned up once after the whole file.
|
|
34
46
|
const tmpDirs = [];
|
|
@@ -36,8 +48,9 @@ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })))
|
|
|
36
48
|
|
|
37
49
|
// A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
|
|
38
50
|
// whose metadata.version matches it (the validator's authoritative version source when there is no
|
|
39
|
-
// package.json) +
|
|
40
|
-
|
|
51
|
+
// package.json) + BOTH live fragments (methodology + orchestration). detectEngine accepts it; pass
|
|
52
|
+
// `orchFragment = null` to model an OLDER engine (<1.2.0) that ships no orchestration fragment.
|
|
53
|
+
const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0', orchFragment = ORCH_FRAGMENT) => {
|
|
41
54
|
const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
|
|
42
55
|
tmpDirs.push(dir);
|
|
43
56
|
const manifest = {
|
|
@@ -54,6 +67,7 @@ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
|
|
|
54
67
|
writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
|
|
55
68
|
mkdirSync(join(dir, 'references'), { recursive: true });
|
|
56
69
|
writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
|
|
70
|
+
if (orchFragment != null) writeFileSync(join(dir, 'references', 'orchestration-slot.md'), orchFragment);
|
|
57
71
|
return dir;
|
|
58
72
|
};
|
|
59
73
|
|
|
@@ -66,6 +80,11 @@ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR:
|
|
|
66
80
|
const wrap = (inner) =>
|
|
67
81
|
`# 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
82
|
|
|
83
|
+
// An entry point carrying BOTH reconciled slots (the orchestration pair sits right under the
|
|
84
|
+
// methodology pair, as the descriptor anchors it) — the deployed shape after a dual-slot reconcile.
|
|
85
|
+
const wrapDual = (methInner, orchInner) =>
|
|
86
|
+
`# 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`;
|
|
87
|
+
|
|
69
88
|
// The exact Session-Protocols line both deployed templates carry — the slot anchor.
|
|
70
89
|
const ANCHOR_LINE =
|
|
71
90
|
'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 +420,32 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
|
|
|
401
420
|
}
|
|
402
421
|
};
|
|
403
422
|
|
|
404
|
-
it('markerless legacy (with anchor) →
|
|
423
|
+
it('markerless legacy (with anchor) → BOTH slots inserted + filled from their own live engine fragments (exit 0)', () => {
|
|
405
424
|
withTempAgents(legacyWithAnchor(), (agents) => {
|
|
406
425
|
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
|
|
407
426
|
const out = readFileSync(agents, 'utf8');
|
|
408
427
|
assert.equal(findSlot(out).state, 'ok');
|
|
409
|
-
assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
|
|
428
|
+
assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'methodology slot filled from the methodology fragment');
|
|
429
|
+
assert.ok(hasOrchSlot(out), 'the orchestration slot was inserted below the methodology pair');
|
|
430
|
+
assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim(), 'orchestration slot filled from the orchestration fragment');
|
|
410
431
|
});
|
|
411
432
|
});
|
|
412
433
|
|
|
413
|
-
it('present empty slot → filled from
|
|
434
|
+
it('present empty methodology slot → BOTH slots filled; each from its OWN engine fragment (exit 0)', () => {
|
|
414
435
|
withTempAgents(wrap('\n'), (agents) => {
|
|
415
436
|
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
|
|
416
|
-
|
|
437
|
+
const out = readFileSync(agents, 'utf8');
|
|
438
|
+
assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
|
|
439
|
+
assert.equal(extractOrch(out).trim(), ORCH_FRAGMENT.trim());
|
|
440
|
+
// Per-slot fragment sourcing: the two slots carry DIFFERENT content (not both the methodology one).
|
|
441
|
+
assert.notEqual(extractSlot(out).trim(), extractOrch(out).trim());
|
|
417
442
|
});
|
|
418
443
|
});
|
|
419
444
|
|
|
420
|
-
it('filled
|
|
421
|
-
const custom =
|
|
445
|
+
it('BOTH slots already filled → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
|
|
446
|
+
const custom = wrapDual('\nuser meth notes\n', '\nuser orch notes\n');
|
|
422
447
|
withTempAgents(custom, (agents) => {
|
|
423
|
-
// Engine pointed at a path that does not exist — a filled
|
|
448
|
+
// Engine pointed at a path that does not exist — a fully-filled entry point must NOT require it.
|
|
424
449
|
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
|
|
425
450
|
assert.equal(readFileSync(agents, 'utf8'), custom);
|
|
426
451
|
});
|
|
@@ -444,7 +469,7 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
|
|
|
444
469
|
});
|
|
445
470
|
});
|
|
446
471
|
|
|
447
|
-
it('explicit [fragment.md] override fills
|
|
472
|
+
it('explicit [fragment.md] override fills methodology ONLY and skips both engine + the orchestration slot', () => {
|
|
448
473
|
const override = '> custom override fragment line\n';
|
|
449
474
|
const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
|
|
450
475
|
tmpDirs.push(fdir);
|
|
@@ -452,7 +477,98 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragm
|
|
|
452
477
|
writeFileSync(fpath, override);
|
|
453
478
|
withTempAgents(wrap('\n'), (agents) => {
|
|
454
479
|
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
|
|
455
|
-
|
|
480
|
+
const out = readFileSync(agents, 'utf8');
|
|
481
|
+
assert.equal(extractSlot(out).trim(), override.trim(), 'methodology filled from the explicit fragment');
|
|
482
|
+
assert.ok(!hasOrchSlot(out), 'an explicit single-file override binds methodology only — no orchestration slot added');
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('dual-block cap → orchestration pointer is SOFT-skipped (reported), the file is byte-unchanged (exit 0)', () => {
|
|
487
|
+
// A 100-line entry point with a FILLED methodology slot + an EMPTY orchestration slot: filling the
|
|
488
|
+
// orchestration slot would push it to 101 > 100, so it is skipped loudly while methodology stays.
|
|
489
|
+
const head = [
|
|
490
|
+
'# AGENTS.md',
|
|
491
|
+
'',
|
|
492
|
+
'## Session Protocols',
|
|
493
|
+
'',
|
|
494
|
+
'Read it before any code change.',
|
|
495
|
+
'',
|
|
496
|
+
START_MARKER,
|
|
497
|
+
FRAGMENT.trim(),
|
|
498
|
+
END_MARKER,
|
|
499
|
+
ORCH_START_MARKER,
|
|
500
|
+
ORCH_END_MARKER,
|
|
501
|
+
];
|
|
502
|
+
const pad = Array.from({ length: AGENTS_MD_CAP - head.length }, (_, i) => `pad line ${i}`);
|
|
503
|
+
const atCap = [...head, ...pad].join('\n') + '\n';
|
|
504
|
+
withTempAgents(atCap, (agents) => {
|
|
505
|
+
const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(ENGINE) });
|
|
506
|
+
assert.match(out, /skipped/i, 'the orchestration cap-skip is reported, not silent');
|
|
507
|
+
assert.equal(readFileSync(agents, 'utf8'), atCap, 'file byte-unchanged — the orchestration pointer was withheld, methodology already present');
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('malformed orchestration pair → hard STOP (nonzero), file byte-unchanged (methodology fine)', () => {
|
|
512
|
+
const malformedOrch =
|
|
513
|
+
`# AGENTS.md\n\nintro line.\n\n${START_MARKER}\n${FRAGMENT.trim()}\n${END_MARKER}\n` +
|
|
514
|
+
`${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n${ORCH_START_MARKER}\n${ORCH_END_MARKER}\n`;
|
|
515
|
+
withTempAgents(malformedOrch, (agents) => {
|
|
516
|
+
assert.throws(() =>
|
|
517
|
+
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) }),
|
|
518
|
+
);
|
|
519
|
+
assert.equal(readFileSync(agents, 'utf8'), malformedOrch, 'no partial write on an orchestration STOP');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('engine too old (no orchestration fragment) → methodology filled, orchestration SOFT-skipped (exit 0, not a regression)', () => {
|
|
524
|
+
// A VALID engine that ships methodology-slot.md but NOT orchestration-slot.md (i.e. <1.2.0). The
|
|
525
|
+
// methodology fill must NOT be discarded — only the recipes pointer is withheld, reported, exit 0.
|
|
526
|
+
const oldEngine = makeEngineFixture(FRAGMENT, '1.0.0', null);
|
|
527
|
+
withTempAgents(wrap('\n'), (agents) => {
|
|
528
|
+
const out = execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { encoding: 'utf8', env: withEngine(oldEngine) });
|
|
529
|
+
const text = readFileSync(agents, 'utf8');
|
|
530
|
+
assert.equal(extractSlot(text).trim(), FRAGMENT.trim(), 'methodology pointer filled (not discarded by the too-old engine)');
|
|
531
|
+
assert.match(out, /skipped/i, 'the orchestration skip is reported (not silent)');
|
|
532
|
+
assert.match(out, /too old/i, 'the skip names the too-old-engine reason');
|
|
533
|
+
assert.ok(!hasOrchSlot(text), 'a too-old engine adds no orchestration pointer');
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('orchestration fragment PRESENT but unreadable → hard STOP (a corrupt engine is NOT mislabeled "too old")', () => {
|
|
538
|
+
if (process.getuid && process.getuid() === 0) return; // root bypasses 0o000 perms — can't restrict
|
|
539
|
+
const corruptEngine = makeEngineFixture(FRAGMENT, '1.2.0', '> orchestration line\n'); // both fragments present
|
|
540
|
+
const orchPath = join(corruptEngine, 'references', 'orchestration-slot.md');
|
|
541
|
+
chmodSync(orchPath, 0o000);
|
|
542
|
+
let restricted = false;
|
|
543
|
+
try {
|
|
544
|
+
readFileSync(orchPath, 'utf8');
|
|
545
|
+
} catch {
|
|
546
|
+
restricted = true;
|
|
547
|
+
}
|
|
548
|
+
if (!restricted) {
|
|
549
|
+
chmodSync(orchPath, 0o644); // exotic FS / perms ignored → can't exercise this path here
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
withTempAgents(wrap('\n'), (agents) => {
|
|
554
|
+
assert.throws(() =>
|
|
555
|
+
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(corruptEngine) }),
|
|
556
|
+
);
|
|
557
|
+
assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), '', 'no partial write on a corrupt-fragment STOP');
|
|
558
|
+
});
|
|
559
|
+
} finally {
|
|
560
|
+
chmodSync(orchPath, 0o644); // restore so the after() cleanup can remove the fixture
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('fully absent engine + a fill needed → hard STOP (methodology fragment also unavailable)', () => {
|
|
565
|
+
// Distinct from the too-old case: a fully absent engine cannot supply EITHER fragment, so the
|
|
566
|
+
// methodology slot fill itself STOPs first — the dual-slot reconcile never silently no-ops.
|
|
567
|
+
withTempAgents(wrap('\n'), (agents) => {
|
|
568
|
+
assert.throws(() =>
|
|
569
|
+
execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
|
|
570
|
+
);
|
|
571
|
+
assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write when the engine is fully absent');
|
|
456
572
|
});
|
|
457
573
|
});
|
|
458
574
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Recipe planner — the pure brain behind the read-only `/agent-workflow-kit recipes` advisor.
|
|
3
|
+
//
|
|
4
|
+
// A "recipe" is a NAMED orchestration pattern over the family's optional execution-backends (the
|
|
5
|
+
// subscription-CLI bridges: codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`), composed
|
|
6
|
+
// into the plan → execute → review flow. The ENGINE owns the canonical narrative
|
|
7
|
+
// (agent-workflow-engine/references/orchestration.md — the when/why, kept in lockstep by the
|
|
8
|
+
// recipe-name parity guard in recipes.test.mjs); this module owns the EXECUTABLE dispatch: given a
|
|
9
|
+
// recipe + the read-only detector's view of the environment, which backend does which role, how it
|
|
10
|
+
// degrades when a backend isn't ready, and the advisory quota/health notes.
|
|
11
|
+
//
|
|
12
|
+
// Invariants (the backends/status posture): pure (no fs/network/CLI in planRecipe/recommendRecipe),
|
|
13
|
+
// read-only, NEVER runs a subscription CLI. The kit only surfaces/selects/plans a recipe — the
|
|
14
|
+
// orchestrator (the main agent) executes it via the bridge skills and always makes the single commit;
|
|
15
|
+
// a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
|
|
16
|
+
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
detectBackends,
|
|
20
|
+
READY,
|
|
21
|
+
NEEDS_SKILL,
|
|
22
|
+
NEEDS_CLI,
|
|
23
|
+
NEEDS_CREDENTIALS,
|
|
24
|
+
DEGRADED,
|
|
25
|
+
} from './detect-backends.mjs';
|
|
26
|
+
|
|
27
|
+
const CODEX = 'codex-cli-bridge';
|
|
28
|
+
const AGY = 'antigravity-cli-bridge';
|
|
29
|
+
|
|
30
|
+
// The manifest-name → human-alias map (the detector emits manifest names; humans say codex/agy).
|
|
31
|
+
export const DISPLAY_ALIASES = { [CODEX]: 'codex', [AGY]: 'agy' };
|
|
32
|
+
|
|
33
|
+
// The backend → role table, keyed by the manifest name the detector emits (status.name) — NOT the
|
|
34
|
+
// display alias. Drift-guarded against each bridge capability.json `provides[]` (recipes.test.mjs).
|
|
35
|
+
export const BACKEND_ROLES = {
|
|
36
|
+
[CODEX]: ['execute', 'review'],
|
|
37
|
+
[AGY]: ['review', 'probe'],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Advisory metadata the DETECTION object does not carry (it has no cost/quota — those live only in
|
|
41
|
+
// capability.json). cost/quota are drift-guarded against the manifests; the agy `health` advisory is
|
|
42
|
+
// static project knowledge (Issue-001: the Antigravity service can stall on substantive prompts —
|
|
43
|
+
// invisible to file-presence detection, so it is NOT a readiness signal, only a standing caveat).
|
|
44
|
+
export const BACKEND_META = {
|
|
45
|
+
[CODEX]: { cost: 'subscription', quota: { kind: 'subscription', finite: true } },
|
|
46
|
+
[AGY]: {
|
|
47
|
+
cost: 'subscription',
|
|
48
|
+
quota: { kind: 'subscription', finite: true },
|
|
49
|
+
health: 'Note: the Antigravity service may stall on substantive prompts (Issue-001) — prefer codex while it lasts.',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Deterministic tie-break order: codex before agy (agy carries the standing health caveat above).
|
|
54
|
+
const BACKEND_PRIORITY = [CODEX, AGY];
|
|
55
|
+
const priorityIndex = (name) => {
|
|
56
|
+
const i = BACKEND_PRIORITY.indexOf(name);
|
|
57
|
+
return i === -1 ? BACKEND_PRIORITY.length : i;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// The four recipes, in lattice order. `role` is the backend role a recipe needs (null = Solo, no
|
|
61
|
+
// backend); `minBackends` is how many READY providers it needs; `degradesTo` is the next-weaker
|
|
62
|
+
// recipe when it can't be satisfied (the chain terminates at Solo, which is always satisfiable).
|
|
63
|
+
export const RECIPES = [
|
|
64
|
+
{
|
|
65
|
+
id: 'solo',
|
|
66
|
+
title: 'Solo',
|
|
67
|
+
role: null,
|
|
68
|
+
minBackends: 0,
|
|
69
|
+
degradesTo: null,
|
|
70
|
+
summary: 'the orchestrator plans, executes, and self-reviews — no backend (always available; the floor).',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'reviewed',
|
|
74
|
+
title: 'Reviewed',
|
|
75
|
+
role: 'review',
|
|
76
|
+
minBackends: 1,
|
|
77
|
+
degradesTo: 'solo',
|
|
78
|
+
summary: 'the orchestrator executes; one backend reviews the result (advisory). Prefers codex when both are ready.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'council',
|
|
82
|
+
title: 'Council',
|
|
83
|
+
role: 'review',
|
|
84
|
+
minBackends: 2,
|
|
85
|
+
degradesTo: 'reviewed',
|
|
86
|
+
summary: 'both backends review independently; the orchestrator synthesizes the two opinions.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'delegated',
|
|
90
|
+
title: 'Delegated',
|
|
91
|
+
role: 'execute',
|
|
92
|
+
minBackends: 1,
|
|
93
|
+
degradesTo: 'solo',
|
|
94
|
+
summary: 'the orchestrator hands a bounded execution sub-task to a backend (codex exec), then reviews the diff and commits.',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const recipeById = (id) => RECIPES.find((r) => r.id === id);
|
|
99
|
+
|
|
100
|
+
// The human reason a non-ready readiness yields (read-only file-presence remedies — never a claim
|
|
101
|
+
// about whether the backend's service is responsive).
|
|
102
|
+
const READINESS_REASON = {
|
|
103
|
+
[NEEDS_SKILL]: 'bridge skill not installed — run /agent-workflow-kit setup',
|
|
104
|
+
[NEEDS_CLI]: 'the CLI is not installed',
|
|
105
|
+
[NEEDS_CREDENTIALS]: 'not signed in (credentials missing)',
|
|
106
|
+
[DEGRADED]: 'wrapper not on PATH — run /agent-workflow-kit setup',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── pure planner ───────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
// Backends (ready or not) whose role table includes `role`.
|
|
112
|
+
const providersOf = (role, detection) => detection.filter((b) => (BACKEND_ROLES[b.name] ?? []).includes(role));
|
|
113
|
+
|
|
114
|
+
// READY providers of `role`, in deterministic priority order (codex before agy) → an array of names.
|
|
115
|
+
const readyProvidersOf = (role, detection) =>
|
|
116
|
+
providersOf(role, detection)
|
|
117
|
+
.filter((b) => b.readiness === READY)
|
|
118
|
+
.sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
|
|
119
|
+
.map((b) => b.name);
|
|
120
|
+
|
|
121
|
+
// Availability = readiness === READY, full stop. A recipe is satisfiable iff it needs no backend OR
|
|
122
|
+
// enough READY providers of its role exist.
|
|
123
|
+
const isSatisfiable = (recipe, detection) =>
|
|
124
|
+
recipe.role === null || readyProvidersOf(recipe.role, detection).length >= recipe.minBackends;
|
|
125
|
+
|
|
126
|
+
// Why a recipe can't run as-is — the specific not-ready providers and their readiness-derived reasons.
|
|
127
|
+
const degradeReason = (recipe, detection) => {
|
|
128
|
+
const providers = providersOf(recipe.role, detection);
|
|
129
|
+
if (providers.length === 0) {
|
|
130
|
+
return `${recipe.title} needs a backend providing ${recipe.role}, but no backend provides it`;
|
|
131
|
+
}
|
|
132
|
+
const ready = providers.filter((b) => b.readiness === READY);
|
|
133
|
+
const detail = providers
|
|
134
|
+
.filter((b) => b.readiness !== READY)
|
|
135
|
+
.map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`)
|
|
136
|
+
.join('; ');
|
|
137
|
+
return `${recipe.title} needs ${recipe.minBackends} backend(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Per-stage dispatch for an EFFECTIVE (already-satisfiable) recipe: the first `minBackends` READY
|
|
141
|
+
// providers, in priority order. Solo dispatches nothing (the orchestrator does it all).
|
|
142
|
+
const dispatchFor = (recipe, detection) => {
|
|
143
|
+
if (recipe.role === null) return [];
|
|
144
|
+
return readyProvidersOf(recipe.role, detection)
|
|
145
|
+
.slice(0, recipe.minBackends)
|
|
146
|
+
.map((name) => ({ role: recipe.role, backend: name, display: DISPLAY_ALIASES[name] }));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const QUOTA_NOTE = "Prefer the cheapest model that fits the task; don't reach for a top-tier model by reflex.";
|
|
150
|
+
const COUNCIL_QUOTA_NOTE = "Council spends two backends' quota for one decision — reserve it for changes that justify the cost.";
|
|
151
|
+
|
|
152
|
+
// Advisory notes for an effective recipe: a quota reminder when any backend is dispatched, the
|
|
153
|
+
// two-quota caveat for Council, and the agy health advisory whenever the dispatch actually uses agy.
|
|
154
|
+
const notesFor = (recipe, dispatch) => {
|
|
155
|
+
const notes = [];
|
|
156
|
+
if (dispatch.length > 0) notes.push(QUOTA_NOTE);
|
|
157
|
+
if (recipe.id === 'council') notes.push(COUNCIL_QUOTA_NOTE);
|
|
158
|
+
if (dispatch.some((d) => d.backend === AGY) && BACKEND_META[AGY].health) notes.push(BACKEND_META[AGY].health);
|
|
159
|
+
return notes;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// planRecipe(recipe, detection) → pure plan. `recipe` is a recipe id or descriptor. Walks the
|
|
163
|
+
// degradation chain (with a stated reason per step) until a satisfiable recipe is reached, then emits
|
|
164
|
+
// the per-stage dispatch + advisory notes. Deterministic; never mutates the detection input.
|
|
165
|
+
export const planRecipe = (recipe, detection) => {
|
|
166
|
+
const requested = typeof recipe === 'string' ? recipeById(recipe) : recipe;
|
|
167
|
+
if (!requested) throw new Error(`unknown recipe: ${recipe}`);
|
|
168
|
+
let current = requested;
|
|
169
|
+
const degradation = [];
|
|
170
|
+
while (!isSatisfiable(current, detection)) {
|
|
171
|
+
const next = recipeById(current.degradesTo);
|
|
172
|
+
degradation.push({ from: current.id, to: next.id, reason: degradeReason(current, detection) });
|
|
173
|
+
current = next;
|
|
174
|
+
}
|
|
175
|
+
const dispatch = dispatchFor(current, detection);
|
|
176
|
+
return {
|
|
177
|
+
requested: requested.id,
|
|
178
|
+
effective: current.id,
|
|
179
|
+
degraded: current.id !== requested.id,
|
|
180
|
+
degradation,
|
|
181
|
+
dispatch,
|
|
182
|
+
notes: notesFor(current, dispatch),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// How close to ready a non-ready backend is — used to surface the most-actionable remedy first.
|
|
187
|
+
const READINESS_RANK = { [DEGRADED]: 3, [NEEDS_CREDENTIALS]: 2, [NEEDS_CLI]: 1, [NEEDS_SKILL]: 0 };
|
|
188
|
+
const READINESS_REMEDY = {
|
|
189
|
+
[NEEDS_SKILL]: 'run /agent-workflow-kit setup',
|
|
190
|
+
[NEEDS_CLI]: 'install its CLI',
|
|
191
|
+
[NEEDS_CREDENTIALS]: 'sign in',
|
|
192
|
+
[DEGRADED]: 'run /agent-workflow-kit setup (wrapper not on PATH)',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// recommendRecipe(detection) → { recipe, clause }. Never blank: both ready → Council (Reviewed the
|
|
196
|
+
// everyday default); one ready → Reviewed; none installed → Solo + a setup pointer; a backend
|
|
197
|
+
// present-but-not-ready → Solo with that backend's specific remedy. Pure.
|
|
198
|
+
export const recommendRecipe = (detection) => {
|
|
199
|
+
const readyReview = readyProvidersOf('review', detection);
|
|
200
|
+
if (readyReview.length >= 2) {
|
|
201
|
+
return { recipe: 'council', clause: 'Council available, Reviewed the everyday default' };
|
|
202
|
+
}
|
|
203
|
+
if (readyReview.length === 1) {
|
|
204
|
+
return { recipe: 'reviewed', clause: `Reviewed available (via ${DISPLAY_ALIASES[readyReview[0]]})` };
|
|
205
|
+
}
|
|
206
|
+
// No ready reviewer → Solo. Say how to unlock more: a present-but-not-ready backend names its
|
|
207
|
+
// remedy; nothing installed names the setup pointer.
|
|
208
|
+
const present = detection.filter((b) => b.readiness !== NEEDS_SKILL && b.readiness !== READY);
|
|
209
|
+
if (present.length === 0) {
|
|
210
|
+
return { recipe: 'solo', clause: 'Solo — run /agent-workflow-kit setup to add a backend' };
|
|
211
|
+
}
|
|
212
|
+
// Rank by how close to ready; break ties with the SAME codex-before-agy priority the dispatch path
|
|
213
|
+
// uses (priorityIndex) so the recommendation is deterministic regardless of detection emission order.
|
|
214
|
+
const best = [...present].sort(
|
|
215
|
+
(a, b) => (READINESS_RANK[b.readiness] ?? -1) - (READINESS_RANK[a.readiness] ?? -1) || priorityIndex(a.name) - priorityIndex(b.name),
|
|
216
|
+
)[0];
|
|
217
|
+
const remedy = READINESS_REMEDY[best.readiness] ?? best.readiness;
|
|
218
|
+
return { recipe: 'solo', clause: `Solo — ${DISPLAY_ALIASES[best.name] ?? best.name}: ${remedy} to unlock Reviewed` };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ── report + CLI ─────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
// The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
|
|
224
|
+
export const buildReport = (detection) => ({
|
|
225
|
+
recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
|
|
226
|
+
id,
|
|
227
|
+
title,
|
|
228
|
+
role,
|
|
229
|
+
minBackends,
|
|
230
|
+
degradesTo,
|
|
231
|
+
summary,
|
|
232
|
+
})),
|
|
233
|
+
recommendation: recommendRecipe(detection),
|
|
234
|
+
plans: RECIPES.map((r) => planRecipe(r.id, detection)),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
|
|
238
|
+
// and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
|
|
239
|
+
export const formatRecipes = (detection) => {
|
|
240
|
+
const lines = [
|
|
241
|
+
'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills and always commits)',
|
|
242
|
+
'',
|
|
243
|
+
];
|
|
244
|
+
for (const r of RECIPES) lines.push(` ${r.title} (${r.id}) — ${r.summary}`);
|
|
245
|
+
const rec = recommendRecipe(detection);
|
|
246
|
+
lines.push('', `recommended here: ${rec.recipe} — ${rec.clause}`, '', 'plan for the current environment:');
|
|
247
|
+
for (const r of RECIPES) {
|
|
248
|
+
const p = planRecipe(r.id, detection);
|
|
249
|
+
const arrow = p.degraded ? ` → ${p.effective}` : '';
|
|
250
|
+
const who = p.dispatch.length ? p.dispatch.map((d) => `${d.display} ${d.role}`).join(', ') : 'orchestrator only';
|
|
251
|
+
lines.push(` ${r.title}${arrow}: ${who}`);
|
|
252
|
+
for (const step of p.degradation) lines.push(` ↳ ${step.reason}`);
|
|
253
|
+
for (const note of p.notes) lines.push(` • ${note}`);
|
|
254
|
+
}
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const main = (argv) => {
|
|
259
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
260
|
+
console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
|
|
261
|
+
|
|
262
|
+
Usage:
|
|
263
|
+
node recipes.mjs [--json]
|
|
264
|
+
|
|
265
|
+
Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
|
|
266
|
+
detector, plans + recommends one for the current environment. Detection only — never writes, never
|
|
267
|
+
commits, never runs a subscription CLI.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const detection = detectBackends();
|
|
271
|
+
if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
|
|
272
|
+
else console.log(formatRecipes(detection));
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
276
|
+
if (isDirectRun) main(process.argv.slice(2));
|