Spec-driven development: specification, design, plan, and step-by-step execution with independent reviews

hooks/register.ts 144 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3// The status line text for each stage skill.
4const STAGES: Record<string, string> = {
5 'sdd:start-work': 'worktree',
6 'sdd:create-spec': 'spec',
7 'sdd:create-design': 'design',
8 'sdd:create-plan': 'plan',
9 'sdd:execute-plan': 'execute',
10 'sdd:end-work': 'integrate',
11}
12
13// The status line text for each section of `progress.md`.
14const SECTIONS: Record<string, string> = {
15 Worktree: 'worktree',
16 Specification: 'spec',
17 Design: 'design',
18 Plan: 'plan',
19 Integration: 'integrate',
20}
21
22// Splits a Markdown file into its `## ` sections: the heading and the lines.
23const sections = (text: string) =>
24 text.split(/^## /m).slice(1).map(part => {
25 const [heading, ...lines] = part.split('\n')
26 return { heading: heading.trim(), lines }
27 })
28
29// Counts the checked items, and names the first unchecked one. A plan's
30// checklist item is a link, so only the link text is kept.
31const progress = (stage: string, lines: string[]) => {
32 const items = lines.filter(line => /^- \[[ x]\] /.test(line))
33 const done = items.filter(line => line.startsWith('- [x] ')).length
34 const next = items.find(line => line.startsWith('- [ ] '))
35 const name = next?.slice(6).replace(/^\[([^\]]*)\]?.*$/, '$1')
36 return `${stage} ${done}/${items.length}` + (name ? ` · ${name}` : '')
37}
38
39// The status of the stage in progress, from `progress.md`.
40const fromProgress = (text: string) => {
41 const section = sections(text).find(
42 s => s.heading in SECTIONS && s.lines.includes('Status: In progress.'))
43 return section && progress(SECTIONS[section.heading], section.lines)
44}
45
46// The status of the execution, from the checklist of `plan.md`.
47const fromPlan = (text: string) => {
48 const section = sections(text).find(s => s.heading === 'Checklist')
49 return section && progress('execute', section.lines)
50}
51
52// A directory option with `~` expanded and no trailing `/`.
53async function expand($: EngineInterface, dir: string) {
54 const path = dir.startsWith('~/') ? `${await $.env.get('HOME')}${dir.slice(1)}` : dir
55 return path.replace(/\/+$/, '')
56}
57
58// The directory of the work directories.
59async function dataDir($: EngineInterface, options: PluginOptions) {
60 return expand($, String(options.data_dir))
61}
62
63// The trimmed output of a Git command, or undefined when it fails. It runs in
64// `cwd`, or in the session's working directory.
65async function git($: EngineInterface, args: string[], cwd?: string) {
66 const run = await $.process.run(['git', ...args], { cwd }).catch(() => undefined)
67 return run?.exitCode === 0 ? run.stdout.trim() : undefined
68}
69
70// The text of a file, or an empty text when it cannot be read.
71async function readOrEmpty($: EngineInterface, path: string) {
72 return $.fs.read(path).catch(() => '')
73}
74
75// The status of a work directory: the stage in progress, otherwise the
76// execution of the plan.
77async function workStatus($: EngineInterface, dir: string) {
78 return fromProgress(await readOrEmpty($, `${dir}/progress.md`))
79 ?? fromPlan(await readOrEmpty($, `${dir}/plan.md`))
80}
81
82export const register: Register = (on, options) => {
83 // The status line is restored once, at the first prompt of a session.
84 let restored = false
85
86 // The model starts a stage through the Skill tool.
87 on('tool.call', { tool: 'Skill' }, ($, e, next) => {
88 if (e.skill in STAGES) $.ui.status(STAGES[e.skill])
89 return next(e)
90 })
91
92 // The user starts a stage with a slash command.
93 on('command.run', ($, e, next) => {
94 if (e.command in STAGES) $.ui.status(STAGES[e.command])
95 return next(e)
96 })
97
98 // A skill changes `progress.md` or `plan.md` of a work.
99 on('tool.call', async ($, e, next) => {
100 const result = await next(e)
101 if ((e.tool !== 'Edit' && e.tool !== 'Write') || result.isError) return result
102 const path = e.file_path
103 if (!path.startsWith(`${await dataDir($, options)}/`)) return result
104 if (!path.endsWith('/progress.md') && !path.endsWith('/plan.md')) return result
105 const status = await workStatus($, path.slice(0, path.lastIndexOf('/')))
106 if (status) $.ui.status(status)
107 return result
108 })
109
110 // The first prompt of a session in a work worktree. A resumed session
111 // enters its worktree after it starts, so this runs at the first prompt.
112 // The work is the branch, and the repository is the directory of the
113 // first worktree.
114 on('prompt.submit', async ($, e, next) => {
115 if (restored) return next(e)
116 restored = true
117 const list = await git($, ['worktree', 'list', '--porcelain'])
118 const root = list?.split('\n')[0]?.replace(/^worktree /, '')
119 const work = await git($, ['branch', '--show-current'])
120 if (root && work) {
121 const dir = `${await dataDir($, options)}/${root.split('/').pop()}/${work}`
122 const status = await workStatus($, dir)
123 if (status) $.ui.status(status)
124 }
125 return next(e)
126 })
127
128 // `EnterWorktree` creates a worktree. A name with a work directory is a
129 // work of `sdd:start-work`: its worktree is `<worktrees_dir>/<name>` on the
130 // branch `<name>`, from the current commit. Every other worktree is left to
131 // Claude Code.
132 on('classic.WorktreeCreate', async ($, e, next) => {
133 const root = await git($, ['rev-parse', '--show-toplevel'], e.cwd)
134 if (!root) return next(e)
135 const work = `${await dataDir($, options)}/${root.split('/').pop()}/${e.name}`
136 if (!await readOrEmpty($, `${work}/progress.md`)) return next(e)
137 const dir = await expand($, String(options.worktrees_dir))
138 const path = `${dir.startsWith('/') ? dir : `${root}/${dir}`}/${e.name}`
139 const run = await $.process.run(['git', 'worktree', 'add', '-b', e.name, path], { cwd: root })
140 if (run.exitCode !== 0) return { block: true, stopReason: run.stderr.trim() }
141 return { worktreePath: path }
142 })
143}
144