@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.
@@ -20,6 +20,7 @@ import {
20
20
  short, shellQuote, writerCommand,
21
21
  collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
22
22
  } from './flow-check-rungs.mjs';
23
+ import { CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
23
24
 
24
25
  // The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
25
26
  // operand shapes: flag values ride the inline --flag='value' form and positionals follow a
@@ -128,10 +129,21 @@ const deltaRefusals = (records, owner) => {
128
129
  // degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
129
130
  // fingerprint completed (its `final` record landed after it). The checker reads raw records,
130
131
  // never the authoritative selection (#65).
131
- const degradeOrderingRefusals = (coreRecords) => {
132
+ const degradeOrderingRefusals = (coreRecords, advisories) => {
132
133
  const refusals = [];
134
+ let contentFree = 0;
133
135
  coreRecords.forEach((r, i) => {
134
136
  if (r.kind !== 'degrade') return;
137
+ // A degrade carries no base and no attempt, so on the content-free fingerprint two records
138
+ // from unrelated clean moments pair up and this rung refuses EVERY commit, whatever tree is
139
+ // being judged. Their order is not a fact about any tree, so it decides nothing here. Stated
140
+ // residual: #64 is therefore unenforceable for a degrade minted on a clean tree until the
141
+ // record carries a base — the queued store migration owns that, and a clean tree gates nothing
142
+ // meanwhile.
143
+ if (r.fingerprint === CONTENT_FREE_FINGERPRINT) {
144
+ contentFree += 1;
145
+ return;
146
+ }
135
147
  const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
136
148
  if (!startedBefore) return;
137
149
  const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
@@ -140,6 +152,9 @@ const degradeOrderingRefusals = (coreRecords) => {
140
152
  refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
141
153
  }
142
154
  });
155
+ if (contentFree > 0) {
156
+ advisories.push(`${contentFree} core degrade(s) minted on a CONTENT-FREE tree are outside the ordering rung (#64): the record carries no base, so two clean moments cannot be shown to be one and their order states nothing about any tree`);
157
+ }
143
158
  return refusals;
144
159
  };
145
160
 
@@ -225,7 +240,7 @@ const baseMotionRefusals = (chain, planId, owner, motion) => {
225
240
  // Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
226
241
  // byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
227
242
  // to the STRICT lane, so a caller that forgets to thread it inherits strictness.
228
- export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
243
+ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard', treeCarriesBytes = true }) => {
229
244
  const refusals = [];
230
245
  const advisories = [];
231
246
  if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
@@ -243,11 +258,25 @@ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flo
243
258
  if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
244
259
  }
245
260
  refusals.push(...deltaRefusals(records, owner));
246
- refusals.push(...degradeOrderingRefusals(coreRead.records));
261
+ refusals.push(...degradeOrderingRefusals(coreRead.records, advisories));
247
262
  if (evidence != null) {
248
- refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
249
- refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
250
- refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
263
+ // The base-keyed rung always runs it asks about this BASE's gate history, which a tree with
264
+ // no content does not change. The two FINGERPRINT-keyed rungs do not, when the CALLER states
265
+ // that the tree carries no bytes: their coverage would then be demanded of the one fingerprint
266
+ // every clean moment shares, so whatever they found there was minted by another moment and
267
+ // possibly another base (the symmetry the #65 content-free arm and commit-guard's content-free
268
+ // lanes state — such evidence must decide nothing, in either direction). The fact is DECLARED
269
+ // by the caller rather than derived here: only the caller knows whether it is judging a commit
270
+ // at all, and a checker that keyed it off the fingerprint alone would also silence the rungs
271
+ // for every routine clean-tree check, where they are exactly what the operator asked for. The
272
+ // skip is RECORDED, never silent.
273
+ refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint, advisories }));
274
+ if (!treeCarriesBytes) {
275
+ advisories.push('the caller states this tree carries NO bytes, so the fingerprint-keyed correlations are skipped: degrade coverage (#25) and receipt coverage (#42) here, and the D10 flow-to-final binding at the guard — evidence at a content-free fingerprint belongs to some other clean moment');
276
+ } else {
277
+ refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
278
+ refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
279
+ }
251
280
  }
252
281
  return { refusals, advisories };
