@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
@@ -0,0 +1,307 @@
1
+ // flow-delta-proof.mjs — the bookkeeping-delta custody proof (#60): the masked revert-and-recompute
2
+ // at mint time, its strict -z index/HEAD parsers, the diff-section mask, the ONE captured read set,
3
+ // computeMaskedFingerprintPayload and mintBookkeepingDelta. Split out of flow-store.mjs unchanged
4
+ // (baseline-practices tranche 2); the facade re-exports both public names.
5
+ //
6
+ // The computation only READS: the working tree is never mutated, and an unconfined delta never
7
+ // lands. Imports run ONE way: this leaf mints through the store's ONE append door
8
+ // (flow-append.mjs) and never reaches the flow-store.mjs facade or its sibling mint leaf — the
9
+ // one-line sha256Hex, HEX64_RE and the git buffer helper below are deliberate copies rather than
10
+ // sideways imports.
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync, lstatSync, readlinkSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { lstatNoFollow } from './atomic-write.mjs';
17
+ import { FLOW_SCHEMA_VERSION, canonicalFlowDigest } from './flow-record.mjs';
18
+ import { isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase } from './core-evidence.mjs';
19
+ import { flowStoreStop, gitLine, describeNonRegular } from './flow-store-read.mjs';
20
+ import { appendFlowRecord } from './flow-append.mjs';
21
+
22
+ const stop = flowStoreStop;
23
+
24
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
25
+ const gitBuf = (args, cwd) => {
26
+ const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
27
+ if (r.error || r.status !== 0) return null;
28
+ return r.stdout;
29
+ };
30
+ const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
31
+
32
+ const HEX64_RE = /^[0-9a-f]{64}$/;
33
+
34
+ // The supported pre-state model; everything else refuses BY NAME (fail closed): the delta lives in
35
+ // the WORKTREE layer of one plain-ASCII, non-binary, non-executable regular path. A tracked path
36
+ // must be CLEAN at the path before the delta (pre-change worktree bytes = its index entry), so the
37
+ // pre-state contributes NO unstaged diff section and the mask is pure section REMOVAL plus
38
+ // untracked-entry splicing — the recompute never regenerates git diff bytes, whose exact form this
39
+ // module cannot promise. Supported transitions: present→present, present→absent, absent→present.
40
+
41
+ const GIT_PLAIN_PATH_RE = /^[\x20-\x7e]+$/;
42
+ const pathNeedsGitQuoting = (rel) => !GIT_PLAIN_PATH_RE.test(rel) || rel.includes('"') || rel.includes('\\');
43
+ const bufferLooksBinary = (buf) => buf.subarray(0, 8192).includes(0);
44
+ const REGULAR_FILE_MODE = '100644';
45
+
46
+ const defaultRunGit = (args, dir) => spawnSync('git', args, { cwd: dir, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
47
+
48
+ // The declared path enters git as a LITERAL pathspec and comes back through a strict -z parse:
49
+ // exactly one NUL-terminated record whose path field EQUALS the declared rel, full-octal mode,
50
+ // an OID of exactly 40 or 64 hex — a glob-capable name ([]*?) or a prefix-valid truncated answer
51
+ // can then never bind the proof to another file (fail closed on every mismatch).
52
+ const OID_PART = '(?:[0-9a-f]{40}|[0-9a-f]{64})';
53
+ const INDEX_META_RE = new RegExp(`^([0-7]{6}) (${OID_PART}) (\\d)$`);
54
+ const TREE_META_RE = new RegExp(`^([0-7]{6}) (\\w+) (${OID_PART})$`);
55
+
56
+ const parseZRecords = (stdout) => {
57
+ const text = stdout.toString('utf8');
58
+ if (text === '') return [];
59
+ if (!text.endsWith('\0')) return null;
60
+ return text.slice(0, -1).split('\0');
61
+ };
62
+
63
+ const splitZEntry = (entry, metaRe) => {
64
+ const at = entry.indexOf('\t');
65
+ if (at === -1) return null;
66
+ const meta = metaRe.exec(entry.slice(0, at));
67
+ return meta == null ? null : { meta, path: entry.slice(at + 1) };
68
+ };
69
+
70
+ const readIndexEntry = (top, rel, runGit) => {
71
+ const out = runGit(['ls-files', '-s', '-z', '--', `:(literal)${rel}`], top);
72
+ if (out.error || out.status !== 0) throw stop(`cannot read the index entry of ${rel} (git ls-files failed) — refusing to mint (fail closed)`);
73
+ const recordsZ = parseZRecords(out.stdout);
74
+ if (recordsZ == null) throw stop(`cannot parse the index entry of ${rel} (unterminated git ls-files output) — refusing to mint (fail closed)`);
75
+ if (recordsZ.length === 0) return null;
76
+ const entry = splitZEntry(recordsZ[0], INDEX_META_RE);
77
+ if (recordsZ.length > 1 || entry == null || entry.meta[3] !== '0' || entry.path !== rel) {
78
+ throw stop(`the declared path ${rel} carries an unmerged or unparseable index entry — an unsupported pre-state class (fail closed)`);
79
+ }
80
+ return { mode: entry.meta[1], sha: entry.meta[2] };
81
+ };
82
+
83
+ // An absent HEAD layer is PROVEN unborn, never assumed: rev-parse must answer with EXACTLY the
84
+ // clean verify-miss status (1) AND HEAD must still resolve as a symbolic ref; any operational
85
+ // fault fails closed. "No entry" is ONLY an empty ls-tree stdout — a non-empty answer must parse
86
+ // as exactly one entry line, else the repository is at fault (a false custody proof otherwise).
87
+ const GIT_VERIFY_MISS_STATUS = 1;
88
+ const readHeadEntry = (top, rel, runGit) => {
89
+ const probe = runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], top);
90
+ if (probe.error || probe.status !== 0) {
91
+ const verifyMiss = !probe.error && probe.status === GIT_VERIFY_MISS_STATUS;
92
+ const sym = verifyMiss ? runGit(['symbolic-ref', '--quiet', 'HEAD'], top) : null;
93
+ if (sym == null || sym.error || sym.status !== 0) {
94
+ 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)');
95
+ }
96
+ return null;
97
+ }
98
+ const out = runGit(['ls-tree', '-z', 'HEAD', '--', `:(literal)${rel}`], top);
99
+ 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)`);
100
+ const recordsZ = parseZRecords(out.stdout);
101
+ if (recordsZ == null) throw stop(`cannot parse the HEAD entry of ${rel} (unterminated git ls-tree output) — refusing to mint (fail closed)`);
102
+ if (recordsZ.length === 0) return null;
103
+ const entry = splitZEntry(recordsZ[0], TREE_META_RE);
104
+ if (recordsZ.length > 1 || entry == null || entry.path !== rel) {
105
+ throw stop(`cannot parse the HEAD entry of ${rel} (unexpected git ls-tree output) — refusing to mint (fail closed)`);
106
+ }
107
+ if (entry.meta[2] !== 'blob') {
108
+ throw stop(`the HEAD entry of ${rel} is a ${entry.meta[2]}, not a blob — an unsupported pre-state class (fail closed)`);
109
+ }
110
+ return { mode: entry.meta[1], sha: entry.meta[3] };
111
+ };
112
+
113
+ const readBlob = (top, sha, runGit) => {
114
+ const out = runGit(['cat-file', 'blob', sha], top);
115
+ if (out.error || out.status !== 0) throw stop(`cannot read blob ${sha} from the object store — refusing to mint (fail closed)`);
116
+ return out.stdout;
117
+ };
118
+
119
+ // Byte-level removal of ONE file's section from a git diff buffer. Hunk lines start with
120
+ // [ +\-\\@], so a line starting "diff --git " is always a section header; the declared path is
121
+ // plain-ASCII by refusal, so its header is these exact bytes. No section = a no-op mask.
122
+ const DIFF_SECTION_START = Buffer.from('\ndiff --git ');
123
+ const removeDiffSection = (buf, rel) => {
124
+ const header = Buffer.from(`diff --git a/${rel} b/${rel}\n`);
125
+ let at = -1;
126
+ if (buf.subarray(0, header.length).equals(header)) at = 0;
127
+ else {
128
+ const i = buf.indexOf(Buffer.concat([Buffer.from('\n'), header]));
129
+ if (i !== -1) at = i + 1;
130
+ }
131
+ if (at === -1) return buf;
132
+ const next = buf.indexOf(DIFF_SECTION_START, at + header.length - 1);
133
+ const end = next === -1 ? buf.length : next + 1;
134
+ return Buffer.concat([buf.subarray(0, at), buf.subarray(end)]);
135
+ };
136
+
137
+ // One untracked entry's payload chunks, branch-for-branch the frozen core's discipline
138
+ // (computeFingerprintPayload) — the NULL-mask parity test pins the byte equality.
139
+ const untrackedEntryChunks = (top, rel, lstat) => {
140
+ const full = join(top, rel);
141
+ let stat = null;
142
+ try {
143
+ stat = lstat(full);
144
+ } catch {
145
+ stat = null;
146
+ }
147
+ if (isNeverCommittableStat(stat)) return [];
148
+ if (stat?.isSymbolicLink()) {
149
+ let target = '?';
150
+ try {
151
+ target = readlinkSync(full);
152
+ } catch {
153
+ target = '?';
154
+ }
155
+ return [Buffer.from(`untracked-symlink:${rel} -> ${target}\n`)];
156
+ }
157
+ if (!stat?.isFile()) return [Buffer.from(`untracked-nonregular:${rel}\n`)];
158
+ if (isBinaryFile(full)) return [Buffer.from(`untracked-binary:${rel}\n`)];
159
+ return [Buffer.from(`untracked:${rel}\n`), readFileSync(full)];
160
+ };
161
+
162
+ // ONE captured read set — every assembly over it (masked and unmasked) binds the SAME tree
163
+ // snapshot, so a tree move between two independent snapshots can never be certified. The three
164
+ // git reads themselves are separate processes; that window is the frozen core's own inherent
165
+ // residual and stays declared, not closed.
166
+ const captureFingerprintPieces = (cwd, { lstat = lstatSync } = {}) => {
167
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
168
+ if (top == null) return null;
169
+ const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
170
+ const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
171
+ const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
172
+ if (staged == null || unstaged == null || untrackedZ == null) return null;
173
+ const entries = untrackedZ.toString('utf8').split('\0').filter(Boolean)
174
+ .map((rel) => ({ rel, chunks: untrackedEntryChunks(top, rel, lstat) }));
175
+ return { staged, unstaged, entries };
176
+ };
177
+
178
+ // mask: null = the exact frozen-core payload; { layer: 'diff', rel } removes the path's unstaged
179
+ // section (its pre-state section is EMPTY by the clean-at-path rule); { layer: 'untracked', rel,
180
+ // insert, preBytes } splices the untracked entry (git emits ls-files sorted by path bytes).
181
+ const assembleMaskedPayload = (pieces, mask) => {
182
+ const unstaged = mask?.layer === 'diff' ? removeDiffSection(pieces.unstaged, mask.rel) : pieces.unstaged;
183
+ let entries = pieces.entries;
184
+ if (mask?.layer === 'untracked') {
185
+ entries = entries.filter((e) => e.rel !== mask.rel);
186
+ if (mask.insert) {
187
+ const at = entries.findIndex((e) => e.rel > mask.rel);
188
+ entries = [...entries];
189
+ entries.splice(at === -1 ? entries.length : at, 0, { rel: mask.rel, chunks: [Buffer.from(`untracked:${mask.rel}\n`), mask.preBytes] });
190
+ }
191
+ }
192
+ return Buffer.concat([pieces.staged, unstaged, ...entries.flatMap((e) => e.chunks)]);
193
+ };
194
+
195
+ export const computeMaskedFingerprintPayload = (cwd, mask = null, fsx) => {
196
+ const pieces = captureFingerprintPieces(cwd, fsx);
197
+ return pieces == null ? null : assembleMaskedPayload(pieces, mask);
198
+ };
199
+
200
+ // mintBookkeepingDelta: the FULL pre-state arrives as EXPLICIT inputs (pre-change worktree bytes +
201
+ // the presence class; tracked-ness derives from the window-constant HEAD/index layers) — never
202
+ // reconstructed from ambient git state. The computation only READS: the working tree is never
203
+ // mutated. The mint refuses unless the masked recompute reproduces fingerprintBefore — an
204
+ // unconfined delta never lands; the proof payload persists so the checker can verify a PROVEN
205
+ // mint against a bare declaration.
206
+ export const mintBookkeepingDelta = ({ cwd = process.cwd(), env = process.env, deps = {}, path: rel, fingerprintBefore, preContent = null, timestamp = new Date().toISOString() } = {}) => {
207
+ if (typeof fingerprintBefore !== 'string' || !HEX64_RE.test(fingerprintBefore)) {
208
+ throw stop('fingerprintBefore must be the 64-hex PRE-DELTA tree fingerprint — the proof compares the masked recompute against it (fail closed)');
209
+ }
210
+ const lex = lexicalRepoRelative(rel);
211
+ if (!lex.ok) throw stop(`the declared path must be lexically repo-relative — ${lex.reason} (fail closed)`);
212
+ if (pathNeedsGitQuoting(rel)) {
213
+ 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)`);
214
+ }
215
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
216
+ if (top == null) throw stop('not inside a git work tree — the custody proof has no meaning outside the fingerprint domain; refusing to mint');
217
+ const preBytes = preContent == null ? null : Buffer.from(preContent);
218
+ if (preBytes !== null && bufferLooksBinary(preBytes)) {
219
+ throw stop(`the pre-change bytes of ${rel} carry binary content — an unsupported pre-state class (fail closed)`);
220
+ }
221
+ const full = join(top, rel);
222
+ const st = lstatNoFollow(full, deps.lstat ?? lstatSync);
223
+ if (st?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
224
+ if (st && !st.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(st)} — an unsupported pre-state class (fail closed)`);
225
+ 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)`);
226
+ const nowBytes = st ? readFileSync(full) : null;
227
+ if (nowBytes !== null && bufferLooksBinary(nowBytes)) {
228
+ throw stop(`the declared path ${rel} carries binary content — an unsupported pre-state class (fail closed)`);
229
+ }
230
+ const preClass = preBytes === null ? 'absent' : 'present';
231
+ if (preClass === 'absent' && nowBytes === null) {
232
+ throw stop('the absent→absent transition is unsupported — supported: present→present, present→absent, absent→present (fail closed)');
233
+ }
234
+ const runGit = deps.runGit ?? defaultRunGit;
235
+ const index = readIndexEntry(top, rel, runGit);
236
+ const head = readHeadEntry(top, rel, runGit);
237
+ for (const [layer, entry] of [['index', index], ['HEAD', head]]) {
238
+ if (entry && entry.mode !== REGULAR_FILE_MODE) {
239
+ 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)`);
240
+ }
241
+ }
242
+ if (index == null && head != null) {
243
+ throw stop(`the declared path ${rel} has a HEAD entry but no index entry (a staged deletion) — an unsupported pre-state class (fail closed)`);
244
+ }
245
+ const tracked = index != null || head != null;
246
+ const headBytes = head == null ? null : readBlob(top, head.sha, runGit);
247
+ const indexBytes = index == null ? null : readBlob(top, index.sha, runGit);
248
+ let mask;
249
+ if (tracked) {
250
+ if (preClass === 'absent') {
251
+ 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)`);
252
+ }
253
+ if (!preBytes.equals(indexBytes)) {
254
+ 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)`);
255
+ }
256
+ mask = { layer: 'diff', rel };
257
+ } else {
258
+ // --no-index: the ignore ANSWER must come from the rules alone — with the index consulted, a
259
+ // tracked glob neighbor (feature-a.md vs the literal feature-[a].md) flips the answer and a
260
+ // genuinely ignored path would spuriously refuse to mint.
261
+ const ig = runGit(['check-ignore', '-q', '--no-index', '--', rel], top);
262
+ if (ig.error || (ig.status !== 0 && ig.status !== 1)) {
263
+ throw stop(`cannot decide the ignore state of ${rel} (git check-ignore failed) — refusing to mint (fail closed)`);
264
+ }
265
+ // An ignored path is outside the fingerprint domain in BOTH states — the mask is a no-op there.
266
+ // Honest limit: an untracked path's MODE is likewise invisible to the frozen payload in both
267
+ // states (an entry is name + bytes only) — untracked mode motion is neither expressible nor
268
+ // claimed; only the CURRENT tree's non-plain modes refuse by name above.
269
+ mask = { layer: 'untracked', rel, insert: preClass === 'present' && ig.status !== 0, preBytes };
270
+ }
271
+ const pieces = captureFingerprintPieces(cwd, deps);
272
+ if (pieces == null) throw stop('cannot capture the fingerprint read set (a git probe failed) — refusing to mint (fail closed)');
273
+ // Bracket: the declared path must still be EXACTLY what the class checks and contentDigest
274
+ // observed — the no-follow class checks repeat first, then presence + bytes must match, so the
275
+ // digest and the captured payload can never bind two different post-states.
276
+ const stAfter = lstatNoFollow(full, deps.lstat ?? lstatSync);
277
+ if (stAfter?.isSymbolicLink()) throw stop(`the declared path ${rel} is a symlink — an unsupported pre-state class (fail closed)`);
278
+ if (stAfter && !stAfter.isFile()) throw stop(`the declared path ${rel} is a ${describeNonRegular(stAfter)} — an unsupported pre-state class (fail closed)`);
279
+ 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)`);
280
+ const bytesAfter = stAfter ? readFileSync(full) : null;
281
+ const declaredMoved = (stAfter == null) !== (nowBytes === null)
282
+ || (nowBytes !== null && bytesAfter !== null && !bytesAfter.equals(nowBytes));
283
+ if (declaredMoved) {
284
+ 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)`);
285
+ }
286
+ const maskedFingerprint = sha256Hex(assembleMaskedPayload(pieces, mask));
287
+ if (maskedFingerprint !== fingerprintBefore) {
288
+ 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)`);
289
+ }
290
+ // Both fingerprints derive from the ONE captured read set — a tree move between two independent
291
+ // snapshots can never be certified as a confined delta.
292
+ const fingerprintAfter = sha256Hex(assembleMaskedPayload(pieces, null));
293
+ const record = {
294
+ schema: FLOW_SCHEMA_VERSION, kind: 'bookkeeping-delta', fingerprintBefore, fingerprintAfter,
295
+ path: rel, contentDigest: nowBytes === null ? null : sha256Hex(nowBytes),
296
+ custodyProof: {
297
+ preClass, tracked,
298
+ headDigest: headBytes === null ? null : sha256Hex(headBytes),
299
+ indexDigest: indexBytes === null ? null : sha256Hex(indexBytes),
300
+ worktreeDigest: preBytes === null ? null : sha256Hex(preBytes),
301
+ maskedFingerprint,
302
+ },
303
+ base: resolveBase(cwd), timestamp,
304
+ };
305
+ const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
306
+ return { writtenPath, record, digest: canonicalFlowDigest(record) };
307
+ };
@@ -0,0 +1,70 @@
1
+ // flow-finding-manifest.mjs — the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24): the
2
+ // SAFE nonce grammar, the manifest filename prefix and the basename derived from the dispatch
3
+ // identity, the closed manifest shape, and the ONE fatal-UTF-8 decoder both kit consumers share.
4
+ // Split out of flow-record.mjs unchanged (baseline-practices tranche 3), which now re-exports every
5
+ // name here.
6
+ //
7
+ // Pure form: no filesystem, no git, no CLI, no side effects on import — it validates and DERIVES a
8
+ // name, it never opens the file the name denotes. Imports run ONE way: the vocabulary leaf owns the
9
+ // schema version and the shared form bindings, and nothing here reaches back up to the facade.
10
+
11
+ import { FLOW_SCHEMA_VERSION, isHex64, isNonEmptyString, isPlainObject, refuse } from './flow-vocabulary.mjs';
12
+
13
+ // ── the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24) — pure form ──────────────────
14
+
15
+ // The SAFE nonce grammar (containment-checked): the nonce enters a DERIVED FILENAME in the git
16
+ // dir, so only this closed byte set is accepted — anything else refuses before a name composes.
17
+ export const SAFE_NONCE_RE = /^[A-Za-z0-9._-]{1,64}$/;
18
+
19
+ export const FINDING_MANIFEST_PREFIX = 'agent-workflow-finding-manifest-';
20
+
21
+ // The manifest filename derives from the DISPATCH IDENTITY {backend, nonce} — two backends can
22
+ // never collide on one nonce (P24). Both halves are containment-checked; null on any violation.
23
+ export const findingManifestBasename = (backend, nonce) => {
24
+ if (typeof backend !== 'string' || !SAFE_NONCE_RE.test(backend)) return null;
25
+ if (typeof nonce !== 'string' || !SAFE_NONCE_RE.test(nonce)) return null;
26
+ return `${FINDING_MANIFEST_PREFIX}${backend}-${nonce}.json`;
27
+ };
28
+
29
+ // The closed manifest shape {schema, backend, nonce, fingerprint, findings} (P24) — findings is
30
+ // the wrapper-captured findings payload VERBATIM (form-provable; semantics stay an honest limit).
31
+ const FINDING_MANIFEST_KEYS = ['schema', 'backend', 'nonce', 'fingerprint', 'findings'];
32
+
33
+ export const validateFindingManifest = (manifest) => {
34
+ if (!isPlainObject(manifest)) return refuse('finding manifest: not an object');
35
+ const stray = Object.keys(manifest).find((k) => !FINDING_MANIFEST_KEYS.includes(k));
36
+ if (stray !== undefined) return refuse(`finding manifest: unknown field "${stray}" — the key set is closed (fail closed)`);
37
+ const missing = FINDING_MANIFEST_KEYS.find((k) => !(k in manifest));
38
+ if (missing !== undefined) return refuse(`finding manifest: missing field "${missing}"`);
39
+ if (manifest.schema !== FLOW_SCHEMA_VERSION) {
40
+ return refuse(`finding manifest: unknown schema ${JSON.stringify(manifest.schema)} — this reader accepts schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
41
+ }
42
+ if (typeof manifest.backend !== 'string' || !SAFE_NONCE_RE.test(manifest.backend)) return refuse('finding manifest: backend must satisfy the safe name grammar ([A-Za-z0-9._-]{1,64})');
43
+ if (typeof manifest.nonce !== 'string' || !SAFE_NONCE_RE.test(manifest.nonce)) return refuse('finding manifest: nonce must satisfy the safe nonce grammar ([A-Za-z0-9._-]{1,64})');
44
+ if (manifest.fingerprint !== null && !isHex64(manifest.fingerprint)) return refuse('finding manifest: fingerprint must be a 64-hex tree fingerprint, or null when the wrapper could not compute one');
45
+ if (!isNonEmptyString(manifest.findings)) return refuse('finding manifest: findings must be the non-empty captured findings payload (one string)');
46
+ if (!manifest.findings.isWellFormed()) return refuse('finding manifest: findings must be a well-formed Unicode string — utf8-hashing a lone surrogate would substitute U+FFFD and corrupt the findingDigest domain (fail closed)');
47
+ return { ok: true };
48
+ };
49
+
50
+ // The ONE manifest reader both kit consumers share (flow-writer's consult arm, the
51
+ // receipt-deadline runner): FATAL UTF-8 decode — a lossy toString would substitute U+FFFD and
52
+ // silently mutate the digest domain — with ignoreBOM, so a BOM-prefixed file keeps refusing at
53
+ // JSON.parse exactly as the pre-helper path did (no behavior widening).
54
+ export const decodeFindingManifest = (bytes) => {
55
+ let text;
56
+ try {
57
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
58
+ } catch {
59
+ return refuse('finding manifest: not valid UTF-8 — a lossy decode would silently mutate the findings digest domain (fail closed)');
60
+ }
61
+ let manifest;
62
+ try {
63
+ manifest = JSON.parse(text);
64
+ } catch {
65
+ return refuse('finding manifest: not valid JSON (fail closed)');
66
+ }
67
+ const valid = validateFindingManifest(manifest);
68
+ if (!valid.ok) return valid;
69
+ return { ok: true, manifest };
70
+ };
@@ -0,0 +1,248 @@
1
+ // flow-legality.mjs — the two raw-order legality walks over an in-memory record list: chain
2
+ // sequence legality (validateChainSequence, with the private round-revision rule it enforces) and
3
+ // stateful-kind supersession legality (validateSupersessions). Split out of flow-record.mjs
4
+ // unchanged (baseline-practices tranche 3), which now re-exports both names here.
5
+ //
6
+ // Pure form: no filesystem, no git, no CLI, no side effects on import. RAW order is the input — the
7
+ // authoritative latest-per-key view deliberately never reaches these walks. Reference RESOLUTION
8
+ // against a real store lands with flow-store/flow-check; this module resolves references only
9
+ // inside the list it is handed. The HIGHEST leaf of the family: it composes the vocabulary, the
10
+ // shape validator and the identity digests, and nothing here reaches back up to the facade.
11
+
12
+ import { ALLOWED_TRANSITIONS, CHAIN_KIND, PLAN_LANE_PURPOSES, refuse } from './flow-vocabulary.mjs';
13
+ import { validateFlowRecord } from './flow-record-shape.mjs';
14
+ import { canonicalFlowDigest, flowCanonicalSerialization } from './flow-record-identity.mjs';
15
+
16
+ // A same-index round REVISION re-states its round: opensFrom/base/fingerprint/commitEpoch are
17
+ // byte-equal to the previous version (the receipt attests the DISPATCHED tree, even when the live
18
+ // tree has moved on), existing ledger entries keep their order (a pending dispatch may land IN
19
+ // PLACE — both digests arrive together; a landed entry stays byte-identical), and new entries
20
+ // append only at the tail. Entry equality is canonical, never insertion-order-sensitive.
21
+ const validateRoundRevision = (prev, r) => {
22
+ for (const field of ['opensFrom', 'base', 'fingerprint', 'commitEpoch']) {
23
+ if (r[field] !== prev[field]) {
24
+ return refuse(`chain sequence: a round revision re-states its round — ${field} stays unchanged (the receipt attests the DISPATCHED tree)`);
25
+ }
26
+ }
27
+ if (r.dispatches.length < prev.dispatches.length) {
28
+ return refuse('chain sequence: a round revision never regresses its dispatch ledger (an entry disappeared)');
29
+ }
30
+ for (let i = 0; i < prev.dispatches.length; i += 1) {
31
+ const before = prev.dispatches[i];
32
+ const after = r.dispatches[i];
33
+ if (flowCanonicalSerialization(before) === flowCanonicalSerialization(after)) continue;
34
+ const landedInPlace = before.receiptDigest === null && after.receiptDigest !== null
35
+ && flowCanonicalSerialization({ ...after, receiptDigest: null, findingManifestDigest: null }) === flowCanonicalSerialization(before);
36
+ if (!landedInPlace) {
37
+ return refuse(`chain sequence: a round revision never regresses or mutates its dispatch ledger (entry ${i} — only pending → landed enriches, in place)`);
38
+ }
39
+ }
40
+ if (r.dispositions.length < prev.dispositions.length) {
41
+ return refuse('chain sequence: a round revision never regresses its disposition ledger (an entry disappeared)');
42
+ }
43
+ for (let i = 0; i < prev.dispositions.length; i += 1) {
44
+ if (flowCanonicalSerialization(prev.dispositions[i]) !== flowCanonicalSerialization(r.dispositions[i])) {
45
+ return refuse(`chain sequence: a round revision never regresses its disposition ledger (entry ${i} — existing dispositions stay byte-identical, new ones append at the tail)`);
46
+ }
47
+ }
48
+ return { ok: true };
49
+ };
50
+
51
+ // ── chain sequence legality (raw order, one chain) ────────────────────────────────────────────────
52
+
53
+ // validateChainSequence(records) → { ok } | { ok: false, reason }. Input: the RAW-order chain
54
+ // records of ONE plan's chain. Enforces: starts at adoption and adoption never recurs (#44/#58);
55
+ // serial-monotonic step grouping with closure scoped per {cycle, stepId} (a stepId reopens in a
56
+ // LATER cycle through an ordinary opener — the redesign valve); the within-step successor table;
57
+ // a step opens with "round" carrying the prior-terminal reference (structural half — digest
58
+ // resolution against the store lands with flow-check); a boundary re-baseline records disjoint
59
+ // base motion anchored to the prior terminal without reopening anything; park admits only resume
60
+ // and both preserve the pre-park {cycle, round}; complete admits no successor. Park/resume/complete
61
+ // are explicit writer actions — this validator only refuses (#59).
62
+ export const validateChainSequence = (records) => {
63
+ if (!Array.isArray(records)) return refuse('chain sequence: records must be an array');
64
+ if (records.length === 0) return { ok: true };
65
+ for (const r of records) {
66
+ if (r?.kind !== CHAIN_KIND) return refuse(`chain sequence: the validator accepts chain records only (got kind ${JSON.stringify(r?.kind)})`);
67
+ const v = validateFlowRecord(r);
68
+ if (!v.ok) return refuse(`chain sequence: malformed member — ${v.reason}`);
69
+ if (r.planId !== records[0].planId) return refuse(`chain sequence: one validator run covers one plan's chain (got "${records[0].planId}" and "${r.planId}")`);
70
+ if (r.owner !== records[0].owner) return refuse(`chain sequence: chain records never migrate owners — every record carries the adoption owner ("${records[0].owner}", got "${r.owner}"); an ownership transfer needs an explicit protocol, never a silent field change`);
71
+ }
72
+ if (records[0].purpose !== 'adoption') {
73
+ return refuse(`chain sequence: the chain starts at adoption — first record is "${records[0].purpose}"`);
74
+ }
75
+ const closureKey = (cycle, stepId) => JSON.stringify([cycle, stepId]);
76
+ const state = {
77
+ mode: 'boundary',
78
+ parked: null,
79
+ completed: false,
80
+ currentStep: null,
81
+ stepCycle: null,
82
+ currentRound: null,
83
+ lastPurpose: null,
84
+ lastTerminated: null,
85
+ boundaryRound: records[0].round,
86
+ closedSteps: new Set(),
87
+ lastCycle: records[0].cycle,
88
+ lastEpoch: records[0].commitEpoch,
89
+ roundLedgers: new Map(),
90
+ };
91
+ const contextCycle = () => (state.mode === 'in-step' ? state.stepCycle : state.lastCycle);
92
+ const contextRound = () => (state.mode === 'in-step' ? state.currentRound : state.boundaryRound);
93
+ const ledgerKey = (r) => JSON.stringify([r.cycle, r.stepId, r.round]);
94
+ for (const r of records.slice(1)) {
95
+ const p = r.purpose;
96
+ if (state.completed) return refuse('chain sequence: complete admits no successor');
97
+ if (r.cycle < state.lastCycle) return refuse(`chain sequence: the cycle index is monotonic (${state.lastCycle} → ${r.cycle})`);
98
+ // A same-index round record is a LEDGER REVISION — a non-lifecycle enrichment that repeats the
99
+ // DISPATCHED tree's epoch and never enters the lifecycle epoch cursor.
100
+ const isRevision = p === 'round' && state.mode === 'in-step' && state.parked === null
101
+ && r.stepId === state.currentStep && r.round === state.currentRound;
102
+ if (!isRevision) {
103
+ if (r.commitEpoch < state.lastEpoch) return refuse(`chain sequence: commitEpoch never regresses (${state.lastEpoch} → ${r.commitEpoch})`);
104
+ state.lastEpoch = r.commitEpoch;
105
+ }
106
+ if (state.parked !== null) {
107
+ if (p !== 'resume') return refuse(`chain sequence: park admits only resume (got "${p}")`);
108
+ if (r.cycle !== state.parked.cycle || r.round !== state.parked.round) {
109
+ return refuse(`chain sequence: resume must carry the pre-park cycle and round (${state.parked.cycle}/${state.parked.round}, got ${r.cycle}/${r.round}) — a new cycle starts by an explicit transition after resume`);
110
+ }
111
+ state.parked = null;
112
+ continue;
113
+ }
114
+ if (p === 'adoption') return refuse("chain sequence: adoption is only ever the chain's first record");
115
+ if (PLAN_LANE_PURPOSES.includes(p)) {
116
+ if (p === 'park') {
117
+ if (r.cycle !== contextCycle() || r.round !== contextRound()) {
118
+ return refuse(`chain sequence: park must carry the pre-park cycle and round (${contextCycle()}/${contextRound()}, got ${r.cycle}/${r.round})`);
119
+ }
120
+ state.parked = { cycle: r.cycle, round: r.round };
121
+ } else if (p === 'resume') {
122
+ return refuse('chain sequence: resume without a preceding park');
123
+ } else {
124
+ if (state.mode === 'in-step') return refuse('chain sequence: complete may not interrupt an open step — the step ends at converged');
125
+ state.completed = true;
126
+ state.lastCycle = r.cycle;
127
+ }
128
+ continue;
129
+ }
130
+ if (state.mode === 'in-step') {
131
+ if (r.stepId !== state.currentStep) {
132
+ return refuse(`chain sequence: step sequences are serial — a record of step "${r.stepId}" interleaves open step "${state.currentStep}"`);
133
+ }
134
+ if (r.cycle !== state.stepCycle) return refuse('chain sequence: the cycle changes only at a step boundary');
135
+ if (!ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].includes(p)) {
136
+ return refuse(`chain sequence: illegal within-step transition ${state.lastPurpose} → ${p} (allowed: ${ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].join(', ')})`);
137
+ }
138
+ if (p === 'round') {
139
+ if (r.round === state.currentRound) {
140
+ const revised = validateRoundRevision(state.roundLedgers.get(ledgerKey(r)), r);
141
+ if (!revised.ok) return revised;
142
+ state.roundLedgers.set(ledgerKey(r), r);
143
+ continue;
144
+ }
145
+ if (r.round < state.currentRound) return refuse(`chain sequence: the round index must increase within a step (${state.currentRound} → ${r.round})`);
146
+ if (r.opensFrom !== null) return refuse('chain sequence: only a step-opening round carries a prior-terminal reference');
147
+ state.currentRound = r.round;
148
+ state.roundLedgers.set(ledgerKey(r), r);
149
+ } else if (r.round !== state.currentRound) {
150
+ return refuse(`chain sequence: a non-round record carries its step's current round index (${state.currentRound}, got ${r.round})`);
151
+ }
152
+ state.lastPurpose = p;
153
+ if (p === 'converged') {
154
+ state.mode = 'boundary';
155
+ state.closedSteps.add(closureKey(state.stepCycle, state.currentStep));
156
+ state.lastTerminated = { step: state.currentStep, round: state.currentRound, cycle: state.stepCycle };
157
+ state.boundaryRound = state.currentRound;
158
+ }
159
+ } else if (p === 'unfreeze') {
160
+ if (state.lastTerminated === null) return refuse('chain sequence: unfreeze requires a prior converged terminal');
161
+ if (r.stepId !== state.lastTerminated.step) {
162
+ return refuse(`chain sequence: unfreeze reopens only the step that just converged ("${state.lastTerminated.step}", got "${r.stepId}")`);
163
+ }
164
+ if (r.round !== state.lastTerminated.round) return refuse('chain sequence: unfreeze carries the converged round index');
165
+ if (r.cycle !== state.lastTerminated.cycle) {
166
+ return refuse("chain sequence: unfreeze reopens only in its terminal's cycle — a later cycle reopens the stepId through an ordinary opening round");
167
+ }
168
+ state.mode = 'in-step';
169
+ state.currentStep = r.stepId;
170
+ state.stepCycle = r.cycle;
171
+ state.currentRound = r.round;
172
+ state.lastPurpose = 'unfreeze';
173
+ state.closedSteps.delete(closureKey(r.cycle, r.stepId));
174
+ } else if (p === 're-baseline') {
175
+ const anchorStep = state.lastTerminated === null ? null : state.lastTerminated.step;
176
+ if (r.stepId !== anchorStep) {
177
+ return refuse(`chain sequence: a boundary re-baseline anchors to the prior terminal's stepId (${JSON.stringify(anchorStep)}, got ${JSON.stringify(r.stepId)}) — it reopens nothing`);
178
+ }
179
+ if (r.round !== state.boundaryRound) return refuse(`chain sequence: a boundary re-baseline carries the boundary round index (${state.boundaryRound}, got ${r.round})`);
180
+ if (r.cycle !== state.lastCycle) return refuse('chain sequence: a re-baseline never moves the cycle — base motion is not a redesign');
181
+ } else if (p === ALLOWED_TRANSITIONS.stepOpening) {
182
+ if (state.closedSteps.has(closureKey(r.cycle, r.stepId))) {
183
+ return refuse(`chain sequence: step "${r.stepId}" already converged in cycle ${r.cycle} — a converged step reopens only through the unfreeze lane`);
184
+ }
185
+ if (r.opensFrom === null) {
186
+ return refuse("chain sequence: a step-opening round must carry the prior-terminal reference (opensFrom) — the plan's first step references the adoption record itself");
187
+ }
188
+ if (r.round < 1) return refuse('chain sequence: a step opens at round 1 or later');
189
+ state.mode = 'in-step';
190
+ state.currentStep = r.stepId;
191
+ state.stepCycle = r.cycle;
192
+ state.currentRound = r.round;
193
+ state.lastPurpose = 'round';
194
+ state.roundLedgers.set(ledgerKey(r), r);
195
+ } else {
196
+ return refuse(`chain sequence: a step sequence opens with "${ALLOWED_TRANSITIONS.stepOpening}" (got "${p}")`);
197
+ }
198
+ state.lastCycle = r.cycle;
199
+ }
200
+ return { ok: true };
201
+ };
202
+
203
+ // ── stateful-kind supersession legality (raw order, in-memory list) ───────────────────────────────
204
+
205
+ // validateSupersessions(records) → { ok } | { ok: false, reason }. Walks RAW order and resolves
206
+ // supersession targets among EARLIER records by per-record canonical digest: down-mark-up/clear
207
+ // must target an earlier down-mark of the SAME backend; a maintainer-override chain is linear per
208
+ // veto instance — the first override carries supersedes: null, every later one must supersede the
209
+ // CURRENT head (a stale target would fork the chain and let latest-per-key bury a live override
210
+ // without explicit supersession, #56). Out-of-order and mis-targeted supersessions refuse by name.
211
+ export const validateSupersessions = (records, digestOf = canonicalFlowDigest) => {
212
+ const seen = new Map();
213
+ const overrideHeads = new Map();
214
+ const activeMarks = new Map();
215
+ for (const r of records) {
216
+ if (r.kind === 'down-mark') {
217
+ if (activeMarks.has(r.backend)) {
218
+ return refuse(`down-mark: backend "${r.backend}" already carries an ACTIVE down-mark — it must be explicitly closed by up/clear before a new mark lands (supersession is explicit, never silent)`);
219
+ }
220
+ activeMarks.set(r.backend, digestOf(r));
221
+ }
222
+ if (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') {
223
+ const target = seen.get(r.target);
224
+ if (target === undefined) return refuse(`${r.kind}: the supersession target does not resolve to an EARLIER record (out-of-order or unknown) — a supersession lands only after its down-mark`);
225
+ if (target.kind !== 'down-mark') return refuse(`${r.kind}: the supersession target is a ${target.kind}, not a down-mark (mis-targeted)`);
226
+ if (target.backend !== r.backend) return refuse(`${r.kind}: the supersession target belongs to backend "${target.backend}", not "${r.backend}" (mis-targeted)`);
227
+ const active = activeMarks.get(r.backend);
228
+ if (active === undefined) return refuse(`${r.kind}: no active down-mark for backend "${r.backend}" — the family is closed (or never opened); a new down-mark opens a new instance`);
229
+ if (r.target !== active) return refuse(`${r.kind}: the supersession targets a stale down-mark — up/clear must target the backend's ACTIVE mark`);
230
+ activeMarks.delete(r.backend);
231
+ }
232
+ if (r.kind === 'maintainer-override') {
233
+ const head = overrideHeads.get(r.vetoReceiptDigest);
234
+ if (r.supersedes === null) {
235
+ if (head !== undefined) return refuse('maintainer-override: only the first override of a veto instance carries supersedes: null — a later override must supersede the CURRENT head');
236
+ } else {
237
+ const target = seen.get(r.supersedes);
238
+ if (target === undefined) return refuse('maintainer-override: supersedes does not resolve to an EARLIER record (out-of-order or unknown)');
239
+ if (target.kind !== 'maintainer-override') return refuse(`maintainer-override: supersedes must target a maintainer-override record, not a ${target.kind} (mis-targeted)`);
240
+ if (target.vetoReceiptDigest !== r.vetoReceiptDigest) return refuse('maintainer-override: the supersession crosses veto instances — one override binds exactly one veto instance (mis-targeted)');
241
+ if (r.supersedes !== head) return refuse('maintainer-override: supersedes targets a STALE override — a later override must supersede the CURRENT head of its veto instance');
242
+ }
243
+ overrideHeads.set(r.vetoReceiptDigest, digestOf(r));
244
+ }
245
+ seen.set(digestOf(r), r);
246
+ }
247
+ return { ok: true };
248
+ };