Enforces the SDLC stage write rules for every agent, taking the stage from the pipeline's status.json

Supports production, sales, billing and inventory management.
See AGENTS.md for the implementation plan, provisioning details and contribution guidelines.
API documentation is available via Swagger UI. The OpenAPI specification is generated from JSDoc comments in server/index.js.
A pre-commit hook is configured to automatically generate or update the tools/openapi-spec.json file whenever changes to server/index.js are committed. This ensures the specification is always up-to-date with the code.
To view the Swagger UI locally:
bash npm run generate-spec ` Alternatively, committing any change to server/index.js` will trigger the pre-commit hook.bash cd server npm start ` This usually runs on http://localhost:3000`.bash npm run start:swagger ` This server is dedicated to serving the Swagger UI and typically runs on http://localhost:3001`.http://localhost:3001/docs.The Plan stage of scripts/sdlc.sh and the /plan command use graft, a prebuilt code graph, to find the files a change touches. Each developer needs it installed and built once. If graft is missing, the Claude hooks do nothing silently and the Plan stage loses its code graph.
bash npm install -g @nanonets/graft graft --version # confirm it is on your PATH ` Update later with graft upgrade`.graft/ folder is git-ignored, so everyone builds their own: ``bash graft build graft check # exits non-zero if the graph is stale; rerun graft build to refresh ``.mcp.json registers the graft MCP server (graft mcp). On first use Claude Code asks you to approve project MCP servers; approve graft.graft ask "where is the sales invoice calculated" --source..claude/settings.json runs .claude/helpers/graft-hooks.cjs at session start, after edits and at stop. This injects a repo map into the session and keeps the graph in sync. The helper looks for graft in several places (a machine-specific path first, then local and global node_modules), so the path baked into it is harmless on other machines.graft_find_code, graft_find_all, graft_trace_calls, graft_file_api and graft_repo_map through the server in .mcp.json.Start a throwaway Postgres, then run the suite against it:
docker run -d --name prw-db -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app -p 5432:5432 postgres:16
DATABASE_URL=postgres://app:app@localhost:5432/app npm run test:integration
The suite creates and drops its own prw_test_* database, so it never touches the data in DATABASE_URL's database. Without DATABASE_URL it fails with DATABASE_URL is required for integration tests.
Integration tests (npm run test:integration) are a required part of the pipeline locally. scripts/sdlc.sh runs them through scripts/sdlc-integration.sh after the unit tests, at the Implement, Test repair and Review checks. The database is chosen in this order, first match wins:
DATABASE_URL set in your shell.DATABASE_URL in the repo-root .env (the file the server reads), if its host is this machine and it answers. This is the fast path: no container to start.postgres:16 container started with Docker and removed afterwards.SDLC_DB=docker skips 1 and 2. A non-local host in .env is ignored with a warning unless SDLC_ALLOW_REMOTE_DB=1. The suite creates and drops its own prw_test_* databases, so several runs can share one server (the database user needs CREATEDB). server/.env is not read by anything; use the repo-root .env.
CI skips them temporarily, with a visible warning. To enable them in CI, set the CI/CD variable SDLC_INTEGRATION_CI=run and provide DATABASE_URL (for example from a postgres:16 service). No code change is needed.
At the start of a run (FROM=spec) scripts/sdlc.sh records every file that is already modified, staged or untracked in Docs/backlog/<slug>/logs/baseline.json. The Ship stage (scripts/sdlc-ship.sh) then commits exactly the files the cycle created, changed or deleted since that snapshot, anywhere in the repo, in one feat(<slug>) commit, and ticks the item in Docs/backlog/index.md in a second commit. Anything else the developer had staged is left staged.
Skipped files are never committed; they are listed with the reason in Docs/backlog/<slug>/logs/ship-skipped.md and in the DONE message:
| Reason | Files |
|---|---|
pre-existing local changes | files that were already modified before the run and changed again |
sensitive file | .env, .env.* (not .env.example), *.pem, *.key, *.p12, id_rsa*, *.keystore |
local or agent configuration | .vscode/, .idea/, .claude/ |
generated | node_modules/, .DS_Store |
too large | files over 1 MiB |
Keys added to a git-ignored .env during the run are copied, with the placeholder value change-me (never the real value), into the sibling .env.example, which is committed. If nothing changed, Ship prints WARNING: nothing to commit and the run still succeeds. Resuming with any FROM other than spec reuses the existing baseline, or creates one with a warning (files changed before the resume then count as pre-existing).
scripts/sdlc-mod.sh runs the normal pipeline (scripts/sdlc.sh, unchanged) for one backlog item in its own git worktree, so several items can run at once. Two Claude Code plugins in .claude/plugins/ add the interface and the guard rails.
bash scripts/sdlc-mod.sh run <slug> # start or resume; worktree at ../<repo>-sdlc/<slug>, branch sdlc/<slug>
bash scripts/sdlc-mod.sh stop <slug> # interrupt the pipeline and everything it started
bash scripts/sdlc-mod.sh status # one line per run
bash scripts/sdlc-mod.sh watch <slug> # live view of one pipeline, read from its worktree (--once prints one frame; WATCH_INTERVAL seconds, default 3)
bash scripts/sdlc-mod.sh changes <slug> [--json] # files changed in the pipeline's worktree: "<changed> <uncommitted>"
bash scripts/sdlc-mod.sh discard <slug> [--yes] [--stop] # delete its worktree, branch and run record (without --yes: show what would go, exit 6)
bash scripts/sdlc-mod.sh restart <slug> [--yes] # stop it if running, discard it, start again from Spec
discard and restart throw away the item's worktree, its local branch sdlc/<slug> (including unpushed commits and uncommitted files) and its run record, but first save the branch tip in .git/sdlc-runs/<slug>.discarded; the command it prints, git branch sdlc/<slug> <sha>, brings the work back. discard refuses a running pipeline unless --stop is given, which stops it first (after --yes); restart stops it itself. restart refuses an item already merged into the base branch (exit 5).
At most 2 pipelines run at once (SDLC_MAX_PARALLEL, exit code 3 when full). Run records live in .git/sdlc-runs/. The wrapper links server/node_modules, graft and .env into each worktree.
Control pane (sdlc-monitor plugin). Start Claude with claude --plugin-dir .claude/plugins/sdlc-monitor, then press the SDLC button above the prompt or type /sdlc-monitor.
N files changed (M uncommitted), or no changes yet while running); the pipeline view shows the same in a Files line. Counts refresh with the pane, and every 10 s for finished pipelines.Docs/backlog/<slug>/manual-inputs.md under ## <stage> and the pipeline hands it to that stage's agent as binding instructions (it cannot override the rule that the tests are locked). Resume points: FROM=spec|plan|red-tests|implement|test-repair|review ./scripts/sdlc.sh <slug>; any other value exits 1 before anything is changed; plan and red-tests refuse once the red-tests commit exists (restart the run to redo them); review resumes from the latest locked tests commit. FROM=test-repair resumes at Test repair after a run stopped there for a non-verdict reason (the repair agent or auditor failed to run, or the claims failed the pre-check; the run prints the exact FROM=test-repair command when it can resume). It skips Implement and keeps test-issues.md, and first checks, reporting every failure together and calling no agent: the red-tests commit exists, the claim file has at least one valid claim, server/__tests__ is unchanged since the red-tests commit (otherwise the restore command git checkout <sha> -- server/__tests__ && git clean -fd server/__tests__ is printed, never run), the implementation changed outside server/__tests__/ and Docs/, the repair was not already accepted (then use FROM=review), and the claims pass the pre-check. FROM=implement deletes test-issues.md and prints a warning first. The same file can be edited by hand.Guard (sdlc-guard plugin). Loaded into every pipeline agent by the wrapper. It reads the current stage from status.json and refuses writes the stage does not allow: Spec and Plan write only that item's docs, Red tests write no source, Implement and Review never touch server/__tests__. It also refuses a doc written before the one it builds on.
Docs/backlog/index.md lists the work items. List them with a status filter:
npm run backlog
npm run backlog -- pending,in-progress
npm run backlog -- blocked --json
In Claude Code: /backlog-list blocked.
Markers in index.md: [ ] pending, [x] completed, [!] blocked, [~] parked (kept for later; scripts/sdlc.sh skips it, and a trailing (parked: reason) shows in the NOTE column). A [ ] item whose sdlc/<slug> branch exists (local or origin) is shown as in-progress. Statuses are pending, in-progress, blocked, parked, completed (aliases open and done, or all). The command only reads, it never changes files.
To stop the inline context injected by the hooks, remove the graft entries under hooks in .claude/settings.json on your machine (do not commit that change). The MCP server stays available through .mcp.json. To turn graft off completely, also remove graft from .mcp.json locally.
hooks/register.ts 105 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Phase = { stage: string; slug: string }
4
5const TESTS = /(^|\/)server\/__tests__\//
6const SERVER = /(^|\/)server\//
7const WRITERS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
8const BASH_WRITE = /(\bsed\s+-i|\btee\b|\brm\b|\bmv\b|\bcp\b|>|\bgit\s+(checkout|restore|apply)\b)/
9// state-file phase names (sdlc-guard.cjs set <phase>) to the stage names status.json uses
10const STATE_PHASE: Record<string, string> = { implement: 'Implement', review: 'Review' }
11
12// The running stage, as scripts/sdlc.sh reports it in status.json (a live pid
13// only), else the phase the /implement and /review commands set in sdlc-state.json.
14async function activePhase($: EngineInterface): Promise<Phase | null> {
15 let best: (Phase & { updated: string }) | null = null
16 let dirs: { name: string; kind: string }[] = []
17 try {
18 dirs = await $.fs.list('Docs/backlog')
19 } catch {
20 dirs = []
21 }
22 for (const d of dirs) {
23 if (d.kind !== 'dir') continue
24 try {
25 const s = JSON.parse((await $.fs.read(`Docs/backlog/${d.name}/logs/status.json`)) as string)
26 if (s.state !== 'running' || (best && s.updated <= best.updated)) continue
27 const live = await $.process.run(['kill', '-0', String(s.pid)])
28 if (live.exitCode === 0) best = { stage: s.stage, slug: s.slug, updated: s.updated }
29 } catch {
30 // no status file, or its process is gone
31 }
32 }
33 if (best) return best
34 try {
35 const s = JSON.parse((await $.fs.read('.claude/sdlc-state.json')) as string)
36 const stage = STATE_PHASE[s.phase]
37
38 return stage ? { stage, slug: s.slug } : null
39 } catch {
40 return null
41 }
42}
43
44// Why a write to `path` is refused in this stage, or undefined when it is fine.
45function refuse(p: Phase, path: string): string | undefined {
46 const isTest = TESTS.test(path)
47 const isSource = SERVER.test(path) && !isTest
48 const isOwnDoc = path.includes(`Docs/backlog/${p.slug}/`)
49
50 switch (p.stage) {
51 case 'Spec':
52 case 'Plan':
53 return isOwnDoc ? undefined : `${p.stage}: only Docs/backlog/${p.slug}/ may be written.`
54 case 'Red tests':
55 return isOwnDoc || isTest ? undefined : 'Red tests: write tests and docs only, never source.'
56 case 'Test repair':
57 return isSource ? 'Test repair: source files are read-only.' : undefined
58 case 'Implement':
59 case 'Review':
60 return isTest ? `${p.stage}: server/__tests__ is read-only. Fix the source, not the tests. If a test is truly wrong, record it in test-issues.md.` : undefined
61 default:
62 return undefined
63 }
64}
65
66async function missingPrereq($: EngineInterface, p: Phase, path: string): Promise<string | undefined> {
67 const needs: Record<string, string> = { 'plan-1.md': 'specs-1.md', 'test-cases-1.md': 'plan-1.md' }
68 const file = path.split('/').pop() ?? ''
69 const need = needs[file]
70
71 if (!need || !path.includes(`Docs/backlog/${p.slug}/`)) return undefined
72
73 return (await $.fs.exists(`Docs/backlog/${p.slug}/${need}`)) ? undefined : `${file} needs ${need} first: run the stages in order.`
74}
75
76// A guard that cannot decide refuses: a crashed check must not become an open door.
77const FAILED = { deny: 'sdlc-guard: could not check the SDLC stage, so the call is refused. Retry, or clear the stage state.' }
78
79export const register: Register = on => {
80 for (const tool of WRITERS) {
81 on('tool.call', { tool }, async ($, e, next) => {
82 const input = e as unknown as { file_path?: string; notebook_path?: string }
83 const path = (input.file_path ?? input.notebook_path ?? '').replace(/\\/g, '/')
84 const phase = await activePhase($)
85
86 if (!phase || !path) return next(e)
87 const why = refuse(phase, path) ?? (await missingPrereq($, phase, path))
88
89 return why ? { deny: `sdlc-guard: ${why}` } : next(e)
90 }).catch(() => FAILED)
91 }
92
93 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
94 const phase = await activePhase($)
95 const isLocked = phase && (phase.stage === 'Implement' || phase.stage === 'Review')
96 const command = (e as unknown as { command?: string }).command ?? ''
97
98 if (isLocked && /__tests__/.test(command) && BASH_WRITE.test(command)) {
99 return { deny: `sdlc-guard: ${phase.stage}: server/__tests__ is read-only. Fix the source, not the tests.` }
100 }
101
102 return next(e)
103 }).catch(() => FAILED)
104}
105