plumbbob 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +137 -0
- package/hooks/bash-guard.sh +58 -0
- package/hooks/post-edit.sh +59 -0
- package/hooks/pre-edit.sh +95 -0
- package/package.json +60 -0
- package/skills/park/SKILL.md +26 -0
- package/skills/plumbbob-docs/SKILL.md +25 -0
- package/skills/plumbbob-interrogate/SKILL.md +33 -0
- package/skills/plumbbob-report/SKILL.md +35 -0
- package/skills/plumbbob-triage/SKILL.md +38 -0
- package/src/cli.ts +121 -0
- package/src/lib/archive.ts +76 -0
- package/src/lib/check.ts +29 -0
- package/src/lib/git.ts +73 -0
- package/src/lib/intent.ts +103 -0
- package/src/lib/settings.ts +90 -0
- package/src/lib/sidecar.ts +82 -0
- package/src/verbs/build.ts +38 -0
- package/src/verbs/done.ts +71 -0
- package/src/verbs/finish.ts +72 -0
- package/src/verbs/mode.ts +27 -0
- package/src/verbs/park.ts +56 -0
- package/src/verbs/revert.ts +110 -0
- package/src/verbs/review.ts +30 -0
- package/src/verbs/setup.ts +95 -0
- package/src/verbs/spike.ts +129 -0
- package/src/verbs/start.ts +103 -0
- package/src/verbs/status.ts +15 -0
- package/src/verbs/wrap.ts +34 -0
- package/templates/build-log.md +54 -0
- package/templates/intent.md +67 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The .plumbbob/ sidecar: control state lives in flat files so the hooks can
|
|
2
|
+
// read it with a grep and no markdown parsing (D7). Functional/procedural,
|
|
3
|
+
// node builtins only (C1/C2).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { gitDir } from './git.ts'
|
|
8
|
+
|
|
9
|
+
const DIRNAME = '.plumbbob'
|
|
10
|
+
|
|
11
|
+
// The five legal control states (README mode machine). `mode` validates against
|
|
12
|
+
// this; the muzzle allows edits iff STATE is BUILD or SPIKE.
|
|
13
|
+
export const VALID_STATES: ReadonlyArray<string> = ['DESIGN', 'BUILD', 'REVIEW', 'SPIKE', 'FINISH']
|
|
14
|
+
|
|
15
|
+
export function sidecarDir(root: string): string {
|
|
16
|
+
return join(root, DIRNAME)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function statePath(root: string): string {
|
|
20
|
+
return join(root, DIRNAME, 'STATE')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
|
|
24
|
+
// number, so the hooks read them with a grep and no markdown parsing.
|
|
25
|
+
export function seamPath(root: string): string {
|
|
26
|
+
return join(root, DIRNAME, 'SEAM')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stepPath(root: string): string {
|
|
30
|
+
return join(root, DIRNAME, 'STEP')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function checkpointsPath(root: string): string {
|
|
34
|
+
return join(root, DIRNAME, 'checkpoints')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function configPath(root: string): string {
|
|
38
|
+
return join(root, DIRNAME, 'config')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function intentPath(root: string): string {
|
|
42
|
+
return join(root, DIRNAME, 'intent.md')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildLogPath(root: string): string {
|
|
46
|
+
return join(root, DIRNAME, 'build-log.md')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A session exists iff STATE exists. Deleting STATE (at finish) is what switches
|
|
50
|
+
// the muzzle off — so it is the single source of truth for "is there a session".
|
|
51
|
+
export function hasSession(root: string): boolean {
|
|
52
|
+
return existsSync(statePath(root))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readState(root: string): string | null {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(statePath(root), 'utf8').trim()
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeState(root: string, state: string): void {
|
|
64
|
+
writeFileSync(statePath(root), `${state}\n`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// D17: keep the sidecar untracked by appending `.plumbbob/` to the repo's
|
|
68
|
+
// git/info/exclude. Idempotent — a re-`start` after finish must not double-add.
|
|
69
|
+
export function excludeSidecar(root: string): void {
|
|
70
|
+
const exclude = join(gitDir(root), 'info', 'exclude')
|
|
71
|
+
let current = ''
|
|
72
|
+
try {
|
|
73
|
+
current = readFileSync(exclude, 'utf8')
|
|
74
|
+
} catch {
|
|
75
|
+
current = ''
|
|
76
|
+
}
|
|
77
|
+
if (current.split('\n').some((line) => line.trim() === `${DIRNAME}/`)) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : ''
|
|
81
|
+
appendFileSync(exclude, `${prefix}${DIRNAME}/\n`)
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `plumbbob build <n>` — read step n's seam from intent.md, write the normalized
|
|
2
|
+
// SEAM + STEP, and enter BUILD. Re-entering from REVIEW just re-derives the same
|
|
3
|
+
// seam and flips back to BUILD; it never checkpoints (only `done` commits).
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
7
|
+
import { hasSession, intentPath, seamPath, stepPath, writeState } from '../lib/sidecar.ts'
|
|
8
|
+
import { parseStepSeam } from '../lib/intent.ts'
|
|
9
|
+
|
|
10
|
+
export function build(cwd: string, args: ReadonlyArray<string>): number {
|
|
11
|
+
const root = findRepoRoot(cwd)
|
|
12
|
+
if (root === null || !hasSession(root)) {
|
|
13
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
14
|
+
return 1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = args.find((a) => !a.startsWith('--'))
|
|
18
|
+
if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) {
|
|
19
|
+
process.stderr.write('plumbbob: build needs a step number. Try: plumbbob build 2.\n')
|
|
20
|
+
return 1
|
|
21
|
+
}
|
|
22
|
+
const step = Number(raw)
|
|
23
|
+
|
|
24
|
+
const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step)
|
|
25
|
+
if (!parsed.ok) {
|
|
26
|
+
process.stderr.write(`plumbbob: ${parsed.error} Fix the step's seam in intent.md, then \`build ${step}\` again.\n`)
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`)
|
|
31
|
+
writeFileSync(stepPath(root), `${step}\n`)
|
|
32
|
+
writeState(root, 'BUILD')
|
|
33
|
+
|
|
34
|
+
process.stdout.write(
|
|
35
|
+
`plumbbob: building step ${step} — STATE=BUILD. Edits are limited to the seam:\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`,
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `plumbbob done` — the step gate. Refuses on a red check, then stages the whole
|
|
2
|
+
// step (D8: `git add -A`; the sidecar is git-excluded so it never lands), warns
|
|
3
|
+
// about anything committed outside the SEAM, takes the checkpoint commit, records
|
|
4
|
+
// its SHA, and returns to DESIGN.
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, readFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { commit, findRepoRoot, stageAll, stagedPaths } from '../lib/git.ts'
|
|
8
|
+
import { checkpointsPath, hasSession, readState, seamPath, stepPath, writeState } from '../lib/sidecar.ts'
|
|
9
|
+
import { runCheck } from '../lib/check.ts'
|
|
10
|
+
import { matchesSeam } from '../lib/intent.ts'
|
|
11
|
+
|
|
12
|
+
export function done(cwd: string): number {
|
|
13
|
+
const root = findRepoRoot(cwd)
|
|
14
|
+
if (root === null || !hasSession(root)) {
|
|
15
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
16
|
+
return 1
|
|
17
|
+
}
|
|
18
|
+
const state = readState(root)
|
|
19
|
+
if (state !== 'BUILD' && state !== 'REVIEW') {
|
|
20
|
+
process.stderr.write(`plumbbob: done runs from BUILD or REVIEW (current state is ${state ?? 'UNKNOWN'}). Run \`plumbbob build <n>\` first.\n`)
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
const step = readStepNumber(root)
|
|
24
|
+
if (step === null) {
|
|
25
|
+
process.stderr.write('plumbbob: no in-flight step — run `plumbbob build <n>` first.\n')
|
|
26
|
+
return 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (runCheck(root) !== 0) {
|
|
30
|
+
process.stderr.write('plumbbob: check failed (red) — done refuses on red. Fix it and re-run `done`.\n')
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stageAll(root)
|
|
35
|
+
const seam = readSeamTokens(root)
|
|
36
|
+
const outside = stagedPaths(root).filter((p) => !matchesSeam(p, seam))
|
|
37
|
+
if (outside.length > 0) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`plumbbob: WARNING committed paths outside the SEAM: ${outside.join(', ')}. The checkpoint captures them, but scope drift may mean the plan needs revising.\n`,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sha = commit(root, `plumbbob: step ${step} done`)
|
|
44
|
+
appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`)
|
|
45
|
+
rmSync(seamPath(root), { force: true })
|
|
46
|
+
rmSync(stepPath(root), { force: true })
|
|
47
|
+
writeState(root, 'DESIGN')
|
|
48
|
+
|
|
49
|
+
process.stdout.write(`plumbbob: step ${step} done — checkpoint ${sha.slice(0, 9)}. STATE=DESIGN.\n`)
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readStepNumber(root: string): number | null {
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(stepPath(root), 'utf8').trim()
|
|
56
|
+
return /^\d+$/.test(raw) ? Number(raw) : null
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readSeamTokens(root: string): ReadonlyArray<string> {
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(seamPath(root), 'utf8')
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map((l) => l.trim())
|
|
67
|
+
.filter((l) => l.length > 0)
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `plumbbob finish` (D19/D20) — the closing gate, symmetric with the step gate.
|
|
2
|
+
// Refuses unless a report exists, appends the checkpoint SHA list to it, archives
|
|
3
|
+
// intent + build-log + report under .plumbbob/archive/, clears the active files,
|
|
4
|
+
// and deletes SEAM, STEP, then STATE LAST (deleting STATE is what switches the
|
|
5
|
+
// muzzle off, so it happens exactly at session end). Never touches git (C5).
|
|
6
|
+
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
8
|
+
import { join, relative } from 'node:path'
|
|
9
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
10
|
+
import {
|
|
11
|
+
buildLogPath,
|
|
12
|
+
checkpointsPath,
|
|
13
|
+
hasSession,
|
|
14
|
+
intentPath,
|
|
15
|
+
seamPath,
|
|
16
|
+
sidecarDir,
|
|
17
|
+
stepPath,
|
|
18
|
+
} from '../lib/sidecar.ts'
|
|
19
|
+
import { archiveSession, reportPath } from '../lib/archive.ts'
|
|
20
|
+
|
|
21
|
+
export function finish(cwd: string): number {
|
|
22
|
+
const root = findRepoRoot(cwd)
|
|
23
|
+
if (root === null || !hasSession(root)) {
|
|
24
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
25
|
+
return 1
|
|
26
|
+
}
|
|
27
|
+
if (!existsSync(reportPath(root))) {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
'plumbbob: finish refuses without a report — run `/plumbbob-report` first (it writes .plumbbob/report.md). ' +
|
|
30
|
+
'The closing gate is symmetric with the step gate: you do not walk away without capturing what happened.\n',
|
|
31
|
+
)
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
appendCheckpointShas(root)
|
|
36
|
+
const archived = archiveSession(root)
|
|
37
|
+
|
|
38
|
+
// Clear the active files — now safely archived.
|
|
39
|
+
rmSync(intentPath(root), { force: true })
|
|
40
|
+
rmSync(buildLogPath(root), { force: true })
|
|
41
|
+
rmSync(reportPath(root), { force: true })
|
|
42
|
+
|
|
43
|
+
// Delete the control files LAST, STATE the very last: while STATE exists the
|
|
44
|
+
// muzzle is live, so it comes off only once everything else is torn down.
|
|
45
|
+
rmSync(seamPath(root), { force: true })
|
|
46
|
+
rmSync(stepPath(root), { force: true })
|
|
47
|
+
rmSync(join(sidecarDir(root), 'STATE'), { force: true })
|
|
48
|
+
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`plumbbob: finished — archived to ${relative(root, archived)}. STATE cleared (muzzle off). ` +
|
|
51
|
+
'Run `plumbbob start "<title>"` to begin the next task.\n',
|
|
52
|
+
)
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Append the recorded checkpoints (baseline + each `step n <sha>`) to the report,
|
|
57
|
+
// so the archived report lists the SHAs (spec: "finish lists the SHAs in the
|
|
58
|
+
// report").
|
|
59
|
+
function appendCheckpointShas(root: string): void {
|
|
60
|
+
let raw = ''
|
|
61
|
+
try {
|
|
62
|
+
raw = readFileSync(checkpointsPath(root), 'utf8')
|
|
63
|
+
} catch {
|
|
64
|
+
raw = ''
|
|
65
|
+
}
|
|
66
|
+
const lines = raw
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((l) => l.trim())
|
|
69
|
+
.filter((l) => l.length > 0)
|
|
70
|
+
.map((l) => `- ${l}`)
|
|
71
|
+
appendFileSync(reportPath(root), ['', '## Checkpoints', '', ...lines, ''].join('\n'))
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `plumbbob mode <x>` — the hidden escape hatch: set STATE directly when reality
|
|
2
|
+
// and the machine desync. Not part of the normal flow; a transition verb, so the
|
|
3
|
+
// dispatch refuses it under CLAUDECODE (D21).
|
|
4
|
+
|
|
5
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
6
|
+
import { hasSession, readState, writeState, VALID_STATES } from '../lib/sidecar.ts'
|
|
7
|
+
|
|
8
|
+
export function mode(cwd: string, args: ReadonlyArray<string>): number {
|
|
9
|
+
const root = findRepoRoot(cwd)
|
|
10
|
+
if (root === null || !hasSession(root)) {
|
|
11
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
12
|
+
return 1
|
|
13
|
+
}
|
|
14
|
+
const target = args[0]
|
|
15
|
+
if (target === undefined) {
|
|
16
|
+
process.stderr.write(`plumbbob: mode needs a state — one of: ${VALID_STATES.join(', ')}.\n`)
|
|
17
|
+
return 1
|
|
18
|
+
}
|
|
19
|
+
if (!VALID_STATES.includes(target)) {
|
|
20
|
+
process.stderr.write(`plumbbob: '${target}' is not a valid state — one of: ${VALID_STATES.join(', ')}.\n`)
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
const previous = readState(root) ?? 'UNKNOWN'
|
|
24
|
+
writeState(root, target)
|
|
25
|
+
process.stdout.write(`STATE: ${previous} -> ${target} (escape hatch — prefer the normal verbs when you can).\n`)
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// `plumbbob park "<text>"` — the dumb capture path: append one raw line under
|
|
2
|
+
// the build-log's Park list. No model turn, no composition (that is /park's job).
|
|
3
|
+
// Exempt from the CLAUDECODE transition refusal so capture is always available.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
7
|
+
import { hasSession, buildLogPath } from '../lib/sidecar.ts'
|
|
8
|
+
|
|
9
|
+
export function park(cwd: string, args: ReadonlyArray<string>): number {
|
|
10
|
+
const root = findRepoRoot(cwd)
|
|
11
|
+
if (root === null || !hasSession(root)) {
|
|
12
|
+
process.stderr.write(
|
|
13
|
+
'plumbbob: no active session — nothing to park to. Run `plumbbob start "<title>"` first.\n',
|
|
14
|
+
)
|
|
15
|
+
return 1
|
|
16
|
+
}
|
|
17
|
+
const text = args
|
|
18
|
+
.filter((a) => !a.startsWith('--'))
|
|
19
|
+
.join(' ')
|
|
20
|
+
.trim()
|
|
21
|
+
if (text.length === 0) {
|
|
22
|
+
process.stderr.write('plumbbob: park needs text. Try: plumbbob park "the idea you do not want to chase right now".\n')
|
|
23
|
+
return 1
|
|
24
|
+
}
|
|
25
|
+
const path = buildLogPath(root)
|
|
26
|
+
const updated = insertParkItem(readFileSync(path, 'utf8'), text)
|
|
27
|
+
if (updated === null) {
|
|
28
|
+
process.stderr.write('plumbbob: could not find a "## Park list" section in build-log.md.\n')
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(path, updated)
|
|
32
|
+
process.stdout.write(`parked: ${text}\n`)
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Append after the last non-blank line of the Park list section (i.e. just before
|
|
37
|
+
// the next `## ` heading). Returns null if there is no Park list to append to.
|
|
38
|
+
function insertParkItem(content: string, text: string): string | null {
|
|
39
|
+
const lines = content.split('\n')
|
|
40
|
+
const headingIdx = lines.findIndex((line) => line.trim() === '## Park list')
|
|
41
|
+
if (headingIdx === -1) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
let nextSection = lines.findIndex((line, i) => i > headingIdx && line.startsWith('## '))
|
|
45
|
+
if (nextSection === -1) {
|
|
46
|
+
nextSection = lines.length
|
|
47
|
+
}
|
|
48
|
+
let insertAt = headingIdx + 1
|
|
49
|
+
for (let i = headingIdx + 1; i < nextSection; i++) {
|
|
50
|
+
if ((lines[i] ?? '').trim() !== '') {
|
|
51
|
+
insertAt = i + 1
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
lines.splice(insertAt, 0, `- [ ] ${text}`)
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `plumbbob revert [--to n]` — git reset --hard to a checkpoint SHA (the most
|
|
2
|
+
// recent step, or `--to n`, with the baseline as fallback), then remove untracked
|
|
3
|
+
// files under the SEAM only. The sidecar is git-excluded (D17), so the reset
|
|
4
|
+
// never touches it — park lines and intent edits survive the revert (C4).
|
|
5
|
+
|
|
6
|
+
import { readFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { findRepoRoot, resetHard, untrackedPaths } from '../lib/git.ts'
|
|
9
|
+
import { checkpointsPath, hasSession, seamPath, stepPath, writeState } from '../lib/sidecar.ts'
|
|
10
|
+
import { matchesSeam } from '../lib/intent.ts'
|
|
11
|
+
|
|
12
|
+
export function revert(cwd: string, args: ReadonlyArray<string>): number {
|
|
13
|
+
const root = findRepoRoot(cwd)
|
|
14
|
+
if (root === null || !hasSession(root)) {
|
|
15
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
16
|
+
return 1
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const to = parseTo(args)
|
|
20
|
+
if (to === 'invalid') {
|
|
21
|
+
process.stderr.write('plumbbob: revert --to needs a step number. Try: plumbbob revert --to 2.\n')
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const checkpoints = readCheckpoints(root)
|
|
26
|
+
let sha: string | undefined
|
|
27
|
+
if (to === null) {
|
|
28
|
+
sha = checkpoints.steps.at(-1)?.sha ?? checkpoints.baseline
|
|
29
|
+
} else {
|
|
30
|
+
const entry = checkpoints.steps.find((e) => e.n === to)
|
|
31
|
+
if (entry === undefined) {
|
|
32
|
+
process.stderr.write(`plumbbob: no checkpoint recorded for step ${to}.\n`)
|
|
33
|
+
return 1
|
|
34
|
+
}
|
|
35
|
+
sha = entry.sha
|
|
36
|
+
}
|
|
37
|
+
if (sha === undefined) {
|
|
38
|
+
process.stderr.write('plumbbob: no baseline recorded in checkpoints — cannot revert.\n')
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compute untracked-in-seam BEFORE the reset (reset --hard leaves untracked and
|
|
43
|
+
// ignored files alone, so they must be removed explicitly afterward).
|
|
44
|
+
const seam = readSeamTokens(root)
|
|
45
|
+
const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam))
|
|
46
|
+
|
|
47
|
+
resetHard(root, sha)
|
|
48
|
+
for (const rel of toRemove) {
|
|
49
|
+
rmSync(join(root, rel), { force: true, recursive: true })
|
|
50
|
+
}
|
|
51
|
+
rmSync(seamPath(root), { force: true })
|
|
52
|
+
rmSync(stepPath(root), { force: true })
|
|
53
|
+
writeState(root, 'DESIGN')
|
|
54
|
+
|
|
55
|
+
process.stdout.write(
|
|
56
|
+
`plumbbob: reverted to ${sha.slice(0, 9)} — STATE=DESIGN. Park lines and intent edits were preserved.\n`,
|
|
57
|
+
)
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseTo(args: ReadonlyArray<string>): number | null | 'invalid' {
|
|
62
|
+
const idx = args.indexOf('--to')
|
|
63
|
+
if (idx === -1) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
const raw = args[idx + 1]
|
|
67
|
+
if (raw === undefined || !/^\d+$/.test(raw)) {
|
|
68
|
+
return 'invalid'
|
|
69
|
+
}
|
|
70
|
+
return Number(raw)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Checkpoints = {
|
|
74
|
+
readonly baseline: string | undefined
|
|
75
|
+
readonly steps: ReadonlyArray<{ readonly n: number; readonly sha: string }>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readCheckpoints(root: string): Checkpoints {
|
|
79
|
+
let content = ''
|
|
80
|
+
try {
|
|
81
|
+
content = readFileSync(checkpointsPath(root), 'utf8')
|
|
82
|
+
} catch {
|
|
83
|
+
return { baseline: undefined, steps: [] }
|
|
84
|
+
}
|
|
85
|
+
let baseline: string | undefined
|
|
86
|
+
const steps: Array<{ readonly n: number; readonly sha: string }> = []
|
|
87
|
+
for (const line of content.split('\n')) {
|
|
88
|
+
const baselineMatch = /^baseline\s+(\S+)/.exec(line)
|
|
89
|
+
if (baselineMatch) {
|
|
90
|
+
baseline = baselineMatch[1]
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const stepMatch = /^step\s+(\d+)\s+(\S+)/.exec(line)
|
|
94
|
+
if (stepMatch) {
|
|
95
|
+
steps.push({ n: Number(stepMatch[1]), sha: stepMatch[2] ?? '' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { baseline, steps }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readSeamTokens(root: string): ReadonlyArray<string> {
|
|
102
|
+
try {
|
|
103
|
+
return readFileSync(seamPath(root), 'utf8')
|
|
104
|
+
.split('\n')
|
|
105
|
+
.map((l) => l.trim())
|
|
106
|
+
.filter((l) => l.length > 0)
|
|
107
|
+
} catch {
|
|
108
|
+
return []
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `plumbbob review` — run the heavy check from BUILD. Green flips to REVIEW (the
|
|
2
|
+
// muzzle goes back on so reading the diff can't slide into editing). Red stays in
|
|
3
|
+
// BUILD. The state only advances on a green check.
|
|
4
|
+
|
|
5
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
6
|
+
import { hasSession, readState, writeState } from '../lib/sidecar.ts'
|
|
7
|
+
import { runCheck } from '../lib/check.ts'
|
|
8
|
+
|
|
9
|
+
export function review(cwd: string): number {
|
|
10
|
+
const root = findRepoRoot(cwd)
|
|
11
|
+
if (root === null || !hasSession(root)) {
|
|
12
|
+
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n')
|
|
13
|
+
return 1
|
|
14
|
+
}
|
|
15
|
+
if (readState(root) !== 'BUILD') {
|
|
16
|
+
process.stderr.write(`plumbbob: review runs from BUILD (current state is ${readState(root) ?? 'UNKNOWN'}). Run \`plumbbob build <n>\` first.\n`)
|
|
17
|
+
return 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (runCheck(root) !== 0) {
|
|
21
|
+
process.stderr.write('plumbbob: check failed (red) — staying in BUILD. Fix it and re-run `review` (or `done` once green).\n')
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
writeState(root, 'REVIEW')
|
|
26
|
+
process.stdout.write(
|
|
27
|
+
'plumbbob: check green — STATE=REVIEW. Read the diff against intent.md (edits muzzled). `build <n>` to re-enter and fix, or `done` to checkpoint.\n',
|
|
28
|
+
)
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `plumbbob setup` — the production installer (D27, replacing dev-install.sh).
|
|
2
|
+
// Copies the hooks to ~/.claude/plumbbob/hooks/ and the skills to
|
|
3
|
+
// ~/.claude/skills/, then merges the hook registration into the settings file the
|
|
4
|
+
// chosen scope selects:
|
|
5
|
+
// (default) ~/.claude/settings.json — global, this machine
|
|
6
|
+
// --project <repo>/.claude/settings.json — committable, enrolls the team
|
|
7
|
+
// --local <repo>/.claude/settings.local.json — personal, untracked
|
|
8
|
+
// Hooks and skills always install once under ~/.claude/; only the registration's
|
|
9
|
+
// location and command-path form vary by scope. `--uninstall` strips the
|
|
10
|
+
// registration (the installed files are left in place). The hooks are
|
|
11
|
+
// session-gated, so a global registration is safe in every repo (C7).
|
|
12
|
+
|
|
13
|
+
import { chmodSync, cpSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { findRepoRoot } from '../lib/git.ts'
|
|
18
|
+
import { mergeRegistration, readSettings, stripRegistration, writeSettings } from '../lib/settings.ts'
|
|
19
|
+
|
|
20
|
+
const HOOK_FILES: ReadonlyArray<string> = ['pre-edit.sh', 'bash-guard.sh', 'post-edit.sh']
|
|
21
|
+
|
|
22
|
+
type Scope = 'global' | 'project' | 'local'
|
|
23
|
+
|
|
24
|
+
export function setup(cwd: string, args: ReadonlyArray<string>): number {
|
|
25
|
+
const scope = pickScope(args)
|
|
26
|
+
const home = process.env.HOME ?? homedir()
|
|
27
|
+
|
|
28
|
+
const settingsFile = resolveSettingsFile(scope, cwd, home)
|
|
29
|
+
if (settingsFile === null) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
`plumbbob: --${scope} writes <repo>/.claude/, but this is not a git repository. Run setup from inside the repo, or use the default global scope.\n`,
|
|
32
|
+
)
|
|
33
|
+
return 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (args.includes('--uninstall')) {
|
|
37
|
+
writeSettings(settingsFile, stripRegistration(readSettings(settingsFile)))
|
|
38
|
+
process.stdout.write(
|
|
39
|
+
`plumbbob: removed the hook registration from ${settingsFile}. The installed hooks/skills under ~/.claude/ were left in place.\n`,
|
|
40
|
+
)
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Hooks + skills install once under ~/.claude/ regardless of scope.
|
|
45
|
+
const installedHooksDir = join(home, '.claude', 'plumbbob', 'hooks')
|
|
46
|
+
const installedSkillsDir = join(home, '.claude', 'skills')
|
|
47
|
+
cpSync(packageDir('hooks'), installedHooksDir, { recursive: true })
|
|
48
|
+
for (const file of HOOK_FILES) {
|
|
49
|
+
chmodSync(join(installedHooksDir, file), 0o755)
|
|
50
|
+
}
|
|
51
|
+
cpSync(packageDir('skills'), installedSkillsDir, { recursive: true })
|
|
52
|
+
|
|
53
|
+
// D27: global registers absolute command paths (its settings file is itself
|
|
54
|
+
// under ~); the repo-scoped files register `~`-prefixed paths so committed
|
|
55
|
+
// settings carry no machine-absolute home dir.
|
|
56
|
+
const commandDir = scope === 'global' ? installedHooksDir : '~/.claude/plumbbob/hooks'
|
|
57
|
+
writeSettings(settingsFile, mergeRegistration(readSettings(settingsFile), commandDir))
|
|
58
|
+
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
`plumbbob: installed hooks → ${installedHooksDir}, skills → ${installedSkillsDir}.\n` +
|
|
61
|
+
`plumbbob: registered the hooks in ${settingsFile} (${scope} scope).\n` +
|
|
62
|
+
'plumbbob: restart Claude Code (or reload settings) for the hooks to take effect.\n' +
|
|
63
|
+
"plumbbob: installed from npm, `plumbbob` (and its `pb` alias) is already on your PATH; from a dev checkout add `alias plumbbob='node <repo>/src/cli.ts'` so the skills' status pre-injection resolves.\n",
|
|
64
|
+
)
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pickScope(args: ReadonlyArray<string>): Scope {
|
|
69
|
+
if (args.includes('--project')) {
|
|
70
|
+
return 'project'
|
|
71
|
+
}
|
|
72
|
+
if (args.includes('--local')) {
|
|
73
|
+
return 'local'
|
|
74
|
+
}
|
|
75
|
+
return 'global'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The settings file the scope writes. Global lives under ~; the repo scopes need
|
|
79
|
+
// a git root and return null outside one so the verb can refuse with a clear note.
|
|
80
|
+
function resolveSettingsFile(scope: Scope, cwd: string, home: string): string | null {
|
|
81
|
+
if (scope === 'global') {
|
|
82
|
+
return join(home, '.claude', 'settings.json')
|
|
83
|
+
}
|
|
84
|
+
const root = findRepoRoot(cwd)
|
|
85
|
+
if (root === null) {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
return scope === 'project' ? join(root, '.claude', 'settings.json') : join(root, '.claude', 'settings.local.json')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// hooks/ and skills/ ship beside src/ in the package; resolve them off this
|
|
92
|
+
// module's URL the way `start` resolves templates/.
|
|
93
|
+
function packageDir(name: string): string {
|
|
94
|
+
return fileURLToPath(new URL(`../../${name}/`, import.meta.url))
|
|
95
|
+
}
|