@sabaiway/agent-workflow-kit 5.10.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 +152 -0
- package/README.md +2 -2
- 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/modes/commit-guard.md +11 -8
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +32 -10
- package/references/modes/worktrees.md +47 -3
- 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/advisor-matrix.mjs +165 -0
- 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 +4 -5
- package/tools/commit-guard.mjs +77 -20
- package/tools/core-evidence.mjs +12 -3
- package/tools/coverage-check.mjs +2 -3
- package/tools/delegation.mjs +2 -3
- package/tools/detect-backends.mjs +2 -3
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +69 -16
- 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-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +22 -8
- 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/observation-builder.mjs +123 -0
- 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/satellite-locator.mjs +179 -0
- 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/worktree-handoff-return.mjs +369 -0
- package/tools/worktree-prompt.mjs +190 -0
- package/tools/worktrees-record.mjs +171 -0
- package/tools/worktrees.mjs +311 -300
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,
|
|
@@ -60,7 +60,7 @@ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 }
|
|
|
60
60
|
// PREVIOUS one and an in-matrix comparison would make a new green final unreachable. The SAME
|
|
61
61
|
// distinction reaches the #65 unanswered-red rung (its own header states the split): the 'gate'
|
|
62
62
|
// lane also answers a red under a provable in-progress retry, for the same reason.
|
|
63
|
-
export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {} } = {}) => {
|
|
63
|
+
export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {}, treeCarriesBytes = true } = {}) => {
|
|
64
64
|
const fingerprintProbe = probes.fingerprint ?? computeTreeFingerprint;
|
|
65
65
|
const owner = deriveFlowOwner(cwd);
|
|
66
66
|
if (owner == null) {
|
|
@@ -161,14 +161,29 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
|
|
|
161
161
|
// authoritative completed final at the CURRENT fingerprint, status green FIRST (a newer red
|
|
162
162
|
// is never bypassed by an older green's matching hash — the guard's own red arm refuses it),
|
|
163
163
|
// then the hash comparison.
|
|
164
|
+
// …and NOT when the CALLER states the tree carries no bytes: the receipt it would bind was then
|
|
165
|
+
// found at the one fingerprint every clean moment of every repository shares, so it was minted by
|
|
166
|
+
// some other moment and its projection hash describes that moment's store, not this one.
|
|
167
|
+
// Correlating it would make the outcome depend on which stray clean moment the store recorded
|
|
168
|
+
// last — the same fact the #65 rung and commit-guard's own content-free lanes apply.
|
|
169
|
+
// The probe stays INSIDE this lane: an armed decision already paid for the tree (evidence.tree),
|
|
170
|
+
// and every other lane — the gate consumer, a broken store, an unarmed flow — must stay inert.
|
|
171
|
+
// A hoisted probe would make those lanes read the whole tree and let an unreadable untracked
|
|
172
|
+
// file throw where the answer is otherwise a quiet, healthy no-op.
|
|
164
173
|
const bindingRefusals = [];
|
|
165
|
-
|
|
174
|
+
const completedFinals = consumer === 'commit-guard' && !healthBroken && treeCarriesBytes
|
|
175
|
+
? authoritativeOfKind(coreRead.records, 'final')
|
|
176
|
+
: [];
|
|
177
|
+
// …and only when a final record EXISTS to bind: with none in the store there is no binding to
|
|
178
|
+
// verify, so reading the tree would answer a question nobody asked.
|
|
179
|
+
if (completedFinals.length > 0) {
|
|
166
180
|
const currentFingerprint = evidence?.tree.fingerprint ?? fingerprintProbe(cwd);
|
|
167
181
|
if (currentFingerprint == null) {
|
|
168
182
|
bindingRefusals.push('the current tree fingerprint is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run run-gates.mjs --final on a healthy tree');
|
|
169
183
|
}
|
|
170
|
-
const currentFinal = currentFingerprint == null
|
|
171
|
-
|
|
184
|
+
const currentFinal = currentFingerprint == null
|
|
185
|
+
? undefined
|
|
186
|
+
: completedFinals.find((r) => r.fingerprintBefore === currentFingerprint);
|
|
172
187
|
if (currentFinal !== undefined && currentFinal.status === 'green') {
|
|
173
188
|
const bound = currentFinal.evidenceHashes?.flow;
|
|
174
189
|
if (typeof bound === 'string') {
|
|
@@ -193,7 +208,7 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
|
|
|
193
208
|
}
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
|
-
const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer });
|
|
211
|
+
const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer, treeCarriesBytes });
|
|
197
212
|
// Semantic refusals bind only an ARMED store; the D10 binding refusals ride the commit-guard
|
|
198
213
|
// lane UNCONDITIONALLY — a deleted or truncated store must never un-arm the binding.
|
|
199
214
|
const effectiveRefusals = healthBroken ? refusals : [...(armed ? [...refusals, ...evidenceRefusals] : []), ...bindingRefusals];
|
|
@@ -245,8 +260,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
245
260
|
}
|
|
246
261
|
};
|
|
247
262
|
|
|
248
|
-
|
|
249
|
-
if (isDirectRun) {
|
|
263
|
+
if (isDirectRun(import.meta.url)) {
|
|
250
264
|
const r = main(process.argv.slice(2));
|
|
251
265
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
252
266
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
@@ -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
|
+
};
|
package/tools/flow-record.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// Plan-4 addition (Decision 7/8 — NOT a design-§5 seed member; the drift-guard test records it as
|
|
27
27
|
// the one post-seed addition): the store-global kind "subset-attempt" — the --pre-review subset
|
|
28
28
|
// hard-stop counter. Key {planId, cycle, stepId, foldBatch, subsetDigest}; attemptIndex is the
|
|
29
|
-
// monotonic per-key index (the locked append factory in flow-
|
|
29
|
+
// monotonic per-key index (the locked append factory in flow-append.mjs computes it under the
|
|
30
30
|
// lock); `diagnosis` rides ONLY attemptIndex >= 3 (Decision 8 — the recorded, self-servable
|
|
31
31
|
// continuation past two reds; never a wait-for-maintainer).
|
|
32
32
|
//
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// flow-store-read.mjs — the read half of the flow store (flow-orchestration, Plan 3 Phase 3):
|
|
2
2
|
// common-dir path resolution, the fail-closed reader, the race-free no-follow file read, and the
|
|
3
3
|
// canonical owning-worktree identity. This module OWNS no write API: the append surface and the
|
|
4
|
-
// lock/CAS
|
|
5
|
-
//
|
|
4
|
+
// lock/CAS discipline live in flow-append.mjs; flow-store.mjs re-exports the EIGHT store-surface
|
|
5
|
+
// names below (never everything here), and the procedures advisor imports ONLY this module.
|
|
6
6
|
// The no-follow read primitive lives in the fs-read-nofollow.mjs LEAF (re-exported here under the
|
|
7
7
|
// same idiom); the transitive read graph is pinned write-module-free and acyclic by
|
|
8
8
|
// test/read-graph-purity.test.mjs (FLOW-READ-GRAPH-PURITY).
|
|
@@ -17,7 +17,7 @@ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
|
17
17
|
export { lstatNoFollowRead, describeNonRegular, readRegularFileNoFollow, readFileBytesNoFollow } from './fs-read-nofollow.mjs';
|
|
18
18
|
|
|
19
19
|
export const FLOW_STORE_STOP = 'FLOW_STORE_STOP';
|
|
20
|
-
// The ONE typed-STOP factory for the store's read AND write halves (
|
|
20
|
+
// The ONE typed-STOP factory for the store's read AND write halves (the write leaves import it).
|
|
21
21
|
export const flowStoreStop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'FlowStoreStop', code: FLOW_STORE_STOP });
|
|
22
22
|
|
|
23
23
|
export const FLOW_STORE_BASENAME = 'agent-workflow-flow.jsonl';
|