@event4u/agent-config 8.9.0 → 8.10.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +5 -5
  4. package/dist/agent-src/commands/cost/report.md +13 -3
  5. package/dist/agent-src/commands/tests/create.md +28 -6
  6. package/dist/agent-src/commands/work.md +33 -0
  7. package/dist/agent-src/commands/worktree/cleanup.md +78 -0
  8. package/dist/agent-src/commands/worktree/create.md +62 -0
  9. package/dist/agent-src/commands/worktree/status.md +63 -0
  10. package/dist/agent-src/commands/worktree/verify.md +70 -0
  11. package/dist/agent-src/commands/worktree.md +85 -0
  12. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +47 -34
  13. package/dist/agent-src/rules/design-fidelity.md +40 -0
  14. package/dist/agent-src/rules/spreadsheet-source-quality.md +61 -0
  15. package/dist/agent-src/skills/accessibility-auditor/evals/evals.json +15 -0
  16. package/dist/agent-src/skills/analysis-skill-router/evals/evals.json +13 -0
  17. package/dist/agent-src/skills/api-testing/SKILL.md +15 -1
  18. package/dist/agent-src/skills/character-consistency/evals/evals.json +16 -0
  19. package/dist/agent-src/skills/command-routing/evals/evals.json +14 -0
  20. package/dist/agent-src/skills/dashboard-design/evals/evals.json +16 -0
  21. package/dist/agent-src/skills/dcf-modeling/evals/evals.json +20 -0
  22. package/dist/agent-src/skills/decision-record/evals/evals.json +20 -0
  23. package/dist/agent-src/skills/design-intelligence/evals/evals.json +14 -0
  24. package/dist/agent-src/skills/design-review/SKILL.md +6 -0
  25. package/dist/agent-src/skills/design-system-capture/SKILL.md +21 -0
  26. package/dist/agent-src/skills/design-system-capture/evals/evals.json +15 -0
  27. package/dist/agent-src/skills/developer-like-execution/evals/evals.json +16 -0
  28. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +27 -0
  29. package/dist/agent-src/skills/doc-coauthoring/evals/evals.json +16 -0
  30. package/dist/agent-src/skills/editorial-calendar/evals/domain-truth.json +16 -0
  31. package/dist/agent-src/skills/editorial-calendar/evals/evals.json +16 -0
  32. package/dist/agent-src/skills/estimate-ticket/evals/evals.json +16 -0
  33. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +30 -0
  34. package/dist/agent-src/skills/existing-ui-audit/evals/triggers.json +47 -0
  35. package/dist/agent-src/skills/fe-design/SKILL.md +36 -5
  36. package/dist/agent-src/skills/fe-design/evals/triggers.json +47 -0
  37. package/dist/agent-src/skills/forecasting/evals/evals.json +20 -0
  38. package/dist/agent-src/skills/fundraising-narrative/evals/domain-truth.json +16 -0
  39. package/dist/agent-src/skills/fundraising-narrative/evals/evals.json +16 -0
  40. package/dist/agent-src/skills/html-deck/SKILL.md +27 -0
  41. package/dist/agent-src/skills/iconography/SKILL.md +24 -0
  42. package/dist/agent-src/skills/incident-commander/evals/domain-truth.json +16 -0
  43. package/dist/agent-src/skills/incident-commander/evals/evals.json +20 -0
  44. package/dist/agent-src/skills/launch-readiness/evals/evals.json +21 -0
  45. package/dist/agent-src/skills/logging-monitoring/evals/evals.json +16 -0
  46. package/dist/agent-src/skills/messaging-architecture/evals/domain-truth.json +16 -0
  47. package/dist/agent-src/skills/messaging-architecture/evals/evals.json +16 -0
  48. package/dist/agent-src/skills/perf-feedback-craft/evals/evals.json +16 -0
  49. package/dist/agent-src/skills/pest-testing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/playwright-testing/SKILL.md +16 -0
  51. package/dist/agent-src/skills/prompt-engineering-patterns/evals/evals.json +16 -0
  52. package/dist/agent-src/skills/refine-prompt/evals/evals.json +16 -0
  53. package/dist/agent-src/skills/refine-ticket/evals/evals.json +16 -0
  54. package/dist/agent-src/skills/release-comms/evals/evals.json +16 -0
  55. package/dist/agent-src/skills/rice-prioritization/evals/evals.json +20 -0
  56. package/dist/agent-src/skills/runway-cognition/evals/evals.json +20 -0
  57. package/dist/agent-src/skills/scenario-modeling/evals/evals.json +20 -0
  58. package/dist/agent-src/skills/spreadsheet-authoring/SKILL.md +98 -0
  59. package/dist/agent-src/skills/spreadsheet-authoring/evals/triggers.json +47 -0
  60. package/dist/agent-src/skills/systematic-debugging/evals/evals.json +20 -0
  61. package/dist/agent-src/skills/test-case-discovery/SKILL.md +118 -0
  62. package/dist/agent-src/skills/test-case-discovery/evals/triggers.json +52 -0
  63. package/dist/agent-src/skills/test-driven-development/SKILL.md +32 -6
  64. package/dist/agent-src/skills/test-driven-development/evals/evals.json +16 -0
  65. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +7 -6
  66. package/dist/agent-src/skills/threat-modeling/evals/evals.json +21 -0
  67. package/dist/agent-src/skills/typography-system/evals/evals.json +14 -0
  68. package/dist/agent-src/skills/unit-economics-modeling/evals/evals.json +24 -0
  69. package/dist/agent-src/skills/verify-completion-evidence/evals/evals.json +16 -0
  70. package/dist/agent-src/skills/vision-articulation/evals/evals.json +16 -0
  71. package/dist/agent-src/skills/voice-and-tone-design/evals/evals.json +16 -0
  72. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +180 -0
  73. package/dist/agent-src/skills/worktree-lifecycle/evals/triggers.json +52 -0
  74. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  75. package/dist/discovery/deprecation-report.md +1 -1
  76. package/dist/discovery/discovery-manifest.json +249 -32
  77. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  78. package/dist/discovery/discovery-manifest.summary.md +6 -6
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +22 -13
  81. package/dist/discovery/trust-report.md +4 -4
  82. package/dist/discovery/workspaces.json +22 -11
  83. package/dist/mcp/registry-manifest.json +2 -2
  84. package/dist/router.json +1 -1
  85. package/docs/CLAIMS.md +3 -3
  86. package/docs/architecture.md +3 -3
  87. package/docs/catalog.md +19 -8
  88. package/docs/command-flows.md +6 -1
  89. package/docs/contracts/CHANGELOG-conventions.md +6 -0
  90. package/docs/contracts/design-artifact-lifecycle.md +93 -0
  91. package/docs/contracts/design-artifact-verification.md +30 -1
  92. package/docs/contracts/release-pr-gating.md +26 -0
  93. package/docs/contracts/release-sizing.md +85 -0
  94. package/docs/contracts/surface-agent-contracts.md +153 -0
  95. package/docs/decisions/ADR-115-command-cluster-consolidation-phase-4.md +1 -1
  96. package/docs/distribution/consumer-matrix.md +96 -0
  97. package/docs/featured-skills.md +2 -2
  98. package/docs/getting-started-by-role.md +1 -1
  99. package/docs/getting-started.md +2 -2
  100. package/docs/governance-advantage.md +1 -1
  101. package/docs/proof.md +4 -4
  102. package/docs/release-runbook.md +3 -0
  103. package/docs/self-review-gate.md +51 -0
  104. package/package.json +1 -1
  105. package/src/scripts/_lib/orchestration_record.ts +13 -0
  106. package/src/scripts/_lib/orchestration_savings.ts +52 -0
  107. package/src/scripts/check_release_adjacent_health.ts +121 -0
  108. package/src/scripts/check_rule_invariants.ts +275 -0
  109. package/src/scripts/consumer_matrix.ts +353 -0
  110. package/src/scripts/generate_index.ts +3 -2
  111. package/src/scripts/lint_changelog_rollback.ts +293 -0
  112. package/src/scripts/orchestration_record.ts +16 -0
  113. package/src/scripts/orchestration_savings_report.ts +36 -3
  114. package/src/scripts/second_brain_retrieval.ts +44 -2
  115. package/src/scripts/self_review_gate.test.ts +65 -0
  116. package/src/scripts/self_review_gate.ts +272 -0
  117. package/src/scripts/trigger_eval_grandfather.json +223 -224
