@sabaiway/agent-workflow-kit 1.29.0 → 1.31.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +7 -2
  3. package/SKILL.md +33 -519
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +145 -4
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +169 -1
  7. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  8. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  9. package/bridges/codex-cli-bridge/bin/codex-review.sh +120 -3
  10. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +132 -0
  11. package/bridges/codex-cli-bridge/capability.json +3 -2
  12. package/capability.json +1 -1
  13. package/launchers/windsurf-workflow.md +4 -2
  14. package/package.json +1 -1
  15. package/references/modes/agents.md +11 -0
  16. package/references/modes/backends.md +9 -0
  17. package/references/modes/bootstrap.md +49 -0
  18. package/references/modes/gates.md +17 -0
  19. package/references/modes/grounding.md +12 -0
  20. package/references/modes/help.md +7 -0
  21. package/references/modes/hook.md +24 -0
  22. package/references/modes/procedures.md +18 -0
  23. package/references/modes/recipes.md +18 -0
  24. package/references/modes/review-state.md +13 -0
  25. package/references/modes/set-recipe.md +23 -0
  26. package/references/modes/setup.md +30 -0
  27. package/references/modes/status.md +22 -0
  28. package/references/modes/uninstall.md +18 -0
  29. package/references/modes/upgrade.md +46 -0
  30. package/references/modes/velocity.md +18 -0
  31. package/references/shared/composition-handoff.md +39 -0
  32. package/references/shared/deploy-tail.md +61 -0
  33. package/references/shared/report-footer.md +116 -0
  34. package/references/templates/agent_rules.md +3 -2
  35. package/references/templates/handover.md +1 -0
  36. package/tools/commands.mjs +15 -1
  37. package/tools/detect-backends.mjs +2 -0
  38. package/tools/engine-source.mjs +2 -1
  39. package/tools/grounding.mjs +263 -0
  40. package/tools/procedures.mjs +50 -5
  41. package/tools/recipes.mjs +78 -12
  42. package/tools/review-state.mjs +395 -0
  43. package/tools/set-recipe.mjs +15 -5
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ // grounding.mjs — the grounded-review facts assembler behind `/agent-workflow-kit grounding`
3
+ // (AD-038). An ungrounded agy review GUESSES (AD-028); the grounding CONTRACT is mechanized
4
+ // (`agy-review --facts @f`, AD-033) but population was a manual chore — this tool emits the two
5
+ // mechanical halves of a facts payload so assembly is a command, not hand-copying:
6
+ //
7
+ // --constraints slice the root AGENTS.md `## 🚫 Hard Constraints` section, verbatim
8
+ // (exactly-one-match — 0 or >1 headings is a loud STOP, never a guess);
9
+ // --plan <path> extract the plan's decision-bearing canonical sections, verbatim + whole:
10
+ // `## Approach` (REQUIRED — its "What we are NOT doing" text rides inside;
11
+ // it is not a heading in canon) and `## Verification` (REQUIRED — STOP if
12
+ // missing), plus `## Decisions (locked)` (optional-if-absent, the engine §7
13
+ // heading this release adds); a DUPLICATE heading is always a STOP.
14
+ //
15
+ // Byte budget: the output honors the same AGY_MAX_PROMPT_BYTES contract the agy wrapper enforces
16
+ // (default 120000; the override may only TIGHTEN — above the OS single-argv ceiling ~131000 is
17
+ // rejected, mirroring agy-review.sh), MINUS --reserve-bytes <n> — the artifact share the caller
18
+ // expects `agy-review` to add around these facts. Overflow is TRIMMED tail-first with a loud
19
+ // in-band marker + stderr report (never a silent cut).
20
+ //
21
+ // Catalog honesty: this is a WRITER — `--out <path>` writes a file. Invariant: `--out` accepts
22
+ // only gitignored / out-of-repo scratch destinations and REFUSES a tracked path AND an in-repo
23
+ // not-ignored path (a new untracked file would itself move the review fingerprint the facts are
24
+ // about to ground). stdout is the default. It never commits and never runs a subscription CLI.
25
+ //
26
+ // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
27
+
28
+ import { readFileSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
29
+ import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { fail } from './orchestration-config.mjs';
33
+
34
+ // The agy single-argv byte contract (mirrors agy-review.sh — the wrapper is the enforcement home).
35
+ export const DEFAULT_MAX_PROMPT_BYTES = 120000;
36
+ export const ARGV_HARD_MAX = 131000;
37
+
38
+ export const CONSTRAINTS_HEADING = /^## .*Hard Constraints$/;
39
+ export const PLAN_SECTIONS = [
40
+ { heading: '## Approach', optional: false },
41
+ { heading: '## Verification', optional: false },
42
+ { heading: '## Decisions (locked)', optional: true },
43
+ ];
44
+
45
+ // ── pure section slicing (exactly-one-match; the inject-methodology discipline) ────────
46
+
47
+ // Slice ONE `## `-level section (its heading line through the line before the next `## ` heading),
48
+ // verbatim. `heading` is a string (trimmed-line equality) or a RegExp over the trimmed line.
49
+ // 0 matches → null when optional, else STOP; >1 matches → always STOP (never guess which).
50
+ export const sliceSection = (text, heading, { optional = false, label = 'document' } = {}) => {
51
+ const lines = text.split('\n');
52
+ const matchesAt = [];
53
+ for (let i = 0; i < lines.length; i += 1) {
54
+ const trimmed = lines[i].trim();
55
+ if (typeof heading === 'string' ? trimmed === heading : heading.test(trimmed)) matchesAt.push(i);
56
+ }
57
+ const shown = typeof heading === 'string' ? heading : String(heading);
58
+ if (matchesAt.length === 0) {
59
+ if (optional) return null;
60
+ throw fail(1, `${label}: required section "${shown}" not found — STOP (nothing sliced)`);
61
+ }
62
+ if (matchesAt.length > 1) {
63
+ throw fail(1, `${label}: section "${shown}" appears ${matchesAt.length} times — must be exactly one; STOP (never guess which)`);
64
+ }
65
+ const start = matchesAt[0];
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i += 1) {
68
+ if (/^## /.test(lines[i])) {
69
+ end = i;
70
+ break;
71
+ }
72
+ }
73
+ return lines.slice(start, end).join('\n').replace(/\n+$/, '\n');
74
+ };
75
+
76
+ // ── assembly ───────────────────────────────────────────────────────────────────────
77
+
78
+ export const assembleGrounding = ({ constraintsText = null, planText = null, planLabel = 'plan' } = {}) => {
79
+ const parts = [];
80
+ if (constraintsText != null) {
81
+ parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
82
+ }
83
+ if (planText != null) {
84
+ for (const { heading, optional } of PLAN_SECTIONS) {
85
+ const section = sliceSection(planText, heading, { optional, label: planLabel });
86
+ if (section != null) parts.push(section);
87
+ }
88
+ }
89
+ return parts.join('\n');
90
+ };
91
+
92
+ // Trim `payload` to `budget` bytes, tail-first, with a loud in-band marker. The budget is a HARD
93
+ // ceiling: when it is smaller than the marker itself, the marker is truncated too (the output may
94
+ // never exceed `budget` — a facts file over the reserved share would push the final wrapper prompt
95
+ // past the argv ceiling; codex R1 finding). Returns { text, trimmedBytes }. Never a silent cut.
96
+ export const trimToBudget = (payload, budget) => {
97
+ const bytes = Buffer.byteLength(payload, 'utf8');
98
+ if (bytes <= budget) return { text: payload, trimmedBytes: 0 };
99
+ const marker = `\n[grounding] TRIMMED: the assembled facts exceeded the byte budget — tail dropped; tighten the sources or raise --reserve-bytes accounting.\n`;
100
+ const keep = Math.max(0, budget - Buffer.byteLength(marker, 'utf8'));
101
+ const joined = Buffer.from(payload, 'utf8').subarray(0, keep).toString('utf8').replace(/�+$/, '') + marker;
102
+ // Hard-cap the final text (covers budget < marker size): byte-slice, then drop any split rune.
103
+ const text = Buffer.from(joined, 'utf8').subarray(0, budget).toString('utf8').replace(/�+$/, '');
104
+ return { text, trimmedBytes: bytes - keep };
105
+ };
106
+
107
+ // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
108
+
109
+ const gitLine = (args, cwd) => {
110
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
111
+ return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
112
+ };
113
+
114
+ // STOP unless `outPath` is a safe scratch destination: outside any git work tree, or gitignored
115
+ // inside one. A TRACKED path and an in-repo NOT-ignored path are both refused (a facts file must
116
+ // never enter the tree it grounds — it would move the fingerprint and could be committed). The
117
+ // check runs on the REAL destination, never the lexical path (codex R1 finding): a symlink leaf is
118
+ // refused outright, and the parent directory is realpath-resolved first, so a gitignored or
119
+ // out-of-repo symlink can never route the write onto a tracked/in-repo file. Returns the resolved
120
+ // real path the caller must write to.
121
+ export const assertScratchDestination = (outPath, cwd) => {
122
+ const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
123
+ let leaf = null;
124
+ try {
125
+ leaf = lstatSync(lexical);
126
+ } catch {
127
+ leaf = null; // absent → a fresh file; the parent still gets realpath-checked below
128
+ }
129
+ if (leaf?.isSymbolicLink()) {
130
+ throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
131
+ }
132
+ let realParent;
133
+ try {
134
+ realParent = realpathSync(dirname(lexical));
135
+ } catch {
136
+ throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
137
+ }
138
+ const full = join(realParent, basename(lexical));
139
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
140
+ if (top == null || top.status !== 0) return full; // no git tree → plain scratch, fine
141
+ const root = top.stdout.replace(/\r?\n$/, '');
142
+ const rel = relative(root, full);
143
+ if (rel.startsWith('..') || isAbsolute(rel)) return full; // really outside the work tree → scratch
144
+ const tracked = gitLine(['ls-files', '--error-unmatch', '--', rel], root);
145
+ if (tracked != null && tracked.status === 0) {
146
+ throw fail(1, `--out refuses a TRACKED path (${rel}) — grounding output is scratch; write it to a gitignored path or outside the repo`);
147
+ }
148
+ const ignored = gitLine(['check-ignore', '-q', '--', rel], root);
149
+ if (ignored == null || ignored.status !== 0) {
150
+ throw fail(1, `--out refuses an in-repo path that is not gitignored (${rel}) — a new untracked file would move the review fingerprint; use a gitignored path or a location outside the repo`);
151
+ }
152
+ return full;
153
+ };
154
+
155
+ // ── CLI ────────────────────────────────────────────────────────────────────────────
156
+
157
+ const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
158
+
159
+ Usage:
160
+ node grounding.mjs [--constraints] [--plan <path>] [--reserve-bytes <n>] [--out <path>]
161
+
162
+ --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
163
+ (exactly one matching heading, else a loud STOP)
164
+ --plan <path> extract the plan's decision-bearing sections verbatim + whole:
165
+ "## Approach" + "## Verification" (REQUIRED — STOP if missing),
166
+ "## Decisions (locked)" when present; a duplicate heading is a STOP
167
+ --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
168
+ budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
169
+ --out <path> write instead of stdout — gitignored / out-of-repo scratch ONLY
170
+ (a tracked or in-repo not-ignored path is refused)
171
+
172
+ Feed the output to the review wrapper: agy-review code --facts @<out>. AGY_MAX_PROMPT_BYTES
173
+ honors the wrapper's contract (default ${DEFAULT_MAX_PROMPT_BYTES}; may only tighten — above the
174
+ OS argv ceiling it is rejected). Writer honesty: --out writes ONE scratch file; nothing else is
175
+ ever written; never commits, never runs a subscription CLI.
176
+
177
+ Exit codes: 0 success; 2 usage; 1 STOP (missing/duplicate section, unreadable file, refused --out).`;
178
+
179
+ const parseArgs = (argv) => {
180
+ let constraints = false;
181
+ let plan = null;
182
+ let out = null;
183
+ let reserve = 0;
184
+ for (let i = 0; i < argv.length; i += 1) {
185
+ const a = argv[i];
186
+ if (a === '--constraints') constraints = true;
187
+ else if (a === '--plan') {
188
+ plan = argv[i + 1];
189
+ if (!plan || plan.startsWith('--')) throw fail(2, '--plan requires a <path>');
190
+ i += 1;
191
+ } else if (a === '--out') {
192
+ out = argv[i + 1];
193
+ if (!out || out.startsWith('--')) throw fail(2, '--out requires a <path>');
194
+ i += 1;
195
+ } else if (a === '--reserve-bytes') {
196
+ const raw = argv[i + 1];
197
+ if (!raw || !/^\d+$/.test(raw)) throw fail(2, '--reserve-bytes requires a non-negative integer');
198
+ reserve = Number(raw);
199
+ i += 1;
200
+ } else throw fail(2, `unknown argument: ${a}`);
201
+ }
202
+ if (!constraints && plan == null) throw fail(2, 'nothing to assemble — pass --constraints and/or --plan <path>');
203
+ return { constraints, plan, out, reserve };
204
+ };
205
+
206
+ const resolveBudget = (env, reserve) => {
207
+ const raw = env.AGY_MAX_PROMPT_BYTES ?? String(DEFAULT_MAX_PROMPT_BYTES);
208
+ if (!/^\d+$/.test(raw)) throw fail(2, `AGY_MAX_PROMPT_BYTES='${raw}' is not a non-negative integer`);
209
+ const max = Number(raw);
210
+ if (max > ARGV_HARD_MAX) {
211
+ throw fail(2, `AGY_MAX_PROMPT_BYTES=${max} exceeds the OS single-argv ceiling (~${ARGV_HARD_MAX}) — the override may only tighten (mirrors agy-review)`);
212
+ }
213
+ const budget = max - reserve;
214
+ if (budget <= 0) throw fail(2, `--reserve-bytes ${reserve} leaves no budget under AGY_MAX_PROMPT_BYTES=${max}`);
215
+ return budget;
216
+ };
217
+
218
+ export const main = (argv, ctx = {}) => {
219
+ const cwd = ctx.cwd ?? process.cwd();
220
+ const env = ctx.env ?? process.env;
221
+ try {
222
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
223
+ const { constraints, plan, out, reserve } = parseArgs(argv);
224
+ const budget = resolveBudget(env, reserve);
225
+
226
+ const readOrStop = (path, label) => {
227
+ try {
228
+ return readFileSync(resolve(cwd, path), 'utf8');
229
+ } catch (err) {
230
+ throw fail(1, `${label} '${path}' is unreadable (${(err && err.code) || err}) — STOP`);
231
+ }
232
+ };
233
+ const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
234
+ const planText = plan != null ? readOrStop(plan, 'plan file') : null;
235
+
236
+ const payload = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
237
+ const { text, trimmedBytes } = trimToBudget(payload, budget);
238
+ const stderr = trimmedBytes > 0
239
+ ? `[grounding] TRIM: assembled ${Buffer.byteLength(payload, 'utf8')} bytes > budget ${budget} (AGY_MAX_PROMPT_BYTES minus --reserve-bytes ${reserve}) — dropped ${trimmedBytes} tail bytes; the cut is marked in-band.`
240
+ : '';
241
+
242
+ if (out != null) {
243
+ const realOut = assertScratchDestination(out, cwd);
244
+ writeFileSync(realOut, text);
245
+ return { code: 0, stdout: `[grounding] wrote ${Buffer.byteLength(text, 'utf8')} bytes to ${out} — pass it as: agy-review code --facts @${out}`, stderr };
246
+ }
247
+ return { code: 0, stdout: text, stderr };
248
+ } catch (err) {
249
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `grounding: ${err.message}` };
250
+ }
251
+ };
252
+
253
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
254
+ if (isDirectRun) {
255
+ const r = main(process.argv.slice(2));
256
+ // Exact writes + a natural exit (codex R2): console.log would append a stray newline to the
257
+ // byte-exact facts payload, and process.exit() can truncate large piped stdout before Node
258
+ // flushes. The payload already ends with '\n' (sliceSection normalizes); only a non-payload
259
+ // message (help / the --out report) gains the terminating newline it lacks.
260
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
261
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
262
+ process.exitCode = r.code;
263
+ }
@@ -18,10 +18,15 @@
18
18
 
19
19
  import { readFileSync, lstatSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
- import { pathToFileURL } from 'node:url';
21
+ import { join, dirname } from 'node:path';
22
+ import { pathToFileURL, fileURLToPath } from 'node:url';
22
23
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
23
24
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
24
25
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
+ // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
27
+ // performs no fs writes, so the "procedures never reaches a writer" import-split invariant holds;
28
+ // the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported).
29
+ import { plansInFlight, PLANS_REL } from './review-state.mjs';
25
30
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
26
31
  // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
27
32
  // (orchestration-write.mjs) — so "the read-only advisor can never reach a writer" is STRUCTURALLY true
@@ -188,6 +193,41 @@ const reviewLoopAdvice = (slots) =>
188
193
  ]
189
194
  : [];
190
195
 
196
+ // The grounding pre-step (AD-038, extending the AD-033 verbatim-contract rendering): whenever the
197
+ // resolved dispatch includes agy-review, print the CONCRETE facts-assembly invocation + the
198
+ // --facts form as a copy-paste pre-step — population, not placeholders. Plan-path population rule
199
+ // (the review-state plan-in-flight detector): exactly ONE plan in flight → render it populated;
200
+ // zero or several → the explicit `--plan <path>` placeholder + a one-line discovery caveat. The
201
+ // suggested --out lives OUTSIDE the repo (/tmp) — grounding.mjs refuses a non-scratch destination.
202
+ const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
203
+ const GROUNDING_FACTS_OUT = '/tmp/review-facts.md';
204
+ const groundingPreStepAdvice = (activity, slots, plans) => {
205
+ if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
206
+ const planArg = plans.length === 1 ? `--plan "${PLANS_REL}/${plans[0]}"` : '--plan <path>';
207
+ // plan-authoring reviews the plan FILE — when exactly one plan is in flight, the review command
208
+ // is populated with the same discovered path (codex R3: a known path never renders a placeholder).
209
+ const reviewForm =
210
+ activity === 'plan-authoring'
211
+ ? plans.length === 1
212
+ ? `agy-review plan "${PLANS_REL}/${plans[0]}"`
213
+ : 'agy-review plan <plan-file>'
214
+ : 'agy-review code';
215
+ // `run:`/`then:` prefixes keep these POPULATED command lines machine-distinguishable from the
216
+ // verbatim contract DESCRIPTORS above (the descriptor drift guard set-equals bare wrapper lines).
217
+ // Path arguments are double-quoted — a skill dir or plan name with a space must stay copy-pasteable.
218
+ const lines = [
219
+ 'Grounding pre-step (agy is dispatched — assemble the verified facts BEFORE the review; grounding.mjs slices verbatim, judgment additions stay yours):',
220
+ ` run: node "${GROUNDING_TOOL}" --constraints ${planArg} --out ${GROUNDING_FACTS_OUT}`,
221
+ ` then: ${reviewForm} --facts @${GROUNDING_FACTS_OUT}`,
222
+ ];
223
+ if (plans.length === 0) {
224
+ lines.push(` ↳ plan discovery: no plan in flight under ${PLANS_REL} — substitute the plan file you are reviewing against, or drop --plan for constraints-only facts.`);
225
+ } else if (plans.length > 1) {
226
+ lines.push(` ↳ plan discovery: ${plans.length} plans in flight under ${PLANS_REL} (${plans.join(', ')}) — populate --plan with the one under review.`);
227
+ }
228
+ return lines;
229
+ };
230
+
191
231
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
192
232
  // at the point of use like reviewLoopAdvice paraphrases §9/§4). Rendered UNCONDITIONALLY for
193
233
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -226,7 +266,7 @@ const contractLines = ({ cmd, contract }) => {
226
266
  return lines;
227
267
  };
228
268
 
229
- const formatHuman = ({ activity, section, slots, warnings }) => {
269
+ const formatHuman = ({ activity, section, slots, warnings, plans }) => {
230
270
  const lines = [
231
271
  section,
232
272
  '',
@@ -238,6 +278,8 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
238
278
  if (s.reason) lines.push(` ↳ ${s.reason}`);
239
279
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
240
280
  }
281
+ const grounding = groundingPreStepAdvice(activity, slots, plans);
282
+ if (grounding.length) lines.push('', ...grounding);
241
283
  const advice = reviewLoopAdvice(slots);
242
284
  if (advice.length) lines.push('', ...advice);
243
285
  lines.push('', ...costLanesAdvice());
@@ -248,7 +290,7 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
248
290
  return lines.join('\n');
249
291
  };
250
292
 
251
- const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
293
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans }) => ({
252
294
  activity,
253
295
  section,
254
296
  slots: Object.fromEntries(
@@ -257,6 +299,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
257
299
  slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
258
300
  ),
259
301
  reviewLoop: reviewLoopAdvice(slots),
302
+ // ADDITIVE (AD-038): the populated grounding pre-step, structured (empty when agy is not dispatched).
303
+ groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
260
304
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
261
305
  costLanes: costLanesAdvice(),
262
306
  configSource,
@@ -311,9 +355,10 @@ export const main = (argv, ctx = {}) => {
311
355
  }
312
356
  const slots = resolveAllSlots({ activity, config, detection, overrides });
313
357
  const warnings = [...detectWarnings, ...collectWarnings(slots)];
358
+ const plans = plansInFlight(cwd);
314
359
  const stdout = json
315
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings }), null, 2)
316
- : formatHuman({ activity, section, slots, warnings });
360
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans }), null, 2)
361
+ : formatHuman({ activity, section, slots, warnings, plans });
317
362
  return { code: 0, stdout, stderr: '' };
318
363
  } catch (err) {
319
364
  return { code: err.exitCode ?? 1, stdout: '', stderr: `procedures: ${err.message}` };
package/tools/recipes.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import {
19
19
  detectBackends,
20
+ wrapperCmdFor,
20
21
  READY,
21
22
  NEEDS_SKILL,
22
23
  NEEDS_CLI,
@@ -300,6 +301,41 @@ export const composeStatusLine = (detection, recommendation) => {
300
301
  return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
301
302
  };
302
303
 
304
+ // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
305
+
306
+ // composeActiveRecipeLine({ config, source }, detection) → ONE line rendering the CONFIGURED recipe of
307
+ // every activity/slot (resolved via resolveActivityRecipe: config entry, else computed default), each
308
+ // with its source label, its degradation stated, and its dispatched wrapper set — explicitly labeled
309
+ // "configured" and contrasted with the readiness-RECOMMENDED recipe (which composeStatusLine shows and
310
+ // which is NOT what runs). This is the machine-composed sibling of composeStatusLine (AD-034): the
311
+ // session-start checklist + the handover "Active recipes:" slot paste it verbatim, so no agent composes
312
+ // the configured-recipe facts by hand. `{ config, source }` is exactly what loadConfig returns (source
313
+ // 'none' when no config file exists). Always exactly one line: no part may carry a newline (pinned).
314
+ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
315
+ const cells = [];
316
+ for (const [activity, def] of Object.entries(ACTIVITIES)) {
317
+ for (const slot of Object.keys(def.slots)) {
318
+ const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
319
+ const { dispatch } = planRecipe(r.recipe, detection);
320
+ const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
321
+ const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
322
+ const head = r.degradedFrom
323
+ ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason})`
324
+ : `${activity}.${slot} = ${r.recipe} (${srcLabel})`;
325
+ const suffix =
326
+ wrappers.length >= 2
327
+ ? ` → every backend every round: ${wrappers.join(' + ')}`
328
+ : wrappers.length === 1
329
+ ? ` → ${wrappers[0]}`
330
+ : '';
331
+ cells.push(`${head}${suffix}`);
332
+ }
333
+ }
334
+ const rec = recommendRecipe(detection);
335
+ const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
336
+ return `active recipes (${origin}): ${cells.join(' · ')} — the configured recipes above are what runs; readiness-recommended here: ${rec.recipe} (informational)`;
337
+ };
338
+
303
339
  // ── report + CLI ─────────────────────────────────────────────────────────────────
