@sabaiway/agent-workflow-kit 1.13.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.
@@ -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
+ }