@tea-agent/loop-agent 0.16.1-beta.1 → 0.16.1

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 (93) hide show
  1. package/AGENTS.md +4 -8
  2. package/CHANGELOG.md +59 -16
  3. package/README.md +76 -299
  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/dag-reconcile-run.js +3 -116
  24. package/dist/commands/eval.js +1176 -13
  25. package/dist/commands/init.js +7 -1
  26. package/dist/executors/dag-pi-executor.js +4 -44
  27. package/dist/executors/shell-executor.js +1 -1
  28. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  29. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  30. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  31. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  32. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  33. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  34. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  35. package/dist/records/promotion.js +3 -1
  36. package/dist/worker/cli.js +83 -0
  37. package/dist/worker/delivery/git-transaction.js +75 -0
  38. package/dist/worker/delivery/verification-bundle.js +13 -2
  39. package/dist/worker/feature/review.js +3 -2
  40. package/dist/worker/observe/static/dag-helpers.js +0 -62
  41. package/dist/worker/observe/static/styles.css +18 -55
  42. package/dist/worker/observe/static/views/dag.js +13 -5
  43. package/dist/worker/outcomes/adapters.js +4 -1
  44. package/dist/worker/outcomes/declared-artifacts.js +103 -0
  45. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  46. package/dist/worker/outcomes/gate.js +10 -11
  47. package/dist/worker/outcomes/projector.js +30 -4
  48. package/dist/worker/outcomes/types.js +3 -0
  49. package/dist/worker/pool/reconcile.js +285 -0
  50. package/dist/worker/run-task/run-task.js +81 -4
  51. package/dist/worker/runner/run-ready.js +25 -2
  52. package/dist/worker/task-graph/ready-planner.js +14 -8
  53. package/dist/worker/task-graph/task-graph-schema.js +5 -3
  54. package/dist/workflows/dag/budget-enforcement.js +67 -0
  55. package/dist/workflows/dag/context-policy.js +137 -0
  56. package/dist/workflows/dag/failure-routing.js +7 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +0 -77
  58. package/dist/workflows/dag/init-hybrid.js +33 -53
  59. package/dist/workflows/dag/knowledge-curator.js +3 -0
  60. package/dist/workflows/dag/node-execution.js +11 -4
  61. package/dist/workflows/dag/prompt.js +1 -1
  62. package/dist/workflows/dag/reconcile-run.js +121 -0
  63. package/dist/workflows/dag/report.js +12 -0
  64. package/dist/workflows/dag/runner.js +43 -16
  65. package/dist/workflows/dag/skill-snapshot.js +11 -7
  66. package/dist/workflows/dag/types.js +18 -1
  67. package/dist/workflows/dag/validate.js +15 -1
  68. package/docs/README.md +3 -1
  69. package/docs/architecture/runtime-boundaries.md +3 -2
  70. package/docs/init-surface.manifest.json +4 -0
  71. package/docs/local-development-environment.md +52 -0
  72. package/docs/templates/agent-dag.schema.json +0 -5
  73. package/docs/templates/agent-dag.supervised-implementation.json +23 -4
  74. package/docs/templates/branch-merge-report.md +14 -0
  75. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  76. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  77. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  78. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  79. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  80. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  81. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  82. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  83. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  84. package/docs/templates/product-line/AGENTS.md +1 -0
  85. package/docs/templates/product-line/README.md +17 -0
  86. package/docs/templates/product-line/acceptance.yaml +9 -0
  87. package/docs/templates/product-line/feature.yaml +11 -0
  88. package/docs/templates/product-line/task-graph.yaml +8 -0
  89. package/docs/templates/product-line/task.yaml +4 -0
  90. package/package.json +2 -1
  91. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  92. package/skills/loop-agent/references/command-reference.md +5 -0
  93. package/skills/loop-agent/references/hybrid-dag.md +3 -3
