plumbbob 0.13.1 → 0.13.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.
@@ -18,6 +18,7 @@
18
18
  // human-owned (check/auto): the tool only ever reads it, never rewrites it. The
19
19
  // phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored: BUILD ⇔
20
20
  // a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
21
+ import { createHash } from 'node:crypto';
21
22
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
22
23
  import { dirname, join } from 'node:path';
23
24
  import { gitPath } from "./git.js";
@@ -228,6 +229,61 @@ export function pausePath(root, slug) {
228
229
  export function clearPause(root, slug) {
229
230
  rmSync(pausePath(root, slug), { force: true });
230
231
  }
232
+ /**
233
+ * STOP: the stamp of the stop card `handoff --stop` last rendered whole, the
234
+ * range it reviews, the TURN it printed in, a hash of what the card showed,
235
+ * and a digest of the detail file it printed from. While a later render
236
+ * matches the range and the hash, handoff prints the one-line reminder;
237
+ * `plumbbob harvest` records the stop only from the detail the card showed,
238
+ * and only once a human turn has read it.
239
+ */
240
+ export function stopPath(root, slug) {
241
+ return join(artifactDir(root, slug), 'STOP');
242
+ }
243
+ /**
244
+ * The stamp of the stop card last rendered, or null when STOP is absent or
245
+ * does not parse.
246
+ */
247
+ export function readStop(root, slug) {
248
+ try {
249
+ const m = /^(\d+) (\d+|-) (\S+)(?: (\S+))?$/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
250
+ if (m === null)
251
+ return null;
252
+ return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]), hash: m[3] ?? '', detail: m[4] ?? null };
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
258
+ /**
259
+ * Record the stamp of the stop card just printed, with the digest of the
260
+ * detail file as it stands, which is the file the card was rendered from.
261
+ * Best-effort: a failed write costs one more full card on a later turn, never
262
+ * this turn's output or its exit code.
263
+ */
264
+ export function writeStop(root, slug, stamp) {
265
+ try {
266
+ const detail = detailDigest(readFileSync(detailPath(root), 'utf8'));
267
+ writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'} ${stamp.hash} ${detail}\n`);
268
+ }
269
+ catch {
270
+ // A read-only tree or a vanished build folder: the card still renders.
271
+ }
272
+ }
273
+ /**
274
+ * Remove the STOP stamp: the stop's record ends the card it recorded. Absent
275
+ * is a no-op.
276
+ */
277
+ export function clearStop(root, slug) {
278
+ rmSync(stopPath(root, slug), { force: true });
279
+ }
280
+ /**
281
+ * The digest a STOP stamp keeps of a detail file, so the record can tell the
282
+ * detail a card showed from one written after it.
283
+ */
284
+ export function detailDigest(detail) {
285
+ return createHash('sha256').update(detail).digest('hex');
286
+ }
231
287
  /**
232
288
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
233
289
  * commits, one line each.
@@ -492,7 +548,7 @@ export function grantPath(root) {
492
548
  }
493
549
  /**
494
550
  * Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
495
- * (`auto` | `range M`), null clears it.
551
+ * (`auto` | `steps <list>`), null clears it.
496
552
  *
497
553
  * The turn hook calls this on every human prompt (minted on a match, cleared
498
554
  * otherwise) so a grant lives exactly one turn by construction. Pairing the
@@ -507,6 +563,40 @@ export function setGrant(root, grant) {
507
563
  writeFileSync(grantPath(root), `${grant}\n`);
508
564
  }
509
565
  }
566
+ /**
567
+ * RUN: the range a `/plumbbob:run` the human typed is landing, written beside
568
+ * GRANT by the turn hook so the checkpoint that completes the range knows a
569
+ * run landed it. The latch never reads it.
570
+ */
571
+ export function runPath(root) {
572
+ return join(root, DIRNAME, 'RUN');
573
+ }
574
+ /**
575
+ * The range number the RUN mark names, or null when the mark is absent or
576
+ * holds anything but a positive number.
577
+ */
578
+ export function readRun(root) {
579
+ try {
580
+ const n = Number(readFileSync(runPath(root), 'utf8').trim());
581
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ /**
588
+ * Rewrite (or clear) the RUN mark: a range number marks that range's run,
589
+ * null clears it. The turn hook calls this on every typed prompt, beside
590
+ * `setGrant`, and the delete lives here with the grant's.
591
+ */
592
+ export function setRun(root, item) {
593
+ if (item === null) {
594
+ rmSync(runPath(root), { force: true });
595
+ }
596
+ else {
597
+ writeFileSync(runPath(root), `${item}\n`);
598
+ }
599
+ }
510
600
  /**
511
601
  * PROMPT: the id of the last prompt the turn hook saw, typed or not.
512
602
  */
@@ -599,11 +689,13 @@ export function readTurn(root) {
599
689
  // ending: a decision ending closes the turn, an orientation ending appears in
600
690
  // it, and a driver ending is recorded only so that the last ending wins.
601
691
  const RELAY_TIERS = ['decision', 'orientation', 'driver'];
602
- // Which ending printed: handoff's six forms first, then one per transition. It
603
- // is what the check's reason names when the ending went missing.
692
+ // Which ending printed: handoff's eight forms first, then one per transition.
693
+ // It is what the check's reason names when the ending went missing.
604
694
  const RELAY_RENDERS = [
605
695
  'card',
606
696
  'reminder',
697
+ 'stop',
698
+ 'stop-reminder',
607
699
  'plan',
608
700
  'boundary',
609
701
  'pointer',
@@ -618,6 +710,7 @@ const RELAY_RENDERS = [
618
710
  'spike-done',
619
711
  'use',
620
712
  'order',
713
+ 'harvest',
621
714
  'finish',
622
715
  'start',
623
716
  ];
@@ -681,10 +774,18 @@ function formatRelay(record) {
681
774
  const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
682
775
  if (record.command !== undefined)
683
776
  header.push(`command ${record.command}`);
777
+ if (record.owes !== undefined) {
778
+ header.push(`owes range ${record.owes.range} of ${record.owes.count} after step ${record.owes.step}`);
779
+ }
684
780
  if (record.held !== undefined)
685
781
  header.push(`held ${record.held}`);
686
782
  return `${header.join('\n')}\n\n${record.text}`;
687
783
  }
784
+ /** The `owes` header field read back, or null when it is absent or does not parse. */
785
+ function owedOf(field) {
786
+ const m = /^range (\d+) of (\d+) after step (\d+)$/.exec(field ?? '');
787
+ return m === null ? null : { range: Number(m[1]), count: Number(m[2]), step: Number(m[3]) };
788
+ }
688
789
  /**
689
790
  * The recorded ending, or null when RELAY is absent or its header does not
690
791
  * parse: a record the check cannot read is no record.
@@ -712,12 +813,14 @@ export function readRelay(root) {
712
813
  if (turn === null || tier === undefined || render === undefined)
713
814
  return null;
714
815
  const command = fields.get('command');
816
+ const owes = owedOf(fields.get('owes'));
715
817
  const held = count(fields.get('held'));
716
818
  return {
717
819
  turn,
718
820
  tier,
719
821
  render,
720
822
  ...(command === undefined ? {} : { command }),
823
+ ...(owes === null ? {} : { owes }),
721
824
  ...(held === null ? {} : { held }),
722
825
  text: raw.slice(split + 2),
723
826
  };
@@ -789,6 +892,8 @@ export function excludeControl(root) {
789
892
  // never writes, and never commits.
790
893
  `${DIRNAME}/TURN`,
791
894
  `${DIRNAME}/GRANT`,
895
+ // The range a typed run is landing, written beside the grant.
896
+ `${DIRNAME}/RUN`,
792
897
  // The id of the last prompt the turn hook saw, which tells a notice folded
793
898
  // into the running turn from one that starts its own.
794
899
  `${DIRNAME}/PROMPT`,
@@ -809,6 +914,9 @@ export function excludeControl(root) {
809
914
  // The pause stamp is in-flight control too: what handoff last showed, so
810
915
  // a repeat prints the reminder. It must never ride a step commit.
811
916
  `${DIRNAME}/builds/*/PAUSE`,
917
+ // The stop stamp likewise: what the stop card last showed, and the turn it
918
+ // printed in, for the reminder and the record's latch.
919
+ `${DIRNAME}/builds/*/STOP`,
812
920
  // The agent-run handoff ledger is step-scoped in-flight state, not a
813
921
  // tracked artifact: it must never ride a step commit into the PR.
814
922
  `${DIRNAME}/builds/*/handoff.json`,
@@ -8,17 +8,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
8
8
  import { findRepoRoot } from "../lib/git.js";
9
9
  import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
10
10
  import { parseStepSeam } from "../lib/intent.js";
11
- import { parseBuildRanges, parseOrderedSteps, rangeStanding, rangeStart, skippedBefore } from "../lib/orient.js";
11
+ import { parseBuildRanges, parseOrderedSteps, rangeStanding, rangeStart, skippedBefore, stopFollowUp } from "../lib/orient.js";
12
12
  import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
13
13
  import { readGrant } from "../lib/latch.js";
14
14
  import { notice } from "../lib/notice.js";
15
15
  import { hasStep, readBuildArgs } from "../lib/steplist.js";
16
+ import { waitingCard } from "./handoff.js";
16
17
  /**
17
18
  * Enter step n (or the next undone step) and write its SEAM/STEP markers.
18
19
  *
19
20
  * Refuses a missing session, a malformed step argument, a range such as `N-M`
20
- * or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), and a seam
21
- * that fails to parse.
21
+ * or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), a stop card
22
+ * still waiting for its reply, and a seam that fails to parse.
22
23
  * On entry it stamps the turn ledger and the step's start time, re-renders the
23
24
  * build-log's Current step line, and names the step when it is the last one a
24
25
  * self-approval grant reaches, or, under `--auto`, a call the plan's ranges
@@ -51,6 +52,15 @@ export function build(cwd, args) {
51
52
  return 1;
52
53
  }
53
54
  const intent = readFileSync(intentPath(root, slug), 'utf8');
55
+ // A stop card waiting for its reply holds every step, bare or named: the
56
+ // step's pause would write over the range's review in the detail file before
57
+ // the reply recorded it. Nothing is written, and on the plan's last range the
58
+ // card outranks "no undone step".
59
+ const waiting = waitingNotice(root, slug, intent);
60
+ if (waiting.length > 0) {
61
+ process.stderr.write(waiting);
62
+ return 1;
63
+ }
54
64
  // In build order: the sequence `## Build order` declares when a refined plan
55
65
  // has one, else the numbering, so this pick and the dashboard's agree.
56
66
  const steps = parseOrderedSteps(intent);
@@ -119,6 +129,31 @@ export function build(cwd, args) {
119
129
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
120
130
  return 0;
121
131
  }
132
+ /**
133
+ * The refusal while a stop card waits for its reply, or '' when none waits:
134
+ * the card, then the reply that records it. `looks good` always does, and the
135
+ * remedy adds the move the card's second row names, read through the same
136
+ * `stopFollowUp`: the next range's run, the finish on the plan's last range,
137
+ * or nothing more when the next run would refuse. The remedy spends a
138
+ * semicolon, since the refusal register keeps its one colon for the prefix.
139
+ */
140
+ function waitingNotice(root, slug, intent) {
141
+ const card = waitingCard(root, slug, intent);
142
+ if (card === null) {
143
+ return '';
144
+ }
145
+ const follow = stopFollowUp(intent);
146
+ const also = follow === null
147
+ ? ''
148
+ : follow === 'finish'
149
+ ? ', and /plumbbob:finish records it, then writes the report'
150
+ : `, and /plumbbob:run records it, then runs range ${follow.range.item} of ${follow.count}`;
151
+ return notice({
152
+ fact: 'no step entered',
153
+ detail: [`range ${card.range} of ${card.count}'s stop card is waiting for its reply`],
154
+ remedy: `reply to the card; looks good records it${also}`,
155
+ });
156
+ }
122
157
  /**
123
158
  * The step a range typed at the CLI starts at: its first undone step in build
124
159
  * order when the plan reads, else its first number written.
@@ -12,7 +12,7 @@
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { relative } from 'node:path';
14
14
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
15
- import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
15
+ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
@@ -22,7 +22,8 @@ import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commit
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
23
23
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
24
24
  import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
25
- import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
25
+ import { boundaryEnding, detailsLink, parseDetailRange, planRecord, stepRecord } from "./handoff.js";
26
+ import { isHarvestDetail } from "./harvest.js";
26
27
  /**
27
28
  * Land a step: latch, check gate, commit, record, return to the boundary.
28
29
  *
@@ -102,7 +103,12 @@ export async function checkpoint(cwd, args) {
102
103
  else {
103
104
  sha = headSha(root);
104
105
  }
105
- clearDetail(root); // the detail is recorded in the Log below; clear it so no stale detail rides into the next step.
106
+ // The detail is recorded in the Log below; clear it so no stale detail rides
107
+ // into the next step. A stop's or a harvest's detail is the record's to
108
+ // clear, so it stays where it is.
109
+ if (!heldDetail(root)) {
110
+ clearDetail(root);
111
+ }
106
112
  appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
107
113
  const flipNotice = flipIntent(root, step);
108
114
  stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
@@ -122,9 +128,13 @@ export async function checkpoint(cwd, args) {
122
128
  // pointer. Read after the flip and the ledger append, so the fold and the
123
129
  // pointer both see the boundary the human is standing at.
124
130
  const landed = boundaryEnding(root, activeBuild(root), step);
131
+ // A landing that completes the range a typed run marked owes the stop card:
132
+ // the record says so, and the relay check holds a turn that ends here.
133
+ const owes = landed.range !== null && readRun(root) === landed.range.item ? { range: landed.range.item, count: landed.range.count, step } : null;
125
134
  printEnding(root, {
126
135
  tier: 'orientation',
127
136
  render: 'checkpoint',
137
+ ...(owes === null ? {} : { owes }),
128
138
  text: ending({
129
139
  lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
130
140
  verdict: landed.verdict,
@@ -302,10 +312,11 @@ function flipIntent(root, step) {
302
312
  }
303
313
  /**
304
314
  * The lead line's trailing parenthetical: the short SHA, and where the Log
305
- * entry landed when one did, as the same `details:` clause Next Up carries.
315
+ * entry landed when one did, as the same short linked `details:` clause Next
316
+ * Up carries.
306
317
  */
307
318
  function leadDetail(sha, pointer) {
308
- return pointer === null ? [sha.slice(0, 9)] : [sha.slice(0, 9), `details: \`${pointer}\``];
319
+ return pointer === null ? [sha.slice(0, 9)] : [sha.slice(0, 9), detailsLink(pointer.path, pointer.line)];
309
320
  }
310
321
  /**
311
322
  * Append the step's entry to the build-log's `## Log` section: the dated line,
@@ -314,8 +325,8 @@ function leadDetail(sha, pointer) {
314
325
  * The build's history accrues at each checkpoint instead of being reconstructed
315
326
  * at finish; the step's title is lifted from intent.md when still present.
316
327
  * Best-effort: a missing or odd build-log never blocks a checkpoint; the
317
- * `checkpoints` SHA is the source of truth. Returns where the line landed, as
318
- * `path:line` from the repo root, or null when nothing was written.
328
+ * `checkpoints` SHA is the source of truth. Returns where the line landed, or
329
+ * null when nothing was written.
319
330
  */
320
331
  function logCheckpoint(root, step, sha, record) {
321
332
  const date = new Date().toISOString().slice(0, 10);
@@ -329,8 +340,8 @@ function logPlan(root, sha, record) {
329
340
  return appendLog(root, planLogLine(new Date().toISOString().slice(0, 10), sha), record);
330
341
  }
331
342
  /**
332
- * Write one Log entry and say where its dated line landed (`path:line` from
333
- * the repo root), or null when the section is missing or the write failed.
343
+ * Write one Log entry and say where its dated line landed, or null when the
344
+ * section is missing or the write failed.
334
345
  */
335
346
  function appendLog(root, line, record) {
336
347
  try {
@@ -341,7 +352,7 @@ function appendLog(root, line, record) {
341
352
  }
342
353
  writeFileSync(path, updated);
343
354
  const at = updated.split('\n').lastIndexOf(line);
344
- return at === -1 ? null : `${relative(root, path)}:${at + 1}`;
355
+ return at === -1 ? null : { path: relative(root, path), line: at + 1 };
345
356
  }
346
357
  catch {
347
358
  // best-effort ledger; never fail a checkpoint over the build-log.
@@ -460,13 +471,15 @@ function fallbackBody(root, step) {
460
471
  * the Log's record, and the caller clears it afterward. A missing or blank file
461
472
  * records nothing, and so does one whose header names a different step: that
462
473
  * file is a leftover, written after its own step had already landed, and
463
- * recording it would file one step's story under another. The plan commit
464
- * passes no step and takes the file as it is.
474
+ * recording it would file one step's story under another. A range's detail
475
+ * and a boundary harvest's record nothing here either: they are
476
+ * `plumbbob harvest`'s to record. The plan commit passes no step and takes the
477
+ * file as it is.
465
478
  */
466
479
  function readDetail(root, step) {
467
480
  try {
468
481
  const raw = readFileSync(detailPath(root), 'utf8').trim();
469
- if (raw.length === 0) {
482
+ if (raw.length === 0 || (step !== null && heldDetail(root))) {
470
483
  return null;
471
484
  }
472
485
  const written = step === null ? null : parseDetailStep(raw);
@@ -476,6 +489,20 @@ function readDetail(root, step) {
476
489
  return null;
477
490
  }
478
491
  }
492
+ /**
493
+ * Whether the detail file is one a step's checkpoint leaves alone: a range's,
494
+ * the stop card's wire, or a boundary harvest's, each read off its header and
495
+ * each recorded and cleared by `plumbbob harvest`.
496
+ */
497
+ function heldDetail(root) {
498
+ try {
499
+ const raw = readFileSync(detailPath(root), 'utf8');
500
+ return parseDetailRange(raw) !== null || isHarvestDetail(raw);
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
479
506
  /**
480
507
  * The step's done-when line from intent.md, or null when absent or unreadable.
481
508
  */