@tea-agent/loop-agent 0.14.0 → 0.16.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/AGENTS.md +1 -1
  2. package/CHANGELOG.md +98 -11
  3. package/README.md +24 -5
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/eval.js +1176 -13
  24. package/dist/commands/init.js +4 -1
  25. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  26. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  27. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  28. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  29. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  30. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  31. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  32. package/dist/worker/cli.js +6 -3
  33. package/dist/worker/delivery/final-verification.js +96 -8
  34. package/dist/worker/delivery/package.js +23 -4
  35. package/dist/worker/delivery/verification-bundle.js +521 -0
  36. package/dist/worker/feature/fullstack-validate.js +337 -0
  37. package/dist/worker/feature/profile-schema.js +44 -0
  38. package/dist/worker/feature/ready-plan-projection.js +1 -0
  39. package/dist/worker/feature/reducer.js +2 -0
  40. package/dist/worker/feature/review.js +106 -11
  41. package/dist/worker/materialize/harness-task-materializer.js +5 -0
  42. package/dist/worker/observability/read-model.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +1 -0
  44. package/dist/worker/outcomes/adapters.js +144 -0
  45. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  46. package/dist/worker/outcomes/gate.js +40 -0
  47. package/dist/worker/outcomes/projector.js +185 -0
  48. package/dist/worker/outcomes/registry.js +1 -0
  49. package/dist/worker/outcomes/store.js +131 -0
  50. package/dist/worker/outcomes/types.js +79 -0
  51. package/dist/worker/report/morning-report.js +4 -3
  52. package/dist/worker/run-task/run-task.js +85 -2
  53. package/dist/worker/runner/run-ready.js +32 -1
  54. package/dist/worker/task-graph/acceptance-schema.js +12 -0
  55. package/dist/worker/task-graph/ready-planner.js +131 -0
  56. package/dist/worker/task-graph/task-graph-schema.js +31 -0
  57. package/dist/worker/task-graph/validate.js +44 -4
  58. package/dist/worker/task-spec/schema.js +9 -0
  59. package/dist/worker/task-spec/validate.js +39 -0
  60. package/dist/worker/task-spec/workflow-routing.js +149 -0
  61. package/dist/workflows/dag/budget-enforcement.js +67 -0
  62. package/dist/workflows/dag/context-policy.js +137 -0
  63. package/dist/workflows/dag/init-hybrid.js +27 -11
  64. package/dist/workflows/dag/knowledge-curator.js +3 -0
  65. package/dist/workflows/dag/node-execution.js +11 -4
  66. package/dist/workflows/dag/prompt.js +1 -1
  67. package/dist/workflows/dag/runner.js +43 -16
  68. package/dist/workflows/dag/skill-snapshot.js +11 -7
  69. package/dist/workflows/dag/types.js +18 -0
  70. package/docs/README.md +1 -0
  71. package/docs/init-surface.manifest.json +7 -7
  72. package/docs/templates/branch-merge-report.md +0 -1
  73. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  74. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  75. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  76. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  77. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  78. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  79. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  80. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  81. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  82. package/docs/templates/product-line/AGENTS.md +1 -0
  83. package/docs/templates/product-line/README.md +17 -0
  84. package/docs/templates/product-line/acceptance.yaml +9 -0
  85. package/docs/templates/product-line/feature.yaml +11 -0
  86. package/docs/templates/product-line/task-graph.yaml +8 -0
  87. package/docs/templates/product-line/task.yaml +4 -0
  88. package/package.json +6 -16
  89. package/skills/browser-tools/SKILL.md +2 -2
  90. package/skills/frontend-design-review/references/review-checklist.md +27 -45
  91. package/skills/frontend-implementation/references/node-contracts.md +4 -4
  92. package/skills/frontend-review/SKILL.md +3 -1
  93. package/skills/frontend-review/references/review-findings.md +2 -1
  94. package/skills/frontend-verification/SKILL.md +3 -1
  95. package/skills/frontend-verification/references/verification-checklist.md +13 -22
  96. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  97. package/docs/agent-dag-recovery-playbook.md +0 -195
  98. package/docs/agent-dag-runner.md +0 -67
  99. package/docs/cursor-prompt-sidecar.md +0 -36
  100. package/docs/decisions/README.md +0 -18
  101. package/docs/design/README.md +0 -167
  102. package/docs/development-principles.md +0 -73
  103. package/docs/exec-plans/README.md +0 -6
  104. package/docs/exec-plans/active/README.md +0 -13
  105. package/docs/exec-plans/completed/README.md +0 -108
  106. package/docs/feature-workflow.md +0 -414
  107. package/docs/loop-agent-harness.md +0 -142
  108. package/docs/production-readiness.md +0 -96
  109. package/docs/progress/README.md +0 -81
  110. package/docs/reports/README.md +0 -163
  111. package/docs/verification-matrix.md +0 -70
  112. package/scripts/check-product-line-docs.sh +0 -29
  113. package/scripts/check-task-pool-root.sh +0 -32
  114. package/scripts/kb-graph-incremental-prepare.sh +0 -5
  115. package/scripts/kb-graph-materialize.sh +0 -4
  116. package/scripts/kb-graph-promote.sh +0 -4
  117. package/scripts/kb-query.sh +0 -5
