@sabaiway/agent-workflow-kit 5.11.0 → 5.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/SKILL.md +1 -1
  3. package/capability.json +1 -1
  4. package/package.json +1 -1
  5. package/references/hooks/gate-approve.mjs +13 -2
  6. package/references/hooks/state-block-guard.mjs +14 -2
  7. package/references/scripts/archive-changelog.mjs +14 -3
  8. package/references/scripts/archive-decisions.mjs +14 -3
  9. package/references/scripts/archive-issues.mjs +14 -3
  10. package/references/scripts/check-docs-size.mjs +14 -3
  11. package/references/scripts/migrate-gates.mjs +13 -2
  12. package/tools/ack-write.mjs +3 -3
  13. package/tools/autonomy-doctor.mjs +2 -3
  14. package/tools/bridge-settings.mjs +2 -3
  15. package/tools/cheap-agents.mjs +3 -3
  16. package/tools/commands.mjs +2 -3
  17. package/tools/commit-guard.mjs +3 -3
  18. package/tools/core-evidence.mjs +2 -3
  19. package/tools/coverage-check.mjs +2 -3
  20. package/tools/delegation.mjs +2 -3
  21. package/tools/detect-backends.mjs +2 -3
  22. package/tools/dispatch-record.mjs +1 -1
  23. package/tools/doc-parity.mjs +2 -3
  24. package/tools/family-registry.mjs +3 -3
  25. package/tools/flow-adoption-mint.mjs +70 -0
  26. package/tools/flow-append.mjs +309 -0
  27. package/tools/flow-chain-state.mjs +91 -0
  28. package/tools/flow-check.mjs +2 -3
  29. package/tools/flow-delta-proof.mjs +307 -0
  30. package/tools/flow-finding-manifest.mjs +70 -0
  31. package/tools/flow-legality.mjs +248 -0
  32. package/tools/flow-record-identity.mjs +115 -0
  33. package/tools/flow-record-shape.mjs +283 -0
  34. package/tools/flow-record.mjs +49 -789
  35. package/tools/flow-store-read.mjs +3 -3
  36. package/tools/flow-store.mjs +35 -812
  37. package/tools/flow-subset-budget.mjs +81 -0
  38. package/tools/flow-vocabulary.mjs +96 -0
  39. package/tools/flow-writer.mjs +3 -3
  40. package/tools/gate-hook.mjs +3 -3
  41. package/tools/gates-init.mjs +3 -3
  42. package/tools/grounding.mjs +2 -3
  43. package/tools/hide-footprint.mjs +2 -3
  44. package/tools/inject-methodology.mjs +2 -3
  45. package/tools/lens-region.mjs +2 -3
  46. package/tools/manifest/validate.mjs +2 -3
  47. package/tools/migrate-adr-store.mjs +3 -3
  48. package/tools/path-inventory.mjs +2 -3
  49. package/tools/procedures.mjs +3 -3
  50. package/tools/receipt-deadline.mjs +2 -3
  51. package/tools/recipes.mjs +2 -3
  52. package/tools/recommendations.mjs +3 -3
  53. package/tools/release-scan.mjs +2 -3
  54. package/tools/repo-search.mjs +2 -3
  55. package/tools/review-state.mjs +3 -3
  56. package/tools/run-gates.mjs +2 -3
  57. package/tools/sandbox-masks.mjs +3 -3
  58. package/tools/set-autonomy.mjs +2 -3
  59. package/tools/set-flow.mjs +3 -3
  60. package/tools/set-recipe.mjs +2 -3
  61. package/tools/setup-backends.mjs +3 -3
  62. package/tools/store-append.mjs +2 -2
  63. package/tools/uninstall.mjs +2 -3
  64. package/tools/velocity-profile.mjs +3 -3
  65. package/tools/worktrees.mjs +3 -3
@@ -1,827 +1,50 @@
1
- // flow-store.mjs — the flow-store IO (flow-orchestration, Phase 2): common-dir path resolution, the
2
- // fail-closed reader, and the lock/CAS serialized append. No CLI, no side effects on import.
1
+ // flow-store.mjs — the flow store's PUBLIC SURFACE (flow-orchestration, Phases 2-3): the 29 names
2
+ // every consumer of the store imports through this ONE path. No CLI, no logic, no side effects on
3
+ // import — this module is re-exports only.
3
4
  //
4
5
  // The store pins to the git COMMON dir because flow records must be shared across worktrees
5
- // (#49/#57), which the per-git-dir core store cannot do; appends are serialized by an exclusive-
6
- // create lock file beside the store because the reusable atomic writer is last-writer-wins with no
7
- // cross-process lock. Everything fails closed: bounded lock waits with named refusals per holder
8
- // class, custody-checked release (only the inode the winning CAS fd proved is ever removed),
9
- // fd-based no-follow reads, and a SEMANTIC append preflight on one captured snapshot (per-record
10
- // validation, malformed-store refusal, replay refusal, chain-sequence and supersession legality) —
11
- // an illegal record never lands.
6
+ // (#49/#57); appends are serialized by an exclusive-create lock file beside the store. Everything
7
+ // fails closed: bounded lock waits with named refusals per holder class, custody-checked release
8
+ // (only the inode the winning CAS fd proved is ever removed), fd-based no-follow reads, and a
9
+ // SEMANTIC append preflight on one captured snapshot an illegal record never lands.
12
10
  //
13
- // That lock/CAS + serialized-append machinery now lives in the PARAMETERIZED store-append.mjs leaf
14
- // (delegation Plan 1 D12), extracted from here unchanged so a second store can ride the identical
15
- // discipline instead of a second copy of it. This module keeps everything flow-SPECIFIC: the seams
16
- // it injects (path resolution, nouns, knob names, validator, parser) and `flowSemanticPreflight` —
17
- // the per-kind legality the lane runs inside the critical section.
18
- //
19
- // Phase 3 adds the mint primitives that need the tree: the adoption mint (frontmatter planId +
20
- // plan content digest, #58), the canonical owning-worktree identity (#49), the generic reference
21
- // validator + prior-terminal resolution in the append preflight (#63), and the bookkeeping-delta
22
- // custody proof (masked revert-and-recompute, #60).
11
+ // LAYOUT (baseline-practices tranche 2): the read half stays in flow-store-read.mjs (it owns no
12
+ // write API, so read-only surfaces like the procedures advisor import it directly), and the
13
+ // flow-SPECIFIC write side lives in five leaves, imports running ONE way mints append → pure
14
+ // leaves flow-record:
15
+ // flow-chain-state.mjs — PURE: the chain-state walk + the generic reference validator (#63)
16
+ // • flow-subset-budget.mjs — PURE: the Decision-7/8 counting-context budget and its gate
17
+ // flow-append.mjs — the ONE write door: the lane over store-append.mjs, the semantic
18
+ // preflight, the run-lock lanes, the locked subset-attempt factory
19
+ // flow-adoption-mint.mjs — the adoption mint (#58)
20
+ // flow-delta-proof.mjs — the bookkeeping-delta custody proof (#60)
21
+ // No leaf imports this facade — that edge would be the cycle test/read-graph-purity.test.mjs reds,
22
+ // and test/flow-store-layout.test.mjs pins the surface, the bindings, the caps and the direction.
23
23
  //
24
24
  // Declared residuals no dependency-free core-Node mechanism can close: the pathname lstat→rename
25
25
  // and reread→rename windows (no flock/fcntl, no inode-conditional unlink or rename) and bind-mount