@@ -0,0 +1,184 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { aliasHistoryPath, commitAliasMove, listAliasNames, listIncumbentCandidateIds, makeDecisionId, readIncumbentAlias, readPromotionDecision, } from "../../infrastructure/evaluation/alias-store.js";
3
+ import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
4
+ async function requireAcceptedCandidate(input) {
5
+ const record = await readCandidateRecord(input.repoRoot, input.candidateId);
6
+ if (record.status !== "accepted") {
7
+ throw new Error(`alias move requires candidate lifecycle accepted; ${input.candidateId} is ${record.status}`);
8
+ }
9
+ return {
10
+ candidateId: record.manifest.candidateId,
11
+ bundleHash: record.manifest.bundleHash,
12
+ };
13
+ }
14
+ function buildDecision(input) {
15
+ const decisionId = makeDecisionId({
16
+ alias: input.alias,
17
+ action: input.action,
18
+ toCandidateId: input.toCandidateId,
19
+ createdAt: input.createdAt,
20
+ });
21
+ return {
22
+ schemaVersion: 1,
23
+ decisionId,
24
+ alias: input.alias,
25
+ action: input.action,
26
+ fromCandidateId: input.fromCandidateId,
27
+ toCandidateId: input.toCandidateId,
28
+ toBundleHash: input.toBundleHash,
29
+ reason: input.reason,
30
+ actor: "human",
31
+ humanRequired: true,
32
+ applied: input.applied,
33
+ createdAt: input.createdAt,
34
+ campaignId: input.campaignId ?? null,
35
+ };
36
+ }
37
+ async function planAliasMove(input) {
38
+ const reason = input.reason.trim();
39
+ if (!reason) {
40
+ throw new Error("alias move requires non-empty --reason");
41
+ }
42
+ const createdAt = input.now ?? new Date().toISOString();
43
+ const previous = await readIncumbentAlias(input.repoRoot, input.alias);
44
+ if (input.action === "rollback" && !previous) {
45
+ throw new Error(`cannot rollback alias ${input.alias}: alias does not exist yet`);
46
+ }
47
+ const target = await requireAcceptedCandidate({
48
+ repoRoot: input.repoRoot,
49
+ candidateId: input.toCandidateId,
50
+ });
51
+ if (previous && previous.candidateId === target.candidateId) {
52
+ throw new Error(`alias ${input.alias} already points at candidate ${target.candidateId}`);
53
+ }
54
+ const decision = buildDecision({
55
+ alias: input.alias,
56
+ action: input.action,
57
+ fromCandidateId: previous?.candidateId ?? null,
58
+ toCandidateId: target.candidateId,
59
+ toBundleHash: target.bundleHash,
60
+ reason,
61
+ applied: !input.dryRun,
62
+ createdAt,
63
+ campaignId: input.campaignId,
64
+ });
65
+ const nextAlias = {
66
+ schemaVersion: 1,
67
+ alias: input.alias,
68
+ candidateId: target.candidateId,
69
+ bundleHash: target.bundleHash,
70
+ updatedAt: createdAt,
71
+ updatedByDecisionId: decision.decisionId,
72
+ previousCandidateId: previous?.candidateId ?? null,
73
+ };
74
+ if (input.dryRun) {
75
+ return {
76
+ dryRun: true,
77
+ decision: { ...decision, applied: false },
78
+ alias: nextAlias,
79
+ previous,
80
+ };
81
+ }
82
+ const historySeq = previous ? await nextHistorySeq(input.repoRoot, input.alias) : 1;
83
+ const historyEvent = {
84
+ schemaVersion: 1,
85
+ seq: historySeq,
86
+ decisionId: decision.decisionId,
87
+ action: input.action,
88
+ fromCandidateId: previous?.candidateId ?? null,
89
+ toCandidateId: target.candidateId,
90
+ toBundleHash: target.bundleHash,
91
+ at: createdAt,
92
+ };
93
+ await commitAliasMove({
94
+ repoRoot: input.repoRoot,
95
+ decision,
96
+ nextAlias,
97
+ historyEvent,
98
+ });
99
+ // W2.4 does not auto-retire the previous incumbent: lifecycle `accepted` ≠
100
+ // alias pointer, and rollback drills must be able to point back without
101
+ // inventing a new candidate id. Operators may `eval candidate transition
102
+ // --to retired` separately.
103
+ return {
104
+ dryRun: false,
105
+ decision,
106
+ alias: (await readIncumbentAlias(input.repoRoot, input.alias)),
107
+ previous,
108
+ };
109
+ }
110
+ async function nextHistorySeq(repoRoot, alias) {
111
+ const text = await readFile(aliasHistoryPath(repoRoot, alias), "utf-8").catch(() => "");
112
+ const lines = text
113
+ .split("\n")
114
+ .map((line) => line.trim())
115
+ .filter(Boolean);
116
+ return lines.length + 1;
117
+ }
118
+ export async function promoteAlias(input) {
119
+ return planAliasMove({
120
+ ...input,
121
+ action: "promote",
122
+ dryRun: input.dryRun !== false,
123
+ });
124
+ }
125
+ export async function rollbackAlias(input) {
126
+ return planAliasMove({
127
+ ...input,
128
+ action: "rollback",
129
+ dryRun: input.dryRun !== false,
130
+ });
131
+ }
132
+ export async function showAlias(input) {
133
+ const current = await readIncumbentAlias(input.repoRoot, input.alias);
134
+ if (!current) {
135
+ throw new Error(`alias not found: ${input.alias}`);
136
+ }
137
+ return current;
138
+ }
139
+ export async function listAliases(input) {
140
+ const names = await listAliasNames(input.repoRoot);
141
+ const rows = [];
142
+ for (const name of names) {
143
+ const current = await readIncumbentAlias(input.repoRoot, name);
144
+ if (current)
145
+ rows.push(current);
146
+ }
147
+ return rows;
148
+ }
149
+ export async function isCandidatePromotionApplied(input) {
150
+ const incumbents = await listIncumbentCandidateIds(input.repoRoot);
151
+ return incumbents.has(input.candidateId);
152
+ }
153
+ export function formatAliasMarkdown(alias) {
154
+ return [
155
+ `# Alias: ${alias.alias}`,
156
+ "",
157
+ `- candidateId: \`${alias.candidateId}\``,
158
+ `- bundleHash: \`${alias.bundleHash}\``,
159
+ `- previousCandidateId: \`${alias.previousCandidateId ?? "null"}\``,
160
+ `- updatedByDecisionId: \`${alias.updatedByDecisionId}\``,
161
+ `- updatedAt: \`${alias.updatedAt}\``,
162
+ "",
163
+ ].join("\n");
164
+ }
165
+ export function formatAliasMoveMarkdown(result) {
166
+ const mode = result.dryRun ? "dry-run" : "applied";
167
+ return [
168
+ `# Alias ${result.decision.action} (${mode})`,
169
+ "",
170
+ `- alias: \`${result.decision.alias}\``,
171
+ `- from: \`${result.decision.fromCandidateId ?? "null"}\``,
172
+ `- to: \`${result.decision.toCandidateId}\``,
173
+ `- bundleHash: \`${result.decision.toBundleHash}\``,
174
+ `- decisionId: \`${result.decision.decisionId}\``,
175
+ `- applied: \`${result.decision.applied}\``,
176
+ `- reason: ${result.decision.reason}`,
177
+ "",
178
+ result.dryRun
179
+ ? "_No alias files were written (dry-run). Pass `--apply` to commit._"
180
+ : "_Alias pointer updated; candidate bundles and completed DAG facts were not rewritten._",
181
+ "",
182
+ ].join("\n");
183
+ }
184
+ export { readPromotionDecision };
@@ -0,0 +1,192 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Campaign / DAG hard-budget contract (Eval Lab W3.4–W3.5 / M3).
4
+ *
5
+ * Token incompleteness: missing `tokensUsed` must never be treated as 0.
6
+ * When `maxTokens` is set, only known token samples accumulate; breach fires
7
+ * only when the known sum exceeds the limit. Calls + wall time + repair passes
8
+ * are the primary hard dimensions for v1.
9
+ */
10
+ export const campaignBudgetModeSchema = z.enum(["hard", "record-only"]);
11
+ export const campaignBudgetLimitsSchema = z
12
+ .object({
13
+ maxTokens: z.number().int().positive().optional(),
14
+ maxWallTimeMs: z.number().int().positive().optional(),
15
+ maxExecutorCalls: z.number().int().positive().optional(),
16
+ maxRepairPasses: z.number().int().nonnegative().optional(),
17
+ maxConcurrency: z.number().int().positive().optional(),
18
+ maxContextChars: z.number().int().positive().optional(),
19
+ })
20
+ .strict()
21
+ .superRefine((limits, ctx) => {
22
+ const hasHardDim = limits.maxTokens !== undefined ||
23
+ limits.maxWallTimeMs !== undefined ||
24
+ limits.maxExecutorCalls !== undefined ||
25
+ limits.maxRepairPasses !== undefined ||
26
+ limits.maxContextChars !== undefined;
27
+ if (!hasHardDim) {
28
+ ctx.addIssue({
29
+ code: z.ZodIssueCode.custom,
30
+ message: "budget.limits requires at least one of maxTokens, maxWallTimeMs, maxExecutorCalls, maxRepairPasses, maxContextChars",
31
+ });
32
+ }
33
+ });
34
+ export const campaignBudgetSchema = z
35
+ .object({
36
+ schemaVersion: z.literal(1),
37
+ mode: campaignBudgetModeSchema.default("hard"),
38
+ limits: campaignBudgetLimitsSchema,
39
+ })
40
+ .strict();
41
+ export function createBudgetLedger(budget) {
42
+ return {
43
+ schemaVersion: 1,
44
+ mode: budget.mode,
45
+ limits: { ...budget.limits },
46
+ consumed: {
47
+ tokens: null,
48
+ wallTimeMs: 0,
49
+ executorCalls: 0,
50
+ repairPasses: 0,
51
+ peakContextChars: 0,
52
+ missingTokenNodeIds: [],
53
+ },
54
+ breaches: [],
55
+ status: budget.mode === "record-only" ? "record-only" : "ok",
56
+ };
57
+ }
58
+ export function resolveEffectiveMaxConcurrent(requested, budget) {
59
+ const limit = budget?.limits.maxConcurrency;
60
+ if (limit === undefined) {
61
+ return { maxConcurrent: Math.max(1, requested), clamped: false };
62
+ }
63
+ const maxConcurrent = Math.max(1, Math.min(requested, limit));
64
+ return { maxConcurrent, clamped: maxConcurrent < requested };
65
+ }
66
+ function pushBreach(ledger, breach) {
67
+ const full = {
68
+ ...breach,
69
+ at: breach.at ?? new Date().toISOString(),
70
+ };
71
+ const duplicate = ledger.breaches.some((existing) => existing.dimension === full.dimension &&
72
+ existing.nodeId === full.nodeId &&
73
+ existing.limit === full.limit);
74
+ if (!duplicate) {
75
+ ledger.breaches.push(full);
76
+ }
77
+ if (ledger.mode === "hard") {
78
+ ledger.status = "breached";
79
+ }
80
+ }
81
+ function checkLimit(ledger, dimension, consumed, limit, opts) {
82
+ if (limit === undefined)
83
+ return undefined;
84
+ const exceeded = opts?.inclusive ? consumed >= limit : consumed > limit;
85
+ if (!exceeded)
86
+ return undefined;
87
+ const breach = {
88
+ dimension,
89
+ limit,
90
+ consumed,
91
+ at: new Date().toISOString(),
92
+ ...(opts?.nodeId ? { nodeId: opts.nodeId } : {}),
93
+ };
94
+ pushBreach(ledger, breach);
95
+ return breach;
96
+ }
97
+ /**
98
+ * Pre-node check (no call increment yet).
99
+ * Discrete call budget uses inclusive compare so maxExecutorCalls=1 allows the
100
+ * first node then blocks starting a second.
101
+ */
102
+ export function checkBudgetPreNode(ledger, sample) {
103
+ ledger.consumed.wallTimeMs = sample.wallTimeMs;
104
+ ledger.consumed.repairPasses = sample.repairPasses;
105
+ return (checkLimit(ledger, "executorCalls", ledger.consumed.executorCalls, ledger.limits.maxExecutorCalls, { inclusive: true }) ??
106
+ checkLimit(ledger, "wallTimeMs", sample.wallTimeMs, ledger.limits.maxWallTimeMs) ??
107
+ checkLimit(ledger, "repairPasses", sample.repairPasses, ledger.limits.maxRepairPasses));
108
+ }
109
+ /**
110
+ * Record one finished node execution attempt and evaluate hard limits.
111
+ * Increments executorCalls by 1. Does not invent tokens when missing.
112
+ */
113
+ export function recordNodeBudgetSample(ledger, sample) {
114
+ ledger.consumed.executorCalls += 1;
115
+ ledger.consumed.wallTimeMs = sample.wallTimeMs;
116
+ ledger.consumed.repairPasses = sample.repairPasses;
117
+ if (sample.tokensUsed === undefined) {
118
+ if (!ledger.consumed.missingTokenNodeIds.includes(sample.nodeId)) {
119
+ ledger.consumed.missingTokenNodeIds.push(sample.nodeId);
120
+ }
121
+ }
122
+ else {
123
+ ledger.consumed.tokens = (ledger.consumed.tokens ?? 0) + sample.tokensUsed;
124
+ }
125
+ if (sample.contextChars !== undefined) {
126
+ ledger.consumed.peakContextChars = Math.max(ledger.consumed.peakContextChars, sample.contextChars);
127
+ }
128
+ return (checkLimit(ledger, "executorCalls", ledger.consumed.executorCalls, ledger.limits.maxExecutorCalls, { nodeId: sample.nodeId, inclusive: true }) ??
129
+ checkLimit(ledger, "wallTimeMs", ledger.consumed.wallTimeMs, ledger.limits.maxWallTimeMs, { nodeId: sample.nodeId }) ??
130
+ checkLimit(ledger, "repairPasses", ledger.consumed.repairPasses, ledger.limits.maxRepairPasses, { nodeId: sample.nodeId }) ??
131
+ (ledger.consumed.tokens !== null
132
+ ? checkLimit(ledger, "tokens", ledger.consumed.tokens, ledger.limits.maxTokens, { nodeId: sample.nodeId })
133
+ : undefined) ??
134
+ checkLimit(ledger, "contextChars", ledger.consumed.peakContextChars, ledger.limits.maxContextChars, { nodeId: sample.nodeId }));
135
+ }
136
+ export function isHardBudgetBreached(ledger) {
137
+ return Boolean(ledger && ledger.mode === "hard" && ledger.status === "breached");
138
+ }
139
+ /** Mark all PENDING nodes as SKIPPED due to budget breach (fail closed). */
140
+ export function skipPendingNodesForBudgetBreach(nodes, breach) {
141
+ const skipped = [];
142
+ const reason = `budget_breach:${breach.dimension}`;
143
+ for (const [id, node] of Object.entries(nodes)) {
144
+ if (node.status !== "PENDING")
145
+ continue;
146
+ node.status = "SKIPPED";
147
+ node.skippedReason = reason;
148
+ skipped.push(id);
149
+ }
150
+ return skipped;
151
+ }
152
+ export function formatBudgetReportMarkdown(ledger) {
153
+ const limits = ledger.limits;
154
+ const c = ledger.consumed;
155
+ const lines = [
156
+ `# Budget Ledger`,
157
+ ``,
158
+ `- **mode**: ${ledger.mode}`,
159
+ `- **status**: ${ledger.status}`,
160
+ ``,
161
+ `## Limits`,
162
+ ``,
163
+ `| dimension | limit |`,
164
+ `|---|---|`,
165
+ `| maxTokens | ${limits.maxTokens ?? "n/a"} |`,
166
+ `| maxWallTimeMs | ${limits.maxWallTimeMs ?? "n/a"} |`,
167
+ `| maxExecutorCalls | ${limits.maxExecutorCalls ?? "n/a"} |`,
168
+ `| maxRepairPasses | ${limits.maxRepairPasses ?? "n/a"} |`,
169
+ `| maxConcurrency | ${limits.maxConcurrency ?? "n/a"} |`,
170
+ `| maxContextChars | ${limits.maxContextChars ?? "n/a"} |`,
171
+ ``,
172
+ `## Consumed`,
173
+ ``,
174
+ `| dimension | value |`,
175
+ `|---|---|`,
176
+ `| tokens | ${c.tokens ?? "n/a (missing)"} |`,
177
+ `| wallTimeMs | ${c.wallTimeMs} |`,
178
+ `| executorCalls | ${c.executorCalls} |`,
179
+ `| repairPasses | ${c.repairPasses} |`,
180
+ `| peakContextChars | ${c.peakContextChars} |`,
181
+ `| missingTokenNodes | ${c.missingTokenNodeIds.join(", ") || "none"} |`,
182
+ ];
183
+ if (ledger.breaches.length > 0) {
184
+ lines.push(``, `## Breaches`, ``);
185
+ for (const breach of ledger.breaches) {
186
+ lines.push(`- **${breach.dimension}**: consumed ${breach.consumed} > limit ${breach.limit}` +
187
+ (breach.nodeId ? ` (node ${breach.nodeId})` : "") +
188
+ ` at ${breach.at}`);
189
+ }
190
+ }
191
+ return `${lines.join("\n")}\n`;
192
+ }
@@ -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
+ }