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.
@@ -1,505 +0,0 @@
1
- import { existsSync } from "fs";
2
- import { join, dirname } from "path";
3
- import { homedir } from "os";
4
- import { DecisionOSStorage } from "./storage.js";
5
- import type { Foundation, Case, PressureEvent, ProjectConfig } from "./schemas.js";
6
-
7
- /**
8
- * Extended Foundation type with source layer information for conflict visibility
9
- */
10
- export interface FoundationWithSource extends Foundation {
11
- _source_layer: string; // Path to the .decision-os folder this came from
12
- _source_scope: "GLOBAL" | "PROJECT";
13
- }
14
-
15
- /**
16
- * Conflict information when foundations overlap across scopes
17
- */
18
- export interface FoundationConflict {
19
- title: string;
20
- global_foundation: Foundation;
21
- project_foundation: Foundation;
22
- recommendation: string;
23
- }
24
-
25
- /**
26
- * Hierarchical storage layer for Decision OS.
27
- * Manages GLOBAL → PROJECT cascading scope model.
28
- *
29
- * Resolution order: PROJECT wins over GLOBAL for conflicts.
30
- * Global foundations are recommendations, not rules.
31
- */
32
- export class HierarchicalDecisionOSStorage {
33
- private layers: DecisionOSStorage[] = []; // [project, global] - nearest first
34
- private projectLayer: DecisionOSStorage;
35
- private globalLayer: DecisionOSStorage | null = null;
36
-
37
- constructor(workspacePath: string) {
38
- this.layers = this.discoverLayers(workspacePath);
39
-
40
- if (this.layers.length === 0) {
41
- throw new Error(`No .decision-os found starting from ${workspacePath}`);
42
- }
43
-
44
- this.projectLayer = this.layers[0];
45
- this.globalLayer = this.layers.length > 1 ? this.layers[this.layers.length - 1] : null;
46
- }
47
-
48
- /**
49
- * Discover all .decision-os layers by walking up from workspace path.
50
- * Returns [nearest, ..., global] order.
51
- */
52
- private discoverLayers(startPath: string): DecisionOSStorage[] {
53
- const layers: DecisionOSStorage[] = [];
54
- const seenPaths = new Set<string>();
55
-
56
- // First, check if startPath itself is or contains .decision-os
57
- let currentPath = startPath;
58
-
59
- // If startPath ends with .decision-os, use its parent as start
60
- if (currentPath.endsWith(".decision-os")) {
61
- currentPath = dirname(currentPath);
62
- }
63
-
64
- // Walk up directory tree looking for .decision-os folders
65
- while (currentPath !== "/" && currentPath !== dirname(currentPath)) {
66
- const dosPath = join(currentPath, ".decision-os");
67
-
68
- if (existsSync(dosPath) && !seenPaths.has(dosPath)) {
69
- seenPaths.add(dosPath);
70
- layers.push(new DecisionOSStorage(dosPath));
71
- }
72
-
73
- currentPath = dirname(currentPath);
74
- }
75
-
76
- // Always include global ~/.decision-os if it exists
77
- const globalPath = join(homedir(), ".decision-os");
78
- if (existsSync(globalPath) && !seenPaths.has(globalPath)) {
79
- seenPaths.add(globalPath);
80
- layers.push(new DecisionOSStorage(globalPath));
81
- }
82
-
83
- return layers;
84
- }
85
-
86
- /**
87
- * Initialize all layers
88
- */
89
- async initialize(): Promise<void> {
90
- for (const layer of this.layers) {
91
- await layer.initialize();
92
- }
93
- }
94
-
95
- /**
96
- * Get the base path of the project layer
97
- */
98
- getProjectPath(): string {
99
- return this.projectLayer.basePath;
100
- }
101
-
102
- /**
103
- * Get the base path of the global layer (if exists)
104
- */
105
- getGlobalPath(): string | null {
106
- return this.globalLayer ? this.globalLayer.basePath : null;
107
- }
108
-
109
- // ============================================================================
110
- // CONFIG
111
- // ============================================================================
112
-
113
- async getConfig(): Promise<ProjectConfig> {
114
- return this.projectLayer.getConfig();
115
- }
116
-
117
- async updateConfig(config: Partial<ProjectConfig>): Promise<void> {
118
- return this.projectLayer.updateConfig(config);
119
- }
120
-
121
- // ============================================================================
122
- // ACTIVE CASE (project-local)
123
- // ============================================================================
124
-
125
- getActiveCase(): string | null {
126
- return this.projectLayer.getActiveCase();
127
- }
128
-
129
- async setActiveCase(caseId: string | null): Promise<void> {
130
- await this.projectLayer.setActiveCase(caseId);
131
- }
132
-
133
- // ============================================================================
134
- // CASES (project-local)
135
- // ============================================================================
136
-
137
- async listCases(): Promise<Case[]> {
138
- return this.projectLayer.listCases();
139
- }
140
-
141
- async getCase(caseId: string): Promise<Case | null> {
142
- return this.projectLayer.getCase(caseId);
143
- }
144
-
145
- async createCase(input: Parameters<DecisionOSStorage["createCase"]>[0]): Promise<Case> {
146
- return this.projectLayer.createCase(input);
147
- }
148
-
149
- async updateCase(caseId: string, updates: Partial<Case>): Promise<Case> {
150
- return this.projectLayer.updateCase(caseId, updates);
151
- }
152
-
153
- async closeCase(
154
- caseId: string,
155
- outcome: Parameters<DecisionOSStorage["closeCase"]>[1]
156
- ): Promise<{ case: Case; forgotten: boolean }> {
157
- return this.projectLayer.closeCase(caseId, outcome);
158
- }
159
-
160
- // ============================================================================
161
- // PRESSURE EVENTS (project-local)
162
- // ============================================================================
163
-
164
- async getPressureEvents(caseId: string): Promise<PressureEvent[]> {
165
- return this.projectLayer.getPressureEvents(caseId);
166
- }
167
-
168
- async logPressure(input: Parameters<DecisionOSStorage["logPressure"]>[0]): Promise<PressureEvent> {
169
- return this.projectLayer.logPressure(input);
170
- }
171
-
172
- async searchPressures(query: string): Promise<PressureEvent[]> {
173
- // Search only in project layer (per user's answer to Q3)
174
- return this.projectLayer.searchPressures(query);
175
- }
176
-
177
- // ============================================================================
178
- // FOUNDATIONS (merged across layers)
179
- // ============================================================================
180
-
181
- /**
182
- * Get merged foundations from all layers.
183
- * Project foundations take precedence over global on title conflict.
184
- * Returns foundations annotated with source information.
185
- */
186
- async getFoundations(filters?: {
187
- context_tags?: string[];
188
- min_confidence?: number;
189
- }): Promise<FoundationWithSource[]> {
190
- const seen = new Map<string, FoundationWithSource>(); // Track by title
191
- const merged: FoundationWithSource[] = [];
192
-
193
- for (const layer of this.layers) {
194
- const foundations = await layer.getFoundations(filters);
195
- const layerPath = layer.basePath;
196
- const isGlobal = layerPath === join(homedir(), ".decision-os");
197
-
198
- for (const f of foundations) {
199
- const annotated: FoundationWithSource = {
200
- ...f,
201
- _source_layer: layerPath,
202
- _source_scope: isGlobal ? "GLOBAL" : "PROJECT",
203
- };
204
-
205
- if (!seen.has(f.title)) {
206
- // First occurrence (nearest layer) wins
207
- seen.set(f.title, annotated);
208
- merged.push(annotated);
209
- }
210
- // If already seen, project wins over global (first layer is project)
211
- }
212
- }
213
-
214
- return merged;
215
- }
216
-
217
- /**
218
- * Detect conflicts where project and global have foundations with same title
219
- * or overlapping context_tags.
220
- */
221
- async detectConflicts(): Promise<FoundationConflict[]> {
222
- if (!this.globalLayer) return [];
223
-
224
- const projectFoundations = await this.projectLayer.getFoundations();
225
- const globalFoundations = await this.globalLayer.getFoundations();
226
- const conflicts: FoundationConflict[] = [];
227
-
228
- for (const pf of projectFoundations) {
229
- // Check for title match
230
- const titleMatch = globalFoundations.find(
231
- (gf) => gf.title.toLowerCase() === pf.title.toLowerCase()
232
- );
233
-
234
- if (titleMatch) {
235
- conflicts.push({
236
- title: pf.title,
237
- global_foundation: titleMatch,
238
- project_foundation: pf,
239
- recommendation: `Project foundation "${pf.title}" shadows global foundation. ` +
240
- `Project version will be used. Consider if global should be updated or removed.`,
241
- });
242
- continue;
243
- }
244
-
245
- // Check for overlapping context_tags
246
- for (const gf of globalFoundations) {
247
- const overlappingTags = pf.context_tags.filter((t) =>
248
- gf.context_tags.includes(t)
249
- );
250
-
251
- if (overlappingTags.length > 0 && pf.title !== gf.title) {
252
- // Different titles but same context - potential friction
253
- const pfBehavior = pf.default_behavior.toLowerCase();
254
- const gfBehavior = gf.default_behavior.toLowerCase();
255
-
256
- // Simple heuristic: if behaviors seem contradictory
257
- const contradictory =
258
- (pfBehavior.includes("always") && gfBehavior.includes("never")) ||
259
- (pfBehavior.includes("never") && gfBehavior.includes("always")) ||
260
- (pfBehavior.includes("prefer") && gfBehavior.includes("avoid"));
261
-
262
- if (contradictory) {
263
- conflicts.push({
264
- title: `${pf.title} vs ${gf.title}`,
265
- global_foundation: gf,
266
- project_foundation: pf,
267
- recommendation: `Potential conflict: Both apply to [${overlappingTags.join(", ")}] ` +
268
- `but may have contradictory guidance. Review and clarify.`,
269
- });
270
- }
271
- }
272
- }
273
- }
274
-
275
- return conflicts;
276
- }
277
-
278
- /**
279
- * Promote pressure events to a foundation in the specified scope.
280
- */
281
- async promoteToFoundation(input: {
282
- title: string;
283
- default_behavior: string;
284
- context_tags: string[];
285
- counter_contexts?: string[];
286
- source_pressures: string[];
287
- exit_criteria?: string;
288
- scope?: "GLOBAL" | "PROJECT";
289
- origin_project?: string;
290
- }): Promise<Foundation> {
291
- const scope = input.scope ?? "PROJECT";
292
- const config = await this.projectLayer.getConfig();
293
- const originProject = input.origin_project ?? config.project;
294
-
295
- const targetLayer = scope === "GLOBAL" && this.globalLayer
296
- ? this.globalLayer
297
- : this.projectLayer;
298
-
299
- // Create foundation with scope and origin metadata
300
- const foundation = await targetLayer.promoteToFoundation({
301
- title: input.title,
302
- default_behavior: input.default_behavior,
303
- context_tags: input.context_tags,
304
- counter_contexts: input.counter_contexts,
305
- source_pressures: input.source_pressures,
306
- exit_criteria: input.exit_criteria,
307
- scope,
308
- origin_project: originProject,
309
- });
310
-
311
- return foundation;
312
- }
313
-
314
- /**
315
- * Elevate a project foundation to global scope.
316
- * Creates a new foundation in global with GF- prefix.
317
- */
318
- async elevateFoundation(input: {
319
- foundation_id: string;
320
- reason?: string;
321
- }): Promise<Foundation> {
322
- if (!this.globalLayer) {
323
- throw new Error(
324
- "No global .decision-os found at ~/.decision-os. " +
325
- "Create it first with: mkdir -p ~/.decision-os/defaults && " +
326
- "echo 'foundations: []' > ~/.decision-os/defaults/foundations.yaml"
327
- );
328
- }
329
-
330
- // Find the foundation in project layer
331
- const projectFoundations = await this.projectLayer.getFoundations();
332
- const foundation = projectFoundations.find((f) => f.id === input.foundation_id);
333
-
334
- if (!foundation) {
335
- throw new Error(`Foundation not found in project: ${input.foundation_id}`);
336
- }
337
-
338
- const config = await this.projectLayer.getConfig();
339
-
340
- // Create in global with GF- prefix (scope: GLOBAL triggers the prefix)
341
- const globalFoundation = await this.globalLayer.promoteToFoundation({
342
- title: foundation.title,
343
- default_behavior: foundation.default_behavior +
344
- (input.reason ? `\n\n[Elevated from ${config.project}: ${input.reason}]` : ""),
345
- context_tags: foundation.context_tags,
346
- counter_contexts: foundation.counter_contexts,
347
- source_pressures: foundation.source_pressures,
348
- exit_criteria: foundation.exit_criteria,
349
- scope: "GLOBAL",
350
- origin_project: config.project,
351
- });
352
-
353
- // Retire the project copy — knowledge now lives in global
354
- await this.projectLayer.removeFoundation(input.foundation_id);
355
- console.error(
356
- `Retired project foundation ${input.foundation_id} — elevated to ${globalFoundation.id}`
357
- );
358
-
359
- return globalFoundation;
360
- }
361
-
362
- /**
363
- * Cross-validate that a global foundation applies in the current project.
364
- * Increases confidence and adds project to validated_in list.
365
- */
366
- async validateFoundation(input: {
367
- foundation_id: string;
368
- validation_notes?: string;
369
- }): Promise<Foundation> {
370
- // Find the foundation across all layers
371
- const allFoundations = await this.getFoundations();
372
- const foundation = allFoundations.find((f) => f.id === input.foundation_id);
373
-
374
- if (!foundation) {
375
- throw new Error(`Foundation not found: ${input.foundation_id}`);
376
- }
377
-
378
- const config = await this.projectLayer.getConfig();
379
-
380
- // Update validated_in
381
- const validatedIn = [...(foundation.validated_in ?? [])];
382
- if (!validatedIn.includes(config.project)) {
383
- validatedIn.push(config.project);
384
- }
385
-
386
- // Increase confidence if validated in multiple projects (3+ validations = confidence boost)
387
- let newConfidence = foundation.confidence;
388
- if (validatedIn.length >= 3 && newConfidence < 3) {
389
- newConfidence = Math.min(3, newConfidence + 1) as 0 | 1 | 2 | 3;
390
- }
391
-
392
- // Update the foundation in its source layer
393
- const targetLayer = foundation._source_scope === "GLOBAL" && this.globalLayer
394
- ? this.globalLayer
395
- : this.projectLayer;
396
-
397
- // Use the updateFoundation method to persist
398
- const updatedFoundation = await targetLayer.updateFoundation(input.foundation_id, {
399
- validated_in: validatedIn,
400
- confidence: newConfidence,
401
- });
402
-
403
- return updatedFoundation;
404
- }
405
-
406
- // ============================================================================
407
- // SUGGEST REVIEW (delegates to project layer)
408
- // ============================================================================
409
-
410
- async suggestReview(): ReturnType<DecisionOSStorage["suggestReview"]> {
411
- return this.projectLayer.suggestReview();
412
- }
413
-
414
- // ============================================================================
415
- // POLICY CHECK (delegates to project layer)
416
- // ============================================================================
417
-
418
- checkPolicy(signals: Parameters<DecisionOSStorage["checkPolicy"]>[0]): ReturnType<DecisionOSStorage["checkPolicy"]> {
419
- return this.projectLayer.checkPolicy(signals);
420
- }
421
-
422
- // ============================================================================
423
- // CONTEXT (merged)
424
- // ============================================================================
425
-
426
- async getContext(): Promise<{
427
- project: string;
428
- active_case: Case | null;
429
- recent_pressures: PressureEvent[];
430
- relevant_foundations: FoundationWithSource[];
431
- conflicts: FoundationConflict[];
432
- layers: string[];
433
- }> {
434
- const config = await this.getConfig();
435
- const activeCase = this.getActiveCase()
436
- ? await this.getCase(this.getActiveCase()!)
437
- : null;
438
-
439
- let recentPressures: PressureEvent[] = [];
440
- if (activeCase) {
441
- recentPressures = await this.getPressureEvents(activeCase.id);
442
- }
443
-
444
- const allFoundations = await this.getFoundations();
445
- const active = allFoundations.filter((f) => f.confidence >= 1);
446
- const conflicts = await this.detectConflicts();
447
-
448
- // Rank foundations by relevance to active case
449
- const ranked = this.rankFoundationsByRelevance(active, activeCase);
450
-
451
- return {
452
- project: config.project,
453
- active_case: activeCase,
454
- recent_pressures: recentPressures.slice(-5),
455
- relevant_foundations: ranked,
456
- conflicts,
457
- layers: this.layers.map((l) => l["basePath"]),
458
- };
459
- }
460
-
461
- /**
462
- * Rank foundations by relevance to the active case.
463
- * Foundations matching the case's affected_surface or touched_areas
464
- * are marked "directly_relevant" and sorted first.
465
- */
466
- private rankFoundationsByRelevance(
467
- foundations: FoundationWithSource[],
468
- activeCase: Case | null
469
- ): FoundationWithSource[] {
470
- if (!activeCase) return foundations;
471
-
472
- // Collect case context tags from signals and touched_areas
473
- const caseTags = new Set<string>();
474
- const surfaces = activeCase.signals?.context?.affected_surface ?? [];
475
- for (const s of surfaces) caseTags.add(s.toUpperCase());
476
- const areas = activeCase.context?.touched_areas ?? [];
477
- for (const a of areas) caseTags.add(a.toUpperCase());
478
-
479
- if (caseTags.size === 0) return foundations;
480
-
481
- // Score each foundation by tag overlap
482
- const scored = foundations.map((f) => {
483
- const overlap = f.context_tags.filter((t) =>
484
- caseTags.has(t.toUpperCase())
485
- ).length;
486
- return { foundation: f, overlap };
487
- });
488
-
489
- // Sort: directly relevant first, then general
490
- scored.sort((a, b) => b.overlap - a.overlap);
491
-
492
- return scored.map(({ foundation, overlap }) => ({
493
- ...foundation,
494
- _relevance: overlap > 0 ? "directly_relevant" as const : "general" as const,
495
- }));
496
- }
497
- }
498
-
499
- /**
500
- * Create a hierarchical storage instance, discovering layers from workspace path.
501
- * Falls back to creating project-only storage if no hierarchy found.
502
- */
503
- export function createHierarchicalStorage(workspacePath: string): HierarchicalDecisionOSStorage {
504
- return new HierarchicalDecisionOSStorage(workspacePath);
505
- }