@sabaiway/agent-workflow-kit 4.2.0 → 4.4.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.
@@ -9,10 +9,16 @@
9
9
  //
10
10
  // lane 1 --pattern <literal> for a pattern with no shell-significant byte
11
11
  // lane 2 --pattern-file <path> the pattern's bytes NEVER enter the command string
12
+ // lane 3 --paths-file <path> the TARGETS' bytes never enter it either
12
13
  //
13
- // The selection rule is enforced by the hook, not by memory: this tool's invocation is in the
14
- // hook's scanned list, so choosing lane 1 for a byte-carrying pattern earns an ASK whose reason
15
- // names lane 2. A wrong choice costs one guiding prompt; it never costs silence.
14
+ // Lane 3 exists because lanes 1-2 answered only half the arguments: a search could name a pattern it
15
+ // could not spell in a shell, but not a PATH it could not spell, and this kit ships shell-byte
16
+ // fixtures as a genre. Both lane files are excluded from the search itself, by REAL path.
17
+ //
18
+ // The hook covers this tool's invocation: choosing lane 1 for a byte-carrying pattern raises an ASK
19
+ // whose reason names lane 2. Stated exactly: that ask goes to the HUMAN, and the reason is context for
20
+ // their decision — it is not delivered to the caller that composed the command. So the ask costs one
21
+ // human decision and never costs silence, but it is not by itself a correction mechanism.
16
22
  //
17
23
  // CONTRACT
18
24
  // LITERAL only — no regex dialect, and none is planned for this slice: a bounded walk cannot
@@ -58,6 +64,15 @@ export const DEFAULT_WALK_BUDGET = 20000;
58
64
  // bounds are advisory, which is the same as absent.
59
65
  export const HARD_MAX_RESULTS = 100000;
60
66
  export const HARD_MAX_FILE_BYTES = 64 * 1024 * 1024;
67
+ // The `--paths-file` lane's own bounds. A target list arrives as a FILE, so neither its size nor its
68
+ // entry count is visible in the command string the caller composed — without ceilings here the lane
69
+ // would be the one unbounded input on a tool whose every other input is bounded.
70
+ export const HARD_MAX_TARGETS = 5000;
71
+ export const HARD_MAX_PATHS_FILE_BYTES = 4 * 1024 * 1024;
72
+ // The AGGREGATE read budget. The walk budget counts ENTRIES; without a byte ceiling a run may read up
73
+ // to the per-file limit for every one of them, which is unbounded work no bound was consulted about.
74
+ export const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
75
+ export const HARD_MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024;
61
76
  const BINARY_SNIFF_BYTES = 8192;
62
77
  // Characters of context kept on EACH side of a match, and a HARD ceiling on the whole snippet.
63
78
  // The ceiling is the load-bearing one: bounding only the context still lets a huge --pattern-file
@@ -73,8 +88,12 @@ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
73
88
  const NONBLOCK = constants.O_NONBLOCK ?? 0;
74
89
  const OPEN_FLAGS = constants.O_RDONLY | NOFOLLOW | NONBLOCK;
75
90
 
76
- class UsageError extends Error {}
77
- class IoError extends Error {}
91
+ // Exported so a sibling kit tool reusing `parsePathsFile` maps the SAME failure to the SAME exit
92
+ // code by construction — an `instanceof` against a private class would silently degrade a usage
93
+ // error into a generic one, and exit-code parity between the two file lanes would be a promise
94
+ // instead of a mechanism.
95
+ export class UsageError extends Error {}
96
+ export class IoError extends Error {}
78
97
 
79
98
  // The pattern is echoed as a DIGEST plus a byte length — never as a first content line. A first
80
99
  // line cannot separate two multiline patterns that share it, and it is unsafe for NUL/control
@@ -104,7 +123,7 @@ export const parseCount = (raw, flag, ceiling) => {
104
123
  };
105
124
 
