@sabaiway/agent-workflow-kit 12.4.0 → 13.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.
@@ -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`);
@@ -2,7 +2,8 @@
2
2
  // The read half lives in dispatch-store-read.mjs so read-only consumers never reach this writer.
3
3
 
4
4
  import { computeTreeFingerprint } from './core-evidence.mjs';
5
- import { validateDelegationRecord } from './dispatch-record.mjs';
5
+ import { validateDelegationRecord, HEAD_BASELINE } from './dispatch-record.mjs';
6
+ import { computeBaseFingerprint } from './dispatch-baseline.mjs';
6
7
  import {
7
8
  delegationStoreStop,
8
9
  resolveDelegationStorePath,
@@ -30,8 +31,8 @@ export const resolveDelegationLockPath = (storePath) => `${storePath}${DELEGATIO
30
31
 
31
32
  export const UNCOMMITTED_STATE_FINGERPRINT = 'the uncommitted-state fingerprint';
32
33
 
33
- export const uncommittedStateFingerprint = (cwd = process.cwd(), fsx) => {
34
- const fingerprint = computeTreeFingerprint(cwd, fsx);
34
+ export const uncommittedStateFingerprint = (cwd = process.cwd(), fsx, base = HEAD_BASELINE) => {
35
+ const fingerprint = base.kind === 'head' ? computeTreeFingerprint(cwd, fsx) : computeBaseFingerprint(cwd, base, fsx);
35
36
  if (fingerprint == null) {
36
37
  throw stop(`cannot compute ${UNCOMMITTED_STATE_FINGERPRINT} — not inside a git work tree (or a git probe failed); a record never carries a null tree digest (fail closed)`);
37
38
  }