@@ -0,0 +1,47 @@
1
+ import { formatContentSha, sha256Hex, } from "./candidate-hash.js";
2
+ /** Frozen placeholder for M4; M5 may replace with real scoring rules bytes. */
3
+ export const SCORING_RULES_V1 = "promotion-policy-v1";
4
+ export function computeScoringRulesHash(policyVersion = SCORING_RULES_V1) {
5
+ return formatContentSha(sha256Hex(policyVersion));
6
+ }
7
+ export function computeBudgetHash(budget) {
8
+ const canonical = {
9
+ schemaVersion: budget.schemaVersion,
10
+ mode: budget.mode,
11
+ limits: Object.fromEntries(Object.entries(budget.limits).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
12
+ };
13
+ return formatContentSha(sha256Hex(JSON.stringify(canonical)));
14
+ }
15
+ export function computePrivateVerifierManifestHash(manifest) {
16
+ const checks = [...manifest.checks]
17
+ .map((check) => ({
18
+ checkId: check.checkId,
19
+ kind: check.kind,
20
+ command: [...check.command],
21
+ cwd: check.cwd ?? ".",
22
+ timeoutMs: check.timeoutMs ?? 60_000,
23
+ expectedExitCode: check.expectedExitCode ?? 0,
24
+ }))
25
+ .sort((a, b) => a.checkId < b.checkId ? -1 : a.checkId > b.checkId ? 1 : 0);
26
+ const canonical = {
27
+ schemaVersion: 1,
28
+ privateVerifierId: manifest.privateVerifierId,
29
+ checks,
30
+ };
31
+ return formatContentSha(sha256Hex(JSON.stringify(canonical)));
32
+ }
33
+ export function computeCampaignPlanHash(campaignId, cells) {
34
+ const sorted = [...cells].sort((a, b) => a.cellId < b.cellId ? -1 : a.cellId > b.cellId ? 1 : 0);
35
+ const canonical = {
36
+ schemaVersion: 1,
37
+ campaignId,
38
+ cells: sorted,
39
+ };
40
+ return formatContentSha(sha256Hex(JSON.stringify(canonical)));
41
+ }
42
+ export function assertPrivateVerifierHash(manifest) {
43
+ const recomputed = computePrivateVerifierManifestHash(manifest);
44
+ if (recomputed !== manifest.manifestHash) {
45
+ throw new Error(`privateVerifier manifestHash mismatch for ${manifest.privateVerifierId}: expected ${recomputed}, got ${manifest.manifestHash}`);
46
+ }
47
+ }
@@ -0,0 +1,372 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { campaignDir, readCampaignManifest, readCampaignPlan, writeCampaignScorecard, } from "../../infrastructure/evaluation/campaign-store.js";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { planCampaign, runCampaignPrivateChecks, } from "./campaign.js";
6
+ import { evaluatePromotionPolicy } from "./promotion-policy.js";
7
+ import { matrixFixtureSchema, matrixRunSchema } from "./types.js";
8
+ function matrixJobsDir(repoRoot, campaignId) {
9
+ return path.join(campaignDir(repoRoot, campaignId), "matrix-jobs");
10
+ }
11
+ function matrixRunPath(repoRoot, campaignId) {
12
+ return path.join(campaignDir(repoRoot, campaignId), "matrix-run.json");
13
+ }
14
+ function evidenceDraftPath(repoRoot, campaignId) {
15
+ return path.join(campaignDir(repoRoot, campaignId), "evidence-draft.json");
16
+ }
17
+ export async function loadMatrixFixture(input) {
18
+ const absolute = path.resolve(input.repoRoot, input.fixturePath);
19
+ return matrixFixtureSchema.parse(JSON.parse(await readFile(absolute, "utf-8")));
20
+ }
21
+ function resolveStubResult(fixture, cell) {
22
+ for (const row of fixture.results) {
23
+ if (row.cellId && row.cellId === cell.cellId) {
24
+ return { verifyPassed: row.verifyPassed, runId: row.runId };
25
+ }
26
+ const match = (!row.candidateId || row.candidateId === cell.candidateId) &&
27
+ (!row.taskRef || row.taskRef === cell.taskRef) &&
28
+ (row.seed === undefined || row.seed === cell.seed) &&
29
+ (!row.split || row.split === cell.split);
30
+ if (match &&
31
+ (row.candidateId || row.taskRef || row.seed !== undefined || row.split)) {
32
+ return { verifyPassed: row.verifyPassed, runId: row.runId };
33
+ }
34
+ }
35
+ return { verifyPassed: fixture.defaultVerifyPassed };
36
+ }
37
+ export function buildMatrixJobSpec(input) {
38
+ return {
39
+ schemaVersion: 1,
40
+ cellId: input.cell.cellId,
41
+ evaluation: {
42
+ campaignId: input.campaignId,
43
+ candidateId: input.cell.candidateId,
44
+ bundleHash: input.cell.bundleHash,
45
+ seed: input.cell.seed,
46
+ split: input.cell.split,
47
+ taskRef: input.cell.taskRef,
48
+ },
49
+ executor: "pi",
50
+ autoPromote: false,
51
+ };
52
+ }
53
+ async function writeMatrixJob(input) {
54
+ const dir = matrixJobsDir(input.repoRoot, input.campaignId);
55
+ await mkdir(dir, { recursive: true });
56
+ const job = buildMatrixJobSpec({
57
+ cell: input.cell,
58
+ campaignId: input.campaignId,
59
+ });
60
+ const jobPath = path.join(dir, `${input.cell.cellId}.json`);
61
+ await writeJsonAtomic(jobPath, job, { repoRoot: input.repoRoot });
62
+ return {
63
+ jobPath: path.relative(input.repoRoot, jobPath).replace(/\\/g, "/"),
64
+ evaluation: job.evaluation,
65
+ };
66
+ }
67
+ /**
68
+ * Candidate matrix runner (M7 companion).
69
+ * Modes:
70
+ * - dry-run: plan public cells only
71
+ * - stub: resolve verifyPassed from fixture (tests / offline)
72
+ * - pi-plan: materialize per-cell Pi evaluation job specs (no spawn)
73
+ * - pi: require injected PiCellExecutor; never auto-promote
74
+ */
75
+ export async function runCampaignMatrix(input) {
76
+ const planned = await planCampaign({
77
+ repoRoot: input.repoRoot,
78
+ campaignId: input.campaignId,
79
+ });
80
+ const publicCells = planned.plan.cells.filter((cell) => cell.executionRole === "candidate_run");
81
+ const limited = input.maxPublicCells !== undefined
82
+ ? publicCells.slice(0, input.maxPublicCells)
83
+ : publicCells;
84
+ let fixture;
85
+ if (input.mode === "stub") {
86
+ if (!input.fixturePath) {
87
+ throw new Error("matrix mode=stub requires --fixture <path>");
88
+ }
89
+ fixture = await loadMatrixFixture({
90
+ repoRoot: input.repoRoot,
91
+ fixturePath: input.fixturePath,
92
+ });
93
+ }
94
+ if (input.mode === "pi" && !input.piExecutor) {
95
+ throw new Error("matrix mode=pi requires an injected PiCellExecutor (CLI refuses live Pi by default; use pi-plan or stub)");
96
+ }
97
+ const cellResults = [];
98
+ for (const cell of limited) {
99
+ if (input.mode === "dry-run") {
100
+ cellResults.push({
101
+ cellId: cell.cellId,
102
+ candidateId: cell.candidateId,
103
+ taskRef: cell.taskRef,
104
+ seed: cell.seed,
105
+ split: cell.split,
106
+ executionRole: cell.executionRole,
107
+ mode: "dry-run",
108
+ skipped: true,
109
+ detail: "dry-run: would execute candidate_run cell",
110
+ });
111
+ continue;
112
+ }
113
+ if (input.mode === "stub") {
114
+ const resolved = resolveStubResult(fixture, cell);
115
+ cellResults.push({
116
+ cellId: cell.cellId,
117
+ candidateId: cell.candidateId,
118
+ taskRef: cell.taskRef,
119
+ seed: cell.seed,
120
+ split: cell.split,
121
+ executionRole: cell.executionRole,
122
+ mode: "stub",
123
+ verifyPassed: resolved.verifyPassed,
124
+ runId: resolved.runId,
125
+ skipped: false,
126
+ });
127
+ continue;
128
+ }
129
+ const job = await writeMatrixJob({
130
+ repoRoot: input.repoRoot,
131
+ campaignId: input.campaignId,
132
+ cell,
133
+ });
134
+ if (input.mode === "pi-plan") {
135
+ cellResults.push({
136
+ cellId: cell.cellId,
137
+ candidateId: cell.candidateId,
138
+ taskRef: cell.taskRef,
139
+ seed: cell.seed,
140
+ split: cell.split,
141
+ executionRole: cell.executionRole,
142
+ mode: "pi-plan",
143
+ jobPath: job.jobPath,
144
+ skipped: true,
145
+ detail: "pi-plan: job materialized; Pi not spawned",
146
+ });
147
+ continue;
148
+ }
149
+ const executed = await input.piExecutor({
150
+ repoRoot: input.repoRoot,
151
+ cell,
152
+ jobPath: job.jobPath,
153
+ evaluation: job.evaluation,
154
+ });
155
+ cellResults.push({
156
+ cellId: cell.cellId,
157
+ candidateId: cell.candidateId,
158
+ taskRef: cell.taskRef,
159
+ seed: cell.seed,
160
+ split: cell.split,
161
+ executionRole: cell.executionRole,
162
+ mode: "pi",
163
+ verifyPassed: executed.verifyPassed,
164
+ runId: executed.runId,
165
+ jobPath: job.jobPath,
166
+ detail: executed.detail,
167
+ skipped: false,
168
+ });
169
+ }
170
+ // Mark held_out / remaining public as skipped for transparency
171
+ for (const cell of planned.plan.cells) {
172
+ if (cell.executionRole === "candidate_run") {
173
+ if (limited.some((row) => row.cellId === cell.cellId))
174
+ continue;
175
+ cellResults.push({
176
+ cellId: cell.cellId,
177
+ candidateId: cell.candidateId,
178
+ taskRef: cell.taskRef,
179
+ seed: cell.seed,
180
+ split: cell.split,
181
+ executionRole: cell.executionRole,
182
+ mode: input.mode,
183
+ skipped: true,
184
+ detail: "skipped by maxPublicCells",
185
+ });
186
+ continue;
187
+ }
188
+ if (cell.executionRole === "promotion_gate_only") {
189
+ cellResults.push({
190
+ cellId: cell.cellId,
191
+ candidateId: cell.candidateId,
192
+ taskRef: cell.taskRef,
193
+ seed: cell.seed,
194
+ split: cell.split,
195
+ executionRole: cell.executionRole,
196
+ mode: input.mode,
197
+ skipped: true,
198
+ detail: "held_out remains promotion_gate_only (not executed in matrix)",
199
+ });
200
+ }
201
+ }
202
+ let privateResults = [];
203
+ if (input.includePrivateChecks !== false && input.mode !== "dry-run") {
204
+ const privateRun = await runCampaignPrivateChecks({
205
+ repoRoot: input.repoRoot,
206
+ campaignId: input.campaignId,
207
+ });
208
+ privateResults = privateRun.results;
209
+ for (const cell of planned.plan.cells) {
210
+ if (cell.executionRole !== "private_verify")
211
+ continue;
212
+ const related = privateResults.filter((row) => row.cellId === cell.cellId);
213
+ const ok = related.length > 0 && related.every((row) => row.ok);
214
+ cellResults.push({
215
+ cellId: cell.cellId,
216
+ candidateId: cell.candidateId,
217
+ taskRef: cell.taskRef,
218
+ seed: cell.seed,
219
+ split: cell.split,
220
+ executionRole: "private_verify",
221
+ mode: input.mode,
222
+ verifyPassed: ok,
223
+ skipped: false,
224
+ detail: `private checks=${related.length}`,
225
+ });
226
+ }
227
+ }
228
+ const now = input.now ?? new Date().toISOString();
229
+ let evidence;
230
+ let evidencePath;
231
+ if (input.writeEvidenceDraft !== false && input.mode !== "dry-run") {
232
+ evidence = buildEvidenceDraft({
233
+ campaignId: input.campaignId,
234
+ manifest: planned.manifest,
235
+ cellResults,
236
+ });
237
+ evidencePath = path
238
+ .relative(input.repoRoot, evidenceDraftPath(input.repoRoot, input.campaignId))
239
+ .replace(/\\/g, "/");
240
+ await writeJsonAtomic(evidenceDraftPath(input.repoRoot, input.campaignId), evidence, { repoRoot: input.repoRoot });
241
+ }
242
+ const matrix = matrixRunSchema.parse({
243
+ schemaVersion: 1,
244
+ campaignId: input.campaignId,
245
+ planHash: planned.plan.planHash,
246
+ mode: input.mode,
247
+ createdAt: now,
248
+ cells: cellResults,
249
+ privateCheckCount: privateResults.length,
250
+ privateCheckFailed: privateResults.filter((row) => !row.ok).length,
251
+ evidencePath,
252
+ autoPromote: false,
253
+ });
254
+ await writeJsonAtomic(matrixRunPath(input.repoRoot, input.campaignId), matrix, {
255
+ repoRoot: input.repoRoot,
256
+ });
257
+ return { matrix, privateResults, evidence };
258
+ }
259
+ function buildEvidenceDraft(input) {
260
+ const challengerCandidateId = input.manifest.challengerCandidateIds[0];
261
+ if (!challengerCandidateId) {
262
+ throw new Error("campaign has no challenger for evidence draft");
263
+ }
264
+ const cells = [];
265
+ for (const row of input.cellResults) {
266
+ if (row.verifyPassed === undefined)
267
+ continue;
268
+ if (row.executionRole !== "candidate_run" &&
269
+ row.executionRole !== "private_verify" &&
270
+ row.executionRole !== "promotion_gate_only") {
271
+ continue;
272
+ }
273
+ // Only include executed public/private pairs; held_out needs external evidence.
274
+ if (row.executionRole === "promotion_gate_only")
275
+ continue;
276
+ cells.push({
277
+ cellId: row.cellId,
278
+ candidateId: row.candidateId,
279
+ taskRef: row.taskRef,
280
+ seed: row.seed,
281
+ split: row.split,
282
+ verifyPassed: row.verifyPassed,
283
+ runId: row.runId,
284
+ });
285
+ }
286
+ if (cells.length === 0) {
287
+ // Minimal placeholder so draft is schema-valid for inspection; scorecard needs more.
288
+ cells.push({
289
+ candidateId: input.manifest.incumbentCandidateId,
290
+ taskRef: "matrix/placeholder",
291
+ seed: 0,
292
+ split: "public",
293
+ verifyPassed: false,
294
+ });
295
+ cells.push({
296
+ candidateId: challengerCandidateId,
297
+ taskRef: "matrix/placeholder",
298
+ seed: 0,
299
+ split: "public",
300
+ verifyPassed: false,
301
+ });
302
+ }
303
+ return {
304
+ schemaVersion: 1,
305
+ campaignId: input.campaignId,
306
+ challengerCandidateId,
307
+ cells,
308
+ diffs: [],
309
+ verifyCommands: { incumbent: [], challenger: [] },
310
+ budgetStatus: { incumbent: "missing", challenger: "missing" },
311
+ };
312
+ }
313
+ export async function readMatrixRun(repoRoot, campaignId) {
314
+ try {
315
+ const raw = await readFile(matrixRunPath(repoRoot, campaignId), "utf-8");
316
+ return matrixRunSchema.parse(JSON.parse(raw));
317
+ }
318
+ catch (error) {
319
+ const code = error.code;
320
+ if (code === "ENOENT")
321
+ return undefined;
322
+ throw error;
323
+ }
324
+ }
325
+ export function formatMatrixRunMarkdown(matrix) {
326
+ const executed = matrix.cells.filter((cell) => !cell.skipped);
327
+ const lines = [
328
+ `# Campaign Matrix: ${matrix.campaignId}`,
329
+ "",
330
+ `- mode: \`${matrix.mode}\``,
331
+ `- planHash: \`${matrix.planHash}\``,
332
+ `- cells: ${matrix.cells.length} (executed/recorded=${executed.length})`,
333
+ `- privateChecks: ${matrix.privateCheckCount} (failed=${matrix.privateCheckFailed})`,
334
+ `- evidencePath: \`${matrix.evidencePath ?? "(none)"}\``,
335
+ `- autoPromote: \`${matrix.autoPromote}\``,
336
+ "",
337
+ "## Cell results",
338
+ "",
339
+ ];
340
+ for (const cell of matrix.cells.slice(0, 40)) {
341
+ lines.push(`- \`${cell.cellId}\` role=${cell.executionRole} skipped=${cell.skipped} passed=${cell.verifyPassed ?? "-"} ${cell.detail ?? ""}`);
342
+ }
343
+ if (matrix.cells.length > 40) {
344
+ lines.push(`- … ${matrix.cells.length - 40} more`);
345
+ }
346
+ lines.push("", "> Matrix never auto-promotes. Use `eval campaign scorecard` + human-gated `eval promote` / `eval experiment accept`.", "");
347
+ return `${lines.join("\n")}\n`;
348
+ }
349
+ /** Optional helper: build scorecard from matrix evidence draft when complete enough. */
350
+ export async function buildScorecardFromMatrixEvidence(input) {
351
+ const manifest = await readCampaignManifest(input.repoRoot, input.campaignId);
352
+ const plan = await readCampaignPlan(input.repoRoot, input.campaignId);
353
+ if (!plan)
354
+ throw new Error(`campaign ${input.campaignId} has no plan`);
355
+ const policy = evaluatePromotionPolicy({
356
+ campaignId: input.campaignId,
357
+ incumbentCandidateId: manifest.incumbentCandidateId,
358
+ evidence: input.evidence,
359
+ });
360
+ await writeCampaignScorecard({
361
+ repoRoot: input.repoRoot,
362
+ scorecard: {
363
+ schemaVersion: 1,
364
+ campaignId: input.campaignId,
365
+ planHash: plan.planHash,
366
+ createdAt: new Date().toISOString(),
367
+ evidenceCellCount: input.evidence.cells.length,
368
+ policy,
369
+ },
370
+ evidence: input.evidence,
371
+ });
372
+ }
@@ -0,0 +1,135 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readCampaignEvidence, readCampaignManifest, readCampaignPlan, readCampaignScorecard, writeCampaignScorecard, } from "../../infrastructure/evaluation/campaign-store.js";
4
+ import { evaluatePromotionPolicy } from "./promotion-policy.js";
5
+ import { campaignEvidenceSchema, } from "./types.js";
6
+ export async function loadCampaignEvidence(input) {
7
+ const absolute = path.resolve(input.repoRoot, input.evidencePath);
8
+ const raw = JSON.parse(await readFile(absolute, "utf-8"));
9
+ return campaignEvidenceSchema.parse(raw);
10
+ }
11
+ export async function buildCampaignScorecard(input) {
12
+ const manifest = await readCampaignManifest(input.repoRoot, input.campaignId);
13
+ const plan = await readCampaignPlan(input.repoRoot, input.campaignId);
14
+ if (!plan) {
15
+ throw new Error(`campaign ${input.campaignId} has no plan; run eval campaign plan first`);
16
+ }
17
+ if (input.evidence.campaignId !== input.campaignId) {
18
+ throw new Error(`evidence campaignId mismatch: expected ${input.campaignId}, got ${input.evidence.campaignId}`);
19
+ }
20
+ const policy = evaluatePromotionPolicy({
21
+ campaignId: input.campaignId,
22
+ incumbentCandidateId: manifest.incumbentCandidateId,
23
+ evidence: input.evidence,
24
+ acknowledgeReview: input.acknowledgeReview,
25
+ });
26
+ const scorecard = {
27
+ schemaVersion: 1,
28
+ campaignId: input.campaignId,
29
+ planHash: plan.planHash,
30
+ createdAt: new Date().toISOString(),
31
+ evidenceCellCount: input.evidence.cells.length,
32
+ policy,
33
+ };
34
+ if (input.persist !== false) {
35
+ await writeCampaignScorecard({
36
+ repoRoot: input.repoRoot,
37
+ scorecard,
38
+ evidence: input.evidence,
39
+ });
40
+ }
41
+ return scorecard;
42
+ }
43
+ export async function auditCampaignEvidence(input) {
44
+ const scorecard = await buildCampaignScorecard({
45
+ ...input,
46
+ persist: false,
47
+ });
48
+ return {
49
+ findings: scorecard.policy.findings,
50
+ policy: scorecard.policy,
51
+ };
52
+ }
53
+ export async function requireCampaignPromotionGate(input) {
54
+ let evidence;
55
+ if (input.evidencePath) {
56
+ evidence = await loadCampaignEvidence({
57
+ repoRoot: input.repoRoot,
58
+ evidencePath: input.evidencePath,
59
+ });
60
+ }
61
+ else {
62
+ evidence = await readCampaignEvidence(input.repoRoot, input.campaignId);
63
+ }
64
+ let scorecard;
65
+ if (evidence) {
66
+ scorecard = await buildCampaignScorecard({
67
+ repoRoot: input.repoRoot,
68
+ campaignId: input.campaignId,
69
+ evidence,
70
+ acknowledgeReview: input.acknowledgeReview,
71
+ persist: true,
72
+ });
73
+ }
74
+ else {
75
+ const existing = await readCampaignScorecard(input.repoRoot, input.campaignId);
76
+ if (!existing) {
77
+ throw new Error(`campaign ${input.campaignId} has no scorecard; run eval campaign scorecard --evidence <path> first`);
78
+ }
79
+ if (input.acknowledgeReview &&
80
+ existing.policy.gates.safetyOk === "review" &&
81
+ !existing.policy.promotionEligible) {
82
+ throw new Error(`campaign ${input.campaignId} scorecard needs re-evaluation with evidence; provide --evidence or re-run scorecard`);
83
+ }
84
+ scorecard = existing;
85
+ }
86
+ if (scorecard.policy.challengerCandidateId !== input.toCandidateId) {
87
+ throw new Error(`promotion target mismatch: scorecard challenger=${scorecard.policy.challengerCandidateId}, --to-candidate=${input.toCandidateId}`);
88
+ }
89
+ if (!scorecard.policy.promotionEligible) {
90
+ throw new Error(`campaign promotion policy rejected: ${scorecard.policy.reasons.join("; ")}`);
91
+ }
92
+ return scorecard;
93
+ }
94
+ export function formatCampaignScorecardMarkdown(scorecard) {
95
+ const { policy } = scorecard;
96
+ const lines = [
97
+ `# Campaign Scorecard: ${scorecard.campaignId}`,
98
+ "",
99
+ `- planHash: \`${scorecard.planHash}\``,
100
+ `- createdAt: \`${scorecard.createdAt}\``,
101
+ `- evidenceCells: ${scorecard.evidenceCellCount}`,
102
+ `- policy: \`${policy.policyVersion}\``,
103
+ `- promotionEligible: **${policy.promotionEligible}**`,
104
+ "",
105
+ "## Gates",
106
+ "",
107
+ `- privateImprovement: \`${policy.gates.privateImprovement}\``,
108
+ `- heldOutNonRegression: \`${policy.gates.heldOutNonRegression}\``,
109
+ `- budgetOk: \`${policy.gates.budgetOk}\``,
110
+ `- safetyOk: \`${policy.gates.safetyOk}\``,
111
+ `- humanRequired: \`${policy.gates.humanRequired}\``,
112
+ "",
113
+ "## Paired tallies",
114
+ "",
115
+ `- private: challengerWins=${policy.paired.private.challengerWins} incumbentWins=${policy.paired.private.incumbentWins} ties=${policy.paired.private.ties}`,
116
+ `- held_out: challengerWins=${policy.paired.held_out.challengerWins} incumbentWins=${policy.paired.held_out.incumbentWins} ties=${policy.paired.held_out.ties}`,
117
+ `- public: challengerWins=${policy.paired.public.challengerWins} incumbentWins=${policy.paired.public.incumbentWins} ties=${policy.paired.public.ties}`,
118
+ "",
119
+ ];
120
+ if (policy.findings.length > 0) {
121
+ lines.push("## Integrity findings", "");
122
+ for (const finding of policy.findings) {
123
+ lines.push(`- [${finding.severity}] \`${finding.code}\`${finding.path ? ` @ ${finding.path}` : ""}: ${finding.message}`);
124
+ }
125
+ lines.push("");
126
+ }
127
+ if (policy.reasons.length > 0) {
128
+ lines.push("## Reasons", "");
129
+ for (const reason of policy.reasons) {
130
+ lines.push(`- ${reason}`);
131
+ }
132
+ lines.push("");
133
+ }
134
+ return `${lines.join("\n")}\n`;
135
+ }