@sabaiway/agent-workflow-kit 5.10.0 → 5.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.
@@ -0,0 +1,369 @@
1
+ // worktree-handoff-return.mjs — the handoff-return rung (delegation Plan 3, Phase 3): deliver a
2
+ // landed satellite's return, prove its destination, and derive its observation when the change set
3
+ // allows one. A `dispatch` verb, so the ledger's ownership invariant holds unchanged; the satellite
4
+ // is located through the shared locator leaf, which is what keeps the 3200-line worktrees tool out
5
+ // of the dispatch CLI's import closure.
6
+ //
7
+ // What the rung DOES, in order: locate the satellite and prove the handoff identity there · read
8
+ // the handoff's raw bytes through the family's no-follow reader and RE-PARSE the record from those
9
+ // same bytes (one byte source for the proof, the delivery and the digest — a record swapped between
10
+ // the identity read and the delivery read refuses) · require prepared-tree AND prepared-head and
11
+ // re-attest BOTH against MAIN (D8: after a commit the clean index reproduces the committed tree, so
12
+ // a tree comparison alone cannot close the window) · deliver every user-owned fragment byte
13
+ // verbatim with its boundaries and byte lengths, naming the MAIN-owned destinations (D15) · print
14
+ // the proof (the handoff digest and both OIDs — the printed proof, not ledger fields: the closed
15
+ // observation key set carries no artifact digest, an ACCEPTED limitation, D11) · print the
16
+ // after-the-fold order (D9) · and append the observation ONLY when the prepared change set lies
17
+ // wholly inside the observation domain (D10) — a deletion, a rename's absent old side, a symlink,
18
+ // a submodule, a mode-only change, a path whose name is not valid UTF-8, and every other
19
+ // unrepresentable form are a NAMED non-record with exit 0, never a partial number. The numerator is
20
+ // the ATTESTED tree's blob bytes (git cat-file over the diff-tree entries' new OIDs, fail-closed on
21
+ // every answer) — never the disk, which an unstaged edit after the prepare moves silently. The
22
+ // attestation is REPEATED immediately before EITHER answer (the house pre-append idiom: it NARROWS
23
+ // the race window rather than closing it — this family defends against a buggy or interrupted
24
+ // producer, never a racing adversary). The fold itself stays orchestrator judgment: the rung
25
+ // delivers and claims nothing about whether it happened.
26
+ //
27
+ // Writer: appends only through the store's single legality door (appendDelegationRecord — D5, the
28
+ // store's refusals travel verbatim). Never commits; spawns git reads plus `git write-tree`, which
29
+ // may write a tree object into the odb and moves no ref — the same probe land itself uses. Every
30
+ // foreign path it prints renders control-byte-safe (displayValue / a hex form), because its output
31
+ // is read in a terminal. Dependency-free, Node >= 22. No CLI (dispatch.mjs owns the verb); no side
32
+ // effects on import.
33
+
34
+ import { spawnSync } from 'node:child_process';
35
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
36
+ import { createHash } from 'node:crypto';
37
+ import { findSatelliteEntry, readSatelliteIdentity, WORKTREES_STOP } from './satellite-locator.mjs';
38
+ import { locateProvisionRecordSection, parseProvisionRecord, displayValue } from './worktrees-record.mjs';
39
+ import { readFileBytesNoFollow } from './fs-read-nofollow.mjs';
40
+ import { computeNumerator } from './dispatch-record.mjs';
41
+ import { resolveRepoRoot, formatRatio, buildObservationRecord } from './observation-builder.mjs';
42
+ import { appendDelegationRecord, DELEGATION_STORE_STOP } from './dispatch-store.mjs';
43
+
44
+ // The worktrees slug grammar, repeated here so the CLI can refuse a malformed slug as USAGE before
45
+ // any probe echoes it — the locator's own refusal interpolates the slug into a terminal message.
46
+ export const HANDOFF_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
47
+
48
+ // D9, doc-parity-bound into references/modes/worktrees.md: a fold is new code, so gates that ran
49
+ // before it attest a tree that no longer exists. The re-attestation is a COMMAND, not advice.
50
+ export const AFTER_FOLD_ORDER = 'a fold landed AFTER the gates leaves those gates STALE — the order is fold → re-stage (git add) → the configured review → run-gates --final (the receipt is minted over the CURRENT post-fold staged tree) → commit-guard --check (the final re-attestation) → the commit ask';
51
+
52
+ // The prepared change set rides the worktrees stream class (D9's taxonomy), and its provenance is
53
+ // self-reported by construction: the denominator is the handoff byte count, a number no wrapper
54
+ // proved — recorded, printed, and excluded from acceptance downstream.
55
+ const STEP_CLASS = 'worktree-stream';
56
+ const PROVENANCE = 'self-reported';
57
+ const REGULAR_MODES = new Set(['100644', '100755']);
58
+ const RECORD_HEADING = '## Provision record';
59
+
60
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
61
+
62
+ const defaultGit = (args, cwd) => {
63
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, maxBuffer: GIT_MAX_BUFFER });
64
+ return {
65
+ status: r.error ? -1 : r.status,
66
+ stdout: r.stdout ?? '',
67
+ stderr: r.error ? String(r.error.message) : (r.stderr ?? ''),
68
+ };
69
+ };
70
+
71
+ // The BYTES twin, for the one probe whose output can carry non-UTF-8 path names: a decoded split
72
+ // cannot be undone, so the diff-tree answer stays a Buffer until each token is proven decodable.
73
+ const defaultGitBuf = (args, cwd) => {
74
+ const r = spawnSync('git', args, { cwd, windowsHide: true, maxBuffer: GIT_MAX_BUFFER });
75
+ return {
76
+ status: r.error ? -1 : r.status,
77
+ stdout: r.stdout ?? Buffer.alloc(0),
78
+ stderr: r.error ? String(r.error.message) : String(r.stderr ?? ''),
79
+ };
80
+ };
81
+
82
+ const stop = (message) => Object.assign(new Error(message), { code: WORKTREES_STOP });
83
+
84
+ const gitRead = (git, args, cwd, label) => {
85
+ const r = git(args, cwd);
86
+ if (r.status !== 0) throw stop(`${label}: ${(r.stderr || r.stdout).trim() || `git exited ${r.status}`}`);
87
+ return r.stdout;
88
+ };
89
+
90
+ // The dispatch-side no-follow read outcome, mapped onto the leaf shape the locator's injected fs
91
+ // seam expects ({ bytes } | { absent } | { unsafe } | { error }). A MAPPING over the family reader,
92
+ // never a second read-door body — the one no-follow body stays fs-read-nofollow.mjs.
93
+ export const leafReadOutcome = (r) => {
94
+ if (r.outcome === 'ok') return { bytes: r.bytes };
95
+ if (r.outcome === 'absent') return { absent: true };
96
+ if (r.outcome === 'foreign') return { unsafe: true };
97
+ return { error: r.code };
98
+ };
99
+
100
+ const defaultFs = () => ({
101
+ lstat: lstatSync,
102
+ readdir: readdirSync,
103
+ realpath: realpathSync,
104
+ readFileNoFollow: (abs) => leafReadOutcome(readFileBytesNoFollow(abs)),
105
+ });
106
+
107
+ // NUL-split that keeps every segment and, on the Buffer lane, proves each token's decodability by
108
+ // a byte round-trip — a token that does not round-trip keeps its hex identity instead of a lossy
109
+ // string, so a non-UTF-8 path can neither alias another path nor forge a printed line.
110
+ const splitNul = (input) => {
111
+ if (typeof input === 'string') return input.split('\0').map((text) => ({ text, ok: true, hex: null }));
112
+ const out = [];
113
+ let start = 0;
114
+ for (let i = 0; i <= input.length; i += 1) {
115
+ if (i !== input.length && input[i] !== 0) continue;
116
+ const seg = input.subarray(start, i);
117
+ const text = seg.toString('utf8');
118
+ const ok = Buffer.from(text, 'utf8').equals(seg);
119
+ out.push({ text, ok, hex: ok ? null : seg.toString('hex') });
120
+ start = i + 1;
121
+ }
122
+ return out;
123
+ };
124
+
125
+ const shownToken = (t) => (t.ok ? displayValue(t.text) : `<non-UTF-8 path 0x${t.hex}>`);
126
+
127
+ // `git diff-tree -r -z -M` raw grammar, parsed STRICTLY: one meta token
128
+ // `:<oldmode> <newmode> <oldsha> <newsha> <status>`, then the path token — two path tokens (old,
129
+ // new) for a rename or copy. EVERY non-empty token outside that grammar REFUSES — a malformed
130
+ // colon-prefixed meta, a stray token where a meta belongs, and a missing or empty path token
131
+ // alike: a skipped entry would record a partial numerator in silence, which is the one outcome
132
+ // the fail-closed contract forbids. Only the empty tokens the -z terminator produces are skipped.
133
+ const META_RE = /^:([0-7]{5,6}) ([0-7]{5,6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z][0-9]*)$/;
134
+
135
+ export const parsePreparedChangeSet = (input) => {
136
+ const tokens = splitNul(input);
137
+ const entries = [];
138
+ for (let i = 0; i < tokens.length; i += 1) {
139
+ const t = tokens[i];
140
+ if (t.ok && t.text === '') continue;
141
+ const m = t.ok ? t.text.match(META_RE) : null;
142
+ if (m === null) {
143
+ throw stop(`cannot parse the prepared change set — malformed diff-tree metadata: ${shownToken(t)}`);
144
+ }
145
+ const [, oldMode, newMode, oldSha, newSha, status] = m;
146
+ const takePath = (what) => {
147
+ const p = tokens[i + 1];
148
+ if (p === undefined || (p.ok && p.text === '')) {
149
+ throw stop(`cannot parse the prepared change set — missing path token (${what}) after ${displayValue(t.text)}`);
150
+ }
151
+ i += 1;
152
+ return p;
153
+ };
154
+ if (status[0] === 'R' || status[0] === 'C') {
155
+ const oldP = takePath('the rename/copy old side');
156
+ const newP = takePath('the rename/copy new side');
157
+ entries.push({
158
+ oldMode, newMode, oldSha, newSha, status,
159
+ oldPath: oldP.text, oldPathUtf8Ok: oldP.ok, oldPathDisplay: shownToken(oldP),
160
+ path: newP.text, pathUtf8Ok: newP.ok, pathDisplay: shownToken(newP),
161
+ });
162
+ } else {
163
+ const p = takePath('the entry path');
164
+ entries.push({ oldMode, newMode, oldSha, newSha, status, path: p.text, pathUtf8Ok: p.ok, pathDisplay: shownToken(p) });
165
+ }
166
+ }
167
+ return entries;
168
+ };
169
+
170
+ // D10 — the observation domain accepts only PRESENT REGULAR files, so every form without a
171
+ // measurable post-image is OUTSIDE it, each by its own name. A mode-only change is its own rule:
172
+ // it has no measurable byte change at all. A path whose NAME is not valid UTF-8 cannot be carried
173
+ // by the record's string domain, so it is out too. A regular BINARY file is INSIDE — its bytes are
174
+ // read like any other. `path` in the answer is always DISPLAY-SAFE; the raw path and the new blob
175
+ // OID ride only the inside answer, where the measurement needs them.
176
+ export const classifyPreparedEntry = (e) => {
177
+ const shown = e.pathDisplay ?? displayValue(e.path);
178
+ if (e.pathUtf8Ok === false || e.oldPathUtf8Ok === false) {
179
+ return { inside: false, form: 'a path whose name is not valid UTF-8', path: e.pathUtf8Ok === false ? shown : e.oldPathDisplay };
180
+ }
181
+ if (e.status[0] === 'D') return { inside: false, form: 'a deletion', path: shown };
182
+ if (e.status[0] === 'R') return { inside: false, form: "a rename's absent old side", path: e.oldPathDisplay ?? displayValue(e.oldPath) };
183
+ if (e.newMode === '160000' || e.oldMode === '160000') return { inside: false, form: 'a submodule', path: shown };
184
+ if (e.newMode === '120000' || e.oldMode === '120000') return { inside: false, form: 'a symlink', path: shown };
185
+ if (e.status[0] === 'M' && e.oldSha === e.newSha) return { inside: false, form: 'a mode-only change', path: shown };
186
+ if ((e.status[0] === 'A' || e.status[0] === 'M') && REGULAR_MODES.has(e.newMode)) {
187
+ return { inside: true, path: e.path, sha: e.newSha, shown };
188
+ }
189
+ return { inside: false, form: `an unrepresentable form (git status ${e.status})`, path: shown };
190
+ };
191
+
192
+ // The numerator's byte source is the ATTESTED tree itself, and every cat-file answer is validated
193
+ // fail-closed: a missing object, a non-blob object and a non-numeric or unsafe size each refuse by
194
+ // name — a guessed size would put an unverifiable number into a record whose whole point is that
195
+ // its scope was attested.
196
+ const blobSize = (git, root, sha, shown) => {
197
+ const type = gitRead(git, ['cat-file', '-t', sha], root, `cannot size the prepared blob ${sha} (${shown})`).trim();
198
+ if (type !== 'blob') {
199
+ throw stop(`the prepared change set entry ${shown} names object ${sha}, which is a ${type}, not a blob — the attested post-image is unmeasurable (fail closed); nothing was appended`);
200
+ }
201
+ const raw = gitRead(git, ['cat-file', '-s', sha], root, `cannot size the prepared blob ${sha} (${shown})`).trim();
202
+ const size = Number(raw);
203
+ if (!/^(?:0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(size)) {
204
+ throw stop(`git cat-file -s answered "${displayValue(raw)}" for ${sha} (${shown}), which is not a byte count this record can carry (fail closed); nothing was appended`);
205
+ }
206
+ return size;
207
+ };
208
+
209
+ // One user-owned fragment, byte verbatim between its two boundary lines: the byte length on the
210
+ // opening boundary is what makes the delivery VERIFIABLE rather than asserted — a fragment can
211
+ // itself carry a line imitating a boundary, and the stated length pins where it really ends.
212
+ const fragmentBlock = (index, where, fragment) => [
213
+ `--- fragment ${index}: ${where} — ${Buffer.byteLength(fragment)} bytes ---`,
214
+ `${fragment}${fragment.endsWith('\n') || fragment === '' ? '' : '\n'}--- end fragment ${index} ---`,
215
+ ].join('\n');
216
+
217
+ const refusal = (reason) => ({ code: 1, stdout: '', stderr: `dispatch handoff-return: ${reason}` });
218
+
219
+ // handoffReturn({ cwd, slug, waveId, planId, phase, env, now, deps }) → { code, stdout, stderr }.
220
+ // Exported so every branch is reachable in-process (D14); dispatch.mjs owns the flag surface.
221
+ export const handoffReturn = ({ cwd, slug, waveId, planId, phase, env = process.env, now = () => new Date().toISOString(), deps = {} }) => {
222
+ const git = deps.git ?? defaultGit;
223
+ const gitBuf = deps.gitBuf ?? defaultGitBuf;
224
+ const fs = deps.fs ?? defaultFs();
225
+ const readBytes = deps.readBytes ?? readFileBytesNoFollow;
226
+ try {
227
+ const root = resolveRepoRoot(cwd);
228
+ if (root === null) return refusal('not inside a git work tree — the rung attests MAIN\'s index and HEAD, and there is neither here (fail closed); nothing was appended');
229
+ // D8's MAIN-side guard, repeated from the worktrees lanes: two linked worktrees share one git
230
+ // common dir, so run from a satellite this rung would attest the WRONG tree.
231
+ const gitDir = gitRead(git, ['rev-parse', '--path-format=absolute', '--git-dir'], root, 'cannot resolve the git dir').trim();
232
+ const commonDir = gitRead(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], root, 'cannot resolve the git common dir').trim();
233
+ if (gitDir !== commonDir) {
234
+ return refusal(`run this from the MAIN worktree: the git dir is not the git common dir (git dir ${gitDir}, common ${commonDir}), so this cwd is inside a linked worktree, where the shared common dir would let the rung attest the wrong tree; nothing was appended`);
235
+ }
236
+ const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
237
+ const identity = readSatelliteIdentity({ entry, slug, fs });
238
+ const raw = readBytes(identity.path);
239
+ if (raw.outcome !== 'ok') {
240
+ return refusal(`the handoff at ${displayValue(identity.path)} could not be read for delivery (${raw.outcome === 'error' ? raw.code : raw.outcome}) — a delivery is the raw bytes or nothing (fail closed); nothing was appended`);
241
+ }
242
+ // ONE byte source: the record the proof attests is RE-PARSED from the very bytes the delivery
243
+ // prints and the digest binds — the identity read (which proved slug/branch/uniqueness) stays
244
+ // the locator's, and a handoff swapped between the two reads refuses here by the disagreement.
245
+ const text = raw.bytes.toString('utf8');
246
+ const record = parseProvisionRecord(text);
247
+ if (record.slug !== slug || record.branch !== identity.branch) {
248
+ return refusal(`the delivered bytes disagree with the proven identity — the delivery read parsed slug ${record.slug === null ? '(missing)' : displayValue(record.slug)} and branch ${record.branch === null ? '(missing)' : displayValue(record.branch)}, while the identity read proved slug ${slug} on branch ${displayValue(identity.branch)}; the handoff changed between the two reads (fail closed); nothing was appended`);
249
+ }
250
+ if (record.prepared === null) {
251
+ return refusal(`the handoff record for "${slug}" carries no prepared-tree — nothing has been landed onto MAIN yet: run land --prepare from MAIN first; nothing was appended`);
252
+ }
253
+ if (record.preparedHead === null) {
254
+ return refusal(`the handoff record for "${slug}" carries no prepared-head (a record written by an earlier kit records only the prepared tree) — re-run land --prepare, which records MAIN's HEAD beside prepared-tree; nothing was appended`);
255
+ }
256
+ const stagedTree = gitRead(git, ['write-tree'], root, 'git write-tree failed').trim();
257
+ if (stagedTree !== record.prepared) {
258
+ return refusal(`the staged write-tree ${stagedTree} does not equal the recorded prepared-tree ${record.prepared} — MAIN's index moved since land --prepare, so the prepared change set on record is not the one in front of this rung: re-run land --prepare; nothing was appended`);
259
+ }
260
+ const liveHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve MAIN HEAD').trim();
261
+ if (liveHead !== record.preparedHead) {
262
+ return refusal(`MAIN's HEAD ${liveHead} is not the recorded prepared-head ${record.preparedHead}, even though the staged write-tree still matches — a clean post-commit index reproduces the committed tree, so the prepared change set was already committed and there is nothing left to attest; a new landing takes a fresh land --prepare; nothing was appended`);
263
+ }
264
+ // The final re-attestation, run immediately before EITHER answer: the derivation between the
265
+ // first attestation and the answer reads the index and HEAD again, so the answer's proof must
266
+ // be re-established at the last moment — the house pre-append idiom, which NARROWS the window
267
+ // rather than closing it. Tree and HEAD refuse separately, so the operator knows what moved.
268
+ const finalAttest = () => {
269
+ // Its OWN probe failures are late refusals too: they answer through the same string lane the
270
+ // drift does, so the caller's withDelivery keeps the round-3 contract on this arm as well —
271
+ // an unanswerable re-attestation loses the proof, never the delivery.
272
+ try {
273
+ const tree = gitRead(git, ['write-tree'], root, 'git write-tree failed').trim();
274
+ if (tree !== record.prepared) {
275
+ return `MAIN's staged write-tree moved while the return was being computed (${record.prepared} → ${tree}) — the delivered proof would be stale; settle MAIN and re-run; nothing was appended`;
276
+ }
277
+ const head = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve MAIN HEAD').trim();
278
+ if (head !== record.preparedHead) {
279
+ return `MAIN's HEAD moved while the return was being computed (${record.preparedHead} → ${head}) — the delivered proof would be stale; nothing was appended`;
280
+ }
281
+ return null;
282
+ } catch (err) {
283
+ if (err?.code !== WORKTREES_STOP) throw err;
284
+ return err.message;
285
+ }
286
+ };
287
+ // Delivery (D15): everything outside the `## Provision record` section is user-owned — the
288
+ // same boundary the record refresh preserves, located on the same fatally-decoded text the
289
+ // record was parsed from (the reader refuses invalid UTF-8, so string slicing is byte-faithful).
290
+ // The delivery is a FACT the moment the attested handoff bytes are in hand: every later refusal
291
+ // — a diff-tree failure, a parser or cat-file refusal, a re-attestation drift — keeps it on
292
+ // stdout, because erasing the return channel over a measurement failure would invert the rung's
293
+ // own order (deliver → prove → count). The PROOF line, by contrast, prints only after the FINAL
294
+ // re-attestation has held: it claims "attested" and "unchanged", and printed any earlier the
295
+ // claim could be stale.
296
+ const section = locateProvisionRecordSection(text);
297
+ const delivery = [
298
+ `dispatch handoff-return: satellite "${slug}" at ${displayValue(entry.path)} · handoff ${displayValue(identity.path)}`,
299
+ `delivery — the user-owned handoff content, byte verbatim (everything outside "${RECORD_HEADING}"):`,
300
+ fragmentBlock(1, `before "${RECORD_HEADING}"`, text.slice(0, section.start)),
301
+ fragmentBlock(2, `after the "${RECORD_HEADING}" section`, text.slice(section.end)),
302
+ 'destinations (MAIN-owned): findings → docs/plans/queue.md · decisions and session records → the docs/ai records. The fold stays orchestrator judgment — this rung delivers and claims nothing about whether it happened.',
303
+ ];
304
+ const withDelivery = (reason) => ({ code: 1, stdout: delivery.join('\n'), stderr: `dispatch handoff-return: ${reason}` });
305
+ const proofLines = [
306
+ `proof — handoff sha256 ${createHash('sha256').update(raw.bytes).digest('hex')} over ${raw.bytes.length} bytes · prepared-tree ${record.prepared} = the staged write-tree (attested) · prepared-head ${record.preparedHead} = MAIN HEAD (unchanged)`,
307
+ `next: ${AFTER_FOLD_ORDER}`,
308
+ ];
309
+ // D10: the change set is classified WHOLE before anything is measured — no partial scope is
310
+ // ever recorded, and the first out-of-domain form names the non-record.
311
+ let classified;
312
+ let numerator = null;
313
+ try {
314
+ const answered = gitBuf(['diff-tree', '-r', '-z', '-M', record.preparedHead, record.prepared], root);
315
+ if (answered.status !== 0) {
316
+ return withDelivery(`cannot enumerate the prepared change set: ${answered.stderr.trim() || `git exited ${answered.status}`}; nothing was appended`);
317
+ }
318
+ classified = parsePreparedChangeSet(answered.stdout).map(classifyPreparedEntry);
319
+ if (classified.every((c) => c.inside)) {
320
+ const computed = computeNumerator(classified.map((c) => ({
321
+ kind: 'new', path: c.path, objectId: c.path, postImageBytes: blobSize(git, root, c.sha, c.shown),
322
+ })));
323
+ if (!computed.ok) return withDelivery(`${computed.reason}; nothing was appended`);
324
+ numerator = computed;
325
+ }
326
+ } catch (err) {
327
+ if (err?.code !== WORKTREES_STOP) throw err;
328
+ return withDelivery(err.message);
329
+ }
330
+ const outside = classified.find((c) => !c.inside);
331
+ if (outside !== undefined) {
332
+ const drifted = finalAttest();
333
+ if (drifted !== null) return withDelivery(drifted);
334
+ return {
335
+ code: 0,
336
+ stdout: [...delivery, ...proofLines, `observation: NOT RECORDED — ${outside.form} at ${outside.path} is outside the observation domain`].join('\n'),
337
+ stderr: '',
338
+ };
339
+ }
340
+ const observation = buildObservationRecord({
341
+ waveId,
342
+ stepClass: STEP_CLASS,
343
+ measured: { numeratorBytes: numerator.numeratorBytes, components: numerator.components, scope: JSON.stringify(classified.map((c) => c.path)) },
344
+ provenance: PROVENANCE,
345
+ denominatorBytes: raw.bytes.length,
346
+ planId,
347
+ phase,
348
+ timestamp: now(),
349
+ });
350
+ const drifted = finalAttest();
351
+ if (drifted !== null) return withDelivery(drifted);
352
+ // The single legality door (D5): a store refusal travels verbatim, with the delivery and the
353
+ // proof above already printed — the observation failed to land, the return did not.
354
+ try {
355
+ const { writtenPath } = appendDelegationRecord({ cwd: root, record: observation, env });
356
+ const objects = new Set(observation.metric.components.map((c) => c.objectId)).size;
357
+ return {
358
+ code: 0,
359
+ stdout: [...delivery, ...proofLines, `observation: RECORDED — ${PROVENANCE} · class ${STEP_CLASS} · plan ${planId} phase ${phase} · ${formatRatio(observation.metric)} · ${objects} object(s) · scope ${displayValue(observation.scope)} → ${displayValue(writtenPath)}`].join('\n'),
360
+ stderr: '',
361
+ };
362
+ } catch (err) {
363
+ if (err?.code !== DELEGATION_STORE_STOP) throw err;
364
+ return { code: 1, stdout: [...delivery, ...proofLines].join('\n'), stderr: `dispatch handoff-return: ${err.message}` };
365
+ }
366
+ } catch (err) {
367
+ return refusal(err?.message ?? String(err));
368
+ }
369
+ };
@@ -0,0 +1,190 @@
1
+ // worktree-prompt.mjs — the satellite session's COLD-START prompt (delegation Plan 3, Phase 2).
2
+ //
3
+ // A satellite is a fresh session in a checkout that looks like the repo and is not: the series index
4
+ // lives only in MAIN, the landing runs only from MAIN, and the one channel back is the handoff. None
5
+ // of that is derivable from inside the worktree, so `provision` ends its report with this prompt and
6
+ // `worktrees prompt <slug>` re-prints it later.
7
+ //
8
+ // PURE over its inputs (plus one injected readdir for the seeded-plan rule): it composes text and
9
+ // decides nothing. Node built-ins plus two pure leaves; no git, no writes, no CLI, no side effects
10
+ // on import. Dependency-free, Node >= 22.
11
+
12
+ import { join } from 'node:path';
13
+ import { plansInFlight, PLANS_REL } from './plan-files.mjs';
14
+ import {
15
+ stop, handoffBasename, QUEUE_SHARED_RULE, composeLandingValue, hasControlByte, displayValue,
16
+ } from './worktrees-record.mjs';
17
+
18
+ export { WORKTREES_STOP } from './worktrees-record.mjs';
19
+
20
+ // The command grammar. Every command this prompt puts in front of a reader is a marked line naming
21
+ // WHO runs it — ` MAIN $ …` or ` HERE $ …` — so the runnable set is enumerable AND attributed.
22
+ // The actor is not decoration: the one command the prompt carries today runs from MAIN and mutates
23
+ // MAIN, which is precisely what the satellite is forbidden to do, and an unattributed `$` line reads
24
+ // as an instruction to whoever is holding the prompt.
25
+ export const PROMPT_ACTORS = Object.freeze({ main: 'MAIN', here: 'HERE' });
26
+ const COMMAND_INDENT = ' ';
27
+ const COMMAND_LINE = new RegExp(`^${COMMAND_INDENT}(MAIN|HERE) \\$ (.*)$`);
28
+
29
+ const commandLine = (actor, command) => `${COMMAND_INDENT}${actor} $ ${command}`;
30
+
31
+ // promptCommands(text) → [{ actor, command }] — the closed set of commands the prompt offers.
32
+ export const promptCommands = (text) => String(text)
33
+ .split('\n')
34
+ .flatMap((line) => {
35
+ const m = line.match(COMMAND_LINE);
36
+ return m === null ? [] : [{ actor: m[1], command: m[2] }];
37
+ });
38
+
39
+ // D7: one writer per worktree is a BAR, not a mechanism — stated at every point of use precisely
40
+ // because nothing refuses a second writer.
41
+ export const ONE_WRITER_BAR = 'ONE writer per worktree: this session is the only agent writing in this checkout, and nothing enforces that — a second session writing here interleaves two agents into one tree, which neither this tool nor git can detect or undo. It is a bar you keep, not a lock you hold.';
42
+
43
+ // The v1 satellite contract, stated where the satellite reads rather than only in the mode doc.
44
+ export const FORBIDDEN_VERBS_BAR = 'Forbidden from this worktree: git commit, git push, git tag, git stash, any history rewrite other than the tool-printed reset of this branch, the kit lifecycle writers, version bumps and publishes, edits to MAIN files, and every write to the shared series index — the landing runs from MAIN and the commit stays a dialogue ask there.';
45
+
46
+ // The seeded plan is the ONE fact only the satellite's own directory answers, and the EXACTLY-ONE
47
+ // rule is the one the resume lane already trusts: a prompt naming the wrong plan is worse than a
48
+ // prompt that refuses to name one.
49
+ export const resolveSeededPlan = ({ wtRoot, readdir }) => {
50
+ // plansInFlight answers [] for EVERY readdir failure, which reads as "no plan in flight" — true
51
+ // for an absent directory and false for a denied or broken one. So the directory is read ONCE,
52
+ // here, and that single result is what gets classified: a second read could succeed where the
53
+ // first failed (or the reverse) and put the two answers back out of step.
54
+ let entries;
55
+ try {
56
+ entries = readdir(join(wtRoot, PLANS_REL), { withFileTypes: true });
57
+ } catch (err) {
58
+ if (err?.code !== 'ENOENT') {
59
+ throw stop(`the worktree's ${PLANS_REL} could not be read (${err?.code ?? 'fs error'}) — the cold-start prompt cannot name a plan it was never able to look for`);
60
+ }
61
+ entries = [];
62
+ }
63
+ const inFlight = plansInFlight(wtRoot, () => entries);
64
+ if (inFlight.length !== 1) {
65
+ // The names are filesystem-provided and this message is read in a terminal, so they are
66
+ // rendered escaped — a refusal must never be the thing that lets a hostile name forge a line.
67
+ throw stop(
68
+ `the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.map(displayValue).join(', ')}] — ` +
69
+ 'the cold-start prompt cannot name a plan the satellite does not uniquely hold',
70
+ );
71
+ }
72
+ return inFlight[0];
73
+ };
74
+
75
+ // The prompt is LINE-oriented, exactly like the record it reads from, so a control byte in ANY value
76
+ // it interpolates forges a line — including a forged command line, which `promptCommands` would then
77
+ // report as real. The values arrive from filesystem names, from a repo path and from a hand-editable
78
+ // record, so none of them is trustworthy by provenance. Note that JSON.stringify is NOT a defence
79
+ // here: it escapes CR/LF but passes U+007F and U+2028/U+2029 through untouched.
80
+ // Fail closed: refuse to compose, never sanitize silently.
81
+ const promptValue = (name, value) => {
82
+ if (value == null) return null;
83
+ if (hasControlByte(value)) {
84
+ throw stop(`the satellite cold-start prompt cannot be composed: ${name} carries a control character, which would forge a line in a line-oriented prompt`);
85
+ }
86
+ return String(value);
87
+ };
88
+
89
+ const required = (name, value) => {
90
+ if (value == null || value === '') {
91
+ throw stop(`the satellite cold-start prompt cannot be composed: ${name} is missing — a prompt short of an orientation fact reads complete and is not`);
92
+ }
93
+ return value;
94
+ };
95
+
96
+ // D16: an orientation value FROZEN into the provision record can go stale — MAIN moves, or a field
97
+ // is hand-edited — so the live value is what renders and the recorded one is only NAMED. An ABSENT
98
+ // field (an older kit's record) is no divergence at all: there is nothing it disagrees with.
99
+ const divergence = (label, live, recorded, cause) => (recorded == null || recorded === live ? [] : [
100
+ ` record divergence: the provision record recorded ${label} as ${JSON.stringify(recorded)} — the live value above is what answers now, so the record is stale: ${cause}.`,
101
+ ]);
102
+
103
+ const MAIN_CAUSE = 'a moved MAIN, or a hand edit';
104
+ // The install posture is NOT a MAIN fact: it is probed on this checkout, so it diverges from the
105
+ // record whenever the checkout's own dependency declaration or node_modules changes — the ordinary
106
+ // case, with no moved MAIN and no hand edit anywhere.
107
+ const INSTALL_CAUSE = 'this checkout answers differently now, most often because its dependency declaration or its node_modules changed since provision';
108
+
109
+ // live: { sharedQueue, landingRule, landingCommand, installPosture, installDescription,
110
+ // installCommand } — derived by the caller at print time. The landing pair and the checkout identity
111
+ // have no "cannot answer" state and are REQUIRED; the series index and the install trio may be
112
+ // absent and then render by omission. The install arrives in THREE parts on purpose: the posture is
113
+ // the record's field and the divergence key and is never printed, the description is its prose half,
114
+ // and the command is the runnable half — because the posture string IS a command in the ordinary
115
+ // case, and printing it as prose would offer an instruction nothing attributes. `record` is the
116
+ // parsed provision record and is required too — a caller that could not read it must say so, not
117
+ // hand in an empty object that renders as "nothing diverged".
118
+ export const composeSatellitePrompt = ({ slug, branch, worktreePath, plan, live, record }) => {
119
+ if (live == null || typeof live !== 'object') {
120
+ throw stop('the satellite cold-start prompt cannot be composed: the live orientation is missing — every value the prompt states is derived at print time, so there is nothing to state without it');
121
+ }
122
+ if (record == null || typeof record !== 'object') {
123
+ throw stop('the satellite cold-start prompt cannot be composed: the provision record is missing — a prompt composed against no record would silently claim nothing has drifted');
124
+ }
125
+ const safeSlug = promptValue('slug', required('slug', slug));
126
+ const safeBranch = promptValue('branch', required('branch', branch));
127
+ const safePath = promptValue('worktree path', required('worktree path', worktreePath));
128
+ const safePlan = promptValue('seeded plan', required('seeded plan', plan));
129
+ const rule = promptValue('live.landingRule', required('live.landingRule', live.landingRule));
130
+ const command = promptValue('live.landingCommand', required('live.landingCommand', live.landingCommand));
131
+ const queue = promptValue('live.sharedQueue', live.sharedQueue);
132
+ const installPosture = promptValue('live.installPosture', live.installPosture);
133
+ const installDescription = promptValue('live.installDescription', live.installDescription);
134
+ const installCommand = promptValue('live.installCommand', live.installCommand);
135
+ const recorded = {
136
+ sharedQueue: promptValue('record shared-queue', record.sharedQueue),
137
+ landing: promptValue('record landing', record.landing),
138
+ install: promptValue('record install', record.install),
139
+ };
140
+
141
+ const handoffRel = `${PLANS_REL}/${handoffBasename(safeSlug)}`;
142
+ const lines = [
143
+ `# Satellite session — ${safeSlug}`,
144
+ '',
145
+ 'A linked git worktree provisioned by agent-workflow. You work HERE; MAIN lands what you produce',
146
+ 'and owns every commit.',
147
+ '',
148
+ '## This checkout',
149
+ `- worktree: ${safePath}`,
150
+ `- branch: ${safeBranch}`,
151
+ `- seeded plan: ${PLANS_REL}/${safePlan}`,
152
+ `- handoff: ${handoffRel}`,
153
+ '',
154
+ '## MAIN orientation — derived LIVE from MAIN, never replayed from the provision record',
155
+ ];
156
+ if (queue !== null) {
157
+ lines.push(
158
+ `- series index: ${queue}`,
159
+ ` ${QUEUE_SHARED_RULE}`,
160
+ ...divergence('shared-queue', queue, recorded.sharedQueue, MAIN_CAUSE),
161
+ );
162
+ }
163
+ lines.push(
164
+ `- landing: ${rule}`,
165
+ commandLine(PROMPT_ACTORS.main, command),
166
+ ...divergence('landing', composeLandingValue({ rule, command }), recorded.landing, MAIN_CAUSE),
167
+ );
168
+ if (installDescription !== null) {
169
+ lines.push(
170
+ '',
171
+ '## This checkout is where the install posture comes from — probed here, not in MAIN',
172
+ `- install: ${installDescription}`,
173
+ ...(installCommand === null ? [] : [commandLine(PROMPT_ACTORS.here, installCommand)]),
174
+ ...divergence('install', installPosture, recorded.install, INSTALL_CAUSE),
175
+ );
176
+ }
177
+ lines.push(
178
+ '',
179
+ '## The return channel',
180
+ `${handoffRel} is the ONE channel back to MAIN: findings for the series index, decisions taken`,
181
+ 'here, and the session records all go there. Everything outside `## Provision record` is yours and',
182
+ `the tool preserves it byte for byte, while ${PLANS_REL} itself never lands — a note left anywhere`,
183
+ 'else in this worktree is lost at cleanup.',
184
+ '',
185
+ '## Bars — kept, not enforced',
186
+ ONE_WRITER_BAR,
187
+ FORBIDDEN_VERBS_BAR,
188
+ );
189
+ return lines.join('\n');
190
+ };