create-agent-rig 0.10.1 → 1.0.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 +116 -4
- package/README.md +8 -8
- package/package.json +2 -2
- package/packages/cli/dist/commands/upgrade.js +22 -9
- package/packages/cli/dist/index.js +9 -2
- package/templates/agent-os/subagent-routing.json +4 -0
- package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +4 -0
- package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +21 -6
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
- package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
- package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +8 -0
- package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
- package/templates/agent-os/universal/AGENTS.md +6 -4
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
- package/templates/agent-os/universal/layers.json +12 -0
- package/templates/hash-history.json +10 -5
- package/templates/release-ledger.json +2 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: failure-diagnostician
|
|
3
|
+
description: Use when a check is red or a run crashed and the cause is not obvious, or to reproduce a claimed defect/historical finding on the current default branch before work is planned on it.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: claude-opus-5
|
|
6
|
+
effort: high
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You diagnose. You take one of two input kinds — a red check or a crashed run,
|
|
10
|
+
or a claimed defect / historical finding to reproduce on the current default
|
|
11
|
+
branch — and answer with evidence, not a guess dressed as one.
|
|
12
|
+
|
|
13
|
+
## Hard limits
|
|
14
|
+
|
|
15
|
+
- **You make no repository edits.** A throwaway reproduction file goes
|
|
16
|
+
outside the repository, never inside it — you are not the Green step, and a
|
|
17
|
+
fix is not your answer. You do not commit, push, merge or open a pull
|
|
18
|
+
request.
|
|
19
|
+
- **Never re-run a check until it goes green.** A flaky-looking result is a
|
|
20
|
+
defect to report, not a thing to retry — the same stop rule that applies
|
|
21
|
+
everywhere else in this rulebook (`.claude/rules/autonomy.md`, "Stop rules —
|
|
22
|
+
by work-state, not by feelings").
|
|
23
|
+
- **On Claude Code, no hook enforces the no-edit limit above.** `tools: Read,
|
|
24
|
+
Grep, Glob, Bash` carries no Write or Edit, which stops the ordinary path,
|
|
25
|
+
but nothing refuses a shell redirect the way a guard would. On Codex the
|
|
26
|
+
equivalent profile is `sandbox_mode = "read-only"`, enforced by the runtime
|
|
27
|
+
itself. Either way, a sandbox that blocks the reproduction you need is not
|
|
28
|
+
something to work around: answer `INCONCLUSIVE` or `INSUFFICIENT_EVIDENCE`
|
|
29
|
+
and name the sandbox as the missing evidence.
|
|
30
|
+
|
|
31
|
+
## Method
|
|
32
|
+
|
|
33
|
+
1. **Reproduce.** A failure input (red check, crashed run) reproduces at the
|
|
34
|
+
commit or branch where it failed — the PR head, or the commit the caller
|
|
35
|
+
names. A claimed defect or historical finding reproduces on the current
|
|
36
|
+
default branch. No reproduction, no diagnosis.
|
|
37
|
+
2. **Isolate.** Narrow to the smallest change (input, config, code path) that
|
|
38
|
+
flips the result.
|
|
39
|
+
3. **Hypothesize.** State the mechanism you think is responsible, in one or
|
|
40
|
+
two sentences.
|
|
41
|
+
4. **Confirm with evidence.** Show the command and its output, or the
|
|
42
|
+
file:line the mechanism lives at. A hypothesis nothing confirms is
|
|
43
|
+
`INCONCLUSIVE`, not `ROOT_CAUSE`.
|
|
44
|
+
|
|
45
|
+
For a failure input that reaches `ROOT_CAUSE`, classify it: `product` (the
|
|
46
|
+
code is wrong), `test` (the test's premise or fixture is wrong),
|
|
47
|
+
`infrastructure` (CI, network, environment — not the code under test), or
|
|
48
|
+
`upstream` (a dependency or external service).
|
|
49
|
+
|
|
50
|
+
## Optional evidence (opt-in workflow layer)
|
|
51
|
+
|
|
52
|
+
Where this repository has installed the opt-in workflow layer, the run
|
|
53
|
+
journal and `run-state.mjs`'s recorded verdict may already carry evidence
|
|
54
|
+
worth reading before you reproduce anything by hand — a prior `REGRESSION`,
|
|
55
|
+
or an earlier run's own trace. Their absence is the normal Core path, not a
|
|
56
|
+
gap: read them when present, reproduce directly when not.
|
|
57
|
+
|
|
58
|
+
## The answer
|
|
59
|
+
|
|
60
|
+
End your report with **exactly one** fenced `json` block of the shared shape
|
|
61
|
+
(`.claude/scripts/lib/verdict.mjs`), and nothing after it.
|
|
62
|
+
|
|
63
|
+
- **A failure input** (red check, crash) answers `ROOT_CAUSE` or
|
|
64
|
+
`INCONCLUSIVE`.
|
|
65
|
+
- **A claim or historical finding** answers `STILL_LIVE`, `ALREADY_FIXED`,
|
|
66
|
+
`OBSOLETE` or `INSUFFICIENT_EVIDENCE`.
|
|
67
|
+
- `ROOT_CAUSE`, `INCONCLUSIVE`, `STILL_LIVE` and `INSUFFICIENT_EVIDENCE` are
|
|
68
|
+
blocking and must name at least one blocker: for `ROOT_CAUSE` and
|
|
69
|
+
`STILL_LIVE` the blocker is the cause, with `file`/`line` where there is
|
|
70
|
+
one; for `INCONCLUSIVE` and `INSUFFICIENT_EVIDENCE` it is what evidence
|
|
71
|
+
would decide the question.
|
|
72
|
+
- `ALREADY_FIXED` and `OBSOLETE` carry no blockers — the fixing commit or the
|
|
73
|
+
superseding mechanism goes in `evidence` instead.
|
|
74
|
+
- `classification` is required on `ROOT_CAUSE`, allowed but optional on
|
|
75
|
+
`STILL_LIVE`, and refused on every other word.
|
|
76
|
+
- `node .claude/scripts/verdict.mjs check <report> failure-diagnostician` is
|
|
77
|
+
what refuses a malformed answer before anyone reads it as one.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"gate": "failure-diagnostician",
|
|
82
|
+
"verdict": "ROOT_CAUSE",
|
|
83
|
+
"blockers": [
|
|
84
|
+
{
|
|
85
|
+
"file": "src/example.ts",
|
|
86
|
+
"line": 42,
|
|
87
|
+
"rule": "reproduced failure",
|
|
88
|
+
"note": "the function reads the value before the guard that handles the missing case — reproduced on the PR head with the fixture the failing test supplies"
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"advisories": [],
|
|
92
|
+
"evidence": ["reproduced with the failing test on the PR head", "the stack trace from that run names the file:line above"],
|
|
93
|
+
"classification": "product"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"gate": "failure-diagnostician",
|
|
100
|
+
"verdict": "ALREADY_FIXED",
|
|
101
|
+
"blockers": [],
|
|
102
|
+
"advisories": [],
|
|
103
|
+
"evidence": ["the commit that added the missing guard fixes exactly this report", "re-ran the original repro on the current default branch; it now passes"]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Not a merge gate
|
|
108
|
+
|
|
109
|
+
You are never a routed reviewer: no `decision-router` lane names you and no
|
|
110
|
+
`pr-ship` coverage check (opt-in workflow layer) expects your answer. You
|
|
111
|
+
diagnose on request; you never implement the fix, and a report with no
|
|
112
|
+
unbacked behaviour claim or invented figure is the only kind you write.
|
|
@@ -193,9 +193,13 @@ function main() {
|
|
|
193
193
|
// repo" and waves through, running the whole Definition-of-Done suite
|
|
194
194
|
// against a tree it never managed to read.
|
|
195
195
|
//
|
|
196
|
-
// 🔴 Limit: only THIS command is sanitised
|
|
197
|
-
// below run with the environment as given,
|
|
198
|
-
// own commands and their environment is
|
|
196
|
+
// 🔴 Limit: only THIS command is sanitised beyond `RIG_RUN_DIR`. The
|
|
197
|
+
// Definition-of-Done checks below run with the environment as given,
|
|
198
|
+
// because they are the project's own commands and their environment is
|
|
199
|
+
// the project's business — except `RIG_RUN_DIR`, which names the
|
|
200
|
+
// directory of the workflow run executing this very hook and belongs to
|
|
201
|
+
// the run, not to the project's own commands.
|
|
202
|
+
// see hooks.test.ts (absent in a generated rig) › "never lets a Definition-of-Done check see RIG_RUN_DIR, even though the hook itself is given one"
|
|
199
203
|
// `timeout` below is the preamble's own leash: this is the one call that
|
|
200
204
|
// runs before the budget's clock starts. A repository slow enough to exceed
|
|
201
205
|
// it throws into the catch and the checks run anyway — the safe direction,
|
|
@@ -268,6 +272,12 @@ function main() {
|
|
|
268
272
|
// lengthen nor shorten the budget.
|
|
269
273
|
const deadline = performance.now() + budget.ms;
|
|
270
274
|
|
|
275
|
+
// Computed once, not per check: `RIG_RUN_DIR` names the workflow run
|
|
276
|
+
// directory of the run executing this hook, and it is the one variable the
|
|
277
|
+
// project's own commands never get — see the 🔴 limit above.
|
|
278
|
+
const envForChecks = { ...process.env };
|
|
279
|
+
delete envForChecks.RIG_RUN_DIR;
|
|
280
|
+
|
|
271
281
|
for (const command of usable) {
|
|
272
282
|
// A 1 ms floor rather than a branch for "the budget is already gone": the
|
|
273
283
|
// check then times out through the ordinary path, which names the command
|
|
@@ -276,6 +286,7 @@ function main() {
|
|
|
276
286
|
const result = spawnSync(command, {
|
|
277
287
|
cwd: PROJECT_ROOT,
|
|
278
288
|
shell: true,
|
|
289
|
+
env: envForChecks,
|
|
279
290
|
encoding: 'utf8',
|
|
280
291
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
281
292
|
timeout: Math.max(1, Math.ceil(deadline - performance.now())),
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// The rulebook is the set of files that decide what this session may do — hooks
|
|
6
6
|
// and settings wiring, the queue config and selector, all scripts, rules, skills
|
|
7
7
|
// and agents, the `.codex/` configuration, the integrity manifest,
|
|
8
|
-
// `.claude/doctor-exemptions.json`,
|
|
9
|
-
//
|
|
8
|
+
// `.claude/doctor-exemptions.json`, the revalidation detection contract
|
|
9
|
+
// `.rig/revalidation.json` — but not the claim records beside it under
|
|
10
|
+
// `.rig/claims/`, which a SELECT has to write — `AGENTS.md` and `CLAUDE.md`.
|
|
11
|
+
// Every one of them was editable by the run it governs: a
|
|
10
12
|
// Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
|
|
11
13
|
// off, and nothing refused it until the merge sweep, long after.
|
|
12
14
|
//
|
|
@@ -49,6 +51,12 @@
|
|
|
49
51
|
// checkout root before guarding a canonical payload path", › "blocks when
|
|
50
52
|
// the checkout root and payload use the same symlink spelling", and
|
|
51
53
|
// › "blocks an existing rulebook file when only the payload path uses a symlink spelling";
|
|
54
|
+
// - an `apply_patch` destination keeps the lexical spelling it named even when
|
|
55
|
+
// a guarded prefix (`.claude/hooks`, say) is itself a symlink/junction to
|
|
56
|
+
// somewhere else inside the checkout — `edit-input.mjs`'s `repositoryPatchPath`
|
|
57
|
+
// carries that spelling alongside the realpath-resolved one, RP-60 — ›
|
|
58
|
+
// "refuses an apply_patch through a guarded prefix junctioned to a target
|
|
59
|
+
// inside the checkout";
|
|
52
60
|
// - an `allow` prefix is a string prefix of the repo-relative path and may
|
|
53
61
|
// not widen the rulebook — an entry that is itself a prefix of a rulebook
|
|
54
62
|
// prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
|
|
@@ -119,8 +127,15 @@ export const relativeTo = (root, filePath) => {
|
|
|
119
127
|
export const isAllowed = (rel, allow) =>
|
|
120
128
|
(Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
// RP-60: `rawFilePath` is the lexical spelling `apply_patch` fragments carry
|
|
131
|
+
// alongside the realpath-resolved `filePath` (`edit-input.mjs`,
|
|
132
|
+
// `repositoryPatchPath`) — a guarded prefix that is itself a symlink/junction
|
|
133
|
+
// to somewhere else inside the checkout resolves away the rulebook spelling
|
|
134
|
+
// otherwise, the same way `canonicalRoot`/`comparisonRoots` above seed both
|
|
135
|
+
// spellings of the checkout root. Every other edit surface never sets it, so
|
|
136
|
+
// this is a no-op for them.
|
|
137
|
+
const protectedRelative = (roots, filePath, rawFilePath) =>
|
|
138
|
+
[...new Set([filePath, rawFilePath, canonicalPath(filePath)].filter((spelling) => typeof spelling === 'string' && spelling !== ''))]
|
|
124
139
|
.flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)))
|
|
125
140
|
.find(isRulebookPath);
|
|
126
141
|
|
|
@@ -151,9 +166,9 @@ function main() {
|
|
|
151
166
|
return 2;
|
|
152
167
|
}
|
|
153
168
|
const paths = [];
|
|
154
|
-
for (const { filePath } of fragments) {
|
|
169
|
+
for (const { filePath, rawFilePath } of fragments) {
|
|
155
170
|
if (typeof filePath !== 'string' || filePath === '') continue;
|
|
156
|
-
const rel = protectedRelative(comparisonRoots, filePath);
|
|
171
|
+
const rel = protectedRelative(comparisonRoots, filePath, rawFilePath);
|
|
157
172
|
if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
|
|
158
173
|
}
|
|
159
174
|
if (paths.length === 0) return 0; // nothing under the rulebook: never judged
|
|
@@ -279,7 +279,12 @@ function patchFragments(command, payloadCwd) {
|
|
|
279
279
|
const moved = current.moveTo
|
|
280
280
|
? movedFragment(current, budget)
|
|
281
281
|
: { fragment: current.additions.join('\n') };
|
|
282
|
-
|
|
282
|
+
// RP-60: `rawFilePath` is the lexical repo-relative spelling, taken
|
|
283
|
+
// before symlink resolution — carried alongside the resolved
|
|
284
|
+
// `filePath` so a guard can still see a patch destination named
|
|
285
|
+
// through a guarded prefix that is itself a symlink/junction to
|
|
286
|
+
// somewhere else inside the checkout.
|
|
287
|
+
fragments.push({ filePath: destination.resolved, rawFilePath: destination.raw, ...moved });
|
|
283
288
|
}
|
|
284
289
|
current = null;
|
|
285
290
|
}
|
|
@@ -510,6 +515,11 @@ function repositoryPatchPath(value, budget) {
|
|
|
510
515
|
const candidate = path.resolve(budget.patchCwd, patchPath);
|
|
511
516
|
if (!isWithin(budget.repoRoot, candidate)) return null;
|
|
512
517
|
|
|
518
|
+
// RP-60: the lexical repo-relative spelling, fixed BEFORE any symlink in the
|
|
519
|
+
// path (a guarded prefix junctioned elsewhere inside the checkout, say) gets
|
|
520
|
+
// resolved away below. One extra string, computed once — not a new loop.
|
|
521
|
+
const raw = path.relative(budget.repoRoot, candidate).split(path.sep).join('/');
|
|
522
|
+
|
|
513
523
|
let existing = candidate;
|
|
514
524
|
const suffix = [];
|
|
515
525
|
while (true) {
|
|
@@ -517,7 +527,7 @@ function repositoryPatchPath(value, budget) {
|
|
|
517
527
|
const resolved = budget.resolvedDirectories.get(existing);
|
|
518
528
|
const resolvedCandidate = path.resolve(resolved, ...suffix);
|
|
519
529
|
if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
|
|
520
|
-
return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
|
|
530
|
+
return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
|
|
521
531
|
}
|
|
522
532
|
try {
|
|
523
533
|
const resolved = realpathSync(existing);
|
|
@@ -533,7 +543,7 @@ function repositoryPatchPath(value, budget) {
|
|
|
533
543
|
}
|
|
534
544
|
const resolvedCandidate = path.resolve(resolved, ...suffix);
|
|
535
545
|
if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
|
|
536
|
-
return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
|
|
546
|
+
return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
|
|
537
547
|
} catch (error) {
|
|
538
548
|
if (error?.code !== 'ENOENT') return null;
|
|
539
549
|
try {
|
|
@@ -215,6 +215,39 @@ The invariants worth your slots are the ones you can finish this sentence about:
|
|
|
215
215
|
*"the last time this went wrong, it cost us ___."* If you cannot finish it, you
|
|
216
216
|
are guessing, and a guessed invariant is the one that will fire on honest work.
|
|
217
217
|
|
|
218
|
+
## The independent-oracle invariant
|
|
219
|
+
|
|
220
|
+
A test of a security, ownership or governance mechanism must not derive its expected result from the same production mechanism it checks.
|
|
221
|
+
Check it against an independent oracle instead: an alternative
|
|
222
|
+
implementation of the check, a mutation proof, or externally observable behaviour.
|
|
223
|
+
|
|
224
|
+
⚠ **The independent-oracle invariant has parts 1 and 3 of the pattern above, and not part 2.** No hook enforces it: "is this expectation derived from the same production
|
|
225
|
+
mechanism" is not decidable from a single diff fragment — it takes reading
|
|
226
|
+
both the test and the code path it claims to verify, and judging which one
|
|
227
|
+
stands in as the oracle. `code-reviewer` is where it is enforced, as a
|
|
228
|
+
checklist item, never a hook — see the generator's
|
|
229
|
+
`test/template/correspondence.test.ts` (absent in a generated rig) ›
|
|
230
|
+
"the rule states the invariant and code-reviewer.md carries a matching checklist item".
|
|
231
|
+
|
|
232
|
+
Why this earned its own name: a test that asks production's own logic what the
|
|
233
|
+
right answer is cannot detect an under-approximation in that logic. Test and
|
|
234
|
+
code are the same computation run twice, agreeing by construction — so the
|
|
235
|
+
test passes, the reviewer sees a test that genuinely exercises the code, and
|
|
236
|
+
CI is green, while the defect the test was written for goes straight through.
|
|
237
|
+
|
|
238
|
+
The fix that came out of it is the worked example:
|
|
239
|
+
`packages/cli/test/uninstall.test.ts` (absent in a generated rig),
|
|
240
|
+
whose `expectImports` re-derives the import edges with a deliberately
|
|
241
|
+
duplicated regex rather than importing production's own — its comment says
|
|
242
|
+
"deliberately a second copy rather than an import of the private constant" —
|
|
243
|
+
so the test can never be satisfied merely by production checking its own
|
|
244
|
+
work.
|
|
245
|
+
|
|
246
|
+
Scope: this applies going forward, to tests of security, ownership and
|
|
247
|
+
governance mechanisms. The existing suite is not retrofitted wholesale — an
|
|
248
|
+
existing test is corrected only where doing so is cheap and the derivation
|
|
249
|
+
is demonstrably vacuous.
|
|
250
|
+
|
|
218
251
|
## About the hooks you were given
|
|
219
252
|
|
|
220
253
|
Generator-authored rulebook artifacts — rules, hooks, skills, scripts and agent
|
|
@@ -73,6 +73,10 @@
|
|
|
73
73
|
* absence itself — `lib/gate-coverage.mjs` is the one that does, and it puts
|
|
74
74
|
* such a verdict in its own list rather than counting it either way. When
|
|
75
75
|
* present the value is a commit SHAPE, not free text: see `isCommitId`.
|
|
76
|
+
* 7. **`failure-diagnostician` answers in this shape and is not a merge gate.**
|
|
77
|
+
* No `decision-router` lane names it and `pr-ship` coverage never expects an
|
|
78
|
+
* answer from it — see `test/template/verdict.test.ts`
|
|
79
|
+
* (absent in a generated rig) › "the diagnostician is never a routed reviewer".
|
|
76
80
|
*/
|
|
77
81
|
|
|
78
82
|
/** Every word any gate in this rulebook may return. */
|
|
@@ -86,6 +90,14 @@ export const VERDICT_WORDS = Object.freeze([
|
|
|
86
90
|
'UNVERIFIABLE',
|
|
87
91
|
'UNMEASURED',
|
|
88
92
|
'NOT_APPLICABLE',
|
|
93
|
+
// RP-195 slice 1: failure-diagnostician's own words, split by what it was
|
|
94
|
+
// asked to look at — a failure, or a claimed/historical finding.
|
|
95
|
+
'ROOT_CAUSE',
|
|
96
|
+
'INCONCLUSIVE',
|
|
97
|
+
'STILL_LIVE',
|
|
98
|
+
'ALREADY_FIXED',
|
|
99
|
+
'OBSOLETE',
|
|
100
|
+
'INSUFFICIENT_EVIDENCE',
|
|
89
101
|
]);
|
|
90
102
|
|
|
91
103
|
/**
|
|
@@ -111,6 +123,15 @@ export const GATE_VOCABULARY = Object.freeze({
|
|
|
111
123
|
'UNMEASURED',
|
|
112
124
|
]),
|
|
113
125
|
'post-deploy-verify': Object.freeze(['HEALTHY', 'REGRESSION']),
|
|
126
|
+
// RP-195 slice 1 (design decision 1): the diagnostician's own words.
|
|
127
|
+
'failure-diagnostician': Object.freeze([
|
|
128
|
+
'ROOT_CAUSE',
|
|
129
|
+
'INCONCLUSIVE',
|
|
130
|
+
'STILL_LIVE',
|
|
131
|
+
'ALREADY_FIXED',
|
|
132
|
+
'OBSOLETE',
|
|
133
|
+
'INSUFFICIENT_EVIDENCE',
|
|
134
|
+
]),
|
|
114
135
|
});
|
|
115
136
|
|
|
116
137
|
/**
|
|
@@ -126,6 +147,13 @@ export const BLOCKING_VERDICTS = Object.freeze([
|
|
|
126
147
|
'PREMISE_FALSE',
|
|
127
148
|
'UNVERIFIABLE',
|
|
128
149
|
'UNMEASURED',
|
|
150
|
+
// RP-195 slice 1 (design decision 1): the cause, for ROOT_CAUSE and
|
|
151
|
+
// STILL_LIVE; the missing evidence, for INCONCLUSIVE and
|
|
152
|
+
// INSUFFICIENT_EVIDENCE. ALREADY_FIXED and OBSOLETE carry no blockers.
|
|
153
|
+
'ROOT_CAUSE',
|
|
154
|
+
'INCONCLUSIVE',
|
|
155
|
+
'STILL_LIVE',
|
|
156
|
+
'INSUFFICIENT_EVIDENCE',
|
|
129
157
|
]);
|
|
130
158
|
|
|
131
159
|
/** The only keys a block may carry. */
|
|
@@ -136,8 +164,16 @@ const SHAPE_KEYS = Object.freeze([
|
|
|
136
164
|
'advisories',
|
|
137
165
|
'evidence',
|
|
138
166
|
'headSha',
|
|
167
|
+
'classification',
|
|
139
168
|
]);
|
|
140
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The one optional key `failure-diagnostician` alone may carry (RP-195 slice
|
|
172
|
+
* 1, design decision 2): required on ROOT_CAUSE, optional on STILL_LIVE,
|
|
173
|
+
* refused on every other word and on every other gate.
|
|
174
|
+
*/
|
|
175
|
+
const CLASSIFICATIONS = Object.freeze(['product', 'test', 'infrastructure', 'upstream']);
|
|
176
|
+
|
|
141
177
|
const FENCE = '```json';
|
|
142
178
|
|
|
143
179
|
/** How much of one reviewer-written value a diagnosis will carry. */
|
|
@@ -443,6 +479,32 @@ export function parseVerdict(text) {
|
|
|
443
479
|
}
|
|
444
480
|
}
|
|
445
481
|
|
|
482
|
+
const classification = parsed.classification;
|
|
483
|
+
const hasClassification = classification !== undefined;
|
|
484
|
+
if (hasClassification) {
|
|
485
|
+
if (!isText(gate) || gate !== 'failure-diagnostician') {
|
|
486
|
+
problems.push(
|
|
487
|
+
'`classification` is refused here: only failure-diagnostician may carry it, and ' +
|
|
488
|
+
`this block names \`gate\` as ${safeForDiagnosis(gate)}.`,
|
|
489
|
+
);
|
|
490
|
+
} else if (verdict !== 'ROOT_CAUSE' && verdict !== 'STILL_LIVE') {
|
|
491
|
+
problems.push(
|
|
492
|
+
`\`classification\` is refused on ${safeForDiagnosis(verdict)} — only ROOT_CAUSE ` +
|
|
493
|
+
'(required) and STILL_LIVE (optional) may carry one.',
|
|
494
|
+
);
|
|
495
|
+
} else if (!CLASSIFICATIONS.includes(classification)) {
|
|
496
|
+
problems.push(
|
|
497
|
+
`\`classification\` is \`${safeForDiagnosis(classification)}\`, which is not one of: ` +
|
|
498
|
+
`${CLASSIFICATIONS.join(', ')}.`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
} else if (isText(gate) && gate === 'failure-diagnostician' && verdict === 'ROOT_CAUSE') {
|
|
502
|
+
problems.push(
|
|
503
|
+
'ROOT_CAUSE names no `classification`: it is required on this word — one of ' +
|
|
504
|
+
`${CLASSIFICATIONS.join(', ')}.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
446
508
|
if (problems.length > 0) return { ok: false, problems };
|
|
447
509
|
|
|
448
510
|
return {
|
|
@@ -457,6 +519,7 @@ export function parseVerdict(text) {
|
|
|
457
519
|
// back without the key at all, so a caller can tell "answered for this
|
|
458
520
|
// commit" from "said nothing about which commit".
|
|
459
521
|
...(headSha === undefined ? {} : { headSha }),
|
|
522
|
+
...(hasClassification ? { classification } : {}),
|
|
460
523
|
},
|
|
461
524
|
};
|
|
462
525
|
}
|
|
@@ -633,13 +633,12 @@ const clearsSpacing = (lastCompletedTier) =>
|
|
|
633
633
|
* items with the whole suite green throughout, and its `budget` stop arriving "later
|
|
634
634
|
* than it should have".
|
|
635
635
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* opened the mirror of the bug it closed (AR-115).
|
|
636
|
+
* Three is the cap: the second round verifies the first round's fixes, and the
|
|
637
|
+
* third lets a round-2 fix be read once more before the item needs a human. A
|
|
638
|
+
* project that wants a different cap sets `options.maxGateRounds` instead of
|
|
639
|
+
* changing this default.
|
|
641
640
|
*/
|
|
642
|
-
export const DEFAULT_MAX_GATE_ROUNDS =
|
|
641
|
+
export const DEFAULT_MAX_GATE_ROUNDS = 3;
|
|
643
642
|
|
|
644
643
|
/**
|
|
645
644
|
* Is this round allowed, and if not, what stops?
|
|
@@ -144,6 +144,21 @@ const ghJson = (args) => JSON.parse(ghText(args));
|
|
|
144
144
|
|
|
145
145
|
const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments';
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A `--state` (or triage) window that came back exactly at its cap: older
|
|
149
|
+
* items may have been left unread, and a window this shape cannot tell the
|
|
150
|
+
* difference from a repository that happens to have exactly `limit` items.
|
|
151
|
+
* See queue-github-pagination.test.ts (absent in a generated rig) ›
|
|
152
|
+
* "a --state %s window that comes back exactly at the limit is announced on
|
|
153
|
+
* stderr" and › "a triage window that comes back exactly at the cap (100) is
|
|
154
|
+
* announced on stderr".
|
|
155
|
+
*/
|
|
156
|
+
const announceCap = (label, limit) => {
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`github-issues: ${label} window capped at ${limit} issues — older ${label} items may be missing; raise limit\n`,
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
147
162
|
// --- the adapter contract ------------------------------------------------------
|
|
148
163
|
|
|
149
164
|
/**
|
|
@@ -151,18 +166,42 @@ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments'
|
|
|
151
166
|
*
|
|
152
167
|
* Deliberately queries fresh on every call and never caches: the queue changes as
|
|
153
168
|
* the loop itself closes items and unblocks their dependents.
|
|
169
|
+
*
|
|
170
|
+
* Open and closed issues are read as two separate `--state` windows rather
|
|
171
|
+
* than one shared `--state all` window: a shared window lets closed history
|
|
172
|
+
* push an older open issue out of it, which used to be silent. See
|
|
173
|
+
* queue-github-pagination.test.ts (absent in a generated rig) › "keeps an
|
|
174
|
+
* older OPEN issue even when 100 CLOSED issues would fill a shared window".
|
|
154
175
|
*/
|
|
155
176
|
export const listEligible = ({ limit = 100, issues = null } = {}) => {
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
let raw;
|
|
178
|
+
let openIssues = null;
|
|
179
|
+
if (issues) {
|
|
180
|
+
raw = issues;
|
|
181
|
+
} else {
|
|
182
|
+
openIssues = ghJson(['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', FIELDS]);
|
|
183
|
+
if (openIssues.length === limit) announceCap('open', limit);
|
|
184
|
+
const closedIssues = ghJson([
|
|
185
|
+
'issue',
|
|
186
|
+
'list',
|
|
187
|
+
'--state',
|
|
188
|
+
'closed',
|
|
189
|
+
'--limit',
|
|
190
|
+
String(limit),
|
|
191
|
+
'--json',
|
|
192
|
+
FIELDS,
|
|
193
|
+
]);
|
|
194
|
+
if (closedIssues.length === limit) announceCap('closed', limit);
|
|
195
|
+
raw = [...openIssues, ...closedIssues];
|
|
196
|
+
}
|
|
158
197
|
const states = Object.fromEntries(raw.map((issue) => [String(issue.number), issue.state]));
|
|
159
198
|
const blocks = blocksIndex(raw);
|
|
160
|
-
|
|
161
|
-
.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED')
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
const eligible =
|
|
200
|
+
openIssues ?? raw.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED');
|
|
201
|
+
return eligible.map((issue) => {
|
|
202
|
+
const ticket = toTicket(issue, states);
|
|
203
|
+
return { ...ticket, blocks: blocks[ticket.id] ?? [] };
|
|
204
|
+
});
|
|
166
205
|
};
|
|
167
206
|
|
|
168
207
|
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
@@ -293,12 +332,30 @@ export const triageItemFor = (proposal) => {
|
|
|
293
332
|
* hand out nothing — "queue empty" and "nothing selectable";
|
|
294
333
|
* twenty such stops must produce one proposal with a count of twenty.
|
|
295
334
|
*/
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
335
|
+
/**
|
|
336
|
+
* The proposals on file, as `{ id, body }` — every `triage`-labelled issue.
|
|
337
|
+
* A window that comes back exactly at its cap is announced on stderr, same
|
|
338
|
+
* as `listEligible`'s.
|
|
339
|
+
*/
|
|
340
|
+
export const listProposals = ({ existing = null, limit = 100 } = {}) => {
|
|
341
|
+
let raw = existing;
|
|
342
|
+
if (!raw) {
|
|
343
|
+
raw = ghJson([
|
|
344
|
+
'issue',
|
|
345
|
+
'list',
|
|
346
|
+
'--label',
|
|
347
|
+
'triage',
|
|
348
|
+
'--state',
|
|
349
|
+
'all',
|
|
350
|
+
'--limit',
|
|
351
|
+
String(limit),
|
|
352
|
+
'--json',
|
|
353
|
+
FIELDS,
|
|
354
|
+
]);
|
|
355
|
+
if (raw.length === limit) announceCap('triage', limit);
|
|
356
|
+
}
|
|
357
|
+
return raw.map((issue) => ({ id: String(issue.number), body: issue.body }));
|
|
358
|
+
};
|
|
302
359
|
|
|
303
360
|
export const proposeTriage = (rawProposal, { existing = null } = {}) => {
|
|
304
361
|
const proposal = withAsOf(rawProposal);
|