@sabaiway/agent-workflow-kit 1.28.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 +93 -0
- package/README.md +4 -1
- package/SKILL.md +67 -6
- 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/contracts.md +1 -0
- package/references/hooks/gate-approve.mjs +300 -0
- package/references/templates/agent_rules.md +3 -2
- package/references/templates/handover.md +1 -0
- package/tools/commands.mjs +24 -3
- package/tools/detect-backends.mjs +2 -0
- package/tools/family-registry.mjs +25 -0
- package/tools/gate-hook.mjs +387 -0
- package/tools/grounding.mjs +263 -0
- package/tools/known-footprint.mjs +1 -0
- package/tools/presentation.mjs +1 -0
- package/tools/procedures.mjs +50 -5
- package/tools/recipes.mjs +78 -12
- package/tools/renderers.mjs +6 -0
- package/tools/review-state.mjs +395 -0
- package/tools/set-recipe.mjs +15 -5
- package/tools/uninstall.mjs +175 -26
- package/tools/velocity-profile.mjs +21 -3
- package/tools/view-model.mjs +18 -1
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
|
+
}
|
package/tools/renderers.mjs
CHANGED
|
@@ -101,6 +101,12 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
101
101
|
else if (s.velocity) {
|
|
102
102
|
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
103
103
|
}
|
|
104
|
+
// gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present.
|
|
105
|
+
if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
|
|
106
|
+
else if (s.hook) {
|
|
107
|
+
const yn = (b) => (b ? 'yes' : 'no');
|
|
108
|
+
lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)}`);
|
|
109
|
+
}
|
|
104
110
|
return lines;
|
|
105
111
|
};
|
|
106
112
|
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// review-state.mjs — the read-only review-receipt checker behind `/agent-workflow-kit review-state`
|
|
3
|
+
// (AD-038). It makes "reviewed ≠ shipped" mechanically detectable: the bridge review wrappers
|
|
4
|
+
// (codex-review / agy-review ≥2.2.0) append one receipt line per successful review; this tool
|
|
5
|
+
// resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
|
|
6
|
+
// recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
|
|
7
|
+
// backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
|
|
8
|
+
// report into a gate exit code (declare it in docs/ai/gates.json — by hand; never auto-seeded).
|
|
9
|
+
//
|
|
10
|
+
// Normative `--check` exit contract (the single home of this list — SKILL.md points here):
|
|
11
|
+
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
|
|
12
|
+
// i.e. no reviewer backend is ready); when no plan is in flight (docs/plans/ holds no
|
|
13
|
+
// top-level .md that is not queue.md and not scratch by the naming convention: prefixes
|
|
14
|
+
// EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
|
|
15
|
+
// clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
|
|
16
|
+
// and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
|
|
17
|
+
// grounding (fresh:true, artifact "code", grounded:true).
|
|
18
|
+
// exit 1 when a recipe-named backend has no current-fingerprint receipt — including the
|
|
19
|
+
// stale-after-edit case (any tracked/untracked change after the review moves the
|
|
20
|
+
// fingerprint) — or when its only current receipts carry grounded:false (an ungrounded
|
|
21
|
+
// agy review under reviewed/council never satisfies the gate).
|
|
22
|
+
// Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
|
|
23
|
+
// (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
|
|
24
|
+
// a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
|
|
25
|
+
//
|
|
26
|
+
// The fingerprint is the ONE canonical uncommitted-state identity — sha256 over: staged diff +
|
|
27
|
+
// unstaged diff + untracked-not-ignored file contents (binary/symlink/non-regular untracked paths
|
|
28
|
+
// ride as name-only notes). Domain == the review-payload domain the wrappers assemble; the prose
|
|
29
|
+
// definition lives in each bridge's capability.json roles.review.contract.receipt, and the bash
|
|
30
|
+
// twin lives in both wrappers — cross-checked by test/review-fingerprint-parity.test.mjs.
|
|
31
|
+
//
|
|
32
|
+
// HUMAN residual (accepted, documented): `git commit --no-verify` skips any pre-commit gate, and
|
|
33
|
+
// deleting/editing the receipt file forges state — receipts live in the git dir (never committable)
|
|
34
|
+
// as an honest self-discipline mechanism, not a security boundary.
|
|
35
|
+
//
|
|
36
|
+
// Read-only: never writes, never commits, never runs a subscription CLI. It DOES spawn `git`
|
|
37
|
+
// (read-only queries) to compute the fingerprint — stated honestly in the catalog. Dependency-free,
|
|
38
|
+
// Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
39
|
+
|
|
40
|
+
import { readFileSync, readdirSync, lstatSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
import { pathToFileURL } from 'node:url';
|
|
43
|
+
import { spawnSync } from 'node:child_process';
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
46
|
+
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
47
|
+
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
48
|
+
|
|
49
|
+
export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
|
|
50
|
+
export const PLANS_REL = 'docs/plans';
|
|
51
|
+
const ACTIVITY = 'plan-execution';
|
|
52
|
+
const SLOT = 'review';
|
|
53
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
|
|
54
|
+
|
|
55
|
+
// ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
|
|
56
|
+
|
|
57
|
+
const gitRaw = (args, cwd) =>
|
|
58
|
+
spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
59
|
+
|
|
60
|
+
// stdout Buffer of a git query, or null when git fails (not a repo / git absent).
|
|
61
|
+
const gitBuf = (args, cwd) => {
|
|
62
|
+
const r = gitRaw(args, cwd);
|
|
63
|
+
if (r.error || r.status !== 0) return null;
|
|
64
|
+
return r.stdout;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const gitLine = (args, cwd) => {
|
|
68
|
+
const buf = gitBuf(args, cwd);
|
|
69
|
+
return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── the canonical fingerprint (node twin of the wrappers' bash implementation) ──────
|
|
73
|
+
|
|
74
|
+
// First 8 KiB contain a NUL byte → binary (git's own heuristic; mirrors the wrappers' is_binary).
|
|
75
|
+
const isBinaryFile = (path) => {
|
|
76
|
+
let fd;
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(path, 'r');
|
|
79
|
+
const buf = Buffer.alloc(8192);
|
|
80
|
+
const n = readSync(fd, buf, 0, 8192, 0);
|
|
81
|
+
return buf.subarray(0, n).includes(0);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
} finally {
|
|
85
|
+
if (fd !== undefined) closeSync(fd);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
|
|
90
|
+
// byte-identical to the wrappers' emit_fingerprint_payload (same git invocations, same headers,
|
|
91
|
+
// same ls-files ordering), emitted from the work-tree ROOT. Returns null outside a git work tree.
|
|
92
|
+
export const computeFingerprintPayload = (cwd) => {
|
|
93
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
94
|
+
if (top == null) return null;
|
|
95
|
+
const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
|
|
96
|
+
const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
|
|
97
|
+
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
98
|
+
if (staged == null || unstaged == null || untrackedZ == null) return null;
|
|
99
|
+
const chunks = [staged, unstaged];
|
|
100
|
+
for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
|
|
101
|
+
const full = join(top, rel);
|
|
102
|
+
let stat = null;
|
|
103
|
+
try {
|
|
104
|
+
stat = lstatSync(full);
|
|
105
|
+
} catch {
|
|
106
|
+
stat = null;
|
|
107
|
+
}
|
|
108
|
+
if (stat?.isSymbolicLink()) {
|
|
109
|
+
let target = '?';
|
|
110
|
+
try {
|
|
111
|
+
target = readlinkSync(full);
|
|
112
|
+
} catch {
|
|
113
|
+
target = '?';
|
|
114
|
+
}
|
|
115
|
+
chunks.push(Buffer.from(`untracked-symlink:${rel} -> ${target}\n`));
|
|
116
|
+
} else if (!stat?.isFile()) {
|
|
117
|
+
chunks.push(Buffer.from(`untracked-nonregular:${rel}\n`));
|
|
118
|
+
} else if (isBinaryFile(full)) {
|
|
119
|
+
chunks.push(Buffer.from(`untracked-binary:${rel}\n`));
|
|
120
|
+
} else {
|
|
121
|
+
chunks.push(Buffer.from(`untracked:${rel}\n`));
|
|
122
|
+
chunks.push(readFileSync(full));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Buffer.concat(chunks);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// sha256 hex of the canonical payload, or null outside a git work tree.
|
|
129
|
+
export const computeTreeFingerprint = (cwd) => {
|
|
130
|
+
const payload = computeFingerprintPayload(cwd);
|
|
131
|
+
return payload == null ? null : createHash('sha256').update(payload).digest('hex');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Clean = nothing staged, nothing unstaged, no untracked-not-ignored paths (the wrappers' no-diff
|
|
135
|
+
// preflight). null when not decidable (not a git work tree). Anchored at the work-tree ROOT like
|
|
136
|
+
// the fingerprint: `git ls-files --others` is cwd-SCOPED, so a subdirectory invocation would
|
|
137
|
+
// otherwise miss root/sibling untracked paths and report a dirty tree as clean (codex R1 finding).
|
|
138
|
+
export const isTreeClean = (cwd) => {
|
|
139
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
140
|
+
if (top == null) return null;
|
|
141
|
+
const staged = gitRaw(['diff', '--cached', '--quiet'], top);
|
|
142
|
+
const unstaged = gitRaw(['diff', '--quiet'], top);
|
|
143
|
+
if (staged.error || unstaged.error || staged.status > 1 || unstaged.status > 1) return null;
|
|
144
|
+
const untracked = gitBuf(['ls-files', '--others', '--exclude-standard'], top);
|
|
145
|
+
if (untracked == null) return null;
|
|
146
|
+
return staged.status === 0 && unstaged.status === 0 && untracked.toString('utf8').trim() === '';
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── plan-in-flight detector (the AD-038 naming convention; documented in queue.md) ─────
|
|
150
|
+
|
|
151
|
+
// Scratch by the naming convention: EXECUTE-/FEEDBACK- prefixes, or a name carrying PROMPT/prompt/
|
|
152
|
+
// handoff. queue.md is the series index, never a plan.
|
|
153
|
+
export const isScratchPlanName = (name) =>
|
|
154
|
+
name === 'queue.md' ||
|
|
155
|
+
name.startsWith('EXECUTE-') ||
|
|
156
|
+
name.startsWith('FEEDBACK-') ||
|
|
157
|
+
name.includes('PROMPT') ||
|
|
158
|
+
name.includes('prompt') ||
|
|
159
|
+
name.includes('handoff');
|
|
160
|
+
|
|
161
|
+
// The in-flight plan files: top-level docs/plans/*.md minus queue.md minus scratch. [] when the
|
|
162
|
+
// directory is absent (no plans → nothing in flight).
|
|
163
|
+
export const plansInFlight = (cwd, readdir = readdirSync) => {
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdir(join(cwd, PLANS_REL), { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
return entries
|
|
171
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md') && !isScratchPlanName(e.name))
|
|
172
|
+
.map((e) => e.name)
|
|
173
|
+
.sort();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ── receipts ───────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export const resolveReceiptsPath = (cwd, env = process.env) => {
|
|
179
|
+
if (env.AW_REVIEW_RECEIPTS) return env.AW_REVIEW_RECEIPTS;
|
|
180
|
+
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
181
|
+
return gitDir == null ? null : join(gitDir, RECEIPTS_BASENAME);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Parse the receipt file → { receipts, malformed }. Absent file → empty (not an error: no review
|
|
185
|
+
// ever ran). A malformed line is counted + reported, never silently dropped.
|
|
186
|
+
export const readReceipts = (path, readFile = readFileSync) => {
|
|
187
|
+
let raw;
|
|
188
|
+
try {
|
|
189
|
+
raw = readFile(path, 'utf8');
|
|
190
|
+
} catch {
|
|
191
|
+
return { receipts: [], malformed: 0 };
|
|
192
|
+
}
|
|
193
|
+
const receipts = [];
|
|
194
|
+
let malformed = 0;
|
|
195
|
+
for (const line of raw.split('\n')) {
|
|
196
|
+
if (line.trim() === '') continue;
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(line);
|
|
199
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.backend === 'string') receipts.push(parsed);
|
|
200
|
+
else malformed += 1;
|
|
201
|
+
} catch {
|
|
202
|
+
malformed += 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { receipts, malformed };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// A receipt that can satisfy the tree check: a FRESH code-mode receipt for the current fingerprint.
|
|
209
|
+
// Plan/diff-mode receipts and continuations are informational-only (see the header contract).
|
|
210
|
+
const satisfies = (receipt, fingerprint) =>
|
|
211
|
+
receipt.fresh === true && receipt.artifact === 'code' && receipt.fingerprint === fingerprint;
|
|
212
|
+
|
|
213
|
+
// Per-backend receipt status for the current fingerprint:
|
|
214
|
+
// current — a satisfying receipt with grounded:true exists (its latest verdict reported);
|
|
215
|
+
// ungrounded — current-fingerprint fresh receipts exist but every one carries grounded:false;
|
|
216
|
+
// stale — this backend has receipts, none for the current fingerprint (edited after review);
|
|
217
|
+
// missing — no usable receipt from this backend at all.
|
|
218
|
+
export const backendReceiptStatus = (receipts, backend, fingerprint) => {
|
|
219
|
+
const own = receipts.filter((r) => r.backend === backend);
|
|
220
|
+
const current = own.filter((r) => satisfies(r, fingerprint));
|
|
221
|
+
const grounded = current.filter((r) => r.grounded === true);
|
|
222
|
+
if (grounded.length > 0) {
|
|
223
|
+
const latest = grounded[grounded.length - 1];
|
|
224
|
+
return { state: 'current', verdict: latest.verdict ?? 'unknown', grounded: true, timestamp: latest.timestamp ?? null };
|
|
225
|
+
}
|
|
226
|
+
if (current.length > 0) {
|
|
227
|
+
const latest = current[current.length - 1];
|
|
228
|
+
return { state: 'ungrounded', verdict: latest.verdict ?? 'unknown', grounded: false, timestamp: latest.timestamp ?? null };
|
|
229
|
+
}
|
|
230
|
+
return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// ── the check + report core ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
// buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
|
|
236
|
+
// EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
|
|
237
|
+
// work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
|
|
238
|
+
// must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
|
|
239
|
+
// flight" (codex R1 finding). Outside a git tree the cwd is the only anchor (and --check exits 0).
|
|
240
|
+
export const buildState = ({ cwd, env = process.env, detect = detectBackends } = {}) => {
|
|
241
|
+
const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
|
|
242
|
+
const { config, source: configSource } = loadConfig(root);
|
|
243
|
+
let detection = [];
|
|
244
|
+
let detectionWarning = null;
|
|
245
|
+
try {
|
|
246
|
+
detection = detect();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
detectionWarning = `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; the review recipe floors at solo.`;
|
|
249
|
+
}
|
|
250
|
+
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
|
|
251
|
+
const { dispatch } = planRecipe(resolved.recipe, detection);
|
|
252
|
+
const requiredBackends = dispatch.map((d) => DISPLAY_ALIASES[d.backend] ?? d.backend);
|
|
253
|
+
const plans = plansInFlight(root);
|
|
254
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
255
|
+
const clean = fingerprint == null ? null : isTreeClean(cwd);
|
|
256
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
257
|
+
const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
258
|
+
const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
|
|
259
|
+
return {
|
|
260
|
+
resolved,
|
|
261
|
+
configSource,
|
|
262
|
+
requiredBackends,
|
|
263
|
+
backends,
|
|
264
|
+
plans,
|
|
265
|
+
fingerprint,
|
|
266
|
+
clean,
|
|
267
|
+
receiptsPath,
|
|
268
|
+
receiptCount: receipts.length,
|
|
269
|
+
malformed,
|
|
270
|
+
detectionWarning,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// The normative --check decision (the header contract, in order). → { code, reason }.
|
|
275
|
+
export const decideCheck = (state) => {
|
|
276
|
+
// A DETECTOR FAILURE is unknown state, not "no reviewer ready" — the advisory tools warn and
|
|
277
|
+
// floor at solo, but a GATE must fail closed (codex R2+R3 findings): a broken detector would
|
|
278
|
+
// otherwise disable the receipt requirement both for a configured non-solo recipe AND for a
|
|
279
|
+
// default-config project (whose computed default would be `reviewed` had a backend been ready —
|
|
280
|
+
// unknowable while the detector is down). The ONLY detector-independent green is an EXPLICIT
|
|
281
|
+
// configured solo (that project asked for no reviewer, readiness is irrelevant to it).
|
|
282
|
+
const explicitSolo = state.resolved.recipe === 'solo' && state.resolved.source === 'config' && !state.resolved.degradedFrom;
|
|
283
|
+
if (state.detectionWarning && !explicitSolo) {
|
|
284
|
+
return { code: 1, reason: `cannot verify receipts — ${state.detectionWarning}` };
|
|
285
|
+
}
|
|
286
|
+
if (state.resolved.recipe === 'solo') {
|
|
287
|
+
const why = state.resolved.degradedFrom
|
|
288
|
+
? `resolved ${ACTIVITY}.${SLOT} recipe degrades to solo here (${state.resolved.reason})`
|
|
289
|
+
: `resolved ${ACTIVITY}.${SLOT} recipe is solo`;
|
|
290
|
+
return { code: 0, reason: `${why} — no receipt required` };
|
|
291
|
+
}
|
|
292
|
+
if (state.plans.length === 0) return { code: 0, reason: 'no plan in flight (docs/plans/ holds no active plan) — no receipt required' };
|
|
293
|
+
if (state.fingerprint == null) return { code: 0, reason: 'not a git work tree — nothing to fingerprint' };
|
|
294
|
+
if (state.clean === true) return { code: 0, reason: 'the working tree is clean — nothing to review' };
|
|
295
|
+
// A malformed receipt line is never silently ignored (No-silent-failures Hard Constraint): it
|
|
296
|
+
// cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
|
|
297
|
+
// line always names it so a PASS over a partially-corrupt file is visible.
|
|
298
|
+
const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
|
|
299
|
+
const failing = state.backends.filter((b) => b.state !== 'current');
|
|
300
|
+
if (failing.length === 0) {
|
|
301
|
+
return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}` };
|
|
302
|
+
}
|
|
303
|
+
const parts = failing.map((b) => {
|
|
304
|
+
if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
|
|
305
|
+
if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
|
|
306
|
+
return `${b.backend}: no receipt — run its review wrapper`;
|
|
307
|
+
});
|
|
308
|
+
return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// ── rendering ───────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
const STATE_GLYPH = { current: '✓', ungrounded: '✗', stale: '✗', missing: '✗' };
|
|
314
|
+
|
|
315
|
+
const formatHuman = (state, check) => {
|
|
316
|
+
const src = state.resolved.source === 'config' ? `from ${CONFIG_REL}` : 'computed default';
|
|
317
|
+
const lines = [
|
|
318
|
+
`review-state — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
|
|
319
|
+
];
|
|
320
|
+
if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
|
|
321
|
+
lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
|
|
322
|
+
if (state.fingerprint == null) lines.push(' tree: not a git work tree');
|
|
323
|
+
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
324
|
+
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
325
|
+
lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
|
|
326
|
+
for (const b of state.backends) {
|
|
327
|
+
const detail =
|
|
328
|
+
b.state === 'current'
|
|
329
|
+
? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
|
|
330
|
+
: b.state === 'ungrounded'
|
|
331
|
+
? `ungrounded for the current tree (verdict: ${b.verdict}) — a grounded fresh run is required`
|
|
332
|
+
: b.state === 'stale'
|
|
333
|
+
? 'stale — no receipt matches the current tree (edited after review)'
|
|
334
|
+
: 'missing — no receipt from this backend';
|
|
335
|
+
lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
|
|
336
|
+
}
|
|
337
|
+
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
338
|
+
return lines.join('\n');
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const HELP = `review-state — read-only review-receipt checker (agent-workflow family, AD-038).
|
|
342
|
+
|
|
343
|
+
Usage:
|
|
344
|
+
node review-state.mjs [--check] [--json]
|
|
345
|
+
|
|
346
|
+
Resolves the effective ${ACTIVITY}.${SLOT} recipe (${CONFIG_REL} + the read-only backend detector),
|
|
347
|
+
recomputes the canonical uncommitted-state fingerprint (staged + unstaged + untracked-not-ignored —
|
|
348
|
+
the review-payload domain), reads the receipt file the review wrappers append to
|
|
349
|
+
(<git dir>/${RECEIPTS_BASENAME}; AW_REVIEW_RECEIPTS overrides), and reports per-backend receipt
|
|
350
|
+
presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and continuations
|
|
351
|
+
(fresh:false) are informational-only — they never satisfy the tree check.
|
|
352
|
+
|
|
353
|
+
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
354
|
+
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
|
|
355
|
+
recipe-named backend is missing, stale (edited after review), or grounded:false under
|
|
356
|
+
reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) — never auto-seeded.
|
|
357
|
+
|
|
358
|
+
Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
|
|
359
|
+
Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a
|
|
360
|
+
self-discipline mechanism, not a security boundary.
|
|
361
|
+
|
|
362
|
+
Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
|
|
363
|
+
|
|
364
|
+
const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--json']);
|
|
365
|
+
|
|
366
|
+
export const main = (argv, ctx = {}) => {
|
|
367
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
368
|
+
const env = ctx.env ?? process.env;
|
|
369
|
+
const detect = ctx.detect ?? detectBackends;
|
|
370
|
+
try {
|
|
371
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
372
|
+
const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
|
|
373
|
+
if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
|
|
374
|
+
const state = buildState({ cwd, env, detect });
|
|
375
|
+
const check = decideCheck(state);
|
|
376
|
+
if (argv.includes('--json')) {
|
|
377
|
+
return { code: argv.includes('--check') ? check.code : 0, stdout: JSON.stringify({ ...state, check }, null, 2), stderr: '' };
|
|
378
|
+
}
|
|
379
|
+
if (argv.includes('--check')) {
|
|
380
|
+
return { code: check.code, stdout: `review-state check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`, stderr: '' };
|
|
381
|
+
}
|
|
382
|
+
return { code: 0, stdout: formatHuman(state, check), stderr: '' };
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
389
|
+
if (isDirectRun) {
|
|
390
|
+
const r = main(process.argv.slice(2));
|
|
391
|
+
// Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio (codex R2).
|
|
392
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
393
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
394
|
+
process.exitCode = r.code;
|
|
395
|
+
}
|