26
- // aliasing. The decideCheck arms, guard/gates wiring, and the arming + writer CLIs (set-flow,
27
- // flow-writer) are LIVE (Plan 3 Phases 2–3); the remaining Plan-3 surface is the deadline runner +
28
- // wrapper manifest lane (Phase 4). Records remain forgeable — a self-discipline mechanism in the
29
- // git dir, not a security boundary.
30
-
31
- import { createHash } from 'node:crypto';
32
- import { readFileSync, lstatSync, readlinkSync } from 'node:fs';
33
- import { join, resolve } from 'node:path';
34
- import { spawnSync } from 'node:child_process';
35
- import { lstatNoFollow } from './atomic-write.mjs';
36
- import { FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions, authoritativeFlowRecords, canonicalFlowDigest, flowRecordKey, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM } from './flow-record.mjs';
37
- import { isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase, computeTreeFingerprint } from './core-evidence.mjs';
38
- import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
39
- import { CONFIG_REL } from './orchestration-config.mjs';
40
- // The lock/CAS discipline and the serialized append itself live in the PARAMETERIZED
41
- // store-append.mjs leaf (D12) — this module injects the flow store's own nouns, seams, validator
42
- // and semantic preflight.
43
- import { createStoreAppendLane } from './store-append.mjs';
44
- // The read half lives in flow-store-read.mjs (it OWNS no write API — read-only surfaces like the
45
- // procedures advisor import it directly) and is RE-EXPORTED here — every existing consumer keeps
46
- // its import site.
47
- import {
48
- FLOW_STORE_STOP, flowStoreStop, FLOW_STORE_BASENAME, FLOW_LOCK_SUFFIX, gitLine,
49
- resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, readFlowStore,
50
- deriveFlowOwner, describeNonRegular,
51
- } from './flow-store-read.mjs';
26
+ // aliasing. Records remain forgeable a self-discipline mechanism in the git dir, not a security
27
+ // boundary.
52
28
 