253
282
  };
@@ -13,7 +13,7 @@ import {
13
13
  CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords, flowTreeIdentity,
14
14
  } from './flow-record.mjs';
15
15
  import { resolveRecordReference } from './flow-store.mjs';
16
- import { authoritativeOfKind, summarizeReviewReceiptsForTree } from './core-evidence.mjs';
16
+ import { authoritativeOfKind, summarizeReviewReceiptsForTree, CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
17
17
  import { FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
18
18
 
19
19
  export const short = (digest) => `${digest.slice(0, 12)}…`;
@@ -161,7 +161,21 @@ export const evaluateVetoOverride = ({ records, vetoReceipt, tree }) => {
161
161
  // runner-attested capability (a one-time unpublished nonce over stdin or an inherited FD, verified
162
162
  // against a one-way commitment recorded in the final-start); it needs its own IPC contract and is
163
163
  // QUEUED, never pretended here.
164
- export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null }) => {
164
+ //
165
+ // The CONTENT-FREE arm (FINGERPRINT-BASE-BINDING, second face): a red final minted while the work
166
+ // tree was CLEAN hashes an empty payload, and that one value is shared by every clean moment of
167
+ // every repository — so the base correlation resolves to as many bases as the store has clean
168
+ // moments and refuses fail-closed forever, on a record that describes no working state at all.
169
+ // The arm is keyed on the FINGERPRINT, not on how many bases happen to correlate: resolving to
170
+ // exactly one base is an accident of store history, never a statement about the tree. It is one
171
+ // half of a symmetry, and it is only sound WITH the other: content-free evidence must decide
172
+ // nothing in either direction, so commit-guard equally refuses to ATTEST from a content-free
173
+ // receipt (commit-guard.mjs, the two content-free lanes) — without that half, stepping over a red
174
+ // here would leave a stale green free to acquit. The skip is RECORDED in the `advisories` sink —
175
+ // a rung that steps over evidence says so. That sink is the ONLY place the skip is observable:
176
+ // decideFlowCheck always passes one, and a caller that omits it (a test, a future consumer) gets
177
+ // the same refusals and no record of the step.
178
+ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null, advisories = [] }) => {
165
179
  if (!hasOwnAdoption(flowRecords, owner)) return [];
166
180
  const adoptionInstants = flowRecords
167
181
  .filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
@@ -192,6 +206,10 @@ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, current
192
206
  for (const { r, i } of finals) {
193
207
  if (r.status !== 'red') continue;
194
208
  if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
209
+ if (r.fingerprintBefore === CONTENT_FREE_FINGERPRINT) {
210
+ advisories.push(`a red final (attempt "${r.attempt}") is OUTSIDE the rung: its tree fingerprint ${short(r.fingerprintBefore)} is CONTENT-FREE — a clean work tree emits an empty payload, so the value identifies no working state, correlates to no base, and gates no commit (#65 content-free arm)`);
211
+ continue;
212
+ }
195
213
  const bases = basesAt(r.fingerprintBefore);
196
214
  if (bases.length === 0) {
197
215
  refusals.push(`a red final (attempt "${r.attempt}") cannot be base-correlated: no flow record carries its tree fingerprint ${short(r.fingerprintBefore)} — the zero-base lane is a fail-closed ambiguity (#65); the rung demands exactly ONE base through the flow store`);
@@ -60,7 +60,7 @@ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 }
60
60
  // PREVIOUS one and an in-matrix comparison would make a new green final unreachable. The SAME
61
61
  // distinction reaches the #65 unanswered-red rung (its own header states the split): the 'gate'
62
62
  // lane also answers a red under a provable in-progress retry, for the same reason.
63
- export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {} } = {}) => {
63
+ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {}, treeCarriesBytes = true } = {}) => {
64
64
  const fingerprintProbe = probes.fingerprint ?? computeTreeFingerprint;
65
65
  const owner = deriveFlowOwner(cwd);
66
66
  if (owner == null) {
@@ -161,14 +161,29 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
161
161
  // authoritative completed final at the CURRENT fingerprint, status green FIRST (a newer red
162
162
  // is never bypassed by an older green's matching hash — the guard's own red arm refuses it),
163
163
  // then the hash comparison.
164
+ // …and NOT when the CALLER states the tree carries no bytes: the receipt it would bind was then
165
+ // found at the one fingerprint every clean moment of every repository shares, so it was minted by
166
+ // some other moment and its projection hash describes that moment's store, not this one.
167
+ // Correlating it would make the outcome depend on which stray clean moment the store recorded
168
+ // last — the same fact the #65 rung and commit-guard's own content-free lanes apply.
169
+ // The probe stays INSIDE this lane: an armed decision already paid for the tree (evidence.tree),
170
+ // and every other lane — the gate consumer, a broken store, an unarmed flow — must stay inert.
171
+ // A hoisted probe would make those lanes read the whole tree and let an unreadable untracked
172
+ // file throw where the answer is otherwise a quiet, healthy no-op.
164
173
  const bindingRefusals = [];
165
- if (consumer === 'commit-guard' && !healthBroken) {
174
+ const completedFinals = consumer === 'commit-guard' && !healthBroken && treeCarriesBytes
175
+ ? authoritativeOfKind(coreRead.records, 'final')
176
+ : [];
177
+ // …and only when a final record EXISTS to bind: with none in the store there is no binding to
178
+ // verify, so reading the tree would answer a question nobody asked.
179
+ if (completedFinals.length > 0) {
166
180
  const currentFingerprint = evidence?.tree.fingerprint ?? fingerprintProbe(cwd);
167
181
  if (currentFingerprint == null) {
168
182
  bindingRefusals.push('the current tree fingerprint is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run run-gates.mjs --final on a healthy tree');
169
183
  }
170
- const currentFinal = currentFingerprint == null ? undefined : authoritativeOfKind(coreRead.records, 'final')
171
- .find((r) => r.fingerprintBefore === currentFingerprint);
184
+ const currentFinal = currentFingerprint == null
185
+ ? undefined
186
+ : completedFinals.find((r) => r.fingerprintBefore === currentFingerprint);
172
187
  if (currentFinal !== undefined && currentFinal.status === 'green') {
173
188
  const bound = currentFinal.evidenceHashes?.flow;
174
189
  if (typeof bound === 'string') {
@@ -193,7 +208,7 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
193
208
  }
194
209
  }
195
210
  }
196
- const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer });
211
+ const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer, treeCarriesBytes });
197
212
  // Semantic refusals bind only an ARMED store; the D10 binding refusals ride the commit-guard
