create-agent-rig 0.6.1 → 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 +143 -0
- package/package.json +3 -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 +122 -81
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +24 -8
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +67 -12
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +5 -9
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +20 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +5 -9
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +164 -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 +61 -27
- 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/scripts/unattended-flag.mjs +33 -19
- package/templates/agent-os/universal/.claude/settings.json +1 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +122 -81
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.codex/hooks.json +17 -17
- 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/docs/decisions/spacing-rations-mechanisms.md +15 -0
- package/templates/agent-os/universal/layers.json +6 -0
- package/templates/hash-history.json +71 -28
- package/templates/release-ledger.json +3 -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'
|
|
@@ -286,22 +302,32 @@ const retryDelayMs = (response, attempt) => {
|
|
|
286
302
|
* - a timeout (`timeoutMs`, default 20 s) through an AbortController — a stalled
|
|
287
303
|
* connection used to block selection forever, and a loop that cannot read its
|
|
288
304
|
* queue must stop, not hang;
|
|
289
|
-
* -
|
|
290
|
-
*
|
|
291
|
-
*
|
|
305
|
+
* - safe reads retry 429/502/503/504 at most `MAX_ATTEMPTS`, honouring
|
|
306
|
+
* `Retry-After`; mutating calls do not retry an ambiguous response, because
|
|
307
|
+
* Jira may have applied the write before a proxy returned the error. The
|
|
308
|
+
* search endpoint opts in explicitly: it is a POST at the transport layer and
|
|
309
|
+
* a read at the operation layer;
|
|
292
310
|
* - `retry.sleep` injectable, so a test measures the delay it would have waited
|
|
293
311
|
* instead of waiting it.
|
|
294
312
|
*
|
|
295
313
|
* Pinned in the generator's `test/template/queue-jira.test.ts` (absent in a
|
|
296
314
|
* generated rig) › "hands fetch an AbortSignal", › "rejects naming the timeout
|
|
297
|
-
* and the route when fetch never resolves", › "
|
|
298
|
-
*
|
|
315
|
+
* and the route when fetch never resolves", › "retries a semantically read-only
|
|
316
|
+
* search POST after a 429", › "keeps bounded retry for a safe issue GET", ›
|
|
317
|
+
* "does not retry %s", › "sleeps for the Retry-After the 429 carried, in
|
|
299
318
|
* milliseconds", › "gives up after four consecutive 503s, naming the status and
|
|
300
319
|
* the attempts" and › "does not retry a 401 — a bad credential is not transient".
|
|
301
320
|
*/
|
|
302
321
|
const request = async (
|
|
303
322
|
route,
|
|
304
|
-
{
|
|
323
|
+
{
|
|
324
|
+
method = 'GET',
|
|
325
|
+
body = null,
|
|
326
|
+
env = process.env,
|
|
327
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
328
|
+
retry = {},
|
|
329
|
+
retryTransient = method === 'GET',
|
|
330
|
+
} = {},
|
|
305
331
|
) => {
|
|
306
332
|
const { baseUrl, email, token } = requireCredentials(env);
|
|
307
333
|
const sleep = retry.sleep ?? defaultSleep;
|
|
@@ -334,13 +360,14 @@ const request = async (
|
|
|
334
360
|
clearTimeout(timer);
|
|
335
361
|
}
|
|
336
362
|
if (response.ok) return payload;
|
|
337
|
-
|
|
363
|
+
const retryable = retryTransient && TRANSIENT.has(response.status);
|
|
364
|
+
if (retryable && attempt < MAX_ATTEMPTS) {
|
|
338
365
|
await sleep(retryDelayMs(response, attempt));
|
|
339
366
|
continue;
|
|
340
367
|
}
|
|
341
368
|
// The status alone; never echo the response body, which can carry the token
|
|
342
369
|
// back in an error envelope.
|
|
343
|
-
const attempts =
|
|
370
|
+
const attempts = retryable ? ` after ${attempt} attempts` : '';
|
|
344
371
|
throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}${attempts}`);
|
|
345
372
|
}
|
|
346
373
|
};
|
|
@@ -363,6 +390,7 @@ const FIELDS = [
|
|
|
363
390
|
'updated',
|
|
364
391
|
'issuelinks',
|
|
365
392
|
'description',
|
|
393
|
+
'comment',
|
|
366
394
|
];
|
|
367
395
|
|
|
368
396
|
// --- the adapter contract ------------------------------------------------------
|
|
@@ -443,6 +471,7 @@ export const search = async ({
|
|
|
443
471
|
pages += 1;
|
|
444
472
|
const page = await request('/rest/api/3/search/jql', {
|
|
445
473
|
method: 'POST',
|
|
474
|
+
retryTransient: true,
|
|
446
475
|
body: {
|
|
447
476
|
jql: query,
|
|
448
477
|
maxResults: limit,
|
|
@@ -517,17 +546,9 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
|
|
|
517
546
|
/**
|
|
518
547
|
* Re-record the item's marker after a write of this adapter's own (AR-140).
|
|
519
548
|
*
|
|
520
|
-
* Every write here
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
|
|
524
|
-
* over that run). So the marker is read back after the write
|
|
525
|
-
* and recorded as the take-up in the declared run; a hold that still fires is
|
|
526
|
-
* a move by something other than this adapter.
|
|
527
|
-
*
|
|
528
|
-
* ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
|
|
529
|
-
* session posts by another route — a REST call by hand, a connector — moves
|
|
530
|
-
* 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.
|
|
531
552
|
*
|
|
532
553
|
* Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
|
|
533
554
|
* and a read-back the tracker refused or a stale run directory is announced on
|
|
@@ -560,7 +581,10 @@ const rebaseline = async (ticket, env) => {
|
|
|
560
581
|
recordMarker(ticket, updatedAt, env);
|
|
561
582
|
};
|
|
562
583
|
|
|
563
|
-
export const claim = async (
|
|
584
|
+
export const claim = async (
|
|
585
|
+
ticket,
|
|
586
|
+
{ transitionId = null, env = process.env, projectRoot = process.cwd() } = {},
|
|
587
|
+
) => {
|
|
564
588
|
if (!transitionId) {
|
|
565
589
|
const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
|
|
566
590
|
const target = available.transitions.find(
|
|
@@ -579,8 +603,18 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
|
|
|
579
603
|
body: { transition: { id: transitionId } },
|
|
580
604
|
env,
|
|
581
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
|
+
}
|
|
582
616
|
await rebaseline(ticket, env);
|
|
583
|
-
return { ok: true };
|
|
617
|
+
return { ok: true, workflowClaimRecorded };
|
|
584
618
|
};
|
|
585
619
|
|
|
586
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
|
}
|