plumbbob 0.13.1 → 0.13.2
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/.claude-plugin/plugin.json +1 -1
- package/README.md +72 -53
- package/dist/cli-core.js +21 -6
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +23 -0
- package/dist/lib/orient.js +601 -58
- package/dist/lib/sidecar.js +111 -3
- package/dist/verbs/build.js +38 -3
- package/dist/verbs/checkpoint.js +40 -13
- package/dist/verbs/handoff.js +452 -46
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +108 -27
- package/package.json +1 -1
- package/skills/build/SKILL.md +18 -6
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +48 -14
- package/skills/plan/SKILL.md +32 -16
- package/skills/refine/SKILL.md +5 -2
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +4 -2
- package/skills/step/SKILL.md +12 -3
- package/skills/verify/SKILL.md +5 -1
- package/templates/intent.md +12 -8
package/dist/lib/sidecar.js
CHANGED
|
@@ -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` | `
|
|
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
|
|
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`,
|
package/dist/verbs/build.js
CHANGED
|
@@ -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),
|
|
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.
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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),
|
|
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,
|
|
318
|
-
*
|
|
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
|
|
333
|
-
*
|
|
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 :
|
|
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.
|
|
464
|
-
*
|
|
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
|
*/
|