SLOPSHOPPER

workflow

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

newguardcommandtoaststatustool
v0.0.1no licenseupdated 2026-10-26ilianusss/claude-workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow
› 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 › /workflow ⎿ workflow: workflow: this folder is not in a git repository. Run git init, then /workflow new <title>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-workflow

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.

Role in the system

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.

Prerequisites

  • Claude Code 2.1.308 or later. Mods use an early-access API that may change between releases.
  • A git repository: the state lives in its .git.

Getting started

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.

Commands

CommandDoes
/workflowThe workflows of this repo
/workflow allOpen 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 nextClear the context and continue: the command between slices
/workflow pauseWrite the handoff and stop
/workflow unlock <file> <reason>Unlock a locked test yourself
/workflow done <slug>, /workflow abandon <slug> <reason>Close one
/workflow adoptSet 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.

Project structure

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

Documentation

Development

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.

Source 25 files
hooks/register.ts 41 lines
1import 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}
41
src/clock.ts 23 lines
1import 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}
23
src/commands.ts 341 lines
1import 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}
341
src/context.ts 65 lines
1import 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}
65
src/guards.ts 76 lines
1import 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}
76
src/prompts.ts 88 lines
1import 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}
88
src/runtime.ts 9 lines
1export type Runtime = {
2  toolsRegistered: boolean
3  pendingNext: string | undefined
4}
5
6export function newRuntime(): Runtime {
7  return { toolsRegistered: false, pendingNext: undefined }
8}
9
src/status.ts 36 lines
1import 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}
36
src/tools.ts 214 lines
1import 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}
214
src/ask.ts 19 lines
1import 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}
19
src/build.ts 289 lines
1import 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}
289
src/drift.ts 67 lines
1import 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