53
29
  export {
54
30
  FLOW_STORE_STOP, FLOW_STORE_BASENAME, FLOW_LOCK_SUFFIX,
55
31
  resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, readFlowStore, deriveFlowOwner,
56
- };
57
-
58
- const stop = flowStoreStop;
59
-
60
- // Wait bound + poll cadence; the env knobs keep hermetic tests off wall-clock.
61
- export const FLOW_LOCK_WAIT_MS = 10_000;
62
- export const FLOW_LOCK_POLL_MS = 100;
63
-
64
- const GIT_MAX_BUFFER = 256 * 1024 * 1024;
65
- const gitBuf = (args, cwd) => {
66
- const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
67
- if (r.error || r.status !== 0) return null;
68
- return r.stdout;
69
- };
70
- const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
71
-
72
- // ── the shared append lane (D12) ──────────────────────────────────────────────────────────────────
73
-
74
- // The lock/CAS discipline, the fd-custody rules and the serialized append are the EXTRACTION of
75
- // exactly this module's former code into store-append.mjs, so behavior is unchanged by
76
- // construction: this store injects its nouns (every refusal still names the flow store), its env
77
- // seam and knob names, its typed-STOP factory, its record validator, its store-text parser, and
78
- // the SEMANTIC preflight below. The flow suites are the characterization bar for that claim.
79
- const flowAppendLane = createStoreAppendLane({
80
- nouns: { store: 'flow store', adj: 'flow-store', record: 'flow record' },
81
- envNames: { store: 'AW_FLOW_STORE', waitKnob: 'AW_FLOW_LOCK_WAIT_MS', pollKnob: 'AW_FLOW_LOCK_POLL_MS' },
82
- stop,
83
- resolveStorePath: resolveFlowStorePath,
84
- resolveLockPath: resolveFlowLockPath,
85
- validateRecord: validateFlowRecord,
86
- parseStoreText: parseFlowStoreText,
87
- lockWaitMs: FLOW_LOCK_WAIT_MS,
88
- lockPollMs: FLOW_LOCK_POLL_MS,
89
- });
90
-
91
- const captureRecordSnapshot = flowAppendLane.captureRecordSnapshot;
92
-
93
- // ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
94
-
95
- // The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
96
- // bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
97
- // all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
98
- export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
99
- const { line, snapshot } = captureRecordSnapshot(record);
100
- // Round-9 fold: subset-attempt records are minted ONLY by the locked factory — foldBatch,
101
- // subsetDigest, and attemptIndex are DERIVED inside its critical section, and a hand-built
102
- // record could forge a fresh counting context and bypass the hard-stop budget.
103
- if (snapshot.kind === 'subset-attempt') {
104
- throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
105
- }
106
- return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: () => ({ line, snapshot }) });
107
- };
108
-
109
- // appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
110
- // runs INSIDE the critical section on the locked store snapshot (Plan 4 Phase 3 / round-1 fold
111
- // F6): a writer's lock-free cap/completeness walk is advisory — the locked snapshot decides, so
112
- // a concurrent append can never slip a stale terminal (or a stranding round) through. The hook
113
- // receives a DEEP-FROZEN CLONE (round-1 fold M5): a buggy preflight throws on any mutation
114
- // attempt and can never skew the bytes the semantic validation and the write see. A throwing
115
- // preflight refuses the append with nothing written. The subset-attempt factory-only rule holds
116
- // on this lane too.
117
- export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env = process.env, deps = {}, preflight = null } = {}) => {
118
- const { line, snapshot } = captureRecordSnapshot(record);
119
- if (snapshot.kind === 'subset-attempt') {
120
- throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
121
- }
122
- return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
123
- if (preflight != null) preflight(deepFreezeClone(records));
124
- return { line, snapshot };
125
- } });
126
- };
127
-
128
- const deepFreezeClone = (value) => {
129
- const freeze = (v) => {
130
- if (v !== null && typeof v === 'object') {
131
- Object.values(v).forEach(freeze);
132
- Object.freeze(v);
133
- }
134
- return v;
135
- };
136
- return freeze(structuredClone(value));
137
- };
138
-
139
- // The SEMANTIC half of the append, handed to the shared lane and run by it INSIDE the critical
140
- // section on the LOCKED store snapshot (a writer's lock-free walk is advisory — only the locked
141
- // snapshot decides): per-kind chain legality, reference resolution, the closure rules, the
142
- // counting-context gate, and supersession legality. An illegal record never lands. Throws a typed
143
- // STOP; the lane releases the lock and re-throws.
144
- const flowSemanticPreflight = ({ records, snapshot, storePath }) => {
145
- if (snapshot.kind === CHAIN_KIND) {
146
- const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
147
- const existingSeq = validateChainSequence(chain);
148
- if (!existingSeq.ok) {
149
- throw stop(`refusing to append to a flow store whose existing chain for plan "${snapshot.planId}" is already illegal (${existingSeq.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
150
- }
151
- const candidateSeq = validateChainSequence([...chain, snapshot]);
152
- if (!candidateSeq.ok) {
153
- throw stop(`refusing an illegal chain record: ${candidateSeq.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
154
- }
155
- // Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
156
- // digest-reference the chain's prior terminal; a round REVISION re-states its reference
157
- // byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
158
- if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
159
- const ref = validateOpenerReference(records, snapshot);
160
- if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
161
- }
162
- if (snapshot.purpose === 'refresh') {
163
- if (resolveRecordReference(records, snapshot.refreshedRecord) === undefined) {
164
- throw stop(`refusing a refresh whose refreshedRecord does not match the store (no record digests to ${snapshot.refreshedRecord.slice(0, 12)}…) — a re-attestation binds an existing record; nothing was written`);
165
- }
166
- if (!isAuthoritativeReferenceTarget(records, snapshot.refreshedRecord)) {
167
- throw stop('refusing a refresh whose refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key; nothing was written');
168
- }
169
- }
170
- }
171
- // The closure rule runs UNDER the lock on the captured snapshot — a writer's lock-free
172
- // usability pre-check can race a concurrent up/clear, and a justification minted after its
173
- // mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
174
- if (snapshot.kind === 'degrade-justification') {
175
- const closed = records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
176
- if (closed) {
177
- throw stop('refusing a degrade-justification whose down-mark is already closed by up/clear — minted-after-close can never satisfy (#25); nothing was written');
178
- }
179
- }
180
- // The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
181
- // {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
182
- // the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
183
- // (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
184
- // stale consult context can never satisfy the decide layer, so the store refuses to strand it.
185
- if (snapshot.kind === 'consult-attestation') {
186
- const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
187
- const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
188
- if (!seq.ok) {
189
- throw stop(`refusing a consult-attestation: the plan "${snapshot.planId}" chain is not a legal open carrier under the lock (${seq.reason}); nothing was written`);
190
- }
191
- const state = walkChainState(chain);
192
- const open = state.mode === 'in-step' && !state.parked && !state.completed;
193
- if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
194
- const shown = !open
195
- ? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
196
- : `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
197
- throw stop(`refusing a consult-attestation whose {cycle, stepId, round} does not match the OPEN step under the lock — ${shown}; a consult binds the open step's round, and a stale context can never satisfy; nothing was written`);
198
- }
199
- }
200
- // The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
201
- // factory computes a passing record; a hand-built one must satisfy the same rules).
202
- if (snapshot.kind === 'subset-attempt') {
203
- const gate = subsetAttemptGate(records, snapshot);
204
- if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
205
- }
206
- const existingSup = validateSupersessions(records);
207
- if (!existingSup.ok) {
208
- throw stop(`refusing to append to a flow store whose existing records already violate supersession legality (${existingSup.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
209
- }
210
- const candidateSup = validateSupersessions([...records, snapshot]);
211
- if (!candidateSup.ok) {
212
- throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
213
- }
214
- };
215
-
216
- // ── the Decision-7/8 subset-attempt lane (Plan 4) — counting-context gate + locked factory ────────
217
-
218
- // The waste bound is the CAP, not the prose (Decision 8): a counting context allows at most
219
- // THREE red attempts (two blind + one diagnosed) on its own; past that, no diagnosis reopens it —
220
- // only a recorded FRESH-EYES consult verdict does, one further attempt per consult.
221
- export const SUBSET_ATTEMPT_MAX_REDS = 3;
222
-
223
- // Past the SECOND red every further attempt at the key rides a recorded diagnosis — the
224
- // obligation keys on REDS, never on the attempt index (a green history stays blind-legal).
225
- export const SUBSET_ATTEMPT_DIAGNOSIS_REDS = 2;
226
-
227
- // subsetAttemptState(records, probe) → { attempts, nextIndex, reds, credits, exhausted } — the
228
- // ONE Decision-7/8 budget walk both consumers share (the locked gate below re-runs it under the
229
- // lock; run-gates' pre-gate check reads it lock-free). The exhaustion ladder is PERMIT-based
230
- // and foldBatch-GLOBAL (round-3 disposition): red counts stay per key, but permits and their
231
- // consumption span EVERY subsetDigest of the round context — one consult verdict is exactly
232
- // ONE further attempt across the whole foldBatch, whichever subset spends it. Consult identity
233
- // {backend, nonce} is tracked STORE-WIDE before any relevance filtering, so a replay from
234
- // another round (or any seen identity — pre-exhaustion or spent) never credits; a credit is
235
- // granted only for a NEW identity whose {planId, cycle, stepId, round} digests to this
236
- // foldBatch while SOME key of the foldBatch is base-exhausted at that point in raw order.
237
- // EVERY attempt recorded past its own key's base budget consumes one credit, whatever its
238
- // status; a tampered store that drove credits negative stays exhausted (fail closed).
239
- export const subsetAttemptState = (records, probe) => {
240
- const key = flowRecordKey({ kind: 'subset-attempt', ...probe });
241
- const attempts = [];
242
- const seenConsults = new Set();
243
- const redsByKey = new Map();
244
- let credits = 0;
245
- const someKeyExhausted = () => [...redsByKey.values()].some((n) => n >= SUBSET_ATTEMPT_MAX_REDS);
246
- for (const r of records) {
247
- if (r.kind === 'subset-attempt' && r.foldBatch === probe.foldBatch) {
248
- const rKey = flowRecordKey(r);
249
- const priorReds = redsByKey.get(rKey) ?? 0;
250
- if (priorReds >= SUBSET_ATTEMPT_MAX_REDS) credits -= 1;
251
- if (r.status === 'red') redsByKey.set(rKey, priorReds + 1);
252
- if (rKey === key) attempts.push(r);
253
- } else if (r.kind === 'consult-attestation') {
254
- const identity = JSON.stringify([r.backend, r.nonce]);
255
- const relevant = subsetFoldBatchDigest({ planId: r.planId, cycle: r.cycle, stepId: r.stepId, round: r.round }) === probe.foldBatch;
256
- if (relevant && !seenConsults.has(identity) && someKeyExhausted()) credits += 1;
257
- seenConsults.add(identity);
258
- }
259
- }
260
- const reds = redsByKey.get(key) ?? 0;
261
- return {
262
- attempts,
263
- nextIndex: attempts.reduce((m, r) => Math.max(m, r.attemptIndex), 0) + 1,
264
- reds,
265
- credits,
266
- exhausted: reds >= SUBSET_ATTEMPT_MAX_REDS && credits <= 0,
267
- };
268
- };
269
-
270
- export const subsetExhaustionRemedy = 'the fresh-eyes lane reopens it (Decision 8 — never a human wait-state): dispatch a MANDATORY grounded bridge consult (a different model) carrying the full attempt/diagnosis trail; its recorded consult-attestation at this round context reopens exactly ONE further diagnosed attempt. Otherwise park the stuck work with its trail and switch to independent work; a fresh context opens with the next round (new foldBatch) or a declared pregateExclude change (new subsetDigest)';
271
-
272
- // The under-lock rules the factory does NOT already enforce itself: the exhaustion ladder and
273
- // the byte-distinct diagnosis (blind thrashing refuses; a NEW hypothesis proceeds). The
274
- // monotonic index and the reds-based diagnosis REQUIREMENT live in the factory alone — it is
275
- // the ONLY entry for this kind (the generic lane refuses it by name, round-9 fold), computes
276
- // the index from the SAME locked snapshot this gate sees, and throws its own named stops first.
277
- const subsetAttemptGate = (records, snapshot) => {
278
- const { attempts, reds, exhausted } = subsetAttemptState(records, snapshot);
279
- if (exhausted) {
280
- return { ok: false, reason: `this counting context already holds ${reds} red attempts — EXHAUSTED (two blind + one diagnosed, Decision 8) and no diagnosis reopens it; ${subsetExhaustionRemedy}` };
281
- }
282
- const prior = attempts.find((r) => r.attemptIndex === snapshot.attemptIndex - 1);
283
- if (typeof snapshot.diagnosis === 'string' && prior != null && prior.diagnosis === snapshot.diagnosis) {
284
- return { ok: false, reason: "the diagnosis is byte-identical to the prior attempt's — a diagnosed continuation states a NEW hypothesis (Decision 8); blind thrashing refuses" };
285
- }
286
- return { ok: true };
287
- };
288
-
289
- // ── the Decision-7 subset-run serializer (round-6 fold) ──────────────────────────────────────────
290
-
291
- // --pre-review's WHOLE armed cycle (budget preflight → gates → append) holds this lock: without
292
- // it a parallel run executes gates whose red can no longer be recorded once the winner lands,
293
- // and an unrecorded red undercounts the budget ("EVERY subset-run red counts"). A SEPARATE lock
294
- // file beside the store — never the store lock itself, so appends from other lanes never block
295
- // behind a minutes-long gate run — riding the same CAS/fd-custody/holder-liveness discipline: a
296
- // crashed holder surfaces as the named DEAD refusal with its rm recovery; a live holder is a
297
- // bounded loud wait (the queued run then re-reads the budget and re-decides).
298
- export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
299
-
300
- export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
301
- const resolved = flowAppendLane.resolveOrStop(cwd, env, 'serialize a subset run against');
302
- const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
303
- return { lockPath, release: () => flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps) };
304
- };
305
-
306
- // The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
307
- // ORDINARY append lock through the full acquire discipline — a DEAD/foreign/malformed lock or
308
- // an unwritable parent surfaces BEFORE any gate spends, with the acquire's own named refusal.
309
- // Stated residual: a lock landing between this probe and the post-run append still refuses at
310
- // append time — closing that would mean holding the append lock across the whole gate run.
311
- export const probeFlowAppendLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
312
- const resolved = flowAppendLane.resolveOrStop(cwd, env, 'probe');
313
- const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(resolved, env, deps);
314
- const issue = flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps);
315
- if (issue != null) throw issue;
316
- };
317
-
318
- // appendSubsetAttempt — the Decision-7 locked append factory: the chain identity is captured
319
- // BEFORE the gates run (the caller's `expected` {planId, cycle, stepId, round}) and re-checked
320
- // under the append lock against the OPEN owning chain; attemptIndex, foldBatch/subsetDigest
321
- // derivation, and the hard-stop state are computed from the captured store snapshot INSIDE the
322
- // critical section — a concurrent appender never duplicates an index, and a round/park/complete
323
- // landing mid-run refuses the append (never a silent misfile). subsetGateIds states what the
324
- // caller RAN — only the caller knows that — but it never DECIDES the counting context: the
325
- // factory re-derives the subset from the declaration + config itself (the R10 rider, via the
326
- // gates-declaration leaf) and refuses a mismatch, so a caller-chosen id list can never forge a
327
- // fresh subsetDigest and bypass the hard-stop budget.
328
- export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, deps = {}, expected, subsetGateIds, status, diagnosis = null, base, fingerprint, timestamp = new Date().toISOString() } = {}) => {
329
- const owner = deriveFlowOwner(cwd);
330
- if (owner == null) throw stop('not inside a git work tree — the subset-attempt mint derives the owning worktree from git (fail closed)');
331
- if (expected == null || typeof expected.planId !== 'string' || expected.planId.length === 0
332
- || !Number.isInteger(expected.cycle) || !Number.isInteger(expected.round)
333
- || (expected.stepId !== null && typeof expected.stepId !== 'string')) {
334
- throw stop('the captured chain identity must be {planId, cycle, stepId|null, round} — the factory re-checks exactly this projection under the lock (fail closed)');
335
- }
336
- if (!Array.isArray(subsetGateIds)) throw stop("subsetGateIds must be the derived subset's ordered gate-id array (fail closed)");
337
- if (status !== 'green' && status !== 'red') throw stop(`status must be green | red (got ${JSON.stringify(status)}) — an unrunnable subset refuses with NO attempt record (fail closed)`);
338
- if (diagnosis !== null && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
339
- throw stop(`diagnosis must be null or a non-empty string (got ${JSON.stringify(diagnosis)}) — a mistyped input would otherwise record diagnosis-less silently (round-11 fold; fail closed)`);
340
- }
341
- let derived;
342
- try {
343
- derived = derivePregateSubsetIds(cwd);
344
- } catch (err) {
345
- throw stop(`the pregate subset cannot be re-derived (${(err && err.message) || err}) — an attempt records only a subset the declaration derives (R10; fail closed)`);
346
- }
347
- if (derived.length !== subsetGateIds.length || derived.some((id, i) => id !== subsetGateIds[i])) {
348
- throw stop(`subsetGateIds [${subsetGateIds.join(', ')}] does not match the subset derived from ${GATES_REL} + ${CONFIG_REL} flow.pregateExclude [${derived.join(', ')}] — the factory re-derives the subset itself (R10), so a caller-chosen id list never binds a counting context (fail closed)`);
349
- }
350
- // Everything downstream binds the factory-owned DERIVED ids — the caller array stays mutable in
351
- // the caller's hands (a deps lock-hook could rewrite it after the check above) and must never
352
- // reach the digest domain.
353
- const subsetIds = Object.freeze([...derived]);
354
- let minted = null;
355
- const value = flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
356
- const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
357
- if (chain.length === 0) throw stop(`no chain exists for plan "${expected.planId}" under the lock — the captured identity is stale; re-run the subset under the current context (fail closed)`);
358
- const seq = validateChainSequence(chain);
359
- if (!seq.ok) throw stop(`the plan "${expected.planId}" chain is illegal under the lock (${seq.reason}) — refusing to bind an attempt to it (fail closed)`);
360
- if (chain[0].owner !== owner) throw stop(`the plan "${expected.planId}" chain is owned by "${chain[0].owner}", not this worktree ("${owner}") — a foreign chain never records this tree's attempts (fail closed)`);
361
- const state = walkChainState(chain);
362
- const open = !state.completed && !state.parked;
363
- const held = open && state.cycle === expected.cycle && state.stepId === expected.stepId && (state.round ?? 0) === expected.round;
364
- if (!held) {
365
- const shown = state.completed ? 'the plan completed' : state.parked ? 'the plan parked' : `the open context is {cycle ${state.cycle}, step ${JSON.stringify(state.stepId)}, round ${state.round ?? 0}}`;
366
- throw stop(`the chain identity moved under the run — captured {cycle ${expected.cycle}, step ${JSON.stringify(expected.stepId)}, round ${expected.round}}, but ${shown} under the lock (a round/park/complete landed mid-run); re-run the subset under the current context (fail closed)`);
367
- }
368
- if (expected.stepId === null && state.openers.length > 0) {
369
- throw stop(`the plan "${expected.planId}" chain sits at a post-convergence boundary — the stepId-null context is legal only before the FIRST round (the adoption context, round-6 fold); open the next step round first (fail closed)`);
370
- }
371
- // Round-9 fold: the EXACTLY-ONE-open-owning-chain rule is re-derived UNDER the lock — an
372
- // adoption/resume landing after the caller's preflight would otherwise record the attempt
373
- // into an already-ambiguous context. (After the specific refusals above, so a parked or
374
- // moved TARGET chain keeps its own named diagnosis.)
375
- const openOwn = [...new Set(records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner).map((r) => r.planId))].filter((planId) => {
376
- const c = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
377
- if (c[0].purpose !== 'adoption' || c[0].owner !== owner || !validateChainSequence(c).ok) return false;
378
- const s = walkChainState(c);
379
- return !s.completed && !s.parked;
380
- });
381
- if (openOwn.length !== 1 || openOwn[0] !== expected.planId) {
382
- throw stop(`this worktree ("${owner}") owns ${openOwn.length} open chains under the lock (${openOwn.join(', ') || 'none'}) — an attempt records only when exactly ONE open owning chain exists and it is the captured one ("${expected.planId}"); a chain landed mid-run — re-run the subset under the current context (fail closed)`);
383
- }
384
- const probe = { planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId, foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds) };
385
- const budget = subsetAttemptState(records, probe);
386
- const attemptIndex = budget.nextIndex;
387
- if (budget.reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
388
- throw stop(`attempt ${attemptIndex} follows ${budget.reds} reds at this counting context and requires a recorded diagnosis (Decision 8 — the blind budget is spent): investigate, then re-run with a non-empty diagnosis byte-distinct from the prior attempt's; never a wait-for-maintainer`);
389
- }
390
- if (attemptIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && diagnosis != null) {
391
- throw stop(`attempt ${attemptIndex} is inside the blind budget (attempts 1-2) — a diagnosis rides only attempt ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8); drop the diagnosis input (the captured context may be stale — fail closed, never silently dropped)`);
392
- }
393
- const { line, snapshot } = captureRecordSnapshot({
394
- schema: FLOW_SCHEMA_VERSION, kind: 'subset-attempt', planId: expected.planId, cycle: expected.cycle,
395
- stepId: expected.stepId, foldBatch: probe.foldBatch, subsetDigest: probe.subsetDigest, attemptIndex,
396
- ...(typeof diagnosis === 'string' ? { diagnosis } : {}), status, base, fingerprint, timestamp,
397
- });
398
- // Computed UNDER the lock from the captured snapshot — a lock-free preflight state could
399
- // pick the wrong message under a concurrent append.
400
- const consumedPermit = budget.reds >= SUBSET_ATTEMPT_MAX_REDS;
401
- const redsAfter = budget.reds + (status === 'red' ? 1 : 0);
402
- const creditsAfter = budget.credits - (consumedPermit ? 1 : 0);
403
- minted = {
404
- attemptIndex,
405
- redsAtKey: redsAfter,
406
- reopened: consumedPermit,
407
- exhaustedAfter: redsAfter >= SUBSET_ATTEMPT_MAX_REDS && creditsAfter <= 0,
408
- };
409
- return { line, snapshot };
410
- } });
411
- return { ...value, ...minted, digest: canonicalFlowDigest(value.record) };
412
- };
413
-
414
- // ── chain-state walk + the generic reference validator (#63) ──────────────────────────────────────
415
-
416
- const TERMINAL_PURPOSES = ['adoption', 'converged', 'complete'];
417
- const HEX64_RE = /^[0-9a-f]{64}$/;
418
-
419
- // The record a step-opening round must reference: the latest converged/complete, else the adoption
420
- // record itself (the plan's first step — the exemption is explicit, never inferred).
421
- export const priorChainTerminal = (chain) => {
422
- let terminal = null;
423
- for (const r of chain) {
424
- if (r.purpose === 'adoption' && terminal === null) terminal = r;
425
- else if (r.purpose === 'converged' || r.purpose === 'complete') terminal = r;
426
- }
427
- return terminal;
428
- };
429
-
430
- // walkChainState(chain) → { mode, parked, completed, cycle, round, stepId, openers, lastTerminal }
431
- // over ONE plan's raw-order chain. Legality lives in validateChainSequence — callers run it first;
432
- // this walk only derives state, including each opener with its at-that-point prior terminal.
433
- export const walkChainState = (chain) => {
434
- const state = {
435
- mode: 'boundary', parked: false, completed: false,
436
- cycle: chain[0]?.cycle ?? null, round: chain[0]?.round ?? null, stepId: null,
437
- openers: [], lastTerminal: null,
438
- };
439
- for (const r of chain) {
440
- state.cycle = r.cycle;
441
- if (r.purpose === 'adoption') { state.lastTerminal = r; state.round = r.round; continue; }
442
- if (r.purpose === 'park') { state.parked = true; continue; }
443
- if (r.purpose === 'resume') { state.parked = false; continue; }
444
- if (r.purpose === 'complete') { state.completed = true; state.lastTerminal = r; continue; }
445
- if (r.purpose === 'converged') { state.mode = 'boundary'; state.lastTerminal = r; state.stepId = null; continue; }
446
- if (r.purpose === 'unfreeze' && state.mode === 'boundary') { state.mode = 'in-step'; state.stepId = r.stepId; state.round = r.round; continue; }
447
- if (r.purpose === 'round') {
448
- if (state.mode === 'boundary') {
449
- state.openers.push({ record: r, priorTerminal: state.lastTerminal });
450
- state.mode = 'in-step';
451
- state.stepId = r.stepId;
452
- state.round = r.round;
453
- } else if (r.round > state.round) state.round = r.round;
454
- }
455
- }
456
- return state;
457
- };
458
-
459
- // Reference checks live ENTIRELY in the digest domain — two byte-different records with one
460
- // canonical serialization are ONE identity, so object identity never decides resolution or
461
- // authority. resolveRecordReference returns the LAST matching record (consistent with the
462
- // latest-per-key authoritative selection); the prefix (records BEFORE the referencing one) is the
463
- // resolution domain, so an out-of-order reference never resolves.
464
- export const resolveRecordReference = (prefixRecords, digest) =>
465
- prefixRecords.findLast((r) => canonicalFlowDigest(r) === digest);
466
-
467
- export const isAuthoritativeReferenceTarget = (scopeRecords, digest) =>
468
- authoritativeFlowRecords(scopeRecords).some((r) => canonicalFlowDigest(r) === digest);
469
-
470
- // validateOpenerReference(prefixRecords, candidate) → { ok } | { ok: false, reason }. The named
471
- // classification of a step-opening round's prior-terminal reference: unresolved · non-chain ·
472
- // another plan · non-terminal · superseded · not-the-prior-terminal.
473
- export const validateOpenerReference = (prefixRecords, candidate) => {
474
- const target = resolveRecordReference(prefixRecords, candidate.opensFrom);
475
- if (target === undefined) {
476
- return { ok: false, reason: `the prior-terminal reference does not match the store — no record digests to ${candidate.opensFrom.slice(0, 12)}…` };
477
- }
478
- if (target.kind !== CHAIN_KIND) {
479
- return { ok: false, reason: `the prior-terminal reference targets a ${target.kind} record, not a chain terminal` };
480
- }
481
- if (target.planId !== candidate.planId) {
482
- return { ok: false, reason: `the prior-terminal reference targets another plan's record ("${target.planId}") — a step never opens from a foreign chain` };
483
- }
484
- if (!TERMINAL_PURPOSES.includes(target.purpose)) {
485
- return { ok: false, reason: `the prior-terminal reference targets a non-terminal record (purpose "${target.purpose}") — an opener references adoption, converged, or complete only` };
486
- }
487
- const chain = prefixRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === candidate.planId);
488
- if (!isAuthoritativeReferenceTarget(chain, candidate.opensFrom)) {
489
- return { ok: false, reason: 'the prior-terminal reference targets a superseded record — reference the latest record of that key' };
490
- }
491
- const prior = priorChainTerminal(chain);
492
- if (prior == null || canonicalFlowDigest(prior) !== candidate.opensFrom) {
493
- return { ok: false, reason: `the prior-terminal reference must target the chain's PRIOR terminal (${prior == null ? 'none' : `${canonicalFlowDigest(prior).slice(0, 12)}…`}), not another step's or an earlier terminal — step minting cannot manufacture fresh budgets` };
494
- }
495
- return { ok: true };
496
- };
497
-
498
- // ── the adoption mint (#58) — frontmatter planId + plan content digest, read-only plan file ──────
499
-
500
- const PLAN_ID_FRONTMATTER_HINT = 'planId: <your-stable-plan-id>';
501
-
502
- // Identity binds only a CLOSED leading frontmatter block — an unterminated block never yields an
503
- // id; CRLF is normalized per line so line endings never fork chain identity.
504
- export const readPlanFrontmatterId = (text) => {
505
- const lines = text.split('\n').map((line) => line.replace(/\r$/, ''));
506
- if (lines[0]?.trim() !== '---') return null;
507
- const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
508
- if (close === -1) return null;
509
- for (const line of lines.slice(1, close)) {
510
- const m = /^planId:[ \t]*(\S+)[ \t]*$/.exec(line);
511
- if (m) return m[1];
512
- }
513
- return null;
514
- };
515
-
516
- export const mintAdoption = ({ cwd = process.cwd(), env = process.env, deps = {}, planPath, planLabel, cycle = 1, commitEpoch = 0, timestamp = new Date().toISOString() } = {}) => {
517
- const owner = deriveFlowOwner(cwd);
518
- if (owner == null) throw stop('not inside a git work tree — the adoption mint derives the owning worktree and the tree fingerprint from git (fail closed)');
519
- let planBytes;
520
- try {
521
- planBytes = readFileSync(resolve(cwd, planPath));
522
- } catch (err) {
523
- throw stop(`cannot read the plan file ${planPath} (${(err && err.code) || (err && err.message) || err}) — the adoption mint READS an existing plan file (fail closed)`);
524
- }
525
- const planId = readPlanFrontmatterId(planBytes.toString('utf8'));
526
- if (planId == null) {
527
- throw stop(`the plan file ${planPath} carries no frontmatter planId — plan filenames are never chain identity. Add this line inside a leading "---" frontmatter block:\n${PLAN_ID_FRONTMATTER_HINT}\nand re-run; the plan file is never written by this mint (fail closed)`);
528
- }
529
- const planDigest = sha256Hex(planBytes);
530
- // A pre-append read purely for the NAMED refusal: the locked append would refuse a second
531
- // adoption anyway, but only this comparison can surface whether the plan content still matches.
532
- const resolved = resolveFlowStorePath(cwd, env);
533
- const adopted = resolved == null ? undefined : readFlowStore(resolved).records
534
- .find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
535
- if (adopted !== undefined) {
536
- throw stop(adopted.planDigest === planDigest
537
- ? `plan "${planId}" is already adopted (content digest unchanged — a rename never resets chain identity); adoption is only ever the chain's first record`
538
- : `plan "${planId}" is already adopted and the plan file content no longer matches its adoption record (recorded ${adopted.planDigest.slice(0, 12)}…, current ${planDigest.slice(0, 12)}…) — re-adopting edited plan content is refused; the digest mismatch is surfaced, never silent`);
539
- }
540
- const fingerprint = computeTreeFingerprint(cwd);
541
- if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the adoption record binds {base, fingerprint} (fail closed)');
542
- const record = {
543
- schema: FLOW_SCHEMA_VERSION, kind: CHAIN_KIND, purpose: 'adoption', planId, cycle, round: 0,
544
- commitEpoch, owner, base: resolveBase(cwd), timestamp, stepId: null, fingerprint,
545
- planLabel: planLabel ?? planId, createdAt: timestamp, planDigest,
546
- };
547
- const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
548
- return { writtenPath, record, digest: canonicalFlowDigest(record) };
549
- };
550
-
551
- // ── the bookkeeping-delta custody proof (#60) — masked revert-and-recompute at mint time ─────────
552
-
553
- // The supported pre-state model; everything else refuses BY NAME (fail closed): the delta lives in
554
- // the WORKTREE layer of one plain-ASCII, non-binary, non-executable regular path. A tracked path
555
- // must be CLEAN at the path before the delta (pre-change worktree bytes = its index entry), so the
556
- // pre-state contributes NO unstaged diff section and the mask is pure section REMOVAL plus
557
- // untracked-entry splicing — the recompute never regenerates git diff bytes, whose exact form this
558
- // module cannot promise. Supported transitions: present→present, present→absent, absent→present.
559
-
560
- const GIT_PLAIN_PATH_RE = /^[\x20-\x7e]+$/;
561
- const pathNeedsGitQuoting = (rel) => !GIT_PLAIN_PATH_RE.test(rel) || rel.includes('"') || rel.includes('\\');
562
- const bufferLooksBinary = (buf) => buf.subarray(0, 8192).includes(0);
563
- const REGULAR_FILE_MODE = '100644';
564
-
565
- const defaultRunGit = (args, dir) => spawnSync('git', args, { cwd: dir, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
566
-
567
- // The declared path enters git as a LITERAL pathspec and comes back through a strict -z parse:
568
- // exactly one NUL-terminated record whose path field EQUALS the declared rel, full-octal mode,
569
- // an OID of exactly 40 or 64 hex — a glob-capable name ([]*?) or a prefix-valid truncated answer
570
- // can then never bind the proof to another file (fail closed on every mismatch).
571
- const OID_PART = '(?:[0-9a-f]{40}|[0-9a-f]{64})';
572
- const INDEX_META_RE = new RegExp(`^([0-7]{6}) (${OID_PART}) (\\d)$`);
573
- const TREE_META_RE = new RegExp(`^([0-7]{6}) (\\w+) (${OID_PART})$`);
574
-
575
- const parseZRecords = (stdout) => {
576
- const text = stdout.toString('utf8');
577
- if (text === '') return [];
578
- if (!text.endsWith('\0')) return null;
579
- return text.slice(0, -1).split('\0');
580
- };
581
-
582
- const splitZEntry = (entry, metaRe) => {
583
- const at = entry.indexOf('\t');
584
- if (at === -1) return null;
585
- const meta = metaRe.exec(entry.slice(0, at));
586
- return meta == null ? null : { meta, path: entry.slice(at + 1) };
587
- };
588
-
589
- const readIndexEntry = (top, rel, runGit) => {
590
- const out = runGit(['ls-files', '-s', '-z', '--', `:(literal)${rel}`], top);
591
- if (out.error || out.status !== 0) throw stop(`cannot read the index entry of ${rel} (git ls-files failed) — refusing to mint (fail closed)`);
592
- const recordsZ = parseZRecords(out.stdout);
593
- if (recordsZ == null) throw stop(`cannot parse the index entry of ${rel} (unterminated git ls-files output) — refusing to mint (fail closed)`);
594
- if (recordsZ.length === 0) return null;
595
- const entry = splitZEntry(recordsZ[0], INDEX_META_RE);
596
- if (recordsZ.length > 1 || entry == null || entry.meta[3] !== '0' || entry.path !== rel) {
597
- throw stop(`the declared path ${rel} carries an unmerged or unparseable index entry — an unsupported pre-state class (fail closed)`);
598
- }
599
- return { mode: entry.meta[1], sha: entry.meta[2] };
600
- };
601
-
602
- // An absent HEAD layer is PROVEN unborn, never assumed: rev-parse must answer with EXACTLY the
603
- // clean verify-miss status (1) AND HEAD must still resolve as a symbolic ref; any operational
604
- // fault fails closed. "No entry" is ONLY an empty ls-tree stdout — a non-empty answer must parse
605
- // as exactly one entry line, else the repository is at fault (a false custody proof otherwise).
606
- const GIT_VERIFY_MISS_STATUS = 1;
607
- const readHeadEntry = (top, rel, runGit) => {
608
- const probe = runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], top);
609
- if (probe.error || probe.status !== 0) {
610
- const verifyMiss = !probe.error && probe.status === GIT_VERIFY_MISS_STATUS;
611
- const sym = verifyMiss ? runGit(['symbolic-ref', '--quiet', 'HEAD'], top) : null;
612
- if (sym == null || sym.error || sym.status !== 0) {
613
- throw stop('cannot decide the HEAD state (git rev-parse --verify HEAD did not answer with a clean verify miss, or symbolic-ref HEAD failed) — refusing to mint (fail closed)');
614
- }
615
- return null;
616
- }
617
- const out = runGit(['ls-tree', '-z', 'HEAD', '--', `:(literal)${rel}`], top);
618
- if (out.error || out.status !== 0) throw stop(`cannot read the HEAD entry of ${rel} (git ls-tree failed with an existing HEAD) — refusing to mint (fail closed)`);
619
- const recordsZ = parseZRecords(out.stdout);
620
- if (recordsZ == null) throw stop(`cannot parse the HEAD entry of ${rel} (unterminated git ls-tree output) — refusing to mint (fail closed)`);
621
- if (recordsZ.length === 0) return null;
622
- const entry = splitZEntry(recordsZ[0], TREE_META_RE);
623
- if (recordsZ.length > 1 || entry == null || entry.path !== rel) {
624
- throw stop(`cannot parse the HEAD entry of ${rel} (unexpected git ls-tree output) — refusing to mint (fail closed)`);
625
- }
626
- if (entry.meta[2] !== 'blob') {
627
- throw stop(`the HEAD entry of ${rel} is a ${entry.meta[2]}, not a blob — an unsupported pre-state class (fail closed)`);
628
- }
629
- return { mode: entry.meta[1], sha: entry.meta[3] };
630
- };
631
-
632
- const readBlob = (top, sha, runGit) => {
633
- const out = runGit(['cat-file', 'blob', sha], top);
634
- if (out.error || out.status !== 0) throw stop(`cannot read blob ${sha} from the object store — refusing to mint (fail closed)`);
635
- return out.stdout;
636
- };
637
-
638
- // Byte-level removal of ONE file's section from a git diff buffer. Hunk lines start with
639
- // [ +\-\\@], so a line starting "diff --git " is always a section header; the declared path is
640
- // plain-ASCII by refusal, so its header is these exact bytes. No section = a no-op mask.
641
- const DIFF_SECTION_START = Buffer.from('\ndiff --git ');
642
- const removeDiffSection = (buf, rel) => {
643
- const header = Buffer.from(`diff --git a/${rel} b/${rel}\n`);
644
- let at = -1;
645
- if (buf.subarray(0, header.length).equals(header)) at = 0;
646
- else {
647
- const i = buf.indexOf(Buffer.concat([Buffer.from('\n'), header]));
648
- if (i !== -1) at = i + 1;
649
- }
650
- if (at === -1) return buf;
651
- const next = buf.indexOf(DIFF_SECTION_START, at + header.length - 1);
652
- const end = next === -1 ? buf.length : next + 1;
653
- return Buffer.concat([buf.subarray(0, at), buf.subarray(end)]);
654
- };
655
-
656
- // One untracked entry's payload chunks, branch-for-branch the frozen core's discipline
657
- // (computeFingerprintPayload) — the NULL-mask parity test pins the byte equality.
658
- const untrackedEntryChunks = (top, rel, lstat) => {
659
- const full = join(top, rel);
660
- let stat = null;
661
- try {
662
- stat = lstat(full);
663
- } catch {
664
- stat = null;
665
- }
666
- if (isNeverCommittableStat(stat)) return [];
667
- if (stat?.isSymbolicLink()) {
668
- let target = '?';
669
- try {
670
- target = readlinkSync(full);
671
- } catch {
672
- target = '?';
673
- }
674
- return [Buffer.from(`untracked-symlink:${rel} -> ${target}\n`)];
675
- }
676
- if (!stat?.isFile()) return [Buffer.from(`untracked-nonregular:${rel}\n`)];
677
- if (isBinaryFile(full)) return [Buffer.from(`untracked-binary:${rel}\n`)];
678
- return [Buffer.from(`untracked:${rel}\n`), readFileSync(full)];
679
- };
32
+ } from './flow-store-read.mjs';
680
33
 
