chainlesschain 0.37.12 → 0.40.1
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/package.json +3 -2
- package/src/commands/agent.js +7 -1
- package/src/commands/ask.js +24 -9
- package/src/commands/chat.js +7 -1
- package/src/commands/cli-anything.js +266 -0
- package/src/commands/compliance.js +216 -0
- package/src/commands/dao.js +312 -0
- package/src/commands/dlp.js +278 -0
- package/src/commands/evomap.js +558 -0
- package/src/commands/hardening.js +230 -0
- package/src/commands/matrix.js +168 -0
- package/src/commands/nostr.js +185 -0
- package/src/commands/pqc.js +162 -0
- package/src/commands/scim.js +218 -0
- package/src/commands/serve.js +109 -0
- package/src/commands/siem.js +156 -0
- package/src/commands/social.js +480 -0
- package/src/commands/terraform.js +148 -0
- package/src/constants.js +1 -0
- package/src/index.js +60 -0
- package/src/lib/autonomous-agent.js +487 -0
- package/src/lib/cli-anything-bridge.js +379 -0
- package/src/lib/cli-context-engineering.js +472 -0
- package/src/lib/compliance-manager.js +290 -0
- package/src/lib/content-recommender.js +205 -0
- package/src/lib/dao-governance.js +296 -0
- package/src/lib/dlp-engine.js +304 -0
- package/src/lib/evomap-client.js +135 -0
- package/src/lib/evomap-federation.js +240 -0
- package/src/lib/evomap-governance.js +250 -0
- package/src/lib/evomap-manager.js +227 -0
- package/src/lib/git-integration.js +1 -1
- package/src/lib/hardening-manager.js +275 -0
- package/src/lib/llm-providers.js +14 -1
- package/src/lib/matrix-bridge.js +196 -0
- package/src/lib/nostr-bridge.js +195 -0
- package/src/lib/permanent-memory.js +370 -0
- package/src/lib/plan-mode.js +211 -0
- package/src/lib/pqc-manager.js +196 -0
- package/src/lib/scim-manager.js +212 -0
- package/src/lib/session-manager.js +38 -0
- package/src/lib/siem-exporter.js +137 -0
- package/src/lib/social-manager.js +283 -0
- package/src/lib/task-model-selector.js +232 -0
- package/src/lib/terraform-manager.js +201 -0
- package/src/lib/ws-server.js +474 -0
- package/src/repl/agent-repl.js +796 -41
- package/src/repl/chat-repl.js +14 -6
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Permanent Memory — cross-session persistent memory with Daily Notes,
|
|
3
|
+
* MEMORY.md knowledge base, and BM25 hybrid search.
|
|
4
|
+
*
|
|
5
|
+
* Graceful degradation: works without DB (file-only mode).
|
|
6
|
+
* Keeps CLI < 2MB — uses BM25 for search, no heavy vector dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { BM25Search } from "./bm25-search.js";
|
|
12
|
+
|
|
13
|
+
// Exported for test injection
|
|
14
|
+
export const _deps = {
|
|
15
|
+
fs,
|
|
16
|
+
path,
|
|
17
|
+
BM25Search,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class CLIPermanentMemory {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {object|null} options.db - Database instance (null for file-only mode)
|
|
24
|
+
* @param {string} options.memoryDir - Directory for memory files
|
|
25
|
+
*/
|
|
26
|
+
constructor({ db, memoryDir } = {}) {
|
|
27
|
+
this.db = db || null;
|
|
28
|
+
this.memoryDir = memoryDir || "";
|
|
29
|
+
this._bm25 = null;
|
|
30
|
+
this._initialized = false;
|
|
31
|
+
this._memoryFileContent = "";
|
|
32
|
+
this._dailyNotes = [];
|
|
33
|
+
this._dbEntries = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initialize: create tables, load MEMORY.md, build BM25 index.
|
|
38
|
+
*/
|
|
39
|
+
initialize() {
|
|
40
|
+
if (this._initialized) return;
|
|
41
|
+
this._initialized = true;
|
|
42
|
+
|
|
43
|
+
// Ensure directories
|
|
44
|
+
if (this.memoryDir) {
|
|
45
|
+
try {
|
|
46
|
+
const dailyDir = _deps.path.join(this.memoryDir, "daily");
|
|
47
|
+
if (!_deps.fs.existsSync(this.memoryDir)) {
|
|
48
|
+
_deps.fs.mkdirSync(this.memoryDir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
if (!_deps.fs.existsSync(dailyDir)) {
|
|
51
|
+
_deps.fs.mkdirSync(dailyDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
} catch (_err) {
|
|
54
|
+
// Directory creation failed — continue in degraded mode
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Create DB table
|
|
59
|
+
if (this.db) {
|
|
60
|
+
try {
|
|
61
|
+
this.db.exec(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS permanent_memory (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
content TEXT NOT NULL,
|
|
65
|
+
source TEXT DEFAULT 'auto',
|
|
66
|
+
category TEXT DEFAULT 'general',
|
|
67
|
+
importance REAL DEFAULT 0.5,
|
|
68
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
69
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
70
|
+
)
|
|
71
|
+
`);
|
|
72
|
+
} catch (_err) {
|
|
73
|
+
// Table creation failed — continue without DB
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Load MEMORY.md
|
|
78
|
+
this._loadMemoryFile();
|
|
79
|
+
|
|
80
|
+
// Load daily notes (recent 7 days)
|
|
81
|
+
this._loadRecentDailyNotes();
|
|
82
|
+
|
|
83
|
+
// Load DB entries
|
|
84
|
+
this._loadDbEntries();
|
|
85
|
+
|
|
86
|
+
// Build BM25 index
|
|
87
|
+
this._buildIndex();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Append content to today's daily note.
|
|
92
|
+
*/
|
|
93
|
+
appendDailyNote(content) {
|
|
94
|
+
if (!this.memoryDir || !content) return null;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
98
|
+
const dailyDir = _deps.path.join(this.memoryDir, "daily");
|
|
99
|
+
const filePath = _deps.path.join(dailyDir, `${today}.md`);
|
|
100
|
+
const timestamp = new Date().toISOString().slice(11, 19);
|
|
101
|
+
const entry = `\n## ${timestamp}\n\n${content}\n`;
|
|
102
|
+
|
|
103
|
+
if (_deps.fs.existsSync(filePath)) {
|
|
104
|
+
_deps.fs.appendFileSync(filePath, entry, "utf-8");
|
|
105
|
+
} else {
|
|
106
|
+
_deps.fs.writeFileSync(
|
|
107
|
+
filePath,
|
|
108
|
+
`# Daily Note: ${today}\n${entry}`,
|
|
109
|
+
"utf-8",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Rebuild index
|
|
114
|
+
this._loadRecentDailyNotes();
|
|
115
|
+
this._buildIndex();
|
|
116
|
+
|
|
117
|
+
return { date: today, path: filePath };
|
|
118
|
+
} catch (_err) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Update a section of MEMORY.md.
|
|
125
|
+
* If section exists, replaces it. Otherwise appends.
|
|
126
|
+
*/
|
|
127
|
+
updateMemoryFile(section, content) {
|
|
128
|
+
if (!this.memoryDir) return null;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const filePath = _deps.path.join(this.memoryDir, "MEMORY.md");
|
|
132
|
+
let existing = "";
|
|
133
|
+
if (_deps.fs.existsSync(filePath)) {
|
|
134
|
+
existing = _deps.fs.readFileSync(filePath, "utf-8");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sectionHeader = `## ${section}`;
|
|
138
|
+
const sectionIdx = existing.indexOf(sectionHeader);
|
|
139
|
+
|
|
140
|
+
if (sectionIdx >= 0) {
|
|
141
|
+
// Find next ## or end of file
|
|
142
|
+
const afterHeader = existing.indexOf(
|
|
143
|
+
"\n## ",
|
|
144
|
+
sectionIdx + sectionHeader.length,
|
|
145
|
+
);
|
|
146
|
+
const endIdx = afterHeader >= 0 ? afterHeader : existing.length;
|
|
147
|
+
const newContent =
|
|
148
|
+
existing.slice(0, sectionIdx) +
|
|
149
|
+
`${sectionHeader}\n\n${content}\n` +
|
|
150
|
+
existing.slice(endIdx);
|
|
151
|
+
_deps.fs.writeFileSync(filePath, newContent, "utf-8");
|
|
152
|
+
} else {
|
|
153
|
+
// Append new section
|
|
154
|
+
const append = existing
|
|
155
|
+
? `\n${sectionHeader}\n\n${content}\n`
|
|
156
|
+
: `# Memory\n\n${sectionHeader}\n\n${content}\n`;
|
|
157
|
+
_deps.fs.writeFileSync(filePath, existing + append, "utf-8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this._loadMemoryFile();
|
|
161
|
+
this._buildIndex();
|
|
162
|
+
return { path: filePath };
|
|
163
|
+
} catch (_err) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* BM25 hybrid search across all memory sources.
|
|
170
|
+
*/
|
|
171
|
+
hybridSearch(query, { topK = 5 } = {}) {
|
|
172
|
+
if (!this._bm25 || !query) return [];
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
return this._bm25.search(query, { topK, threshold: 0.1 });
|
|
176
|
+
} catch (_err) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get relevant context for a query (used by CLIContextEngineering).
|
|
183
|
+
* Returns array of { content, source, score }.
|
|
184
|
+
*/
|
|
185
|
+
getRelevantContext(query, limit = 3) {
|
|
186
|
+
if (!query) return [];
|
|
187
|
+
|
|
188
|
+
this.initialize();
|
|
189
|
+
const results = this.hybridSearch(query, { topK: limit });
|
|
190
|
+
return results.map((r) => ({
|
|
191
|
+
content: (r.doc.content || "").substring(0, 300),
|
|
192
|
+
source: r.doc.source || "memory",
|
|
193
|
+
score: r.score,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Auto-summarize session messages and store key facts.
|
|
199
|
+
* Called at session end.
|
|
200
|
+
*/
|
|
201
|
+
autoSummarize(sessionMessages) {
|
|
202
|
+
if (!sessionMessages || sessionMessages.length < 4) return [];
|
|
203
|
+
|
|
204
|
+
const facts = [];
|
|
205
|
+
|
|
206
|
+
// Extract tool usage patterns
|
|
207
|
+
const toolUses = sessionMessages.filter(
|
|
208
|
+
(m) => m.role === "tool" || m.tool_calls,
|
|
209
|
+
);
|
|
210
|
+
if (toolUses.length > 0) {
|
|
211
|
+
const toolNames = new Set();
|
|
212
|
+
for (const m of sessionMessages) {
|
|
213
|
+
if (m.tool_calls) {
|
|
214
|
+
for (const tc of m.tool_calls) {
|
|
215
|
+
toolNames.add(tc.function?.name || tc.name || "unknown");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (toolNames.size > 0) {
|
|
220
|
+
facts.push(`Tools used: ${[...toolNames].join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Extract user questions/topics
|
|
225
|
+
const userMsgs = sessionMessages.filter((m) => m.role === "user");
|
|
226
|
+
if (userMsgs.length > 0) {
|
|
227
|
+
const topics = userMsgs
|
|
228
|
+
.slice(0, 3)
|
|
229
|
+
.map((m) => (m.content || "").substring(0, 60).replace(/\n/g, " "))
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
if (topics.length > 0) {
|
|
232
|
+
facts.push(`Topics discussed: ${topics.join("; ")}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Store facts
|
|
237
|
+
for (const fact of facts) {
|
|
238
|
+
this._storeEntry(fact, "auto-summary");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Append to daily note
|
|
242
|
+
if (facts.length > 0) {
|
|
243
|
+
this.appendDailyNote(
|
|
244
|
+
`Session summary:\n${facts.map((f) => `- ${f}`).join("\n")}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return facts;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Store a permanent memory entry in DB.
|
|
253
|
+
*/
|
|
254
|
+
_storeEntry(content, source = "auto", importance = 0.5) {
|
|
255
|
+
if (!this.db) return null;
|
|
256
|
+
try {
|
|
257
|
+
const id = `pm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
258
|
+
this.db
|
|
259
|
+
.prepare(
|
|
260
|
+
"INSERT INTO permanent_memory (id, content, source, importance) VALUES (?, ?, ?, ?)",
|
|
261
|
+
)
|
|
262
|
+
.run(id, content, source, importance);
|
|
263
|
+
return id;
|
|
264
|
+
} catch (_err) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Internal ───
|
|
270
|
+
|
|
271
|
+
_loadMemoryFile() {
|
|
272
|
+
if (!this.memoryDir) return;
|
|
273
|
+
try {
|
|
274
|
+
const filePath = _deps.path.join(this.memoryDir, "MEMORY.md");
|
|
275
|
+
if (_deps.fs.existsSync(filePath)) {
|
|
276
|
+
this._memoryFileContent = _deps.fs.readFileSync(filePath, "utf-8");
|
|
277
|
+
}
|
|
278
|
+
} catch (_err) {
|
|
279
|
+
this._memoryFileContent = "";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_loadRecentDailyNotes() {
|
|
284
|
+
if (!this.memoryDir) return;
|
|
285
|
+
this._dailyNotes = [];
|
|
286
|
+
try {
|
|
287
|
+
const dailyDir = _deps.path.join(this.memoryDir, "daily");
|
|
288
|
+
if (!_deps.fs.existsSync(dailyDir)) return;
|
|
289
|
+
|
|
290
|
+
const files = _deps.fs
|
|
291
|
+
.readdirSync(dailyDir)
|
|
292
|
+
.filter((f) => f.endsWith(".md"))
|
|
293
|
+
.sort()
|
|
294
|
+
.reverse()
|
|
295
|
+
.slice(0, 7);
|
|
296
|
+
|
|
297
|
+
for (const f of files) {
|
|
298
|
+
const content = _deps.fs.readFileSync(
|
|
299
|
+
_deps.path.join(dailyDir, f),
|
|
300
|
+
"utf-8",
|
|
301
|
+
);
|
|
302
|
+
this._dailyNotes.push({
|
|
303
|
+
date: f.replace(".md", ""),
|
|
304
|
+
content,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} catch (_err) {
|
|
308
|
+
// Non-critical
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_loadDbEntries() {
|
|
313
|
+
if (!this.db) return;
|
|
314
|
+
this._dbEntries = [];
|
|
315
|
+
try {
|
|
316
|
+
this._dbEntries = this.db
|
|
317
|
+
.prepare(
|
|
318
|
+
"SELECT id, content, source, importance FROM permanent_memory ORDER BY importance DESC LIMIT 100",
|
|
319
|
+
)
|
|
320
|
+
.all();
|
|
321
|
+
} catch (_err) {
|
|
322
|
+
// Table may not exist
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_buildIndex() {
|
|
327
|
+
const docs = [];
|
|
328
|
+
|
|
329
|
+
// MEMORY.md sections
|
|
330
|
+
if (this._memoryFileContent) {
|
|
331
|
+
const sections = this._memoryFileContent.split(/^## /m).filter(Boolean);
|
|
332
|
+
for (const section of sections) {
|
|
333
|
+
const firstLine = section.split("\n")[0].trim();
|
|
334
|
+
docs.push({
|
|
335
|
+
id: `memfile-${firstLine.substring(0, 30)}`,
|
|
336
|
+
title: firstLine,
|
|
337
|
+
content: section.substring(0, 500),
|
|
338
|
+
source: "MEMORY.md",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Daily notes
|
|
344
|
+
for (const note of this._dailyNotes) {
|
|
345
|
+
docs.push({
|
|
346
|
+
id: `daily-${note.date}`,
|
|
347
|
+
title: `Daily Note ${note.date}`,
|
|
348
|
+
content: note.content.substring(0, 500),
|
|
349
|
+
source: "daily-note",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// DB entries
|
|
354
|
+
for (const entry of this._dbEntries) {
|
|
355
|
+
docs.push({
|
|
356
|
+
id: entry.id,
|
|
357
|
+
title: (entry.content || "").substring(0, 60),
|
|
358
|
+
content: entry.content || "",
|
|
359
|
+
source: entry.source || "db",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (docs.length > 0) {
|
|
364
|
+
this._bm25 = new _deps.BM25Search();
|
|
365
|
+
this._bm25.indexDocuments(docs);
|
|
366
|
+
} else {
|
|
367
|
+
this._bm25 = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/lib/plan-mode.js
CHANGED
|
@@ -54,6 +54,26 @@ const WRITE_TOOLS = new Set([
|
|
|
54
54
|
/**
|
|
55
55
|
* A single item in an execution plan
|
|
56
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* Risk weights for tool categories
|
|
59
|
+
*/
|
|
60
|
+
const TOOL_RISK_WEIGHTS = {
|
|
61
|
+
read_file: 1,
|
|
62
|
+
search_files: 1,
|
|
63
|
+
list_dir: 1,
|
|
64
|
+
list_skills: 1,
|
|
65
|
+
write_file: 2,
|
|
66
|
+
edit_file: 2,
|
|
67
|
+
run_skill: 2,
|
|
68
|
+
run_shell: 3,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const IMPACT_MULTIPLIERS = {
|
|
72
|
+
low: 1,
|
|
73
|
+
medium: 2,
|
|
74
|
+
high: 3,
|
|
75
|
+
};
|
|
76
|
+
|
|
57
77
|
export class PlanItem {
|
|
58
78
|
constructor(data = {}) {
|
|
59
79
|
this.id =
|
|
@@ -69,6 +89,16 @@ export class PlanItem {
|
|
|
69
89
|
this.result = null;
|
|
70
90
|
this.error = null;
|
|
71
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Calculate risk score for this item.
|
|
95
|
+
* Score = tool_weight × impact_multiplier
|
|
96
|
+
*/
|
|
97
|
+
get riskScore() {
|
|
98
|
+
const toolWeight = TOOL_RISK_WEIGHTS[this.tool] || 1;
|
|
99
|
+
const impactMul = IMPACT_MULTIPLIERS[this.estimatedImpact] || 1;
|
|
100
|
+
return toolWeight * impactMul;
|
|
101
|
+
}
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/**
|
|
@@ -102,6 +132,115 @@ export class ExecutionPlan {
|
|
|
102
132
|
getItem(itemId) {
|
|
103
133
|
return this.items.find((i) => i.id === itemId);
|
|
104
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Topological sort of items by dependencies.
|
|
138
|
+
* Returns items in execution order. Throws if cycle detected.
|
|
139
|
+
*/
|
|
140
|
+
topologicalSort() {
|
|
141
|
+
const itemMap = new Map(this.items.map((i) => [i.id, i]));
|
|
142
|
+
const visited = new Set();
|
|
143
|
+
const visiting = new Set();
|
|
144
|
+
const sorted = [];
|
|
145
|
+
|
|
146
|
+
const visit = (id) => {
|
|
147
|
+
if (visited.has(id)) return;
|
|
148
|
+
if (visiting.has(id))
|
|
149
|
+
throw new Error(`Dependency cycle detected involving ${id}`);
|
|
150
|
+
|
|
151
|
+
visiting.add(id);
|
|
152
|
+
const item = itemMap.get(id);
|
|
153
|
+
if (item && item.dependencies) {
|
|
154
|
+
for (const depId of item.dependencies) {
|
|
155
|
+
if (itemMap.has(depId)) {
|
|
156
|
+
visit(depId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
visiting.delete(id);
|
|
161
|
+
visited.add(id);
|
|
162
|
+
if (item) sorted.push(item);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
for (const item of this.items) {
|
|
166
|
+
visit(item.id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return sorted;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Execute items in DAG topological order using provided executor.
|
|
174
|
+
* If a dependency fails, downstream items are marked as blocked.
|
|
175
|
+
*
|
|
176
|
+
* @param {function} executor - async (item) => result
|
|
177
|
+
* @returns {Array<{ item: PlanItem, success: boolean, result: any, error: string }>}
|
|
178
|
+
*/
|
|
179
|
+
async executeInOrder(executor) {
|
|
180
|
+
const sorted = this.topologicalSort();
|
|
181
|
+
const results = [];
|
|
182
|
+
const failedIds = new Set();
|
|
183
|
+
|
|
184
|
+
for (const item of sorted) {
|
|
185
|
+
// Check if any dependency failed
|
|
186
|
+
const blocked = (item.dependencies || []).some((depId) =>
|
|
187
|
+
failedIds.has(depId),
|
|
188
|
+
);
|
|
189
|
+
if (blocked) {
|
|
190
|
+
item.status = PlanStatus.FAILED;
|
|
191
|
+
item.error = "Blocked by failed dependency";
|
|
192
|
+
failedIds.add(item.id);
|
|
193
|
+
results.push({ item, success: false, result: null, error: item.error });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
item.status = PlanStatus.EXECUTING;
|
|
198
|
+
try {
|
|
199
|
+
const result = await executor(item);
|
|
200
|
+
item.status = PlanStatus.COMPLETED;
|
|
201
|
+
item.result = result;
|
|
202
|
+
results.push({ item, success: true, result, error: null });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
item.status = PlanStatus.FAILED;
|
|
205
|
+
item.error = err.message;
|
|
206
|
+
failedIds.add(item.id);
|
|
207
|
+
results.push({
|
|
208
|
+
item,
|
|
209
|
+
success: false,
|
|
210
|
+
result: null,
|
|
211
|
+
error: err.message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Calculate aggregate risk score for the plan.
|
|
221
|
+
*/
|
|
222
|
+
getRiskAssessment() {
|
|
223
|
+
const scores = this.items.map((i) => i.riskScore);
|
|
224
|
+
const total = scores.reduce((sum, s) => sum + s, 0);
|
|
225
|
+
const max = Math.max(...scores, 0);
|
|
226
|
+
const avg = scores.length > 0 ? total / scores.length : 0;
|
|
227
|
+
|
|
228
|
+
let level = "low";
|
|
229
|
+
if (max >= 6 || avg >= 4) level = "high";
|
|
230
|
+
else if (max >= 4 || avg >= 2) level = "medium";
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
level,
|
|
234
|
+
totalScore: total,
|
|
235
|
+
maxScore: max,
|
|
236
|
+
averageScore: Math.round(avg * 100) / 100,
|
|
237
|
+
itemScores: this.items.map((i) => ({
|
|
238
|
+
id: i.id,
|
|
239
|
+
title: i.title,
|
|
240
|
+
score: i.riskScore,
|
|
241
|
+
})),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
105
244
|
}
|
|
106
245
|
|
|
107
246
|
/**
|
|
@@ -116,6 +255,14 @@ export class PlanModeManager extends EventEmitter {
|
|
|
116
255
|
this.currentPlan = null;
|
|
117
256
|
this.history = [];
|
|
118
257
|
this.blockedToolLog = [];
|
|
258
|
+
this._hookDb = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Set DB reference for hook execution.
|
|
263
|
+
*/
|
|
264
|
+
setHookDb(db) {
|
|
265
|
+
this._hookDb = db;
|
|
119
266
|
}
|
|
120
267
|
|
|
121
268
|
/**
|
|
@@ -141,6 +288,7 @@ export class PlanModeManager extends EventEmitter {
|
|
|
141
288
|
this.blockedToolLog = [];
|
|
142
289
|
|
|
143
290
|
this.emit("enter", { plan: this.currentPlan, state: this.state });
|
|
291
|
+
this._fireHook("PlanModeEnter", { planId: this.currentPlan.id });
|
|
144
292
|
return { plan: this.currentPlan };
|
|
145
293
|
}
|
|
146
294
|
|
|
@@ -217,6 +365,10 @@ export class PlanModeManager extends EventEmitter {
|
|
|
217
365
|
plan: this.currentPlan,
|
|
218
366
|
approvedCount: approvedItems.length,
|
|
219
367
|
});
|
|
368
|
+
this._fireHook("PlanApproved", {
|
|
369
|
+
planId: this.currentPlan.id,
|
|
370
|
+
itemCount: approvedItems.length,
|
|
371
|
+
});
|
|
220
372
|
return { plan: this.currentPlan, approvedCount: approvedItems.length };
|
|
221
373
|
}
|
|
222
374
|
|
|
@@ -233,6 +385,7 @@ export class PlanModeManager extends EventEmitter {
|
|
|
233
385
|
}
|
|
234
386
|
|
|
235
387
|
this.state = PlanState.REJECTED;
|
|
388
|
+
this._fireHook("PlanRejected", { planId: this.currentPlan.id, reason });
|
|
236
389
|
return this.exitPlanMode({ savePlan: true, reason: reason || "rejected" });
|
|
237
390
|
}
|
|
238
391
|
|
|
@@ -297,6 +450,13 @@ export class PlanModeManager extends EventEmitter {
|
|
|
297
450
|
}
|
|
298
451
|
}
|
|
299
452
|
|
|
453
|
+
// Risk assessment
|
|
454
|
+
const risk = plan.getRiskAssessment();
|
|
455
|
+
lines.push("");
|
|
456
|
+
lines.push(
|
|
457
|
+
`**Risk**: ${risk.level} (total: ${risk.totalScore}, max: ${risk.maxScore}, avg: ${risk.averageScore})`,
|
|
458
|
+
);
|
|
459
|
+
|
|
300
460
|
if (this.blockedToolLog.length > 0) {
|
|
301
461
|
lines.push("");
|
|
302
462
|
lines.push(
|
|
@@ -307,12 +467,63 @@ export class PlanModeManager extends EventEmitter {
|
|
|
307
467
|
return lines.filter(Boolean).join("\n");
|
|
308
468
|
}
|
|
309
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Get risk assessment for current plan.
|
|
472
|
+
*/
|
|
473
|
+
getRiskAssessment() {
|
|
474
|
+
if (!this.currentPlan) return null;
|
|
475
|
+
return this.currentPlan.getRiskAssessment();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Execute approved plan items in DAG order.
|
|
480
|
+
* @param {function} executor - async (item) => result
|
|
481
|
+
*/
|
|
482
|
+
async executePlan(executor) {
|
|
483
|
+
if (!this.currentPlan) return { error: "No active plan" };
|
|
484
|
+
if (this.state !== PlanState.APPROVED)
|
|
485
|
+
return { error: "Plan not approved" };
|
|
486
|
+
|
|
487
|
+
this.state = PlanState.EXECUTING;
|
|
488
|
+
this.currentPlan.status = PlanState.EXECUTING;
|
|
489
|
+
|
|
490
|
+
const results = await this.currentPlan.executeInOrder(async (item) => {
|
|
491
|
+
this._fireHook("PlanItemExecute", {
|
|
492
|
+
planId: this.currentPlan.id,
|
|
493
|
+
itemId: item.id,
|
|
494
|
+
tool: item.tool,
|
|
495
|
+
});
|
|
496
|
+
return executor(item);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const allDone = results.every((r) => r.success);
|
|
500
|
+
this.state = allDone ? PlanState.COMPLETED : PlanState.COMPLETED;
|
|
501
|
+
this.currentPlan.status = allDone
|
|
502
|
+
? PlanState.COMPLETED
|
|
503
|
+
: PlanState.COMPLETED;
|
|
504
|
+
|
|
505
|
+
return { results, success: allDone };
|
|
506
|
+
}
|
|
507
|
+
|
|
310
508
|
/**
|
|
311
509
|
* Get plans history
|
|
312
510
|
*/
|
|
313
511
|
getHistory() {
|
|
314
512
|
return this.history;
|
|
315
513
|
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Fire a hook event (best-effort, non-blocking).
|
|
517
|
+
*/
|
|
518
|
+
_fireHook(eventName, context) {
|
|
519
|
+
if (!this._hookDb) return;
|
|
520
|
+
// Dynamic import to avoid circular deps
|
|
521
|
+
import("./hook-manager.js")
|
|
522
|
+
.then(({ executeHooks }) => {
|
|
523
|
+
executeHooks(this._hookDb, eventName, context).catch(() => {});
|
|
524
|
+
})
|
|
525
|
+
.catch(() => {});
|
|
526
|
+
}
|
|
316
527
|
}
|
|
317
528
|
|
|
318
529
|
// Singleton
|