@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,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
|
+
}
|