@sabaiway/agent-workflow-kit 5.3.0 → 5.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +53 -5
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +622 -30
  9. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +731 -3
  10. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  11. package/bridges/codex-cli-bridge/capability.json +15 -10
  12. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +16 -12
  13. package/capability.json +1 -1
  14. package/package.json +1 -1
  15. package/references/modes/core-evidence.md +1 -1
  16. package/references/modes/coverage-check.md +1 -1
  17. package/references/modes/dispatch.md +29 -0
  18. package/references/modes/gates.md +7 -2
  19. package/references/modes/receipt-deadline.md +3 -3
  20. package/references/modes/recommendations.md +3 -1
  21. package/references/modes/upgrade.md +1 -1
  22. package/references/modes/velocity.md +5 -1
  23. package/references/scripts/migrate-gates.mjs +102 -10
  24. package/references/scripts/migrate-gates.test.mjs +37 -0
  25. package/tools/commands.mjs +7 -0
  26. package/tools/core-evidence.mjs +79 -5
  27. package/tools/coverage-check.mjs +23 -7
  28. package/tools/coverage-producer.mjs +68 -0
  29. package/tools/coverage-state.mjs +24 -0
  30. package/tools/declared-paths.mjs +32 -0
  31. package/tools/detect-backends.mjs +5 -4
  32. package/tools/dispatch-record.mjs +10 -3
  33. package/tools/dispatch-store.mjs +392 -0
  34. package/tools/dispatch.mjs +1779 -0
  35. package/tools/doc-parity.mjs +27 -4
  36. package/tools/exec-producer.mjs +483 -0
  37. package/tools/exec-receipt.mjs +263 -0
  38. package/tools/flow-store.mjs +111 -462
  39. package/tools/gates-declaration.mjs +49 -0
  40. package/tools/gates-init.mjs +83 -6
  41. package/tools/receipt-deadline.mjs +25 -3
  42. package/tools/recommendations.mjs +63 -19
  43. package/tools/release-scan.mjs +33 -0
  44. package/tools/run-gates.mjs +111 -32
  45. package/tools/store-append.mjs +444 -0
  46. package/tools/velocity-profile.mjs +102 -23
@@ -33,6 +33,8 @@ import {
33
33
  ACKS_FILE,
34
34
  } from './recommendations.mjs';
35
35
  import { SKIPPED_READONLY } from './setup-backends.mjs';
36
+ // The host-conditional qualifier every settings-derived runtime claim carries (Decision 11).
37
+ import { HOST_HONORS_QUALIFIER } from './velocity-profile.mjs';
36
38
  import { LATENT_ARM_NOTICE } from './review-state.mjs';
37
39
  import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE, RESUME_VERIFY_RULE } from './worktrees.mjs';
38
40
  // The flow contract constants: the accepted schema version + the honest lagging-kit sentence
@@ -42,6 +44,9 @@ import { FLOW_SCHEMA_VERSION, FLOW_LAGGING_KIT_CONTRACT } from './orchestration-
42
44
  import { FLOW_BOOKKEEPING_FLOOR_RESIDUAL } from './set-flow.mjs';
43
45
  import { FLOW_ARMED_HALVES_HEADER } from './procedures.mjs';
44
46
  import { RECEIPT_DEADLINE_CONTRACT } from './receipt-deadline.mjs';
47
+ import { DISPATCH_CONTRACT } from './dispatch.mjs';
48
+ // The coverage vocabulary leaf: a CLOSED value set the gates contract doc must enumerate.
49
+ import { COVERAGE } from './coverage-state.mjs';
45
50
 
46
51
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
47
52
 
@@ -55,6 +60,8 @@ const WORKTREES_DOC = 'references/modes/worktrees.md';
55
60
  const PROCEDURES_DOC = 'references/modes/procedures.md';
56
61
  const SET_FLOW_DOC = 'references/modes/set-flow.md';
57
62
  const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
63
+ const GATES_DOC = 'references/modes/gates.md';
64
+ const DISPATCH_DOC = 'references/modes/dispatch.md';
58
65
 
