@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,10 @@
1
+ {
2
+ "name": "doug-gates",
3
+ "displayName": "Doug Gates",
4
+ "version": "1.0.1",
5
+ "description": "Deterministic guardrails for Claude Code: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, a verification gate on Stop and on SubagentStop, a snapshot of the plan and session into the anchor before compaction and re-anchoring after it, and a run trace of subagents and tool calls. Configured by .doug/config.json.",
6
+ "author": {
7
+ "name": "Doug contributors"
8
+ },
9
+ "hooks": "./hooks/hooks.json"
10
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Schwartz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,184 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/protect-paths.mjs\"",
10
+ "timeout": 5
11
+ },
12
+ {
13
+ "type": "command",
14
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/secret-scan.mjs\"",
15
+ "timeout": 5
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "matcher": "Bash",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/guard-bash.mjs\"",
25
+ "timeout": 5
26
+ },
27
+ {
28
+ "type": "command",
29
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/secret-scan.mjs\"",
30
+ "timeout": 5
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ "matcher": "WebFetch|WebSearch|Bash",
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/research-cap.mjs\"",
40
+ "timeout": 5
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
49
+ "timeout": 5
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "PostToolUse": [
55
+ {
56
+ "matcher": "Edit|Write|MultiEdit",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/format-on-edit.mjs\"",
61
+ "timeout": 30
62
+ },
63
+ {
64
+ "type": "command",
65
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/edit-loop.mjs\"",
66
+ "timeout": 5
67
+ }
68
+ ]
69
+ },
70
+ {
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
75
+ "timeout": 5
76
+ }
77
+ ]
78
+ }
79
+ ],
80
+ "InstructionsLoaded": [
81
+ {
82
+ "hooks": [
83
+ {
84
+ "type": "command",
85
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
86
+ "timeout": 5
87
+ }
88
+ ]
89
+ }
90
+ ],
91
+ "PermissionDenied": [
92
+ {
93
+ "hooks": [
94
+ {
95
+ "type": "command",
96
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
97
+ "timeout": 5
98
+ }
99
+ ]
100
+ }
101
+ ],
102
+ "Stop": [
103
+ {
104
+ "hooks": [
105
+ {
106
+ "type": "command",
107
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-gate.mjs\"",
108
+ "timeout": 600
109
+ }
110
+ ]
111
+ }
112
+ ],
113
+ "SessionStart": [
114
+ {
115
+ "matcher": "compact|resume",
116
+ "hooks": [
117
+ {
118
+ "type": "command",
119
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/reanchor.mjs\"",
120
+ "timeout": 5
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "hooks": [
126
+ {
127
+ "type": "command",
128
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start-baseline.mjs\"",
129
+ "timeout": 5
130
+ }
131
+ ]
132
+ }
133
+ ],
134
+ "PostCompact": [
135
+ {
136
+ "hooks": [
137
+ {
138
+ "type": "command",
139
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/reanchor.mjs\"",
140
+ "timeout": 5
141
+ }
142
+ ]
143
+ }
144
+ ],
145
+ "SubagentStart": [
146
+ {
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
151
+ "timeout": 5
152
+ }
153
+ ]
154
+ }
155
+ ],
156
+ "SubagentStop": [
157
+ {
158
+ "hooks": [
159
+ {
160
+ "type": "command",
161
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/trace.mjs\"",
162
+ "timeout": 5
163
+ },
164
+ {
165
+ "type": "command",
166
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-gate.mjs\"",
167
+ "timeout": 600
168
+ }
169
+ ]
170
+ }
171
+ ],
172
+ "PreCompact": [
173
+ {
174
+ "hooks": [
175
+ {
176
+ "type": "command",
177
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/reanchor.mjs\"",
178
+ "timeout": 5
179
+ }
180
+ ]
181
+ }
182
+ ]
183
+ }
184
+ }
@@ -0,0 +1,111 @@
1
+ // Worker context handoff (card worker-context-handoff, brief A): a subagent cannot be observed
2
+ // compacting (PreCompact/PostCompact never fire inside one — research note
3
+ // .doug/.state/research/worker-context-handoff.md), so this is graceful degradation, not compaction.
4
+ // The measured signal is the worker's own transcript (subagents/agent-<agent_id>.jsonl): the last
5
+ // assistant line's usage against its model's context window. Nothing here blocks; scripts/trace.mjs
6
+ // uses it to notice a worker approaching its limit, and scripts/stop-gate.mjs uses it to refuse an
7
+ // unearned partial claim.
8
+
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ // The recorded transcript path for a hook input firing inside a subagent (agent_id present). Prefers
13
+ // an explicit agent_transcript_path (as SubagentStop already carries), then a transcript_path that
14
+ // already names the subagent's own file, then derives the documented layout
15
+ // (<dirname of transcript_path>/<session_id>/subagents/agent-<agent_id>.jsonl). null without agent_id
16
+ // or without any path to derive from — never a guess.
17
+ export function agentTranscriptPath(input) {
18
+ if (!input || !input.agent_id) return null;
19
+ if (typeof input.agent_transcript_path === "string" && input.agent_transcript_path) return input.agent_transcript_path;
20
+ const tp = input.transcript_path;
21
+ if (typeof tp !== "string" || !tp) return null;
22
+ if (tp.endsWith(`agent-${input.agent_id}.jsonl`)) return tp;
23
+ return join(dirname(tp), String(input.session_id || ""), "subagents", `agent-${input.agent_id}.jsonl`);
24
+ }
25
+
26
+ // The context window for a model id: 1M for a Fable/Opus/Sonnet 5 model, 200k otherwise (an unknown
27
+ // or missing model errs early, on the smaller window).
28
+ export function windowFor(model) {
29
+ if (typeof model !== "string") return 200000;
30
+ return /fable-5|opus-5|sonnet-5/.test(model) ? 1000000 : 200000;
31
+ }
32
+
33
+ // The last assistant line with usage in a transcript file, against its model's window. null when the
34
+ // file is missing, unreadable, or has no assistant line carrying usage — never a number in that case.
35
+ // Parses line by line and skips bad lines, the way lib/trace.mjs usageFromTranscript does (that
36
+ // function is unchanged; this one answers a different question — the LAST reading, not a sum).
37
+ export function contextReading(file, { windows = windowFor } = {}) {
38
+ if (!file || !existsSync(file)) return null;
39
+ let text;
40
+ try {
41
+ text = readFileSync(file, "utf8");
42
+ } catch {
43
+ return null;
44
+ }
45
+ let lastMessage = null;
46
+ for (const line of text.split("\n")) {
47
+ if (!line.trim()) continue;
48
+ let o;
49
+ try {
50
+ o = JSON.parse(line);
51
+ } catch {
52
+ continue;
53
+ }
54
+ if (o && o.type === "assistant" && o.message && o.message.usage) lastMessage = o.message;
55
+ }
56
+ if (!lastMessage) return null;
57
+ const u = lastMessage.usage;
58
+ const tokens = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
59
+ const model = lastMessage.model || null;
60
+ const window = windows(model);
61
+ return { tokens, model, window, pct: Math.round((100 * tokens) / window) };
62
+ }
63
+
64
+ // Writes state.agents[agentId].context from a fresh reading, keeping the running maximum pct
65
+ // (maxPct — what stop-gate.mjs checks a partial claim against) and preserving notifiedAt across
66
+ // updates (cleared only by the caller, when it actually notifies). `reading.size` (review MAJOR/minor
67
+ // 4) — the transcript file's byte length at the time of the reading — passes through unchanged when the
68
+ // caller supplies one (scripts/trace.mjs does, to skip re-reading an unchanged file; a one-off
69
+ // SubagentStop reading doesn't, and that's fine: size ends up undefined, JSON drops it). Returns the new
70
+ // context entry.
71
+ export function recordAgentContext(state, agentId, reading, now) {
72
+ state.agents = state.agents || {};
73
+ const prev = state.agents[agentId] || {};
74
+ const prevContext = prev.context || {};
75
+ const maxPct = typeof prevContext.maxPct === "number" ? Math.max(prevContext.maxPct, reading.pct) : reading.pct;
76
+ const entry = {
77
+ pct: reading.pct,
78
+ tokens: reading.tokens,
79
+ window: reading.window,
80
+ model: reading.model,
81
+ at: now.toISOString(),
82
+ maxPct,
83
+ notifiedAt: prevContext.notifiedAt,
84
+ size: reading.size,
85
+ };
86
+ state.agents[agentId] = { ...prev, context: entry };
87
+ return entry;
88
+ }
89
+
90
+ // True when a context entry has crossed contextWindow.threshold (default 80) and either has never
91
+ // notified or has grown by contextWindow.repeatAfter (default 5) points since the last notice.
92
+ export function shouldNotify(entry, cfg) {
93
+ if (!entry || typeof entry.pct !== "number") return false;
94
+ const cw = (cfg && cfg.contextWindow) || {};
95
+ const threshold = cw.threshold ?? 80;
96
+ const repeatAfter = cw.repeatAfter ?? 5;
97
+ if (entry.pct < threshold) return false;
98
+ if (typeof entry.notifiedAt === "number" && entry.pct < entry.notifiedAt + repeatAfter) return false;
99
+ return true;
100
+ }
101
+
102
+ // The one-line notice delivered to a worker via PostToolUse additionalContext. Quoted verbatim by
103
+ // brief B's agents and workflow — keep this wording.
104
+ export function noticeText(entry, cfg) {
105
+ const cw = (cfg && cfg.contextWindow) || {};
106
+ const threshold = cw.threshold ?? 80;
107
+ return (
108
+ `[doug] Worker context at ${entry.pct}% of ${entry.window} tokens (threshold ${threshold}). ` +
109
+ `Finish the file you are on and its named test, commit, then return partial=true with a handoff (completed, remaining, next, verify). Do not start another file.`
110
+ );
111
+ }
@@ -0,0 +1,110 @@
1
+ // PreCompact snapshot (decision 0002 #4, card precompact-anchor): what the next context must know, written into
2
+ // .doug/anchor.md between two marker lines so the rest of the anchor (the plan section, the user's own notes) is
3
+ // kept. Deterministic: the plan file, the decision files, this session's edited files and recent commands, and
4
+ // (card precompact-keeps-handoff) a git HEAD lookup shelled out to `git`. Nothing here estimates tokens; the
5
+ // compaction payload's estimates are ignored on purpose.
6
+
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const SNAPSHOT_START = "<!-- doug:compaction-snapshot start -->";
12
+ export const SNAPSHOT_END = "<!-- doug:compaction-snapshot end -->";
13
+
14
+ export function readPlanSummary(dir) {
15
+ const file = join(dir, ".doug/plan.json");
16
+ if (!existsSync(file)) return null;
17
+ try {
18
+ const p = JSON.parse(readFileSync(file, "utf8"));
19
+ if (!p || typeof p !== "object" || !Array.isArray(p.tasks)) return null;
20
+ return { title: p.title || "(untitled)", status: p.status || "unknown", card: typeof p.card === "string" ? p.card : null, tasks: p.tasks.map((t) => ({ id: t.id, files: Array.isArray(t.files) ? t.files : [] })) };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ // Short HEAD sha and current branch, tolerant of a non-repo or a git failure (moved from stop-gate.mjs; used by
27
+ // both the Stop gate's own handoff and the PreCompact snapshot).
28
+ export function gitHead(dir) {
29
+ try {
30
+ const sha = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: dir, encoding: "utf8", timeout: 2000 });
31
+ const branch = spawnSync("git", ["branch", "--show-current"], { cwd: dir, encoding: "utf8", timeout: 2000 });
32
+ return {
33
+ sha: !sha.error && sha.status === 0 ? (sha.stdout || "").trim() || null : null,
34
+ branch: !branch.error && branch.status === 0 ? (branch.stdout || "").trim() || null : null,
35
+ };
36
+ } catch {
37
+ return { sha: null, branch: null };
38
+ }
39
+ }
40
+
41
+ export function readDecisions(dir) {
42
+ const d = join(dir, "docs/decisions");
43
+ if (!existsSync(d)) return [];
44
+ try {
45
+ return readdirSync(d).filter((f) => f.endsWith(".md")).sort();
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ // The section text, without the markers. `handoff` is optional (card context-window-handoff): when present, the
52
+ // section gains a "Handoff boundary" block recording why the snapshot was taken outside a real compaction — the
53
+ // boundary name, the Stop gate's last result, the current HEAD, and the context pct that triggered it. Absent,
54
+ // the output is unchanged from before that card. The handoff block sits right after the heading line, before the
55
+ // plan/decisions/edited-files lines (card precompact-keeps-handoff, review MAJOR): those grow unbounded with a
56
+ // session's edited-file count, and SessionStart's re-injection caps the whole anchor at 4000 characters, so a few
57
+ // short handoff lines must be near the top to survive that cap on a long session.
58
+ export function snapshotSection({ plan, decisions, state, now, handoff }) {
59
+ const lines = [`## Compaction snapshot (doug, ${now.toISOString()})`];
60
+ if (handoff) {
61
+ lines.push("");
62
+ lines.push("### Handoff boundary");
63
+ lines.push(`Boundary: ${handoff.boundary}`);
64
+ if (handoff.gate) {
65
+ const at = handoff.gate.at;
66
+ lines.push(`Gate: ${handoff.gate.ok ? "green" : "red"}${Number.isFinite(at) ? ` (at ${new Date(at).toISOString()})` : ""}`);
67
+ }
68
+ const head = handoff.head || {};
69
+ lines.push(`HEAD: ${head.sha || "unknown"}${head.branch ? ` (${head.branch})` : ""}`);
70
+ if (typeof handoff.contextPct === "number") lines.push(`Context: ${handoff.contextPct}%`);
71
+ }
72
+ lines.push("");
73
+ if (plan) {
74
+ lines.push(`Plan: ${plan.title} [${plan.status}]${plan.card ? ` (card ${plan.card})` : ""}`);
75
+ for (const t of plan.tasks) lines.push(`- ${t.id} owns ${t.files.length ? t.files.join(", ") : "(no files)"}`);
76
+ } else lines.push("Plan: none in .doug/plan.json");
77
+ lines.push("");
78
+ lines.push(decisions.length ? `Decisions (docs/decisions): ${decisions.join(", ")}` : "Decisions: none under docs/decisions");
79
+ const edited = (state && state.editedFiles) || [];
80
+ const commands = ((state && state.commands) || []).slice(-10);
81
+ lines.push("");
82
+ lines.push(edited.length ? `Edited this session: ${edited.join(", ")}` : "Edited this session: nothing yet");
83
+ lines.push(commands.length ? `Recent commands:\n${commands.map((c) => `- ${c}`).join("\n")}` : "Recent commands: none");
84
+ return lines.join("\n");
85
+ }
86
+
87
+ // Splits anchor text into the compaction-snapshot block (markers included) and the rest, in the order each
88
+ // appeared. No snapshot section: `snapshot` is null and `rest` is the whole text unchanged. Used to put the
89
+ // snapshot first when re-injecting, so it is never cut by content that precedes it.
90
+ export function splitSnapshot(text) {
91
+ const t = text || "";
92
+ const start = t.indexOf(SNAPSHOT_START);
93
+ const end = t.indexOf(SNAPSHOT_END);
94
+ if (start < 0 || end < start) return { snapshot: null, rest: t };
95
+ const endIdx = end + SNAPSHOT_END.length;
96
+ const snapshot = t.slice(start, endIdx);
97
+ const rest = (t.slice(0, start) + t.slice(endIdx)).replace(/\n{3,}/g, "\n\n").trim();
98
+ return { snapshot, rest };
99
+ }
100
+
101
+ // The anchor with the snapshot section replaced or appended; the text outside the markers is kept byte for byte.
102
+ export function mergeSnapshot(anchorText, section) {
103
+ const block = `${SNAPSHOT_START}\n${section}\n${SNAPSHOT_END}\n`;
104
+ const text = anchorText || "";
105
+ const start = text.indexOf(SNAPSHOT_START);
106
+ const end = text.indexOf(SNAPSHOT_END);
107
+ if (start >= 0 && end > start) return text.slice(0, start) + block + text.slice(end + SNAPSHOT_END.length).replace(/^\n/, "");
108
+ if (!text.trim()) return block;
109
+ return text.replace(/\n*$/, "\n\n") + block;
110
+ }
@@ -0,0 +1,160 @@
1
+ // Shared helpers for the "dirty tree at session start" baseline (card stop-gate-waiting-on-subagents,
2
+ // stop-gate-session-start): which paths are already dirty in the working tree, and a cheap content
3
+ // identity for each so a path already dirty when the baseline was captured is never mistaken for
4
+ // this session's own work later. Used by both the stop gate (its first-run fallback capture) and the
5
+ // SessionStart script (its earlier capture, when the platform fires it).
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFileSync, readdirSync } from "node:fs";
9
+ import { createHash } from "node:crypto";
10
+ import { join, resolve, sep } from "node:path";
11
+ import { matchAny } from "./glob.mjs";
12
+
13
+ // The working tree's changed paths (tracked and untracked), relative to `dir`. Null when `dir` is not
14
+ // a git repo or git is unavailable — callers treat that as "unknown" rather than "nothing changed".
15
+ export function changedFiles(dir) {
16
+ try {
17
+ const out = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
18
+ return out
19
+ .split("\n")
20
+ .map((l) => /^.. (.*)$/.exec(l))
21
+ .filter(Boolean)
22
+ .map((m) => m[1].replace(/^"|"$/g, ""))
23
+ .map((p) => (p.includes(" -> ") ? p.split(" -> ")[1] : p))
24
+ // Claude Code keeps subagent worktrees under .claude/worktrees/ inside the repo, untracked and unignored.
25
+ // Their contents (including node_modules) are not this session's changes.
26
+ .filter((p) => !p.startsWith(".claude/worktrees/"))
27
+ // The hooks' own state lives under .doug/.state/ (lib/state.mjs) and is untracked; a project that has not
28
+ // gitignored it must not have the state file itself show up as a change (lib/scope.mjs exempts it too).
29
+ .filter((p) => !p.startsWith(".doug/.state/"))
30
+ // .doug/anchor.md is a handoff/compaction snapshot the hooks themselves write (lib/anchor.mjs, reanchor.mjs
31
+ // on PreCompact, and stop-gate.mjs's context-window notice); like .doug/.state/, the harness's own write to
32
+ // it must never read as "this session changed a file" — otherwise a green Stop's own notice would make the
33
+ // very next Stop demand evidence for a session that changed nothing (lib/scope.mjs's ALWAYS_ALLOWED exempts
34
+ // it from plan scope for the same reason).
35
+ .filter((p) => p !== ".doug/anchor.md");
36
+ } catch {
37
+ return null; // not a git repo or git unavailable
38
+ }
39
+ }
40
+
41
+ // Cheap content identity for a dirty path: size + a hash of its bytes, or null when it cannot be read
42
+ // (deleted, or never existed as a regular file). Used to tell a path that was already dirty when the
43
+ // session's baseline was captured from one this session actually changed further.
44
+ export function fileIdentity(absPath) {
45
+ try {
46
+ const buf = readFileSync(absPath);
47
+ return `${buf.length}:${createHash("sha1").update(buf).digest("hex")}`;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // The repo's current HEAD sha in `dir`, or null when there is no git repo, no HEAD yet (a repo with no
54
+ // commits), or git is unavailable. Recorded once per session alongside state.baseline (card
55
+ // stop-scan-committed-changes), so a later Stop can scan `git diff --name-only <that sha>..HEAD` for changes
56
+ // the session committed instead of leaving dirty in the working tree (committedSince below).
57
+ export function headAt(dir) {
58
+ try {
59
+ const out = execFileSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
60
+ const sha = out.trim();
61
+ return sha || null;
62
+ } catch {
63
+ return null; // no git repo, no HEAD yet, or git unavailable
64
+ }
65
+ }
66
+
67
+ // A git commit sha, abbreviated or full (Minor 1, card stop-scan-committed-changes): state.baselineHead is
68
+ // agent-writable data (the whole session state file lives under .doug/.state/, which nothing prevents an
69
+ // agent from editing directly), so committedSince below must never hand it to git unvalidated - a string
70
+ // like "--output=/tmp/x" passed where git expects a revision could otherwise be read as an option instead,
71
+ // letting a forged state file steer the git invocation itself.
72
+ const GIT_SHA_RE = /^[0-9a-f]{7,64}$/;
73
+
74
+ // The paths that differ between `baseHead` and HEAD (card stop-scan-committed-changes): a protected- or
75
+ // proposal-path change the session commits before it stops leaves a clean tree, which changedFiles above
76
+ // cannot see. Filtered the same way changedFiles filters its own housekeeping paths. Null — callers treat
77
+ // this exactly like changedFiles' null, "unknown" rather than "nothing changed" — when `baseHead` does not
78
+ // look like a plausible git sha (GIT_SHA_RE above; Minor 1 - never recorded, e.g. an old state file predating
79
+ // this key, or a forged/malformed one), HEAD does not exist (a repo with no commits), or git exits non-zero
80
+ // (a rewritten base git no longer knows).
81
+ export function committedSince(dir, baseHead) {
82
+ if (typeof baseHead !== "string" || !GIT_SHA_RE.test(baseHead)) return null;
83
+ try {
84
+ // The trailing "--" (Minor 1) tells git that nothing after it is an option, belt-and-braces alongside the
85
+ // GIT_SHA_RE check above: even a validated-looking sha is passed as a positional revision, never anything
86
+ // git could interpret as a flag.
87
+ const out = execFileSync("git", ["diff", "--name-only", "--no-renames", baseHead, "HEAD", "--"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
88
+ return out
89
+ .split("\n")
90
+ .map((l) => l.trim())
91
+ .filter(Boolean)
92
+ .filter((p) => !p.startsWith(".claude/worktrees/"))
93
+ .filter((p) => !p.startsWith(".doug/.state/"))
94
+ .filter((p) => p !== ".doug/anchor.md");
95
+ } catch {
96
+ return null; // baseHead unknown to git, no HEAD, or git unavailable
97
+ }
98
+ }
99
+
100
+ // The path segments of a glob pattern before its first glob metacharacter (*, ?, {) — for
101
+ // "docs/decisions/**" that is "docs/decisions/", for ".claude/rules/**" it is ".claude/rules/". Used by
102
+ // walkProposalPathFiles below to know where on disk to start walking without interpreting the glob itself. A
103
+ // pattern with no metacharacter before its first "/" (or none at all) yields "".
104
+ function fixedPrefix(pattern) {
105
+ const idx = pattern.search(/[*?{]/);
106
+ const cut = idx === -1 ? pattern : pattern.slice(0, idx);
107
+ const lastSlash = cut.lastIndexOf("/");
108
+ return lastSlash === -1 ? "" : cut.slice(0, lastSlash + 1);
109
+ }
110
+
111
+ // Every file on disk under each of `patterns`' fixed prefixes that itself matches one of `patterns` (card
112
+ // stop-scan-committed-changes): `git status` cannot see a path under a directory a project's own .gitignore
113
+ // covers (e.g. a .gitignore that ignores .claude/ entirely, hiding a Bash-written .claude/rules/*.md from git
114
+ // completely), so proposalPaths gets its own direct filesystem walk instead of relying on changedFiles.
115
+ // Symlinks are skipped by design, never followed and never reported (Minor 2): this is a deliberate choice to
116
+ // keep the walk cheap and confined to real files under `dir`, not an oversight, but it is a known hole - a
117
+ // proposalPaths file reachable only through a symlinked subdirectory (or a symlinked file directly) is
118
+ // invisible to this scan, the same way it is invisible to `git status` for an ignored one. A prefix that does
119
+ // not exist on disk, or resolves outside `dir` (Minor 3 - e.g. a "../**" pattern, which would otherwise walk
120
+ // the project's parent directory), is silently skipped. Deduped, relpaths using forward slashes. Only
121
+ // proposalPaths uses this — protectedPaths does not need it, per the card's goal.
122
+ export function walkProposalPathFiles(dir, patterns) {
123
+ const list = Array.isArray(patterns) ? patterns : [];
124
+ const prefixes = [...new Set(list.map(fixedPrefix).filter((p) => p !== ""))];
125
+ const root = resolve(dir);
126
+ const out = new Set();
127
+ const walk = (absDir, relDir) => {
128
+ let entries;
129
+ try {
130
+ entries = readdirSync(absDir, { withFileTypes: true });
131
+ } catch {
132
+ return; // prefix does not exist, or is not a directory
133
+ }
134
+ for (const entry of entries) {
135
+ if (entry.isSymbolicLink()) continue;
136
+ const relPath = relDir + entry.name;
137
+ if (entry.isDirectory()) walk(join(absDir, entry.name), relPath + "/");
138
+ else if (entry.isFile() && matchAny(list, relPath)) out.add(relPath);
139
+ }
140
+ };
141
+ for (const prefix of prefixes) {
142
+ const abs = resolve(dir, prefix);
143
+ // Minor 3: clamp to the project dir - a prefix must resolve to `dir` itself or somewhere under it.
144
+ if (abs !== root && !abs.startsWith(root + sep)) continue;
145
+ walk(abs, prefix);
146
+ }
147
+ return [...out];
148
+ }
149
+
150
+ export function captureBaseline(dir, paths) {
151
+ const baseline = {};
152
+ for (const p of paths) baseline[p] = fileIdentity(join(dir, p));
153
+ return baseline;
154
+ }
155
+
156
+ // Paths considered this session's own work: absent from the baseline (new since the session started),
157
+ // or present but whose content identity has moved since the baseline was captured.
158
+ export function sinceBaseline(dir, paths, baseline) {
159
+ return paths.filter((p) => !(p in baseline) || fileIdentity(join(dir, p)) !== baseline[p]);
160
+ }