681
- // ONE captured read set — every assembly over it (masked and unmasked) binds the SAME tree
682
- // snapshot, so a tree move between two independent snapshots can never be certified. The three
683
- // git reads themselves are separate processes; that window is the frozen core's own inherent
684
- // residual and stays declared, not closed.
685
- const captureFingerprintPieces = (cwd, { lstat = lstatSync } = {}) => {
686
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
687
- if (top == null) return null;
688
- const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
689
- const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
690
- const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
691
- if (staged == null || unstaged == null || untrackedZ == null) return null;
692
- const entries = untrackedZ.toString('utf8').split('\0').filter(Boolean)
693
- .map((rel) => ({ rel, chunks: untrackedEntryChunks(top, rel, lstat) }));
694
- return { staged, unstaged, entries };
695
- };
34
+ export {
35
+ priorChainTerminal, walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget,
36
+ validateOpenerReference,
37
+ } from './flow-chain-state.mjs';
696
38
 
697
- // mask: null = the exact frozen-core payload; { layer: 'diff', rel } removes the path's unstaged
698
- // section (its pre-state section is EMPTY by the clean-at-path rule); { layer: 'untracked', rel,
699
- // insert, preBytes } splices the untracked entry (git emits ls-files sorted by path bytes).
700
- const assembleMaskedPayload = (pieces, mask) => {
701
- const unstaged = mask?.layer === 'diff' ? removeDiffSection(pieces.unstaged, mask.rel) : pieces.unstaged;
702
- let entries = pieces.entries;
703
- if (mask?.layer === 'untracked') {
704
- entries = entries.filter((e) => e.rel !== mask.rel);
705
- if (mask.insert) {
706
- const at = entries.findIndex((e) => e.rel > mask.rel);
707
- entries = [...entries];
708
- entries.splice(at === -1 ? entries.length : at, 0, { rel: mask.rel, chunks: [Buffer.from(`untracked:${mask.rel}\n`), mask.preBytes] });
709
- }
710
- }
711
- return Buffer.concat([pieces.staged, unstaged, ...entries.flatMap((e) => e.chunks)]);
712
- };
39
+ export {
40
+ SUBSET_ATTEMPT_MAX_REDS, SUBSET_ATTEMPT_DIAGNOSIS_REDS, subsetAttemptState, subsetExhaustionRemedy,
41
+ } from './flow-subset-budget.mjs';
713
42
 
