create-agent-rig 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/package.json +1 -1
  3. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
  4. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +117 -80
  5. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +84 -16
  6. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +23 -3
  7. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +65 -7
  8. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +2 -2
  9. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +2 -2
  10. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +109 -0
  11. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  12. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  13. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  14. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  15. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  16. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  17. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  18. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  19. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +41 -19
  20. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  21. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +640 -59
  22. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  23. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  24. package/templates/agent-os/universal/.claude/settings.json +1 -1
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +117 -80
  26. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +84 -16
  27. package/templates/agent-os/universal/.codex/hooks.json +1 -1
  28. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  29. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  30. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  31. package/templates/agent-os/universal/layers.json +5 -0
  32. package/templates/hash-history.json +104 -29
  33. package/templates/release-ledger.json +3 -1
@@ -0,0 +1,800 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import {
4
+ closeSync,
5
+ constants,
6
+ fstatSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ } from 'node:fs';
13
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
14
+ import { withoutGitLocation } from '../git-env.mjs';
15
+
16
+ export const CLAIM_SCHEMA_VERSION = 1;
17
+ export const RESULTS = Object.freeze([
18
+ 'BASELINE_CREATED',
19
+ 'CURRENT',
20
+ 'CHANGED',
21
+ 'CONFLICT',
22
+ 'UNVERIFIABLE',
23
+ ]);
24
+
25
+ const TICKET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
26
+ const CONTRACT_PATH = '.rig/revalidation.json';
27
+ const MAX_CONTRACT_BYTES = 256 * 1024;
28
+ const MAX_CLAIM_BYTES = 256 * 1024;
29
+ const MAX_PAIRED_FACT_BYTES = 16 * 1024 * 1024;
30
+ const SHA256 = /^[0-9a-f]{64}$/;
31
+ const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
32
+
33
+ const stable = (value) => {
34
+ if (Array.isArray(value)) return value.map(stable);
35
+ if (value && typeof value === 'object') {
36
+ return Object.fromEntries(
37
+ Object.entries(value)
38
+ .sort(([left], [right]) => left.localeCompare(right))
39
+ .map(([key, entry]) => [key, stable(entry)]),
40
+ );
41
+ }
42
+ return value;
43
+ };
44
+
45
+ const digest = (value) =>
46
+ createHash('sha256')
47
+ .update(Buffer.isBuffer(value) ? value : JSON.stringify(stable(value)))
48
+ .digest('hex');
49
+
50
+ const pathExists = (path) => {
51
+ try {
52
+ lstatSync(path);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ const assertInsideRepository = (projectRoot, path, label) => {
60
+ const root = realpathSync(projectRoot);
61
+ const resolved = realpathSync(path);
62
+ const fromRoot = relative(root, resolved);
63
+ if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
64
+ throw new Error(`${label} escapes the repository`);
65
+ }
66
+ return { root, resolved };
67
+ };
68
+
69
+ const readRepositoryFile = (projectRoot, path, { label, maxBytes }) => {
70
+ let declared;
71
+ try {
72
+ declared = lstatSync(path);
73
+ } catch (error) {
74
+ throw new Error(
75
+ error?.code === 'ENOENT' ? `${label} is missing` : `${label} is unreadable`,
76
+ { cause: error },
77
+ );
78
+ }
79
+ if (declared.isSymbolicLink()) throw new Error(`${label} is a symlink`);
80
+ if (!declared.isFile()) throw new Error(`${label} is not a regular file`);
81
+ let fd;
82
+ try {
83
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
84
+ } catch (error) {
85
+ let symlink = false;
86
+ try {
87
+ symlink = lstatSync(path).isSymbolicLink();
88
+ } catch {
89
+ // Preserve the open failure when the pathname itself cannot be classified.
90
+ }
91
+ if (symlink) throw new Error(`${label} is a symlink`, { cause: error });
92
+ throw new Error(
93
+ error?.code === 'ENOENT' ? `${label} is missing` : `${label} is unreadable`,
94
+ { cause: error },
95
+ );
96
+ }
97
+ try {
98
+ const opened = fstatSync(fd);
99
+ if (!opened.isFile()) throw new Error(`${label} is not a regular file`);
100
+ if (opened.size > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
101
+ const openedPath = lstatSync(path);
102
+ if (openedPath.isSymbolicLink()) throw new Error(`${label} is a symlink`);
103
+ if (!openedPath.isFile()) throw new Error(`${label} is not a regular file`);
104
+ assertInsideRepository(projectRoot, path, label);
105
+ const current = lstatSync(path);
106
+ if (current.isSymbolicLink()) throw new Error(`${label} is a symlink`);
107
+ if (!current.isFile()) throw new Error(`${label} is not a regular file`);
108
+ if (current.dev !== opened.dev || current.ino !== opened.ino) {
109
+ throw new Error(`${label} changed during validation`);
110
+ }
111
+ return readFileSync(fd);
112
+ } catch (error) {
113
+ if (String(error?.message ?? error).startsWith(`${label} `)) throw error;
114
+ throw new Error(`${label} is unreadable`, { cause: error });
115
+ } finally {
116
+ closeSync(fd);
117
+ }
118
+ };
119
+
120
+ const assertRepositoryDirectory = (projectRoot, path, label) => {
121
+ const stat = lstatSync(path);
122
+ if (stat.isSymbolicLink()) throw new Error(`${label} is a symlink`);
123
+ if (!stat.isDirectory()) throw new Error(`${label} is not a directory`);
124
+ const { resolved } = assertInsideRepository(projectRoot, path, label);
125
+ return { resolved, dev: stat.dev, ino: stat.ino };
126
+ };
127
+
128
+ const ensureClaimDirectory = (projectRoot, path) => {
129
+ const rigDir = dirname(path);
130
+ assertRepositoryDirectory(projectRoot, rigDir, 'claim root .rig');
131
+ try {
132
+ mkdirSync(path);
133
+ } catch (error) {
134
+ if (error?.code !== 'EEXIST') throw error;
135
+ }
136
+ return assertRepositoryDirectory(projectRoot, path, 'claim directory .rig/claims');
137
+ };
138
+
139
+ // Node has no public `openat(2)` binding. A child with the validated claim
140
+ // directory as its cwd gives the relative create the same stable directory
141
+ // handle: renaming or replacing the pathname after spawn cannot redirect that
142
+ // cwd. If the path was already redirected before spawn, the child resolves its
143
+ // own cwd outside the repository and refuses before creating anything.
144
+ const writeClaimExclusive = (projectRoot, path, content, expectedDirectory) => {
145
+ const script = String.raw`
146
+ const { constants, openSync, readFileSync, realpathSync, writeFileSync, closeSync, statSync } = require('node:fs');
147
+ const { isAbsolute, relative, sep } = require('node:path');
148
+ const [name, repository, expectedPath, expectedDev, expectedIno] = process.argv.slice(1);
149
+ const root = realpathSync(repository);
150
+ const cwd = realpathSync('.');
151
+ const fromRoot = relative(root, cwd);
152
+ if (fromRoot === '..' || fromRoot.startsWith('..' + sep) || isAbsolute(fromRoot)) {
153
+ throw new Error('claim directory .rig/claims escapes the repository');
154
+ }
155
+ const cwdStat = statSync('.');
156
+ if (cwd !== expectedPath || String(cwdStat.dev) !== expectedDev || String(cwdStat.ino) !== expectedIno) {
157
+ throw new Error('claim directory .rig/claims changed during validation');
158
+ }
159
+ const fd = openSync(name, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
160
+ try { writeFileSync(fd, readFileSync(0)); } finally { closeSync(fd); }
161
+ `;
162
+ execFileSync(
163
+ process.execPath,
164
+ [
165
+ '-e',
166
+ script,
167
+ basename(path),
168
+ realpathSync(projectRoot),
169
+ expectedDirectory.resolved,
170
+ String(expectedDirectory.dev),
171
+ String(expectedDirectory.ino),
172
+ ],
173
+ {
174
+ cwd: dirname(path),
175
+ env: withoutGitLocation(),
176
+ input: content,
177
+ stdio: ['pipe', 'ignore', 'pipe'],
178
+ maxBuffer: 1024 * 1024,
179
+ },
180
+ );
181
+ };
182
+
183
+ const replaceClaim = (projectRoot, path, content, expectedDirectory) => {
184
+ const script = String.raw`
185
+ const { constants, openSync, readFileSync, realpathSync, writeFileSync, closeSync, renameSync, unlinkSync, statSync } = require('node:fs');
186
+ const { isAbsolute, relative, sep } = require('node:path');
187
+ const [name, temporary, repository, expectedPath, expectedDev, expectedIno] = process.argv.slice(1);
188
+ const root = realpathSync(repository);
189
+ const cwd = realpathSync('.');
190
+ const fromRoot = relative(root, cwd);
191
+ if (fromRoot === '..' || fromRoot.startsWith('..' + sep) || isAbsolute(fromRoot)) {
192
+ throw new Error('claim directory .rig/claims escapes the repository');
193
+ }
194
+ const cwdStat = statSync('.');
195
+ if (cwd !== expectedPath || String(cwdStat.dev) !== expectedDev || String(cwdStat.ino) !== expectedIno) {
196
+ throw new Error('claim directory .rig/claims changed during validation');
197
+ }
198
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
199
+ try { writeFileSync(fd, readFileSync(0)); } finally { closeSync(fd); }
200
+ try { renameSync(temporary, name); } catch (error) {
201
+ try { unlinkSync(temporary); } catch {}
202
+ throw error;
203
+ }
204
+ `;
205
+ const temporary = `.${basename(path)}.${process.pid}.tmp`;
206
+ execFileSync(
207
+ process.execPath,
208
+ [
209
+ '-e',
210
+ script,
211
+ basename(path),
212
+ temporary,
213
+ realpathSync(projectRoot),
214
+ expectedDirectory.resolved,
215
+ String(expectedDirectory.dev),
216
+ String(expectedDirectory.ino),
217
+ ],
218
+ {
219
+ cwd: dirname(path),
220
+ env: withoutGitLocation(),
221
+ input: content,
222
+ stdio: ['pipe', 'ignore', 'pipe'],
223
+ maxBuffer: 1024 * 1024,
224
+ },
225
+ );
226
+ };
227
+
228
+ const safeRelativePath = (value) => {
229
+ if (
230
+ typeof value !== 'string' ||
231
+ value === '' ||
232
+ isAbsolute(value) ||
233
+ value.split(/[\\/]/).some((part) => part === '' || part === '.' || part === '..')
234
+ ) {
235
+ throw new Error(`paired fact path ${JSON.stringify(value)} is not a safe repository-relative path`);
236
+ }
237
+ return value.replaceAll('\\', '/');
238
+ };
239
+
240
+ export const readRevalidationContract = (projectRoot) => {
241
+ const path = join(projectRoot, CONTRACT_PATH);
242
+ let raw;
243
+ try {
244
+ raw = readRepositoryFile(projectRoot, path, {
245
+ label: `detection contract ${CONTRACT_PATH}`,
246
+ maxBytes: MAX_CONTRACT_BYTES,
247
+ }).toString('utf8');
248
+ } catch (error) {
249
+ throw new Error(`no-detection-contract: ${error.message}`, { cause: error });
250
+ }
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(raw);
254
+ } catch {
255
+ throw new Error(`no-detection-contract: ${CONTRACT_PATH} is not valid JSON`);
256
+ }
257
+ const detection = parsed?.detection;
258
+ const sources = detection?.sources;
259
+ const pairedFacts = parsed?.pairedFacts;
260
+ if (
261
+ parsed?.schemaVersion !== CLAIM_SCHEMA_VERSION ||
262
+ detection?.mode !== 'pull' ||
263
+ !Array.isArray(sources) ||
264
+ !sources.includes('run-state') ||
265
+ !sources.includes('journal') ||
266
+ detection?.acceptedLatency !== '24h' ||
267
+ detection?.push !== false ||
268
+ !Array.isArray(pairedFacts)
269
+ ) {
270
+ throw new Error(
271
+ `no-detection-contract: ${CONTRACT_PATH} must declare schemaVersion 1, pull via ` +
272
+ 'run-state and journal, acceptedLatency 24h, push false, and pairedFacts',
273
+ );
274
+ }
275
+ const ids = new Set();
276
+ const normalisedPairs = pairedFacts.map((pair) => {
277
+ if (
278
+ !pair ||
279
+ typeof pair !== 'object' ||
280
+ typeof pair.id !== 'string' ||
281
+ pair.id === '' ||
282
+ ids.has(pair.id) ||
283
+ !Array.isArray(pair.paths) ||
284
+ pair.paths.length !== 2
285
+ ) {
286
+ throw new Error(
287
+ `no-detection-contract: every pairedFacts entry in ${CONTRACT_PATH} needs ` +
288
+ 'a unique id and exactly two paths',
289
+ );
290
+ }
291
+ ids.add(pair.id);
292
+ const paths = pair.paths.map(safeRelativePath);
293
+ if (paths[0] === paths[1]) {
294
+ throw new Error(`no-detection-contract: paired fact ${pair.id} names the same path twice`);
295
+ }
296
+ return { id: pair.id, paths };
297
+ });
298
+ return {
299
+ schemaVersion: CLAIM_SCHEMA_VERSION,
300
+ detection: {
301
+ mode: 'pull',
302
+ sources: ['run-state', 'journal'],
303
+ acceptedLatency: '24h',
304
+ push: false,
305
+ },
306
+ pairedFacts: normalisedPairs,
307
+ };
308
+ };
309
+
310
+ const pairedFingerprintsOf = (projectRoot, pairs) =>
311
+ pairs.map(({ id, paths }) => ({
312
+ id,
313
+ paths: paths.map((path) => {
314
+ const file = join(projectRoot, path);
315
+ const raw = readRepositoryFile(projectRoot, file, {
316
+ label: `paired fact ${path}`,
317
+ maxBytes: MAX_PAIRED_FACT_BYTES,
318
+ });
319
+ return { path, value: digest(raw) };
320
+ }),
321
+ }));
322
+
323
+ const ticketIdOf = (ticket) => {
324
+ const id = String(ticket?.id ?? '');
325
+ if (!TICKET_ID.test(id) || id === '.' || id === '..') {
326
+ throw new Error(`claim record: unusable ticket id ${JSON.stringify(id)}`);
327
+ }
328
+ return id;
329
+ };
330
+
331
+ export const claimPathFor = (projectRoot, ticket) =>
332
+ join(projectRoot, '.rig', 'claims', `${ticketIdOf(ticket)}.json`);
333
+
334
+ const pointerFor = (projectRoot, ticket) =>
335
+ relative(projectRoot, claimPathFor(projectRoot, ticket)).replaceAll('\\', '/');
336
+
337
+ const normaliseLinks = (ticket) => ({
338
+ blockedBy: (ticket?.blockedBy ?? [])
339
+ .map(({ id, resolved }) => ({ id: String(id), resolved: Boolean(resolved) }))
340
+ .sort((left, right) => left.id.localeCompare(right.id)),
341
+ blocks: (ticket?.blocks ?? []).map(String).sort(),
342
+ });
343
+
344
+ const WORKFLOW_STATES = new Set(['open', 'in-progress', 'closed']);
345
+
346
+ const workflowPositionOf = ({ ticket, point, claimedState, workflowClaim = null }) => {
347
+ const ownClaim = workflowClaim?.claimedState === claimedState;
348
+ const expected = point === 'SELECT' && !ownClaim ? 'open' : claimedState;
349
+ if (!WORKFLOW_STATES.has(expected)) {
350
+ throw new Error(`claim record: unsupported claimed workflow state ${JSON.stringify(expected)}`);
351
+ }
352
+ const actual = ticket?.state ?? null;
353
+ const acknowledged = expected === 'open' || ownClaim;
354
+ return actual === expected && acknowledged
355
+ ? { position: 'expected' }
356
+ : { position: 'unexpected', value: actual };
357
+ };
358
+
359
+ const fingerprintsOf = ({
360
+ ticket,
361
+ point,
362
+ claimedState,
363
+ workflowClaim = null,
364
+ targetSha,
365
+ pairedFacts = [],
366
+ }) => {
367
+ const commentaryIds = (ticket?.commentary?.ids ?? []).map(String).sort();
368
+ const commentaryCount = Number.isInteger(ticket?.commentary?.count)
369
+ ? ticket.commentary.count
370
+ : commentaryIds.length;
371
+ const scopeInput = {
372
+ title: ticket?.title ?? null,
373
+ body: ticket?.body ?? null,
374
+ labels: (ticket?.labels ?? [])
375
+ .map(String)
376
+ .filter((label) => !['ready', 'blocked', 'in-progress', 'escalated'].includes(label))
377
+ .sort(),
378
+ links: normaliseLinks(ticket),
379
+ workflow: workflowPositionOf({ ticket, point, claimedState, workflowClaim }),
380
+ pairedFacts,
381
+ };
382
+ return {
383
+ scope: {
384
+ algorithm: 'sha256',
385
+ value: digest(scopeInput),
386
+ targetSha,
387
+ },
388
+ commentary: {
389
+ algorithm: 'sha256',
390
+ value: digest({ count: commentaryCount, ids: commentaryIds }),
391
+ count: commentaryCount,
392
+ },
393
+ };
394
+ };
395
+
396
+ const fingerprintIdentity = (fingerprints) => ({
397
+ scope: fingerprints.scope.value,
398
+ commentary: fingerprints.commentary.value,
399
+ });
400
+
401
+ /** Add an existing checkpoint's non-fingerprint drift without creating another engine. */
402
+ export const withAdditionalDrift = (detection, sources = []) => {
403
+ if (sources.length === 0) return detection;
404
+ for (const source of sources) {
405
+ if (
406
+ source !== 'task:state' &&
407
+ !(typeof source === 'string' && /^main:[^\0\r\n]+$/.test(source))
408
+ ) {
409
+ throw new Error(`unsupported additional revalidation source ${JSON.stringify(source)}`);
410
+ }
411
+ }
412
+ const next =
413
+ detection.action === 'continue'
414
+ ? { ...detection, result: 'CHANGED', changed: true, action: 'hold' }
415
+ : detection;
416
+ const combined = { ...next, source: [...new Set([...next.source, ...sources])] };
417
+ return {
418
+ ...combined,
419
+ id: digest({
420
+ ticket: combined.ticket,
421
+ checkpoint: combined.checkpoint,
422
+ result: combined.result,
423
+ movedFingerprintSet: combined.movedFingerprintSet,
424
+ source: combined.source,
425
+ sourcePointer: combined.sourcePointer,
426
+ }),
427
+ };
428
+ };
429
+
430
+ const committedObjectOf = (projectRoot, path) => {
431
+ try {
432
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', `HEAD:${path}`], {
433
+ encoding: 'utf8',
434
+ env: withoutGitLocation(),
435
+ stdio: ['ignore', 'pipe', 'ignore'],
436
+ }).trim();
437
+ } catch {
438
+ return null;
439
+ }
440
+ };
441
+
442
+ const objectOf = (projectRoot, raw) =>
443
+ execFileSync('git', ['-C', projectRoot, 'hash-object', '--stdin'], {
444
+ encoding: 'utf8',
445
+ env: withoutGitLocation(),
446
+ input: raw,
447
+ stdio: ['pipe', 'pipe', 'ignore'],
448
+ }).trim();
449
+
450
+ const readClaim = (projectRoot, path) => {
451
+ const raw = readRepositoryFile(projectRoot, path, {
452
+ label: 'claim record',
453
+ maxBytes: MAX_CLAIM_BYTES,
454
+ });
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(raw.toString('utf8'));
458
+ } catch {
459
+ throw new Error('claim record is not valid JSON');
460
+ }
461
+ if (
462
+ !parsed ||
463
+ typeof parsed !== 'object' ||
464
+ Array.isArray(parsed) ||
465
+ parsed.schemaVersion !== CLAIM_SCHEMA_VERSION ||
466
+ typeof parsed.ticket !== 'string' ||
467
+ parsed.fingerprints?.scope?.algorithm !== 'sha256' ||
468
+ !SHA256.test(parsed.fingerprints?.scope?.value ?? '') ||
469
+ (parsed.fingerprints.scope.targetSha !== null &&
470
+ !GIT_OBJECT_ID.test(parsed.fingerprints.scope.targetSha ?? '')) ||
471
+ parsed.fingerprints?.commentary?.algorithm !== 'sha256' ||
472
+ !SHA256.test(parsed.fingerprints?.commentary?.value ?? '') ||
473
+ !Number.isInteger(parsed.fingerprints?.commentary?.count) ||
474
+ parsed.fingerprints.commentary.count < 0 ||
475
+ (parsed.workflowClaim !== undefined &&
476
+ (!parsed.workflowClaim ||
477
+ typeof parsed.workflowClaim !== 'object' ||
478
+ Array.isArray(parsed.workflowClaim) ||
479
+ !WORKFLOW_STATES.has(parsed.workflowClaim.claimedState)))
480
+ ) {
481
+ throw new Error('claim record has an unsupported shape');
482
+ }
483
+ return { parsed, raw };
484
+ };
485
+
486
+ export const recordClaimTransition = ({ projectRoot = process.cwd(), ticket, claimedState }) => {
487
+ if (!WORKFLOW_STATES.has(claimedState)) {
488
+ throw new Error(`claim record: unsupported claimed workflow state ${JSON.stringify(claimedState)}`);
489
+ }
490
+ const path = claimPathFor(projectRoot, ticket);
491
+ if (!pathExists(path)) return null;
492
+ const claim = readClaim(projectRoot, path).parsed;
493
+ if (claim.ticket !== ticketIdOf(ticket)) {
494
+ throw new Error(`claim ticket conflicts with ${ticketIdOf(ticket)}`);
495
+ }
496
+ if (claim.workflowClaim?.claimedState === claimedState) return claim.workflowClaim;
497
+
498
+ const expectedDirectory = assertRepositoryDirectory(
499
+ projectRoot,
500
+ dirname(path),
501
+ 'claim directory .rig/claims',
502
+ );
503
+ const next = { ...claim, workflowClaim: { claimedState } };
504
+ const content = `${JSON.stringify(next, null, 2)}\n`;
505
+ replaceClaim(projectRoot, path, content, expectedDirectory);
506
+ const persistedDirectory = assertRepositoryDirectory(
507
+ projectRoot,
508
+ dirname(path),
509
+ 'claim directory .rig/claims',
510
+ );
511
+ if (
512
+ persistedDirectory.resolved !== expectedDirectory.resolved ||
513
+ persistedDirectory.dev !== expectedDirectory.dev ||
514
+ persistedDirectory.ino !== expectedDirectory.ino
515
+ ) {
516
+ throw new Error('claim directory .rig/claims changed during claim transition recording');
517
+ }
518
+ const persisted = readRepositoryFile(projectRoot, path, {
519
+ label: 'claim record',
520
+ maxBytes: MAX_CLAIM_BYTES,
521
+ });
522
+ if (!persisted.equals(Buffer.from(content))) {
523
+ throw new Error('claim transition postcondition failed');
524
+ }
525
+ return next.workflowClaim;
526
+ };
527
+
528
+ const resultOf = ({
529
+ ticket,
530
+ point,
531
+ result,
532
+ action,
533
+ movedFingerprintSet = [],
534
+ pointer,
535
+ evidence,
536
+ identity = null,
537
+ }) => {
538
+ const id = ticketIdOf(ticket);
539
+ return {
540
+ schemaVersion: CLAIM_SCHEMA_VERSION,
541
+ id: digest({ id, point, result, movedFingerprintSet, pointer, identity }),
542
+ ticket: id,
543
+ point,
544
+ checkpoint: point,
545
+ result,
546
+ changed:
547
+ result === 'CHANGED' || result === 'CONFLICT'
548
+ ? true
549
+ : result === 'UNVERIFIABLE'
550
+ ? null
551
+ : false,
552
+ source: movedFingerprintSet.map((set) => `claim:${set}`),
553
+ action,
554
+ movedFingerprintSet,
555
+ sourcePointer: pointer,
556
+ evidence,
557
+ };
558
+ };
559
+
560
+ /**
561
+ * An UNVERIFIABLE result for a failure OUTSIDE the claim comparison — the
562
+ * tracker could not be read at all, so nothing about the claim was observed.
563
+ *
564
+ * Distinct from the UNVERIFIABLE results `revalidateClaim` itself returns:
565
+ * those mean the claim RECORD is missing, untracked or unreadable. This one
566
+ * means the question was never put to the tracker. Both carry `changed: null`
567
+ * and both hold, which is the property that matters — "could not check" is
568
+ * never "checked and fine". Pinned in the generator's
569
+ * `test/template/revalidate-adapter.test.ts` — absent in a generated rig — ›
570
+ * "returns a structured verdict rather than a Node stack trace when credentials are missing".
571
+ */
572
+ export const unverifiableResult = ({ ticket, point, reason, identity }) =>
573
+ resultOf({
574
+ ticket,
575
+ point,
576
+ result: 'UNVERIFIABLE',
577
+ action: 'unverifiable',
578
+ pointer: null,
579
+ evidence: { error: reason },
580
+ identity,
581
+ });
582
+
583
+ export const revalidateClaim = ({
584
+ projectRoot,
585
+ ticket,
586
+ point,
587
+ claimedState = 'in-progress',
588
+ targetSha,
589
+ allowCreate = false,
590
+ isResume = false,
591
+ }) => {
592
+ const path = claimPathFor(projectRoot, ticket);
593
+ const pointer = pointerFor(projectRoot, ticket);
594
+ let pairedFacts;
595
+ let current;
596
+ try {
597
+ const contract = readRevalidationContract(projectRoot);
598
+ pairedFacts = pairedFingerprintsOf(projectRoot, contract.pairedFacts);
599
+ current = fingerprintsOf({ ticket, point, claimedState, targetSha, pairedFacts });
600
+ } catch (error) {
601
+ return resultOf({
602
+ ticket,
603
+ point,
604
+ result: 'UNVERIFIABLE',
605
+ action: 'unverifiable',
606
+ pointer,
607
+ evidence: { error: error.message },
608
+ identity: error.message,
609
+ });
610
+ }
611
+ if (ticket?.commentary?.complete === false) {
612
+ return resultOf({
613
+ ticket,
614
+ point,
615
+ result: 'UNVERIFIABLE',
616
+ action: 'unverifiable',
617
+ pointer,
618
+ evidence: { error: 'commentary evidence is incomplete: total does not match unique ids' },
619
+ identity: 'commentary-incomplete',
620
+ });
621
+ }
622
+ const committedObject = committedObjectOf(projectRoot, pointer);
623
+ if (!pathExists(path)) {
624
+ if (committedObject !== null) {
625
+ return resultOf({
626
+ ticket,
627
+ point,
628
+ result: 'UNVERIFIABLE',
629
+ action: 'unverifiable',
630
+ pointer,
631
+ evidence: { error: `missing tracked claim record ${pointer}` },
632
+ identity: 'missing-tracked',
633
+ });
634
+ }
635
+ if (point === 'SELECT' && allowCreate && !isResume) {
636
+ try {
637
+ const expectedDirectory = ensureClaimDirectory(projectRoot, dirname(path));
638
+ const claim = {
639
+ schemaVersion: CLAIM_SCHEMA_VERSION,
640
+ ticket: ticketIdOf(ticket),
641
+ fingerprints: current,
642
+ };
643
+ const content = `${JSON.stringify(claim, null, 2)}\n`;
644
+ writeClaimExclusive(projectRoot, path, content, expectedDirectory);
645
+ const persistedDirectory = assertRepositoryDirectory(
646
+ projectRoot,
647
+ dirname(path),
648
+ 'claim directory .rig/claims',
649
+ );
650
+ if (
651
+ persistedDirectory.resolved !== expectedDirectory.resolved ||
652
+ persistedDirectory.dev !== expectedDirectory.dev ||
653
+ persistedDirectory.ino !== expectedDirectory.ino
654
+ ) {
655
+ throw new Error('claim directory .rig/claims changed during baseline creation');
656
+ }
657
+ const persisted = readRepositoryFile(projectRoot, path, {
658
+ label: 'claim record',
659
+ maxBytes: MAX_CLAIM_BYTES,
660
+ });
661
+ if (!persisted.equals(Buffer.from(content))) {
662
+ throw new Error('claim baseline postcondition failed');
663
+ }
664
+ } catch (error) {
665
+ return resultOf({
666
+ ticket,
667
+ point,
668
+ result: 'UNVERIFIABLE',
669
+ action: 'unverifiable',
670
+ pointer,
671
+ evidence: { error: `claim baseline could not be created: ${error.message}` },
672
+ identity: 'create-refused',
673
+ });
674
+ }
675
+ return resultOf({
676
+ ticket,
677
+ point,
678
+ result: 'BASELINE_CREATED',
679
+ action: 'continue',
680
+ pointer,
681
+ evidence: { claim: pointer },
682
+ identity: fingerprintIdentity(current),
683
+ });
684
+ }
685
+ return resultOf({
686
+ ticket,
687
+ point,
688
+ result: 'UNVERIFIABLE',
689
+ action: 'unverifiable',
690
+ pointer,
691
+ evidence: { error: `missing claim record ${pointer}` },
692
+ identity: 'missing',
693
+ });
694
+ }
695
+
696
+ if (committedObject === null) {
697
+ return resultOf({
698
+ ticket,
699
+ point,
700
+ result: 'UNVERIFIABLE',
701
+ action: 'unverifiable',
702
+ pointer,
703
+ evidence: { error: `untracked or unversioned claim record ${pointer}` },
704
+ identity: 'unversioned',
705
+ });
706
+ }
707
+
708
+ let claim;
709
+ try {
710
+ const read = readClaim(projectRoot, path);
711
+ if (objectOf(projectRoot, read.raw) !== committedObject) {
712
+ throw new Error('tracked claim worktree content diverges from its committed Git version');
713
+ }
714
+ claim = read.parsed;
715
+ } catch (error) {
716
+ return resultOf({
717
+ ticket,
718
+ point,
719
+ result: 'UNVERIFIABLE',
720
+ action: 'unverifiable',
721
+ pointer,
722
+ evidence: { error: `unreadable claim record ${pointer}: ${error.message}` },
723
+ identity: error.message,
724
+ });
725
+ }
726
+ if (claim.ticket !== ticketIdOf(ticket)) {
727
+ return resultOf({
728
+ ticket,
729
+ point,
730
+ result: 'CONFLICT',
731
+ action: 'hold',
732
+ movedFingerprintSet: ['scope'],
733
+ pointer,
734
+ evidence: { error: `claim ticket conflicts with ${ticketIdOf(ticket)}` },
735
+ identity: claim.ticket,
736
+ });
737
+ }
738
+
739
+ current = fingerprintsOf({
740
+ ticket,
741
+ point,
742
+ claimedState,
743
+ workflowClaim: claim.workflowClaim,
744
+ targetSha,
745
+ pairedFacts,
746
+ });
747
+
748
+ const scopeMoved =
749
+ claim.fingerprints.scope.value !== current.scope.value ||
750
+ claim.fingerprints.scope.targetSha !== current.scope.targetSha;
751
+ const commentaryMoved = claim.fingerprints.commentary.value !== current.commentary.value;
752
+ const movedFingerprintSet = [
753
+ ...(scopeMoved ? ['scope'] : []),
754
+ ...(point === 'BEFORE_CLOSE' && commentaryMoved ? ['commentary'] : []),
755
+ ];
756
+ return resultOf({
757
+ ticket,
758
+ point,
759
+ result: movedFingerprintSet.length > 0 ? 'CHANGED' : 'CURRENT',
760
+ action: movedFingerprintSet.length > 0 ? 'hold' : 'continue',
761
+ movedFingerprintSet,
762
+ pointer,
763
+ evidence: {
764
+ claim: pointer,
765
+ ...(commentaryMoved && point !== 'BEFORE_CLOSE' ? { observedFingerprintSet: ['commentary'] } : {}),
766
+ },
767
+ identity: fingerprintIdentity(current),
768
+ });
769
+ };
770
+
771
+ const gitText = (projectRoot, args) =>
772
+ execFileSync('git', ['-C', projectRoot, ...args], {
773
+ encoding: 'utf8',
774
+ env: withoutGitLocation(),
775
+ stdio: ['ignore', 'pipe', 'pipe'],
776
+ }).trim();
777
+
778
+ export const targetShaOf = (projectRoot, ref = null) => {
779
+ const candidates = ref
780
+ ? [ref]
781
+ : [
782
+ (() => {
783
+ try {
784
+ return gitText(projectRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
785
+ } catch {
786
+ return null;
787
+ }
788
+ })(),
789
+ 'master',
790
+ 'main',
791
+ ].filter(Boolean);
792
+ for (const candidate of candidates) {
793
+ try {
794
+ return gitText(projectRoot, ['rev-parse', '--verify', candidate]);
795
+ } catch {
796
+ // Try the next conventional target name.
797
+ }
798
+ }
799
+ return null;
800
+ };