@sabaiway/agent-workflow-kit 5.11.0 → 5.11.1
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.
- package/CHANGELOG.md +61 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +13 -2
- package/references/hooks/state-block-guard.mjs +14 -2
- package/references/scripts/archive-changelog.mjs +14 -3
- package/references/scripts/archive-decisions.mjs +14 -3
- package/references/scripts/archive-issues.mjs +14 -3
- package/references/scripts/check-docs-size.mjs +14 -3
- package/references/scripts/migrate-gates.mjs +13 -2
- package/tools/ack-write.mjs +3 -3
- package/tools/autonomy-doctor.mjs +2 -3
- package/tools/bridge-settings.mjs +2 -3
- package/tools/cheap-agents.mjs +3 -3
- package/tools/commands.mjs +2 -3
- package/tools/commit-guard.mjs +3 -3
- package/tools/core-evidence.mjs +2 -3
- package/tools/coverage-check.mjs +2 -3
- package/tools/delegation.mjs +2 -3
- package/tools/detect-backends.mjs +2 -3
- package/tools/doc-parity.mjs +2 -3
- package/tools/family-registry.mjs +3 -3
- package/tools/flow-adoption-mint.mjs +70 -0
- package/tools/flow-append.mjs +309 -0
- package/tools/flow-chain-state.mjs +91 -0
- package/tools/flow-check.mjs +2 -3
- package/tools/flow-delta-proof.mjs +307 -0
- package/tools/flow-record.mjs +1 -1
- package/tools/flow-store-read.mjs +3 -3
- package/tools/flow-store.mjs +35 -812
- package/tools/flow-subset-budget.mjs +81 -0
- package/tools/flow-writer.mjs +3 -3
- package/tools/gate-hook.mjs +3 -3
- package/tools/gates-init.mjs +3 -3
- package/tools/grounding.mjs +2 -3
- package/tools/hide-footprint.mjs +2 -3
- package/tools/inject-methodology.mjs +2 -3
- package/tools/lens-region.mjs +2 -3
- package/tools/manifest/validate.mjs +2 -3
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/path-inventory.mjs +2 -3
- package/tools/procedures.mjs +3 -3
- package/tools/receipt-deadline.mjs +2 -3
- package/tools/recipes.mjs +2 -3
- package/tools/recommendations.mjs +3 -3
- package/tools/release-scan.mjs +2 -3
- package/tools/repo-search.mjs +2 -3
- package/tools/review-state.mjs +3 -3
- package/tools/run-gates.mjs +2 -3
- package/tools/sandbox-masks.mjs +3 -3
- package/tools/set-autonomy.mjs +2 -3
- package/tools/set-flow.mjs +3 -3
- package/tools/set-recipe.mjs +2 -3
- package/tools/setup-backends.mjs +3 -3
- package/tools/store-append.mjs +2 -2
- package/tools/uninstall.mjs +2 -3
- package/tools/velocity-profile.mjs +3 -3
- package/tools/worktrees.mjs +3 -3
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// flow-append.mjs — the flow store's ONE write door (flow-orchestration Phase 2, extracted from
|
|
2
|
+
// flow-store.mjs unchanged by baseline-practices tranche 2): the flow lane over the parameterized
|
|
3
|
+
// createStoreAppendLane, the SEMANTIC append preflight, the two generic append entries, the
|
|
4
|
+
// subset-run serializer + the pre-gate lock probe, and the Decision-7 locked subset-attempt factory.
|
|
5
|
+
//
|
|
6
|
+
// The lock/CAS discipline itself lives one module further down in store-append.mjs (delegation
|
|
7
|
+
// Plan 1 D12); this leaf injects the flow store's nouns, env seams, knob names, validator, parser
|
|
8
|
+
// and the per-kind legality the lane runs inside the critical section.
|
|
9
|
+
//
|
|
10
|
+
// Imports run ONE way: this module composes the two PURE leaves (flow-chain-state.mjs,
|
|
11
|
+
// flow-subset-budget.mjs) and the read half; the two mint leaves compose THIS one. Nothing here
|
|
12
|
+
// reaches back up to the flow-store.mjs facade — that edge would be the cycle
|
|
13
|
+
// test/read-graph-purity.test.mjs reds.
|
|
14
|
+
//
|
|
15
|
+
// flowSemanticPreflight, deepFreezeClone and the lane's own captureRecordSnapshot/
|
|
16
|
+
// appendResolvedRecord stay PRIVATE: the factory-only rule for subset-attempt records is kept by
|
|
17
|
+
// NOT publishing them, so a hand-built record can never forge a fresh counting context.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions,
|
|
21
|
+
canonicalFlowDigest, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM,
|
|
22
|
+
} from './flow-record.mjs';
|
|
23
|
+
import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
|
|
24
|
+
import { CONFIG_REL } from './orchestration-config.mjs';
|
|
25
|
+
import { createStoreAppendLane } from './store-append.mjs';
|
|
26
|
+
import {
|
|
27
|
+
flowStoreStop, resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, deriveFlowOwner,
|
|
28
|
+
} from './flow-store-read.mjs';
|
|
29
|
+
import {
|
|
30
|
+
walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget, validateOpenerReference,
|
|
31
|
+
} from './flow-chain-state.mjs';
|
|
32
|
+
import {
|
|
33
|
+
SUBSET_ATTEMPT_MAX_REDS, SUBSET_ATTEMPT_DIAGNOSIS_REDS, subsetAttemptState, subsetAttemptGate,
|
|
34
|
+
} from './flow-subset-budget.mjs';
|
|
35
|
+
|
|
36
|
+
const stop = flowStoreStop;
|
|
37
|
+
|
|
38
|
+
// Wait bound + poll cadence; the env knobs keep hermetic tests off wall-clock.
|
|
39
|
+
export const FLOW_LOCK_WAIT_MS = 10_000;
|
|
40
|
+
export const FLOW_LOCK_POLL_MS = 100;
|
|
41
|
+
|
|
42
|
+
// ── the shared append lane (D12) ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// The lock/CAS discipline, the fd-custody rules and the serialized append are the EXTRACTION of
|
|
45
|
+
// exactly this module's former code into store-append.mjs, so behavior is unchanged by
|
|
46
|
+
// construction: this store injects its nouns (every refusal still names the flow store), its env
|
|
47
|
+
// seam and knob names, its typed-STOP factory, its record validator, its store-text parser, and
|
|
48
|
+
// the SEMANTIC preflight below. The flow suites are the characterization bar for that claim.
|
|
49
|
+
const flowAppendLane = createStoreAppendLane({
|
|
50
|
+
nouns: { store: 'flow store', adj: 'flow-store', record: 'flow record' },
|
|
51
|
+
envNames: { store: 'AW_FLOW_STORE', waitKnob: 'AW_FLOW_LOCK_WAIT_MS', pollKnob: 'AW_FLOW_LOCK_POLL_MS' },
|
|
52
|
+
stop,
|
|
53
|
+
resolveStorePath: resolveFlowStorePath,
|
|
54
|
+
resolveLockPath: resolveFlowLockPath,
|
|
55
|
+
validateRecord: validateFlowRecord,
|
|
56
|
+
parseStoreText: parseFlowStoreText,
|
|
57
|
+
lockWaitMs: FLOW_LOCK_WAIT_MS,
|
|
58
|
+
lockPollMs: FLOW_LOCK_POLL_MS,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const captureRecordSnapshot = flowAppendLane.captureRecordSnapshot;
|
|
62
|
+
|
|
63
|
+
// ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
|
|
64
|
+
|
|
65
|
+
// The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
|
|
66
|
+
// bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
|
|
67
|
+
// all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
|
|
68
|
+
export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
|
|
69
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
70
|
+
// Round-9 fold: subset-attempt records are minted ONLY by the locked factory — foldBatch,
|
|
71
|
+
// subsetDigest, and attemptIndex are DERIVED inside its critical section, and a hand-built
|
|
72
|
+
// record could forge a fresh counting context and bypass the hard-stop budget.
|
|
73
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
74
|
+
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)');
|
|
75
|
+
}
|
|
76
|
+
return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: () => ({ line, snapshot }) });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
|
|
80
|
+
// runs INSIDE the critical section on the locked store snapshot (Plan 4 Phase 3 / round-1 fold
|
|
81
|
+
// F6): a writer's lock-free cap/completeness walk is advisory — the locked snapshot decides, so
|
|
82
|
+
// a concurrent append can never slip a stale terminal (or a stranding round) through. The hook
|
|
83
|
+
// receives a DEEP-FROZEN CLONE (round-1 fold M5): a buggy preflight throws on any mutation
|
|
84
|
+
// attempt and can never skew the bytes the semantic validation and the write see. A throwing
|
|
85
|
+
// preflight refuses the append with nothing written. The subset-attempt factory-only rule holds
|
|
86
|
+
// on this lane too.
|
|
87
|
+
export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env = process.env, deps = {}, preflight = null } = {}) => {
|
|
88
|
+
const { line, snapshot } = captureRecordSnapshot(record);
|
|
89
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
90
|
+
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)');
|
|
91
|
+
}
|
|
92
|
+
return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
|
|
93
|
+
if (preflight != null) preflight(deepFreezeClone(records));
|
|
94
|
+
return { line, snapshot };
|
|
95
|
+
} });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const deepFreezeClone = (value) => {
|
|
99
|
+
const freeze = (v) => {
|
|
100
|
+
if (v !== null && typeof v === 'object') {
|
|
101
|
+
Object.values(v).forEach(freeze);
|
|
102
|
+
Object.freeze(v);
|
|
103
|
+
}
|
|
104
|
+
return v;
|
|
105
|
+
};
|
|
106
|
+
return freeze(structuredClone(value));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// The SEMANTIC half of the append, handed to the shared lane and run by it INSIDE the critical
|
|
110
|
+
// section on the LOCKED store snapshot (a writer's lock-free walk is advisory — only the locked
|
|
111
|
+
// snapshot decides): per-kind chain legality, reference resolution, the closure rules, the
|
|
112
|
+
// counting-context gate, and supersession legality. An illegal record never lands. Throws a typed
|
|
113
|
+
// STOP; the lane releases the lock and re-throws.
|
|
114
|
+
const flowSemanticPreflight = ({ records, snapshot, storePath }) => {
|
|
115
|
+
if (snapshot.kind === CHAIN_KIND) {
|
|
116
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
117
|
+
const existingSeq = validateChainSequence(chain);
|
|
118
|
+
if (!existingSeq.ok) {
|
|
119
|
+
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)`);
|
|
120
|
+
}
|
|
121
|
+
const candidateSeq = validateChainSequence([...chain, snapshot]);
|
|
122
|
+
if (!candidateSeq.ok) {
|
|
123
|
+
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`);
|
|
124
|
+
}
|
|
125
|
+
// Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
|
|
126
|
+
// digest-reference the chain's prior terminal; a round REVISION re-states its reference
|
|
127
|
+
// byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
|
|
128
|
+
if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
|
|
129
|
+
const ref = validateOpenerReference(records, snapshot);
|
|
130
|
+
if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
|
|
131
|
+
}
|
|
132
|
+
if (snapshot.purpose === 'refresh') {
|
|
133
|
+
if (resolveRecordReference(records, snapshot.refreshedRecord) === undefined) {
|
|
134
|
+
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`);
|
|
135
|
+
}
|
|
136
|
+
if (!isAuthoritativeReferenceTarget(records, snapshot.refreshedRecord)) {
|
|
137
|
+
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');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// The closure rule runs UNDER the lock on the captured snapshot — a writer's lock-free
|
|
142
|
+
// usability pre-check can race a concurrent up/clear, and a justification minted after its
|
|
143
|
+
// mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
|
|
144
|
+
if (snapshot.kind === 'degrade-justification') {
|
|
145
|
+
const closed = records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
|
|
146
|
+
if (closed) {
|
|
147
|
+
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');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
|
|
151
|
+
// {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
|
|
152
|
+
// the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
|
|
153
|
+
// (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
|
|
154
|
+
// stale consult context can never satisfy the decide layer, so the store refuses to strand it.
|
|
155
|
+
if (snapshot.kind === 'consult-attestation') {
|
|
156
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
|
|
157
|
+
const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
|
|
158
|
+
if (!seq.ok) {
|
|
159
|
+
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`);
|
|
160
|
+
}
|
|
161
|
+
const state = walkChainState(chain);
|
|
162
|
+
const open = state.mode === 'in-step' && !state.parked && !state.completed;
|
|
163
|
+
if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
|
|
164
|
+
const shown = !open
|
|
165
|
+
? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
|
|
166
|
+
: `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
|
|
167
|
+
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`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
|
|
171
|
+
// factory computes a passing record; a hand-built one must satisfy the same rules).
|
|
172
|
+
if (snapshot.kind === 'subset-attempt') {
|
|
173
|
+
const gate = subsetAttemptGate(records, snapshot);
|
|
174
|
+
if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
|
|
175
|
+
}
|
|
176
|
+
const existingSup = validateSupersessions(records);
|
|
177
|
+
if (!existingSup.ok) {
|
|
178
|
+
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)`);
|
|
179
|
+
}
|
|
180
|
+
const candidateSup = validateSupersessions([...records, snapshot]);
|
|
181
|
+
if (!candidateSup.ok) {
|
|
182
|
+
throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ── the Decision-7 subset-run serializer (round-6 fold) ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
// --pre-review's WHOLE armed cycle (budget preflight → gates → append) holds this lock: without
|
|
189
|
+
// it a parallel run executes gates whose red can no longer be recorded once the winner lands,
|
|
190
|
+
// and an unrecorded red undercounts the budget ("EVERY subset-run red counts"). A SEPARATE lock
|
|
191
|
+
// file beside the store — never the store lock itself, so appends from other lanes never block
|
|
192
|
+
// behind a minutes-long gate run — riding the same CAS/fd-custody/holder-liveness discipline: a
|
|
193
|
+
// crashed holder surfaces as the named DEAD refusal with its rm recovery; a live holder is a
|
|
194
|
+
// bounded loud wait (the queued run then re-reads the budget and re-decides).
|
|
195
|
+
export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
|
|
196
|
+
|
|
197
|
+
export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
198
|
+
const resolved = flowAppendLane.resolveOrStop(cwd, env, 'serialize a subset run against');
|
|
199
|
+
const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
|
|
200
|
+
return { lockPath, release: () => flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps) };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
|
|
204
|
+
// ORDINARY append lock through the full acquire discipline — a DEAD/foreign/malformed lock or
|
|
205
|
+
// an unwritable parent surfaces BEFORE any gate spends, with the acquire's own named refusal.
|
|
206
|
+
// Stated residual: a lock landing between this probe and the post-run append still refuses at
|
|
207
|
+
// append time — closing that would mean holding the append lock across the whole gate run.
|
|
208
|
+
export const probeFlowAppendLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
|
|
209
|
+
const resolved = flowAppendLane.resolveOrStop(cwd, env, 'probe');
|
|
210
|
+
const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(resolved, env, deps);
|
|
211
|
+
const issue = flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps);
|
|
212
|
+
if (issue != null) throw issue;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// appendSubsetAttempt — the Decision-7 locked append factory: the chain identity is captured
|
|
216
|
+
// BEFORE the gates run (the caller's `expected` {planId, cycle, stepId, round}) and re-checked
|
|
217
|
+
// under the append lock against the OPEN owning chain; attemptIndex, foldBatch/subsetDigest
|
|
218
|
+
// derivation, and the hard-stop state are computed from the captured store snapshot INSIDE the
|
|
219
|
+
// critical section — a concurrent appender never duplicates an index, and a round/park/complete
|
|
220
|
+
// landing mid-run refuses the append (never a silent misfile). subsetGateIds states what the
|
|
221
|
+
// caller RAN — only the caller knows that — but it never DECIDES the counting context: the
|
|
222
|
+
// factory re-derives the subset from the declaration + config itself (the R10 rider, via the
|
|
223
|
+
// gates-declaration leaf) and refuses a mismatch, so a caller-chosen id list can never forge a
|
|
224
|
+
// fresh subsetDigest and bypass the hard-stop budget.
|
|
225
|
+
export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, deps = {}, expected, subsetGateIds, status, diagnosis = null, base, fingerprint, timestamp = new Date().toISOString() } = {}) => {
|
|
226
|
+
const owner = deriveFlowOwner(cwd);
|
|
227
|
+
if (owner == null) throw stop('not inside a git work tree — the subset-attempt mint derives the owning worktree from git (fail closed)');
|
|
228
|
+
if (expected == null || typeof expected.planId !== 'string' || expected.planId.length === 0
|
|
229
|
+
|| !Number.isInteger(expected.cycle) || !Number.isInteger(expected.round)
|
|
230
|
+
|| (expected.stepId !== null && typeof expected.stepId !== 'string')) {
|
|
231
|
+
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)');
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(subsetGateIds)) throw stop("subsetGateIds must be the derived subset's ordered gate-id array (fail closed)");
|
|
234
|
+
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)`);
|
|
235
|
+
if (diagnosis !== null && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
236
|
+
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)`);
|
|
237
|
+
}
|
|
238
|
+
let derived;
|
|
239
|
+
try {
|
|
240
|
+
derived = derivePregateSubsetIds(cwd);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
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)`);
|
|
243
|
+
}
|
|
244
|
+
if (derived.length !== subsetGateIds.length || derived.some((id, i) => id !== subsetGateIds[i])) {
|
|
245
|
+
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)`);
|
|
246
|
+
}
|
|
247
|
+
// Everything downstream binds the factory-owned DERIVED ids — the caller array stays mutable in
|
|
248
|
+
// the caller's hands (a deps lock-hook could rewrite it after the check above) and must never
|
|
249
|
+
// reach the digest domain.
|
|
250
|
+
const subsetIds = Object.freeze([...derived]);
|
|
251
|
+
let minted = null;
|
|
252
|
+
const value = flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
|
|
253
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
|
|
254
|
+
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)`);
|
|
255
|
+
const seq = validateChainSequence(chain);
|
|
256
|
+
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)`);
|
|
257
|
+
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)`);
|
|
258
|
+
const state = walkChainState(chain);
|
|
259
|
+
const open = !state.completed && !state.parked;
|
|
260
|
+
const held = open && state.cycle === expected.cycle && state.stepId === expected.stepId && (state.round ?? 0) === expected.round;
|
|
261
|
+
if (!held) {
|
|
262
|
+
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}}`;
|
|
263
|
+
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)`);
|
|
264
|
+
}
|
|
265
|
+
if (expected.stepId === null && state.openers.length > 0) {
|
|
266
|
+
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)`);
|
|
267
|
+
}
|
|
268
|
+
// Round-9 fold: the EXACTLY-ONE-open-owning-chain rule is re-derived UNDER the lock — an
|
|
269
|
+
// adoption/resume landing after the caller's preflight would otherwise record the attempt
|
|
270
|
+
// into an already-ambiguous context. (After the specific refusals above, so a parked or
|
|
271
|
+
// moved TARGET chain keeps its own named diagnosis.)
|
|
272
|
+
const openOwn = [...new Set(records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner).map((r) => r.planId))].filter((planId) => {
|
|
273
|
+
const c = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
274
|
+
if (c[0].purpose !== 'adoption' || c[0].owner !== owner || !validateChainSequence(c).ok) return false;
|
|
275
|
+
const s = walkChainState(c);
|
|
276
|
+
return !s.completed && !s.parked;
|
|
277
|
+
});
|
|
278
|
+
if (openOwn.length !== 1 || openOwn[0] !== expected.planId) {
|
|
279
|
+
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)`);
|
|
280
|
+
}
|
|
281
|
+
const probe = { planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId, foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds) };
|
|
282
|
+
const budget = subsetAttemptState(records, probe);
|
|
283
|
+
const attemptIndex = budget.nextIndex;
|
|
284
|
+
if (budget.reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
|
|
285
|
+
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`);
|
|
286
|
+
}
|
|
287
|
+
if (attemptIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && diagnosis != null) {
|
|
288
|
+
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)`);
|
|
289
|
+
}
|
|
290
|
+
const { line, snapshot } = captureRecordSnapshot({
|
|
291
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'subset-attempt', planId: expected.planId, cycle: expected.cycle,
|
|
292
|
+
stepId: expected.stepId, foldBatch: probe.foldBatch, subsetDigest: probe.subsetDigest, attemptIndex,
|
|
293
|
+
...(typeof diagnosis === 'string' ? { diagnosis } : {}), status, base, fingerprint, timestamp,
|
|
294
|
+
});
|
|
295
|
+
// Computed UNDER the lock from the captured snapshot — a lock-free preflight state could
|
|
296
|
+
// pick the wrong message under a concurrent append.
|
|
297
|
+
const consumedPermit = budget.reds >= SUBSET_ATTEMPT_MAX_REDS;
|
|
298
|
+
const redsAfter = budget.reds + (status === 'red' ? 1 : 0);
|
|
299
|
+
const creditsAfter = budget.credits - (consumedPermit ? 1 : 0);
|
|
300
|
+
minted = {
|
|
301
|
+
attemptIndex,
|
|
302
|
+
redsAtKey: redsAfter,
|
|
303
|
+
reopened: consumedPermit,
|
|
304
|
+
exhaustedAfter: redsAfter >= SUBSET_ATTEMPT_MAX_REDS && creditsAfter <= 0,
|
|
305
|
+
};
|
|
306
|
+
return { line, snapshot };
|
|
307
|
+
} });
|
|
308
|
+
return { ...value, ...minted, digest: canonicalFlowDigest(value.record) };
|
|
309
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// flow-chain-state.mjs — the chain-state walk and the generic prior-terminal reference validator
|
|
2
|
+
// (#63): priorChainTerminal, walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget
|
|
3
|
+
// and validateOpenerReference. Split out of flow-store.mjs unchanged (baseline-practices tranche 2),
|
|
4
|
+
// which now re-exports every name here.
|
|
5
|
+
//
|
|
6
|
+
// PURE over read results and the LOWEST of the store's five leaves: no store IO, no git, no fs, and
|
|
7
|
+
// the record vocabulary is its only tools sibling. Imports run ONE way — flow-append.mjs composes
|
|
8
|
+
// this module; nothing here reaches back up to the facade.
|
|
9
|
+
|
|
10
|
+
import { CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords } from './flow-record.mjs';
|
|
11
|
+
|
|
12
|
+
const TERMINAL_PURPOSES = ['adoption', 'converged', 'complete'];
|
|
13
|
+
|
|
14
|
+
// The record a step-opening round must reference: the latest converged/complete, else the adoption
|
|
15
|
+
// record itself (the plan's first step — the exemption is explicit, never inferred).
|
|
16
|
+
export const priorChainTerminal = (chain) => {
|
|
17
|
+
let terminal = null;
|
|
18
|
+
for (const r of chain) {
|
|
19
|
+
if (r.purpose === 'adoption' && terminal === null) terminal = r;
|
|
20
|
+
else if (r.purpose === 'converged' || r.purpose === 'complete') terminal = r;
|
|
21
|
+
}
|
|
22
|
+
return terminal;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// walkChainState(chain) → { mode, parked, completed, cycle, round, stepId, openers, lastTerminal }
|
|
26
|
+
// over ONE plan's raw-order chain. Legality lives in validateChainSequence — callers run it first;
|
|
27
|
+
// this walk only derives state, including each opener with its at-that-point prior terminal.
|
|
28
|
+
export const walkChainState = (chain) => {
|
|
29
|
+
const state = {
|
|
30
|
+
mode: 'boundary', parked: false, completed: false,
|
|
31
|
+
cycle: chain[0]?.cycle ?? null, round: chain[0]?.round ?? null, stepId: null,
|
|
32
|
+
openers: [], lastTerminal: null,
|
|
33
|
+
};
|
|
34
|
+
for (const r of chain) {
|
|
35
|
+
state.cycle = r.cycle;
|
|
36
|
+
if (r.purpose === 'adoption') { state.lastTerminal = r; state.round = r.round; continue; }
|
|
37
|
+
if (r.purpose === 'park') { state.parked = true; continue; }
|
|
38
|
+
if (r.purpose === 'resume') { state.parked = false; continue; }
|
|
39
|
+
if (r.purpose === 'complete') { state.completed = true; state.lastTerminal = r; continue; }
|
|
40
|
+
if (r.purpose === 'converged') { state.mode = 'boundary'; state.lastTerminal = r; state.stepId = null; continue; }
|
|
41
|
+
if (r.purpose === 'unfreeze' && state.mode === 'boundary') { state.mode = 'in-step'; state.stepId = r.stepId; state.round = r.round; continue; }
|
|
42
|
+
if (r.purpose === 'round') {
|
|
43
|
+
if (state.mode === 'boundary') {
|
|
44
|
+
state.openers.push({ record: r, priorTerminal: state.lastTerminal });
|
|
45
|
+
state.mode = 'in-step';
|
|
46
|
+
state.stepId = r.stepId;
|
|
47
|
+
state.round = r.round;
|
|
48
|
+
} else if (r.round > state.round) state.round = r.round;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return state;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Reference checks live ENTIRELY in the digest domain — two byte-different records with one
|
|
55
|
+
// canonical serialization are ONE identity, so object identity never decides resolution or
|
|
56
|
+
// authority. resolveRecordReference returns the LAST matching record (consistent with the
|
|
57
|
+
// latest-per-key authoritative selection); the prefix (records BEFORE the referencing one) is the
|
|
58
|
+
// resolution domain, so an out-of-order reference never resolves.
|
|
59
|
+
export const resolveRecordReference = (prefixRecords, digest) =>
|
|
60
|
+
prefixRecords.findLast((r) => canonicalFlowDigest(r) === digest);
|
|
61
|
+
|
|
62
|
+
export const isAuthoritativeReferenceTarget = (scopeRecords, digest) =>
|
|
63
|
+
authoritativeFlowRecords(scopeRecords).some((r) => canonicalFlowDigest(r) === digest);
|
|
64
|
+
|
|
65
|
+
// validateOpenerReference(prefixRecords, candidate) → { ok } | { ok: false, reason }. The named
|
|
66
|
+
// classification of a step-opening round's prior-terminal reference: unresolved · non-chain ·
|
|
67
|
+
// another plan · non-terminal · superseded · not-the-prior-terminal.
|
|
68
|
+
export const validateOpenerReference = (prefixRecords, candidate) => {
|
|
69
|
+
const target = resolveRecordReference(prefixRecords, candidate.opensFrom);
|
|
70
|
+
if (target === undefined) {
|
|
71
|
+
return { ok: false, reason: `the prior-terminal reference does not match the store — no record digests to ${candidate.opensFrom.slice(0, 12)}…` };
|
|
72
|
+
}
|
|
73
|
+
if (target.kind !== CHAIN_KIND) {
|
|
74
|
+
return { ok: false, reason: `the prior-terminal reference targets a ${target.kind} record, not a chain terminal` };
|
|
75
|
+
}
|
|
76
|
+
if (target.planId !== candidate.planId) {
|
|
77
|
+
return { ok: false, reason: `the prior-terminal reference targets another plan's record ("${target.planId}") — a step never opens from a foreign chain` };
|
|
78
|
+
}
|
|
79
|
+
if (!TERMINAL_PURPOSES.includes(target.purpose)) {
|
|
80
|
+
return { ok: false, reason: `the prior-terminal reference targets a non-terminal record (purpose "${target.purpose}") — an opener references adoption, converged, or complete only` };
|
|
81
|
+
}
|
|
82
|
+
const chain = prefixRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === candidate.planId);
|
|
83
|
+
if (!isAuthoritativeReferenceTarget(chain, candidate.opensFrom)) {
|
|
84
|
+
return { ok: false, reason: 'the prior-terminal reference targets a superseded record — reference the latest record of that key' };
|
|
85
|
+
}
|
|
86
|
+
const prior = priorChainTerminal(chain);
|
|
87
|
+
if (prior == null || canonicalFlowDigest(prior) !== candidate.opensFrom) {
|
|
88
|
+
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` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
};
|
package/tools/flow-check.mjs
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
// discipline) — a poisoned override can neither redirect nor mask the real stores.
|
|
20
20
|
|
|
21
21
|
import { lstatSync } from 'node:fs';
|
|
22
|
-
import { pathToFileURL } from 'node:url';
|
|
23
22
|
import {
|
|
24
23
|
CHAIN_KIND, canonicalFlowDigest, ownerScopedFlowProjection, flowProjectionHash,
|
|
25
24
|
} from './flow-record.mjs';
|
|
25
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
26
26
|
import { resolveFlowStorePath, readFlowStore, deriveFlowOwner } from './flow-store.mjs';
|
|
27
27
|
import {
|
|
28
28
|
resolveEvidencePath, readEvidence, resolveBase, authoritativeOfKind,
|
|
@@ -260,8 +260,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
260
260
|
}
|
|
261
261
|
};
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
if (isDirectRun) {
|
|
263
|
+
if (isDirectRun(import.meta.url)) {
|
|
265
264
|
const r = main(process.argv.slice(2));
|
|
266
265
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
267
266
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|