@sabaiway/agent-workflow-kit 10.6.0 → 11.1.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.
- package/CHANGELOG.md +133 -0
- package/README.md +7 -6
- package/SKILL.md +9 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -6
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/launchers/windsurf-workflow.md +2 -2
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +5 -5
- package/references/modes/commit-guard.md +2 -2
- package/references/modes/control-bytes.md +91 -0
- package/references/modes/flow-writer.md +16 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +1 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/robustness-brief.md +61 -0
- package/references/modes/velocity.md +2 -0
- package/references/robustness-literals.json +107 -0
- package/references/scripts/archiver-structure.test.mjs +48 -3
- package/references/scripts/install-git-hooks.test.mjs +31 -1
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/agent_rules.md +1 -1
- package/references/templates/changelog.md +1 -1
- package/references/templates/decisions.md +2 -2
- package/tools/changed-surface.mjs +1 -1
- package/tools/commands.mjs +14 -0
- package/tools/commit-guard.mjs +20 -9
- package/tools/control-bytes.mjs +264 -0
- package/tools/core-evidence.mjs +33 -20
- package/tools/coverage-check.mjs +1 -1
- package/tools/dispatch-store-read.mjs +2 -2
- package/tools/flow-check-git-lane.mjs +2 -1
- package/tools/flow-delta-proof.mjs +4 -4
- package/tools/flow-record-shape.mjs +44 -2
- package/tools/flow-round-gates.mjs +151 -0
- package/tools/flow-store-read.mjs +1 -1
- package/tools/flow-writer.mjs +235 -82
- package/tools/fold-scope.mjs +7 -3
- package/tools/fs-read-nofollow.mjs +56 -4
- package/tools/git-env.mjs +120 -0
- package/tools/grounding.mjs +4 -3
- package/tools/plan-shape-facts.mjs +23 -10
- package/tools/plan-shape.mjs +8 -0
- package/tools/procedures.mjs +18 -4
- package/tools/recommendations.mjs +53 -3
- package/tools/repo-lex.mjs +29 -0
- package/tools/review-state.mjs +43 -34
- package/tools/robustness-brief.mjs +209 -0
- package/tools/robustness-literals.mjs +101 -0
- package/tools/sandbox-masks.mjs +2 -1
- package/tools/set-flow.mjs +2 -1
- package/tools/source-size-scope.mjs +19 -25
- package/tools/tracked-tree-census.mjs +3 -3
- package/tools/velocity-profile.mjs +5 -2
- package/tools/worktree-handoff-return.mjs +2 -2
- package/tools/worktrees.mjs +2 -2
package/tools/commit-guard.mjs
CHANGED
|
@@ -54,11 +54,12 @@ import {
|
|
|
54
54
|
import { resolveLcovPath } from './coverage-check.mjs';
|
|
55
55
|
import { GATES_REL, loadDeclaration } from './run-gates.mjs';
|
|
56
56
|
import { computeFlowDecision } from './flow-check.mjs';
|
|
57
|
+
import { GIT_MAX_BUFFER, resolveGitLocation } from './git-env.mjs';
|
|
58
|
+
import { escapeForDisplay } from './repo-lex.mjs';
|
|
57
59
|
|
|
58
60
|
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
59
61
|
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
60
62
|
|
|
61
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
62
63
|
const gitLine = (args, cwd) => {
|
|
63
64
|
const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
64
65
|
if (r.error || r.status !== 0) return null;
|
|
@@ -201,28 +202,38 @@ const flowAdvisoryLines = (flow) => (flow.present && flow.armed
|
|
|
201
202
|
: []);
|
|
202
203
|
|
|
203
204
|
export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
205
|
+
// FIRST of all: WHICH repository. The location is a closed six-state table judged by realpath
|
|
206
|
+
// agreement (the git-env leaf); every state but work-tree refuses by its own name — a redirecting
|
|
207
|
+
// GIT_DIR, a killed or absent git, a bare repository — instead of guarding another tree or
|
|
208
|
+
// passing a tree git could not describe. A pre-commit hook's own env agrees and passes.
|
|
209
|
+
const location = resolveGitLocation(cwd, { env });
|
|
210
|
+
if (location.state !== 'work-tree') {
|
|
211
|
+
// The cause can carry a repository PATH (a newline-bearing one included) — escaped, ONE line.
|
|
212
|
+
return { code: 1, lines: [`commit-guard: REFUSED — the git location is ${location.state} (${escapeForDisplay(location.cause)}) — nothing is guarded until git names one work tree from ${escapeForDisplay(cwd)}`] };
|
|
213
|
+
}
|
|
214
|
+
const rootTop = location.top;
|
|
215
|
+
// NEXT: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
|
|
207
216
|
// the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
|
|
208
217
|
// would send the operator down a recovery they must redo.
|
|
209
|
-
const working = computeWorkingState(cwd);
|
|
218
|
+
const working = computeWorkingState(cwd, { env });
|
|
219
|
+
if (working === null) return { code: 1, lines: ['commit-guard: REFUSED — the working state is undecidable (a git query failed or was killed) — nothing is guarded'] };
|
|
210
220
|
const indexLag = decideIndexLag(working);
|
|
211
221
|
if (indexLag !== null) return indexLag;
|
|
212
|
-
const fingerprint = computeTreeFingerprint(cwd);
|
|
222
|
+
const fingerprint = computeTreeFingerprint(cwd, { env });
|
|
223
|
+
if (fingerprint === null) return { code: 1, lines: ['commit-guard: REFUSED — the tree fingerprint is undecidable (a git query failed or was killed) — nothing is guarded'] };
|
|
213
224
|
// The CONTENT-FREE lanes — the second pure tree property, decided here for the same reason the
|
|
214
225
|
// index lag is: no store read can answer it. A payload with no bytes states nothing about what
|
|
215
226
|
// this commit will carry, and its fingerprint is the ONE value every clean moment of every
|
|
216
227
|
// repository shares, so a receipt found at it was minted by some other moment, possibly at
|
|
217
228
|
// another base. Such evidence must therefore decide NOTHING here — neither refuse nor attest
|
|
218
229
|
// (the same fact flow-check-rungs.mjs applies to a red final). The index tells the two lanes
|
|
219
|
-
// apart
|
|
220
|
-
//
|
|
230
|
+
// apart. The payload runs --no-textconv and --ignore-submodules=none, so a config-hidden gitlink
|
|
231
|
+
// rides it; the arm stays because a content-free payload over a dirty index is undecidable by any receipt.
|
|
221
232
|
const contentFree = fingerprint === CONTENT_FREE_FINGERPRINT;
|
|
222
233
|
if (contentFree && working.stagedDirty) {
|
|
223
234
|
return {
|
|
224
235
|
code: 1,
|
|
225
|
-
lines: [`commit-guard: REFUSED — the index carries staged content the
|
|
236
|
+
lines: [`commit-guard: REFUSED — the index carries staged content the payload cannot see, so no final receipt can describe what this commit will carry. Recovery: make the staged change visible to \`git diff --cached\`, then re-run node ${FINAL_RUN_TOOL} --final`],
|
|
226
237
|
};
|
|
227
238
|
}
|
|
228
239
|
// The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// control-bytes.mjs — the read-only control-byte gate over the WORK TREE (the contract:
|
|
3
|
+
// docs/ai/specs/kit/control-bytes.md; the mode: references/modes/control-bytes.md). Measured
|
|
4
|
+
// 2026-08-30: an editor wrote a raw NUL into a test source, git classed the file as binary, the
|
|
5
|
+
// letter-level checker skipped the blob by kind, and the byte reached a reviewer one round later.
|
|
6
|
+
// This tool makes that byte a mechanical refusal: it judges BYTES, never letters, over tracked plus
|
|
7
|
+
// untracked-not-ignored paths — the name always, a regular file's content unless a git attribute
|
|
8
|
+
// says binary, a symlink's target string without following it — and refuses, never skips, on
|
|
9
|
+
// anything it cannot judge. main(argv, deps) returns { code, lines } and never exits; every spawn,
|
|
10
|
+
// fs and env read is injectable; the CLI half prints and sets the exit code. Read-only: it writes
|
|
11
|
+
// nothing and spawns only the git argv declared below (plus the location leaf's rev-parse probes).
|
|
12
|
+
|
|
13
|
+
import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
16
|
+
import { probeRegularFileNoFollow, readFileBytesNoFollowCapped } from './fs-read-nofollow.mjs';
|
|
17
|
+
import { GIT_MAX_BUFFER, parseIndexEntries, resolveGitLocation, withGitPath } from './git-env.mjs';
|
|
18
|
+
import { isRefusedControlByte, renderPathForDisplay } from './repo-lex.mjs';
|
|
19
|
+
|
|
20
|
+
export const READ_CAP = 16 * 1024 * 1024;
|
|
21
|
+
// The exact read-only git argv this tool spawns — pinned by the kit read-only tier registry.
|
|
22
|
+
export const CONTROL_BYTES_GIT_ARGV = Object.freeze([
|
|
23
|
+
Object.freeze(['ls-files', '-s', '-z']),
|
|
24
|
+
Object.freeze(['ls-files', '--others', '--exclude-standard', '-z']),
|
|
25
|
+
Object.freeze(['check-attr', '--stdin', '-z', 'binary', 'diff']),
|
|
26
|
+
Object.freeze(['config', '--type=bool', '--get', 'core.symlinks']),
|
|
27
|
+
Object.freeze(['ls-files', '-v', '-z']),
|
|
28
|
+
]);
|
|
29
|
+
const [TRACKED_ARGV, UNTRACKED_ARGV, ATTRIBUTES_ARGV, SYMLINKS_ARGV, SPARSE_ARGV] = CONTROL_BYTES_GIT_ARGV;
|
|
30
|
+
export const defaultIo = Object.freeze({ open: openSync, fstat: fstatSync, read: readSync, close: closeSync, constants: fsConstants, lstat: lstatSync, readlink: readlinkSync, realpath: realpathSync, stat: statSync });
|
|
31
|
+
|
|
32
|
+
const USAGE = `usage: node control-bytes.mjs [--check] [--cwd <dir>]
|
|
33
|
+
Refuses any raw C0 or DEL byte (TAB, LF, CR admitted) in the work tree: a path's name, a regular
|
|
34
|
+
file's content (unless its git attribute says binary or -diff) or a symlink's target string.
|
|
35
|
+
--cwd <dir> anchor at that directory's work-tree root (default: the process cwd)
|
|
36
|
+
--check the gate spelling; the plain run prints the same report and shares the exit table
|
|
37
|
+
exit 0 clean · 1 a finding or a refusal · 2 usage`;
|
|
38
|
+
|
|
39
|
+
const NUL = Buffer.from([0]);
|
|
40
|
+
const render = renderPathForDisplay;
|
|
41
|
+
// Every DYNAMIC diagnostic (a location cause, git stderr, an fs error code, a user argument) rides
|
|
42
|
+
// the same renderer as paths before it reaches a line — SAFE (never a raw byte or U+FFFD), while
|
|
43
|
+
// injectivity is promised for NAMES only: a string-originated diagnostic folds invalid bytes first.
|
|
44
|
+
const safe = (text) => render(Buffer.isBuffer(text) ? text : Buffer.from(String(text), 'utf8'));
|
|
45
|
+
const refusalLine = (message) => `control-bytes: REFUSED — ${message}`;
|
|
46
|
+
const absent = (path, tracked) => `${render(path)}: absent (${tracked ? 'tracked but missing from the work tree — stage the deletion with git rm or git add -A, or it' : 'git named it, it'} vanished before the read)`;
|
|
47
|
+
const unreadable = (path, why) => `${render(path)}: unreadable (${safe(why)})`;
|
|
48
|
+
const hexByte = (byte) => `0x${byte.toString(16).padStart(2, '0')}`;
|
|
49
|
+
const errorCode = (err) => err?.code ?? err?.message ?? String(err);
|
|
50
|
+
|
|
51
|
+
const parseArgs = (argv, io) => {
|
|
52
|
+
const opts = { check: false, cwd: null };
|
|
53
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
54
|
+
const arg = argv[i];
|
|
55
|
+
if (arg === '--check') {
|
|
56
|
+
opts.check = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--cwd') {
|
|
60
|
+
const value = argv[i + 1];
|
|
61
|
+
if (value === undefined || value.startsWith('--')) return { error: '--cwd needs a directory' };
|
|
62
|
+
try {
|
|
63
|
+
if (!io.stat(value).isDirectory()) return { error: `--cwd ${safe(value)} is not a directory` };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { error: `--cwd ${safe(value)}: ${safe(errorCode(err))}` };
|
|
66
|
+
}
|
|
67
|
+
opts.cwd = value;
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
return { error: `unknown argument: ${safe(arg)}` };
|
|
72
|
+
}
|
|
73
|
+
return opts;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// One git query → { stdout } or { failure } — a synchronous throw, ENOENT, a signal and an exit
|
|
77
|
+
// outside `ok` are four named failures, never a silent empty answer.
|
|
78
|
+
const gitQuery = (run, args, { cwd, env, input }, ok = [0]) => {
|
|
79
|
+
let r;
|
|
80
|
+
try {
|
|
81
|
+
r = run('git', args, { cwd, env: withGitPath(env), input, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { failure: `git ${args.join(' ')} threw synchronously (${safe(err?.message ?? err)})` };
|
|
84
|
+
}
|
|
85
|
+
if (r.error) return { failure: `git ${args.join(' ')} could not run (${safe(errorCode(r.error))})${r.error.code === 'ENOENT' ? ' — git is not on PATH' : ''}` };
|
|
86
|
+
if (r.signal) return { failure: `git ${args.join(' ')} was killed by ${safe(r.signal)}` };
|
|
87
|
+
const stderr = String(r.stderr ?? '').trim();
|
|
88
|
+
if (!ok.includes(r.status)) return { failure: `git ${args.join(' ')} exited ${r.status}${stderr ? ` (${safe(stderr)})` : ''}` };
|
|
89
|
+
return { stdout: Buffer.isBuffer(r.stdout) ? r.stdout : Buffer.from(r.stdout ?? '') };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const splitZ = (buf) => {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (let start = 0; start < buf.length;) {
|
|
95
|
+
let end = buf.indexOf(0, start);
|
|
96
|
+
if (end === -1) end = buf.length;
|
|
97
|
+
if (end > start) out.push(buf.subarray(start, end));
|
|
98
|
+
start = end + 1;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// `check-attr --stdin -z` answers path NUL attribute NUL value NUL; `binary` set or `diff` unset
|
|
104
|
+
// is git's own "binary" — the content skip, keyed by the path's exact bytes.
|
|
105
|
+
const attributeSkips = (stdout) => {
|
|
106
|
+
const skips = new Set();
|
|
107
|
+
const fields = splitZ(stdout);
|
|
108
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
109
|
+
const [path, attribute, value] = [fields[i], fields[i + 1].toString('utf8'), fields[i + 2].toString('utf8')];
|
|
110
|
+
if ((attribute === 'binary' && value === 'set') || (attribute === 'diff' && value === 'unset')) skips.add(path.toString('hex'));
|
|
111
|
+
}
|
|
112
|
+
return skips;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const kindOfMode = (mode) => (mode === '120000' ? 'symlink' : mode === '160000' ? 'gitlink' : mode === '100644' || mode === '100755' ? 'file' : null);
|
|
116
|
+
// An UNTRACKED entry git names with a trailing slash is its nested-repository rule (the `nested/`
|
|
117
|
+
// line): name-only, like a gitlink, and counted as a skip by kind beside the never-committable stat
|
|
118
|
+
// classes. A directory at a slash-less entry is a leaf that changed kind — a refusal.
|
|
119
|
+
const kindOfStat = (st, nested) => (st.isSymbolicLink() ? 'symlink' : st.isFile() ? 'file' : st.isDirectory() ? (nested ? 'nested-repository' : 'directory') : 'never-committable');
|
|
120
|
+
|
|
121
|
+
export const main = (argv, deps = {}) => {
|
|
122
|
+
const io = { ...defaultIo, ...(deps.io ?? {}) };
|
|
123
|
+
const run = deps.spawn ?? spawnSync;
|
|
124
|
+
const env = deps.env ?? process.env;
|
|
125
|
+
const opts = parseArgs(argv, io);
|
|
126
|
+
if (opts.error) return { code: 2, lines: [`control-bytes: usage — ${opts.error}`, USAGE] };
|
|
127
|
+
const cwd = opts.cwd ?? deps.cwd ?? process.cwd();
|
|
128
|
+
const location = resolveGitLocation(cwd, { spawn: run, env, realpath: io.realpath });
|
|
129
|
+
if (location.state !== 'work-tree') return { code: 1, lines: [refusalLine(`the git location is ${location.state} — ${safe(location.cause)}`)] };
|
|
130
|
+
const top = location.top;
|
|
131
|
+
const git = (args, input, ok) => gitQuery(run, args, { cwd: top, env, input }, ok);
|
|
132
|
+
const tracked = git(TRACKED_ARGV);
|
|
133
|
+
if (tracked.failure) return { code: 1, lines: [refusalLine(tracked.failure)] };
|
|
134
|
+
const entries = parseIndexEntries(tracked.stdout);
|
|
135
|
+
const unmerged = entries.filter((e) => e.stage !== 0).length;
|
|
136
|
+
if (unmerged > 0) return { code: 1, lines: [refusalLine(`the git index is UNMERGED (${unmerged} conflict-stage entr(ies)) — resolve the conflict, then re-run`)] };
|
|
137
|
+
const others = git(UNTRACKED_ARGV);
|
|
138
|
+
if (others.failure) return { code: 1, lines: [refusalLine(others.failure)] };
|
|
139
|
+
const domain = [...entries.map((e) => ({ path: e.path, mode: e.mode })), ...splitZ(others.stdout).map((path) => ({ path, mode: null }))]
|
|
140
|
+
.sort((a, b) => Buffer.compare(a.path, b.path));
|
|
141
|
+
if (domain.length === 0) return { code: 1, lines: [refusalLine(`the domain is EMPTY — no tracked and no untracked-not-ignored path under ${render(Buffer.from(top))}`)] };
|
|
142
|
+
const attributes = git(ATTRIBUTES_ARGV, Buffer.concat(domain.flatMap((d) => [d.path, NUL])));
|
|
143
|
+
if (attributes.failure) return { code: 1, lines: [refusalLine(attributes.failure)] };
|
|
144
|
+
const skipByAttribute = attributeSkips(attributes.stdout);
|
|
145
|
+
// core.symlinks: exit 1 = unset = git's default (true); `false` = the host materialises a tracked
|
|
146
|
+
// symlink as a regular file holding the target bytes — refusing it there would refuse forever.
|
|
147
|
+
const symlinks = git(SYMLINKS_ARGV, undefined, [0, 1]);
|
|
148
|
+
if (symlinks.failure) return { code: 1, lines: [refusalLine(symlinks.failure)] };
|
|
149
|
+
const symlinksMaterialised = symlinks.stdout.toString('utf8').trim() === 'false';
|
|
150
|
+
// Sparse checkouts: a tracked path whose skip-worktree bit is set (`ls-files -v`, a tag that
|
|
151
|
+
// uppercases to S — core-evidence's rule) may be ABSENT by design: a counted skip, never a refusal.
|
|
152
|
+
const sparse = git(SPARSE_ARGV);
|
|
153
|
+
if (sparse.failure) return { code: 1, lines: [refusalLine(sparse.failure)] };
|
|
154
|
+
const sparseSkip = new Set();
|
|
155
|
+
for (const record of splitZ(sparse.stdout)) {
|
|
156
|
+
if (record.length > 2 && String.fromCharCode(record[0]).toUpperCase() === 'S') sparseSkip.add(record.subarray(2).toString('hex'));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const findings = [];
|
|
160
|
+
const refusals = [];
|
|
161
|
+
const skippedByAttribute = [];
|
|
162
|
+
let skippedByKind = 0;
|
|
163
|
+
const judge = (bytes, surface, path) => {
|
|
164
|
+
let first = -1;
|
|
165
|
+
let count = 0;
|
|
166
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
167
|
+
if (!isRefusedControlByte(bytes[i])) continue;
|
|
168
|
+
if (first === -1) first = i;
|
|
169
|
+
count += 1;
|
|
170
|
+
}
|
|
171
|
+
if (first !== -1) findings.push(`${render(path)} ${surface} offset ${first}: ${hexByte(bytes[first])}${count > 1 ? ` (+${count - 1} more)` : ''}`);
|
|
172
|
+
};
|
|
173
|
+
// An ABSENT path: a sparse (skip-worktree) tracked one is a counted skip; every other refuses.
|
|
174
|
+
const settleAbsent = (path, tracked) => {
|
|
175
|
+
if (tracked && sparseSkip.has(path.toString('hex'))) skippedByKind += 1;
|
|
176
|
+
else refusals.push(absent(path, tracked));
|
|
177
|
+
};
|
|
178
|
+
// A descriptor read's outcome, settled: ok judges the bytes on `surface`; anything else refuses.
|
|
179
|
+
const settle = (read, path, tracked, surface, named) => {
|
|
180
|
+
if (read.outcome === 'ok') {
|
|
181
|
+
if (read.bytes) judge(read.bytes, surface, path);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (read.outcome === 'over-cap') refusals.push(`${render(path)}: over the read cap (${READ_CAP} bytes) — the gate read cap+1 bytes on the descriptor and stopped; ${surface === 'content' ? 'declare the path binary in .gitattributes or shrink it' : 'restore the link or shorten its target'}`);
|
|
185
|
+
else if (read.outcome === 'absent') settleAbsent(path, tracked);
|
|
186
|
+
else if (read.outcome === 'foreign') refusals.push(unreadable(path, `a ${read.className} where git named a ${named}`));
|
|
187
|
+
else refusals.push(unreadable(path, read.code));
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
190
|
+
for (const { path, mode } of domain) {
|
|
191
|
+
judge(path, 'name', path);
|
|
192
|
+
// Filesystem calls drop git's nested-repository trailing slash: lstat on a slash-terminated
|
|
193
|
+
// name FOLLOWS a symlink standing there (POSIX), which would skip a swapped link unjudged.
|
|
194
|
+
const fsPath = path[path.length - 1] === 0x2f ? path.subarray(0, path.length - 1) : path;
|
|
195
|
+
const full = Buffer.concat([Buffer.from(`${top}/`), fsPath]);
|
|
196
|
+
const tracked = mode !== null;
|
|
197
|
+
let kind = null;
|
|
198
|
+
if (tracked) {
|
|
199
|
+
kind = kindOfMode(mode);
|
|
200
|
+
if (kind === null) {
|
|
201
|
+
refusals.push(unreadable(path, `index mode ${mode} names no readable kind`));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
try {
|
|
206
|
+
kind = kindOfStat(io.lstat(full), path[path.length - 1] === 0x2f);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err?.code === 'ENOENT') settleAbsent(path, tracked);
|
|
209
|
+
else refusals.push(unreadable(path, errorCode(err)));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (kind === 'gitlink') continue;
|
|
214
|
+
if (kind === 'directory') {
|
|
215
|
+
refusals.push(unreadable(path, 'a directory where git named a file'));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (kind === 'never-committable' || kind === 'nested-repository') {
|
|
219
|
+
skippedByKind += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'symlink') {
|
|
223
|
+
let leafIsFile = false;
|
|
224
|
+
if (symlinksMaterialised) {
|
|
225
|
+
try {
|
|
226
|
+
leafIsFile = io.lstat(full).isFile();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err?.code === 'ENOENT') settleAbsent(path, tracked);
|
|
229
|
+
else refusals.push(unreadable(path, errorCode(err)));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (leafIsFile) {
|
|
234
|
+
settle(readFileBytesNoFollowCapped(full, READ_CAP, io), path, tracked, 'symlink-target', 'symlink');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
judge(io.readlink(full, { encoding: 'buffer' }), 'symlink-target', path);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err?.code === 'ENOENT') settleAbsent(path, tracked);
|
|
241
|
+
else refusals.push(unreadable(path, err?.code === 'EINVAL' ? 'not a symlink where git named one' : errorCode(err)));
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// An attribute skip covers the CONTENT only: the leaf still owes the descriptor's kind check.
|
|
246
|
+
const skipped = skipByAttribute.has(path.toString('hex'));
|
|
247
|
+
const read = skipped ? probeRegularFileNoFollow(full, io) : readFileBytesNoFollowCapped(full, READ_CAP, io);
|
|
248
|
+
if (settle(read, path, tracked, 'content', 'file') && skipped) skippedByAttribute.push(path);
|
|
249
|
+
}
|
|
250
|
+
const lines = [
|
|
251
|
+
`control-bytes: work tree ${render(Buffer.from(top))}`,
|
|
252
|
+
`paths judged: ${domain.length} · skipped by attribute: ${skippedByAttribute.length} · skipped by kind: ${skippedByKind} · findings: ${findings.length}`,
|
|
253
|
+
...skippedByAttribute.map((path) => `skipped by attribute: ${render(path)}`),
|
|
254
|
+
...findings,
|
|
255
|
+
...refusals.map(refusalLine),
|
|
256
|
+
];
|
|
257
|
+
return { code: findings.length > 0 || refusals.length > 0 ? 1 : 0, lines };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (isDirectRun(import.meta.url)) {
|
|
261
|
+
const result = main(process.argv.slice(2));
|
|
262
|
+
(result.code === 2 ? process.stderr : process.stdout).write(`${result.lines.join('\n')}\n`);
|
|
263
|
+
process.exitCode = result.code;
|
|
264
|
+
}
|
package/tools/core-evidence.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// re-derived): safe repo-relative path resolution, no-follow real-path containment, shell-free
|
|
25
25
|
// argv, per-run timeout, N/N reruns, the quarantine lane (reruns 3, timeout 120s).
|
|
26
26
|
// Import posture: this module is the DAG BOTTOM — it imports only node built-ins + atomic-write +
|
|
27
|
-
// changed-surface + the pure leaves (repo-lex, fs-read-nofollow, coverage-state), and OWNS the
|
|
27
|
+
// changed-surface + the pure leaves (repo-lex, fs-read-nofollow, coverage-state, git-env), and OWNS the
|
|
28
28
|
// canonical review-domain primitives (tree fingerprint, receipt read
|
|
29
29
|
// path, attesting predicate, verdict vocabulary, testId format, base resolution). review-state
|
|
30
30
|
// RE-EXPORTS its historical public API from here, so its consumers (and the bash-twin parity
|
|
@@ -43,6 +43,7 @@ import { parsePositiveIntKnob, probeVerdict } from './changed-surface.mjs';
|
|
|
43
43
|
import { isDirectRun } from './direct-run.mjs';
|
|
44
44
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
45
45
|
import { lexicalRepoRelative } from './repo-lex.mjs';
|
|
46
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
|
|
46
47
|
// The coverage vocabulary leaf: run-gates RECORDS the token this validator checks, and run-gates
|
|
47
48
|
// imports THIS module (the sole-writer boundary), so their shared home sits below both.
|
|
48
49
|
import { COVERAGE, FINAL_COVERAGE_STATES } from './coverage-state.mjs';
|
|
@@ -52,7 +53,6 @@ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${messag
|
|
|
52
53
|
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
53
54
|
|
|
54
55
|
const isoNow = () => new Date().toISOString();
|
|
55
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / TAP stream can be large; never truncate
|
|
56
56
|
|
|
57
57
|
const gitRaw = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
58
58
|
|
|
@@ -114,15 +114,32 @@ export const isNeverCommittableStat = (stat) =>
|
|
|
114
114
|
stat != null &&
|
|
115
115
|
(stat.isCharacterDevice() || stat.isBlockDevice() || stat.isFIFO() || stat.isSocket());
|
|
116
116
|
|
|
117
|
+
// The git runner seam the tree computations share — (args, dir, input, env) — and its guard: a
|
|
118
|
+
// runner that THROWS answers as a failed spawn (measured: a raw stack out of a fingerprint).
|
|
119
|
+
const defaultRunGit = (args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
120
|
+
const guardedRunner = (runGit, env) => (args, dir, input, override) => {
|
|
121
|
+
try { return runGit(args, dir, input, override ?? env); } catch (err) { return { error: err, status: null, signal: null, stdout: Buffer.alloc(0), stderr: '' }; }
|
|
122
|
+
};
|
|
123
|
+
const stdoutOf = (r) => (r.error || r.status !== 0 ? null : r.stdout);
|
|
124
|
+
// Only a work-tree location is computed over (git-env's table under the runner's own env) — a redirecting GIT_DIR never hashes another repository in silence.
|
|
125
|
+
const locateWorkTree = (cwd, run, env) => {
|
|
126
|
+
const location = resolveGitLocation(cwd, { spawn: (cmd, args, opts) => run(args, opts.cwd, undefined, opts.env), env });
|
|
127
|
+
return location.state === 'work-tree' ? location.top : null;
|
|
128
|
+
};
|
|
129
|
+
|
|
117
130
|
// The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
|
|
118
|
-
// byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null
|
|
119
|
-
//
|
|
120
|
-
export const
|
|
121
|
-
|
|
131
|
+
// byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null on
|
|
132
|
+
// every location but a work tree. lstat is injectable ONLY for the never-committable filter tests.
|
|
133
|
+
export const FINGERPRINT_CACHED_DIFF_ARGV = Object.freeze(['diff', '--cached', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
|
|
134
|
+
export const FINGERPRINT_UNSTAGED_DIFF_ARGV = Object.freeze(['diff', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none']);
|
|
135
|
+
export const computeFingerprintPayload = (cwd, { lstat = lstatSync, runGit = null, env = process.env } = {}) => {
|
|
136
|
+
const run = guardedRunner(runGit ?? defaultRunGit, env);
|
|
137
|
+
const top = locateWorkTree(cwd, run, env);
|
|
122
138
|
if (top == null) return null;
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
139
|
+
const buf = (args) => stdoutOf(run(args, top));
|
|
140
|
+
const staged = buf(FINGERPRINT_CACHED_DIFF_ARGV);
|
|
141
|
+
const unstaged = buf(FINGERPRINT_UNSTAGED_DIFF_ARGV);
|
|
142
|
+
const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
126
143
|
if (staged == null || unstaged == null || untrackedZ == null) return null;
|
|
127
144
|
const chunks = [staged, unstaged];
|
|
128
145
|
for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
|
|
@@ -348,21 +365,17 @@ const flaggedIndexLag = (top, runGit, lstat, readlink) => {
|
|
|
348
365
|
return { paths: lagging, submodules: laggingSubmodules, flagged: laggingFlags };
|
|
349
366
|
};
|
|
350
367
|
|
|
351
|
-
export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null } = {}) => {
|
|
352
|
-
const run = runGit ??
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
const top = topRun.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
368
|
+
export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null, env = process.env } = {}) => {
|
|
369
|
+
const run = guardedRunner(runGit ?? defaultRunGit, env);
|
|
370
|
+
const top = locateWorkTree(cwd, run, env);
|
|
371
|
+
if (top == null) return null;
|
|
356
372
|
// `--ignore-submodules=none` on the staged probe too: a config-hidden STAGED gitlink would
|
|
357
373
|
// otherwise make stagedDirty false, and isTreeClean would call such a tree clean.
|
|
358
374
|
const staged = run(['diff', '--cached', '--quiet', '--ignore-submodules=none'], top);
|
|
359
375
|
// ONLY 0 or 1 is a usable answer: a signal-killed probe reports status null, which the old
|
|
360
376
|
// `> 1` guard let through as "nothing staged" — a fail-OPEN the whole arm cannot afford.
|
|
361
377
|
if (staged.error || (staged.status !== 0 && staged.status !== 1)) return null;
|
|
362
|
-
const buf = (args) =>
|
|
363
|
-
const r = run(args, top);
|
|
364
|
-
return r.error || r.status !== 0 ? null : r.stdout;
|
|
365
|
-
};
|
|
378
|
+
const buf = (args) => stdoutOf(run(args, top));
|
|
366
379
|
// The FULL probe forces submodules back in: diff.ignoreSubmodules / submodule.<n>.ignore would
|
|
367
380
|
// otherwise erase a dirty submodule from the comparison entirely.
|
|
368
381
|
const changedZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=none']);
|
|
@@ -370,7 +383,7 @@ export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlin
|
|
|
370
383
|
const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
371
384
|
if (changedZ == null || plainZ == null || untrackedZ == null) return null;
|
|
372
385
|
const split = (b) => b.toString('utf8').split('\0').filter(Boolean);
|
|
373
|
-
const changed = split(changedZ);
|
|
386
|
+
const changed = [...new Set(split(changedZ))]; // an unmerged path is listed once per conflict side
|
|
374
387
|
const plain = new Set(split(plainZ));
|
|
375
388
|
const flagged = flaggedIndexLag(top, run, lstat, readlink);
|
|
376
389
|
if (flagged == null) return null;
|
|
@@ -950,6 +963,7 @@ export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } =
|
|
|
950
963
|
// captured BEFORE the runs and re-checked after them; any drift refuses below.
|
|
951
964
|
const base = resolveBase(cwd);
|
|
952
965
|
const fingerprint = computeTreeFingerprint(cwd);
|
|
966
|
+
if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the git location is not a work tree; nothing was observed, no runs were spent');
|
|
953
967
|
const { entry, file, resolveReason } = probeBound({ testId, rootTop, env, reruns, timeoutS });
|
|
954
968
|
const verdict = probeVerdict(entry);
|
|
955
969
|
const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
|
|
@@ -974,7 +988,6 @@ export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } =
|
|
|
974
988
|
`${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
|
|
975
989
|
);
|
|
976
990
|
}
|
|
977
|
-
if (fingerprint == null) throw stop('cannot compute the tree fingerprint — not a git work tree');
|
|
978
991
|
// Post-run drift recheck: fingerprint, base, and the test-file bytes must all equal the
|
|
979
992
|
// pre-run capture — a mutating test / parallel edit / commit would otherwise mint a record
|
|
980
993
|
// whose fields attest different trees.
|
package/tools/coverage-check.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
27
28
|
import { computeChangedSurface } from './changed-surface.mjs';
|
|
28
29
|
import { isDirectRun } from './direct-run.mjs';
|
|
29
30
|
import { lcovCoveredMap, uncoveredChangedFromLcov } from './lcov.mjs';
|
|
@@ -47,7 +48,6 @@ const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${m
|
|
|
47
48
|
|
|
48
49
|
export const LCOV_BASENAME = 'agent-workflow-lcov.info';
|
|
49
50
|
|
|
50
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
51
51
|
const gitLine = (args, cwd) => {
|
|
52
52
|
const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
53
53
|
if (r.error || r.status !== 0) return null;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { join, isAbsolute, normalize, sep } from 'node:path';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
3
4
|
import {
|
|
4
5
|
validateDelegationRecord, canonicalDelegationDigest, allowedSuccessorKinds,
|
|
5
6
|
isThreadTerminalRecord,
|
|
6
7
|
} from './dispatch-record.mjs';
|
|
7
8
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
9
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
8
10
|
|
|
9
11
|
export const DELEGATION_STORE_STOP = 'DELEGATION_STORE_STOP';
|
|
10
12
|
export const delegationStoreStop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'DelegationStoreStop', code: DELEGATION_STORE_STOP });
|
|
11
13
|
const stop = delegationStoreStop;
|
|
12
14
|
|
|
13
15
|
export const DELEGATION_STORE_BASENAME = 'agent-workflow-delegation.jsonl';
|
|
14
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
15
|
-
|
|
16
16
|
const runGit = (args, cwd, spawn = spawnSync) => spawn('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
17
17
|
const readGitLine = (args, cwd, spawn) => {
|
|
18
18
|
const result = runGit(args, cwd, spawn);
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
// pure decision cores can never reach a git spawn through it.
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
7
8
|
|
|
8
9
|
const short = (digest) => `${digest.slice(0, 12)}…`;
|
|
9
10
|
|
|
10
11
|
// computeChangedSurface exists for COVERAGE and excludes test files by design — the base-
|
|
11
12
|
// intersection inputs come from these helpers instead: every changed path counts, tests included.
|
|
12
13
|
const gitPathList = (args, cwd) => {
|
|
13
|
-
const r = spawnSync('git', args, { cwd, maxBuffer:
|
|
14
|
+
const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
14
15
|
if (r.error || r.status !== 0) return null;
|
|
15
16
|
return r.stdout.toString('utf8').split('\0').filter(Boolean);
|
|
16
17
|
};
|
|
@@ -13,15 +13,15 @@ import { createHash } from 'node:crypto';
|
|
|
13
13
|
import { readFileSync, lstatSync, readlinkSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
16
17
|
import { lstatNoFollow } from './atomic-write.mjs';
|
|
17
18
|
import { FLOW_SCHEMA_VERSION, canonicalFlowDigest } from './flow-record.mjs';
|
|
18
|
-
import { isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase } from './core-evidence.mjs';
|
|
19
|
+
import { FINGERPRINT_CACHED_DIFF_ARGV, FINGERPRINT_UNSTAGED_DIFF_ARGV, isNeverCommittableStat, isBinaryFile, lexicalRepoRelative, resolveBase } from './core-evidence.mjs';
|
|
19
20
|
import { flowStoreStop, gitLine, describeNonRegular } from './flow-store-read.mjs';
|
|
20
21
|
import { appendFlowRecord } from './flow-append.mjs';
|
|
21
22
|
|
|
22
23
|
const stop = flowStoreStop;
|
|
23
24
|
|
|
24
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
25
25
|
const gitBuf = (args, cwd) => {
|
|
26
26
|
const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
27
27
|
if (r.error || r.status !== 0) return null;
|
|
@@ -166,8 +166,8 @@ const untrackedEntryChunks = (top, rel, lstat) => {
|
|
|
166
166
|
const captureFingerprintPieces = (cwd, { lstat = lstatSync } = {}) => {
|
|
167
167
|
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
168
168
|
if (top == null) return null;
|
|
169
|
-
const staged = gitBuf(
|
|
170
|
-
const unstaged = gitBuf(
|
|
169
|
+
const staged = gitBuf(FINGERPRINT_CACHED_DIFF_ARGV, top);
|
|
170
|
+
const unstaged = gitBuf(FINGERPRINT_UNSTAGED_DIFF_ARGV, top);
|
|
171
171
|
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
172
172
|
if (staged == null || unstaged == null || untrackedZ == null) return null;
|
|
173
173
|
const entries = untrackedZ.toString('utf8').split('\0').filter(Boolean)
|
|
@@ -79,6 +79,7 @@ const FIELD_CHECKS = {
|
|
|
79
79
|
attemptIndex: { ok: (v) => Number.isInteger(v) && v >= 1, want: 'a positive integer attempt index (monotonic per counting context)' },
|
|
80
80
|
status: { ok: (v) => v === 'green' || v === 'red', want: 'the closed enum green | red' },
|
|
81
81
|
diagnosis: { ok: isNonEmptyString, want: 'a non-empty diagnosis statement (Decision 8 — the recorded continuation past two reds)' },
|
|
82
|
+
walk: { ok: isPlainObject, want: 'the closed walk object' },
|
|
82
83
|
};
|
|
83
84
|
|
|
84
85
|
const CHAIN_COMMON_FIELDS = ['planId', 'cycle', 'round', 'commitEpoch', 'owner', 'base', 'timestamp'];
|
|
@@ -171,33 +172,54 @@ const validateDispatches = (label, list) => {
|
|
|
171
172
|
// The per-finding disposition ledger (#13/#33): every council finding lands as exactly one of the
|
|
172
173
|
// three closed arms, each carrying its proof (a consult-attestation/red-proof digest, a debt entry,
|
|
173
174
|
// or a stated rejection reason).
|
|
175
|
+
// The new arms are escalated with its override, and custody-lost keyed on receiptDigest with no findingDigest.
|
|
174
176
|
const DISPOSITION_KEYS = {
|
|
175
177
|
folded: ['findingDigest', 'action', 'proofKind', 'proofDigest'],
|
|
176
178
|
queued: ['findingDigest', 'action', 'debtId', 'debtDigest'],
|
|
177
179
|
rejected: ['findingDigest', 'action', 'reason'],
|
|
180
|
+
escalated: ['findingDigest', 'action', 'overrideDigest'],
|
|
181
|
+
'custody-lost': ['action', 'receiptDigest', 'findingManifestDigest', 'reason'],
|
|
178
182
|
};
|
|
183
|
+
const PROOF_KINDS = ['consult-attestation', 'red-proof'];
|
|
184
|
+
const QUEUED_PROOF_KEYS = ['claim', 'proofKind', 'proofDigest'];
|
|
185
|
+
const LOSS_REASONS = ['absent', 'foreign', 'error', 'malformed', 'foreign-identity', 'swapped'];
|
|
179
186
|
|
|
180
187
|
const validateDispositions = (label, list) => {
|
|
181
188
|
const seenFindings = new Set();
|
|
189
|
+
const seenLostReceipts = new Set();
|
|
182
190
|
for (let i = 0; i < list.length; i += 1) {
|
|
183
191
|
const d = list[i];
|
|
184
192
|
const at = `${label}: dispositions[${i}]`;
|
|
185
193
|
if (!isPlainObject(d)) return refuse(`${at} must be an object`);
|
|
186
|
-
const
|
|
194
|
+
const baseKeys = Object.hasOwn(DISPOSITION_KEYS, d.action) ? DISPOSITION_KEYS[d.action] : undefined;
|
|
195
|
+
const armKeys = d.action === 'queued' && QUEUED_PROOF_KEYS.some((key) => key in d) ? [...baseKeys, ...QUEUED_PROOF_KEYS] : baseKeys;
|
|
187
196
|
if (armKeys === undefined) return refuse(`${at}: action must be one of ${Object.keys(DISPOSITION_KEYS).join(' | ')} (got ${JSON.stringify(d.action)}) — an inherited prototype key never resolves an arm (fail closed)`);
|
|
188
197
|
const stray = Object.keys(d).find((k) => !armKeys.includes(k));
|
|
189
198
|
if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the ${d.action} arm's key set is closed`);
|
|
190
199
|
const missing = armKeys.find((k) => !(k in d));
|
|
191
200
|
if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
|
|
201
|
+
if (d.action === 'custody-lost') {
|
|
202
|
+
if (!isHex64(d.receiptDigest)) return refuse(`${at}: receiptDigest must be the 64-hex digest of the lost receipt`);
|
|
203
|
+
if (!isHex64(d.findingManifestDigest)) return refuse(`${at}: findingManifestDigest must be the 64-hex ledger digest of the lost manifest`);
|
|
204
|
+
if (!LOSS_REASONS.includes(d.reason)) return refuse(`${at}: reason must be one of ${LOSS_REASONS.join(' | ')}`);
|
|
205
|
+
if (seenLostReceipts.has(d.receiptDigest)) return refuse(`${at}: duplicate receiptDigest — one custody-lost entry per lost receipt`);
|
|
206
|
+
seenLostReceipts.add(d.receiptDigest);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
192
209
|
if (!isHex64(d.findingDigest)) return refuse(`${at}: findingDigest must be the 64-hex digest of the finding`);
|
|
193
210
|
if (seenFindings.has(d.findingDigest)) return refuse(`${at}: duplicate findingDigest — every finding gets exactly one disposition, whichever the arm`);
|
|
194
211
|
seenFindings.add(d.findingDigest);
|
|
195
212
|
if (d.action === 'folded') {
|
|
196
|
-
if (
|
|
213
|
+
if (!PROOF_KINDS.includes(d.proofKind)) return refuse(`${at}: proofKind must be consult-attestation | red-proof (the fold's proof record class)`);
|
|
197
214
|
if (!isHex64(d.proofDigest)) return refuse(`${at}: proofDigest must be the 64-hex digest of the proof record`);
|
|
198
215
|
} else if (d.action === 'queued') {
|
|
199
216
|
if (!isNonEmptyString(d.debtId)) return refuse(`${at}: debtId must be the non-empty stable debt-queue id`);
|
|
200
217
|
if (!isHex64(d.debtDigest)) return refuse(`${at}: debtDigest must be the 64-hex digest of the debt entry`);
|
|
218
|
+
if ('claim' in d && !isNonEmptyString(d.claim)) return refuse(`${at}: claim must be a non-empty invariant`);
|
|
219
|
+
if ('proofKind' in d && !PROOF_KINDS.includes(d.proofKind)) return refuse(`${at}: proofKind must be consult-attestation | red-proof`);
|
|
220
|
+
if ('proofDigest' in d && !isHex64(d.proofDigest)) return refuse(`${at}: proofDigest must be the 64-hex digest of the proof record`);
|
|
221
|
+
} else if (d.action === 'escalated') {
|
|
222
|
+
if (!isHex64(d.overrideDigest)) return refuse(`${at}: overrideDigest must be the 64-hex digest of the maintainer override`);
|
|
201
223
|
} else if (!isNonEmptyString(d.reason)) {
|
|
202
224
|
return refuse(`${at}: reason must be a non-empty statement of why the finding is rejected`);
|
|
203
225
|
}
|
|
@@ -205,6 +227,24 @@ const validateDispositions = (label, list) => {
|
|
|
205
227
|
return { ok: true };
|
|
206
228
|
};
|
|
207
229
|
|
|
230
|
+
const validateWalk = (walk) => {
|
|
231
|
+
const keys = ['listVersion', 'rows', 'uncovered'];
|
|
232
|
+
if (!isPlainObject(walk)) return refuse('internal-attestation: walk must be an object');
|
|
233
|
+
const stray = Object.keys(walk).find((key) => !keys.includes(key));
|
|
234
|
+
if (stray !== undefined) return refuse(`internal-attestation: walk has unknown field "${stray}"`);
|
|
235
|
+
const missing = keys.find((key) => !(key in walk));
|
|
236
|
+
if (missing !== undefined) return refuse(`internal-attestation: walk is missing field "${missing}"`);
|
|
237
|
+
if (!Number.isInteger(walk.listVersion) || walk.listVersion < 1) return refuse('internal-attestation: walk.listVersion must be a positive integer');
|
|
238
|
+
for (const name of ['rows', 'uncovered']) {
|
|
239
|
+
if (!Array.isArray(walk[name])) return refuse(`internal-attestation: walk.${name} must be an array`);
|
|
240
|
+
const itemKeys = name === 'rows' ? ['id', 'class', 'checked'] : ['id', 'class'];
|
|
241
|
+
for (const [i, item] of walk[name].entries()) {
|
|
242
|
+
if (!isPlainObject(item) || Object.keys(item).length !== itemKeys.length || !itemKeys.every((key) => key in item && isNonEmptyString(item[key]))) return refuse(`internal-attestation: walk.${name}[${i}] must be the closed {${itemKeys.join(', ')}} string object`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { ok: true };
|
|
246
|
+
};
|
|
247
|
+
|
|
208
248
|
// The three-layer pre-state digest set (#60): HEAD entry, index entry, worktree bytes — null means
|
|
209
249
|
// "no entry in that layer". tracked-ness is derived (a HEAD or index entry exists); the presence
|
|
210
250
|
// class is the WORKTREE layer. The fingerprint domain (staged + unstaged + untracked) is exactly
|
|
@@ -272,8 +312,10 @@ export const validateFlowRecord = (record) => {
|
|
|
272
312
|
}
|
|
273
313
|
fieldToCheck.diagnosis = 'diagnosis';
|
|
274
314
|
}
|
|
315
|
+
if (record.kind === 'internal-attestation' && 'walk' in record) fieldToCheck.walk = 'walk';
|
|
275
316
|
const checked = checkFields(record.kind, record, fieldToCheck);
|
|
276
317
|
if (!checked.ok) return checked;
|
|
318
|
+
if (record.kind === 'internal-attestation' && 'walk' in record) return validateWalk(record.walk);
|
|
277
319
|
if (record.kind === 'down-mark') {
|
|
278
320
|
if (!isCanonicalInstant(record.timestamp)) return refuse('down-mark: timestamp must be a canonical UTC ISO instant (toISOString round-trip) — the TTL window needs comparable instants');
|
|
279
321
|
if (Date.parse(record.expiresAt) <= Date.parse(record.timestamp)) return refuse('down-mark: expiresAt must be strictly after timestamp — an already-expired mark is refused at the record level');
|