create-agent-rig 0.5.0 → 0.6.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 +170 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// has real dependencies, move to an adapter whose tracker can express them
|
|
13
13
|
// (`github-issues`). Ordering the list by hand is not a dependency graph.
|
|
14
14
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
|
|
15
|
+
import { duplicateOf, fingerprintOf, validateProposal, lifecycleOf } from './core.mjs';
|
|
16
|
+
import { withAsOf } from './as-of.mjs';
|
|
16
17
|
import { recordEscalation } from '../run-state.mjs';
|
|
17
18
|
|
|
18
19
|
export const name = 'plan-md';
|
|
@@ -60,6 +61,17 @@ const MARKERS = {
|
|
|
60
61
|
triage: /\[triage\]/i,
|
|
61
62
|
triggerAuto: /\[trigger-auto\]/i,
|
|
62
63
|
triggerHuman: /\[trigger-human\]/i,
|
|
64
|
+
// `[owner:<name>]` — the repository the item belongs to (AR-132), the same
|
|
65
|
+
// fact the tracker adapters read out of an `owner-<name>` label. Case-exact,
|
|
66
|
+
// because the name is compared exactly to `options.owner`.
|
|
67
|
+
owner: /\[owner:([^\]\s]+)\]/,
|
|
68
|
+
// AR-144: the lifecycle vocabulary and the scheduling flag, the same words the
|
|
69
|
+
// tracker adapters read as labels. Case-insensitive, as the tier and trigger
|
|
70
|
+
// markers are (the owner marker is case-exact, because it is compared to a name).
|
|
71
|
+
keepCore: /\[keep-core\]/i,
|
|
72
|
+
reScope: /\[re-scope\]/i,
|
|
73
|
+
obsolete: /\[obsolete\]/i,
|
|
74
|
+
parked: /\[parked\]/i,
|
|
63
75
|
};
|
|
64
76
|
|
|
65
77
|
/**
|
|
@@ -91,7 +103,10 @@ export const parsePlan = (plan) => {
|
|
|
91
103
|
if (!match) continue;
|
|
92
104
|
const raw = match[1];
|
|
93
105
|
const title = raw
|
|
94
|
-
.replace(
|
|
106
|
+
.replace(
|
|
107
|
+
/\[(elevated|triage|trigger-auto|trigger-human|keep-core|re-scope|obsolete|parked|owner:[^\]\s]+)\]/gi,
|
|
108
|
+
'',
|
|
109
|
+
)
|
|
95
110
|
.replace(/\s+/g, ' ')
|
|
96
111
|
.trim();
|
|
97
112
|
items.push({
|
|
@@ -114,12 +129,29 @@ export const parsePlan = (plan) => {
|
|
|
114
129
|
blocks: [],
|
|
115
130
|
priority: items.length,
|
|
116
131
|
createdAt: null,
|
|
132
|
+
// A flat list carries no marker at all, so revalidation at SELECT records
|
|
133
|
+
// `changed: null` for it — a blind spot, never an "unchanged".
|
|
134
|
+
updatedAt: null,
|
|
117
135
|
triage: MARKERS.triage.test(raw),
|
|
118
136
|
trigger: MARKERS.triggerAuto.test(raw)
|
|
119
137
|
? 'auto'
|
|
120
138
|
: MARKERS.triggerHuman.test(raw)
|
|
121
139
|
? 'human'
|
|
122
140
|
: null,
|
|
141
|
+
owner: MARKERS.owner.exec(raw)?.[1] ?? null,
|
|
142
|
+
// The markers present, handed to the one precedence rule (`core.mjs` ›
|
|
143
|
+
// lifecycleOf) rather than re-deriving it here. Hygiene cannot report a
|
|
144
|
+
// contradiction on this adapter: a flat list carries no labels for it to read.
|
|
145
|
+
...lifecycleOf(
|
|
146
|
+
[
|
|
147
|
+
['keep-core', MARKERS.keepCore],
|
|
148
|
+
['re-scope', MARKERS.reScope],
|
|
149
|
+
['obsolete', MARKERS.obsolete],
|
|
150
|
+
['parked', MARKERS.parked],
|
|
151
|
+
]
|
|
152
|
+
.filter(([, marker]) => marker.test(raw))
|
|
153
|
+
.map(([label]) => label),
|
|
154
|
+
),
|
|
123
155
|
});
|
|
124
156
|
}
|
|
125
157
|
return items;
|
|
@@ -190,10 +222,20 @@ export const claim = (ticket) => ({
|
|
|
190
222
|
'its own worktree and say so in the journal.',
|
|
191
223
|
});
|
|
192
224
|
|
|
225
|
+
/** One item by position; a flat list has no closed state, so an absent line is `null`. */
|
|
226
|
+
export const find = (id, options = {}) =>
|
|
227
|
+
parsePlan(readPlan(options)).find((ticket) => ticket.id === String(id)) ?? null;
|
|
228
|
+
|
|
193
229
|
export const close = (ticket, { prUrl = null, planPath: p } = {}) => {
|
|
194
230
|
const file = p ?? 'PLAN.md';
|
|
195
|
-
|
|
196
|
-
|
|
231
|
+
const before = readFileSync(file, 'utf8');
|
|
232
|
+
const present = parsePlan(before).some((item) => item.id === String(ticket.id));
|
|
233
|
+
writeFileSync(file, closeInPlan(before, ticket.id));
|
|
234
|
+
// `transitioned` means the line was there and is now gone — an id the plan
|
|
235
|
+
// never carried closed nothing, and saying otherwise would publish a close
|
|
236
|
+
// that changed no state (AR-135).
|
|
237
|
+
const gone = !parsePlan(readFileSync(file, 'utf8')).some((item) => item.id === String(ticket.id));
|
|
238
|
+
return { ok: true, prUrl, transitioned: present && gone };
|
|
197
239
|
};
|
|
198
240
|
|
|
199
241
|
/** A flat list has no comment thread; the journal is where this lands. */
|
|
@@ -242,8 +284,10 @@ export const triageItemFor = (proposal) => {
|
|
|
242
284
|
`- **part to change** — ${proposal.part}`,
|
|
243
285
|
`- **proposed change** — ${proposal.change}`,
|
|
244
286
|
`- **how the next run proves it** — ${proposal.proof}`,
|
|
287
|
+
...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
|
|
245
288
|
'',
|
|
246
289
|
`fingerprint: ${fingerprint}`,
|
|
290
|
+
...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
|
|
247
291
|
'',
|
|
248
292
|
'The loop proposes; the owner patches. Self-applying a change to its own',
|
|
249
293
|
'rulebook is how an unattended run drifts irreversibly.',
|
|
@@ -316,8 +360,26 @@ export const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
|
316
360
|
const bulletFor = (item, proposal, seen) =>
|
|
317
361
|
`- **${oneLine(item.title)}** — finding: ${oneLine(proposal.finding)} · ` +
|
|
318
362
|
`part: ${oneLine(proposal.part)} · proof: ${oneLine(proposal.proof)} · ` +
|
|
363
|
+
`${proposal.measured ? `measured: ${oneLine(proposal.measured)} · inferred: ${oneLine(proposal.inferred)} · ` : ''}` +
|
|
364
|
+
`${proposal.asOf ? `asOf: ${proposal.asOf} · ` : ''}` +
|
|
319
365
|
`fingerprint: \`${item.fingerprint}\` · seen ×${seen}`;
|
|
320
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The proposals on file, as `{ id, body }` — every Operator-queue line carrying
|
|
369
|
+
* this adapter's trailing field shape, the whole line as the body so `asOfOf`
|
|
370
|
+
* and `citedPathsOf` can read it. An absent Operator queue lists nothing.
|
|
371
|
+
*/
|
|
372
|
+
export const listProposals = ({ planPath: p } = {}) => {
|
|
373
|
+
const plan = readFileSync(p ?? 'PLAN.md', 'utf8');
|
|
374
|
+
const { found, lines, start, end } = sectionRange(plan, OPERATOR_QUEUE);
|
|
375
|
+
if (!found) return [];
|
|
376
|
+
const proposals = [];
|
|
377
|
+
for (let i = start; i < end; i += 1) {
|
|
378
|
+
if (TAIL.test(lines[i])) proposals.push({ id: String(i), body: lines[i] });
|
|
379
|
+
}
|
|
380
|
+
return proposals;
|
|
381
|
+
};
|
|
382
|
+
|
|
321
383
|
/**
|
|
322
384
|
* File a proposal into the **Operator queue** — never the Agent queue.
|
|
323
385
|
*
|
|
@@ -387,7 +449,8 @@ const bulletFor = (item, proposal, seen) =>
|
|
|
387
449
|
* the bullet stays unselectable — but a reader sees it under the wrong
|
|
388
450
|
* heading.
|
|
389
451
|
*/
|
|
390
|
-
export const proposeTriage = (
|
|
452
|
+
export const proposeTriage = (rawProposal, { planPath: p } = {}) => {
|
|
453
|
+
const proposal = withAsOf(rawProposal);
|
|
391
454
|
const item = triageItemFor(proposal);
|
|
392
455
|
const file = p ?? 'PLAN.md';
|
|
393
456
|
const plan = readFileSync(file, 'utf8');
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
3
|
+
/**
|
|
4
|
+
* Revalidation at BEFORE_PR — is the branch about to ship still the branch the
|
|
5
|
+
* run took up?
|
|
6
|
+
*
|
|
7
|
+
* node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Two sources, compared and named separately, because a hold that cannot say
|
|
10
|
+
* WHAT moved sends the run to re-read everything:
|
|
11
|
+
*
|
|
12
|
+
* - `task:updatedAt` — the item's marker now, read through the queue adapter,
|
|
13
|
+
* against the take-up snapshot `queue/index.mjs next` recorded in the run's
|
|
14
|
+
* `state.json` (`takeUps`, AR-133). No snapshot, no marker or no run → that
|
|
15
|
+
* source is `null`: not looked, never "unchanged".
|
|
16
|
+
* - `main:<path>` — what the default branch changed since this branch forked
|
|
17
|
+
* (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
|
|
18
|
+
* paths. Cited is a labelled assumption, not a recorded fact: the paths the
|
|
19
|
+
* branch itself touches, plus every `blockers[].file` of a `check-premises`
|
|
20
|
+
* record in this run's journal — the files the run said its premises rest
|
|
21
|
+
* on. An unrelated change on the default branch does not hold.
|
|
22
|
+
*
|
|
23
|
+
* At BEFORE_CLOSE (AR-135) there is no git and no main: the sources are
|
|
24
|
+
* `task:updatedAt` against the LAST VALIDATION — the `task.to` of this run's
|
|
25
|
+
* latest `revalidation` event for the item, falling back to the take-up
|
|
26
|
+
* snapshot — and `task:state`, which is expected `in-progress` at close and
|
|
27
|
+
* is a change when someone closed the item or moved it back. The item comes
|
|
28
|
+
* from the adapter's `find`, which sees closed items where `listEligible`
|
|
29
|
+
* drops them; one the tracker no longer offers at all reads `missing`, and
|
|
30
|
+
* either holds on `task:state` (revalidate.test.ts › "holds on task:state when
|
|
31
|
+
* someone already closed the item", › "holds on task:state when the tracker no
|
|
32
|
+
* longer offers the item"). The result lists the item's dependants (`blocks`)
|
|
33
|
+
* and re-reads each one's state through the same `find` (revalidate.test.ts ›
|
|
34
|
+
* "re-reads each dependant's state, and names one the tracker no longer
|
|
35
|
+
* offers") for the loop's write-back.
|
|
36
|
+
*
|
|
37
|
+
* The aggregates are `queue/core.mjs` › beforePrRevalidationOf and
|
|
38
|
+
* beforeCloseRevalidationOf; this file is the I/O around them.
|
|
39
|
+
*
|
|
40
|
+
* `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
|
|
41
|
+
* (AR-136) is the second half of the evidence: after the re-read, it appends a
|
|
42
|
+
* `revalidation-outcome` record whose `answers` is the seq of the latest
|
|
43
|
+
* `revalidation` for that ticket and point in this run — the join a report
|
|
44
|
+
* needs, made by the writer rather than guessed by the reader. It refuses
|
|
45
|
+
* without a run, without a matching revalidation, and with any word but
|
|
46
|
+
* `true`/`false`, and writes nothing then. Exit 2 on `hold`, 0 on `continue` and `unverifiable`, 1 when
|
|
47
|
+
* the arguments cannot be acted on (unknown point, no ticket, a base that is
|
|
48
|
+
* not a revision) — and then nothing is journalled, because a refusal is not
|
|
49
|
+
* an answer.
|
|
50
|
+
*
|
|
51
|
+
* ⚠ It reads `<base>` as it is in this checkout and never updates the remote
|
|
52
|
+
* ref itself; `pr-ship` step 1 does that before calling this. A stale ref
|
|
53
|
+
* makes this compare against yesterday's main and report `continue` — see
|
|
54
|
+
* revalidate.test.ts › "reads the ref as it is: a stale origin/master reports
|
|
55
|
+
* continue".
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { execFileSync } from 'node:child_process';
|
|
59
|
+
import { realpathSync } from 'node:fs';
|
|
60
|
+
import { dirname, join } from 'node:path';
|
|
61
|
+
import { fileURLToPath } from 'node:url';
|
|
62
|
+
import { withoutGitLocation } from './git-env.mjs';
|
|
63
|
+
import { readRun, recordEvent } from './run-journal.mjs';
|
|
64
|
+
import { readState } from './run-state.mjs';
|
|
65
|
+
import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
|
|
66
|
+
import { beforeCloseRevalidationOf, beforePrRevalidationOf, revalidationOf } from './queue/core.mjs';
|
|
67
|
+
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
|
|
68
|
+
|
|
69
|
+
// Derived from the one source, never restated here (AR-137).
|
|
70
|
+
export const POINTS = REVALIDATES;
|
|
71
|
+
|
|
72
|
+
const revisionOrNull = (value) =>
|
|
73
|
+
typeof value === 'string' && value !== '' && !value.startsWith('-') ? value : null;
|
|
74
|
+
|
|
75
|
+
const parseArgs = (argv) => {
|
|
76
|
+
const args = {
|
|
77
|
+
outcome: false,
|
|
78
|
+
point: null,
|
|
79
|
+
ticket: null,
|
|
80
|
+
base: 'origin/master',
|
|
81
|
+
config: null,
|
|
82
|
+
json: false,
|
|
83
|
+
actionChanged: null,
|
|
84
|
+
note: null,
|
|
85
|
+
bad: null,
|
|
86
|
+
};
|
|
87
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
88
|
+
const arg = argv[i];
|
|
89
|
+
if (i === 0 && arg === 'outcome') args.outcome = true;
|
|
90
|
+
else if (arg === '--json') args.json = true;
|
|
91
|
+
else if (arg === '--action-changed') args.actionChanged = argv[++i] ?? null;
|
|
92
|
+
else if (arg === '--note') args.note = argv[++i] ?? null;
|
|
93
|
+
else if (arg === '--point') args.point = argv[++i] ?? null;
|
|
94
|
+
else if (arg === '--ticket') {
|
|
95
|
+
// The id reaches git-free paths only, but on github-issues it becomes a
|
|
96
|
+
// `gh` argv element: a value starting with `-` would be read as an option.
|
|
97
|
+
const value = revisionOrNull(argv[++i]);
|
|
98
|
+
if (value === null) args.bad = arg;
|
|
99
|
+
else args.ticket = value;
|
|
100
|
+
}
|
|
101
|
+
else if (arg === '--config') args.config = argv[++i] ?? null;
|
|
102
|
+
else if (arg === '--base') {
|
|
103
|
+
const value = revisionOrNull(argv[++i]);
|
|
104
|
+
if (value === null) args.bad = arg;
|
|
105
|
+
else args.base = value;
|
|
106
|
+
} else if (args.bad === null) args.bad = arg;
|
|
107
|
+
}
|
|
108
|
+
return args;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const git = (args) =>
|
|
112
|
+
execFileSync('git', args, {
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
env: withoutGitLocation(),
|
|
115
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
116
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const pathsOf = (raw) => raw.split('\0').filter(Boolean);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The paths a `check-premises` record in this run named. Only that gate: a
|
|
123
|
+
* reviewer blocker names where a finding is, not what the task rests on.
|
|
124
|
+
*/
|
|
125
|
+
const citedByPremises = (runDir) => {
|
|
126
|
+
if (!runDir) return [];
|
|
127
|
+
const { decisions } = readRun({ runDir });
|
|
128
|
+
return decisions
|
|
129
|
+
.filter((record) => record.gate === 'check-premises')
|
|
130
|
+
.flatMap((record) => (Array.isArray(record.blockers) ? record.blockers : []))
|
|
131
|
+
.map((blocker) => blocker?.file)
|
|
132
|
+
.filter((file) => typeof file === 'string' && file !== '');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The last validation's marker for this item: the latest revalidation event, any point. */
|
|
136
|
+
const lastValidationOf = (runDir, id) => {
|
|
137
|
+
if (!runDir) return null;
|
|
138
|
+
const { events } = readRun({ runDir });
|
|
139
|
+
const last = [...events]
|
|
140
|
+
.reverse()
|
|
141
|
+
.find((e) => e.kind === 'revalidation' && String(e.data?.ticket) === String(id));
|
|
142
|
+
const to = last?.data?.task?.to;
|
|
143
|
+
return typeof to === 'string' ? to : null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const invokedDirectly = () => {
|
|
147
|
+
if (!process.argv[1]) return false;
|
|
148
|
+
const real = (p) => {
|
|
149
|
+
try {
|
|
150
|
+
return realpathSync(p);
|
|
151
|
+
} catch {
|
|
152
|
+
return p;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const refuse = (message) => {
|
|
159
|
+
process.stderr.write(`${message}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (invokedDirectly()) {
|
|
164
|
+
const args = parseArgs(process.argv.slice(2));
|
|
165
|
+
if (args.bad !== null) refuse(`unrecognised or unusable argument: ${args.bad}`);
|
|
166
|
+
// An outcome may answer any point, SELECT included — that one is written by
|
|
167
|
+
// `queue/index.mjs next`, so it is not in POINTS, which names what THIS
|
|
168
|
+
// script can revalidate.
|
|
169
|
+
const known = args.outcome ? ALL_POINTS : POINTS;
|
|
170
|
+
if (!known.includes(args.point)) {
|
|
171
|
+
refuse(`unknown point: ${args.point ?? '(none)'}. This script knows ${known.join(', ')}.`);
|
|
172
|
+
}
|
|
173
|
+
if (!args.ticket) refuse('--ticket is required: the item whose take-up this branch is.');
|
|
174
|
+
|
|
175
|
+
const runDir = process.env.RIG_RUN_DIR || null;
|
|
176
|
+
|
|
177
|
+
if (args.outcome) {
|
|
178
|
+
if (!runDir) refuse('outcome needs RIG_RUN_DIR: an outcome answers a revalidation in a run, and there is none.');
|
|
179
|
+
if (args.actionChanged !== 'true' && args.actionChanged !== 'false') {
|
|
180
|
+
refuse(`--action-changed must be true or false, got ${args.actionChanged ?? '(none)'}.`);
|
|
181
|
+
}
|
|
182
|
+
const { events } = readRun({ runDir });
|
|
183
|
+
const target = [...events]
|
|
184
|
+
.reverse()
|
|
185
|
+
.find(
|
|
186
|
+
(e) =>
|
|
187
|
+
e.kind === 'revalidation' &&
|
|
188
|
+
String(e.data?.ticket) === String(args.ticket) &&
|
|
189
|
+
e.data?.point === args.point,
|
|
190
|
+
);
|
|
191
|
+
if (!target) {
|
|
192
|
+
refuse(`no revalidation of ${args.ticket} at ${args.point} in ${runDir} for this outcome to answer.`);
|
|
193
|
+
}
|
|
194
|
+
const record = recordEvent({
|
|
195
|
+
runDir,
|
|
196
|
+
kind: 'revalidation-outcome',
|
|
197
|
+
data: {
|
|
198
|
+
ticket: args.ticket,
|
|
199
|
+
point: args.point,
|
|
200
|
+
actionChanged: args.actionChanged === 'true',
|
|
201
|
+
note: args.note,
|
|
202
|
+
answers: target.seq,
|
|
203
|
+
},
|
|
204
|
+
now: new Date().toISOString(),
|
|
205
|
+
});
|
|
206
|
+
process.stdout.write(
|
|
207
|
+
args.json
|
|
208
|
+
? `${JSON.stringify(record, null, 2)}\n`
|
|
209
|
+
: `revalidation-outcome: ${args.ticket} at ${args.point} answers seq ${target.seq} — actionChanged ${args.actionChanged}\n`,
|
|
210
|
+
);
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
215
|
+
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
216
|
+
const config = loadConfig(configPath);
|
|
217
|
+
const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
218
|
+
const options = optionsWithPlanPath(config.options, configPath);
|
|
219
|
+
|
|
220
|
+
if (args.point === 'BEFORE_CLOSE') {
|
|
221
|
+
const ticket = await adapter.find(args.ticket, options);
|
|
222
|
+
const takeUp = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
|
|
223
|
+
// The NEWER of the two, not the last validation first (AR-140): an adapter
|
|
224
|
+
// re-records the take-up after each write of its own, and a comment posted
|
|
225
|
+
// after BEFORE_PR would otherwise hold this close on the run's own move.
|
|
226
|
+
// ISO strings compare as text; a missing side yields to the other.
|
|
227
|
+
const lastValidation = lastValidationOf(runDir, args.ticket);
|
|
228
|
+
const baseline =
|
|
229
|
+
lastValidation !== null && takeUp !== null
|
|
230
|
+
? takeUp > lastValidation
|
|
231
|
+
? takeUp
|
|
232
|
+
: lastValidation
|
|
233
|
+
: (lastValidation ?? takeUp);
|
|
234
|
+
const task =
|
|
235
|
+
ticket && baseline !== null
|
|
236
|
+
? revalidationOf({ ticket, snapshot: baseline })
|
|
237
|
+
: { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
|
|
238
|
+
// Not found is not "in progress": the tracker no longer offers the item.
|
|
239
|
+
const actual = ticket ? ticket.state : 'missing';
|
|
240
|
+
// The dependants' state is RE-READ, not copied off the item: what this close
|
|
241
|
+
// releases is only what is still waiting, and a dependant somebody closed
|
|
242
|
+
// ahead of its blocker is reported as such for the write-back.
|
|
243
|
+
const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
|
|
244
|
+
const dependantState = {};
|
|
245
|
+
for (const dependant of dependants) {
|
|
246
|
+
dependantState[dependant] = (await adapter.find(dependant, options))?.state ?? 'missing';
|
|
247
|
+
}
|
|
248
|
+
const aggregate = beforeCloseRevalidationOf({ ticket: args.ticket, task, state: actual });
|
|
249
|
+
const result = {
|
|
250
|
+
...aggregate,
|
|
251
|
+
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
252
|
+
state: { expected: 'in-progress', actual },
|
|
253
|
+
dependants,
|
|
254
|
+
dependantState,
|
|
255
|
+
};
|
|
256
|
+
if (runDir) {
|
|
257
|
+
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
258
|
+
}
|
|
259
|
+
if (args.json) {
|
|
260
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
261
|
+
} else {
|
|
262
|
+
const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
|
|
263
|
+
process.stdout.write(`revalidate BEFORE_CLOSE: ${args.ticket} ${result.action}${detail}\n`);
|
|
264
|
+
if (result.action === 'hold') {
|
|
265
|
+
process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
process.exit(result.action === 'hold' ? 2 : 0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let mergeBase;
|
|
272
|
+
try {
|
|
273
|
+
mergeBase = git(['merge-base', args.base, 'HEAD']).trim();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tickets = await adapter.listEligible(options);
|
|
279
|
+
const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
|
|
280
|
+
|
|
281
|
+
const snapshot = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
|
|
282
|
+
// At SELECT a missing snapshot is a first sight only when no earlier run
|
|
283
|
+
// took the item up either — `queue/index.mjs` asks `previousTakeUp` (AR-138);
|
|
284
|
+
// here the question is this run's own take-up, which SELECT wrote — and it
|
|
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`.
|
|
288
|
+
const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
|
|
289
|
+
const task = ticket && snapshot !== null ? revalidationOf({ ticket, snapshot }) : unverifiable;
|
|
290
|
+
|
|
291
|
+
const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
|
|
292
|
+
const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
|
|
293
|
+
const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
|
|
294
|
+
const mainChanged = mainPaths.filter((path) => cited.includes(path));
|
|
295
|
+
|
|
296
|
+
const aggregate = beforePrRevalidationOf({ ticket: args.ticket, task, mainChanged });
|
|
297
|
+
const result = {
|
|
298
|
+
...aggregate,
|
|
299
|
+
task: { changed: task.changed, from: task.task.from, to: task.task.to },
|
|
300
|
+
main: { base: args.base, mergeBase, cited, changed: mainChanged },
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (runDir) {
|
|
304
|
+
recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (args.json) {
|
|
308
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
309
|
+
} else {
|
|
310
|
+
const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
|
|
311
|
+
process.stdout.write(`revalidate BEFORE_PR: ${args.ticket} ${result.action}${detail}\n`);
|
|
312
|
+
if (result.action === 'hold') {
|
|
313
|
+
process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
process.exit(result.action === 'hold' ? 2 : 0);
|
|
317
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
3
|
+
/**
|
|
4
|
+
* The Revalidation Experiment's report — what the evidence log says, over the
|
|
5
|
+
* run directories of this rig since a date (AR-136).
|
|
6
|
+
*
|
|
7
|
+
* node .claude/scripts/revalidation-report.mjs --since <ISO date> [--runs <dir>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* It reads `<runs>/<run-id>/` — `--runs <dir>`, or by default `.claude/runs/`
|
|
10
|
+
* under the MAIN checkout, resolved through `queue/checkout.mjs` so a report
|
|
11
|
+
* run from a linked worktree reads the runs the loop actually declared
|
|
12
|
+
* (revalidation-evidence.test.ts › "reads the main checkout's runs by default,
|
|
13
|
+
* even from a linked worktree") — through `run-journal.mjs` › readRun, the same
|
|
14
|
+
* reader every gate uses, with the same refusal: a journal whose sequence is
|
|
15
|
+
* broken is not read. Such a run is COUNTED under `skipped`, with the reason,
|
|
16
|
+
* never dropped silently; a report that quietly narrowed its own base would be
|
|
17
|
+
* the kind of number the journal README says will be believed.
|
|
18
|
+
*
|
|
19
|
+
* Per point (SELECT, BEFORE_PR, BEFORE_CLOSE), over `revalidation` events at
|
|
20
|
+
* or after `since`:
|
|
21
|
+
* opportunities — every event; catches — `changed: true`;
|
|
22
|
+
* unverifiable — `changed: null`;
|
|
23
|
+
* actionChanged — catches whose `revalidation-outcome` says true;
|
|
24
|
+
* falseHolds — catches whose outcome says false;
|
|
25
|
+
* unresolved — catches with no outcome at all (the run skipped the re-read).
|
|
26
|
+
* An outcome answers the revalidation whose seq its `answers` names, in the
|
|
27
|
+
* SAME run — an outcome cannot reach across runs. `noise` counts the sources
|
|
28
|
+
* behind the false holds, which is where the mechanism's cost is.
|
|
29
|
+
*
|
|
30
|
+
* The primary metric is `actionChanged`, not `opportunities` or `catches`: a
|
|
31
|
+
* hold that changed nothing is noise, and the report says so by name.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readdirSync, realpathSync, statSync } from 'node:fs';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { mainCheckoutRoot } from './queue/checkout.mjs';
|
|
38
|
+
import { readRun } from './run-journal.mjs';
|
|
39
|
+
import { POINTS } from './lib/revalidation-points.mjs';
|
|
40
|
+
|
|
41
|
+
export { POINTS };
|
|
42
|
+
|
|
43
|
+
const emptyCounts = () => ({
|
|
44
|
+
opportunities: 0,
|
|
45
|
+
catches: 0,
|
|
46
|
+
unverifiable: 0,
|
|
47
|
+
actionChanged: 0,
|
|
48
|
+
falseHolds: 0,
|
|
49
|
+
unresolved: 0,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** The report over already-read runs — pure, so the counting is testable alone. */
|
|
53
|
+
export const reportOf = ({ runs, since }) => {
|
|
54
|
+
const sinceMs = Date.parse(since);
|
|
55
|
+
const points = Object.fromEntries(POINTS.map((point) => [point, emptyCounts()]));
|
|
56
|
+
const noise = {};
|
|
57
|
+
const read = [];
|
|
58
|
+
const skipped = [];
|
|
59
|
+
for (const { run, events, error } of runs) {
|
|
60
|
+
if (error) {
|
|
61
|
+
skipped.push({ run, why: error });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
read.push(run);
|
|
65
|
+
const outcomes = new Map();
|
|
66
|
+
for (const event of events) {
|
|
67
|
+
if (event.kind === 'revalidation-outcome' && Number.isInteger(event.data?.answers)) {
|
|
68
|
+
outcomes.set(event.data.answers, event.data);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
if (event.kind !== 'revalidation') continue;
|
|
73
|
+
// Written as "inside the window", so an `at` that does not parse falls
|
|
74
|
+
// OUT — the NaN comparison would otherwise count it in.
|
|
75
|
+
if (!(Date.parse(event.at) >= sinceMs)) continue;
|
|
76
|
+
const bucket = points[event.data?.point];
|
|
77
|
+
if (!bucket) continue;
|
|
78
|
+
bucket.opportunities += 1;
|
|
79
|
+
if (event.data.changed === null) bucket.unverifiable += 1;
|
|
80
|
+
if (event.data.changed !== true) continue;
|
|
81
|
+
bucket.catches += 1;
|
|
82
|
+
const outcome = outcomes.get(event.seq);
|
|
83
|
+
if (!outcome) bucket.unresolved += 1;
|
|
84
|
+
else if (outcome.actionChanged === true) bucket.actionChanged += 1;
|
|
85
|
+
else {
|
|
86
|
+
bucket.falseHolds += 1;
|
|
87
|
+
for (const source of Array.isArray(event.data.source) ? event.data.source : []) {
|
|
88
|
+
noise[source] = (noise[source] ?? 0) + 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const totals = emptyCounts();
|
|
94
|
+
for (const counts of Object.values(points)) {
|
|
95
|
+
for (const key of Object.keys(totals)) totals[key] += counts[key];
|
|
96
|
+
}
|
|
97
|
+
return { since, points, noise, totals, runs: { read: read.length, skipped } };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Every run directory under `runsDir`, read or refused — never both, never neither. */
|
|
101
|
+
export const readRuns = (runsDir) => {
|
|
102
|
+
let names;
|
|
103
|
+
try {
|
|
104
|
+
names = readdirSync(runsDir).filter((name) => statSync(join(runsDir, name)).isDirectory());
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new Error(`cannot list runs in ${runsDir}: ${error.message}`, { cause: error });
|
|
107
|
+
}
|
|
108
|
+
return names.sort().map((run) => {
|
|
109
|
+
try {
|
|
110
|
+
const { events } = readRun({ runDir: join(runsDir, run) });
|
|
111
|
+
return { run, events };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { run, error: String(error?.message ?? error) };
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const render = (report) => {
|
|
119
|
+
const line = (name, c) =>
|
|
120
|
+
`${name}: ${c.opportunities} opportunities, ${c.catches} catches, ${c.actionChanged} actionChanged, ` +
|
|
121
|
+
`${c.falseHolds} falseHolds, ${c.unresolved} unresolved, ${c.unverifiable} unverifiable`;
|
|
122
|
+
const lines = [`revalidation since ${report.since}`];
|
|
123
|
+
for (const point of POINTS) lines.push(line(point, report.points[point]));
|
|
124
|
+
lines.push(line('totals', report.totals));
|
|
125
|
+
const noise = Object.entries(report.noise);
|
|
126
|
+
lines.push(
|
|
127
|
+
noise.length === 0
|
|
128
|
+
? 'noise: none'
|
|
129
|
+
: `noise: ${noise.map(([source, count]) => `${source} ×${count}`).join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
lines.push(`runs: ${report.runs.read} read, ${report.runs.skipped.length} skipped`);
|
|
132
|
+
for (const { run, why } of report.runs.skipped) lines.push(` skipped ${run} — ${why}`);
|
|
133
|
+
return `${lines.join('\n')}\n`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const parseArgs = (argv) => {
|
|
137
|
+
const args = { since: null, runs: null, json: false, bad: null };
|
|
138
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
139
|
+
const arg = argv[i];
|
|
140
|
+
if (arg === '--json') args.json = true;
|
|
141
|
+
else if (arg === '--since') args.since = argv[++i] ?? null;
|
|
142
|
+
else if (arg === '--runs') args.runs = argv[++i] ?? null;
|
|
143
|
+
else if (args.bad === null) args.bad = arg;
|
|
144
|
+
}
|
|
145
|
+
return args;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const invokedDirectly = () => {
|
|
149
|
+
if (!process.argv[1]) return false;
|
|
150
|
+
const real = (p) => {
|
|
151
|
+
try {
|
|
152
|
+
return realpathSync(p);
|
|
153
|
+
} catch {
|
|
154
|
+
return p;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (invokedDirectly()) {
|
|
161
|
+
const args = parseArgs(process.argv.slice(2));
|
|
162
|
+
const refuse = (message) => {
|
|
163
|
+
process.stderr.write(`${message}\n`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
};
|
|
166
|
+
if (args.bad !== null) refuse(`unrecognised argument: ${args.bad}`);
|
|
167
|
+
if (!args.since || Number.isNaN(Date.parse(args.since))) {
|
|
168
|
+
refuse(`--since needs an ISO date (got ${args.since ?? '(none)'}); a report with no window reports nothing honest.`);
|
|
169
|
+
}
|
|
170
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
171
|
+
const runsDir =
|
|
172
|
+
args.runs ?? join(mainCheckoutRoot(join(scriptsDir, '..', '..')), '.claude', 'runs');
|
|
173
|
+
let runs;
|
|
174
|
+
try {
|
|
175
|
+
runs = readRuns(runsDir);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
refuse(error.message);
|
|
178
|
+
}
|
|
179
|
+
const report = reportOf({ runs, since: new Date(args.since).toISOString() });
|
|
180
|
+
process.stdout.write(args.json ? `${JSON.stringify(report, null, 2)}\n` : render(report));
|
|
181
|
+
}
|