plumbbob 0.10.3 → 0.11.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.10.3",
4
+ "version": "0.11.0",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/dist/cli-core.js CHANGED
@@ -79,11 +79,15 @@ const VERBS = [
79
79
  },
80
80
  {
81
81
  name: 'handoff',
82
- description: 'print the standardized build hand-off block (pause or post-checkpoint); read-only',
83
- synopsis: ['handoff [<n>] [--build <slug>]'],
82
+ description: 'print the footer card that ends a turn (pause, boundary, plan, or driver); read-only',
83
+ synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
84
84
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
85
- flags: [BUILD_FLAG],
86
- notes: 'Read-only; refuses (exit 1) only with no session.',
85
+ flags: [
86
+ { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
87
+ { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
88
+ BUILD_FLAG,
89
+ ],
90
+ notes: 'Read-only; refuses (exit 1) with no session, or with --plan and --driver together.',
87
91
  },
88
92
  {
89
93
  name: 'check',
@@ -267,7 +267,7 @@ export function resolveAgent(root, name, opts = {}) {
267
267
  }
268
268
  return {
269
269
  ok: false,
270
- error: `no agent named "${name}" — looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}.`,
270
+ error: `no agent named "${name}", looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}`,
271
271
  };
272
272
  }
273
273
  /**
@@ -610,7 +610,7 @@ function parseChildEnvelope(stdout) {
610
610
  raw = JSON.parse(stdout);
611
611
  }
612
612
  catch {
613
- return { ok: false, error: 'the agent wrote non-JSON to stdout — the envelope must be a single JSON object.' };
613
+ return { ok: false, error: 'the agent wrote non-JSON to stdout, so the envelope is not a single JSON object' };
614
614
  }
615
615
  return parseEnvelope(raw);
616
616
  }
package/dist/lib/git.js CHANGED
@@ -76,19 +76,74 @@ export function isDirty(root) {
76
76
  * commits are surfaced, never blocked). `--first-parent` keeps the count on
77
77
  * the branch's own line: merging upstream reads as the one merge commit, not
78
78
  * the dozens it carried; those didn't land "outside the ledger" in any sense
79
- * the receipt should nag about. Best-effort and never throws: 0 when the range
80
- * is empty, `sha` is unknown to the repo, or HEAD is unborn: the count is
81
- * informational (the human commits freely), never a gate.
79
+ * the receipt should nag about. `excluding` is a commit-message pattern whose
80
+ * matches are left out of the count, which is how a caller drops plumbbob's own
81
+ * commits from a tally of what landed around them.
82
+ *
83
+ * Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
84
+ * the repo, or HEAD is unborn: the count is informational (the human commits
85
+ * freely), never a gate.
82
86
  */
83
- export function commitsSince(root, sha) {
87
+ export function commitsSince(root, sha, excluding) {
84
88
  try {
85
- const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
89
+ const skip = excluding === undefined ? [] : ['--grep', excluding, '--invert-grep'];
90
+ const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', ...skip, `${sha}..HEAD`]), 10);
86
91
  return Number.isFinite(n) && n > 0 ? n : 0;
87
92
  }
88
93
  catch {
89
94
  return 0;
90
95
  }
91
96
  }
97
+ /**
98
+ * The working tree's changes against the index, one entry per file, as
99
+ * `git diff --numstat` reports them; a binary file counts 0/0.
100
+ *
101
+ * This feeds the recap's `diff` and `seam` rows at the pause: information,
102
+ * never a gate, so it is best-effort and never throws ([] on any failure).
103
+ */
104
+ export function diffNumstat(root) {
105
+ let out;
106
+ try {
107
+ out = runGit(root, ['diff', '--numstat']);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ if (out.length === 0) {
113
+ return [];
114
+ }
115
+ return out.split('\n').map((line) => {
116
+ const [added, removed, ...path] = line.split('\t');
117
+ return { added: toCount(added), removed: toCount(removed), path: path.join('\t') };
118
+ });
119
+ }
120
+ /**
121
+ * A numstat count field as a number: `-` (a binary file) and anything else
122
+ * unparseable read as 0.
123
+ */
124
+ function toCount(field) {
125
+ const n = Number.parseInt(field ?? '', 10);
126
+ return Number.isFinite(n) && n >= 0 ? n : 0;
127
+ }
128
+ /**
129
+ * The raw `git diff` patch for `paths` (working tree against the index), or ''
130
+ * when nothing differs or git refuses.
131
+ *
132
+ * Only the pause's inline-diff fence reads this, and only after the numstat
133
+ * count came in at 20 changed lines or fewer, so the patch is always small.
134
+ * Best-effort like diffNumstat: information, never a gate.
135
+ */
136
+ export function diffPatch(root, paths) {
137
+ if (paths.length === 0) {
138
+ return '';
139
+ }
140
+ try {
141
+ return runGit(root, ['diff', '--', ...paths]);
142
+ }
143
+ catch {
144
+ return '';
145
+ }
146
+ }
92
147
  // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
93
148
  // only: stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
94
149
  /**
@@ -0,0 +1,140 @@
1
+ // The one renderer for every line plumbbob says out loud and a skill relays,
2
+ // and the assembly that stacks those lines into an ending. It has three heads,
3
+ // and the ending picks which: a transition's lead line goes to stdout wearing a
4
+ // bold label, like every other part of the ending
5
+ // (`**Checkpoint**: Step 15 complete (2d917cde7)`); an advisory riding that
6
+ // ending goes to stdout too, as a bare sentence, since a block with one speaker
7
+ // needs no one named; and a refusal goes to stderr keeping the `plumbbob:`
8
+ // prefix, which earns its colon where checkride's output and git's share one
9
+ // result. Under every head the shape is the same: the fact as one clause, the
10
+ // detail a trailing parenthetical that degrades by count instead of running off
11
+ // the edge, and an optional indented arrow line carrying what to do next. One
12
+ // shape, so moving it is one edit rather than a sweep. The pointer at the next
13
+ // step is not a line's job: `handoff` renders one, and the verb prints it as
14
+ // the ending's last part. Pure string assembly, no fs, no gate.
15
+ /** The column budget a notice line aims to fit, matching the readout fence. */
16
+ const WIDTH = 80;
17
+ /** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
18
+ const FLOOR = 2;
19
+ /**
20
+ * Render one prefixed notice, newline included.
21
+ *
22
+ * The refusal head, and the head for a warning that joins no ending (an
23
+ * ambient agent that could not resolve, say):
24
+ *
25
+ * ```text
26
+ * plumbbob: no build named "auth" in .plumbbob/builds/ (rate-limit, logging)
27
+ * → plumbbob use <slug>
28
+ * ```
29
+ */
30
+ export function notice(n) {
31
+ return line(`${n.prefix ?? 'plumbbob'}: ${trimFact(n.fact)}${n.advisory === true ? ' ⚠' : ''}`, n);
32
+ }
33
+ /**
34
+ * Render one transition, newline included.
35
+ *
36
+ * The lead line of an ending, wearing a bold label so it reads as a part of the
37
+ * ending rather than as a second grammar beside it:
38
+ *
39
+ * ```text
40
+ * **Checkpoint**: Step 15 complete (2d917cde7)
41
+ * **Parked**: should /password-reset get the same throttle? (tangent)
42
+ * ```
43
+ *
44
+ * A transition never warns: an advisory qualifies the line and prints beneath
45
+ * it, on the same stream, as its own part of the ending.
46
+ */
47
+ export function transition(t) {
48
+ return line(`**${t.label}**: ${trimFact(t.fact)}`, t);
49
+ }
50
+ /**
51
+ * Render one advisory of an ending, newline included.
52
+ *
53
+ * No prefix and no label: it rides stdout inside a block that has one speaker
54
+ * already, so it reads as the sentence it is, the warning glyph trailing the
55
+ * fact and the remedy on the line beneath.
56
+ *
57
+ * ```text
58
+ * Staged paths reach outside Step 16's seam ⚠ (test/integration/spike.test.ts)
59
+ * → the checkpoint captures them, so revise the plan with /plumbbob:step
60
+ * ```
61
+ */
62
+ export function advisory(a) {
63
+ return line(`${capitalize(trimFact(a.fact))} ⚠`, a);
64
+ }
65
+ /**
66
+ * Assemble a transition's whole ending: the labeled lead line, the Verdict
67
+ * where one was measured, the advisories, and the pointer.
68
+ *
69
+ * The order is the type's field order, so a verb composes parts and never a
70
+ * sequence. A part with nothing in it vanishes rather than leaving a gap.
71
+ */
72
+ export function ending(e) {
73
+ return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.pointer ?? null]);
74
+ }
75
+ /**
76
+ * Stack a turn's parts: one blank line between each, one beneath the last.
77
+ *
78
+ * The trailing blank is the ending's own: the block is the turn's last text,
79
+ * and one flush against what follows cannot read as an ending. Empty parts drop
80
+ * out, so a caller can pass a part it may not have.
81
+ */
82
+ export function blocks(parts) {
83
+ const kept = parts.filter((p) => p !== null && p.trim().length > 0);
84
+ return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
85
+ }
86
+ /**
87
+ * The head, its trailing parenthetical, and the remedy line beneath, assembled.
88
+ *
89
+ * The one place the parts meet, so a prefixed line and a labeled one can never
90
+ * drift in anything but their heads.
91
+ */
92
+ function line(head, n) {
93
+ const detail = fit(head, (n.detail ?? []).map((d) => d.trim()).filter((d) => d.length > 0));
94
+ const text = detail.length === 0 ? head : `${head} (${detail.join(', ')})`;
95
+ return n.remedy === undefined ? `${text}\n` : `${text}\n → ${n.remedy}\n`;
96
+ }
97
+ /**
98
+ * The fact as a bare clause: whitespace trimmed and one terminal period
99
+ * dropped.
100
+ *
101
+ * The formatter owns the line's punctuation, so a message composed elsewhere
102
+ * (a parse error, a park line a human wrote) still reads as one clause with a
103
+ * parenthetical after it rather than `… . (detail)`.
104
+ */
105
+ function trimFact(fact) {
106
+ const text = fact.trim();
107
+ return text.endsWith('.') && !text.endsWith('..') ? text.slice(0, -1) : text;
108
+ }
109
+ /**
110
+ * The detail items that fit the width budget, with the overrun collapsed to a
111
+ * count.
112
+ *
113
+ * Two items or fewer are the fact's own qualifiers rather than a list, so they
114
+ * always render whole even when the line runs long: a notice wraps where a
115
+ * fence row cannot. A real list drops from the tail until the line fits,
116
+ * naming at least `FLOOR` of them, because a count with nothing beside it
117
+ * sizes a universe the reader cannot see.
118
+ */
119
+ function fit(head, items) {
120
+ if (items.length <= FLOOR)
121
+ return items;
122
+ for (let keep = items.length; keep > FLOOR; keep -= 1) {
123
+ const shown = keep === items.length ? items : [...items.slice(0, keep), others(items.length - keep)];
124
+ if (columns(head) + columns(` (${shown.join(', ')})`) <= WIDTH)
125
+ return shown;
126
+ }
127
+ return [...items.slice(0, FLOOR), others(items.length - FLOOR)];
128
+ }
129
+ /** A fact's first letter raised, so an unprefixed advisory opens as a sentence. */
130
+ function capitalize(fact) {
131
+ return fact.length === 0 ? fact : `${fact[0]?.toUpperCase()}${fact.slice(1)}`;
132
+ }
133
+ /** The collapsed tail naming how many items the line left out. */
134
+ function others(count) {
135
+ return `and ${count} other${count === 1 ? '' : 's'}`;
136
+ }
137
+ /** A string's printed width, counted in code points so the glyphs measure as one column each. */
138
+ function columns(text) {
139
+ return [...text].length;
140
+ }