@dougharness/cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse (Edit|Write|MultiEdit): run the configured formatter on the edited file(s).
3
+ // Silent on success. On failure, tells Claude what the formatter said (usually a syntax error).
4
+
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { extname, join } from "node:path";
8
+ import { runHook, allow, addContext } from "../lib/io.mjs";
9
+ import { loadConfig, projectDir } from "../lib/config.mjs";
10
+ import { toProjectRelative, filePathsFromToolInput } from "../lib/paths.mjs";
11
+ import { tail, commandEnv } from "../lib/run.mjs";
12
+
13
+ runHook("format-on-edit", async (input) => {
14
+ const dir = projectDir(input);
15
+ const cfg = loadConfig(dir);
16
+ const fmt = cfg.formatter;
17
+ if (!fmt || !Array.isArray(fmt.command) || fmt.command.length === 0) return allow();
18
+
19
+ const targets = [];
20
+ for (const p of filePathsFromToolInput(input.tool_input)) {
21
+ const rel = toProjectRelative(dir, p);
22
+ if (!rel) continue;
23
+ if (fmt.extensions && fmt.extensions.length && !fmt.extensions.includes(extname(rel))) continue;
24
+ if (!existsSync(join(dir, rel))) continue;
25
+ if (!targets.includes(rel)) targets.push(rel);
26
+ }
27
+ if (targets.length === 0) return allow();
28
+
29
+ const [bin, ...args] = fmt.command;
30
+ const res = spawnSync(bin, [...args, ...targets], {
31
+ cwd: dir,
32
+ encoding: "utf8",
33
+ timeout: fmt.timeoutMs || 20000,
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ env: commandEnv(),
36
+ });
37
+ if (res.status === 0) return allow();
38
+
39
+ const output = tail((res.stderr || "") + "\n" + (res.stdout || ""), 20);
40
+ return addContext(
41
+ "PostToolUse",
42
+ `[doug] Formatter "${fmt.command.join(" ")}" failed on ${targets.join(", ")} (exit ${res.status ?? "timeout"}). ` +
43
+ `This usually means the file no longer parses. Output:\n${output}`,
44
+ );
45
+ });
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (Bash): deny --no-verify, force-push to protected branches, destructive commands,
3
+ // and the wrong package manager. Rules live in lib/bash-rules.mjs and are unit-tested.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { runHook, allow, denyTool } from "../lib/io.mjs";
7
+ import { loadConfig, projectDir } from "../lib/config.mjs";
8
+ import { evaluateBash } from "../lib/bash-rules.mjs";
9
+ import { recordCommand } from "../lib/state.mjs";
10
+
11
+ function currentBranch(dir) {
12
+ // symbolic-ref works even before the first commit; rev-parse does not.
13
+ try {
14
+ return execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 }).trim();
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ runHook("guard-bash", async (input) => {
21
+ const command = input.tool_input && input.tool_input.command;
22
+ if (!command) return allow();
23
+ const dir = projectDir(input);
24
+ const cfg = loadConfig(dir);
25
+ // Only resolve the branch when a push or a reset is involved; keeps the hot path at zero subprocesses.
26
+ const branch = /git\s+(push|reset)\b/.test(command) ? currentBranch(dir) : null;
27
+ const reasons = evaluateBash(command, { config: cfg, currentBranch: branch });
28
+ if (reasons.length) return denyTool(reasons.join(" "));
29
+ // An allowed command is about to run: the stop gate reads these as evidence that the session verified its work.
30
+ recordCommand(dir, input.session_id, command);
31
+ return allow();
32
+ });
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (Edit|Write|MultiEdit|NotebookEdit): deny edits to protected paths.
3
+ // Protected patterns come from .doug/config.json `protectedPaths` (globs, ".gitignore"-like).
4
+ // Bash writes are not intercepted here; the stop gate scans `git status` for protected changes.
5
+
6
+ import { isAbsolute, resolve } from "node:path";
7
+ import { runHook, allow, denyTool } from "../lib/io.mjs";
8
+ import { loadConfig, projectDir } from "../lib/config.mjs";
9
+ import { matchAny } from "../lib/glob.mjs";
10
+ import { toProjectRelative, filePathsFromToolInput, isAllowedOutside, realpathDeepestExisting } from "../lib/paths.mjs";
11
+
12
+ runHook("protect-paths", async (input) => {
13
+ const dir = projectDir(input);
14
+ const cfg = loadConfig(dir);
15
+ const realDir = realpathDeepestExisting(dir);
16
+ const paths = filePathsFromToolInput(input.tool_input);
17
+ if (paths.length === 0) return allow();
18
+
19
+ for (const p of paths) {
20
+ const abs = isAbsolute(p) ? p : resolve(dir, p);
21
+ const realAbs = realpathDeepestExisting(abs);
22
+ const rel = toProjectRelative(realDir, realAbs);
23
+ if (rel === null) {
24
+ // Outside the project even after resolving aliases: allowed only when an allowedOutsidePaths entry
25
+ // covers it.
26
+ if (isAllowedOutside(realAbs, cfg.allowedOutsidePaths)) continue;
27
+ return denyTool(
28
+ `Refusing to edit ${p}: it is outside the project directory ${dir}, and no allowedOutsidePaths entry in .doug/config.json covers it.`,
29
+ );
30
+ }
31
+ const hit = matchAny(cfg.protectedPaths, rel);
32
+ if (hit) {
33
+ return denyTool(
34
+ `Refusing to edit ${rel}: it matches protected path pattern "${hit}" in .doug/config.json. ` +
35
+ `If this file must change, ask the user to edit it or to adjust protectedPaths.`,
36
+ );
37
+ }
38
+ // Decisions/rules (card memory-decisions): written only through an approved proposal diff.
39
+ const proposalHit = matchAny(cfg.proposalPaths, rel);
40
+ if (proposalHit) {
41
+ return denyTool(
42
+ `Refusing to edit ${rel}: it matches proposal-only path "${proposalHit}" in .doug/config.json. ` +
43
+ `Decisions and rules are written only through an approved proposal: /doug-decide, or ` +
44
+ `memory.mjs decision|rule propose then learn.mjs apply.`,
45
+ );
46
+ }
47
+ }
48
+ return allow();
49
+ });
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart (compact|resume): re-inject the few facts that must survive compaction. Sources: `anchor` lines
3
+ // and `commands` in .doug/config.json, plus .doug/anchor.md if the user or a workflow wrote a current-task
4
+ // anchor; when that file holds a compaction snapshot (see below), the snapshot goes first so the 4000-character
5
+ // cap never cuts it. Deterministic, no model calls.
6
+ // PreCompact: before compaction, snapshot the plan, task ownership, decisions, this session's edited files and
7
+ // recent commands, and (card precompact-keeps-handoff) a Handoff boundary block naming the compaction trigger,
8
+ // the Stop gate's last result when one was recorded, the current HEAD, and the context percentage when one was
9
+ // recorded, into .doug/anchor.md (lib/anchor.mjs); tell the user via a top-level systemMessage that the snapshot
10
+ // was taken (this build's hook-output validator rejects hookSpecificOutput.hookEventName "PreCompact"/
11
+ // "PostCompact", so no hookSpecificOutput is emitted for either event; the model-facing re-anchor rides on
12
+ // SessionStart(compact)).
13
+ // PostCompact: clears state.context (card context-window-handoff), so a context reading recorded before the cut
14
+ // cannot produce a handoff notice after it, then allow() with no output. The payload's token estimates are
15
+ // ignored: nothing here estimates tokens (decision 0002 #4).
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { runHook, allow, addContext, systemMessage } from "../lib/io.mjs";
20
+ import { loadConfig, projectDir } from "../lib/config.mjs";
21
+ import { loadState, saveState } from "../lib/state.mjs";
22
+ import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection, splitSnapshot } from "../lib/anchor.mjs";
23
+
24
+ runHook("reanchor", async (input) => {
25
+ const dir = projectDir(input);
26
+ const cfg = loadConfig(dir);
27
+ const anchorFile = join(dir, ".doug/anchor.md");
28
+
29
+ if (input.hook_event_name === "PreCompact") {
30
+ const state = loadState(dir, input.session_id);
31
+ const section = snapshotSection({
32
+ plan: readPlanSummary(dir),
33
+ decisions: readDecisions(dir),
34
+ state,
35
+ now: new Date(),
36
+ handoff: {
37
+ boundary: `compaction (${typeof input.trigger === "string" && input.trigger ? input.trigger : "auto"})`,
38
+ gate: state.lastGate ? { ok: state.lastGate.ok, at: state.lastGate.at } : null,
39
+ head: gitHead(dir),
40
+ contextPct: state.context && Number.isFinite(state.context.pct) ? state.context.pct : undefined,
41
+ },
42
+ });
43
+ mkdirSync(join(dir, ".doug"), { recursive: true });
44
+ const before = existsSync(anchorFile) ? readFileSync(anchorFile, "utf8") : "";
45
+ writeFileSync(anchorFile, mergeSnapshot(before, section));
46
+ return systemMessage(
47
+ "[doug] Compacting: .doug/anchor.md now holds a snapshot of the plan, task ownership, decisions, and this session's edited files and recent commands; the SessionStart hook re-injects it after compaction.",
48
+ );
49
+ }
50
+
51
+ if (input.hook_event_name === "PostCompact") {
52
+ // Clear the recorded context reading (card context-window-handoff) so a pre-compaction reading cannot
53
+ // produce a handoff notice after the cut.
54
+ const state = loadState(dir, input.session_id);
55
+ if (state.context !== undefined) {
56
+ delete state.context;
57
+ saveState(dir, input.session_id, state);
58
+ }
59
+ return allow();
60
+ }
61
+
62
+ const parts = [];
63
+ if (Array.isArray(cfg.anchor) && cfg.anchor.length) {
64
+ parts.push("Project facts that must hold:\n" + cfg.anchor.map((l) => `- ${l}`).join("\n"));
65
+ }
66
+ const cmds = Object.entries(cfg.commands || {}).filter(([, v]) => typeof v === "string" && v);
67
+ if (cmds.length) {
68
+ parts.push("Commands:\n" + cmds.map(([k, v]) => `- ${k}: ${v}`).join("\n"));
69
+ }
70
+ if (existsSync(anchorFile)) {
71
+ const text = readFileSync(anchorFile, "utf8").trim();
72
+ if (text) {
73
+ const { snapshot, rest } = splitSnapshot(text);
74
+ const ordered = snapshot ? (rest ? `${snapshot}\n\n${rest}` : snapshot) : text;
75
+ parts.push("Current task anchor (.doug/anchor.md):\n" + ordered.slice(0, 4000));
76
+ }
77
+ }
78
+ if (parts.length === 0) return allow();
79
+
80
+ return addContext("SessionStart", `[doug] Context was compacted or resumed. Re-anchoring.\n\n${parts.join("\n\n")}`);
81
+ });
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (WebFetch|WebSearch|Bash): card research-fetch-cap, extended by card research-cap-bash-fetch.
3
+ // Researchers ran unbounded searches and fetches and took a computer down (four of five researchers burned all
4
+ // 30 turns fetching and wrote nothing). This caps each subagent's combined WebSearch, WebFetch, and Bash
5
+ // curl/wget calls at research.maxFetches (default 6, .doug/config.json), denying the call past the cap and
6
+ // telling the agent to write its findings now instead.
7
+ //
8
+ // Counted from a per-agent counter file, not the run trace: trace.mjs runs as a separate PreToolUse hook on the
9
+ // same event, in parallel with this one, so the current call's own trace line may or may not exist yet when
10
+ // this hook reads it. A counter file this hook owns and appends to itself has no such race with another hook.
11
+ //
12
+ // Bash counting (card research-cap-bash-fetch): a Bash command is split into its top-level simple commands by
13
+ // this script's own splitTopLevel (quote- and heredoc-aware; see its comment below, round 2) and each split
14
+ // command's resolved forms (bash-rules commandForms, which walks past prefixes such as sudo, env, an env
15
+ // assignment, and a shell's -c script) are checked for a first word whose basename, lower-cased, is curl or
16
+ // wget. A split command counts as one fetch when any of its forms resolves that way; an ordinary Bash command
17
+ // with no curl/wget form is never counted and never denied by this hook. This hook now spawns on every
18
+ // subagent's and every main-session Bash call, not only its WebFetch/WebSearch calls.
19
+ //
20
+ // Known limits (round 2, major 2 - measured, not guessed): the cap is a budget nudge for researchers, not a
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.
26
+ //
27
+ // Known limit: several calls issued in one message can each read the same count before any of them appends, so
28
+ // a single burst can push an agent's count past the cap by the number of calls it issues at once.
29
+ //
30
+ // Known limits (round 2 reviewer, measured against this script): splitTopLevel tracks quotes and heredocs only,
31
+ // so it still miscounts (a false count, and a false denial at the cap) text after an unquoted "#" comment that
32
+ // contains a separator and the verb (`ls -la # see notes; curl https://x` counts one; `curl https://x # note;
33
+ // wget y` counts two), an ANSI-C quoted string (`echo $'it\'s; curl x'`), and a backslash line-continuation
34
+ // whose next line begins with the bare verb. A shell -c script is seen only when the fetch is its first
35
+ // command: `bash -c 'curl a; curl b'` counts one, `bash -c "echo hi; curl https://x"` and
36
+ // `sh -c 'cd /tmp && curl https://x'` count zero, and so does `if true; then curl https://x; fi`. Also not
37
+ // counted at all: command substitution `$(curl ...)`, backticks, a `( ... )` subshell, process substitution
38
+ // `<(curl ...)`, and a quoted or backslashed verb (`"curl"`, `\curl`). The count is read before the appends, so
39
+ // one command with several fetches can push an agent's count past the cap by that command's own number of
40
+ // fetches (five used plus a command with three fetches ends at eight of six) - the same shape as the several-
41
+ // calls-in-one-message limit above.
42
+ //
43
+ // Fail-open, visibly (P3, card research-fetch-cap): this script has no local try/catch of its own. An I/O error
44
+ // (e.g. the counter path is unreadable) propagates to runHook (lib/io.mjs), which allows the call but emits a
45
+ // systemMessage naming this hook, so the failure is visible instead of silently swallowed here.
46
+
47
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
48
+ import { join } from "node:path";
49
+ import { runHook, allow, denyTool } from "../lib/io.mjs";
50
+ import { loadConfig, projectDir } from "../lib/config.mjs";
51
+ import { commandForms } from "../lib/bash-rules.mjs";
52
+
53
+ const RESEARCH_CAP_DIR_RELPATH = ".doug/.state/research-cap";
54
+ const FETCH_VERBS = new Set(["curl", "wget"]);
55
+
56
+ function safeId(id) {
57
+ return String(id || "")
58
+ .replace(/[^A-Za-z0-9_.-]/g, "_")
59
+ .replace(/\.{2,}/g, "_")
60
+ .slice(0, 120);
61
+ }
62
+
63
+ function counterFile(dir, sessionId, agentId) {
64
+ // A missing or empty session id falls back to "no-session", like the trace hook's safeId (P5): the agent id
65
+ // is never empty at this point (the caller already returned early when it was).
66
+ return join(dir, RESEARCH_CAP_DIR_RELPATH, safeId(sessionId || "no-session"), `${safeId(agentId)}.log`);
67
+ }
68
+
69
+ function countCalls(file) {
70
+ if (!existsSync(file)) return 0;
71
+ return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).length;
72
+ }
73
+
74
+ // A form's first word, with its basename lower-cased (a path prefix stripped, matching bash-rules'
75
+ // case-insensitive-filesystem reasoning): "sudo curl https://x" -> "sudo", "/usr/bin/curl https://x" -> "curl".
76
+ function firstWordBasename(form) {
77
+ const first = String(form).trim().split(/\s+/)[0] || "";
78
+ return first.slice(first.lastIndexOf("/") + 1).toLowerCase();
79
+ }
80
+
81
+ // Round 2 (card research-cap-bash-fetch, major 1): splits a compound Bash command line into its top-level
82
+ // simple commands, quote- and heredoc-aware, walking the string once. Unlike bash-rules' splitCommands (which
83
+ // splits on ; && || | & and newline everywhere, quote-blind), this only splits on those outside single quotes,
84
+ // outside double quotes (a backslash-escaped \" inside double quotes does not close them), and outside a
85
+ // heredoc body - so quoted text that merely mentions ";" or "curl" is never miscounted as a separate command.
86
+ //
87
+ // A heredoc starts at <<WORD, <<-WORD, <<'WORD', or <<"WORD" (never <<<, a here-string: three or more "<" in a
88
+ // row is never treated as a heredoc operator). Its body is every line after the operator's line up to the line
89
+ // that is exactly WORD (a <<- terminator may be indented with leading tabs); that body is dropped from the
90
+ // command text entirely, and the newline that closes the terminator line is treated like any other top-level
91
+ // newline (it ends the command that carried the heredoc). An unterminated quote or heredoc has no closing
92
+ // point, so it swallows the rest of the string into the current command - fewer splits than a real shell would
93
+ // make, never more. Never throws: a malformed <<-with-no-word falls back to literal "<<" text; a non-string
94
+ // command returns [].
95
+ function splitTopLevel(command) {
96
+ if (typeof command !== "string") return [];
97
+ const results = [];
98
+ let current = "";
99
+ const push = () => {
100
+ const trimmed = current.trim();
101
+ if (trimmed) results.push(trimmed);
102
+ current = "";
103
+ };
104
+
105
+ let inSingle = false;
106
+ let inDouble = false;
107
+ let heredocWord = null; // trigger seen on the current line, body starts at the next newline
108
+ let heredocDash = false;
109
+ let inHeredocBody = false;
110
+ let heredocLine = ""; // buffers the body line currently being read, to compare against heredocWord
111
+
112
+ const n = command.length;
113
+ let i = 0;
114
+ while (i < n) {
115
+ const ch = command[i];
116
+
117
+ if (inHeredocBody) {
118
+ if (ch === "\n") {
119
+ const line = heredocDash ? heredocLine.replace(/^\t+/, "") : heredocLine;
120
+ heredocLine = "";
121
+ if (line === heredocWord) {
122
+ inHeredocBody = false;
123
+ heredocWord = null;
124
+ push(); // the terminator's newline ends the command that carried the heredoc, like any other newline
125
+ }
126
+ i += 1;
127
+ continue;
128
+ }
129
+ heredocLine += ch;
130
+ i += 1;
131
+ continue;
132
+ }
133
+
134
+ if (inSingle) {
135
+ current += ch;
136
+ if (ch === "'") inSingle = false;
137
+ i += 1;
138
+ continue;
139
+ }
140
+
141
+ if (inDouble) {
142
+ if (ch === "\\" && command[i + 1] === '"') {
143
+ current += ch + command[i + 1];
144
+ i += 2;
145
+ continue;
146
+ }
147
+ current += ch;
148
+ if (ch === '"') inDouble = false;
149
+ i += 1;
150
+ continue;
151
+ }
152
+
153
+ if (ch === "'") {
154
+ inSingle = true;
155
+ current += ch;
156
+ i += 1;
157
+ continue;
158
+ }
159
+ if (ch === '"') {
160
+ inDouble = true;
161
+ current += ch;
162
+ i += 1;
163
+ continue;
164
+ }
165
+
166
+ if (ch === "<") {
167
+ let j = i;
168
+ while (command[j] === "<") j += 1;
169
+ const run = j - i;
170
+ if (run !== 2) {
171
+ // one "<" (a plain redirect) or three-plus (a here-string, <<<, or longer): never a heredoc operator
172
+ current += command.slice(i, j);
173
+ i = j;
174
+ continue;
175
+ }
176
+ let k = j;
177
+ let dash = false;
178
+ if (command[k] === "-") {
179
+ dash = true;
180
+ k += 1;
181
+ }
182
+ while (command[k] === " " || command[k] === "\t") k += 1;
183
+ let word = "";
184
+ if (command[k] === "'" || command[k] === '"') {
185
+ const q = command[k];
186
+ k += 1;
187
+ while (k < n && command[k] !== q) {
188
+ word += command[k];
189
+ k += 1;
190
+ }
191
+ if (k < n) k += 1; // skip the closing quote
192
+ } else {
193
+ while (k < n && /[^\s;&|<>()]/.test(command[k])) {
194
+ word += command[k];
195
+ k += 1;
196
+ }
197
+ }
198
+ if (!word) {
199
+ // no word found after "<<": not a valid heredoc trigger, treat the two "<" as ordinary text
200
+ current += command.slice(i, j);
201
+ i = j;
202
+ continue;
203
+ }
204
+ current += command.slice(i, k); // the operator text itself is part of the command that carries it
205
+ heredocWord = word;
206
+ heredocDash = dash;
207
+ i = k;
208
+ continue;
209
+ }
210
+
211
+ if (ch === "\n") {
212
+ if (heredocWord !== null) {
213
+ inHeredocBody = true;
214
+ heredocLine = "";
215
+ i += 1;
216
+ continue;
217
+ }
218
+ push();
219
+ i += 1;
220
+ continue;
221
+ }
222
+ if (ch === "&" && command[i + 1] === "&") {
223
+ push();
224
+ i += 2;
225
+ continue;
226
+ }
227
+ if (ch === "|" && command[i + 1] === "|") {
228
+ push();
229
+ i += 2;
230
+ continue;
231
+ }
232
+ if (ch === ";" || ch === "|" || ch === "&") {
233
+ push();
234
+ i += 1;
235
+ continue;
236
+ }
237
+
238
+ current += ch;
239
+ i += 1;
240
+ }
241
+ push(); // an unterminated quote or heredoc body just folds whatever is left into this last command
242
+ return results;
243
+ }
244
+
245
+ // Returns one verb ("curl" or "wget") per split command of a Bash command line that resolves to a fetch;
246
+ // non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
247
+ // 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.
249
+ function bashFetchVerbs(command) {
250
+ const verbs = [];
251
+ for (const cmd of splitTopLevel(command)) {
252
+ for (const form of commandForms(cmd)) {
253
+ const verb = firstWordBasename(form);
254
+ if (FETCH_VERBS.has(verb)) {
255
+ verbs.push(verb);
256
+ break;
257
+ }
258
+ }
259
+ }
260
+ return verbs;
261
+ }
262
+
263
+ runHook("research-cap", async (input) => {
264
+ const agentId = input.agent_id;
265
+ if (!agentId) return allow(); // the main session is never capped
266
+
267
+ const dir = projectDir(input);
268
+ const cfg = loadConfig(dir);
269
+ const max = cfg.research && cfg.research.maxFetches;
270
+ if (!Number.isInteger(max) || max <= 0) return allow(); // cap off
271
+
272
+ let entries;
273
+ if (input.tool_name === "Bash") {
274
+ const verbs = bashFetchVerbs(input.tool_input && input.tool_input.command);
275
+ if (verbs.length === 0) return allow(); // an ordinary Bash command is never counted or denied here
276
+ entries = verbs.map((verb) => ({ tool: "Bash", verb }));
277
+ } else {
278
+ entries = [{ tool: input.tool_name }];
279
+ }
280
+
281
+ const file = counterFile(dir, input.session_id, agentId);
282
+ const count = countCalls(file);
283
+ if (count >= max) {
284
+ return denyTool(
285
+ `[doug] Research budget reached: this agent has used ${count} of ${max} WebSearch/WebFetch/curl/wget ` +
286
+ `calls (research.maxFetches in .doug/config.json). Stop searching now: write your findings from what ` +
287
+ `you have, and mark anything unanswered unverified.`,
288
+ );
289
+ }
290
+ mkdirSync(join(dir, RESEARCH_CAP_DIR_RELPATH, safeId(input.session_id || "no-session")), { recursive: true });
291
+ for (const entry of entries) {
292
+ appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...entry }) + "\n");
293
+ }
294
+ return allow();
295
+ });
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (Edit/Write/MultiEdit/NotebookEdit/Bash): deny text that carries a credential shape.
3
+ // Rules live in lib/secret-rules.mjs and are unit-tested; this script only wires them to the hook I/O.
4
+
5
+ import { runHook, allow, denyTool } from "../lib/io.mjs";
6
+ import { loadConfig, projectDir } from "../lib/config.mjs";
7
+ import { matchAny } from "../lib/glob.mjs";
8
+ import { toProjectRelative } from "../lib/paths.mjs";
9
+ import { scanText, candidateTexts, denyReason } from "../lib/secret-rules.mjs";
10
+
11
+ runHook("secret-scan", async (input) => {
12
+ const dir = projectDir(input);
13
+ const cfg = loadConfig(dir);
14
+ if (cfg.secrets.enabled === false) return allow();
15
+ for (const { label, path, text } of candidateTexts(input.tool_input)) {
16
+ let effectiveLabel = label;
17
+ if (typeof path === "string") {
18
+ const rel = toProjectRelative(dir, path);
19
+ if (rel !== null && matchAny(cfg.secrets.ignorePaths, rel)) continue;
20
+ effectiveLabel = rel ?? path;
21
+ }
22
+ const hit = scanText(text, cfg.secrets);
23
+ if (hit) return denyTool(denyReason(hit, effectiveLabel));
24
+ }
25
+ return allow();
26
+ });
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart, every source (no matcher — startup, resume, clear, compact, fork all confirmed to fire this):
3
+ // capture the dirty-tree baseline as early as this event allows, so a tree the session never touches can skip
4
+ // verification entirely at Stop instead of waiting for the stop gate's own first-run fallback capture
5
+ // (card stop-gate-session-start; see docs/research/stop-gate-session-start.md for what is and is not
6
+ // confirmed about this event). Idempotent: only writes state.baseline when it is absent, so firing late,
7
+ // firing again for a subagent sharing the parent session_id, or firing more than once, can never clobber a
8
+ // baseline that is already there.
9
+ //
10
+ // The capture always happens on the first fire, but it is only labelled "session-start" (letting the stop
11
+ // gate narrow anyChange to the baseline-relative set) when BOTH:
12
+ // - the session demonstrably has not acted yet (no recorded commands, edits, turns, or a prior gate) —
13
+ // a late fire (ordering is unverified, per the research note) can otherwise land after the session
14
+ // already changed something through Bash or Edit, and that must not be baselined as pre-existing; and
15
+ // - the source is "startup" — `clear` and `fork` hand a NEW session_id a state file that may carry a
16
+ // previous session's uncommitted, never-verified work, which must not be baselined away either.
17
+ // Anything else is labelled "stop", the same as the stop gate's own fallback capture, so the stop gate's
18
+ // narrowing never applies and it keeps reading the full changed set, exactly as if this had never fired.
19
+ //
20
+ // Never blocks: SessionStart cannot fail a session anyway. The happy path exits 0 with no stdout; an
21
+ // unexpected error still exits 0, via runHook's fail-open path, which does write a systemMessage.
22
+ import { runHook, allow, sessionStartReason } from "../lib/io.mjs";
23
+ import { projectDir, loadConfig } from "../lib/config.mjs";
24
+ import { loadState, saveState } from "../lib/state.mjs";
25
+ import { changedFiles, captureBaseline, headAt, walkProposalPathFiles } from "../lib/baseline.mjs";
26
+
27
+ runHook("session-start-baseline", async (input) => {
28
+ const dir = projectDir(input);
29
+ const state = loadState(dir, input.session_id);
30
+ if (state.baseline !== null) return allow(); // already captured; idempotent
31
+ const changed = changedFiles(dir);
32
+ if (!Array.isArray(changed)) return allow(); // not a git repo, or git unavailable
33
+ const cfg = loadConfig(dir);
34
+ // Proposal-path files a project's own .gitignore hides from `git status` entirely (card
35
+ // stop-scan-committed-changes) are folded into the same baseline capture as `changed`, so a pre-existing,
36
+ // untouched one is never later read as new by the stop gate's sinceBaseline check.
37
+ const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
38
+ const baseline = captureBaseline(dir, [...new Set([...changed, ...walked])]);
39
+ const baselineHead = headAt(dir);
40
+ // Re-read immediately before writing: capturing the baseline above can take a while on a large dirty
41
+ // tree, and another hook (guard-bash's recordCommand, edit-loop's editedFiles) may have written state
42
+ // in that window. Writing back the state object read at the top of this function would silently discard
43
+ // that write, so merge only the baseline fields onto the freshest state instead.
44
+ const fresh = loadState(dir, input.session_id);
45
+ if (fresh.baseline !== null) return allow(); // captured by a concurrent fire meanwhile
46
+ const pristine = fresh.commands.length === 0 && fresh.editedFiles.length === 0 && fresh.turns === 0 && fresh.lastGate === null;
47
+ fresh.baseline = baseline;
48
+ fresh.baselineSource = pristine && sessionStartReason(input) === "startup" ? "session-start" : "stop";
49
+ // Never overwrite a non-null baselineHead (card stop-scan-committed-changes) - matches the baseline field's
50
+ // own idempotency above, for the same reasons (a late or repeated fire must never clobber it).
51
+ if (fresh.baselineHead === null) fresh.baselineHead = baselineHead;
52
+ // This capture always performs the proposalPaths walk above, so the baseline it just wrote can be trusted
53
+ // for the stop gate's walk-based check (state.baselineWalked; see its definition in lib/state.mjs).
54
+ fresh.baselineWalked = true;
55
+ saveState(dir, input.session_id, fresh);
56
+ return allow();
57
+ });