role-os 2.0.0 → 2.1.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.
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Brainstorm Mission — v0.2
3
+ *
4
+ * Multi-perspective search + controlled compression + deliberate recombination.
5
+ * A lawful thinking pipeline, not "parallel notes."
6
+ *
7
+ * Pipeline: Frame → Scout (parallel) → Normalize → Synthesize → Expand → Judge → Return
8
+ *
9
+ * v0.2 additions:
10
+ * - "invention" evidence mode: speculative at medium, mixed at high — where new ideas live
11
+ * - Incubation bucket in Synthesize: promising-but-under-evidenced directions get a lawful lane
12
+ * - Judge action field: build_now / hold_for_followon / archive_but_retain
13
+ * - Optional incubation expand pass when novelty_bias is high
14
+ */
15
+
16
+ // ── Evidence mode constraint matrix ─────────────────────────────────────────
17
+
18
+ /**
19
+ * Legal (evidence_grade, confidence) pairs per evidence mode.
20
+ *
21
+ * Rule: confidence 'high' is ONLY legal for evidence_grade 'grounded' — EXCEPT in invention mode.
22
+ * Rule: 'strict' mode forbids all non-grounded claims.
23
+ * Rule: 'speculative' mode allows speculative claims but caps at 'medium'.
24
+ * Rule: 'invention' mode opens the gate — mixed claims can be high, speculative can be medium.
25
+ * This is where new ideas live. Following the herd gives you more of the same.
26
+ */
27
+ const EVIDENCE_CONSTRAINTS = {
28
+ strict: {
29
+ grounded: ["high", "medium", "low"],
30
+ mixed: [],
31
+ speculative: [],
32
+ },
33
+ mixed: {
34
+ grounded: ["high", "medium", "low"],
35
+ mixed: ["medium", "low"],
36
+ speculative: ["low"],
37
+ },
38
+ speculative: {
39
+ grounded: ["high", "medium", "low"],
40
+ mixed: ["medium", "low"],
41
+ speculative: ["medium", "low"],
42
+ },
43
+ invention: {
44
+ grounded: ["high", "medium", "low"],
45
+ mixed: ["high", "medium", "low"],
46
+ speculative: ["medium", "low"],
47
+ },
48
+ };
49
+
50
+ const VALID_EVIDENCE_MODES = ["strict", "mixed", "speculative", "invention"];
51
+ const VALID_EVIDENCE_GRADES = ["grounded", "mixed", "speculative"];
52
+ const VALID_CONFIDENCE_LEVELS = ["high", "medium", "low"];
53
+ const VALID_OUTPUT_MODES = ["idea_set", "strategy", "concepts", "opportunity_map"];
54
+ const VALID_NOVELTY_BIAS = ["low", "medium", "high"];
55
+ const VALID_STATEMENT_KINDS = ["claim", "opportunity", "risk", "tension", "unknown"];
56
+
57
+ // ── Evidence mode validation ────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Check if a (grade, confidence) pair is legal under the given evidence mode.
61
+ *
62
+ * @param {string} mode - 'strict' | 'mixed' | 'speculative'
63
+ * @param {string} grade - 'grounded' | 'mixed' | 'speculative'
64
+ * @param {string} confidence - 'high' | 'medium' | 'low'
65
+ * @returns {{ valid: boolean, reason?: string }}
66
+ */
67
+ export function validateEvidencePair(mode, grade, confidence) {
68
+ if (!VALID_EVIDENCE_MODES.includes(mode)) {
69
+ return { valid: false, reason: `Invalid evidence mode: "${mode}"` };
70
+ }
71
+ if (!VALID_EVIDENCE_GRADES.includes(grade)) {
72
+ return { valid: false, reason: `Invalid evidence grade: "${grade}"` };
73
+ }
74
+ if (!VALID_CONFIDENCE_LEVELS.includes(confidence)) {
75
+ return { valid: false, reason: `Invalid confidence level: "${confidence}"` };
76
+ }
77
+
78
+ const allowed = EVIDENCE_CONSTRAINTS[mode][grade];
79
+ if (!allowed.includes(confidence)) {
80
+ return {
81
+ valid: false,
82
+ reason: `Evidence mode "${mode}" does not allow (grade: "${grade}", confidence: "${confidence}")`,
83
+ };
84
+ }
85
+
86
+ return { valid: true };
87
+ }
88
+
89
+ /**
90
+ * Validate all statements in a scout finding against the evidence mode.
91
+ *
92
+ * @param {string} mode - Evidence mode
93
+ * @param {Array<{evidence_grade: string, confidence: string, id?: string}>} statements
94
+ * @returns {{ valid: boolean, violations: Array<{statementId: string, grade: string, confidence: string, reason: string}> }}
95
+ */
96
+ export function validateFindingStatements(mode, statements) {
97
+ const violations = [];
98
+
99
+ for (let i = 0; i < statements.length; i++) {
100
+ const s = statements[i];
101
+ const result = validateEvidencePair(mode, s.evidence_grade, s.confidence);
102
+ if (!result.valid) {
103
+ violations.push({
104
+ statementId: s.id || `statement-${i}`,
105
+ grade: s.evidence_grade,
106
+ confidence: s.confidence,
107
+ reason: result.reason,
108
+ });
109
+ }
110
+ }
111
+
112
+ return { valid: violations.length === 0, violations };
113
+ }
114
+
115
+ // ── BrainstormRequest defaults & validation ─────────────────────────────────
116
+
117
+ const REQUEST_DEFAULTS = {
118
+ output_mode: "concepts",
119
+ breadth: 3,
120
+ depth: 1,
121
+ novelty_bias: "medium",
122
+ evidence_mode: "mixed",
123
+ };
124
+
125
+ const BREADTH_MIN = 1;
126
+ const BREADTH_MAX = 7;
127
+ const DEPTH_MIN = 1;
128
+ const DEPTH_MAX = 3;
129
+
130
+ /**
131
+ * Validate a BrainstormRequest object.
132
+ *
133
+ * @param {object} request
134
+ * @returns {{ valid: boolean, issues: string[] }}
135
+ */
136
+ export function validateRequest(request) {
137
+ const issues = [];
138
+
139
+ if (!request) {
140
+ return { valid: false, issues: ["Request is null or undefined"] };
141
+ }
142
+
143
+ // Required fields
144
+ if (!request.topic || typeof request.topic !== "string" || request.topic.trim().length === 0) {
145
+ issues.push("topic is required and must be a non-empty string");
146
+ }
147
+ if (!request.objective || typeof request.objective !== "string" || request.objective.trim().length === 0) {
148
+ issues.push("objective is required and must be a non-empty string");
149
+ }
150
+
151
+ // Output mode
152
+ if (request.output_mode && !VALID_OUTPUT_MODES.includes(request.output_mode)) {
153
+ issues.push(`output_mode must be one of: ${VALID_OUTPUT_MODES.join(", ")}`);
154
+ }
155
+
156
+ // Breadth
157
+ if (request.breadth !== undefined) {
158
+ if (typeof request.breadth !== "number" || !Number.isInteger(request.breadth)) {
159
+ issues.push("breadth must be an integer");
160
+ } else if (request.breadth < BREADTH_MIN || request.breadth > BREADTH_MAX) {
161
+ issues.push(`breadth must be between ${BREADTH_MIN} and ${BREADTH_MAX}`);
162
+ }
163
+ }
164
+
165
+ // Depth
166
+ if (request.depth !== undefined) {
167
+ if (typeof request.depth !== "number" || !Number.isInteger(request.depth)) {
168
+ issues.push("depth must be an integer");
169
+ } else if (request.depth < DEPTH_MIN || request.depth > DEPTH_MAX) {
170
+ issues.push(`depth must be between ${DEPTH_MIN} and ${DEPTH_MAX}`);
171
+ }
172
+ }
173
+
174
+ // Novelty bias
175
+ if (request.novelty_bias && !VALID_NOVELTY_BIAS.includes(request.novelty_bias)) {
176
+ issues.push(`novelty_bias must be one of: ${VALID_NOVELTY_BIAS.join(", ")}`);
177
+ }
178
+
179
+ // Evidence mode
180
+ if (request.evidence_mode && !VALID_EVIDENCE_MODES.includes(request.evidence_mode)) {
181
+ issues.push(`evidence_mode must be one of: ${VALID_EVIDENCE_MODES.join(", ")}`);
182
+ }
183
+
184
+ // Optional arrays
185
+ if (request.constraints && !Array.isArray(request.constraints)) {
186
+ issues.push("constraints must be an array of strings");
187
+ }
188
+ if (request.search_axes && !Array.isArray(request.search_axes)) {
189
+ issues.push("search_axes must be an array of strings");
190
+ }
191
+
192
+ // Audience
193
+ if (request.audience !== undefined && typeof request.audience !== "string") {
194
+ issues.push("audience must be a string");
195
+ }
196
+
197
+ return { valid: issues.length === 0, issues };
198
+ }
199
+
200
+ // ── Frame resolver ──────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Default scout roster for v0.1 (legacy — kept for backwards compatibility).
204
+ */
205
+ const V01_SCOUT_ROSTER = [
206
+ "Context Scout",
207
+ "User Value Scout",
208
+ "Creative Leap Scout",
209
+ ];
210
+
211
+ /**
212
+ * v0.3 analyst roster — structurally specialized roles with hard boundaries.
213
+ * Each analyst has: role-native schema, input partition, forbidden topics, permitted claim kinds.
214
+ */
215
+ const V03_ANALYST_ROSTER = [
216
+ "Context Analyst",
217
+ "User Value Analyst",
218
+ "Mechanics Analyst",
219
+ "Positioning Analyst",
220
+ ];
221
+
222
+ /**
223
+ * Expander roster by depth level.
224
+ */
225
+ const EXPANDER_ROSTER = [
226
+ "Product Expander", // depth >= 1
227
+ "Scenario Expander", // depth >= 2
228
+ "Moat Expander", // depth >= 3
229
+ ];
230
+
231
+ /**
232
+ * Resolve a raw task description into a BrainstormRequest with defaults applied.
233
+ *
234
+ * In v0.1, this does simple extraction. In production, an LLM would parse
235
+ * the task description into structured fields.
236
+ *
237
+ * @param {object} input - Partial BrainstormRequest (at minimum: topic + objective)
238
+ * @returns {{ request: object, scoutRoster: string[], expanderRoster: string[], executionPlan: object }}
239
+ */
240
+ export function resolveFrame(input) {
241
+ const request = {
242
+ topic: input.topic,
243
+ objective: input.objective,
244
+ audience: input.audience || null,
245
+ constraints: input.constraints || [],
246
+ search_axes: input.search_axes || [],
247
+ output_mode: input.output_mode || REQUEST_DEFAULTS.output_mode,
248
+ breadth: input.breadth ?? REQUEST_DEFAULTS.breadth,
249
+ depth: input.depth ?? REQUEST_DEFAULTS.depth,
250
+ novelty_bias: input.novelty_bias || REQUEST_DEFAULTS.novelty_bias,
251
+ evidence_mode: input.evidence_mode || REQUEST_DEFAULTS.evidence_mode,
252
+ role_mode: input.role_mode || "v03", // "v01" for legacy scouts, "v03" for specialized analysts
253
+ };
254
+
255
+ // Determine analyst/scout roster based on role_mode
256
+ const useV03 = request.role_mode === "v03";
257
+ const analystRoster = useV03 ? [...V03_ANALYST_ROSTER] : [...V01_SCOUT_ROSTER];
258
+
259
+ // v0.3: Contrarian Analyst runs after Normalize (sees atoms not brief)
260
+ const includeContrarian = useV03;
261
+
262
+ // Determine expander roster based on depth
263
+ const expanderRoster = EXPANDER_ROSTER.slice(0, request.depth);
264
+
265
+ // v0.2: Incubation expand pass when novelty_bias is high
266
+ const incubationExpand = request.novelty_bias === "high";
267
+ const incubationSteps = incubationExpand ? 1 : 0;
268
+
269
+ // Build execution plan
270
+ const phases = [
271
+ { phase: "frame", steps: 1 },
272
+ { phase: useV03 ? "analyze" : "scout", steps: analystRoster.length, parallel: true },
273
+ { phase: "normalize", steps: 1 },
274
+ ];
275
+
276
+ if (useV03) {
277
+ // v0.3 pipeline: cross-examine and rebut between normalize and synthesize
278
+ phases.push(
279
+ { phase: "cross_examine", steps: includeContrarian ? analystRoster.length + 1 : analystRoster.length, parallel: true },
280
+ { phase: "rebut", steps: analystRoster.length, parallel: true },
281
+ );
282
+ }
283
+
284
+ phases.push(
285
+ { phase: "synthesize", steps: 1 },
286
+ { phase: "expand", steps: request.breadth * expanderRoster.length, parallel: false },
287
+ );
288
+
289
+ if (incubationExpand) {
290
+ phases.push({ phase: "incubation_expand", steps: 1, speculative: true });
291
+ }
292
+
293
+ phases.push(
294
+ { phase: "judge", steps: 1 },
295
+ { phase: "return", steps: 1 },
296
+ );
297
+
298
+ // Calculate total steps
299
+ let totalSteps = 0;
300
+ for (const p of phases) totalSteps += p.steps;
301
+
302
+ const executionPlan = {
303
+ phases,
304
+ totalSteps,
305
+ maxJudgeLoops: 3,
306
+ analystCount: analystRoster.length,
307
+ expanderCount: expanderRoster.length,
308
+ directionsToExpand: request.breadth,
309
+ incubationExpand,
310
+ roleMode: request.role_mode,
311
+ includeContrarian,
312
+ // Legacy compat
313
+ scoutCount: analystRoster.length,
314
+ };
315
+
316
+ return { request, scoutRoster: analystRoster, expanderRoster, executionPlan };
317
+ }
318
+
319
+ // ── Scout finding schema validation ─────────────────────────────────────────
320
+
321
+ /**
322
+ * Validate a ScoutFinding object.
323
+ *
324
+ * @param {object} finding
325
+ * @param {string} evidenceMode - The active evidence mode for constraint checking
326
+ * @returns {{ valid: boolean, issues: string[] }}
327
+ */
328
+ export function validateScoutFinding(finding, evidenceMode) {
329
+ const issues = [];
330
+
331
+ if (!finding) return { valid: false, issues: ["Finding is null"] };
332
+
333
+ if (!finding.id || typeof finding.id !== "string") {
334
+ issues.push("id is required");
335
+ }
336
+ if (!finding.role || typeof finding.role !== "string") {
337
+ issues.push("role is required");
338
+ }
339
+ if (!finding.axis || typeof finding.axis !== "string") {
340
+ issues.push("axis is required");
341
+ }
342
+
343
+ if (!Array.isArray(finding.statements)) {
344
+ issues.push("statements must be an array");
345
+ return { valid: false, issues };
346
+ }
347
+
348
+ if (finding.statements.length < 3) {
349
+ issues.push("statements must have at least 3 entries");
350
+ }
351
+ if (finding.statements.length > 12) {
352
+ issues.push("statements must have at most 12 entries");
353
+ }
354
+
355
+ for (let i = 0; i < finding.statements.length; i++) {
356
+ const s = finding.statements[i];
357
+ const prefix = `statements[${i}]`;
358
+
359
+ if (!s.id) issues.push(`${prefix}.id is required`);
360
+ if (!s.text || typeof s.text !== "string") issues.push(`${prefix}.text is required`);
361
+ if (!VALID_STATEMENT_KINDS.includes(s.kind)) {
362
+ issues.push(`${prefix}.kind must be one of: ${VALID_STATEMENT_KINDS.join(", ")}`);
363
+ }
364
+ if (!VALID_EVIDENCE_GRADES.includes(s.evidence_grade)) {
365
+ issues.push(`${prefix}.evidence_grade must be one of: ${VALID_EVIDENCE_GRADES.join(", ")}`);
366
+ }
367
+ if (!VALID_CONFIDENCE_LEVELS.includes(s.confidence)) {
368
+ issues.push(`${prefix}.confidence must be one of: ${VALID_CONFIDENCE_LEVELS.join(", ")}`);
369
+ }
370
+
371
+ // Evidence mode constraint
372
+ if (evidenceMode && s.evidence_grade && s.confidence) {
373
+ const pair = validateEvidencePair(evidenceMode, s.evidence_grade, s.confidence);
374
+ if (!pair.valid) {
375
+ issues.push(`${prefix}: ${pair.reason}`);
376
+ }
377
+ }
378
+ }
379
+
380
+ return { valid: issues.length === 0, issues };
381
+ }
382
+
383
+ // ── Normalized finding set validation ───────────────────────────────────────
384
+
385
+ /**
386
+ * Validate a NormalizedFindingSet object.
387
+ *
388
+ * @param {object} findingSet
389
+ * @returns {{ valid: boolean, issues: string[] }}
390
+ */
391
+ export function validateNormalizedFindingSet(findingSet) {
392
+ const issues = [];
393
+
394
+ if (!findingSet) return { valid: false, issues: ["Finding set is null"] };
395
+
396
+ // Atoms
397
+ if (!Array.isArray(findingSet.atoms)) {
398
+ issues.push("atoms must be an array");
399
+ } else {
400
+ for (let i = 0; i < findingSet.atoms.length; i++) {
401
+ const a = findingSet.atoms[i];
402
+ const p = `atoms[${i}]`;
403
+ if (!a.id) issues.push(`${p}.id is required`);
404
+ if (!a.statement) issues.push(`${p}.statement is required`);
405
+ if (!VALID_STATEMENT_KINDS.includes(a.kind)) {
406
+ issues.push(`${p}.kind must be one of: ${VALID_STATEMENT_KINDS.join(", ")}`);
407
+ }
408
+ if (!Array.isArray(a.source_roles) || a.source_roles.length === 0) {
409
+ issues.push(`${p}.source_roles must be a non-empty array`);
410
+ }
411
+ if (!Array.isArray(a.source_statement_ids) || a.source_statement_ids.length === 0) {
412
+ issues.push(`${p}.source_statement_ids must be a non-empty array`);
413
+ }
414
+ if (typeof a.support_count !== "number" || a.support_count < 0) {
415
+ issues.push(`${p}.support_count must be a non-negative number`);
416
+ }
417
+ if (typeof a.challenge_count !== "number" || a.challenge_count < 0) {
418
+ issues.push(`${p}.challenge_count must be a non-negative number`);
419
+ }
420
+ if (!VALID_EVIDENCE_GRADES.includes(a.evidence_grade)) {
421
+ issues.push(`${p}.evidence_grade is invalid`);
422
+ }
423
+ if (!VALID_CONFIDENCE_LEVELS.includes(a.confidence)) {
424
+ issues.push(`${p}.confidence is invalid`);
425
+ }
426
+ }
427
+ }
428
+
429
+ // Conflicts
430
+ if (!Array.isArray(findingSet.conflicts)) {
431
+ issues.push("conflicts must be an array");
432
+ } else {
433
+ for (let i = 0; i < findingSet.conflicts.length; i++) {
434
+ const c = findingSet.conflicts[i];
435
+ const p = `conflicts[${i}]`;
436
+ if (!c.id) issues.push(`${p}.id is required`);
437
+ if (!c.finding_a_id) issues.push(`${p}.finding_a_id is required`);
438
+ if (!c.finding_b_id) issues.push(`${p}.finding_b_id is required`);
439
+ if (!c.reason) issues.push(`${p}.reason is required`);
440
+ if (!["hard", "soft"].includes(c.severity)) {
441
+ issues.push(`${p}.severity must be "hard" or "soft"`);
442
+ }
443
+ }
444
+ }
445
+
446
+ // Stats
447
+ if (!findingSet.stats) {
448
+ issues.push("stats is required");
449
+ } else {
450
+ const s = findingSet.stats;
451
+ if (typeof s.total_source_statements !== "number") issues.push("stats.total_source_statements must be a number");
452
+ if (typeof s.total_atoms !== "number") issues.push("stats.total_atoms must be a number");
453
+ if (typeof s.grounded_count !== "number") issues.push("stats.grounded_count must be a number");
454
+ if (typeof s.mixed_count !== "number") issues.push("stats.mixed_count must be a number");
455
+ if (typeof s.speculative_count !== "number") issues.push("stats.speculative_count must be a number");
456
+ }
457
+
458
+ // Flags
459
+ if (!Array.isArray(findingSet.unsupported_high_confidence_flags)) {
460
+ issues.push("unsupported_high_confidence_flags must be an array");
461
+ }
462
+
463
+ if (typeof findingSet.duplicates_collapsed !== "number") {
464
+ issues.push("duplicates_collapsed must be a number");
465
+ }
466
+
467
+ return { valid: issues.length === 0, issues };
468
+ }
469
+
470
+ // ── Synthesis report validation ─────────────────────────────────────────────
471
+
472
+ /**
473
+ * Validate a SynthesisReport object.
474
+ *
475
+ * @param {object} report
476
+ * @param {number} expectedBreadth - How many advancing directions are required
477
+ * @returns {{ valid: boolean, issues: string[] }}
478
+ */
479
+ export function validateSynthesisReport(report, expectedBreadth) {
480
+ const issues = [];
481
+
482
+ if (!report) return { valid: false, issues: ["Report is null"] };
483
+
484
+ if (!report.topic_model || typeof report.topic_model !== "string") {
485
+ issues.push("topic_model is required");
486
+ }
487
+
488
+ if (!Array.isArray(report.major_themes) || report.major_themes.length < 3) {
489
+ issues.push("major_themes must have at least 3 entries");
490
+ }
491
+
492
+ if (!Array.isArray(report.tensions)) {
493
+ issues.push("tensions must be an array");
494
+ }
495
+
496
+ // Breadth enforcement — exact match
497
+ if (!Array.isArray(report.advancing_directions)) {
498
+ issues.push("advancing_directions must be an array");
499
+ } else {
500
+ if (report.advancing_directions.length !== expectedBreadth) {
501
+ issues.push(`advancing_directions must have exactly ${expectedBreadth} entries (got ${report.advancing_directions.length})`);
502
+ }
503
+ for (let i = 0; i < report.advancing_directions.length; i++) {
504
+ const d = report.advancing_directions[i];
505
+ const p = `advancing_directions[${i}]`;
506
+ if (!d.id) issues.push(`${p}.id is required`);
507
+ if (!d.name) issues.push(`${p}.name is required`);
508
+ if (!d.thesis) issues.push(`${p}.thesis is required`);
509
+ if (!d.why_it_matters) issues.push(`${p}.why_it_matters is required`);
510
+ if (!Array.isArray(d.supporting_atoms) || d.supporting_atoms.length < 2) {
511
+ issues.push(`${p}.supporting_atoms must cite at least 2 finding atoms`);
512
+ }
513
+ if (!Array.isArray(d.risks)) {
514
+ issues.push(`${p}.risks must be an array`);
515
+ }
516
+ }
517
+ }
518
+
519
+ if (!Array.isArray(report.archived_directions)) {
520
+ issues.push("archived_directions must be an array");
521
+ } else {
522
+ for (let i = 0; i < report.archived_directions.length; i++) {
523
+ const d = report.archived_directions[i];
524
+ if (!d.name) issues.push(`archived_directions[${i}].name is required`);
525
+ if (!d.reason) issues.push(`archived_directions[${i}].reason is required`);
526
+ }
527
+ }
528
+
529
+ // Incubation directions (v0.2) — optional third lane for promising-but-under-evidenced ideas
530
+ if (report.incubation_directions !== undefined) {
531
+ if (!Array.isArray(report.incubation_directions)) {
532
+ issues.push("incubation_directions must be an array");
533
+ } else {
534
+ for (let i = 0; i < report.incubation_directions.length; i++) {
535
+ const d = report.incubation_directions[i];
536
+ const p = `incubation_directions[${i}]`;
537
+ if (!d.id) issues.push(`${p}.id is required`);
538
+ if (!d.name) issues.push(`${p}.name is required`);
539
+ if (!d.thesis) issues.push(`${p}.thesis is required`);
540
+ if (!d.why_it_matters) issues.push(`${p}.why_it_matters is required`);
541
+ if (!Array.isArray(d.supporting_atoms) || d.supporting_atoms.length === 0) {
542
+ issues.push(`${p}.supporting_atoms must cite at least 1 finding atom`);
543
+ }
544
+ if (!d.advancement_criteria || typeof d.advancement_criteria !== "string") {
545
+ issues.push(`${p}.advancement_criteria is required — what evidence would advance this direction to the next pass`);
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ return { valid: issues.length === 0, issues };
552
+ }
553
+
554
+ // ── Expanded concept validation ─────────────────────────────────────────────
555
+
556
+ /**
557
+ * Validate an ExpandedConcept object.
558
+ *
559
+ * @param {object} concept
560
+ * @returns {{ valid: boolean, issues: string[] }}
561
+ */
562
+ export function validateExpandedConcept(concept) {
563
+ const issues = [];
564
+
565
+ if (!concept) return { valid: false, issues: ["Concept is null"] };
566
+
567
+ if (!concept.direction_id) issues.push("direction_id is required");
568
+ if (!concept.direction_name) issues.push("direction_name is required");
569
+ if (!concept.thesis) issues.push("thesis is required");
570
+
571
+ // Product shape (required for v0.1 — Product Expander always runs)
572
+ if (!concept.product_shape) {
573
+ issues.push("product_shape is required");
574
+ } else {
575
+ const ps = concept.product_shape;
576
+ if (!ps.target_user) issues.push("product_shape.target_user is required");
577
+ if (!ps.core_mechanism) issues.push("product_shape.core_mechanism is required");
578
+ if (!Array.isArray(ps.features) || ps.features.length === 0) {
579
+ issues.push("product_shape.features must be a non-empty array");
580
+ }
581
+ if (!ps.core_loop) issues.push("product_shape.core_loop is required");
582
+ if (!ps.smallest_proof) issues.push("product_shape.smallest_proof is required");
583
+ }
584
+
585
+ // Scenarios (optional — depth >= 2)
586
+ if (concept.scenarios !== undefined) {
587
+ if (!Array.isArray(concept.scenarios)) {
588
+ issues.push("scenarios must be an array");
589
+ } else {
590
+ for (let i = 0; i < concept.scenarios.length; i++) {
591
+ const s = concept.scenarios[i];
592
+ if (!s.situation) issues.push(`scenarios[${i}].situation is required`);
593
+ if (!s.user_action) issues.push(`scenarios[${i}].user_action is required`);
594
+ if (!s.outcome) issues.push(`scenarios[${i}].outcome is required`);
595
+ }
596
+ }
597
+ }
598
+
599
+ // Moat (optional — depth >= 3)
600
+ if (concept.moat !== undefined) {
601
+ const m = concept.moat;
602
+ if (!m.differentiation) issues.push("moat.differentiation is required");
603
+ if (!m.stickiness) issues.push("moat.stickiness is required");
604
+ if (!m.competitive_response) issues.push("moat.competitive_response is required");
605
+ if (!Array.isArray(m.phase_plan) || m.phase_plan.length === 0) {
606
+ issues.push("moat.phase_plan must be a non-empty array");
607
+ }
608
+ }
609
+
610
+ return { valid: issues.length === 0, issues };
611
+ }
612
+
613
+ // ── Judge report validation ─────────────────────────────────────────────────
614
+
615
+ const VALID_DISPOSITIONS = ["accept", "revise_expand", "revise_synthesize", "reject"];
616
+ const VALID_QUALITY_LEVELS = ["strong", "adequate", "weak"];
617
+ const VALID_DIRECTION_VERDICTS = ["ready_to_advance", "needs_incubation", "not_active_now"];
618
+ const VALID_JUDGE_ACTIONS = ["build_now", "hold_for_followon", "archive_but_retain"];
619
+ const VALID_FAILING_CRITERIA = [
620
+ "weak_differentiation",
621
+ "low_evidence_support",
622
+ "generic_expansion",
623
+ "contradiction_unresolved",
624
+ "poor_objective_alignment",
625
+ "infeasible",
626
+ ];
627
+
628
+ /**
629
+ * Validate a JudgeReport object.
630
+ *
631
+ * @param {object} report
632
+ * @returns {{ valid: boolean, issues: string[] }}
633
+ */
634
+ export function validateJudgeReport(report) {
635
+ const issues = [];
636
+
637
+ if (!report) return { valid: false, issues: ["Report is null"] };
638
+
639
+ if (!VALID_DISPOSITIONS.includes(report.disposition)) {
640
+ issues.push(`disposition must be one of: ${VALID_DISPOSITIONS.join(", ")}`);
641
+ }
642
+ if (!VALID_QUALITY_LEVELS.includes(report.overall_quality)) {
643
+ issues.push(`overall_quality must be one of: ${VALID_QUALITY_LEVELS.join(", ")}`);
644
+ }
645
+
646
+ if (!Array.isArray(report.per_direction)) {
647
+ issues.push("per_direction must be an array");
648
+ } else {
649
+ for (let i = 0; i < report.per_direction.length; i++) {
650
+ const d = report.per_direction[i];
651
+ const p = `per_direction[${i}]`;
652
+ if (!d.direction_id) issues.push(`${p}.direction_id is required`);
653
+ if (!VALID_DIRECTION_VERDICTS.includes(d.verdict)) {
654
+ issues.push(`${p}.verdict must be one of: ${VALID_DIRECTION_VERDICTS.join(", ")}`);
655
+ }
656
+ if (!Array.isArray(d.reasons) || d.reasons.length === 0) {
657
+ issues.push(`${p}.reasons must be a non-empty array`);
658
+ }
659
+ if (d.failing_criteria) {
660
+ for (const fc of d.failing_criteria) {
661
+ if (!VALID_FAILING_CRITERIA.includes(fc)) {
662
+ issues.push(`${p}.failing_criteria contains invalid value: "${fc}"`);
663
+ }
664
+ }
665
+ }
666
+ // v0.2: action recommendation per direction
667
+ if (d.action !== undefined && !VALID_JUDGE_ACTIONS.includes(d.action)) {
668
+ issues.push(`${p}.action must be one of: ${VALID_JUDGE_ACTIONS.join(", ")}`);
669
+ }
670
+ }
671
+ }
672
+
673
+ if (!Array.isArray(report.reasons) || report.reasons.length === 0) {
674
+ issues.push("reasons must be a non-empty array");
675
+ }
676
+
677
+ // Revision targets required for revise dispositions
678
+ if (report.disposition === "revise_expand" || report.disposition === "revise_synthesize") {
679
+ if (!Array.isArray(report.revision_targets) || report.revision_targets.length === 0) {
680
+ issues.push("revision_targets required for revise dispositions");
681
+ }
682
+ }
683
+
684
+ return { valid: issues.length === 0, issues };
685
+ }
686
+
687
+ // ── Brainstorm result validation ────────────────────────────────────────────
688
+
689
+ /**
690
+ * Validate a BrainstormResult object.
691
+ *
692
+ * @param {object} result
693
+ * @returns {{ valid: boolean, issues: string[] }}
694
+ */
695
+ export function validateBrainstormResult(result) {
696
+ const issues = [];
697
+
698
+ if (!result) return { valid: false, issues: ["Result is null"] };
699
+
700
+ if (!result.core_read || typeof result.core_read !== "string") {
701
+ issues.push("core_read is required");
702
+ }
703
+
704
+ if (!Array.isArray(result.opportunity_map) || result.opportunity_map.length === 0) {
705
+ issues.push("opportunity_map must be a non-empty array");
706
+ }
707
+
708
+ if (!Array.isArray(result.unresolved_tensions)) {
709
+ issues.push("unresolved_tensions must be an array");
710
+ }
711
+
712
+ if (!Array.isArray(result.open_questions)) {
713
+ issues.push("open_questions must be an array");
714
+ }
715
+
716
+ // v0.2: Incubation concepts (optional — present when novelty_bias is high)
717
+ if (result.incubation_concepts !== undefined) {
718
+ if (!Array.isArray(result.incubation_concepts)) {
719
+ issues.push("incubation_concepts must be an array");
720
+ } else {
721
+ for (let i = 0; i < result.incubation_concepts.length; i++) {
722
+ const ic = result.incubation_concepts[i];
723
+ const p = `incubation_concepts[${i}]`;
724
+ if (!ic.name) issues.push(`${p}.name is required`);
725
+ if (!ic.thesis) issues.push(`${p}.thesis is required`);
726
+ if (!ic.speculative_shape) issues.push(`${p}.speculative_shape is required`);
727
+ if (!ic.advancement_criteria) issues.push(`${p}.advancement_criteria is required`);
728
+ }
729
+ }
730
+ }
731
+
732
+ if (!result.evidence_trail) {
733
+ issues.push("evidence_trail is required");
734
+ } else {
735
+ const t = result.evidence_trail;
736
+ if (!Array.isArray(t.scouts_run)) issues.push("evidence_trail.scouts_run must be an array");
737
+ if (typeof t.total_findings !== "number") issues.push("evidence_trail.total_findings must be a number");
738
+ if (typeof t.atoms_after_normalize !== "number") issues.push("evidence_trail.atoms_after_normalize must be a number");
739
+ if (typeof t.directions_considered !== "number") issues.push("evidence_trail.directions_considered must be a number");
740
+ if (typeof t.directions_advancing !== "number") issues.push("evidence_trail.directions_advancing must be a number");
741
+ if (typeof t.expansion_passes !== "number") issues.push("evidence_trail.expansion_passes must be a number");
742
+ if (typeof t.judge_loops !== "number") issues.push("evidence_trail.judge_loops must be a number");
743
+ if (!VALID_DISPOSITIONS.includes(t.judge_disposition)) {
744
+ issues.push("evidence_trail.judge_disposition is invalid");
745
+ }
746
+ }
747
+
748
+ if (!result.request) {
749
+ issues.push("request is required");
750
+ }
751
+
752
+ return { valid: issues.length === 0, issues };
753
+ }
754
+
755
+ // ── Exports for constants ───────────────────────────────────────────────────
756
+
757
+ export {
758
+ EVIDENCE_CONSTRAINTS,
759
+ VALID_EVIDENCE_MODES,
760
+ VALID_EVIDENCE_GRADES,
761
+ VALID_CONFIDENCE_LEVELS,
762
+ VALID_OUTPUT_MODES,
763
+ VALID_NOVELTY_BIAS,
764
+ VALID_STATEMENT_KINDS,
765
+ VALID_DISPOSITIONS,
766
+ VALID_QUALITY_LEVELS,
767
+ VALID_DIRECTION_VERDICTS,
768
+ VALID_FAILING_CRITERIA,
769
+ VALID_JUDGE_ACTIONS,
770
+ V01_SCOUT_ROSTER,
771
+ V03_ANALYST_ROSTER,
772
+ EXPANDER_ROSTER,
773
+ REQUEST_DEFAULTS,
774
+ BREADTH_MIN,
775
+ BREADTH_MAX,
776
+ DEPTH_MIN,
777
+ DEPTH_MAX,
778
+ };