plumbbob 0.1.3 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +69 -26
  2. package/dist/cli.js +99 -0
  3. package/dist/lib/archive.js +69 -0
  4. package/dist/lib/check.js +26 -0
  5. package/dist/lib/git.js +62 -0
  6. package/dist/lib/intent.js +88 -0
  7. package/dist/lib/settings.js +79 -0
  8. package/dist/lib/sidecar.js +69 -0
  9. package/dist/verbs/build.js +30 -0
  10. package/dist/verbs/done.js +63 -0
  11. package/dist/verbs/finish.js +54 -0
  12. package/dist/verbs/mode.js +26 -0
  13. package/dist/verbs/park.js +51 -0
  14. package/dist/verbs/revert.js +136 -0
  15. package/dist/verbs/review.js +24 -0
  16. package/dist/verbs/setup.js +141 -0
  17. package/dist/verbs/spike.js +113 -0
  18. package/dist/verbs/start.js +68 -0
  19. package/dist/verbs/status.js +13 -0
  20. package/dist/verbs/wrap.js +28 -0
  21. package/hooks/bash-guard.sh +5 -1
  22. package/hooks/pre-edit.sh +17 -0
  23. package/package.json +6 -4
  24. package/skills/park/SKILL.md +4 -4
  25. package/skills/pb-build/SKILL.md +19 -0
  26. package/skills/pb-done/SKILL.md +18 -0
  27. package/skills/pb-finish/SKILL.md +18 -0
  28. package/skills/pb-revert/SKILL.md +19 -0
  29. package/skills/pb-review/SKILL.md +18 -0
  30. package/skills/pb-spike/SKILL.md +19 -0
  31. package/skills/pb-start/SKILL.md +19 -0
  32. package/skills/pb-wrap/SKILL.md +18 -0
  33. package/skills/plumbbob-docs/SKILL.md +2 -2
  34. package/skills/plumbbob-interrogate/SKILL.md +2 -2
  35. package/skills/plumbbob-report/SKILL.md +2 -2
  36. package/skills/plumbbob-triage/SKILL.md +2 -2
  37. package/src/cli.ts +0 -121
  38. package/src/lib/archive.ts +0 -76
  39. package/src/lib/check.ts +0 -29
  40. package/src/lib/git.ts +0 -73
  41. package/src/lib/intent.ts +0 -103
  42. package/src/lib/settings.ts +0 -90
  43. package/src/lib/sidecar.ts +0 -82
  44. package/src/verbs/build.ts +0 -38
  45. package/src/verbs/done.ts +0 -71
  46. package/src/verbs/finish.ts +0 -72
  47. package/src/verbs/mode.ts +0 -27
  48. package/src/verbs/park.ts +0 -56
  49. package/src/verbs/revert.ts +0 -110
  50. package/src/verbs/review.ts +0 -30
  51. package/src/verbs/setup.ts +0 -95
  52. package/src/verbs/spike.ts +0 -129
  53. package/src/verbs/start.ts +0 -103
  54. package/src/verbs/status.ts +0 -15
  55. package/src/verbs/wrap.ts +0 -34
