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,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
+ }
@@ -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
+ }