decision-os-mcp 0.3.2 → 0.4.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.
package/src/storage.ts DELETED
@@ -1,829 +0,0 @@
1
- import { readFile, writeFile, mkdir, readdir, rm, unlink } from "fs/promises";
2
- import { existsSync } from "fs";
3
- import { join, dirname } from "path";
4
- import YAML from "yaml";
5
- import { z } from "zod";
6
- import {
7
- Case as CaseSchema,
8
- PressureEvent as PressureEventSchema,
9
- Foundation as FoundationSchema,
10
- ProjectConfig as ProjectConfigSchema,
11
- } from "./schemas.js";
12
- import type { Case, PressureEvent, Foundation, ProjectConfig } from "./schemas.js";
13
-
14
- const PressureEventsFileSchema = z.object({
15
- events: z.array(PressureEventSchema).default([]),
16
- });
17
-
18
- const FoundationsFileSchema = z.object({
19
- foundations: z.array(FoundationSchema).default([]),
20
- });
21
-
22
- /**
23
- * Storage layer for Decision OS data.
24
- * Manages reading/writing cases, pressure events, and foundations
25
- * to the .decision-os folder structure.
26
- */
27
- export class DecisionOSStorage {
28
- private _basePath: string;
29
- private activeCase: string | null = null;
30
- private nextPressureId: number = 1;
31
- private nextCaseId: number = 1;
32
- private nextFoundationId: number = 1;
33
-
34
- constructor(basePath: string) {
35
- this._basePath = basePath;
36
- }
37
-
38
- get basePath(): string {
39
- return this._basePath;
40
- }
41
-
42
- // ============================================================================
43
- // INITIALIZATION
44
- // ============================================================================
45
-
46
- async initialize(): Promise<void> {
47
- // Ensure directory structure exists
48
- await this.ensureDir(this._basePath);
49
- await this.ensureDir(join(this._basePath, "cases"));
50
- await this.ensureDir(join(this._basePath, "defaults"));
51
-
52
- // Create config if it doesn't exist
53
- const configPath = join(this._basePath, "config.yaml");
54
- if (!existsSync(configPath)) {
55
- const defaultConfig: ProjectConfig = {
56
- project: "unnamed-project",
57
- version: 1,
58
- scope: "PROJECT",
59
- };
60
- await this.writeYaml(configPath, defaultConfig);
61
- }
62
-
63
- // Restore persisted active case
64
- await this.restoreActiveCase();
65
-
66
- // Initialize counters from existing data
67
- await this.initializeCounters();
68
- }
69
-
70
- private async restoreActiveCase(): Promise<void> {
71
- const activeCasePath = join(this._basePath, ".active-case");
72
- if (existsSync(activeCasePath)) {
73
- const caseId = (await readFile(activeCasePath, "utf-8")).trim();
74
- if (caseId && existsSync(join(this._basePath, "cases", caseId, "case.yaml"))) {
75
- this.activeCase = caseId;
76
- console.error(`Restored active case: ${caseId}`);
77
- } else {
78
- // Stale reference, clean up
79
- await unlink(activeCasePath).catch(() => {});
80
- }
81
- }
82
- }
83
-
84
- private async persistActiveCase(): Promise<void> {
85
- const activeCasePath = join(this._basePath, ".active-case");
86
- if (this.activeCase) {
87
- await writeFile(activeCasePath, this.activeCase, "utf-8");
88
- } else {
89
- await unlink(activeCasePath).catch(() => {});
90
- }
91
- }
92
-
93
- private async initializeCounters(): Promise<void> {
94
- // Scan existing cases for highest ID
95
- const cases = await this.listCases();
96
- for (const c of cases) {
97
- const match = c.id.match(/^(\d+)/);
98
- if (match) {
99
- const num = parseInt(match[1], 10);
100
- if (num >= this.nextCaseId) {
101
- this.nextCaseId = num + 1;
102
- }
103
- }
104
-
105
- // Also scan pressure events in each case
106
- const pressures = await this.getPressureEvents(c.id);
107
- for (const p of pressures) {
108
- const peMatch = p.id.match(/PE-(\d+)/);
109
- if (peMatch) {
110
- const num = parseInt(peMatch[1], 10);
111
- if (num >= this.nextPressureId) {
112
- this.nextPressureId = num + 1;
113
- }
114
- }
115
- }
116
- }
117
-
118
- // Scan foundations (both F- and GF- prefixes)
119
- const foundations = await this.getFoundations();
120
- for (const f of foundations) {
121
- const fMatch = f.id.match(/(?:G?F)-(\d+)/);
122
- if (fMatch) {
123
- const num = parseInt(fMatch[1], 10);
124
- if (num >= this.nextFoundationId) {
125
- this.nextFoundationId = num + 1;
126
- }
127
- }
128
- }
129
- }
130
-
131
- // ============================================================================
132
- // CONFIG
133
- // ============================================================================
134
-
135
- async getConfig(): Promise<ProjectConfig> {
136
- const configPath = join(this._basePath, "config.yaml");
137
- return this.readYamlValidated(configPath, ProjectConfigSchema, "project config");
138
- }
139
-
140
- async updateConfig(config: Partial<ProjectConfig>): Promise<void> {
141
- const current = await this.getConfig();
142
- const updated = { ...current, ...config };
143
- const validated = ProjectConfigSchema.parse(updated);
144
- await this.writeYaml(join(this._basePath, "config.yaml"), validated);
145
- }
146
-
147
- // ============================================================================
148
- // ACTIVE CASE
149
- // ============================================================================
150
-
151
- getActiveCase(): string | null {
152
- return this.activeCase;
153
- }
154
-
155
- async setActiveCase(caseId: string | null): Promise<void> {
156
- this.activeCase = caseId;
157
- await this.persistActiveCase();
158
- }
159
-
160
- // ============================================================================
161
- // CASES
162
- // ============================================================================
163
-
164
- async listCases(): Promise<Case[]> {
165
- const casesDir = join(this._basePath, "cases");
166
- if (!existsSync(casesDir)) return [];
167
-
168
- const entries = await readdir(casesDir, { withFileTypes: true });
169
- const cases: Case[] = [];
170
-
171
- for (const entry of entries) {
172
- if (entry.isDirectory() && !entry.name.startsWith("_")) {
173
- const casePath = join(casesDir, entry.name, "case.yaml");
174
- if (existsSync(casePath)) {
175
- try {
176
- const caseData = await this.readYamlValidated(
177
- casePath,
178
- CaseSchema,
179
- "case"
180
- );
181
- cases.push(caseData);
182
- } catch {
183
- // Skip invalid cases
184
- }
185
- }
186
- }
187
- }
188
-
189
- return cases.sort((a, b) => a.id.localeCompare(b.id));
190
- }
191
-
192
- async getCase(caseId: string): Promise<Case | null> {
193
- const casePath = join(this._basePath, "cases", caseId, "case.yaml");
194
- if (!existsSync(casePath)) return null;
195
- return this.readYamlValidated(casePath, CaseSchema, "case");
196
- }
197
-
198
- async createCase(input: {
199
- title: string;
200
- goal?: string;
201
- signals?: Case["signals"];
202
- touched_areas?: string[];
203
- }): Promise<Case> {
204
- const id = `${String(this.nextCaseId).padStart(4, "0")}-${this.slugify(input.title)}`;
205
- this.nextCaseId++;
206
-
207
- const resolvedGoal =
208
- typeof input.goal === "string" && input.goal.trim().length > 0
209
- ? input.goal.trim()
210
- : input.title;
211
-
212
- const caseData: Case = {
213
- id,
214
- title: input.title,
215
- goal: resolvedGoal,
216
- status: "ACTIVE",
217
- created_at: new Date().toISOString(),
218
- context: {
219
- touched_areas: input.touched_areas,
220
- },
221
- signals: input.signals,
222
- pressure_events: [],
223
- };
224
- const validated = CaseSchema.parse(caseData);
225
-
226
- const caseDir = join(this._basePath, "cases", id);
227
- await this.ensureDir(caseDir);
228
- await this.writeYaml(join(caseDir, "case.yaml"), validated);
229
-
230
- // Create empty pressures file
231
- await this.writeYaml(join(caseDir, "pressures.yaml"), { events: [] });
232
-
233
- // Set as active
234
- await this.setActiveCase(validated.id);
235
-
236
- return validated;
237
- }
238
-
239
- async updateCase(caseId: string, updates: Partial<Case>): Promise<Case> {
240
- const current = await this.getCase(caseId);
241
- if (!current) {
242
- throw new Error(`Case not found: ${caseId}`);
243
- }
244
-
245
- const updated = { ...current, ...updates };
246
- const validated = CaseSchema.parse(updated);
247
- await this.writeYaml(
248
- join(this._basePath, "cases", caseId, "case.yaml"),
249
- validated
250
- );
251
- return validated;
252
- }
253
-
254
- async closeCase(
255
- caseId: string,
256
- outcome: {
257
- regret: string | number;
258
- notes?: string;
259
- regressions?: string;
260
- }
261
- ): Promise<{ case: Case; forgotten: boolean }> {
262
- const normalizedRegret = String(outcome.regret) as "0" | "1" | "2" | "3";
263
- const current = await this.getCase(caseId);
264
- const existingSignals = current?.signals ?? {};
265
- const updated = await this.updateCase(caseId, {
266
- status: "COMPLETED",
267
- completed_at: new Date().toISOString(),
268
- signals: {
269
- ...existingSignals,
270
- outcome: {
271
- regret: normalizedRegret,
272
- notes: outcome.notes,
273
- regressions: outcome.regressions as "NONE" | "MINOR" | "MAJOR",
274
- },
275
- },
276
- });
277
-
278
- if (this.activeCase === caseId) {
279
- await this.setActiveCase(null);
280
- }
281
-
282
- // Auto-forget: delete cases with regret 0 and no unpromoted PEs
283
- let forgotten = false;
284
- if (normalizedRegret === "0") {
285
- const pressures = await this.getPressureEvents(caseId);
286
- const allPromoted = pressures.length === 0 ||
287
- pressures.every(p => p.promoted_to_foundation);
288
-
289
- if (allPromoted) {
290
- await this.forgetCase(caseId);
291
- forgotten = true;
292
- }
293
- }
294
-
295
- return { case: updated, forgotten };
296
- }
297
-
298
- /**
299
- * Delete a case directory. Knowledge lives in foundations, not cases.
300
- * Cases with no novel pressure (regret 0, no unpromoted PEs) are forgotten.
301
- */
302
- async forgetCase(caseId: string): Promise<void> {
303
- const caseDir = join(this._basePath, "cases", caseId);
304
- if (existsSync(caseDir)) {
305
- await rm(caseDir, { recursive: true, force: true });
306
- console.error(`Forgot case ${caseId}: no novel pressure retained`);
307
- }
308
- }
309
-
310
- // ============================================================================
311
- // PRESSURE EVENTS
312
- // ============================================================================
313
-
314
- async getPressureEvents(caseId: string): Promise<PressureEvent[]> {
315
- const pressuresPath = join(
316
- this._basePath,
317
- "cases",
318
- caseId,
319
- "pressures.yaml"
320
- );
321
- if (!existsSync(pressuresPath)) return [];
322
-
323
- const data = await this.readYamlValidated(
324
- pressuresPath,
325
- PressureEventsFileSchema,
326
- "pressure events"
327
- );
328
- return data.events;
329
- }
330
-
331
- async logPressure(input: {
332
- case_id?: string;
333
- expected: string;
334
- actual: string;
335
- adaptation: string;
336
- remember: string;
337
- pressure_type?: string;
338
- context_tags?: string[];
339
- }): Promise<PressureEvent> {
340
- const caseId = input.case_id || this.activeCase;
341
- if (!caseId) {
342
- throw new Error("No active case. Create a case first or specify case_id.");
343
- }
344
-
345
- const caseData = await this.getCase(caseId);
346
- if (!caseData) {
347
- throw new Error(`Case not found: ${caseId}`);
348
- }
349
-
350
- const id = `PE-${String(this.nextPressureId).padStart(4, "0")}`;
351
- this.nextPressureId++;
352
-
353
- const pressure: PressureEvent = {
354
- id,
355
- timestamp: new Date().toISOString(),
356
- case_id: caseId,
357
- pressure_type: input.pressure_type as PressureEvent["pressure_type"],
358
- context_tags: input.context_tags,
359
- expected: input.expected,
360
- actual: input.actual,
361
- adaptation: input.adaptation,
362
- remember: input.remember,
363
- };
364
- const validated = PressureEventSchema.parse(pressure);
365
-
366
- // Add to case's pressure events
367
- const pressures = await this.getPressureEvents(caseId);
368
- pressures.push(validated);
369
- await this.writeYaml(
370
- join(this._basePath, "cases", caseId, "pressures.yaml"),
371
- { events: pressures }
372
- );
373
-
374
- // Update case's pressure_events list
375
- const currentPEs = caseData.pressure_events || [];
376
- await this.updateCase(caseId, {
377
- pressure_events: [...currentPEs, id],
378
- });
379
-
380
- return validated;
381
- }
382
-
383
- async searchPressures(query: string): Promise<PressureEvent[]> {
384
- const cases = await this.listCases();
385
- const allPressures: PressureEvent[] = [];
386
-
387
- for (const c of cases) {
388
- const pressures = await this.getPressureEvents(c.id);
389
- allPressures.push(...pressures);
390
- }
391
-
392
- const lowerQuery = query.toLowerCase();
393
- return allPressures.filter(
394
- (p) =>
395
- p.expected.toLowerCase().includes(lowerQuery) ||
396
- p.actual.toLowerCase().includes(lowerQuery) ||
397
- p.adaptation.toLowerCase().includes(lowerQuery) ||
398
- p.remember.toLowerCase().includes(lowerQuery) ||
399
- p.context_tags?.some((t) => t.toLowerCase().includes(lowerQuery))
400
- );
401
- }
402
-
403
- // ============================================================================
404
- // FOUNDATIONS
405
- // ============================================================================
406
-
407
- async getFoundations(filters?: {
408
- context_tags?: string[];
409
- min_confidence?: number;
410
- }): Promise<Foundation[]> {
411
- const foundationsPath = join(this._basePath, "defaults", "foundations.yaml");
412
- if (!existsSync(foundationsPath)) return [];
413
-
414
- const data = await this.readYamlValidated(
415
- foundationsPath,
416
- FoundationsFileSchema,
417
- "foundations"
418
- );
419
- let foundations = data.foundations;
420
-
421
- if (filters?.context_tags?.length) {
422
- foundations = foundations.filter((f) =>
423
- f.context_tags.some((t) => filters.context_tags!.includes(t))
424
- );
425
- }
426
-
427
- if (filters?.min_confidence !== undefined) {
428
- foundations = foundations.filter(
429
- (f) => f.confidence >= filters.min_confidence!
430
- );
431
- }
432
-
433
- return foundations;
434
- }
435
-
436
- async promoteToFoundation(input: {
437
- title: string;
438
- default_behavior: string;
439
- context_tags: string[];
440
- counter_contexts?: string[];
441
- source_pressures: string[];
442
- exit_criteria?: string;
443
- scope?: "GLOBAL" | "PROJECT";
444
- origin_project?: string;
445
- }): Promise<Foundation> {
446
- // Use GF- prefix for global foundations, F- for project
447
- const scope = input.scope ?? "PROJECT";
448
- const prefix = scope === "GLOBAL" ? "GF" : "F";
449
- const id = `${prefix}-${String(this.nextFoundationId).padStart(4, "0")}`;
450
- this.nextFoundationId++;
451
-
452
- const foundation: Foundation = {
453
- id,
454
- title: input.title,
455
- default_behavior: input.default_behavior,
456
- context_tags: input.context_tags,
457
- counter_contexts: input.counter_contexts,
458
- confidence: 1, // Start at 1/3
459
- scope,
460
- origin_project: input.origin_project,
461
- validated_in: input.origin_project ? [input.origin_project] : undefined,
462
- exit_criteria: input.exit_criteria,
463
- source_pressures: input.source_pressures,
464
- created_at: new Date().toISOString(),
465
- updated_at: new Date().toISOString(),
466
- };
467
- const validated = FoundationSchema.parse(foundation);
468
-
469
- const foundationsPath = join(this._basePath, "defaults", "foundations.yaml");
470
- let data: { foundations: Foundation[] };
471
-
472
- if (existsSync(foundationsPath)) {
473
- data = await this.readYamlValidated(
474
- foundationsPath,
475
- FoundationsFileSchema,
476
- "foundations"
477
- );
478
- } else {
479
- data = { foundations: [] };
480
- }
481
-
482
- data.foundations.push(validated);
483
- await this.writeYaml(foundationsPath, data);
484
-
485
- // Mark source pressures as promoted
486
- for (const peId of input.source_pressures) {
487
- await this.markPressurePromoted(peId, id);
488
- }
489
-
490
- return validated;
491
- }
492
-
493
- async updateFoundation(
494
- foundationId: string,
495
- updates: Partial<Foundation>
496
- ): Promise<Foundation> {
497
- const foundationsPath = join(this._basePath, "defaults", "foundations.yaml");
498
- if (!existsSync(foundationsPath)) {
499
- throw new Error(`Foundation not found: ${foundationId}`);
500
- }
501
-
502
- const data = await this.readYamlValidated(
503
- foundationsPath,
504
- FoundationsFileSchema,
505
- "foundations"
506
- );
507
-
508
- const idx = data.foundations.findIndex((f) => f.id === foundationId);
509
- if (idx === -1) {
510
- throw new Error(`Foundation not found: ${foundationId}`);
511
- }
512
-
513
- const updated = {
514
- ...data.foundations[idx],
515
- ...updates,
516
- updated_at: new Date().toISOString(),
517
- };
518
- const validated = FoundationSchema.parse(updated);
519
- data.foundations[idx] = validated;
520
-
521
- await this.writeYaml(foundationsPath, data);
522
- return validated;
523
- }
524
-
525
- /**
526
- * Remove a foundation by ID. Used when elevating a project foundation
527
- * to global scope — the project copy is retired.
528
- */
529
- async removeFoundation(foundationId: string): Promise<boolean> {
530
- const foundationsPath = join(this._basePath, "defaults", "foundations.yaml");
531
- if (!existsSync(foundationsPath)) return false;
532
-
533
- const data = await this.readYamlValidated(
534
- foundationsPath,
535
- FoundationsFileSchema,
536
- "foundations"
537
- );
538
-
539
- const idx = data.foundations.findIndex((f) => f.id === foundationId);
540
- if (idx === -1) return false;
541
-
542
- data.foundations.splice(idx, 1);
543
- await this.writeYaml(foundationsPath, data);
544
- return true;
545
- }
546
-
547
- private async markPressurePromoted(
548
- pressureId: string,
549
- foundationId: string
550
- ): Promise<void> {
551
- const cases = await this.listCases();
552
- for (const c of cases) {
553
- const pressures = await this.getPressureEvents(c.id);
554
- const idx = pressures.findIndex((p) => p.id === pressureId);
555
- if (idx !== -1) {
556
- pressures[idx].promoted_to_foundation = foundationId;
557
- await this.writeYaml(
558
- join(this._basePath, "cases", c.id, "pressures.yaml"),
559
- { events: pressures }
560
- );
561
- return;
562
- }
563
- }
564
- }
565
-
566
- // ============================================================================
567
- // SUGGEST REVIEW (retrospective)
568
- // ============================================================================
569
-
570
- async suggestReview(): Promise<{
571
- foundation_candidates: Array<{
572
- theme: string;
573
- pressure_events: string[];
574
- remember_lines: string[];
575
- shared_tags: string[];
576
- }>;
577
- blocking_forgetting: Array<{
578
- case_id: string;
579
- title: string;
580
- unpromoted_pe_count: number;
581
- pe_ids: string[];
582
- }>;
583
- high_regret_no_pe: Array<{
584
- case_id: string;
585
- title: string;
586
- regret: string;
587
- }>;
588
- summary: string;
589
- }> {
590
- const cases = await this.listCases();
591
- const allUnpromoted: PressureEvent[] = [];
592
- const blockingForgetting: Array<{
593
- case_id: string;
594
- title: string;
595
- unpromoted_pe_count: number;
596
- pe_ids: string[];
597
- }> = [];
598
- const highRegretNoPE: Array<{
599
- case_id: string;
600
- title: string;
601
- regret: string;
602
- }> = [];
603
-
604
- for (const c of cases) {
605
- if (c.status !== "COMPLETED") continue;
606
-
607
- const pressures = await this.getPressureEvents(c.id);
608
- const unpromoted = pressures.filter(p => !p.promoted_to_foundation);
609
- const regret = c.signals?.outcome?.regret;
610
-
611
- // Collect all unpromoted PEs for pattern detection
612
- allUnpromoted.push(...unpromoted);
613
-
614
- // Cases blocking forgetting: regret 0 but unpromoted PEs remain
615
- if (regret === "0" && unpromoted.length > 0) {
616
- blockingForgetting.push({
617
- case_id: c.id,
618
- title: c.title,
619
- unpromoted_pe_count: unpromoted.length,
620
- pe_ids: unpromoted.map(p => p.id),
621
- });
622
- }
623
-
624
- // High regret with no PEs: possible missed captures
625
- if (regret && parseInt(regret) >= 2 && pressures.length === 0) {
626
- highRegretNoPE.push({
627
- case_id: c.id,
628
- title: c.title,
629
- regret,
630
- });
631
- }
632
- }
633
-
634
- // Group unpromoted PEs by shared context_tags
635
- const tagGroups = new Map<string, PressureEvent[]>();
636
- for (const pe of allUnpromoted) {
637
- const tags = pe.context_tags ?? [];
638
- for (const tag of tags) {
639
- if (!tagGroups.has(tag)) tagGroups.set(tag, []);
640
- tagGroups.get(tag)!.push(pe);
641
- }
642
- }
643
-
644
- // Find clusters of 2+ PEs with shared tags as foundation candidates
645
- const foundationCandidates: Array<{
646
- theme: string;
647
- pressure_events: string[];
648
- remember_lines: string[];
649
- shared_tags: string[];
650
- }> = [];
651
- const seenPECombos = new Set<string>();
652
-
653
- for (const [tag, pes] of tagGroups) {
654
- if (pes.length < 2) continue;
655
- const peIds = pes.map(p => p.id).sort().join(",");
656
- if (seenPECombos.has(peIds)) continue;
657
- seenPECombos.add(peIds);
658
-
659
- // Find all shared tags across this group
660
- const sharedTags = [...new Set(
661
- pes.flatMap(p => p.context_tags ?? [])
662
- )].filter(t =>
663
- pes.every(p => p.context_tags?.includes(t))
664
- );
665
-
666
- foundationCandidates.push({
667
- theme: tag,
668
- pressure_events: pes.map(p => p.id),
669
- remember_lines: pes.map(p => `${p.id}: ${p.remember}`),
670
- shared_tags: sharedTags,
671
- });
672
- }
673
-
674
- // Build summary
675
- const parts: string[] = [];
676
- if (foundationCandidates.length > 0) {
677
- parts.push(`${foundationCandidates.length} foundation candidate(s) from clustered PEs`);
678
- }
679
- if (blockingForgetting.length > 0) {
680
- parts.push(`${blockingForgetting.length} case(s) blocking forgetting (regret 0 but unpromoted PEs)`);
681
- }
682
- if (highRegretNoPE.length > 0) {
683
- parts.push(`${highRegretNoPE.length} high-regret case(s) with no PEs (possible missed captures)`);
684
- }
685
- if (parts.length === 0) {
686
- parts.push("Nothing to review. All learnings extracted or cases forgotten.");
687
- }
688
-
689
- return {
690
- foundation_candidates: foundationCandidates,
691
- blocking_forgetting: blockingForgetting,
692
- high_regret_no_pe: highRegretNoPE,
693
- summary: parts.join(". ") + ".",
694
- };
695
- }
696
-
697
- // ============================================================================
698
- // POLICY CHECK
699
- // ============================================================================
700
-
701
- checkPolicy(signals: {
702
- risk_level?: string;
703
- reversibility?: string;
704
- repo_scope?: string;
705
- affected_surface?: string[];
706
- uncertainty?: string;
707
- }): {
708
- require_options_comparison: boolean;
709
- validation_level: string;
710
- warnings: string[];
711
- } {
712
- const warnings: string[] = [];
713
- let requireComparison = false;
714
- let validationLevel = "BASIC";
715
-
716
- // Check if options comparison required
717
- if (
718
- signals.reversibility === "HARD" ||
719
- signals.risk_level === "HIGH" ||
720
- signals.repo_scope === "CROSS_REPO" ||
721
- signals.affected_surface?.includes("CORE_DOMAIN") ||
722
- signals.affected_surface?.includes("DATA_MODEL") ||
723
- signals.affected_surface?.includes("SECURITY_BOUNDARY") ||
724
- signals.uncertainty === "HIGH"
725
- ) {
726
- requireComparison = true;
727
- warnings.push(
728
- "Policy requires MINIMAL vs ROBUST options comparison before implementation."
729
- );
730
- }
731
-
732
- // Determine validation level
733
- if (
734
- signals.risk_level === "HIGH" ||
735
- signals.reversibility === "HARD" ||
736
- signals.affected_surface?.includes("SECURITY_BOUNDARY") ||
737
- signals.affected_surface?.includes("INFRA_DEPLOY") ||
738
- signals.affected_surface?.includes("PERFORMANCE_CRITICAL") ||
739
- signals.affected_surface?.includes("DATA_MODEL") ||
740
- signals.uncertainty === "HIGH"
741
- ) {
742
- validationLevel = "STRICT";
743
- } else if (
744
- signals.risk_level === "MEDIUM" ||
745
- signals.repo_scope === "CROSS_REPO" ||
746
- signals.affected_surface?.includes("INTEGRATION") ||
747
- signals.affected_surface?.includes("CORE_DOMAIN")
748
- ) {
749
- validationLevel = "STANDARD";
750
- }
751
-
752
- return {
753
- require_options_comparison: requireComparison,
754
- validation_level: validationLevel,
755
- warnings,
756
- };
757
- }
758
-
759
- // ============================================================================
760
- // CONTEXT (for LLM)
761
- // ============================================================================
762
-
763
- async getContext(): Promise<{
764
- project: string;
765
- active_case: Case | null;
766
- recent_pressures: PressureEvent[];
767
- relevant_foundations: Foundation[];
768
- }> {
769
- const config = await this.getConfig();
770
- const activeCase = this.activeCase
771
- ? await this.getCase(this.activeCase)
772
- : null;
773
-
774
- let recentPressures: PressureEvent[] = [];
775
- if (activeCase) {
776
- recentPressures = await this.getPressureEvents(activeCase.id);
777
- }
778
-
779
- const foundations = await this.getFoundations();
780
-
781
- return {
782
- project: config.project,
783
- active_case: activeCase,
784
- recent_pressures: recentPressures.slice(-5), // Last 5
785
- relevant_foundations: foundations.filter((f) => f.confidence >= 1),
786
- };
787
- }
788
-
789
- // ============================================================================
790
- // HELPERS
791
- // ============================================================================
792
-
793
- private async ensureDir(path: string): Promise<void> {
794
- if (!existsSync(path)) {
795
- await mkdir(path, { recursive: true });
796
- }
797
- }
798
-
799
- private async readYamlValidated<Schema extends z.ZodTypeAny>(
800
- path: string,
801
- schema: Schema,
802
- label: string
803
- ): Promise<z.output<Schema>> {
804
- const content = await readFile(path, "utf-8");
805
- const parsed = YAML.parse(content);
806
- const result = schema.safeParse(parsed);
807
- if (!result.success) {
808
- const issues = result.error.issues
809
- .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
810
- .join("; ");
811
- throw new Error(`Invalid ${label} in ${path}: ${issues}`);
812
- }
813
- return result.data;
814
- }
815
-
816
- private async writeYaml(path: string, data: unknown): Promise<void> {
817
- await this.ensureDir(dirname(path));
818
- const content = YAML.stringify(data, { lineWidth: 0 });
819
- await writeFile(path, content, "utf-8");
820
- }
821
-
822
- private slugify(text: string): string {
823
- return text
824
- .toLowerCase()
825
- .replace(/[^a-z0-9]+/g, "-")
826
- .replace(/^-+|-+$/g, "")
827
- .slice(0, 50);
828
- }
829
- }