plumbbob 0.1.3 → 0.2.1
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/README.md +69 -26
- package/dist/cli.js +99 -0
- package/dist/lib/archive.js +69 -0
- package/dist/lib/check.js +26 -0
- package/dist/lib/git.js +62 -0
- package/dist/lib/intent.js +88 -0
- package/dist/lib/settings.js +79 -0
- package/dist/lib/sidecar.js +69 -0
- package/dist/verbs/build.js +30 -0
- package/dist/verbs/done.js +63 -0
- package/dist/verbs/finish.js +54 -0
- package/dist/verbs/mode.js +26 -0
- package/dist/verbs/park.js +51 -0
- package/dist/verbs/revert.js +96 -0
- package/dist/verbs/review.js +24 -0
- package/dist/verbs/setup.js +141 -0
- package/dist/verbs/spike.js +113 -0
- package/dist/verbs/start.js +68 -0
- package/dist/verbs/status.js +13 -0
- package/dist/verbs/wrap.js +28 -0
- package/hooks/bash-guard.sh +5 -1
- package/package.json +6 -4
- package/skills/park/SKILL.md +4 -4
- package/skills/pb-build/SKILL.md +19 -0
- package/skills/pb-done/SKILL.md +18 -0
- package/skills/pb-finish/SKILL.md +18 -0
- package/skills/pb-revert/SKILL.md +19 -0
- package/skills/pb-review/SKILL.md +18 -0
- package/skills/pb-spike/SKILL.md +19 -0
- package/skills/pb-start/SKILL.md +19 -0
- package/skills/pb-wrap/SKILL.md +18 -0
- package/skills/plumbbob-docs/SKILL.md +2 -2
- package/skills/plumbbob-interrogate/SKILL.md +2 -2
- package/skills/plumbbob-report/SKILL.md +2 -2
- package/skills/plumbbob-triage/SKILL.md +2 -2
- package/src/cli.ts +0 -121
- package/src/lib/archive.ts +0 -76
- package/src/lib/check.ts +0 -29
- package/src/lib/git.ts +0 -73
- package/src/lib/intent.ts +0 -103
- package/src/lib/settings.ts +0 -90
- package/src/lib/sidecar.ts +0 -82
- package/src/verbs/build.ts +0 -38
- package/src/verbs/done.ts +0 -71
- package/src/verbs/finish.ts +0 -72
- package/src/verbs/mode.ts +0 -27
- package/src/verbs/park.ts +0 -56
- package/src/verbs/revert.ts +0 -110
- package/src/verbs/review.ts +0 -30
- package/src/verbs/setup.ts +0 -95
- package/src/verbs/spike.ts +0 -129
- package/src/verbs/start.ts +0 -103
- package/src/verbs/status.ts +0 -15
- package/src/verbs/wrap.ts +0 -34
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
|
-
}
|
package/src/lib/settings.ts
DELETED
|
@@ -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
|
-
}
|
package/src/lib/sidecar.ts
DELETED
|
@@ -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
|
-
}
|
package/src/verbs/build.ts
DELETED
|
@@ -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
|
-
}
|
package/src/verbs/finish.ts
DELETED
|
@@ -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
|
-
}
|