SLOPSHOPPER

plugin

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

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plugin
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dashboard ⎿ plugin: dh dashboard exited 0 but printed no URL ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ plugin: ctx ▰▰▰▰▰▱▱▱▱▱ 49% · gate pending · 5h 31% · $0.42
README

dev-harness

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.

Instalar

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.

Carregar a partir do clone (desenvolvimento)

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.

Fontes e pacote

CaminhoPapel
.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/.

Idioma

  • O Coordenador responde no idioma em que você escreve. Inglês é o padrão quando não há sinal; escreva em português e ele responde em português.
  • No primeiro /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.
  • Entre agentes tudo é inglês: despachos, respostas dos especialistas, código, commits e casos de eval. project.yaml e perfis também.
  • Documentos HTML gerados pela skill doc-template-html seguem o mesmo campo: bash .skills/doc-template-html/scripts/stamp.sh --lang en ... (padrão pt-br).
  • Documentação do kit: pt-br em 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.

Manutenção

go test ./...
go run ./cmd/dh validate --source-only .
go run ./cmd/dh build
go run ./cmd/dh validate .
Source 5 files
hooks/panel.ts 382 lines
1import 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}
382
hooks/runtime-guard.ts 82 lines
1import 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}
82
hooks/dashboard-command.ts 29 lines
1import 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}
29
hooks/snapshot-writer.ts 176 lines
1import 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}
176
hooks/status-drift.ts 52 lines
1// 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