@event4u/agent-config 13.0.0 → 14.1.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.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +195 -315
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +336 -112
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2128 -1326
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1764 -899
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +5 -5
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gate_ledger.ts +5 -1
  75. package/src/scripts/_lib/gh_transient.ts +80 -0
  76. package/src/scripts/_lib/release_material.ts +16 -5
  77. package/src/scripts/_lib/review_independence.ts +111 -0
  78. package/src/scripts/_lib/session_register.ts +14 -0
  79. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  80. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  81. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  82. package/src/scripts/ai_council/_py_json.ts +96 -0
  83. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  84. package/src/scripts/ai_council/clients.ts +80 -143
  85. package/src/scripts/ai_council/config.ts +25 -11
  86. package/src/scripts/ai_council/probe_store.ts +196 -0
  87. package/src/scripts/ai_council/qualification.ts +425 -0
  88. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  89. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  90. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  91. package/src/scripts/build_archive_index.ts +62 -3
  92. package/src/scripts/capture_skill_catalogue.ts +264 -4
  93. package/src/scripts/check_evaluator_schema.ts +249 -0
  94. package/src/scripts/check_gate_coverage.ts +93 -0
  95. package/src/scripts/check_pr_ci_current.ts +492 -0
  96. package/src/scripts/check_review_schema.ts +195 -0
  97. package/src/scripts/check_rule_activation_census.ts +451 -0
  98. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  99. package/src/scripts/condense.ts +1 -1
  100. package/src/scripts/council_cli.ts +141 -191
  101. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  102. package/src/scripts/git_authorization_hook.ts +186 -1
  103. package/src/scripts/hook_manifest.yaml +72 -5
  104. package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
  105. package/src/scripts/hooks/concern_registry.ts +4 -0
  106. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  107. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  108. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  109. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  110. package/src/scripts/hooks_doctor.ts +112 -0
  111. package/src/scripts/interruption_report.ts +443 -0
  112. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  113. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  114. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  115. package/src/scripts/release.ts +38 -53
  116. package/src/scripts/rule_activation_census.ts +292 -0
  117. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  118. package/src/scripts/schemas/review-findings.schema.json +31 -0
  119. package/src/scripts/self_review_gate.ts +6 -0
  120. package/src/scripts/session_register_hook.ts +30 -1
  121. package/src/scripts/sync_pr_branch.ts +271 -0
  122. package/src/scripts/test_council_qualification.ts +213 -0
  123. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Is the open PR's CI verdict about the code that is actually on the branch?
