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,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plumbbob-interrogate
|
|
3
|
+
description: DESIGN-phase frame interrogation — attack the plan for holes and append them as Open questions, without deciding anything.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Edit, Bash(plumbbob status:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — interrogate the frame
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`plumbbob status`
|
|
12
|
+
|
|
13
|
+
## Wrong-state refusal
|
|
14
|
+
|
|
15
|
+
This skill runs in **DESIGN only**. Read the state injected above and **stop before touching anything** if it is not `DESIGN`:
|
|
16
|
+
|
|
17
|
+
- `STATE: BUILD` or `STATE: REVIEW` — you are mid-step. Return to DESIGN first: finish the step with `plumbbob done`, or drop a half-built step with `plumbbob revert`. Then re-invoke.
|
|
18
|
+
- `STATE: SPIKE` — close the experiment with `plumbbob spike done` first.
|
|
19
|
+
- `STATE: FINISH` — the session is wrapping up; there is nothing left to interrogate.
|
|
20
|
+
- `NO ACTIVE SESSION` — start one with `plumbbob start "<title>"`.
|
|
21
|
+
|
|
22
|
+
When the state is wrong, refuse in one line naming the verb above, and edit nothing.
|
|
23
|
+
|
|
24
|
+
## What this skill does
|
|
25
|
+
|
|
26
|
+
Hand the frame (the **Frame** and **Architecture sketch** in `intent.md`) a cold, adversarial read and **attack it for holes** — ambiguities, unhandled edge cases, hidden assumptions, and collisions with the existing code. This is the one place early divergence is wanted, and it is divergence in the **problem space only**: surface what is unclear, never propose a solution and never pick between options.
|
|
27
|
+
|
|
28
|
+
## The one hard contract
|
|
29
|
+
|
|
30
|
+
- Append every hole as a bullet under `intent.md`'s `## Open questions` section, and **nowhere else**.
|
|
31
|
+
- **Never append to `## Decisions`** — resolving a hole is the human's convergence, not yours (D13). You surface; you do not decide.
|
|
32
|
+
- One question per hole, phrased as a question, each standing on its own.
|
|
33
|
+
- Once the Open questions are appended, **end your turn**. Do not answer them, do not start the next step, do not slide from interrogating into deciding.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plumbbob-report
|
|
3
|
+
description: FINISH-phase report — synthesize intent + build-log into exactly .plumbbob/report.md with the five required sections.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Write, Bash(plumbbob status:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — write the report
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`plumbbob status`
|
|
12
|
+
|
|
13
|
+
## Wrong-state refusal
|
|
14
|
+
|
|
15
|
+
This skill writes the report **in FINISH only**. Read the state injected above:
|
|
16
|
+
|
|
17
|
+
- `STATE: FINISH` — proceed.
|
|
18
|
+
- Any other active state (`DESIGN`, `BUILD`, `REVIEW`, `SPIKE`) — **stop and tell the human to run `plumbbob wrap` first** (D28). Entering FINISH is what unlocks writing `report.md`, and this skill is the thing that tells you to wrap.
|
|
19
|
+
- `NO ACTIVE SESSION` — there is nothing to report; start a session with `plumbbob start "<title>"`.
|
|
20
|
+
|
|
21
|
+
## What this skill does
|
|
22
|
+
|
|
23
|
+
The first of the three Finish steps. Read `intent.md` and `build-log.md` and synthesize the conclusion — the "yeah, I did that" artifact — into **exactly one file: `.plumbbob/report.md`**. Write nothing else, and touch no other path.
|
|
24
|
+
|
|
25
|
+
## Required sections
|
|
26
|
+
|
|
27
|
+
`.plumbbob/report.md` must contain these five sections, in order:
|
|
28
|
+
|
|
29
|
+
1. **What shipped** — what this session actually built, measured against the Frame's "done looks like".
|
|
30
|
+
2. **The decisions and why** — the decisions taken (the `D#` ledger) and the reasoning behind each.
|
|
31
|
+
3. **Parked items and how each was triaged** — every Park-list item and its triage outcome (blocker / tangent / pivot signal).
|
|
32
|
+
4. **Final status** — done, partial, or abandoned, with the checkpoint SHAs.
|
|
33
|
+
5. **Deferred tangents (future Plumbbobs)** — the tangents worth their own future session.
|
|
34
|
+
|
|
35
|
+
Once `report.md` exists, the human runs `plumbbob finish` (the closing gate refuses without it) to archive and clear the session.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plumbbob-triage
|
|
3
|
+
description: DESIGN-phase, step-boundary triage — propose one class per parked item, write only after the human confirms each.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
model: opus
|
|
6
|
+
allowed-tools: Read, Edit, Bash(plumbbob status:*)
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Plumbbob — triage the park list
|
|
10
|
+
|
|
11
|
+
Current session state (injected when this skill runs): !`plumbbob status`
|
|
12
|
+
|
|
13
|
+
## Wrong-state refusal
|
|
14
|
+
|
|
15
|
+
This skill runs in **DESIGN only**, at a **step boundary** (after the step's check has gone green). Read the state injected above and **stop before writing anything** if it is not `DESIGN`:
|
|
16
|
+
|
|
17
|
+
- `STATE: BUILD` or `STATE: REVIEW` — finish the step first with `plumbbob done`, which returns you to DESIGN; then triage.
|
|
18
|
+
- `STATE: SPIKE` — close the experiment with `plumbbob spike done` first.
|
|
19
|
+
- `STATE: FINISH` — too late to triage; the report skill folds the park list in instead.
|
|
20
|
+
- `NO ACTIVE SESSION` — start one with `plumbbob start "<title>"`.
|
|
21
|
+
|
|
22
|
+
When the state is wrong, refuse in one line naming the verb above.
|
|
23
|
+
|
|
24
|
+
## What this skill does
|
|
25
|
+
|
|
26
|
+
Walk the **Park list** in `build-log.md` item by item and, for each, **propose exactly one class**:
|
|
27
|
+
|
|
28
|
+
- **blocker** — the plan was wrong or incomplete; can't proceed. Folds into `intent.md` and is handled now.
|
|
29
|
+
- **tangent** — a different path, not clearly better. **The default** — defer or kill.
|
|
30
|
+
- **pivot signal** — real evidence the whole approach is wrong. Stop and replan deliberately.
|
|
31
|
+
|
|
32
|
+
## The one hard contract
|
|
33
|
+
|
|
34
|
+
You **propose**; the **human calls it** (D13). For each item, state your proposed class and one line of reasoning, then **wait for the human to confirm or override**. Write **only after** per-item confirmation:
|
|
35
|
+
|
|
36
|
+
- Record each confirmed class in `build-log.md`'s `## Triage` section.
|
|
37
|
+
- A confirmed **blocker** also folds its decision into `intent.md`.
|
|
38
|
+
- Never reclassify or resolve an item the human hasn't confirmed, and default every uncertain item to **tangent**, never to blocker.
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plumbbob CLI — hand-rolled argv dispatch, zero runtime deps, node: builtins
|
|
3
|
+
// only (D1/C2). Functional/procedural: no classes, no `this`, no default
|
|
4
|
+
// export (C1). Verbs are wired up build step by build step.
|
|
5
|
+
|
|
6
|
+
import { start } from './verbs/start.ts'
|
|
7
|
+
import { status } from './verbs/status.ts'
|
|
8
|
+
import { mode } from './verbs/mode.ts'
|
|
9
|
+
import { park } from './verbs/park.ts'
|
|
10
|
+
import { build } from './verbs/build.ts'
|
|
11
|
+
import { review } from './verbs/review.ts'
|
|
12
|
+
import { done } from './verbs/done.ts'
|
|
13
|
+
import { revert } from './verbs/revert.ts'
|
|
14
|
+
import { spike } from './verbs/spike.ts'
|
|
15
|
+
import { wrap } from './verbs/wrap.ts'
|
|
16
|
+
import { finish } from './verbs/finish.ts'
|
|
17
|
+
import { setup } from './verbs/setup.ts'
|
|
18
|
+
|
|
19
|
+
type Verb = {
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly summary: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VERBS: ReadonlyArray<Verb> = [
|
|
25
|
+
{ name: 'start', summary: 'scaffold .plumbbob/; STATE=DESIGN; record the baseline commit' },
|
|
26
|
+
{ name: 'status', summary: 'print the session state, or NO ACTIVE SESSION' },
|
|
27
|
+
{ name: 'build', summary: 'build <n>: write SEAM from step n; STATE=BUILD' },
|
|
28
|
+
{ name: 'review', summary: 'run the heavy check; if green flip to STATE=REVIEW' },
|
|
29
|
+
{ name: 'done', summary: 'ensure check green; checkpoint commit + record SHA; STATE=DESIGN' },
|
|
30
|
+
{ name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA; STATE=DESIGN' },
|
|
31
|
+
{ name: 'park', summary: 'park "<text>": append a raw line to the park list' },
|
|
32
|
+
{ name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
|
|
33
|
+
{ name: 'wrap', summary: 'set STATE=FINISH so /plumbbob-report and /plumbbob-docs can run' },
|
|
34
|
+
{ name: 'finish', summary: 'refuse unless a report is archived; archive; clear; muzzle off' },
|
|
35
|
+
{ name: 'mode', summary: 'mode <x>: set STATE directly (hidden escape hatch)' },
|
|
36
|
+
{ name: 'setup', summary: 'install hooks + skills; register them (default global; --project / --local per D27)' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
// D21: deciding/transition verbs are human-only. In a Claude Code session
|
|
40
|
+
// (CLAUDECODE set) the dispatch refuses them so the model cannot drive a state
|
|
41
|
+
// transition. `park` and `status` are the deliberate exceptions — dumb capture
|
|
42
|
+
// and read-only inspection are model-safe.
|
|
43
|
+
const TRANSITION_VERBS: ReadonlySet<string> = new Set([
|
|
44
|
+
'start',
|
|
45
|
+
'build',
|
|
46
|
+
'review',
|
|
47
|
+
'done',
|
|
48
|
+
'revert',
|
|
49
|
+
'spike',
|
|
50
|
+
'wrap',
|
|
51
|
+
'finish',
|
|
52
|
+
'mode',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
function formatHelp(): string {
|
|
56
|
+
const width = Math.max(...VERBS.map((v) => v.name.length))
|
|
57
|
+
const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`)
|
|
58
|
+
return ['plumbbob — attention-first build process', '', 'Usage: plumbbob <verb> [args]', '', 'Verbs:', ...rows, ''].join(
|
|
59
|
+
'\n',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dispatch(verb: string, cwd: string, rest: ReadonlyArray<string>): number {
|
|
64
|
+
switch (verb) {
|
|
65
|
+
case 'start':
|
|
66
|
+
return start(cwd, rest)
|
|
67
|
+
case 'status':
|
|
68
|
+
return status(cwd)
|
|
69
|
+
case 'mode':
|
|
70
|
+
return mode(cwd, rest)
|
|
71
|
+
case 'park':
|
|
72
|
+
return park(cwd, rest)
|
|
73
|
+
case 'build':
|
|
74
|
+
return build(cwd, rest)
|
|
75
|
+
case 'review':
|
|
76
|
+
return review(cwd)
|
|
77
|
+
case 'done':
|
|
78
|
+
return done(cwd)
|
|
79
|
+
case 'revert':
|
|
80
|
+
return revert(cwd, rest)
|
|
81
|
+
case 'spike':
|
|
82
|
+
return spike(cwd, rest)
|
|
83
|
+
case 'wrap':
|
|
84
|
+
return wrap(cwd)
|
|
85
|
+
case 'finish':
|
|
86
|
+
return finish(cwd)
|
|
87
|
+
case 'setup':
|
|
88
|
+
return setup(cwd, rest)
|
|
89
|
+
default:
|
|
90
|
+
process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`)
|
|
91
|
+
return 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function run(argv: ReadonlyArray<string>): number {
|
|
96
|
+
const verb = argv[0] ?? 'help'
|
|
97
|
+
const rest = argv.slice(1)
|
|
98
|
+
|
|
99
|
+
if (verb === 'help' || verb === '--help' || verb === '-h') {
|
|
100
|
+
process.stdout.write(`${formatHelp()}\n`)
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (TRANSITION_VERBS.has(verb) && process.env.CLAUDECODE) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`plumbbob: '${verb}' is a deciding verb — only the human runs it (you appear to be in a Claude Code session). ` +
|
|
107
|
+
`Do not retry. Ask the human to run \`plumbbob ${verb}\` in their terminal.\n`,
|
|
108
|
+
)
|
|
109
|
+
return 1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
return dispatch(verb, process.cwd(), rest)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
116
|
+
process.stderr.write(`plumbbob: ${verb} failed: ${message}\n`)
|
|
117
|
+
return 1
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.exit(run(process.argv.slice(2)))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The finish-phase archive helper (D20: archive is local-only markdown in v1).
|
|
2
|
+
// `finish` copies the three active files into .plumbbob/archive/<date>-<slug>/
|
|
3
|
+
// before clearing the actives — "archive-then-clear, never destroy" (C4).
|
|
4
|
+
// Functional/procedural, node builtins only (C1/C2).
|
|
5
|
+
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { sidecarDir, intentPath, buildLogPath } from './sidecar.ts'
|
|
9
|
+
|
|
10
|
+
// report.md lives beside intent.md / build-log.md in the sidecar. It is written by
|
|
11
|
+
// /plumbbob-report and is the gate `finish` refuses without (D19). Derived from
|
|
12
|
+
// the exported sidecarDir so this module needs nothing new from sidecar.ts.
|
|
13
|
+
export function reportPath(root: string): string {
|
|
14
|
+
return join(sidecarDir(root), 'report.md')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function archiveRoot(root: string): string {
|
|
18
|
+
return join(sidecarDir(root), 'archive')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A filesystem-safe slug from arbitrary title text: lowercased, runs of
|
|
22
|
+
// non-alphanumerics collapsed to a single hyphen, ends trimmed. Empty → `session`.
|
|
23
|
+
function slugify(title: string): string {
|
|
24
|
+
const slug = title
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '')
|
|
28
|
+
return slug.length === 0 ? 'session' : slug
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The session title is intent.md's first `# ` heading (start stamps `# <title>`).
|
|
32
|
+
function sessionTitle(root: string): string {
|
|
33
|
+
let content = ''
|
|
34
|
+
try {
|
|
35
|
+
content = readFileSync(intentPath(root), 'utf8')
|
|
36
|
+
} catch {
|
|
37
|
+
return 'session'
|
|
38
|
+
}
|
|
39
|
+
for (const line of content.split('\n')) {
|
|
40
|
+
const m = /^#\s+(.+)$/.exec(line)
|
|
41
|
+
if (m) {
|
|
42
|
+
return (m[1] ?? '').trim()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return 'session'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Today as YYYY-MM-DD (UTC); the archive directory is <date>-<slug>.
|
|
49
|
+
function today(): string {
|
|
50
|
+
return new Date().toISOString().slice(0, 10)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Choose <date>-<slug>, disambiguating with -2, -3, … if a same-day, same-slug
|
|
54
|
+
// session was already archived — so a second session lands ALONGSIDE the first,
|
|
55
|
+
// never on top of it.
|
|
56
|
+
function uniqueArchiveDir(root: string, base: string): string {
|
|
57
|
+
let candidate = join(archiveRoot(root), base)
|
|
58
|
+
let n = 2
|
|
59
|
+
while (existsSync(candidate)) {
|
|
60
|
+
candidate = join(archiveRoot(root), `${base}-${n}`)
|
|
61
|
+
n += 1
|
|
62
|
+
}
|
|
63
|
+
return candidate
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Copy intent + build-log + report into archive/<date>-<slug>/ and return the
|
|
67
|
+
// directory created. The report must already exist (finish guards that); intent
|
|
68
|
+
// and build-log always exist in an active session.
|
|
69
|
+
export function archiveSession(root: string): string {
|
|
70
|
+
const dir = uniqueArchiveDir(root, `${today()}-${slugify(sessionTitle(root))}`)
|
|
71
|
+
mkdirSync(dir, { recursive: true })
|
|
72
|
+
copyFileSync(intentPath(root), join(dir, 'intent.md'))
|
|
73
|
+
copyFileSync(buildLogPath(root), join(dir, 'build-log.md'))
|
|
74
|
+
copyFileSync(reportPath(root), join(dir, 'report.md'))
|
|
75
|
+
return dir
|
|
76
|
+
}
|
package/src/lib/check.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
}
|