106
125
  const parseArgs = (argv) => {
107
- const opts = { pattern: null, patternFile: null, paths: [], max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, json: false };
126
+ const opts = { pattern: null, patternFile: null, paths: [], pathsFile: null, max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES, json: false };
108
127
  for (let i = 0; i < argv.length; i += 1) {
109
128
  const arg = argv[i];
110
129
  const next = () => {
@@ -115,8 +134,10 @@ const parseArgs = (argv) => {
115
134
  if (arg === '--pattern') opts.pattern = next();
116
135
  else if (arg === '--pattern-file') opts.patternFile = next();
117
136
  else if (arg === '--path') opts.paths.push(next());
137
+ else if (arg === '--paths-file') opts.pathsFile = next();
118
138
  else if (arg === '--max') opts.max = parseCount(next(), '--max', HARD_MAX_RESULTS);
119
139
  else if (arg === '--max-bytes') opts.maxBytes = parseCount(next(), '--max-bytes', HARD_MAX_FILE_BYTES);
140
+ else if (arg === '--max-total-bytes') opts.maxTotalBytes = parseCount(next(), '--max-total-bytes', HARD_MAX_TOTAL_BYTES);
120
141
  else if (arg === '--json') opts.json = true;
121
142
  else throw new UsageError(`unknown argument: ${arg} (see --help)`);
122
143
  }
@@ -124,14 +145,107 @@ const parseArgs = (argv) => {
124
145
  throw new UsageError('--pattern and --pattern-file are mutually exclusive — the lane must be unambiguous');
125
146
  }
126
147
  if (opts.pattern === null && opts.patternFile === null) throw new UsageError('one of --pattern or --pattern-file is required');
127
- if (opts.paths.length === 0) opts.paths.push('.');
148
+ // The default target is applied only when the caller named NEITHER lane: `--paths-file` supplies
149
+ // targets after argv is parsed, so defaulting here on an empty `--path` list would silently union
150
+ // the whole root into an explicitly named list.
151
+ if (opts.paths.length === 0 && opts.pathsFile === null) opts.paths.push('.');
128
152
  return opts;
129
153
  };
130
154
 
155
+ // The `--paths-file` format, pinned rather than discovered: one target per line, UTF-8, LF or CRLF, a
156
+ // trailing delimiter is not an extra entry, EMPTY lines are ignored, duplicates collapse. There is no
157
+ // comment syntax and no escaping, so a filename containing a newline CANNOT be expressed by this lane
158
+ // — stated here because an unstated gap in a lane that exists to carry awkward names is the defect,
159
+ // not the gap.
160
+ //
161
+ // A line is NOT trimmed. This lane exists to carry names a command string cannot, and a leading or
162
+ // trailing space is exactly such a name: trimming would silently rewrite the caller's target, which is
163
+ // the failure mode the lane was built to remove. A whitespace-only line is therefore a real target and
164
+ // either resolves or fails loudly — never a silent stand-in for the root.
165
+ //
166
+ // THREE name classes this lane CANNOT express, stated rather than discovered: one containing a
167
+ // newline (there is no escaping); one ENDING in a carriage return (a trailing CR is stripped as the
168
+ // CRLF delimiter it almost always is, and no line-oriented format can tell those apart without an
169
+ // encoding this lane deliberately does not have); and one whose bytes are not valid UTF-8. The first
170
+ // two are refused by silence — the target simply will not be found, loudly. The third is refused
171
+ // EXPLICITLY, because `Buffer.toString('utf8')` would replace the offending bytes with U+FFFD and the
172
+ // lane would then search a DIFFERENT path that happens to exist, which is worse than any refusal.
173
+ // `ignoreBOM: true` means "do not TREAT a leading U+FEFF as a byte-order mark", i.e. keep it as a
174
+ // character. The default strips it, which would rewrite a legitimate name beginning with U+FEFF into
175
+ // a different one — the same silent-substitution class as a lossy decode, one layer down.
176
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
177
+
178
+ export const decodeLaneFile = (buf, flag) => {
179
+ try {
180
+ return strictUtf8.decode(buf);
181
+ } catch {
182
+ throw new UsageError(`${flag} is not valid UTF-8 — a lossy decode would silently name a different path`);
183
+ }
184
+ };
185
+ export const parsePathsFile = (raw, flag = '--paths-file') => {
186
+ const entries = raw.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)).filter((line) => line !== '');
187
+ // The ceiling counts TARGETS, so it is applied after dedupe: a file repeating one path 5001 times
188
+ // names one target and refusing it would be a bound on the file's shape rather than on the work.
189
+ const unique = [...new Set(entries)];
190
+ if (unique.length > HARD_MAX_TARGETS) {
191
+ throw new UsageError(`${flag} lists ${unique.length} distinct targets, above the ceiling of ${HARD_MAX_TARGETS}`);
192
+ }
193
+ if (unique.length === 0) throw new UsageError(`${flag} names no target — a blank list is never "search everything"`);
194
+ return unique;
195
+ };
196
+
197
+ // ── the acceptance predicate for a caller-supplied TARGET ─────────────────────────────
198
+ //
199
+ // ONE closed rule set, applied before any target reaches `resolve()`. It replaces what were seven
200
+ // checks discovered one review round at a time, all of which turned out to be the same defect: a
201
+ // caller string is handed to `path.resolve()` / `realpathSync`, whose semantics differ from the
202
+ // operating system's, so the tool can end up answering about a DIFFERENT object than the string
203
+ // denotes. That is the one outcome worse than any refusal.
204
+ //
205
+ // The rule the predicate enforces: **a target is accepted only if it names exactly one filesystem
206
+ // object unambiguously.** Awkward-but-unambiguous names are supported on purpose — edge whitespace,
207
+ // backticks, control bytes — because carrying those is what the out-of-band lane exists for.
208
+ //
209
+ // Separators are PLATFORM-CORRECT, and that is load-bearing: on POSIX a backslash is an ordinary
210
+ // byte in a filename, so splitting on it would refuse `\..` — a legal file the OS answers about
211
+ // normally. An over-refusal is a smaller defect than a wrong answer, but it is still a defect.
212
+ const TARGET_SEPARATORS = Object.freeze(sep === '\\' ? ['/', '\\'] : ['/']);
213
+ const TARGET_SEPARATOR_SPLIT = sep === '\\' ? /[\\/]/u : /\//u;
214
+
215
+ // A trailing separator, or a trailing `.` component, is an ASSERTION by the caller that the target is
216
+ // a DIRECTORY — that is what it means to the operating system, which answers ENOTDIR when it is not.
217
+ // `resolve()` erases both forms before the filesystem sees them, so the assertion has to be carried
218
+ // separately and checked after resolution. Refusing these outright was wrong in both directions: it
219
+ // rejected `existing-directory/`, which names one object unambiguously, while still accepting
220
+ // `regular-file/.`, which the OS refuses.
221
+ //
222
+ // Stated divergence, and it differs per tool. `path-inventory` reports a symlink BY TYPE and never
223
+ // follows it, so `symlink-to-a-directory/` is not a directory there, while the OS would dereference
224
+ // it — following it would contradict that tool's louder promise. `repo-search` DOES resolve an
225
+ // explicitly named target (its threat model says so at the top of this file), so the assertion is
226
+ // checked against the resolved object and it agrees with the OS.
227
+ export const requiresDirectory = (target) => {
228
+ if (TARGET_SEPARATORS.some((separator) => target.endsWith(separator))) return true;
229
+ const components = target.split(TARGET_SEPARATOR_SPLIT);
230
+ return components[components.length - 1] === '.';
231
+ };
232
+
233
+ export const assertNameableTarget = (target, flag = '--path') => {
234
+ const refuse = (why) => {
235
+ throw new UsageError(`${flag} ${why} — got ${JSON.stringify(target)}`);
236
+ };
237
+ if (target === '') refuse('must not be empty; an empty target resolves to the whole root');
238
+ if (target.includes('\0')) refuse('must not contain a NUL byte; no filesystem path can hold one');
239
+ if (target.split(TARGET_SEPARATOR_SPLIT).includes('..')) {
240
+ refuse('must not contain a ".." component; resolve() collapses it lexically, so the answer could be about a different object than the OS would reach');
241
+ }
242
+ };
243
+
131
244
  // Containment on the REAL path. A lexical check passes `link/secret.txt` whenever `link` resolves
