@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.
- package/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- 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
|
+
});
|