Dev Harness session snapshots and dashboard, written by the mod; local files only, no network

English: README.en.md
Kit portátil de desenvolvimento assistido por IA. Instale no Claude Code, desenvolva.
Licença MIT. Plano de produto: docs/plano-produto.html. Tutorial: docs/tutorial.html.
Pelo marketplace do próprio repositório, dentro de uma sessão do Claude Code no projeto em que você vai trabalhar. A forma por URL baixa só o marketplace.json, sem clonar o repositório:
/plugin marketplace add https://raw.githubusercontent.com/danielmalka/dev-harness/main/.claude-plugin/marketplace.json
/plugin install dh@dev-harness
A forma curta /plugin marketplace add danielmalka/dev-harness também funciona, mas clona o repositório inteiro (cerca de 90 MB, por causa dos binários dh de cada versão) e já falhou uma vez com fetch-pack: invalid index-pack output (não reproduzido). Com um clone local, /plugin marketplace add /caminho/do/clone serve também. Em todas as formas o install baixa só a pasta do plugin, na tag fixada em .claude-plugin/marketplace.json, de onde vem a versão. Para um time, o projeto pode declarar o marketplace em extraKnownMarketplaces e o plugin em enabledPlugins no .claude/settings.json, e o Claude instala para quem confiar na pasta.
Versão atual: 0.24.0 (tag v0.24.0). Instalação por URL do marketplace.json, sem clonar o repositório. Extensão VS Code (dev-harness-vscode, verificada em VS Code real no Remote WSL e no Windows): view com dashboard local, status bar com limites, lançador de sessão; instale o .vsix da release mais recente via "Extensions: Install from VSIX...". Dashboard local em http://127.0.0.1:4747 com /dashboard (registrado pelo mod), parado com dh dashboard --stop [--port N]; mostra os projetos registrados em ~/.harness/config.yaml, uma barra de progresso por PRD aberto, sessões do Claude Code com estado, métricas por projeto no card (duração de ticket e de PRD com n e "sem data", tickets por PRD, incidentes, memória, última consolidação, dois gráficos) e leitura de MEMORY/RISKS sob demanda, avatar com limites de 5h/wk (layout 70/30: projetos à esquerda, avatar e limites à direita sticky). Painel de status abaixo do prompt: contexto, agentes, dh slice progress, gate pending, limites 5h/wk (🔴 80%+), custo. Travas de commit: nega menção a IA em git commit, gh pr create/edit, gh release create; nega commit quando houve edição após último gate verde; agora escopo do repositório da cada commit (não mais bloqueia edições em outro repo). Validação: ticket aberto sob PRD marcado entregue em, ou todos os tickets prontos mas PRD não. Mensagens de painel e dh seguem idioma de .harness/project.yaml. Testes: 147 pass. Kit enxuto (13 agentes, 19 comandos, 21 skills) e evals fora do fluxo. Tutorial: docs/tutorial.html. CLIs externas rodam em clone descartável. PRD com quatro seções; ticket único substitui story. /dh:plan-loop disputa ondas com CLIs; /dh:auto encadeia discover-plan-build. Roadmap: docs/roadmap.html. Documentação por projeto (pdocs): /dh:document pdocs cria ~/.harness/projects/<nome>/pdocs/index.html, o dashboard serve em /pdocs/<nome>/ e liga no card. Notas: CHANGELOG.md.
Modo global (0.21.0): quem não pode deixar pasta do dh no repositório usa ~/.harness/ (DH_HOME muda o lugar), criada pelo dh e única por máquina e por ambiente: config.yaml com o registro de todos os projetos, projects/<nome>/ com MEMORY, tasks e PRDs, sessions/ e dashboard/. .harness/ interno vence quando existe; a trava de memória vale nos dois lugares. O /dh:setup escolhe o modo e registra o projeto; dh link registra ou religa (depois de mover o repositório), dh harness-path mostra o modo e a pasta resolvidos e dh projects --json lista os projetos. No trabalho, adicione ~/.harness em additionalDirectories do ~/.claude/settings.json (o doctor só sugere). As variáveis de ambiente antigas do dashboard (raízes e sprites) foram removidas; ver CHANGELOG.md 0.21.0 e a seção "Modo global" do tutorial.
Migrando de uma instalação anterior à 0.8.0: o id do plugin mudou de dev-harness@dev-harness para dh@dev-harness e os comandos de /dev-harness:<comando> para /dh:<comando>. Rode /plugin uninstall dev-harness@dev-harness e depois /plugin install dh@dev-harness; troque /dev-harness: por /dh: em scripts e anotações próprias. Nome do marketplace, repositório, binário Go dh e caminho de snapshot não mudam. Ver CHANGELOG.md 0.8.0.
Pré-requisitos: Git e Claude Code autenticado com acesso a um modelo. Python 3 só para a fixture slice-01. Go 1.22+ só para quem mantém o kit.
No projeto em que você vai trabalhar:
claude --plugin-dir "/caminho/absoluto/do/clone/dist/claude-code/dev-harness" --agent coordinator
Substitua o caminho pelo resultado de pwd no clone, mais /dist/claude-code/dev-harness. Atenção: dentro de aspas o ~ não expande e o Claude ignora o plugin em silêncio (--agent coordinator falha com "not found"). Use $HOME/... ou o caminho completo. Se o runtime exigir o nome qualificado, use --agent dh:coordinator.
Depois, na sessão:
/dh:doctor
/dh:setup
O diagnóstico mecânico também roda sem o Claude:
dist/claude-code/dev-harness/bin/dh doctor dist/claude-code/dev-harness
Tutorial completo: docs/tutorial.html. Fixture da primeira fatia: evals/fixtures/slice-01.
| Caminho | Papel |
|---|---|
.agents/ .commands/ .skills/ templates/ profiles/ | Fontes canônicas |
cmd/dh, internal/ | Binário Go dh: validate, build, doctor, snapshot. go run ./cmd/dh build gera dist/claude-code/dev-harness |
dist/ | Pacote gerado. Não editar. |
adapters/claude-code/ | Mapeamento para o Claude Code |
adapters/generic/ | Uso manual em outra IA, sem paridade |
Templates de trabalho em templates/en/ e templates/pt-br/ (versões espelhadas): PRD, Task/Bug (o ticket), ADR e RFC, além dos três registros de memória (MEMORY, EPOCHAL, RISKS). O setup grava language em .harness/project.yaml e escolhe a pasta. Quando usar cada um: docs/tutoriais/templates.html.
Perfis (base, go-api, typescript-web, typescript-api, php, kotlin, python) estão em inglês em profiles/.
/dh:setup, o idioma da sessão é gravado em .harness/project.yaml como language: en ou language: pt-br. Esse campo escolhe templates/<lang>/ para os registros de memória e os artefatos de trabalho (PRD, task, ADR, RFC). Para trocar, edite o campo ou peça ao Coordenador; registros já escritos não são traduzidos.project.yaml e perfis também.doc-template-html seguem o mesmo campo: bash .skills/doc-template-html/scripts/stamp.sh --lang en ... (padrão pt-br).docs/ e inglês em docs/en/; cada página tem link para a outra versão. Este README tem versão em README.en.md. O plano de produto é interno e existe só em pt-br.go test ./...
go run ./cmd/dh validate --source-only .
go run ./cmd/dh build
go run ./cmd/dh validate .hooks/panel.ts 382 lines1import type { EngineInterface, Register } from 'claude-code'
2import { denySpawn, guardFile, homeFrom, resolvePath } from './runtime-guard.ts'
3import { DASHBOARD_COMMAND, dhPath, registerDashboard } from './dashboard-command.ts'
4import { registerSnapshot } from './snapshot-writer.ts'
5import { driftMessage, findDrift, statusValue } from './status-drift.ts'
6
7// Commit/PR text that mentions AI: an empty `attribution` covers the default footer, this covers the rest.
8const AI_MENTION = /co-authored-by:\s*claude|generated with \[?claude|🤖|anthropic\.com|\bclaude code\b/i
9const GIT_C = String.raw`(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)\s+)?`
10const SHIPS = new RegExp(String.raw`\b(git\s+${GIT_C}commit|gh\s+pr\s+(create|edit)|gh\s+release\s+create)\b`)
11const COMMIT = new RegExp(String.raw`\bgit\s+${GIT_C}commit(?![\w-])`)
12const GENERIC_GATE = /\b(make\s+(check|test|lint|ci|stan|gate)|go\s+(test|vet)|golangci-lint|pytest|ruff\s+check|cargo\s+(test|clippy)|(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|check|typecheck)|tsc\b|phpstan|phpunit|pint)\b/
13const GATE_KEYS = /^ {2}(test|lint|vet|check|typecheck|stan|fmt[\w-]*):\s*\n\s+value:\s*(.+)$/gm
14
15export function bar(pct: number, width = 10): string {
16 const full = Math.round((Math.min(Math.max(pct, 0), 100) / 100) * width)
17 return '▰'.repeat(full) + '▱'.repeat(width - full)
18}
19
20export function deniesAiMention(command: string): boolean {
21 return SHIPS.test(command) && AI_MENTION.test(command)
22}
23
24export function parseGateCommands(projectYaml: string): string[] {
25 return [...projectYaml.matchAll(GATE_KEYS)]
26 .map(m => (m[2] ?? '').trim().replace(/^["']|["']$/g, ''))
27 .filter(v => v && v !== 'null')
28}
29
30export function isGateCommand(command: string, projectGates: string[]): boolean {
31 return GENERIC_GATE.test(command) || projectGates.some(g => command.includes(g))
32}
33
34export function parseLanguage(projectYaml: string): 'en' | 'pt-br' {
35 return /^language:\s*(\S+)/m.exec(projectYaml)?.[1]?.replace(/^["']|["']$/g, '').toLowerCase() === 'pt-br' ? 'pt-br' : 'en'
36}
37
38// D5: dh bookkeeping under .harness/ (or, global mode, under <home>/projects/) is not code churn,
39// so it does not dirty the gate. `home` is resolvePath-normalized; '' skips the global check.
40export function isHarnessPath(p: unknown, home = ''): boolean {
41 const n = String(p ?? '').replace(/\\/g, '/')
42 const abs = /^(\/|[A-Za-z]:)/.test(n)
43 const r = resolvePath('/', n) // `.harness/../src/a.go` is code, not bookkeeping
44 if (r.includes('/.harness/')) return true
45 return !!home && abs && r.startsWith(`${home === '/' ? '' : home}/projects/`)
46}
47
48const unquote = (s: string) => s.replace(/^(["'])(.*)\1$/, '$2')
49
50export { resolvePath }
51
52// Back to the platform's form for git and fs calls: `/C:/x` -> `C:/x`; POSIX paths unchanged.
53export const native = (p: string) => p.replace(/^\/([A-Z]):/, '$1:')
54
55// Directory a git commit (or a gate) at offset `at` runs in: each `cd <dir>` before `at` moves it
56// (relative to the previous one; a bare `cd` goes home), then `git -C <dir>` on the commit itself
57// resolves against that. `home` expands a leading `~`.
58// ponytail: shell parsing by regex; pushd, `cd -`, env-var dirs and `--git-dir` resolve wrong and fail open.
59export function commandDir(command: string, cwd: string, home: string, at = command.length): string {
60 let dir = cwd
61 const expand = (d: string) => resolvePath(dir, unquote(d).replace(/^~(?=\/|$)/, home))
62 for (const m of command.slice(0, at).matchAll(/(?:^|[;&|(\n]\s*|\bthen\s+)cd(?:\s+("[^"]*"|'[^']*'|[^\s;&|)]+))?(?=\s*(?:$|[;&|)\n]))/g)) {
63 dir = m[1] === undefined ? (home || dir) : expand(m[1])
64 }
65 const c = /^git\s+-C\s+("[^"]*"|'[^']*'|\S+)\s+/.exec(command.slice(at))
66 return c ? expand(c[1] ?? '.') : dir
67}
68
69// Offset of the first gate command in `command`, or -1.
70function gateAt(command: string, projectGates: string[]): number {
71 const hits = [GENERIC_GATE.exec(command)?.index ?? -1, ...projectGates.map(g => command.indexOf(g))].filter(i => i >= 0)
72 return hits.length ? Math.min(...hits) : -1
73}
74
75const MSG = {
76 en: {
77 ai: 'dev-harness: commit/PR text mentions AI/Claude. Remove the mention and retry.',
78 gate: (hint: string) => `dev-harness: files were edited after the last green gate in this session. Run ${hint} and commit again.`,
79 gateDefault: "the project's quality gate (tests + lint)",
80 ctx: (p: number, hard: boolean) => `Context at ${p}% - ${hard ? 'time to /compact or /dh:handoff' : 'consider /compact at the next pause'}`,
81 },
82 'pt-br': {
83 ai: 'dev-harness: commit/PR menciona IA/Claude. Remova a menção e tente de novo.',
84 gate: (hint: string) => `dev-harness: houve edição depois do último gate verde nesta sessão. Rode ${hint} e commite de novo.`,
85 gateDefault: 'o quality gate do projeto (testes + lint)',
86 ctx: (p: number, hard: boolean) => `Contexto em ${p}% - ${hard ? 'hora de /compact ou /dh:handoff' : 'considere /compact no próximo ponto de pausa'}`,
87 },
88}
89
90export function taskStatus(taskMd: string): 'done' | 'blocked' | 'open' {
91 const s = statusValue(taskMd)
92 if (/^(conclu|pronta, entregue|done)/.test(s)) return 'done'
93 if (/^bloque/.test(s)) return 'blocked'
94 return 'open'
95}
96
97// ponytail: module-level state; a mod reload resets gate/agents (current session only).
98// Absolute paths edited since the last green gate of their repository.
99const dirty = new Set<string>()
100const inside = (f: string, root: string) => f === root || f.startsWith(root === '/' ? '/' : `${root}/`)
101const running = new Map<string, string>()
102const warned = new Set<number>()
103
104async function home($: EngineInterface): Promise<string> {
105 try { return (await $.env.get('HOME')) ?? '' } catch { return '' }
106}
107
108// Repository root of `dir` from git; `dir` itself when git does not answer.
109async function repoRoot($: EngineInterface, dir: string): Promise<string> {
110 try {
111 const r = await $.process.run(['git', '-C', native(dir), 'rev-parse', '--show-toplevel'], { timeoutMs: 2_000 })
112 const top = r.exitCode === 0 ? r.stdout.trim() : ''
113 return /^(\/|[A-Za-z]:)/.test(top) ? resolvePath('/', top) : dir
114 } catch {
115 return dir
116 }
117}
118
119async function rootOf($: EngineInterface, command: string, at?: number): Promise<string> {
120 return repoRoot($, commandDir(command, await $.session.cwd(), await home($), at))
121}
122
123// <home> by the ADR-007 rule, plus the user home for `~`; literal names so `claude plugin validate` lists them.
124async function homes($: EngineInterface): Promise<{ home: string; user: string; cwd: string }> {
125 try {
126 const cwd = await $.session.cwd().catch(() => '/')
127 const dh = await $.env.get('DH_HOME'), hm = await $.env.get('HOME'), up = await $.env.get('USERPROFILE')
128 return { home: homeFrom(dh, hm, up, cwd), user: hm || up || '', cwd }
129 } catch {
130 return { home: '', user: '', cwd: '/' }
131 }
132}
133
134// R8: <home> is read here and passed in; guardFile cannot take $ across the import.
135async function guardWrite($: EngineInterface, e: any, next: any): Promise<any> {
136 const { home, user, cwd } = await homes($)
137 return guardFile(home, e, async (x: typeof e) => markDirty($, await next(x), x), cwd, user)
138}
139
140// R20: `dh harness-path --json <dir>`, the only resolver (R2: the mod never reads the global registry file).
141// undefined = dh failed (missing binary, exit != 0, bad JSON, timeout).
142type Harness = { mode: 'repo' | 'global' | 'none'; dir: string }
143const harnesses = new Map<string, Harness | undefined>()
144
145// The argv for `dh harness-path`, or undefined when spawning is unsafe: on Windows dhPath is a .cmd that
146// cmd.exe parses, so a directory with a cmd metacharacter is never passed (treated as a dh failure).
147export function harnessArgv(pluginRoot: string, dir: string): string[] | undefined {
148 const dh = dhPath(pluginRoot)
149 const d = native(dir)
150 if (dh.endsWith('.cmd') && /[&|<>^%!"()\r\n]/.test(d)) return undefined
151 return [dh, 'harness-path', '--json', d]
152}
153
154// What `dh harness-path --json` printed, or undefined when it is not a usable answer: the dir must be
155// absolute, at most 4096 chars, and free of control/line-separator characters (it is shown to the model).
156export function parseHarness(stdout: string): Harness | undefined {
157 try {
158 const o = JSON.parse(stdout)
159 if (o?.mode === 'none') return { mode: 'none', dir: '' }
160 const d = o?.dir
161 if ((o?.mode === 'repo' || o?.mode === 'global') && typeof d === 'string' && d.length <= 4096
162 && /^(\/|[A-Za-z]:|\\\\)/.test(d) && !/[\u0000-\u001f\u007f\u2028\u2029]/.test(d)) return { mode: o.mode, dir: d }
163 } catch { /* fail open */ }
164 return undefined
165}
166
167async function resolveHarness($: EngineInterface, dir: string): Promise<Harness | undefined> {
168 try {
169 const argv = harnessArgv($.plugin.root, dir)
170 if (!argv) return undefined
171 const r = await $.process.run(argv, { timeoutMs: 2_000 })
172 return r.exitCode === 0 ? parseHarness(r.stdout) : undefined
173 } catch {
174 return undefined // fail open
175 }
176}
177
178// Cached per directory. repo/global stick for the session; none and failures are dropped by every
179// prompt.compose (so /dh:setup or dh link apply without a restart) and re-resolved on the next read.
180// ponytail: readers between two composes reuse a none/failed answer, so a gate or status line never spawns dh per call.
181// Resolved from the repository root (as the commit gate does), so a session opened in a subfolder
182// finds its repo's harness; a non-git dir resolves as itself. Roots are cached per directory.
183// ponytail: a `git init` mid-session is not seen for a dir already cached as non-git.
184const roots = new Map<string, string>()
185async function rootFor($: EngineInterface, dir: string): Promise<string> {
186 const key = resolvePath('/', dir)
187 let root = roots.get(key)
188 if (root === undefined) roots.set(key, root = await repoRoot($, key))
189 return root
190}
191
192async function harnessOf($: EngineInterface, dir: string): Promise<{ root: string; h: Harness | undefined }> {
193 const root = await rootFor($, dir)
194 if (!harnesses.has(root)) harnesses.set(root, await resolveHarness($, root))
195 return { root, h: harnesses.get(root) }
196}
197
198// The folder holding project.yaml, tasks/ and prd/ for `dir`: the resolved one, else `<dir>/.harness` when it exists, else `<root>/.harness`.
199async function harnessDir($: EngineInterface, dir: string): Promise<string> {
200 try {
201 const { root, h } = await harnessOf($, dir)
202 if (h && h.mode !== 'none') return h.dir
203 // 0.20.0 read `<cwd>/.harness`; keep it when that folder exists below the git toplevel.
204 const own = `${dir}/.harness`
205 return (await $.fs.exists(own).catch(() => false)) ? own : `${native(root)}/.harness`
206 } catch {
207 return `${dir}/.harness`
208 }
209}
210
211export const HARNESS_SECTION = 'dh:harness'
212
213export function harnessText(h: Harness): string {
214 return h.mode === 'none'
215 ? 'Dev Harness: no harness for this project (mode none). Run /dh:setup to create one, or dh link if it already exists.'
216 : `Dev Harness: mode ${h.mode}; harness dir ${JSON.stringify(h.dir)}. Records (MEMORY.md, EPOCHAL.md, RISKS.md, project.yaml, tasks/, prd/) live there.`
217}
218
219async function composeSection($: EngineInterface): Promise<{ id: string; text: string; scope: 'session' } | undefined> {
220 for (const [k, v] of harnesses) if (!v || v.mode === 'none') harnesses.delete(k)
221 const { h } = await harnessOf($, await $.session.cwd())
222 return h ? { id: HARNESS_SECTION, text: harnessText(h), scope: 'session' } : undefined
223}
224
225async function readProject($: EngineInterface, root?: string): Promise<{ gates: string[]; lang: 'en' | 'pt-br' }> {
226 try {
227 const y = await $.fs.read(`${await harnessDir($, root ?? await $.session.cwd())}/project.yaml`) as string
228 return { gates: parseGateCommands(y), lang: parseLanguage(y) }
229 } catch {
230 return { gates: [], lang: 'en' }
231 }
232}
233
234async function dhProgress($: EngineInterface): Promise<string | undefined> {
235 try {
236 const dir = `${await harnessDir($, await $.session.cwd())}/tasks`
237 let done = 0, blocked = 0, total = 0
238 for (const entry of await $.fs.list(dir)) {
239 if (entry.kind !== 'dir') continue
240 const file = `${dir}/${entry.name}/TASK.md`
241 if (!(await $.fs.exists(file))) continue
242 const st = taskStatus(await $.fs.read(file) as string)
243 total++
244 if (st === 'done') done++
245 if (st === 'blocked') blocked++
246 }
247 if (!total) return undefined
248 return `dh ${bar((done / total) * 100, 5)} ${done}/${total}${blocked ? ` (${blocked} blocked)` : ''}`
249 } catch {
250 return undefined
251 }
252}
253
254async function refresh($: EngineInterface): Promise<void> {
255 const u = await $.session.usage()
256 const pct = u.context.percent ?? 0
257 const parts = [`ctx ${bar(pct)} ${pct}%`]
258 if (running.size) parts.push(`agents ${running.size}`)
259 const dh = await dhProgress($)
260 if (dh) parts.push(dh)
261 const cwd = await $.session.cwd()
262 if ([...dirty].some(f => inside(f, resolvePath('/', cwd)))) parts.push('gate pending')
263 for (const r of u.rateLimits) {
264 const label = r.kind === 'five_hour' ? '5h' : r.kind === 'seven_day' ? 'wk' : undefined
265 if (label) parts.push(`${label} ${Math.round(r.percentUsed)}%${r.percentUsed >= 80 ? ' 🔴' : ''}`)
266 }
267 if (u.cost) parts.push(`$${u.cost.usd.toFixed(2)}`)
268 $.ui.status(parts.join(' · '))
269 for (const t of [60, 80]) {
270 if (pct >= t && !warned.has(t)) {
271 warned.add(t)
272 $.ui.toast(MSG[(await readProject($)).lang].ctx(pct, t >= 80))
273 }
274 }
275}
276
277// R20: fail-open on any read error; no tickets (or no readable PRDs) means no drift.
278async function driftNow($: EngineInterface, cwd: string): Promise<ReturnType<typeof findDrift>> {
279 try {
280 const hdir = await harnessDir($, cwd)
281 const tickets: { id: string; md: string }[] = []
282 for (const entry of await $.fs.list(`${hdir}/tasks`)) {
283 const file = `${hdir}/tasks/${entry.name}/TASK.md`
284 if (entry.kind === 'dir' && (await $.fs.exists(file))) tickets.push({ id: entry.name, md: await $.fs.read(file) as string })
285 }
286 if (!tickets.length) return []
287 const prds = new Map<string, string>()
288 for (const dir of [`${cwd}/docs/prd`, `${hdir}/prd`]) {
289 try {
290 for (const f of (await $.fs.list(dir)).sort((a, b) => (a.name < b.name ? -1 : 1))) {
291 const id = /^(PRD-\d+)/.exec(f.name)?.[1]
292 if (!id || f.kind !== 'file' || !f.name.endsWith('.md') || f.name.endsWith('.review.md') || prds.has(id)) continue
293 prds.set(id, statusValue(await $.fs.read(`${dir}/${f.name}`) as string))
294 }
295 } catch { /* directory absent */ }
296 }
297 return findDrift(tickets, prds, taskStatus)
298 } catch {
299 return []
300 }
301}
302
303async function guardBash($: EngineInterface, command: string): Promise<string | undefined> {
304 const aiHit = deniesAiMention(command)
305 const commit = COMMIT.exec(command)
306 if (!aiHit && !commit) return undefined
307 // The commit's repository, not the session's, decides the gate, the language and the drift check.
308 const root = commit ? await rootOf($, command, commit.index) : await $.session.cwd()
309 const { gates, lang } = await readProject($, native(root))
310 if (aiHit) return MSG[lang].ai
311 if ([...dirty].some(f => inside(f, root))) return MSG[lang].gate(gates.length ? gates.join(' && ') : MSG[lang].gateDefault)
312 const drift = await driftNow($, native(root))
313 return drift.length ? driftMessage(drift, lang, native(root)) : undefined
314}
315
316async function markDirty<R extends { deny?: unknown; isError?: boolean }>($: EngineInterface, r: R, e: any): Promise<R> {
317 const p = e.file_path ?? e.notebook_path
318 if (r.deny === undefined && r.isError !== true && p && !isHarnessPath(p, (await homes($)).home)) {
319 dirty.add(resolvePath(await $.session.cwd().catch(() => '/'), String(p)))
320 }
321 return r
322}
323
324// ponytail: a mod failure never blocks a tool (fail-open); the trap is a convenience, not security.
325const failOpen = ($: EngineInterface, e: any, next: any) => next(e)
326
327// hooks.json `modules` accepts one entry per plugin and a duplicate on(event) is refused,
328// so panel.ts is the entry and chains the runtime-guard checks (ADR-005) ahead of its own.
329// The loader refuses $ passed across an import: denySpawn gets `{}`, guardFile gets <home> as a string.
330export const register: Register = on => {
331 registerSnapshot(on)
332 registerDashboard(on)
333
334 on('session.start', async ($, e, next) => {
335 const r = await next(e)
336 await $.command.register(DASHBOARD_COMMAND).catch(() => undefined)
337 await refresh($).catch(() => undefined)
338 return r
339 })
340
341 on('turn.complete', async ($, e, next) => {
342 const r = await next(e)
343 if (e.agentId) running.delete(e.agentId)
344 await refresh($).catch(() => undefined)
345 return r
346 })
347
348 on('agent.spawn', ($, e, next) => denySpawn({}, e, async (x: typeof e) => {
349 const r = await next(x)
350 if (r.agentId) {
351 running.set(r.agentId, x.description)
352 await refresh($).catch(() => undefined)
353 }
354 return r
355 })).catch(failOpen)
356
357 on('tool.call', { tool: 'Edit' }, guardWrite).catch(failOpen)
358 on('tool.call', { tool: 'Write' }, guardWrite).catch(failOpen)
359 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => markDirty($, await next(e), e)).catch(failOpen)
360
361 // R20: tell the session where its harness lives; a dh failure adds nothing (fail-open).
362 on('prompt.compose', async ($, e, next) => {
363 const r = await next(e)
364 const section = await composeSection($).catch(() => undefined)
365 return section ? { sections: [...r.sections, section] } : r
366 }).catch(failOpen)
367
368 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
369 const denied = await guardBash($, e.command).catch(() => undefined)
370 if (denied) return { deny: denied }
371 const r = await next(e)
372 // ponytail: pre-filter by generic + session gates (no git spawn on plain Bash); a custom gate that only
373 // another repo's project.yaml names does not clear that repo.
374 const at = r.deny === undefined && r.isError !== true && dirty.size ? gateAt(e.command, (await readProject($)).gates) : -1
375 if (at >= 0) {
376 const root = await rootOf($, e.command, at)
377 for (const f of dirty) if (inside(f, root)) dirty.delete(f)
378 }
379 return r
380 }).catch(failOpen)
381}
382hooks/runtime-guard.ts 82 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const FILES = ['MEMORY.md', 'EPOCHAL.md', 'RISKS.md']
4
5// Normalizes `p` against `base`: absolute result, no `.`/`..`/empty segments. A Windows drive path
6// (`C:\\x`, `c:/x`) is absolute too and reads as `/C:/x`, so git's `C:/repo` and the engine's `C:\\repo` meet.
7export function resolvePath(base: string, p: string): string {
8 const abs = (x: string) => x.replace(/\\/g, '/').replace(/^\/?([A-Za-z]):(?=\/|$)/, (_m, d: string) => `/${d.toUpperCase()}:`)
9 const n = abs(p)
10 const out: string[] = []
11 for (const seg of (n.startsWith('/') ? n : `${abs(base)}/${n}`).split('/')) {
12 if (seg === '..') out.pop()
13 else if (seg && seg !== '.') out.push(seg)
14 }
15 return `/${out.join('/')}`
16}
17
18// ADR-007: <home> = DH_HOME, else HOME/.harness, else USERPROFILE/.harness; '' when none is set.
19// Pure, so each module reads the three variables itself (literal names; $ does not cross an import).
20export function homeFrom(dhHome: unknown, home: unknown, userProfile: unknown, cwd = '/'): string {
21 const base = dhHome ? String(dhHome) : home ? `${home}/.harness` : userProfile ? `${userProfile}/.harness` : ''
22 return base ? resolvePath(cwd, base) : ''
23}
24
25export function denySpawn($: any, e: any, next: any) {
26 try {
27 if (e.parentAgentId) return { deny: 'only the Coordinator dispatches agents (dev-harness runtime guard)' }
28 } catch {} // fail open
29 return next(e)
30}
31
32// The protected record `file_path` names, or undefined: `<x>/.harness/<file>` (repo mode, by suffix) or
33// exactly `<home>/projects/<one segment>/<file>` (global mode), after expanding a leading `~` to `userHome`
34// and resolving against `cwd`. Case-insensitive on every platform: a look-alike denied is the safe side.
35// `home` '' turns the global pattern off (fail open, ADR-007 §4).
36// ponytail: accepted limits (ADR-005): a symlinked spelling of <home> is not resolved, and only Write/Edit
37// are guarded; a Bash redirect or any other write tool is outside the guard.
38export function protectedHit(home: string, filePath: unknown, cwd = '/', userHome = ''): string | undefined {
39 if (typeof filePath !== 'string' || !filePath) return undefined
40 const expanded = userHome ? filePath.replace(/^~(?=$|[\\/])/, userHome) : filePath
41 const abs = resolvePath(cwd, expanded) // `..` cannot walk around either pattern
42 const low = abs.toLowerCase()
43 const repo = FILES.find(f => low.endsWith(`/.harness/${f.toLowerCase()}`))
44 if (repo) return `.harness/${repo}`
45 if (!home) return undefined
46 const h = resolvePath('/', home)
47 const prefix = `${h === '/' ? '' : h}/projects/`
48 if (!low.startsWith(prefix.toLowerCase())) return undefined
49 const rest = abs.slice(prefix.length).split('/')
50 const file = FILES.find(f => f.toLowerCase() === rest[1]?.toLowerCase())
51 return rest.length === 2 && rest[0] && file ? abs : undefined
52}
53
54export function guardFile(home: string, e: any, next: any, cwd = '/', userHome = '') {
55 try {
56 const hit = e.agentId ? protectedHit(home, e.file_path, cwd, userHome) : undefined
57 if (hit) return { deny: `${hit} is written only by the Coordinator (dev-harness runtime guard); return the proposed update instead.` }
58 } catch {} // fail open
59 return next(e)
60}
61
62async function harnessHome($: EngineInterface): Promise<{ home: string; cwd: string; user: string }> {
63 try {
64 const cwd = await $.session.cwd().catch(() => '/')
65 const dh = await $.env.get('DH_HOME'), hm = await $.env.get('HOME'), up = await $.env.get('USERPROFILE')
66 return { home: homeFrom(dh, hm, up, cwd), cwd, user: hm || up || '' }
67 } catch {
68 return { home: '', cwd: '/', user: '' } // fail open: only the suffix pattern holds
69 }
70}
71
72const guard = async ($: EngineInterface, e: any, next: any) => {
73 const { home, cwd, user } = await harnessHome($)
74 return guardFile(home, e, next, cwd, user)
75}
76
77export const register: Register = on => {
78 on('agent.spawn', denySpawn)
79 on('tool.call', { tool: 'Write' }, guard)
80 on('tool.call', { tool: 'Edit' }, guard)
81}
82hooks/dashboard-command.ts 29 lines1import type { On } from 'claude-code'
2
3// PRD-012 R6: `/dh:dashboard` exists only as a command the mod registers; it runs the plugin's own
4// `dh dashboard --detach` (idempotent) and answers with the URL it prints.
5export const DASHBOARD_COMMAND = { name: 'dashboard', description: 'Start the Dev Harness dashboard (local, detached) and print its URL' }
6
7export function dhPath(root: string): string {
8 return /^[A-Za-z]:[\\/]|^\\\\/.test(root) ? `${root}\\bin\\dh.cmd` : `${root}/bin/dh`
9}
10
11// Each user token is a plain flag or word: no shell metacharacters reach dh.cmd on Windows.
12const ARG_OK = /^--?[A-Za-z][\w-]*(=[\w.:-]+)?$|^[\w.:-]+$/
13
14export function registerDashboard(on: On): void {
15 on('command.run', { command: 'dashboard' }, async ($, e) => {
16 try {
17 const extra = e.args.trim().split(/\s+/).filter(Boolean) // dh flags only: argv, no shell
18 const bad = extra.find(t => !ARG_OK.test(t))
19 if (bad !== undefined) return { text: `dh dashboard: unsupported argument ${JSON.stringify(bad)}; use plain flags such as --port=4748` }
20 const r = await $.process.run([dhPath($.plugin.root), 'dashboard', '--detach', ...extra], { timeoutMs: 15_000 })
21 const url = r.stdout.trim()
22 if (r.exitCode === 0 && !url) return { text: 'dh dashboard exited 0 but printed no URL' }
23 return { text: r.exitCode === 0 ? url : `dh dashboard failed (exit ${r.exitCode}): ${(r.stderr || r.stdout).trim()}` }
24 } catch (err) {
25 return { text: `dh dashboard failed: ${String(err)}` }
26 }
27 }).catch((_$: unknown, e: any, next: any) => next(e))
28}
29hooks/snapshot-writer.ts 176 lines1import type { EngineInterface, On } from 'claude-code'
2import { homeFrom } from './runtime-guard.ts'
3
4// PRD-012 R11/R13/R13b/R14/R15: the mod writes the per-session snapshot (schema 2) that
5// `dh snapshot event` used to write, plus activity/rate_limits, and keeps updated_at alive.
6const SAFE_ID = /^[\w-]{1,128}$/
7const BEAT_MS = 30_000
8const JOB_MS = 2_000
9
10type Ev = Record<string, any>
11type Snap = Record<string, any>
12type Limits = { five_hour?: number; seven_day?: number }
13
14// state is the schema 1 vocabulary; activity is the dashboard's (R13 table).
15const MAP: Record<string, { activity: string; state: string }> = {
16 SessionStart: { activity: 'idle', state: 'idle' },
17 UserPromptSubmit: { activity: 'working', state: 'active' },
18 PostToolUse: { activity: 'working', state: 'active' },
19 SubagentStart: { activity: 'working', state: 'active' },
20 SubagentStop: { activity: 'working', state: 'active' },
21 PermissionRequest: { activity: 'waiting', state: 'active' },
22 Notification: { activity: 'waiting', state: 'active' }, // permission_prompt only
23 StopFailure: { activity: 'error', state: 'idle' },
24 Stop: { activity: 'done', state: 'idle' },
25 SessionEnd: { activity: 'idle', state: 'closed' },
26}
27
28export function iso(ms: number): string {
29 return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z')
30}
31
32export function limitsFrom(rl: { kind: string; percentUsed: number }[]): Limits | undefined {
33 const out: Limits = {}
34 for (const r of rl) {
35 if (r.kind === 'five_hour') out.five_hour = r.percentUsed
36 if (r.kind === 'seven_day') out.seven_day = r.percentUsed
37 }
38 return Object.keys(out).length ? out : undefined
39}
40
41// Pure: previous file content + one classic hook event -> next content, or undefined when the event writes nothing.
42// Unknown fields of `prev` (tasks, ...) ride along.
43export function applyEvent(prev: Snap, name: string, e: Ev, now: string, limits?: Limits): Snap | undefined {
44 const m = MAP[name]
45 if (!m || (name === 'Notification' && e.notification_type !== 'permission_prompt')) return undefined
46 // A subagent finishing says nothing about the main thread: SubagentStop keeps the current activity.
47 const activity = name === 'SubagentStop' ? (prev.activity ?? m.activity) : m.activity
48 const s: Snap = { ...prev, schema: 2, session_id: e.session_id, state: m.state, activity, updated_at: now }
49 if (prev.activity !== activity || !prev.activity_at) s.activity_at = now
50 if (name === 'SessionStart') {
51 const title = e.session_name ?? e.session_title
52 if (typeof title === 'string') s.session_name = title
53 if (!s.started_at) s.started_at = now
54 if (typeof e.cwd === 'string') s.cwd = e.cwd
55 if (typeof e.agent_type === 'string') s.agent = e.agent_type
56 if (typeof e.model === 'string') s.model = { ...(prev.model ?? {}), id: e.model }
57 } else if (!s.cwd && typeof e.cwd === 'string') {
58 s.cwd = e.cwd // mod loaded mid-session: still attributable to a project
59 }
60 if (name === 'SubagentStart' || name === 'SubagentStop') {
61 const rec: Ev = { at: now, event: name }
62 if (typeof e.agent_type === 'string') rec.agent_type = e.agent_type
63 if (typeof e.agent_id === 'string') rec.agent_id = e.agent_id
64 s.events = [...(Array.isArray(prev.events) ? prev.events : []), rec].slice(-50)
65 }
66 if (limits) s.rate_limits = limits
67 return s
68}
69
70// ponytail: module-level state; a mod reload resets it (current session only).
71let chain: Promise<unknown> = Promise.resolve()
72let beatSession = ''
73let beat: { cancel: () => void } | undefined
74const SKIP_LIMIT = 3
75const skips = new Map<string, number>() // consecutive skipped writes per session (unparseable file)
76
77// R10: <home>/sessions, <home> by the ADR-007 rule (DH_HOME is the only control); '' when no home is known.
78// $.fs.write creates missing parent directories (engine type doc), so a clean machine needs no `dh` call first.
79async function snapshotDir($: EngineInterface): Promise<string> {
80 const home = homeFrom(await $.env.get('DH_HOME'), await $.env.get('HOME'), await $.env.get('USERPROFILE'), await $.session.cwd().catch(() => '/'))
81 return home ? `${home.replace(/^\/([A-Z]):/, '$1:')}/sessions` : ''
82}
83
84// {} when the file does not exist; undefined when it exists but cannot be read or parsed (the caller skips the write).
85async function readPrev($: EngineInterface, path: string): Promise<Snap | undefined> {
86 try {
87 if (!(await $.fs.exists(path))) return {}
88 const o = JSON.parse(await $.fs.read(path) as string)
89 return o && typeof o === 'object' && !Array.isArray(o) ? o : undefined
90 } catch {
91 return undefined
92 }
93}
94
95// A hung fs/usage call must never hold the session: each serialized job gets 2s.
96function withTimeout<T>($: EngineInterface, p: Promise<T>): Promise<T> {
97 let t: { cancel: () => void } | undefined
98 const limit = new Promise<never>((_, reject) => { t = $.clock.after(JOB_MS, () => reject(new Error('snapshot job timed out'))) })
99 return Promise.race([p, limit]).finally(() => t?.cancel())
100}
101
102async function usageLimits($: EngineInterface): Promise<Limits | undefined> {
103 try { return limitsFrom((await $.session.usage()).rateLimits) } catch { return undefined } // no usage: keep previous
104}
105
106async function touch($: EngineInterface): Promise<void> {
107 const id = beatSession
108 const dir = await snapshotDir($)
109 if (!id || !dir) return
110 const path = `${dir}/${id}.json`
111 const prev = await readPrev($, path)
112 const limits = await usageLimits($) // a limit's age must reflect when it was measured
113 const now = iso(await $.clock.now())
114 // Re-checked after the last await, inside the serialized job: an ended session is never resurrected.
115 if (!prev || beatSession !== id || !prev.session_id || prev.state === 'closed') return
116 await $.fs.write(path, JSON.stringify({ ...prev, updated_at: now, ...(limits ? { rate_limits: limits } : {}) }, null, 2) + '\n')
117}
118
119async function write($: EngineInterface, name: string, e: Ev): Promise<void> {
120 if (!SAFE_ID.test(String(e.session_id ?? ''))) return
121 try {
122 const dir = await snapshotDir($)
123 if (!dir) return
124 const path = `${dir}/${e.session_id}.json`
125 const limits = await usageLimits($)
126 let prev = await readPrev($, path)
127 if (!prev) {
128 // exists but unreadable/partial: do not overwrite what another writer is mid-way through,
129 // unless it stays unparseable for SKIP_LIMIT writes in a row (then it is corrupt, not partial)
130 const n = (skips.get(e.session_id) ?? 0) + 1
131 if (n < SKIP_LIMIT) { skips.set(e.session_id, n); return }
132 prev = {}
133 }
134 skips.delete(e.session_id)
135 const next = applyEvent(prev, name, e, iso(await $.clock.now()), limits)
136 if (!next) return
137 await $.fs.write(path, JSON.stringify(next, null, 2) + '\n')
138 if (name !== 'SessionEnd') {
139 beatSession = e.session_id
140 beat ??= $.clock.every(BEAT_MS, () => { chain = chain.then(() => withTimeout($, touch($))).catch(() => undefined) })
141 }
142 } finally {
143 if (name === 'SessionEnd' && e.session_id === beatSession) {
144 beat?.cancel()
145 beat = undefined
146 beatSession = ''
147 }
148 }
149}
150
151// Events are serialized so two quick hooks never read-modify-write the same file out of order.
152// A failed write is silent: a snapshot is a convenience, never a reason to disturb the session.
153async function record($: EngineInterface, name: string, e: Ev): Promise<void> {
154 const job = chain.then(() => withTimeout($, write($, name, e)))
155 chain = job.catch(() => undefined)
156 await job.catch(() => undefined)
157}
158
159// ponytail: a mod failure never disturbs the session (fail-open).
160const failOpen = (_$: unknown, e: any, next: any) => next(e)
161
162export function registerSnapshot(on: On): void {
163 on('classic.SessionStart', async ($, e, next) => { await record($, 'SessionStart', e); return next(e) }).catch(failOpen)
164 on('classic.UserPromptSubmit', async ($, e, next) => { void record($, 'UserPromptSubmit', e); return next(e) }).catch(failOpen)
165 on('classic.PostToolUse', async ($, e, next) => { void record($, 'PostToolUse', e); return next(e) }).catch(failOpen)
166 // Hot-path events (prompt, tool, subagent) do not wait for the write; chain keeps the order.
167 // PermissionRequest/Notification record before next(): next waits for the person's answer.
168 on('classic.PermissionRequest', async ($, e, next) => { await record($, 'PermissionRequest', e); return next(e) }).catch(failOpen)
169 on('classic.Notification', async ($, e, next) => { await record($, 'Notification', e); return next(e) }).catch(failOpen)
170 on('classic.StopFailure', async ($, e, next) => { await record($, 'StopFailure', e); return next(e) }).catch(failOpen)
171 on('classic.Stop', async ($, e, next) => { await record($, 'Stop', e); return next(e) }).catch(failOpen)
172 on('classic.SubagentStart', async ($, e, next) => { void record($, 'SubagentStart', e); return next(e) }).catch(failOpen)
173 on('classic.SubagentStop', async ($, e, next) => { void record($, 'SubagentStop', e); return next(e) }).catch(failOpen)
174 on('classic.SessionEnd', async ($, e, next) => { await record($, 'SessionEnd', e); return next(e) }).catch(failOpen)
175}
176hooks/status-drift.ts 52 lines1// PRD-012 R19/R20: ticket/PRD status drift. Pure, mirrors internal/dashboard + internal/kit/status_drift.go.
2// Shared fixture: internal/dashboard/testdata/status-cases.json.
3const STATUS_ROW = /^\|\s*Status\s*\|\s*([^|]+)\|/m
4const STATUS_BOLD = /\*\*Status:?\*\*:?\s*([^\n]+)/
5const PRD_FIELD = /^\|\s*(?:Story \/ PRD|PRD \(RF-[^)]*\))\s*\|\s*([^|]+)\|/m
6const PRD_ID = /PRD-\d+/
7
8export function statusValue(md: string): string {
9 return (md.match(STATUS_ROW)?.[1] ?? md.match(STATUS_BOLD)?.[1] ?? '').trim().toLowerCase()
10}
11
12// The PRD id a ticket links to, or '' ("fora"/"nenhum" or no PRD-n).
13export function prdLink(md: string): string {
14 const v = md.match(PRD_FIELD)?.[1]
15 if (v === undefined || /^(fora|nenhum)/.test(v.trim().toLowerCase())) return ''
16 return v.match(PRD_ID)?.[0] ?? ''
17}
18
19export type Drift = { prd: string; kind: 'open-in-delivered' | 'all-done'; tickets: string[] }
20
21// tickets: id + TASK.md text; prds: PRD id -> lowercased header Status. classify = taskStatus (panel.ts).
22export function findDrift(tickets: { id: string; md: string }[], prds: Map<string, string>, classify: (md: string) => string): Drift[] {
23 const by = new Map<string, { total: number; open: string[] }>()
24 for (const t of tickets) {
25 const id = prdLink(t.md)
26 if (!id || !prds.has(id)) continue
27 const c = by.get(id) ?? { total: 0, open: [] }
28 c.total++
29 if (classify(t.md) !== 'done') c.open.push(t.id)
30 by.set(id, c)
31 }
32 const out: Drift[] = []
33 for (const id of [...by.keys()].sort()) {
34 const c = by.get(id)!
35 const delivered = (prds.get(id) ?? '').startsWith('entregue em')
36 c.open.sort()
37 if (delivered && c.open.length) out.push({ prd: id, kind: 'open-in-delivered', tickets: c.open })
38 if (!delivered && c.total > 0 && !c.open.length) out.push({ prd: id, kind: 'all-done', tickets: [] })
39 }
40 return out
41}
42
43export function driftMessage(drift: Drift[], lang: 'en' | 'pt-br', cwd = ''): string {
44 const pt = lang === 'pt-br'
45 const lines = drift.map(d => d.kind === 'open-in-delivered'
46 ? (pt ? `${d.prd}: tickets ainda abertos (${d.tickets.join(', ')}) mas o PRD está "entregue em"; conclua os tickets ou corrija o Status do PRD.`
47 : `${d.prd}: tickets still open (${d.tickets.join(', ')}) but the PRD is marked "entregue em"; close the tickets or fix the PRD Status.`)
48 : (pt ? `${d.prd}: todos os tickets ligados estão concluídos mas o Status do PRD não começa com "entregue em"; atualize o Status.`
49 : `${d.prd}: all linked tickets are done but the PRD Status does not start with "entregue em"; update the Status.`))
50 return `dev-harness: ${pt ? 'deriva de status entre tickets e PRD bloqueia este commit' : 'ticket/PRD status drift blocks this commit'}\n${lines.join('\n')}\n${pt ? `Corrija e commite de novo. (Verificado só em ${cwd || 'o diretório da sessão'}.)` : `Fix it and commit again. (Checked in the session directory only: ${cwd || 'cwd'}.)`}`
51}
52