@codedology/code-forge 0.4.0 → 0.5.1

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.
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { logWarning } from '../util/error-log.mjs';
15
+ import { keyedLock } from '../util/locks.mjs';
15
16
  import { writeSafe } from '../util/redact.mjs';
16
17
  import { estimateCostUsd } from './prices.mjs';
17
18
 
@@ -113,6 +114,19 @@ export function budgetUsdOf(cfg) {
113
114
  /** The plain refusal text (R6). @param {number} budget @param {number} spent */
114
115
  export const budgetReachedMessage = (budget, spent) => `budget.usd ${budget.toFixed(2)} reached (spent ${spent.toFixed(2)}); raise budget.usd or end the run`;
115
116
 
117
+ /**
118
+ * B41: the refusal text when the ledger spend is under the budget but the sessions still running
119
+ * (reserved) plus this session's estimate would pass it.
120
+ * @param {number} budget @param {number} spent @param {number} reserved @param {number} estimate
121
+ */
122
+ export const budgetWouldExceedMessage = (budget, spent, reserved, estimate) =>
123
+ reserved > 0
124
+ ? `budget.usd ${budget.toFixed(2)} would be exceeded: spent ${spent.toFixed(2)}, reserved by running sessions ${reserved.toFixed(2)}, this session about ${estimate.toFixed(2)}; it starts when running sessions finish or when budget.usd is raised`
125
+ : `budget.usd ${budget.toFixed(2)} would be exceeded: spent ${spent.toFixed(2)}, this session about ${estimate.toFixed(2)}; raise budget.usd or end the run`;
126
+
127
+ /** B41: the refusal text when this session's estimate alone is above the budget. @param {number} budget @param {number} estimate */
128
+ export const budgetEstimateAboveMessage = (budget, estimate) => `this session's estimated cost (about ${estimate.toFixed(2)}) is above budget.usd ${budget.toFixed(2)}; raise budget.usd`;
129
+
116
130
  /** The one-time warning text at 80 %. @param {number} budget @param {number} spent */
117
131
  export const budgetWarningMessage = (budget, spent) => `budget.usd: spent ${spent.toFixed(2)} of ${budget.toFixed(2)} (≥ 80%); new sessions stop at 100%`;
118
132
 
@@ -124,22 +138,31 @@ const warnedRuns = new Set();
124
138
 
125
139
  /**
126
140
  * The budget check run before every session spawn. Reads the run's spend from `rows` (only rows
127
- * whose `run` is `run` count): spent ≥ budget ⇒ `{refuse: true}` and a `budget.refused` row;
141
+ * whose `run` is `run` count): spent ≥ budget, or (B41) spent + reserved + estimate > budget ⇒
142
+ * `{refuse: true}` and a `budget.refused` row (with `reserved_usd`/`estimate_usd` when either is > 0);
143
+ * the message is the B33 text when spent ≥ budget, else `budgetEstimateAboveMessage` when the
144
+ * estimate alone is above the budget, else `budgetWouldExceedMessage`;
128
145
  * spent ≥ 80 % of budget ⇒ one `budget.warning` row and stderr line per run — skipped when the
129
146
  * ledger already holds a `budget.warning` row for the run.
130
147
  * @param {{budget: number, run: string, rows: Array<Record<string, any>>, block?: string | null, role?: string,
131
- * writeRow?: ((row: Record<string, any>) => Promise<unknown>) | null, stderr: {write: (s: string) => unknown}}} opts
148
+ * writeRow?: ((row: Record<string, any>) => Promise<unknown>) | null, stderr: {write: (s: string) => unknown},
149
+ * reserved?: number, estimate?: number}} opts - B41: `reserved` (other sessions' holds) and
150
+ * `estimate` (this session) join the refusal test; the 80 % warning still reads `spent` only.
132
151
  * @returns {Promise<{refuse: boolean, spent: number, message?: string}>}
133
152
  */
134
- export async function checkBudget({ budget, run, rows, block = null, role, writeRow = null, stderr }) {
153
+ export async function checkBudget({ budget, run, rows, block = null, role, writeRow = null, stderr, reserved = 0, estimate = 0 }) {
135
154
  const { usd: spent, unknown } = runSpend(rows, run);
155
+ const held = isAmount(reserved) ? reserved : 0;
156
+ const next = isAmount(estimate) ? estimate : 0;
136
157
  const base = { run, block, ...(role ? { role } : {}), budget_usd: budget, spent_usd: spent, unknown_usd_sessions: unknown };
137
- if (spent >= budget) {
138
- const message = budgetReachedMessage(budget, spent);
158
+ // B41: the sessions still running (reserved) and this one (estimate) count too
159
+ if (spent >= budget || roundUsd(spent + held + next) > budget) {
160
+ // spent ≥ budget: the B33 text; else the estimate alone, else the running sessions' holds
161
+ const message = spent >= budget ? budgetReachedMessage(budget, spent) : next > budget ? budgetEstimateAboveMessage(budget, next) : budgetWouldExceedMessage(budget, spent, held, next);
139
162
  writeSafe(stderr, `code-forge: ${message}\n`);
140
163
  if (writeRow) {
141
164
  try {
142
- await writeRow({ event: 'budget.refused', ...base });
165
+ await writeRow({ event: 'budget.refused', ...base, ...(held > 0 || next > 0 ? { reserved_usd: held, estimate_usd: next } : {}) });
143
166
  } catch {
144
167
  // the refusal stands without its row
145
168
  }
@@ -169,3 +192,97 @@ export async function checkBudget({ budget, run, rows, block = null, role, write
169
192
 
170
193
  /** The fail-closed refusal text: a budget is set but the run's spend cannot be read. */
171
194
  export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend cannot be read; not starting a session";
195
+
196
+ /*
197
+ * B41 — budget reservations under concurrency.
198
+ *
199
+ * Several sessions of one run can start at once (B40 slots, B42 ticket pool). The ledger only
200
+ * learns a session's cost when its row is written, so a plain "spent < budget" check lets every
201
+ * session that starts before the first row lands go through. Instead the check AND a reservation
202
+ * of the session's estimated cost happen under ONE in-process lock per run (`withBudgetLock`):
203
+ *
204
+ * spent ≥ budget, or spent + reserved + estimate > budget ⇒ refused (the B33 message, unchanged)
205
+ *
206
+ * The reservation is held until the session row is written (or the spawn fails, throws or is
207
+ * refused) and then released. Reservations live in this process only (module level, keyed by run).
208
+ *
209
+ * The estimate (`estimateSessionUsd`): tokens_in = the packet's bytes / 4 (the ledger's own
210
+ * `estimateTokens` rule) and a fixed, deliberately high tokens_out per role and level
211
+ * (`TOKENS_OUT_ESTIMATE`), priced with the static table. A provider/level with no price reserves 0.
212
+ */
213
+
214
+ /**
215
+ * Conservative tokens_out per role and level, used only for reservations. Coders run an agentic
216
+ * tool loop whose billed tokens dwarf the brief, so they get the largest figures; an author writes a
217
+ * whole plan; reviewers, facts and judges return one structured answer; S2 a short judgement.
218
+ * Each is several times what a typical session of that kind reports.
219
+ */
220
+ export const TOKENS_OUT_ESTIMATE = Object.freeze({
221
+ coder: Object.freeze({ L0: 8000, L1: 16000, L2: 32000, L3: 64000 }),
222
+ author: Object.freeze({ L0: 8000, L1: 12000, L2: 16000, L3: 24000 }),
223
+ reviewer: Object.freeze({ L0: 4000, L1: 6000, L2: 8000, L3: 12000 }),
224
+ facts: Object.freeze({ L0: 4000, L1: 6000, L2: 8000, L3: 12000 }),
225
+ judge: Object.freeze({ L0: 2000, L1: 3000, L2: 4000, L3: 6000 }),
226
+ s2: Object.freeze({ L0: 1000, L1: 1500, L2: 2000, L3: 3000 }),
227
+ });
228
+
229
+ /** tokens_out when the role or level is not in the table (the coder L3 figure: the largest). */
230
+ export const TOKENS_OUT_FALLBACK = 64000;
231
+
232
+ /**
233
+ * The dollars reserved for one session before it starts. `tokensIn` not an amount ⇒ 0 in.
234
+ * @param {{provider: string, level: string, role: string, tokensIn?: number | null}} args
235
+ * @returns {number} USD at 4 decimals; 0 when the provider/level has no price.
236
+ */
237
+ export function estimateSessionUsd({ provider, level, role, tokensIn }) {
238
+ const out = /** @type {Record<string, Record<string, number>>} */ (TOKENS_OUT_ESTIMATE)[role]?.[level] ?? TOKENS_OUT_FALLBACK;
239
+ const priced = priceSession({ provider, level, tokens_in: isAmount(tokensIn) ? tokensIn : 0, tokens_out: out });
240
+ return priced.usd ?? 0;
241
+ }
242
+
243
+ /** @type {Map<string, Map<number, number>>} run → (reservation id → USD) */
244
+ const reservations = new Map();
245
+ let nextReservation = 0;
246
+ let lockCalls = 0;
247
+
248
+ /** USD reserved right now for `run` by sessions that have not written their row. @param {string} run */
249
+ export function reservedUsd(run) {
250
+ let sum = 0;
251
+ for (const usd of reservations.get(run)?.values() ?? []) sum += usd;
252
+ return roundUsd(sum);
253
+ }
254
+
255
+ /**
256
+ * Reserve `usd` for `run`; call only inside {@link withBudgetLock}. Returns an idempotent release.
257
+ * @param {string} run @param {number} usd @returns {() => void}
258
+ */
259
+ export function reserveBudget(run, usd) {
260
+ const id = ++nextReservation;
261
+ let map = reservations.get(run);
262
+ if (!map) reservations.set(run, (map = new Map()));
263
+ map.set(id, isAmount(usd) ? usd : 0);
264
+ return () => {
265
+ const m = reservations.get(run);
266
+ if (!m) return;
267
+ m.delete(id);
268
+ if (m.size === 0) reservations.delete(run);
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Run `fn` under the run's budget lock: budget checks of one run never interleave.
274
+ * @template T @param {string} run @param {() => T | Promise<T>} fn @returns {Promise<T>}
275
+ */
276
+ export function withBudgetLock(run, fn) {
277
+ lockCalls += 1;
278
+ return keyedLock(`budget:${run}`, fn);
279
+ }
280
+
281
+ /** Test spy: how many times {@link withBudgetLock} was entered since the last reset. */
282
+ export const budgetLockCalls = () => lockCalls;
283
+
284
+ /** Test helper: drop every reservation and zero the lock counter. */
285
+ export function resetBudgetReservations() {
286
+ reservations.clear();
287
+ lockCalls = 0;
288
+ }
@@ -15,10 +15,13 @@
15
15
  * private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
16
16
  * `spawnSession` refuses a facts cwd below any `.git`. {@link pathViolation} still refuses
17
17
  * `~`, `..` and absolute paths outside the tool roots, so the delegate cannot leave it.
18
- * Step 3 (deterministic): the answer is validated against `schema/facts.schema.json`; a row whose
19
- * `command` is not an allowed read-only form ({@link readOnlyViolation}'s allow-list; env claims only
20
- * `printenv NAME >/dev/null`, their excerpt always blanked) or a VERIFIED row with an empty excerpt
21
- * refuses the whole sheet; every claim, command, excerpt and reason passes through B0 `redact`. The
18
+ * Step 3 (deterministic): the answer is validated against `schema/facts.schema.json` (only a
19
+ * schema-invalid answer refuses the sheet); rows match claims by their echoed claim
20
+ * ({@link echoedClaim}). A row whose `command` is not an allowed read-only form
21
+ * ({@link readOnlyViolation}'s allow-list; env claims only `printenv NAME >/dev/null`, their excerpt
22
+ * always blanked), or a VERIFIED row with an empty excerpt or from a silent `test`, is downgraded
23
+ * to UNVERIFIABLE on its own (B39) and never counted; every claim, command, excerpt and reason
24
+ * passes through B0 `redact`. The
22
25
  * sheet records the brief's sha256 and mtime in its header, both taken from the ONE read of the
23
26
  * brief that also feeds the extractor; {@link checkSheetFresh} is what `author` and `plan check` use
24
27
  * to refuse a stale one.
@@ -220,14 +223,20 @@ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
220
223
  * secret-path check and could name what the check never saw): the allow-list never needs one
221
224
  * (fail closed).
222
225
  * @param {string} command
223
- * @returns {{segments: string[][], refused: string | null}}
226
+ * @returns {{segments: string[][], separators: string[], pipedOut?: boolean[], refused: string | null}}
227
+ * `separators`: every separator met, in order (`|`, `||`, `&&`, `;`, `&`, newline, CR);
228
+ * `pipedOut[i]`: segment i feeds a single `|` (its output is not the command's output).
224
229
  */
225
230
  function splitCommand(command) {
226
- if (/[`]/.test(command)) return { segments: [], refused: 'backticks are not allowed' };
227
- if (/\$/.test(command)) return { segments: [], refused: '$ expansion is not allowed' };
228
- if (/[<>]/.test(command)) return { segments: [], refused: 'redirection is not allowed' };
231
+ if (/[`]/.test(command)) return { segments: [], separators: [], refused: 'backticks are not allowed' };
232
+ if (/\$/.test(command)) return { segments: [], separators: [], refused: '$ expansion is not allowed' };
233
+ if (/[<>]/.test(command)) return { segments: [], separators: [], refused: 'redirection is not allowed' };
229
234
  /** @type {string[][]} */
230
235
  const segments = [[]];
236
+ /** @type {string[]} */
237
+ const separators = [];
238
+ /** @type {boolean[]} */
239
+ const piped = [false];
231
240
  let word = '';
232
241
  let inWord = false;
233
242
  const flush = () => {
@@ -239,29 +248,37 @@ function splitCommand(command) {
239
248
  const c = command[i];
240
249
  if (c === "'" || c === '"') {
241
250
  const end = command.indexOf(c, i + 1);
242
- if (end < 0) return { segments: [], refused: 'unbalanced quote' };
251
+ if (end < 0) return { segments: [], separators: [], refused: 'unbalanced quote' };
243
252
  word += command.slice(i + 1, end);
244
253
  inWord = true;
245
254
  i = end;
246
255
  } else if (c === '\\') {
247
- if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], refused: 'line continuation is not allowed' };
256
+ if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], separators: [], refused: 'line continuation is not allowed' };
248
257
  word += command[i + 1];
249
258
  inWord = true;
250
259
  i += 1;
251
260
  } else if (SEPARATORS.has(c)) {
252
261
  flush();
253
- if ((c === '&' || c === '|') && command[i + 1] === c) i += 1;
262
+ if ((c === '&' || c === '|') && command[i + 1] === c) {
263
+ separators.push(c + c);
264
+ i += 1;
265
+ } else {
266
+ separators.push(c);
267
+ if (c === '|') piped[piped.length - 1] = true;
268
+ }
254
269
  segments.push([]);
270
+ piped.push(false);
255
271
  } else if (c === ' ' || c === '\t') {
256
272
  flush();
257
273
  } else {
258
- if (GLOB_CHARS.has(c)) return { segments: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
274
+ if (GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
259
275
  word += c;
260
276
  inWord = true;
261
277
  }
262
278
  }
263
279
  flush();
264
- return { segments: segments.filter((s) => s.length > 0), refused: null };
280
+ const kept = segments.map((s, i) => /** @type {[string[], boolean]} */ ([s, piped[i]])).filter(([s]) => s.length > 0);
281
+ return { segments: kept.map(([s]) => s), separators, pipedOut: kept.map(([, p]) => p), refused: null };
265
282
  }
266
283
 
267
284
  /** A token that names a secret-looking file (read refused, whatever the command). */
@@ -414,17 +431,67 @@ function segmentViolation(argv) {
414
431
  }
415
432
  }
416
433
 
434
+ /** A bare CLI name (never a path) for the `<cli> … --help` forms. */
435
+ const BARE_CLI = /^[a-z0-9][a-z0-9._-]*$/;
436
+ /** A subcommand word of the `<cli> <subcommand> --help | grep -c -- <flag>` form. */
437
+ const SUBCOMMAND_WORD = /^[a-z][a-z0-9-]*$/;
438
+ /** The flag a help page is searched for (`grep -c -- <flag>`). */
439
+ const HELP_FLAG = /^--?[A-Za-z0-9][A-Za-z0-9-]*$/;
440
+
441
+ /**
442
+ * Never a `<cli>` of the subcommand-help form, even when the brief names it: interpreters and shells
443
+ * run a project file (`node scripts --help`), and BSD file tools take `--help` as an operand after
444
+ * one (`rm foo --help`). git keeps its own read list.
445
+ */
446
+ export const SUBCOMMAND_HELP_DENY = Object.freeze([
447
+ 'node', 'deno', 'bun', 'sh', 'bash', 'zsh', 'fish', 'dash', 'python', 'python3', 'ruby', 'perl', 'php',
448
+ 'rm', 'mv', 'cp', 'kill', 'pkill', 'chmod', 'chown', 'ln', 'dd', 'tee', 'git',
449
+ // launchers that run their subcommand as a program
450
+ 'npx', 'pnpx', 'bunx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'env', 'sudo', 'doas', 'xargs', 'exec', 'nice', 'nohup', 'timeout', 'time', 'watch', 'command', 'builtin', 'eval', 'source',
451
+ ]);
452
+ const SUBCOMMAND_HELP_DENIED = new Set(SUBCOMMAND_HELP_DENY);
453
+
454
+ /**
455
+ * The CLIs the subcommand-help form may name: the first word of every `command` claim of the brief.
456
+ * @param {ReadonlyArray<Claim>} claims
457
+ * @returns {Set<string>}
458
+ */
459
+ export function claimClis(claims) {
460
+ return new Set(claims.filter((c) => c.kind === 'command').map((c) => c.token.split(' ')[0]));
461
+ }
462
+
463
+ /**
464
+ * Is `segments` (split at `separators`) exactly `<cli> <subcommand> --help | grep -c -- <flag>`?
465
+ * One pipe and nothing else; `<cli>` a bare name that a `command` claim of the brief starts with
466
+ * (`clis`), never a build runner nor one of {@link SUBCOMMAND_HELP_DENY}; a plain lowercase
467
+ * subcommand word; a flag-shaped pattern. The two-token `<cli> --help` form stays a segment of its
468
+ * own ({@link segmentViolation}).
469
+ * @param {string[][]} segments @param {string[]} separators @param {ReadonlySet<string>} clis
470
+ * @returns {boolean}
471
+ */
472
+ function isSubcommandHelpGrep(segments, separators, clis) {
473
+ if (segments.length !== 2 || separators.length !== 1 || separators[0] !== '|') return false;
474
+ const [help, grep] = segments;
475
+ const cli = help[0];
476
+ if (help.length !== 3 || help[2] !== '--help' || !BARE_CLI.test(cli) || !clis.has(cli) || BUILD_RUNNERS.has(cli) || SUBCOMMAND_HELP_DENIED.has(cli) || !SUBCOMMAND_WORD.test(help[1])) return false;
477
+ return grep.length === 4 && grep[0] === 'grep' && grep[1] === '-c' && grep[2] === '--' && HELP_FLAG.test(grep[3]);
478
+ }
479
+
417
480
  /**
418
481
  * Why a delegate's `command` is not an allowed read-only command, or null when it is. An
419
482
  * ALLOW-LIST (fail closed): every segment between `;`, `&&`, `||`, `|`, `&`, newline or CR must
420
- * be one of the forms in {@link segmentViolation}; `$`, backticks and redirections are refused.
483
+ * be one of the forms in {@link segmentViolation}, or the whole command is exactly
484
+ * `<cli> <subcommand> --help | grep -c -- <flag>` for a `<cli>` in `opts.clis`
485
+ * ({@link isSubcommandHelpGrep}); `$`, backticks and redirections are refused.
421
486
  * @param {string} command
487
+ * @param {{clis?: ReadonlySet<string>}} [opts] - `clis`: {@link claimClis} of the brief (none ⇒ the subcommand form is never admitted).
422
488
  * @returns {string | null}
423
489
  */
424
- export function readOnlyViolation(command) {
425
- const { segments, refused } = splitCommand(command);
490
+ export function readOnlyViolation(command, opts = {}) {
491
+ const { segments, separators, refused } = splitCommand(command);
426
492
  if (refused) return refused;
427
493
  if (segments.length === 0) return 'empty command';
494
+ if (isSubcommandHelpGrep(segments, separators, opts.clis ?? new Set()) && !segments.flat().some((t) => SECRET_PATH.some((re) => re.test(t)))) return null;
428
495
  for (const argv of segments) {
429
496
  const why = segmentViolation(argv);
430
497
  if (why) return why;
@@ -435,12 +502,90 @@ export function readOnlyViolation(command) {
435
502
  /** The one form an env claim may be checked with: existence only, the value never printed. */
436
503
  const ENV_CHECK = /^printenv ([A-Z][A-Z0-9_]*) >\/dev\/null$/;
437
504
 
505
+ /** @param {string} text @returns {string} `text` trimmed, without one pair of surrounding backtick runs. */
506
+ function unquoteBackticks(text) {
507
+ const t = text.trim();
508
+ const m = /^(`+)([\s\S]*?)(`+)$/.exec(t);
509
+ return m && t.length > 1 ? m[2].trim() : t;
510
+ }
511
+
512
+ /**
513
+ * The claim token a delegate echoed back: the packet lists claims as `- F<n> <kind>: <token>`, so
514
+ * the delegate may copy `<kind>: <token>`, with or without backticks. Only a leading `<kind>: ` and
515
+ * surrounding backticks are stripped; anything else stays (and then does not match).
516
+ * @param {string} claim @param {string} kind
517
+ * @returns {string}
518
+ */
519
+ export function echoedClaim(claim, kind) {
520
+ let t = unquoteBackticks(String(claim));
521
+ if (t.startsWith(`${kind}: `)) t = t.slice(kind.length + 2);
522
+ return unquoteBackticks(t);
523
+ }
524
+
525
+ /**
526
+ * Does this allow-listed segment print nothing on success? From {@link segmentViolation}'s list:
527
+ * `test …`; `grep`/`rg` with `-q`/`--quiet`/`--silent` (a short cluster holding `q` counts, operands
528
+ * after `--` do not); `which -s` (a short cluster holding `s`). `[ … ]` is never allowed.
529
+ * @param {string[]} argv
530
+ * @returns {boolean}
531
+ */
532
+ function isSilentSegment(argv) {
533
+ const [verb, ...rest] = argv;
534
+ if (verb === 'test') return true;
535
+ const end = rest.indexOf('--');
536
+ const opts = end < 0 ? rest : rest.slice(0, end);
537
+ if (verb === 'grep' || verb === 'rg') return opts.some((t) => /^-[a-zA-Z]*q/.test(t) || t === '--quiet' || t === '--silent');
538
+ if (verb === 'which') return opts.some((t) => /^-[a-zA-Z]*s/.test(t));
539
+ return false;
540
+ }
541
+
542
+ /**
543
+ * Does the command print nothing on success? Only a segment not piped into another one prints to
544
+ * the command's output; the command is silent when every such segment is silent.
545
+ * @param {string} command
546
+ * @returns {boolean}
547
+ */
548
+ export function isSilentCommand(command) {
549
+ const { segments, pipedOut = [] } = splitCommand(command);
550
+ return segments.length > 0 && segments.every((argv, i) => pipedOut[i] || isSilentSegment(argv));
551
+ }
552
+
553
+ /** @typedef {{fact_id: string, reason: string}} Downgrade */
554
+
438
555
  /**
439
- * Validate the delegate's answer (step 3). Claims the delegate left out are kept as UNVERIFIABLE
440
- * rows ("no answer") — a missing answer is never read as a fact.
556
+ * Which check rule a counted row (VERIFIED or NOT-FOUND) breaks, or null. A row the delegate
557
+ * already tagged UNVERIFIABLE is never counted, so it keeps its own `why`.
558
+ * @param {Fact} row @param {Claim} claim @param {ReadonlySet<string>} clis - {@link claimClis}.
559
+ * @returns {string | null}
560
+ */
561
+ function ruleBroken(row, claim, clis) {
562
+ if (row.tag === 'UNVERIFIABLE') return null;
563
+ if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
564
+ if (claim.kind === 'env') {
565
+ const env = ENV_CHECK.exec(row.command);
566
+ if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
567
+ } else {
568
+ const why = readOnlyViolation(row.command, { clis });
569
+ if (why) return `check command not allowed: ${why}; not counted`;
570
+ if (row.tag === 'VERIFIED' && isSilentCommand(row.command)) return 'silent command; excerpt cannot be its output; not counted';
571
+ }
572
+ if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
573
+ return null;
574
+ }
575
+
576
+ /**
577
+ * Validate the delegate's answer (step 3). Only a schema-invalid answer is refused. A row answers
578
+ * claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
579
+ * the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
580
+ * equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
581
+ * delegate left out are kept as UNVERIFIABLE rows ("no answer") — a missing answer is never read
582
+ * as a fact. A VERIFIED or NOT-FOUND row that breaks a check rule (a command off the read-only
583
+ * allow-list, an env claim not checked with `printenv NAME >/dev/null`, a VERIFIED row with an
584
+ * empty excerpt or from a silent `test`) becomes UNVERIFIABLE with `why` = the rule and is listed
585
+ * in `downgraded`. Every row shows the CANONICAL claim token and kind.
441
586
  * @param {unknown} answer @param {ReadonlyArray<Claim>} claims
442
- * @returns {Fact[]} the rows, in claim order.
443
- * @throws {FactsError} `refused` naming each fact id and reason (never the output excerpt).
587
+ * @returns {{facts: Fact[], downgraded: Downgrade[]}} the rows in claim order, and the downgrades.
588
+ * @throws {FactsError} `refused` when the answer does not match the schema.
444
589
  */
445
590
  export function validateFacts(answer, claims) {
446
591
  if (!validateFile(answer)) {
@@ -448,33 +593,46 @@ export function validateFacts(answer, claims) {
448
593
  throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
449
594
  }
450
595
  const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
451
- const problems = [];
452
- for (const row of rows) {
453
- if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) problems.push(`${row.fact_id}: VERIFIED with an empty output excerpt`);
454
- if (row.command.trim().length === 0) {
455
- if (row.tag !== 'UNVERIFIABLE') problems.push(`${row.fact_id}: ${row.tag} without a command`);
456
- continue;
457
- }
458
- if (row.kind === 'env') {
459
- const env = ENV_CHECK.exec(row.command);
460
- if (!env || env[1] !== row.claim) problems.push(`${row.fact_id}: an env claim may only be checked with printenv ${row.claim} >/dev/null`);
461
- continue;
462
- }
463
- const why = readOnlyViolation(row.command);
464
- if (why) problems.push(`${row.fact_id}: command is not read-only: ${why}`);
465
- }
466
- if (problems.length > 0) throw new FactsError('refused', `facts sheet refused: ${problems.join('; ')}`);
467
596
  /** @type {Fact[]} */
468
- const out = [];
597
+ const facts = [];
598
+ /** @type {Downgrade[]} */
599
+ const downgraded = [];
600
+ const clis = claimClis(claims);
601
+ // each row answers at most ONE claim: pass 1 anchors rows by fact_id (with the kind, else with the
602
+ // echoed claim); pass 2 gives the rows left over to claims still open, by kind + echoed claim
603
+ /** @type {Set<Fact>} */
604
+ const taken = new Set();
605
+ /** @type {Array<Fact | undefined>} */
606
+ const chosen = claims.map((claim, i) => {
607
+ const id = `F${i + 1}`;
608
+ const row = rows.find((r) => !taken.has(r) && r.fact_id === id && r.kind === claim.kind) ?? rows.find((r) => !taken.has(r) && r.fact_id === id && echoedClaim(r.claim, claim.kind) === claim.token);
609
+ if (row) taken.add(row);
610
+ return row;
611
+ });
469
612
  claims.forEach((claim, i) => {
470
- const row = rows.find((r) => r.claim === claim.token && r.kind === claim.kind);
471
- out.push(
472
- row
473
- ? { ...row, fact_id: `F${i + 1}`, claim: redact(row.claim), command: redact(row.command), output_excerpt: row.kind === 'env' ? '' : redact(row.output_excerpt), why: row.why === null ? null : redact(row.why) }
474
- : { fact_id: `F${i + 1}`, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' },
475
- );
613
+ if (chosen[i]) return;
614
+ const row = rows.find((r) => !taken.has(r) && r.kind === claim.kind && echoedClaim(r.claim, claim.kind) === claim.token);
615
+ if (row) taken.add(row);
616
+ chosen[i] = row;
476
617
  });
477
- return out;
618
+ claims.forEach((claim, i) => {
619
+ const id = `F${i + 1}`;
620
+ const row = chosen[i];
621
+ if (!row) {
622
+ facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' });
623
+ return;
624
+ }
625
+ const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
626
+ const broken = ruleBroken(row, claim, clis);
627
+ if (broken) {
628
+ const reason = redact(broken);
629
+ downgraded.push({ fact_id: id, reason });
630
+ facts.push({ ...base, tag: 'UNVERIFIABLE', why: reason });
631
+ } else {
632
+ facts.push({ ...base, tag: row.tag, why: row.why === null ? null : redact(row.why) });
633
+ }
634
+ });
635
+ return { facts, downgraded };
478
636
  }
479
637
 
480
638
  // ---------------------------------------------------------------------------------------------
@@ -560,20 +718,27 @@ export function checkSheetFresh(sheetText, briefPath) {
560
718
  const RULE = [
561
719
  '# Facts delegate',
562
720
  '',
563
- 'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c <flag>`, `which <cli>`, `ls <path>`,',
564
- '`<cli> --version`, `npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
721
+ 'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c -- <flag>`,',
722
+ '`<cli> <subcommand> --help | grep -c -- <flag>`, `which <cli>`, `ls <path>`, `<cli> --version`,',
723
+ '`npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
565
724
  'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
566
- 'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess. Only these forms are',
567
- 'accepted, joined by `|` if needed: which, command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
725
+ 'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess: the excerpt is the',
726
+ "command's literal output, never your reading of it. For a path use `ls <path>` (it prints the path); `test` prints",
727
+ 'nothing, so a `test` check can never be VERIFIED. Only these forms are accepted, joined by `|` if needed: which,',
728
+ 'command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
568
729
  'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
569
730
  '-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, `<cli> --help|-h|--version` (a bare CLI name, no path, no build runner), git',
570
- 'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`.',
731
+ 'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`;',
732
+ 'and, as a whole command only, `<cli> <subcommand> --help | grep -c -- <flag>` (`<cli>` the first word of a',
733
+ 'command claim below, never an interpreter, shell or file tool; a lowercase subcommand word; no other pipe or',
734
+ 'argument). A check that breaks these rules is not counted.',
571
735
  'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
572
736
  '/bin, no secret files.',
573
737
  'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
574
738
  '',
575
739
  'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
576
- 'one row per claim, `fact_id`, `claim` and `kind` copied from the list, `why` null unless UNVERIFIABLE.',
740
+ 'one row per claim: `fact_id` (F<n>) and `kind` copied from the list, `claim` = the token alone (the text after',
741
+ '`<kind>: `), `why` null unless UNVERIFIABLE.',
577
742
  '',
578
743
  '## Claims',
579
744
  '',
@@ -797,7 +962,7 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
797
962
  * Build the facts sheet (steps 1–3) and write it to `outPath`.
798
963
  * @param {BuildFactsOpts} opts
799
964
  * @param {import('./spawn.mjs').SessionDeps} [deps]
800
- * @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[]}>}
965
+ * @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[], downgraded?: Downgrade[]}>}
801
966
  */
802
967
  export async function buildFacts(opts, deps = {}) {
803
968
  const brief = readBriefOnce(opts.briefPath);
@@ -831,7 +996,7 @@ export async function buildFacts(opts, deps = {}) {
831
996
  deps,
832
997
  );
833
998
  if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
834
- const facts = validateFacts(result.answer, claims);
999
+ const { facts, downgraded } = validateFacts(result.answer, claims);
835
1000
  const now = (opts.now ?? (() => new Date()))();
836
1001
  const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
837
1002
  const text = renderSheet({
@@ -847,7 +1012,7 @@ export async function buildFacts(opts, deps = {}) {
847
1012
  if (writeRow) {
848
1013
  await writeRow({ event: 'facts.built', role: 'facts', run: opts.run ?? null, claims: claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length, tokens_in: result.row?.tokens_in ?? null, tokens_out: result.row?.tokens_out ?? null });
849
1014
  }
850
- return { status: 'ok', outPath: opts.outPath, claims, facts };
1015
+ return { status: 'ok', outPath: opts.outPath, claims, facts, downgraded };
851
1016
  } finally {
852
1017
  removeSnapshot(snapDir);
853
1018
  rmSync(dir, { recursive: true, force: true });