@sabaiway/agent-workflow-kit 4.1.0 → 4.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 CHANGED
@@ -4,6 +4,43 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 4.2.0 — a search whose pattern contains `>` no longer has to ask (AD-080)
8
+
9
+ **Read this if you have ever approved `grep -rn "=>" src`.** 4.1.0 explained why that prompt cannot
10
+ be fixed inside the guard. This release stops routing the search through the guard at all.
11
+
12
+ **New: `tools/repo-search.mjs`** — a literal, read-only repository search with two lanes.
13
+
14
+ - `--pattern <literal>` for an ordinary pattern.
15
+ - `--pattern-file <path>` for a pattern containing `>`, `` ` `` or `$(` — the bytes the residual scan
16
+ actually matches (`|` and `&&` do not trip it; they only take a command off the compound
17
+ read-lane). Write the pattern with your host's file-write tool and pass the plain path. **The
18
+ pattern's bytes never enter the command string**, so the scan has nothing to scan. That is not
19
+ obfuscation — encoding would disguise bytes that stay on the shell surface where bash can still run
20
+ them; these leave the surface entirely.
21
+
22
+ **You do not have to remember which lane to use.** The tool's invocation prefix is now in the hook's
23
+ scanned list, so picking the inline lane for a byte-carrying pattern earns a refusal that NAMES
24
+ `--pattern-file`. A wrong choice costs one guiding prompt; it never costs silence.
25
+
26
+ **The hook gained coverage, it did not lose any.** A non-core command never reached the residual scan
27
+ before — that is exactly why a plain kit tool is promptless — so a real redirection or command
28
+ substitution on this tool's own invocation would have gone unexamined. It is examined now, by the
29
+ UNCHANGED scan run over one more prefix. Nothing about what a byte MEANS was re-litigated; AD-079
30
+ stands, and the tool is deliberately NOT in the seeded read-only core, so it inherits no compound
31
+ read-lane allow.
32
+
33
+ **Scope, kept narrow on purpose.** Literal search only — an arbitrary synchronous regex cannot be
34
+ bounded by checks between work units, so the class is removed rather than mitigated. Only regular
35
+ files are read (a FIFO or device read hangs and defeats every bound). Any bound that fires returns a
36
+ structured `incomplete` naming which bound it was — never a silent empty result. The pattern file is
37
+ excluded from its own search by resolved path, so an exotic query cannot match itself.
38
+
39
+ **Honest residual.** Nothing forces the lane on a caller who ignores it: a bare `grep` prompts exactly
40
+ as before. A literal inline `$(` is still indistinguishable from an active one, permanently — use the
41
+ file lane. Bytes in search PATHS still over-ask. And promptlessness rests on your settings honouring
42
+ the tool's allow rule: the hook returning "no decision" is not the same as an allow.
43
+
7
44
  ## 4.1.0 — why the gate hook still over-asks, established rather than assumed (AD-079)
8
45
 
9
46
  **Read this if the hook has ever made you approve `grep -rn "=>" src` or a plain read wearing
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '4.1.0'
6
+ version: '4.2.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "4.1.0",
6
+ "version": "4.2.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "4.1.0",
3
+ "version": "4.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",
@@ -261,6 +261,33 @@ export const matchSeededCorePrefix = (command) => {
261
261
  return matched ? matched.join(' ') : null;
262
262
  };
263
263
 
264
+ // Kit tools whose invocations rung (b) scans even though they are NOT in the seeded read-only core.
265
+ // The distinction is deliberate and load-bearing: CORING one of these would also hand it the
266
+ // rung (c) read-lane allow, which is exactly what a tool taking caller-supplied arguments must not
267
+ // have. This SEPARATE list buys the residual coverage without that side effect.
268
+ //
269
+ // AD-079 line: this is the SAME unchanged scan run over a different prefix — it never asks what a
270
+ // byte MEANS, so it is on the permitted side. Nothing here parses or deletes a span.
271
+ // KIT-QUALIFIED, forward-slash canonical. Matching on the bare basename was the first attempt and
272
+ // review killed it: it would pull ANY unrelated `repo-search.mjs` into rung (b2), turning a
273
+ // pre-existing NO decision into an ASK — a change to a decision path this release has no business
274
+ // touching.
275
+ export const SCANNED_TOOL_PATHS = Object.freeze(['agent-workflow-kit/tools/repo-search.mjs']);
276
+
277
+ // Quotes stripped and separators canonicalised, so a relative, absolute, quoted or Windows-separated
278
+ // spelling all compare the same. SUBSTRING, not equality, and scanned across EVERY token rather than
279
+ // just the one after `node`: both reviewers found the strict form under-inclusive, one through node
280
+ // flags (`node --no-warnings <tool>`), one through an operator attached to the path
281
+ // (`node <tool>>out`). A missed invocation silently restores the unscanned behaviour on exactly the
282
+ // surface this exists to cover, while a spurious match merely over-asks — so inclusiveness wins.
283
+ const canonicalToken = (token) => token.replace(/["']/gu, '').replace(/\\/gu, '/');
284
+
285
+ export const matchScannedToolPrefix = (command) => {
286
+ const tokens = tokenizeCommand(command).map(canonicalToken);
287
+ const hit = SCANNED_TOOL_PATHS.find((path) => tokens.some((token) => token.includes(path)));
288
+ return hit ?? null;
289
+ };
290
+
264
291
  // String-level, conservative: the hook sees the PRE-SHELL command string, so every class is a raw
265
292
  // substring scan (never a whitespace-token check — a token check misses `"--output=f"` / `'>' f`
266
293
  // where the quotes are still in the string but the shell will strip them). A quoted metacharacter
@@ -380,6 +407,19 @@ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gate
380
407
  };
381
408
  }
382
409
  }
410
+ // (b2) the same guard over the scanned kit tools. The refusal NAMES the lane that avoids it, so a
411
+ // caller who picked the inline lane for a shell-significant pattern is corrected by the mechanism
412
+ // rather than expected to have remembered the rule.
413
+ const scannedTool = matchScannedToolPrefix(trimmed);
414
+ if (scannedTool !== null) {
415
+ const residualClasses = detectResidualClasses(trimmed);
416
+ if (residualClasses.length > 0) {
417
+ return {
418
+ permissionDecision: DECISION_ASK,
419
+ permissionDecisionReason: `agent-workflow residual guard: "${scannedTool}" carries ${residualClasses.join(' + ')} — pass the pattern with --pattern-file (its bytes then never enter the command string), or confirm by hand if the byte is really part of the invocation`,
420
+ };
421
+ }
422
+ }
383
423
  // (c) read-lane allow — opt-in (lanes.json), mode-fenced like (a) but cwd-agnostic (a read is a
384
424
  // read from any directory). Runs AFTER (b), so a residual-carrying core command still ASKs.
385
425
  if (readLaneOn === true && ALLOW_PERMISSION_MODES.includes(permissionMode) && isReadLaneCommand(trimmed)) {
@@ -33,10 +33,11 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
33
33
  - `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd ${PROJECT_ROOT}` (wildcard — the read-only deployment advisor; its rendered apply one-liners are writers and still prompt)
34
34
  - `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs --strict <skill-dir>` (wildcard)
35
35
  - `node ${CLAUDE_SKILL_DIR}/tools/release-scan.mjs <path>` (wildcard)
36
+ - `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead, so the bytes never enter the command string. This is the ONE tier tool that DOES carry hook residual coverage — see the honesty note below)
36
37
  - `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--final`** forms all still prompt (`--final` WRITES the final-run receipt into the core-evidence store — a recording run is never auto-approved).
37
38
  - Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
38
39
 
39
- Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
40
+ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with ONE deliberate exception, `repo-search.mjs`**, whose invocation prefix is in the hook's scanned list precisely because it takes caller-supplied argument bytes: a real redirection or command substitution on ITS invocation raises the ask, and the refusal names the `--pattern-file` lane that avoids it. It is NOT in the seeded core, so it never inherits the read-lane compound allow. Every other tier entry: the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
40
41
 
41
42
  **Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
42
43
 
@@ -16,6 +16,14 @@ and improvised shapes are where approval prompts come from. The bar:
16
16
  promptless by construction.
17
17
  - **Improvised file writes ride the host's file-edit tools** (Write/Edit or the equivalent) —
18
18
  never an ad-hoc heredoc or shell-redirect write.
19
+ - **Searching for TEXT is its own case.** A pattern carrying `>`, `` ` `` or `$(` prompts on a
20
+ seeded-core command however it is quoted — the guard scans the raw string and a quote-stripped
21
+ copy. Quoting is not a workaround. (`|`/`&&` do not trip it.) Use the host's search tool if it has
22
+ one; else, where the kit tier is seeded, `node <kit>/tools/repo-search.mjs --pattern <literal>`,
23
+ switching to `--pattern-file <path>` for a byte-carrying pattern — written with the file-write tool
24
+ above, so its bytes never enter the command string; else one plain command, accepting the prompt.
25
+ A wrong lane earns a refusal that NAMES the file lane. Residual: a bare `grep` still prompts, and
26
+ the file lane needs promptless host writes.
19
27
 
20
28
  **Scope — improvised shapes only.** The executable commands a mode doc itself prescribes (the
21
29
  `node …/tools/…` dispatch lines, `--apply` lanes, install/symlink steps) are OUTSIDE this
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ // repo-search.mjs — the promptless repository search lane (LITERAL search, read-only).
3
+ //
4
+ // WHY THIS EXISTS. A search whose pattern carries a shell-significant byte (`>`, `` ` ``, `$(`)
5
+ // cannot be issued as a seeded-core command without raising the residual ASK: the guard scans the
6
+ // raw command string AND a quote-stripped copy, so no quoting protects the byte. That is not a bug
7
+ // to fix in the guard — AD-079 closed that direction on four verified counterexamples. This tool
8
+ // routes around it instead, in two lanes:
9
+ //
10
+ // lane 1 --pattern <literal> for a pattern with no shell-significant byte
11
+ // lane 2 --pattern-file <path> the pattern's bytes NEVER enter the command string
12
+ //
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.
16
+ //
17
+ // CONTRACT
18
+ // LITERAL only — no regex dialect, and none is planned for this slice: a bounded walk cannot
19
+ // interrupt a catastrophically backtracking RegExp call, so the class is removed, not mitigated.
20
+ // Multiline patterns DO match (the search runs over the whole decoded buffer; a hit reports the
21
+ // line it starts on).
22
+ // Four outcomes, never collapsed: matches (0), no matches (0 with an explicitly empty result),
23
+ // INCOMPLETE (3, naming the bound that fired), invalid input (2), I/O failure (1).
24
+ // CONTAINMENT is decided on the REAL path, never lexically — a symlinked ancestor resolves out of
25
+ // the root while passing every `..` check, and on Windows `relative()` across drives returns an
26
+ // absolute path that contains no `..` at all.
27
+ // Every file is opened NO-FOLLOW and NON-BLOCKING, then `fstat`-ed on the descriptor actually
28
+ // opened: an lstat-then-read pair loses to a swap between the two calls, and the swapped-in FIFO
29
+ // is precisely the blocking read this tool promises cannot happen.
30
+ // Directories are walked INCREMENTALLY with the budget checked before each entry — reading and
31
+ // sorting a whole directory first is unbounded work in exactly the case bounds exist for.
32
+ //
33
+ // THREAT MODEL, stated rather than implied. Explicit `--path`/`--pattern-file` targets are
34
+ // resolved (so a symlink INSIDE the root is followed by design) and then containment-checked on
35
+ // the real path; the WALK never traverses a symlink at all. What is NOT defended against is an
36
+ // adversary mutating the tree DURING the walk: a directory swapped for a symlink between its
37
+ // lstat and its opendir would be traversed, and closing that needs descriptor-relative traversal
38
+ // (`openat` semantics) which dependency-free Node does not expose. This tool searches a workspace
39
+ // its own agent controls; concurrent hostile mutation is out of scope, and saying so is the
40
+ // honest close — an unstated residual would be the defect.
41
+ // Pure reader — no writes, no subprocess, no network. Dependency-free, Node >= 22, no side
42
+ // effects on import (the isDirectRun idiom).
43
+
44
+ import { openSync, fstatSync, readSync, closeSync, opendirSync, realpathSync, lstatSync, constants } from 'node:fs';
45
+ import { createHash } from 'node:crypto';
46
+ import { join, resolve, relative, isAbsolute, sep } from 'node:path';
47
+ import { pathToFileURL } from 'node:url';
48
+
49
+ export const EXIT_OK = 0;
50
+ export const EXIT_ERROR = 1;
51
+ export const EXIT_USAGE = 2;
52
+ export const EXIT_INCOMPLETE = 3;
53
+
54
+ export const DEFAULT_MAX_RESULTS = 200;
55
+ export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
56
+ export const DEFAULT_WALK_BUDGET = 20000;
57
+ // Hard ceilings: a caller-supplied bound may lower these, never raise them. Without a ceiling the
58
+ // bounds are advisory, which is the same as absent.
59
+ export const HARD_MAX_RESULTS = 100000;
60
+ export const HARD_MAX_FILE_BYTES = 64 * 1024 * 1024;
61
+ const BINARY_SNIFF_BYTES = 8192;
62
+ // Characters of context kept on EACH side of a match, and a HARD ceiling on the whole snippet.
63
+ // The ceiling is the load-bearing one: bounding only the context still lets a huge --pattern-file
64
+ // matched in many places accumulate, because the match itself rode into every snippet. With a total
65
+ // cap the stored size per match is a constant, so no size of pattern or file can grow it.
66
+ const SNIPPET_CONTEXT = 200;
67
+ const SNIPPET_MAX = 512;
68
+ const NEVER_WALKED = Object.freeze(['.git', 'node_modules']);
69
+ // O_NOFOLLOW refuses a symlinked leaf at open time; O_NONBLOCK means a FIFO that slipped in returns
70
+ // instead of hanging. Both are POSIX; on a platform lacking them the flags degrade to 0 and the
71
+ // fstat check below is the remaining guard.
72
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
73
+ const NONBLOCK = constants.O_NONBLOCK ?? 0;
74
+ const OPEN_FLAGS = constants.O_RDONLY | NOFOLLOW | NONBLOCK;
75
+
76
+ class UsageError extends Error {}
77
+ class IoError extends Error {}
78
+
79
+ // The pattern is echoed as a DIGEST plus a byte length — never as a first content line. A first
80
+ // line cannot separate two multiline patterns that share it, and it is unsafe for NUL/control
81
+ // bytes; a digest separates them and survives any byte.
82
+ export const patternDigest = (pattern) => {
83
+ const buf = Buffer.from(pattern, 'utf8');
84
+ return { digest: createHash('sha256').update(buf).digest('hex').slice(0, 16), bytes: buf.length };
85
+ };
86
+
87
+ // Exactly ONE trailing line ending is stripped — CRLF as one unit: a pattern file written by an
88
+ // editor almost always ends in one, while a pattern that deliberately ends in a blank line keeps it.
89
+ // Leaving a stray `\r` would make the search silently fail against LF content, which is the worst
90
+ // possible failure mode for a tool whose whole job is finding text.
91
+ export const resolvePattern = (raw) => {
92
+ if (raw.endsWith('\r\n')) return raw.slice(0, -2);
93
+ return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
94
+ };
95
+
96
+ // `Number()` turns a long digit string into Infinity, which would disable the very bound being
97
+ // parsed. Safe-integer and a hard ceiling are both required.
98
+ export const parseCount = (raw, flag, ceiling) => {
99
+ if (!/^\d{1,15}$/u.test(raw ?? '')) throw new UsageError(`${flag} needs a plain non-negative integer, got: ${raw ?? '(missing)'}`);
100
+ const n = Number(raw);
101
+ if (!Number.isSafeInteger(n)) throw new UsageError(`${flag} is not a safe integer: ${raw}`);
102
+ if (n > ceiling) throw new UsageError(`${flag} exceeds the hard ceiling ${ceiling}: ${raw}`);
103
+ return n;
104
+ };
105
+
106
+ const parseArgs = (argv) => {
107
+ const opts = { pattern: null, patternFile: null, paths: [], max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, json: false };
108
+ for (let i = 0; i < argv.length; i += 1) {
109
+ const arg = argv[i];
110
+ const next = () => {
111
+ i += 1;
112
+ if (i >= argv.length) throw new UsageError(`${arg} requires a value`);
113
+ return argv[i];
114
+ };
115
+ if (arg === '--pattern') opts.pattern = next();
116
+ else if (arg === '--pattern-file') opts.patternFile = next();
117
+ else if (arg === '--path') opts.paths.push(next());
118
+ else if (arg === '--max') opts.max = parseCount(next(), '--max', HARD_MAX_RESULTS);
119
+ else if (arg === '--max-bytes') opts.maxBytes = parseCount(next(), '--max-bytes', HARD_MAX_FILE_BYTES);
120
+ else if (arg === '--json') opts.json = true;
121
+ else throw new UsageError(`unknown argument: ${arg} (see --help)`);
122
+ }
123
+ if (opts.pattern !== null && opts.patternFile !== null) {
124
+ throw new UsageError('--pattern and --pattern-file are mutually exclusive — the lane must be unambiguous');
125
+ }
126
+ 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('.');
128
+ return opts;
129
+ };
130
+
131
+ // Containment on the REAL path. A lexical check passes `link/secret.txt` whenever `link` resolves
132
+ // outside, and on Windows a cross-drive `relative()` returns an absolute path carrying no `..` —
133
+ // both were live review findings, not hypotheticals.
134
+ export const resolveTarget = (realRoot, target) => {
135
+ const lexical = resolve(realRoot, target);
136
+ let real;
137
+ try {
138
+ real = realpathSync(lexical);
139
+ } catch (err) {
140
+ if (err?.code === 'ENOENT') throw new IoError(`no such path: ${target}`);
141
+ throw new IoError(`cannot resolve ${target} (${err?.code ?? err?.message ?? err})`);
142
+ }
143
+ const rel = relative(realRoot, real);
144
+ if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) {
145
+ throw new IoError(`target resolves outside the search root: ${target}`);
146
+ }
147
+ return real;
148
+ };
149
+
150
+ // Open → fstat the DESCRIPTOR → read bounded. The descriptor is what was actually opened, so a swap
151
+ // after the check cannot substitute a different node; O_NOFOLLOW refuses a symlinked leaf outright.
152
+ const readRegularFile = (abs, maxBytes, state, io = {}) => {
153
+ const open = io.open ?? openSync;
154
+ const fstat = io.fstat ?? fstatSync;
155
+ const read = io.read ?? readSync;
156
+ const close = io.close ?? closeSync;
157
+ let fd;
158
+ try {
159
+ fd = open(abs, OPEN_FLAGS);
160
+ } catch (err) {
161
+ // ELOOP is a symlink refused by O_NOFOLLOW — a counted skip, not an error.
162
+ if (err?.code === 'ELOOP') state.skipped.symlinks += 1;
163
+ else state.skipped.unreadable += 1;
164
+ return null;
165
+ }
166
+ try {
167
+ const stat = fstat(fd);
168
+ if (!stat.isFile()) {
169
+ state.skipped.special += 1;
170
+ return null;
171
+ }
172
+ if (stat.size > maxBytes) {
173
+ state.skipped.large += 1;
174
+ // A skipped file is NOT a silent omission: the search is incomplete and says which bound did
175
+ // it. Reporting it only as a counter would let a partial search read as "no matches".
176
+ if (state.incomplete === null) {
177
+ state.incomplete = { bound: 'max-file-bytes', detail: `at least one file exceeds ${maxBytes} byte(s) and was not searched` };
178
+ }
179
+ return null;
180
+ }
181
+ const buf = Buffer.allocUnsafe(stat.size);
182
+ let got = 0;
183
+ while (got < stat.size) {
184
+ const n = read(fd, buf, got, stat.size - got, got);
185
+ if (n <= 0) break;
186
+ got += n;
187
+ }
188
+ // A short read means the file changed under us. Returning the partial buffer would let a
189
+ // truncated file come back as a confident "no matches" — the file is classified unreadable
190
+ // instead, which is counted and visible.
191
+ if (got !== stat.size) {
192
+ state.skipped.unreadable += 1;
193
+ return null;
194
+ }
195
+ return buf;
196
+ } catch {
197
+ state.skipped.unreadable += 1;
198
+ return null;
199
+ } finally {
200
+ close(fd);
201
+ }
202
+ };
203
+
204
+ const isBinary = (buf) => buf.subarray(0, BINARY_SNIFF_BYTES).includes(0);
205
+
206
+ // Whole-buffer search, so a MULTILINE pattern matches; the line number is derived from the offset.
207
+ // The newline cursor is carried ACROSS matches rather than recounted from zero for each one — with
208
+ // the result cap at six figures, recounting is quadratic in the file length and burns the event
209
+ // loop on exactly the large files a search is aimed at.
210
+ const searchBuffer = (buf, pattern, relPath, state) => {
211
+ const text = buf.toString('utf8');
212
+ let from = 0;
213
+ let line = 1;
214
+ let counted = 0;
215
+ for (;;) {
216
+ const at = text.indexOf(pattern, from);
217
+ if (at === -1) return;
218
+ if (state.matches.length >= state.max) {
219
+ state.incomplete = { bound: 'max-results', detail: `stopped at ${state.max} result(s); more may exist` };
220
+ return;
221
+ }
222
+ for (let i = counted; i < at; i += 1) if (text.charCodeAt(i) === 10) line += 1;
223
+ counted = at;
224
+ // A pattern that STARTS on a newline would otherwise take that same byte as its own line start
225
+ // and report an empty snippet; searching back from the byte BEFORE it reports the line the match
226
+ // actually begins on.
227
+ const back = text.charCodeAt(at) === 10 ? Math.max(0, at - 1) : at;
228
+ const lineStart = text.lastIndexOf('\n', back) + 1;
229
+ // The snippet must span the WHOLE match: taking the line end from the match's START truncates a
230
+ // multiline pattern at its first newline, so the snippet could exclude the very text that
231
+ // matched (`\nbeta` in "alpha\nbeta" reported "alpha"). It is taken from the match's END.
232
+ const lineEndRaw = text.indexOf('\n', at + pattern.length);
233
+ const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
234
+ // The snippet is WINDOWED around the match, not the whole line. A minified file is one enormous
235
+ // line, and storing it per match turns a 200-result search into hundreds of megabytes — the
236
+ // bound has to apply to what is kept, not only to how many are kept.
237
+ const from0 = Math.max(lineStart, at - SNIPPET_CONTEXT);
238
+ const to0 = Math.min(lineEnd, at + pattern.length + SNIPPET_CONTEXT);
239
+ const windowed = `${from0 > lineStart ? '…' : ''}${text.slice(from0, to0)}${to0 < lineEnd ? '…' : ''}`;
240
+ const snippet = windowed.length > SNIPPET_MAX ? `${windowed.slice(0, SNIPPET_MAX - 1)}…` : windowed;
241
+ state.matches.push({ file: relPath, line, text: snippet });
242
+ from = at + Math.max(1, pattern.length);
243
+ }
244
+ };
245
+
246
+ const spend = (state) => {
247
+ if (state.walked >= state.walkBudget) {
248
+ if (state.incomplete === null) {
249
+ state.incomplete = { bound: 'walk-budget', detail: `stopped after ${state.walkBudget} entries; the tree was not fully traversed` };
250
+ }
251
+ return false;
252
+ }
253
+ state.walked += 1;
254
+ return true;
255
+ };
256
+
257
+ const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
258
+ if (state.incomplete !== null || !spend(state)) return;
259
+
260
+ let stat;
261
+ try {
262
+ stat = (state.io.lstat ?? lstatSync)(abs);
263
+ } catch {
264
+ state.skipped.unreadable += 1;
265
+ return;
266
+ }
267
+ if (stat.isSymbolicLink()) {
268
+ state.skipped.symlinks += 1;
269
+ return;
270
+ }
271
+ if (stat.isDirectory()) {
272
+ // The default prune never applies to a directory the caller NAMED: `--path node_modules` asking
273
+ // for nothing back, silently, is a worse answer than searching it.
274
+ if (!isExplicitTarget && NEVER_WALKED.includes(abs.split(sep).pop())) return;
275
+ let dir;
276
+ try {
277
+ dir = (state.io.opendir ?? opendirSync)(abs);
278
+ } catch {
279
+ state.skipped.unreadable += 1;
280
+ return;
281
+ }
282
+ try {
283
+ // Incremental: the budget is consulted before each entry, so one enormous directory cannot
284
+ // force unbounded work (or unbounded memory) before the first check.
285
+ for (;;) {
286
+ const entry = dir.readSync();
287
+ if (entry === null) break;
288
+ if (state.incomplete !== null) break;
289
+ walk(root, join(abs, entry.name), pattern, state);
290
+ }
291
+ } finally {
292
+ dir.closeSync();
293
+ }
294
+ return;
295
+ }
296
+ if (state.excludePath !== null && abs === state.excludePath) {
297
+ state.skipped.patternFile += 1;
298
+ return;
299
+ }
300
+ const buf = readRegularFile(abs, state.maxBytes, state, state.io);
301
+ if (buf === null) return;
302
+ if (isBinary(buf)) {
303
+ state.skipped.binary += 1;
304
+ return;
305
+ }
306
+ searchBuffer(buf, pattern, relative(root, abs) || abs, state);
307
+ };
308
+
309
+ // `io` injects the filesystem primitives so the failure branches — a vanished entry, an unopenable
310
+ // directory, a symlink refused by O_NOFOLLOW, a file truncated mid-read — are reachable from tests.
311
+ // Every one of them is a COUNTED skip in production, and a counted skip that no test ever exercises
312
+ // is indistinguishable from a silent one.
313
+ export const search = ({ root, pattern, paths, max, maxBytes, excludePath = null, walkBudget = DEFAULT_WALK_BUDGET, io = {} }) => {
314
+ const state = {
315
+ matches: [],
316
+ incomplete: null,
317
+ skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0 },
318
+ excludePath,
319
+ io,
320
+ walked: 0,
321
+ walkBudget,
322
+ max,
323
+ maxBytes,
324
+ };
325
+ for (const target of paths) {
326
+ // The target list is bounded too: a caller passing thousands of --path values would otherwise
327
+ // spend unbounded work resolving them while walk() returns immediately.
328
+ if (state.incomplete !== null) break;
329
+ walk(root, resolveTarget(root, target), pattern, state, true);
330
+ }
331
+ return {
332
+ pattern: patternDigest(pattern),
333
+ matches: state.matches,
334
+ incomplete: state.incomplete,
335
+ skipped: state.skipped,
336
+ scanned: state.walked,
337
+ };
338
+ };
339
+
340
+ const formatResult = (result) => {
341
+ const lines = [
342
+ `repo-search — literal pattern sha256:${result.pattern.digest} (${result.pattern.bytes} byte(s)) · ${result.scanned} entr(ies) scanned`,
343
+ ];
344
+ for (const m of result.matches) lines.push(`${m.file}:${m.line}: ${m.text}`);
345
+ if (result.matches.length === 0) lines.push(' no matches');
346
+ const skips = Object.entries(result.skipped).filter(([, n]) => n > 0);
347
+ if (skips.length) lines.push(` skipped: ${skips.map(([k, n]) => `${k}=${n}`).join(', ')}`);
348
+ if (result.incomplete) lines.push(` ⚠ INCOMPLETE (${result.incomplete.bound}): ${result.incomplete.detail}`);
349
+ return lines.join('\n');
350
+ };
351
+
352
+ const HELP = `repo-search — literal repository search that never has to ride a shell metacharacter.
353
+
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]
357
+
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.
362
+
363
+ LITERAL only, multiline patterns supported. Reads regular files only, opened no-follow. Skipped
364
+ entries (symlinks, non-regular, binary, oversized, unreadable) are counted and reported, never
365
+ dropped silently; an oversized file additionally makes the whole search INCOMPLETE.
366
+
367
+ Exit codes: 0 search completed (matches or an explicitly empty result) · 1 I/O failure or refusal ·
368
+ 2 usage / invalid input · 3 completed but INCOMPLETE (a bound fired; the bound is named).`;
369
+
370
+ export const main = (argv, ctx = {}) => {
371
+ try {
372
+ if (argv.includes('--help') || argv.includes('-h')) return { code: EXIT_OK, stdout: HELP, stderr: '', result: null };
373
+ const root = realpathSync(resolve(ctx.cwd ?? process.cwd()));
374
+ const opts = parseArgs(argv);
375
+ let raw;
376
+ let excludePath = null;
377
+ 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');
383
+ } else {
384
+ raw = opts.pattern;
385
+ }
386
+ const pattern = opts.patternFile !== null ? resolvePattern(raw) : raw;
387
+ if (pattern === '') throw new UsageError('the pattern is empty — it would match every line of every file');
388
+
389
+ const result = search({ root, pattern, paths: opts.paths, max: opts.max, maxBytes: opts.maxBytes, excludePath });
390
+ const stdout = opts.json ? JSON.stringify(result, null, 2) : formatResult(result);
391
+ return { code: result.incomplete ? EXIT_INCOMPLETE : EXIT_OK, stdout, stderr: '', result };
392
+ } catch (err) {
393
+ if (err instanceof UsageError) return { code: EXIT_USAGE, stdout: '', stderr: `repo-search: ${err.message}`, result: null };
394
+ if (err instanceof IoError) return { code: EXIT_ERROR, stdout: '', stderr: `repo-search: ${err.message}`, result: null };
395
+ return { code: EXIT_ERROR, stdout: '', stderr: `repo-search: ${err?.message ?? err}`, result: null };
396
+ }
397
+ };
398
+
399
+ const emitResult = (r) => {
400
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
401
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
402
+ process.exitCode = r.code;
403
+ };
404
+
405
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
406
+ if (isDirectRun) emitResult(main(process.argv.slice(2)));
@@ -144,6 +144,11 @@ export const KIT_READONLY_TOOLS = Object.freeze([
144
144
  KIT_RUN_GATES_TOOL,
145
145
  'tools/manifest/validate.mjs',
146
146
  'tools/release-scan.mjs',
147
+ // The literal search lane. It is in the tier for the ordinary reason (a pure reader the agent
148
+ // calls constantly), and its promptlessness additionally depends on it: a non-core command gets
149
+ // NO decision from the hook, which is not the same as an allow, so without this rule the lane
150
+ // falls through to whatever the host policy happens to be.
151
+ 'tools/repo-search.mjs',
147
152
  ]);
148
153
  // Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
149
154
  // --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps