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
|
@@ -5,14 +5,79 @@
|
|
|
5
5
|
* run took up?
|
|
6
6
|
*
|
|
7
7
|
* node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
|
|
8
|
+
* node .claude/scripts/revalidate.mjs --point BEFORE_PR --owner-directed [--base origin/master] [--json]
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* BEFORE_PR has two mutually exclusive modes and neither is inferred (RP-94):
|
|
11
|
+
* `--ticket` for a branch that is an item's take-up, `--owner-directed` for
|
|
12
|
+
* owner-directed work or a hotfix that has no item — the path `pr-ship` step 4
|
|
13
|
+
* already named while step 1 could not execute it. Passing both, or neither,
|
|
14
|
+
* is exit 1 — owner-directed-revalidation.test.ts › "refuses both modes at
|
|
15
|
+
* once: exit 1, stderr only, nothing journaled" and › "refuses neither mode:
|
|
16
|
+
* exit 1, and the message names both ways forward".
|
|
11
17
|
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Owner-directed mode runs the `main:<path>` comparison below and nothing
|
|
19
|
+
* else. It resolves no queue config, so no tracker or adapter is reached and
|
|
20
|
+
* no credential is needed — owner-directed-revalidation.test.ts › "needs no
|
|
21
|
+
* tracker credentials: an adapter name that cannot resolve is never reached".
|
|
22
|
+
* A `hold` there is the same exit 2 as the ticketed path's — › "HOLDs when the
|
|
23
|
+
* default branch moved under a path the branch touches".
|
|
24
|
+
*
|
|
25
|
+
* FOUR refusals keep it from becoming a way around the claim chain — exit 1,
|
|
26
|
+
* nothing journalled, each with its own test in that file:
|
|
27
|
+
*
|
|
28
|
+
* - an unresolved `revalidationHold` in this run's state, which is what the
|
|
29
|
+
* ticketed path writes when it holds or answers UNVERIFIABLE — › "refuses
|
|
30
|
+
* when this run carries an unresolved revalidation hold";
|
|
31
|
+
* - a take-up this run declares — › "refuses when the declared run already
|
|
32
|
+
* carries a take-up";
|
|
33
|
+
* - a tracked `.rig/claims/*.json` this branch touches, added, modified,
|
|
34
|
+
* removed or renamed — › "refuses when the branch diff adds a tracked claim
|
|
35
|
+
* record", › "refuses when the branch diff modifies a tracked claim record",
|
|
36
|
+
* › "refuses when the branch RENAMES a claim record — the case
|
|
37
|
+
* --diff-filter=AM could not see" and › "refuses when the branch DELETES its
|
|
38
|
+
* claim record";
|
|
39
|
+
* - `BEFORE_CLOSE` — › "refuses owner-directed at BEFORE_CLOSE — the mode
|
|
40
|
+
* exists for BEFORE_PR only".
|
|
41
|
+
*
|
|
42
|
+
* The first of those is the one this mode most needs, and the first version
|
|
43
|
+
* shipped without it: a ticketed call that had already held was re-run here and
|
|
44
|
+
* exited 0 with nothing in the repository changed.
|
|
45
|
+
*
|
|
46
|
+
* ⚠ Its limits, stated because the mode is a governance surface. What makes a
|
|
47
|
+
* call owner-directed is the CALLER's word plus those four refusals: nothing
|
|
48
|
+
* here can prove an item does not exist. Three specific gaps, each measured
|
|
49
|
+
* rather than reasoned:
|
|
50
|
+
*
|
|
51
|
+
* - with no `RIG_RUN_DIR` there is no run state, so the hold and take-up
|
|
52
|
+
* refusals have nothing to read and cannot fire — and nothing is journalled
|
|
53
|
+
* either. The result says so in `evidence.runState`, and the report on
|
|
54
|
+
* stdout says so out loud — › "says out loud that an undeclared run checked
|
|
55
|
+
* neither the hold nor the take-up".
|
|
56
|
+
* - the claim refusal reads the branch DIFF, so a claim record already on the
|
|
57
|
+
* default branch, or written but not committed, is not seen.
|
|
58
|
+
* - `--base` decides more here than the verdict. The claim comparison that
|
|
59
|
+
* would otherwise survive a wrong base is absent, AND the claim-touch refusal
|
|
60
|
+
* reads the same `mergeBase..HEAD` range — so `--base HEAD` empties the
|
|
61
|
+
* branch diff and disarms that refusal as well as reporting `continue`. Pass
|
|
62
|
+
* the up-to-date `origin/<default>`; `pr-ship` step 1 refreshes it, since
|
|
63
|
+
* this script never talks to a remote itself.
|
|
64
|
+
*
|
|
65
|
+
* One existing checkpoint chain, with one authoritative durable baseline:
|
|
66
|
+
*
|
|
67
|
+
* - `.rig/claims/<ticket>.json` carries versioned, content-blind `scope` and
|
|
68
|
+
* `commentary` fingerprint sets. Scope is authoritative at BEFORE_PR;
|
|
69
|
+
* commentary is observed but does not hold until BEFORE_CLOSE. Missing,
|
|
70
|
+
* untracked or unreadable claim state is `UNVERIFIABLE` and exits 2. So is a
|
|
71
|
+
* tracker whose adapter this script cannot READ (RP-64) — that one means the
|
|
72
|
+
* question was never put, rather than that the claim record is unreadable.
|
|
73
|
+
* ⚠ Reads, precisely: the queue CONFIG failing to resolve at all — an unknown
|
|
74
|
+
* adapter name, a malformed `queue.json` — is exit 1, the refusal path, not a
|
|
75
|
+
* hold, and it is the operator's to fix rather than a claim waiting on a
|
|
76
|
+
* tracker. Pinned in the generator's
|
|
77
|
+
* `test/template/revalidate-adapter.test.ts` — absent in a generated rig —
|
|
78
|
+
* › "refuses an adapter name it cannot resolve with a readable message, not a
|
|
79
|
+
* stack trace" and › "refuses a queue config that is not valid JSON with a
|
|
80
|
+
* readable message, not a stack trace".
|
|
16
81
|
* - `main:<path>` — what the default branch changed since this branch forked
|
|
17
82
|
* (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
|
|
18
83
|
* paths. Cited is a labelled assumption, not a recorded fact: the paths the
|
|
@@ -20,33 +85,49 @@
|
|
|
20
85
|
* record in this run's journal — the files the run said its premises rest
|
|
21
86
|
* on. An unrelated change on the default branch does not hold.
|
|
22
87
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
88
|
+
* `updatedAt` and `takeUps` are still projected into `task` as compatibility
|
|
89
|
+
* evidence, but never contribute a source or action.
|
|
90
|
+
*
|
|
91
|
+
* At BEFORE_CLOSE (AR-135) there is no main comparison: both claim fingerprint
|
|
92
|
+
* sets are authoritative. Workflow state is already inside `claim:scope`,
|
|
93
|
+
* normalised to the adapter's expected claimed state, so an expected claim
|
|
94
|
+
* transition stays current while a close or rollback moves scope. The item comes
|
|
28
95
|
* from the adapter's `find`, which sees closed items where `listEligible`
|
|
29
96
|
* drops them; one the tracker no longer offers at all reads `missing`, and
|
|
30
|
-
*
|
|
31
|
-
* someone already closed the item", › "holds on
|
|
32
|
-
*
|
|
97
|
+
* holds on `claim:scope` (revalidate.test.ts › "holds on claim:scope when
|
|
98
|
+
* someone already closed the item", › "holds on claim:scope when the item was
|
|
99
|
+
* moved back to open"). The result lists the item's dependants (`blocks`)
|
|
33
100
|
* and re-reads each one's state through the same `find` (revalidate.test.ts ›
|
|
34
101
|
* "re-reads each dependant's state, and names one the tracker no longer
|
|
35
102
|
* offers") for the loop's write-back.
|
|
36
103
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
104
|
+
* `outcome --point <P> {--ticket <id> | --owner-directed} --action-changed
|
|
105
|
+
* true|false [--note …]` (AR-136, extended by RP-94) is the second half of the
|
|
106
|
+
* evidence: after the re-read, it appends a `revalidation-outcome` record whose
|
|
107
|
+
* `answers` is the seq of the latest matching `revalidation` at that point in
|
|
108
|
+
* this run — the join a report needs, made by the writer rather than guessed by
|
|
109
|
+
* the reader.
|
|
39
110
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
111
|
+
* **Which revalidation it matches depends on the mode, and the two never
|
|
112
|
+
* cross.** `--ticket` matches by key and skips owner-directed detections
|
|
113
|
+
* outright; `--owner-directed` matches by `mode`, because such a detection
|
|
114
|
+
* carries `ticket: null` and cannot be addressed by key — and a ticketed
|
|
115
|
+
* `--ticket null` must not answer it either. Pinned in the generator's
|
|
116
|
+
* `test/template/owner-directed-revalidation.test.ts` (absent in a generated
|
|
117
|
+
* rig) › "answers an owner-directed hold with an owner-directed outcome", ›
|
|
118
|
+
* "refuses a ticketed outcome aimed at an owner-directed detection" and ›
|
|
119
|
+
* "refuses an owner-directed outcome when a ticketed hold is the only one this
|
|
120
|
+
* run carries — and leaves that hold latched".
|
|
121
|
+
*
|
|
122
|
+
* It refuses without a run, without a matching revalidation, and with any word
|
|
123
|
+
* but `true`/`false`, and writes nothing then. The typed resolution names the
|
|
124
|
+
* stable detection id and clears only the matching run-level hold — and only a
|
|
125
|
+
* hold whose id it actually names, which is why an owner-directed outcome
|
|
126
|
+
* cannot release a ticketed one. Exit 2 on `hold` or `unverifiable`, 0 on
|
|
127
|
+
* `continue`, and 1 when the call cannot be acted on (unknown point, neither
|
|
128
|
+
* mode or both, a base that is not a revision — or, on the paths that reach it,
|
|
129
|
+
* a queue config that does not resolve) — and then nothing is journalled,
|
|
130
|
+
* because a refusal is not an answer.
|
|
50
131
|
*
|
|
51
132
|
* ⚠ It reads `<base>` as it is in this checkout and never updates the remote
|
|
52
133
|
* ref itself; `pr-ship` step 1 does that before calling this. A stale ref
|
|
@@ -56,15 +137,30 @@
|
|
|
56
137
|
*/
|
|
57
138
|
|
|
58
139
|
import { execFileSync } from 'node:child_process';
|
|
140
|
+
import { createHash } from 'node:crypto';
|
|
59
141
|
import { realpathSync } from 'node:fs';
|
|
60
142
|
import { dirname, join } from 'node:path';
|
|
61
143
|
import { fileURLToPath } from 'node:url';
|
|
62
144
|
import { withoutGitLocation } from './git-env.mjs';
|
|
63
145
|
import { readRun, recordEvent } from './run-journal.mjs';
|
|
64
|
-
import {
|
|
146
|
+
import {
|
|
147
|
+
clearRevalidationHold,
|
|
148
|
+
readState,
|
|
149
|
+
readStateForSelection,
|
|
150
|
+
recordRevalidationHold,
|
|
151
|
+
} from './run-state.mjs';
|
|
65
152
|
import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
|
|
66
|
-
import {
|
|
153
|
+
import { takeUpEvidenceOf } from './queue/core.mjs';
|
|
67
154
|
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
|
|
155
|
+
import { projectRootOfConfig } from './queue/index.mjs';
|
|
156
|
+
import {
|
|
157
|
+
CLAIM_SCHEMA_VERSION,
|
|
158
|
+
revalidateClaim,
|
|
159
|
+
targetShaOf,
|
|
160
|
+
unverifiableResult,
|
|
161
|
+
withAdditionalDrift,
|
|
162
|
+
} from './lib/claim-records.mjs';
|
|
163
|
+
import { DEFAULT_SCAN_LIMIT, findSecretValues } from './lib/secrets.mjs';
|
|
68
164
|
|
|
69
165
|
// Derived from the one source, never restated here (AR-137).
|
|
70
166
|
export const POINTS = REVALIDATES;
|
|
@@ -77,6 +173,7 @@ const parseArgs = (argv) => {
|
|
|
77
173
|
outcome: false,
|
|
78
174
|
point: null,
|
|
79
175
|
ticket: null,
|
|
176
|
+
ownerDirected: false,
|
|
80
177
|
base: 'origin/master',
|
|
81
178
|
config: null,
|
|
82
179
|
json: false,
|
|
@@ -87,6 +184,7 @@ const parseArgs = (argv) => {
|
|
|
87
184
|
for (let i = 0; i < argv.length; i += 1) {
|
|
88
185
|
const arg = argv[i];
|
|
89
186
|
if (i === 0 && arg === 'outcome') args.outcome = true;
|
|
187
|
+
else if (arg === '--owner-directed') args.ownerDirected = true;
|
|
90
188
|
else if (arg === '--json') args.json = true;
|
|
91
189
|
else if (arg === '--action-changed') args.actionChanged = argv[++i] ?? null;
|
|
92
190
|
else if (arg === '--note') args.note = argv[++i] ?? null;
|
|
@@ -132,7 +230,7 @@ const citedByPremises = (runDir) => {
|
|
|
132
230
|
.filter((file) => typeof file === 'string' && file !== '');
|
|
133
231
|
};
|
|
134
232
|
|
|
135
|
-
/** The last
|
|
233
|
+
/** The last compatibility marker observed for this item, at any revalidation point. */
|
|
136
234
|
const lastValidationOf = (runDir, id) => {
|
|
137
235
|
if (!runDir) return null;
|
|
138
236
|
const { events } = readRun({ runDir });
|
|
@@ -155,6 +253,223 @@ const invokedDirectly = () => {
|
|
|
155
253
|
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
156
254
|
};
|
|
157
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The adapter's own message, unless it carries something credential-shaped.
|
|
258
|
+
*
|
|
259
|
+
* The messages this is written for name environment VARIABLES rather than their
|
|
260
|
+
* values, and naming them is exactly what the caller acts on. But an adapter is
|
|
261
|
+
* free to put a URL or a response body into a message, and this text is
|
|
262
|
+
* published to stdout and into a verdict a run journals — neither of which
|
|
263
|
+
* `guard-secret-file` or `validate-no-secrets` can see — so it is checked
|
|
264
|
+
* against the one credential vocabulary this repository has before it goes
|
|
265
|
+
* anywhere.
|
|
266
|
+
*
|
|
267
|
+
* ⚠ **It is defence in depth, not a general redacter.** Measured on the shapes
|
|
268
|
+
* an adapter could plausibly produce: an Atlassian token value, an opaque token
|
|
269
|
+
* after a credential keyword, and a token in a query string are caught by
|
|
270
|
+
* `findSecretValues`; `Authorization: Bearer <opaque>` and `Authorization: Basic
|
|
271
|
+
* <base64>` are NOT, and no shape outside the vocabulary is.
|
|
272
|
+
*
|
|
273
|
+
* 🔴 **URL userinfo is matched HERE rather than left to the vocabulary, because
|
|
274
|
+
* it is reachable through the adapter this repository configures.** `jira.mjs`
|
|
275
|
+
* builds its own errors from method, route and status — but its network arm
|
|
276
|
+
* re-raises the underlying error untouched, and `requireCredentials` accepts any
|
|
277
|
+
* `JIRA_BASE_URL` that begins with `https://`, userinfo included. Undici then
|
|
278
|
+
* throws "Request cannot be constructed from a URL that includes credentials:
|
|
279
|
+
* https://user:<password>@host/…". `findSecretValues` does not see that shape,
|
|
280
|
+
* and unlike the pre-RP-64 crash — which put it on stderr — this path PERSISTS
|
|
281
|
+
* it into the run journal. So the reason is withheld on userinfo as well.
|
|
282
|
+
*
|
|
283
|
+
* An earlier version of this comment argued the blind spots were acceptable
|
|
284
|
+
* because no adapter here produces them. That was false, and resting a safety
|
|
285
|
+
* property on a claim about every present and future adapter is the wrong shape
|
|
286
|
+
* of argument regardless.
|
|
287
|
+
*
|
|
288
|
+
* All-or-nothing on purpose: `findSecretValues` never returns the matched text,
|
|
289
|
+
* so redacting in place would need a second matcher, and a partial redacter is
|
|
290
|
+
* where redacters leak.
|
|
291
|
+
*
|
|
292
|
+
* Exported so the control itself is testable rather than only reachable through
|
|
293
|
+
* a subprocess. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
|
|
294
|
+
* — absent in a generated rig — › "publishes a message that names only environment variables"
|
|
295
|
+
* and › "withholds a message carrying a credential-shaped value".
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* Userinfo present in a URL at all — `//<anything but a slash or space>@host`.
|
|
299
|
+
* One forward pass, one negated bounded class, so it cannot backtrack.
|
|
300
|
+
*
|
|
301
|
+
* 🔴 It matches the CLASS, not a list of spellings, and that is the whole
|
|
302
|
+
* lesson of how it got here. It first required `user:pass@`, which published
|
|
303
|
+
* `//<token>@host`. Widened to make the password optional, it published
|
|
304
|
+
* `//:<token>@host` — the shape `https://${JIRA_EMAIL}:${JIRA_API_TOKEN}@host`
|
|
305
|
+
* degrades to when the first variable is unset, so the likeliest accident of
|
|
306
|
+
* the three. Two rounds of enumerating forms; the invariant was always "there
|
|
307
|
+
* is userinfo here", and it is shorter than any enumeration of it.
|
|
308
|
+
*
|
|
309
|
+
* The class excludes `/` and whitespace, which is what keeps an ordinary URL,
|
|
310
|
+
* a bare email address in prose, and a registry path carrying an `@scope`
|
|
311
|
+
* published. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
|
|
312
|
+
* — absent in a generated rig — › "withholds a URL whose userinfo is %s — every
|
|
313
|
+
* shape, not the ones enumerated so far" and › "still publishes a message
|
|
314
|
+
* carrying %s", which are tables rather than cases so a future narrowing that
|
|
315
|
+
* handles the known spellings and reopens the class goes red.
|
|
316
|
+
*/
|
|
317
|
+
const URL_USERINFO = /\/\/[^\s/@]*@/;
|
|
318
|
+
|
|
319
|
+
export const safeReason = (text) => {
|
|
320
|
+
// Scan and publish the SAME prefix: findSecretValues reads at most
|
|
321
|
+
// DEFAULT_SCAN_LIMIT, and publishing more than was scanned would ship the
|
|
322
|
+
// unscanned tail verbatim.
|
|
323
|
+
const scanned = String(text ?? '').slice(0, DEFAULT_SCAN_LIMIT);
|
|
324
|
+
return findSecretValues(scanned).length === 0 && !URL_USERINFO.test(scanned)
|
|
325
|
+
? scanned
|
|
326
|
+
: 'the queue adapter could not be read; its message is withheld because it carries a credential-shaped value';
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The revalidation boundary for a tracker that cannot be read (RP-64).
|
|
331
|
+
*
|
|
332
|
+
* 🔴 It answers `UNVERIFIABLE` and exits 2 — the same hold path a real drift
|
|
333
|
+
* takes, and never 0. "The adapter was unreachable" is not evidence that the
|
|
334
|
+
* branch is still the branch the run took up, and a caller that read it as a
|
|
335
|
+
* pass would carry an unchecked claim into a PR. A drift the adapter DID report
|
|
336
|
+
* still comes back as `hold`, and an unchanged claim still as `continue`;
|
|
337
|
+
* this only replaces the crash. Pinned in the generator's
|
|
338
|
+
* `test/template/revalidate-adapter.test.ts` — absent in a generated rig — ›
|
|
339
|
+
* "holds the same way at BEFORE_CLOSE, which reads the adapter through a different call"
|
|
340
|
+
* and › "still refuses an unusable invocation as before — this did not swallow argument errors".
|
|
341
|
+
*
|
|
342
|
+
* The detection `identity` names the point and the operation and NOT the
|
|
343
|
+
* message, so it is stable across retries of the same outage — which also means
|
|
344
|
+
* two unrelated failures at one operation share an id, and one `outcome`
|
|
345
|
+
* answers both.
|
|
346
|
+
*/
|
|
347
|
+
const answerUnverifiable = ({ runDir, ticket, point, json }, operation, cause) => {
|
|
348
|
+
const result = unverifiableResult({
|
|
349
|
+
ticket: { id: ticket },
|
|
350
|
+
point,
|
|
351
|
+
reason: safeReason(
|
|
352
|
+
`the queue adapter could not be read (${operation}): ${cause?.message ?? cause}`,
|
|
353
|
+
),
|
|
354
|
+
identity: `adapter-unreadable:${operation}`,
|
|
355
|
+
});
|
|
356
|
+
if (runDir) {
|
|
357
|
+
// Announce, never throw: a stale or unwritable RIG_RUN_DIR made these throw
|
|
358
|
+
// INSIDE the catch that was handling the adapter failure, and the run ended
|
|
359
|
+
// on the Node stack trace this whole change exists to remove.
|
|
360
|
+
try {
|
|
361
|
+
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
362
|
+
recordRevalidationHold(runDir, result);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
process.stderr.write(
|
|
365
|
+
`could not journal this revalidation into ${runDir}: ${error?.message ?? error}\n`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (json) {
|
|
370
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
371
|
+
} else {
|
|
372
|
+
process.stdout.write(
|
|
373
|
+
`revalidate ${point}: ${ticket} unverifiable — the queue adapter could not be read (${operation})\n`,
|
|
374
|
+
);
|
|
375
|
+
process.stdout.write(` ${result.evidence.error}\n`);
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
' this is NOT a pass: nothing about the claim was observed. Fix the adapter and run it again.\n',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
process.exit(2);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A tracked claim record, by repository-relative path (RP-94).
|
|
385
|
+
*
|
|
386
|
+
* ⚠ It is a SECOND spelling of the path `claimPathFor` builds, and the two are
|
|
387
|
+
* kept in step by `test/template/owner-directed-revalidation.test.ts` (absent
|
|
388
|
+
* in a generated rig) › "matches the path claimPathFor actually builds, from
|
|
389
|
+
* the repository root and from a nested rig root" — a correspondence check
|
|
390
|
+
* rather than a comment asking the next reader to remember. The leading
|
|
391
|
+
* `(^|/)` is why a rig whose root sits below the git root is still matched;
|
|
392
|
+
* this mode resolves no queue config, so it cannot ask where that root is.
|
|
393
|
+
*/
|
|
394
|
+
const CLAIM_RECORD = /(^|\/)\.rig\/claims\/[^/]+\.json$/;
|
|
395
|
+
|
|
396
|
+
const OWNER_DIRECTED = 'owner-directed';
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The BEFORE_PR verdict for work that has no item (RP-94).
|
|
400
|
+
*
|
|
401
|
+
* It carries the SAME `main:<path>` drift decision the ticketed path reaches —
|
|
402
|
+
* this mode drops the claim comparison because there is no claim, and drops
|
|
403
|
+
* nothing else. `ticket` is `null` rather than a placeholder: a record naming
|
|
404
|
+
* an item that does not exist is worse than one that admits it has none, and
|
|
405
|
+
* every reader downstream distinguishes them by that field.
|
|
406
|
+
*
|
|
407
|
+
* Pinned in the generator's `test/template/owner-directed-revalidation.test.ts`
|
|
408
|
+
* — absent in a generated rig — › "runs BEFORE_PR with no item and no claim
|
|
409
|
+
* when the default branch did not move under the branch" and › "HOLDs when the
|
|
410
|
+
* default branch moved under a path the branch touches".
|
|
411
|
+
*/
|
|
412
|
+
const ownerDirectedResult = ({ point, base, mergeBase, cited, changed, now, runDeclared }) => {
|
|
413
|
+
const source = changed.map((path) => `main:${path}`);
|
|
414
|
+
const held = source.length > 0;
|
|
415
|
+
return {
|
|
416
|
+
schemaVersion: CLAIM_SCHEMA_VERSION,
|
|
417
|
+
// 🔴 `mergeBase` is in the hash, and it is what keeps this id from being a
|
|
418
|
+
// CONSTANT. Without it the digest was `{mode, point, source}` alone — the
|
|
419
|
+
// same value for every `continue` that has ever run, and the same for any
|
|
420
|
+
// two holds naming the same paths. `revalidation-report.mjs` flattens every
|
|
421
|
+
// run into one typed-resolution index, so one `--action-changed false`
|
|
422
|
+
// recorded last week would mark a genuine hold today as already answered:
|
|
423
|
+
// the metric the report exists to produce, quietly wrong. It stays stable
|
|
424
|
+
// across RETRIES of the same checkpoint on the same branch, which is the
|
|
425
|
+
// property `answerUnverifiable`'s identity has and the one that matters.
|
|
426
|
+
id: createHash('sha256')
|
|
427
|
+
.update(JSON.stringify({ mode: OWNER_DIRECTED, point, mergeBase, source }))
|
|
428
|
+
.digest('hex'),
|
|
429
|
+
ticket: null,
|
|
430
|
+
mode: OWNER_DIRECTED,
|
|
431
|
+
point,
|
|
432
|
+
checkpoint: point,
|
|
433
|
+
result: held ? 'CHANGED' : 'CURRENT',
|
|
434
|
+
changed: held,
|
|
435
|
+
source,
|
|
436
|
+
action: held ? 'hold' : 'continue',
|
|
437
|
+
movedFingerprintSet: [],
|
|
438
|
+
sourcePointer: null,
|
|
439
|
+
evidence: {
|
|
440
|
+
claim: 'not compared: owner-directed work has no item, so there is no claim record',
|
|
441
|
+
tracker: 'not read: owner-directed mode resolves no queue adapter',
|
|
442
|
+
// 🔴 "Could not check" is recorded as itself, never as "checked and
|
|
443
|
+
// clean". With no run directory the hold and take-up refusals have
|
|
444
|
+
// nothing to read, and a reader who saw only `continue` would take the
|
|
445
|
+
// pair for having passed.
|
|
446
|
+
runState: runDeclared
|
|
447
|
+
? 'read fail-closed: no unresolved revalidation hold, no declared take-up'
|
|
448
|
+
: 'NOT read: no RIG_RUN_DIR, so neither the revalidation hold nor the take-up was checked',
|
|
449
|
+
},
|
|
450
|
+
observedAt: now,
|
|
451
|
+
task: { changed: null, from: null, to: null },
|
|
452
|
+
main: { base, mergeBase, cited, changed },
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** Every adapter call in this script goes through here, or it can still crash. */
|
|
457
|
+
const readAdapter = async (operation, read, context) => {
|
|
458
|
+
try {
|
|
459
|
+
return await read();
|
|
460
|
+
} catch (error) {
|
|
461
|
+
answerUnverifiable(context, operation, error);
|
|
462
|
+
}
|
|
463
|
+
// Reached only if answerUnverifiable failed to exit. Throwing rather than
|
|
464
|
+
// returning null keeps a null ticket from reaching revalidateClaim and
|
|
465
|
+
// resolving to `continue` — the silent pass this whole change forbids.
|
|
466
|
+
//
|
|
467
|
+
// Outside the catch on purpose, and it carries no `cause`: the caught error
|
|
468
|
+
// is the raw adapter message, the one thing the frame above exists to
|
|
469
|
+
// withhold, and Node's uncaught printer walks a cause chain.
|
|
470
|
+
throw new Error('unreachable: answerUnverifiable did not exit');
|
|
471
|
+
};
|
|
472
|
+
|
|
158
473
|
const refuse = (message) => {
|
|
159
474
|
process.stderr.write(`${message}\n`);
|
|
160
475
|
process.exit(1);
|
|
@@ -170,7 +485,31 @@ if (invokedDirectly()) {
|
|
|
170
485
|
if (!known.includes(args.point)) {
|
|
171
486
|
refuse(`unknown point: ${args.point ?? '(none)'}. This script knows ${known.join(', ')}.`);
|
|
172
487
|
}
|
|
173
|
-
|
|
488
|
+
// RP-94. BEFORE_PR has two modes, and neither is ever inferred. `pr-ship`
|
|
489
|
+
// already named owner-directed work with no item as a legitimate path while
|
|
490
|
+
// this script refused every call without `--ticket`, so that path could not
|
|
491
|
+
// be walked at all. The mode is now stated at the call site — silence is a
|
|
492
|
+
// refusal, not a default, because a mode chosen by absence is a mode nobody
|
|
493
|
+
// reviewed.
|
|
494
|
+
if (args.ticket && args.ownerDirected) {
|
|
495
|
+
refuse(
|
|
496
|
+
'--ticket and --owner-directed are mutually exclusive: a branch that is an item\'s ' +
|
|
497
|
+
'take-up is not owner-directed work. Pass exactly one.',
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (args.ownerDirected) {
|
|
501
|
+
if (args.point !== 'BEFORE_PR') {
|
|
502
|
+
refuse(
|
|
503
|
+
`--owner-directed is a BEFORE_PR mode only; ${args.point} compares the claim record ` +
|
|
504
|
+
'itself and still needs --ticket.',
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
} else if (!args.ticket) {
|
|
508
|
+
refuse(
|
|
509
|
+
'--ticket is required: the item whose take-up this branch is. For owner-directed ' +
|
|
510
|
+
'work or a hotfix that has no item, pass --owner-directed instead.',
|
|
511
|
+
);
|
|
512
|
+
}
|
|
174
513
|
|
|
175
514
|
const runDir = process.env.RIG_RUN_DIR || null;
|
|
176
515
|
|
|
@@ -180,49 +519,260 @@ if (invokedDirectly()) {
|
|
|
180
519
|
refuse(`--action-changed must be true or false, got ${args.actionChanged ?? '(none)'}.`);
|
|
181
520
|
}
|
|
182
521
|
const { events } = readRun({ runDir });
|
|
522
|
+
// RP-94. An owner-directed detection carries `ticket: null`, so it cannot
|
|
523
|
+
// be addressed by key — `String(null)` would also match a literal ticket
|
|
524
|
+
// named "null". It is addressed by MODE instead, which is the only thing
|
|
525
|
+
// that distinguishes it. Without this, a `hold` the owner-directed path
|
|
526
|
+
// returned had no way to be answered at all: the skill's stated exit-2
|
|
527
|
+
// remedy was a command the script refused, which is the same shape of
|
|
528
|
+
// contradiction RP-94 exists to remove.
|
|
529
|
+
const subject = args.ownerDirected ? `${OWNER_DIRECTED} work` : args.ticket;
|
|
183
530
|
const target = [...events]
|
|
184
531
|
.reverse()
|
|
185
532
|
.find(
|
|
186
533
|
(e) =>
|
|
187
534
|
e.kind === 'revalidation' &&
|
|
188
|
-
|
|
189
|
-
|
|
535
|
+
e.data?.point === args.point &&
|
|
536
|
+
(args.ownerDirected
|
|
537
|
+
? e.data?.mode === OWNER_DIRECTED
|
|
538
|
+
: e.data?.mode !== OWNER_DIRECTED &&
|
|
539
|
+
String(e.data?.ticket) === String(args.ticket)),
|
|
190
540
|
);
|
|
191
541
|
if (!target) {
|
|
192
|
-
refuse(`no revalidation of ${
|
|
542
|
+
refuse(`no revalidation of ${subject} at ${args.point} in ${runDir} for this outcome to answer.`);
|
|
193
543
|
}
|
|
544
|
+
const now = new Date().toISOString();
|
|
545
|
+
const actionRequired = args.actionChanged === 'true';
|
|
194
546
|
const record = recordEvent({
|
|
195
547
|
runDir,
|
|
196
548
|
kind: 'revalidation-outcome',
|
|
197
549
|
data: {
|
|
198
|
-
|
|
550
|
+
detectionId: target.data?.id,
|
|
551
|
+
action: actionRequired ? 'semantic decision' : 'continue',
|
|
552
|
+
actionRequired,
|
|
553
|
+
driftOrigin: 'unknown',
|
|
554
|
+
resolvedAt: now,
|
|
555
|
+
// `null`, never a placeholder, for the same reason the detection this
|
|
556
|
+
// answers carries none.
|
|
557
|
+
ticket: args.ownerDirected ? null : args.ticket,
|
|
558
|
+
...(args.ownerDirected ? { mode: OWNER_DIRECTED } : {}),
|
|
199
559
|
point: args.point,
|
|
200
|
-
actionChanged:
|
|
560
|
+
actionChanged: actionRequired,
|
|
201
561
|
note: args.note,
|
|
202
562
|
answers: target.seq,
|
|
203
563
|
},
|
|
204
|
-
now
|
|
564
|
+
now,
|
|
205
565
|
});
|
|
566
|
+
clearRevalidationHold(runDir, target.data?.id);
|
|
206
567
|
process.stdout.write(
|
|
207
568
|
args.json
|
|
208
569
|
? `${JSON.stringify(record, null, 2)}\n`
|
|
209
|
-
: `revalidation-outcome: ${
|
|
570
|
+
: `revalidation-outcome: ${subject} at ${args.point} answers seq ${target.seq} — actionChanged ${args.actionChanged}\n`,
|
|
210
571
|
);
|
|
211
572
|
process.exit(0);
|
|
212
573
|
}
|
|
213
574
|
|
|
575
|
+
if (args.ownerDirected) {
|
|
576
|
+
// RP-94. BEFORE_PR for work that has no item: the same main-vs-branch drift
|
|
577
|
+
// comparison, with no tracker, no adapter and no claim record.
|
|
578
|
+
//
|
|
579
|
+
// 🔴 It sits ABOVE the queue-config resolution on purpose. "This mode reads
|
|
580
|
+
// no tracker" has to be a property of the control flow rather than a
|
|
581
|
+
// promise in a comment — nothing below this block runs, so a missing
|
|
582
|
+
// credential or an adapter name that does not resolve cannot reach it.
|
|
583
|
+
// Pinned by › "needs no tracker credentials: an adapter name that cannot
|
|
584
|
+
// resolve is never reached".
|
|
585
|
+
//
|
|
586
|
+
// The refusals below are what keep the mode from becoming a way around the
|
|
587
|
+
// claim chain, and every one is decided BEFORE anything is journalled: a
|
|
588
|
+
// refusal is not an answer, so it leaves no revalidation record behind.
|
|
589
|
+
//
|
|
590
|
+
// 🔴 Read FAIL-CLOSED. `readState` is the permissive reader and its own
|
|
591
|
+
// header forbids exactly this use — "a corrupt file there may be hiding a
|
|
592
|
+
// persisted stop" — so an unreadable run refuses here instead of reading as
|
|
593
|
+
// "this run declares nothing". The first version used `readState`, and a
|
|
594
|
+
// truncated `state.json` carrying a take-up was measured continuing.
|
|
595
|
+
let state = {};
|
|
596
|
+
if (runDir) {
|
|
597
|
+
try {
|
|
598
|
+
state = readStateForSelection(runDir);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
refuse(
|
|
601
|
+
`--owner-directed refused: this run's state could not be read (${runDir}): ` +
|
|
602
|
+
`${error.message}. An unreadable run may be hiding a stop.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 🔴 The refusal this mode most needs, and the one its first version did
|
|
608
|
+
// not have. A ticketed BEFORE_PR that holds or comes back UNVERIFIABLE
|
|
609
|
+
// latches `revalidationHold`; re-running the same checkpoint here was
|
|
610
|
+
// MEASURED exiting 0 with nothing in the repository changed — the exact
|
|
611
|
+
// bypass RP-94 names. Neither other refusal can fire in that state, and
|
|
612
|
+
// that is structural rather than unlucky: `takeUps` is never populated on
|
|
613
|
+
// the default `plan-md` adapter at all, and the commonest hold is a
|
|
614
|
+
// MISSING claim record, which is precisely when the branch writes none.
|
|
615
|
+
// This one is adapter-independent because `recordRevalidationHold` is.
|
|
616
|
+
const hold = state.revalidationHold;
|
|
617
|
+
if (hold) {
|
|
618
|
+
// A malformed hold still refuses — it is a hold either way — but it must
|
|
619
|
+
// not print `undefined at undefined`, which reads as a broken command
|
|
620
|
+
// rather than as the stop it is.
|
|
621
|
+
const describe = (value, fallback) => (typeof value === 'string' ? value : fallback);
|
|
622
|
+
refuse(
|
|
623
|
+
`--owner-directed refused: this run carries an unresolved revalidation hold ` +
|
|
624
|
+
`(${describe(hold.ticket, 'an unnamed item')} at ` +
|
|
625
|
+
`${describe(hold.checkpoint, 'an unnamed checkpoint')}, ` +
|
|
626
|
+
`${describe(hold.result, 'result unrecorded')}, detection ` +
|
|
627
|
+
`${describe(hold.detectionId, 'unrecorded')}). ` +
|
|
628
|
+
'Resolve it with `revalidate.mjs outcome`; re-running the checkpoint in the other ' +
|
|
629
|
+
'mode is not a resolution, it is the bypass this mode refuses.',
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const takeUps = state.takeUps;
|
|
634
|
+
// A present-but-unreadable take-up record is a refusal, not an empty one:
|
|
635
|
+
// `Object.keys` answers `[]` for a number and for a list, which would turn
|
|
636
|
+
// "this cannot be read" into "there is nothing here".
|
|
637
|
+
if (
|
|
638
|
+
takeUps !== undefined &&
|
|
639
|
+
(typeof takeUps !== 'object' || takeUps === null || Array.isArray(takeUps))
|
|
640
|
+
) {
|
|
641
|
+
refuse(
|
|
642
|
+
`--owner-directed refused: this run's take-up record is not readable ` +
|
|
643
|
+
`(takeUps is ${Array.isArray(takeUps) ? 'a list' : typeof takeUps}, expected an object).`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const takenUp = Object.keys(takeUps ?? {});
|
|
647
|
+
if (takenUp.length > 0) {
|
|
648
|
+
refuse(
|
|
649
|
+
`--owner-directed refused: this run already declares a take-up (${takenUp.join(', ')}). ` +
|
|
650
|
+
'A run holding an item revalidates with --ticket; owner-directed is for work with none.',
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let mergeBase;
|
|
655
|
+
try {
|
|
656
|
+
mergeBase = git(['merge-base', args.base, 'HEAD']).trim();
|
|
657
|
+
} catch (error) {
|
|
658
|
+
refuse(
|
|
659
|
+
`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Any claim record this branch TOUCHES, in either direction.
|
|
664
|
+
//
|
|
665
|
+
// 🔴 `--no-renames`, and no `--diff-filter`, because both narrower forms
|
|
666
|
+
// were measured letting a claim record through. `--diff-filter=AM` reports
|
|
667
|
+
// NOTHING for `git mv .rig/claims/RP-1.json .rig/claims/RP-2.json` — git
|
|
668
|
+
// calls it `R100` — so a branch that demonstrably ends up carrying a claim
|
|
669
|
+
// record passed the check. And a branch that DELETES its claim makes the
|
|
670
|
+
// ticketed call `UNVERIFIABLE`, so excluding `D` left the deletion on the
|
|
671
|
+
// bypass path rather than out of scope. `--no-renames` splits a rename back
|
|
672
|
+
// into its delete and its add, which is what puts both halves in front of
|
|
673
|
+
// the filter. Match the CLASS — "this branch touched the claim store" —
|
|
674
|
+
// rather than enumerating the statuses that class can wear.
|
|
675
|
+
const claimsTouched = pathsOf(
|
|
676
|
+
git(['diff', '--name-only', '--no-renames', '-z', mergeBase, 'HEAD']),
|
|
677
|
+
).filter((path) => CLAIM_RECORD.test(path));
|
|
678
|
+
if (claimsTouched.length > 0) {
|
|
679
|
+
refuse(
|
|
680
|
+
`--owner-directed refused: this branch touches tracked claim records ` +
|
|
681
|
+
`(${claimsTouched.join(', ')}). A branch that writes, moves or removes a claim is ` +
|
|
682
|
+
"an item's take-up; revalidate it with --ticket.",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
|
|
687
|
+
const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
|
|
688
|
+
const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
|
|
689
|
+
const mainChanged = mainPaths.filter((path) => cited.includes(path));
|
|
690
|
+
const result = ownerDirectedResult({
|
|
691
|
+
point: args.point,
|
|
692
|
+
base: args.base,
|
|
693
|
+
mergeBase,
|
|
694
|
+
cited,
|
|
695
|
+
changed: mainChanged,
|
|
696
|
+
now: new Date().toISOString(),
|
|
697
|
+
runDeclared: Boolean(runDir),
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
if (runDir) {
|
|
701
|
+
recordEvent({ runDir, kind: 'revalidation', data: result, now: result.observedAt });
|
|
702
|
+
// 🔴 No run-level revalidation hold is recorded here, and the reason is
|
|
703
|
+
// NOT the one first written down. That said "a hold written here could
|
|
704
|
+
// never be cleared" — which stopped being true the moment `outcome
|
|
705
|
+
// --owner-directed` computed the very id `clearRevalidationHold` matches
|
|
706
|
+
// on. The real reason is narrower: `recordRevalidationHold` requires a
|
|
707
|
+
// string ticket, and this mode has none to give it without inventing
|
|
708
|
+
// one, which is the thing the whole mode refuses to do.
|
|
709
|
+
//
|
|
710
|
+
// ⚠ **State the asymmetry rather than let a reader assume symmetry.** A
|
|
711
|
+
// ticketed hold has TWO stops — the exit code, and a latch that
|
|
712
|
+
// `queue/index.mjs` and `unresolvedBlockingDetectionOf` both read (the
|
|
713
|
+
// latter also requires a string ticket, so it skips this one). An
|
|
714
|
+
// owner-directed hold has ONE: the exit code below. A caller who ignores
|
|
715
|
+
// it is not stopped a second time. Widening the latch to a ticketless
|
|
716
|
+
// hold is a run-state schema change — Tier 2, and not this hotfix's.
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (args.json) {
|
|
720
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
721
|
+
} else {
|
|
722
|
+
const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
|
|
723
|
+
process.stdout.write(
|
|
724
|
+
`revalidate ${args.point}: ${OWNER_DIRECTED} (no item) ${result.action}${detail}\n`,
|
|
725
|
+
);
|
|
726
|
+
if (!runDir) {
|
|
727
|
+
// Loud, on the normal path, not only in the JSON: a run this command
|
|
728
|
+
// could not inspect must not read as a run it inspected and cleared.
|
|
729
|
+
process.stdout.write(
|
|
730
|
+
' ⚠ no RIG_RUN_DIR: the revalidation-hold and take-up refusals were NOT checked,\n' +
|
|
731
|
+
' and nothing was journalled. This is not evidence that neither exists.\n',
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (result.action === 'hold') {
|
|
735
|
+
process.stdout.write(
|
|
736
|
+
' re-read the default branch on those paths before opening or updating the PR,\n' +
|
|
737
|
+
' then record what the re-read concluded:\n' +
|
|
738
|
+
` node .claude/scripts/revalidate.mjs outcome --point ${args.point} --owner-directed --action-changed <true | false>\n`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
process.exit(result.action === 'hold' ? 2 : 0);
|
|
743
|
+
}
|
|
744
|
+
|
|
214
745
|
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
215
746
|
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
216
|
-
|
|
217
|
-
|
|
747
|
+
// 🔴 The queue CONFIG, not the tracker behind it. `readAdapter` covers every
|
|
748
|
+
// adapter CALL, but resolving the config sat outside it, so an unknown
|
|
749
|
+
// adapter name or a malformed `queue.json` still crashed with the raw Node
|
|
750
|
+
// stack trace this script exists to remove — and the `[cause]` chain of the
|
|
751
|
+
// malformed case printed the parse error underneath it.
|
|
752
|
+
//
|
|
753
|
+
// It stays exit 1 rather than becoming `UNVERIFIABLE`: a config the operator
|
|
754
|
+
// has to fix is the command refusing, not a claim held pending a tracker
|
|
755
|
+
// that might come back. `refuse` is the path this file already uses for that.
|
|
756
|
+
let config;
|
|
757
|
+
let adapter;
|
|
758
|
+
try {
|
|
759
|
+
config = loadConfig(configPath);
|
|
760
|
+
adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
761
|
+
} catch (error) {
|
|
762
|
+
refuse(
|
|
763
|
+
`the queue configuration at ${configPath} could not be resolved: ` +
|
|
764
|
+
safeReason(error?.message ?? String(error)),
|
|
765
|
+
);
|
|
766
|
+
}
|
|
218
767
|
const options = optionsWithPlanPath(config.options, configPath);
|
|
768
|
+
const claimRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
219
769
|
|
|
220
770
|
if (args.point === 'BEFORE_CLOSE') {
|
|
221
|
-
const
|
|
771
|
+
const context = { runDir, ticket: args.ticket, point: args.point, json: args.json };
|
|
772
|
+
const ticket = await readAdapter('find', () => adapter.find(args.ticket, options), context);
|
|
222
773
|
const takeUp = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// after BEFORE_PR would otherwise hold this close on the run's own move.
|
|
774
|
+
// Preserve the newest compatibility marker as evidence. This comparison
|
|
775
|
+
// never decides drift; the durable claim below is the authority.
|
|
226
776
|
// ISO strings compare as text; a missing side yields to the other.
|
|
227
777
|
const lastValidation = lastValidationOf(runDir, args.ticket);
|
|
228
778
|
const baseline =
|
|
@@ -233,7 +783,7 @@ if (invokedDirectly()) {
|
|
|
233
783
|
: (lastValidation ?? takeUp);
|
|
234
784
|
const task =
|
|
235
785
|
ticket && baseline !== null
|
|
236
|
-
?
|
|
786
|
+
? takeUpEvidenceOf({ ticket, snapshot: baseline })
|
|
237
787
|
: { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
|
|
238
788
|
// Not found is not "in progress": the tracker no longer offers the item.
|
|
239
789
|
const actual = ticket ? ticket.state : 'missing';
|
|
@@ -243,18 +793,33 @@ if (invokedDirectly()) {
|
|
|
243
793
|
const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
|
|
244
794
|
const dependantState = {};
|
|
245
795
|
for (const dependant of dependants) {
|
|
246
|
-
dependantState[dependant] =
|
|
796
|
+
dependantState[dependant] =
|
|
797
|
+
(await readAdapter('find dependant', () => adapter.find(dependant, options), context))
|
|
798
|
+
?.state ?? 'missing';
|
|
247
799
|
}
|
|
248
|
-
const
|
|
800
|
+
const claim = revalidateClaim({
|
|
801
|
+
projectRoot: claimRoot,
|
|
802
|
+
ticket: ticket ?? { id: args.ticket },
|
|
803
|
+
point: 'BEFORE_CLOSE',
|
|
804
|
+
claimedState: adapter.claimedState,
|
|
805
|
+
// Close has no caller-selected comparison base. Resolve the same default
|
|
806
|
+
// target SELECT pinned, so a missing `origin/master` cannot turn an
|
|
807
|
+
// otherwise current local rig into claim:scope drift.
|
|
808
|
+
targetSha: targetShaOf(claimRoot),
|
|
809
|
+
});
|
|
249
810
|
const result = {
|
|
250
|
-
...
|
|
811
|
+
...claim,
|
|
812
|
+
observedAt: new Date().toISOString(),
|
|
251
813
|
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
252
|
-
state: { expected:
|
|
814
|
+
state: { expected: adapter.claimedState, actual },
|
|
253
815
|
dependants,
|
|
254
816
|
dependantState,
|
|
255
817
|
};
|
|
256
818
|
if (runDir) {
|
|
257
819
|
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
820
|
+
if (result.action === 'hold' || result.action === 'unverifiable') {
|
|
821
|
+
recordRevalidationHold(runDir, result);
|
|
822
|
+
}
|
|
258
823
|
}
|
|
259
824
|
if (args.json) {
|
|
260
825
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -265,7 +830,7 @@ if (invokedDirectly()) {
|
|
|
265
830
|
process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
|
|
266
831
|
}
|
|
267
832
|
}
|
|
268
|
-
process.exit(result.action === 'hold' ? 2 : 0);
|
|
833
|
+
process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
|
|
269
834
|
}
|
|
270
835
|
|
|
271
836
|
let mergeBase;
|
|
@@ -275,33 +840,49 @@ if (invokedDirectly()) {
|
|
|
275
840
|
refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
|
|
276
841
|
}
|
|
277
842
|
|
|
278
|
-
const tickets = await adapter.listEligible(options)
|
|
843
|
+
const tickets = await readAdapter('listEligible', () => adapter.listEligible(options), {
|
|
844
|
+
runDir,
|
|
845
|
+
ticket: args.ticket,
|
|
846
|
+
point: args.point,
|
|
847
|
+
json: args.json,
|
|
848
|
+
});
|
|
279
849
|
const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
|
|
280
850
|
|
|
281
851
|
const snapshot = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// becomes the baseline;
|
|
286
|
-
// here it is a comparison that cannot be made — the run never recorded a
|
|
287
|
-
// take-up for this item, so `null`, not the SELECT point's `false`.
|
|
852
|
+
// BEFORE_PR reports this run's marker snapshot as compatibility evidence.
|
|
853
|
+
// A missing marker is evidence that cannot be compared, but it does not make
|
|
854
|
+
// the authoritative claim unverifiable; `revalidateClaim` decides that.
|
|
288
855
|
const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
|
|
289
|
-
const task = ticket && snapshot !== null ?
|
|
856
|
+
const task = ticket && snapshot !== null ? takeUpEvidenceOf({ ticket, snapshot }) : unverifiable;
|
|
290
857
|
|
|
291
858
|
const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
|
|
292
859
|
const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
|
|
293
860
|
const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
|
|
294
861
|
const mainChanged = mainPaths.filter((path) => cited.includes(path));
|
|
295
862
|
|
|
296
|
-
const
|
|
863
|
+
const claim = revalidateClaim({
|
|
864
|
+
projectRoot: claimRoot,
|
|
865
|
+
ticket: ticket ?? { id: args.ticket },
|
|
866
|
+
point: 'BEFORE_PR',
|
|
867
|
+
claimedState: adapter.claimedState,
|
|
868
|
+
targetSha: targetShaOf(claimRoot, args.base),
|
|
869
|
+
});
|
|
870
|
+
const aggregate = withAdditionalDrift(
|
|
871
|
+
claim,
|
|
872
|
+
mainChanged.map((path) => `main:${path}`),
|
|
873
|
+
);
|
|
297
874
|
const result = {
|
|
298
875
|
...aggregate,
|
|
876
|
+
observedAt: new Date().toISOString(),
|
|
299
877
|
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
300
878
|
main: { base: args.base, mergeBase, cited, changed: mainChanged },
|
|
301
879
|
};
|
|
302
880
|
|
|
303
881
|
if (runDir) {
|
|
304
882
|
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
883
|
+
if (result.action === 'hold' || result.action === 'unverifiable') {
|
|
884
|
+
recordRevalidationHold(runDir, result);
|
|
885
|
+
}
|
|
305
886
|
}
|
|
306
887
|
|
|
307
888
|
if (args.json) {
|
|
@@ -313,5 +894,5 @@ if (invokedDirectly()) {
|
|
|
313
894
|
process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
|
|
314
895
|
}
|
|
315
896
|
}
|
|
316
|
-
process.exit(result.action === 'hold' ? 2 : 0);
|
|
897
|
+
process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
|
|
317
898
|
}
|