@sabaiway/agent-workflow-kit 13.0.0 → 13.2.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 +188 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +9 -9
- package/references/modes/review-state.md +1 -1
- package/tools/checkpoint-core.mjs +118 -0
- package/tools/checkpoint-restore.mjs +126 -0
- package/tools/checkpoint.mjs +146 -0
- package/tools/dispatch-baseline.mjs +172 -0
- package/tools/dispatch-record.mjs +28 -1
- package/tools/dispatch-store-read.mjs +6 -1
- package/tools/dispatch-store.mjs +4 -3
- package/tools/dispatch.mjs +110 -71
- package/tools/exec-producer.mjs +57 -37
- package/tools/held-session.mjs +22 -9
- package/tools/plan-files.mjs +2 -1
- package/tools/review-state.mjs +10 -6
- package/tools/task-brief.mjs +270 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/index.md
|
|
2
|
+
import { lstatSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { snapshotScope } from './dispatch-baseline.mjs';
|
|
7
|
+
import { isNeverCommittableStat } from './core-evidence.mjs';
|
|
8
|
+
import { isThreadTerminalRecord } from './dispatch-record.mjs';
|
|
9
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
|
|
10
|
+
|
|
11
|
+
export const CHECKPOINT_REF_PREFIX = 'refs/agent-workflow/checkpoints/';
|
|
12
|
+
const ACCEPT = 0;
|
|
13
|
+
const REFUSE = 1;
|
|
14
|
+
const INCREMENT = 1;
|
|
15
|
+
const EMPTY = '';
|
|
16
|
+
const SPACE = ' ';
|
|
17
|
+
const NEWLINE = '\n';
|
|
18
|
+
const UTF8 = 'utf8';
|
|
19
|
+
const TEMP_PREFIX = 'aw-checkpoint-';
|
|
20
|
+
const INDEX_NAME = 'index';
|
|
21
|
+
const LITERAL_PATHSPECS = '1';
|
|
22
|
+
const WORK_TREE = 'work-tree';
|
|
23
|
+
const MISSING_PATH_CODES = ['ENOENT', 'ENOTDIR'];
|
|
24
|
+
const OID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
25
|
+
const NAMES = { scope: 'scope', sequence: 'sequence' };
|
|
26
|
+
const DISPATCH_KIND = 'dispatch';
|
|
27
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
28
|
+
const LOCATION_ERROR = 'checkpoint requires a git work tree';
|
|
29
|
+
const SEQUENCE_ERROR = 'expected contiguous numbered refs on trees';
|
|
30
|
+
const STAT_ERROR = 'cannot classify path';
|
|
31
|
+
const TYPE_ERROR = 'present path is not a regular file, symlink or directory';
|
|
32
|
+
const OID_ERROR = 'git did not return a tree oid';
|
|
33
|
+
const GIT = 'git';
|
|
34
|
+
const GIT_ARGS = {
|
|
35
|
+
refs: ['for-each-ref', '--format=%(refname) %(objectname)'],
|
|
36
|
+
empty: ['read-tree', '--empty'],
|
|
37
|
+
add: ['add', '-f', '--'],
|
|
38
|
+
tree: ['write-tree'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const refuse = (reason, code = REFUSE) => ({ ok: false, code, reason });
|
|
42
|
+
const defaultRunGit = (args, cwd, input, env) => spawnSync(GIT, args, {
|
|
43
|
+
cwd, input, env: withGitPath(env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
export const makeRunner = ({ runGit = defaultRunGit, env = process.env } = {}) => (args, cwd, input, override = env) => {
|
|
46
|
+
try { return runGit(args, cwd, input, override); }
|
|
47
|
+
catch (error) { return { error, status: null, stdout: Buffer.alloc(ACCEPT) }; }
|
|
48
|
+
};
|
|
49
|
+
export const readGitBytes = ({ run, top, env }, args, override = env) => {
|
|
50
|
+
const result = run(args, top, undefined, override);
|
|
51
|
+
if (result == null || result.error || result.signal || result.status !== ACCEPT || result.stdout == null) {
|
|
52
|
+
throw new Error(`${GIT} ${args.join(SPACE)} failed: ${result?.error?.message ?? String(result?.stderr ?? EMPTY).trim()}`);
|
|
53
|
+
}
|
|
54
|
+
return Buffer.from(result.stdout);
|
|
55
|
+
};
|
|
56
|
+
export const readGit = (context, args, override = context.env) => readGitBytes(context, args, override).toString(UTF8).trimEnd();
|
|
57
|
+
export const withRepository = (cwd, io, action) => {
|
|
58
|
+
try {
|
|
59
|
+
const env = io.env ?? process.env;
|
|
60
|
+
const run = makeRunner({ ...io, env });
|
|
61
|
+
const location = resolveGitLocation(cwd, { env,
|
|
62
|
+
spawn: (command, args, options) => run(args, options.cwd, undefined, options.env) });
|
|
63
|
+
if (location.state !== WORK_TREE) return refuse(location.cause ?? LOCATION_ERROR);
|
|
64
|
+
return action({ cwd, top: location.top, env, run, io: { ...io, env, runGit: run } });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return refuse(error.message);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
export const readCheckpointRefs = (context, prefix = CHECKPOINT_REF_PREFIX) => {
|
|
70
|
+
const lines = readGit(context, [...GIT_ARGS.refs, prefix]).split(NEWLINE).filter(Boolean);
|
|
71
|
+
return lines.map((line) => {
|
|
72
|
+
const [ref, oid, extra] = line.split(SPACE);
|
|
73
|
+
if (!ref.startsWith(prefix) || !OID_PATTERN.test(oid) || extra !== undefined) {
|
|
74
|
+
throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
75
|
+
}
|
|
76
|
+
return { ref, oid };
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
export const withTemporaryIndex = (env, action) => {
|
|
80
|
+
const directory = mkdtempSync(join(tmpdir(), TEMP_PREFIX));
|
|
81
|
+
try {
|
|
82
|
+
return action({ ...env, GIT_INDEX_FILE: join(directory, INDEX_NAME), GIT_LITERAL_PATHSPECS: LITERAL_PATHSPECS });
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(directory, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
export const readScopeStat = (context, path) => {
|
|
88
|
+
try {
|
|
89
|
+
const stat = (context.io.lstat ?? lstatSync)(join(context.top, path));
|
|
90
|
+
if (stat == null) throw new Error(STAT_ERROR);
|
|
91
|
+
return stat;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (MISSING_PATH_CODES.includes(error.code)) return null;
|
|
94
|
+
throw new Error(`${NAMES.scope}: ${path}: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
export const writeSnapshot = (context, baseTreeOid) => {
|
|
98
|
+
const scope = snapshotScope(context.top, { baseTreeOid }, context.io);
|
|
99
|
+
if (!scope.ok) return refuse(scope.reason);
|
|
100
|
+
const paths = scope.paths.filter((path) => {
|
|
101
|
+
const stat = readScopeStat(context, path);
|
|
102
|
+
if (stat === null) return false;
|
|
103
|
+
if (isNeverCommittableStat(stat) || !(stat.isFile() || stat.isSymbolicLink() || stat.isDirectory())) {
|
|
104
|
+
throw new Error(`${NAMES.scope}: ${path}: ${TYPE_ERROR}`);
|
|
105
|
+
}
|
|
106
|
+
return stat.isFile() || stat.isSymbolicLink();
|
|
107
|
+
});
|
|
108
|
+
return withTemporaryIndex(context.env, (env) => {
|
|
109
|
+
readGit(context, GIT_ARGS.empty, env);
|
|
110
|
+
for (const path of paths) readGit(context, [...GIT_ARGS.add, path], env);
|
|
111
|
+
const oid = readGit(context, GIT_ARGS.tree, env);
|
|
112
|
+
if (!OID_PATTERN.test(oid)) return refuse(`${NAMES.scope}: ${OID_ERROR}`);
|
|
113
|
+
return { ok: true, oid };
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
export const findOpenCheckpointThread = (records, held) => records.find((record, index) =>
|
|
117
|
+
record.kind === DISPATCH_KIND && record.baseline?.kind === CHECKPOINT_KIND && held.has(record.baseline.treeOid)
|
|
118
|
+
&& !records.slice(index + INCREMENT).some((successor) => successor.nonce === record.nonce && isThreadTerminalRecord(successor)));
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/restore.md
|
|
2
|
+
import { unlinkSync, rmdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { resolveTreeObject, snapshotScope } from './dispatch-baseline.mjs';
|
|
5
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
6
|
+
import { CHECKPOINT_REF_PREFIX, withRepository, readGit, readGitBytes, readCheckpointRefs,
|
|
7
|
+
withTemporaryIndex, readScopeStat, writeSnapshot, findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
8
|
+
|
|
9
|
+
const REFUSE = 1;
|
|
10
|
+
const START = 0;
|
|
11
|
+
const NEXT = 1;
|
|
12
|
+
const LAST = -1;
|
|
13
|
+
const SLASH = '/';
|
|
14
|
+
const NUL = '\0';
|
|
15
|
+
const UTF8 = 'utf8';
|
|
16
|
+
const ROOT = '.';
|
|
17
|
+
const FORBIDDEN_SEGMENTS = ['', '.', '..', '.git'];
|
|
18
|
+
const ENTRY_PATTERN = /^([0-9]{6}) [^ ]+ [0-9a-f]+\t([\s\S]+)$/;
|
|
19
|
+
const SEQUENCE_REF_PATTERN = /^[^/]+\/(?:0|[1-9][0-9]*)$/;
|
|
20
|
+
const ALLOWED_MODES = ['100644', '100755', '120000'];
|
|
21
|
+
const LEDGER_ABSENT = 'absent';
|
|
22
|
+
const LEDGER_OK = 'ok';
|
|
23
|
+
const NONEMPTY_CODES = ['ENOTEMPTY', 'EEXIST'];
|
|
24
|
+
const NAMES = { scope: 'target-scope', ledger: 'ledger', open: 'open-thread', conflict: 'type-conflict' };
|
|
25
|
+
const ENCODING_ERROR = 'target tree paths are not lossless UTF-8';
|
|
26
|
+
const ENTRY_ERROR = 'unreadable target tree entry';
|
|
27
|
+
const UNREFERENCED_ERROR = 'no checkpoint sequence carries target';
|
|
28
|
+
const MISMATCH = 'checkpoint restore mismatch';
|
|
29
|
+
const GIT_ARGS = {
|
|
30
|
+
resolve: ['rev-parse', '--verify'],
|
|
31
|
+
entries: ['ls-tree', '-r', '-z'],
|
|
32
|
+
read: ['read-tree'],
|
|
33
|
+
checkout: ['checkout-index', '-a', '-f'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const refuse = (reason) => ({ ok: false, code: REFUSE, reason });
|
|
37
|
+
const readTargetEntries = (context, target) => {
|
|
38
|
+
const bytes = readGitBytes(context, [...GIT_ARGS.entries, target]);
|
|
39
|
+
const text = bytes.toString(UTF8);
|
|
40
|
+
if (!Buffer.from(text, UTF8).equals(bytes)) throw new Error(`${NAMES.scope}: ${ENCODING_ERROR}`);
|
|
41
|
+
return text.split(NUL).filter(Boolean).map((entry) => {
|
|
42
|
+
const parsed = ENTRY_PATTERN.exec(entry);
|
|
43
|
+
if (parsed === null) throw new Error(`${NAMES.scope}: ${ENTRY_ERROR}`);
|
|
44
|
+
const [, mode, path] = parsed;
|
|
45
|
+
if (!ALLOWED_MODES.includes(mode) || path.split(SLASH).some((segment) => FORBIDDEN_SEGMENTS.includes(segment))) {
|
|
46
|
+
throw new Error(`${NAMES.scope}: ${path}`);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const readHeldSet = (context, target) => {
|
|
52
|
+
const refs = readCheckpointRefs(context).filter(({ ref }) => SEQUENCE_REF_PATTERN.test(ref.slice(CHECKPOINT_REF_PREFIX.length)));
|
|
53
|
+
const sequences = new Set(refs.filter(({ oid }) => oid === target).map(({ ref }) => dirname(ref)));
|
|
54
|
+
if (sequences.size === START) throw new Error(`${NAMES.scope}: ${UNREFERENCED_ERROR}: ${target}`);
|
|
55
|
+
return new Set([target, ...refs.filter(({ ref }) => sequences.has(dirname(ref))).map(({ oid }) => oid)]);
|
|
56
|
+
};
|
|
57
|
+
const checkLedger = (context, held) => {
|
|
58
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
59
|
+
if (ledger.state !== LEDGER_ABSENT && ledger.state !== LEDGER_OK) return refuse(`${NAMES.ledger}: ${ledger.reason}`);
|
|
60
|
+
const open = findOpenCheckpointThread(ledger.records ?? [], held);
|
|
61
|
+
return open === undefined ? { ok: true } : refuse(`${NAMES.open}: ${open.nonce}`);
|
|
62
|
+
};
|
|
63
|
+
const listParents = (path) => {
|
|
64
|
+
const parts = path.split(SLASH).slice(START, LAST);
|
|
65
|
+
return parts.map((part, index) => parts.slice(START, index + NEXT).join(SLASH));
|
|
66
|
+
};
|
|
67
|
+
const checkTypes = (context, paths) => {
|
|
68
|
+
for (const path of paths) {
|
|
69
|
+
for (const parent of listParents(path)) {
|
|
70
|
+
const stat = readScopeStat(context, parent);
|
|
71
|
+
if (stat === null) break;
|
|
72
|
+
if (!stat.isDirectory()) return refuse(`${NAMES.conflict}: ${parent}: ${path}`);
|
|
73
|
+
}
|
|
74
|
+
if (readScopeStat(context, path)?.isDirectory()) return refuse(`${NAMES.conflict}: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
};
|
|
78
|
+
const removeEmptyParents = (context, path) => {
|
|
79
|
+
const parent = dirname(path);
|
|
80
|
+
if (parent === ROOT) return;
|
|
81
|
+
const stat = readScopeStat(context, parent);
|
|
82
|
+
if (stat === null || !stat.isDirectory()) return;
|
|
83
|
+
try {
|
|
84
|
+
rmdirSync(join(context.top, parent));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!NONEMPTY_CODES.includes(error.code)) throw error;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
removeEmptyParents(context, parent);
|
|
90
|
+
};
|
|
91
|
+
const removeExtras = (context, domain, targets) => {
|
|
92
|
+
for (const path of domain) {
|
|
93
|
+
if (targets.has(path) || path.split(SLASH).some((segment) => FORBIDDEN_SEGMENTS.includes(segment))) continue;
|
|
94
|
+
if (!listParents(path).every((parent) => readScopeStat(context, parent)?.isDirectory())) continue;
|
|
95
|
+
const stat = readScopeStat(context, path);
|
|
96
|
+
if (stat === null || !(stat.isFile() || stat.isSymbolicLink())) continue;
|
|
97
|
+
unlinkSync(join(context.top, path));
|
|
98
|
+
removeEmptyParents(context, path);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const restoreCheckpoint = (cwd, oid, io = {}) => withRepository(cwd, io, (context) => {
|
|
103
|
+
const tree = resolveTreeObject(context.top, oid, context.io);
|
|
104
|
+
if (!tree.ok) return refuse(tree.reason);
|
|
105
|
+
const target = readGit(context, [...GIT_ARGS.resolve, oid]);
|
|
106
|
+
const entries = readTargetEntries(context, target);
|
|
107
|
+
const scope = snapshotScope(context.top, { baseTreeOid: target }, context.io);
|
|
108
|
+
if (!scope.ok) return refuse(scope.reason);
|
|
109
|
+
const domain = new Set(scope.paths);
|
|
110
|
+
const outside = entries.find((path) => !domain.has(path));
|
|
111
|
+
if (outside !== undefined) return refuse(`${NAMES.scope}: ${outside}`);
|
|
112
|
+
const held = readHeldSet(context, target);
|
|
113
|
+
const ledger = checkLedger(context, held);
|
|
114
|
+
if (!ledger.ok) return ledger;
|
|
115
|
+
const types = checkTypes(context, entries);
|
|
116
|
+
if (!types.ok) return types;
|
|
117
|
+
withTemporaryIndex(context.env, (env) => {
|
|
118
|
+
readGit(context, [...GIT_ARGS.read, target], env);
|
|
119
|
+
readGit(context, GIT_ARGS.checkout, env);
|
|
120
|
+
});
|
|
121
|
+
removeExtras(context, scope.paths, new Set(entries));
|
|
122
|
+
const snapshot = writeSnapshot(context, target);
|
|
123
|
+
if (!snapshot.ok) return snapshot;
|
|
124
|
+
return snapshot.oid === target ? { ok: true, target, proof: snapshot.oid }
|
|
125
|
+
: refuse(`${MISMATCH}: ${target} ${snapshot.oid}`);
|
|
126
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/index.md
|
|
2
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { resolveTreeObject } from './dispatch-baseline.mjs';
|
|
5
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
6
|
+
import { PLANS_REL, isScratchPlanName } from './plan-files.mjs';
|
|
7
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
8
|
+
import { CHECKPOINT_REF_PREFIX, refuse, withRepository, readGit, readCheckpointRefs, writeSnapshot,
|
|
9
|
+
findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
10
|
+
import { restoreCheckpoint } from './checkpoint-restore.mjs';
|
|
11
|
+
|
|
12
|
+
export { CHECKPOINT_REF_PREFIX } from './checkpoint-core.mjs';
|
|
13
|
+
const ACCEPT = 0;
|
|
14
|
+
const REFUSE = 1;
|
|
15
|
+
const USAGE = 2;
|
|
16
|
+
const FIRST_NUMBER = 0;
|
|
17
|
+
const INCREMENT = 1;
|
|
18
|
+
const LAST = -1;
|
|
19
|
+
const EMPTY = '';
|
|
20
|
+
const NEWLINE = '\n';
|
|
21
|
+
const SLASH = '/';
|
|
22
|
+
const STRING = 'string';
|
|
23
|
+
const MARKDOWN = '.md';
|
|
24
|
+
const NUMBER_PATTERN = /^(0|[1-9][0-9]*)$/;
|
|
25
|
+
const VERBS = { mint: 'mint', newest: 'newest', verify: 'verify', prune: 'prune', restore: 'restore' };
|
|
26
|
+
const NAMES = { stem: 'stem', sequence: 'sequence', ledger: 'ledger', open: 'open-thread', empty: 'no-checkpoint' };
|
|
27
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
28
|
+
const LEDGER_ABSENT = 'absent';
|
|
29
|
+
const LEDGER_OK = 'ok';
|
|
30
|
+
const CLEAN = 'CLEAN';
|
|
31
|
+
const DIRTY = 'DIRTY';
|
|
32
|
+
const PROOF = 'proof';
|
|
33
|
+
const PLAN_FLAG = '--plan';
|
|
34
|
+
const PLAN_ARG_COUNT = 3;
|
|
35
|
+
const OID_ARG_COUNT = 2;
|
|
36
|
+
const ARGV_OFFSET = 2;
|
|
37
|
+
const USAGE_TEXT = 'usage: checkpoint mint|newest|prune --plan <plan> | verify|restore <oid>';
|
|
38
|
+
const PLAN_ERROR = 'expected a readable regular plan directly under docs/plans';
|
|
39
|
+
const SEQUENCE_ERROR = 'expected contiguous numbered refs on trees';
|
|
40
|
+
const GIT_ARGS = {
|
|
41
|
+
checkRef: ['check-ref-format'],
|
|
42
|
+
resolve: ['rev-parse', '--verify'],
|
|
43
|
+
update: ['update-ref', '--no-deref'],
|
|
44
|
+
remove: ['update-ref', '--no-deref', '-d'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const readPlan = (context, planPath) => {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof planPath !== STRING || isAbsolute(planPath)) return refuse(`${NAMES.stem}: ${PLAN_ERROR}`);
|
|
50
|
+
const path = resolve(context.cwd, planPath);
|
|
51
|
+
const parent = relative(context.top, dirname(path)).split(sep).join(SLASH);
|
|
52
|
+
const name = basename(path);
|
|
53
|
+
if (parent !== PLANS_REL || !name.endsWith(MARKDOWN) || isScratchPlanName(name)
|
|
54
|
+
|| !(context.io.lstat ?? lstatSync)(path).isFile()) return refuse(`${NAMES.stem}: ${PLAN_ERROR}`);
|
|
55
|
+
readFileSync(path);
|
|
56
|
+
const stem = name.slice(FIRST_NUMBER, -MARKDOWN.length);
|
|
57
|
+
readGit(context, [...GIT_ARGS.checkRef, `${CHECKPOINT_REF_PREFIX}${stem}/${FIRST_NUMBER}`]);
|
|
58
|
+
return { ok: true, stem };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return refuse(`${NAMES.stem}: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const readSequence = (context, stem) => {
|
|
64
|
+
const prefix = `${CHECKPOINT_REF_PREFIX}${stem}${SLASH}`;
|
|
65
|
+
const refs = readCheckpointRefs(context, prefix).map(({ ref, oid }) => {
|
|
66
|
+
const suffix = ref.slice(prefix.length);
|
|
67
|
+
if (!NUMBER_PATTERN.test(suffix)) {
|
|
68
|
+
throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
69
|
+
}
|
|
70
|
+
return { ref, oid, n: Number(suffix), stem };
|
|
71
|
+
}).sort((left, right) => left.n - right.n);
|
|
72
|
+
for (const [index, checkpoint] of refs.entries()) {
|
|
73
|
+
if (!Number.isSafeInteger(checkpoint.n) || checkpoint.n !== index) throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
74
|
+
const tree = resolveTreeObject(context.top, checkpoint.oid, context.io);
|
|
75
|
+
if (!tree.ok) throw new Error(tree.reason);
|
|
76
|
+
}
|
|
77
|
+
return refs;
|
|
78
|
+
};
|
|
79
|
+
const withSequence = (cwd, planPath, io, action) => withRepository(cwd, io, (context) => {
|
|
80
|
+
const plan = readPlan(context, planPath);
|
|
81
|
+
if (!plan.ok) return plan;
|
|
82
|
+
return action(context, plan.stem, readSequence(context, plan.stem));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const mintCheckpoint = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
86
|
+
const newest = refs.at(LAST);
|
|
87
|
+
const snapshot = writeSnapshot(context, newest?.oid ?? null);
|
|
88
|
+
if (!snapshot.ok) return snapshot;
|
|
89
|
+
if (snapshot.oid === newest?.oid) return { ok: true, ...newest };
|
|
90
|
+
const n = newest === undefined ? FIRST_NUMBER : newest.n + INCREMENT;
|
|
91
|
+
const ref = `${CHECKPOINT_REF_PREFIX}${stem}/${n}`;
|
|
92
|
+
readGit(context, [...GIT_ARGS.update, ref, snapshot.oid]);
|
|
93
|
+
return { ok: true, stem, n, ref, oid: snapshot.oid };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const newestCheckpoint = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
97
|
+
const newest = refs.at(LAST);
|
|
98
|
+
return newest === undefined ? refuse(`${NAMES.empty}: ${stem}`) : { ok: true, ...newest };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const verifyCheckpoint = (cwd, oid, io = {}) => withRepository(cwd, io, (context) => {
|
|
102
|
+
const tree = resolveTreeObject(context.top, oid, context.io);
|
|
103
|
+
if (!tree.ok) return refuse(tree.reason);
|
|
104
|
+
const target = readGit(context, [...GIT_ARGS.resolve, oid]);
|
|
105
|
+
const snapshot = writeSnapshot(context, target);
|
|
106
|
+
if (!snapshot.ok) return snapshot;
|
|
107
|
+
return { ok: true, clean: target === snapshot.oid, target, oid: snapshot.oid };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const pruneCheckpoints = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
111
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
112
|
+
if (ledger.state !== LEDGER_ABSENT && ledger.state !== LEDGER_OK) return refuse(`${NAMES.ledger}: ${ledger.reason}`);
|
|
113
|
+
const records = ledger.records ?? [];
|
|
114
|
+
const held = new Set(refs.map(({ oid }) => oid));
|
|
115
|
+
const open = findOpenCheckpointThread(records, held);
|
|
116
|
+
if (open !== undefined) return refuse(`${NAMES.open}: ${open.nonce}`);
|
|
117
|
+
for (const { ref } of [...refs].reverse()) readGit(context, [...GIT_ARGS.remove, ref]);
|
|
118
|
+
return { ok: true };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const PLAN_OPERATIONS = { [VERBS.mint]: mintCheckpoint, [VERBS.newest]: newestCheckpoint, [VERBS.prune]: pruneCheckpoints };
|
|
122
|
+
const formatRef = ({ stem, n, oid }) => `${CHECKPOINT_KIND} ${stem}/${n} ${oid}${NEWLINE}`;
|
|
123
|
+
const formatRefusal = ({ code, reason }) => ({ code, stdout: EMPTY, stderr: `${reason}${NEWLINE}` });
|
|
124
|
+
export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
|
|
125
|
+
const [verb, operand, planPath] = argv;
|
|
126
|
+
const planVerb = Object.hasOwn(PLAN_OPERATIONS, verb);
|
|
127
|
+
const oidVerb = verb === VERBS.verify || verb === VERBS.restore;
|
|
128
|
+
if (!(planVerb && argv.length === PLAN_ARG_COUNT && operand === PLAN_FLAG && planPath)
|
|
129
|
+
&& !(oidVerb && argv.length === OID_ARG_COUNT && operand)) return formatRefusal(refuse(USAGE_TEXT, USAGE));
|
|
130
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
131
|
+
const operation = verb === VERBS.restore ? restoreCheckpoint : verifyCheckpoint;
|
|
132
|
+
const result = planVerb ? PLAN_OPERATIONS[verb](cwd, planPath, deps) : operation(cwd, operand, deps);
|
|
133
|
+
if (!result.ok) return formatRefusal(result);
|
|
134
|
+
if (verb === VERBS.restore) return { code: ACCEPT,
|
|
135
|
+
stdout: `${CHECKPOINT_KIND} ${VERBS.restore} ${result.target} ${PROOF} ${result.proof}${NEWLINE}`, stderr: EMPTY };
|
|
136
|
+
if (verb === VERBS.verify) return { code: result.clean ? ACCEPT : REFUSE,
|
|
137
|
+
stdout: `${result.clean ? CLEAN : DIRTY} ${result.target} ${result.oid}${NEWLINE}`, stderr: EMPTY };
|
|
138
|
+
return { code: ACCEPT, stdout: verb === VERBS.prune ? EMPTY : formatRef(result), stderr: EMPTY };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (isDirectRun(import.meta.url)) {
|
|
142
|
+
const result = main();
|
|
143
|
+
process.stdout.write(result.stdout);
|
|
144
|
+
process.stderr.write(result.stderr);
|
|
145
|
+
process.exitCode = result.code;
|
|
146
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// dispatch-baseline.mjs — snapshotScope, computeBasePayload, computeBaseFingerprint,
|
|
2
|
+
// isCleanAgainstBase, runBaseDiff and resolveTreeObject; spec:dispatch-baseline.
|
|
3
|
+
// Writes only its own temporary indexes and the blob objects stored by forced adds.
|
|
4
|
+
// Head measurements delegate unchanged to core-evidence without a temporary index.
|
|
5
|
+
// runBaseDiff returns null for head; callers must use their existing head diff path.
|
|
6
|
+
|
|
7
|
+
import { mkdtempSync, rmSync, lstatSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, dirname, basename, relative } from 'node:path';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { computeFingerprintPayload, isTreeClean, isNeverCommittableStat } from './core-evidence.mjs';
|
|
13
|
+
import { KIT_OWN_PATHS, KNOWN_FOOTPRINT, patternToProbe, isDirPattern, expandGlob, isGlobPattern } from './known-footprint.mjs';
|
|
14
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
|
|
15
|
+
|
|
16
|
+
const TEMP_PREFIX = 'aw-dispatch-baseline-';
|
|
17
|
+
const INDEX_NAME = 'index';
|
|
18
|
+
const HASH_ALGORITHM = 'sha256';
|
|
19
|
+
const PAYLOAD_DIFF_ARGS = ['--no-ext-diff', '--no-textconv', '--ignore-submodules=none'];
|
|
20
|
+
const SCOPE_EXCLUSIONS = ['/docs/plans/', '/.claude/settings.json', '/.claude/settings.local.json', '/.mcp.json'];
|
|
21
|
+
const MISSING_PATH_CODES = ['ENOENT', 'ENOTDIR'];
|
|
22
|
+
const ABSENT_HEAD_STATUS = 1;
|
|
23
|
+
const splitPaths = (bytes) => {
|
|
24
|
+
const text = bytes.toString('utf8');
|
|
25
|
+
if (!Buffer.from(text).equals(bytes)) throw new Error('git path names are not lossless UTF-8');
|
|
26
|
+
return text.split('\0').filter(Boolean).map((path) => path.replace(/\/$/, ''));
|
|
27
|
+
};
|
|
28
|
+
const defaultRunGit = (args, cwd, input, env) => spawnSync('git', args, {
|
|
29
|
+
cwd, input, env: withGitPath(env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
const makeRunner = ({ runGit = defaultRunGit, env = process.env } = {}) => (args, cwd, input, override = env) => {
|
|
32
|
+
try { return runGit(args, cwd, input, override); }
|
|
33
|
+
catch (error) { return { error, status: null, stdout: Buffer.alloc(0) }; }
|
|
34
|
+
};
|
|
35
|
+
const readGit = (run, args, top, env) => {
|
|
36
|
+
const result = run(args, top, undefined, env);
|
|
37
|
+
if (result == null || result.error || result.signal || result.status !== 0 || result.stdout == null) {
|
|
38
|
+
throw new Error(`git ${args.join(' ')} failed: ${result?.error?.message ?? String(result?.stderr ?? '').trim()}`);
|
|
39
|
+
}
|
|
40
|
+
return Buffer.from(result.stdout);
|
|
41
|
+
};
|
|
42
|
+
const locateTop = (cwd, run, env) => {
|
|
43
|
+
const location = resolveGitLocation(cwd, { env, spawn: (command, args, options) => run(args, options.cwd, undefined, options.env) });
|
|
44
|
+
if (location.state !== 'work-tree') throw new Error(location.cause ?? 'not inside a git work tree');
|
|
45
|
+
return location.top;
|
|
46
|
+
};
|
|
47
|
+
const withTemporaryIndex = (env, action) => {
|
|
48
|
+
const directory = mkdtempSync(join(tmpdir(), TEMP_PREFIX));
|
|
49
|
+
try {
|
|
50
|
+
return action({ ...env, GIT_INDEX_FILE: join(directory, INDEX_NAME), GIT_LITERAL_PATHSPECS: '1' });
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(directory, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const readStat = (path, lstat) => {
|
|
56
|
+
try {
|
|
57
|
+
const stat = lstat(path);
|
|
58
|
+
if (stat == null) throw new Error(`cannot stat ${path}`);
|
|
59
|
+
return stat;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (MISSING_PATH_CODES.includes(error.code)) return null;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const readTreePaths = (run, top, treeOid, env) => splitPaths(readGit(run, ['ls-tree', '-r', '--name-only', '-z', treeOid], top, env));
|
|
66
|
+
const readHeadPaths = (run, top, env) => {
|
|
67
|
+
const head = run(['rev-parse', '--verify', '--quiet', 'HEAD'], top, undefined, env);
|
|
68
|
+
if (head && !head.error && !head.signal && head.status === ABSENT_HEAD_STATUS) return [];
|
|
69
|
+
if (!head || head.error || head.signal || head.status !== 0) throw new Error('cannot resolve HEAD');
|
|
70
|
+
return readTreePaths(run, top, 'HEAD', env);
|
|
71
|
+
};
|
|
72
|
+
const excludePatterns = (top, paths) => [
|
|
73
|
+
...SCOPE_EXCLUSIONS,
|
|
74
|
+
...KNOWN_FOOTPRINT.flatMap(({ pattern }) => isGlobPattern(pattern)
|
|
75
|
+
? expandGlob(pattern, {
|
|
76
|
+
dir: top,
|
|
77
|
+
readdir: (parent) => paths.filter((path) => dirname(path) === relative(top, parent).split('\\').join('/')).map((path) => basename(path)),
|
|
78
|
+
stat: () => ({ isFile: () => true }),
|
|
79
|
+
})
|
|
80
|
+
: [pattern]),
|
|
81
|
+
];
|
|
82
|
+
const collectScope = (top, baseTreeOid, run, lstat, env) => withTemporaryIndex(env, (indexEnv) => {
|
|
83
|
+
readGit(run, ['read-tree', '--empty'], top, indexEnv);
|
|
84
|
+
const paths = new Set(splitPaths(readGit(run, ['ls-files', '--others', '--exclude-standard', '-z'], top, indexEnv)));
|
|
85
|
+
for (const pattern of KIT_OWN_PATHS) {
|
|
86
|
+
const probe = patternToProbe(pattern);
|
|
87
|
+
const stat = readStat(join(top, probe), lstat);
|
|
88
|
+
if (stat === null) continue;
|
|
89
|
+
if (isDirPattern(pattern) && stat.isDirectory()) {
|
|
90
|
+
for (const path of splitPaths(readGit(run, ['ls-files', '--others', '-z', '--', probe], top, indexEnv))) paths.add(path);
|
|
91
|
+
} else paths.add(probe.replace(/\/$/, ''));
|
|
92
|
+
}
|
|
93
|
+
for (const path of readHeadPaths(run, top, indexEnv)) paths.add(path);
|
|
94
|
+
if (baseTreeOid !== null) for (const path of readTreePaths(run, top, baseTreeOid, indexEnv)) paths.add(path);
|
|
95
|
+
const exclusions = excludePatterns(top, [...paths]).map((pattern) => ({ probe: patternToProbe(pattern), directory: isDirPattern(pattern) }));
|
|
96
|
+
return [...paths].filter((path) => !path.split('/').includes('node_modules')
|
|
97
|
+
&& !exclusions.some(({ probe, directory }) => directory ? path === probe.slice(0, -1) || path.startsWith(probe) : path === probe)).sort();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const snapshotScope = (cwd, { baseTreeOid = null } = {}, io = {}) => {
|
|
101
|
+
try {
|
|
102
|
+
const env = io.env ?? process.env;
|
|
103
|
+
const run = makeRunner(io);
|
|
104
|
+
const top = locateTop(cwd, run, env);
|
|
105
|
+
return { ok: true, paths: collectScope(top, baseTreeOid, run, io.lstat ?? lstatSync, env) };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { ok: false, reason: `snapshot scope: ${error.message}` };
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const resolveTreeObject = (cwd, oid, io = {}) => {
|
|
112
|
+
try {
|
|
113
|
+
const env = io.env ?? process.env;
|
|
114
|
+
const run = makeRunner(io);
|
|
115
|
+
const top = locateTop(cwd, run, env);
|
|
116
|
+
const type = readGit(run, ['cat-file', '-t', oid], top, env).toString('utf8').trim();
|
|
117
|
+
return type === 'tree' ? { ok: true } : { ok: false, reason: `${oid} names a ${type}, not a tree` };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { ok: false, reason: `${oid} is an unresolvable tree object: ${error.message}` };
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const runBaseDiff = (cwd, base, args, io = {}) => {
|
|
124
|
+
if (base.kind !== 'checkpoint') return null;
|
|
125
|
+
try {
|
|
126
|
+
const env = io.env ?? process.env;
|
|
127
|
+
const run = makeRunner(io);
|
|
128
|
+
const top = locateTop(cwd, run, env);
|
|
129
|
+
if (!resolveTreeObject(top, base.treeOid, io).ok) return null;
|
|
130
|
+
const scope = snapshotScope(top, { baseTreeOid: base.treeOid }, io);
|
|
131
|
+
if (!scope.ok) return null;
|
|
132
|
+
const basePaths = readTreePaths(run, top, base.treeOid, env);
|
|
133
|
+
const present = [];
|
|
134
|
+
const removed = [];
|
|
135
|
+
for (const path of scope.paths) {
|
|
136
|
+
const stat = readStat(join(top, path), io.lstat ?? lstatSync);
|
|
137
|
+
const baseHoldsPath = basePaths.includes(path);
|
|
138
|
+
const neverCommittable = stat !== null && isNeverCommittableStat(stat);
|
|
139
|
+
const materialized = stat !== null && !neverCommittable && (stat.isFile() || stat.isSymbolicLink());
|
|
140
|
+
if (neverCommittable && baseHoldsPath) throw new Error(`base path ${path} is no longer committable`);
|
|
141
|
+
if (materialized) present.push(path);
|
|
142
|
+
if (!materialized && !neverCommittable && (stat === null || baseHoldsPath)) removed.push(path);
|
|
143
|
+
}
|
|
144
|
+
const excludedBasePaths = basePaths.filter((path) => !scope.paths.includes(path));
|
|
145
|
+
const conflict = present.find((path) => excludedBasePaths.some((excluded) =>
|
|
146
|
+
path.startsWith(`${excluded}/`) || excluded.startsWith(`${path}/`)));
|
|
147
|
+
if (conflict !== undefined) throw new Error(`live path ${conflict} conflicts with an excluded base path`);
|
|
148
|
+
return withTemporaryIndex(env, (indexEnv) => {
|
|
149
|
+
readGit(run, ['read-tree', base.treeOid], top, indexEnv);
|
|
150
|
+
if (removed.length > 0) readGit(run, ['update-index', '--remove', '--', ...removed], top, indexEnv);
|
|
151
|
+
if (present.length > 0) readGit(run, ['add', '-f', '--', ...present], top, indexEnv);
|
|
152
|
+
return readGit(run, ['diff', '--cached', base.treeOid, ...args], top, indexEnv);
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const computeBasePayload = (cwd, base, io = {}) => base.kind === 'head'
|
|
160
|
+
? computeFingerprintPayload(cwd, io)
|
|
161
|
+
: runBaseDiff(cwd, base, PAYLOAD_DIFF_ARGS, io);
|
|
162
|
+
|
|
163
|
+
export const computeBaseFingerprint = (cwd, base, io) => {
|
|
164
|
+
const payload = computeBasePayload(cwd, base, io);
|
|
165
|
+
return payload === null ? null : createHash(HASH_ALGORITHM).update(payload).digest('hex');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const isCleanAgainstBase = (cwd, base, io) => {
|
|
169
|
+
if (base.kind === 'head') return isTreeClean(cwd, io);
|
|
170
|
+
const payload = computeBasePayload(cwd, base, io);
|
|
171
|
+
return payload === null ? null : payload.length === 0;
|
|
172
|
+
};
|
|
@@ -131,13 +131,21 @@ const short = (v) => {
|
|
|
131
131
|
// ── the closed vocabulary ─────────────────────────────────────────────────────────────────────────
|
|
132
132
|
|
|
133
133
|
export const DELEGATION_SCHEMA_VERSION = 1;
|
|
134
|
+
export const BASELINE_KINDS = deepFreeze(['head', 'checkpoint']);
|
|
135
|
+
export const HEAD_BASELINE = deepFreeze({ kind: 'head', treeOid: null });
|
|
136
|
+
export const OPTIONAL_FIELDS = deepFreeze({ dispatch: ['baseline'] });
|
|
137
|
+
const TREE_OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
138
|
+
export const isTreeOid = (v) => typeof v === 'string' && TREE_OID_RE.test(v);
|
|
139
|
+
export const readsBaseline = (dispatch) => Object.hasOwn(dispatch, 'baseline')
|
|
140
|
+
? { kind: dispatch.baseline.kind, treeOid: dispatch.baseline.treeOid }
|
|
141
|
+
: { ...HEAD_BASELINE };
|
|
134
142
|
|
|
135
143
|
export const DELEGATION_KINDS = deepFreeze(['pre-registration', 'dispatch', 'return', 'fold', 'observation', 'degrade']);
|
|
136
144
|
|
|
137
145
|
// The closed key set per kind (schema + kind are implicit on every record).
|
|
138
146
|
export const DELEGATION_KEY_SETS = deepFreeze({
|
|
139
147
|
'pre-registration': ['waveId', 'stepClasses', 'pairingKey', 'minPerClass', 'meanLThreshold', 'firstPassNum', 'firstPassDen', 'timestamp'],
|
|
140
|
-
dispatch: ['waveId', 'nonce', 'stepClass', 'vehicle', 'backend', 'contractDigest', 'preTreeDigest', 'baselineClean', 'deadlineS', 'retryOf', 'retryIndex', 'retryCap', 'rationale', 'timestamp'],
|
|
148
|
+
dispatch: ['waveId', 'nonce', 'stepClass', 'vehicle', 'backend', 'contractDigest', 'preTreeDigest', 'baselineClean', 'deadlineS', 'retryOf', 'retryIndex', 'retryCap', 'rationale', 'timestamp', 'baseline'],
|
|
141
149
|
return: ['role', 'backend', 'nonce', 'contractDigest', 'preTreeDigest', 'postTreeDigest', 'diffDigest', 'diffLength', 'reportDigest', 'reportLength', 'bundleDigest', 'bundleLength', 'metric', 'outcome', 'exitStatus', 'sessionId', 'wrapperVersion', 'posture', 'timestamp'],
|
|
142
150
|
fold: ['nonce', 'returnDigest', 'treeDigestAtFold', 'verdict', 'timestamp'],
|
|
143
151
|
observation: ['waveId', 'stepClass', 'scope', 'metric', 'planId', 'phase', 'timestamp'],
|
|
@@ -217,6 +225,7 @@ const namesAnObject = (kind) => kind !== 'gate-output';
|
|
|
217
225
|
// ── nested closed forms ───────────────────────────────────────────────────────────────────────────
|
|
218
226
|
|
|
219
227
|
const VEHICLE_KEYS = ['requested', 'selected'];
|
|
228
|
+
const BASELINE_KEYS = ['kind', 'treeOid'];
|
|
220
229
|
const POSTURE_KEYS = ['model', 'effort', 'tier'];
|
|
221
230
|
const METRIC_KEYS = ['numeratorBytes', 'denominatorBytes', 'components', 'provenance', 'eligible', 'ineligibleReason'];
|
|
222
231
|
const COMPONENT_KEYS = ['kind', 'path', 'objectId', 'bytes'];
|
|
@@ -235,6 +244,18 @@ const checkClosedKeys = (at, value, keys) => {
|
|
|
235
244
|
return { ok: true };
|
|
236
245
|
};
|
|
237
246
|
|
|
247
|
+
const validateBaseline = (at, baseline) => {
|
|
248
|
+
const closed = checkClosedKeys(at, baseline, BASELINE_KEYS);
|
|
249
|
+
if (!closed.ok) return closed;
|
|
250
|
+
if (!BASELINE_KINDS.includes(baseline.kind)) return refuse(`${at}: unknown kind ${short(baseline.kind)} — expected ${BASELINE_KINDS.join(' | ')}`);
|
|
251
|
+
if (baseline.treeOid !== null && (typeof baseline.treeOid !== 'string' || !TREE_OID_RE.test(baseline.treeOid))) {
|
|
252
|
+
return refuse(`${at}: treeOid must be null or exactly 40 or 64 lowercase hex characters (got ${short(baseline.treeOid)})`);
|
|
253
|
+
}
|
|
254
|
+
if (baseline.kind === 'head' && baseline.treeOid !== null) return refuse(`${at}: head requires treeOid null`);
|
|
255
|
+
if (baseline.kind === 'checkpoint' && baseline.treeOid === null) return refuse(`${at}: checkpoint requires treeOid to be non-null`);
|
|
256
|
+
return { ok: true };
|
|
257
|
+
};
|
|
258
|
+
|
|
238
259
|
const validateVehicle = (at, vehicle) => {
|
|
239
260
|
const closed = checkClosedKeys(at, vehicle, VEHICLE_KEYS);
|
|
240
261
|
if (!closed.ok) return closed;
|
|
@@ -324,6 +345,7 @@ const FIELD_CHECKS = {
|
|
|
324
345
|
firstPassNum: { ok: isByteCount, want: 'a non-negative integer first-pass numerator' },
|
|
325
346
|
firstPassDen: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer first-pass denominator' },
|
|
326
347
|
vehicle: { ok: isPlainObject, want: 'the closed vehicle pair {requested, selected}' },
|
|
348
|
+
baseline: { ok: isPlainObject, want: 'the closed baseline pair {kind, treeOid}' },
|
|
327
349
|
// The safe token grammar, not merely non-empty: the backend NAMES an artifact beside the store
|
|
328
350
|
// (the exec receipt and its report), so a name outside this grammar records a dispatch whose own
|
|
329
351
|
// receipt could never be written. One grammar on both sides, or the ledger accepts what the
|
|
@@ -375,6 +397,7 @@ const checkFields = (kind, record) => {
|
|
|
375
397
|
return refuse(`${kind}: unknown field "${stray}" — the key set is closed (the canonical digest is identity; a stray key would fork it)`);
|
|
376
398
|
}
|
|
377
399
|
for (const field of fields) {
|
|
400
|
+
if (!Object.hasOwn(record, field) && (OPTIONAL_FIELDS[kind] ?? []).includes(field)) continue;
|
|
378
401
|
if (!own.includes(field)) {
|
|
379
402
|
return refuse(`${kind}: missing field "${field}" — every field of the closed set is pinned as an OWN ENUMERABLE key (the digest domain)`);
|
|
380
403
|
}
|
|
@@ -492,6 +515,10 @@ export const validateDelegationRecord = (record) => {
|
|
|
492
515
|
}
|
|
493
516
|
if (record.kind === 'dispatch') {
|
|
494
517
|
const vehicle = validateVehicle('dispatch: vehicle', record.vehicle);
|
|
518
|
+
if (Object.hasOwn(record, 'baseline')) {
|
|
519
|
+
const baseline = validateBaseline('dispatch: baseline', record.baseline);
|
|
520
|
+
if (!baseline.ok) return baseline;
|
|
521
|
+
}
|
|
495
522
|
return vehicle.ok ? validateDispatchCrossFields(record) : vehicle;
|
|
496
523
|
}
|
|
497
524
|
if (record.kind === 'return') return validateReturnCrossFields(record);
|
|
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
validateDelegationRecord, canonicalDelegationDigest, allowedSuccessorKinds,
|
|
6
|
-
isThreadTerminalRecord,
|
|
6
|
+
isThreadTerminalRecord, readsBaseline,
|
|
7
7
|
} from './dispatch-record.mjs';
|
|
8
8
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
9
9
|
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
@@ -175,6 +175,11 @@ export const delegationSemanticPreflight = ({ records, snapshot, storePath }) =>
|
|
|
175
175
|
if (snapshot.stepClass !== prior.dispatch.stepClass) {
|
|
176
176
|
throw stop(`refusing a retry: it declares step class "${snapshot.stepClass}" but its retry origin "${snapshot.retryOf}" was dispatched as "${prior.dispatch.stepClass}" — the pairing key is the step class, so a chain that changes it mid-way would split one attempt's accounting across two classes; nothing was written`);
|
|
177
177
|
}
|
|
178
|
+
const base = readsBaseline(snapshot);
|
|
179
|
+
const priorBase = readsBaseline(prior.dispatch);
|
|
180
|
+
if (base.kind !== priorBase.kind || base.treeOid !== priorBase.treeOid) {
|
|
181
|
+
throw stop(`refusing a retry: its base {kind "${base.kind}", treeOid ${base.treeOid}} differs from its retry origin "${snapshot.retryOf}"'s {kind "${priorBase.kind}", treeOid ${priorBase.treeOid}} — a retry chain is measured against ONE base; nothing was written`);
|
|
182
|
+
}
|
|
178
183
|
const origin = findRetryChainOrigin(records, prior.dispatch);
|
|
179
184
|
if (snapshot.retryIndex > origin.retryCap) {
|
|
180
185
|
throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} exceeds the retryCap ${origin.retryCap} recorded on the thread's ORIGIN dispatch ("${origin.nonce}") — a fresh contract never manufactures a fresh retry budget; nothing was written`);
|