Drives a project workflow (spec, design, slices, build, review, retro) from one /workflow command, with tests locked between red and green.

A Claude Code mod that runs a project from idea to pull request with one command, /workflow, and keeps a slice's tests locked between writing them and making them pass.
It is the workflow half of the setup in ilianusss/.claude, loaded there as a submodule, and it also installs on its own as a plugin. The knowledge it relies on (writing-tests-first, writing-clean-code, writing-secure-code, following-git-conventions) and the reviewer agent live in that repo; without them the phases still run, with less guidance.
.git.At the prompt of a Claude Code session:
/plugin install workflow --marketplace ilianusss/claude-workflow
Then, in a repo, type /workflow new <title>. A project without .claude/workflow.json starts with the setup phase, which writes it.
| Command | Does |
|---|---|
/workflow | The workflows of this repo |
/workflow all | Open workflows in every repo |
/workflow new <title> | Start one: size, commits and branch are asked in a dialog |
/workflow continue [slug] | Resume one, with a drift check after a long break |
/workflow next | Clear the context and continue: the command between slices |
/workflow pause | Write the handoff and stop |
/workflow unlock <file> <reason> | Unlock a locked test yourself |
/workflow done <slug>, /workflow abandon <slug> <reason> | Close one |
/workflow adopt | Set up a project, or rebuild workflows from their committed specs |
The phases, the slice cycle and the test lock are described in docs/workflow.md of the setup repo.
hooks/ the module Claude Code loads
src/ commands, tools, guards, state, the test lock
phases/ what Claude does in each phase, with the files it may write
guides/ question banks, the decision guide, EARS, the drift check
templates/ spec, design, slices, ADR, project CLAUDE.md, workflow.json
tests/ claude plugin test suites, against a fake git and file system
claude --plugin-dir . # a session with the mod loaded, reloaded on save
claude plugin validate . # what the engine would refuse
claude plugin test . # the test suites
The engine writes the API types into .claude-plugin/types/ each time it loads the mod; grep them rather than trusting memory, since the API moves between releases. A hook must be a function literal, at most one hook per event without a matcher, and $ is never put in an object or handed to a callback: the engine refuses to load the module otherwise.
hooks/register.ts 41 lines1import type { Register } from 'claude-code'
2
3import { clearThenSubmit } from '../src/clock'
4import { registerCommand, registerCommands } from '../src/commands'
5import { loadContext } from '../src/context'
6import { registerGuards } from '../src/guards'
7import { phasePrompt } from '../src/prompts'
8import { newRuntime } from '../src/runtime'
9import { announce, refreshStatus } from '../src/status'
10import { registerToolHandlers, registerToolsOnce } from '../src/tools'
11
12export const register: Register = on => {
13 const runtime = newRuntime()
14
15 registerCommands(on, runtime)
16 registerToolHandlers(on, runtime)
17 registerGuards(on)
18
19 on('session.start', async ($, e, next) => {
20 const started = await next(e)
21 await registerCommand($)
22 const context = await loadContext($)
23 if (context === undefined || context.workflows.length === 0) return started
24 await registerToolsOnce($, runtime)
25 await announce($, context, await $.clock.now())
26 await refreshStatus($, context)
27 return started
28 })
29
30 on('turn.complete', async ($, e, next) => {
31 const completed = await next(e)
32 const slug = runtime.pendingNext
33 if (slug === undefined || e.agentId !== undefined || e.reason !== 'answer') return completed
34 runtime.pendingNext = undefined
35 const context = await loadContext($)
36 const workflow = context?.workflows.find(candidate => candidate.slug === slug)
37 if (context !== undefined && workflow !== undefined) clearThenSubmit($, await phasePrompt($, context, workflow))
38 return completed
39 })
40}
41src/clock.ts 23 lines1import type { EngineInterface } from 'claude-code'
2
3export function iso(ms: number): string {
4 return new Date(ms).toISOString()
5}
6
7export function day(ms: number): string {
8 return iso(ms).slice(0, 10)
9}
10
11export function submitLater($: EngineInterface, text: string): void {
12 $.clock.after(0, () => void $.prompt.submit({ text }))
13}
14
15export function clearThenSubmit($: EngineInterface, text: string): void {
16 $.clock.after(0, () => {
17 void (async () => {
18 await $.command.run({ command: 'clear' })
19 await $.prompt.submit({ text })
20 })()
21 })
22}
23src/commands.ts 341 lines1import type { EngineInterface, On, PromptOrigin } from 'claude-code'
2
3import { ask, askOrUndefined } from './ask'
4import { unlockFile } from './build'
5import { clearThenSubmit, day, iso, submitLater } from './clock'
6import { DEFAULT_SPECS_DIR, isOpen, loadContext, readSlices } from './context'
7import type { Context } from './context'
8import { age, driftFacts } from './drift'
9import { ADOPT, BRANCH, COMMITS, RETRO, SIZE, SWITCH, WORKTREE } from './labels'
10import { pausePrompt, phasePrompt, setupPrompt } from './prompts'
11import { Refusal, describe, refuse } from './refusal'
12import { addWorktree, isDefaultBranch, switchBranch } from './repo'
13import type { Runtime } from './runtime'
14import { parseSlices } from './slices'
15import { firstPhase, slugify, withLog } from './state'
16import type { Phase, Size, Workflow } from './state'
17import { phaseLabel, refreshStatus } from './status'
18import { openWorkflowsEverywhere, saveWorkflow } from './store'
19import { registerToolsOnce } from './tools'
20
21const RECENT_MS = 30 * 60_000
22
23const USAGE = [
24 'Usage:',
25 '- /workflow: the workflows of this repo',
26 '- /workflow all: open workflows in every repo',
27 '- /workflow new <title>: start one',
28 '- /workflow continue [slug]: resume one',
29 '- /workflow next: clear the context and continue',
30 '- /workflow pause: write the handoff and stop',
31 '- /workflow unlock <file> <reason>: unlock a locked test yourself',
32 '- /workflow done <slug>, /workflow abandon <slug> <reason>: close one',
33 '- /workflow adopt: set up this project, or rebuild workflows from their specs',
34].join('\n')
35
36type Session = { context: Context; now: number; runtime: Runtime }
37
38function words(text: string): string[] {
39 return text.trim().split(/\s+/).filter(word => word.length > 0)
40}
41
42async function status($: EngineInterface, { context, now }: Session): Promise<string> {
43 if (context.workflows.length === 0) return 'workflow: no workflow in this repo. /workflow new <title> starts one.'
44 const rows: string[] = []
45 for (const workflow of context.workflows) {
46 const here = workflow.branch === context.repo.branch ? ' (current branch)' : ''
47 rows.push(`- ${workflow.slug}: ${await phaseLabel($, context, workflow)}, ${workflow.status}, updated ${age(workflow.updated, now)}${here}`)
48 }
49 return ['Workflows in this repo:', ...rows].join('\n')
50}
51
52async function all($: EngineInterface, now: number): Promise<string> {
53 const open = (await openWorkflowsEverywhere($)).filter(entry => entry.status === 'active' || entry.status === 'paused')
54 if (open.length === 0) return 'workflow: no open workflow anywhere.'
55 return ['Open workflows:', ...open.map(entry => `- ${entry.root}: ${entry.slug} (${entry.phase}), updated ${age(entry.updated, now)}`)].join('\n')
56}
57
58async function placement($: EngineInterface, session: Session, slug: string): Promise<{ branch: string; worktree: string; here: boolean }> {
59 const { context } = session
60 const branch = `feat/${slug}`
61 if (context.current !== undefined) {
62 const choice = await ask($, `This checkout runs "${context.current.slug}". Where should the new workflow go?`, [WORKTREE.create, WORKTREE.here], 'Checkout')
63 if (choice === WORKTREE.create) {
64 const folder = context.repo.root.split('/').pop() ?? 'repo'
65 const path = `${context.repo.root.slice(0, context.repo.root.length - folder.length)}${folder}-${slug}`
66 await addWorktree($, context.repo, branch, path)
67 return { branch, worktree: path, here: false }
68 }
69 }
70 const options = isDefaultBranch(context.repo.branch)
71 ? [BRANCH.create(branch), BRANCH.stay(context.repo.branch)]
72 : [BRANCH.stay(context.repo.branch), BRANCH.create(branch)]
73 const choice = await ask($, `Which branch should "${slug}" use?`, options, 'Branch')
74 if (choice === BRANCH.create(branch)) {
75 await switchBranch($, context.repo, branch, true)
76 return { branch, worktree: context.repo.root, here: true }
77 }
78 return { branch: context.repo.branch, worktree: context.repo.root, here: true }
79}
80
81async function start($: EngineInterface, session: Session, title: string): Promise<string> {
82 const { context, now, runtime } = session
83 const slug = slugify(title)
84 if (slug.length === 0) return 'workflow: give the workflow a title: /workflow new <title>'
85 if (context.workflows.some(workflow => workflow.slug === slug)) {
86 return `workflow: "${slug}" exists already. /workflow continue ${slug} resumes it.`
87 }
88 let size: Size
89 let commits: boolean
90 let where: Awaited<ReturnType<typeof placement>>
91 try {
92 size = (await ask($, SIZE.question(title), [SIZE.feature, SIZE.project], 'Size')) === SIZE.project ? 'project' : 'feature'
93 commits = (await ask($, COMMITS.question, [COMMITS.yes, COMMITS.no], 'Commits')) === COMMITS.yes
94 where = await placement($, session, slug)
95 } catch (error) {
96 return error instanceof Error && error.message.startsWith('git ')
97 ? `workflow: ${error.message}. Nothing was created.`
98 : 'workflow: cancelled, nothing was created.'
99 }
100
101 const phase = firstPhase(size, context.config === undefined)
102 const workflow: Workflow = {
103 slug,
104 title,
105 size,
106 phase,
107 status: 'active',
108 branch: where.branch,
109 worktree: where.worktree,
110 slice: 0,
111 commits,
112 autoNext: false,
113 paths: [],
114 session: await $.session.id(),
115 updated: iso(now),
116 goal: title,
117 docs: [],
118 stopped: 'Not started.',
119 next: `Start the ${phase} phase.`,
120 questions: [],
121 log: [`${day(now)} created (${size})`],
122 }
123 await saveWorkflow($, context.repo, context.paths, workflow)
124 await registerToolsOnce($, runtime)
125 if (!where.here) {
126 return `workflow: started "${slug}" (${size}) in the new worktree ${where.worktree} on ${where.branch}. Start a session there and run /workflow continue ${slug}.`
127 }
128 const fresh = await loadContext($)
129 await refreshStatus($, fresh)
130 submitLater($, await phasePrompt($, fresh ?? context, workflow))
131 return `workflow: started "${slug}" (${size}) on ${where.branch}, phase ${phase}.`
132}
133
134function pick(context: Context, slug: string | undefined): Workflow {
135 if (slug !== undefined) {
136 const named = context.workflows.find(workflow => workflow.slug === slug)
137 if (named === undefined) refuse(`no workflow "${slug}" here. /workflow lists them.`)
138 return named
139 }
140 if (context.current !== undefined) return context.current
141 const open = context.workflows.filter(isOpen)
142 if (open.length === 1 && open[0] !== undefined) return open[0]
143 if (open.length === 0) refuse('no workflow to continue here. /workflow new <title> starts one.')
144 refuse(`several workflows are open: ${open.map(workflow => workflow.slug).join(', ')}. Name one: /workflow continue <slug>.`)
145}
146
147async function resume($: EngineInterface, session: Session, slug: string | undefined, clearFirst: boolean): Promise<string> {
148 const { now } = session
149 let context = session.context
150 const target = pick(context, slug)
151 if (!isOpen(target)) return `workflow: "${target.slug}" is ${target.status}.`
152 if (target.branch !== context.repo.branch) {
153 const answer = await askOrUndefined(
154 $,
155 `"${target.slug}" works on ${target.branch}, and this checkout is on ${context.repo.branch}. Switch?`,
156 [SWITCH.yes(target.branch), SWITCH.no],
157 'Branch',
158 )
159 if (answer !== SWITCH.yes(target.branch)) return `workflow: stayed on ${context.repo.branch}; nothing changed.`
160 await switchBranch($, context.repo, target.branch, false)
161 context = (await loadContext($)) ?? context
162 }
163
164 const sessionId = await $.session.id()
165 const elsewhere =
166 target.session !== sessionId && now - Date.parse(target.updated) < RECENT_MS
167 ? ` Another session worked on it ${Math.round((now - Date.parse(target.updated)) / 60_000)} minutes ago: make sure it has stopped.`
168 : ''
169 const drift = await driftFacts($, context, target, now)
170 let slice = target.slice
171 if (target.phase === 'build' && slice === 0) {
172 slice = (await readSlices($, context, target)).find(candidate => !candidate.done)?.number ?? 1
173 }
174 const resumed: Workflow = { ...target, status: 'active', session: sessionId, slice, updated: iso(now) }
175 await saveWorkflow($, context.repo, context.paths, resumed)
176 await refreshStatus($)
177 const prompt = await phasePrompt($, context, resumed, drift)
178 if (clearFirst) clearThenSubmit($, prompt)
179 else submitLater($, prompt)
180 const action = clearFirst ? 'clearing the context, then continuing' : 'continuing'
181 return `workflow: ${action} "${target.slug}": ${await phaseLabel($, context, resumed)}.${elsewhere}`
182}
183
184async function pause($: EngineInterface, { context, now }: Session, slug: string | undefined): Promise<string> {
185 const target = pick(context, slug)
186 await saveWorkflow($, context.repo, context.paths, withLog({ ...target, status: 'paused', updated: iso(now) }, day(now), 'paused'))
187 await refreshStatus($)
188 submitLater($, pausePrompt(target))
189 return `workflow: pausing "${target.slug}". Claude writes the handoff, then stops.`
190}
191
192async function close($: EngineInterface, session: Session, slug: string | undefined, reason: string, abandon: boolean): Promise<string> {
193 const { context, now } = session
194 const target = pick(context, slug)
195 if (abandon) {
196 const closed = withLog({ ...target, status: 'abandoned', updated: iso(now) }, day(now), `abandoned: ${reason || 'no reason given'}`)
197 await saveWorkflow($, context.repo, context.paths, closed)
198 await refreshStatus($)
199 return `workflow: abandoned "${target.slug}".`
200 }
201 if (target.size === 'project' && target.phase !== 'retro' && target.phase !== 'done') {
202 const answer = await askOrUndefined($, `Run the retro of "${target.slug}" before closing it?`, [RETRO.run, RETRO.close], 'Retro')
203 if (answer === undefined) return 'workflow: cancelled, nothing changed.'
204 if (answer === RETRO.run) {
205 const retro: Workflow = { ...target, phase: 'retro', status: 'active', updated: iso(now) }
206 await saveWorkflow($, context.repo, context.paths, withLog(retro, day(now), 'retro started'))
207 await refreshStatus($)
208 submitLater($, await phasePrompt($, context, retro))
209 return `workflow: starting the retro of "${target.slug}".`
210 }
211 }
212 const done: Workflow = { ...target, phase: 'done', status: 'done', updated: iso(now) }
213 await saveWorkflow($, context.repo, context.paths, withLog(done, day(now), 'done'))
214 await refreshStatus($)
215 return `workflow: "${target.slug}" is done.`
216}
217
218function byThePerson(origin: PromptOrigin): boolean {
219 return origin.kind === 'composer' || origin.kind === 'bridge'
220}
221
222async function unlock($: EngineInterface, session: Session, args: string[], origin: PromptOrigin): Promise<string> {
223 if (!byThePerson(origin)) return 'workflow: only the user unlocks a test, by typing /workflow unlock. Claude asks with mcp__workflow__unlock.'
224 const [file, ...reason] = args
225 if (file === undefined) return 'workflow: /workflow unlock <file> <reason>'
226 return `workflow: ${await unlockFile($, session.context, file, reason.join(' ') || 'unlocked by the user', true, session.now)}`
227}
228
229function inferred(files: ReadonlySet<string>, slicesText: string): { size: Size; phase: Phase; slice: number } {
230 const size: Size = files.has('spec.md') ? 'project' : 'feature'
231 if (files.has('slices.md')) {
232 const slices = parseSlices(slicesText)
233 const open = slices.find(slice => !slice.done)
234 return open === undefined ? { size, phase: 'inspect', slice: slices.length } : { size, phase: 'build', slice: open.number }
235 }
236 if (files.has('design.md')) return { size, phase: 'slice', slice: 0 }
237 if (files.has('spec.md')) return { size, phase: 'design', slice: 0 }
238 return { size, phase: firstPhase(size, false), slice: 0 }
239}
240
241async function adopt($: EngineInterface, session: Session): Promise<string> {
242 const { context, now, runtime } = session
243 const report: string[] = []
244 const specsDir = context.config?.specsDir ?? DEFAULT_SPECS_DIR
245 let folders: string[] = []
246 try {
247 folders = (await $.fs.list(`${context.repo.root}/${specsDir}`)).filter(entry => entry.kind === 'dir').map(entry => entry.name)
248 } catch {
249 folders = []
250 }
251 for (const slug of folders.filter(name => !context.workflows.some(workflow => workflow.slug === name))) {
252 const answer = await askOrUndefined($, `Rebuild a workflow for ${specsDir}/${slug}? Its documents are here, its state is not.`, [ADOPT.rebuild, ADOPT.skip], 'Adopt')
253 if (answer !== ADOPT.rebuild) continue
254 const names = new Set((await $.fs.list(`${context.repo.root}/${specsDir}/${slug}`)).map(entry => entry.name))
255 const slicesText = names.has('slices.md') ? await $.fs.read(`${context.repo.root}/${specsDir}/${slug}/slices.md`) : ''
256 const { size, phase, slice } = inferred(names, slicesText)
257 const workflow: Workflow = {
258 slug,
259 title: slug,
260 size,
261 phase,
262 status: 'active',
263 branch: context.repo.branch,
264 worktree: context.repo.root,
265 slice,
266 commits: false,
267 autoNext: false,
268 paths: [],
269 session: await $.session.id(),
270 updated: iso(now),
271 goal: '',
272 docs: [...names].map(name => `${specsDir}/${slug}/${name}`),
273 stopped: 'Rebuilt by /workflow adopt from the committed documents.',
274 next: `Check the rebuilt state, then continue the ${phase} phase.`,
275 questions: [],
276 log: [`${day(now)} rebuilt by adopt`],
277 }
278 await saveWorkflow($, context.repo, context.paths, workflow)
279 report.push(`rebuilt ${slug} (${phase}${phase === 'build' ? `, slice ${slice}` : ''}, on ${context.repo.branch})`)
280 }
281 if (report.length > 0) {
282 await registerToolsOnce($, runtime)
283 await refreshStatus($)
284 }
285 if (context.config === undefined) {
286 submitLater($, await setupPrompt($))
287 report.push('no .claude/workflow.json yet: Claude runs the setup phase')
288 }
289 return report.length > 0 ? `workflow: ${report.join('; ')}.` : 'workflow: nothing to adopt here: the setup exists and every spec folder has its workflow.'
290}
291
292async function dispatch($: EngineInterface, session: Session, args: string, origin: PromptOrigin): Promise<string> {
293 const [subcommand = 'status', ...rest] = words(args)
294 switch (subcommand) {
295 case 'status':
296 return status($, session)
297 case 'new':
298 return start($, session, rest.join(' '))
299 case 'continue':
300 return resume($, session, rest[0], false)
301 case 'next':
302 return resume($, session, rest[0], true)
303 case 'pause':
304 return pause($, session, rest[0])
305 case 'unlock':
306 return unlock($, session, rest, origin)
307 case 'done':
308 return close($, session, rest[0], '', false)
309 case 'abandon':
310 return close($, session, rest[0], rest.slice(1).join(' '), true)
311 case 'adopt':
312 return adopt($, session)
313 default:
314 return USAGE
315 }
316}
317
318export async function registerCommand($: EngineInterface): Promise<void> {
319 await $.command.register({
320 name: 'workflow',
321 description: "This repo's workflows; new, continue, next, pause, unlock, done, abandon, adopt, all.",
322 argumentHint: '[new <title> | continue [slug] | next | pause | unlock <file> <reason> | done | abandon | adopt | all]',
323 })
324}
325
326export function registerCommands(on: On, runtime: Runtime): void {
327 on('command.run', { command: 'workflow' }, async ($, e) => {
328 try {
329 const now = await $.clock.now()
330 if (words(e.args)[0] === 'all') return { text: await all($, now) }
331 const context = await loadContext($)
332 if (context === undefined) {
333 return { text: 'workflow: this folder is not in a git repository. Run git init, then /workflow new <title>.' }
334 }
335 return { text: await dispatch($, { context, now, runtime }, e.args, e.origin) }
336 } catch (error) {
337 return { text: error instanceof Refusal ? `workflow: ${error.message}` : `workflow: ${describe(error)}` }
338 }
339 })
340}
341src/context.ts 65 lines1import type { EngineInterface } from 'claude-code'
2
3import { parseConfig } from './config'
4import type { Config } from './config'
5import { findRepo } from './repo'
6import type { Repo } from './repo'
7import { parseSlices } from './slices'
8import type { Slice } from './slices'
9import { listWorkflows, pathsFor } from './store'
10import type { Paths } from './store'
11import type { Workflow } from './state'
12
13export type Context = {
14 repo: Repo
15 paths: Paths
16 workflows: Workflow[]
17 current: Workflow | undefined
18 config: Config | undefined
19 configErrors: string[]
20}
21
22export const DEFAULT_SPECS_DIR = 'docs/specs'
23
24export function isOpen(workflow: Workflow): boolean {
25 return workflow.status === 'active' || workflow.status === 'paused'
26}
27
28export async function loadContext($: EngineInterface): Promise<Context | undefined> {
29 const repo = await findRepo($, await $.session.cwd())
30 if (repo === undefined) return undefined
31 const paths = pathsFor(repo)
32 const workflows = await listWorkflows($, paths)
33 let config: Config | undefined
34 let configErrors: string[] = []
35 try {
36 const parsed = parseConfig(await $.fs.read(`${repo.root}/.claude/workflow.json`))
37 if ('config' in parsed) config = parsed.config
38 else configErrors = parsed.errors
39 } catch {
40 configErrors = []
41 }
42 const current = workflows.find(workflow => isOpen(workflow) && workflow.branch === repo.branch)
43 return { repo, paths, workflows, current, config, configErrors }
44}
45
46export function specsFolder(context: Context, workflow: Workflow): string {
47 return `${context.config?.specsDir ?? DEFAULT_SPECS_DIR}/${workflow.slug}`
48}
49
50export async function readSlices($: EngineInterface, context: Context, workflow: Workflow): Promise<Slice[]> {
51 try {
52 return parseSlices(await $.fs.read(`${context.repo.root}/${specsFolder(context, workflow)}/slices.md`))
53 } catch {
54 return []
55 }
56}
57
58export async function readDoc($: EngineInterface, context: Context, workflow: Workflow, name: string): Promise<string | undefined> {
59 try {
60 return await $.fs.read(`${context.repo.root}/${specsFolder(context, workflow)}/${name}`)
61 } catch {
62 return undefined
63 }
64}
65src/guards.ts 76 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { afterShell } from './build'
4import { DEFAULT_SPECS_DIR, loadContext } from './context'
5import { matchesAny } from './glob'
6import { readPhase, writeScope } from './phases'
7import { describe } from './refusal'
8import { relativeTo } from './repo'
9import { refreshStatus } from './status'
10import { readLock } from './store'
11
12async function writeRefusal($: EngineInterface, path: string): Promise<string | undefined> {
13 const context = await loadContext($)
14 if (context === undefined) return undefined
15 if (relativeTo(context.paths.dir, path) !== undefined) {
16 return `workflow: only the workflow mod writes ${context.paths.dir}. Update the handoff with mcp__workflow__handoff; phases move with mcp__workflow__advance.`
17 }
18 const workflow = context.current
19 const file = relativeTo(context.repo.root, path)
20 if (workflow === undefined || file === undefined) return undefined
21
22 let lock
23 try {
24 lock = await readLock($, context.paths, workflow.slug)
25 } catch (error) {
26 return `workflow: ${describe(error)}`
27 }
28 if (lock?.phase === 'green' && lock.files[file] !== undefined && !lock.unlocked.includes(file)) {
29 return `workflow: ${file} is a locked test of slice ${lock.slice}: it stays as written until mcp__workflow__verify passes. If the test is wrong, stop, explain why with evidence, and call mcp__workflow__unlock with the reason: the user decides.`
30 }
31
32 if (workflow.status !== 'active') return undefined
33 let scope: string[]
34 try {
35 scope = writeScope(await readPhase($, workflow.phase), workflow, context.config?.specsDir ?? DEFAULT_SPECS_DIR)
36 } catch {
37 return undefined
38 }
39 if (scope.length > 0 && !matchesAny(file, scope)) {
40 return `workflow: the ${workflow.phase} phase of "${workflow.slug}" may only write ${scope.join(', ')}. Code starts in the build phase; if this change is needed now, ask the user.`
41 }
42 return undefined
43}
44
45function failed(what: string): { deny: string } {
46 return { deny: `workflow: the guard on ${what} failed, so the call is refused.` }
47}
48
49export function registerGuards(on: On): void {
50 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
51 const reason = await writeRefusal($, e.file_path)
52 return reason === undefined ? next(e) : { deny: reason }
53 }).catch(($, e, next) => (next.called ? next(e) : failed('an Edit')))
54
55 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
56 const reason = await writeRefusal($, e.file_path)
57 return reason === undefined ? next(e) : { deny: reason }
58 }).catch(($, e, next) => (next.called ? next(e) : failed('a Write')))
59
60 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
61 const reason = await writeRefusal($, e.notebook_path)
62 return reason === undefined ? next(e) : { deny: reason }
63 }).catch(($, e, next) => (next.called ? next(e) : failed('a notebook edit')))
64
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 const ran = await next(e)
67 if (ran.deny !== undefined) return ran
68 const context = await loadContext($)
69 if (context?.current === undefined) return ran
70 const notes = await afterShell($, context, e.command, ran, await $.clock.now())
71 if (notes.length === 0) return ran
72 await refreshStatus($, context)
73 return { ...ran, context: [...(ran.context ?? []), ...notes] }
74 })
75}
76src/prompts.ts 88 lines1import type { EngineInterface } from 'claude-code'
2
3import { readDoc, readSlices, specsFolder } from './context'
4import type { Context } from './context'
5import { readPhase } from './phases'
6import { sections } from './slices'
7import type { Workflow } from './state'
8
9function bulletOr(items: readonly string[], none: string): string {
10 return items.length > 0 ? items.join('; ') : none
11}
12
13function standing(workflow: Workflow): string {
14 return [
15 '## Where the workflow stands',
16 '',
17 `- Goal: ${workflow.goal || '(not written yet)'}`,
18 `- Where we stopped: ${workflow.stopped || '(nothing yet)'}`,
19 `- Next action: ${workflow.next || '(decide it from the phase instructions)'}`,
20 `- Open questions: ${bulletOr(workflow.questions, 'none')}`,
21 `- Documents: ${bulletOr(workflow.docs, 'none yet')}`,
22 ].join('\n')
23}
24
25function files($: EngineInterface, specs: string): string {
26 return [
27 '## Files',
28 '',
29 `- This workflow's documents: \`${specs}/\``,
30 `- Guides the phase names: \`${$.plugin.root}/guides/\``,
31 `- Templates: \`${$.plugin.root}/templates/\``,
32 ].join('\n')
33}
34
35async function currentSlice($: EngineInterface, context: Context, workflow: Workflow): Promise<string> {
36 const slices = await readSlices($, context, workflow)
37 const slice = slices.find(candidate => candidate.number === workflow.slice)
38 if (slice === undefined) {
39 return `## Slice ${workflow.slice}\n\n\`slices.md\` has no slice ${workflow.slice}. Tell the user, and stop.`
40 }
41 const design = (await readDoc($, context, workflow, 'design.md')) ?? ''
42 const needed = sections(design, slice.designSections)
43 return [
44 `## This slice (${slice.number} of ${slices.length})`,
45 '',
46 slice.text,
47 '',
48 '## Design sections it needs',
49 '',
50 needed.length > 0 ? needed : '(the slice names none)',
51 ].join('\n')
52}
53
54export async function phasePrompt(
55 $: EngineInterface,
56 context: Context,
57 workflow: Workflow,
58 drift?: string,
59): Promise<string> {
60 const doc = await readPhase($, workflow.phase)
61 const specs = specsFolder(context, workflow)
62 const slice = workflow.phase === 'build' ? `, slice ${workflow.slice}` : ''
63 const parts = [
64 `# Workflow: ${workflow.title}`,
65 `You are working on the workflow \`${workflow.slug}\` (${workflow.size}), phase ${workflow.phase}${slice}, on branch \`${workflow.branch}\`. The workflow mod keeps its state: change it only through the mcp__workflow__* tools. Commits after each slice: ${workflow.commits ? 'yes' : 'no, the user commits'}.`,
66 drift,
67 doc.body,
68 standing(workflow),
69 files($, specs),
70 workflow.phase === 'build' ? await currentSlice($, context, workflow) : undefined,
71 ]
72 return parts.filter((part): part is string => part !== undefined && part.length > 0).join('\n\n')
73}
74
75export async function setupPrompt($: EngineInterface): Promise<string> {
76 const doc = await readPhase($, 'setup')
77 return [
78 '# Workflow setup',
79 'This project has no `.claude/workflow.json` yet. Set it up so workflows can run here.',
80 doc.body,
81 `Templates: \`${$.plugin.root}/templates/\``,
82 ].join('\n\n')
83}
84
85export function pausePrompt(workflow: Workflow): string {
86 return `Pause the workflow \`${workflow.slug}\`: call mcp__workflow__handoff now with where you stopped, the next concrete action and any open questions. Then stop and tell the user it is paused and how to resume (\`/workflow continue ${workflow.slug}\`).`
87}
88src/runtime.ts 9 lines1export type Runtime = {
2 toolsRegistered: boolean
3 pendingNext: string | undefined
4}
5
6export function newRuntime(): Runtime {
7 return { toolsRegistered: false, pendingNext: undefined }
8}
9src/status.ts 36 lines1import type { EngineInterface } from 'claude-code'
2
3import { isOpen, loadContext, readSlices } from './context'
4import type { Context } from './context'
5import { age } from './drift'
6import { readLock } from './store'
7import type { Workflow } from './state'
8
9export async function phaseLabel($: EngineInterface, context: Context, workflow: Workflow): Promise<string> {
10 if (workflow.phase !== 'build') return workflow.phase
11 return `build ${workflow.slice}/${(await readSlices($, context, workflow)).length}`
12}
13
14export async function refreshStatus($: EngineInterface, given?: Context): Promise<void> {
15 const context = given ?? (await loadContext($))
16 const current = context?.current
17 if (context === undefined || current === undefined) {
18 $.ui.status(undefined)
19 return
20 }
21 let locked = ''
22 try {
23 locked = (await readLock($, context.paths, current.slug))?.phase === 'green' ? ', tests locked' : ''
24 } catch {
25 locked = ', lock changed outside the mod'
26 }
27 $.ui.status(`workflow ${current.slug}: ${await phaseLabel($, context, current)}${locked}`)
28}
29
30export async function announce($: EngineInterface, context: Context, now: number): Promise<void> {
31 const open = context.workflows.filter(isOpen)
32 if (open.length === 0) return
33 const listed = open.map(workflow => `${workflow.slug} (${workflow.phase}, ${age(workflow.updated, now)})`).join(', ')
34 $.ui.toast(`workflow: open here: ${listed}. /workflow continue resumes one.`)
35}
36src/tools.ts 214 lines1import type { EngineInterface, On, RegisteredToolSpec } from 'claude-code'
2
3import { askOrUndefined } from './ask'
4import { lockSlice, unlockFile, verifySlice } from './build'
5import { day, iso } from './clock'
6import { loadContext, readSlices } from './context'
7import type { Context } from './context'
8import { GATE } from './labels'
9import { Refusal, describe, refuse } from './refusal'
10import type { Runtime } from './runtime'
11import { phaseAfter, withLog } from './state'
12import type { Workflow } from './state'
13import { refreshStatus } from './status'
14import { saveWorkflow } from './store'
15
16const TOOLS: readonly RegisteredToolSpec[] = [
17 {
18 name: 'handoff',
19 description:
20 'Save where the current workflow stopped, the next concrete action and the open questions into its state. Call it before a session ends, after a slice closes, or when the user pauses.',
21 inputSchema: {
22 type: 'object',
23 properties: {
24 stopped: { type: 'string', description: 'Where things stand, in one or two sentences.' },
25 next: { type: 'string', description: 'The next concrete action.' },
26 questions: { type: 'array', items: { type: 'string' }, description: 'Questions still open for the user.' },
27 paths: {
28 type: 'array',
29 items: { type: 'string' },
30 description: 'The folders this workflow touches, relative to the repo root. Set it in the design phase; the drift check watches them.',
31 },
32 },
33 required: ['stopped', 'next'],
34 },
35 isDeferred: false,
36 },
37 {
38 name: 'advance',
39 description:
40 "Ask the user to approve the current phase and move the workflow to its next phase. The user answers in Claude Code's own dialog. Give a short summary of what the phase produced.",
41 inputSchema: {
42 type: 'object',
43 properties: { summary: { type: 'string', description: 'What the phase produced, for the user to approve.' } },
44 required: ['summary'],
45 },
46 isDeferred: false,
47 },
48 {
49 name: 'lock',
50 description:
51 "Lock the current slice's tests once they fail for the right reason. Answers with the exact command to run when the mod has not seen it fail on the current code.",
52 inputSchema: { type: 'object', properties: {} },
53 isDeferred: false,
54 },
55 {
56 name: 'verify',
57 description:
58 "Close the current slice once its tests, lint, typecheck and the full suite passed on the current code. Answers with the checks still missing.",
59 inputSchema: { type: 'object', properties: {} },
60 isDeferred: false,
61 },
62 {
63 name: 'unlock',
64 description:
65 'Ask the user to unlock a locked test file, with the reason the test is wrong. The user answers in a dialog; only they can unlock.',
66 inputSchema: {
67 type: 'object',
68 properties: {
69 file: { type: 'string', description: 'The locked file, relative to the repo root.' },
70 reason: { type: 'string', description: 'Why the test is wrong, with the evidence.' },
71 },
72 required: ['file', 'reason'],
73 },
74 isDeferred: false,
75 },
76]
77
78export async function registerToolsOnce($: EngineInterface, runtime: Runtime): Promise<void> {
79 if (runtime.toolsRegistered) return
80 runtime.toolsRegistered = true
81 for (const tool of TOOLS) await $.tool.register(tool)
82}
83
84function textInput(input: Record<string, unknown>, key: string): string {
85 const value = input[key]
86 return typeof value === 'string' ? value.trim() : ''
87}
88
89function listInput(input: Record<string, unknown>, key: string): string[] | undefined {
90 const value = input[key]
91 return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined
92}
93
94function currentOf(context: Context): Workflow {
95 if (context.current === undefined) refuse('no workflow runs on this branch. /workflow continue <slug> picks one.')
96 return context.current
97}
98
99async function handoff($: EngineInterface, context: Context, input: Record<string, unknown>, now: number): Promise<string> {
100 const workflow = currentOf(context)
101 await saveWorkflow($, context.repo, context.paths, {
102 ...workflow,
103 stopped: textInput(input, 'stopped'),
104 next: textInput(input, 'next'),
105 questions: listInput(input, 'questions') ?? [],
106 paths: listInput(input, 'paths') ?? workflow.paths,
107 updated: iso(now),
108 })
109 return 'Saved the handoff.'
110}
111
112async function advance($: EngineInterface, context: Context, summary: string, now: number): Promise<string> {
113 const workflow = currentOf(context)
114 if (workflow.phase === 'build') {
115 refuse('the build phase moves on by itself: each slice closes with mcp__workflow__verify, and the last one moves the workflow to inspect.')
116 }
117 if (workflow.phase === 'done') refuse(`the workflow "${workflow.slug}" is done.`)
118 const target = phaseAfter(workflow.size, workflow.phase)
119 let slice = workflow.slice
120 if (target === 'build') {
121 const open = (await readSlices($, context, workflow)).find(candidate => !candidate.done)
122 if (open === undefined) refuse('slices.md has no slice left to build: write it, or fix its checkboxes, first.')
123 slice = open.number
124 }
125
126 const answer = await askOrUndefined(
127 $,
128 `Approve the ${workflow.phase} phase of "${workflow.slug}" and move to ${target}?\n\n${summary}`,
129 [GATE.approve, GATE.wait],
130 'Gate',
131 )
132 if (answer === undefined) return `The user closed the dialog: "${workflow.slug}" stays in its ${workflow.phase} phase.`
133 if (answer !== GATE.approve) return `The user is not ready to leave the ${workflow.phase} phase (${answer}). Ask what should change.`
134
135 const moved: Workflow = { ...workflow, phase: target, slice, status: target === 'done' ? 'done' : workflow.status, updated: iso(now) }
136 await saveWorkflow($, context.repo, context.paths, withLog(moved, day(now), `${workflow.phase} approved`))
137 await refreshStatus($)
138 if (target === 'done') return `Approved: "${workflow.slug}" is done.`
139 return `Approved: "${workflow.slug}" is now in its ${target} phase. Call mcp__workflow__handoff with the next action, then tell the user that /workflow next starts ${target} in a fresh context.`
140}
141
142type ToolAnswer = { result: string } | { deny: string }
143
144function failure(error: unknown): ToolAnswer {
145 return { deny: error instanceof Refusal ? `workflow: ${error.message}` : `workflow: the tool failed: ${describe(error)}` }
146}
147
148async function contextOf($: EngineInterface): Promise<Context> {
149 const context = await loadContext($)
150 if (context === undefined) refuse('this session is not in a git repository.')
151 return context
152}
153
154async function answered($: EngineInterface, text: string): Promise<ToolAnswer> {
155 await refreshStatus($)
156 return { result: text }
157}
158
159async function handoffTool($: EngineInterface, input: Record<string, unknown>): Promise<ToolAnswer> {
160 try {
161 return await answered($, await handoff($, await contextOf($), input, await $.clock.now()))
162 } catch (error) {
163 return failure(error)
164 }
165}
166
167async function advanceTool($: EngineInterface, input: Record<string, unknown>): Promise<ToolAnswer> {
168 try {
169 return await answered($, await advance($, await contextOf($), textInput(input, 'summary'), await $.clock.now()))
170 } catch (error) {
171 return failure(error)
172 }
173}
174
175async function lockTool($: EngineInterface): Promise<ToolAnswer> {
176 try {
177 return await answered($, await lockSlice($, await contextOf($), await $.clock.now()))
178 } catch (error) {
179 return failure(error)
180 }
181}
182
183async function verifyTool($: EngineInterface, runtime: Runtime): Promise<ToolAnswer> {
184 try {
185 const context = await contextOf($)
186 const verified = await verifySlice($, context, await $.clock.now())
187 if (verified.chainNext) runtime.pendingNext = context.current?.slug
188 return await answered($, verified.text)
189 } catch (error) {
190 return failure(error)
191 }
192}
193
194async function unlockTool($: EngineInterface, input: Record<string, unknown>): Promise<ToolAnswer> {
195 try {
196 const context = await contextOf($)
197 return await answered($, await unlockFile($, context, textInput(input, 'file'), textInput(input, 'reason'), false, await $.clock.now()))
198 } catch (error) {
199 return failure(error)
200 }
201}
202
203function inputOf(e: unknown): Record<string, unknown> {
204 return typeof e === 'object' && e !== null ? (e as Record<string, unknown>) : {}
205}
206
207export function registerToolHandlers(on: On, runtime: Runtime): void {
208 on('tool.call', { tool: 'mcp__workflow__handoff' }, ($, e) => handoffTool($, inputOf(e)))
209 on('tool.call', { tool: 'mcp__workflow__advance' }, ($, e) => advanceTool($, inputOf(e)))
210 on('tool.call', { tool: 'mcp__workflow__lock' }, $ => lockTool($))
211 on('tool.call', { tool: 'mcp__workflow__verify' }, $ => verifyTool($, runtime))
212 on('tool.call', { tool: 'mcp__workflow__unlock' }, ($, e) => unlockTool($, inputOf(e)))
213}
214src/ask.ts 19 lines1import type { EngineInterface } from 'claude-code'
2
3export function ask($: EngineInterface, question: string, options: readonly string[], header: string): Promise<string> {
4 return $.ui.ask(question, { options, header })
5}
6
7export async function askOrUndefined(
8 $: EngineInterface,
9 question: string,
10 options: readonly string[],
11 header: string,
12): Promise<string | undefined> {
13 try {
14 return await ask($, question, options, header)
15 } catch {
16 return undefined
17 }
18}
19src/build.ts 289 lines1import type { EngineInterface, ToolCallResult } from 'claude-code'
2
3import { askOrUndefined } from './ask'
4import { checksFor, isWatched, testFilesCommands } from './checks'
5import { day, iso } from './clock'
6import type { Config } from './config'
7import { readSlices, specsFolder } from './context'
8import type { Context } from './context'
9import { matchesAny } from './glob'
10import { sha256 } from './hash'
11import { UNLOCK } from './labels'
12import { latestRun, removedLines } from './lock'
13import type { Lock, LockedFile } from './lock'
14import { refuse } from './refusal'
15import { changedFiles, relativeTo, treeHash } from './repo'
16import { addRun, readLock, readRuns, saveLock, saveWorkflow } from './store'
17import { withLog } from './state'
18import type { Workflow } from './state'
19
20const EXIT_CODE = /^Exit code (\d+)/
21
22function plural(count: number, noun: string): string {
23 return `${count} ${noun}${count === 1 ? '' : 's'}`
24}
25
26function inBuild(context: Context): Workflow {
27 const workflow = context.current
28 if (workflow === undefined) refuse('no workflow runs on this branch. /workflow continue <slug> picks one.')
29 if (workflow.phase !== 'build') {
30 refuse(`the workflow "${workflow.slug}" is in its ${workflow.phase} phase: tests are locked and verified in the build phase only.`)
31 }
32 return workflow
33}
34
35function configOf(context: Context): Config {
36 if (context.config !== undefined) return context.config
37 if (context.configErrors.length > 0) refuse(`.claude/workflow.json has problems: ${context.configErrors.join('; ')}.`)
38 refuse('this project has no .claude/workflow.json. /workflow adopt sets it up.')
39}
40
41function testGlobs(config: Config): string[] {
42 return [...config.testGlobs, ...config.packages.flatMap(entry => entry.testGlobs)]
43}
44
45async function readText($: EngineInterface, root: string, file: string): Promise<string | undefined> {
46 try {
47 return await $.fs.read(`${root}/${file}`)
48 } catch {
49 return undefined
50 }
51}
52
53async function snapshot(text: string): Promise<LockedFile> {
54 return { hash: await sha256(text), content: text }
55}
56
57async function changedLockedFiles($: EngineInterface, context: Context, lock: Lock): Promise<string[]> {
58 const changed: string[] = []
59 for (const [file, locked] of Object.entries(lock.files)) {
60 if (lock.unlocked.includes(file)) continue
61 const text = await readText($, context.repo.root, file)
62 if (text === undefined || (await sha256(text)) !== locked.hash) changed.push(file)
63 }
64 return changed
65}
66
67async function restore($: EngineInterface, context: Context, lock: Lock, files: readonly string[]): Promise<void> {
68 for (const file of files) {
69 const locked = lock.files[file]
70 if (locked !== undefined) await $.fs.write(`${context.repo.root}/${file}`, locked.content)
71 }
72}
73
74export async function lockSlice($: EngineInterface, context: Context, now: number): Promise<string> {
75 const workflow = inBuild(context)
76 const config = configOf(context)
77 const lock = await readLock($, context.paths, workflow.slug)
78 if (lock?.phase === 'green') {
79 refuse(`slice ${lock.slice} is locked already: implement until its checks pass, then call mcp__workflow__verify.`)
80 }
81
82 const globs = testGlobs(config)
83 const known = lock?.files ?? {}
84 const candidates = [...new Set([...(await changedFiles($, context.repo)), ...Object.keys(known)])]
85 const testFiles = candidates.filter(file => matchesAny(file, globs)).sort()
86 const current = new Map<string, string | undefined>()
87 for (const file of testFiles) current.set(file, await readText($, context.repo.root, file))
88
89 const losses = Object.entries(known)
90 .filter(([file]) => matchesAny(file, globs) && !(lock?.unlocked ?? []).includes(file))
91 .flatMap(([file, locked]) => removedLines(locked.content, current.get(file) ?? '').map(line => `- ${file}: \`${line}\``))
92 if (losses.length > 0) {
93 refuse(
94 [
95 'These lines of tests locked in an earlier slice are gone:',
96 ...losses,
97 '',
98 'Adding tests is fine; removing or changing locked lines is not. Restore them, or, if a test is wrong, call mcp__workflow__unlock with the reason: the user decides.',
99 ].join('\n'),
100 )
101 }
102
103 const sliceFiles: string[] = []
104 for (const file of testFiles) {
105 const text = current.get(file)
106 if (text !== undefined && known[file]?.hash !== (await sha256(text))) sliceFiles.push(file)
107 }
108 if (sliceFiles.length === 0) {
109 refuse(`no new or changed test file since the last lock. Write this slice's tests first (test files match ${globs.join(', ')}).`)
110 }
111
112 const commands = testFilesCommands(config, sliceFiles)
113 const tree = await treeHash($, context.repo, context.paths.treeIndex)
114 const runs = await readRuns($, context.paths, workflow.slug)
115 const problems = commands.flatMap(command => {
116 const run = latestRun(runs, command)
117 if (run === undefined) return [`- \`${command}\` has not run on these tests yet.`]
118 if (run.tree !== tree) return [`- The code changed since \`${command}\` last ran.`]
119 if (run.exitCode === 0) {
120 return [`- \`${command}\` passed: tests are locked once they fail for the right reason. A test that passes on its first run is suspect.`]
121 }
122 return []
123 })
124 if (problems.length > 0) {
125 refuse(
126 [
127 ...problems,
128 '',
129 `Run ${commands.map(command => `\`${command}\``).join(', then ')} exactly as written, with nothing appended, and read every failure: an import, compile or syntax error is a broken test, not a red one. Then call mcp__workflow__lock again.`,
130 ].join('\n'),
131 )
132 }
133
134 const files: Record<string, LockedFile> = { ...known }
135 for (const file of sliceFiles) files[file] = await snapshot(current.get(file) ?? '')
136 for (const file of config.testConfig) {
137 const text = await readText($, context.repo.root, file)
138 if (text !== undefined) files[file] = await snapshot(text)
139 }
140 await saveLock($, context.paths, { slug: workflow.slug, slice: workflow.slice, phase: 'green', files, sliceFiles, unlocked: [], updated: iso(now) })
141 await saveWorkflow(
142 $,
143 context.repo,
144 context.paths,
145 withLog({ ...workflow, updated: iso(now) }, day(now), `slice ${workflow.slice} tests locked: ${sliceFiles.join(', ')}`),
146 )
147 return [
148 `Locked ${plural(sliceFiles.length, 'test file')} for slice ${workflow.slice}: ${sliceFiles.join(', ')}.`,
149 '',
150 'They are frozen until verify passes: the mod refuses edits to them and restores any change made through the shell. Implement the code, run each check exactly as written until it passes, then call mcp__workflow__verify:',
151 ...checksFor(config, sliceFiles).map(command => `- \`${command}\``),
152 ].join('\n')
153}
154
155export async function verifySlice(
156 $: EngineInterface,
157 context: Context,
158 now: number,
159): Promise<{ text: string; chainNext: boolean }> {
160 const workflow = inBuild(context)
161 const config = configOf(context)
162 const lock = await readLock($, context.paths, workflow.slug)
163 if (lock === undefined || lock.phase !== 'green') {
164 refuse(`nothing is locked for slice ${workflow.slice}: write its tests, see them fail, and call mcp__workflow__lock first.`)
165 }
166
167 const tampered = await changedLockedFiles($, context, lock)
168 if (tampered.length > 0) {
169 await restore($, context, lock, tampered)
170 refuse(`these locked tests had changed and were restored: ${tampered.join(', ')}. Run the checks again on the restored tests.`)
171 }
172
173 const checks = checksFor(config, lock.sliceFiles)
174 const tree = await treeHash($, context.repo, context.paths.treeIndex)
175 const runs = await readRuns($, context.paths, workflow.slug)
176 const missing = checks.filter(command => {
177 const run = latestRun(runs, command)
178 return run === undefined || run.tree !== tree || run.exitCode !== 0
179 })
180 if (missing.length > 0) {
181 refuse(
182 [
183 `Slice ${workflow.slice} is not done yet. Run each of these exactly as written, on the current code, until it passes, then call mcp__workflow__verify again:`,
184 ...missing.map(command => `- \`${command}\``),
185 ].join('\n'),
186 )
187 }
188
189 const files: Record<string, LockedFile> = { ...lock.files }
190 for (const file of lock.unlocked) {
191 const text = await readText($, context.repo.root, file)
192 if (text === undefined) delete files[file]
193 else files[file] = await snapshot(text)
194 }
195 await saveLock($, context.paths, { ...lock, files, phase: 'open', unlocked: [], updated: iso(now) })
196
197 const slices = await readSlices($, context, workflow)
198 const isLast = !slices.some(slice => slice.number > workflow.slice)
199 const advanced: Workflow = isLast ? { ...workflow, phase: 'inspect' } : { ...workflow, slice: workflow.slice + 1 }
200 await saveWorkflow($, context.repo, context.paths, withLog({ ...advanced, updated: iso(now) }, day(now), `slice ${workflow.slice} verified`))
201
202 const nextStep = isLast
203 ? 'Every slice is done: tell the user that /workflow next starts the inspect phase in a fresh context.'
204 : workflow.autoNext
205 ? `Slice ${workflow.slice + 1} starts by itself in a fresh context once this turn ends.`
206 : `Tell the user that /workflow next starts slice ${workflow.slice + 1} in a fresh context.`
207 const text = [
208 `Slice ${workflow.slice} verified.`,
209 '',
210 `1. Tick slice ${workflow.slice} in \`${specsFolder(context, workflow)}/slices.md\`.`,
211 workflow.commits
212 ? '2. Commit the slice, following the following-git-conventions skill.'
213 : '2. Do not commit: in this workflow the user commits.',
214 '3. Call mcp__workflow__handoff with where things stand and the next action.',
215 `4. ${nextStep}`,
216 ].join('\n')
217 return { text, chainNext: !isLast && workflow.autoNext }
218}
219
220export async function unlockFile(
221 $: EngineInterface,
222 context: Context,
223 given: string,
224 reason: string,
225 byThePerson: boolean,
226 now: number,
227): Promise<string> {
228 const workflow = context.current
229 if (workflow === undefined) refuse('no workflow runs on this branch.')
230 const lock = await readLock($, context.paths, workflow.slug)
231 if (lock === undefined) refuse(`nothing is locked in "${workflow.slug}".`)
232 const file = given.startsWith('/') ? (relativeTo(context.repo.root, given) ?? given) : given
233 if (lock.files[file] === undefined) refuse(`${file} is not locked. Locked files: ${Object.keys(lock.files).join(', ')}.`)
234 if (lock.unlocked.includes(file)) return `${file} is unlocked already.`
235
236 if (!byThePerson) {
237 const answer = await askOrUndefined(
238 $,
239 `Unlock ${file} in the workflow "${workflow.slug}"?\n\nClaude's reason: ${reason}`,
240 [UNLOCK.yes, UNLOCK.no],
241 'Unlock',
242 )
243 if (answer !== UNLOCK.yes) {
244 return `The user kept ${file} locked${answer === undefined ? '' : ` (${answer})`}. Do not change it: explain the problem to the user instead.`
245 }
246 }
247 await saveLock($, context.paths, { ...lock, unlocked: [...lock.unlocked, file], updated: iso(now) })
248 await saveWorkflow($, context.repo, context.paths, withLog({ ...workflow, updated: iso(now) }, day(now), `unlocked ${file}: ${reason}`))
249 return `Unlocked ${file}. You may change it now; say what changed and why in your report.`
250}
251
252function exitCodeOf(ran: ToolCallResult<'Bash'>): number | undefined {
253 if (ran.deny !== undefined) return undefined
254 if (ran.isError === true) {
255 const match = EXIT_CODE.exec(ran.text ?? '')
256 return match === null ? undefined : Number(match[1])
257 }
258 const record = ran.result as { interrupted?: boolean; backgroundTaskId?: string } | undefined
259 return record?.interrupted === true || record?.backgroundTaskId !== undefined ? undefined : 0
260}
261
262export async function afterShell(
263 $: EngineInterface,
264 context: Context,
265 command: string,
266 ran: ToolCallResult<'Bash'>,
267 now: number,
268): Promise<string[]> {
269 const workflow = context.current
270 if (workflow === undefined) return []
271 const lock = await readLock($, context.paths, workflow.slug)
272 if (lock?.phase === 'green') {
273 const changed = await changedLockedFiles($, context, lock)
274 if (changed.length > 0) {
275 await restore($, context, lock, changed)
276 return changed.map(
277 file =>
278 `workflow: restored ${file}: it is a locked test of slice ${lock.slice} and that command changed it. Locked tests stay as written until verify passes; if one is wrong, call mcp__workflow__unlock with the reason.`,
279 )
280 }
281 }
282 const exitCode = exitCodeOf(ran)
283 if (context.config !== undefined && exitCode !== undefined && isWatched(context.config, command)) {
284 const tree = await treeHash($, context.repo, context.paths.treeIndex)
285 await addRun($, context.paths, workflow.slug, { command: command.trim(), exitCode, tree, at: iso(now) })
286 }
287 return []
288}
289src/drift.ts 67 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Context } from './context'
4import { defaultBranch, git, lines } from './repo'
5import type { Workflow } from './state'
6
7export const DAY_MS = 86_400_000
8const DRIFT_AFTER_DAYS = 14
9const SHOWN_COMMITS = '20'
10const LOCKFILES = [
11 'package-lock.json',
12 'pnpm-lock.yaml',
13 'yarn.lock',
14 'bun.lockb',
15 'Cargo.lock',
16 'go.sum',
17 'poetry.lock',
18 'uv.lock',
19 'Pipfile.lock',
20 'Gemfile.lock',
21 'composer.lock',
22]
23
24export function daysSince(iso: string, now: number): number {
25 return Math.max(0, Math.floor((now - Date.parse(iso)) / DAY_MS))
26}
27
28export function age(iso: string, now: number): string {
29 const days = daysSince(iso, now)
30 if (days === 0) return 'today'
31 return days === 1 ? '1 day ago' : `${days} days ago`
32}
33
34export async function driftFacts(
35 $: EngineInterface,
36 context: Context,
37 workflow: Workflow,
38 now: number,
39): Promise<string | undefined> {
40 const days = daysSince(workflow.updated, now)
41 const base = await defaultBranch($, context.repo)
42 const counted = base === undefined ? undefined : await git($, context.repo.root, ['rev-list', '--count', `HEAD..${base}`])
43 const behind = counted?.exitCode === 0 ? Number(counted.stdout.trim()) || 0 : 0
44 if (days <= DRIFT_AFTER_DAYS && behind === 0) return undefined
45
46 const facts = ['## Drift since this workflow was last touched', '', `- Last update: ${days} days ago.`]
47 if (base !== undefined) {
48 facts.push(`- This branch is ${behind} commits behind ${base}.`)
49 const scope = workflow.paths.length > 0 ? ['--', ...workflow.paths] : []
50 const log = await git($, context.repo.root, ['log', '--oneline', '-n', SHOWN_COMMITS, `--since=${workflow.updated}`, base, ...scope])
51 const commits = log.exitCode === 0 ? lines(log.stdout) : []
52 const where = workflow.paths.length > 0 ? ` in ${workflow.paths.join(', ')}` : ''
53 facts.push(commits.length > 0 ? `- Commits on ${base} since then${where}:` : `- No commit on ${base} since then${where}.`)
54 facts.push(...commits.map(commit => ` - ${commit}`))
55 if (behind > 0) {
56 const stat = await git($, context.repo.root, ['diff', '--stat', `HEAD...${base}`, '--', ...LOCKFILES])
57 const changed = stat.exitCode === 0 ? lines(stat.stdout) : []
58 if (changed.length > 0) facts.push('- Lockfile changes:', ...changed.map(line => ` - ${line}`))
59 }
60 }
61 facts.push(
62 '',
63 `Before anything else, follow \`${$.plugin.root}/guides/drift-check.md\`: run the project checks once, summarize in at most 10 lines what this means for the next step, and offer a rebase if the branch is behind.`,
64 )
65 return facts.join('\n')
66}
67