@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.
- package/CHANGELOG.md +65 -0
- package/README.md +4 -1
- package/SKILL.md +52 -10
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size.mjs +4 -1
- package/references/scripts/check-docs-size.test.mjs +24 -0
- package/references/templates/orchestration.json +5 -0
- package/tools/engine-source.mjs +5 -0
- package/tools/engine-source.test.mjs +48 -0
- package/tools/family-registry.mjs +22 -16
- package/tools/family-registry.test.mjs +55 -23
- package/tools/inject-methodology.mjs +25 -0
- package/tools/inject-methodology.test.mjs +73 -0
- package/tools/procedures.mjs +324 -0
- package/tools/procedures.test.mjs +303 -0
- package/tools/recipes.mjs +64 -0
- package/tools/recipes.test.mjs +175 -0
- package/tools/uninstall.mjs +58 -10
- package/tools/uninstall.test.mjs +29 -0
- package/tools/velocity-profile.mjs +571 -0
- package/tools/velocity-profile.test.mjs +496 -0
|
@@ -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
|
|
114
|
-
//
|
|
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.
|
|
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('
|
|
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.
|
|
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('
|
|
147
|
+
it('an OK engine MISSING the orchestration fragment gets the recipes caveat (only)', () => {
|
|
137
148
|
const rows = surveyFamily(engineDeps({
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
142
|
-
assert.equal(
|
|
143
|
-
assert.
|
|
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('
|
|
170
|
+
it('an engine MISSING BOTH fragments surfaces BOTH caveats (neither overwrites the other)', () => {
|
|
147
171
|
const rows = surveyFamily(engineDeps({
|
|
148
|
-
|
|
172
|
+
readVersion: () => ({ version: '1.1.0' }),
|
|
173
|
+
statType: fragmentStat({ orch: null, proc: null }),
|
|
149
174
|
}));
|
|
150
|
-
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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
|
+
}
|