304
340
 
305
341
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
@@ -343,38 +379,68 @@ export const formatRecipes = (detection) => {
343
379
  };
344
380
 
345
381
  // The full argv vocabulary — anything else rejects LOUDLY. The old parse silently routed unknown
346
- // args into the multi-line human render; with `--status-line` (whose output is pasted as fact) a
347
- // mistyped flag masquerading as a mode would be a silent failure, so the parse is strict now.
348
- const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line']);
382
+ // args into the multi-line human render; with `--status-line` / `--active-line` (whose output is
383
+ // pasted as fact) a mistyped flag masquerading as a mode would be a silent failure, so the parse is
384
+ // strict now.
385
+ const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line', '--active-line']);
386
+ const EXCLUSIVE_ARGS = ['--json', '--status-line', '--active-line']; // each owns stdout whole
349
387
 
350
- const main = (argv) => {
388
+ const main = async (argv) => {
351
389
  if (argv.includes('--help') || argv.includes('-h')) {
352
390
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
353
391
 
354
392
  Usage:
355
- node recipes.mjs [--json | --status-line]
393
+ node recipes.mjs [--json | --status-line | --active-line]
356
394
 
357
395
  Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
358
396
  detector, plans + recommends one for the current environment. --status-line prints exactly ONE
359
397
  line — the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim.
360
- --json emits the structured report (incl. the same line as \`statusLine\`); the two are mutually
398
+ --active-line prints exactly ONE line the CONFIGURED recipe per activity/slot, resolved from the
399
+ per-project docs/ai/orchestration.json (read from the current directory) + live readiness, with
400
+ degradation stated; paste it verbatim at session start / into the handover "Active recipes:" slot.
401
+ --json emits the structured report (incl. the same line as \`statusLine\`); the three are mutually
361
402
  exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
362
403
  return;
363
404
  }
364
405
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
365
406
  if (unknown !== undefined) {
366
407
  console.error(`[agent-workflow-kit] unknown argument: ${unknown}`);
367
- process.exit(1);
408
+ return 1;
368
409
  }
369
- if (argv.includes('--json') && argv.includes('--status-line')) {
370
- console.error('[agent-workflow-kit] --json and --status-line are mutually exclusive pick one output');
371
- process.exit(1);
410
+ const exclusive = EXCLUSIVE_ARGS.filter((a) => argv.includes(a));
411
+ if (exclusive.length > 1) {
412
+ console.error(`[agent-workflow-kit] ${exclusive.join(' and ')} are mutually exclusive — pick one output`);
413
+ return 1;
372
414
  }
373
415
  const detection = detectBackends();
374
- if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
416
+ if (argv.includes('--active-line')) {
417
+ // Lazy import: orchestration-config.mjs statically imports this module (ACTIVITIES/SLOT_RECIPES),
418
+ // so the config reader is pulled in at run time only — no static import cycle.
419
+ const { loadConfig } = await import('./orchestration-config.mjs');
420
+ try {
421
+ console.log(composeActiveRecipeLine(loadConfig(process.cwd()), detection));
422
+ } catch (err) {
423
+ console.error(`[agent-workflow-kit] ${err.message}`);
424
+ return err.exitCode ?? 1;
425
+ }
426
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
375
427
  else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
376
428
  else console.log(formatRecipes(detection));
429
+ return 0;
377
430
  };
378
431
 
379
432
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
380
- if (isDirectRun) main(process.argv.slice(2));
433
+ // Natural exit via process.exitCode — never process.exit inside the async main (it would drop buffered
434
+ // stdio writes on piped stderr), and never a TOP-LEVEL await here: orchestration-config.mjs statically
435
+ // imports this module, so awaiting the dynamic import during our own evaluation would deadlock the cycle.
436
+ if (isDirectRun) {
437
+ main(process.argv.slice(2)).then(
438
+ (code) => {
439
+ process.exitCode = code ?? 0;
440
+ },
441
+ (err) => {
442
+ console.error(`[agent-workflow-kit] ${(err && err.message) || err}`);
443
+ process.exitCode = 1;
444
+ },
445
+ );
446
+ }