@@ -0,0 +1,51 @@
1
+ # Self-review gate (dogfooded PR review)
2
+
3
+ The package reviews its own PRs with the exact machinery it ships —
4
+ [`adversarial-review`](../src/skills/adversarial-review/SKILL.md) +
5
+ [`agent-security-review`](../src/skills/agent-security-review/SKILL.md) — via
6
+ [`.github/workflows/self-review-gate.yml`](../.github/workflows/self-review-gate.yml)
7
+ and [`src/scripts/self_review_gate.ts`](../src/scripts/self_review_gate.ts).
8
+
9
+ `road-to-maintainer-bus-factor` Phase 1.
10
+
11
+ ## Current status — ADVISORY, inert without a secret
12
+
13
+ This is an **honest floor, not independent human review**, and today it is
14
+ **advisory + inert-without-secret**. Do not read it as "every PR is
15
+ AI-reviewed" — that is only true once the maintainer arms it (below).
16
+
17
+ | Job | Runs | Spend | Blocks merge |
18
+ |---|---|---|---|
19
+ | `gate-dry-run` | every PR | none (no API) | never — prints the review plan only |
20
+ | `live-advisory` | every PR **iff** `ANTHROPIC_API_KEY` is set | one review call | never — posts findings, records what *would* block |
21
+
22
+ Without the `ANTHROPIC_API_KEY` repo secret, `live-advisory` is a **logged
23
+ no-op** (never a failing check) — exactly the `cross-model-canary.yml` pattern.
24
+
25
+ ## The teeth (defined + wired, not yet armed)
26
+
27
+ `self_review_gate.ts` exposes pure, unit-tested:
28
+
29
+ - `classifyBlocking(finding)` — a finding is merge-blocking **iff**
30
+ `kind ∈ {security, claim}` **and** `severity ∈ {critical, high}`. Style and
31
+ correctness findings, and low/medium security findings, advise only. (Council
32
+ 2026-07-08, claude-sonnet-4-5 + gpt-4o: a 100 %-blocking gate at
33
+ solo-maintainer token cost gets ignored or gamed — block only on the narrow
34
+ security/claim × high+ intersection.)
35
+ - `gateVerdict(findings, {enforce})` — mirrors
36
+ `check_quality_regression.gateVerdict`: `0` pass / `2` block. Shipped
37
+ `enforce: false` (advisory always returns `0` and reports the would-block
38
+ set).
39
+
40
+ ## Arming it (maintainer, one flip)
41
+
42
+ 1. Add the `ANTHROPIC_API_KEY` repo secret (per-PR budget sign-off) — turns
43
+ `live-advisory` from no-op into a real dogfooded review.
44
+ 2. Pass `--enforce` in the live job to arm the teeth (block on
45
+ security/claim × high+).
46
+ 3. Require the `Self-review gate` check in branch protection
47
+ (`road-to-maintainer-bus-factor` Phase 2) so even solo merges pass the gate.
48
+ 4. Record the floor CLAIM on the proof page **once it is live** — not before
49
+ (an inert gate is not a passed gate).
50
+
51
+ Blocker `self-review-gate-cost` (maintainer-owned) gates steps 1–2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "8.9.0",
3
+ "version": "8.10.0",
4
4
  "description": "Universal AI Agent OS — audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -45,6 +45,10 @@ export interface RecordInput {
