create-agent-rig 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -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 +19 -12
- 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 +268 -48
- 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 +19 -12
- 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 +36 -15
- package/templates/release-ledger.json +2 -1
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
import { duplicateOf, fingerprintOf, validateProposal, ownerOfLabels, lifecycleOf } from './core.mjs';
|
|
27
27
|
import { withAsOf } from './as-of.mjs';
|
|
28
28
|
import { recordEscalation, recordTakeUp } from '../run-state.mjs';
|
|
29
|
+
import { recordClaimTransition } from '../lib/claim-records.mjs';
|
|
29
30
|
|
|
30
31
|
export const name = 'jira';
|
|
32
|
+
export const claimedState = 'in-progress';
|
|
31
33
|
|
|
32
34
|
/** Jira's own default priority ladder. An unrecognised name sorts last, never first. */
|
|
33
35
|
const PRIORITY = { highest: 1, high: 2, medium: 3, low: 4, lowest: 5 };
|
|
@@ -76,6 +78,14 @@ export const toTicket = (issue) => {
|
|
|
76
78
|
const labels = fields.labels ?? [];
|
|
77
79
|
const links = fields.issuelinks ?? [];
|
|
78
80
|
const category = statusCategory(fields);
|
|
81
|
+
const comments = Array.isArray(fields.comment?.comments) ? fields.comment.comments : [];
|
|
82
|
+
const commentaryIds = comments
|
|
83
|
+
.map((comment) => comment?.id)
|
|
84
|
+
.filter((id) => id !== undefined && id !== null)
|
|
85
|
+
.map(String);
|
|
86
|
+
const commentaryCount = Number.isInteger(fields.comment?.total)
|
|
87
|
+
? fields.comment.total
|
|
88
|
+
: comments.length;
|
|
79
89
|
|
|
80
90
|
// 🔴 INVARIANT 1: the dependency is the LINK, and the blocker's own status
|
|
81
91
|
// decides. A `blocked` label is a snapshot nobody updates when the blocker
|
|
@@ -116,16 +126,22 @@ export const toTicket = (issue) => {
|
|
|
116
126
|
? Number(fields.priority?.id)
|
|
117
127
|
: 999),
|
|
118
128
|
createdAt: toIso(fields.created),
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// change, edit and comment is Jira's contract, assumed and not checked
|
|
122
|
-
// here. `null` when the search did not carry it — never `''`, which would
|
|
123
|
-
// compare equal to itself and read as "unchanged" where the truth is "not
|
|
124
|
-
// looked".
|
|
129
|
+
// Compatibility evidence only: Jira's last-modified field is retained in
|
|
130
|
+
// `takeUps`, but content-blind claim fingerprints decide drift.
|
|
125
131
|
updatedAt: toIso(fields.updated),
|
|
126
132
|
// Flattened from the document description — the same text this adapter
|
|
127
133
|
// already reads internally, now visible to the shared hygiene checks.
|
|
128
134
|
body: descriptionTextOf(issue) || null,
|
|
135
|
+
commentary: {
|
|
136
|
+
count: commentaryCount,
|
|
137
|
+
ids: commentaryIds,
|
|
138
|
+
// Jira may return only the first page while still declaring the total.
|
|
139
|
+
// A partial set cannot truthfully fingerprint commentary; the shared
|
|
140
|
+
// claim resolver turns this explicit false into UNVERIFIABLE.
|
|
141
|
+
complete:
|
|
142
|
+
commentaryIds.length === commentaryCount &&
|
|
143
|
+
new Set(commentaryIds).size === commentaryIds.length,
|
|
144
|
+
},
|
|
129
145
|
triage: labels.includes('triage'),
|
|
130
146
|
trigger: labels.includes('trigger-auto')
|
|
131
147
|
? 'auto'
|
|
@@ -374,6 +390,7 @@ const FIELDS = [
|
|
|
374
390
|
'updated',
|
|
375
391
|
'issuelinks',
|
|
376
392
|
'description',
|
|
393
|
+
'comment',
|
|
377
394
|
];
|
|
378
395
|
|
|
379
396
|
// --- the adapter contract ------------------------------------------------------
|
|
@@ -529,17 +546,9 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
|
|
|
529
546
|
/**
|
|
530
547
|
* Re-record the item's marker after a write of this adapter's own (AR-140).
|
|
531
548
|
*
|
|
532
|
-
* Every write here
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
|
|
536
|
-
* over that run). So the marker is read back after the write
|
|
537
|
-
* and recorded as the take-up in the declared run; a hold that still fires is
|
|
538
|
-
* a move by something other than this adapter.
|
|
539
|
-
*
|
|
540
|
-
* ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
|
|
541
|
-
* session posts by another route — a REST call by hand, a connector — moves
|
|
542
|
-
* the marker like anyone else's, and the next check holds on it.
|
|
549
|
+
* Every write here moves Jira's `updated`, so it is read back and retained for
|
|
550
|
+
* attribution and compatibility. It does not re-baseline `.rig/claims/` and
|
|
551
|
+
* cannot produce or clear a drift decision.
|
|
543
552
|
*
|
|
544
553
|
* Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
|
|
545
554
|
* and a read-back the tracker refused or a stale run directory is announced on
|
|
@@ -572,7 +581,10 @@ const rebaseline = async (ticket, env) => {
|
|
|
572
581
|
recordMarker(ticket, updatedAt, env);
|
|
573
582
|
};
|
|
574
583
|
|
|
575
|
-
export const claim = async (
|
|
584
|
+
export const claim = async (
|
|
585
|
+
ticket,
|
|
586
|
+
{ transitionId = null, env = process.env, projectRoot = process.cwd() } = {},
|
|
587
|
+
) => {
|
|
576
588
|
if (!transitionId) {
|
|
577
589
|
const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
|
|
578
590
|
const target = available.transitions.find(
|
|
@@ -591,8 +603,18 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
|
|
|
591
603
|
body: { transition: { id: transitionId } },
|
|
592
604
|
env,
|
|
593
605
|
});
|
|
606
|
+
let workflowClaimRecorded = false;
|
|
607
|
+
try {
|
|
608
|
+
workflowClaimRecorded =
|
|
609
|
+
recordClaimTransition({ projectRoot, ticket, claimedState }) !== null;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
process.stderr.write(
|
|
612
|
+
`${ticket.id}: the workflow claim landed, but its durable acknowledgement was NOT recorded — ` +
|
|
613
|
+
`${error.message}\n`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
594
616
|
await rebaseline(ticket, env);
|
|
595
|
-
return { ok: true };
|
|
617
|
+
return { ok: true, workflowClaimRecorded };
|
|
596
618
|
};
|
|
597
619
|
|
|
598
620
|
export const comment = async (ticket, body, { env = process.env } = {}) => {
|
|
@@ -17,6 +17,7 @@ import { withAsOf } from './as-of.mjs';
|
|
|
17
17
|
import { recordEscalation } from '../run-state.mjs';
|
|
18
18
|
|
|
19
19
|
export const name = 'plan-md';
|
|
20
|
+
export const claimedState = 'open';
|
|
20
21
|
|
|
21
22
|
const AGENT_QUEUE = /^##\s+Agent queue\s*$/i;
|
|
22
23
|
const OPERATOR_QUEUE = /^##\s+Operator queue\s*$/i;
|
|
@@ -129,9 +130,10 @@ export const parsePlan = (plan) => {
|
|
|
129
130
|
blocks: [],
|
|
130
131
|
priority: items.length,
|
|
131
132
|
createdAt: null,
|
|
132
|
-
// A flat list carries no marker
|
|
133
|
-
//
|
|
133
|
+
// A flat list carries no compatibility marker; claim fingerprints still
|
|
134
|
+
// provide the authoritative revalidation baseline.
|
|
134
135
|
updatedAt: null,
|
|
136
|
+
commentary: { count: 0, ids: [] },
|
|
135
137
|
triage: MARKERS.triage.test(raw),
|
|
136
138
|
trigger: MARKERS.triggerAuto.test(raw)
|
|
137
139
|
? 'auto'
|
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
*
|
|
7
7
|
* node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* WHAT moved sends the run to re-read everything:
|
|
9
|
+
* One existing checkpoint chain, with one authoritative durable baseline:
|
|
11
10
|
*
|
|
12
|
-
* - `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - `.rig/claims/<ticket>.json` carries versioned, content-blind `scope` and
|
|
12
|
+
* `commentary` fingerprint sets. Scope is authoritative at BEFORE_PR;
|
|
13
|
+
* commentary is observed but does not hold until BEFORE_CLOSE. Missing,
|
|
14
|
+
* untracked or unreadable claim state is `UNVERIFIABLE` and exits 2. So is a
|
|
15
|
+
* tracker whose adapter this script cannot READ (RP-64) — that one means the
|
|
16
|
+
* question was never put, rather than that the claim record is unreadable.
|
|
17
|
+
* ⚠ Reads, precisely: the queue CONFIG failing to resolve at all — an unknown
|
|
18
|
+
* adapter name, a malformed `queue.json` — is exit 1, the refusal path, not a
|
|
19
|
+
* hold, and it is the operator's to fix rather than a claim waiting on a
|
|
20
|
+
* tracker. Pinned in the generator's
|
|
21
|
+
* `test/template/revalidate-adapter.test.ts` — absent in a generated rig —
|
|
22
|
+
* › "refuses an adapter name it cannot resolve with a readable message, not a
|
|
23
|
+
* stack trace" and › "refuses a queue config that is not valid JSON with a
|
|
24
|
+
* readable message, not a stack trace".
|
|
16
25
|
* - `main:<path>` — what the default branch changed since this branch forked
|
|
17
26
|
* (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
|
|
18
27
|
* paths. Cited is a labelled assumption, not a recorded fact: the paths the
|
|
@@ -20,33 +29,34 @@
|
|
|
20
29
|
* record in this run's journal — the files the run said its premises rest
|
|
21
30
|
* on. An unrelated change on the default branch does not hold.
|
|
22
31
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* `updatedAt` and `takeUps` are still projected into `task` as compatibility
|
|
33
|
+
* evidence, but never contribute a source or action.
|
|
34
|
+
*
|
|
35
|
+
* At BEFORE_CLOSE (AR-135) there is no main comparison: both claim fingerprint
|
|
36
|
+
* sets are authoritative. Workflow state is already inside `claim:scope`,
|
|
37
|
+
* normalised to the adapter's expected claimed state, so an expected claim
|
|
38
|
+
* transition stays current while a close or rollback moves scope. The item comes
|
|
28
39
|
* from the adapter's `find`, which sees closed items where `listEligible`
|
|
29
40
|
* drops them; one the tracker no longer offers at all reads `missing`, and
|
|
30
|
-
*
|
|
31
|
-
* someone already closed the item", › "holds on
|
|
32
|
-
*
|
|
41
|
+
* holds on `claim:scope` (revalidate.test.ts › "holds on claim:scope when
|
|
42
|
+
* someone already closed the item", › "holds on claim:scope when the item was
|
|
43
|
+
* moved back to open"). The result lists the item's dependants (`blocks`)
|
|
33
44
|
* and re-reads each one's state through the same `find` (revalidate.test.ts ›
|
|
34
45
|
* "re-reads each dependant's state, and names one the tracker no longer
|
|
35
46
|
* offers") for the loop's write-back.
|
|
36
47
|
*
|
|
37
|
-
* The aggregates are `queue/core.mjs` › beforePrRevalidationOf and
|
|
38
|
-
* beforeCloseRevalidationOf; this file is the I/O around them.
|
|
39
|
-
*
|
|
40
48
|
* `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
|
|
41
49
|
* (AR-136) is the second half of the evidence: after the re-read, it appends a
|
|
42
50
|
* `revalidation-outcome` record whose `answers` is the seq of the latest
|
|
43
51
|
* `revalidation` for that ticket and point in this run — the join a report
|
|
44
52
|
* needs, made by the writer rather than guessed by the reader. It refuses
|
|
45
53
|
* without a run, without a matching revalidation, and with any word but
|
|
46
|
-
* `true`/`false`, and writes nothing then.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
54
|
+
* `true`/`false`, and writes nothing then. The typed resolution names the
|
|
55
|
+
* stable detection id and clears only the matching run-level hold. Exit 2 on
|
|
56
|
+
* `hold` or `unverifiable`, 0 on `continue`, and 1 when the call cannot be
|
|
57
|
+
* acted on (unknown point, no ticket, a base that is not a revision — or, on
|
|
58
|
+
* the paths that reach it, a queue config that does not resolve) — and then
|
|
59
|
+
* nothing is journalled, because a refusal is not an answer.
|
|
50
60
|
*
|
|
51
61
|
* ⚠ It reads `<base>` as it is in this checkout and never updates the remote
|
|
52
62
|
* ref itself; `pr-ship` step 1 does that before calling this. A stale ref
|
|
@@ -61,10 +71,18 @@ import { dirname, join } from 'node:path';
|
|
|
61
71
|
import { fileURLToPath } from 'node:url';
|
|
62
72
|
import { withoutGitLocation } from './git-env.mjs';
|
|
63
73
|
import { readRun, recordEvent } from './run-journal.mjs';
|
|
64
|
-
import { readState } from './run-state.mjs';
|
|
74
|
+
import { clearRevalidationHold, readState, recordRevalidationHold } from './run-state.mjs';
|
|
65
75
|
import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
|
|
66
|
-
import {
|
|
76
|
+
import { takeUpEvidenceOf } from './queue/core.mjs';
|
|
67
77
|
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
|
|
78
|
+
import { projectRootOfConfig } from './queue/index.mjs';
|
|
79
|
+
import {
|
|
80
|
+
revalidateClaim,
|
|
81
|
+
targetShaOf,
|
|
82
|
+
unverifiableResult,
|
|
83
|
+
withAdditionalDrift,
|
|
84
|
+
} from './lib/claim-records.mjs';
|
|
85
|
+
import { DEFAULT_SCAN_LIMIT, findSecretValues } from './lib/secrets.mjs';
|
|
68
86
|
|
|
69
87
|
// Derived from the one source, never restated here (AR-137).
|
|
70
88
|
export const POINTS = REVALIDATES;
|
|
@@ -132,7 +150,7 @@ const citedByPremises = (runDir) => {
|
|
|
132
150
|
.filter((file) => typeof file === 'string' && file !== '');
|
|
133
151
|
};
|
|
134
152
|
|
|
135
|
-
/** The last
|
|
153
|
+
/** The last compatibility marker observed for this item, at any revalidation point. */
|
|
136
154
|
const lastValidationOf = (runDir, id) => {
|
|
137
155
|
if (!runDir) return null;
|
|
138
156
|
const { events } = readRun({ runDir });
|
|
@@ -155,6 +173,150 @@ const invokedDirectly = () => {
|
|
|
155
173
|
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
156
174
|
};
|
|
157
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The adapter's own message, unless it carries something credential-shaped.
|
|
178
|
+
*
|
|
179
|
+
* The messages this is written for name environment VARIABLES rather than their
|
|
180
|
+
* values, and naming them is exactly what the caller acts on. But an adapter is
|
|
181
|
+
* free to put a URL or a response body into a message, and this text is
|
|
182
|
+
* published to stdout and into a verdict a run journals — neither of which
|
|
183
|
+
* `guard-secret-file` or `validate-no-secrets` can see — so it is checked
|
|
184
|
+
* against the one credential vocabulary this repository has before it goes
|
|
185
|
+
* anywhere.
|
|
186
|
+
*
|
|
187
|
+
* ⚠ **It is defence in depth, not a general redacter.** Measured on the shapes
|
|
188
|
+
* an adapter could plausibly produce: an Atlassian token value, an opaque token
|
|
189
|
+
* after a credential keyword, and a token in a query string are caught by
|
|
190
|
+
* `findSecretValues`; `Authorization: Bearer <opaque>` and `Authorization: Basic
|
|
191
|
+
* <base64>` are NOT, and no shape outside the vocabulary is.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 **URL userinfo is matched HERE rather than left to the vocabulary, because
|
|
194
|
+
* it is reachable through the adapter this repository configures.** `jira.mjs`
|
|
195
|
+
* builds its own errors from method, route and status — but its network arm
|
|
196
|
+
* re-raises the underlying error untouched, and `requireCredentials` accepts any
|
|
197
|
+
* `JIRA_BASE_URL` that begins with `https://`, userinfo included. Undici then
|
|
198
|
+
* throws "Request cannot be constructed from a URL that includes credentials:
|
|
199
|
+
* https://user:<password>@host/…". `findSecretValues` does not see that shape,
|
|
200
|
+
* and unlike the pre-RP-64 crash — which put it on stderr — this path PERSISTS
|
|
201
|
+
* it into the run journal. So the reason is withheld on userinfo as well.
|
|
202
|
+
*
|
|
203
|
+
* An earlier version of this comment argued the blind spots were acceptable
|
|
204
|
+
* because no adapter here produces them. That was false, and resting a safety
|
|
205
|
+
* property on a claim about every present and future adapter is the wrong shape
|
|
206
|
+
* of argument regardless.
|
|
207
|
+
*
|
|
208
|
+
* All-or-nothing on purpose: `findSecretValues` never returns the matched text,
|
|
209
|
+
* so redacting in place would need a second matcher, and a partial redacter is
|
|
210
|
+
* where redacters leak.
|
|
211
|
+
*
|
|
212
|
+
* Exported so the control itself is testable rather than only reachable through
|
|
213
|
+
* a subprocess. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
|
|
214
|
+
* — absent in a generated rig — › "publishes a message that names only environment variables"
|
|
215
|
+
* and › "withholds a message carrying a credential-shaped value".
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* Userinfo present in a URL at all — `//<anything but a slash or space>@host`.
|
|
219
|
+
* One forward pass, one negated bounded class, so it cannot backtrack.
|
|
220
|
+
*
|
|
221
|
+
* 🔴 It matches the CLASS, not a list of spellings, and that is the whole
|
|
222
|
+
* lesson of how it got here. It first required `user:pass@`, which published
|
|
223
|
+
* `//<token>@host`. Widened to make the password optional, it published
|
|
224
|
+
* `//:<token>@host` — the shape `https://${JIRA_EMAIL}:${JIRA_API_TOKEN}@host`
|
|
225
|
+
* degrades to when the first variable is unset, so the likeliest accident of
|
|
226
|
+
* the three. Two rounds of enumerating forms; the invariant was always "there
|
|
227
|
+
* is userinfo here", and it is shorter than any enumeration of it.
|
|
228
|
+
*
|
|
229
|
+
* The class excludes `/` and whitespace, which is what keeps an ordinary URL,
|
|
230
|
+
* a bare email address in prose, and a registry path carrying an `@scope`
|
|
231
|
+
* published. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
|
|
232
|
+
* — absent in a generated rig — › "withholds a URL whose userinfo is %s — every
|
|
233
|
+
* shape, not the ones enumerated so far" and › "still publishes a message
|
|
234
|
+
* carrying %s", which are tables rather than cases so a future narrowing that
|
|
235
|
+
* handles the known spellings and reopens the class goes red.
|
|
236
|
+
*/
|
|
237
|
+
const URL_USERINFO = /\/\/[^\s/@]*@/;
|
|
238
|
+
|
|
239
|
+
export const safeReason = (text) => {
|
|
240
|
+
// Scan and publish the SAME prefix: findSecretValues reads at most
|
|
241
|
+
// DEFAULT_SCAN_LIMIT, and publishing more than was scanned would ship the
|
|
242
|
+
// unscanned tail verbatim.
|
|
243
|
+
const scanned = String(text ?? '').slice(0, DEFAULT_SCAN_LIMIT);
|
|
244
|
+
return findSecretValues(scanned).length === 0 && !URL_USERINFO.test(scanned)
|
|
245
|
+
? scanned
|
|
246
|
+
: 'the queue adapter could not be read; its message is withheld because it carries a credential-shaped value';
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The revalidation boundary for a tracker that cannot be read (RP-64).
|
|
251
|
+
*
|
|
252
|
+
* 🔴 It answers `UNVERIFIABLE` and exits 2 — the same hold path a real drift
|
|
253
|
+
* takes, and never 0. "The adapter was unreachable" is not evidence that the
|
|
254
|
+
* branch is still the branch the run took up, and a caller that read it as a
|
|
255
|
+
* pass would carry an unchecked claim into a PR. A drift the adapter DID report
|
|
256
|
+
* still comes back as `hold`, and an unchanged claim still as `continue`;
|
|
257
|
+
* this only replaces the crash. Pinned in the generator's
|
|
258
|
+
* `test/template/revalidate-adapter.test.ts` — absent in a generated rig — ›
|
|
259
|
+
* "holds the same way at BEFORE_CLOSE, which reads the adapter through a different call"
|
|
260
|
+
* and › "still refuses an unusable invocation as before — this did not swallow argument errors".
|
|
261
|
+
*
|
|
262
|
+
* The detection `identity` names the point and the operation and NOT the
|
|
263
|
+
* message, so it is stable across retries of the same outage — which also means
|
|
264
|
+
* two unrelated failures at one operation share an id, and one `outcome`
|
|
265
|
+
* answers both.
|
|
266
|
+
*/
|
|
267
|
+
const answerUnverifiable = ({ runDir, ticket, point, json }, operation, cause) => {
|
|
268
|
+
const result = unverifiableResult({
|
|
269
|
+
ticket: { id: ticket },
|
|
270
|
+
point,
|
|
271
|
+
reason: safeReason(
|
|
272
|
+
`the queue adapter could not be read (${operation}): ${cause?.message ?? cause}`,
|
|
273
|
+
),
|
|
274
|
+
identity: `adapter-unreadable:${operation}`,
|
|
275
|
+
});
|
|
276
|
+
if (runDir) {
|
|
277
|
+
// Announce, never throw: a stale or unwritable RIG_RUN_DIR made these throw
|
|
278
|
+
// INSIDE the catch that was handling the adapter failure, and the run ended
|
|
279
|
+
// on the Node stack trace this whole change exists to remove.
|
|
280
|
+
try {
|
|
281
|
+
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
282
|
+
recordRevalidationHold(runDir, result);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
process.stderr.write(
|
|
285
|
+
`could not journal this revalidation into ${runDir}: ${error?.message ?? error}\n`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (json) {
|
|
290
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
291
|
+
} else {
|
|
292
|
+
process.stdout.write(
|
|
293
|
+
`revalidate ${point}: ${ticket} unverifiable — the queue adapter could not be read (${operation})\n`,
|
|
294
|
+
);
|
|
295
|
+
process.stdout.write(` ${result.evidence.error}\n`);
|
|
296
|
+
process.stdout.write(
|
|
297
|
+
' this is NOT a pass: nothing about the claim was observed. Fix the adapter and run it again.\n',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
process.exit(2);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Every adapter call in this script goes through here, or it can still crash. */
|
|
304
|
+
const readAdapter = async (operation, read, context) => {
|
|
305
|
+
try {
|
|
306
|
+
return await read();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
answerUnverifiable(context, operation, error);
|
|
309
|
+
}
|
|
310
|
+
// Reached only if answerUnverifiable failed to exit. Throwing rather than
|
|
311
|
+
// returning null keeps a null ticket from reaching revalidateClaim and
|
|
312
|
+
// resolving to `continue` — the silent pass this whole change forbids.
|
|
313
|
+
//
|
|
314
|
+
// Outside the catch on purpose, and it carries no `cause`: the caught error
|
|
315
|
+
// is the raw adapter message, the one thing the frame above exists to
|
|
316
|
+
// withhold, and Node's uncaught printer walks a cause chain.
|
|
317
|
+
throw new Error('unreachable: answerUnverifiable did not exit');
|
|
318
|
+
};
|
|
319
|
+
|
|
158
320
|
const refuse = (message) => {
|
|
159
321
|
process.stderr.write(`${message}\n`);
|
|
160
322
|
process.exit(1);
|
|
@@ -191,18 +353,26 @@ if (invokedDirectly()) {
|
|
|
191
353
|
if (!target) {
|
|
192
354
|
refuse(`no revalidation of ${args.ticket} at ${args.point} in ${runDir} for this outcome to answer.`);
|
|
193
355
|
}
|
|
356
|
+
const now = new Date().toISOString();
|
|
357
|
+
const actionRequired = args.actionChanged === 'true';
|
|
194
358
|
const record = recordEvent({
|
|
195
359
|
runDir,
|
|
196
360
|
kind: 'revalidation-outcome',
|
|
197
361
|
data: {
|
|
362
|
+
detectionId: target.data?.id,
|
|
363
|
+
action: actionRequired ? 'semantic decision' : 'continue',
|
|
364
|
+
actionRequired,
|
|
365
|
+
driftOrigin: 'unknown',
|
|
366
|
+
resolvedAt: now,
|
|
198
367
|
ticket: args.ticket,
|
|
199
368
|
point: args.point,
|
|
200
|
-
actionChanged:
|
|
369
|
+
actionChanged: actionRequired,
|
|
201
370
|
note: args.note,
|
|
202
371
|
answers: target.seq,
|
|
203
372
|
},
|
|
204
|
-
now
|
|
373
|
+
now,
|
|
205
374
|
});
|
|
375
|
+
clearRevalidationHold(runDir, target.data?.id);
|
|
206
376
|
process.stdout.write(
|
|
207
377
|
args.json
|
|
208
378
|
? `${JSON.stringify(record, null, 2)}\n`
|
|
@@ -213,16 +383,35 @@ if (invokedDirectly()) {
|
|
|
213
383
|
|
|
214
384
|
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
215
385
|
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
216
|
-
|
|
217
|
-
|
|
386
|
+
// 🔴 The queue CONFIG, not the tracker behind it. `readAdapter` covers every
|
|
387
|
+
// adapter CALL, but resolving the config sat outside it, so an unknown
|
|
388
|
+
// adapter name or a malformed `queue.json` still crashed with the raw Node
|
|
389
|
+
// stack trace this script exists to remove — and the `[cause]` chain of the
|
|
390
|
+
// malformed case printed the parse error underneath it.
|
|
391
|
+
//
|
|
392
|
+
// It stays exit 1 rather than becoming `UNVERIFIABLE`: a config the operator
|
|
393
|
+
// has to fix is the command refusing, not a claim held pending a tracker
|
|
394
|
+
// that might come back. `refuse` is the path this file already uses for that.
|
|
395
|
+
let config;
|
|
396
|
+
let adapter;
|
|
397
|
+
try {
|
|
398
|
+
config = loadConfig(configPath);
|
|
399
|
+
adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
400
|
+
} catch (error) {
|
|
401
|
+
refuse(
|
|
402
|
+
`the queue configuration at ${configPath} could not be resolved: ` +
|
|
403
|
+
safeReason(error?.message ?? String(error)),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
218
406
|
const options = optionsWithPlanPath(config.options, configPath);
|
|
407
|
+
const claimRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
219
408
|
|
|
220
409
|
if (args.point === 'BEFORE_CLOSE') {
|
|
221
|
-
const
|
|
410
|
+
const context = { runDir, ticket: args.ticket, point: args.point, json: args.json };
|
|
411
|
+
const ticket = await readAdapter('find', () => adapter.find(args.ticket, options), context);
|
|
222
412
|
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.
|
|
413
|
+
// Preserve the newest compatibility marker as evidence. This comparison
|
|
414
|
+
// never decides drift; the durable claim below is the authority.
|
|
226
415
|
// ISO strings compare as text; a missing side yields to the other.
|
|
227
416
|
const lastValidation = lastValidationOf(runDir, args.ticket);
|
|
228
417
|
const baseline =
|
|
@@ -233,7 +422,7 @@ if (invokedDirectly()) {
|
|
|
233
422
|
: (lastValidation ?? takeUp);
|
|
234
423
|
const task =
|
|
235
424
|
ticket && baseline !== null
|
|
236
|
-
?
|
|
425
|
+
? takeUpEvidenceOf({ ticket, snapshot: baseline })
|
|
237
426
|
: { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
|
|
238
427
|
// Not found is not "in progress": the tracker no longer offers the item.
|
|
239
428
|
const actual = ticket ? ticket.state : 'missing';
|
|
@@ -243,18 +432,33 @@ if (invokedDirectly()) {
|
|
|
243
432
|
const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
|
|
244
433
|
const dependantState = {};
|
|
245
434
|
for (const dependant of dependants) {
|
|
246
|
-
dependantState[dependant] =
|
|
435
|
+
dependantState[dependant] =
|
|
436
|
+
(await readAdapter('find dependant', () => adapter.find(dependant, options), context))
|
|
437
|
+
?.state ?? 'missing';
|
|
247
438
|
}
|
|
248
|
-
const
|
|
439
|
+
const claim = revalidateClaim({
|
|
440
|
+
projectRoot: claimRoot,
|
|
441
|
+
ticket: ticket ?? { id: args.ticket },
|
|
442
|
+
point: 'BEFORE_CLOSE',
|
|
443
|
+
claimedState: adapter.claimedState,
|
|
444
|
+
// Close has no caller-selected comparison base. Resolve the same default
|
|
445
|
+
// target SELECT pinned, so a missing `origin/master` cannot turn an
|
|
446
|
+
// otherwise current local rig into claim:scope drift.
|
|
447
|
+
targetSha: targetShaOf(claimRoot),
|
|
448
|
+
});
|
|
249
449
|
const result = {
|
|
250
|
-
...
|
|
450
|
+
...claim,
|
|
451
|
+
observedAt: new Date().toISOString(),
|
|
251
452
|
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
252
|
-
state: { expected:
|
|
453
|
+
state: { expected: adapter.claimedState, actual },
|
|
253
454
|
dependants,
|
|
254
455
|
dependantState,
|
|
255
456
|
};
|
|
256
457
|
if (runDir) {
|
|
257
458
|
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
459
|
+
if (result.action === 'hold' || result.action === 'unverifiable') {
|
|
460
|
+
recordRevalidationHold(runDir, result);
|
|
461
|
+
}
|
|
258
462
|
}
|
|
259
463
|
if (args.json) {
|
|
260
464
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -265,7 +469,7 @@ if (invokedDirectly()) {
|
|
|
265
469
|
process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
|
|
266
470
|
}
|
|
267
471
|
}
|
|
268
|
-
process.exit(result.action === 'hold' ? 2 : 0);
|
|
472
|
+
process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
|
|
269
473
|
}
|
|
270
474
|
|
|
271
475
|
let mergeBase;
|
|
@@ -275,33 +479,49 @@ if (invokedDirectly()) {
|
|
|
275
479
|
refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
|
|
276
480
|
}
|
|
277
481
|
|
|
278
|
-
const tickets = await adapter.listEligible(options)
|
|
482
|
+
const tickets = await readAdapter('listEligible', () => adapter.listEligible(options), {
|
|
483
|
+
runDir,
|
|
484
|
+
ticket: args.ticket,
|
|
485
|
+
point: args.point,
|
|
486
|
+
json: args.json,
|
|
487
|
+
});
|
|
279
488
|
const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
|
|
280
489
|
|
|
281
490
|
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`.
|
|
491
|
+
// BEFORE_PR reports this run's marker snapshot as compatibility evidence.
|
|
492
|
+
// A missing marker is evidence that cannot be compared, but it does not make
|
|
493
|
+
// the authoritative claim unverifiable; `revalidateClaim` decides that.
|
|
288
494
|
const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
|
|
289
|
-
const task = ticket && snapshot !== null ?
|
|
495
|
+
const task = ticket && snapshot !== null ? takeUpEvidenceOf({ ticket, snapshot }) : unverifiable;
|
|
290
496
|
|
|
291
497
|
const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
|
|
292
498
|
const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
|
|
293
499
|
const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
|
|
294
500
|
const mainChanged = mainPaths.filter((path) => cited.includes(path));
|
|
295
501
|
|
|
296
|
-
const
|
|
502
|
+
const claim = revalidateClaim({
|
|
503
|
+
projectRoot: claimRoot,
|
|
504
|
+
ticket: ticket ?? { id: args.ticket },
|
|
505
|
+
point: 'BEFORE_PR',
|
|
506
|
+
claimedState: adapter.claimedState,
|
|
507
|
+
targetSha: targetShaOf(claimRoot, args.base),
|
|
508
|
+
});
|
|
509
|
+
const aggregate = withAdditionalDrift(
|
|
510
|
+
claim,
|
|
511
|
+
mainChanged.map((path) => `main:${path}`),
|
|
512
|
+
);
|
|
297
513
|
const result = {
|
|
298
514
|
...aggregate,
|
|
515
|
+
observedAt: new Date().toISOString(),
|
|
299
516
|
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
300
517
|
main: { base: args.base, mergeBase, cited, changed: mainChanged },
|
|
301
518
|
};
|
|
302
519
|
|
|
303
520
|
if (runDir) {
|
|
304
521
|
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
522
|
+
if (result.action === 'hold' || result.action === 'unverifiable') {
|
|
523
|
+
recordRevalidationHold(runDir, result);
|
|
524
|
+
}
|
|
305
525
|
}
|
|
306
526
|
|
|
307
527
|
if (args.json) {
|
|
@@ -313,5 +533,5 @@ if (invokedDirectly()) {
|
|
|
313
533
|
process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
|
|
314
534
|
}
|
|
315
535
|
}
|
|
316
|
-
process.exit(result.action === 'hold' ? 2 : 0);
|
|
536
|
+
process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
|
|
317
537
|
}
|