59
66
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
60
67
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -99,6 +106,11 @@ export const BINDINGS = Object.freeze([
99
106
  // (the incident's "mode-doc apply text stays in lockstep" acceptance as a mechanism, not prose).
100
107
  // Bound in BOTH docs that name the path (recommendations.md + velocity.md).
101
108
  valueBinding('acks-file', ACKS_FILE, ACKS_FILE, [RECOMMENDATIONS_DOC, VELOCITY_DOC]),
109
+ // The host-conditional qualifier (Decision 11): the tier notice, the USAGE text and the autonomy
110
+ // render all state a settings key's RUNTIME effect through this one phrase, so the mode doc that
111
+ // documents those surfaces must carry it too — a reworded doc that quietly re-promises the effect
112
+ // fails here instead of shipping.
113
+ valueBinding('host-honors-qualifier', HOST_HONORS_QUALIFIER, HOST_HONORS_QUALIFIER, [VELOCITY_DOC]),
102
114
  // The refresh read-only degrade outcome (REFRESH-EROFS-HONESTY / AD-056): the new skipped-readonly
103
115
  // token must render in BOTH mode contracts that enumerate the placed-bridge refresh outcomes
104
116
  // (setup.md owns --refresh-placed; upgrade.md pastes its lines) — a reworded doc dropping the
@@ -142,6 +154,15 @@ export const BINDINGS = Object.freeze([
142
154
  // satisfaction — is the tool's identity; a mode doc silently drifting off it would re-open the
143
155
  // #50 misclassification this runner exists to close.
144
156
  valueBinding('receipt-deadline-contract', RECEIPT_DEADLINE_CONTRACT, RECEIPT_DEADLINE_CONTRACT, [RECEIPT_DEADLINE_DOC]),
157
+ // The delegation engine's contract sentence (delegation Plan 1 Phase 3): the FORM-only limit and
158
+ // the aggregator's refusals are exactly what a reader must not be able to mis-learn from the mode
159
+ // doc — a doc that softened either would promise a judgment the checker never makes, or a number
160
+ // the aggregator refuses to compute.
161
+ valueBinding('dispatch-contract', DISPATCH_CONTRACT, DISPATCH_CONTRACT, [DISPATCH_DOC]),
162
+ // The runner's `coverage=` summary vocabulary (Decision 8): the gates contract doc enumerates the
163
+ // CLOSED value set, so a renamed or added value fails here instead of leaving the doc describing
164
+ // a vocabulary the runner no longer speaks. One binding per value — the set is small and closed.
165
+ ...Object.values(COVERAGE).map((value) => valueBinding(`coverage-state:${value}`, value, `\`coverage=${value}\``, [GATES_DOC])),
145
166
  ].map((b) => Object.freeze(b)));
146
167
 
147
168
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -187,12 +208,14 @@ Usage:
187
208
 
188
209
  A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
189
210
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
190
- contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
191
- degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
211
+ contract (section header, empty line, verdict templates), the acks-store path, the host-conditional
212
+ qualifier every settings-derived runtime claim carries, the setup refresh degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
192
213
  orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
193
214
  worktrees cleanup-ownership rule, the worktrees include-identity rule, the worktrees
194
- resume-verify rule, and the flow tolerate contract (the accepted flow schema version + the
195
- lagging-kit sentence, procedures.md) to
215
+ resume-verify rule, the flow tolerate contract (the accepted flow schema version + the
216
+ lagging-kit sentence, procedures.md), the receipt-deadline arrival contract, the dispatch engine's
217
+ FORM-only + aggregate-refusal contract (dispatch.md), and the runner's closed coverage= summary
218
+ vocabulary (gates.md) — to
196
219
  the exact token its references/modes/*.md contract must carry, and
197
220
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
198
221
  or an absent token FAILS CLOSED.
@@ -0,0 +1,483 @@
1
+ // exec-producer.mjs — the GIT-side metric producer for an exec return (delegation Plan 2, Phase 1).
2
+ // It answers the two questions a return record cannot answer for itself: which objects the delegated
3
+ // run touched and how many bytes they represent, and what the canonical integration bundle is. It
4
+ // never writes, never appends and never spawns anything but git reads.
5
+ //
6
+ // dispatch-record.mjs:74-76 named this module before it existed: "the metric's byte domains are
7
+ // computed over STRUCTURED inputs handed in by a producer; the git-side producer is Plan 2, and it
8
+ // owns minting a stable objectId".
9
+ //
10
+ // ONE DOMAIN, THREE LAYERS (D7). The enumeration walks HEAD → index → worktree, exactly the layers
11
+ // the canonical payload concatenates — never the collapsed HEAD → worktree view. The difference is
12
+ // not academic: a staged change reverted in the worktree keeps its bytes in the payload (and so in
13
+ // the DENOMINATOR) while HEAD→worktree shows nothing at all, so the object would vanish from the
14
+ // NUMERATOR and the two halves of one ratio would describe different change sets. The diff bytes are
15
+ // computeFingerprintPayload's own bytes, imported rather than rebuilt.
16
+ //
17
+ // ONE OBJECT, ONE ENTRY. An object touched in both layers is a single entry, and its pre-image is
18
+ // always the HEAD blob — never the index blob: the numerator answers what the delegate could have
19
+ // spared the ORCHESTRATOR, measured against the dispatch baseline, and a metric-eligible dispatch
20
+ // starts from a clean tree where HEAD is that baseline.
21
+ //
22
+ // RENAMES ARE NOT RECONSTRUCTED, AND THAT IS THE DESIGN — a boundary, not an omission. git's OWN
23
+ // rename detection is consumed where git offers it (`--raw -M` sees a staged `git mv` within a layer),
24
+ // but a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
25
+ // so every one arrives as a tracked deletion plus an UNTRACKED creation — is accounted as exactly
26
+ // that: a deletion plus a creation.
27
+ //
28
+ // This module once matched those pairs by blob identity, and the machinery was SUBTRACTED after it
29
+ // produced silent-error edges in four consecutive review rounds: hashing that read through symlinks
30
+ // (and blocked forever on a symlink to a FIFO), abbreviated object names that never matched in a
31
+ // SHA-256 repository, source images taken from the wrong layer, attribute filters keyed to the
32
+ // destination path, a failed probe read as "not a candidate", link targets keyed through a lossy
33
+ // decode, and an ambiguity rule that could not see its own second key. Every one of those was a
34
+ // numerator that was WRONG WITHOUT ANYONE NOTICING — the worst failure mode an accounting path has.
35
+ // An identity heuristic buys accuracy on the ordinary refactor and pays for it in exactly that
36
+ // currency, so the trade was refused.
37
+ //
38
+ // The cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
39
+ // them — and a blanket "always optimistic" would be a false reassurance about an accounting number.
40
+ // Write O for the pre-image size:
41
+ // • A PLAIN RENAME `a→b` emits `deleted pre:a (O)` + `new new:b (O)` = 2O against a recognised O.
42
+ // OVER-counts, always.
43
+ // • RENAME-THEN-EDIT emits `deleted pre:a (O)` + `new new:b (F')` = O+F' against a recognised O.
44
+ // OVER-counts, always.
45
+ // • RENAME-THEN-RECREATE is INDETERMINATE, and this is the one to be careful about. Moving `a` to
46
+ // `b` and writing a fresh `a` of size F leaves git reporting a MODIFIED `a` beside an untracked
47
+ // `b`, so the emission is `modified pre:a (O)` + `new new:b (O)` = 2O, against a recognised
48
+ // O+F. The sign CROSSES at F = O: with F < O it over-counts, with F > O it UNDER-counts, and the
49
+ // metric then reads pessimistic. There is no lane that could mark just this case ineligible —
50
+ // the evidence is byte-identical to copying `a` to `b` and editing `a` (no git datum separates
51
+ // them: the index stat cache would, but `ino` is legitimately zero under core.checkStat=minimal
52
+ // and on Windows), so a predicate for it cannot be evaluated at this layer, and one broad enough
53
+ // to catch it — any `modified` beside a `new` — would make most returns ineligible.
54
+ // Every object here is one git can PROVE: a tracked path with a recorded blob, or an untracked path
55
+ // that exists. Nothing is paired on a resemblance.
56
+ //
57
+ // CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and the limit is the PAYLOAD's, not this module's. `git
58
+ // diff` honours `diff.ignoreSubmodules` and skips index entries carrying assume-unchanged or
59
+ // skip-worktree, so a path can be changed and stay invisible to both probes below. The kit already
60
+ // owns a probe that sees them — computeWorkingState (core-evidence.mjs:341) forces
61
+ // `--ignore-submodules=none` and folds in flaggedIndexLag — and `isTreeClean` consumes it, so a
62
+ // dispatch opened over such a tree records baselineClean:false and its return is metric-INELIGIBLE by
63
+ // name. What this module must NOT do is force those flags HERE: D7 binds the numerator to the byte
64
+ // domain of computeFingerprintPayload, which uses the same plain `git diff` — forcing them on one
65
+ // side only would let the numerator count objects the denominator cannot see, a worse failure than
66
+ // the blindness. Closing it for real means changing the shared payload, which is a Plan-1-frozen
67
+ // surface the review lane also consumes; that is queued as its own item, and Phase 2's `return` owns
68
+ // the return-time guard.
69
+ //
70
+ // IDENTITY CARRIES ITS DOMAIN (D6). `pre:<path>` for an object with a pre-image (keyed by the name it
71
+ // HAD) and `new:<path>` for a created one. The prefix is load-bearing: a rename a→b beside a
72
+ // re-created `a` would otherwise hand two objects one identity, and computeNumerator refuses a second
73
+ // size under an id it already counted — the whole return would be refused for an ordinary refactor.
74
+ //
75
+ // TYPE BEATS STATUS for the size rule; STATUS decides the identity domain. A gitlink contributes ZERO
76
+ // bytes deliberately — moving one costs this repository no bytes — but the zero belongs to the image
77
+ // actually COUNTED, not to the entry's kind: a regular file or a symlink REPLACED by a gitlink is
78
+ // still emitted as `submodule` (the type matrix is D6's) while keeping its real pre-image size, since
79
+ // paying it zero would drop a whole object's bytes out of the numerator on a type change. Every OTHER
80
+ // unknown size REFUSES — the fail-closed rule is never a silent zero.
81
+ //
82
+ // A TRANSIENT object (staged, then deleted from the worktree) counts its INDEX image once: the
83
+ // delegate authored those bytes, the payload carries them in both layers, and dropping them would
84
+ // credit the delegate with less than it wrote.
85
+ //
86
+ // Fail closed: outside a git work tree, on an unborn branch (no HEAD means no pre-image to attribute
87
+ // bytes against), on any git read that fails, and on any size it cannot establish.
88
+
89
+ import { join } from 'node:path';
90
+ import { createHash } from 'node:crypto';
91
+ import {
92
+ gitBuf, computeFingerprintPayload, isBinaryFile, isNeverCommittableStat, resolveBase,
93
+ } from './core-evidence.mjs';
94
+ import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
95
+ import { frameIntegrationBundle } from './dispatch-record.mjs';
96
+
97
+ const refuse = (reason) => ({ ok: false, reason });
98
+
99
+ const GITLINK_MODE = '160000';
100
+ const SYMLINK_MODE = '120000';
101
+ const ABSENT_MODE = '000000';
102
+ const SUBMODULE_BYTES = 0;
103
+
104
+ export const PRE_IMAGE_ID_PREFIX = 'pre:';
105
+ export const NEW_IMAGE_ID_PREFIX = 'new:';
106
+
107
+ const isAbsentSha = (sha) => sha === undefined || /^0+$/.test(sha);
108
+
109
+ // PATHS ARE BYTES, and this module keys objects BY path. A NUL-delimited stream decoded whole with
110
+ // toString('utf8') folds every invalid byte to U+FFFD, so two DISTINCT paths — `x\xfe.txt` and
111
+ // `x\xff.txt` — arrive as one string and therefore as one objectId: computeNumerator then either
112
+ // refuses the whole return under a duplicate id or counts one object where there were two. So every
113
+ // -z stream is split into BUFFER segments and each name is trusted only if it survives a byte
114
+ // round-trip; a name that does not is a REFUSAL, never a repaired string. (The same defense runs one
115
+ // module down at core-evidence.mjs:199-210 — this is the family's idiom, not a new invention.)
116
+ const splitZBytes = (buf) => {
117
+ const out = [];
118
+ let start = 0;
119
+ for (let i = 0; i < buf.length; i += 1) {
120
+ if (buf[i] !== 0) continue;
121
+ out.push(buf.subarray(start, i));
122
+ start = i + 1;
123
+ }
124
+ if (start < buf.length) out.push(buf.subarray(start));
125
+ return out;
126
+ };
127
+
128
+ const decodesExactly = (slice) => Buffer.from(slice.toString('utf8'), 'utf8').equals(slice);
129
+
130
+ const undecodablePath = (where, slice) => refuse(`git reported a path in ${where} whose bytes are not valid UTF-8 (${JSON.stringify(slice.toString('utf8'))}) — decoding it would fold DISTINCT paths onto ONE objectId and silently merge or drop an object, so the enumeration refuses rather than counting on a collapsed identity (fail closed)`);
131
+
132
+ const gitTop = (cwd) => {
133
+ const buf = gitBuf(['rev-parse', '--show-toplevel'], cwd);
134
+ return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
135
+ };
136
+
137
+ const blobSize = (top, sha) => {
138
+ const buf = gitBuf(['cat-file', '-s', sha], top);
139
+ if (buf == null) return null;
140
+ const size = Number(buf.toString('utf8').trim());
141
+ return Number.isSafeInteger(size) && size >= 0 ? size : null;
142
+ };
143
+
144
+ // The raw record grammar, one shape for both layers: ":<srcMode> <dstMode> <srcSha> <dstSha> <status>"
145
+ // followed by one path, or two when the status renames. The META field is ASCII by construction; only
146
+ // the PATH fields carry arbitrary bytes, so only they are round-trip checked.
147
+ const parseRawRecords = (buf, where) => {
148
+ const fields = splitZBytes(buf);
149
+ const records = [];
150
+ const unparseable = () => refuse(`a git raw record in ${where} is unparseable — the enumeration refuses rather than skipping a touched object`);
151
+ for (let i = 0; i < fields.length; i += 1) {
152
+ const meta = fields[i].toString('utf8');
153
+ if (meta === '' || meta[0] !== ':') continue;
154
+ const parts = meta.slice(1).split(' ');
155
+ if (parts.length < 5) return unparseable();
156
+ const [srcMode, dstMode, srcSha, dstSha, statusField] = parts;
157
+ const status = statusField[0];
158
+ const renamed = status === 'R' || status === 'C';
159
+ const first = fields[i + 1];
160
+ const second = renamed ? fields[i + 2] : undefined;
161
+ if (first === undefined || (renamed && second === undefined)) return unparseable();
162
+ i += renamed ? 2 : 1;
163
+ if (!decodesExactly(first)) return undecodablePath(where, first);
164
+ if (renamed && !decodesExactly(second)) return undecodablePath(where, second);
165
+ const srcPath = first.toString('utf8');
166
+ records.push({
167
+ srcMode, dstMode, srcSha, dstSha, status,
168
+ srcPath,
169
+ dstPath: renamed ? second.toString('utf8') : srcPath,
170
+ });
171
+ }
172
+ return { ok: true, records };
173
+ };
174
+
175
+ // git's OWN notion of binary, asked per layer: --numstat prints "-" for both counts on a binary path.
176
+ // The record is `<added>TAB<deleted>TAB<path>` — split by the FIRST TWO tabs only, because a path may
177
+ // legally contain tabs of its own and a three-way split silently drops such a record, losing the
178
+ // binary marker and sizing the object by status instead of type. An INCOMPLETE record is refused, not
179
+ // skipped: a marker that could not be read is not a marker that is absent.
180
+ const binaryNames = (top, args, where) => {
181
+ const buf = gitBuf(['diff', ...args, '--no-ext-diff', '--numstat', '-z', '-M'], top);
182
+ return buf == null
183
+ ? refuse('git could not read the numstat binary markers (fail closed)')
184
+ : parseNumstatMarkers(buf, where);
185
+ };
186
+
187
+ // Exported as a TEST SEAM, and only for that: both refusals below guard against output git does not
188
+ // produce on demand — a record short of its two separators, and a rename record that promises two
189
+ // path segments and carries fewer. They are the fail-closed arms of this reader, so they are pinned
190
+ // directly rather than left as unreachable prose.
191
+ export const parseNumstatMarkers = (buf, where) => {
192
+ const fields = splitZBytes(buf);
193
+ const names = new Set();
194
+ const TAB = 0x09;
195
+ for (let i = 0; i < fields.length; i += 1) {
196
+ const record = fields[i];
197
+ if (record.length === 0) continue;
198
+ const firstTab = record.indexOf(TAB);
199
+ const secondTab = firstTab === -1 ? -1 : record.indexOf(TAB, firstTab + 1);
200
+ if (firstTab === -1 || secondTab === -1) {
201
+ return refuse(`a git numstat record in ${where} carries fewer than the two separators its grammar requires (${JSON.stringify(record.toString('utf8'))}) — the enumeration refuses rather than skipping a marker it could not read`);
202
+ }
203
+ const added = record.subarray(0, firstTab).toString('utf8');
204
+ const deleted = record.subarray(firstTab + 1, secondTab).toString('utf8');
205
+ const isBinary = added === '-' && deleted === '-';
206
+ const pathSlice = record.subarray(secondTab + 1);
207
+ // A rename leaves the path field EMPTY and follows with two separate NUL-terminated names. A
208
+ // record that promises them and does not carry them is REFUSED, not skipped — the same rule the
209
+ // incomplete-record arm above states, and skipping it would drop a marker while claiming to read
210
+ // every one.
211
+ let slices;
212
+ if (pathSlice.length === 0) {
213
+ const from = fields[i + 1];
214
+ const to = fields[i + 2];
215
+ i += 2;
216
+ if (from === undefined || to === undefined) {
217
+ return refuse(`a git numstat rename record in ${where} is missing the path segments its grammar promises — the enumeration refuses rather than skipping a marker it could not read`);
218
+ }
219
+ slices = [from, to];
220
+ } else {
221
+ slices = [pathSlice];
222
+ }
223
+ for (const slice of slices) {
224
+ if (!decodesExactly(slice)) return undecodablePath(where, slice);
225
+ if (isBinary) names.add(slice.toString('utf8'));
226
+ }
227
+ }
228
+ return { ok: true, names };
229
+ };
230
+
231
+ // ── the object model ──────────────────────────────────────────────────────────────────────────────
232
+ // One record per touched object: where it came from in HEAD, where it ended up, and every name it
233
+ // wore in between (the names the binary oracle and the type probes are asked about).
234
+
235
+ const makeObject = ({ headPath, headSha, headMode }) => ({
236
+ headPath, headSha, headMode,
237
+ finalPath: null, finalMode: ABSENT_MODE, finalSource: null,
238
+ indexPath: null, indexSha: null, indexMode: ABSENT_MODE,
239
+ names: new Set([headPath].filter(Boolean)),
240
+ });
241
+
242
+ const kindForModes = (modes) => {
243
+ if (modes.includes(GITLINK_MODE)) return 'submodule';
244
+ if (modes.includes(SYMLINK_MODE)) return 'symlink';
245
+ return null;
246
+ };
247
+
248
+ const buildObjects = (staged, unstaged) => {
249
+ const objects = [];
250
+ // HEAD → index. A staged record's DESTINATION name is how the unstaged layer will refer to it.
251
+ const byIndexPath = new Map();
252
+ for (const r of staged) {
253
+ const hasHead = !isAbsentSha(r.srcSha) && r.srcMode !== ABSENT_MODE;
254
+ const object = makeObject({
255
+ headPath: hasHead ? r.srcPath : null,
256
+ headSha: hasHead ? r.srcSha : null,
257
+ headMode: hasHead ? r.srcMode : ABSENT_MODE,
258
+ });
259
+ if (r.status !== 'D') {
260
+ object.finalPath = r.dstPath;
261
+ object.finalMode = r.dstMode;
262
+ object.finalSource = 'index';
263
+ object.indexPath = r.dstPath;
264
+ object.indexSha = r.dstSha;
265
+ object.indexMode = r.dstMode;
266
+ object.names.add(r.dstPath);
267
+ byIndexPath.set(r.dstPath, object);
268
+ }
269
+ objects.push(object);
270
+ }
271
+ // index → worktree. A path absent from the staged layer is unchanged there, so its index blob IS
272
+ // its HEAD blob and the object enters the model here.
273
+ for (const r of unstaged) {
274
+ const known = byIndexPath.get(r.srcPath);
275
+ const object = known ?? makeObject({ headPath: r.srcPath, headSha: r.srcSha, headMode: r.srcMode });
276
+ if (known === undefined) {
277
+ object.indexPath = r.srcPath;
278
+ object.indexSha = r.srcSha;
279
+ object.indexMode = r.srcMode;
280
+ objects.push(object);
281
+ }
282
+ object.names.add(r.srcPath);
283
+ if (r.status === 'D') {
284
+ object.finalPath = null;
285
+ object.finalMode = ABSENT_MODE;
286
+ object.finalSource = null;
287
+ } else {
288
+ object.finalPath = r.dstPath;
289
+ object.finalMode = r.dstMode;
290
+ object.finalSource = 'worktree';
291
+ object.names.add(r.dstPath);
292
+ }
293
+ }
294
+ return objects;
295
+ };
296
+
297
+ // ── entry construction ────────────────────────────────────────────────────────────────────────────
298
+
299
+ const sizeRefusal = (kind, path) => refuse(`cannot establish the size of the ${kind} path "${path}" — the enumeration refuses rather than counting an unknown as zero (fail closed)`);
300
+
301
+ const entryForObject = (top, object, binary, lstat) => {
302
+ const hasPreImage = object.headPath !== null;
303
+ // An object with no pre-image is identified by the name it was ADDED under: the index is the first
304
+ // place it existed, and a later filesystem move must not re-key it (a transient object, which ends
305
+ // nowhere at all, has only that name to begin with).
306
+ const identityPath = hasPreImage ? object.headPath : (object.indexPath ?? object.finalPath);
307
+ const objectId = `${hasPreImage ? PRE_IMAGE_ID_PREFIX : NEW_IMAGE_ID_PREFIX}${identityPath}`;
308
+ const path = object.finalPath ?? object.headPath ?? object.indexPath;
309
+ const typeKind = kindForModes([object.headMode, object.indexMode, object.finalMode])
310
+ ?? ([...object.names].some((n) => binary.has(n)) ? 'binary' : null);
311
+
312
+ if (typeKind !== null) {
313
+ // The COUNTED image decides the size, even when the emitted kind comes from another layer. A
314
+ // gitlink in ANY layer makes the kind `submodule` (TYPE beats STATUS, D6) — but a regular file
315
+ // REPLACED by a gitlink is still counted at its HEAD blob, and paying it the submodule's
316
+ // deliberate zero would drop a whole object's bytes out of the numerator on a type change. Zero
317
+ // belongs to a counted image that is ITSELF a gitlink: that is the case where the move costs this
318
+ // repository no bytes.
319
+ const countedMode = hasPreImage
320
+ ? object.headMode
321
+ : (object.finalSource === 'worktree' ? object.finalMode : object.indexMode);
322
+ if (countedMode === GITLINK_MODE) return { ok: true, entry: { kind: typeKind, path, objectId, sizeBytes: SUBMODULE_BYTES } };
323
+ const size = hasPreImage
324
+ ? blobSize(top, object.headSha)
325
+ : (object.finalSource === 'worktree' ? lstat(join(top, object.finalPath))?.size ?? null : blobSize(top, object.indexSha));
326
+ if (size == null) return sizeRefusal(typeKind, path);
327
+ return { ok: true, entry: { kind: typeKind, path, objectId, sizeBytes: size } };
328
+ }
329
+
330
+ if (!hasPreImage) {
331
+ const size = object.finalSource === 'worktree'
332
+ ? lstat(join(top, object.finalPath))?.size ?? null
333
+ : blobSize(top, object.indexSha);
334
+ if (size == null) return sizeRefusal('created', path);
335
+ return { ok: true, entry: { kind: 'new', path, objectId, postImageBytes: size } };
336
+ }
337
+
338
+ const preImageBytes = blobSize(top, object.headSha);
339
+ if (preImageBytes == null) return sizeRefusal('pre-image of', identityPath);
340
+ if (object.finalPath === null) return { ok: true, entry: { kind: 'deleted', path, objectId, preImageBytes } };
341
+ if (object.finalPath !== object.headPath) {
342
+ return { ok: true, entry: { kind: 'renamed', path: object.finalPath, objectId, preImageBytes, fromPath: object.headPath } };
343
+ }
344
+ return { ok: true, entry: { kind: 'modified', path, objectId, preImageBytes } };
345
+ };
346
+
347
+ const entryForUntracked = (top, rel, lstat) => {
348
+ const full = join(top, rel);
349
+ const stat = lstat(full);
350
+ if (isNeverCommittableStat(stat)) return { ok: true, entry: null };
351
+ const objectId = `${NEW_IMAGE_ID_PREFIX}${rel}`;
352
+ if (stat == null || !stat.isFile()) {
353
+ return stat != null && stat.isSymbolicLink()
354
+ ? { ok: true, entry: { kind: 'symlink', path: rel, objectId, sizeBytes: stat.size } }
355
+ : { ok: true, entry: { kind: 'non-regular', path: rel, objectId, sizeBytes: 0 } };
356
+ }
357
+ return isBinaryFile(full)
358
+ ? { ok: true, entry: { kind: 'binary', path: rel, objectId, sizeBytes: stat.size } }
359
+ : { ok: true, entry: { kind: 'new', path: rel, objectId, postImageBytes: stat.size } };
360
+ };
361
+
362
+ // enumerateReturnedObjects(cwd, io?) → { ok: true, entries } | { ok: false, reason }. The entries are
363
+ // the STRUCTURED input computeNumerator consumes; this module never computes the numerator itself, so
364
+ // the closed vocabulary stays the single authority on what a component is. `io.lstat` and
365
+ // `io.untracked` are TEST SEAMS for the arms git cannot be made to produce on demand (an unstatable
366
+ // path, a never-committable class); an injected lstat returning undefined falls through to the real one.
367
+ export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
368
+ const top = gitTop(cwd);
369
+ if (top == null) return refuse('not inside a git work tree — an exec return is enumerated against a repository (fail closed)');
370
+ if (resolveBase(top) == null) {
371
+ return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
372
+ }
373
+ // ABSENT and UNREADABLE are different answers. ENOENT means the path vanished between `ls-files`
374
+ // and the stat, which the payload itself records name-only — mirroring it keeps numerator and
375
+ // denominator over ONE object set. Every OTHER errno (EACCES, EIO, ENOTDIR, ELOOP on a parent) is
376
+ // a FAILED PROBE, and a failed probe recorded as a zero-byte non-regular would be an accounting
377
+ // number nobody could tell was wrong. So the failure is captured and the whole enumeration refuses
378
+ // with the path and the code; this module's contract is a refusal OBJECT, never a throw.
379
+ let probeFailure = null;
380
+ const lstat = (path) => {
381
+ try {
382
+ const injected = io.lstat?.(path);
383
+ return injected === undefined ? lstatNoFollowRead(path) : injected;
384
+ } catch (err) {
385
+ if (err?.code === 'ENOENT') return null;
386
+ probeFailure ??= { path: String(path), code: err?.code ?? err?.message ?? 'lstat failed' };
387
+ return null;
388
+ }
389
+ };
390
+ const refusedProbe = () => (probeFailure === null
391
+ ? null
392
+ : refuse(`could not stat "${probeFailure.path}" (${probeFailure.code}) — an unreadable path is a FAILED probe, not an absent one, and counting it as a zero-byte non-regular would put a number nobody can check into the numerator (fail closed)`));
393
+ // --no-abbrev: the raw format ABBREVIATES object names by default, and these names are handed
394
+ // straight to `cat-file -s` to size an image. A full name is read so the value never depends on an
395
+ // abbreviation staying unique in this repository, nor on the repository's object format —
396
+ // `--abbrev=40` is NOT the same instruction, since in a SHA-256 repository it truncates the 64-hex
397
+ // name to 40.
398
+ const stagedRaw = gitBuf(['diff', '--cached', '--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff'], top);
399
+ const unstagedRaw = gitBuf(['diff', '--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff'], top);
400
+ if (stagedRaw == null || unstagedRaw == null) return refuse('git could not read the tracked change set (fail closed)');
401
+ const staged = parseRawRecords(stagedRaw, 'the staged change set');
402
+ if (!staged.ok) return staged;
403
+ const unstaged = parseRawRecords(unstagedRaw, 'the unstaged change set');
404
+ if (!unstaged.ok) return unstaged;
405
+ const stagedBinary = binaryNames(top, ['--cached'], 'the staged binary markers');
406
+ if (!stagedBinary.ok) return stagedBinary;
407
+ const unstagedBinary = binaryNames(top, [], 'the unstaged binary markers');
408
+ if (!unstagedBinary.ok) return unstagedBinary;
409
+ const binary = new Set([...stagedBinary.names, ...unstagedBinary.names]);
410
+ let untracked = io.untracked?.();
411
+ if (untracked === undefined) {
412
+ const buf = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
413
+ if (buf == null) return refuse('git could not read the untracked section (fail closed)');
414
+ const slices = splitZBytes(buf).filter((slice) => slice.length > 0);
415
+ const undecodable = slices.find((slice) => !decodesExactly(slice));
416
+ if (undecodable !== undefined) return undecodablePath('the untracked section', undecodable);
417
+ untracked = slices.map((slice) => slice.toString('utf8'));
418
+ }
419
+
420
+ // Tracked objects and untracked paths are enumerated INDEPENDENTLY: nothing here pairs a deletion
421
+ // with a creation, so no path is ever consumed by another object's identity.
422
+ const objects = buildObjects(staged.records, unstaged.records);
423
+ const entries = [];
424
+ for (const object of objects) {
425
+ const built = entryForObject(top, object, binary, lstat);
426
+ // A recorded probe failure OUTRANKS whatever refusal the entry builder produced: a null size from
427
+ // an unreadable path would otherwise surface as "cannot establish the size", losing the path and
428
+ // the errno this module promised to name.
429
+ if (!built.ok) return refusedProbe() ?? built;
430
+ entries.push(built.entry);
431
+ }
432
+ for (const rel of untracked) {
433
+ const built = entryForUntracked(top, rel, lstat);
434
+ // A recorded probe failure OUTRANKS whatever refusal the entry builder produced: a null size from
435
+ // an unreadable path would otherwise surface as "cannot establish the size", losing the path and
436
+ // the errno this module promised to name.
437
+ if (!built.ok) return refusedProbe() ?? built;
438
+ if (built.entry !== null) entries.push(built.entry);
439
+ }
440
+ // Checked LAST and over the whole walk: a failed stat anywhere refuses the entire enumeration, so
441
+ // no partially-honest entry list can escape on the strength of the paths that happened to work.
442
+ return refusedProbe() ?? { ok: true, entries };
443
+ };
444
+
445
+ // computeReturnedDiff(cwd) → the canonical uncommitted-state payload BYTES, or a refusal. This is the
446
+ // diff half of the integration bundle, and it is the very payload computeTreeFingerprint digests — so
447
+ // the bytes the denominator counts and the digest the records bind describe one state. It refuses
448
+ // where the enumeration refuses: one entry point may not answer for a tree the other rejects. That
449
+ // sentence used to hold only for the two SHARED preconditions below, which let an AMBIGUOUS rename
450
+ // refuse the enumeration while the diff still handed back bytes — so the enumeration is run here and
451
+ // its refusal surfaced verbatim. One extra pass, once per return, never in a loop.
452
+ export const computeReturnedDiff = (cwd = process.cwd(), io = {}) => {
453
+ const top = gitTop(cwd);
454
+ if (top == null) return refuse('not inside a git work tree — there is no returned diff to compute (fail closed)');
455
+ if (resolveBase(top) == null) {
456
+ return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
457
+ }
458
+ const enumerated = enumerateReturnedObjects(cwd, io);
459
+ if (!enumerated.ok) return enumerated;
460
+ // The payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
461
+ // or vanishing untracked file throws out of it. This entry point promises a refusal object, and a
462
+ // caller that framed a bundle around a thrown read would have no bytes and no reason either.
463
+ let payload;
464
+ try {
465
+ payload = computeFingerprintPayload(cwd);
466
+ } catch (err) {
467
+ return refuse(`the canonical payload could not be read (${err?.code ?? err?.message ?? 'read failed'}) — the returned diff is refused rather than framed around a partial read (fail closed)`);
468
+ }
469
+ return payload == null
470
+ ? refuse('the canonical payload could not be computed (fail closed)')
471
+ : { ok: true, diff: payload };
472
+ };
473
+
474
+ // assembleIntegrationBundle(diff, report) → the framed bundle with its digest and length. The framing
475
+ // itself belongs to the vocabulary; this only carries the two parts to it and hashes the result.
476
+ export const assembleIntegrationBundle = (diff, report) => {
477
+ const bundle = frameIntegrationBundle(diff, report);
478
+ return {
479
+ bundle,
480
+ bundleDigest: createHash('sha256').update(bundle).digest('hex'),
481
+ bundleLength: bundle.length,
482
+ };
483
+ };