@dougharness/cli 1.0.2 → 1.1.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.
Files changed (82) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -0
  19. package/dist/generate/config.js +4 -1
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +15 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
  41. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  42. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  43. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  44. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  45. package/node_modules/@dougharness/flow/package.json +2 -1
  46. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  47. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  48. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  49. package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
  50. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  51. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  52. package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
  53. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  54. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  55. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  56. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  57. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
  58. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  59. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  60. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  61. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  62. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  63. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  64. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  65. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  66. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  67. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  68. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  69. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  70. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  71. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  72. package/node_modules/@dougharness/gates/package.json +1 -1
  73. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  74. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
  75. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  76. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  77. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  78. package/package.json +3 -3
  79. package/templates/board-app.js +2 -2
  80. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  81. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  82. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -0,0 +1,225 @@
1
+ // Verified-tree record (card verified-tree-record): "these commands exited 0 on exactly this tree".
2
+ //
3
+ // computeTree hashes the WORKING tree (tracked, modified and untracked-but-not-ignored files) into a git tree
4
+ // sha through a private temp index, so the real index and the staged diff are never touched. A writer records
5
+ // the sha after a green run; a reader credits the record when the current tree is the recorded one, or differs
6
+ // only on preCommit.noTestPaths (the pre-commit rule), under the same Node and the same command set.
7
+ //
8
+ // Standing: a record is an honest-path record, not proof. A forged record credits Stop, SubagentStop, land and
9
+ // verify.mjs (accepted, same standing as the proposal ledger, decision 0006 amendment). The commit cannot be
10
+ // forged past: the pre-commit hook never reads it.
11
+ //
12
+ // Every export except resolveCommands never throws (hook readers fail open): failure returns null / false / no-op.
13
+ // resolveCommands is moved unchanged from stop-gate.mjs and expects a loaded config.
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { randomBytes } from "node:crypto";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ export const RECORD_RELPATH = ".doug/.state/verified-tree.json";
22
+ export const EXCLUDED = [".doug/.state", ".claude/worktrees", ".doug/anchor.md"];
23
+
24
+ const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
25
+ const GIT_FLAGS = ["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false"];
26
+
27
+ function cleanGitEnv(extra = {}) {
28
+ const env = {};
29
+ for (const [k, v] of Object.entries(process.env)) if (!k.startsWith("GIT_")) env[k] = v;
30
+ return { ...env, ...extra };
31
+ }
32
+
33
+ function git(dir, args, env) {
34
+ return spawnSync("git", [...GIT_FLAGS, ...args], { cwd: dir, encoding: "utf8", env, timeout: 10000, maxBuffer: 64 * 1024 * 1024 });
35
+ }
36
+
37
+ function unlinkQuiet(p) {
38
+ try {
39
+ unlinkSync(p);
40
+ } catch {
41
+ // already gone
42
+ }
43
+ }
44
+
45
+ export function computeTree(dir, opts) {
46
+ let tmpIndex = null;
47
+ try {
48
+ const tmpDir = (opts && opts.tmpDir) || join(dir, ".doug/.state");
49
+ const baseEnv = cleanGitEnv();
50
+ const where = git(dir, ["rev-parse", "--git-path", "index"], baseEnv);
51
+ if (where.status !== 0) return null;
52
+ const realIndex = resolve(dir, String(where.stdout).trim());
53
+ mkdirSync(tmpDir, { recursive: true });
54
+ tmpIndex = join(tmpDir, `verified-tree.index.${process.pid}.${randomBytes(4).toString("hex")}`);
55
+ if (existsSync(realIndex)) {
56
+ // Stat BEFORE copying: a git replacing the index between copy and stat would pair new times with old bytes.
57
+ const st = statSync(realIndex);
58
+ copyFileSync(realIndex, tmpIndex);
59
+ // git's racy-clean check compares entry mtimes with the index FILE's mtime; a fresh copy would look newer
60
+ // than the real index and let a same-size edit made in the same instant as the last index write read as
61
+ // unchanged (a stale blob in the tree). Keep the real index's times, floored to whole seconds (an older
62
+ // time is always the safe side, and it needs no nanosecond support).
63
+ utimesSync(tmpIndex, Math.floor(st.atimeMs / 1000), Math.floor(st.mtimeMs / 1000));
64
+ }
65
+ const env = cleanGitEnv({ GIT_INDEX_FILE: tmpIndex });
66
+ // No pathspec and no exclude pathspecs: an exclude of a gitignored dir makes `git add` exit 1.
67
+ if (git(dir, ["add", "-A"], env).status !== 0) return null;
68
+ // The seed may carry tracked entries for excluded paths (anchor.md is tracked in this repo).
69
+ if (git(dir, ["rm", "-r", "-q", "-f", "--cached", "--ignore-unmatch", "--", ...EXCLUDED], env).status !== 0) return null;
70
+ const staged = git(dir, ["ls-files", "-s"], env);
71
+ if (staged.status !== 0) return null;
72
+ if (String(staged.stdout).split("\n").some((l) => l.startsWith("160000 "))) return null;
73
+ const tags = git(dir, ["ls-files", "-v"], env);
74
+ if (tags.status !== 0) return null;
75
+ if (String(tags.stdout).split("\n").some((l) => /^[a-zS] /.test(l))) return null;
76
+ const w = git(dir, ["write-tree"], env);
77
+ if (w.status !== 0) return null;
78
+ const sha = String(w.stdout).trim();
79
+ return SHA_RE.test(sha) ? sha : null;
80
+ } catch {
81
+ return null;
82
+ } finally {
83
+ if (tmpIndex) {
84
+ unlinkQuiet(tmpIndex);
85
+ unlinkQuiet(`${tmpIndex}.lock`);
86
+ }
87
+ }
88
+ }
89
+
90
+ const isObj = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
91
+
92
+ export function validRecord(r) {
93
+ try {
94
+ return (
95
+ isObj(r) &&
96
+ r.version === 1 &&
97
+ typeof r.tree === "string" &&
98
+ SHA_RE.test(r.tree) &&
99
+ Array.isArray(r.commands) &&
100
+ r.commands.length > 0 &&
101
+ r.commands.every((c) => isObj(c) && typeof c.name === "string" && typeof c.command === "string" && c.command !== "" && Number.isInteger(c.exit) && typeof c.durationMs === "number") &&
102
+ isObj(r.totals) &&
103
+ typeof r.node === "string" &&
104
+ typeof r.at === "string"
105
+ );
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ export function readRecord(dir) {
112
+ try {
113
+ const r = JSON.parse(readFileSync(join(dir, RECORD_RELPATH), "utf8"));
114
+ return validRecord(r) ? r : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export function writeRecord(dir, record) {
121
+ let tmp = null;
122
+ try {
123
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
124
+ tmp = join(dir, `${RECORD_RELPATH}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`);
125
+ writeFileSync(tmp, JSON.stringify(record));
126
+ renameSync(tmp, join(dir, RECORD_RELPATH));
127
+ return true;
128
+ } catch {
129
+ if (tmp) unlinkQuiet(tmp);
130
+ return false;
131
+ }
132
+ }
133
+
134
+ export function removeRecord(dir) {
135
+ try {
136
+ unlinkSync(join(dir, RECORD_RELPATH));
137
+ } catch {
138
+ // ENOENT or unremovable: nothing to do
139
+ }
140
+ }
141
+
142
+ // The pre-commit rule (.githooks/pre-commit, lib/baseline.mjs): equal to the entry, or under an entry ending in `/`.
143
+ export function noTestPathHit(list, p) {
144
+ try {
145
+ return list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e));
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ export function resolveCommands(cfg) {
152
+ const out = [];
153
+ for (const entry of cfg.stopGate.commands || []) {
154
+ if (typeof entry !== "string") continue;
155
+ const named = cfg.commands && cfg.commands[entry];
156
+ out.push({ name: entry, command: typeof named === "string" ? named : entry });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ // -> { ok: true, diff } or { ok: false, why }.
162
+ export function creditable(dir, cfg, record, currentTree, commands) {
163
+ try {
164
+ if (!validRecord(record)) return { ok: false, why: "no valid record" };
165
+ if (!record.commands.every((c) => c.exit === 0)) return { ok: false, why: "record has a failing command" };
166
+ if (record.node !== process.version) return { ok: false, why: "record is from another Node version" };
167
+ if (typeof currentTree !== "string" || !SHA_RE.test(currentTree)) return { ok: false, why: "current tree unknown" };
168
+ const rec = new Set(record.commands.map((c) => c.command));
169
+ const now = new Set(commands);
170
+ if (rec.size !== now.size || ![...rec].every((c) => now.has(c))) return { ok: false, why: "command set differs" };
171
+ if (record.tree === currentTree) return { ok: true, diff: [] };
172
+ const list = cfg && cfg.preCommit && cfg.preCommit.noTestPaths;
173
+ if (!Array.isArray(list) || list.length === 0 || !list.every((e) => typeof e === "string" && e !== "")) return { ok: false, why: "tree differs and no noTestPaths list" };
174
+ const d = git(dir, ["diff-tree", "-r", "-z", "--name-only", "--no-renames", record.tree, currentTree], cleanGitEnv());
175
+ if (d.status !== 0) return { ok: false, why: "recorded tree unknown to git" };
176
+ const diff = String(d.stdout).split("\0").filter((p) => p !== "");
177
+ if (diff.length === 0) return { ok: false, why: "empty diff between differing trees" };
178
+ if (diff.includes(".doug/config.json")) return { ok: false, why: ".doug/config.json changed" };
179
+ const bad = diff.find((p) => !noTestPathHit(list, p));
180
+ if (bad !== undefined) return { ok: false, why: `${bad} changed and is not a no-test path` };
181
+ return { ok: true, diff };
182
+ } catch {
183
+ return { ok: false, why: "error" };
184
+ }
185
+ }
186
+
187
+ // CLI for the pre-commit hook: `node verified-tree.mjs tree [dir]` prints the tree sha (empty on failure);
188
+ // `node verified-tree.mjs record <treeBefore> <commandsJson> [dir]` writes a by-pre-commit record when the
189
+ // working tree is still treeBefore. Always exits 0.
190
+ function isMain() {
191
+ try {
192
+ return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ if (isMain()) {
198
+ try {
199
+ const [cmd, a, b, c] = process.argv.slice(2);
200
+ if (cmd === "tree") {
201
+ process.stdout.write(computeTree(a || process.cwd()) || "");
202
+ } else if (cmd === "record") {
203
+ const dir = c || process.cwd();
204
+ const after = computeTree(dir);
205
+ if (after && after === a) {
206
+ const commands = JSON.parse(b);
207
+ const totals = {};
208
+ for (const cm of commands) {
209
+ if (cm.totals && cm.totals.status === "ok") totals[cm.command] = cm.totals;
210
+ }
211
+ writeRecord(dir, {
212
+ version: 1,
213
+ tree: after,
214
+ node: process.version,
215
+ at: new Date().toISOString(),
216
+ by: "pre-commit",
217
+ commands: commands.map((cm) => ({ name: cm.name, command: cm.command, exit: cm.exit, durationMs: cm.durationMs })),
218
+ totals,
219
+ });
220
+ }
221
+ }
222
+ } catch {
223
+ // never fail the hook
224
+ }
225
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/gates",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "private": true,
5
5
  "description": "Deterministic Claude Code hooks: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, stop gate, re-anchor after compaction.",
6
6
  "type": "module",
@@ -19,10 +19,33 @@
19
19
  //
20
20
  // Known limits (round 2, major 2 - measured, not guessed): the cap is a budget nudge for researchers, not a
21
21
  // security boundary. Not counted at all: xargs curl; a for/while loop body (do curl ...); timeout 10 curl ...;
22
- // and any other wrapper resolveVerb does not skip (docker run ... curl, ssh host curl); other fetchers (gh api,
23
- // a node -e "fetch(...)" one-liner, python, etc.). Counted although no network call happens: curl --version,
24
- // curl --help. A shell -c script with several curls in it (bash -c 'curl a; curl b') counts once, since
25
- // commandForms reads the whole -c script as one form.
22
+ // eval curl ...; and any other wrapper resolveVerb does not skip (docker run ... curl, ssh host curl); other
23
+ // fetchers (gh api, a node -e "fetch(...)" one-liner, python, etc.). Counted although no network call happens:
24
+ // curl --version, curl --help. A shell -c script with several curls in it (bash -c 'curl a; curl b') counts
25
+ // once, since commandForms reads the whole -c script as one form.
26
+ //
27
+ // Loopback exemption (card research-cap-localhost, whole-command grammar, fix pass 5): a local model probe is not
28
+ // web research, so it is neither counted nor denied. The exemption applies only when the entire raw command
29
+ // string, before any splitting, is ONE simple fetch (isLoopbackFetch):
30
+ // 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick, a
31
+ // backslash or !; an unterminated quote is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-]
32
+ // and a redirect's > are allowed, so & | ; ( ) { } [ ] * ? ~ # $ ` \ ! < and a newline are never exempt.
33
+ // 2. The first word is exactly curl or wget: no path, wrapper, assignment or other case.
34
+ // 3. Every other word is an allowlisted option (value-less -s -S -f -L -i -v -g or --silent and similar,
35
+ // combinable in one short token; value-taking -o -H -d -X -w -m for curl, -O -T and --header for wget,
36
+ // whose value is any word and is never URL-checked), a redirect, or a loopback URL: host exactly localhost,
37
+ // 127.0.0.1 or [::1] with an optional scheme and port, matched whole (localhost.evil.com, localhost@evil.com
38
+ // and 127.0.0.1.nip.io are remote). [::1] is a glob unquoted, so it is exempt only inside quotes. At least
39
+ // one loopback URL is required; there is no other positional word.
40
+ // 4. A redirect is only >, >> or 2>, attached to or followed by one unquoted, charset-clean target that is
41
+ // exactly /dev/null or does not start with /dev/ (bash opens /dev/tcp and /dev/udp as sockets).
42
+ // A command outside the grammar is not an error: it is counted by bashFetchVerbs exactly as at HEAD, one count
43
+ // per split command whose first resolved fetch form is curl or wget. Only Bash curl/wget is affected; WebFetch
44
+ // and WebSearch always count. Anything doubtful counts: a compound line, a pipe, a wrapper, a mixed local and
45
+ // remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
46
+ // Accepted limits of the exemption: an env proxy (https_proxy=http://evil.com in the environment, not on the
47
+ // command) routes the request out; -L, and wget by default, follow a redirect a local server can point at a
48
+ // remote host; a ~/.curlrc or ~/.wgetrc can add a URL or a proxy.
26
49
  //
27
50
  // Known limit: several calls issued in one message can each read the same count before any of them appends, so
28
51
  // a single burst can push an agent's count past the cap by the number of calls it issues at once.
@@ -242,11 +265,116 @@ function splitTopLevel(command) {
242
265
  return results;
243
266
  }
244
267
 
268
+ const LOOPBACK_TOKEN = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#].*)?$/i;
269
+ const UNQUOTED_SAFE = /[A-Za-z0-9._:/=@%+,-]/;
270
+ const REDIRECT = /^2?>>?(.*)$/;
271
+ const REDIRECT_TARGET = /^[A-Za-z0-9._:/=@%+,-]+$/;
272
+
273
+ // Option allowlist. A loopback fetch may carry only these options; every other option (-K/--config, -x/--proxy,
274
+ // wget -i/--input-file, --url, --, curl -T, anything unknown) can hide a remote target or proxy. Value-less flags
275
+ // may be combined in one short token; a value-taking letter may come last with its value attached (-qO-,
276
+ // -sSo/tmp/x). A value is never URL-checked.
277
+ const FETCH_OPTIONS = {
278
+ curl: {
279
+ flags: new Set("sSfLivg"),
280
+ longFlags: new Set(["silent", "show-error", "fail", "location", "include", "verbose", "compressed", "no-progress-meter"]),
281
+ valueShort: new Set("oHdXwm"),
282
+ valueLong: new Set(["output", "header", "data", "data-raw", "data-binary", "json", "request", "write-out", "max-time", "connect-timeout"]),
283
+ },
284
+ wget: {
285
+ flags: new Set("q"),
286
+ longFlags: new Set(["quiet"]),
287
+ valueShort: new Set("OT"),
288
+ valueLong: new Set(["output-document", "header", "post-data", "timeout"]),
289
+ },
290
+ };
291
+
292
+ // Words of one simple command, or null when the text leaves the grammar's character set (rule 1). Each word keeps
293
+ // its raw source text (quotes included), its unquoted value, and whether it holds an unquoted redirect ">".
294
+ function tokenize(text) {
295
+ const words = [];
296
+ let raw = "";
297
+ let val = "";
298
+ let gt = false;
299
+ let open = false;
300
+ let quote = "";
301
+ const end = () => {
302
+ if (open) words.push({ raw, val, gt });
303
+ raw = "";
304
+ val = "";
305
+ gt = false;
306
+ open = false;
307
+ };
308
+ for (const ch of text) {
309
+ if (quote) {
310
+ raw += ch;
311
+ if (ch === quote) quote = "";
312
+ else if (quote === '"' && "$`\\!".includes(ch)) return null;
313
+ else val += ch;
314
+ } else if (ch === " " || ch === "\t") end();
315
+ else {
316
+ open = true;
317
+ raw += ch;
318
+ if (ch === "'" || ch === '"') quote = ch;
319
+ else if (ch === ">") {
320
+ gt = true;
321
+ val += ch;
322
+ } else if (UNQUOTED_SAFE.test(ch)) val += ch;
323
+ else return null;
324
+ }
325
+ }
326
+ if (quote) return null;
327
+ end();
328
+ return words;
329
+ }
330
+
331
+ // True only when the whole raw command is one simple loopback fetch (see the header). Never throws: string tests
332
+ // only; anything outside the grammar returns false and is counted as at HEAD.
333
+ function isLoopbackFetch(command) {
334
+ const words = tokenize(String(command).trim());
335
+ if (!words || words.length < 2 || !Object.hasOwn(FETCH_OPTIONS, words[0].raw)) return false;
336
+ const opts = FETCH_OPTIONS[words[0].raw];
337
+ let urls = 0;
338
+ let wantValue = false;
339
+ for (let k = 1; k < words.length; k += 1) {
340
+ const { raw, val, gt } = words[k];
341
+ if (gt) {
342
+ const m = REDIRECT.exec(raw);
343
+ if (!m) return false;
344
+ let target = m[1];
345
+ if (target === "") {
346
+ k += 1;
347
+ if (k >= words.length) return false;
348
+ target = words[k].raw;
349
+ }
350
+ if (!REDIRECT_TARGET.test(target) || (target.startsWith("/dev/") && target !== "/dev/null")) return false;
351
+ } else if (wantValue) wantValue = false;
352
+ else if (raw.startsWith("--")) {
353
+ const eq = val.indexOf("=");
354
+ const name = val.slice(2, eq === -1 ? undefined : eq);
355
+ if (opts.valueLong.has(name)) wantValue = eq === -1;
356
+ else if (eq !== -1 || !opts.longFlags.has(name)) return false;
357
+ } else if (raw.startsWith("-")) {
358
+ if (raw.length === 1) return false;
359
+ for (let c = 1; c < raw.length; c += 1) {
360
+ if (opts.flags.has(raw[c])) continue;
361
+ if (!opts.valueShort.has(raw[c])) return false;
362
+ wantValue = c === raw.length - 1;
363
+ break;
364
+ }
365
+ } else if (LOOPBACK_TOKEN.test(val) && !/\s/.test(val)) urls += 1;
366
+ else return false;
367
+ }
368
+ return urls > 0;
369
+ }
370
+
245
371
  // Returns one verb ("curl" or "wget") per split command of a Bash command line that resolves to a fetch;
246
372
  // non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
247
373
  // fetches. commandForms runs on each split command's original text with its quotes intact (it does its own
248
- // quote parsing for a shell -c script), never on stripped or reconstructed text.
374
+ // quote parsing for a shell -c script), never on stripped or reconstructed text. A command that is one simple
375
+ // loopback fetch (isLoopbackFetch) is not a fetch to count.
249
376
  function bashFetchVerbs(command) {
377
+ if (isLoopbackFetch(command)) return [];
250
378
  const verbs = [];
251
379
  for (const cmd of splitTopLevel(command)) {
252
380
  for (const form of commandForms(cmd)) {