@sabaiway/agent-workflow-kit 10.0.0 → 10.2.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 +40 -0
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +4 -2
- package/bridges/antigravity-cli-bridge/bin/agy-review-harness.test.mjs +288 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-verdict.test.mjs +109 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +19 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +5 -336
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/fold-scope.mjs +34 -7
- package/tools/queue-audit-cli.mjs +135 -0
- package/tools/queue-audit-rows.mjs +310 -0
- package/tools/queue-audit.mjs +164 -0
- package/tools/spec-check.mjs +11 -1
- package/tools/spec-coverage-cli.mjs +211 -0
- package/tools/spec-coverage.mjs +88 -0
|
@@ -10,283 +10,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { spawnSync, execFile } from 'node:child_process';
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// --add-dir escape — the staging dir's perms + the offloaded artifact's perms/contents WHILE they
|
|
19
|
-
// still exist (agy-review's trap removes the staging dir on exit). Kept inline so the file is
|
|
20
|
-
// standalone (the kit mirror is byte-equality; no shared helper grows that set).
|
|
21
|
-
// The envelope `agy --output-format json` returns: ONE object whose `response` carries the model's
|
|
22
|
-
// text VERBATIM. Encoded in node so an arbitrary body (quotes, backslashes, multibyte) round-trips
|
|
23
|
-
// exactly — hand-rolled JSON quoting in bash is the defect farm this fake exists to avoid.
|
|
24
|
-
const FAKE_ENVELOPE_ENCODER = [
|
|
25
|
-
'const text = require("node:fs").readFileSync(0, "utf8");',
|
|
26
|
-
'const [cid, status, shape] = process.argv.slice(1);',
|
|
27
|
-
'const envelope = { conversation_id: cid, status, response: text, duration_seconds: 1.5, num_turns: 1,',
|
|
28
|
-
' usage: { input_tokens: 10, output_tokens: 5, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 15 } };',
|
|
29
|
-
// The fed lane ROUTES later turns at this field, so the two ways it can be present-but-unusable —
|
|
30
|
-
// absent, and present with the wrong TYPE — are knobs, not just a bad-grammar string.
|
|
31
|
-
'if (shape === "missing") delete envelope.conversation_id;',
|
|
32
|
-
'if (shape === "number") envelope.conversation_id = 42;',
|
|
33
|
-
'process.stdout.write(`${JSON.stringify(envelope)}\\n`);',
|
|
34
|
-
].join('\n');
|
|
35
|
-
|
|
36
|
-
const FAKE_AGY = [
|
|
37
|
-
'#!/usr/bin/env bash',
|
|
38
|
-
'set -u',
|
|
39
|
-
// A capability probe is NOT a paid dispatch: --help / --version answer BEFORE any capture file is
|
|
40
|
-
// touched, so the wrapper's pre-spend door can never read as a spent run (the sentinel) nor shift
|
|
41
|
-
// the fed lane's per-turn counter. Keyed on the FIRST argument like the real CLI, so a prompt that
|
|
42
|
-
// merely CONTAINS "--help" is never intercepted.
|
|
43
|
-
'case "${1:-}" in',
|
|
44
|
-
' --help|-h)',
|
|
45
|
-
' if [[ -n "${AGY_FAKE_HELP_EXIT:-}" ]]; then echo "fake agy: help unavailable" >&2; exit "$AGY_FAKE_HELP_EXIT"; fi',
|
|
46
|
-
' for f in --output-format --json-schema --disable-slash-commands --effort --mode; do',
|
|
47
|
-
' if [[ "$f" == "${AGY_FAKE_HELP_OMIT:-}" ]]; then continue; fi',
|
|
48
|
-
' printf " %s fake capability line\\n" "$f"',
|
|
49
|
-
' done',
|
|
50
|
-
' if [[ -n "${AGY_FAKE_HELP_EXTRA:-}" ]]; then printf "%s\\n" "$AGY_FAKE_HELP_EXTRA"; fi',
|
|
51
|
-
' exit 0 ;;',
|
|
52
|
-
' --version) printf "%s\\n" "${AGY_FAKE_VERSION:-1.1.13}"; exit 0 ;;',
|
|
53
|
-
'esac',
|
|
54
|
-
': "${AGY_FAKE_ARGV:=/dev/null}"',
|
|
55
|
-
': "${AGY_FAKE_ENV:=/dev/null}"',
|
|
56
|
-
': "${AGY_FAKE_PROMPT:=/dev/null}"',
|
|
57
|
-
': "${AGY_FAKE_SENTINEL:=/dev/null}"',
|
|
58
|
-
'printf invoked > "$AGY_FAKE_SENTINEL"',
|
|
59
|
-
// The fed lane dispatches from the private staging dir (deliberately — see the wrapper), so the
|
|
60
|
-
// fake's own cwd is the only handle a test has on a directory the exit trap then removes.
|
|
61
|
-
'printf "%s" "$PWD" > "${AGY_FAKE_CWD:-/dev/null}"',
|
|
62
|
-
'{ for a in "$@"; do printf "%s\\n" "$a"; done; } > "$AGY_FAKE_ARGV"',
|
|
63
|
-
'{ echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY:-<unset>}"; } > "$AGY_FAKE_ENV"',
|
|
64
|
-
'prompt=""',
|
|
65
|
-
'prev=""; for a in "$@"; do if [[ "$prev" == "-p" ]]; then prompt="$a"; printf "%s" "$a" > "$AGY_FAKE_PROMPT"; fi; prev="$a"; done',
|
|
66
|
-
// Every answer leaves through ONE emitter, so the transport is a property of the INVOCATION
|
|
67
|
-
// (`--output-format json` ⇒ an envelope, otherwise text) and every existing output knob keeps
|
|
68
|
-
// meaning exactly what it meant. AGY_FAKE_RAW_STDOUT bypasses the encoder entirely — the
|
|
69
|
-
// unparseable-payload arm; AGY_FAKE_STDERR is the CLI's own diagnostic.
|
|
70
|
-
'aw_fmt=""',
|
|
71
|
-
'prev=""; for a in "$@"; do if [[ "$prev" == "--output-format" ]]; then aw_fmt="$a"; fi; prev="$a"; done',
|
|
72
|
-
// AGY_FAKE_BAD_TURN scopes the two transport-breaking knobs to ONE turn: a fed run whose turn 1
|
|
73
|
-
// answers normally and whose turn 2 does not is the only way to reach the "no LATER turn is spent"
|
|
74
|
-
// arms — an unscoped knob breaks turn 1 and the run never gets there.
|
|
75
|
-
'aw_fake_emit() {',
|
|
76
|
-
' local body="$1" aw_status="${AGY_FAKE_STATUS:-SUCCESS}" aw_raw=""',
|
|
77
|
-
' if [[ -n "${AGY_FAKE_RAW_STDOUT+x}" ]]; then aw_raw=1; fi',
|
|
78
|
-
' if [[ -n "${AGY_FAKE_BAD_TURN:-}" && "$turn" != "${AGY_FAKE_BAD_TURN}" ]]; then aw_status="SUCCESS"; aw_raw=""; fi',
|
|
79
|
-
' if [[ -n "${AGY_FAKE_STDERR:-}" ]]; then printf "%s\\n" "$AGY_FAKE_STDERR" >&2; fi',
|
|
80
|
-
' if [[ -n "$aw_raw" ]]; then printf "%s" "$AGY_FAKE_RAW_STDOUT"; return 0; fi',
|
|
81
|
-
' if [[ "$aw_fmt" != "json" ]]; then printf "%s\\n" "$body"; return 0; fi',
|
|
82
|
-
` printf "%s\\n" "$body" | node -e '${FAKE_ENVELOPE_ENCODER}' \\`,
|
|
83
|
-
' "${AGY_FAKE_CONV_ID:-11111111-2222-3333-4444-555555555555}" "$aw_status" "${AGY_FAKE_CONV_SHAPE:-ok}"',
|
|
84
|
-
'}',
|
|
85
|
-
'prev=""; for a in "$@"; do',
|
|
86
|
-
' if [[ "$prev" == "--add-dir" ]]; then',
|
|
87
|
-
' printf "%s" "$a" > "${AGY_FAKE_ADDDIR:-/dev/null}"',
|
|
88
|
-
' stat -c "%a" "$a" > "${AGY_FAKE_ADDDIR_MODE:-/dev/null}" 2>/dev/null || true',
|
|
89
|
-
' art="$a/precomputed-change-set"',
|
|
90
|
-
' if [[ -f "$art" ]]; then stat -c "%a" "$art" > "${AGY_FAKE_ARTIFACT_MODE:-/dev/null}" 2>/dev/null || true; cp "$art" "${AGY_FAKE_ARTIFACT_COPY:-/dev/null}" 2>/dev/null || true; fi',
|
|
91
|
-
' fi; prev="$a"',
|
|
92
|
-
'done',
|
|
93
|
-
'if [[ -n "${AGY_FAKE_SLEEP:-}" ]]; then sleep "$AGY_FAKE_SLEEP"; fi',
|
|
94
|
-
// ── multi-turn support (the fed lane) ──────────────────────────────────────────────────────────
|
|
95
|
-
// The single-file captures above record the LAST invocation; a chunked feed needs a PER-TURN
|
|
96
|
-
// record, so each invocation also writes prompt/argv to "<file>.<turn>" and bumps a counter file.
|
|
97
|
-
'turn=1',
|
|
98
|
-
'if [[ -n "${AGY_FAKE_TURNS:-}" ]]; then',
|
|
99
|
-
' if [[ -s "$AGY_FAKE_TURNS" ]]; then turn=$(( $(cat "$AGY_FAKE_TURNS") + 1 )); fi',
|
|
100
|
-
' printf "%s" "$turn" > "$AGY_FAKE_TURNS"',
|
|
101
|
-
' printf "%s" "$prompt" > "${AGY_FAKE_PROMPT}.$turn"',
|
|
102
|
-
' { for a in "$@"; do printf "%s\\n" "$a"; done; } > "${AGY_FAKE_ARGV}.$turn"',
|
|
103
|
-
'fi',
|
|
104
|
-
'if [[ -n "${AGY_FAKE_FAIL_TURN:-}" && "$turn" == "${AGY_FAKE_FAIL_TURN}" ]]; then',
|
|
105
|
-
' printf "FAKE_TURN_FAILURE\\n" >&2; exit 3',
|
|
106
|
-
'fi',
|
|
107
|
-
// A FEED turn: the model was told to reply OK only. This fake deliberately misbehaves — it emits a
|
|
108
|
-
// PREMATURE verdict — so the isolation invariant (feed output never reaches stdout or the parsed
|
|
109
|
-
// capture) is proven against the worst case, not the polite one.
|
|
110
|
-
'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"--- BEGIN CHANGE-SET PART "* && "$prompt" != *"Requested addresses"* ]]; then',
|
|
111
|
-
' aw_fake_emit "$(printf "PREMATURE_FEED_CHATTER\\n### Verdict\\nREWORK")"; exit 0',
|
|
112
|
-
'fi',
|
|
113
|
-
// The FINAL turn carries the delivery-proof request. The fake answers it the only honest way:
|
|
114
|
-
// by reading the bodies it was actually fed, turn by turn — so a wrapper that never delivered a
|
|
115
|
-
// part cannot be satisfied by this stub either.
|
|
116
|
-
'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"Requested addresses"* ]]; then',
|
|
117
|
-
' req="$(printf "%s" "$prompt" | awk "/^Requested addresses/{f=1; next} f && /^###/{exit} f{print}")"',
|
|
118
|
-
' entries=()',
|
|
119
|
-
' mapfile -t _items <<< "$req"',
|
|
120
|
-
' for _it in "${_items[@]}"; do',
|
|
121
|
-
' [[ -n "$_it" ]] || continue',
|
|
122
|
-
' k="$(printf "%s" "$_it" | awk "{print \\$2}")"; l="$(printf "%s" "$_it" | awk "{print \\$4}")"',
|
|
123
|
-
' src="$k"',
|
|
124
|
-
' if [[ "${AGY_FAKE_PROOF_DUP:-}" == "1" ]]; then src=1; fi',
|
|
125
|
-
' if [[ "${AGY_FAKE_PROOF_OMIT:-}" == "$k" ]]; then continue; fi',
|
|
126
|
-
' body="$(awk -v want="$l" "f && /^--- END CHANGE-SET PART /{exit} f{c++; if (c==want) {print; exit}} /^--- BEGIN CHANGE-SET PART /{f=1}" "${AGY_FAKE_PROMPT}.$src")"',
|
|
127
|
-
' if [[ "${AGY_FAKE_PROOF_CORRUPT:-}" == "$k" ]]; then body="${body}X"; fi',
|
|
128
|
-
' entry="$(printf "part %s line %s: %s" "$k" "$l" "$body")"',
|
|
129
|
-
// Shape knobs the grammar must survive (a bullet) or reject (everything else).
|
|
130
|
-
' if [[ "${AGY_FAKE_PROOF_BULLET:-}" == "1" ]]; then entry="- $entry"; fi',
|
|
131
|
-
' if [[ "${AGY_FAKE_PROOF_NESTED:-}" == "$k" ]]; then entry="note: I believe $entry"; fi',
|
|
132
|
-
' if [[ "${AGY_FAKE_PROOF_PAD:-}" == "1" ]]; then entry="$(printf "part %02d line %04d: %s" "$k" "$l" "$body")"; fi',
|
|
133
|
-
' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then entry="$(printf "Part %s Line %s: %s" "$k" "$l" "$body")"; fi',
|
|
134
|
-
' if [[ "${AGY_FAKE_PROOF_HUGE:-}" == "$k" ]]; then entry="$(printf "part %s line 99999999999999999999: %s" "$k" "$body")"; fi',
|
|
135
|
-
' entries+=("$entry")',
|
|
136
|
-
' if [[ "${AGY_FAKE_PROOF_TWICE:-}" == "1" ]]; then entries+=("$entry"); fi',
|
|
137
|
-
' done',
|
|
138
|
-
' if [[ "${AGY_FAKE_PROOF_EXTRA:-}" == "1" ]]; then entries+=("part 99 line 1: an address nobody asked for"); fi',
|
|
139
|
-
' if [[ "${AGY_FAKE_PROOF_HUGE_EXTRA:-}" == "1" ]]; then entries+=("part 99999999999999999999 line 1: an invented giant address"); fi',
|
|
140
|
-
' out="$( {',
|
|
141
|
-
' if [[ "${AGY_FAKE_PROOF_LATE:-}" == "1" ]]; then printf "### Verdict\\nSHIP\\n"; fi',
|
|
142
|
-
' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then printf "### Delivery Proof\\n"; else printf "### Delivery proof\\n"; fi',
|
|
143
|
-
' if [[ "${AGY_FAKE_PROOF_OUTSIDE:-}" == "1" ]]; then',
|
|
144
|
-
' printf "(nothing here)\\n### Verdict\\nSHIP\\n"',
|
|
145
|
-
' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
|
|
146
|
-
' else',
|
|
147
|
-
' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
|
|
148
|
-
' printf "### Verdict\\nSHIP\\n"',
|
|
149
|
-
' fi',
|
|
150
|
-
' } )"',
|
|
151
|
-
' aw_fake_emit "$out"',
|
|
152
|
-
' exit 0',
|
|
153
|
-
'fi',
|
|
154
|
-
// Unset AGY_FAKE_OUTPUT → a verdict-carrying default (D4: a verdict-less run is a FAILURE, so
|
|
155
|
-
// the success-path tests need one); an EXPLICIT empty value exercises the empty-output failure.
|
|
156
|
-
'if [[ -z "${AGY_FAKE_OUTPUT+x}" ]]; then aw_fake_emit "$(printf "FAKE_AGY_REVIEW_OUTPUT\\n### Verdict\\nSHIP")"; else aw_fake_emit "$AGY_FAKE_OUTPUT"; fi',
|
|
157
|
-
'exit "${AGY_FAKE_EXIT:-0}"',
|
|
158
|
-
'',
|
|
159
|
-
].join('\n');
|
|
160
|
-
|
|
161
|
-
// A PATH whose entries are symlinks to the real PATH binaries EXCEPT the excluded names. Excluding
|
|
162
|
-
// `agy-run` forces agy-review onto its `$HERE/agy.sh` fallback (the repo's CURRENT agy.sh, not a
|
|
163
|
-
// possibly-stale installed one), keeping the test hermetic; excluding `agy` ensures the only agy is
|
|
164
|
-
// our fake (prepended via $HOME/.local/bin). Ported from codex-review.test.mjs.
|
|
165
|
-
const makePathWithout = (root, exclude = []) => {
|
|
166
|
-
const skip = new Set(exclude);
|
|
167
|
-
const dir = mkdtempSync(join(root, 'nobin-'));
|
|
168
|
-
for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
|
|
169
|
-
let names;
|
|
170
|
-
try { names = readdirSync(d); } catch { continue; }
|
|
171
|
-
for (const name of names) {
|
|
172
|
-
if (skip.has(name)) continue;
|
|
173
|
-
const link = join(dir, name);
|
|
174
|
-
if (existsSync(link)) continue;
|
|
175
|
-
try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return dir;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// The PATH farms and the sandbox base are READ-ONLY per invocation, so both are built ONCE and
|
|
182
|
-
// shared: a per-run farm rebuild (thousands of symlinks) plus a per-test `git init`+commit were
|
|
183
|
-
// the suite's dominant wall cost, not the wrapper under test.
|
|
184
|
-
const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-review-shared-'));
|
|
185
|
-
after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
|
|
186
|
-
const farms = new Map();
|
|
187
|
-
const farmFor = (exclude) => {
|
|
188
|
-
const key = exclude.join('|');
|
|
189
|
-
if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
|
|
190
|
-
return farms.get(key);
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const TEMPLATE_HOME = (() => {
|
|
194
|
-
const home = join(SHARED_ROOT, 'template-home');
|
|
195
|
-
const bin = join(home, '.local', 'bin');
|
|
196
|
-
mkdirSync(bin, { recursive: true });
|
|
197
|
-
writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
|
|
198
|
-
const repo = join(home, 'repo');
|
|
199
|
-
mkdirSync(repo);
|
|
200
|
-
const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
201
|
-
g('init', '-q');
|
|
202
|
-
g('config', 'user.email', 'probe@example.com');
|
|
203
|
-
g('config', 'user.name', 'probe');
|
|
204
|
-
writeFileSync(join(repo, 'base.txt'), 'committed base\n');
|
|
205
|
-
g('add', '-A');
|
|
206
|
-
g('commit', '-qm', 'base');
|
|
207
|
-
return home;
|
|
208
|
-
})();
|
|
209
|
-
|
|
210
|
-
// `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
|
|
211
|
-
// untracked file so `code` mode has a diff to review.
|
|
212
|
-
const makeSandbox = ({ clean = false } = {}) => {
|
|
213
|
-
const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
|
|
214
|
-
cpSync(TEMPLATE_HOME, home, { recursive: true });
|
|
215
|
-
const bin = join(home, '.local', 'bin');
|
|
216
|
-
chmodSync(join(bin, 'agy'), 0o755);
|
|
217
|
-
const repo = join(home, 'repo');
|
|
218
|
-
const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
219
|
-
if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
|
|
220
|
-
return { home, bin, repo, g };
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// Capture files are per-INVOCATION: a second run() on the same sandbox must not inherit the first
|
|
224
|
-
// run's turn counter or per-turn prompt files (the fed lane reads them back by turn index).
|
|
225
|
-
let runSeq = 0;
|
|
226
|
-
// ASYNCHRONOUS on purpose. A blocking spawnSync here holds the event loop for the whole dispatch,
|
|
227
|
-
// which pinned this file to ONE core: 208 points in a serial chain, 91.4s solo at 103% CPU while
|
|
228
|
-
// the other seven cores idled. Awaiting the child instead lets a `{ concurrency }` describe
|
|
229
|
-
// overlap its tests. The per-test environment still rides the CHILD's options — `process.env` is
|
|
230
|
-
// never mutated, which is what keeps overlapping tests from reading each other's PATH.
|
|
231
|
-
const run = (sb, { args, env = {}, cwd, wrapper } = {}) => new Promise((settle) => {
|
|
232
|
-
const { home, bin, repo } = sb;
|
|
233
|
-
const farm = farmFor(['agy', 'agy-run']);
|
|
234
|
-
const tag = `cap-${++runSeq}`;
|
|
235
|
-
const cap = {
|
|
236
|
-
argv: join(home, `${tag}-argv`), env: join(home, `${tag}-env`), prompt: join(home, `${tag}-prompt`),
|
|
237
|
-
sentinel: join(home, `${tag}-sentinel`), adddir: join(home, `${tag}-adddir`),
|
|
238
|
-
adddirMode: join(home, `${tag}-adddir-mode`), artifactMode: join(home, `${tag}-artifact-mode`),
|
|
239
|
-
artifactCopy: join(home, `${tag}-artifact-copy`), turns: join(home, `${tag}-turns`),
|
|
240
|
-
dispatchCwd: join(home, `${tag}-dispatch-cwd`),
|
|
241
|
-
};
|
|
242
|
-
const child = execFile('bash', [wrapper || WRAPPER, ...args], {
|
|
243
|
-
cwd: cwd || repo,
|
|
244
|
-
encoding: 'utf8',
|
|
245
|
-
timeout: 30000,
|
|
246
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
247
|
-
env: {
|
|
248
|
-
HOME: home,
|
|
249
|
-
PATH: `${bin}:${farm}`,
|
|
250
|
-
// Keep the wrapper's mktemp working when the suite runs inside an OS sandbox whose /tmp is
|
|
251
|
-
// read-only (only $TMPDIR is writable there).
|
|
252
|
-
TMPDIR: process.env.TMPDIR ?? '/tmp',
|
|
253
|
-
AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
|
|
254
|
-
AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
|
|
255
|
-
AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
|
|
256
|
-
AGY_FAKE_TURNS: cap.turns, AGY_FAKE_CWD: cap.dispatchCwd,
|
|
257
|
-
...env,
|
|
258
|
-
},
|
|
259
|
-
}, (error, stdout, stderr) => {
|
|
260
|
-
const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
|
|
261
|
-
// Per-turn captures are read EAGERLY: callers rmSync the sandbox before asserting.
|
|
262
|
-
const turns = existsSync(cap.turns) ? Number(readFileSync(cap.turns, 'utf8')) : 0;
|
|
263
|
-
const prompts = [];
|
|
264
|
-
const argvs = [];
|
|
265
|
-
for (let i = 1; i <= turns; i += 1) {
|
|
266
|
-
prompts.push(readIf(`${cap.prompt}.${i}`));
|
|
267
|
-
argvs.push(readIf(`${cap.argv}.${i}`));
|
|
268
|
-
}
|
|
269
|
-
settle({
|
|
270
|
-
status: error ? (error.code ?? 1) : 0, signal: error?.signal ?? null, stdout, stderr,
|
|
271
|
-
invoked: existsSync(cap.sentinel),
|
|
272
|
-
argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
|
|
273
|
-
adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
|
|
274
|
-
artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
|
|
275
|
-
dispatchCwd: readIf(cap.dispatchCwd).trim(), turns, prompts, argvs,
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
// The wrapper refuses many inputs BEFORE it reads stdin, so the pipe can already be closed here.
|
|
279
|
-
// The blocking spawn swallowed that; an async one throws EPIPE at the test. A closed pipe is the
|
|
280
|
-
// refusal working — but ONLY EPIPE is: any other write failure is a real fault and must reach
|
|
281
|
-
// the test instead of passing as a green.
|
|
282
|
-
child.stdin.on('error', (err) => { if (err.code !== 'EPIPE') throw err; });
|
|
283
|
-
child.stdin.end();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// runAsync was the async twin kept for the two sleep-bound timeout tests, back when run() blocked.
|
|
287
|
-
// run() IS that twin now, with the fuller capture surface, so the twin is one name pointing at it
|
|
288
|
-
// — two spawn paths could only drift.
|
|
289
|
-
const runAsync = run;
|
|
13
|
+
import {
|
|
14
|
+
WRAPPER, farmFor, makeSandbox, run, runAsync, readReceipts, RECEIPTS_REL,
|
|
15
|
+
ARTIFACT_HEADER, SHAPE_HEADER, FED_CAP, FED_WIDE_CAP, MAX_COUNTABLE_PROOF_ADDRESS,
|
|
16
|
+
isAssemblerBanner, seedFedChangeSet, inlineArtifactOf, bodyOf, requestedBlockOf, requestedOf, fedRun,
|
|
17
|
+
} from './agy-review-harness.test.mjs';
|
|
290
18
|
|
|
291
19
|
describe('agy-review.sh — model policy advisory (1)', { concurrency: 2 }, () => {
|
|
292
20
|
it('warns for a non-frontier model but still runs', async () => {
|
|
@@ -935,59 +663,6 @@ describe('agy-review.sh — repo file map budget (Phase 2)', { concurrency: 2 },
|
|
|
935
663
|
});
|
|
936
664
|
});
|
|
937
665
|
|
|
938
|
-
// ── the chunked-feed code review with PROVEN delivery (Phase 3) ──────────────────────────────────
|
|
939
|
-
// agy takes its prompt as ONE argv, and this host AUTO-DENIES agy's native read_file tool — so an
|
|
940
|
-
// over-cap change set can never be FETCHED by the model. It is DELIVERED instead: partitioned into
|
|
941
|
-
// under-cap parts, fed over continuation turns, then reviewed in a final turn. Delivery is PROVEN,
|
|
942
|
-
// never assumed: the wrapper picks a line from each part's body AFTER assembly and the final answer
|
|
943
|
-
// must reproduce every picked line verbatim. Envelope and body are formally separate — only BODIES
|
|
944
|
-
// concatenate, and they concatenate to the change set byte-for-byte.
|
|
945
|
-
const ARTIFACT_HEADER = '## The change set under review (assembled working-tree diff — repo-complete)';
|
|
946
|
-
const SHAPE_HEADER = '\n## Output — Markdown, this exact shape, nothing else';
|
|
947
|
-
const FED_CAP = 6000;
|
|
948
|
-
// The proof asks the model to COUNT to the address with no tool, so the address has to be reachable
|
|
949
|
-
// that way. The walk used to start at each part's middle: measured 847..1024 on a 701464-byte change
|
|
950
|
-
// set, which is where the three live false refusals sat. FED_WIDE_CAP gives the countability
|
|
951
|
-
// regression parts wide enough that a midpoint address is unmistakably out of reach — under the
|
|
952
|
-
// narrow FED_CAP the midpoint lands at 80, close enough to the bar that fixture drift could hide it.
|
|
953
|
-
const MAX_COUNTABLE_PROOF_ADDRESS = 40;
|
|
954
|
-
const FED_WIDE_CAP = 24000;
|
|
955
|
-
// The assembler's OWN section banners — the vocabulary the proof selector refuses, because it emits
|
|
956
|
-
// them for every change set and a model can rebuild the per-path forms from part 1's git-status
|
|
957
|
-
// block. A change set's own `=== … ===` line is NOT in this set and stays admissible.
|
|
958
|
-
const isAssemblerBanner = (line) =>
|
|
959
|
-
/^=== (repo file map|git status|staged diff|unstaged diff|untracked)/.test(line) && line.endsWith(' ===');
|
|
960
|
-
|
|
961
|
-
// A change set big enough to need several parts under FED_CAP.
|
|
962
|
-
const seedFedChangeSet = (sb, { lines = 400, multibyte = false } = {}) => {
|
|
963
|
-
const body = Array.from({ length: lines }, (_, i) =>
|
|
964
|
-
multibyte
|
|
965
|
-
? `line ${String(i).padStart(4, '0')} — multibyte marker ${'\u044e'.repeat(20)}`
|
|
966
|
-
: `unique change-set line ${String(i).padStart(4, '0')} — a distinctive body marker ${'x'.repeat(20)}`).join('\n');
|
|
967
|
-
writeFileSync(join(sb.repo, 'oversized.txt'), `${body}\n`);
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
const inlineArtifactOf = (prompt) => prompt.slice(prompt.indexOf(ARTIFACT_HEADER), prompt.indexOf(SHAPE_HEADER));
|
|
971
|
-
const bodyOf = (turnPrompt) => {
|
|
972
|
-
const begin = turnPrompt.match(/--- BEGIN CHANGE-SET PART \d+ OF \d+ ---\n/);
|
|
973
|
-
if (!begin) return null;
|
|
974
|
-
const start = begin.index + begin[0].length;
|
|
975
|
-
return turnPrompt.slice(start, turnPrompt.indexOf('\n--- END CHANGE-SET PART ', start));
|
|
976
|
-
};
|
|
977
|
-
// The addresses ride ONE PER LINE — that format is what makes a collision with a proof candidate
|
|
978
|
-
// constructively impossible, so the parser reads lines, never a delimiter-joined field.
|
|
979
|
-
const requestedBlockOf = (finalPrompt) => {
|
|
980
|
-
const start = finalPrompt.indexOf('Requested addresses');
|
|
981
|
-
assert.notEqual(start, -1, 'the final turn states which lines it requires');
|
|
982
|
-
const after = finalPrompt.slice(finalPrompt.indexOf('\n', start) + 1);
|
|
983
|
-
return after.slice(0, after.indexOf('\n###')).split('\n').filter(Boolean);
|
|
984
|
-
};
|
|
985
|
-
const requestedOf = (finalPrompt) => requestedBlockOf(finalPrompt).map((item) => {
|
|
986
|
-
const [, part, line] = item.match(/^part (\d+) line (\d+)$/);
|
|
987
|
-
return { part: Number(part), line: Number(line) };
|
|
988
|
-
});
|
|
989
|
-
const fedRun = (sb, extraEnv = {}) =>
|
|
990
|
-
run(sb, { args: ['code', '--facts', 'grounded fact'], env: { AGY_MAX_PROMPT_BYTES: String(FED_CAP), ...extraEnv } });
|
|
991
666
|
|
|
992
667
|
describe('agy-review.sh — chunked feed: the change set is DELIVERED (Phase 3)', { concurrency: 2 }, () => {
|
|
993
668
|
it('an over-cap code review feeds every part and the concatenated BODIES reproduce the change set exactly', async () => {
|
|
@@ -2368,12 +2043,6 @@ describe('agy-review.sh — declared contract is really accepted (forward guard)
|
|
|
2368
2043
|
const RECEIPT_FIXTURE = JSON.parse(
|
|
2369
2044
|
'{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.3.0","timestamp":"2026-07-03T12:00:00Z","probe":false,"posture":{"model":"<display>"},"delivery":"inline"}',
|
|
2370
2045
|
);
|
|
2371
|
-
const RECEIPTS_REL = join('.git', 'agent-workflow-review-receipts.jsonl');
|
|
2372
|
-
const readReceipts = (repo) => {
|
|
2373
|
-
const p = join(repo, RECEIPTS_REL);
|
|
2374
|
-
if (!existsSync(p)) return [];
|
|
2375
|
-
return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
2376
|
-
};
|
|
2377
2046
|
const sha256HexOf = async (buf) => {
|
|
2378
2047
|
const { createHash } = await import('node:crypto');
|
|
2379
2048
|
return createHash('sha256').update(buf).digest('hex');
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.2.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/tools/fold-scope.mjs
CHANGED
|
@@ -44,25 +44,52 @@ const contains = (haystack, needle) => normalize(haystack).toLowerCase().include
|
|
|
44
44
|
// AND a boundary: it closes the block it interrupts, so text past a fence can never join the bullet
|
|
45
45
|
// before it (which would let a far-side literal satisfy a near-side claim). A `-` plus any whitespace
|
|
46
46
|
// run opens a block; a blank or indented line continues it; any other unindented line closes it.
|
|
47
|
-
// Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them
|
|
48
|
-
|
|
47
|
+
// Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them —
|
|
48
|
+
// each carrying the body index it OPENS at, because a second reader (queue-audit.mjs) reports rows by
|
|
49
|
+
// file line and a scan that dropped the index would have to re-derive it against a different grammar.
|
|
50
|
+
//
|
|
51
|
+
// `fenceContinues` is the SECOND reader's question, and it is a different one. A deferral row asks
|
|
52
|
+
// what a bullet CLAIMS, so a fence must cut it. A queue row asks what a bullet COSTS and whether it
|
|
53
|
+
// is still work, and there the fence-as-boundary is a hole: measured, a row carrying a code block
|
|
54
|
+
// reported ONE line and its `**DONE 2026-01-01:**` two lines further down was invisible, so the
|
|
55
|
+
// per-row cap could be walked straight past and a closure went unseen.
|
|
56
|
+
//
|
|
57
|
+
// Under the option only a NESTED fence continues an open block — one whose opening line is indented,
|
|
58
|
+
// which is what makes it part of the list item at all. A fence opening at column 0 is a
|
|
59
|
+
// DOCUMENT-level block and still closes the row, exactly as an unindented line does; absorbing it
|
|
60
|
+
// charged a one-line row for six. The run is decided ONCE, at its opening line, so a content line
|
|
61
|
+
// inside it cannot re-decide the question.
|
|
62
|
+
//
|
|
63
|
+
// The absorbed lines never enter `lines` — a marker inside a quotation is not a status — so the
|
|
64
|
+
// block records where they were: `span` is the row's PHYSICAL extent, and `gaps` holds the `lines`
|
|
65
|
+
// indices a fence run follows, so a reader assembling a multi-line span cannot join text from both
|
|
66
|
+
// sides of a code block into one claim.
|
|
67
|
+
export const bulletBlocks = (lines, fencedLines, from, to, { fenceContinues = false } = {}) => {
|
|
49
68
|
const blocks = [];
|
|
50
69
|
let current = null;
|
|
70
|
+
let absorbing = null;
|
|
51
71
|
const close = () => {
|
|
52
72
|
if (current) blocks.push(current);
|
|
53
73
|
current = null;
|
|
54
74
|
};
|
|
55
75
|
for (let index = from; index < to; index += 1) {
|
|
56
76
|
if (fencedLines.has(index)) {
|
|
57
|
-
|
|
77
|
+
if (absorbing === null) absorbing = Boolean(fenceContinues && current && /^\s+\S/.test(lines[index]));
|
|
78
|
+
if (!absorbing) close();
|
|
79
|
+
else {
|
|
80
|
+
current.span += 1;
|
|
81
|
+
current.gaps.add(current.lines.length - 1);
|
|
82
|
+
}
|
|
58
83
|
continue;
|
|
59
84
|
}
|
|
85
|
+
absorbing = null;
|
|
60
86
|
const line = lines[index];
|
|
61
87
|
if (BULLET.test(line)) {
|
|
62
88
|
close();
|
|
63
|
-
current = [line];
|
|
89
|
+
current = { start: index, lines: [line], span: 1, gaps: new Set() };
|
|
64
90
|
} else if (current && (line.trim() === '' || /^\s+\S/.test(line))) {
|
|
65
|
-
current.push(line);
|
|
91
|
+
current.lines.push(line);
|
|
92
|
+
current.span += 1;
|
|
66
93
|
} else {
|
|
67
94
|
close();
|
|
68
95
|
}
|
|
@@ -83,7 +110,7 @@ export const extractAcceptance = (planText) => {
|
|
|
83
110
|
if (!open) return [];
|
|
84
111
|
const next = headings.find((heading) => heading.index > open.index && heading.level <= 2);
|
|
85
112
|
return bulletBlocks(lines, fencedLines, open.index + 1, next ? next.index : lines.length)
|
|
86
|
-
.map((block) => normalize(block.join('\n').replace(/^-\s+/, '')))
|
|
113
|
+
.map((block) => normalize(block.lines.join('\n').replace(/^-\s+/, '')))
|
|
87
114
|
.filter(Boolean);
|
|
88
115
|
};
|
|
89
116
|
|
|
@@ -134,7 +161,7 @@ const exposureOf = (value) => {
|
|
|
134
161
|
|
|
135
162
|
const topLevelRows = (queueText) => {
|
|
136
163
|
const { lines, fencedLines } = tokenizeMarkdown(String(queueText ?? ''), 'the queue');
|
|
137
|
-
return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.join('\n'));
|
|
164
|
+
return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.lines.join('\n'));
|
|
138
165
|
};
|
|
139
166
|
|
|
140
167
|
const EMPTY_ROW = () => ({ found: false, matches: 0, fields: {}, missing: [...ROW_FIELDS], duplicates: [], exposure: null, closed: null, claimInInvariant: false });
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The CLI half of the queue auditor: argv and fs, no rule (the rule is queue-audit.mjs).
|
|
3
|
+
//
|
|
4
|
+
// Split for the same reason fold-scope is split — a module you can hold whole is the unit of review,
|
|
5
|
+
// and the rules file had reached the source-size cap. Read-only: it reads the file it is pointed at
|
|
6
|
+
// and writes nothing. Dependency-free, Node >= 22.
|
|
7
|
+
//
|
|
8
|
+
// Exit codes: 0 accept; 1 refuse (a terminal/record row still listed, or a cap breach); 2 usage —
|
|
9
|
+
// a missing/unknown flag, a flag with no value, an unreadable path, or a section that is not there.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
13
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
14
|
+
import { CLASSES, DEFAULTS, auditQueue, checkQueue, formatReport } from './queue-audit.mjs';
|
|
15
|
+
|
|
16
|
+
const HELP = `queue-audit — classify the backlog queue's rows (agent-workflow family).
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
node queue-audit-cli.mjs --report <queue-file> [--section "## Pending / backlog (newest)"]
|
|
20
|
+
node queue-audit-cli.mjs --check <queue-file> [--section "…"] [--max-rows N] [--max-row-lines N]
|
|
21
|
+
|
|
22
|
+
--report one tab-separated line per row: file line, class, row length, title, the literal evidence.
|
|
23
|
+
Deterministic — this is the manifest a deletion is driven by, never a regex guess.
|
|
24
|
+
--check refuses when a terminal or record row is still listed, when a row over the per-row line cap
|
|
25
|
+
carries work (live, parked and ambiguous alike), or when more rows than the row cap carry
|
|
26
|
+
work. Ambiguous rows are reported and never refuse on their own: a row that contradicts
|
|
27
|
+
itself, or names a status word outside a status position, is settled by a human.
|
|
28
|
+
|
|
29
|
+
Classes: ${CLASSES.join(' · ')}. Defaults: --max-rows ${DEFAULTS.maxRows}, --max-row-lines ${DEFAULTS.maxRowLines}.
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 accept; 1 refuse; 2 usage (missing/unknown flag, unreadable path, bad section).`;
|
|
32
|
+
|
|
33
|
+
// A flag whose value is MISSING refuses. `--section` with nothing after it used to fall through to
|
|
34
|
+
// `null`, which means "audit the whole document" — silently widening the domain of a report a
|
|
35
|
+
// deletion is driven by, in exactly the direction that costs live rows.
|
|
36
|
+
const valueOf = (argv, index, flag) => {
|
|
37
|
+
const value = argv[index + 1];
|
|
38
|
+
if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parseArgv = (argv) => {
|
|
43
|
+
// `--help` is answered ONLY when it is the whole invocation. A help flag that wins from anywhere
|
|
44
|
+
// makes `--check <dirty-file> --help` exit 0 — the gate's refusal replaced by a help page, which
|
|
45
|
+
// is the same bypass a second mode flag would be, reached by a flag nobody reads as dangerous.
|
|
46
|
+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { mode: 'help' };
|
|
47
|
+
const options = { mode: null, path: null, section: null };
|
|
48
|
+
// Every option here is a SINGLETON. A repeat used to win silently: a second `--section` moved the
|
|
49
|
+
// domain the report covers and a softer `--max-rows` moved the ratchet, both without a word — and
|
|
50
|
+
// both in the direction that lets a queue keep rows a check would have refused.
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const once = (flag) => {
|
|
53
|
+
if (seen.has(flag)) throw fail(2, `${flag} was given twice — each option is named exactly once`);
|
|
54
|
+
seen.add(flag);
|
|
55
|
+
};
|
|
56
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
57
|
+
const arg = argv[index];
|
|
58
|
+
if (arg === '--help' || arg === '-h') throw fail(2, '--help is answered only when it is the whole invocation — it never rides another mode');
|
|
59
|
+
else if (arg === '--report' || arg === '--check') {
|
|
60
|
+
// The mode is set ONCE. A later flag overwriting an earlier one would let `--check <f>
|
|
61
|
+
// --report <f>` answer a refusal question with an exit-0 report — the gate's verdict replaced
|
|
62
|
+
// by a listing, silently. Repetition is as wrong as conflict: both mean the caller asked two
|
|
63
|
+
// questions and only one was answered.
|
|
64
|
+
if (options.mode) throw fail(2, `--${options.mode} was already given — name exactly one of --report or --check`);
|
|
65
|
+
options.mode = arg.slice(2);
|
|
66
|
+
options.path = valueOf(argv, index, arg);
|
|
67
|
+
index += 1;
|
|
68
|
+
} else if (arg === '--section') {
|
|
69
|
+
once(arg);
|
|
70
|
+
options.section = valueOf(argv, index, arg);
|
|
71
|
+
index += 1;
|
|
72
|
+
} else if (arg === '--max-rows' || arg === '--max-row-lines') {
|
|
73
|
+
once(arg);
|
|
74
|
+
const value = Number(valueOf(argv, index, arg));
|
|
75
|
+
if (!Number.isInteger(value) || value <= 0) throw fail(2, `${arg} takes a positive integer, got "${argv[index + 1]}"`);
|
|
76
|
+
options[arg === '--max-rows' ? 'maxRows' : 'maxRowLines'] = value;
|
|
77
|
+
index += 1;
|
|
78
|
+
} else throw fail(2, `unknown argument "${arg}" — run with --help`);
|
|
79
|
+
}
|
|
80
|
+
if (!options.mode) throw fail(2, 'one of --report or --check is required — run with --help');
|
|
81
|
+
if (!options.path) throw fail(2, `${`--${options.mode}`} takes a queue file path`);
|
|
82
|
+
// A cap named beside `--report` used to be accepted and then ignored, and the run still exited 0 —
|
|
83
|
+
// so an operator who meant to ask a question about the caps was told nothing and read the silence
|
|
84
|
+
// as an answer. The caps belong to `--check`; naming one here is a usage error, not a no-op.
|
|
85
|
+
const capsInReport = ['--max-rows', '--max-row-lines'].filter((flag) => seen.has(flag));
|
|
86
|
+
if (options.mode === 'report' && capsInReport.length) {
|
|
87
|
+
throw fail(2, `${capsInReport.join(' and ')} ${capsInReport.length > 1 ? 'are' : 'is'} a --check option — --report lists every row and judges no cap`);
|
|
88
|
+
}
|
|
89
|
+
return options;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const main = (argv, { log = console.log, error = console.error } = {}) => {
|
|
93
|
+
let options;
|
|
94
|
+
try {
|
|
95
|
+
options = parseArgv(argv);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
error(err.message);
|
|
98
|
+
return err.exitCode ?? 2;
|
|
99
|
+
}
|
|
100
|
+
if (options.mode === 'help') {
|
|
101
|
+
log(HELP);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let text;
|
|
106
|
+
try {
|
|
107
|
+
text = readFileSync(options.path, 'utf8');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
error(`cannot read ${options.path}: ${err.message}`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (options.mode === 'report') {
|
|
115
|
+
log(formatReport(auditQueue(text, { section: options.section, label: options.path }), { label: options.path }));
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const result = checkQueue(text, { ...options, label: options.path });
|
|
119
|
+
for (const note of result.notes) log(note);
|
|
120
|
+
for (const problem of result.problems) error(problem);
|
|
121
|
+
log(
|
|
122
|
+
`${options.path}: ${result.total} rows — ` +
|
|
123
|
+
CLASSES.map((klass) => `${result.counts[klass]} ${klass}`).join(' · '),
|
|
124
|
+
);
|
|
125
|
+
return result.ok ? 0 : 1;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
error(err.message);
|
|
128
|
+
return err.exitCode ?? 1;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// `process.exitCode`, never `process.exit()`: stdout is a PIPE under a gate runner, and an immediate
|
|
133
|
+
// exit drops whatever of a large `--report` has not been flushed yet. A truncated manifest is worse
|
|
134
|
+
// than none — it is the document a deletion is driven by, and a short one reads as a complete one.
|
|
135
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|