create-agent-rig 0.10.1 → 1.0.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 -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/packages/cli/dist/integrations/doctor-guards.js +7 -3
- package/templates/agent-os/subagent-routing.json +9 -5
- 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 +5 -1
- package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +142 -14
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +5 -1
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +168 -17
- 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 +157 -10
- 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 +2 -2
- package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/AGENTS.md +11 -5
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +1 -1
- 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 +118 -22
- package/templates/release-ledger.json +3 -1
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* Claude sends Write/Edit fields directly. Codex sends an apply_patch command,
|
|
5
5
|
* so added lines are returned for ordinary edits. A move is different: the
|
|
6
6
|
* destination receives the existing file too, so guards inspect the resulting
|
|
7
|
-
* content instead of only the patch additions when inspection succeeds.
|
|
7
|
+
* content instead of only the patch additions when inspection succeeds. A
|
|
8
|
+
* removal — a `*** Delete File:` section, or the source half of a
|
|
9
|
+
* `*** Move to:` — also becomes its own fragment, `removes: true`, `fragment:
|
|
10
|
+
* ''` (RP-214): the path stops existing, which a guard judging paths still
|
|
11
|
+
* needs to see even though there is no text to inspect.
|
|
8
12
|
*
|
|
9
13
|
* Inspection is bounded globally per patch: sources, hunks, output, splices,
|
|
10
14
|
* comparisons, sections and path components.
|
|
@@ -240,6 +244,21 @@ function patchFragments(command, payloadCwd) {
|
|
|
240
244
|
let current = null;
|
|
241
245
|
let patchRefusal = null;
|
|
242
246
|
|
|
247
|
+
// RP-214: one counter, spent on every path this section resolves — the
|
|
248
|
+
// destination (or the removed path, for a Delete File section) and, for a
|
|
249
|
+
// Move, the source too (below). Still one forward pass, still the same
|
|
250
|
+
// bound.
|
|
251
|
+
const overPathComponentBudget = (value) => {
|
|
252
|
+
budget.pathComponents += String(value ?? '').replaceAll('\\', '/').split('/').length;
|
|
253
|
+
return budget.pathComponents > MAX_PATCH_PATH_COMPONENTS;
|
|
254
|
+
};
|
|
255
|
+
const pathComponentRefusal = () => ({
|
|
256
|
+
filePath: '',
|
|
257
|
+
fragment: '',
|
|
258
|
+
inspectionRefusal: `apply_patch destination path component count exceeds the ${MAX_PATCH_PATH_COMPONENTS}-component inspection limit`,
|
|
259
|
+
appliesToAll: true,
|
|
260
|
+
});
|
|
261
|
+
|
|
243
262
|
const flush = () => {
|
|
244
263
|
if (current !== null) {
|
|
245
264
|
budget.sections += 1;
|
|
@@ -254,16 +273,8 @@ function patchFragments(command, payloadCwd) {
|
|
|
254
273
|
return false;
|
|
255
274
|
}
|
|
256
275
|
const destinationPath = current.moveTo ?? current.sourcePath;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
.split('/').length;
|
|
260
|
-
if (budget.pathComponents > MAX_PATCH_PATH_COMPONENTS) {
|
|
261
|
-
patchRefusal = {
|
|
262
|
-
filePath: '',
|
|
263
|
-
fragment: '',
|
|
264
|
-
inspectionRefusal: `apply_patch destination path component count exceeds the ${MAX_PATCH_PATH_COMPONENTS}-component inspection limit`,
|
|
265
|
-
appliesToAll: true,
|
|
266
|
-
};
|
|
276
|
+
if (overPathComponentBudget(destinationPath)) {
|
|
277
|
+
patchRefusal = pathComponentRefusal();
|
|
267
278
|
current = null;
|
|
268
279
|
return false;
|
|
269
280
|
}
|
|
@@ -274,12 +285,58 @@ function patchFragments(command, payloadCwd) {
|
|
|
274
285
|
fragment: '',
|
|
275
286
|
inspectionRefusal: 'patch destination is outside the repository or cannot be resolved safely',
|
|
276
287
|
appliesToAll: true,
|
|
288
|
+
// RP-214: a Delete File section has no destination — only the
|
|
289
|
+
// removed path itself — so this refusal is about a removal too;
|
|
290
|
+
// carry the flag so a guard that must not treat a removal as a
|
|
291
|
+
// write (guard-secret-file) can still tell the two apart.
|
|
292
|
+
...(current.removes ? { removes: true } : {}),
|
|
293
|
+
});
|
|
294
|
+
} else if (current.removes) {
|
|
295
|
+
// RP-214: `*** Delete File: <path>` used to flush only the SECTION
|
|
296
|
+
// BEFORE it and never turn the removed path itself into a fragment —
|
|
297
|
+
// resolved the same way every other verb resolves its path, through
|
|
298
|
+
// `repositoryPatchPath` and the same budgets above, in this same pass.
|
|
299
|
+
fragments.push({
|
|
300
|
+
filePath: destination.resolved,
|
|
301
|
+
rawFilePath: destination.raw,
|
|
302
|
+
fragment: '',
|
|
303
|
+
removes: true,
|
|
277
304
|
});
|
|
278
305
|
} else {
|
|
279
306
|
const moved = current.moveTo
|
|
280
307
|
? movedFragment(current, budget)
|
|
281
308
|
: { fragment: current.additions.join('\n') };
|
|
282
|
-
|
|
309
|
+
// RP-60: `rawFilePath` is the lexical repo-relative spelling, taken
|
|
310
|
+
// before symlink resolution — carried alongside the resolved
|
|
311
|
+
// `filePath` so a guard can still see a patch destination named
|
|
312
|
+
// through a guarded prefix that is itself a symlink/junction to
|
|
313
|
+
// somewhere else inside the checkout.
|
|
314
|
+
fragments.push({ filePath: destination.resolved, rawFilePath: destination.raw, ...moved });
|
|
315
|
+
|
|
316
|
+
if (current.moveTo) {
|
|
317
|
+
// RP-214: a Move's SOURCE stops existing too — it is a removal the
|
|
318
|
+
// line above never surfaced, because `current.moveTo ?? …`
|
|
319
|
+
// resolves only the destination. One more path through the same
|
|
320
|
+
// resolver and the same budgets, still inside this one flush.
|
|
321
|
+
if (overPathComponentBudget(current.sourcePath)) {
|
|
322
|
+
patchRefusal = pathComponentRefusal();
|
|
323
|
+
current = null;
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
const source = repositoryPatchPath(current.sourcePath, budget);
|
|
327
|
+
fragments.push(
|
|
328
|
+
source === null
|
|
329
|
+
? {
|
|
330
|
+
filePath: '',
|
|
331
|
+
fragment: '',
|
|
332
|
+
inspectionRefusal:
|
|
333
|
+
'patch destination is outside the repository or cannot be resolved safely',
|
|
334
|
+
appliesToAll: true,
|
|
335
|
+
removes: true,
|
|
336
|
+
}
|
|
337
|
+
: { filePath: source.resolved, rawFilePath: source.raw, fragment: '', removes: true },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
283
340
|
}
|
|
284
341
|
current = null;
|
|
285
342
|
}
|
|
@@ -298,7 +355,17 @@ function patchFragments(command, payloadCwd) {
|
|
|
298
355
|
current.moveTo = move[1];
|
|
299
356
|
continue;
|
|
300
357
|
}
|
|
301
|
-
|
|
358
|
+
// RP-214: a Delete File section becomes its own fragment — `removes:
|
|
359
|
+
// true`, resolved through the same path the other verbs use — instead of
|
|
360
|
+
// only flushing whatever section came before it.
|
|
361
|
+
const del = /^\*\*\* Delete File: (.+)$/.exec(line);
|
|
362
|
+
if (del) {
|
|
363
|
+
if (!flush()) break;
|
|
364
|
+
current = { sourcePath: del[1], moveTo: null, additions: [], hunks: [], activeHunk: null, removes: true };
|
|
365
|
+
if (!flush()) break;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (/^\*\*\* End Patch/.test(line)) {
|
|
302
369
|
if (!flush()) break;
|
|
303
370
|
continue;
|
|
304
371
|
}
|
|
@@ -481,9 +548,88 @@ function inspectionRefusal(current, reason) {
|
|
|
481
548
|
return { fragment: current.additions.join('\n'), inspectionRefusal: reason };
|
|
482
549
|
}
|
|
483
550
|
|
|
551
|
+
// RP-244: a Win32 verbatim (`\\?\`) or device-namespace (`\\.\`) prefix, in
|
|
552
|
+
// any slash mix, names the plain drive (or UNC) path underneath it. The
|
|
553
|
+
// backslash-to-slash conversion below would collapse the leading `//` these
|
|
554
|
+
// prefixes depend on, so they are stripped first. Anchored at the very start:
|
|
555
|
+
// a prefix check, not a scan, which keeps this inside the fail-open
|
|
556
|
+
// bounded-work rule.
|
|
557
|
+
//
|
|
558
|
+
// RP-244 round 2: `[\\/]+` (not `[\\/]`) after the `?`/`.` marker, so a
|
|
559
|
+
// doubled separator still strips. A DRIVE match (`\\?\C:\…`) is the only case
|
|
560
|
+
// that yields a plain, prefix-free spelling — every other two-separator input
|
|
561
|
+
// (verbatim UNC, plain UNC, a device path with no drive letter such as
|
|
562
|
+
// `\\?\Volume{GUID}\…`) is judged as UNDECIDABLE by a repository root spelled
|
|
563
|
+
// as a plain path, so it keeps a leading `//` instead, with its remainder
|
|
564
|
+
// normalised as ABSOLUTE (clamped at root) rather than relative — a relative
|
|
565
|
+
// normalisation let a crafted run of `../` segments do unbounded work instead
|
|
566
|
+
// of collapsing at the root.
|
|
567
|
+
const WIN32_VERBATIM_UNC_PREFIX = /^[\\/]{2}[?.][\\/]+UNC(?:[\\/]+|$)/i;
|
|
568
|
+
const WIN32_VERBATIM_DRIVE_PREFIX = /^[\\/]{2}[?.][\\/]+([A-Za-z]:)/;
|
|
569
|
+
// RP-244 round 3: a drive letter followed immediately by a separator — the
|
|
570
|
+
// drive ROOT, as opposed to the drive-RELATIVE `C:foo\bar` spelling below,
|
|
571
|
+
// which carries no separator there. `slashed` has already had every
|
|
572
|
+
// backslash converted to `/` by the time this is applied, so only the
|
|
573
|
+
// forward-slash form is checked.
|
|
574
|
+
const DRIVE_ROOT_PREFIX = /^[A-Za-z]:\//;
|
|
575
|
+
// RP-244 round 4: a drive letter with NO separator immediately following —
|
|
576
|
+
// the DRIVE-RELATIVE spelling, which Win32 resolves against the current
|
|
577
|
+
// directory of that drive rather than the drive root, so (unlike
|
|
578
|
+
// DRIVE_ROOT_PREFIX above) it has no root of its own to clamp at. Mutually
|
|
579
|
+
// exclusive with DRIVE_ROOT_PREFIX by construction: one requires a `/`
|
|
580
|
+
// immediately after the colon, this one requires there is none (including
|
|
581
|
+
// end of string, for a bare `C:`).
|
|
582
|
+
const DRIVE_RELATIVE_PREFIX = /^[A-Za-z]:(?!\/)/;
|
|
583
|
+
|
|
584
|
+
// RP-244 round 3: the DRIVE branch below and the plain fallback both used to
|
|
585
|
+
// run `path.posix.normalize` over the drive letter and its remainder
|
|
586
|
+
// TOGETHER, relatively — so a leading `..` walked straight past the drive
|
|
587
|
+
// letter (`C:/../Users/…` normalised to the relative `Users/…`, not clamped
|
|
588
|
+
// at `C:/`) instead of stopping at the root the way Win32 does, and a long
|
|
589
|
+
// run of `../` segments did unbounded relative work instead of the linear
|
|
590
|
+
// work an absolute normalisation does. Splitting the drive off first and
|
|
591
|
+
// normalising only the remainder, ABSOLUTE, fixes both: `path.posix.normalize`
|
|
592
|
+
// clamps an absolute `..` run at `/` instead of carrying it past the drive.
|
|
593
|
+
//
|
|
594
|
+
// RP-244 round 4: the drive-RELATIVE case (no separator after the colon) got
|
|
595
|
+
// the same together-normalisation treatment via the plain `else` branch below
|
|
596
|
+
// — `path.posix.normalize` reads a leading `C:..` segment as an ordinary
|
|
597
|
+
// filename, not the literal `..`, so a second `..` cancelled it and the
|
|
598
|
+
// drive marker vanished from the result (`C:../../a/b` → `a/b`). Splitting
|
|
599
|
+
// the drive off here too and normalising the remainder alone — RELATIVELY,
|
|
600
|
+
// never absolutely, since a drive-relative spelling has no root to clamp
|
|
601
|
+
// at — keeps a `..` in the remainder from ever reaching back far enough to
|
|
602
|
+
// cancel the marker itself; an empty remainder (a bare `C:`) is left as-is
|
|
603
|
+
// rather than turned into `C:.`, unchanged from before this round.
|
|
604
|
+
function clampAtDriveRoot(slashed) {
|
|
605
|
+
if (DRIVE_ROOT_PREFIX.test(slashed)) {
|
|
606
|
+
return slashed.slice(0, 2) + path.posix.normalize(slashed.slice(2));
|
|
607
|
+
}
|
|
608
|
+
if (DRIVE_RELATIVE_PREFIX.test(slashed)) {
|
|
609
|
+
const remainder = slashed.slice(2);
|
|
610
|
+
return remainder === '' ? slashed : slashed.slice(0, 2) + path.posix.normalize(remainder);
|
|
611
|
+
}
|
|
612
|
+
return path.posix.normalize(slashed);
|
|
613
|
+
}
|
|
614
|
+
|
|
484
615
|
function normalisePath(value) {
|
|
485
|
-
const
|
|
486
|
-
|
|
616
|
+
const raw = String(value ?? '').trim();
|
|
617
|
+
if (raw === '') return '';
|
|
618
|
+
const driveMatch = WIN32_VERBATIM_DRIVE_PREFIX.exec(raw);
|
|
619
|
+
if (driveMatch) {
|
|
620
|
+
const slashed = raw.replace(WIN32_VERBATIM_DRIVE_PREFIX, '$1').replaceAll('\\', '/');
|
|
621
|
+
return slashed === '' ? '' : clampAtDriveRoot(slashed);
|
|
622
|
+
}
|
|
623
|
+
const uncMatch = WIN32_VERBATIM_UNC_PREFIX.exec(raw);
|
|
624
|
+
if (uncMatch || /^[\\/]{2}/.test(raw)) {
|
|
625
|
+
const rest = (uncMatch ? raw.slice(uncMatch[0].length) : raw.slice(2)).replaceAll('\\', '/');
|
|
626
|
+
// Absolute normalisation, not relative: clamps a leading `../` run at the
|
|
627
|
+
// root instead of carrying it through — the outer `'/' +` restores the
|
|
628
|
+
// `//` marker that `path.posix.normalize` collapses to one.
|
|
629
|
+
return '/' + path.posix.normalize('/' + rest);
|
|
630
|
+
}
|
|
631
|
+
const slashed = raw.replaceAll('\\', '/');
|
|
632
|
+
return slashed === '' ? '' : clampAtDriveRoot(slashed);
|
|
487
633
|
}
|
|
488
634
|
|
|
489
635
|
function canonicalPatchPath(value) {
|
|
@@ -510,6 +656,11 @@ function repositoryPatchPath(value, budget) {
|
|
|
510
656
|
const candidate = path.resolve(budget.patchCwd, patchPath);
|
|
511
657
|
if (!isWithin(budget.repoRoot, candidate)) return null;
|
|
512
658
|
|
|
659
|
+
// RP-60: the lexical repo-relative spelling, fixed BEFORE any symlink in the
|
|
660
|
+
// path (a guarded prefix junctioned elsewhere inside the checkout, say) gets
|
|
661
|
+
// resolved away below. One extra string, computed once — not a new loop.
|
|
662
|
+
const raw = path.relative(budget.repoRoot, candidate).split(path.sep).join('/');
|
|
663
|
+
|
|
513
664
|
let existing = candidate;
|
|
514
665
|
const suffix = [];
|
|
515
666
|
while (true) {
|
|
@@ -517,7 +668,7 @@ function repositoryPatchPath(value, budget) {
|
|
|
517
668
|
const resolved = budget.resolvedDirectories.get(existing);
|
|
518
669
|
const resolvedCandidate = path.resolve(resolved, ...suffix);
|
|
519
670
|
if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
|
|
520
|
-
return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
|
|
671
|
+
return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
|
|
521
672
|
}
|
|
522
673
|
try {
|
|
523
674
|
const resolved = realpathSync(existing);
|
|
@@ -533,7 +684,7 @@ function repositoryPatchPath(value, budget) {
|
|
|
533
684
|
}
|
|
534
685
|
const resolvedCandidate = path.resolve(resolved, ...suffix);
|
|
535
686
|
if (!isWithin(budget.repoRoot, resolvedCandidate)) return null;
|
|
536
|
-
return path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/');
|
|
687
|
+
return { raw, resolved: path.relative(budget.repoRoot, resolvedCandidate).split(path.sep).join('/') };
|
|
537
688
|
} catch (error) {
|
|
538
689
|
if (error?.code !== 'ENOENT') return null;
|
|
539
690
|
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);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The one repo-root-safe entry point for filing an improvement-triage
|
|
3
|
+
// proposal (RP-209). It resolves its config, and through it the active
|
|
4
|
+
// board's adapter and plan path, from its OWN location — exactly like
|
|
5
|
+
// `index.mjs`'s `projectRoot` — so a session standing in a subdirectory
|
|
6
|
+
// files into the project's real PLAN.md rather than a cwd-relative one
|
|
7
|
+
// that happens not to exist there.
|
|
8
|
+
//
|
|
9
|
+
// node .claude/scripts/queue/propose.mjs --file <proposal.json>
|
|
10
|
+
// node .claude/scripts/queue/propose.mjs --file - # stdin
|
|
11
|
+
// node .claude/scripts/queue/propose.mjs --file <path> --config <queue.json>
|
|
12
|
+
//
|
|
13
|
+
// The proposal object is whatever the active adapter's `proposeTriage`
|
|
14
|
+
// already accepts. The result prints as one JSON line on stdout; the
|
|
15
|
+
// process exits 0 only when `ok === true`. When `RIG_RUN_DIR` is declared,
|
|
16
|
+
// one `proposal` event is recorded in the run journal either way, so a
|
|
17
|
+
// failed filing is journalled as a failure rather than going nowhere
|
|
18
|
+
// silently.
|
|
19
|
+
//
|
|
20
|
+
// See the generator's test/template/queue-propose.test.ts (absent in a
|
|
21
|
+
// generated rig).
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './index.mjs';
|
|
26
|
+
|
|
27
|
+
const parseArgs = (argv) => {
|
|
28
|
+
const args = { file: null, config: null };
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
if (argv[i] === '--file') args.file = argv[++i];
|
|
31
|
+
else if (argv[i] === '--config') args.config = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
return args;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readStdin = () =>
|
|
37
|
+
new Promise((resolve, reject) => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
40
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
41
|
+
process.stdin.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const readProposalRaw = (file) => (file === '-' ? readStdin() : Promise.resolve(readFileSync(file, 'utf8')));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Small, deliberately: the journal is a trace of the filing decision, not a
|
|
48
|
+
* second copy of the proposal or of the adapter's whole response.
|
|
49
|
+
*/
|
|
50
|
+
const journalDataFor = (result, reason) => {
|
|
51
|
+
const data = { ok: result?.ok === true };
|
|
52
|
+
if (result?.item?.fingerprint !== undefined) data.id = result.item.fingerprint;
|
|
53
|
+
if (result?.filed !== undefined) data.filed = result.filed;
|
|
54
|
+
if (result?.incremented !== undefined) data.incremented = result.incremented;
|
|
55
|
+
if (reason !== undefined) data.reason = reason;
|
|
56
|
+
return data;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const main = async () => {
|
|
60
|
+
const args = parseArgs(process.argv.slice(2));
|
|
61
|
+
if (!args.file) {
|
|
62
|
+
process.stderr.write('propose: --file <proposal.json> is required (or --file - for stdin).\n');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = await readProposalRaw(args.file);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
process.stderr.write(`propose: could not read ${args.file}: ${error.message}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let proposal;
|
|
75
|
+
try {
|
|
76
|
+
proposal = JSON.parse(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
process.stderr.write(`propose: ${args.file} is not valid JSON: ${error.message}\n`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolved against this file's own URL, not the cwd — the same rule
|
|
83
|
+
// `index.mjs` follows, for the same reason: the CLI runs from the project
|
|
84
|
+
// root, from a worktree, and from a subdirectory the session happens to be
|
|
85
|
+
// standing in.
|
|
86
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
const projectRoot = join(scriptDir, '..', '..', '..');
|
|
88
|
+
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
89
|
+
|
|
90
|
+
let result;
|
|
91
|
+
let reason;
|
|
92
|
+
try {
|
|
93
|
+
const config = loadConfig(configPath);
|
|
94
|
+
const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
95
|
+
const options = optionsWithPlanPath(config.options, configPath);
|
|
96
|
+
result = await adapter.proposeTriage(proposal, options);
|
|
97
|
+
if (result?.ok !== true) reason = result?.why ?? 'proposeTriage returned ok: false';
|
|
98
|
+
} catch (error) {
|
|
99
|
+
reason = error.message ?? String(error);
|
|
100
|
+
result = { ok: false, reason };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const exitCode = result?.ok === true ? 0 : 1;
|
|
104
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
105
|
+
if (exitCode !== 0) process.stderr.write(`propose: ${reason}\n`);
|
|
106
|
+
|
|
107
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
108
|
+
if (runDir) {
|
|
109
|
+
let journal = null;
|
|
110
|
+
try {
|
|
111
|
+
journal = await import('../run-journal.mjs');
|
|
112
|
+
journal.recordEvent({
|
|
113
|
+
runDir,
|
|
114
|
+
kind: 'proposal',
|
|
115
|
+
data: journalDataFor(result, reason),
|
|
116
|
+
now: new Date().toISOString(),
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const classify = journal?.isTraceExhausted;
|
|
120
|
+
if (typeof classify === 'function' && classify(error)) {
|
|
121
|
+
// The trace is over; the filing already happened and stands. Loud on
|
|
122
|
+
// stderr, exit code stays whatever the filing decided — mirrors the
|
|
123
|
+
// pattern in `index.mjs` and the `loop` skill's own journal section.
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`run journal: ${error.message}\n` +
|
|
126
|
+
` the proposal result above was NOT recorded in ${runDir}. This run's ` +
|
|
127
|
+
"trace ends here; the filing above stands.\n",
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
process.stderr.write(`run journal: ${error.message}\n`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.exit(exitCode);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
main();
|