45
45
  session_tier?: string | null | undefined;
46
46
  dispatch_outcome?: DispatchOutcome | undefined;
47
47
  verify_mode?: VerifyMode | undefined;
48
+ /** QUALITY: subagent return adopted without parent rework. Optional boolean (null = not measured). */
49
+ first_pass_success?: boolean | undefined;
50
+ /** QUALITY: slice retried on a higher tier after a verification failure. Optional boolean (null = not measured). */
51
+ escalated?: boolean | undefined;
48
52
  // Audit-log envelope (sensible defaults for a dispatch record)
49
53
  phase?: LinePhase | undefined;
50
54
  outcome?: LineOutcome | undefined;
@@ -127,6 +131,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
127
131
  const phase = input.phase ?? 'implement';
128
132
  if (!PHASES.includes(phase)) errors.push(`phase must be one of ${PHASES.join(' | ')}`);
129
133
 
134
+ if (input.first_pass_success !== undefined && typeof input.first_pass_success !== 'boolean') {
135
+ errors.push('first_pass_success must be a boolean (true = return adopted without parent rework) or omitted');
136
+ }
137
+ if (input.escalated !== undefined && typeof input.escalated !== 'boolean') {
138
+ errors.push('escalated must be a boolean (true = retried on a higher tier after verification failure) or omitted');
139
+ }
140
+
130
141
  if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
131
142
  if (!input.id) errors.push('id (ULID or content hash) is required');
132
143
 
@@ -146,6 +157,8 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
146
157
  tier_source: input.tier_source ?? null,
