@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,353 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Consumer-proof release matrix — road-to-proof-under-real-conditions Phase 1.
4
+ *
5
+ * Exercises the PUBLISHED TARBALL the way a consumer does: pack → fresh
6
+ * global install (isolated prefix) → `init` into a fresh project → doctor →
7
+ * conformance → MCP stdio handshake → hook health → projection presence →
8
+ * uninstall. Optional upgrade leg: install the last published minor from the
9
+ * registry, then upgrade to the packed tarball and assert doctor stays green.
10
+ *
11
+ * Why this exists: the release-PR gate skips source-level matrices (soundly —
12
+ * a version-bump diff cannot regress source behaviour), but nothing between
13
+ * merge and tag exercised the tarball shape. Two published minors shipped
14
+ * without `src/install/` while published code imported from it; `tsx` was
15
+ * missing from the published package; each manifested only at publish time.
16
+ * Every leg here asserts exit code + an expected artifact, no LLM calls.
17
+ *
18
+ * Usage:
19
+ * npx tsx src/scripts/consumer_matrix.ts [--tarball <path>] [--only leg1,leg2]
20
+ * [--skip-registry] [--keep-tmp] [--json]
21
+ *
22
+ * Without --tarball the script runs `npm pack` itself (requires a built
23
+ * tree — `npm ci && npm run build` first; CI does this, local carve-out
24
+ * runs do it once).
25
+ *
26
+ * Exit codes: 0 all requested legs green · 1 leg failure · 2 usage/env.
27
+ */
28
+
29
+ import { spawnSync, spawn } from 'node:child_process';
30
+ import * as fs from 'node:fs';
31
+ import * as os from 'node:os';
32
+ import * as path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ const _HERE = fileURLToPath(import.meta.url);
36
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
37
+ const PKG_NAME = '@event4u/agent-config';
38
+
39
+ interface Ctx {
40
+ tmpRoot: string;
41
+ prefix: string; // isolated npm global prefix
42
+ projectDir: string; // fresh consumer project
43
+ tarball: string;
44
+ bin: string; // installed agent-config binary
45
+ keepTmp: boolean;
46
+ skipRegistry: boolean;
47
+ results: LegResult[];
48
+ }
49
+
50
+ interface LegResult {
51
+ leg: string;
52
+ ok: boolean;
53
+ detail: string;
54
+ durationMs: number;
55
+ }
56
+
57
+ function log(msg: string): void {
58
+ process.stderr.write(`${msg}\n`);
59
+ }
60
+
61
+ function run(
62
+ cmd: string,
63
+ args: string[],
64
+ opts: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
65
+ ): { status: number; stdout: string; stderr: string } {
66
+ const r = spawnSync(cmd, args, {
67
+ cwd: opts.cwd ?? REPO_ROOT,
68
+ env: { ...process.env, ...opts.env },
69
+ encoding: 'utf8',
70
+ timeout: opts.timeoutMs ?? 600_000,
71
+ maxBuffer: 32 * 1024 * 1024,
72
+ });
73
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
74
+ }
75
+
76
+ function fail(msg: string): never {
77
+ log(`❌ consumer-matrix: ${msg}`);
78
+ process.exit(1);
79
+ }
80
+
81
+ /**
82
+ * Env for running the installed binary against the isolated prefix.
83
+ * HOME is redirected into the tmp root so the simulation is a FRESH
84
+ * MACHINE even on a maintainer host: without this, an existing global
85
+ * install (~/.event4u, ~/.claude, …) trips the cross-scope drift guard
86
+ * inside `init` and the leg fails on developer machines while passing
87
+ * on clean CI runners — the opposite of what a consumer gate is for.
88
+ */
89
+ function binEnv(ctx: Ctx): NodeJS.ProcessEnv {
90
+ const home = path.join(ctx.tmpRoot, 'home');
91
+ fs.mkdirSync(home, { recursive: true });
92
+ return {
93
+ PATH: `${path.join(ctx.prefix, 'bin')}${path.delimiter}${process.env.PATH ?? ''}`,
94
+ npm_config_prefix: ctx.prefix,
95
+ npm_config_cache: path.join(ctx.tmpRoot, 'npm-cache'),
96
+ HOME: home,
97
+ // Never let a maintainer checkout leak into the consumer simulation.
98
+ AGENT_CONFIG_DEV_MODE: '',
99
+ };
100
+ }
101
+
102
+ // ── legs ────────────────────────────────────────────────────────────
103
+
104
+ function legPack(ctx: Ctx): string {
105
+ if (ctx.tarball) {
106
+ if (!fs.existsSync(ctx.tarball)) fail(`--tarball not found: ${ctx.tarball}`);
107
+ return `reused ${path.basename(ctx.tarball)}`;
108
+ }
109
+ const r = run('npm', ['pack', '--ignore-scripts', '--silent', '--pack-destination', ctx.tmpRoot]);
110
+ if (r.status !== 0) throw new Error(`npm pack failed: ${r.stderr.slice(-400)}`);
111
+ const name = r.stdout.trim().split('\n').pop() ?? '';
112
+ ctx.tarball = path.join(ctx.tmpRoot, name);
113
+ if (!fs.existsSync(ctx.tarball)) throw new Error(`packed tarball missing: ${ctx.tarball}`);
114
+ return path.basename(ctx.tarball);
115
+ }
116
+
117
+ function legFreshInstall(ctx: Ctx): string {
118
+ const r = run('npm', ['install', '-g', '--prefix', ctx.prefix, ctx.tarball], { timeoutMs: 900_000 });
119
+ if (r.status !== 0) throw new Error(`global install failed: ${r.stderr.slice(-600)}`);
120
+ ctx.bin = path.join(ctx.prefix, 'bin', 'agent-config');
121
+ if (!fs.existsSync(ctx.bin)) throw new Error(`installed binary missing at ${ctx.bin}`);
122
+ const v = run(ctx.bin, ['--version'], { env: binEnv(ctx) });
123
+ if (v.status !== 0) throw new Error(`--version failed (missing runtime dep in tarball?): ${v.stderr.slice(-400)}`);
124
+ return `bin ok, version ${v.stdout.trim()}`;
125
+ }
126
+
127
+ function legInit(ctx: Ctx): string {
128
+ fs.mkdirSync(ctx.projectDir, { recursive: true });
129
+ run('git', ['init', '-q', '.'], { cwd: ctx.projectDir });
130
+ // --scope=auto is the working headless consumer invocation. The bare
131
+ // default ('project default for backward compatibility') hard-fails on
132
+ // a fresh machine with '--scope=project is reserved for maintainers'
133
+ // (ADR-020) — a real trap this matrix documents; see
134
+ // docs/distribution/consumer-matrix.md § Headless default-scope trap.
135
+ const r = run(ctx.bin, ['init', '--tools=claude-code,cursor,windsurf', '--scope=auto', '--yes'], {
136
+ cwd: ctx.projectDir,
137
+ env: binEnv(ctx),
138
+ timeoutMs: 600_000,
139
+ });
140
+ if (r.status !== 0) throw new Error(`init failed: ${(r.stderr + r.stdout).slice(-600)}`);
141
+ // ADR-020: consumer installs are GLOBAL-only — assert the global trees.
142
+ const home = path.join(ctx.tmpRoot, 'home');
143
+ for (const p of ['.claude', '.cursor', '.codeium/windsurf', '.event4u']) {
144
+ if (!fs.existsSync(path.join(home, p))) throw new Error(`init did not write ~/${p}`);
145
+ }
146
+ return 'init --scope=auto wrote global ~/.claude, ~/.cursor, ~/.codeium/windsurf, ~/.event4u';
147
+ }
148
+
149
+ function legConformance(ctx: Ctx): string {
150
+ const r = run(ctx.bin, ['conformance'], { cwd: ctx.projectDir, env: binEnv(ctx) });
151
+ if (r.status !== 0) throw new Error(`conformance exit ${r.status}: ${(r.stdout + r.stderr).slice(-600)}`);
152
+ return 'conformance green (embeds doctor --ci + installed-and-firing checks)';
153
+ }
154
+
155
+ async function legMcpHandshake(ctx: Ctx): Promise<string> {
156
+ // Newline-delimited JSON-RPC per src/cli/mcp/stdio.ts.
157
+ return await new Promise<string>((resolve, reject) => {
158
+ const child = spawn(ctx.bin, ['mcp-server'], {
159
+ cwd: ctx.projectDir,
160
+ env: { ...process.env, ...binEnv(ctx) },
161
+ stdio: ['pipe', 'pipe', 'pipe'],
162
+ });
163
+ let out = '';
164
+ const timer = setTimeout(() => {
165
+ child.kill();
166
+ reject(new Error(`no initialize response within 20s; stdout so far: ${out.slice(0, 300)}`));
167
+ }, 20_000);
168
+ child.stdout.on('data', (d: Buffer) => {
169
+ out += d.toString();
170
+ const line = out.split('\n').find((l) => l.trim().startsWith('{'));
171
+ if (line) {
172
+ try {
173
+ const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: unknown };
174
+ clearTimeout(timer);
175
+ child.kill();
176
+ if (msg.id === 1 && msg.result && !msg.error) {
177
+ resolve('initialize handshake ok');
178
+ } else {
179
+ reject(new Error(`unexpected first message: ${line.slice(0, 300)}`));
180
+ }
181
+ } catch {
182
+ /* partial line — keep buffering */
183
+ }
184
+ }
185
+ });
186
+ child.on('error', (e) => {
187
+ clearTimeout(timer);
188
+ reject(e);
189
+ });
190
+ child.on('exit', (code) => {
191
+ if (out === '') {
192
+ clearTimeout(timer);
193
+ reject(new Error(`mcp-server exited (${code}) before responding`));
194
+ }
195
+ });
196
+ child.stdin.write(
197
+ `${JSON.stringify({
198
+ jsonrpc: '2.0',
199
+ id: 1,
200
+ method: 'initialize',
201
+ params: {
202
+ protocolVersion: '2025-03-26',
203
+ capabilities: {},
204
+ clientInfo: { name: 'consumer-matrix', version: '0' },
205
+ },
206
+ })}\n`,
207
+ );
208
+ });
209
+ }
210
+
211
+ function legHooks(ctx: Ctx): string {
212
+ const r = run(ctx.bin, ['hooks:doctor'], { cwd: ctx.projectDir, env: binEnv(ctx) });
213
+ if (r.status !== 0) throw new Error(`hooks:doctor exit ${r.status}: ${(r.stdout + r.stderr).slice(-600)}`);
214
+ return 'hooks:doctor green';
215
+ }
216
+
217
+ function legProjections(ctx: Ctx): string {
218
+ const home = path.join(ctx.tmpRoot, 'home');
219
+ const missing = ['.cursor/rules', '.codeium/windsurf', '.claude/skills']
220
+ .filter((p) => !fs.existsSync(path.join(home, p)))
221
+ .join(', ');
222
+ if (missing) throw new Error(`global projection trees missing: ${missing}`);
223
+ return 'cursor + windsurf + claude projection trees present in global scope';
224
+ }
225
+
226
+ function legUninstall(ctx: Ctx): string {
227
+ const r = run(ctx.bin, ['uninstall', '--global', '--tools=claude-code,cursor,windsurf', '--force'], {
228
+ cwd: ctx.projectDir,
229
+ env: binEnv(ctx),
230
+ });
231
+ if (r.status !== 0) throw new Error(`uninstall exit ${r.status}: ${(r.stdout + r.stderr).slice(-600)}`);
232
+ const lock = path.join(ctx.tmpRoot, 'home', '.event4u', 'agent-config', 'installed.lock');
233
+ if (fs.existsSync(lock)) throw new Error(`global lockfile still present after uninstall: ${lock}`);
234
+ return 'uninstall --global green, lockfile removed';
235
+ }
236
+
237
+ function legUpgrade(ctx: Ctx): string {
238
+ if (ctx.skipRegistry) return 'skipped (--skip-registry)';
239
+ // Last published version from the registry (network).
240
+ const view = run('npm', ['view', PKG_NAME, 'version'], { timeoutMs: 120_000 });
241
+ if (view.status !== 0) throw new Error(`npm view failed: ${view.stderr.slice(-300)}`);
242
+ const published = view.stdout.trim();
243
+ const upPrefix = path.join(ctx.tmpRoot, 'prefix-upgrade');
244
+ const upProject = path.join(ctx.tmpRoot, 'project-upgrade');
245
+ fs.mkdirSync(upProject, { recursive: true });
246
+ const upHome = path.join(ctx.tmpRoot, 'home-upgrade');
247
+ fs.mkdirSync(upHome, { recursive: true });
248
+ const env = {
249
+ PATH: `${path.join(upPrefix, 'bin')}${path.delimiter}${process.env.PATH ?? ''}`,
250
+ npm_config_prefix: upPrefix,
251
+ npm_config_cache: path.join(ctx.tmpRoot, 'npm-cache'),
252
+ HOME: upHome,
253
+ AGENT_CONFIG_DEV_MODE: '',
254
+ };
255
+ let r = run('npm', ['install', '-g', '--prefix', upPrefix, `${PKG_NAME}@${published}`], { timeoutMs: 900_000 });
256
+ if (r.status !== 0) throw new Error(`registry install of ${published} failed: ${r.stderr.slice(-400)}`);
257
+ const bin = path.join(upPrefix, 'bin', 'agent-config');
258
+ r = run(bin, ['init', '--tools=claude-code', '--scope=auto', '--yes'], { cwd: upProject, env, timeoutMs: 600_000 });
259
+ if (r.status !== 0) throw new Error(`init on ${published} failed: ${(r.stderr + r.stdout).slice(-400)}`);
260
+ // Upgrade: overwrite the global install with the packed tarball.
261
+ r = run('npm', ['install', '-g', '--prefix', upPrefix, ctx.tarball], { timeoutMs: 900_000 });
262
+ if (r.status !== 0) throw new Error(`tarball upgrade failed: ${r.stderr.slice(-400)}`);
263
+ r = run(bin, ['conformance'], { cwd: upProject, env });
264
+ if (r.status !== 0) {
265
+ throw new Error(`conformance red after ${published} → tarball upgrade: ${(r.stdout + r.stderr).slice(-600)}`);
266
+ }
267
+ return `upgrade ${published} → packed tarball, conformance green`;
268
+ }
269
+
270
+ // ── main ────────────────────────────────────────────────────────────
271
+
272
+ const LEGS: Array<{ name: string; fn: (ctx: Ctx) => string | Promise<string> }> = [
273
+ { name: 'pack', fn: legPack },
274
+ { name: 'fresh-install', fn: legFreshInstall },
275
+ { name: 'init', fn: legInit },
276
+ { name: 'conformance', fn: legConformance },
277
+ { name: 'mcp-handshake', fn: legMcpHandshake },
278
+ { name: 'hooks', fn: legHooks },
279
+ { name: 'projections', fn: legProjections },
280
+ { name: 'uninstall', fn: legUninstall },
281
+ { name: 'upgrade', fn: legUpgrade },
282
+ ];
283
+
284
+ async function main(): Promise<number> {
285
+ const argv = process.argv.slice(2);
286
+ const getOpt = (flag: string): string | undefined => {
287
+ const i = argv.indexOf(flag);
288
+ return i >= 0 ? argv[i + 1] : undefined;
289
+ };
290
+ const only = getOpt('--only')?.split(',').map((s) => s.trim());
291
+ const unknownLeg = only?.find((l) => !LEGS.some((x) => x.name === l));
292
+ if (unknownLeg) {
293
+ log(`❌ unknown leg '${unknownLeg}'. Legs: ${LEGS.map((l) => l.name).join(', ')}`);
294
+ return 2;
295
+ }
296
+
297
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'consumer-matrix-'));
298
+ const ctx: Ctx = {
299
+ tmpRoot,
300
+ prefix: path.join(tmpRoot, 'prefix'),
301
+ projectDir: path.join(tmpRoot, 'project'),
302
+ tarball: getOpt('--tarball') ?? '',
303
+ bin: '',
304
+ keepTmp: argv.includes('--keep-tmp'),
305
+ skipRegistry: argv.includes('--skip-registry'),
306
+ results: [],
307
+ };
308
+ fs.mkdirSync(ctx.prefix, { recursive: true });
309
+ log(`consumer-matrix · tmp ${tmpRoot} · node ${process.version}`);
310
+
311
+ let failed = false;
312
+ for (const leg of LEGS) {
313
+ if (only && !only.includes(leg.name)) continue;
314
+ // Legs after a hard dependency failure cannot run meaningfully.
315
+ if (failed) {
316
+ ctx.results.push({ leg: leg.name, ok: false, detail: 'skipped (earlier leg failed)', durationMs: 0 });
317
+ continue;
318
+ }
319
+ const t0 = Date.now();
320
+ try {
321
+ const detail = await leg.fn(ctx);
322
+ ctx.results.push({ leg: leg.name, ok: true, detail, durationMs: Date.now() - t0 });
323
+ log(` ✅ ${leg.name} — ${detail}`);
324
+ } catch (err) {
325
+ ctx.results.push({
326
+ leg: leg.name,
327
+ ok: false,
328
+ detail: (err as Error).message,
329
+ durationMs: Date.now() - t0,
330
+ });
331
+ log(` ❌ ${leg.name} — ${(err as Error).message}`);
332
+ failed = true;
333
+ }
334
+ }
335
+
336
+ if (argv.includes('--json')) {
337
+ process.stdout.write(`${JSON.stringify({ ok: !failed, results: ctx.results }, null, 2)}\n`);
338
+ }
339
+ if (!ctx.keepTmp) {
340
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
341
+ } else {
342
+ log(`kept tmp: ${tmpRoot}`);
343
+ }
344
+ return failed ? 1 : 0;
345
+ }
346
+
347
+ main().then(
348
+ (code) => process.exit(code),
349
+ (err) => {
350
+ log(`❌ consumer-matrix crashed: ${(err as Error).stack ?? err}`);
351
+ process.exit(2);
352
+ },
353
+ );
@@ -422,8 +422,9 @@ export function _render_catalog(
422
422
  '> **Non-coding domain correctness is scoped, not proven.** The',
423
423
  '> `finance` / `founder` / `ops` / `content` skills are forged on TS/PHP',
424
424
  `> codebases; ${ds.validated} of ${ds.total} default-surface domain skills carry a sourced`,
425
- '> `evals/domain-truth.json` fixture (deterministic targets, keys from',
426
- '> cited formulas); the rest are labeled `unvalidated` until they pass one',
425
+ '> `evals/domain-truth.json` fixture (deterministic targets with keys from',
426
+ '> cited formulas, or rubric targets matching a named external practice);',
427
+ '> the rest are labeled `unvalidated` until they pass one',
427
428
  '> (`./scripts-run src/scripts/domain_soundness_status`). A disclaimer',
428
429
  '> floor bounds liability, not correctness — see [the proof page](proof.md).',
429
430
  '',
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CHANGELOG `Rollback:` linter.
4
+ *
5
+ * Normative source: `docs/contracts/release-sizing.md § Rollback path`.
6
+ * Every NEW minor / major section in `CHANGELOG.md` — a heading matching
7
+ * the repo's `## [X.Y.0](compare-link) (date)` shape (patch component 0)
8
+ * — must contain at least one `Rollback:` line anywhere in its section,
9
+ * naming how a consumer undoes the subsystem the release introduced or
10
+ * substantially reworked.
11
+ *
12
+ * Cutoff — decidable, no retro-fail: the gate only fires for section
13
+ * versions **strictly greater** than the current `package.json` version
14
+ * at lint time. Historical sections are grandfathered by construction.
15
+ * `--cutoff X.Y.Z` overrides the cutoff (e.g. the release pipeline can
16
+ * pass the PREVIOUS released version so the section it is about to cut
17
+ * is itself in scope).
18
+ *
19
+ * Shares the heading / era regexes with the drift gate via
20
+ * `src/scripts/_lib/changelog_eras.ts` — no parallel copies.
21
+ *
22
+ * CLI: exit 0 clean, 1 violations (or selftest failure), 2 usage error.
23
+ * `--selftest` proves the rule on inline fixtures: a minor section
24
+ * without `Rollback:` fails; one with it passes.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { fileURLToPath, pathToFileURL } from 'node:url';
30
+
31
+ import {
32
+ CHANGELOG,
33
+ REPO_ROOT,
34
+ VERSION_HEADING_RE,
35
+ } from './_lib/changelog_eras.js';
36
+
37
+ const _HERE = fileURLToPath(import.meta.url);
38
+
39
+ const ROLLBACK_LINE_RE = /\bRollback:/;
40
+ // Section ends at the next version heading, the next era header, or any
41
+ // other level-1/2 heading (`## [Unreleased]`, `# Era: …`). `###` sub-
42
+ // headings stay inside the section.
43
+ const SECTION_BOUNDARY_RE = /^##? /;
44
+
45
+ export interface Violation {
46
+ readonly version: string;
47
+ readonly line: number; // 1-based heading line
48
+ }
49
+
50
+ function parse_semver(v: string): [number, number, number] | null {
51
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
52
+ if (!m) {
53
+ return null;
54
+ }
55
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
56
+ }
57
+
58
+ function semver_gt(a: [number, number, number], b: [number, number, number]): boolean {
59
+ if (a[0] !== b[0]) return a[0] > b[0];
60
+ if (a[1] !== b[1]) return a[1] > b[1];
61
+ return a[2] > b[2];
62
+ }
63
+
64
+ /** Read the cutoff version from package.json at the repo root. */
65
+ function package_json_version(): string {
66
+ const pkg_path = path.join(REPO_ROOT, 'package.json');
67
+ const pkg = JSON.parse(fs.readFileSync(pkg_path, 'utf-8')) as { version?: string };
68
+ if (typeof pkg.version !== 'string' || parse_semver(pkg.version) === null) {
69
+ throw new Error(`package.json version is not bare semver: '${String(pkg.version)}'`);
70
+ }
71
+ return pkg.version;
72
+ }
73
+
74
+ /**
75
+ * Core check: return one Violation per minor/major section (`## [X.Y.0]`)
76
+ * whose version is strictly greater than `cutoff` and whose body carries
77
+ * no `Rollback:` line.
78
+ */
79
+ export function check_rollback_lines(lines: readonly string[], cutoff: string): Violation[] {
80
+ const cut = parse_semver(cutoff);
81
+ if (cut === null) {
82
+ throw new Error(`cutoff is not bare semver (X.Y.Z): '${cutoff}'`);
83
+ }
84
+ const violations: Violation[] = [];
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const m = VERSION_HEADING_RE.exec(lines[i]!);
87
+ if (!m) {
88
+ continue;
89
+ }
90
+ const version = m.groups!['version']!;
91
+ const parsed = parse_semver(version);
92
+ if (parsed === null || parsed[2] !== 0) {
93
+ continue; // patch release — not a minor/major section
94
+ }
95
+ if (!semver_gt(parsed, cut)) {
96
+ continue; // at or below the cutoff — grandfathered
97
+ }
98
+ // Section body: heading line down to (exclusive) the next boundary.
99
+ let end = lines.length;
100
+ for (let j = i + 1; j < lines.length; j++) {
101
+ if (SECTION_BOUNDARY_RE.test(lines[j]!)) {
102
+ end = j;
103
+ break;
104
+ }
105
+ }
106
+ const has_rollback = lines.slice(i, end).some((ln) => ROLLBACK_LINE_RE.test(ln));
107
+ if (!has_rollback) {
108
+ violations.push({ version, line: i + 1 });
109
+ }
110
+ }
111
+ return violations;
112
+ }
113
+
114
+ // ─── selftest fixtures ─────────────────────────────────────────────────────────
115
+
116
+ function _fixture(section_body: string): string[] {
117
+ return (
118
+ '# Changelog\n\n' +
119
+ '## [Unreleased]\n\n' +
120
+ '# Era: 9.1.x — current\n\n' +
121
+ '> Started at `9.1.0`.\n\n' +
122
+ section_body +
123
+ '# Era: pre-9.0.0 — archived\n'
124
+ ).split('\n');
125
+ }
126
+
127
+ function run_selftest(): number {
128
+ const cutoff = '9.0.0';
129
+ let failed = 0;
130
+ const assert = (label: string, ok: boolean): void => {
131
+ process.stdout.write(`${ok ? '✅' : '❌'} selftest: ${label}\n`);
132
+ if (!ok) failed += 1;
133
+ };
134
+
135
+ // 1. New minor section WITHOUT Rollback: → exactly one violation.
136
+ const red = check_rollback_lines(
137
+ _fixture(
138
+ '## [9.1.0](https://example/compare/9.0.0...9.1.0) (2026-07-10)\n\n' +
139
+ '### Features\n\n* **workspace:** new subsystem ([abc1234](https://example))\n\n',
140
+ ),
141
+ cutoff,
142
+ );
143
+ assert('minor section without Rollback: FAILS', red.length === 1 && red[0]!.version === '9.1.0');
144
+
145
+ // 2. Same section WITH a Rollback: line → clean.
146
+ const green = check_rollback_lines(
147
+ _fixture(
148
+ '## [9.1.0](https://example/compare/9.0.0...9.1.0) (2026-07-10)\n\n' +
149
+ '### Features\n\n* **workspace:** new subsystem ([abc1234](https://example))\n' +
150
+ ' Rollback: set `workspace.enabled: false` in `.agent-settings.yml`.\n\n',
151
+ ),
152
+ cutoff,
153
+ );
154
+ assert('minor section with Rollback: passes', green.length === 0);
155
+
156
+ // 3. Historical minor section at/below the cutoff → grandfathered.
157
+ const old = check_rollback_lines(
158
+ _fixture('## [9.0.0](https://example/compare/8.9.0...9.0.0) (2026-06-01)\n\n* old entry\n\n'),
159
+ cutoff,
160
+ );
161
+ assert('section at cutoff is grandfathered', old.length === 0);
162
+
163
+ // 4. New PATCH section without Rollback: → out of scope.
164
+ const patch = check_rollback_lines(
165
+ _fixture('## [9.1.1](https://example/compare/9.1.0...9.1.1) (2026-07-11)\n\n* a fix\n\n'),
166
+ cutoff,
167
+ );
168
+ assert('patch section is out of scope', patch.length === 0);
169
+
170
+ // 5. Rollback: in a FOLLOWING section must not satisfy the earlier one.
171
+ const bleed = check_rollback_lines(
172
+ _fixture(
173
+ '## [9.2.0](https://example/compare/9.1.0...9.2.0) (2026-08-01)\n\n* no rollback here\n\n' +
174
+ '## [9.1.0](https://example/compare/9.0.0...9.1.0) (2026-07-10)\n\n' +
175
+ 'Rollback: only covers 9.1.0.\n\n',
176
+ ),
177
+ cutoff,
178
+ );
179
+ assert('Rollback: does not bleed across sections', bleed.length === 1 && bleed[0]!.version === '9.2.0');
180
+
181
+ if (failed > 0) {
182
+ process.stderr.write(`❌ selftest: ${failed} fixture(s) failed.\n`);
183
+ return 1;
184
+ }
185
+ process.stdout.write('✅ selftest: all fixtures behave as specified.\n');
186
+ return 0;
187
+ }
188
+
189
+ // ─── CLI ───────────────────────────────────────────────────────────────────────
190
+
191
+ interface ParsedArgs {
192
+ selftest: boolean;
193
+ cutoff: string | null;
194
+ changelog: string | null;
195
+ quiet: boolean;
196
+ }
197
+
198
+ function _argparse_error(message: string): never {
199
+ process.stderr.write(`lint_changelog_rollback: error: ${message}\n`);
200
+ process.exit(2);
201
+ }
202
+
203
+ function parse_args(argv: readonly string[]): ParsedArgs {
204
+ const args: ParsedArgs = { selftest: false, cutoff: null, changelog: null, quiet: false };
205
+ for (let i = 0; i < argv.length; i++) {
206
+ const arg = argv[i]!;
207
+ if (arg === '--selftest') {
208
+ args.selftest = true;
209
+ } else if (arg === '--cutoff') {
210
+ const v = argv[++i];
211
+ if (v === undefined) _argparse_error('argument --cutoff: expected one argument');
212
+ args.cutoff = v;
213
+ } else if (arg.startsWith('--cutoff=')) {
214
+ args.cutoff = arg.slice('--cutoff='.length);
215
+ } else if (arg === '--changelog') {
216
+ const v = argv[++i];
217
+ if (v === undefined) _argparse_error('argument --changelog: expected one argument');
218
+ args.changelog = v;
219
+ } else if (arg.startsWith('--changelog=')) {
220
+ args.changelog = arg.slice('--changelog='.length);
221
+ } else if (arg === '--quiet') {
222
+ args.quiet = true;
223
+ } else if (arg === '-h' || arg === '--help') {
224
+ process.stdout.write(
225
+ 'usage: lint_changelog_rollback [-h] [--selftest] [--cutoff X.Y.Z] [--changelog PATH] [--quiet]\n',
226
+ );
227
+ process.exit(0);
228
+ } else {
229
+ _argparse_error(`unrecognized arguments: ${arg}`);
230
+ }
231
+ }
232
+ return args;
233
+ }
234
+
235
+ function main(argv?: readonly string[]): number {
236
+ const args = parse_args(argv ?? process.argv.slice(2));
237
+ if (args.selftest) {
238
+ return run_selftest();
239
+ }
240
+
241
+ const changelog_path = args.changelog ?? CHANGELOG;
242
+ const cutoff = args.cutoff ?? package_json_version();
243
+ const text = fs.readFileSync(changelog_path, 'utf-8');
244
+ const lines = text.split(/\r\n|\r|\n/);
245
+
246
+ const violations = check_rollback_lines(lines, cutoff);
247
+ if (violations.length > 0) {
248
+ process.stderr.write(
249
+ `❌ ${violations.length} minor/major CHANGELOG section(s) above ` +
250
+ `cutoff ${cutoff} without a Rollback: line:\n`,
251
+ );
252
+ for (const v of violations) {
253
+ process.stderr.write(` - ## [${v.version}] (line ${v.line})\n`);
254
+ }
255
+ process.stderr.write(
256
+ '\nEvery new minor/major section must name a rollback path for each ' +
257
+ 'introduced or substantially reworked subsystem via a `Rollback:` ' +
258
+ 'line — see docs/contracts/release-sizing.md § Rollback path.\n',
259
+ );
260
+ return 1;
261
+ }
262
+
263
+ if (!args.quiet) {
264
+ process.stdout.write(
265
+ `✅ CHANGELOG Rollback: gate clean (cutoff ${cutoff}, ` +
266
+ `${path.relative(REPO_ROOT, changelog_path)}).\n`,
267
+ );
268
+ }
269
+ return 0;
270
+ }
271
+
272
+ function _isCliEntry(): boolean {
273
+ if (process.argv[1] === undefined) {
274
+ return false;
275
+ }
276
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
277
+ if (import.meta.url === argvUrl) {
278
+ return true;
279
+ }
280
+ try {
281
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
282
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
283
+ return here === argv;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ if (_isCliEntry() || process.argv[1] === _HERE) {
290
+ process.exit(main());
291
+ }
292
+
293
+ export { main, run_selftest, parse_semver, semver_gt, package_json_version };