package/src/lib/check.ts DELETED
@@ -1,29 +0,0 @@
1
- // The heavy check (D16/D24): the full gate that `review` and `done` refuse to
2
- // advance past while red. The command is read from `.plumbbob/config` (`check=`)
3
- // so non-pnpm repos gate too; tests point it at a stub (`true`/`false`) since a
4
- // real `pnpm check` would recurse into vitest (D14).
5
-
6
- import { spawnSync } from 'node:child_process'
7
- import { readFileSync } from 'node:fs'
8
- import { configPath } from './sidecar.ts'
9
-
10
- const DEFAULT_CHECK = 'pnpm run check'
11
-
12
- function readCheckCommand(root: string): string {
13
- let config = ''
14
- try {
15
- config = readFileSync(configPath(root), 'utf8')
16
- } catch {
17
- return DEFAULT_CHECK
18
- }
19
- const line = config.split('\n').find((l) => l.startsWith('check='))
20
- return line === undefined ? DEFAULT_CHECK : line.slice('check='.length).trim()
21
- }
22
-
23
- // Runs the configured check in `root`, streaming its output to the terminal so
24
- // the human sees what failed. Returns the exit code (0 = green).
25
- export function runCheck(root: string): number {
26
- const command = readCheckCommand(root)
27
- const result = spawnSync(command, { cwd: root, shell: true, stdio: 'inherit' })
28
- return result.status ?? 1
29
- }
package/src/lib/git.ts DELETED
@@ -1,73 +0,0 @@
1
- // Thin git wrapper over `node:child_process` (C2: node builtins only, zero
2
- // runtime deps). Functional/procedural, no classes (C1). Plumbbob's git
3
- // footprint is additive (C5); these helpers only read and locate.
4
-
5
- import { execFileSync } from 'node:child_process'
6
-
7
- function runGit(root: string, args: ReadonlyArray<string>): string {
8
- return execFileSync('git', ['-C', root, ...args], {
9
- encoding: 'utf8',
10
- stdio: ['ignore', 'pipe', 'ignore'],
11
- }).trim()
12
- }
13
-
14
- // The git toplevel for `cwd`, or null when `cwd` is not inside a git repo.
15
- export function findRepoRoot(cwd: string): string | null {
16
- try {
17
- return runGit(cwd, ['rev-parse', '--show-toplevel'])
18
- } catch {
19
- return null
20
- }
21
- }
22
-
23
- // Absolute path to the .git directory (handles worktrees/linked git dirs).
24
- export function gitDir(root: string): string {
25
- return runGit(root, ['rev-parse', '--absolute-git-dir'])
26
- }
27
-
28
- export function headSha(root: string): string {
29
- return runGit(root, ['rev-parse', 'HEAD'])
30
- }
31
-
32
- export function hasCommit(root: string): boolean {
33
- try {
34
- runGit(root, ['rev-parse', '--verify', 'HEAD'])
35
- return true
36
- } catch {
37
- return false
38
- }
39
- }
40
-
41
- // Dirty = any tracked change or non-ignored untracked file. The sidecar is
42
- // git-excluded (D17), so an active session never reads as dirty.
43
- export function isDirty(root: string): boolean {
44
- return runGit(root, ['status', '--porcelain']).length > 0
45
- }
46
-
47
- // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
48
- // only (C5): stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
49
-
50
- export function stageAll(root: string): void {
51
- runGit(root, ['add', '-A'])
52
- }
53
-
54
- export function stagedPaths(root: string): ReadonlyArray<string> {
55
- const out = runGit(root, ['diff', '--cached', '--name-only'])
56
- return out.length === 0 ? [] : out.split('\n')
57
- }
58
-
59
- export function untrackedPaths(root: string): ReadonlyArray<string> {
60
- const out = runGit(root, ['ls-files', '--others', '--exclude-standard'])
61
- return out.length === 0 ? [] : out.split('\n')
62
- }
63
-
64
- // Commit whatever is staged as a checkpoint and return its SHA. --allow-empty so
65
- // a step that touched only ignored files still gets a checkpoint to revert to.
66
- export function commit(root: string, message: string): string {
67
- runGit(root, ['commit', '--allow-empty', '-m', message])
68
- return headSha(root)
69
- }
70
-
71
- export function resetHard(root: string, sha: string): void {
72
- runGit(root, ['reset', '--hard', sha])
73
- }
package/src/lib/intent.ts DELETED
@@ -1,103 +0,0 @@
1
- // The strict intent parser. `build <n>` reads the nth step under `## Steps` and
2
- // extracts its seam — the backtick-wrapped paths on the single `seam:` sub-line.
3
- // Strict by design: it refuses precisely on globs, absolute paths, a missing
4
- // step, a missing seam, or more than one seam line, rather than guessing.
5
-
6
- const GLOB_CHARS = /[*?[\]{}]/
7
-
8
- export type SeamParse =
9
- | { readonly ok: true; readonly seam: ReadonlyArray<string> }
10
- | { readonly ok: false; readonly error: string }
11
-
12
- export function parseStepSeam(content: string, step: number): SeamParse {
13
- const lines = content.split('\n')
14
-
15
- const stepsIdx = lines.findIndex((l) => l.trim() === '## Steps')
16
- if (stepsIdx === -1) {
17
- return fail('intent.md has no "## Steps" section.')
18
- }
19
- let sectionEnd = lines.findIndex((l, i) => i > stepsIdx && l.startsWith('## '))
20
- if (sectionEnd === -1) {
21
- sectionEnd = lines.length
22
- }
23
-
24
- const itemStarts: Array<{ readonly n: number; readonly idx: number }> = []
25
- for (let i = stepsIdx + 1; i < sectionEnd; i++) {
26
- const m = /^(\d+)\.\s/.exec(lines[i] ?? '')
27
- if (m) {
28
- itemStarts.push({ n: Number(m[1]), idx: i })
29
- }
30
- }
31
-
32
- const pos = itemStarts.findIndex((s) => s.n === step)
33
- if (pos === -1) {
34
- return fail(`intent.md has no step ${step} under "## Steps".`)
35
- }
36
- const itemStart = itemStarts[pos]?.idx ?? stepsIdx
37
- const nextStart = itemStarts[pos + 1]?.idx
38
- const itemEnd = nextStart === undefined ? sectionEnd : nextStart
39
- const itemLines = lines.slice(itemStart, itemEnd)
40
-
41
- const seamLines = itemLines
42
- .map((l, i) => ({ l, i }))
43
- .filter(({ l }) => /^\s*-\s*seam:/.test(l))
44
- .map(({ i }) => i)
45
- if (seamLines.length === 0) {
46
- return fail(`step ${step} has no \`seam:\` line.`)
47
- }
48
- if (seamLines.length > 1) {
49
- return fail(`step ${step} has more than one \`seam:\` line.`)
50
- }
51
-
52
- // The seam declaration is the seam line plus any wrapped continuation lines.
53
- // Truncate each at an HTML-comment opener so a trailing `<!-- ... -->` note
54
- // (which may carry its own backticks) is never read as a seam token; a
55
- // continuation ends at the first line whose pre-comment text has no backtick.
56
- const seamStart = seamLines[0] ?? 0
57
- const decl: string[] = []
58
- for (let i = seamStart; i < itemLines.length; i++) {
59
- const beforeComment = (itemLines[i] ?? '').split('<!--')[0] ?? ''
60
- if (i > seamStart && !beforeComment.includes('`')) {
61
- break
62
- }
63
- decl.push(beforeComment)
64
- }
65
-
66
- const tokens: string[] = []
67
- const re = /`([^`]+)`/g
68
- for (const line of decl) {
69
- for (let m = re.exec(line); m !== null; m = re.exec(line)) {
70
- tokens.push(m[1] ?? '')
71
- }
72
- }
73
- if (tokens.length === 0) {
74
- return fail(`step ${step}'s seam lists no files.`)
75
- }
76
-
77
- const seam: string[] = []
78
- for (const raw of tokens) {
79
- const token = raw.trim().replace(/^\.\//, '')
80
- if (token === '') {
81
- return fail(`step ${step}'s seam has an empty token.`)
82
- }
83
- if (GLOB_CHARS.test(token)) {
84
- return fail(`step ${step}'s seam token \`${raw}\` is a glob; seams are exact paths or \`dir/\` grants (D23).`)
85
- }
86
- if (token.startsWith('/')) {
87
- return fail(`step ${step}'s seam token \`${raw}\` is absolute; seams are repo-relative.`)
88
- }
89
- seam.push(token)
90
- }
91
- return { ok: true, seam }
92
- }
93
-
94
- // Seam membership (D23): a repo-relative path is in-seam if it equals an exact
95
- // token, or is prefixed by a `dir/` grant. Shared by `done` (scope-drift warn)
96
- // and `revert` (untracked cleanup); the pre-edit hook reimplements it in sh.
97
- export function matchesSeam(relPath: string, tokens: ReadonlyArray<string>): boolean {
98
- return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token))
99
- }
100
-
101
- function fail(error: string): SeamParse {
102
- return { ok: false, error }
103
- }
@@ -1,90 +0,0 @@
1
- // Claude Code settings.json hook registration (D27). A pure-TS JSON merge (zero
2
- // runtime deps, node builtins only — C2): strip our entries, then re-add them, so
3
- // a re-run is byte-identical. The same logic serves all three D27 scopes — only
4
- // the settings file path and the command-path representation differ, both chosen
5
- // by the `setup` verb. Functional/procedural, no classes (C1).
6
-
7
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
- import { dirname } from 'node:path'
9
-
10
- // A registration entry is "ours" iff one of its hook commands points into our
11
- // installed hooks dir. This marker matches BOTH the absolute (global) and the
12
- // `~`-prefixed (repo-scoped) command forms, so uninstall finds either.
13
- const OURS_MARKER = '.claude/plumbbob/hooks/'
14
-
15
- // The PreToolUse edit matcher covers all four editing tools (D5).
16
- const EDIT_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit'
17
-
18
- type HookCommand = { readonly type: string; readonly command: string }
19
- type HookEntry = { readonly matcher?: string; readonly hooks?: ReadonlyArray<HookCommand> }
20
- type HookMap = { readonly [event: string]: ReadonlyArray<HookEntry> | undefined }
21
- interface Settings {
22
- hooks?: HookMap
23
- [key: string]: unknown
24
- }
25
-
26
- // The three hook registration entries, pointing at `hooksDir` (absolute for the
27
- // global scope; `~`-prefixed for the repo-scoped files so committed settings
28
- // carry no machine-absolute home dir — a leading `~/` is shell-expanded when the
29
- // hook command runs). Mirrors the PreToolUse/PostToolUse shape dev-install wrote.
30
- function registrationEntries(hooksDir: string): { readonly PreToolUse: HookEntry[]; readonly PostToolUse: HookEntry[] } {
31
- const dir = hooksDir.endsWith('/') ? hooksDir.slice(0, -1) : hooksDir
32
- const entry = (matcher: string, file: string): HookEntry => ({
33
- matcher,
34
- hooks: [{ type: 'command', command: `${dir}/${file}` }],
35
- })
36
- return {
37
- PreToolUse: [entry(EDIT_MATCHER, 'pre-edit.sh'), entry('Bash', 'bash-guard.sh')],
38
- PostToolUse: [entry(EDIT_MATCHER, 'post-edit.sh')],
39
- }
40
- }
41
-
42
- function isOurs(entry: HookEntry): boolean {
43
- return (entry.hooks ?? []).some((h) => h.command.includes(OURS_MARKER))
44
- }
45
-
46
- function stripOurs(list: ReadonlyArray<HookEntry> | undefined): HookEntry[] {
47
- return (list ?? []).filter((e) => !isOurs(e))
48
- }
49
-
50
- // Merge our registration into a parsed settings object (strip-then-add). Returns
51
- // a new object; every unrelated key and unrelated hook is preserved, and key
52
- // order is stable across runs so a second merge is byte-identical.
53
- export function mergeRegistration(settings: Settings, hooksDir: string): Settings {
54
- const entries = registrationEntries(hooksDir)
55
- const existing: HookMap = settings.hooks ?? {}
56
- const hooks: { [event: string]: ReadonlyArray<HookEntry> | undefined } = { ...existing }
57
- hooks.PreToolUse = [...stripOurs(existing.PreToolUse), ...entries.PreToolUse]
58
- hooks.PostToolUse = [...stripOurs(existing.PostToolUse), ...entries.PostToolUse]
59
- return { ...settings, hooks }
60
- }
61
-
62
- // Remove only our entries from the two events we manage, leaving everything else
63
- // untouched (uninstall, and the idempotent strip half of a re-install).
64
- export function stripRegistration(settings: Settings): Settings {
65
- const existing = settings.hooks
66
- if (existing === undefined) {
67
- return settings
68
- }
69
- const hooks: { [event: string]: ReadonlyArray<HookEntry> | undefined } = { ...existing }
70
- if (existing.PreToolUse !== undefined) {
71
- hooks.PreToolUse = stripOurs(existing.PreToolUse)
72
- }
73
- if (existing.PostToolUse !== undefined) {
74
- hooks.PostToolUse = stripOurs(existing.PostToolUse)
75
- }
76
- return { ...settings, hooks }
77
- }
78
-
79
- export function readSettings(path: string): Settings {
80
- try {
81
- return JSON.parse(readFileSync(path, 'utf8')) as Settings
82
- } catch {
83
- return {}
84
- }
85
- }
86
-
87
- export function writeSettings(path: string, settings: Settings): void {
88
- mkdirSync(dirname(path), { recursive: true })
89
- writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`)
90
- }
@@ -1,82 +0,0 @@
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
- }
@@ -1,38 +0,0 @@
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
- }
package/src/verbs/done.ts DELETED
@@ -1,71 +0,0 @@
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
- }
@@ -1,72 +0,0 @@
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
- }
package/src/verbs/mode.ts DELETED
@@ -1,27 +0,0 @@
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
- }
package/src/verbs/park.ts DELETED
@@ -1,56 +0,0 @@
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
- }