198
213
  // lane UNCONDITIONALLY — a deleted or truncated store must never un-arm the binding.
199
214
  const effectiveRefusals = healthBroken ? refusals : [...(armed ? [...refusals, ...evidenceRefusals] : []), ...bindingRefusals];
@@ -0,0 +1,123 @@
1
+ // observation-builder.mjs — measure a scope and construct the OBSERVATION record (delegation
2
+ // Plan 3, Phase 3). Extracted from dispatch.mjs so `dispatch observe` and the handoff-return rung
3
+ // build the IDENTICAL record through ONE path: the rung is imported by dispatch.mjs for its verb,
4
+ // so a rung importing dispatch.mjs back would be exactly the cycle test/read-graph-purity.test.mjs
5
+ // pins the tools graph against. Every function here moved byte-for-byte; the only new export is
6
+ // the record construction the observe verb previously inlined.
7
+ //
8
+ // Spawns nothing but git READS (through the shared gitLine). No CLI, no writes, no side effects on
9
+ // import. Dependency-free, Node >= 22.
10
+
11
+ import { readFileSync, openSync, closeSync, realpathSync, constants as fsConstants } from 'node:fs';
12
+ import { resolve, sep } from 'node:path';
13
+ import {
14
+ DELEGATION_SCHEMA_VERSION, computeNumerator, evaluateObservationEligibility,
15
+ } from './dispatch-record.mjs';
16
+ import { lstatNoFollowRead, describeNonRegular } from './fs-read-nofollow.mjs';
17
+ import { gitLine } from './flow-store-read.mjs';
18
+ import { lexicalRepoRelative } from './repo-lex.mjs';
19
+
20
+ // The solo baseline counts each scope object's POST-IMAGE — the bytes on disk after the
21
+ // construction — which is exactly the `new` numerator rule (D6). No exec diff kind enumerates
22
+ // ranges, so a measured observation can never claim a partial object.
23
+ const SOLO_COMPONENT_KIND = 'new';
24
+
25
+ // The scope's anchor is the git TOP-LEVEL, never the caller's cwd: a recorded scope must name the
26
+ // same objects whoever runs the tool from wherever. `null` outside a work tree — a repo-relative
27
+ // domain with no repository has nothing to be relative TO, and falling back to cwd would be a
28
+ // second, incompatible semantics for the same field.
29
+ export const resolveRepoRoot = (cwd) => {
30
+ if (gitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
31
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
32
+ return top === null ? null : realpathSync(top);
33
+ };
34
+
35
+ // The read is no-follow on the LEAF (a symlinked leaf is already refused by name above; O_NOFOLLOW
36
+ // makes a swap between the classification and the read fail loudly rather than counting another
37
+ // object's bytes). Honest limit: classify-then-read is not race-free, and it is not meant to be —
38
+ // the scope is the orchestrator's OWN work tree and the result is a MAGNITUDE, never a store
39
+ // identity, so a pathname race costs a wrong byte count, not a forged record.
40
+ const readObjectBytes = (path) => {
41
+ const fd = openSync(path, (fsConstants.O_RDONLY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
42
+ try {
43
+ return readFileSync(fd);
44
+ } finally {
45
+ closeSync(fd);
46
+ }
47
+ };
48
+
49
+ // One scope object → one numerator entry. Refuses by NAME on anything it cannot count honestly: a
50
+ // path escaping the repo LEXICALLY, an absent path, a non-regular path (a symlinked leaf included —
51
+ // following one would count another object's bytes under this name), and a path whose REAL location
52
+ // is outside the repository. The last one is the case the lexical rule alone cannot see: it rejects
53
+ // `../x` while accepting `link/x`, where `link` is an ancestor symlink pointing out of the tree.
54
+ // The identity is the CANONICAL repo-relative path taken from the verified real path — not a content
55
+ // hash. The solo domain has no rename lineage for a content id to protect, and a content id would
56
+ // let one object read between two measurements look like TWO objects instead of refusing as the
57
+ // producer contradiction it is ("one identity, one size"). Two equal-byte files at different paths
58
+ // are two objects and count twice; one path reached twice (a second listing, an in-repo ancestor
59
+ // symlink) is one object and counts once.
60
+ export const measureObject = (root, rel) => {
61
+ const lexical = lexicalRepoRelative(rel);
62
+ if (!lexical.ok) return { ok: false, reason: `scope path "${rel}": ${lexical.reason}` };
63
+ const path = resolve(root, rel);
64
+ const stat = lstatNoFollowRead(path);
65
+ if (stat === null) return { ok: false, reason: `scope path "${rel}" does not exist — an observation counts objects that are actually there (fail closed)` };
66
+ if (!stat.isFile()) return { ok: false, reason: `scope path "${rel}" is a ${describeNonRegular(stat)}, not a regular file — the scope names repository objects (fail closed)` };
67
+ const real = realpathSync(path);
68
+ if (!real.startsWith(`${root}${sep}`)) {
69
+ return { ok: false, reason: `scope path "${rel}" resolves to ${real}, which leaves the repository at ${root} — an ancestor symlink is not a way out of the scope domain (fail closed)` };
70
+ }
71
+ const canonical = real.slice(root.length + 1);
72
+ const bytes = readObjectBytes(path);
73
+ return { ok: true, entry: { kind: SOLO_COMPONENT_KIND, path: canonical, objectId: canonical, postImageBytes: bytes.length } };
74
+ };
75
+
76
+ // One repo-relative path per scope entry, in the order given. The measured CANONICAL paths become
77
+ // the record's `scope` as a canonical JSON array, so what was measured and what is written down are
78
+ // the same statement — and a path carrying a space says so unambiguously.
79
+ export const measureScope = (root, paths) => {
80
+ const entries = [];
81
+ for (const rel of paths) {
82
+ const measured = measureObject(root, rel);
83
+ if (!measured.ok) return measured;
84
+ entries.push(measured.entry);
85
+ }
86
+ const numerator = computeNumerator(entries);
87
+ return numerator.ok
88
+ ? { ...numerator, scope: JSON.stringify(entries.map((e) => e.path)) }
89
+ : { ok: false, reason: numerator.reason };
90
+ };
91
+
92
+ export const ratio = (value) => value.toFixed(3);
93
+
94
+ // L is printed ONLY where the metric is eligible: an ineligible metric has a NAMED reason and no
95
+ // ratio at all, and printing a number beside the name is how a silent zero gets read as a
96
+ // measurement.
97
+ export const formatRatio = (metric) => (metric.eligible
98
+ ? `L = ${ratio(metric.numeratorBytes / metric.denominatorBytes)} (${metric.numeratorBytes} B / ${metric.denominatorBytes} B)`
99
+ : `L = n/a — INELIGIBLE (${metric.ineligibleReason})`);
100
+
101
+ // The ONE observation-record construction: the eligibility is evaluated here, over the same two
102
+ // numbers the record carries, so a record and its own eligibility can never disagree at the door.
103
+ export const buildObservationRecord = ({ waveId, stepClass, measured, provenance, denominatorBytes, planId, phase, timestamp }) => {
104
+ const eligibility = evaluateObservationEligibility({ numeratorBytes: measured.numeratorBytes, denominatorBytes });
105
+ return {
106
+ schema: DELEGATION_SCHEMA_VERSION,
107
+ kind: 'observation',
108
+ waveId,
109
+ stepClass,
110
+ scope: measured.scope,
111
+ metric: {
112
+ numeratorBytes: measured.numeratorBytes,
113
+ denominatorBytes,
114
+ components: measured.components,
115
+ provenance,
116
+ eligible: eligibility.eligible,
117
+ ineligibleReason: eligibility.ineligibleReason,
118
+ },
119
+ planId,
120
+ phase,
121
+ timestamp,
122
+ };
123
+ };
@@ -0,0 +1,179 @@
1
+ // satellite-locator.mjs — slug → satellite worktree, and the proof that the handoff found there IS
2
+ // that satellite's identity (delegation Plan 3, Phase 2).
3
+ //
4
+ // Extracted out of worktrees.mjs so BOTH modes can ask the question: `worktrees prompt` composes a
5
+ // satellite's cold-start prompt today, and the dispatch-side handoff-return rung — a later phase,
6
+ // not yet wired — will read what came back through this same leaf. That rung is a `dispatch` verb,
7
+ // so this leaf is what will keep the 3200-line worktrees tool out of the dispatch CLI's closure.
8
+ //
9
+ // READ-ONLY by construction: it writes nothing, spawns nothing, and — deliberately — CONTENT-READS
10
+ // nothing of its own. The git runner and the fs seams are INJECTED, so a caller decides what may
11
+ // run, and the content read arrives as `fs.readFileNoFollow(abs)` returning the family's structured
12
+ // outcome { bytes } | { absent } | { unsafe } | { error: code }. That is not fastidiousness: the ONE
13
+ // no-follow read door lives in worktrees.mjs, its single body is pinned there by a tripwire, and a
14
+ // second body here would be exactly the duplication that pin exists to prevent. `fs` is the shape
15
+ // worktrees.mjs builds: { lstat, readdir, realpath, readFileNoFollow }.
16
+ //
17
+ // Node built-ins plus two pure leaves. No side effects on import; no CLI. Dependency-free, Node >= 22.
18
+
19
+ import { join } from 'node:path';
20
+ import { PLANS_REL } from './plan-files.mjs';
21
+ import { stop, handoffBasename, parseProvisionRecord, displayValue } from './worktrees-record.mjs';
22
+
23
+ // Every refusal below is the worktrees STOP, unchanged — a caller that reached this leaf directly
24
+ // (the dispatch side does) must be able to recognize it without importing worktrees.mjs.
25
+ export { WORKTREES_STOP } from './worktrees-record.mjs';
26
+
27
+ export const DEFAULT_BRANCH_PREFIX = 'aw/';
28
+
29
+ export const parseWorktreeList = (text) => {
30
+ const entries = [];
31
+ let fields = [];
32
+ const finishEntry = () => {
33
+ if (fields.length === 0) return;
34
+ const entry = { path: null, head: null, branch: null, detached: false, prunable: false, bare: false };
35
+ for (const field of fields) {
36
+ if (field.startsWith('worktree ')) entry.path = field.slice('worktree '.length);
37
+ else if (field.startsWith('HEAD ')) entry.head = field.slice('HEAD '.length);
38
+ else if (field.startsWith('branch ')) entry.branch = field.slice('branch '.length);
39
+ else if (field === 'detached') entry.detached = true;
40
+ else if (field === 'bare') entry.bare = true;
41
+ else if (field === 'prunable' || field.startsWith('prunable ')) entry.prunable = true;
42
+ }
43
+ if (entry.path !== null) entries.push(entry);
44
+ fields = [];
45
+ };
46
+ for (const field of String(text).split('\0')) {
47
+ if (field === '') finishEntry();
48
+ else fields.push(field);
49
+ }
50
+ finishEntry();
51
+ return entries;
52
+ };
53
+
54
+ export const listWorktrees = (git, cwd) => {
55
+ const r = git(['worktree', 'list', '--porcelain', '-z'], cwd);
56
+ if (r.status !== 0) throw stop(`git worktree list failed: ${r.stderr.trim() || r.stdout.trim()}`);
57
+ return parseWorktreeList(r.stdout);
58
+ };
59
+
60
+ export const classifyNodeNoFollow = (path, fs) => {
61
+ const node = (() => {
62
+ try {
63
+ return { stat: fs.lstat(path) };
64
+ } catch (error) {
65
+ return error?.code === 'ENOENT'
66
+ ? { stat: null }
67
+ : { error: error?.code ?? 'fs error' };
68
+ }
69
+ })();
70
+ if (node.error) return { kind: 'error', error: node.error };
71
+ if (node.stat === null) return { kind: 'absent' };
72
+ if (!node.stat.isSymbolicLink()) {
73
+ if (node.stat.isDirectory()) return { kind: 'plain-directory', stat: node.stat };
74
+ if (node.stat.isFile()) return { kind: 'regular-file', stat: node.stat };
75
+ return { kind: 'special', stat: node.stat };
76
+ }
77
+ const realPath = (() => {
78
+ try {
79
+ return { path: fs.realpath(path) };
80
+ } catch (error) {
81
+ return { error: error?.code ?? 'fs error' };
82
+ }
83
+ })();
84
+ if (realPath.error) return { kind: 'symlink-unresolvable', error: realPath.error };
85
+ const target = (() => {
86
+ try {
87
+ return { stat: fs.lstat(realPath.path) };
88
+ } catch (error) {
89
+ return { error: error?.code ?? 'fs error' };
90
+ }
91
+ })();
92
+ if (target.error) return { kind: 'symlink-unresolvable', error: target.error };
93
+ if (target.stat.isDirectory()) return { kind: 'symlink-to-directory', realPath: realPath.path, stat: node.stat };
94
+ if (target.stat.isFile()) return { kind: 'symlink-to-file', realPath: realPath.path, stat: node.stat };
95
+ return { kind: 'symlink-to-special', realPath: realPath.path, stat: node.stat };
96
+ };
97
+
98
+ // Whole-chain no-follow: the worktree root, docs, and docs/plans must be plain directories;
99
+ // handoff candidates count ONLY as regular files. states: ok | absent | unreadable.
100
+ // ANY stat failure (not just readdir) renders honestly — list must never crash on a bad node.
101
+ export const scanPlansDir = ({ wtRoot, fs }) => {
102
+ if (classifyNodeNoFollow(wtRoot, fs).kind !== 'plain-directory') return { state: 'unreadable' };
103
+ const docs = classifyNodeNoFollow(join(wtRoot, 'docs'), fs);
104
+ if (docs.kind === 'absent') return { state: 'absent' };
105
+ if (docs.kind !== 'plain-directory') return { state: 'unreadable' };
106
+ const plans = classifyNodeNoFollow(join(wtRoot, PLANS_REL), fs);
107
+ if (plans.kind === 'absent') return { state: 'absent' };
108
+ if (plans.kind !== 'plain-directory') return { state: 'unreadable' };
109
+ let names;
110
+ try {
111
+ names = fs.readdir(join(wtRoot, PLANS_REL));
112
+ } catch {
113
+ return { state: 'unreadable' };
114
+ }
115
+ const handoffs = [];
116
+ const nonRegular = [];
117
+ for (const n of names) {
118
+ if (!/^handoff-.+\.md$/.test(n)) continue;
119
+ const cand = classifyNodeNoFollow(join(wtRoot, PLANS_REL, n), fs);
120
+ if (cand.kind !== 'regular-file') nonRegular.push(n);
121
+ else handoffs.push(n);
122
+ }
123
+ return { state: 'ok', handoffs, nonRegular };
124
+ };
125
+
126
+ export const branchNameOf = (entry) => entry.branch?.replace(/^refs\/heads\//, '') ?? null;
127
+
128
+ export const findSatelliteEntry = ({ root, slug, branch, git, fs }) => {
129
+ const entries = listWorktrees(git, root).slice(1);
130
+ const exactHandoff = [];
131
+ for (const entry of entries) {
132
+ if (entry.prunable) continue;
133
+ const scan = scanPlansDir({ wtRoot: entry.path, fs });
134
+ if (scan.state === 'ok' && scan.handoffs.includes(handoffBasename(slug))) exactHandoff.push(entry);
135
+ }
136
+ if (exactHandoff.length > 1) {
137
+ throw stop(`multiple worktrees carry ${handoffBasename(slug)} — cleanup the duplicate identity before continuing`);
138
+ }
139
+ if (branch !== null) {
140
+ const byBranch = entries.filter((entry) => entry.branch === `refs/heads/${branch}`);
141
+ if (byBranch.length > 1) throw stop(`multiple worktrees claim branch ${branch}`);
142
+ if (byBranch.length === 1) return byBranch[0];
143
+ }
144
+ if (exactHandoff.length === 1) return exactHandoff[0];
145
+ const fallback = entries.filter((entry) => entry.branch === `refs/heads/${DEFAULT_BRANCH_PREFIX}${slug}`);
146
+ if (fallback.length === 1) return fallback[0];
147
+ throw stop(`no registered satellite worktree for ${slug}`);
148
+ };
149
+
150
+ export const readSatelliteIdentity = ({ entry, slug, expectedBranch, fs, abandon = false }) => {
151
+ const name = handoffBasename(slug);
152
+ const scan = scanPlansDir({ wtRoot: entry.path, fs });
153
+ if (scan.state === 'ok' && scan.nonRegular.includes(name)) {
154
+ throw stop(`handoff identity mismatch: ${name} is not a regular file`);
155
+ }
156
+ if (scan.state !== 'ok' || !scan.handoffs.includes(name)) {
157
+ if (abandon) throw stop(`${name} is absent — force deletion is forbidden without the handoff identity`);
158
+ throw stop(`handoff identity mismatch: expected ${name} in the satellite`);
159
+ }
160
+ // Every value below reaches a terminal, and every one of them is foreign: the names come from a
161
+ // directory listing, the record fields from a hand-editable file. They render ESCAPED — the guard
162
+ // that refuses them for the prompt must not be undone by the message that reports them.
163
+ if (scan.handoffs.length !== 1) {
164
+ throw stop(`handoff identity mismatch: expected exactly ${name}, found [${scan.handoffs.map(displayValue).join(', ')}]`);
165
+ }
166
+ const leaf = fs.readFileNoFollow(join(entry.path, PLANS_REL, name));
167
+ if (!leaf.bytes) throw stop(`handoff identity mismatch: ${name} is not readable as a regular file`);
168
+ const record = parseProvisionRecord(String(leaf.bytes));
169
+ const liveBranch = branchNameOf(entry);
170
+ const wantedBranch = expectedBranch ?? liveBranch;
171
+ if (record.slug !== slug || record.branch !== wantedBranch || liveBranch !== wantedBranch) {
172
+ throw stop(
173
+ `handoff identity mismatch: expected slug ${slug} and branch ${displayValue(wantedBranch)}; ` +
174
+ `record has slug ${record.slug === null ? '(missing)' : displayValue(record.slug)} and branch ${record.branch === null ? '(missing)' : displayValue(record.branch)}, ` +
175
+ `live branch ${liveBranch === null ? '(detached)' : displayValue(liveBranch)}`,
176
+ );
177
+ }
178
+ return { record, path: join(entry.path, PLANS_REL, name), branch: wantedBranch };
179
+ };