From 464fd51049eebd130bc89179af143ccee353b42f Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:05:52 +0800 Subject: [PATCH 01/12] fix: bound and validate offline replay input before parsing --- README.md | 2 ++ bin/aas.mjs | 50 +++++++++++++++++++++++++++++---------------- test/stack.test.mjs | 18 ++++++++++++++++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0214ab5..444e44b 100644 --- a/README.md +++ b/README.md @@ -280,3 +280,5 @@ The export budget counts the formatted JSON bytes actually downloaded, including Saved-file reads use nonblocking descriptors and validate regular-file type and size before reading, so a named pipe cannot hold the reader open. Invalid UTF-8 is rejected rather than silently replacing evidence bytes. History summaries and run listings use the same bounded saved-file reader, including when a malformed entry is skipped as unavailable. + +Replay checks actual UTF-8 bytes for both files and pipes, stops oversized streams as soon as the budget is crossed, and rejects nonregular files, invalid encoding, and malformed JSON without echoing document contents. diff --git a/bin/aas.mjs b/bin/aas.mjs index ad547f7..103f876 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1685,30 +1685,44 @@ function writeCliError(error, { asJson = false, usage = false } = {}) { } async function readReplayInput(source, { stdin = process.stdin } = {}) { + let bytes; if (source === "-") { if (stdin.isTTY) throw new UsageError("replay reads stdin only from a pipe; pass a bundle file instead"); const chunks = []; + let size = 0; for await (const chunk of stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk); - } - const text = Buffer.concat(chunks).toString("utf8"); - if (text.length > CHILD_JSON_LIMIT) { - throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); + const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + size += buffer.length; + if (size > CHILD_JSON_LIMIT) throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); + chunks.push(buffer); } - if (text.trim() === "") throw new Error("replay received an empty bundle document"); - return text; + bytes = Buffer.concat(chunks); + } else { + let fd; + try { + fd = openSync(source, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > CHILD_JSON_LIMIT) throw new Error("replay file exceeds the byte limit or is not a regular file"); + bytes = Buffer.alloc(CHILD_JSON_LIMIT + 1); + let size = 0; + while (size < bytes.length) { + const read = readSync(fd, bytes, size, bytes.length - size, null); + if (!read) break; + size += read; + } + if (size > CHILD_JSON_LIMIT) throw new Error("replay bundle exceeds the byte limit"); + bytes = bytes.subarray(0, size); + } catch (error) { + if (error.code) throw new Error("replay cannot read bundle file"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); } } let text; - try { - text = readFileSync(source, "utf8"); - } catch { - throw new Error(`replay cannot read bundle file: ${source}`); - } - if (text.length > CHILD_JSON_LIMIT) { - throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); - } - if (text.trim() === "") throw new Error("replay received an empty bundle document"); - return text; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { throw new Error("replay requires valid UTF-8"); } + if (!text.trim()) throw new Error("replay received an empty bundle document"); + try { return JSON.parse(text); } + catch { throw new Error("replay bundle contains invalid JSON"); } } function runRunsCommand(args, { asJson } = {}) { @@ -1902,7 +1916,7 @@ async function runReplayCommand(args, { asJson, nodeVersion, stdin } = {}) { assertFullStackNodeVersion(nodeVersion === undefined ? {} : { version: nodeVersion }); let bundleDoc; try { - bundleDoc = JSON.parse(await readReplayInput(source, { stdin })); + bundleDoc = await readReplayInput(source, { stdin }); } catch (error) { if (error instanceof UsageError) throw error; writeCliError(error, { asJson: json || asJson, usage: false }); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 703671b..61d8fe0 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1673,3 +1673,21 @@ test('saved manifests reject missing records arrays and invalid artifact fields assert.equal(Object.keys(exportRunBundle('complete-stages',{outputRoot}).stages).length,3); assert.equal(full.manifest.stages.prove.artifact,'stages/prove.json'); }); + +test('replay bounds UTF-8 bytes and redacts malformed JSON for files and streams', async () => { + const root = tempRoot(), path = join(root, 'replay.json'); + const sources = [Buffer.from(JSON.stringify({pad: 'é'.repeat(CHILD_JSON_LIMIT / 2)})), Buffer.from('{"SYNTHETIC_PARSE_MARKER":invalid}'), Buffer.from([0xff])]; + for (const input of sources) { + writeFileSync(path, input); + for (const source of [path, '-']) { + const result = await captureMain(['replay', source, '--json'], {stdin: Readable.from([input])}); + assert.equal(result.exitCode, 1); + assert.doesNotMatch(result.stderr, /SYNTHETIC_PARSE_MARKER/); + assert.match(result.stderr, input.length > CHILD_JSON_LIMIT ? /byte limit/ : /invalid JSON|UTF-8/); + } + } + let consumed = 0; + async function* oversized() { consumed++; yield Buffer.alloc(CHILD_JSON_LIMIT + 1); consumed++; yield Buffer.from('{}'); } + const bounded = await captureMain(['replay', '-', '--json'], {stdin: oversized()}); + assert.equal(bounded.exitCode, 1); assert.equal(consumed, 1); +}); From 96027a1ed3a26ff62bdc0ed89b0b515c1a1af798 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:06:22 +0800 Subject: [PATCH 02/12] fix: preserve existing case exports unless overwrite is explicit --- README.md | 2 ++ bin/aas.mjs | 22 ++++++++++++++++------ test/stack.test.mjs | 12 ++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 444e44b..94218a1 100644 --- a/README.md +++ b/README.md @@ -282,3 +282,5 @@ Saved-file reads use nonblocking descriptors and validate regular-file type and History summaries and run listings use the same bounded saved-file reader, including when a malformed entry is skipped as unavailable. Replay checks actual UTF-8 bytes for both files and pipes, stops oversized streams as soon as the budget is crossed, and rejects nonregular files, invalid encoding, and malformed JSON without echoing document contents. + +Exporting with --out creates a new handoff atomically and refuses to replace an existing file. Pass --overwrite with --out to explicitly replace it. --json supports machine-readable export success and error output. diff --git a/bin/aas.mjs b/bin/aas.mjs index 103f876..a7aac2b 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -18,7 +18,7 @@ import { mkdtempSync, readFileSync, readdirSync, - renameSync, + renameSync, linkSync, rmSync, unlinkSync, writeFileSync, @@ -240,7 +240,7 @@ export function helpText() { Usage: aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] - aas export [--out ] + aas export [--out ] [--overwrite] [--json] aas replay [--json] aas inspect [--root output-dir] [--json|--markdown] aas cases [--before run-id] [--limit 1..50] [--json] @@ -1332,6 +1332,8 @@ export function writeAtomicFile( target, data, { + replace = true, + link = linkSync, writeFile = writeFileSync, rename = renameSync, unlink = unlinkSync, @@ -1344,7 +1346,8 @@ export function writeAtomicFile( try { writeFile(temporary, data, { encoding: "utf8", flag: "wx" }); written = true; - rename(temporary, target); + if (replace) rename(temporary, target); + else link(temporary, target); } catch (error) { throw error; } finally { @@ -1853,9 +1856,15 @@ function printReplayReport(result, asJson) { function runExportCommand(args, { asJson } = {}) { let runId = null; let out = null; + let overwrite = false; for (let index = 0; index < args.length; index += 1) { const token = args[index]; - if (token === "--out") { + if (token === "--overwrite") { + if (overwrite) throw new UsageError("Duplicate export option: --overwrite"); + overwrite = true; + } else if (token === "--json") { + continue; + } else if (token === "--out") { const value = args[index + 1]; if (value === undefined || value.startsWith("-")) throw new UsageError("Missing value for export option: --out"); if (out !== null) throw new UsageError("Duplicate export option: --out"); @@ -1871,7 +1880,8 @@ function runExportCommand(args, { asJson } = {}) { runId = token; } } - if (runId === null) throw new UsageError("Usage: aas export [--out ]"); + if (runId === null) throw new UsageError("Usage: aas export [--out ] [--overwrite] [--json]"); + if (overwrite && out === null) throw new UsageError("--overwrite requires --out"); let bundle; try { bundle = exportRunBundle(runId, {}); @@ -1887,7 +1897,7 @@ function runExportCommand(args, { asJson } = {}) { return; } try { - writeAtomicFile(out, text, {}); + writeAtomicFile(out, text, { replace: overwrite }); } catch (error) { writeCliError(error, { asJson, usage: false }); process.exitCode = 1; diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 61d8fe0..93a7063 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1691,3 +1691,15 @@ test('replay bounds UTF-8 bytes and redacts malformed JSON for files and streams const bounded = await captureMain(['replay', '-', '--json'], {stdin: oversized()}); assert.equal(bounded.exitCode, 1); assert.equal(consumed, 1); }); + +test('handoff export preserves existing files unless replacement is explicit', () => { + const root = tempRoot(), target = join(root, 'case.json'); + writeAtomicFile(target, 'original'); + assert.throws(() => writeAtomicFile(target, 'replacement', {replace: false}), /exist|EEXIST/); + assert.equal(readFileSync(target, 'utf8'), 'original'); + assert.deepEqual(readdirSync(root), ['case.json']); + writeAtomicFile(join(root, 'new.json'), 'new', {replace: false}); + assert.equal(readFileSync(join(root, 'new.json'), 'utf8'), 'new'); + writeAtomicFile(target, 'replacement', {replace: true}); + assert.equal(readFileSync(target, 'utf8'), 'replacement'); +}); From 99335b34ebda5fd1f6e82b99ec82f782b59a9388 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:07:06 +0800 Subject: [PATCH 03/12] feat: use alternate case stores across offline CLI workflows --- README.md | 2 ++ bin/aas.mjs | 47 ++++++++++++++++++++++++++++++--------------- test/stack.test.mjs | 16 +++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 94218a1..bbf743d 100644 --- a/README.md +++ b/README.md @@ -284,3 +284,5 @@ History summaries and run listings use the same bounded saved-file reader, inclu Replay checks actual UTF-8 bytes for both files and pipes, stops oversized streams as soon as the budget is crossed, and rejects nonregular files, invalid encoding, and malformed JSON without echoing document contents. Exporting with --out creates a new handoff atomically and refuses to replace an existing file. Pass --overwrite with --out to explicitly replace it. --json supports machine-readable export success and error output. + +All saved-case commands (runs, cases, compare, inspect, export, and prune) accept --root output-directory. Use a copied case store without moving it into the checkout or bootstrapping components; paths with spaces are supported when quoted. Prune still requires an explicit --keep and supports --dry-run. diff --git a/bin/aas.mjs b/bin/aas.mjs index a7aac2b..20e013f 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -263,6 +263,7 @@ Options: --prove simulate|rail Prove path: canned operator simulation (default) or review of the same-case rail bundle --domain refund|inventory Synthetic action domain (default: refund) + --root output-dir Select saved-case storage for inspect/runs/cases/compare/export/prune --json Print the run report as JSON -h, --help Show this help @@ -1728,11 +1729,11 @@ async function readReplayInput(source, { stdin = process.stdin } = {}) { catch { throw new Error("replay bundle contains invalid JSON"); } } -function runRunsCommand(args, { asJson } = {}) { +function runRunsCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { if (args.some((token) => token !== "--json")) { throw new UsageError(`Unsupported runs option (expected [--json])`); } - const runs = listRuns({}); + const runs = listRuns({ outputRoot }); if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, runs }, null, 2)}\n`); } else if (runs.length === 0) { @@ -1747,12 +1748,12 @@ function runRunsCommand(args, { asJson } = {}) { process.exitCode = 0; } -async function runCasesCommand(args, { asJson } = {}) { +async function runCasesCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { const { listCasePage, parseCasePageArgs } = await import("./case-review.mjs"); let pageOptions; try { pageOptions = parseCasePageArgs(args); } catch (error) { throw new UsageError(error.message); } if ((pageOptions.limit !== undefined && (pageOptions.limit < 1 || pageOptions.limit > 50)) || (pageOptions.before !== undefined && (!isValidRunId(pageOptions.before) || pageOptions.before.length > 200))) throw new UsageError("Invalid history page options."); - const page = listCasePage(pageOptions); + const page = listCasePage({ ...pageOptions, outputRoot }); const cases = page.cases; if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...page }, null, 2)}\n`); @@ -1791,17 +1792,17 @@ function printComparison(result, asJson) { process.exitCode = result.classification === "not-comparable" ? 1 : 0; } -function runCompareCommand(args, { asJson } = {}) { +function runCompareCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { const ids = args.filter((token) => token !== "--json"); if (ids.some((token) => typeof token === "string" && token.startsWith("-"))) { throw new UsageError(`Unsupported compare option: ${args.find((token) => String(token).startsWith("-"))}`); } if (ids.length !== 2) throw new UsageError("Usage: aas compare [--json]"); - const result = compareRuns(ids[0], ids[1], {}); + const result = compareRuns(ids[0], ids[1], { outputRoot }); printComparison(result, asJson); } -function runPruneCommand(args, { asJson } = {}) { +function runPruneCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { let keep = null; let dryRun = false; for (let index = 0; index < args.length; index += 1) { @@ -1820,7 +1821,7 @@ function runPruneCommand(args, { asJson } = {}) { } } if (keep === null) throw new UsageError("Usage: aas prune --keep [--dry-run] [--json]"); - const result = pruneRuns({ keep, dryRun }); + const result = pruneRuns({ keep, dryRun, outputRoot }); if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); } else if (dryRun) { @@ -1853,7 +1854,7 @@ function printReplayReport(result, asJson) { process.stdout.write(`${lines.join("\n")}\n`); } -function runExportCommand(args, { asJson } = {}) { +function runExportCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { let runId = null; let out = null; let overwrite = false; @@ -1884,7 +1885,7 @@ function runExportCommand(args, { asJson } = {}) { if (overwrite && out === null) throw new UsageError("--overwrite requires --out"); let bundle; try { - bundle = exportRunBundle(runId, {}); + bundle = exportRunBundle(runId, { outputRoot }); } catch (error) { writeCliError(error, { asJson, usage: false }); process.exitCode = 1; @@ -1945,6 +1946,18 @@ async function runReplayCommand(args, { asJson, nodeVersion, stdin } = {}) { process.exitCode = result.ok ? 0 : 1; } +function parseRootArgs(args) { + const remaining = []; + let outputRoot = DEFAULT_PATHS.outputRoot, seen = false; + for (let index = 0; index < args.length; index++) { + if (args[index] !== "--root") { remaining.push(args[index]); continue; } + if (seen || !args[index + 1]?.trim() || args[index + 1].startsWith("--")) throw new UsageError("--root requires one output directory path"); + seen = true; + outputRoot = args[++index]; + } + return { args: remaining, outputRoot }; +} + export async function main(argv = process.argv.slice(2), options = {}) { const command = argv[0] ?? "help"; const asJson = has(argv, "--json"); @@ -1968,10 +1981,11 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (command === "runs" || command === "cases" || command === "compare" || command === "prune") { try { - if (command === "runs") runRunsCommand(argv.slice(1), { asJson }); - else if (command === "cases") await runCasesCommand(argv.slice(1), { asJson }); - else if (command === "compare") runCompareCommand(argv.slice(1), { asJson }); - else runPruneCommand(argv.slice(1), { asJson }); + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + if (command === "runs") runRunsCommand(args, { asJson, outputRoot }); + else if (command === "cases") await runCasesCommand(args, { asJson, outputRoot }); + else if (command === "compare") runCompareCommand(args, { asJson, outputRoot }); + else runPruneCommand(args, { asJson, outputRoot }); } catch (error) { const usage = error instanceof UsageError; writeCliError(error, { asJson, usage }); @@ -1981,7 +1995,10 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (command === "export" || command === "replay") { try { - if (command === "export") runExportCommand(argv.slice(1), { asJson }); + if (command === "export") { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + runExportCommand(args, { asJson, outputRoot }); + } else { await runReplayCommand(argv.slice(1), { asJson, diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 93a7063..3fe4277 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1703,3 +1703,19 @@ test('handoff export preserves existing files unless replacement is explicit', ( writeAtomicFile(target, 'replacement', {replace: true}); assert.equal(readFileSync(target, 'utf8'), 'replacement'); }); + +test('saved-case commands use an explicit output root without component setup', async () => { + const outputRoot = tempRoot(); writeCase(outputRoot, 'root-a'); writeCase(outputRoot, 'root-b'); + for (const command of [['runs'], ['cases'], ['compare', 'root-a', 'root-b'], ['export', 'root-a'], ['prune', '--keep', '1', '--dry-run']]) { + const result = await captureMain([...command, '--root', outputRoot, '--json']); + assert.equal(result.exitCode, 0, result.stderr); + assert.ok(JSON.parse(result.stdout)); + } + const target = join(outputRoot, 'handoff.json'); + assert.equal((await captureMain(['export','root-a','--root',outputRoot,'--out',target,'--json'])).exitCode, 0); + assert.equal((await captureMain(['export','root-b','--root',outputRoot,'--out',target,'--json'])).exitCode, 1); + assert.equal(JSON.parse(readFileSync(target,'utf8')).report.run_id, 'root-a'); + for (const args of [['--root'], ['--root',''], ['--root',outputRoot,'--root',outputRoot]]) { + assert.equal((await captureMain(['cases',...args,'--json'])).exitCode, 2); + } +}); From afbc7961d2e1d7559d096b96b2cf1150d92988f0 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:07:27 +0800 Subject: [PATCH 04/12] test: exercise alternate roots with complete saved bundles --- test/stack.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 3fe4277..4046c25 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1705,7 +1705,8 @@ test('handoff export preserves existing files unless replacement is explicit', ( }); test('saved-case commands use an explicit output root without component setup', async () => { - const outputRoot = tempRoot(); writeCase(outputRoot, 'root-a'); writeCase(outputRoot, 'root-b'); + const outputRoot = tempRoot(); + for (const runId of ['root-a', 'root-b']) await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); for (const command of [['runs'], ['cases'], ['compare', 'root-a', 'root-b'], ['export', 'root-a'], ['prune', '--keep', '1', '--dry-run']]) { const result = await captureMain([...command, '--root', outputRoot, '--json']); assert.equal(result.exitCode, 0, result.stderr); From 43d3dc04b720ac213dbc9bc44c6cd95a93c88f45 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:07:54 +0800 Subject: [PATCH 05/12] feat: expose history gaps and continuation in terminal output --- README.md | 2 ++ bin/aas.mjs | 8 +++++++- test/stack.test.mjs | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bbf743d..7b98faa 100644 --- a/README.md +++ b/README.md @@ -286,3 +286,5 @@ Replay checks actual UTF-8 bytes for both files and pipes, stops oversized strea Exporting with --out creates a new handoff atomically and refuses to replace an existing file. Pass --overwrite with --out to explicitly replace it. --json supports machine-readable export success and error output. All saved-case commands (runs, cases, compare, inspect, export, and prune) accept --root output-directory. Use a copied case store without moving it into the checkout or bootstrapping components; paths with spaces are supported when quoted. Prune still requires an explicit --keep and supports --dry-run. + +Human-readable cases output includes scanned count, each unavailable case ID, and next_cursor. An entirely damaged page still gives its continuation cursor, so older readable cases remain reachable. Continue with --before and the same root/page options. diff --git a/bin/aas.mjs b/bin/aas.mjs index 20e013f..f64ee3f 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1758,7 +1758,7 @@ async function runCasesCommand(args, { asJson, outputRoot = DEFAULT_PATHS.output if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...page }, null, 2)}\n`); } else if (cases.length === 0) { - process.stdout.write("no cases yet\n"); + process.stdout.write(page.scanned ? "no readable cases in this page\n" : "no cases in this page\n"); } else { for (const entry of cases) { process.stdout.write( @@ -1767,6 +1767,12 @@ async function runCasesCommand(args, { asJson, outputRoot = DEFAULT_PATHS.output ); } } + if (!asJson) { + process.stdout.write(`scanned: ${page.scanned}\n`); + for (const runId of page.unavailable) process.stdout.write(`unavailable: ${runId}\n`); + process.stdout.write(`next_cursor: ${page.next_cursor ?? "none"}\n`); + if (page.next_cursor) process.stdout.write(`Continue with --before ${page.next_cursor} and the same root and page options.\n`); + } process.exitCode = 0; } diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 4046c25..cd15134 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1720,3 +1720,13 @@ test('saved-case commands use an explicit output root without component setup', assert.equal((await captureMain(['cases',...args,'--json'])).exitCode, 2); } }); + +test('human history reports damaged entries and continuation even on empty pages', async () => { + const outputRoot = tempRoot(); writeCase(outputRoot, 'case-a'); + mkdirSync(join(outputRoot, 'runs', 'case-z'), {recursive:true}); + const first = await captureMain(['cases','--root',outputRoot,'--limit','1']); + assert.equal(first.exitCode, 0); assert.doesNotMatch(first.stdout, /no cases yet/); + assert.match(first.stdout, /unavailable: case-z/); assert.match(first.stdout, /next_cursor: case-z/); + const next = await captureMain(['cases','--root',outputRoot,'--limit','1','--before','case-z']); + assert.match(next.stdout, /case-a outcome=/); assert.match(next.stdout, /next_cursor: none/); +}); From 5872767a210062eac293db8199ca032c1f66874f Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:08:35 +0800 Subject: [PATCH 06/12] feat: export CLI comparison reviews with reliable failure status --- README.md | 2 ++ bin/aas.mjs | 21 ++++++++++++++------- test/stack.test.mjs | 13 +++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7b98faa..0c3a8a0 100644 --- a/README.md +++ b/README.md @@ -288,3 +288,5 @@ Exporting with --out creates a new handoff atomically and refuses to replace an All saved-case commands (runs, cases, compare, inspect, export, and prune) accept --root output-directory. Use a copied case store without moving it into the checkout or bootstrapping components; paths with spaces are supported when quoted. Prune still requires an explicit --keep and supports --dry-run. Human-readable cases output includes scanned count, each unavailable case ID, and next_cursor. An entirely damaged page still gives its continuation cursor, so older readable cases remain reachable. Continue with --before and the same root/page options. + +Use aas compare left-id right-id --root output-directory --markdown to print the GUI comparison handoff from the terminal. All formats return exit 1 when cases cannot be compared; JSON also sets ok to false. Comparable differences remain exit 0 and never establish causation. diff --git a/bin/aas.mjs b/bin/aas.mjs index f64ee3f..05b1456 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -244,7 +244,7 @@ Usage: aas replay [--json] aas inspect [--root output-dir] [--json|--markdown] aas cases [--before run-id] [--limit 1..50] [--json] - aas compare [--json] + aas compare [--root output-dir] [--json|--markdown] aas help Commands: @@ -1777,8 +1777,9 @@ async function runCasesCommand(args, { asJson, outputRoot = DEFAULT_PATHS.output } function printComparison(result, asJson) { + process.exitCode = result.classification === "not-comparable" ? 1 : 0; if (asJson) { - process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: result.classification !== "not-comparable", ...result }, null, 2)}\n`); return; } process.stdout.write(`comparison: ${result.classification}\n`); @@ -1798,14 +1799,20 @@ function printComparison(result, asJson) { process.exitCode = result.classification === "not-comparable" ? 1 : 0; } -function runCompareCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { - const ids = args.filter((token) => token !== "--json"); +async function runCompareCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { + const formats = args.filter(token => token === "--json" || token === "--markdown"); + if (formats.length > 1) throw new UsageError("Choose one comparison output format"); + const ids = args.filter((token) => token !== "--json" && token !== "--markdown"); if (ids.some((token) => typeof token === "string" && token.startsWith("-"))) { throw new UsageError(`Unsupported compare option: ${args.find((token) => String(token).startsWith("-"))}`); } - if (ids.length !== 2) throw new UsageError("Usage: aas compare [--json]"); + if (ids.length !== 2) throw new UsageError("Usage: aas compare [--root output-dir] [--json|--markdown]"); const result = compareRuns(ids[0], ids[1], { outputRoot }); - printComparison(result, asJson); + if (formats[0] === "--markdown") { + const { renderComparisonMarkdown } = await import("./case-review.mjs"); + process.stdout.write(renderComparisonMarkdown(result)); + process.exitCode = result.classification === "not-comparable" ? 1 : 0; + } else printComparison(result, asJson); } function runPruneCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { @@ -1990,7 +1997,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { const { args, outputRoot } = parseRootArgs(argv.slice(1)); if (command === "runs") runRunsCommand(args, { asJson, outputRoot }); else if (command === "cases") await runCasesCommand(args, { asJson, outputRoot }); - else if (command === "compare") runCompareCommand(args, { asJson, outputRoot }); + else if (command === "compare") await runCompareCommand(args, { asJson, outputRoot }); else runPruneCommand(args, { asJson, outputRoot }); } catch (error) { const usage = error instanceof UsageError; diff --git a/test/stack.test.mjs b/test/stack.test.mjs index cd15134..b50ea51 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1730,3 +1730,16 @@ test('human history reports damaged entries and continuation even on empty pages const next = await captureMain(['cases','--root',outputRoot,'--limit','1','--before','case-z']); assert.match(next.stdout, /case-a outcome=/); assert.match(next.stdout, /next_cursor: none/); }); + +test('comparison handoffs support Markdown and fail machine callers on unavailable cases', async () => { + const outputRoot = tempRoot(); writeCase(outputRoot, 'compare-a'); writeCase(outputRoot, 'compare-b'); + const good = await captureMain(['compare','compare-a','compare-b','--root',outputRoot,'--markdown']); + assert.equal(good.exitCode, 0, good.stderr); assert.match(good.stdout, /# Saved case comparison/); + assert.match(good.stdout, /do not establish causation/); + for (const format of ['--json','--markdown']) { + const bad = await captureMain(['compare','compare-a','missing','--root',outputRoot,format]); + assert.equal(bad.exitCode, 1); + if (format === '--json') assert.equal(JSON.parse(bad.stdout).ok, false); + } + assert.equal((await captureMain(['compare','compare-a','compare-b','--markdown','--json'])).exitCode, 2); +}); From eb7351759a30234699af47acb44637007723fabb Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:09:10 +0800 Subject: [PATCH 07/12] feat: verify saved cases directly from the offline CLI --- README.md | 2 ++ bin/aas.mjs | 17 +++++++++++++++++ test/stack.test.mjs | 13 +++++++++++++ 3 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 0c3a8a0..1acc940 100644 --- a/README.md +++ b/README.md @@ -290,3 +290,5 @@ All saved-case commands (runs, cases, compare, inspect, export, and prune) accep Human-readable cases output includes scanned count, each unavailable case ID, and next_cursor. An entirely damaged page still gives its continuation cursor, so older readable cases remain reachable. Continue with --before and the same root/page options. Use aas compare left-id right-id --root output-directory --markdown to print the GUI comparison handoff from the terminal. All formats return exit 1 when cases cannot be compared; JSON also sets ok to false. Comparable differences remain exit 0 and never establish causation. + +Use aas verify run-id --root output-directory --json to verify a saved case directly. It performs the same receipt and review checks as imported replay, with the same pinned component requirements, and never executes an action or modifies the case store. Refused or simulation-only cases without same-case evidence return unavailable and exit 1. diff --git a/bin/aas.mjs b/bin/aas.mjs index 05b1456..44fb0b5 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -242,6 +242,7 @@ Usage: aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] aas export [--out ] [--overwrite] [--json] aas replay [--json] + aas verify [--root output-dir] [--json] aas inspect [--root output-dir] [--json|--markdown] aas cases [--before run-id] [--limit 1..50] [--json] aas compare [--root output-dir] [--json|--markdown] @@ -251,6 +252,7 @@ Commands: demo Run decide, act, and prove and persist one run bundle export Print one run bundle as portable JSON (or write it with --out) replay Re-verify an exported bundle offline without rerunning the action + verify Re-verify one saved case without exporting or rerunning actions runs List persisted runs newest-first cases List bounded case summaries (outcome, policy, review, digest) compare Compare two cases and classify identical, different, or not comparable @@ -1979,6 +1981,21 @@ export async function main(argv = process.argv.slice(2), options = {}) { process.exitCode = 0; return; } + if (command === "verify") { + try { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + const ids = args.filter(token => token !== "--json"); + if (ids.length !== 1 || !isValidRunId(ids[0]) || args.filter(token => token === "--json").length > 1) throw new UsageError("Usage: aas verify [--root output-dir] [--json]"); + assertFullStackNodeVersion(options.nodeVersion === undefined ? {} : {version:options.nodeVersion}); + const result = replayBundle(exportRunBundle(ids[0], {outputRoot})); + printReplayReport(result, asJson); + process.exitCode = result.ok ? 0 : 1; + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, {asJson, usage}); process.exitCode = usage ? 2 : 1; + } + return; + } if (command === "inspect") { try { const { parseInspectArgs, inspectCase, renderCaseMarkdown } = await import("./case-review.mjs"); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index b50ea51..ed4d0a7 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1743,3 +1743,16 @@ test('comparison handoffs support Markdown and fail machine callers on unavailab } assert.equal((await captureMain(['compare','compare-a','compare-b','--markdown','--json'])).exitCode, 2); }); + +test('verify reads a saved case without executing actions or changing its files', async () => { + const outputRoot=tempRoot(), runId='verify-refused'; + const result=await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + const before=readFileSync(join(result.bundleDir,'manifest.json'),'utf8'); + const verified=await captureMain(['verify',runId,'--root',outputRoot,'--json']); + assert.equal(verified.exitCode,1); const report=JSON.parse(verified.stdout); + assert.equal(report.ok,false); assert.match(report.reason,/unavailable/); + assert.equal(readFileSync(join(result.bundleDir,'manifest.json'),'utf8'),before); + assert.deepEqual(readdirSync(join(outputRoot,'runs')),[runId]); + assert.equal((await captureMain(['verify',runId,'--root',outputRoot,'--json'],{nodeVersion:'20.19.0'})).exitCode,1); + for(const args of [[],['a','b'],['a','--markdown']]) assert.equal((await captureMain(['verify',...args])).exitCode,2); +}); From 81d1b9b0891b4837d54e488166c044cf9f024e27 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:09:56 +0800 Subject: [PATCH 08/12] feat: resolve the latest complete saved case without shell listing --- README.md | 2 ++ bin/aas.mjs | 23 +++++++++++++++++++++-- test/stack.test.mjs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1acc940..9154557 100644 --- a/README.md +++ b/README.md @@ -292,3 +292,5 @@ Human-readable cases output includes scanned count, each unavailable case ID, an Use aas compare left-id right-id --root output-directory --markdown to print the GUI comparison handoff from the terminal. All formats return exit 1 when cases cannot be compared; JSON also sets ok to false. Comparable differences remain exit 0 and never establish causation. Use aas verify run-id --root output-directory --json to verify a saved case directly. It performs the same receipt and review checks as imported replay, with the same pinned component requirements, and never executes an action or modifies the case store. Refused or simulation-only cases without same-case evidence return unavailable and exit 1. + +Use aas latest --root output-directory to print the ID named by the latest complete-bundle pointer, or add --json for machine output. It validates the referenced saved bundle and fails closed for missing, inconsistent, or unreadable pointers/cases. It never guesses by sorting directory names. diff --git a/bin/aas.mjs b/bin/aas.mjs index 44fb0b5..52476c4 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -242,6 +242,7 @@ Usage: aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] aas export [--out ] [--overwrite] [--json] aas replay [--json] + aas latest [--root output-dir] [--json] aas verify [--root output-dir] [--json] aas inspect [--root output-dir] [--json|--markdown] aas cases [--before run-id] [--limit 1..50] [--json] @@ -253,6 +254,7 @@ Commands: export Print one run bundle as portable JSON (or write it with --out) replay Re-verify an exported bundle offline without rerunning the action verify Re-verify one saved case without exporting or rerunning actions + latest Print the latest complete saved run identity runs List persisted runs newest-first cases List bounded case summaries (outcome, policy, review, digest) compare Compare two cases and classify identical, different, or not comparable @@ -1132,9 +1134,11 @@ export function listRuns({ outputRoot = DEFAULT_PATHS.outputRoot, limit = Number function readLatestRunId(outputRoot) { try { - const pointer = JSON.parse(readFileSync(join(outputRoot, "latest.json"), "utf8")); + const pointer = readBoundedCaseJson(join(outputRoot, "latest.json")); const runId = pointer?.run_id; - return typeof runId === "string" && RUN_ID_PATTERN.test(runId) ? runId : null; + if (!isValidRunId(runId) || pointer.manifest !== `runs/${runId}/manifest.json`) return null; + if (pointer.schema_version !== undefined && pointer.schema_version !== "agent-action-stack.latest/v1") return null; + return runId; } catch { return null; } @@ -1981,6 +1985,21 @@ export async function main(argv = process.argv.slice(2), options = {}) { process.exitCode = 0; return; } + if (command === "latest") { + try { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + if (args.length > 1 || args.some(token => token !== "--json")) throw new UsageError("Usage: aas latest [--root output-dir] [--json]"); + const runId = readLatestRunId(outputRoot); + if (runId === null) throw new Error("Latest case pointer is missing or invalid"); + exportRunBundle(runId, {outputRoot}); + process.stdout.write(asJson ? JSON.stringify({ok:true,run_id:runId}) + "\n" : runId + "\n"); + process.exitCode = 0; + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, {asJson,usage}); process.exitCode = usage ? 2 : 1; + } + return; + } if (command === "verify") { try { const { args, outputRoot } = parseRootArgs(argv.slice(1)); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index ed4d0a7..f599c9d 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1756,3 +1756,16 @@ test('verify reads a saved case without executing actions or changing its files' assert.equal((await captureMain(['verify',runId,'--root',outputRoot,'--json'],{nodeVersion:'20.19.0'})).exitCode,1); for(const args of [[],['a','b'],['a','--markdown']]) assert.equal((await captureMain(['verify',...args])).exitCode,2); }); + +test('latest resolves the persisted pointer and fails closed on unavailable identities', async () => { + const outputRoot=tempRoot(); + for(const runId of ['z-case','a-case']) await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + const latest=await captureMain(['latest','--root',outputRoot]); + assert.equal(latest.exitCode,0); assert.equal(latest.stdout.trim(),'a-case'); + assert.equal(JSON.parse((await captureMain(['latest','--root',outputRoot,'--json'])).stdout).run_id,'a-case'); + for(const pointer of [{run_id:'missing'},{run_id:'../escape'},{run_id:'z-case',manifest:'runs/a-case/manifest.json'}]) { + writeFileSync(join(outputRoot,'latest.json'),JSON.stringify(pointer)); + assert.equal((await captureMain(['latest','--root',outputRoot,'--json'])).exitCode,1); + } + assert.equal((await captureMain(['latest','extra'])).exitCode,2); +}); From 7a200e8e5e47ca2c0980e4489af5189b3b2c293b Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:10:56 +0800 Subject: [PATCH 09/12] feat: include settings and policy failures in saved case handoffs --- README.md | 2 ++ bin/case-review.mjs | 14 ++++++++++++++ test/stack.test.mjs | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 9154557..1c11316 100644 --- a/README.md +++ b/README.md @@ -294,3 +294,5 @@ Use aas compare left-id right-id --root output-directory --markdown to print the Use aas verify run-id --root output-directory --json to verify a saved case directly. It performs the same receipt and review checks as imported replay, with the same pinned component requirements, and never executes an action or modifies the case store. Refused or simulation-only cases without same-case evidence return unavailable and exit 1. Use aas latest --root output-directory to print the ID named by the latest complete-bundle pointer, or add --json for machine output. It validates the referenced saved bundle and fails closed for missing, inconsistent, or unreadable pointers/cases. It never guesses by sorting directory names. + +Case-review Markdown now carries all five requested settings and a policy-failure summary with rule identity, path, kind, and reason code. It includes at most 50 failed rules with bounded text fields and an explicit omitted count; raw response values remain in the original artifact only. Missing rule records never imply a policy pass. diff --git a/bin/case-review.mjs b/bin/case-review.mjs index 563b9a7..b08e617 100644 --- a/bin/case-review.mjs +++ b/bin/case-review.mjs @@ -38,11 +38,14 @@ export function inspectCase(runId, options = {}) { const report = bundle.report; const review = bundle.stages.prove?.result; const rail = bundle.stages.act?.rail_bundle; + const failedRules = (Array.isArray(bundle.stages.decide?.rule_results) ? bundle.stages.decide.rule_results : []).filter(rule => rule?.passed === false); + const policyFailures = { total: failedRules.length, omitted: Math.max(0, failedRules.length - 50), rules: failedRules.slice(0, 50).map(rule => Object.fromEntries(['rule_id','path','kind','reason_code'].map(key => [key, typeof rule[key] === 'string' ? rule[key].slice(0, 300) : null]))) }; const computed = rail && typeof rail === 'object' ? 'sha256:' + createHash('sha256').update(JSON.stringify(rail)).digest('hex') : null; return { schema_version: 'agent-action-stack.case-review/v1', run_id: runId, created_at: bundle.manifest.created_at ?? null, domain: report.domain ?? null, requested_options: report.requested_options ?? null, + policy_failures: policyFailures, stages: ['decide','act','prove'].map(name => ({ name, status: bundle.manifest.stages[name]?.status ?? 'unknown', reason: bundle.manifest.stages[name]?.reason ?? null, code: bundle.manifest.stages[name]?.code ?? null, artifact_available: Object.hasOwn(bundle.stages,name) })), policy_id: report.stages?.decide?.policy_id ?? null, action_id: bundle.stages.act?.action_id ?? null, outcome: report.stages?.act?.outcome ?? null, @@ -70,6 +73,17 @@ export function renderCaseMarkdown(review) { '- Run ID: '+markdownText(review.run_id), '- Created: '+markdownText(review.created_at), '- Domain: '+markdownText(review.domain), '- Policy: '+markdownText(review.policy_id), '- Action: '+markdownText(review.action_id), '- Outcome: '+markdownText(review.outcome), '- Recorded review verdict: '+markdownText(review.review_verdict), '', '## Stage record', '']; + const stageHeading = lines.splice(-2); + lines.push('## Requested settings', ''); + for (const key of ['response','domain','fault','prove','dispute']) { + const value = review.requested_options?.[key]; + lines.push('- '+key+': '+markdownText(typeof value === 'string' || typeof value === 'boolean' ? value : null)); + } + lines.push('', '## Policy failures', ''); + if (!review.policy_failures?.total) lines.push('No failed rule records are available; this alone does not prove a policy pass.'); + for (const rule of review.policy_failures?.rules ?? []) lines.push('- '+markdownText(rule.rule_id)+': '+markdownText(rule.path)+'; '+markdownText(rule.kind)+'; '+markdownText(rule.reason_code)); + if (review.policy_failures?.omitted) lines.push(review.policy_failures.omitted+' additional failures omitted; inspect the decide artifact for all records.'); + lines.push('', ...stageHeading); for(const stage of review.stages??[]) lines.push('- '+markdownText(stage.name)+': '+markdownText(stage.status)+'; artifact '+(stage.artifact_available?'present':'absent')+'; reason '+markdownText(stage.reason)+'; code '+markdownText(stage.code)); lines.push('', '## Evidence binding', '', '- Recorded digest: '+markdownText(review.recorded_evidence_digest), '- Recomputed digest: '+markdownText(review.recomputed_evidence_digest), '- Digests match: '+markdownText(review.digest_matches), '', '## Component revisions', ''); for(const entry of review.component_provenance??[]) lines.push('- '+markdownText(entry.name)+': '+markdownText(entry.commit)); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index f599c9d..940c8bf 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1769,3 +1769,13 @@ test('latest resolves the persisted pointer and fails closed on unavailable iden } assert.equal((await captureMain(['latest','extra'])).exitCode,2); }); + +test('case review handoffs include requested settings and bounded policy failures', async () => { + const {inspectCase,renderCaseMarkdown}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(), runId='policy-review'; + const rules=[{rule_id:'allowed',passed:true},...Array.from({length:55},(_,index)=>({rule_id:'failed-'+index,path:'decision',kind:'equals',passed:false,reason_code:'not_equal',raw:'OMIT_RAW'}))]; + await runDemo(['--response','fail','--domain','inventory'],{paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,rule_results:rules},status:0})}); + const review=inspectCase(runId,{outputRoot}); + assert.equal(review.policy_failures.total,55);assert.equal(review.policy_failures.rules.length,50);assert.equal(review.policy_failures.omitted,5); + const markdown=renderCaseMarkdown(review);assert.match(markdown,/## Requested settings/);assert.match(markdown,/domain: inventory/);assert.match(markdown,/not\\_equal/);assert.match(markdown,/5 additional failures omitted/);assert.doesNotMatch(markdown,/OMIT_RAW/); +}); From 2190049af144b318e511aa43e73fe181bb8cc109 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:11:54 +0800 Subject: [PATCH 10/12] feat: explain case verification readiness and recovery steps --- README.md | 2 ++ bin/case-review.mjs | 7 +++++++ test/stack.test.mjs | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index 1c11316..cdb56f2 100644 --- a/README.md +++ b/README.md @@ -296,3 +296,5 @@ Use aas verify run-id --root output-directory --json to verify a saved case dire Use aas latest --root output-directory to print the ID named by the latest complete-bundle pointer, or add --json for machine output. It validates the referenced saved bundle and fails closed for missing, inconsistent, or unreadable pointers/cases. It never guesses by sorting directory names. Case-review Markdown now carries all five requested settings and a policy-failure summary with rule identity, path, kind, and reason code. It includes at most 50 failed rules with bounded text fields and an explicit omitted count; raw response values remain in the original artifact only. Missing rule records never imply a policy pass. + +Case reviews classify verification readiness as unavailable, conflicting, or ready and explain the next read-only step. Ready means that recorded action identity and recomputed digest agree; it does not mean receipts are verified. Missing rail evidence, missing reviews, and conflicting bindings receive separate recovery guidance. diff --git a/bin/case-review.mjs b/bin/case-review.mjs index b08e617..c1c2a81 100644 --- a/bin/case-review.mjs +++ b/bin/case-review.mjs @@ -41,7 +41,13 @@ export function inspectCase(runId, options = {}) { const failedRules = (Array.isArray(bundle.stages.decide?.rule_results) ? bundle.stages.decide.rule_results : []).filter(rule => rule?.passed === false); const policyFailures = { total: failedRules.length, omitted: Math.max(0, failedRules.length - 50), rules: failedRules.slice(0, 50).map(rule => Object.fromEntries(['rule_id','path','kind','reason_code'].map(key => [key, typeof rule[key] === 'string' ? rule[key].slice(0, 300) : null]))) }; const computed = rail && typeof rail === 'object' ? 'sha256:' + createHash('sha256').update(JSON.stringify(rail)).digest('hex') : null; + let readiness; + if (!rail || typeof rail !== 'object' || Array.isArray(rail)) readiness = {state:'unavailable',reason:'No same-case rail bundle was saved.',next_step:'Inspect stage records. A skipped action or simulation-only case has no same-case evidence to verify.'}; + else if (review?.verdict !== 'recorded') readiness = {state:'unavailable',reason:'No recorded same-case review was saved.',next_step:'Inspect the prove stage diagnostic. Existing evidence cannot be repaired by rerunning verification.'}; + else if (computed !== review.evidenceDigest || typeof bundle.stages.act?.action_id !== 'string' || review.actionId !== bundle.stages.act.action_id) readiness = {state:'conflicting',reason:'Recorded review identity or digest differs from the saved action evidence.',next_step:'Preserve the case and compare it with the original handoff; do not treat it as verified.'}; + else readiness = {state:'ready',reason:'The saved action identity and evidence digest agree. Receipts remain unverified.',next_step:'Run aas verify '+runId+' with the correct --root and installed pinned components.'}; return { + verification_readiness: readiness, schema_version: 'agent-action-stack.case-review/v1', run_id: runId, created_at: bundle.manifest.created_at ?? null, domain: report.domain ?? null, requested_options: report.requested_options ?? null, @@ -87,6 +93,7 @@ export function renderCaseMarkdown(review) { for(const stage of review.stages??[]) lines.push('- '+markdownText(stage.name)+': '+markdownText(stage.status)+'; artifact '+(stage.artifact_available?'present':'absent')+'; reason '+markdownText(stage.reason)+'; code '+markdownText(stage.code)); lines.push('', '## Evidence binding', '', '- Recorded digest: '+markdownText(review.recorded_evidence_digest), '- Recomputed digest: '+markdownText(review.recomputed_evidence_digest), '- Digests match: '+markdownText(review.digest_matches), '', '## Component revisions', ''); for(const entry of review.component_provenance??[]) lines.push('- '+markdownText(entry.name)+': '+markdownText(entry.commit)); + lines.push('', '## Verification next step', '', '- Readiness: '+markdownText(review.verification_readiness?.state), '- Reason: '+markdownText(review.verification_readiness?.reason), '- Next step: '+markdownText(review.verification_readiness?.next_step)); lines.push('', '## Limits', ''); for(const limit of review.limits??[]) lines.push('- '+markdownText(limit)); return lines.join('\n')+'\n'; diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 940c8bf..659bf2d 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1779,3 +1779,15 @@ test('case review handoffs include requested settings and bounded policy failure assert.equal(review.policy_failures.total,55);assert.equal(review.policy_failures.rules.length,50);assert.equal(review.policy_failures.omitted,5); const markdown=renderCaseMarkdown(review);assert.match(markdown,/## Requested settings/);assert.match(markdown,/domain: inventory/);assert.match(markdown,/not\\_equal/);assert.match(markdown,/5 additional failures omitted/);assert.doesNotMatch(markdown,/OMIT_RAW/); }); + +test('case reviews explain verification readiness without claiming receipt verification', async () => { + const {inspectCase,renderCaseMarkdown}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(),rail={synthetic:true},digest='sha256:'+createHash('sha256').update(JSON.stringify(rail)).digest('hex'); + const variants=[['no-rail',{},null,'unavailable'],['no-review',{action_id:'a',rail_bundle:rail},null,'unavailable'],['conflict',{action_id:'a',rail_bundle:rail},{verdict:'recorded',actionId:'a',evidenceDigest:'wrong'},'conflicting'],['ready',{action_id:'a',rail_bundle:rail},{verdict:'recorded',actionId:'a',evidenceDigest:digest},'ready']]; + for(const [runId,act,review,state] of variants){ + persistRunBundle({outputRoot,runId,report:{run_id:runId,stages:{}},stages:{act:{status:'passed',raw:act},prove:{status:'passed',raw:{result:review}}},componentProvenance:[],exitCode:0}); + const report=inspectCase(runId,{outputRoot});assert.equal(report.verification_readiness.state,state); + const md=renderCaseMarkdown(report);assert.match(md,/## Verification next step/);assert.match(md,/Receipt verification was not performed/); + if(state==='ready') assert.match(md,/aas verify ready/); + } +}); From 1196aa7d98e4643c7b9f7bfbd3945473d891166a Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:12:45 +0800 Subject: [PATCH 11/12] feat: filter bounded case history without losing pagination --- README.md | 2 ++ bin/aas.mjs | 2 +- bin/case-review.mjs | 18 +++++++++++++++--- test/stack.test.mjs | 13 +++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cdb56f2..2f8c276 100644 --- a/README.md +++ b/README.md @@ -298,3 +298,5 @@ Use aas latest --root output-directory to print the ID named by the latest compl Case-review Markdown now carries all five requested settings and a policy-failure summary with rule identity, path, kind, and reason code. It includes at most 50 failed rules with bounded text fields and an explicit omitted count; raw response values remain in the original artifact only. Missing rule records never imply a policy pass. Case reviews classify verification readiness as unavailable, conflicting, or ready and explain the next read-only step. Ready means that recorded action identity and recomputed digest agree; it does not mean receipts are verified. Missing rail evidence, missing reviews, and conflicting bindings receive separate recovery guidance. + +Terminal case history supports --domain refund|inventory|unknown, --outcome settled|compensated|none, and --search text (case-insensitive, 1 to 200 characters). Filters combine and inspect summary metadata only. They apply within each bounded page: an empty filtered page can still have next_cursor, and callers must keep the same filters when continuing. diff --git a/bin/aas.mjs b/bin/aas.mjs index 52476c4..8bd58a6 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -245,7 +245,7 @@ Usage: aas latest [--root output-dir] [--json] aas verify [--root output-dir] [--json] aas inspect [--root output-dir] [--json|--markdown] - aas cases [--before run-id] [--limit 1..50] [--json] + aas cases [--root output-dir] [--before run-id] [--limit 1..50] [--domain refund|inventory|unknown] [--outcome settled|compensated|none] [--search text] [--json] aas compare [--root output-dir] [--json|--markdown] aas help diff --git a/bin/case-review.mjs b/bin/case-review.mjs index c1c2a81..4a99594 100644 --- a/bin/case-review.mjs +++ b/bin/case-review.mjs @@ -3,9 +3,12 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { DEFAULT_PATHS, UsageError, exportRunBundle, isValidRunId, summarizeRun } from "./aas.mjs"; -export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = null, limit = 25 } = {}) { +export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = null, limit = 25, domain = null, outcome = null, search = null } = {}) { if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("History limit must be an integer from 1 to 50."); if (before !== null && (!isValidRunId(before) || before.length > 200)) throw new Error("Invalid history cursor."); + if (domain !== null && !['refund','inventory','unknown'].includes(domain)) throw new Error('Domain filter must be refund, inventory, or unknown.'); + if (outcome !== null && !['settled','compensated','none'].includes(outcome)) throw new Error('Outcome filter must be settled, compensated, or none.'); + if (search !== null && (typeof search !== 'string' || !search.trim() || search.length > 200)) throw new Error('Search must contain 1 to 200 characters.'); let entries; try { entries = readdirSync(join(outputRoot, "runs"), { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return { cases: [], next_cursor: null, scanned: 0, unavailable: [] }; throw error; } @@ -14,7 +17,13 @@ export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = n const candidates = ids.slice(0, limit); const cases = [], unavailable = []; for (const runId of candidates) { - try { cases.push(summarizeRun(runId, { outputRoot })); } + try { + const summary = summarizeRun(runId, { outputRoot }); + if (domain !== null && (summary.domain ?? 'unknown') !== domain) continue; + if (outcome !== null && (summary.outcome ?? 'none') !== outcome) continue; + if (search !== null && !['run_id','domain','policy_id','action_id','outcome','review_verdict','evidence_digest'].some(key => typeof summary[key] === 'string' && summary[key].toLowerCase().includes(search.trim().toLowerCase()))) continue; + cases.push(summary); + } catch { unavailable.push(runId); } } return { cases, next_cursor: ids.length > limit ? candidates.at(-1) : null, scanned: candidates.length, unavailable }; @@ -25,8 +34,11 @@ export function parseCasePageArgs(args) { for (let i = 0; i < args.length; i++) { const key = args[i]; if (key === "--json") continue; - if (!["--before", "--limit"].includes(key) || i + 1 >= args.length || Object.hasOwn(result, key.slice(2))) throw new Error("Usage: aas cases [--before run-id] [--limit 1..50] [--json]"); + if (!["--before", "--limit", "--domain", "--outcome", "--search"].includes(key) || i + 1 >= args.length || Object.hasOwn(result, key.slice(2))) throw new Error("Usage: aas cases [--before run-id] [--limit 1..50] [--json]"); const value = args[++i]; + if (key === '--domain' && !['refund','inventory','unknown'].includes(value)) throw new Error('Invalid domain filter.'); + if (key === '--outcome' && !['settled','compensated','none'].includes(value)) throw new Error('Invalid outcome filter.'); + if (key === '--search' && (!value.trim() || value.length > 200)) throw new Error('Search must contain 1 to 200 characters.'); if (key === "--limit" && !/^[1-9][0-9]?$/.test(value)) throw new Error("Invalid history limit."); result[key.slice(2)] = key === "--limit" ? Number(value) : value; } diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 659bf2d..85ec457 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1791,3 +1791,16 @@ test('case reviews explain verification readiness without claiming receipt verif if(state==='ready') assert.match(md,/aas verify ready/); } }); + +test('filtered case pages preserve scan bounds and continuation across nonmatches', async () => { + const {listCasePage}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(); + for(const [runId,domain] of [['case-z','refund'],['case-a','inventory']]) await runDemo(['--domain',domain],{paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,policy_id:'bounded-policy'},status:0})}); + const first=listCasePage({outputRoot,limit:1,domain:'inventory'}); + assert.deepEqual(first.cases,[]);assert.equal(first.next_cursor,'case-z');assert.equal(first.scanned,1); + const next=listCasePage({outputRoot,limit:1,before:first.next_cursor,domain:'inventory',outcome:'none',search:'BOUNDED-POLICY'}); + assert.equal(next.cases[0].run_id,'case-a');assert.equal(next.next_cursor,null); + const cli=await captureMain(['cases','--root',outputRoot,'--domain','inventory','--outcome','none','--search','bounded-policy','--json']); + assert.equal(cli.exitCode,0,cli.stderr);assert.equal(JSON.parse(cli.stdout).cases.length,1); + for(const args of [['--domain','real'],['--outcome','paid'],['--search',''],['--search','x'.repeat(201)],['--domain','refund','--domain','inventory']]) assert.equal((await captureMain(['cases',...args,'--json'])).exitCode,2); +}); From 491eede8a88e9ba70a37298f0620a92a3201585a Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:17:37 +0800 Subject: [PATCH 12/12] fix: honor configured Python in the review handoff example --- examples/README.md | 2 ++ examples/review-handoff.mjs | 17 ++++------------- test/stack.test.mjs | 5 +++++ 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/README.md b/examples/README.md index 57c3a95..ed30ad2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -92,3 +92,5 @@ treats an observed effect as proof of external truth. explicitly; it never reports success for an unverified binding. - Legal effect is always `not-determined`. These examples make no claim of AP2/UCP compliance or of real-world reversibility. + +The review-handoff example uses the same bounded Python discovery as the CLI, including AAS_PYTHON and the Windows py launcher. An invalid explicit override fails before any component executes. diff --git a/examples/review-handoff.mjs b/examples/review-handoff.mjs index 0dc0d4a..42558f2 100644 --- a/examples/review-handoff.mjs +++ b/examples/review-handoff.mjs @@ -18,6 +18,7 @@ * when policy refuses, verification fails, or a binding mismatches. */ import { spawnSync } from "node:child_process"; +import { selectPython } from "../bin/aas.mjs"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -68,17 +69,6 @@ function parseArgs(argv) { return options; } -function resolvePython() { - for (const bin of ["python3", "python"]) { - const probe = spawnSync(bin, ["-c", "import sys; print(sys.version_info[0] * 100 + sys.version_info[1])"], { - encoding: "utf8", - shell: false, - }); - if (probe.error || probe.status !== 0) continue; - if (Number.parseInt(probe.stdout.trim(), 10) >= 311) return bin; - } - fail("decide needs Python 3.11+ on PATH as python3 (the testbench declares requires-python >= 3.11)"); -} function run(bin, args, { cwd, env }) { const result = spawnSync(bin, args, { cwd, env, encoding: "utf8", shell: false }); @@ -103,13 +93,14 @@ function note(text) { function main() { const { domain, response, fault } = parseArgs(process.argv.slice(2)); const fixture = DOMAIN_FIXTURES[domain]; - const python = resolvePython(); + const python = selectPython(); + if (!python) fail("decide needs Python 3.11+; set AAS_PYTHON to a working interpreter."); const scratch = mkdtempSync(join(tmpdir(), "aas-integrator-")); try { note(`domain: ${domain} (${fixture.action})`); // 1. Policy evaluation. - const decided = run(python, [ + const decided = run(python.bin, [...python.prefix, "-m", "constitutional_agent_testbench.cli", "evaluate", diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 85ec457..fd09591 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1804,3 +1804,8 @@ test('filtered case pages preserve scan bounds and continuation across nonmatche assert.equal(cli.exitCode,0,cli.stderr);assert.equal(JSON.parse(cli.stdout).cases.length,1); for(const args of [['--domain','real'],['--outcome','paid'],['--search',''],['--search','x'.repeat(201)],['--domain','refund','--domain','inventory']]) assert.equal((await captureMain(['cases',...args,'--json'])).exitCode,2); }); + +test('review handoff example honors the explicit interpreter override', () => { + const child=spawnSync(process.execPath,[join(ROOT,'examples/review-handoff.mjs')],{cwd:ROOT,encoding:'utf8',timeout:5000,env:{...process.env,AAS_PYTHON:'aas-synthetic-missing-python'}}); + assert.equal(child.status,1);assert.match(child.stderr,/AAS_PYTHON/);assert.match(child.stderr,/did not report a usable Python version/); +});