@sabaiway/agent-workflow-kit 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +51 -4
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +616 -24
  9. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +700 -1
  10. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  11. package/bridges/codex-cli-bridge/capability.json +15 -10
  12. package/capability.json +1 -1
  13. package/package.json +1 -1
  14. package/references/modes/dispatch.md +29 -0
  15. package/references/modes/gates.md +6 -3
  16. package/references/modes/procedures.md +2 -0
  17. package/references/modes/receipt-deadline.md +3 -3
  18. package/references/modes/recommendations.md +1 -1
  19. package/references/modes/velocity.md +1 -0
  20. package/tools/commands.mjs +7 -0
  21. package/tools/core-evidence.mjs +37 -3
  22. package/tools/detect-backends.mjs +5 -4
  23. package/tools/dispatch-record.mjs +10 -3
  24. package/tools/dispatch-store.mjs +392 -0
  25. package/tools/dispatch.mjs +1779 -0
  26. package/tools/doc-parity.mjs +10 -2
  27. package/tools/exec-producer.mjs +483 -0
  28. package/tools/exec-receipt.mjs +263 -0
  29. package/tools/flow-check-cores.mjs +253 -0
  30. package/tools/flow-check-git-lane.mjs +56 -0
  31. package/tools/flow-check-rungs.mjs +330 -0
  32. package/tools/flow-check.mjs +23 -611
  33. package/tools/flow-store.mjs +111 -462
  34. package/tools/gates-declaration.mjs +13 -1
  35. package/tools/gates-init.mjs +134 -22
  36. package/tools/procedures.mjs +64 -5
  37. package/tools/receipt-deadline.mjs +25 -3
  38. package/tools/recommendations.mjs +108 -7
  39. package/tools/release-scan.mjs +33 -0
  40. package/tools/source-size-check.mjs +320 -0
  41. package/tools/source-size-config.mjs +244 -0
  42. package/tools/source-size-core.mjs +53 -0
  43. package/tools/source-size-gate-cmd.mjs +55 -0
  44. package/tools/source-size-judge.mjs +114 -0
  45. package/tools/source-size-refusal.mjs +70 -0
  46. package/tools/source-size-report.mjs +254 -0
  47. package/tools/source-size-scope.mjs +145 -0
  48. package/tools/store-append.mjs +444 -0
  49. package/tools/velocity-profile.mjs +24 -3