714
- export const computeMaskedFingerprintPayload = (cwd, mask = null, fsx) => {
715
- const pieces = captureFingerprintPieces(cwd, fsx);
716
- return pieces == null ? null : assembleMaskedPayload(pieces, mask);
717
- };
43
+ export {
44
+ FLOW_LOCK_WAIT_MS, FLOW_LOCK_POLL_MS, appendFlowRecord, appendFlowRecordWithPreflight,
45
+ SUBSET_RUN_LOCK_INFIX, acquireSubsetRunLock, probeFlowAppendLock, appendSubsetAttempt,
46
+ } from './flow-append.mjs';
718
47
 
719
- // mintBookkeepingDelta: the FULL pre-state arrives as EXPLICIT inputs (pre-change worktree bytes +
720
- // the presence class; tracked-ness derives from the window-constant HEAD/index layers) — never
721
- // reconstructed from ambient git state. The computation only READS: the working tree is never
722
- // mutated. The mint refuses unless the masked recompute reproduces fingerprintBefore — an
723
- // unconfined delta never lands; the proof payload persists so the checker can verify a PROVEN
724
- // mint against a bare declaration.
725
- export const mintBookkeepingDelta = ({ cwd = process.cwd(), env = process.env, deps = {}, path: rel, fingerprintBefore, preContent = null, timestamp = new Date().toISOString() } = {}) => {
726
- if (typeof fingerprintBefore !== 'string' || !HEX64_RE.test(fingerprintBefore)) {
727
- throw stop('fingerprintBefore must be the 64-hex PRE-DELTA tree fingerprint — the proof compares the masked recompute against it (fail closed)');
728
- }
729
- const lex = lexicalRepoRelative(rel);
730
- if (!lex.ok) throw stop(`the declared path must be lexically repo-relative — ${lex.reason} (fail closed)`);
731
- if (pathNeedsGitQuoting(rel)) {
732
- throw stop(`the declared path "${rel}" needs git diff-header quoting — an unsupported pre-state class (the masked recompute matches plain header bytes only; fail closed)`);
733
- }
734
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
735
- if (top == null) throw stop('not inside a git work tree — the custody proof has no meaning outside the fingerprint domain; refusing to mint');
736
- const preBytes = preContent == null ? null : Buffer.from(preContent);
737
- if (preBytes !== null && bufferLooksBinary(preBytes)) {
738
- throw stop(`the pre-change bytes of ${rel} carry binary content — an unsupported pre-state class (fail closed)`);
739
- }
740
- const full = join(top, rel);
741
- const st = lstatNoFollow(full, deps.lstat ?? lstatSync);
742
- if (st?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
743
- if (st && !st.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(st)} — an unsupported pre-state class (fail closed)`);
744
- if (st && (st.mode & 0o111) !== 0) throw stop(`the declared path ${rel} carries an executable mode — an unsupported pre-state class (mode motion cannot be expressed; fail closed)`);
745
- const nowBytes = st ? readFileSync(full) : null;
746
- if (nowBytes !== null && bufferLooksBinary(nowBytes)) {
747
- throw stop(`the declared path ${rel} carries binary content — an unsupported pre-state class (fail closed)`);
748
- }
749
- const preClass = preBytes === null ? 'absent' : 'present';
750
- if (preClass === 'absent' && nowBytes === null) {
751
- throw stop('the absent→absent transition is unsupported — supported: present→present, present→absent, absent→present (fail closed)');
752
- }
753
- const runGit = deps.runGit ?? defaultRunGit;
754
- const index = readIndexEntry(top, rel, runGit);
755
- const head = readHeadEntry(top, rel, runGit);
756
- for (const [layer, entry] of [['index', index], ['HEAD', head]]) {
757
- if (entry && entry.mode !== REGULAR_FILE_MODE) {
758
- throw stop(`the ${layer} entry of ${rel} carries mode ${entry.mode} — an unsupported pre-state class (only plain ${REGULAR_FILE_MODE} regular files are expressible; fail closed)`);
759
- }
760
- }
761
- if (index == null && head != null) {
762
- throw stop(`the declared path ${rel} has a HEAD entry but no index entry (a staged deletion) — an unsupported pre-state class (fail closed)`);
763
- }
764
- const tracked = index != null || head != null;
765
- const headBytes = head == null ? null : readBlob(top, head.sha, runGit);
766
- const indexBytes = index == null ? null : readBlob(top, index.sha, runGit);
767
- let mask;
768
- if (tracked) {
769
- if (preClass === 'absent') {
770
- throw stop(`the declared path ${rel} is tracked while its pre-change worktree state is absent — a dirty pre-state at the declared path is an unsupported pre-state class (the masked proof covers a clean-at-path pre-state only; fail closed)`);
771
- }
772
- if (!preBytes.equals(indexBytes)) {
773
- throw stop(`the declared path ${rel} has a dirty pre-state (the pre-change worktree bytes do not equal the index entry) — an unsupported pre-state class (the masked proof covers a clean-at-path pre-state only; fail closed)`);
774
- }
775
- mask = { layer: 'diff', rel };
776
- } else {
777
- // --no-index: the ignore ANSWER must come from the rules alone — with the index consulted, a
778
- // tracked glob neighbor (feature-a.md vs the literal feature-[a].md) flips the answer and a
779
- // genuinely ignored path would spuriously refuse to mint.
780
- const ig = runGit(['check-ignore', '-q', '--no-index', '--', rel], top);
781
- if (ig.error || (ig.status !== 0 && ig.status !== 1)) {
782
- throw stop(`cannot decide the ignore state of ${rel} (git check-ignore failed) — refusing to mint (fail closed)`);
783
- }
784
- // An ignored path is outside the fingerprint domain in BOTH states — the mask is a no-op there.
785
- // Honest limit: an untracked path's MODE is likewise invisible to the frozen payload in both
786
- // states (an entry is name + bytes only) — untracked mode motion is neither expressible nor
787
- // claimed; only the CURRENT tree's non-plain modes refuse by name above.
788
- mask = { layer: 'untracked', rel, insert: preClass === 'present' && ig.status !== 0, preBytes };
789
- }
790
- const pieces = captureFingerprintPieces(cwd, deps);
791
- if (pieces == null) throw stop('cannot capture the fingerprint read set (a git probe failed) — refusing to mint (fail closed)');
792
- // Bracket: the declared path must still be EXACTLY what the class checks and contentDigest
793
- // observed — the no-follow class checks repeat first, then presence + bytes must match, so the
794
- // digest and the captured payload can never bind two different post-states.
795
- const stAfter = lstatNoFollow(full, deps.lstat ?? lstatSync);
796
- if (stAfter?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
797
- if (stAfter && !stAfter.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(stAfter)} — an unsupported pre-state class (fail closed)`);
798
- if (stAfter && (stAfter.mode & 0o111) !== 0) throw stop(`the declared path ${rel} carries an executable mode — an unsupported pre-state class (mode motion cannot be expressed; fail closed)`);
799
- const bytesAfter = stAfter ? readFileSync(full) : null;
800
- const declaredMoved = (stAfter == null) !== (nowBytes === null)
801
- || (nowBytes !== null && bytesAfter !== null && !bytesAfter.equals(nowBytes));
802
- if (declaredMoved) {
803
- throw stop(`the declared path ${rel} moved under the mint (its bytes or presence changed during the capture) — contentDigest and the captured payload must bind ONE post-state; retry on a quiescent tree (fail closed)`);
804
- }
805
- const maskedFingerprint = sha256Hex(assembleMaskedPayload(pieces, mask));
806
- if (maskedFingerprint !== fingerprintBefore) {
807
- throw stop(`the delta is NOT confined to the declared path ${rel} — the masked revert-and-recompute (${maskedFingerprint.slice(0, 12)}…) does not reproduce fingerprintBefore (${fingerprintBefore.slice(0, 12)}…); something else moved in the window (fail closed)`);
808
- }
809
- // Both fingerprints derive from the ONE captured read set — a tree move between two independent
810
- // snapshots can never be certified as a confined delta.
811
- const fingerprintAfter = sha256Hex(assembleMaskedPayload(pieces, null));
812
- const record = {
813
- schema: FLOW_SCHEMA_VERSION, kind: 'bookkeeping-delta', fingerprintBefore, fingerprintAfter,
814
- path: rel, contentDigest: nowBytes === null ? null : sha256Hex(nowBytes),
815
- custodyProof: {
816
- preClass, tracked,
817
- headDigest: headBytes === null ? null : sha256Hex(headBytes),
818
- indexDigest: indexBytes === null ? null : sha256Hex(indexBytes),
819
- worktreeDigest: preBytes === null ? null : sha256Hex(preBytes),
820
- maskedFingerprint,
821
- },
822
- base: resolveBase(cwd), timestamp,
823
- };
824
- const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
825
- return { writtenPath, record, digest: canonicalFlowDigest(record) };
826
- };
48
+ export { readPlanFrontmatterId, mintAdoption } from './flow-adoption-mint.mjs';
827
49
 
50
+ export { computeMaskedFingerprintPayload, mintBookkeepingDelta } from './flow-delta-proof.mjs';