@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +112 -0
- package/package.json +2 -1
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/lib/adaptive-mode.cjs +170 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/bandit-router.cjs +368 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/hedge-ensemble.cjs +217 -0
- package/scripts/lib/mmr-rerank.cjs +154 -0
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -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
|
};
|