132
245
  // outside, and on Windows a cross-drive `relative()` returns an absolute path carrying no `..` —
133
246
  // both were live review findings, not hypotheticals.
134
247
  export const resolveTarget = (realRoot, target) => {
248
+ assertNameableTarget(target);
135
249
  const lexical = resolve(realRoot, target);
136
250
  let real;
137
251
  try {
@@ -144,12 +258,15 @@ export const resolveTarget = (realRoot, target) => {
144
258
  if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) {
145
259
  throw new IoError(`target resolves outside the search root: ${target}`);
146
260
  }
261
+ if (requiresDirectory(target) && !lstatSync(real).isDirectory()) {
262
+ throw new IoError(`no such path: ${target} — a trailing separator or "." asserts a directory, and this is not one`);
263
+ }
147
264
  return real;
148
265
  };
149
266
 
150
267
  // Open → fstat the DESCRIPTOR → read bounded. The descriptor is what was actually opened, so a swap
151
268
  // after the check cannot substitute a different node; O_NOFOLLOW refuses a symlinked leaf outright.
152
- const readRegularFile = (abs, maxBytes, state, io = {}) => {
269
+ const readRegularFile = (abs, maxBytes, state, io = {}, boundName = 'max-file-bytes') => {
153
270
  const open = io.open ?? openSync;
154
271
  const fstat = io.fstat ?? fstatSync;
155
272
  const read = io.read ?? readSync;
@@ -174,7 +291,7 @@ const readRegularFile = (abs, maxBytes, state, io = {}) => {
174
291
  // A skipped file is NOT a silent omission: the search is incomplete and says which bound did
175
292
  // it. Reporting it only as a counter would let a partial search read as "no matches".
176
293
  if (state.incomplete === null) {
177
- state.incomplete = { bound: 'max-file-bytes', detail: `at least one file exceeds ${maxBytes} byte(s) and was not searched` };
294
+ state.incomplete = { bound: boundName, detail: `at least one file exceeds ${maxBytes} byte(s) and was not searched` };
178
295
  }
179
296
  return null;
180
297
  }
@@ -184,6 +301,10 @@ const readRegularFile = (abs, maxBytes, state, io = {}) => {
184
301
  const n = read(fd, buf, got, stat.size - got, got);
185
302
  if (n <= 0) break;
186
303
  got += n;
304
+ // Charged INSIDE the loop, per successful chunk. After the loop is too late: a `read` that
305
+ // throws mid-file skips the charge entirely, so a series of partial reads that end in a fault
306
+ // would move real bytes the aggregate budget never learns about.
307
+ state.bytesRead += n;
187
308
  }
188
309
  // A short read means the file changed under us. Returning the partial buffer would let a
189
310
  // truncated file come back as a confident "no matches" — the file is classified unreadable
@@ -293,11 +414,29 @@ const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
293
414
  }
294
415
  return;
295
416
  }
296
- if (state.excludePath !== null && abs === state.excludePath) {
297
- state.skipped.patternFile += 1;
417
+ const excludedAs = state.excludePaths.get(abs);
418
+ if (excludedAs !== undefined) {
419
+ state.skipped[excludedAs] += 1;
420
+ return;
421
+ }
422
+ // The walk budget counts ENTRIES, not bytes, so without this a run may read up to the per-file
423
+ // ceiling for every one of them — twenty thousand files at the hard per-file ceiling is work no
424
+ // bound was ever consulted about. The sibling inventory tool already carries an aggregate byte
425
+ // budget for exactly this reason; this closes the asymmetry rather than stating it.
426
+ const remaining = state.maxTotalBytes - state.bytesRead;
427
+ if (remaining <= 0) {
428
+ state.incomplete = state.incomplete ?? {
429
+ bound: '--max-total-bytes',
430
+ detail: `stopped after reading ${state.bytesRead} byte(s); the tree was not fully searched`,
431
+ };
298
432
  return;
299
433
  }
300
- const buf = readRegularFile(abs, state.maxBytes, state, state.io);
434
+ // The read is bounded by whichever ceiling binds FIRST. Checking the aggregate only BEFORE the file
435
+ // and then handing the reader the full per-file limit lets a single file larger than the remaining
436
+ // budget be read whole — the budget would bound the accounting, not the work.
437
+ const limit = Math.min(state.maxBytes, remaining);
438
+ const boundName = limit < state.maxBytes ? '--max-total-bytes' : 'max-file-bytes';
439
+ const buf = readRegularFile(abs, limit, state, state.io, boundName);
301
440
  if (buf === null) return;
302
441
  if (isBinary(buf)) {
303
442
  state.skipped.binary += 1;
@@ -310,15 +449,20 @@ const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
310
449
  // directory, a symlink refused by O_NOFOLLOW, a file truncated mid-read — are reachable from tests.
311
450
  // Every one of them is a COUNTED skip in production, and a counted skip that no test ever exercises
312
451
  // is indistinguishable from a silent one.
313
- export const search = ({ root, pattern, paths, max, maxBytes, excludePath = null, walkBudget = DEFAULT_WALK_BUDGET, io = {} }) => {
452
+ // `excludePaths` maps a REAL path to the skip counter it belongs to: BOTH lane files must be kept out
453
+ // of their own search (a `--paths-file` under a searched target would otherwise match on its own
454
+ // contents), and a skip that is not attributed to its lane is indistinguishable from a silent one.
455
+ export const search = ({ root, pattern, paths, max, maxBytes, excludePaths = new Map(), walkBudget = DEFAULT_WALK_BUDGET, maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, io = {} }) => {
314
456
  const state = {
315
457
  matches: [],
316
458
  incomplete: null,
317
- skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0 },
318
- excludePath,
459
+ skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0, pathsFile: 0 },
460
+ excludePaths,
319
461
  io,
320
462
  walked: 0,
321
463
  walkBudget,
464
+ bytesRead: 0,
465
+ maxTotalBytes,
322
466
  max,
323
467
  maxBytes,
324
468
  };
@@ -334,6 +478,9 @@ export const search = ({ root, pattern, paths, max, maxBytes, excludePath = null
334
478
  incomplete: state.incomplete,
335
479
  skipped: state.skipped,
336
480
  scanned: state.walked,
481
+ // Reported so the aggregate budget is observable rather than internal: a bound that nobody can
482
+ // read is a bound nobody can test.
483
+ bytesRead: state.bytesRead,
337
484
  };
338
485
  };
339
486
 
@@ -351,14 +498,27 @@ const formatResult = (result) => {
351
498
 
352
499
  const HELP = `repo-search — literal repository search that never has to ride a shell metacharacter.
353
500
 
354
- Usage:
355
- node repo-search.mjs --pattern <literal> [--path <p>]... [--max <n>] [--max-bytes <n>] [--json]
356
- node repo-search.mjs --pattern-file <path> [--path <p>]... [--max <n>] [--max-bytes <n>] [--json]
501
+ Usage — pick ONE pattern lane and ANY combination of target lanes:
502
+ pattern lane --pattern <literal> | --pattern-file <path> (mutually exclusive)
503
+ target lanes [--path <p>]... and/or --paths-file <path> (union; default: .)
504
+ shared flags [--max <n>] [--max-bytes <n>] [--max-total-bytes <n>] [--json]
505
+
506
+ A target must NAME EXACTLY ONE filesystem object: no empty value, no NUL byte, and no ".."
507
+ component (resolve() collapses it before the filesystem sees it). A trailing "/" or "/." is NOT
508
+ rejected — it ASSERTS the target is a directory, exactly as it does to the OS: it holds for a real
509
+ directory and fails for anything else. Awkward-but-unambiguous names — edge whitespace, backticks,
510
+ control bytes — are supported, and --paths-file is the lane for the ones a command string cannot
511
+ carry.
512
+
513
+ Two out-of-band lanes, one per argument half — their bytes never enter the command string, so the
514
+ residual guard has nothing to scan:
515
+ --pattern-file <path> a PATTERN carrying shell-significant bytes (\`>\`, \`$(\`, a backtick)
516
+ --paths-file <path> TARGET paths carrying the same, one per line
357
517
 
358
- --pattern-file is the lane for a pattern carrying shell-significant bytes (\`>\`, \`$(\`, a backtick):
359
- its bytes never enter the command string, so the residual guard has nothing to scan. Write the file
360
- with your host's file-write tool, then pass the plain path here, and delete it when you are done —
361
- this tool never writes.
518
+ Write either file with your host's file-write tool, pass the plain path here, and delete it when you
519
+ are done this tool never writes. --paths-file format: one target per line, UTF-8, LF or CRLF; blank
520
+ lines ignored; duplicates collapse; no comment syntax and no escaping, so a filename containing a
521
+ newline cannot be expressed by this lane. Both lane files are excluded from the search itself.
362
522
 
363
523
  LITERAL only, multiline patterns supported. Reads regular files only, opened no-follow. Skipped
364
524
  entries (symlinks, non-regular, binary, oversized, unreadable) are counted and reported, never
@@ -372,21 +532,49 @@ export const main = (argv, ctx = {}) => {
372
532
  if (argv.includes('--help') || argv.includes('-h')) return { code: EXIT_OK, stdout: HELP, stderr: '', result: null };
373
533
  const root = realpathSync(resolve(ctx.cwd ?? process.cwd()));
374
534
  const opts = parseArgs(argv);
535
+ const excludePaths = new Map();
536
+ // ONE reader for both out-of-band lanes, so their failure classification cannot drift: a missing,
537
+ // unreadable or non-regular lane file is an IoError (exit 1) for the pattern lane and for the
538
+ // target lane alike.
539
+ const readLaneFile = (rel, flag, maxBytes, counter) => {
540
+ const abs = resolveTarget(root, rel);
541
+ // `bytesRead` is present but SEPARATE from the search's budget: reading a lane file is the
542
+ // caller's own instruction, not tree traversal, so it is accounted and then discarded.
543
+ const state = { skipped: { symlinks: 0, special: 0, unreadable: 0, large: 0 }, incomplete: null, bytesRead: 0 };
544
+ const buf = readRegularFile(abs, maxBytes, state);
545
+ if (buf === null) throw new IoError(`cannot read ${flag} ${rel} as a regular file`);
546
+ excludePaths.set(abs, counter);
547
+ return decodeLaneFile(buf, flag);
548
+ };
375
549
  let raw;
376
- let excludePath = null;
377
550
  if (opts.patternFile !== null) {
378
- excludePath = resolveTarget(root, opts.patternFile);
379
- const state = { skipped: { symlinks: 0, special: 0, unreadable: 0, large: 0 }, incomplete: null };
380
- const buf = readRegularFile(excludePath, HARD_MAX_FILE_BYTES, state);
381
- if (buf === null) throw new IoError(`cannot read --pattern-file ${opts.patternFile} as a regular file`);
382
- raw = buf.toString('utf8');
551
+ raw = readLaneFile(opts.patternFile, '--pattern-file', HARD_MAX_FILE_BYTES, 'patternFile');
383
552
  } else {
384
553
  raw = opts.pattern;
385
554
  }
386
555
  const pattern = opts.patternFile !== null ? resolvePattern(raw) : raw;
387
556
  if (pattern === '') throw new UsageError('the pattern is empty — it would match every line of every file');
388
557
 
389
- const result = search({ root, pattern, paths: opts.paths, max: opts.max, maxBytes: opts.maxBytes, excludePath });
558
+ const named = [...opts.paths];
559
+ if (opts.pathsFile !== null) {
560
+ const listed = readLaneFile(opts.pathsFile, '--paths-file', HARD_MAX_PATHS_FILE_BYTES, 'pathsFile');
561
+ named.push(...parsePathsFile(listed));
562
+ }
563
+ // Dedupe across the UNION, not only within each lane: the same target named by `--path` and by
564
+ // the file would otherwise be walked twice.
565
+ const paths = [...new Set(named)];
566
+ // EVERY target is validated BEFORE any of them is walked. Validating lazily per target means an
567
+ // invalid one late in the list is only refused if the walk gets that far — and a bound that fires
568
+ // on an earlier target ends the loop first, so the same invocation would be accepted or refused
569
+ // depending on how much work happened to be done. A refusal must not depend on scheduling.
570
+ for (const target of paths) assertNameableTarget(target);
571
+ // The ceiling holds over the UNION. `parsePathsFile` bounds the file, but thousands of `--path`
572
+ // values would otherwise walk straight past it.
573
+ if (paths.length > HARD_MAX_TARGETS) {
574
+ throw new UsageError(`more than the ceiling of ${HARD_MAX_TARGETS} targets`);
575
+ }
576
+
577
+ const result = search({ root, pattern, paths, max: opts.max, maxBytes: opts.maxBytes, maxTotalBytes: opts.maxTotalBytes, excludePaths });
390
578
  const stdout = opts.json ? JSON.stringify(result, null, 2) : formatResult(result);
391
579
  return { code: result.incomplete ? EXIT_INCOMPLETE : EXIT_OK, stdout, stderr: '', result };
392
580
  } catch (err) {
@@ -34,8 +34,14 @@ import { createHash, randomUUID } from 'node:crypto';
34
34
  import { computeTreeFingerprint } from './review-state.mjs';
35
35
  // The D3(a) final receipt rides the core-evidence SOLE WRITER (the sole-writer boundary — this
36
36
  // runner never opens the store itself) + the canonical per-kind serialization its hashes bind.
37
- import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION } from './core-evidence.mjs';
38
- import { LCOV_BASENAME } from './coverage-check.mjs';
37
+ import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION, resolveBase } from './core-evidence.mjs';
38
+ import {
39
+ LCOV_BASENAME,
40
+ commitmentFor,
41
+ ATTEST_NONCE_ENV,
42
+ ATTEST_FINGERPRINT_ENV,
43
+ ATTEST_BASE_ENV,
44
+ } from './coverage-check.mjs';
39
45
 
40
46
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
41
47
  // user can open (the orchestration-config CONFIG_REL idiom).
@@ -196,10 +202,19 @@ export const selectGates = (gates, onlyIds) => {
196
202
  // silently attest against the wrong git dir or lcov instead of the computed one.
197
203
  export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
198
204
 
205
+ // The attestation variables ride the same STRIP but are NOT producer variables: a producer variable
206
+ // is something a gate cmd may legitimately reference, and a missing one refuses the run up front.
207
+ // A capability is the opposite — no gate may reference it, exactly one gate is handed it, and every
208
+ // other child (and any descendant it spawns) must see it absent, host-set copies included, or that
209
+ // descendant could certify a foreign lcov later. Conflating the two lists made a gate that merely
210
+ // MENTIONS the name refuse the whole run.
211
+ export const RESERVED_CAPABILITY_ENV = Object.freeze([ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]);
212
+
199
213
  export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
200
214
  const env = { ...process.env };
201
215
  delete env.NODE_TEST_CONTEXT;
202
216
  for (const name of RESERVED_PRODUCER_ENV) delete env[name];
217
+ for (const name of RESERVED_CAPABILITY_ENV) delete env[name];
203
218
  return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
204
219
  };
205
220
 
@@ -327,13 +342,22 @@ const matchesCanonicalCheck = (check, cmd, projectDir) => {
327
342
  }
328
343
  };
329
344
 
345
+ // canonicalCheckerGates(gates, projectDir) → every gate that IS the canonical coverage-check. The
346
+ // count is load-bearing twice over: --final refuses more than one (the attestation capability would
347
+ // reach more than one process) and this predicate must refuse the same declaration, or a consumer
348
+ // would advertise final-capability for a declaration --final then rejects.
349
+ export const canonicalCheckerGates = (gates, projectDir) =>
350
+ gates.filter((g) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], g.cmd, projectDir));
351
+
330
352
  // isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
331
- // (every canonical core check present + the checker LAST) — the ONE home consumers (the
332
- // recommendations guard-install probe) read instead of re-deriving the rule.
353
+ // (every canonical core check present + EXACTLY ONE canonical checker + that checker LAST) — the
354
+ // ONE home consumers (the recommendations guard-install probe, the worktrees report) read instead
355
+ // of re-deriving the rule.
333
356
  export const isFinalCapableDeclaration = (gates, projectDir) => {
334
357
  if (!Array.isArray(gates) || gates.length === 0) return false;
335
358
  const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
336
359
  if (missing.length > 0) return false;
360
+ if (canonicalCheckerGates(gates, projectDir).length !== 1) return false;
337
361
  return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
338
362
  };
339
363
  const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
@@ -389,6 +413,13 @@ export const runCli = (argv, deps = {}) => {
389
413
  if (missing.length > 0) {
390
414
  throw fail(EXIT.malformed, `--final refuses a weakened declaration — missing the canonical core check(s): ${missing.map((c) => c.name).join(', ')} (each must be ONE plain --check invocation of the kit's OWN tool in ${GATES_REL} — a masked form, a compound, or a lookalike path never counts)`);
391
415
  }
416
+ // EXACTLY ONE canonical checker. Two gates may carry the same canonical cmd under different
417
+ // ids, and the capability is handed to every match — so without this the "exactly one gate
418
+ // holds it" guarantee is prose, and an extra copy would run with a live attestation context.
419
+ const canonicalCheckers = canonicalCheckerGates(declaration.gates, projectDir);
420
+ if (canonicalCheckers.length > 1) {
421
+ throw fail(EXIT.malformed, `--final refuses the declaration — ${canonicalCheckers.length} gates are the canonical coverage-check (${canonicalCheckers.map((g) => JSON.stringify(g.id)).join(', ')}); exactly ONE may be, or the attestation context would be handed to more than one process`);
422
+ }
392
423
  const lastGate = declaration.gates[declaration.gates.length - 1];
393
424
  if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], lastGate.cmd, projectDir)) {
394
425
  throw fail(EXIT.malformed, `--final refuses the declaration — the CANONICAL coverage-check gate must be the LAST declared gate (nothing may run after the checker consumed the lcov; "${lastGate.id}" is declared last)`);
@@ -430,7 +461,29 @@ export const runCli = (argv, deps = {}) => {
430
461
  }
431
462
  // --final needs the pre-run fingerprint (the receipt binds before == after == current).
432
463
  const finalFingerprintBefore = opts.final ? fingerprint(projectDir) : null;
433
- const finalAttempt = opts.final ? randomUUID() : null;
464
+ const finalBase = opts.final ? resolveBase(projectDir) ?? '' : null;
465
+ // The attestation handshake (see coverage-check.mjs): a fresh random nonce rides the child
466
+ // environment, and the attempt id this run records is the one-way COMMITMENT over it plus the
467
+ // identity. Persisting only the commitment is what makes the context unreproducible from the
468
+ // repository afterwards — a plain recorded id is public, and attesting from one let an ordinary
469
+ // interrupted run certify a later run's lcov. The commitment is also the only place the BASE is
470
+ // bound, since no record stores it.
471
+ const finalNonce = opts.final ? randomUUID() : null;
472
+ const finalAttempt = opts.final ? commitmentFor(finalNonce, finalFingerprintBefore ?? '', finalBase) : null;
473
+ if (opts.final) {
474
+ // ONLY the canonical checker receives the capability — the same predicate the --final preflight
475
+ // uses to recognise it. Every other gate gets the producer variables and nothing else.
476
+ gateSpawn = (cmd, cwd2) => {
477
+ const producers = { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) };
478
+ if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], cmd, projectDir)) return spawn(cmd, cwd2, producers);
479
+ return spawn(cmd, cwd2, {
480
+ ...producers,
481
+ [ATTEST_NONCE_ENV]: finalNonce,
482
+ [ATTEST_FINGERPRINT_ENV]: finalFingerprintBefore ?? '',
483
+ [ATTEST_BASE_ENV]: finalBase,
484
+ });
485
+ };
486
+ }
434
487
  let finalError = null;
