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.
- package/CHANGELOG.md +180 -0
- package/package.json +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +117 -80
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +84 -16
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +23 -3
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +65 -7
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +2 -2
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +2 -2
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +109 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
- package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +41 -19
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +640 -59
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
- package/templates/agent-os/universal/.claude/settings.json +1 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +117 -80
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +84 -16
- package/templates/agent-os/universal/.codex/hooks.json +1 -1
- package/templates/agent-os/universal/.rig/revalidation.json +10 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
- package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
- package/templates/agent-os/universal/layers.json +5 -0
- package/templates/hash-history.json +104 -29
- package/templates/release-ledger.json +3 -1
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
* actionChanged — catches whose `revalidation-outcome` says true;
|
|
24
24
|
* falseHolds — catches whose outcome says false;
|
|
25
25
|
* unresolved — catches with no outcome at all (the run skipped the re-read).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* A typed outcome answers the stable `detectionId`, including across harness
|
|
27
|
+
* runs; legacy evidence still joins by `answers` within the same run. `noise`
|
|
28
|
+
* counts the sources behind false holds, which is where the mechanism's cost is.
|
|
29
29
|
*
|
|
30
30
|
* The primary metric is `actionChanged`, not `opportunities` or `catches`: a
|
|
31
31
|
* hold that changed nothing is noise, and the report says so by name.
|
|
@@ -37,6 +37,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
37
|
import { mainCheckoutRoot } from './queue/checkout.mjs';
|
|
38
38
|
import { readRun } from './run-journal.mjs';
|
|
39
39
|
import { POINTS } from './lib/revalidation-points.mjs';
|
|
40
|
+
import { typedResolutionOf, typedResolutionsOf } from './lib/revalidation-evidence.mjs';
|
|
40
41
|
|
|
41
42
|
export { POINTS };
|
|
42
43
|
|
|
@@ -56,18 +57,25 @@ export const reportOf = ({ runs, since }) => {
|
|
|
56
57
|
const noise = {};
|
|
57
58
|
const read = [];
|
|
58
59
|
const skipped = [];
|
|
60
|
+
const resolutions = typedResolutionsOf(
|
|
61
|
+
runs.flatMap(({ events = [], error }) => (error ? [] : events)),
|
|
62
|
+
);
|
|
63
|
+
const legacyOutcomes = new Map();
|
|
64
|
+
for (const { run, events = [], error } of runs) {
|
|
65
|
+
if (error) continue;
|
|
66
|
+
for (const event of events) {
|
|
67
|
+
if (event.kind !== 'revalidation-outcome') continue;
|
|
68
|
+
if (Number.isInteger(event.data?.answers)) {
|
|
69
|
+
legacyOutcomes.set(`${run}:${event.data.answers}`, event.data);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
59
73
|
for (const { run, events, error } of runs) {
|
|
60
74
|
if (error) {
|
|
61
75
|
skipped.push({ run, why: error });
|
|
62
76
|
continue;
|
|
63
77
|
}
|
|
64
78
|
read.push(run);
|
|
65
|
-
const outcomes = new Map();
|
|
66
|
-
for (const event of events) {
|
|
67
|
-
if (event.kind === 'revalidation-outcome' && Number.isInteger(event.data?.answers)) {
|
|
68
|
-
outcomes.set(event.data.answers, event.data);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
79
|
for (const event of events) {
|
|
72
80
|
if (event.kind !== 'revalidation') continue;
|
|
73
81
|
// Written as "inside the window", so an `at` that does not parse falls
|
|
@@ -76,13 +84,22 @@ export const reportOf = ({ runs, since }) => {
|
|
|
76
84
|
const bucket = points[event.data?.point];
|
|
77
85
|
if (!bucket) continue;
|
|
78
86
|
bucket.opportunities += 1;
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
const result = event.data?.result;
|
|
88
|
+
const unverifiable = result === 'UNVERIFIABLE' || event.data.changed === null;
|
|
89
|
+
if (unverifiable) bucket.unverifiable += 1;
|
|
90
|
+
const caught =
|
|
91
|
+
typeof result === 'string'
|
|
92
|
+
? result !== 'CURRENT' && result !== 'BASELINE_CREATED'
|
|
93
|
+
: event.data.changed === true;
|
|
94
|
+
if (!caught) continue;
|
|
81
95
|
bucket.catches += 1;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
const typed = typedResolutionOf(resolutions, event);
|
|
97
|
+
const outcome = typed ?? legacyOutcomes.get(`${run}:${event.seq}`);
|
|
98
|
+
const actionRequired = outcome?.actionRequired ?? outcome?.actionChanged;
|
|
99
|
+
if (typeof actionRequired !== 'boolean') bucket.unresolved += 1;
|
|
100
|
+
else if (actionRequired) {
|
|
101
|
+
bucket.actionChanged += 1;
|
|
102
|
+
} else {
|
|
86
103
|
bucket.falseHolds += 1;
|
|
87
104
|
for (const source of Array.isArray(event.data.source) ? event.data.source : []) {
|
|
88
105
|
noise[source] = (noise[source] ?? 0) + 1;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The run's own state — three of the four values `stopConditionOf` asks for and
|
|
3
|
-
* nothing used to answer, plus
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* nothing used to answer, plus compatibility evidence in `takeUps` and a
|
|
4
|
+
* checkpoint refusal in `revalidationHold`. The take-up marker is not a drift
|
|
5
|
+
* authority; `.rig/claims/` is. The fourth stop input, `killSwitch`, is deliberately not here:
|
|
6
6
|
* it is already mechanical in `guard-bash` and scripted in preflight, and a
|
|
7
7
|
* second answer to "is the brake on" is the disagreement `invariants.md`
|
|
8
8
|
* forbids.
|
|
@@ -49,25 +49,39 @@
|
|
|
49
49
|
* per run is the caller's part of the contract, and the `loop` skill states it.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
closeSync,
|
|
54
|
+
constants,
|
|
55
|
+
fstatSync,
|
|
56
|
+
lstatSync,
|
|
57
|
+
openSync,
|
|
58
|
+
opendirSync,
|
|
59
|
+
readFileSync,
|
|
60
|
+
realpathSync,
|
|
61
|
+
renameSync,
|
|
62
|
+
unlinkSync,
|
|
63
|
+
writeFileSync,
|
|
64
|
+
} from 'node:fs';
|
|
53
65
|
import { basename, dirname, join } from 'node:path';
|
|
54
66
|
import { fileURLToPath } from 'node:url';
|
|
55
67
|
|
|
56
68
|
const STATE = 'state.json';
|
|
69
|
+
const MAX_STATE_BYTES = 256 * 1024;
|
|
57
70
|
|
|
58
71
|
/** The state file's path inside a run directory — one definition, not two. */
|
|
59
72
|
export const statePathIn = (runDir) => join(runDir, STATE);
|
|
60
73
|
|
|
61
74
|
/**
|
|
62
|
-
* What
|
|
75
|
+
* What a non-selection caller has recorded so far; `{}` when it has recorded
|
|
76
|
+
* nothing.
|
|
63
77
|
*
|
|
64
78
|
* 🔴 **Unreadable is empty, and that is a decision rather than an oversight.**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
79
|
+
* This is the compatibility reader for writers and diagnostic commands whose
|
|
80
|
+
* established failure mode is "no state". Selection does NOT call it: a
|
|
81
|
+
* corrupt file there may be hiding a persisted stop, so `readStateForSelection`
|
|
82
|
+
* below is deliberately fail-closed. Keeping the two entry points named makes
|
|
83
|
+
* that boundary explicit instead of letting one permissive helper silently
|
|
84
|
+
* decide whether work may start.
|
|
71
85
|
*
|
|
72
86
|
* Note the asymmetry with `run-journal.mjs`, which refuses a broken sequence
|
|
73
87
|
* loudly: the journal's whole job is to be trustworthy evidence, so a journal
|
|
@@ -84,6 +98,61 @@ export const readState = (runDir) => {
|
|
|
84
98
|
}
|
|
85
99
|
};
|
|
86
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Selection's fail-closed view of run state. An absent file is still an empty
|
|
103
|
+
* run, but a present file that cannot be read or interpreted may be hiding a
|
|
104
|
+
* persisted stop such as `revalidationHold` and must not become `{}`.
|
|
105
|
+
*/
|
|
106
|
+
export const readStateForSelection = (runDir) => {
|
|
107
|
+
if (!runDir) return {};
|
|
108
|
+
const statePath = statePathIn(runDir);
|
|
109
|
+
let pathStat;
|
|
110
|
+
try {
|
|
111
|
+
pathStat = lstatSync(statePath);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code === 'ENOENT') return {};
|
|
114
|
+
throw new Error('run state is unreadable', { cause: error });
|
|
115
|
+
}
|
|
116
|
+
if (pathStat.isSymbolicLink()) throw new Error('run state is a symlink');
|
|
117
|
+
if (!pathStat.isFile()) throw new Error('run state is invalid: expected a regular file');
|
|
118
|
+
let fd;
|
|
119
|
+
try {
|
|
120
|
+
fd = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error('run state is unreadable', { cause: error });
|
|
123
|
+
}
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
const stat = fstatSync(fd);
|
|
127
|
+
if (!stat.isFile()) throw new Error('run state is invalid: expected a regular file');
|
|
128
|
+
if (stat.size > MAX_STATE_BYTES) {
|
|
129
|
+
throw new Error(`run state exceeds ${MAX_STATE_BYTES} bytes`);
|
|
130
|
+
}
|
|
131
|
+
const current = lstatSync(statePath);
|
|
132
|
+
if (current.isSymbolicLink()) throw new Error('run state is a symlink');
|
|
133
|
+
if (!current.isFile()) throw new Error('run state is invalid: expected a regular file');
|
|
134
|
+
if (current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
135
|
+
throw new Error('run state changed during validation');
|
|
136
|
+
}
|
|
137
|
+
raw = readFileSync(fd, 'utf8');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (String(error?.message ?? error).startsWith('run state ')) throw error;
|
|
140
|
+
throw new Error('run state is unreadable', { cause: error });
|
|
141
|
+
} finally {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error('run state is corrupt or invalid JSON', { cause: error });
|
|
149
|
+
}
|
|
150
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
151
|
+
throw new Error('run state is invalid: expected a JSON object');
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
};
|
|
155
|
+
|
|
87
156
|
/**
|
|
88
157
|
* Merge `patch` over what is already recorded, and return the result.
|
|
89
158
|
*
|
|
@@ -103,8 +172,8 @@ export const readState = (runDir) => {
|
|
|
103
172
|
* there the failure is loud rather than torn, which is the direction to prefer
|
|
104
173
|
* if it ever has to be handled.
|
|
105
174
|
*/
|
|
106
|
-
|
|
107
|
-
const next = { ...
|
|
175
|
+
const writeState = (runDir, current, patch) => {
|
|
176
|
+
const next = { ...current, ...patch };
|
|
108
177
|
const file = statePathIn(runDir);
|
|
109
178
|
// The temp name carries the pid so a second writer cannot clobber the first
|
|
110
179
|
// one's half-written file — the merge above is still unsafe under two
|
|
@@ -140,6 +209,8 @@ export const updateState = (runDir, patch) => {
|
|
|
140
209
|
return next;
|
|
141
210
|
};
|
|
142
211
|
|
|
212
|
+
export const updateState = (runDir, patch) => writeState(runDir, readState(runDir), patch);
|
|
213
|
+
|
|
143
214
|
/**
|
|
144
215
|
* Count one task that hit a wall, and hand back the new total.
|
|
145
216
|
*
|
|
@@ -203,8 +274,8 @@ export const recordEscalation = (runDir) => {
|
|
|
203
274
|
|
|
204
275
|
/**
|
|
205
276
|
* The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
|
|
206
|
-
* seen at SELECT.
|
|
207
|
-
*
|
|
277
|
+
* seen at SELECT. It is evidence/compatibility state only; it neither decides
|
|
278
|
+
* drift nor whether a durable claim baseline may be created.
|
|
208
279
|
*
|
|
209
280
|
* Per run, like everything else here — a snapshot from yesterday's run is not
|
|
210
281
|
* a take-up this run made. Merged by id, so a second item does not erase the
|
|
@@ -223,6 +294,41 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
|
223
294
|
return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
|
|
224
295
|
};
|
|
225
296
|
|
|
297
|
+
/** Persist a checkpoint refusal so the next selector cannot progress the run. */
|
|
298
|
+
export const recordRevalidationHold = (runDir, detection = {}) => {
|
|
299
|
+
if (!runDir) return null;
|
|
300
|
+
const result = detection.result;
|
|
301
|
+
if (
|
|
302
|
+
typeof detection.ticket !== 'string' ||
|
|
303
|
+
typeof detection.checkpoint !== 'string' ||
|
|
304
|
+
typeof detection.id !== 'string' ||
|
|
305
|
+
!['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(result)
|
|
306
|
+
) {
|
|
307
|
+
throw new Error('run state: revalidation hold needs a ticket, checkpoint, detection id, and blocking result');
|
|
308
|
+
}
|
|
309
|
+
return writeState(
|
|
310
|
+
runDir,
|
|
311
|
+
readStateForSelection(runDir),
|
|
312
|
+
{
|
|
313
|
+
revalidationHold: {
|
|
314
|
+
kind: 'revalidation-hold',
|
|
315
|
+
ticket: detection.ticket,
|
|
316
|
+
checkpoint: detection.checkpoint,
|
|
317
|
+
result,
|
|
318
|
+
detectionId: detection.id,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Clear only the hold the recorded resolution actually answers. */
|
|
325
|
+
export const clearRevalidationHold = (runDir, detectionId) => {
|
|
326
|
+
if (!runDir || typeof detectionId !== 'string') return null;
|
|
327
|
+
const state = readStateForSelection(runDir);
|
|
328
|
+
if (state.revalidationHold?.detectionId !== detectionId) return state;
|
|
329
|
+
return writeState(runDir, state, { revalidationHold: undefined });
|
|
330
|
+
};
|
|
331
|
+
|
|
226
332
|
/**
|
|
227
333
|
* The take-up an EARLIER run recorded for this item, or null (AR-138).
|
|
228
334
|
*
|
|
@@ -232,8 +338,7 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
|
232
338
|
* when this run has no take-up for the item, SELECT asks the sibling run
|
|
233
339
|
* directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
|
|
234
340
|
* `loop` skill declares — and takes the first that recorded one. The answer
|
|
235
|
-
* names the
|
|
236
|
-
* baseline it compared against.
|
|
341
|
+
* names the evidence source in the event; it is never the fingerprint baseline.
|
|
237
342
|
*
|
|
238
343
|
* 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
|
|
239
344
|
* skill declares — and so is the run asking. The first version took every
|
|
@@ -246,45 +351,68 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
|
246
351
|
* another naming looks at no siblings at all, and the limit is the mirror
|
|
247
352
|
* image: an earlier run declared under another naming is not seen here.
|
|
248
353
|
*
|
|
249
|
-
* Bounded
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
354
|
+
* Bounded: the runs root is walked through one directory handle and at most
|
|
355
|
+
* 10 000 entries are looked at, whatever is in there; at most 200 candidate
|
|
356
|
+
* runs are returned. `previousRunEvidence` says when either boundary truncated
|
|
357
|
+
* the search, so authoritative resume detection can fail closed instead of
|
|
358
|
+
* reading "not found in the subset" as "first sight". Compatibility marker
|
|
359
|
+
* lookup remains fail-soft and may discard that completeness signal. Never
|
|
360
|
+
* this run's own state — that is {@link readState}'s answer, and the caller asks
|
|
361
|
+
* it first.
|
|
255
362
|
*/
|
|
256
363
|
const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
|
|
257
364
|
const RUNS_ROOT_ENTRY_BUDGET = 10_000;
|
|
258
365
|
const RUNS_READ_CAP = 200;
|
|
259
366
|
|
|
260
|
-
|
|
261
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The bounded, newest-first sibling run directories that can carry evidence.
|
|
369
|
+
*
|
|
370
|
+
* Enumeration is shared by compatibility take-up lookup and durable journal
|
|
371
|
+
* lookup so they cannot disagree about which runs are in scope. Callers choose
|
|
372
|
+
* their own failure direction: take-up evidence is optional and catches an
|
|
373
|
+
* error; resume detection is authoritative for baseline creation and fails
|
|
374
|
+
* closed when this enumeration cannot be completed.
|
|
375
|
+
*/
|
|
376
|
+
export const previousRunEvidence = (runDir) => {
|
|
377
|
+
if (!runDir) return { runDirs: [], complete: true };
|
|
262
378
|
const root = dirname(runDir);
|
|
263
379
|
const self = basename(runDir);
|
|
264
|
-
if (!RUN_DIR_NAME.test(self)) return
|
|
380
|
+
if (!RUN_DIR_NAME.test(self)) return { runDirs: [], complete: true };
|
|
265
381
|
const names = [];
|
|
266
|
-
let
|
|
267
|
-
|
|
268
|
-
dir = opendirSync(root);
|
|
269
|
-
} catch {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
382
|
+
let reachedEnd = false;
|
|
383
|
+
const dir = opendirSync(root);
|
|
272
384
|
try {
|
|
273
385
|
for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
|
|
274
386
|
const entry = dir.readSync();
|
|
275
|
-
if (entry === null)
|
|
387
|
+
if (entry === null) {
|
|
388
|
+
reachedEnd = true;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
276
391
|
if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
|
|
277
392
|
names.push(entry.name);
|
|
278
393
|
}
|
|
279
394
|
}
|
|
280
|
-
} catch {
|
|
281
|
-
return null;
|
|
282
395
|
} finally {
|
|
283
396
|
dir.closeSync();
|
|
284
397
|
}
|
|
285
398
|
names.sort().reverse();
|
|
286
|
-
|
|
287
|
-
|
|
399
|
+
return {
|
|
400
|
+
runDirs: names.slice(0, RUNS_READ_CAP).map((name) => join(root, name)),
|
|
401
|
+
complete: reachedEnd && names.length <= RUNS_READ_CAP,
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
export const previousRunDirs = (runDir) => previousRunEvidence(runDir).runDirs;
|
|
406
|
+
|
|
407
|
+
export const previousTakeUp = (runDir, id) => {
|
|
408
|
+
if (!runDir || id === undefined || id === null) return null;
|
|
409
|
+
let candidates;
|
|
410
|
+
try {
|
|
411
|
+
candidates = previousRunDirs(runDir);
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
for (const candidate of candidates) {
|
|
288
416
|
let state;
|
|
289
417
|
try {
|
|
290
418
|
state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
|
|
@@ -378,6 +506,20 @@ export const stopInputsOf = (state = {}) => {
|
|
|
378
506
|
refuse('lastDeployVerdict', verdict);
|
|
379
507
|
}
|
|
380
508
|
|
|
509
|
+
const revalidationHold = state.revalidationHold ?? null;
|
|
510
|
+
if (
|
|
511
|
+
revalidationHold !== null &&
|
|
512
|
+
(typeof revalidationHold !== 'object' ||
|
|
513
|
+
Array.isArray(revalidationHold) ||
|
|
514
|
+
revalidationHold.kind !== 'revalidation-hold' ||
|
|
515
|
+
typeof revalidationHold.ticket !== 'string' ||
|
|
516
|
+
typeof revalidationHold.checkpoint !== 'string' ||
|
|
517
|
+
typeof revalidationHold.detectionId !== 'string' ||
|
|
518
|
+
!['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(revalidationHold.result))
|
|
519
|
+
) {
|
|
520
|
+
refuse('revalidationHold', revalidationHold);
|
|
521
|
+
}
|
|
522
|
+
|
|
381
523
|
// `triggersFired` is deliberately absent from this, and its absence is a
|
|
382
524
|
// decision rather than an oversight: `core.mjs` compares `fired !== true`
|
|
383
525
|
// strictly, so a string, a number or a nonsense shape can only ever leave an
|
|
@@ -386,6 +528,7 @@ export const stopInputsOf = (state = {}) => {
|
|
|
386
528
|
return {
|
|
387
529
|
consecutiveEscalations: count,
|
|
388
530
|
lastDeployVerdict: normalised,
|
|
531
|
+
revalidationHold,
|
|
389
532
|
// Any truthy value means exhausted: the only writer stores `true`, and a
|
|
390
533
|
// hand-edit reaching for `"yes"` means yes. A flag has an honest `false`,
|
|
391
534
|
// unlike a count — so unlike `escalations` above, nothing here needs
|