@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,268 @@
1
+ // Pure rule functions for the secret-scan guard so they can be unit-tested without spawning.
2
+ // Only new text (Write content, Edit/MultiEdit new_string, NotebookEdit new_source, Bash command)
3
+ // is ever scanned; old_string is never included, so removing a secret is never blocked.
4
+
5
+ // AWS access key ids: of the IAM unique-ID prefixes, only AKIA (long-term) and ASIA (temporary STS)
6
+ // denote access key ids (docs.aws.amazon.com .../reference_identifiers.html). The documented example
7
+ // id is the prefix plus 16 [A-Z0-9] characters; the AccessKeyId API contract allows 16-128 `\w`
8
+ // characters, so the suffix is widened to 16 or more (docs.aws.amazon.com .../API_AccessKey.html).
9
+ const AWS_KEY_ID_SRC = "(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16,}(?![A-Za-z0-9])";
10
+
11
+ // PEM private-key headers: RFC 7468's ABNF (`preeb = "-----BEGIN " label "-----"`; a label is one or
12
+ // more labelchars joined by a single SP or "-") applied to the PRIVATE KEY (section 10) and ENCRYPTED
13
+ // PRIVATE KEY (section 11) labels; RSA/DSA/EC/SM2/OPENSSH are OpenSSL/OpenSSH label words preceding
14
+ // it (rfc-editor.org/rfc/rfc7468.txt). The label-word count and length are capped (round 2, M1) so the
15
+ // group is a single bounded repeat, not an unbounded quantifier nested inside another.
16
+ const PEM_PRIVATE_KEY_SRC = "-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----";
17
+
18
+ // GitHub tokens: the classic prefixes ghp_/gho_/ghu_/ghs_/ghr_ (docs.github.com, "about authentication
19
+ // to GitHub") each followed by 36-255 characters (github.blog's "up to 255 characters" integrator
20
+ // guidance; "30 random plus a 6-character checksum" is stated there for OAuth (gho_) tokens only —
21
+ // the other prefixes' 36-char shape is an inference from the same page, not separately documented),
22
+ // the github_pat_ fine-grained form (internal shape undocumented; 22 is Doug's own floor, 255 the
23
+ // same integrator ceiling), and the 2026 stateless installation-token form ghs_<appid>_<JWT>
24
+ // (docs.github.com, same page). Every repeat is bounded (round 2, M1) so no run is rescanned from
25
+ // every start position, and the trailing check is (?![A-Za-z0-9]), not \b, so a token glued to a
26
+ // following "_" still hits (round 2, m1).
27
+ const GITHUB_TOKEN_SRC =
28
+ "\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}(?![A-Za-z0-9])" +
29
+ "|\\bgithub_pat_[A-Za-z0-9_]{22,255}(?![A-Za-z0-9])" +
30
+ "|\\bghs_[0-9]{1,10}_[A-Za-z0-9_-]{8,2048}\\.[A-Za-z0-9_-]{8,2048}\\.[A-Za-z0-9_-]{8,2048}(?![A-Za-z0-9])";
31
+
32
+ // Slack tokens: bot/user/workflow/app-level strings begin xoxb-/xoxp-/xwfp-/xapp-, each divided into
33
+ // dash-separated sections whose final section is the secret (docs.slack.dev/authentication/tokens);
34
+ // rotation gives refresh tokens a bare "xoxe-" prefix (docs.slack.dev/authentication/using-token-
35
+ // rotation) — a rotated *access* token's "xoxe." prefix needs no separate alternative, since the
36
+ // xoxp-/xoxb- token it wraps already matches on its own (round 2, m2: the old xoxe\. alternative was
37
+ // dead code). The prefix match below is a single bounded literal (no nested quantifier, round 2, M1);
38
+ // scanSlackToken then reads the run of dash-joined sections and checks the final section's length in
39
+ // plain code, never with a `(?:[A-Za-z0-9]+-)*` group, so nothing is rescanned from every start
40
+ // position. The final section must be at least 6 characters (the documented pre-2016 legacy minimum);
41
+ // xoxa-/xoxr-/xoxs- have no Slack source and are dropped.
42
+ const SLACK_PREFIX_SRC = "\\b(?:xoxb|xoxp|xwfp|xapp|xoxe)-";
43
+ const SLACK_RUN_CHARS = /[A-Za-z0-9-]/;
44
+ const SLACK_RUN_WINDOW = 512; // generous vs. any real Slack token; keeps the scan bounded per match
45
+
46
+ function scanSlackToken(text) {
47
+ const re = new RegExp(SLACK_PREFIX_SRC, "g");
48
+ let m;
49
+ while ((m = re.exec(text))) {
50
+ const start = m.index + m[0].length;
51
+ const end = Math.min(text.length, start + SLACK_RUN_WINDOW);
52
+ let i = start;
53
+ while (i < end && SLACK_RUN_CHARS.test(text[i])) i++;
54
+ const run = text.slice(start, i);
55
+ const sections = run.split("-");
56
+ const last = sections[sections.length - 1];
57
+ if (last.length >= 6) return true;
58
+ re.lastIndex = Math.max(re.lastIndex, i);
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // Inline credential assignment: Doug's own rule (round 2 redesign, no external source), written
64
+ // independently of the prior rule. The regex finds a credential key name — captured in group 1, so
65
+ // its left boundary can be judged in code, not the regex — (password, passwd, pwd, secret, client
66
+ // secret, api key, access token, auth token, private key, access key — words joined by `_`, `-`, or
67
+ // nothing, case-insensitive) followed by `=`/`:` and an optional opening quote (captured in group 2).
68
+ // Left boundary (round 4, since round 3's letter/digit exclusion also killed camelCase and glued
69
+ // keys like dbPassword/jwtSecret/dbpassword — see isValidKeyBoundary): the long names may be
70
+ // preceded by anything at all, including `$` (round 5 correction: PHP/Perl spell a variable with a
71
+ // leading `$`, e.g. `$password = "..."`, and that is a real assignment, not shell interpolation);
72
+ // only the bare `pwd` alternative keeps round 3's strict rule (not preceded by a letter, digit, or
73
+ // `$`), with one exception — a lower-to-upper case step right before it (dbPwd hits; XPWD, preceded
74
+ // by an upper-case letter, does not; nor does $PWD/$pwd, the shell-variable case that rule exists
75
+ // for). The VALUE itself is read
76
+ // by plain code, not by a regex quantifier, from a single bounded slice taken up front (round 3,
77
+ // R3-2: that one slice backs both the end-of-line check and the closing-quote search below, so
78
+ // neither is an unbounded scan over the rest of the text — see readInlineValue). A quoted value ends
79
+ // at the next quote character of the SAME kind that opened it, within that slice and never past its
80
+ // first newline (round 3, R3-3: an embedded apostrophe inside a double-quoted value no longer ends
81
+ // it early; no matching quote on the line disqualifies the match rather than swallowing the rest of
82
+ // the slice). A bare value ends at the next whitespace/`;`/`,`/`)`/`}`/`]`. It is flagged when the
83
+ // value is 12+ characters, has at least one letter and at least one digit or one of `/+=`, is not a
84
+ // reference (starts with `$`, `{`, `<`, `%`, `process.env`, or `os.environ`), is not a filesystem
85
+ // path or network address whether quoted or bare (round 3 R3-1, widened round 4: see
86
+ // isInlinePathOrUrl), and is not a configured placeholder prefix.
87
+ const INLINE_KEY_SRC =
88
+ "(password|passwd|pwd|secret|client[_-]?secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key|access[_-]?key)" +
89
+ "\\s*[:=]\\s*([\"'`])?";
90
+ const INLINE_BARE_STOP = /^[^\s;,)}\]]*/;
91
+ const INLINE_WINDOW = 4096; // shared bound for the quoted branch's end-of-line and closing-quote search
92
+ const INLINE_BARE_WINDOW = 512;
93
+ const INLINE_REFERENCE_MARKERS = /^[${<%]/;
94
+ const INLINE_PATH_START = /^(?:\/|\.\/|\.\.\/|~)/;
95
+ const INLINE_ANY_QUOTE = /["'`]/;
96
+ const INLINE_LETTER_OR_DIGIT = /[A-Za-z0-9]/;
97
+ const INLINE_LOWER = /[a-z]/;
98
+ const INLINE_UPPER = /[A-Z]/;
99
+
100
+ // Judges a matched key name's left boundary in plain code (round 4), since the exception for `pwd`
101
+ // (a camelCase step is allowed; any other letter/digit is not) needs the matched key's own casing,
102
+ // which a single case-insensitive regex can't branch on. `left` is the character immediately before
103
+ // the key, or undefined at the start of the text. The long names may be preceded by anything at all
104
+ // (dbPassword, jwtSecret, dbpassword, ADMIN_API_KEY, and a PHP/Perl `$password = "..."` or
105
+ // `$secret = '...'` all still hit — round 5 correction: a leading `$` is how those languages spell
106
+ // a variable, not shell/template interpolation, so it is not excluded there). Only bare `pwd` is
107
+ // pickier: `left` must be absent, not a letter or digit, or — the camelCase exception — a lower-case
108
+ // letter immediately followed by an upper-case first letter of the match (dbPwd hits; XPWD and
109
+ // $PWD/$pwd, the shell-variable false positive round 3 introduced this rule for, do not).
110
+ function isValidKeyBoundary(left, keyText) {
111
+ if (left === undefined) return true;
112
+ if (keyText.toLowerCase() !== "pwd") return true;
113
+ if (left === "$") return false;
114
+ if (!INLINE_LETTER_OR_DIGIT.test(left)) return true;
115
+ return INLINE_LOWER.test(left) && INLINE_UPPER.test(keyText.charAt(0));
116
+ }
117
+
118
+ // Reads the value following a matched key+separator. Quoted: slice at most INLINE_WINDOW characters
119
+ // once (bounding both checks below to that one slice, round 3, R3-2), cut the slice at its first
120
+ // newline if any (a value never spans lines), then find the next occurrence of the SAME quote
121
+ // character within what's left; no such quote means no value (null), never "keep scanning" or "keep
122
+ // the rest of the slice" (round 3, R3-3). Bare: up to the next whitespace/`;`/`,`/`)`/`}`/`]`, itself
123
+ // a single bounded slice.
124
+ function readInlineValue(text, pos, quote) {
125
+ if (quote) {
126
+ const window = text.slice(pos, Math.min(text.length, pos + INLINE_WINDOW));
127
+ const newline = window.indexOf("\n");
128
+ const line = newline === -1 ? window : window.slice(0, newline);
129
+ const rel = line.indexOf(quote);
130
+ if (rel === -1) return null;
131
+ const value = line.slice(0, rel);
132
+ // A real value never itself starts with a quote character of any kind (that first character
133
+ // would immediately close an empty value) or with whitespace (round 4: a wider left-boundary
134
+ // means the "opening" quote is now sometimes the tail of an unrelated string a few characters
135
+ // earlier — e.g. a key matched inside a JS string literal whose own closing quote sits right
136
+ // where a value would start, with the match then running on to the next quote later in the
137
+ // source line); either shape means the match crossed into unrelated text, so there is no value.
138
+ if (INLINE_ANY_QUOTE.test(value.charAt(0)) || /\s/.test(value.charAt(0))) return null;
139
+ return value;
140
+ }
141
+ const window = text.slice(pos, Math.min(text.length, pos + INLINE_BARE_WINDOW));
142
+ return INLINE_BARE_STOP.exec(window)[0];
143
+ }
144
+
145
+ function isInlineReference(value) {
146
+ const lower = value.toLowerCase();
147
+ return INLINE_REFERENCE_MARKERS.test(value) || lower.startsWith("process.env") || lower.startsWith("os.environ");
148
+ }
149
+
150
+ // A value naming a filesystem location or a network address is not a credential literal (round 3,
151
+ // R3-1, widened to quoted values in round 4): it starts with a slash, a `./` or `../` relative
152
+ // prefix, or a `~` home shorthand, or it carries a scheme separator (colon, two slashes) further in.
153
+ function isInlinePathOrUrl(value) {
154
+ return INLINE_PATH_START.test(value) || value.includes(":" + "//");
155
+ }
156
+
157
+ export const RULES = [
158
+ {
159
+ id: "awsKeyId",
160
+ what: "an AWS access key id",
161
+ source: [
162
+ "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html",
163
+ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html",
164
+ "https://docs.aws.amazon.com/IAM/latest/APIReference/API_AccessKey.html",
165
+ ],
166
+ test: (text) => new RegExp(AWS_KEY_ID_SRC).test(text),
167
+ },
168
+ {
169
+ id: "pemPrivateKey",
170
+ what: "a PEM private-key header",
171
+ source: "https://www.rfc-editor.org/rfc/rfc7468.txt",
172
+ test: (text) => new RegExp(PEM_PRIVATE_KEY_SRC).test(text),
173
+ },
174
+ {
175
+ id: "githubToken",
176
+ what: "a GitHub token",
177
+ source: [
178
+ "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github",
179
+ "https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/",
180
+ ],
181
+ test: (text) => new RegExp(GITHUB_TOKEN_SRC).test(text),
182
+ },
183
+ {
184
+ id: "slackToken",
185
+ what: "a Slack token",
186
+ source: ["https://docs.slack.dev/authentication/tokens", "https://docs.slack.dev/authentication/using-token-rotation"],
187
+ test: (text) => scanSlackToken(text),
188
+ },
189
+ {
190
+ id: "inlineAssignment",
191
+ what: "an inline credential assignment (password, secret, api_key, or token with a literal value)",
192
+ source: "Doug's own design (no external source)",
193
+ test: (text, secrets) => {
194
+ const placeholders = ((secrets && secrets.placeholders) || []).map((p) => String(p).toLowerCase());
195
+ const re = new RegExp(INLINE_KEY_SRC, "gi");
196
+ let m;
197
+ while ((m = re.exec(text))) {
198
+ const left = m.index > 0 ? text.charAt(m.index - 1) : undefined;
199
+ if (!isValidKeyBoundary(left, m[1])) continue;
200
+ const pos = m.index + m[0].length;
201
+ const quote = m[2];
202
+ const value = readInlineValue(text, pos, quote);
203
+ if (value === null || value.length < 12) continue;
204
+ if (!/[A-Za-z]/.test(value)) continue;
205
+ if (!/[0-9/+=]/.test(value)) continue;
206
+ if (isInlineReference(value)) continue;
207
+ if (isInlinePathOrUrl(value)) continue;
208
+ const lower = value.toLowerCase();
209
+ if (placeholders.some((p) => lower.startsWith(p))) continue;
210
+ return true;
211
+ }
212
+ return false;
213
+ },
214
+ },
215
+ ];
216
+
217
+ // Returns { rule, what } for the first enabled rule that hits, else null.
218
+ export function scanText(text, secrets) {
219
+ if (typeof text !== "string") return null;
220
+ for (const rule of RULES) {
221
+ if (secrets && secrets.rules && secrets.rules[rule.id] === false) continue;
222
+ if (rule.test(text, secrets)) return { rule: rule.id, what: rule.what };
223
+ }
224
+ return null;
225
+ }
226
+
227
+ // Extracts the candidate texts to scan (and their labels/paths) from a tool_input.
228
+ export function candidateTexts(toolInput) {
229
+ if (!toolInput || typeof toolInput !== "object") return [];
230
+ const out = [];
231
+ const pathOf = (v) => (typeof v === "string" ? v : null);
232
+ if (typeof toolInput.command === "string") {
233
+ out.push({ label: "this command", path: null, text: toolInput.command });
234
+ }
235
+ if (typeof toolInput.content === "string") {
236
+ const path = pathOf(toolInput.file_path);
237
+ out.push({ label: path ?? "this edit", path, text: toolInput.content });
238
+ }
239
+ if (typeof toolInput.new_string === "string") {
240
+ const path = pathOf(toolInput.file_path);
241
+ out.push({ label: path ?? "this edit", path, text: toolInput.new_string });
242
+ }
243
+ if (typeof toolInput.new_source === "string") {
244
+ const path = pathOf(toolInput.notebook_path);
245
+ out.push({ label: path ?? "this edit", path, text: toolInput.new_source });
246
+ }
247
+ if (Array.isArray(toolInput.edits)) {
248
+ for (const e of toolInput.edits) {
249
+ if (e && typeof e === "object" && typeof e.new_string === "string") {
250
+ const raw = toolInput.file_path ?? e.file_path ?? null;
251
+ const path = pathOf(raw);
252
+ out.push({ label: path ?? "this edit", path, text: e.new_string });
253
+ }
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ export function denyReason(hit, label) {
260
+ const lead =
261
+ label === "this command"
262
+ ? `Refusing to run this command: it contains what looks like ${hit.what} (rule ${hit.rule}).`
263
+ : `Refusing to write ${label}: it contains what looks like ${hit.what} (rule ${hit.rule}).`;
264
+ return (
265
+ lead +
266
+ ` Reference credentials through an environment variable or a secret manager instead of writing them into files or commands. If this is a placeholder, add its prefix to secrets.placeholders in .doug/config.json; to exempt a path, add it to secrets.ignorePaths; secrets.rules.${hit.rule}: false turns the rule off.`
267
+ );
268
+ }
@@ -0,0 +1,114 @@
1
+ // Per-session state for hooks, stored under <project>/.doug/.state/<session_id>.json.
2
+ // Written atomically (tmp + rename). Never shared between sessions.
3
+
4
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export const STATE_DIR_RELPATH = ".doug/.state";
8
+
9
+ function safeId(id) {
10
+ return String(id || "no-session")
11
+ .replace(/[^A-Za-z0-9_.-]/g, "_")
12
+ .replace(/\.{2,}/g, "_")
13
+ .slice(0, 120);
14
+ }
15
+
16
+ export function statePath(dir, sessionId) {
17
+ return join(dir, STATE_DIR_RELPATH, safeId(sessionId) + ".json");
18
+ }
19
+
20
+ export function loadState(dir, sessionId) {
21
+ const file = statePath(dir, sessionId);
22
+ if (!existsSync(file)) return emptyState();
23
+ try {
24
+ return { ...emptyState(), ...JSON.parse(readFileSync(file, "utf8")) };
25
+ } catch {
26
+ return emptyState();
27
+ }
28
+ }
29
+
30
+ export function saveState(dir, sessionId, state) {
31
+ const file = statePath(dir, sessionId);
32
+ mkdirSync(join(dir, STATE_DIR_RELPATH), { recursive: true });
33
+ const tmp = file + "." + process.pid + ".tmp";
34
+ writeFileSync(tmp, JSON.stringify(state));
35
+ renameSync(tmp, file);
36
+ }
37
+
38
+ export function emptyState() {
39
+ return {
40
+ edits: {}, // relpath -> [timestamps]
41
+ editedFiles: [], // relpaths edited this session (deduped)
42
+ turns: 0,
43
+ stopBlocks: 0,
44
+ // Consecutive deferred main Stops (card stop-gate-deferral-cap): reset to 0 by a SubagentStop of this
45
+ // session, or by a main Stop that reaches the deferral check point and does not defer (stop-gate.mjs).
46
+ // Absent from an old state file reads as 0 via the emptyState spread below.
47
+ deferrals: 0,
48
+ agentBlocks: {}, // agent_id -> blocks of that subagent's SubagentStop, capped like stopBlocks but apart from it
49
+ // agent_id -> { context: { pct, tokens, window, model, at, maxPct, notifiedAt } }: a worker's measured context
50
+ // (card worker-context-handoff, lib/agent-context.mjs), read from its own transcript by scripts/trace.mjs on
51
+ // PostToolUse and checked by scripts/stop-gate.mjs's SubagentStop gate before accepting a partial claim.
52
+ // Absent from an old state file loads as {} (the spread of emptyState() above supplies it).
53
+ agents: {},
54
+ lastGate: null, // { ok, at, results }
55
+ commands: [], // Bash commands the guard let through this session, newest last, clipped; the stop gate reads them as verification evidence
56
+ baseline: null, // relpath -> content identity, captured on SessionStart or (fallback) the stop gate's first run this session; null until captured
57
+ baselineSource: null, // "session-start" | "stop" | null (not yet captured) — where state.baseline came from
58
+ // git HEAD sha recorded in the same write as `baseline` (card stop-scan-committed-changes), or null when
59
+ // there was no git repo or no HEAD yet at capture time. Never overwritten once non-null. Lets a later Stop
60
+ // scan `git diff --name-only <this>..HEAD` (baseline.mjs's committedSince) for paths the session committed
61
+ // instead of leaving dirty, which a working-tree-only scan can never see. Absent from an old state file
62
+ // predating this key loads as null via the emptyState spread above, which degrades to today's behavior.
63
+ baselineHead: null,
64
+ // True once `baseline` can be trusted to hold identities for every current proposalPaths file on disk
65
+ // (card stop-scan-committed-changes): both capture sites set this in the same write as `baseline`, since
66
+ // they always perform that walk. Absent from a state file whose baseline was captured before this key
67
+ // existed loads as false via the emptyState spread above: a pre-existing, untouched proposalPaths file
68
+ // (e.g. an old ADR) that was never walked into that baseline would otherwise read as "new" the first time
69
+ // a later stop-gate.mjs walks the filesystem and compares against it. Rather than sitting out the
70
+ // walk-based check for the rest of such a session, stop-gate.mjs's one-time repair (Minor 4) backfills
71
+ // the missing identities into `baseline` on the very first Stop that finds this false, sets it true right
72
+ // there, and only that one Stop still skips the walk-based check (it just backfilled from whatever the
73
+ // tree looks like now, not from true session start) - the walk is live again from the next Stop on.
74
+ baselineWalked: false,
75
+ // Recorded by a green gate (card stop-gate-process-storm): { head, tree, at } where `head` is headAt(dir)
76
+ // and `tree` is captureBaseline(dir, [...changed, ...walkProposalPathFiles(dir, cfg.proposalPaths)]) — the
77
+ // fingerprint of the tree the commands were about to run against, taken BEFORE they ran, not after. A
78
+ // command that itself changes a file this fingerprint covers (e.g. a formatter) is therefore never matched
79
+ // by a later Stop/SubagentStop's sameTree comparison even though that same gate just passed — deliberate:
80
+ // `tree` promises "this is what was verified", and a command's own edit was never actually verified against.
81
+ // When it does match, the later Stop/SubagentStop skips re-running the verification commands entirely
82
+ // instead of every concurrent SubagentStop starting its own full suite (the 2026-09-16 process storm). A
83
+ // red gate never sets this; absent from an old state file loads as null via the emptyState spread above,
84
+ // which degrades to today's behavior (always verify).
85
+ verified: null,
86
+ // The standing tests_red_by_design claim (card stop-gate-block-ledger, d183db5's honoured-claim path):
87
+ // { files, at, agent } — `files` is the coverage's claimed file list, `at` an ISO timestamp, `agent` the
88
+ // subagent's id (not its type). Written only in stop-gate.mjs's honoured-claim branch, alongside (never
89
+ // instead of) the existing state.lastGate.redByDesign write; state.lastGate itself is replaced wholesale
90
+ // on every later gate run (line ~942), so that record alone would not stand for a later block to read.
91
+ // This key does: the block-ledger classifier reads redByDesignClaim.files to label a later block
92
+ // 'expected-red' when it covers it. No expiry and no say in whether a stop is let through — that stays
93
+ // the Decide card stop-gate-honours-tester-claim's to add. Absent from an old state file loads as null
94
+ // via the emptyState spread above.
95
+ redByDesignClaim: null,
96
+ // context (card context-window-handoff): { pct, at, notifiedAt? } — absent until the status line first
97
+ // records a reading (statusline.mjs), so not a default key here. pct and at are written by the status line
98
+ // on every rounded-pct change; notifiedAt is written by the Stop gate (stop-gate.mjs) when it notices, and
99
+ // is preserved across later pct updates so repeat logic tracks growth since the last notice, not since the
100
+ // last render. Cleared by reanchor.mjs on PostCompact.
101
+ };
102
+ }
103
+
104
+ export const MAX_COMMANDS = 100;
105
+
106
+ // Remember an allowed Bash command (clipped to 200 characters, the last MAX_COMMANDS kept).
107
+ export function recordCommand(dir, sessionId, command) {
108
+ if (typeof command !== "string" || !command.trim()) return;
109
+ const state = loadState(dir, sessionId);
110
+ const commands = Array.isArray(state.commands) ? state.commands : [];
111
+ commands.push(command.replace(/\s+/g, " ").trim().slice(0, 200));
112
+ state.commands = commands.slice(-MAX_COMMANDS);
113
+ saveState(dir, sessionId, state);
114
+ }
@@ -0,0 +1,196 @@
1
+ // Run trace (decision 0002, card run-trace): the trace hook appends one JSON line per SubagentStart, SubagentStop,
2
+ // PreToolUse, and PostToolUse event to <project>/.doug/.state/trace/<session>.jsonl. The readers here (replay,
3
+ // per-agent attribution) are what `doug trace` prints. Tokens are recorded only where Claude Code reports them:
4
+ // a SubagentStop carries the agent's transcript path, whose assistant lines carry per-message usage. Everything
5
+ // else records null; nothing is ever estimated. `context` (card worker-context-handoff) is the worker's own
6
+ // measured context pct on a PostToolUse inside a subagent, from lib/agent-context.mjs; null everywhere else.
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export const TRACE_DIR_RELPATH = ".doug/.state/trace";
12
+
13
+ function safeId(id) {
14
+ return String(id || "no-session")
15
+ .replace(/[^A-Za-z0-9_.-]/g, "_")
16
+ .replace(/\.{2,}/g, "_")
17
+ .slice(0, 120);
18
+ }
19
+
20
+ export function tracePath(dir, sessionId) {
21
+ return join(dir, TRACE_DIR_RELPATH, safeId(sessionId) + ".jsonl");
22
+ }
23
+
24
+ function clip(s, n = 120) {
25
+ const t = String(s).replace(/\s+/g, " ").trim();
26
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
27
+ }
28
+
29
+ // A one-line summary of a tool's input: the command, the file, the pattern, or the description. Never the whole input.
30
+ // "skill" (card learn-signals) records a Skill tool call's skill name, so trace-derived signals can count
31
+ // which skills actually get invoked.
32
+ export function detailOf(toolInput) {
33
+ if (!toolInput || typeof toolInput !== "object") return null;
34
+ for (const key of ["command", "file_path", "notebook_path", "pattern", "description", "prompt", "url", "skill"]) {
35
+ if (typeof toolInput[key] === "string" && toolInput[key].trim()) return clip(toolInput[key]);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Token usage summed from a Claude Code transcript (one line per content block, each repeating its message's
41
+ // usage, so the largest value per message id counts once). null when the file is missing or has no usage lines.
42
+ export function usageFromTranscript(file) {
43
+ if (!file || !existsSync(file)) return null;
44
+ const perMessage = new Map();
45
+ let text;
46
+ try {
47
+ text = readFileSync(file, "utf8");
48
+ } catch {
49
+ return null;
50
+ }
51
+ for (const line of text.split("\n")) {
52
+ if (!line.trim()) continue;
53
+ let o;
54
+ try {
55
+ o = JSON.parse(line);
56
+ } catch {
57
+ continue;
58
+ }
59
+ const m = o && o.message;
60
+ if (o.type !== "assistant" || !m || !m.usage) continue;
61
+ const id = m.id || o.requestId || o.uuid;
62
+ const u = m.usage;
63
+ const cur = perMessage.get(id) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, model: m.model || null };
64
+ cur.input = Math.max(cur.input, u.input_tokens || 0);
65
+ cur.output = Math.max(cur.output, u.output_tokens || 0);
66
+ cur.cacheRead = Math.max(cur.cacheRead, u.cache_read_input_tokens || 0);
67
+ cur.cacheWrite = Math.max(cur.cacheWrite, u.cache_creation_input_tokens || 0);
68
+ if (m.model) cur.model = m.model;
69
+ perMessage.set(id, cur);
70
+ }
71
+ if (perMessage.size === 0) return null;
72
+ const out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: perMessage.size, model: null };
73
+ const models = new Map();
74
+ for (const v of perMessage.values()) {
75
+ out.input += v.input;
76
+ out.output += v.output;
77
+ out.cacheRead += v.cacheRead;
78
+ out.cacheWrite += v.cacheWrite;
79
+ if (v.model) models.set(v.model, (models.get(v.model) || 0) + 1);
80
+ }
81
+ out.model = [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m)[0] || null;
82
+ return out;
83
+ }
84
+
85
+ // The line for one hook input. `now` is injectable for tests. `context` (card worker-context-handoff) is the
86
+ // caller's already-computed reading pct for a PostToolUse inside a subagent (scripts/trace.mjs); null when there
87
+ // is none, which is every other event and every caller that doesn't pass it.
88
+ //
89
+ // InstructionsLoaded and PermissionDenied (card learn-signals) record path-only, never-block observation, the
90
+ // same as every other event here: InstructionsLoaded's `detail` is the loaded file's path and its `reason` the
91
+ // matcher's load_reason ("session_start", "path_glob_match", ...) - `file_content` is never read off the input,
92
+ // so it can never end up on the line even by accident. PermissionDenied's `detail` is the denied tool's input
93
+ // (via detailOf, same as a Pre/PostToolUse line), `ok` is forced false, and `reason` is the clipped denial_reason.
94
+ // Every other event's `reason` is null.
95
+ export function traceLine(input, { now = new Date(), context = null } = {}) {
96
+ const event = input.hook_event_name || null;
97
+ const response = input.tool_response ?? input.tool_output ?? null;
98
+ let ok = null;
99
+ if (event === "PostToolUse") {
100
+ ok = response === null ? null : !(typeof response === "object" && (response.is_error === true || typeof response.error === "string"));
101
+ } else if (event === "PermissionDenied") {
102
+ ok = false;
103
+ }
104
+ const tokens = event === "SubagentStop" ? usageFromTranscript(input.agent_transcript_path) : null;
105
+ let detail = null;
106
+ let reason = null;
107
+ if (event === "PreToolUse" || event === "PostToolUse" || event === "PermissionDenied") {
108
+ detail = detailOf(input.tool_input);
109
+ }
110
+ if (event === "PermissionDenied") {
111
+ reason = typeof input.denial_reason === "string" && input.denial_reason.trim() ? clip(input.denial_reason, 120) : null;
112
+ } else if (event === "InstructionsLoaded") {
113
+ detail = typeof input.file_path === "string" && input.file_path ? input.file_path : null;
114
+ reason = typeof input.load_reason === "string" && input.load_reason ? input.load_reason : null;
115
+ }
116
+ return {
117
+ t: now.toISOString(),
118
+ event,
119
+ session: input.session_id || null,
120
+ agent: input.agent_id || null,
121
+ agentType: input.agent_type || null,
122
+ tool: input.tool_name || null,
123
+ toolUseId: input.tool_use_id || null,
124
+ detail,
125
+ ok,
126
+ tokens,
127
+ context,
128
+ reason,
129
+ };
130
+ }
131
+
132
+ export function appendTrace(dir, line) {
133
+ mkdirSync(join(dir, TRACE_DIR_RELPATH), { recursive: true });
134
+ appendFileSync(tracePath(dir, line.session), JSON.stringify(line) + "\n");
135
+ }
136
+
137
+ // Every well-formed line of a trace file, in order.
138
+ export function readTrace(file) {
139
+ const out = [];
140
+ for (const line of readFileSync(file, "utf8").split("\n")) {
141
+ if (!line.trim()) continue;
142
+ try {
143
+ const o = JSON.parse(line);
144
+ if (o && typeof o === "object" && o.event) out.push(o);
145
+ } catch {
146
+ // a torn line from a crashed hook is skipped, never fatal
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ export function agentLabel(line) {
153
+ return line.agent ? `${line.agentType || "agent"}:${String(line.agent).slice(0, 8)}` : "main";
154
+ }
155
+
156
+ // One text line per event: time, who, what.
157
+ export function replay(lines) {
158
+ return lines.map((l) => {
159
+ const time = /T(\d\d:\d\d:\d\d)/.exec(l.t || "")?.[1] || "??:??:??";
160
+ const parts = [time, agentLabel(l).padEnd(22), (l.event || "").padEnd(13)];
161
+ if (l.tool) parts.push(l.tool);
162
+ if (l.detail) parts.push(l.detail);
163
+ if (l.ok === false) parts.push("FAILED");
164
+ if ((l.event === "InstructionsLoaded" || l.event === "PermissionDenied") && l.reason) parts.push(l.reason);
165
+ if (l.event === "SubagentStop") parts.push(l.tokens ? `tokens in ${l.tokens.input} out ${l.tokens.output}` : "tokens null");
166
+ return parts.join(" ").trimEnd();
167
+ });
168
+ }
169
+
170
+ // Per agent: events, tool calls by name, failed calls, wall time, and tokens (null unless a SubagentStop reported them).
171
+ export function attribution(lines) {
172
+ const byAgent = new Map();
173
+ for (const l of lines) {
174
+ const key = l.agent || "main";
175
+ const a = byAgent.get(key) || { agent: key, agentType: l.agent ? l.agentType || null : "main", events: 0, toolCalls: {}, failedCalls: 0, startedAt: l.t || null, endedAt: l.t || null, wallMs: null, tokens: null };
176
+ a.events += 1;
177
+ if (l.agentType && l.agent) a.agentType = l.agentType;
178
+ if (l.event === "PreToolUse" && l.tool) a.toolCalls[l.tool] = (a.toolCalls[l.tool] || 0) + 1;
179
+ if (l.event === "PostToolUse" && l.ok === false) a.failedCalls += 1;
180
+ if (l.event === "SubagentStart") a.startedAt = l.t || a.startedAt;
181
+ if (l.event === "SubagentStop") {
182
+ a.endedAt = l.t || a.endedAt;
183
+ a.tokens = l.tokens || null;
184
+ } else if (l.t && (!a.endedAt || l.t > a.endedAt)) a.endedAt = l.t;
185
+ if (l.t && (!a.startedAt || l.t < a.startedAt) && l.event !== "SubagentStart") a.startedAt = a.startedAt || l.t;
186
+ byAgent.set(key, a);
187
+ }
188
+ const out = [...byAgent.values()];
189
+ for (const a of out) {
190
+ const s = Date.parse(a.startedAt || "");
191
+ const e = Date.parse(a.endedAt || "");
192
+ a.wallMs = Number.isFinite(s) && Number.isFinite(e) && e >= s ? e - s : null;
193
+ }
194
+ out.sort((x, y) => (x.agent === "main" ? -1 : y.agent === "main" ? 1 : String(x.startedAt).localeCompare(String(y.startedAt))));
195
+ return out;
196
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@dougharness/gates",
3
+ "version": "1.0.1",
4
+ "private": true,
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
+ "type": "module",
7
+ "license": "MIT",
8
+ "files": [
9
+ "scripts",
10
+ "lib",
11
+ "hooks",
12
+ ".claude-plugin"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.16"
16
+ }
17
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse (Edit|Write|MultiEdit): record edits per file for this session and nudge
3
+ // when the same file is edited `threshold` times inside `windowMinutes`. Also records the
4
+ // set of edited files so the stop gate knows whether anything changed.
5
+
6
+ import { runHook, allow, addContext } from "../lib/io.mjs";
7
+ import { loadConfig, projectDir } from "../lib/config.mjs";
8
+ import { loadState, saveState } from "../lib/state.mjs";
9
+ import { toProjectRelative, filePathsFromToolInput } from "../lib/paths.mjs";
10
+
11
+ runHook("edit-loop", async (input) => {
12
+ const dir = projectDir(input);
13
+ const cfg = loadConfig(dir);
14
+ const paths = filePathsFromToolInput(input.tool_input).map((p) => toProjectRelative(dir, p)).filter(Boolean);
15
+ if (paths.length === 0) return allow();
16
+
17
+ const now = Date.now();
18
+ const windowMs = (cfg.editLoop.windowMinutes || 30) * 60000;
19
+ const threshold = cfg.editLoop.threshold || 6;
20
+ const state = loadState(dir, input.session_id);
21
+ const looping = [];
22
+
23
+ for (const rel of paths) {
24
+ const stamps = (state.edits[rel] || []).filter((t) => now - t < windowMs);
25
+ stamps.push(now);
26
+ state.edits[rel] = stamps;
27
+ if (!state.editedFiles.includes(rel)) state.editedFiles.push(rel);
28
+ if (stamps.length >= threshold && (stamps.length - threshold) % 3 === 0) looping.push({ rel, n: stamps.length });
29
+ }
30
+ saveState(dir, input.session_id, state);
31
+
32
+ if (looping.length === 0) return allow();
33
+ const lines = looping.map(({ rel, n }) => `${rel} has been edited ${n} times in the last ${cfg.editLoop.windowMinutes || 30} minutes.`);
34
+ return addContext(
35
+ "PostToolUse",
36
+ `[doug] Possible edit loop. ${lines.join(" ")} Before editing it again: re-read the whole file, run the relevant test once, ` +
37
+ `and state in one sentence what is actually wrong. If the approach is not working, say so and propose a different one.`,
38
+ );
39
+ });