435
488
  let startEvidenceHashes = null;
436
489
  if (opts.final) {
@@ -458,6 +511,19 @@ export const runCli = (argv, deps = {}) => {
458
511
  const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
459
512
  for (const line of formatTable(results)) log(line);
460
513
  const allGreen = results.every((result) => result.ok);
514
+ // A green gate's stdout is deliberately not echoed — the table IS the report. But the checker
515
+ // exits 0 both when it certifies and when it WITHHOLDS a verdict, so on a plain run the table
516
+ // would read PASS over a coverage claim that was never made: the same false reassurance one
517
+ // layer up from the defect this whole mechanism exists to close. Surface it, and only it.
518
+ if (!opts.final) {
519
+ const checkerAt = selected.findIndex((gate) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gate.cmd, projectDir));
520
+ const checkerRow = checkerAt === -1 ? null : results[checkerAt];
521
+ if (checkerRow?.ok && /^coverage-check: attested=no$/m.test(String(checkerRow.stdout ?? ''))) {
522
+ log(`── ${checkerRow.id} — NO COVERAGE VERDICT (the gate passed; it did not certify)`);
523
+ for (const line of String(checkerRow.stdout).split(/\r?\n/).filter((l) => /^coverage-check: (NO VERDICT|skipped-no-lcov)/.test(l))) log(line);
524
+ log(' A coverage verdict is issued only by run-gates.mjs --final, which owns the lcov for the whole run.');
525
+ }
526
+ }
461
527
  if (opts.final) {
462
528
  // The checker's verbatim diagnostics surface even on green — skipped-no-lcov and the
463
529
  // out-of-domain/unsupported lists must never vanish into a suppressed green stdout.
@@ -493,6 +559,22 @@ export const runCli = (argv, deps = {}) => {
493
559
  const shaLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => shaLineRe.test(l));
494
560
  const shaValue = shaLines.length === 1 ? shaLineRe.exec(shaLines[0])[1] : null;
495
561
  const lcovSha256 = shaValue !== null && shaValue !== 'none' ? shaValue : null;
562
+ // The attestation line, on the SAME exactly-one-anchored-line contract as the sha: a green
563
+ // exit status alone never proves the checker certified anything — it exits 0 both when it
564
+ // attests and when it withholds a verdict. Without this arm a gate that removed the start
565
+ // record mid-run would yield a green receipt carrying no coverage claim at all.
566
+ const attestLineRe = /^coverage-check: attested=(yes|no)$/;
567
+ const attestLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => attestLineRe.test(l));
568
+ const attested = attestLines.length === 1 ? attestLineRe.exec(attestLines[0])[1] : null;
569
+ if (allGreen && integrityFailure === null && lcovSha256 !== null) {
570
+ if (attestLines.length !== 1) {
571
+ integrityFailure = attestLines.length === 0
572
+ ? 'the coverage-check gate printed no attested= line — whether coverage was certified is unknowable (fail closed)'
573
+ : `the coverage-check gate printed ${attestLines.length} attested= lines — exactly ONE full machine line binds the receipt`;
574
+ } else if (attested !== 'yes') {
575
+ integrityFailure = 'the coverage-check gate consumed an lcov but did NOT certify it — the final run reached the checker without a valid attestation context';
576
+ }
577
+ }
496
578
  if (allGreen && integrityFailure === null) {
497
579
  if (shaLines.length !== 1) {
498
580
  integrityFailure = shaLines.length === 0
@@ -543,7 +625,10 @@ export const runCli = (argv, deps = {}) => {
543
625
  logError(`[run-gates] --final could not write its receipt: ${err.message}`);
544
626
  }
545
627
  }
546
- log(composeSummaryLine({ status: allGreen ? 'ok' : 'fail', results }));
628
+ // The summary line is the MACHINE report, so it must agree with the exit code: an integrity
629
+ // failure mints a RED receipt and exits finalFailed, and a line still saying status=ok there
630
+ // would be a silent green in the one place a reader parses instead of reads.
631
+ log(composeSummaryLine({ status: allGreen && finalError === null ? 'ok' : 'fail', results }));
547
632
  if (finalError) return EXIT.finalFailed;
548
633
  return allGreen ? EXIT.ok : EXIT.fail;
549
634
  } catch (err) {
@@ -149,6 +149,10 @@ export const KIT_READONLY_TOOLS = Object.freeze([
149
149
  // NO decision from the hook, which is not the same as an allow, so without this rule the lane
150
150
  // falls through to whatever the host policy happens to be.
151
151
  'tools/repo-search.mjs',
152
+ // The inventory lane, in the tier for the same reason and with the same dependency: the corpus of
153
+ // useless approvals is mostly small path questions batched into a composed shell because no single
154
+ // call answered them, and a lane the agent must still ask about is not a lane.
155
+ 'tools/path-inventory.mjs',
152
156
  ]);
153
157
  // Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
154
158
  // --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
@@ -342,7 +346,7 @@ const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--br
342
346
 
343
347
  Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
344
348
  Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
345
- --kit-tools additionally seeds the audited kit-tool tier: 9 read-only kit tools by resolved
349
+ --kit-tools additionally seeds the audited kit-tool tier: ${KIT_WILDCARD_TOOLS.length} read-only kit tools by resolved
346
350
  absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
347
351
  (project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
348
352
  preview byte-strings. Never touches settings.local.json.
@@ -644,7 +648,7 @@ const formatAllowlist = (result) => [
644
648
  // The tier's honest posture, printed on every --kit-tools run: run-gates is project-exec (never
645
649
  // "read-only"), previews stay dry-run-only, and the tier gets none of the hook's residual ask-net.
646
650
  const KIT_TIER_NOTICE =
647
- 'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage (settings-level posture only - see the velocity mode notes).';
651
+ 'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage EXCEPT repo-search.mjs and path-inventory.mjs, whose invocations the hook scans because they take caller-supplied argument bytes (settings-level posture only for the rest - see the velocity mode notes).';
648
652
 
649
653
  const formatKitTier = (result) =>
650
654
  result.kitTools