SLOPSHOPPER

sdd

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

guardstatuspromptprocess
A shopper browsing a rack in a slop shop
Source 1 files
hooks/register.ts 144 lines
1import 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