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/AGENTS.md +69 -0
- package/README.md +38 -3
- package/package.json +11 -1
- package/templates/AGENTS.md +38 -0
- package/templates/claude-desktop-mcp-config.json +11 -0
- package/src/hierarchical-storage.ts +0 -505
- package/src/index.ts +0 -932
- package/src/schemas.ts +0 -296
- package/src/storage.ts +0 -829
- package/test/hierarchical-storage.test.ts +0 -223
- package/test/storage.test.ts +0 -551
- package/tsconfig.json +0 -19
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
|
-
}
|