4
+ *
5
+ * "Fix the CI" names a remote state, and three different things can make a
6
+ * locally-verified fix look delivered when it is not. All three were measured on
7
+ * one run (2026-08-17, PR #1391) and none of them is visible from a green local
8
+ * gate:
9
+ *
10
+ * 1. **Unpushed work.** The fix is committed locally, the PR still carries the
11
+ * failure. A pre-push hook refusing is enough to produce this silently.
12
+ * 2. **Stale green.** `gh pr checks` reports the last run it has, which may
13
+ * have run against the pre-fix commit. Reading that green as a verdict on
14
+ * the current head is the sharpest of the three, because the output says
15
+ * SUCCESS.
16
+ * 3. **A foreign head.** Someone pressed *Update branch* on the PR, so the
17
+ * branch is behind its OWN remote counterpart and the PR head is a commit
18
+ * this session never saw.
19
+ *
20
+ * Prose cannot enforce any of that — hence a gate. It answers one question:
21
+ * does a settled, successful check set exist whose head SHA is the PR head, and
22
+ * is that PR head what the local branch says it is?
23
+ *
24
+ * NOT a merge-readiness check and deliberately not a base-freshness check —
25
+ * `check_branch_freshness` owns "is the branch behind its base" and duplicating
26
+ * it here would mean two gates owning one verdict.
27
+ *
28
+ * Degrades rather than blocks on anything it cannot observe: no `gh`, no auth,
29
+ * an unresponsive endpoint, or no open PR all exit 0 with the reason named. A
30
+ * gate that blocks a push because the network is down is a gate that gets
31
+ * switched off. What it BLOCKS is the case where the remote answered and the
32
+ * answer is "your fix is not in this PR" or "this green is not about your code".
33
+ *
34
+ * Exit codes (contract §6): 0 = current, or unobservable-and-said-so ·
35
+ * 1 = the remote answered and the verdict is not about the branch head ·
36
+ * 2 = internal error. `scanned:` is emitted on EVERY exit path.
37
+ *
38
+ * ## The per-target ledger, and the exemption that was withdrawn
39
+ *
40
+ * This gate first shipped with a `// ledger-exempt:` marker arguing it had "no
41
+ * target population to account for — a short-circuiting chain of 0-3 remote
42
+ * facts". Its completion review refuted that from the file itself: the check
43
+ * rows ARE a population, and the parse below **drops** every row whose `state`
44
+ * is not a string. A dropped row could be the failing one, so the gate could
45
+ * print *"green on its own head"* over a check it never read — the precise
46
+ * "absence of scanning looks like absence of findings" shape the ledger exists
47
+ * to count.
48
+ *
49
+ * The exemption's secondary claim was accurate — every `main()` exit already
50
+ * self-reports through `scanReport` with an explicit `allowEmpty` reason — and
51
+ * that is exactly why the first argument was easy to believe. It was still
52
+ * resting on the false half.
53
+ */
54
+
55
+ import * as path from 'node:path';
56
+ import { spawnSync } from 'node:child_process';
57
+ import { fileURLToPath, pathToFileURL } from 'node:url';
58
+
59
+ import { GateLedger } from './_lib/gate_ledger.js';
60
+ import { reportScanned } from './_lib/scan_scope.js';
61
+
62
+ const NETWORK_TIMEOUT_MS = 8_000;
63
+
64
+ /**
65
+ * Emit the count through the shared helper, so the published number is by
66
+ * construction the one the scope assertion just accepted.
67
+ *
68
+ * `allowEmpty` is the load-bearing parameter, and it is also why this gate does
69
+ * NOT carry a `min_scanned` floor in `src/config/gate-coverage.yml`. The number
70
+ * of facts compared here is 0–3 depending on remote state, and zero is a
71
+ * legitimate pass on two paths (no open PR, nothing observable). A floor cannot
72
+ * express that, and the manifest's own header says padding it to raise a
73
+ * coverage figure is the opposite of gaming. Same reasoning, same shape, as the
74
+ * sibling network probe `check_branch_freshness` — which is likewise unregistered
75
+ * for exactly this reason. "There was nothing to check" and "I checked and it was
76
+ * fine" must not print the same thing, which `allowEmpty` is what enforces.
77
+ */
78
+ function scanReport(scanned: number, allowEmpty?: string): void {
79
+ reportScanned({
80
+ gate: 'check_pr_ci_current',
81
+ scanned,
82
+ units: 'remote fact(s) compared',
83
+ roots: ['origin'],
84
+ ...(allowEmpty === undefined ? {} : { allowEmpty }),
85
+ });
86
+ }
87
+
88
+ export interface Probe {
89
+ ok: boolean;
90
+ stdout: string;
91
+ reason?: string;
92
+ }
93
+
94
+ function run(cmd: string, args: readonly string[], cwd: string): Probe {
95
+ // Bounds `git` as well as `gh`: the sibling gate records that an unresponsive
96
+ // SSH remote reached through an UNTIMED git call is how a stated worst case
97
+ // stops being one.
98
+ const res = spawnSync(cmd, [...args], {
99
+ cwd,
100
+ encoding: 'utf-8',
101
+ timeout: NETWORK_TIMEOUT_MS,
102
+ maxBuffer: 16 * 1024 * 1024,
103
+ });
104
+ if (res.error !== undefined || res.status === null) {
105
+ return { ok: false, stdout: '', reason: `${cmd} did not answer within ${String(NETWORK_TIMEOUT_MS / 1000)}s` };
106
+ }
107
+ if (res.status !== 0) {
108
+ return { ok: false, stdout: res.stdout ?? '', reason: (res.stderr ?? '').trim().split('\n')[0] ?? 'non-zero exit' };
109
+ }
110
+ return { ok: true, stdout: res.stdout ?? '' };
111
+ }
112
+
113
+ export interface CheckRow {
114
+ state: string;
115
+ name: string;
116
+ }
117
+
118
+ export interface Verdict {
119
+ /** 0 = current or unobservable · 1 = the remote answered and it is stale. */
120
+ exit: 0 | 1;
121
+ /** One line, always printed — the reason is the whole product of this gate. */
122
+ message: string;
123
+ /** How many facts the gate actually compared. */
124
+ scanned: number;
125
+ }
126
+
127
+ export interface Facts {
128
+ /** null when there is no open PR for this branch — nothing to verify. */
129
+ pr: number | null;
130
+ /** The PR head SHA the FORGE reports. */
131
+ prHead: string | null;
132
+ /** Local branch tip. */
133
+ localHead: string | null;
134
+ /** The head SHA the reported checks ran against. */
135
+ checksHead: string | null;
136
+ rows: readonly CheckRow[];
137
+ /**
138
+ * Names of check rows the parse DROPPED because their `state` was not a
139
+ * string. Carried out rather than swallowed: a dropped row could be the
140
+ * failing one, so it is a skip the ledger must count, not a non-event.
141
+ */
142
+ droppedRows?: readonly string[];
143
+ /** Set when something could not be observed; forces the degrade path. */
144
+ unobservable: string | null;
145
+ /**
146
+ * How the local tip relates to the PR head — ancestry, not equality, because
147
+ * the two disagreements mean opposite things.
148
+ *
149
+ * `ahead` local is a descendant: normal state before a push, and the
150
+ * "fix never pushed" defect after one.
151
+ * `behind` local is an ancestor: the PR head is a commit this session never
152
+ * made — someone pressed Update branch. NEVER ok, in either mode:
153
+ * a push would be rejected, and forcing over it is what
154
+ * git-history-discipline gates.
155
+ * `diverged` neither contains the other. Never ok either.
156
+ */
157
+ relation: 'equal' | 'ahead' | 'behind' | 'diverged' | 'unknown';
158
+ }
159
+
160
+ export interface DecideOptions {
161
+ /**
162
+ * True when the gate runs BEFORE a push (the pre-push chain).
163
+ *
164
+ * Load-bearing, and the reason this flag exists at all: before a push, local
165
+ * being ahead of the PR head is the normal state of every branch with work on
166
+ * it. A gate that treated it as a defect would refuse every push there is —
167
+ * which is the chicken-and-egg this flag resolves rather than papers over.
168
+ * `behind` and `diverged` still block in both modes.
169
+ */
170
+ prePush?: boolean;
171
+ }
172
+
173
+ /**
174
+ * Pure decision over already-gathered facts, so the whole verdict table is
175
+ * testable without a network or a forge.
176
+ */
177
+ export function decide(f: Facts, opts: DecideOptions = {}): Verdict {
178
+ if (f.unobservable !== null) {
179
+ return { exit: 0, message: `unverified — ${f.unobservable}. Not a pass; nothing was observed.`, scanned: 0 };
180
+ }
181
+ if (f.pr === null) {
182
+ return { exit: 0, message: 'no open PR for this branch — no remote verdict to be current with.', scanned: 0 };
183
+ }
184
+ if (f.relation === 'behind' || f.relation === 'diverged') {
185
+ return {
186
+ exit: 1,
187
+ message:
188
+ `PR #${String(f.pr)} head ${f.prHead?.slice(0, 9) ?? '?'} is not reachable from the local ` +
189
+ `branch (${f.relation}) — the PR carries a commit this checkout does not have, which is what ` +
190
+ 'a GitHub *Update branch* press looks like. Merge it in; never force over it.',
191
+ scanned: 2,
192
+ };
193
+ }
194
+ if (f.relation === 'ahead') {
195
+ if (opts.prePush === true) {
196
+ return {
197
+ exit: 0,
198
+ message:
199
+ `PR #${String(f.pr)} is behind the local branch, which is the normal state before a ` +
200
+ 'push. Re-run after pushing to verify the remote verdict.',
201
+ scanned: 2,
202
+ };
203
+ }
204
+ return {
205
+ exit: 1,
206
+ message:
207
+ `PR #${String(f.pr)} head is ${f.prHead?.slice(0, 9) ?? '?'} but the local branch is at ` +
208
+ `${f.localHead?.slice(0, 9) ?? '?'} — the PR does NOT contain what is committed here. ` +
209
+ 'A local green says nothing about a fix that was never pushed.',
210
+ scanned: 2,
211
+ };
212
+ }
213
+ if (f.rows.length === 0) {
214
+ return {
215
+ exit: 1,
216
+ message:
217
+ `PR #${String(f.pr)} reports no checks at all. Absence of a red is not a green — ` +
218
+ 'either the run never registered, or the workflow paths do not match this diff.',
219
+ scanned: 2,
220
+ };
221
+ }
222
+ if (f.checksHead !== null && f.prHead !== null && f.checksHead !== f.prHead) {
223
+ return {
224
+ exit: 1,
225
+ message:
226
+ `STALE VERDICT — PR #${String(f.pr)} checks ran against ${f.checksHead.slice(0, 9)}, ` +
227
+ `head is ${f.prHead.slice(0, 9)}. Whatever those checks say, they do not say it about ` +
228
+ 'the current code. This is the failure mode that reads as SUCCESS.',
229
+ scanned: 3,
230
+ };
231
+ }
232
+ const unsettled = f.rows.filter((r) => ['PENDING', 'QUEUED', 'IN_PROGRESS'].includes(r.state));
233
+ if (unsettled.length > 0) {
234
+ return {
235
+ exit: 1,
236
+ message:
237
+ `PR #${String(f.pr)} has ${String(unsettled.length)} check(s) still running on ` +
238
+ `${f.prHead?.slice(0, 9) ?? 'head'} — pending is a reason to WAIT, never a verdict.`,
239
+ scanned: f.rows.length,
240
+ };
241
+ }
242
+ const bad = f.rows.filter((r) => !['SUCCESS', 'SKIPPED', 'NEUTRAL'].includes(r.state));
243
+ if (bad.length > 0) {
244
+ return {
245
+ exit: 1,
246
+ message:
247
+ `PR #${String(f.pr)} is RED on its current head: ` +
248
+ bad.map((r) => `${r.state} ${r.name}`).join(', '),
249
+ scanned: f.rows.length,
250
+ };
251
+ }
252
+ return {
253
+ exit: 0,
254
+ message:
255
+ `PR #${String(f.pr)} is green on its own head ${f.prHead?.slice(0, 9) ?? '?'}, ` +
256
+ 'and the local branch matches it.',
257
+ scanned: f.rows.length,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Whether {@link decide} reached the check rows at all.
263
+ *
264
+ * Every branch above the row logic — unobservable, no open PR, behind/diverged,
265
+ * ahead — settles the verdict WITHOUT reading a single row state. Recording
266
+ * those rows as `complete` would publish `scanned=N` for a run that inspected
267
+ * none of them: the absence-of-scanning-reads-as-absence-of-findings inflation
268
+ * the ledger exists to count, in the gate that adopted it. Under `--pre-push`,
269
+ * `ahead` is the NORMAL state, so this is the common path, not the exotic one.
270
+ */
271
+ export function rowsWereEvaluated(f: Facts): boolean {
272
+ if (f.unobservable !== null) return false;
273
+ if (f.pr === null) return false;
274
+ return f.relation !== 'behind' && f.relation !== 'diverged' && f.relation !== 'ahead';
275
+ }
276
+
277
+ /**
278
+ * Publish the row-level accounting for one run.
279
+ *
280
+ * Three populations, three outcomes: rows the parse dropped are skips (a dropped
281
+ * row could be the failing one), rows a row-reading verdict covered are
282
+ * completions, and rows the verdict never reached are out of scope for THIS run.
283
+ *
284
+ * The line is emitted even under `--quiet` — {@link GateLedger.report} writes
285
+ * unconditionally by design, so a `--quiet` run of this gate is one line louder
286
+ * than before. Stated here because it is a change to this gate's own contract.
287
+ */
288
+ function reportRowLedger(f: Facts): void {
289
+ const ledger = new GateLedger('check_pr_ci_current');
290
+ const dropped = new Set(f.droppedRows ?? []);
291
+ // Duplicate check names are a normal remote condition — a cancelled re-run
292
+ // beside a live one, two workflows sharing a job name, or a dropped row
293
+ // whose name equals a kept row's. `plan` throws on a repeat, and this gate
294
+ // promises to degrade rather than block, so the plan is deduplicated.
295
+ const planned = [...new Set([...f.rows.map((r) => r.name), ...dropped])];
296
+ ledger.plan(planned);
297
+ const evaluated = rowsWereEvaluated(f);
298
+ for (const name of planned) {
299
+ if (dropped.has(name)) ledger.skip(name, 'not_applicable_kind');
300
+ else if (evaluated) ledger.complete(name);
301
+ else ledger.outOfScope(name, 'precondition_unmet');
302
+ }
303
+ ledger.report();
304
+ }
305
+
306
+ /**
307
+ * Ancestry between the local tip and the PR head.
308
+ *
309
+ * Equality is not enough: "local is ahead" and "local is behind" are opposite
310
+ * situations with opposite correct actions (push vs merge), and an equality test
311
+ * collapses them into one indistinguishable mismatch. A SHA the checkout does not
312
+ * have is `unknown` rather than a guess — the fetch is the caller's business.
313
+ */
314
+ export function relate(
315
+ repo: string,
316
+ local: string | null,
317
+ pr: string | null,
318
+ ): Facts['relation'] {
319
+ if (local === null || pr === null) return 'unknown';
320
+ if (local === pr) return 'equal';
321
+ if (!run('git', ['cat-file', '-e', `${pr}^{commit}`], repo).ok) return 'unknown';
322
+ const prReachable = run('git', ['merge-base', '--is-ancestor', pr, local], repo).ok;
323
+ const localReachable = run('git', ['merge-base', '--is-ancestor', local, pr], repo).ok;
324
+ if (prReachable) return 'ahead';
325
+ if (localReachable) return 'behind';
326
+ return 'diverged';
327
+ }
328
+
329
+ /** Gather the facts `decide` consumes. Every failure becomes `unobservable`. */
330
+ export function gather(repo: string): Facts {
331
+ const empty: Facts = {
332
+ pr: null, prHead: null, localHead: null, checksHead: null, rows: [], droppedRows: [], unobservable: null, relation: 'unknown',
333
+ };
334
+
335
+ const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo);
336
+ if (!branch.ok) return { ...empty, unobservable: `cannot read the current branch (${branch.reason ?? '?'})` };
337
+ const branchName = branch.stdout.trim();
338
+ if (branchName === 'HEAD' || branchName === '') {
339
+ return { ...empty, unobservable: 'detached HEAD — no branch to resolve a PR for' };
340
+ }
341
+
342
+ const local = run('git', ['rev-parse', 'HEAD'], repo);
343
+ if (!local.ok) return { ...empty, unobservable: `cannot read the local head (${local.reason ?? '?'})` };
344
+
345
+ const pr = run(
346
+ 'gh',
347
+ ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,headRefOid', '--limit', '1'],
348
+ repo,
349
+ );
350
+ if (!pr.ok) return { ...empty, unobservable: `gh could not be reached (${pr.reason ?? '?'})` };
351
+ let parsed: Array<{ number?: number; headRefOid?: string }>;
352
+ try {
353
+ parsed = JSON.parse(pr.stdout || '[]') as Array<{ number?: number; headRefOid?: string }>;
354
+ } catch {
355
+ return { ...empty, unobservable: 'gh returned output this gate could not parse' };
356
+ }
357
+ const first = parsed[0];
358
+ if (first?.number === undefined) {
359
+ return { ...empty, localHead: local.stdout.trim(), unobservable: null };
360
+ }
361
+
362
+ const checks = run('gh', ['pr', 'checks', String(first.number), '--json', 'state,name'], repo);
363
+ // `gh pr checks` exits non-zero when any check is failing, so a non-zero exit
364
+ // with parseable stdout is DATA, not an error. Treating it as unobservable
365
+ // would make the gate blind in exactly the red case it exists for.
366
+ let rows: CheckRow[] = [];
367
+ // A row whose `state` is not a string is DROPPED, and that drop is the one
368
+ // thing in this gate worth counting: a dropped row could be the failing one,
369
+ // and without accounting the gate prints "green on its own head" over a
370
+ // check it never read. `dropped` is surfaced by the caller through the
371
+ // ledger rather than swallowed here.
372
+ let dropped: string[] = [];
373
+ if (checks.stdout.trim() !== '') {
374
+ try {
375
+ const parsed = JSON.parse(checks.stdout) as CheckRow[];
376
+ rows = parsed.filter((r) => typeof r.state === 'string');
377
+ dropped = parsed
378
+ .filter((r) => typeof r.state !== 'string')
379
+ .map((r, i) => (typeof r.name === 'string' ? r.name : `row-${String(i)}`));
380
+ } catch {
381
+ rows = [];
382
+ }
383
+ } else if (!checks.ok) {
384
+ return {
385
+ ...empty,
386
+ pr: first.number,
387
+ prHead: first.headRefOid ?? null,
388
+ localHead: local.stdout.trim(),
389
+ unobservable: `gh pr checks gave no readable output (${checks.reason ?? '?'})`,
390
+ };
391
+ }
392
+
393
+ // The SHA the checks ran against. `gh pr checks` does not expose it, so it
394
+ // comes from the check runs on the PR head — if the forge reports runs for a
395
+ // different SHA, that is the stale-green case.
396
+ const runsHead = run(
397
+ 'gh',
398
+ ['api', `repos/{owner}/{repo}/commits/${first.headRefOid ?? 'HEAD'}/check-runs`, '--jq', '.check_runs[0].head_sha'],
399
+ repo,
400
+ );
401
+
402
+ const localSha = local.stdout.trim();
403
+ const prSha = first.headRefOid ?? null;
404
+ return {
405
+ pr: first.number,
406
+ prHead: prSha,
407
+ localHead: localSha,
408
+ checksHead: runsHead.ok && runsHead.stdout.trim() !== '' ? runsHead.stdout.trim() : null,
409
+ rows,
410
+ droppedRows: dropped,
411
+ unobservable: null,
412
+ relation: relate(repo, localSha, prSha),
413
+ };
414
+ }
415
+
416
+ export function main(argv?: readonly string[]): number {
417
+ const args = argv ?? process.argv.slice(2);
418
+ let repo = process.cwd();
419
+ let quiet = false;
420
+ let prePush = false;
421
+ for (let i = 0; i < args.length; i++) {
422
+ const a = args[i] as string;
423
+ if (a === '--pre-push') {
424
+ prePush = true;
425
+ } else if (a === '--repo') {
426
+ const v = args[++i];
427
+ if (v === undefined || v.startsWith('--')) {
428
+ process.stderr.write('❌ check_pr_ci_current: --repo requires a value\n');
429
+ scanReport(0, 'argument error — nothing was compared');
430
+ return 1;
431
+ }
432
+ repo = v;
433
+ } else if (a === '--quiet') {
434
+ quiet = true;
435
+ } else if (a === '-h' || a === '--help') {
436
+ process.stdout.write(
437
+ 'usage: check_pr_ci_current [--repo PATH] [--pre-push] [--quiet]\n' +
438
+ ' --pre-push tolerates the PR being behind the local branch, which is\n' +
439
+ ' the normal state before a push; it still blocks a PR head the local\n' +
440
+ ' branch cannot reach.\n' +
441
+ ' Verifies the open PR carries the local commits AND that its check\n' +
442
+ ' verdict ran against the PR head. Degrades to exit 0 when the remote\n' +
443
+ ' cannot be observed, naming what is unverified.\n',
444
+ );
445
+ scanReport(0, 'help output — nothing was compared');
446
+ return 0;
447
+ } else {
448
+ process.stderr.write(`❌ check_pr_ci_current: unknown argument \`${a}\`\n`);
449
+ scanReport(0, 'argument error — nothing was compared');
450
+ return 1;
451
+ }
452
+ }
453
+
454
+ let verdict: Verdict;
455
+ try {
456
+ const facts = gather(repo);
457
+ // The verdict comes FIRST: whether the rows were inspected at all is a
458
+ // property of the verdict's path, not of the parse (see reportRowLedger).
459
+ verdict = decide(facts, { prePush });
460
+ reportRowLedger(facts);
461
+ } catch (exc) {
462
+ scanReport(0, 'internal error — nothing was compared');
463
+ process.stderr.write(
464
+ `❌ check_pr_ci_current: internal error: ${exc instanceof Error ? exc.message : String(exc)}\n`,
465
+ );
466
+ return 2;
467
+ }
468
+
469
+ // The degrade path stays LOUD even under --quiet: "unverified" reported
470
+ // silently is indistinguishable from "verified", which is the whole defect.
471
+ if (verdict.exit === 1) {
472
+ process.stdout.write(`❌ ${verdict.message}\n`);
473
+ } else if (verdict.message.startsWith('unverified')) {
474
+ process.stdout.write(`⚠️ check_pr_ci_current: ${verdict.message}\n`);
475
+ } else if (!quiet) {
476
+ process.stdout.write(`✅ ${verdict.message}\n`);
477
+ }
478
+ scanReport(
479
+ verdict.scanned,
480
+ verdict.scanned === 0 ? 'no open PR, or the remote could not be observed — stated above' : undefined,
481
+ );
482
+ return verdict.exit;
483
+ }
484
+
485
+ const _HERE = fileURLToPath(import.meta.url);
486
+ function _isCliEntry(): boolean {
487
+ if (process.argv[1] === undefined) return false;
488
+ return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href || process.argv[1] === _HERE;
489
+ }
490
+ if (_isCliEntry()) {
491
+ process.exit(main());
492
+ }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Gate — every committed review-findings ledger conforms to its schema AND
4
+ * declares its own independence consistently.
5
+ *
6
+ * The defect this closes is not a malformed file. It is a well-formed one that
7
+ * SAYS NOTHING: a ledger produced by a single-model pass was byte-
8
+ * indistinguishable from one backed by independent models, so a consumer read
9
+ * "no findings" as cross-model acceptance. Silence defaulted to the strongest
10
+ * possible reading, which is the wrong direction for an integrity field.
11
+ *
12
+ * Two checks, and the second is the one with teeth:
13
+ * 1. schema conformance against review-findings.schema.json;
14
+ * 2. `acceptance_status` and `assurance` must be the values
15
+ * `review_independence` derives — so a hand-set `accepted` beside a
16
+ * same-family reviewer set is caught rather than trusted.
17
+ *
18
+ * Exit codes:
19
+ * 0 — every ledger conforms and declares consistently
20
+ * 1 — one or more violations
21
+ * 2 — the gate could not run (no ledger directory)
22
+ */
23
+ import * as fs from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ import { GateLedger } from './_lib/gate_ledger.js';
29
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
30
+ import { independenceViolations } from './_lib/review_independence.js';
31
+ import { load_schema, validate, type YamlValue } from './validate_frontmatter.js';
32
+
33
+ const _HERE = fileURLToPath(import.meta.url);
34
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
35
+ const LEDGER_DIR = path.join(process.cwd(), 'agents', 'evidence', 'release-findings');
36
+
37
+ function selfTest(): number {
38
+ const mk = (name: string, body: unknown): string => {
39
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'crs-selftest-'));
40
+ const dir = path.join(root, 'agents', 'evidence', 'release-findings');
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(body, null, 2) + '\n', 'utf-8');
43
+ return root;
44
+ };
45
+ const run = (root: string): number =>
46
+ runGateCli(REPO_ROOT, 'src/scripts/check_review_schema.ts', ['--quiet'], root);
47
+
48
+ const base = {
49
+ schema_version: 1,
50
+ release: '1.0.0',
51
+ reviewers: ['anthropic', 'openai'],
52
+ findings: [],
53
+ };
54
+
55
+ return runSelfTest({
56
+ gate: 'check_review_schema',
57
+ minCases: 4,
58
+ minRejectCases: 3,
59
+ cases: [
60
+ {
61
+ name: 'a cross-family ledger declaring accepted passes',
62
+ expect: 'accept',
63
+ run: () =>
64
+ run(
65
+ mk('1.0.0.json', {
66
+ ...base,
67
+ review_independence: 'cross-family',
68
+ acceptance_status: 'accepted',
69
+ assurance: 'independent',
70
+ }),
71
+ ),
72
+ },
73
+ {
74
+ name: 'a same-family ledger claiming accepted is rejected — the whole point of the pair',
75
+ expect: 'reject',
76
+ run: () =>
77
+ run(
78
+ mk('1.0.0.json', {
79
+ ...base,
80
+ reviewers: ['anthropic', 'anthropic'],
81
+ review_independence: 'same-family',
82
+ acceptance_status: 'accepted',
83
+ assurance: 'independent',
84
+ }),
85
+ ),
86
+ },
87
+ {
88
+ name: 'a ledger that declares no independence at all is rejected — silence read as acceptance is the defect',
89
+ expect: 'reject',
90
+ run: () => run(mk('1.0.0.json', base)),
91
+ },
92
+ {
93
+ name: 'a ledger whose assurance contradicts its independence is rejected',
94
+ expect: 'reject',
95
+ run: () =>
96
+ run(
97
+ mk('1.0.0.json', {
98
+ ...base,
99
+ reviewers: ['anthropic'],
100
+ review_independence: 'single-member',
101
+ acceptance_status: 'provisional',
102
+ assurance: 'independent',
103
+ }),
104
+ ),
105
+ },
106
+ ],
107
+ });
108
+ }
109
+
110
+ function main(argv?: readonly string[]): number {
111
+ const args = argv ?? process.argv.slice(2);
112
+ if (args.includes('--self-test')) return selfTest();
113
+ const quiet = args.includes('--quiet');
114
+
115
+ if (!fs.existsSync(LEDGER_DIR)) {
116
+ process.stderr.write(
117
+ `❌ check_review_schema: ledger directory absent (${LEDGER_DIR}) — the gate cannot run\n`,
118
+ );
119
+ return 2;
120
+ }
121
+
122
+ const files = fs.readdirSync(LEDGER_DIR).filter((n) => n.endsWith('.json')).sort();
123
+ if (files.length === 0) {
124
+ process.stderr.write(
125
+ `❌ check_review_schema: 0 ledgers under ${LEDGER_DIR} — a gate that scanned nothing has verified nothing\n`,
126
+ );
127
+ return 2;
128
+ }
129
+
130
+ const ledger = new GateLedger('check_review_schema');
131
+ ledger.plan(files);
132
+ const schema = load_schema('review-findings');
133
+ const problems: string[] = [];
134
+
135
+ for (const name of files) {
136
+ const before = problems.length;
137
+ let doc: Record<string, unknown>;
138
+ try {
139
+ doc = JSON.parse(fs.readFileSync(path.join(LEDGER_DIR, name), 'utf-8')) as Record<string, unknown>;
140
+ } catch (exc) {
141
+ problems.push(`${name}: unparseable JSON — ${String(exc)}`);
142
+ ledger.fail(name, 'unparseable');
143
+ continue;
144
+ }
145
+ for (const e of validate(doc as YamlValue, schema).filter((x) => x.severity !== 'warning')) {
146
+ problems.push(`${name}: schema — ${e.format()}`);
147
+ }
148
+ for (const v of independenceViolations(doc)) {
149
+ problems.push(`${name}: independence — ${v}`);
150
+ }
151
+ if (problems.length > before) ledger.fail(name, `${String(problems.length - before)} violation(s)`);
152
+ else ledger.complete(name);
153
+ }
154
+
155
+ ledger.finalize();
156
+ process.stderr.write(`scanned: ${files.length}\n`);
157
+
158
+ if (problems.length > 0) {
159
+ process.stdout.write(
160
+ `❌ check_review_schema: ${problems.length} violation(s) across ${files.length} ledger(s)\n\n`,
161
+ );
162
+ for (const p of problems) process.stdout.write(` ${p}\n`);
163
+ process.stdout.write(
164
+ `\nA review artifact that does not declare its independence reads as cross-model\n` +
165
+ `acceptance to every consumer. Derive the fields with _lib/review_independence.ts.\n`,
166
+ );
167
+ return 1;
168
+ }
169
+
170
+ if (!quiet) {
171
+ process.stdout.write(
172
+ `✅ check_review_schema: ${files.length} ledger(s) conform and declare independence consistently.\n`,
173
+ );
174
+ }
175
+ return 0;
176
+ }
177
+
178
+ function _isCliEntry(): boolean {
179
+ if (process.argv[1] === undefined) return false;
180
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
181
+ try {
182
+ return (
183
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
184
+ fs.realpathSync(path.resolve(process.argv[1]))
185
+ );
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ if (_isCliEntry() || process.argv[1] === _HERE) {
192
+ process.exit(main());
193
+ }
194
+
195
+ export { main };