@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +41 -0
- package/README.md +5 -5
- package/dist/agent-src/commands/cost/report.md +13 -3
- package/dist/agent-src/commands/tests/create.md +28 -6
- package/dist/agent-src/commands/work.md +33 -0
- package/dist/agent-src/commands/worktree/cleanup.md +78 -0
- package/dist/agent-src/commands/worktree/create.md +62 -0
- package/dist/agent-src/commands/worktree/status.md +63 -0
- package/dist/agent-src/commands/worktree/verify.md +70 -0
- package/dist/agent-src/commands/worktree.md +85 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +47 -34
- package/dist/agent-src/rules/design-fidelity.md +40 -0
- package/dist/agent-src/rules/spreadsheet-source-quality.md +61 -0
- package/dist/agent-src/skills/accessibility-auditor/evals/evals.json +15 -0
- package/dist/agent-src/skills/analysis-skill-router/evals/evals.json +13 -0
- package/dist/agent-src/skills/api-testing/SKILL.md +15 -1
- package/dist/agent-src/skills/character-consistency/evals/evals.json +16 -0
- package/dist/agent-src/skills/command-routing/evals/evals.json +14 -0
- package/dist/agent-src/skills/dashboard-design/evals/evals.json +16 -0
- package/dist/agent-src/skills/dcf-modeling/evals/evals.json +20 -0
- package/dist/agent-src/skills/decision-record/evals/evals.json +20 -0
- package/dist/agent-src/skills/design-intelligence/evals/evals.json +14 -0
- package/dist/agent-src/skills/design-review/SKILL.md +6 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +21 -0
- package/dist/agent-src/skills/design-system-capture/evals/evals.json +15 -0
- package/dist/agent-src/skills/developer-like-execution/evals/evals.json +16 -0
- package/dist/agent-src/skills/doc-coauthoring/SKILL.md +27 -0
- package/dist/agent-src/skills/doc-coauthoring/evals/evals.json +16 -0
- package/dist/agent-src/skills/editorial-calendar/evals/domain-truth.json +16 -0
- package/dist/agent-src/skills/editorial-calendar/evals/evals.json +16 -0
- package/dist/agent-src/skills/estimate-ticket/evals/evals.json +16 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +30 -0
- package/dist/agent-src/skills/existing-ui-audit/evals/triggers.json +47 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +36 -5
- package/dist/agent-src/skills/fe-design/evals/triggers.json +47 -0
- package/dist/agent-src/skills/forecasting/evals/evals.json +20 -0
- package/dist/agent-src/skills/fundraising-narrative/evals/domain-truth.json +16 -0
- package/dist/agent-src/skills/fundraising-narrative/evals/evals.json +16 -0
- package/dist/agent-src/skills/html-deck/SKILL.md +27 -0
- package/dist/agent-src/skills/iconography/SKILL.md +24 -0
- package/dist/agent-src/skills/incident-commander/evals/domain-truth.json +16 -0
- package/dist/agent-src/skills/incident-commander/evals/evals.json +20 -0
- package/dist/agent-src/skills/launch-readiness/evals/evals.json +21 -0
- package/dist/agent-src/skills/logging-monitoring/evals/evals.json +16 -0
- package/dist/agent-src/skills/messaging-architecture/evals/domain-truth.json +16 -0
- package/dist/agent-src/skills/messaging-architecture/evals/evals.json +16 -0
- package/dist/agent-src/skills/perf-feedback-craft/evals/evals.json +16 -0
- package/dist/agent-src/skills/pest-testing/SKILL.md +1 -1
- package/dist/agent-src/skills/playwright-testing/SKILL.md +16 -0
- package/dist/agent-src/skills/prompt-engineering-patterns/evals/evals.json +16 -0
- package/dist/agent-src/skills/refine-prompt/evals/evals.json +16 -0
- package/dist/agent-src/skills/refine-ticket/evals/evals.json +16 -0
- package/dist/agent-src/skills/release-comms/evals/evals.json +16 -0
- package/dist/agent-src/skills/rice-prioritization/evals/evals.json +20 -0
- package/dist/agent-src/skills/runway-cognition/evals/evals.json +20 -0
- package/dist/agent-src/skills/scenario-modeling/evals/evals.json +20 -0
- package/dist/agent-src/skills/spreadsheet-authoring/SKILL.md +98 -0
- package/dist/agent-src/skills/spreadsheet-authoring/evals/triggers.json +47 -0
- package/dist/agent-src/skills/systematic-debugging/evals/evals.json +20 -0
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +118 -0
- package/dist/agent-src/skills/test-case-discovery/evals/triggers.json +52 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +32 -6
- package/dist/agent-src/skills/test-driven-development/evals/evals.json +16 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +7 -6
- package/dist/agent-src/skills/threat-modeling/evals/evals.json +21 -0
- package/dist/agent-src/skills/typography-system/evals/evals.json +14 -0
- package/dist/agent-src/skills/unit-economics-modeling/evals/evals.json +24 -0
- package/dist/agent-src/skills/verify-completion-evidence/evals/evals.json +16 -0
- package/dist/agent-src/skills/vision-articulation/evals/evals.json +16 -0
- package/dist/agent-src/skills/voice-and-tone-design/evals/evals.json +16 -0
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +180 -0
- package/dist/agent-src/skills/worktree-lifecycle/evals/triggers.json +52 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +249 -32
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +22 -13
- package/dist/discovery/trust-report.md +4 -4
- package/dist/discovery/workspaces.json +22 -11
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1
- package/docs/CLAIMS.md +3 -3
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +19 -8
- package/docs/command-flows.md +6 -1
- package/docs/contracts/CHANGELOG-conventions.md +6 -0
- package/docs/contracts/design-artifact-lifecycle.md +93 -0
- package/docs/contracts/design-artifact-verification.md +30 -1
- package/docs/contracts/release-pr-gating.md +26 -0
- package/docs/contracts/release-sizing.md +85 -0
- package/docs/contracts/surface-agent-contracts.md +153 -0
- package/docs/decisions/ADR-115-command-cluster-consolidation-phase-4.md +1 -1
- package/docs/distribution/consumer-matrix.md +96 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/governance-advantage.md +1 -1
- package/docs/proof.md +4 -4
- package/docs/release-runbook.md +3 -0
- package/docs/self-review-gate.md +51 -0
- package/package.json +1 -1
- package/src/scripts/_lib/orchestration_record.ts +13 -0
- package/src/scripts/_lib/orchestration_savings.ts +52 -0
- package/src/scripts/check_release_adjacent_health.ts +121 -0
- package/src/scripts/check_rule_invariants.ts +275 -0
- package/src/scripts/consumer_matrix.ts +353 -0
- package/src/scripts/generate_index.ts +3 -2
- package/src/scripts/lint_changelog_rollback.ts +293 -0
- package/src/scripts/orchestration_record.ts +16 -0
- package/src/scripts/orchestration_savings_report.ts +36 -3
- package/src/scripts/second_brain_retrieval.ts +44 -2
- package/src/scripts/self_review_gate.test.ts +65 -0
- package/src/scripts/self_review_gate.ts +272 -0
- 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.
|
|
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 };
|