@hegemonart/get-design-done 1.22.0 → 1.23.5

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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * hedge-ensemble.cjs — AdaNormalHedge weighted-majority over verifier
3
+ * + checker agents (Plan 23.5-02).
4
+ *
5
+ * Parameter-free: no manual learning rate. Weights self-adapt via
6
+ * the AdaNormalHedge regret-bound trick — η is recomputed each round
7
+ * from cumulative loss variance, eliminating the typical "tune η or
8
+ * suffer" tax.
9
+ *
10
+ * Weights persist at `.design/telemetry/hedge-weights.json` (atomic
11
+ * .tmp + rename). Schema:
12
+ * { schema_version: '1.0.0',
13
+ * generated_at: ISO,
14
+ * pools: { <poolId>: { agents: { <agentId>: {weight, cumLoss, cumLoss2, rounds} } } } }
15
+ *
16
+ * Reused by adaptive_mode = "hedge" or "full" — see Plan 23.5-04.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const DEFAULT_WEIGHTS_PATH = '.design/telemetry/hedge-weights.json';
25
+ const SCHEMA_VERSION = '1.0.0';
26
+ const DEFAULT_VOTE_THRESHOLD = 0.5;
27
+
28
+ function resolvePath(opts = {}) {
29
+ if (opts.weightsPath) {
30
+ return path.isAbsolute(opts.weightsPath)
31
+ ? opts.weightsPath
32
+ : path.resolve(opts.baseDir ?? process.cwd(), opts.weightsPath);
33
+ }
34
+ return path.resolve(opts.baseDir ?? process.cwd(), DEFAULT_WEIGHTS_PATH);
35
+ }
36
+
37
+ /**
38
+ * @returns {{schema_version: string, generated_at: string, pools: object}}
39
+ */
40
+ function loadWeights(opts = {}) {
41
+ const p = resolvePath(opts);
42
+ if (!fs.existsSync(p)) {
43
+ return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), pools: {} };
44
+ }
45
+ try {
46
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
47
+ if (!data.pools || typeof data.pools !== 'object') data.pools = {};
48
+ return data;
49
+ } catch {
50
+ return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), pools: {} };
51
+ }
52
+ }
53
+
54
+ function saveWeights(state, opts = {}) {
55
+ const p = resolvePath(opts);
56
+ fs.mkdirSync(path.dirname(p), { recursive: true });
57
+ state.generated_at = new Date().toISOString();
58
+ const tmp = p + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
60
+ fs.renameSync(tmp, p);
61
+ return p;
62
+ }
63
+
64
+ function ensurePool(state, poolId) {
65
+ if (!state.pools[poolId]) state.pools[poolId] = { agents: {} };
66
+ return state.pools[poolId];
67
+ }
68
+
69
+ function ensureAgent(pool, agentId) {
70
+ if (!pool.agents[agentId]) {
71
+ pool.agents[agentId] = {
72
+ weight: 1, // uniform start; normalised on read
73
+ cumLoss: 0,
74
+ cumLoss2: 0,
75
+ rounds: 0,
76
+ };
77
+ }
78
+ return pool.agents[agentId];
79
+ }
80
+
81
+ /**
82
+ * Apply one round of losses to a pool. losses: Record<agentId, lossInZeroOne>.
83
+ *
84
+ * AdaNormalHedge update (parameter-free):
85
+ * For each agent i:
86
+ * R_i = sum of (mean_loss - loss_i) over rounds (instantaneous regret)
87
+ * C_i = sum of (loss_i - mean_loss)^2 (cumulative loss variance)
88
+ * Set η_i = sqrt(ln(N) / max(1, C_i)) per-agent learning rate.
89
+ * weight_i ∝ Phi(R_i, C_i) where Phi is a positive-only potential.
90
+ *
91
+ * Simplification used here: w_i *= exp(-η * loss_i) with η derived
92
+ * from cumulative variance — gives the same regret bound as full
93
+ * AdaNormalHedge for the binary-loss case we care about (verifier
94
+ * pass/fail). Trade off: slightly less tight bound vs the full
95
+ * potential, but no need to plumb regret tracking everywhere.
96
+ *
97
+ * @param {{poolId: string, losses: Record<string, number>, baseDir?: string, weightsPath?: string, eta?: number}} input
98
+ * @returns {{weights: Record<string, number>, weightsPath: string}}
99
+ */
100
+ function loss(input) {
101
+ if (!input || typeof input.poolId !== 'string' || input.poolId.length === 0) {
102
+ throw new TypeError('hedge-ensemble.loss: poolId (string) required');
103
+ }
104
+ if (!input.losses || typeof input.losses !== 'object') {
105
+ throw new TypeError('hedge-ensemble.loss: losses (Record<string, number>) required');
106
+ }
107
+ const state = loadWeights(input);
108
+ const pool = ensurePool(state, input.poolId);
109
+ // First, ensure every losing agent exists.
110
+ for (const [agentId, lossVal] of Object.entries(input.losses)) {
111
+ if (typeof lossVal !== 'number' || Number.isNaN(lossVal)) {
112
+ throw new TypeError(`hedge-ensemble.loss: losses.${agentId} must be a number`);
113
+ }
114
+ }
115
+ for (const agentId of Object.keys(input.losses)) {
116
+ ensureAgent(pool, agentId);
117
+ }
118
+ const N = Object.keys(pool.agents).length;
119
+ // Compute mean loss this round (over agents that received a value).
120
+ const lossList = Object.values(input.losses);
121
+ const meanLoss = lossList.length > 0 ? lossList.reduce((a, b) => a + b, 0) / lossList.length : 0;
122
+ // Update each agent's cumulative variance + regret-like signal, then
123
+ // recompute its weight via exp(-η_i * loss_i).
124
+ for (const [agentId, rawLoss] of Object.entries(input.losses)) {
125
+ const lossVal = Math.min(1, Math.max(0, rawLoss));
126
+ const a = pool.agents[agentId];
127
+ const dev = lossVal - meanLoss;
128
+ a.cumLoss += lossVal;
129
+ a.cumLoss2 += dev * dev;
130
+ a.rounds += 1;
131
+ const eta =
132
+ typeof input.eta === 'number'
133
+ ? input.eta
134
+ : Math.sqrt(Math.log(Math.max(2, N)) / Math.max(1, a.cumLoss2));
135
+ a.weight *= Math.exp(-eta * lossVal);
136
+ if (!Number.isFinite(a.weight) || a.weight <= 0) a.weight = 1e-9;
137
+ }
138
+ // Renormalize.
139
+ const total = Object.values(pool.agents).reduce((s, x) => s + x.weight, 0) || 1;
140
+ /** @type {Record<string, number>} */
141
+ const out = {};
142
+ for (const agentId of Object.keys(pool.agents)) {
143
+ pool.agents[agentId].weight /= total;
144
+ out[agentId] = pool.agents[agentId].weight;
145
+ }
146
+ const writtenPath = saveWeights(state, input);
147
+ return { weights: out, weightsPath: writtenPath };
148
+ }
149
+
150
+ /**
151
+ * Compute the weighted-majority verdict for a pool given each agent's
152
+ * binary vote (pass=1, fail=0). Vote passes when the weighted sum
153
+ * exceeds threshold (default 0.5).
154
+ *
155
+ * @param {{poolId: string, votes: Record<string, 0|1|boolean>, threshold?: number, baseDir?: string, weightsPath?: string}} input
156
+ * @returns {{passes: boolean, weighted: number, threshold: number, perAgent: Record<string, {weight: number, vote: number}>}}
157
+ */
158
+ function vote(input) {
159
+ if (!input || typeof input.poolId !== 'string') {
160
+ throw new TypeError('hedge-ensemble.vote: poolId required');
161
+ }
162
+ if (!input.votes || typeof input.votes !== 'object') {
163
+ throw new TypeError('hedge-ensemble.vote: votes required');
164
+ }
165
+ const state = loadWeights(input);
166
+ const pool = ensurePool(state, input.poolId);
167
+ const threshold = typeof input.threshold === 'number' ? input.threshold : DEFAULT_VOTE_THRESHOLD;
168
+ let total = 0;
169
+ /** @type {Record<string, {weight: number, vote: number}>} */
170
+ const perAgent = {};
171
+ let weightSum = 0;
172
+ for (const [agentId, raw] of Object.entries(input.votes)) {
173
+ const v = raw === true || raw === 1 ? 1 : 0;
174
+ const a = ensureAgent(pool, agentId);
175
+ perAgent[agentId] = { weight: a.weight, vote: v };
176
+ total += a.weight * v;
177
+ weightSum += a.weight;
178
+ }
179
+ // Normalise the weighted sum against the SUM of voting agents'
180
+ // weights — agents in the pool that didn't vote this round don't
181
+ // dilute the result.
182
+ const weighted = weightSum > 0 ? total / weightSum : 0;
183
+ return { passes: weighted >= threshold, weighted, threshold, perAgent };
184
+ }
185
+
186
+ /**
187
+ * Read current weights for a pool, normalised over the pool's agents.
188
+ *
189
+ * @param {{poolId: string, baseDir?: string, weightsPath?: string}} input
190
+ * @returns {Record<string, number>}
191
+ */
192
+ function weights(input) {
193
+ if (!input || typeof input.poolId !== 'string') {
194
+ throw new TypeError('hedge-ensemble.weights: poolId required');
195
+ }
196
+ const state = loadWeights(input);
197
+ const pool = state.pools[input.poolId];
198
+ if (!pool) return {};
199
+ const total = Object.values(pool.agents).reduce((s, x) => s + x.weight, 0);
200
+ /** @type {Record<string, number>} */
201
+ const out = {};
202
+ for (const [k, v] of Object.entries(pool.agents)) {
203
+ out[k] = total > 0 ? v.weight / total : 0;
204
+ }
205
+ return out;
206
+ }
207
+
208
+ module.exports = {
209
+ loss,
210
+ vote,
211
+ weights,
212
+ loadWeights,
213
+ saveWeights,
214
+ DEFAULT_VOTE_THRESHOLD,
215
+ DEFAULT_WEIGHTS_PATH,
216
+ SCHEMA_VERSION,
217
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * mmr-rerank.cjs — Maximal Marginal Relevance post-pass on top-K
3
+ * (Plan 23.5-03).
4
+ *
5
+ * Solves the "all 5 surfaced learnings are about the same thing"
6
+ * failure mode in the Phase 14.5 decision-injector. Greedy selection
7
+ * with the standard MMR criterion:
8
+ *
9
+ * nextItem = argmax_{i ∉ selected} λ * relevance(i) − (1 − λ) * max_sim(i, selected)
10
+ *
11
+ * Similarity is token-overlap (Jaccard on case-folded word n-grams,
12
+ * default n=2). No external deps, no embedding API.
13
+ *
14
+ * Pure helper — caller supplies the candidates and a relevance score
15
+ * already computed by upstream (e.g. grep hit count, BM25, or the
16
+ * decision-injector's existing rank function). MMR re-ranks ONLY.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const DEFAULT_LAMBDA = 0.7;
22
+ const DEFAULT_NGRAM = 2;
23
+ const TOKEN_RE = /[\p{L}\p{N}_-]+/gu;
24
+
25
+ /**
26
+ * Tokenize a string into case-folded alphanumeric+underscore+dash runs.
27
+ *
28
+ * @param {string} text
29
+ * @returns {string[]}
30
+ */
31
+ function tokenize(text) {
32
+ if (typeof text !== 'string' || text.length === 0) return [];
33
+ const matches = text.toLowerCase().match(TOKEN_RE);
34
+ return matches ? matches : [];
35
+ }
36
+
37
+ /**
38
+ * Build a Set of word n-grams from a string.
39
+ *
40
+ * @param {string} text
41
+ * @param {number} n
42
+ * @returns {Set<string>}
43
+ */
44
+ function ngrams(text, n) {
45
+ const toks = tokenize(text);
46
+ if (toks.length < n) return new Set(toks);
47
+ const out = new Set();
48
+ for (let i = 0; i <= toks.length - n; i++) {
49
+ out.add(toks.slice(i, i + n).join(' '));
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Jaccard similarity between two strings on word n-grams.
56
+ *
57
+ * @param {string} a
58
+ * @param {string} b
59
+ * @param {number} [n]
60
+ * @returns {number} 0..1
61
+ */
62
+ function similarity(a, b, n = DEFAULT_NGRAM) {
63
+ const A = ngrams(a, n);
64
+ const B = ngrams(b, n);
65
+ if (A.size === 0 || B.size === 0) return 0;
66
+ let inter = 0;
67
+ for (const g of A) if (B.has(g)) inter += 1;
68
+ const union = A.size + B.size - inter;
69
+ return union > 0 ? inter / union : 0;
70
+ }
71
+
72
+ /**
73
+ * Re-rank an array of items using the MMR criterion.
74
+ *
75
+ * @param {Array<{text: string, relevance?: number}>} items
76
+ * @param {{lambda?: number, k?: number, ngram?: number, textOf?: (item: object) => string, relevanceOf?: (item: object) => number}} [opts]
77
+ * @returns {Array<object>} subset of input in MMR-selected order
78
+ */
79
+ function rerank(items, opts = {}) {
80
+ if (!Array.isArray(items)) {
81
+ throw new TypeError('mmr-rerank.rerank: items must be an array');
82
+ }
83
+ if (items.length === 0) return [];
84
+ const lambda = typeof opts.lambda === 'number' ? opts.lambda : DEFAULT_LAMBDA;
85
+ const ngram = typeof opts.ngram === 'number' ? opts.ngram : DEFAULT_NGRAM;
86
+ const k = typeof opts.k === 'number' && opts.k > 0 ? Math.min(opts.k, items.length) : items.length;
87
+ const textOf =
88
+ typeof opts.textOf === 'function'
89
+ ? opts.textOf
90
+ : (it) => (typeof it === 'string' ? it : (it && typeof it.text === 'string' ? it.text : ''));
91
+ const relOf =
92
+ typeof opts.relevanceOf === 'function'
93
+ ? opts.relevanceOf
94
+ : (it) => {
95
+ if (it && typeof it.relevance === 'number') return it.relevance;
96
+ if (it && typeof it.score === 'number') return it.score;
97
+ return 1;
98
+ };
99
+
100
+ // Pre-tokenize candidates.
101
+ const grams = items.map((it) => ngrams(textOf(it), ngram));
102
+ const relevance = items.map((it) => relOf(it));
103
+ const remaining = items.map((_, i) => i);
104
+ /** @type {number[]} */
105
+ const selected = [];
106
+
107
+ while (selected.length < k && remaining.length > 0) {
108
+ let bestIdx = -1;
109
+ let bestScore = -Infinity;
110
+ for (const i of remaining) {
111
+ let maxSim = 0;
112
+ for (const j of selected) {
113
+ const sim = jaccard(grams[i], grams[j]);
114
+ if (sim > maxSim) maxSim = sim;
115
+ }
116
+ const score = lambda * relevance[i] - (1 - lambda) * maxSim;
117
+ if (score > bestScore) {
118
+ bestScore = score;
119
+ bestIdx = i;
120
+ }
121
+ }
122
+ if (bestIdx === -1) break;
123
+ selected.push(bestIdx);
124
+ const pos = remaining.indexOf(bestIdx);
125
+ if (pos !== -1) remaining.splice(pos, 1);
126
+ }
127
+ return selected.map((i) => items[i]);
128
+ }
129
+
130
+ /**
131
+ * Jaccard between two pre-built ngram sets. Faster than calling
132
+ * `similarity()` from the rerank loop.
133
+ *
134
+ * @param {Set<string>} A
135
+ * @param {Set<string>} B
136
+ * @returns {number}
137
+ */
138
+ function jaccard(A, B) {
139
+ if (A.size === 0 || B.size === 0) return 0;
140
+ let inter = 0;
141
+ for (const g of A) if (B.has(g)) inter += 1;
142
+ const union = A.size + B.size - inter;
143
+ return union > 0 ? inter / union : 0;
144
+ }
145
+
146
+ module.exports = {
147
+ rerank,
148
+ similarity,
149
+ tokenize,
150
+ ngrams,
151
+ jaccard,
152
+ DEFAULT_LAMBDA,
153
+ DEFAULT_NGRAM,
154
+ };
@@ -204,11 +204,176 @@ function loadMotionMapSchema(projectRoot) {
204
204
  return JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
205
205
  }
206
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // Phase 23 Plan 23-01 — planner + verifier decision contracts.
209
+ //
210
+ // These parsers ride the same extract→parse→validate pipeline as
211
+ // parseMotionMap. Validation is structural (required fields, enums,
212
+ // types) — full JSON Schema validation is delegated to ajv when callers
213
+ // want strict enforcement; the in-line validators keep the no-deps
214
+ // guarantee for the hot path.
215
+ // ---------------------------------------------------------------------------
216
+
217
+ const VALID_VERIFIER_VERDICTS = ['pass', 'fail', 'gap'];
218
+ const VALID_GAP_SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
219
+ const VALID_VERIFIER_CONFIDENCE = ['high', 'med', 'low'];
220
+
221
+ function validatePlannerDecision(data) {
222
+ const errors = [];
223
+ if (!data || typeof data !== 'object') {
224
+ return { ok: false, errors: ['Top-level value is not an object'] };
225
+ }
226
+ if (data.schema_version !== '1.0.0') {
227
+ errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
228
+ }
229
+ if (typeof data.plan_id !== 'string' || data.plan_id.length === 0) {
230
+ errors.push('plan_id is required (non-empty string)');
231
+ }
232
+ if (!Array.isArray(data.tasks) || data.tasks.length === 0) {
233
+ errors.push('tasks must be a non-empty array');
234
+ } else {
235
+ data.tasks.forEach((task, i) => {
236
+ const tag = `tasks[${i}]`;
237
+ if (!task || typeof task !== 'object') {
238
+ errors.push(`${tag} is not an object`);
239
+ return;
240
+ }
241
+ if (typeof task.task_id !== 'string' || task.task_id.length === 0) {
242
+ errors.push(`${tag}.task_id is required (non-empty string)`);
243
+ }
244
+ if (typeof task.summary !== 'string' || task.summary.length < 3) {
245
+ errors.push(`${tag}.summary required, ≥3 chars`);
246
+ }
247
+ if (!Array.isArray(task.touches) || task.touches.some((t) => typeof t !== 'string')) {
248
+ errors.push(`${tag}.touches must be an array of strings`);
249
+ }
250
+ if (task.dependencies !== undefined &&
251
+ (!Array.isArray(task.dependencies) ||
252
+ task.dependencies.some((d) => typeof d !== 'string'))) {
253
+ errors.push(`${tag}.dependencies must be an array of strings`);
254
+ }
255
+ if (task.parallel_safe !== undefined && typeof task.parallel_safe !== 'boolean') {
256
+ errors.push(`${tag}.parallel_safe must be boolean`);
257
+ }
258
+ if (task.estimated_minutes !== undefined &&
259
+ (typeof task.estimated_minutes !== 'number' || task.estimated_minutes < 0)) {
260
+ errors.push(`${tag}.estimated_minutes must be a non-negative number`);
261
+ }
262
+ });
263
+ }
264
+ if (!Array.isArray(data.waves) || data.waves.length === 0) {
265
+ errors.push('waves must be a non-empty array');
266
+ } else {
267
+ data.waves.forEach((wave, i) => {
268
+ const tag = `waves[${i}]`;
269
+ if (!wave || typeof wave !== 'object') {
270
+ errors.push(`${tag} is not an object`);
271
+ return;
272
+ }
273
+ if (typeof wave.wave !== 'string' || wave.wave.length === 0) {
274
+ errors.push(`${tag}.wave required (non-empty string)`);
275
+ }
276
+ if (!Array.isArray(wave.task_ids) || wave.task_ids.length === 0) {
277
+ errors.push(`${tag}.task_ids must be a non-empty array`);
278
+ }
279
+ });
280
+ }
281
+ return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
282
+ }
283
+
284
+ function validateVerifierDecision(data) {
285
+ const errors = [];
286
+ if (!data || typeof data !== 'object') {
287
+ return { ok: false, errors: ['Top-level value is not an object'] };
288
+ }
289
+ if (data.schema_version !== '1.0.0') {
290
+ errors.push(`schema_version must be "1.0.0" (got ${JSON.stringify(data.schema_version)})`);
291
+ }
292
+ if (!VALID_VERIFIER_VERDICTS.includes(data.verdict)) {
293
+ errors.push(`verdict must be one of [${VALID_VERIFIER_VERDICTS.join('|')}] (got ${JSON.stringify(data.verdict)})`);
294
+ }
295
+ if (!Array.isArray(data.gaps)) {
296
+ errors.push('gaps must be an array');
297
+ } else {
298
+ data.gaps.forEach((gap, i) => {
299
+ const tag = `gaps[${i}]`;
300
+ if (!gap || typeof gap !== 'object') {
301
+ errors.push(`${tag} is not an object`);
302
+ return;
303
+ }
304
+ if (typeof gap.id !== 'string' || gap.id.length === 0) {
305
+ errors.push(`${tag}.id is required (non-empty string)`);
306
+ }
307
+ if (!VALID_GAP_SEVERITIES.includes(gap.severity)) {
308
+ errors.push(`${tag}.severity must be one of [${VALID_GAP_SEVERITIES.join('|')}]`);
309
+ }
310
+ if (typeof gap.area !== 'string' || gap.area.length === 0) {
311
+ errors.push(`${tag}.area is required (non-empty string)`);
312
+ }
313
+ if (typeof gap.summary !== 'string' || gap.summary.length < 3) {
314
+ errors.push(`${tag}.summary required, ≥3 chars`);
315
+ }
316
+ });
317
+ }
318
+ if (!Array.isArray(data.must_fix_before_ship) ||
319
+ data.must_fix_before_ship.some((s) => typeof s !== 'string')) {
320
+ errors.push('must_fix_before_ship must be an array of strings');
321
+ }
322
+ if (!VALID_VERIFIER_CONFIDENCE.includes(data.confidence)) {
323
+ errors.push(`confidence must be one of [${VALID_VERIFIER_CONFIDENCE.join('|')}]`);
324
+ }
325
+ return errors.length === 0 ? { ok: true, data } : { ok: false, errors };
326
+ }
327
+
328
+ /**
329
+ * Extract + validate the planner decision JSON block from markdown output.
330
+ * @param {string} markdown
331
+ * @returns {{ ok: true, data: object } | { ok: false, error: string }}
332
+ */
333
+ function parsePlannerDecision(markdown) {
334
+ const extracted = extractJsonBlock(markdown);
335
+ if (!extracted.ok) return { ok: false, error: extracted.error };
336
+ const parsed = parseJson(extracted.raw);
337
+ if (!parsed.ok) return { ok: false, error: parsed.error };
338
+ const validated = validatePlannerDecision(parsed.data);
339
+ if (!validated.ok) {
340
+ return {
341
+ ok: false,
342
+ error: `Planner decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
343
+ };
344
+ }
345
+ return { ok: true, data: validated.data };
346
+ }
347
+
348
+ /**
349
+ * Extract + validate the verifier decision JSON block from markdown output.
350
+ * @param {string} markdown
351
+ * @returns {{ ok: true, data: object } | { ok: false, error: string }}
352
+ */
353
+ function parseVerifierDecision(markdown) {
354
+ const extracted = extractJsonBlock(markdown);
355
+ if (!extracted.ok) return { ok: false, error: extracted.error };
356
+ const parsed = parseJson(extracted.raw);
357
+ if (!parsed.ok) return { ok: false, error: parsed.error };
358
+ const validated = validateVerifierDecision(parsed.data);
359
+ if (!validated.ok) {
360
+ return {
361
+ ok: false,
362
+ error: `Verifier decision contract violations:\n${validated.errors.map((e) => ` - ${e}`).join('\n')}`,
363
+ };
364
+ }
365
+ return { ok: true, data: validated.data };
366
+ }
367
+
207
368
  module.exports = {
208
369
  parseMotionMap,
370
+ parsePlannerDecision,
371
+ parseVerifierDecision,
209
372
  parseGenericContract,
210
373
  loadMotionMapSchema,
211
374
  validateMotionMap,
375
+ validatePlannerDecision,
376
+ validateVerifierDecision,
212
377
  extractJsonBlock,
213
378
  parseJson,
214
379
  // Exported for testing
@@ -217,4 +382,7 @@ module.exports = {
217
382
  VALID_TRANSITION_FAMILIES,
218
383
  VALID_DURATION_CLASSES,
219
384
  VALID_TRIGGERS,
385
+ VALID_VERIFIER_VERDICTS,
386
+ VALID_GAP_SEVERITIES,
387
+ VALID_VERIFIER_CONFIDENCE,
220
388
  };