147
158
  dispatch_tokens: input.dispatch_tokens ?? null,
148
159
  session_tier: input.session_tier ?? null,
160
+ first_pass_success: input.first_pass_success ?? null,
161
+ escalated: input.escalated ?? null,
149
162
  };
150
163
 
151
164
  const line: Record<string, unknown> = {
@@ -39,6 +39,10 @@ export interface OrchestrationRecord {
39
39
  dispatch_tokens?: number | null;
40
40
  /** The orchestrator's own tier — the baseline the downshift is measured against. */
41
41
  session_tier?: string | null;
42
+ /** QUALITY: subagent return adopted without parent rework. null/absent = pre-extension line / not measured. */
43
+ first_pass_success?: boolean | null;
44
+ /** QUALITY: slice retried on a higher tier after a verification failure. null/absent = pre-extension line / not measured. */
45
+ escalated?: boolean | null;
42
46
  }
43
47
 
44
48
  /** A parsed audit-log-v1 line (only the fields this aggregator touches). */
@@ -65,6 +69,21 @@ export interface ModeledCost {
65
69
  weights: TierWeights;
66
70
  }
67
71
 
72
+ export interface QualityStats {
73
+ /** Dispatches carrying at least one of the two quality booleans (the n behind the ≥20 pairing gate). */
74
+ quality_lines: number;
75
+ /** Dispatches carrying a boolean first_pass_success. */
76
+ first_pass_lines: number;
77
+ first_pass_success_count: number;
78
+ /** first_pass_success_count / first_pass_lines, 0..1; null when no line carries the field. */
79
+ first_pass_success_rate: number | null;
80
+ /** Dispatches carrying a boolean escalated. */
81
+ escalated_lines: number;
82
+ escalated_count: number;
83
+ /** escalated_count / escalated_lines, 0..1; null when no line carries the field. */
84
+ escalation_rate: number | null;
85
+ }
86
+
68
87
  export interface SavingsReport {
69
88
  /** Lines carrying an orchestration object with spawn_count > 0. */
70
89
  dispatches: number;
@@ -84,6 +103,8 @@ export interface SavingsReport {
84
103
  measured_share: number;
85
104
  /** Modeled downshift cost-% (tier-weight based). Distinct from the measured token counts. */
86
105
  modeled_cost: ModeledCost;
106
+ /** QUALITY dimension — paired with cost per council verdict; never report savings alone. */
107
+ quality: QualityStats;
87
108
  /** Honest caveats about what the number does and does not mean. */
88
109
  notes: string[];
89
110
  }
@@ -115,6 +136,15 @@ export function aggregateOrchestrationSavings(
115
136
  by_task_class: {},
116
137
  measured_share: 0,
117
138
  modeled_cost: { covered_dispatches: 0, baseline_cost_units: 0, delegated_cost_units: 0, cost_reduction_pct: null, weights },
139
+ quality: {
140
+ quality_lines: 0,
141
+ first_pass_lines: 0,
142
+ first_pass_success_count: 0,
143
+ first_pass_success_rate: null,
144
+ escalated_lines: 0,
145
+ escalated_count: 0,
146
+ escalation_rate: null,
147
+ },
118
148
  notes: [],
119
149
  };
120
150
 
@@ -154,6 +184,21 @@ export function aggregateOrchestrationSavings(
154
184
  report.modeled_cost.baseline_cost_units += dt * wSession;
155
185
  report.modeled_cost.delegated_cost_units += dt * wChosen;
156
186
  }
187
+
188
+ // Quality dimension — only booleans count; null/absent = pre-extension
189
+ // line / not measured (never a silent false).
190
+ const q = report.quality;
191
+ const fps = typeof o.first_pass_success === 'boolean';
192
+ const esc = typeof o.escalated === 'boolean';
193
+ if (fps || esc) q.quality_lines += 1;
194
+ if (fps) {
195
+ q.first_pass_lines += 1;
196
+ if (o.first_pass_success === true) q.first_pass_success_count += 1;
197
+ }
198
+ if (esc) {
199
+ q.escalated_lines += 1;
200
+ if (o.escalated === true) q.escalated_count += 1;
201
+ }
157
202
  }
158
203
 
159
204
  report.measured_share = report.dispatches === 0 ? 0 : report.by_provenance.measured.dispatches / report.dispatches;
@@ -161,6 +206,10 @@ export function aggregateOrchestrationSavings(
161
206
  const mc = report.modeled_cost;
162
207
  mc.cost_reduction_pct = mc.baseline_cost_units > 0 ? (mc.baseline_cost_units - mc.delegated_cost_units) / mc.baseline_cost_units : null;
163
208
 
209
+ const q = report.quality;
210
+ q.first_pass_success_rate = q.first_pass_lines > 0 ? q.first_pass_success_count / q.first_pass_lines : null;
211
+ q.escalation_rate = q.escalated_lines > 0 ? q.escalated_count / q.escalated_lines : null;
212
+
164
213
  // Honest caveats — always attached so a consumer never over-reads the number.
165
214
  if (report.dispatches === 0) {
166
215
  report.notes.push('No orchestration telemetry yet (0 dispatches). Data accrues as `subagents.auto: on` delegates real slices (recorded via orchestration_record).');
@@ -174,6 +223,9 @@ export function aggregateOrchestrationSavings(
174
223
  } else {
175
224
  report.notes.push('MODELED cost-% unavailable: no dispatch yet carries dispatch_tokens + session_tier + tier_chosen. Record those (orchestration_record --dispatch-tokens/--session-tier/--tier-chosen) to populate it.');
176
225
  }
226
+ if (q.quality_lines === 0) {
227
+ report.notes.push('QUALITY columns empty: no dispatch yet carries first_pass_success / escalated. Record them (orchestration_record --first-pass-success/--escalated) — cost and quality are reported as a PAIR, never savings alone.');
228
+ }
177
229
  }
178
230
 
179
231
  return report;
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Release-adjacent workflow health tripwire — road-to-proof-under-real-conditions
4
+ * Phase 1. The "red for five releases without anyone noticing" detector:
5
+ * the MCP worker deploy failed on every release from one minor line to the
6
+ * next and nothing surfaced it, because post-tag workflows are nobody's PR.
7
+ *
8
+ * For each watched workflow, look at its most recent COMPLETED run on the
9
+ * default branch (any trigger). If that run's conclusion is not `success`
10
+ * and it finished more than --max-red-hours ago (default 48) with no newer
11
+ * successful run, the workflow counts as durably red → exit 1 and print a
12
+ * GitHub error annotation per offender. The calling workflow turns that
13
+ * into a visible failure on main.
14
+ *
15
+ * Auth: GITHUB_TOKEN (or GH_TOKEN) env. Repo: GITHUB_REPOSITORY env or
16
+ * --repo owner/name.
17
+ *
18
+ * Exit codes: 0 all green/absent · 1 durably-red workflow(s) · 2 usage/env.
19
+ */
20
+
21
+ import { argv, env, exit } from 'node:process';
22
+
23
+ const WATCHED = [
24
+ 'publish-npm.yml',
25
+ 'deploy-mcp-worker.yml',
26
+ 'deploy-site.yml',
27
+ 'site.yml',
28
+ 'consumer-matrix.yml',
29
+ 'release.yml',
30
+ ] as const;
31
+
32
+ interface RunInfo {
33
+ conclusion: string | null;
34
+ updated_at: string;
35
+ html_url: string;
36
+ }
37
+
38
+ async function latestCompletedRun(
39
+ repo: string,
40
+ token: string,
41
+ workflow: string,
42
+ branch: string,
43
+ ): Promise<RunInfo | null> {
44
+ const url =
45
+ `https://api.github.com/repos/${repo}/actions/workflows/${workflow}/runs` +
46
+ `?status=completed&per_page=1${branch ? `&branch=${branch}` : ''}`;
47
+ const res = await fetch(url, {
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ Accept: 'application/vnd.github+json',
51
+ 'X-GitHub-Api-Version': '2022-11-28',
52
+ },
53
+ });
54
+ if (res.status === 404) return null; // workflow not present (yet)
55
+ if (!res.ok) throw new Error(`GitHub API ${res.status} for ${workflow}: ${await res.text()}`);
56
+ const body = (await res.json()) as { workflow_runs?: RunInfo[] };
57
+ return body.workflow_runs?.[0] ?? null;
58
+ }
59
+
60
+ async function main(): Promise<number> {
61
+ const args = argv.slice(2);
62
+ const getOpt = (flag: string): string | undefined => {
63
+ const i = args.indexOf(flag);
64
+ return i >= 0 ? args[i + 1] : undefined;
65
+ };
66
+ const token = env.GITHUB_TOKEN ?? env.GH_TOKEN ?? '';
67
+ const repo = getOpt('--repo') ?? env.GITHUB_REPOSITORY ?? '';
68
+ const maxRedHours = Number(getOpt('--max-red-hours') ?? '48');
69
+ if (!token || !repo) {
70
+ process.stderr.write('❌ release-adjacent-health: GITHUB_TOKEN and GITHUB_REPOSITORY (or --repo) required.\n');
71
+ return 2;
72
+ }
73
+
74
+ // Tag/release-triggered workflows have no branch association — query
75
+ // without the branch filter for those; branch-scoped for PR/push ones.
76
+ const branchless = new Set(['publish-npm.yml', 'deploy-mcp-worker.yml', 'release.yml']);
77
+ let red = 0;
78
+ for (const wf of WATCHED) {
79
+ let run: RunInfo | null;
80
+ try {
81
+ run = await latestCompletedRun(repo, token, wf, branchless.has(wf) ? '' : 'main');
82
+ } catch (err) {
83
+ process.stderr.write(`⚠️ ${wf}: ${(err as Error).message}\n`);
84
+ continue; // transient API failure must not fake a red
85
+ }
86
+ if (!run) {
87
+ process.stderr.write(`· ${wf}: no completed runs (or workflow absent) — skipping\n`);
88
+ continue;
89
+ }
90
+ if (run.conclusion === 'success') {
91
+ process.stderr.write(`✅ ${wf}: last completed run green\n`);
92
+ continue;
93
+ }
94
+ const ageHours = (Date.now() - Date.parse(run.updated_at)) / 3_600_000;
95
+ if (ageHours >= maxRedHours) {
96
+ process.stdout.write(
97
+ `::error::release-adjacent workflow durably red: ${wf} — last completed run ` +
98
+ `'${run.conclusion}' ${ageHours.toFixed(0)}h ago with no newer success. ${run.html_url}\n`,
99
+ );
100
+ red += 1;
101
+ } else {
102
+ process.stderr.write(
103
+ `⚠️ ${wf}: last run '${run.conclusion}' ${ageHours.toFixed(0)}h ago (< ${maxRedHours}h window)\n`,
104
+ );
105
+ }
106
+ }
107
+ if (red > 0) {
108
+ process.stderr.write(`❌ ${red} release-adjacent workflow(s) durably red.\n`);
109
+ return 1;
110
+ }
111
+ process.stderr.write('✅ release-adjacent workflows healthy.\n');
112
+ return 0;
113
+ }
114
+
115
+ main().then(
116
+ (code) => exit(code),
117
+ (err) => {
118
+ process.stderr.write(`❌ release-adjacent-health crashed: ${(err as Error).stack ?? err}\n`);
119
+ exit(2);
120
+ },
121
+ );
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Kernel semantic-invariants checker.
4
+ *
5
+ * Counterfactual (2026-07-10): PRs #840/#844 showed MERGED on GitHub but their
6
+ * content vanished from main (merge commits unreachable after a history repair);
7
+ * re-landed as #847 (13f244c9b — non-destructive "Irreversible external action"
8
+ * row + "Never act while asking" clause) and #849 (2c46e7f3b — direct-answers
9
+ * "no duration estimates" + "never cite the rule"). This checker's invariant set
10
+ * covers those exact strings — it would have flagged the loss at CI time.
11
+ *
12
+ * Loads `tests/golden/invariants.json` and verifies that every invariant
13
+ * string (whitespace-normalised: runs of whitespace collapse to a single
14
+ * space) is present in BOTH the kernel rule's `src/rules/` source and its
15
+ * `dist/agent-src/rules/` projection. A missing string means a rule's
16
+ * behaviour guarantee was silently lost in a merge / condensation pass.
17
+ *
18
+ * Kernel set: docs/contracts/kernel-membership.md § 4 (9 locked rules).
19
+ *
20
+ * Usage:
21
+ * npx tsx src/scripts/check_rule_invariants.ts [--root ROOT]
22
+ * npx tsx src/scripts/check_rule_invariants.ts --mutation-selftest
23
+ *
24
+ * `--mutation-selftest` picks the first invariant and verifies the check
25
+ * WOULD fail if that string were absent (in-memory mutation) — guards the
26
+ * checker itself against a silently-green normalisation bug.
27
+ *
28
+ * Exit codes: 0 = clean / selftest passed, 1 = missing invariant(s) or
29
+ * selftest failure, 2 = usage error, 3 = internal error.
30
+ */
31
+
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+
36
+ // src/scripts/check_rule_invariants.ts → two dirs up is the repo root.
37
+ const _HERE = fileURLToPath(import.meta.url);
38
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
39
+
40
+ const INVARIANTS_JSON = 'tests/golden/invariants.json';
41
+
42
+ interface InvariantEntry {
43
+ rule: string;
44
+ file: string;
45
+ dist: string;
46
+ strings: string[];
47
+ }
48
+
49
+ interface Miss {
50
+ rule: string;
51
+ file: string;
52
+ invariant: string;
53
+ }
54
+
55
+ /** Collapse every run of whitespace (incl. newlines) to a single space. */
56
+ function normalize(text: string): string {
57
+ return text.replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ function loadEntries(root: string): InvariantEntry[] {
61
+ const p = path.join(root, INVARIANTS_JSON);
62
+ const raw = fs.readFileSync(p, 'utf-8');
63
+ const parsed = JSON.parse(raw) as unknown;
64
+ if (!Array.isArray(parsed)) {
65
+ throw new Error(`${INVARIANTS_JSON}: expected a top-level array`);
66
+ }
67
+ for (const entry of parsed as InvariantEntry[]) {
68
+ if (
69
+ typeof entry.rule !== 'string' ||
70
+ typeof entry.file !== 'string' ||
71
+ typeof entry.dist !== 'string' ||
72
+ !Array.isArray(entry.strings) ||
73
+ entry.strings.some((s) => typeof s !== 'string' || s.length === 0)
74
+ ) {
75
+ throw new Error(
76
+ `${INVARIANTS_JSON}: malformed entry ${JSON.stringify(entry.rule ?? entry)}`,
77
+ );
78
+ }
79
+ }
80
+ return parsed as InvariantEntry[];
81
+ }
82
+
83
+ /** Check one entry against pre-normalised file contents. */
84
+ function checkEntry(
85
+ entry: InvariantEntry,
86
+ normalizedByFile: ReadonlyMap<string, string>,
87
+ ): Miss[] {
88
+ const misses: Miss[] = [];
89
+ for (const target of [entry.file, entry.dist]) {
90
+ const haystack = normalizedByFile.get(target);
91
+ if (haystack === undefined) {
92
+ for (const s of entry.strings) {
93
+ misses.push({ rule: entry.rule, file: target, invariant: s });
94
+ }
95
+ continue;
96
+ }
97
+ for (const s of entry.strings) {
98
+ if (!haystack.includes(normalize(s))) {
99
+ misses.push({ rule: entry.rule, file: target, invariant: s });
100
+ }
101
+ }
102
+ }
103
+ return misses;
104
+ }
105
+
106
+ function readNormalized(root: string, entries: readonly InvariantEntry[]): Map<string, string> {
107
+ const out = new Map<string, string>();
108
+ for (const entry of entries) {
109
+ for (const target of [entry.file, entry.dist]) {
110
+ if (out.has(target)) {
111
+ continue;
112
+ }
113
+ const p = path.join(root, target);
114
+ try {
115
+ out.set(target, normalize(fs.readFileSync(p, 'utf-8')));
116
+ } catch {
117
+ // Leave absent — checkEntry reports every string as missing.
118
+ }
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function scan(root: string): Miss[] {
125
+ const entries = loadEntries(root);
126
+ const normalizedByFile = readNormalized(root, entries);
127
+ const misses: Miss[] = [];
128
+ for (const entry of entries) {
129
+ misses.push(...checkEntry(entry, normalizedByFile));
130
+ }
131
+ return misses;
132
+ }
133
+
134
+ function formatText(misses: Miss[], invariantCount: number): string {
135
+ if (misses.length === 0) {
136
+ return `✅ All ${invariantCount} kernel rule invariants present in src + dist.`;
137
+ }
138
+ const lines: string[] = [
139
+ `❌ ${misses.length} missing kernel rule invariant(s):\n`,
140
+ ];
141
+ for (const m of misses) {
142
+ lines.push(` 🔴 ${m.rule} — ${m.file}\n missing: "${m.invariant}"`);
143
+ }
144
+ lines.push(
145
+ '\nA kernel rule lost a load-bearing clause — likely a merge or condensation ' +
146
+ 'dropped it silently (see the #840/#844 incident in this file header). ' +
147
+ 'Restore the clause or deliberately update tests/golden/invariants.json.',
148
+ );
149
+ return lines.join('\n');
150
+ }
151
+
152
+ /**
153
+ * Mutation self-test: remove the first invariant string from its src file's
154
+ * content in-memory and verify the check catches the absence.
155
+ */
156
+ function mutationSelftest(root: string): number {
157
+ const entries = loadEntries(root);
158
+ const first = entries[0];
159
+ if (first === undefined || first.strings[0] === undefined) {
160
+ process.stderr.write('mutation-selftest: no invariants defined\n');
161
+ return 1;
162
+ }
163
+ const invariant = first.strings[0];
164
+ const p = path.join(root, first.file);
165
+ const original = normalize(fs.readFileSync(p, 'utf-8'));
166
+ const needle = normalize(invariant);
167
+ if (!original.includes(needle)) {
168
+ process.stderr.write(
169
+ `mutation-selftest: baseline broken — "${invariant}" not present in ${first.file}\n`,
170
+ );
171
+ return 1;
172
+ }
173
+ const mutated = original.split(needle).join('');
174
+ if (mutated.includes(needle)) {
175
+ process.stderr.write('mutation-selftest: mutation did not remove the string\n');
176
+ return 1;
177
+ }
178
+ // Run the REAL check path against the mutated content: swap the src
179
+ // file's normalised content in-memory and assert checkEntry reports
180
+ // the removed invariant as missing.
181
+ const normalizedByFile = readNormalized(root, [first]);
182
+ normalizedByFile.set(first.file, mutated);
183
+ const misses = checkEntry(first, normalizedByFile);
184
+ const detected = misses.some(
185
+ (m) => m.file === first.file && m.invariant === invariant,
186
+ );
187
+ if (!detected) {
188
+ process.stderr.write(
189
+ `mutation-selftest: FAILED — removing "${invariant}" from ${first.file} was NOT detected\n`,
190
+ );
191
+ return 1;
192
+ }
193
+ process.stdout.write(
194
+ `✅ mutation-selftest passed: removing "${invariant}" from ${first.file} is detected.\n`,
195
+ );
196
+ return 0;
197
+ }
198
+
199
+ interface ParsedArgs {
200
+ root: string;
201
+ mutationSelftest: boolean;
202
+ }
203
+
204
+ function parse_args(argv: readonly string[]): ParsedArgs {
205
+ let root = ROOT;
206
+ let selftest = false;
207
+ for (let i = 0; i < argv.length; i++) {
208
+ const arg = argv[i]!;
209
+ if (arg === '--root') {
210
+ const v = argv[++i];
211
+ if (v === undefined) {
212
+ _argparse_error('argument --root: expected one argument');
213
+ }
214
+ root = v;
215
+ } else if (arg.startsWith('--root=')) {
216
+ root = arg.slice('--root='.length);
217
+ } else if (arg === '--mutation-selftest') {
218
+ selftest = true;
219
+ } else if (arg === '-h' || arg === '--help') {
220
+ process.stdout.write(
221
+ 'usage: check_rule_invariants [-h] [--root ROOT] [--mutation-selftest]\n',
222
+ );
223
+ process.exit(0);
224
+ } else {
225
+ _argparse_error(`unrecognized arguments: ${arg}`);
226
+ }
227
+ }
228
+ return { root, mutationSelftest: selftest };
229
+ }
230
+
231
+ function _argparse_error(message: string): never {
232
+ process.stderr.write(`check_rule_invariants: error: ${message}\n`);
233
+ process.exit(2);
234
+ }
235
+
236
+ function main(): number {
237
+ const args = parse_args(process.argv.slice(2));
238
+ try {
239
+ if (args.mutationSelftest) {
240
+ return mutationSelftest(args.root);
241
+ }
242
+ const entries = loadEntries(args.root);
243
+ const invariantCount = entries.reduce((n, e) => n + e.strings.length, 0);
244
+ const misses = scan(args.root);
245
+ process.stdout.write(formatText(misses, invariantCount) + '\n');
246
+ return misses.length ? 1 : 0;
247
+ } catch (e) {
248
+ const msg = e instanceof Error ? e.message : String(e);
249
+ process.stderr.write(`Internal error: ${msg}\n`);
250
+ return 3;
251
+ }
252
+ }
253
+
254
+ function _isCliEntry(): boolean {
255
+ if (process.argv[1] === undefined) {
256
+ return false;
257
+ }
258
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
259
+ if (import.meta.url === argvUrl) {
260
+ return true;
261
+ }
262
+ try {
263
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
264
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
265
+ return here === argv;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ if (_isCliEntry() || process.argv[1] === _HERE) {
272
+ process.exit(main());
273
+ }
274
+
275
+ export { type InvariantEntry, type Miss, ROOT, INVARIANTS_JSON, normalize, scan, main };