@@ -0,0 +1,263 @@
1
+ // exec-receipt.mjs — the wrapper-minted EXEC RECEIPT contract (delegation Plan 2, Phase 1). Pure
2
+ // form: no filesystem, no git, no CLI, no side effects on import. The bridge wrapper MINTS these
3
+ // bytes; `dispatch return` (Phase 2) reads them and absorbs the run into the delegation ledger.
4
+ //
5
+ // Why the artifact exists at all: the bridge is dependency-free bash with NO path to the kit
6
+ // (codex-cli-bridge/capability.json detect.installed resolves the BRIDGE's own directory), so it
7
+ // cannot append to the ledger — a bash-side append would re-implement the lock/CAS leaf and could not
8
+ // run the store's cross-record preflight, a second and drifting legality door. The wrapper therefore
9
+ // mints what it can PROVE about its own run, and the kit absorbs it through the one append door.
10
+ //
11
+ // TWO STATES, one path (D1). `reserved` is written atomically and no-clobber BEFORE the run is spent
12
+ // — that write IS the nonce reservation, so a duplicate nonce refuses before any spend. `terminal`
13
+ // replaces it at exit, written by the run that owns the reservation. The state split is what makes
14
+ // the artifact a safe arrival signal: `terminal` means the report beside it is already complete.
15
+ //
16
+ // The reserved state carries everything knowable PRE-SPEND (wrapperVersion, posture, capS,
17
+ // killGraceS, contractDigest) and NULL in every terminal-only field, so a `--no-receipt` absorb can
18
+ // still source the fields a tree cannot supply. A reserved receipt filling a terminal-only field
19
+ // refuses: the two states are distinguishable by their content, not only by their label.
20
+ //
21
+ // `contractDigest` is computed by the WRAPPER from the dispatch file it was actually handed — an
22
+ // independently produced value, never a copy of what the ledger holds. Without it the store's
23
+ // return↔dispatch correlation would compare the dispatch record against values derived from itself,
24
+ // and a run that executed a DIFFERENT contract would correlate cleanly.
25
+ //
26
+ // The wrapper's outcome vocabulary is a SUBSET of the ledger's (D3): a run can prove only what it
27
+ // observed about itself. `success` = exit 0 with a session id; `missing-identity` = exit 0 without
28
+ // one; `transport-failure` = any nonzero exit, the timeout codes included. Every orchestrator
29
+ // judgment (contract-refusal, partial-edit, acceptance-failure, stale-return, store-failure) is
30
+ // recorded at absorb time, never claimed here.
31
+ //
32
+ // Named grammars are taken by reference, never re-stated: the safe token grammar SAFE_NONCE_RE and
33
+ // the 64-hex digest. Descriptor discipline follows dispatch-record.mjs — own enumerable DATA
34
+ // properties only, each read exactly once, so the bytes a validator approved are the bytes a reader
35
+ // re-reads.
36
+ //
37
+ // Honest limit: a receipt is forgeable, exactly like every other record in this family. What it
38
+ // defends against is a BUGGY or interrupted producer, not a hostile one.
39
+ //
40
+ // Second honest limit, stated where the name is built: the length prefix closes the SEPARATOR
41
+ // ambiguity, not the CASE one. `Codex` and `codex` are both safe tokens and compose different names —
42
+ // but the same FILE on a case-insensitive filesystem, where an atomic no-clobber mint could then
43
+ // refuse a legitimately different dispatch. Closing it means narrowing a grammar the FAMILY owns
44
+ // (SAFE_NONCE_RE lives in flow-record.mjs and is taken by reference here), and the review lane's
45
+ // manifest names carry the identical axis — so it is one family decision, queued as
46
+ // ARTIFACT-BASENAME-NOT-INJECTIVE, not a local fork.
47
+
48
+ import { SAFE_NONCE_RE } from './flow-record.mjs';
49
+
50
+ const refuse = (reason) => ({ ok: false, reason });
51
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
52
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
53
+ const isHex64 = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
54
+ const isSafeToken = (v) => typeof v === 'string' && SAFE_NONCE_RE.test(v);
55
+
56
+ // The artifact name is LENGTH-PREFIXED, and that is what makes it injective. Both tokens share the
57
+ // safe grammar, which admits `-`, so a plain `<backend>-<nonce>` join is ambiguous: {backend "a-b",
58
+ // nonce "c"} and {backend "a", nonce "b-c"} compose the SAME file, and the no-clobber reservation
59
+ // would then refuse a genuinely different dispatch. Restricting the backend instead was tried and
60
+ // rejected — the ledger's own contract (dispatch-record.mjs) admits any safe token as a backend, so
61
+ // a stricter rule here would record dispatches whose receipts could never be named. The length
62
+ // prefix keeps every character inside the safe set, keeps the name greppable, and recovers the pair:
63
+ // read digits to the first `-`, take that many characters as the backend, the remainder is the nonce.
64
+ const lengthPrefixed = (prefix, backend, nonce, suffix) =>
65
+ (isSafeToken(backend) && isSafeToken(nonce) ? `${prefix}${backend.length}-${backend}-${nonce}${suffix}` : null);
66
+ const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
67
+ const isByteCount = (v) => Number.isSafeInteger(v) && v >= 0;
68
+
69
+ const isDataProperty = (obj, field) => {
70
+ const descriptor = Object.getOwnPropertyDescriptor(obj, field);
71
+ return descriptor !== undefined && Object.hasOwn(descriptor, 'value');
72
+ };
73
+
74
+ const ACCESSOR_REFUSAL = 'is an ACCESSOR — a receipt field must be a data property, or a re-read could answer differently than the validator did';
75
+
76
+ const short = (v) => {
77
+ let s;
78
+ try {
79
+ s = JSON.stringify(v);
80
+ } catch {
81
+ return `<unserializable ${typeof v}>`;
82
+ }
83
+ if (s === undefined) s = `<${typeof v}>`;
84
+ return s.length > 80 ? `${s.slice(0, 79)}…` : s;
85
+ };
86
+
87
+ const deepFreeze = (value) => {
88
+ if (value !== null && typeof value === 'object') {
89
+ Object.values(value).forEach(deepFreeze);
90
+ Object.freeze(value);
91
+ }
92
+ return value;
93
+ };
94
+
95
+ // ── the closed vocabulary ─────────────────────────────────────────────────────────────────────────
96
+
97
+ export const EXEC_RECEIPT_SCHEMA_VERSION = 1;
98
+ export const EXEC_RECEIPT_KIND = 'exec-receipt';
99
+
100
+ export const EXEC_RECEIPT_STATES = deepFreeze(['reserved', 'terminal']);
101
+
102
+ export const EXEC_RECEIPT_KEYS = deepFreeze([
103
+ 'state', 'backend', 'nonce', 'owner', 'contractDigest', 'wrapperVersion', 'posture',
104
+ 'capS', 'killGraceS', 'sessionId', 'exitStatus', 'outcome', 'reportDigest', 'reportLength', 'timestamp',
105
+ ]);
106
+
107
+ // The fields only a finished run can fill; a reservation carries null in every one of them.
108
+ export const TERMINAL_ONLY_FIELDS = deepFreeze(['sessionId', 'exitStatus', 'outcome', 'reportDigest', 'reportLength']);
109
+
110
+ // D3 — the three outcomes a wrapper can prove about its own run.
111
+ export const WRAPPER_OUTCOMES = deepFreeze(['success', 'transport-failure', 'missing-identity']);
112
+
113
+ const POSTURE_KEYS = deepFreeze(['model', 'effort', 'tier']);
114
+
115
+ const IDENTITY_FIELDS = ['schema', 'kind'];
116
+
117
+ export const EXEC_RECEIPT_BASENAME_PREFIX = 'agent-workflow-exec-receipt-';
118
+ export const EXEC_REPORT_BASENAME_PREFIX = 'agent-workflow-exec-report-';
119
+
120
+ // The {backend, nonce}-derived artifact names. Null when either token leaves the safe grammar — an
121
+ // unsafe token would compose a path, and a name that can escape its directory is never built.
122
+ export const execReceiptBasename = (backend, nonce) =>
123
+ lengthPrefixed(EXEC_RECEIPT_BASENAME_PREFIX, backend, nonce, '.json');
124
+
125
+ export const execReportBasename = (backend, nonce) =>
126
+ lengthPrefixed(EXEC_REPORT_BASENAME_PREFIX, backend, nonce, '.txt');
127
+
128
+ // ── per-field shapes ──────────────────────────────────────────────────────────────────────────────
129
+
130
+ const FIELD_CHECKS = {
131
+ state: { ok: (v) => EXEC_RECEIPT_STATES.includes(v), want: `one of ${EXEC_RECEIPT_STATES.join(' | ')}` },
132
+ backend: { ok: isSafeToken, want: 'a backend name in the safe token grammar ([A-Za-z0-9._-]{1,64})' },
133
+ nonce: { ok: isSafeToken, want: 'a dispatch nonce in the safe token grammar ([A-Za-z0-9._-]{1,64})' },
134
+ owner: { ok: isNonEmptyString, want: 'the non-empty opaque token identifying the run that holds the reservation' },
135
+ contractDigest: { ok: isHex64, want: 'the 64-hex digest the WRAPPER computed from the dispatch file it ran' },
136
+ wrapperVersion: { ok: isNonEmptyString, want: 'the non-empty minting wrapper version' },
137
+ posture: { ok: isPlainObject, want: 'the closed posture object {model, effort, tier}' },
138
+ capS: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'the positive integer wall-clock cap the run ACTUALLY applied' },
139
+ killGraceS: { ok: isByteCount, want: 'the non-negative integer kill grace the run ACTUALLY applied' },
140
+ sessionId: { ok: (v) => v === null || isNonEmptyString(v), want: 'a non-empty backend session id, or null where no session existed' },
141
+ exitStatus: { ok: (v) => v === null || isByteCount(v), want: 'a non-negative integer process exit status, or null on a reservation' },
142
+ outcome: { ok: (v) => v === null || WRAPPER_OUTCOMES.includes(v), want: `one of ${WRAPPER_OUTCOMES.join(' | ')}, or null on a reservation` },
143
+ reportDigest: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex digest of the report artifact, or null when no report was written' },
144
+ reportLength: { ok: (v) => v === null || isByteCount(v), want: 'the non-negative byte length of the report artifact, or null on a reservation' },
145
+ timestamp: { ok: isCanonicalInstant, want: 'a canonical UTC ISO instant (toISOString round-trip)' },
146
+ };
147
+
148
+ const validatePosture = (posture) => {
149
+ if (!isPlainObject(posture)) return refuse('posture must be an object');
150
+ const own = Object.keys(posture);
151
+ const stray = own.find((k) => !POSTURE_KEYS.includes(k));
152
+ if (stray !== undefined) return refuse(`posture: unknown field "${stray}" — the posture key set is closed`);
153
+ const missing = POSTURE_KEYS.find((k) => !own.includes(k));
154
+ if (missing !== undefined) return refuse(`posture: missing field "${missing}"`);
155
+ const accessor = POSTURE_KEYS.find((k) => !isDataProperty(posture, k));
156
+ if (accessor !== undefined) return refuse(`posture: field "${accessor}" ${ACCESSOR_REFUSAL}`);
157
+ if (!isNonEmptyString(posture.model)) return refuse(`posture: model must be a non-empty model name (got ${short(posture.model)})`);
158
+ const bad = ['effort', 'tier'].find((k) => posture[k] !== null && !isNonEmptyString(posture[k]));
159
+ return bad === undefined ? { ok: true } : refuse(`posture: ${bad} must be a non-empty string or null (got ${short(posture[bad])})`);
160
+ };
161
+
162
+ // The state contract, both directions: a reservation proves nothing about a run that has not
163
+ // finished, and a terminal receipt that left a terminal field null would be a reservation wearing the
164
+ // wrong label.
165
+ const validateStateFields = (receipt) => {
166
+ if (receipt.state === 'reserved') {
167
+ const filled = TERMINAL_ONLY_FIELDS.find((f) => receipt[f] !== null);
168
+ return filled === undefined
169
+ ? { ok: true }
170
+ : refuse(`a RESERVED receipt carries null in every terminal-only field — "${filled}" is ${short(receipt[filled])}; a reservation is minted before the run is spent and can prove nothing about its outcome`);
171
+ }
172
+ const empty = ['exitStatus', 'outcome', 'reportLength'].find((f) => receipt[f] === null);
173
+ if (empty !== undefined) {
174
+ return refuse(`a TERMINAL receipt requires "${empty}" — a null there is a reservation wearing the terminal label`);
175
+ }
176
+ return { ok: true };
177
+ };
178
+
179
+ // D3's mapping, enforced as a TOTAL relation so no run can record an outcome its own numbers deny.
180
+ const validateOutcomeMapping = (receipt) => {
181
+ if (receipt.state !== 'terminal') return { ok: true };
182
+ if (receipt.outcome === 'success') {
183
+ if (receipt.exitStatus !== 0) return refuse(`outcome "success" requires exitStatus 0 (got ${receipt.exitStatus}) — a nonzero exit never reports success`);
184
+ if (receipt.sessionId === null) return refuse('outcome "success" requires a non-null sessionId — a run that identified no session is "missing-identity"');
185
+ return { ok: true };
186
+ }
187
+ if (receipt.outcome === 'missing-identity') {
188
+ if (receipt.exitStatus !== 0) return refuse(`outcome "missing-identity" requires exitStatus 0 (got ${receipt.exitStatus}) — a nonzero exit is "transport-failure"`);
189
+ if (receipt.sessionId !== null) return refuse(`outcome "missing-identity" requires sessionId null (got ${short(receipt.sessionId)})`);
190
+ return { ok: true };
191
+ }
192
+ return receipt.exitStatus === 0
193
+ ? refuse('outcome "transport-failure" requires a nonzero exitStatus — a run that exited 0 is "success" or "missing-identity"')
194
+ : { ok: true };
195
+ };
196
+
197
+ // A TERMINAL receipt always has a report behind it — that is the publication ORDER, not a courtesy:
198
+ // the report is written atomically FIRST and the reservation is replaced by the terminal receipt
199
+ // LAST, and a wrapper that cannot complete either write exits nonzero having published no terminal
200
+ // receipt at all. So on `terminal` the digest is REQUIRED, and an empty report stays perfectly
201
+ // expressible as the sha256 of no bytes with reportLength 0. The absent form belongs to the
202
+ // reservation (both fields null), which is exactly what the `--no-receipt` absorb lane reads.
203
+ const validateReportPair = (receipt) => {
204
+ if (receipt.state !== 'terminal') return { ok: true };
205
+ if (receipt.reportDigest !== null) return { ok: true };
206
+ return refuse(`a TERMINAL receipt requires a reportDigest (reportLength ${receipt.reportLength}) — the report is published BEFORE the terminal receipt replaces the reservation, so a terminal artifact with no report behind it is a state no completed run mints; an EMPTY report is the sha256 of no bytes with length 0`);
207
+ };
208
+
209
+ // validateExecReceipt(receipt) → { ok: true } | { ok: false, reason }. Fail closed on an unknown
210
+ // schema/kind/state, a missing, accessor or malformed field, any key outside the closed set, and every
211
+ // cross-field relation the state pins. Never throws on a DATA record.
212
+ export const validateExecReceipt = (receipt) => {
213
+ if (!isPlainObject(receipt)) return refuse('exec receipt is not an object');
214
+ const own = Object.keys(receipt);
215
+ const missingIdentity = IDENTITY_FIELDS.find((f) => !own.includes(f));
216
+ if (missingIdentity !== undefined) return refuse(`missing field "${missingIdentity}" — the identifying fields are read before any value is`);
217
+ const accessorIdentity = IDENTITY_FIELDS.find((f) => !isDataProperty(receipt, f));
218
+ if (accessorIdentity !== undefined) return refuse(`field "${accessorIdentity}" ${ACCESSOR_REFUSAL}`);
219
+ if (receipt.schema !== EXEC_RECEIPT_SCHEMA_VERSION) {
220
+ return refuse(`unknown schema ${short(receipt.schema)} — this reader accepts exec-receipt schema ${EXEC_RECEIPT_SCHEMA_VERSION} only (fail closed)`);
221
+ }
222
+ if (receipt.kind !== EXEC_RECEIPT_KIND) {
223
+ return refuse(`unknown kind ${short(receipt.kind)} — this reader accepts "${EXEC_RECEIPT_KIND}" only (fail closed)`);
224
+ }
225
+ const allowed = [...IDENTITY_FIELDS, ...EXEC_RECEIPT_KEYS];
226
+ const stray = own.find((k) => !allowed.includes(k));
227
+ if (stray !== undefined) return refuse(`unknown field "${stray}" — the exec-receipt key set is closed`);
228
+ for (const field of EXEC_RECEIPT_KEYS) {
229
+ if (!own.includes(field)) return refuse(`missing field "${field}"`);
230
+ if (!isDataProperty(receipt, field)) return refuse(`field "${field}" ${ACCESSOR_REFUSAL}`);
231
+ if (!FIELD_CHECKS[field].ok(receipt[field])) {
232
+ return refuse(`${field} must be ${FIELD_CHECKS[field].want} (got ${short(receipt[field])})`);
233
+ }
234
+ }
235
+ const posture = validatePosture(receipt.posture);
236
+ if (!posture.ok) return posture;
237
+ const state = validateStateFields(receipt);
238
+ if (!state.ok) return state;
239
+ const mapping = validateOutcomeMapping(receipt);
240
+ if (!mapping.ok) return mapping;
241
+ return validateReportPair(receipt);
242
+ };
243
+
244
+ // parseExecReceipt(text) → { ok: true, receipt } | { ok: false, reason }.
245
+ export const parseExecReceipt = (text) => {
246
+ if (typeof text !== 'string') return refuse('exec receipt: the artifact must be text');
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(text);
250
+ } catch {
251
+ return refuse('exec receipt: the artifact is not valid JSON (fail closed)');
252
+ }
253
+ const valid = validateExecReceipt(parsed);
254
+ return valid.ok ? { ok: true, receipt: parsed } : valid;
255
+ };
256
+
257
+ // The wrapper's own mapping, exported so the bridge's minted bytes and the kit's expectation come from
258
+ // ONE rule rather than two implementations that agree today (D3).
259
+ export const wrapperOutcomeFor = (exitStatus, sessionId) => {
260
+ if (!isByteCount(exitStatus)) return null;
261
+ if (exitStatus !== 0) return 'transport-failure';
262
+ return isNonEmptyString(sessionId) ? 'success' : 'missing-identity';
263
+ };
@@ -0,0 +1,253 @@
1
+ // flow-check-cores.mjs — the checker's decision cores over the FULL read-results of BOTH stores
2
+ // (flow + core evidence) and the tree context: store health, chain adoption and transition
3
+ // legality, prior-terminal references, worktree scoping, bookkeeping-delta custody and
4
+ // re-attestation, degrade-before-final ordering, armed base motion — and `decideFlowCheck`, the
5
+ // ONE composition every consumer reads. Split out of flow-check.mjs (baseline-practices tranche 1);
6
+ // the CLI, the store reads and the report render stay there.
7
+ //
8
+ // Pure: no store IO and no git of its own — the base-motion inputs arrive as INJECTED resolvers,
9
+ // so flow-check-git-lane.mjs is never imported here. The evidence rungs live one module down
10
+ // (flow-check-rungs.mjs), which also owns the refusal vocabulary both halves share.
11
+
12
+ import {
13
+ CHAIN_KIND, validateChainSequence, validateSupersessions, canonicalFlowDigest,
14
+ authoritativeFlowRecords,
15
+ } from './flow-record.mjs';
16
+ import {
17
+ walkChainState, validateOpenerReference, resolveRecordReference, isAuthoritativeReferenceTarget,
18
+ } from './flow-store.mjs';
19
+ import {
20
+ short, shellQuote, writerCommand,
21
+ collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
22
+ } from './flow-check-rungs.mjs';
23
+
24
+ // The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
25
+ // operand shapes: flag values ride the inline --flag='value' form and positionals follow a
26
+ // literal ` -- ` — one shape for EVERY id, so a leading-dash operand stays recoverable.
27
+ const parkRecovery = (planId) =>
28
+ `recovery (pasteable): ${writerCommand(`park -- ${shellQuote(planId)}`)}`;
29
+
30
+ // Arms in dependency order; the first failing arm reports, and integrityClean gates the caller's
31
+ // dependent arms (base motion) off a broken chain.
32
+ const planRefusals = (records, chain, planId, owner, advisories) => {
33
+ if (chain[0].purpose !== 'adoption') {
34
+ return { integrityClean: false, refusals: [`plan "${planId}": the chain has no content-digest-bound adoption record — a chain starts at adoption binding the plan content digest (#58); the store is append-only, so inspect how this chain was written`] };
35
+ }
36
+ const seq = validateChainSequence(chain);
37
+ if (!seq.ok) return { integrityClean: false, refusals: [`plan "${planId}": illegal transition — ${seq.reason}`] };
38
+ const state = walkChainState(chain);
39
+ const referenceIssues = [];
40
+ for (const { record } of state.openers) {
41
+ const check = validateOpenerReference(records.slice(0, records.indexOf(record)), record);
42
+ if (!check.ok) referenceIssues.push(`plan "${planId}": step-opening round (step "${record.stepId}") — ${check.reason}`);
43
+ }
44
+ for (const r of chain) {
45
+ if (r.purpose !== 'refresh') continue;
46
+ const prefix = records.slice(0, records.indexOf(r));
47
+ if (resolveRecordReference(prefix, r.refreshedRecord) === undefined) {
48
+ referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord does not match the store (no earlier record digests to ${short(r.refreshedRecord)}) — a re-attestation binds an existing record`);
49
+ } else if (!isAuthoritativeReferenceTarget(prefix, r.refreshedRecord)) {
50
+ referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key (as of the refresh's own raw position)`);
51
+ }
52
+ }
53
+ if (referenceIssues.length > 0) return { integrityClean: false, refusals: referenceIssues };
54
+ const open = !state.completed && !state.parked && state.mode === 'in-step';
55
+ if (!open) return { integrityClean: true, refusals: [] };
56
+ if (chain[0].owner !== owner) {
57
+ advisories.push(`plan "${planId}": an OPEN chain owned by "${chain[0].owner}" (a foreign worktree) — advisory visibility only, never this tree's refusal (#57)`);
58
+ return { integrityClean: true, refusals: [] };
59
+ }
60
+ return { integrityClean: true, refusals: [`plan "${planId}" has an OPEN chain owned by this worktree ("${owner}"): step "${state.stepId}" is not converged — a commit closes only at a terminal. ${parkRecovery(planId)}`] };
61
+ };
62
+
63
+ // The custody arm verifies the PERSISTED proof against a bare declaration (#60): the masked
64
+ // recompute must equal fingerprintBefore, and every delta must be re-attested by a SUBSEQUENT
65
+ // chain refresh binding {refreshedRecord, fingerprintBefore = the delta's fingerprintAfter} (#45)
66
+ // — an earlier or fingerprint-mismatched record never satisfies (raw order decides). Satisfaction
67
+ // is STORE-GLOBAL: the locked delta shape carries no chain field, so WHICH chain's refresh cap
68
+ // the re-attestation consumes is the Plan-3 decideCheck arm (#61), not a Plan-2 refusal.
69
+ // The recovery lane needs the invoker's OWN OPEN chains: a refresh is a within-step record, so
70
+ // only such a chain can carry the re-attestation (and its refresh cap is what the mint consumes,
71
+ // #61). A command under a "pasteable" label is always CONCRETE — with no own open chain the
72
+ // recovery states the precondition instead of printing a placeholder command.
73
+ const ownOpenChainPlanIds = (records, owner) =>
74
+ [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))].filter((planId) => {
75
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
76
+ if (chain[0].owner !== owner || chain[0].purpose !== 'adoption' || !validateChainSequence(chain).ok) return false;
77
+ const state = walkChainState(chain);
78
+ return !state.completed && !state.parked && state.mode === 'in-step';
79
+ });
80
+
81
+ // The ONE per-record custody predicate (Plan 4 Phase 3, round-2 fold): the confinement equality
82
+ // + the mint-only invariants the record-level shape validation cannot see — shared by the
83
+ // gate-time walk below and the writer's terminal move validation, so a forged proof can neither
84
+ // pass the gates nor carry a terminal. → issue string | null.
85
+ export const deltaCustodyIssue = (r) => {
86
+ if (r.custodyProof.maskedFingerprint !== r.fingerprintBefore) {
87
+ return `the persisted custody proof does not prove confinement (maskedFingerprint ${short(r.custodyProof.maskedFingerprint)} ≠ fingerprintBefore ${short(r.fingerprintBefore)}) — a bare or tampered declaration never passes; re-mint through mintBookkeepingDelta`;
88
+ }
89
+ const proof = r.custodyProof;
90
+ const mintInvariant = !proof.tracked ? null
91
+ : proof.preClass !== 'present' ? 'a tracked path with an absent pre-state never mints'
92
+ : proof.indexDigest === null ? 'a staged deletion (a HEAD entry without an index entry) never mints'
93
+ : proof.worktreeDigest !== proof.indexDigest ? 'the clean-at-path rule (pre-change worktree bytes = the index entry) never minted this'
94
+ : null;
95
+ return mintInvariant === null ? null : `the persisted custody proof violates a mint invariant — ${mintInvariant}; an unmintable proof never passes (fail closed)`;
96
+ };
97
+
98
+ const deltaRefusals = (records, owner) => {
99
+ const refusals = [];
100
+ const openPlanIds = ownOpenChainPlanIds(records, owner);
101
+ // The re-attestation OBLIGATION binds only AUTHORITATIVE deltas: a superseded same-key delta
102
+ // never enters classifyDeltaChain and the refresh preflight refuses to reference it, so
103
+ // demanding its refresh would be exactly the unrecoverable red the plan bans — supersession is
104
+ // the store's own recovery valve. Custody and mint checks stay RAW-wide (tamper detection).
105
+ const authoritative = new Set(authoritativeFlowRecords(records));
106
+ records.forEach((r, i) => {
107
+ if (r.kind !== 'bookkeeping-delta') return;
108
+ const custody = deltaCustodyIssue(r);
109
+ if (custody !== null) {
110
+ refusals.push(`bookkeeping-delta at ${r.path}: ${custody}`);
111
+ return;
112
+ }
113
+ if (!authoritative.has(r)) return;
114
+ const digest = canonicalFlowDigest(r);
115
+ const satisfied = records.some((s, j) => j > i && s.kind === CHAIN_KIND && s.purpose === 'refresh'
116
+ && s.refreshedRecord === digest && s.fingerprintBefore === r.fingerprintAfter);
117
+ if (!satisfied) {
118
+ const recovery = openPlanIds.length > 0
119
+ ? `recovery (pasteable; choose the chain whose refresh cap this consumes, #61): ${openPlanIds.map((planId) => writerCommand(`refresh --cause='bookkeeping delta re-attestation' --refreshed-record=${digest} -- ${shellQuote(planId)}`)).join(' OR ')}`
120
+ : `recovery: no own OPEN chain can carry the re-attestation yet — open the owning plan's step round, then mint the refresh binding --refreshed-record=${digest}`;
121
+ refusals.push(`bookkeeping-delta at ${r.path}: no satisfying re-attestation — a SUBSEQUENT chain refresh must bind {refreshedRecord: ${short(digest)}, fingerprintBefore: ${short(r.fingerprintAfter)}}; an earlier or fingerprint-mismatched record never satisfies. ${recovery}`);
122
+ }
123
+ });
124
+ return refusals;
125
+ };
126
+
127
+ // Degrade-before-final (#64), decidable from RAW core-store order and grouped BY FINGERPRINT: a
128
+ // degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
129
+ // fingerprint completed (its `final` record landed after it). The checker reads raw records,
130
+ // never the authoritative selection (#65).
131
+ const degradeOrderingRefusals = (coreRecords) => {
132
+ const refusals = [];
133
+ coreRecords.forEach((r, i) => {
134
+ if (r.kind !== 'degrade') return;
135
+ const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
136
+ if (!startedBefore) return;
137
+ const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
138
+ && coreRecords.some((c, k) => k > j && c.kind === 'final' && c.attempt === s.attempt && c.fingerprintBefore === s.fingerprint));
139
+ if (!cured) {
140
+ refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
141
+ }
142
+ });
143
+ return refusals;
144
+ };
145
+
146
+ // #62: base delta ∩ plan surface — disjoint ⇒ re-baseline, intersecting/undecidable ⇒ refresh.
147
+ export const classifyBaseMotion = ({ baseDelta, changedSurface }) => {
148
+ if (!baseDelta?.ok) {
149
+ return { motion: 'undecidable', requires: 'refresh', reason: `the base delta is undecidable (${baseDelta?.reason ?? 'no delta supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
150
+ }
151
+ if (!changedSurface?.ok) {
152
+ return { motion: 'undecidable', requires: 'refresh', reason: `the changed surface is undecidable (${changedSurface?.reason ?? 'no surface supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
153
+ }
154
+ const surface = new Set(changedSurface.paths);
155
+ const witness = baseDelta.paths.find((p) => surface.has(p));
156
+ if (witness !== undefined) return { motion: 'intersecting', requires: 'refresh', witness };
157
+ return { motion: 'disjoint', requires: 're-baseline' };
158
+ };
159
+
160
+ // In-step base transitions of the LAST segment (lifecycle projection — round revisions collapsed)
161
+ // must land the class the delta requires; boundary and park→resume are exempt (every commit moves
162
+ // HEAD); the tail binds only a live in-step chain.
163
+ const baseMotionRefusals = (chain, planId, owner, motion) => {
164
+ if (chain[0].owner !== owner) return [];
165
+ const display = (b) => (b == null ? 'null' : short(b));
166
+ const refusals = [];
167
+ const classify = (fromBase, toBase) => classifyBaseMotion({
168
+ baseDelta: motion.resolveBaseDelta(fromBase, toBase),
169
+ changedSurface: motion.resolveChangedSurface(),
170
+ });
171
+ const requirement = (cls) => (cls.motion === 'disjoint' ? 'the delta is disjoint from the plan surface — re-baseline only, never a dispatch (#40)'
172
+ : cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}`
173
+ : cls.reason);
174
+ const seenRounds = new Set();
175
+ const lifecycle = chain.filter((r) => {
176
+ if (r.purpose !== 'round') return true;
177
+ const key = JSON.stringify([r.cycle, r.stepId, r.round]);
178
+ if (seenRounds.has(key)) return false;
179
+ seenRounds.add(key);
180
+ return true;
181
+ });
182
+ const isSegmentStart = (r) => r.purpose === 'resume' || r.purpose === 'unfreeze' || (r.purpose === 'round' && r.opensFrom !== null);
183
+ const states = [];
184
+ const walk = { mode: 'boundary', parked: false };
185
+ for (const r of lifecycle) {
186
+ states.push({ ...walk });
187
+ if (r.purpose === 'park') walk.parked = true;
188
+ else if (r.purpose === 'resume') walk.parked = false;
189
+ else if (r.purpose === 'converged' || r.purpose === 'complete') walk.mode = 'boundary';
190
+ else if (r.purpose === 'unfreeze' || (r.purpose === 'round' && walk.mode === 'boundary')) walk.mode = 'in-step';
191
+ }
192
+ const segStart = lifecycle.reduce((last, r, i) => (isSegmentStart(r) ? i : last), 0);
193
+ for (let i = segStart + 1; i < lifecycle.length; i += 1) {
194
+ const prev = lifecycle[i - 1];
195
+ const r = lifecycle[i];
196
+ if (states[i].mode !== 'in-step' || states[i].parked || r.base === prev.base) continue;
197
+ const cls = classify(prev.base, r.base);
198
+ if (r.purpose !== cls.requires) {
199
+ refusals.push(`plan "${planId}": a mid-step base transition (${display(prev.base)} → ${display(r.base)}) landed a "${r.purpose}" record but requires a ${cls.requires} record — ${requirement(cls)}; final gates must re-run after base motion (#62)`);
200
+ continue;
201
+ }
202
+ if (r.purpose === 're-baseline' && r.baseBefore !== prev.base) {
203
+ refusals.push(`plan "${planId}": the mid-step re-baseline's baseBefore (${display(r.baseBefore)}) does not match the previous record's base (${display(prev.base)}) — a re-baseline binds the actual pre-motion base (#62)`);
204
+ }
205
+ }
206
+ const state = walkChainState(chain);
207
+ if (state.completed || state.parked || state.mode !== 'in-step') return refusals;
208
+ const recorded = lifecycle[lifecycle.length - 1].base;
209
+ if (recorded === motion.currentBase) return refusals;
210
+ const cls = classify(recorded, motion.currentBase);
211
+ const recovery = cls.requires === 're-baseline'
212
+ ? writerCommand(`re-baseline -- ${shellQuote(planId)}`)
213
+ : writerCommand(`refresh --cause='base motion' --refreshed-record=${canonicalFlowDigest(chain[chain.length - 1])} -- ${shellQuote(planId)}`);
214
+ const tailRequirement = cls.requires === 're-baseline'
215
+ ? 'a re-baseline record suffices (the delta is disjoint from the plan surface)'
216
+ : `a refresh dispatch is REQUIRED (${cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}` : cls.reason})`;
217
+ refusals.push(`plan "${planId}": the base moved under the armed chain (recorded ${display(recorded)} → current ${display(motion.currentBase)}) and no ${cls.requires} record landed — ${tailRequirement}; final gates must re-run after base motion (#62). recovery (pasteable): ${recovery}`);
218
+ return refusals;
219
+ };
220
+
221
+ // decideFlowCheck({ flowRead, coreRead, owner, motion?, evidence?, consumer? }) → { refusals,
222
+ // advisories }. Pure — consumes the FULL read-results of both stores; store health fails closed
223
+ // BEFORE any content judgment. `motion` ({ currentBase, resolveBaseDelta, resolveChangedSurface })
224
+ // arms the Step-1.4 base-motion refusals; `evidence` ({ receipts, tree, backends }) arms the three
225
+ // Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
226
+ // byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
227
+ // to the STRICT lane, so a caller that forgets to thread it inherits strictness.
228
+ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
229
+ const refusals = [];
230
+ const advisories = [];
231
+ if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
232
+ else if (flowRead.malformed > 0) refusals.push(`the flow store carries ${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]}) — unknown kinds and broken records fail closed; inspect ${flowPath}`);
233
+ if (coreRead.readError) refusals.push(`the core evidence store is unreadable (${coreRead.readError}) — inspect ${corePath} (fail closed)`);
234
+ else if ((coreRead.malformed ?? 0) > 0) refusals.push(`the core evidence store carries ${coreRead.malformed} malformed line(s) (${coreRead.malformedReasons[0]}) — inspect ${corePath} (fail closed)`);
235
+ if (refusals.length > 0) return { refusals, advisories };
236
+ const records = flowRead.records;
237
+ const sup = validateSupersessions(records);
238
+ if (!sup.ok) refusals.push(`supersession legality: ${sup.reason} — inspect ${flowPath}`);
239
+ for (const planId of [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))]) {
240
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
241
+ const plan = planRefusals(records, chain, planId, owner, advisories);
242
+ refusals.push(...plan.refusals);
243
+ if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
244
+ }
245
+ refusals.push(...deltaRefusals(records, owner));
246
+ refusals.push(...degradeOrderingRefusals(coreRead.records));
247
+ if (evidence != null) {
248
+ refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
249
+ refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
250
+ refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
251
+ }
252
+ return { refusals, advisories };
253
+ };
@@ -0,0 +1,56 @@
1
+ // flow-check-git-lane.mjs — the all-path git lane for the checker's base-motion inputs (#62/P22):
2
+ // toplevel-rooted, submodules never ignored, test files included. Split out of flow-check.mjs
3
+ // (baseline-practices tranche 1) and deliberately a LEAF — it imports no flow-check sibling, so the
4
+ // pure decision cores can never reach a git spawn through it.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const short = (digest) => `${digest.slice(0, 12)}…`;
9
+
10
+ // computeChangedSurface exists for COVERAGE and excludes test files by design — the base-
11
+ // intersection inputs come from these helpers instead: every changed path counts, tests included.
12
+ const gitPathList = (args, cwd) => {
13
+ const r = spawnSync('git', args, { cwd, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
14
+ if (r.error || r.status !== 0) return null;
15
+ return r.stdout.toString('utf8').split('\0').filter(Boolean);
16
+ };
17
+
18
+ export const resolveGitToplevel = (cwd) => {
19
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
20
+ if (r.error || r.status !== 0) return null;
21
+ const top = r.stdout.toString('utf8').replace(/\r?\n$/, '');
22
+ return top === '' ? null : top;
23
+ };
24
+
25
+ export const computeAllPathBaseDelta = (cwd, fromBase, toBase) => {
26
+ const isSha = (v) => typeof v === 'string' && /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(v);
27
+ if (!isSha(fromBase) || !isSha(toBase)) {
28
+ return { ok: false, reason: `a base delta needs two shas (got ${JSON.stringify(fromBase)} → ${JSON.stringify(toBase)})` };
29
+ }
30
+ const root = resolveGitToplevel(cwd);
31
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the base delta is unresolvable (fail closed)' };
32
+ const paths = gitPathList(['diff', '--name-only', '--no-renames', '--ignore-submodules=none', '-z', fromBase, toBase], root);
33
+ if (paths == null) return { ok: false, reason: `git diff ${short(fromBase)} ${short(toBase)} failed — an unresolvable base delta fails closed` };
34
+ return { ok: true, paths };
35
+ };
36
+
37
+ export const computeAllPathWorktreeSurface = (cwd) => {
38
+ const root = resolveGitToplevel(cwd);
39
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the worktree surface is unresolvable (fail closed)' };
40
+ // assume-unchanged/skip-worktree lie to git diff — any flagged entry fails the surface closed.
41
+ const flagged = gitPathList(['ls-files', '-v', '-z'], root);
42
+ if (flagged == null) return { ok: false, reason: 'the worktree surface is unresolvable (git ls-files -v failed) — fail closed' };
43
+ for (const entry of flagged) {
44
+ if (entry.length < 3 || entry[1] !== ' ') return { ok: false, reason: `the worktree surface is unresolvable (unparseable ls-files -v entry ${JSON.stringify(entry)}) — fail closed` };
45
+ const assumeUnchanged = /[a-z]/.test(entry[0]);
46
+ const skipWorktree = entry[0].toUpperCase() === 'S';
47
+ if (assumeUnchanged || skipWorktree) {
48
+ const flags = [assumeUnchanged ? 'assume-unchanged' : null, skipWorktree ? 'skip-worktree' : null].filter(Boolean).join(' + ');
49
+ return { ok: false, reason: `index-flagged entry ${entry.slice(2)} (${flags}) hides changes from git diff — the worktree surface is undecidable (fail closed)` };
50
+ }
51
+ }
52
+ const tracked = gitPathList(['diff', 'HEAD', '--name-only', '--no-renames', '--ignore-submodules=none', '-z'], root);
53
+ const untracked = gitPathList(['ls-files', '--others', '--exclude-standard', '-z'], root);
54
+ if (tracked == null || untracked == null) return { ok: false, reason: 'the worktree surface is unresolvable (git diff/ls-files failed) — fail closed' };
55
+ return { ok: true, paths: [...new Set([...tracked, ...untracked])] };
56
+ };