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.
@@ -0,0 +1,129 @@
1
+ // `plumbbob spike` (D18) — the spike lifecycle for a genuine fork the design
2
+ // phase couldn't settle. `spike "<slug>" [opt…]` creates a sibling git worktree +
3
+ // `spike/<slug>-<opt>` branch per option OUTSIDE the repo root (default opts a/b)
4
+ // and sets STATE=SPIKE; the main tree stays DESIGN-locked while you experiment in
5
+ // the worktrees, which are hook-dormant by construction — the untracked sidecar
6
+ // (D17) doesn't exist in a fresh checkout, so the hooks find no STATE there.
7
+ // `spike done` removes every spike worktree + branch and returns to DESIGN.
8
+ //
9
+ // Worktree git calls run directly here rather than via lib/git.ts (which holds the
10
+ // shared additive read/commit helpers): worktree management is spike-local, and
11
+ // this is the only place Plumbbob creates branches.
12
+
13
+ import { execFileSync } from 'node:child_process'
14
+ import { existsSync } from 'node:fs'
15
+ import { basename, dirname, join } from 'node:path'
16
+ import { findRepoRoot } from '../lib/git.ts'
17
+ import { hasSession, readState, writeState } from '../lib/sidecar.ts'
18
+
19
+ const DEFAULT_OPTIONS: ReadonlyArray<string> = ['a', 'b']
20
+
21
+ export function spike(cwd: string, args: ReadonlyArray<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
+ const positionals = args.filter((a) => !a.startsWith('--'))
28
+ if (positionals[0] === 'done') {
29
+ return spikeDone(root)
30
+ }
31
+ return spikeStart(root, positionals)
32
+ }
33
+
34
+ function spikeStart(root: string, positionals: ReadonlyArray<string>): number {
35
+ const state = readState(root)
36
+ if (state === 'SPIKE') {
37
+ process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n')
38
+ return 1
39
+ }
40
+ if (state !== 'DESIGN') {
41
+ process.stderr.write(
42
+ `plumbbob: spike starts from DESIGN (current state is ${state ?? 'UNKNOWN'}). ` +
43
+ 'A spike is a deliberate fork — close the current step first with `done`.\n',
44
+ )
45
+ return 1
46
+ }
47
+ const slug = sanitize(positionals[0] ?? '')
48
+ if (slug.length === 0) {
49
+ process.stderr.write('plumbbob: spike needs a slug. Try: plumbbob spike "auth-store" a b.\n')
50
+ return 1
51
+ }
52
+ const explicit = positionals.slice(1).map(sanitize).filter((o) => o.length > 0)
53
+ const options = explicit.length > 0 ? explicit : DEFAULT_OPTIONS
54
+
55
+ const created: string[] = []
56
+ for (const opt of options) {
57
+ const path = join(dirname(root), `${basename(root)}-spike-${slug}-${opt}`)
58
+ if (existsSync(path)) {
59
+ process.stderr.write(`plumbbob: ${path} already exists — remove it or run \`plumbbob spike done\` first.\n`)
60
+ return 1
61
+ }
62
+ git(root, ['worktree', 'add', '-b', `spike/${slug}-${opt}`, path, 'HEAD'])
63
+ created.push(path)
64
+ }
65
+
66
+ writeState(root, 'SPIKE')
67
+ process.stdout.write(
68
+ `plumbbob: STATE=SPIKE — the main tree stays DESIGN-locked. Experiment in the throwaway worktrees:\n${created
69
+ .map((p) => ` ${p}`)
70
+ .join('\n')}\nWhen you've decided, record the verdict in intent.md and run \`plumbbob spike done\`.\n`,
71
+ )
72
+ return 0
73
+ }
74
+
75
+ function spikeDone(root: string): number {
76
+ const state = readState(root)
77
+ if (state !== 'SPIKE') {
78
+ process.stderr.write(`plumbbob: no active spike (state is ${state ?? 'UNKNOWN'}).\n`)
79
+ return 1
80
+ }
81
+ for (const path of spikeWorktrees(root)) {
82
+ git(root, ['worktree', 'remove', '--force', path])
83
+ }
84
+ git(root, ['worktree', 'prune'])
85
+ for (const branch of spikeBranches(root)) {
86
+ git(root, ['branch', '-D', branch])
87
+ }
88
+ writeState(root, 'DESIGN')
89
+ process.stdout.write(
90
+ 'plumbbob: spike closed — STATE=DESIGN, worktrees and branches removed. ' +
91
+ 'Record the verdict (which option won, and why) in intent.md before you `build`.\n',
92
+ )
93
+ return 0
94
+ }
95
+
96
+ // Worktree paths whose checked-out branch is under spike/ — parsed from the
97
+ // porcelain output (blank-line-separated `worktree <path>` / `branch <ref>` blocks).
98
+ function spikeWorktrees(root: string): ReadonlyArray<string> {
99
+ const out = git(root, ['worktree', 'list', '--porcelain'])
100
+ const paths: string[] = []
101
+ let current: string | null = null
102
+ for (const line of out.split('\n')) {
103
+ if (line.startsWith('worktree ')) {
104
+ current = line.slice('worktree '.length)
105
+ } else if (current !== null && line.startsWith('branch refs/heads/spike/')) {
106
+ paths.push(current)
107
+ }
108
+ }
109
+ return paths
110
+ }
111
+
112
+ function spikeBranches(root: string): ReadonlyArray<string> {
113
+ const out = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/spike/'])
114
+ return out.length === 0 ? [] : out.split('\n').filter((b) => b.length > 0)
115
+ }
116
+
117
+ function sanitize(raw: string): string {
118
+ return raw
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9]+/g, '-')
121
+ .replace(/^-+|-+$/g, '')
122
+ }
123
+
124
+ function git(root: string, args: ReadonlyArray<string>): string {
125
+ return execFileSync('git', ['-C', root, ...args], {
126
+ encoding: 'utf8',
127
+ stdio: ['ignore', 'pipe', 'inherit'],
128
+ }).trim()
129
+ }
@@ -0,0 +1,103 @@
1
+ // `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, enter
2
+ // DESIGN. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
3
+
4
+ import { fileURLToPath } from 'node:url'
5
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { findRepoRoot, hasCommit, headSha, isDirty } from '../lib/git.ts'
8
+ import {
9
+ sidecarDir,
10
+ checkpointsPath,
11
+ configPath,
12
+ intentPath,
13
+ buildLogPath,
14
+ writeState,
15
+ hasSession,
16
+ excludeSidecar,
17
+ } from '../lib/sidecar.ts'
18
+
19
+ const DEFAULT_CHECK = 'pnpm run check'
20
+
21
+ export function start(cwd: string, args: ReadonlyArray<string>): number {
22
+ const positionals = args.filter((a) => !a.startsWith('--'))
23
+ const allowDirty = args.includes('--allow-dirty')
24
+ const title = (positionals[0] ?? '').trim()
25
+
26
+ if (title.length === 0) {
27
+ process.stderr.write('plumbbob: start needs a title. Try: plumbbob start "what you are building".\n')
28
+ return 1
29
+ }
30
+
31
+ const root = findRepoRoot(cwd)
32
+ if (root === null) {
33
+ process.stderr.write(
34
+ 'plumbbob: not a git repository. Plumbbob records a baseline commit — run `git init` and make an initial commit first.\n',
35
+ )
36
+ return 1
37
+ }
38
+ if (!hasCommit(root)) {
39
+ process.stderr.write(
40
+ 'plumbbob: this repository has no commits yet. Make an initial commit so `start` can record a baseline.\n',
41
+ )
42
+ return 1
43
+ }
44
+ if (hasSession(root)) {
45
+ process.stderr.write(
46
+ 'plumbbob: a session is already active here. Run `plumbbob finish` to close it before starting another.\n',
47
+ )
48
+ return 1
49
+ }
50
+ if (isDirty(root)) {
51
+ if (!allowDirty) {
52
+ process.stderr.write(
53
+ 'plumbbob: the working tree is dirty. Commit or stash first, or `plumbbob start --allow-dirty "<title>"` to record the current HEAD as the baseline.\n',
54
+ )
55
+ return 1
56
+ }
57
+ process.stderr.write(
58
+ 'plumbbob: WARNING --allow-dirty: recording HEAD as baseline with a dirty tree. A later revert-to-baseline will DISCARD the uncommitted work.\n',
59
+ )
60
+ }
61
+
62
+ const sha = headSha(root)
63
+ const check = detectCheck(root)
64
+
65
+ mkdirSync(sidecarDir(root), { recursive: true })
66
+ writeState(root, 'DESIGN')
67
+ writeFileSync(checkpointsPath(root), `baseline ${sha}\n`)
68
+ writeFileSync(configPath(root), `check=${check.command}\n`)
69
+ writeFileSync(intentPath(root), stamp(readTemplate('intent.md'), title, check.command))
70
+ writeFileSync(buildLogPath(root), stamp(readTemplate('build-log.md'), title, check.command))
71
+ excludeSidecar(root)
72
+
73
+ if (check.warn) {
74
+ process.stderr.write(
75
+ `plumbbob: WARNING the heavy check '${check.command}' is not defined in this repo's package.json. Edit .plumbbob/config (check=...) to set the real gate before \`review\`/\`done\`.\n`,
76
+ )
77
+ }
78
+ process.stdout.write(
79
+ `plumbbob: started "${title}" — STATE=DESIGN, baseline ${sha.slice(0, 9)}. Frame and decide in .plumbbob/intent.md; flip to BUILD only once the decisions are made.\n`,
80
+ )
81
+ return 0
82
+ }
83
+
84
+ // D24: default the heavy check to `pnpm run check`; warn (but still record it)
85
+ // when the target repo has no such script, so a non-pnpm repo gets a clear nudge.
86
+ function detectCheck(root: string): { readonly command: string; readonly warn: boolean } {
87
+ try {
88
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
89
+ scripts?: Record<string, string>
90
+ }
91
+ return { command: DEFAULT_CHECK, warn: pkg.scripts?.check === undefined }
92
+ } catch {
93
+ return { command: DEFAULT_CHECK, warn: true }
94
+ }
95
+ }
96
+
97
+ function readTemplate(name: string): string {
98
+ return readFileSync(fileURLToPath(new URL(`../../templates/${name}`, import.meta.url)), 'utf8')
99
+ }
100
+
101
+ function stamp(template: string, title: string, check: string): string {
102
+ return template.split('{{TITLE}}').join(title).split('{{CHECK}}').join(check)
103
+ }
@@ -0,0 +1,15 @@
1
+ // `plumbbob status` — print the session state, or NO ACTIVE SESSION. Read-only,
2
+ // always exits 0. Skills pre-inject this output to gate their own behavior.
3
+
4
+ import { findRepoRoot } from '../lib/git.ts'
5
+ import { hasSession, readState } from '../lib/sidecar.ts'
6
+
7
+ export function status(cwd: string): number {
8
+ const root = findRepoRoot(cwd)
9
+ if (root === null || !hasSession(root)) {
10
+ process.stdout.write('NO ACTIVE SESSION\n')
11
+ return 0
12
+ }
13
+ process.stdout.write(`STATE: ${readState(root) ?? 'UNKNOWN'}\n`)
14
+ return 0
15
+ }
@@ -0,0 +1,34 @@
1
+ // `plumbbob wrap` (D19/D28) — the FINISH-entry verb. Sets STATE=FINISH so
2
+ // /plumbbob-report can write report.md and /plumbbob-docs can touch docs/.
3
+ // `finish` stays the closing gate; wrap just opens the one state where
4
+ // documentation may be projected. A transition verb, so dispatch refuses it under
5
+ // CLAUDECODE (D21).
6
+
7
+ import { findRepoRoot } from '../lib/git.ts'
8
+ import { hasSession, readState, writeState } from '../lib/sidecar.ts'
9
+
10
+ export function wrap(cwd: 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
+ const state = readState(root)
17
+ if (state === 'FINISH') {
18
+ process.stdout.write('plumbbob: already in FINISH. Run `/plumbbob-report`, then `plumbbob finish` to close.\n')
19
+ return 0
20
+ }
21
+ if (state !== 'DESIGN') {
22
+ process.stderr.write(
23
+ `plumbbob: wrap enters FINISH from DESIGN (current state is ${state ?? 'UNKNOWN'}). ` +
24
+ 'Close the current step first — `done` from BUILD/REVIEW, or `spike done` from SPIKE.\n',
25
+ )
26
+ return 1
27
+ }
28
+ writeState(root, 'FINISH')
29
+ process.stdout.write(
30
+ 'plumbbob: STATE=FINISH. Now `/plumbbob-report` writes the report (and `/plumbbob-docs` may touch docs/); ' +
31
+ 'then `plumbbob finish` archives and closes.\n',
32
+ )
33
+ return 0
34
+ }
@@ -0,0 +1,54 @@
1
+ <!--
2
+ build-log.md — your live ledger for execution. Append constantly; reorganize at
3
+ step boundaries. The antidote to "my plan got lost in the noise."
4
+
5
+ Steps : where you are. One step in flight at a time.
6
+ Park list : where ideas go so you do not chase them. CAPTURE, never act inline.
7
+ Triage : the boundary ritual that keeps you on one branch.
8
+ Log : the audit trail. Feeds /plumbbob-report, then gets archived.
9
+ -->
10
+
11
+ # Build log — {{TITLE}}
12
+
13
+ **Current step:** none (DESIGN) · **STATE:** DESIGN
14
+ **Heavy check:** {{CHECK}}
15
+
16
+ ## Steps
17
+
18
+ *(Mirror of intent.md's Steps, with live status. Only ONE step is in flight. A step
19
+ is done only after `plumbbob done` — check green + checkpoint taken.)*
20
+
21
+ - ☐ 1. <step>
22
+
23
+ ## Park list
24
+
25
+ > Mid-step, every new problem / idea / "ooh what if" lands HERE, untouched, and you
26
+ > go straight back to the step. Acting the instant an idea arrives is the disease.
27
+ > Capture is one line (`/park` composes it, or raw `plumbbob park`). Triage happens
28
+ > only at the boundary.
29
+
30
+ ## Triage *(run at each step boundary, after green)*
31
+
32
+ Classify each parked item as exactly ONE. Naming it before acting is what keeps you
33
+ from sprawling across branches.
34
+
35
+ | Class | Meaning | Action |
36
+ |------------------|-------------------------------------------|-------------------------------|
37
+ | **blocker** | Plan was wrong/incomplete; can't proceed | `revert`, fold into intent.md |
38
+ | **tangent** | A different path, not clearly better | Defer or kill. Default here. |
39
+ | **pivot signal** | Evidence the whole approach is wrong | Stop. Replan deliberately. |
40
+
41
+ > Reality check: almost everything that *feels* like a pivot is a tangent. Require a
42
+ > failed assumption, not a shinier idea, before you pivot.
43
+
44
+ Triage results this boundary:
45
+
46
+ - (none yet)
47
+
48
+ ## Log
49
+
50
+ *(Append-only. One decision or event per line, dated. What you point at to say "I
51
+ did that — the LLM helped, but those were my calls." `/plumbbob-report` reads this;
52
+ `plumbbob finish` archives it under `.plumbbob/archive/`.)*
53
+
54
+ - <date> — <decision / event / what shipped this step>
@@ -0,0 +1,67 @@
1
+ <!--
2
+ intent.md — your canonical intent, written in DESIGN before any code. This is the
3
+ head; the chat is the hand. When the model floods you, read this, not your memory.
4
+
5
+ Size to the work: a small change fills Frame + a couple of Decisions and deletes
6
+ the rest; a medium feature fills it all. Opinionated where decided, explicit where
7
+ open. If the implementor (you-later, or the LLM) has to guess, the doc failed.
8
+ -->
9
+
10
+ # {{TITLE}}
11
+
12
+ **STATE:** DESIGN
13
+ **Phase** (bookkeeping while in DESIGN): frame
14
+ **Size:** tiny | small | medium
15
+
16
+ ## Frame
17
+
18
+ *(You, on paper first. The problem in plain words — before any solution.)*
19
+
20
+ - **Problem:** <what is wrong or missing, and why it matters>
21
+ - **Smallest thing that solves it:** <the minimal change, not the ideal system>
22
+ - **Done looks like:** <the observable, checkable outcome>
23
+ - **Explicitly NOT doing:** <scope you are refusing, so it cannot creep in>
24
+
25
+ ## Architecture sketch
26
+
27
+ *(Hand-drawn is best. Photograph it in, or describe the boxes and arrows.)*
28
+
29
+ ```
30
+ <ascii, or a link to the paper sketch>
31
+ ```
32
+
33
+ ## Decisions
34
+
35
+ *(One line each. Settled, not re-litigated in the chat. Grows as you resolve the
36
+ holes the interrogation surfaces, and as blockers fold in during BUILD.)*
37
+
38
+ - D1: <decision> — *because* <the one reason that mattered>
39
+
40
+ ## Constraints
41
+
42
+ *(Hard rules the build must honor. Triage and review read against these.)*
43
+
44
+ - C1: <e.g. functional/procedural only; no new dependencies>
45
+
46
+ ## Steps
47
+
48
+ *(The build plan. Each step carries its own seam — the paths `plumbbob build <n>`
49
+ writes into `.plumbbob/SEAM` and the seam-guard enforces. Keep each step small
50
+ enough to verify in one review pass. A blocker may rewrite a step's seam: fold the
51
+ new decision, revise the seam here, then `build <n>` again.)*
52
+
53
+ 1. [ ] <step> — **done when:** <criterion, ideally a test or check result>
54
+ - seam: `<file>`, `<file>`
55
+
56
+ ## Open questions
57
+
58
+ *(Holes you could NOT resolve on paper. Do not guess them into Decisions. A genuine
59
+ fork goes to a SPIKE; record the verdict below and in Decisions.)*
60
+
61
+ - Q1: <unresolved> — *resolve by:* decide | spike | ask
62
+
63
+ ## Verdicts
64
+
65
+ *(Filled in as spikes and forks resolve — the audit trail of "these were my calls.")*
66
+
67
+ - <date> — <fork> → chose <option> because <reason>; deleted <the rest>