@sabaiway/agent-workflow-kit 1.29.0 → 1.30.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 +57 -0
- package/README.md +2 -0
- package/SKILL.md +37 -3
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +145 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +169 -1
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +120 -3
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +132 -0
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/agent_rules.md +3 -2
- package/references/templates/handover.md +1 -0
- package/tools/commands.mjs +15 -1
- package/tools/detect-backends.mjs +2 -0
- package/tools/grounding.mjs +263 -0
- package/tools/procedures.mjs +50 -5
- package/tools/recipes.mjs +78 -12
- package/tools/review-state.mjs +395 -0
- 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
|
+
}
|
package/tools/procedures.mjs
CHANGED
|
@@ -18,10 +18,15 @@
|
|
|
18
18
|
|
|
19
19
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
|
-
import {
|
|
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
|
|
347
|
-
// mistyped flag masquerading as a mode would be a silent failure, so the parse is
|
|
348
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
408
|
+
return 1;
|
|
368
409
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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('--
|
|
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
|
-
|
|
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
|
+
}
|