wispy-cli 1.1.2 → 1.2.2
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/bin/wispy.mjs +225 -0
- package/core/deploy.mjs +292 -0
- package/core/engine.mjs +112 -58
- package/core/harness.mjs +531 -0
- package/core/index.mjs +2 -0
- package/lib/channels/index.mjs +229 -4
- package/lib/wispy-tui.mjs +430 -30
- package/package.json +2 -2
package/core/harness.mjs
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/harness.mjs — Execution Harness for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Mediates ALL tool execution with:
|
|
5
|
+
* - Permission checks (auto/notify/approve)
|
|
6
|
+
* - Dry-run simulation
|
|
7
|
+
* - Pre/post snapshots + unified diffs
|
|
8
|
+
* - Execution receipts
|
|
9
|
+
* - Audit logging
|
|
10
|
+
* - Event emission for TUI/channel approval UX
|
|
11
|
+
*
|
|
12
|
+
* v1.2.0
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
|
|
20
|
+
import { EVENT_TYPES } from "./audit.mjs";
|
|
21
|
+
|
|
22
|
+
// ── Receipt ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export class Receipt {
|
|
25
|
+
constructor() {
|
|
26
|
+
this.id = generateId();
|
|
27
|
+
this.timestamp = new Date().toISOString();
|
|
28
|
+
this.sessionId = null;
|
|
29
|
+
this.toolName = null;
|
|
30
|
+
this.args = {};
|
|
31
|
+
this.permissionLevel = "auto";
|
|
32
|
+
this.approved = null; // null = not needed, true/false = approval result
|
|
33
|
+
this.dryRun = false;
|
|
34
|
+
this.duration = 0;
|
|
35
|
+
this.success = false;
|
|
36
|
+
this.result = null;
|
|
37
|
+
this.diff = null; // { before, after, unified } for file ops
|
|
38
|
+
this.error = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toMarkdown() {
|
|
42
|
+
const icon = this.success ? "✅" : "❌";
|
|
43
|
+
const dryTag = this.dryRun ? " [DRY RUN]" : "";
|
|
44
|
+
const lines = [
|
|
45
|
+
`${icon} **${this.toolName}**${dryTag}`,
|
|
46
|
+
`- ID: \`${this.id}\``,
|
|
47
|
+
`- Time: ${new Date(this.timestamp).toLocaleTimeString()}`,
|
|
48
|
+
`- Duration: ${this.duration}ms`,
|
|
49
|
+
`- Permission: ${this.permissionLevel}`,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (this.approved !== null) {
|
|
53
|
+
lines.push(`- Approved: ${this.approved ? "✅ yes" : "❌ no"}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.error) {
|
|
57
|
+
lines.push(`- Error: ${this.error}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.diff?.unified) {
|
|
61
|
+
const stats = diffStats(this.diff.unified);
|
|
62
|
+
lines.push(`- Changes: +${stats.added} lines, -${stats.removed} lines`);
|
|
63
|
+
lines.push("", "```diff", this.diff.unified.slice(0, 2000), "```");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toJSON() {
|
|
70
|
+
return {
|
|
71
|
+
id: this.id,
|
|
72
|
+
timestamp: this.timestamp,
|
|
73
|
+
sessionId: this.sessionId,
|
|
74
|
+
toolName: this.toolName,
|
|
75
|
+
args: this.args,
|
|
76
|
+
permissionLevel: this.permissionLevel,
|
|
77
|
+
approved: this.approved,
|
|
78
|
+
dryRun: this.dryRun,
|
|
79
|
+
duration: this.duration,
|
|
80
|
+
success: this.success,
|
|
81
|
+
result: this.result,
|
|
82
|
+
diff: this.diff,
|
|
83
|
+
error: this.error,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── HarnessResult ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export class HarnessResult {
|
|
91
|
+
constructor({ result, receipt, denied = false, dryRun = false }) {
|
|
92
|
+
this.result = result;
|
|
93
|
+
this.receipt = receipt;
|
|
94
|
+
this.denied = denied;
|
|
95
|
+
this.dryRun = dryRun;
|
|
96
|
+
this.success = receipt?.success ?? (!denied);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Sandbox modes ─────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
// Which tools get file snapshots (pre/post diff)
|
|
103
|
+
const FILE_SNAPSHOT_TOOLS = new Set(["write_file", "file_edit"]);
|
|
104
|
+
|
|
105
|
+
// File-path arg for each tool (to know what to snapshot)
|
|
106
|
+
function getFilePath(toolName, args) {
|
|
107
|
+
if (toolName === "write_file" || toolName === "file_edit" || toolName === "read_file") {
|
|
108
|
+
return args.path;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resolve a file path the same way tools.mjs does
|
|
114
|
+
function resolvePath(p) {
|
|
115
|
+
if (!p) return null;
|
|
116
|
+
let resolved = p.replace(/^~/, os.homedir());
|
|
117
|
+
if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
|
|
118
|
+
return resolved;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Unified diff (no external deps) ──────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compute a simple unified diff between two strings.
|
|
125
|
+
* Returns unified diff string.
|
|
126
|
+
*/
|
|
127
|
+
export function computeUnifiedDiff(before, after, filePath = "file") {
|
|
128
|
+
if (before === after) return "";
|
|
129
|
+
|
|
130
|
+
const beforeLines = before ? before.split("\n") : [];
|
|
131
|
+
const afterLines = after ? after.split("\n") : [];
|
|
132
|
+
|
|
133
|
+
// LCS-based diff — simple Myers-like algorithm
|
|
134
|
+
const hunks = computeHunks(beforeLines, afterLines, 3);
|
|
135
|
+
|
|
136
|
+
if (hunks.length === 0) return "";
|
|
137
|
+
|
|
138
|
+
const lines = [
|
|
139
|
+
`--- a/${filePath}`,
|
|
140
|
+
`+++ b/${filePath}`,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
for (const hunk of hunks) {
|
|
144
|
+
lines.push(hunk.header);
|
|
145
|
+
lines.push(...hunk.lines);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function computeHunks(oldLines, newLines, context = 3) {
|
|
152
|
+
// Build edit script using simple LCS
|
|
153
|
+
const edits = shortestEditScript(oldLines, newLines);
|
|
154
|
+
|
|
155
|
+
if (edits.length === 0) return [];
|
|
156
|
+
|
|
157
|
+
// Group edits into hunks with context
|
|
158
|
+
const hunks = [];
|
|
159
|
+
let i = 0;
|
|
160
|
+
|
|
161
|
+
while (i < edits.length) {
|
|
162
|
+
if (edits[i].type === "equal") { i++; continue; }
|
|
163
|
+
|
|
164
|
+
// Found a change — build a hunk
|
|
165
|
+
const hunkStart = i;
|
|
166
|
+
const hunkEdits = [edits[i]];
|
|
167
|
+
i++;
|
|
168
|
+
|
|
169
|
+
// Extend hunk while changes are within 2*context of each other
|
|
170
|
+
while (i < edits.length) {
|
|
171
|
+
if (edits[i].type !== "equal") {
|
|
172
|
+
hunkEdits.push(edits[i]);
|
|
173
|
+
i++;
|
|
174
|
+
} else {
|
|
175
|
+
// Count consecutive equal lines
|
|
176
|
+
let equalCount = 0;
|
|
177
|
+
let j = i;
|
|
178
|
+
while (j < edits.length && edits[j].type === "equal") { equalCount++; j++; }
|
|
179
|
+
if (equalCount <= 2 * context && j < edits.length && edits[j].type !== "equal") {
|
|
180
|
+
// Merge into current hunk
|
|
181
|
+
hunkEdits.push(...edits.slice(i, j));
|
|
182
|
+
i = j;
|
|
183
|
+
} else {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Compute old/new line ranges
|
|
190
|
+
let oldStart = null; let oldCount = 0;
|
|
191
|
+
let newStart = null; let newCount = 0;
|
|
192
|
+
const hunkLines = [];
|
|
193
|
+
|
|
194
|
+
// Add leading context
|
|
195
|
+
const firstEdit = hunkEdits[0];
|
|
196
|
+
const ctxStart = Math.max(0, firstEdit.oldIdx - context);
|
|
197
|
+
oldStart = ctxStart + 1; // 1-indexed
|
|
198
|
+
newStart = firstEdit.newIdx - (firstEdit.oldIdx - ctxStart) + 1; // 1-indexed
|
|
199
|
+
for (let k = ctxStart; k < firstEdit.oldIdx; k++) {
|
|
200
|
+
hunkLines.push(` ${oldLines[k]}`);
|
|
201
|
+
oldCount++; newCount++;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Add the hunk edits
|
|
205
|
+
for (const edit of hunkEdits) {
|
|
206
|
+
if (edit.type === "equal") {
|
|
207
|
+
hunkLines.push(` ${oldLines[edit.oldIdx]}`);
|
|
208
|
+
oldCount++; newCount++;
|
|
209
|
+
} else if (edit.type === "delete") {
|
|
210
|
+
hunkLines.push(`-${oldLines[edit.oldIdx]}`);
|
|
211
|
+
oldCount++;
|
|
212
|
+
} else if (edit.type === "insert") {
|
|
213
|
+
hunkLines.push(`+${newLines[edit.newIdx]}`);
|
|
214
|
+
newCount++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add trailing context
|
|
219
|
+
const lastEdit = hunkEdits[hunkEdits.length - 1];
|
|
220
|
+
const lastOldIdx = lastEdit.type === "insert" ? lastEdit.oldIdx : lastEdit.oldIdx + 1;
|
|
221
|
+
const ctxEnd = Math.min(oldLines.length, lastOldIdx + context);
|
|
222
|
+
for (let k = lastOldIdx; k < ctxEnd; k++) {
|
|
223
|
+
hunkLines.push(` ${oldLines[k]}`);
|
|
224
|
+
oldCount++; newCount++;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
hunks.push({
|
|
228
|
+
header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
|
|
229
|
+
lines: hunkLines,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return hunks;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function shortestEditScript(oldLines, newLines) {
|
|
237
|
+
// Simple O(nd) diff using dynamic programming LCS
|
|
238
|
+
const m = oldLines.length;
|
|
239
|
+
const n = newLines.length;
|
|
240
|
+
|
|
241
|
+
// Build LCS table
|
|
242
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
243
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
244
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
245
|
+
if (oldLines[i] === newLines[j]) {
|
|
246
|
+
dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
247
|
+
} else {
|
|
248
|
+
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Trace back
|
|
254
|
+
const edits = [];
|
|
255
|
+
let i = 0; let j = 0;
|
|
256
|
+
while (i < m || j < n) {
|
|
257
|
+
if (i < m && j < n && oldLines[i] === newLines[j]) {
|
|
258
|
+
edits.push({ type: "equal", oldIdx: i, newIdx: j });
|
|
259
|
+
i++; j++;
|
|
260
|
+
} else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
|
|
261
|
+
edits.push({ type: "insert", oldIdx: i, newIdx: j });
|
|
262
|
+
j++;
|
|
263
|
+
} else {
|
|
264
|
+
edits.push({ type: "delete", oldIdx: i, newIdx: j });
|
|
265
|
+
i++;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return edits;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function diffStats(unifiedDiff) {
|
|
273
|
+
let added = 0; let removed = 0;
|
|
274
|
+
for (const line of unifiedDiff.split("\n")) {
|
|
275
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added++;
|
|
276
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed++;
|
|
277
|
+
}
|
|
278
|
+
return { added, removed };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── ID generation (no uuid dep needed) ───────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
function generateId() {
|
|
284
|
+
const ts = Date.now().toString(36);
|
|
285
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
286
|
+
return `rcpt-${ts}-${rand}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Dry-run simulation ────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function simulateDryRun(toolName, args) {
|
|
292
|
+
switch (toolName) {
|
|
293
|
+
case "write_file":
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
dryRun: true,
|
|
297
|
+
preview: `Would write ${(args.content ?? "").length} chars to: ${args.path}`,
|
|
298
|
+
content: args.content,
|
|
299
|
+
path: args.path,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
case "file_edit":
|
|
303
|
+
return {
|
|
304
|
+
success: true,
|
|
305
|
+
dryRun: true,
|
|
306
|
+
preview: `Would replace text in: ${args.path}`,
|
|
307
|
+
old_text: args.old_text,
|
|
308
|
+
new_text: args.new_text,
|
|
309
|
+
path: args.path,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
case "run_command":
|
|
313
|
+
return {
|
|
314
|
+
success: true,
|
|
315
|
+
dryRun: true,
|
|
316
|
+
preview: `Would execute: ${args.command}`,
|
|
317
|
+
command: args.command,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
case "git":
|
|
321
|
+
return {
|
|
322
|
+
success: true,
|
|
323
|
+
dryRun: true,
|
|
324
|
+
preview: `Would run: git ${args.command}`,
|
|
325
|
+
command: args.command,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
default:
|
|
329
|
+
return {
|
|
330
|
+
success: true,
|
|
331
|
+
dryRun: true,
|
|
332
|
+
preview: `Would call ${toolName} with ${JSON.stringify(args).slice(0, 100)}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Harness class ──────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
export class Harness extends EventEmitter {
|
|
340
|
+
/**
|
|
341
|
+
* @param {import('./tools.mjs').ToolRegistry} toolRegistry
|
|
342
|
+
* @param {import('./permissions.mjs').PermissionManager} permissions
|
|
343
|
+
* @param {import('./audit.mjs').AuditLog} audit
|
|
344
|
+
* @param {object} config
|
|
345
|
+
*/
|
|
346
|
+
constructor(toolRegistry, permissions, audit, config = {}) {
|
|
347
|
+
super();
|
|
348
|
+
this.tools = toolRegistry;
|
|
349
|
+
this.permissions = permissions;
|
|
350
|
+
this.audit = audit;
|
|
351
|
+
this.config = config;
|
|
352
|
+
|
|
353
|
+
// Sandbox config per-tool: "preview" | "diff" | null
|
|
354
|
+
this._sandboxModes = {
|
|
355
|
+
run_command: "preview",
|
|
356
|
+
write_file: "diff",
|
|
357
|
+
file_edit: "diff",
|
|
358
|
+
git: "preview",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Main entry point — replaces direct tool.execute() calls.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} toolName
|
|
366
|
+
* @param {object} args
|
|
367
|
+
* @param {object} context - { sessionId, userId, channel, dryRun?, executeToolFn }
|
|
368
|
+
* @returns {HarnessResult}
|
|
369
|
+
*/
|
|
370
|
+
async execute(toolName, args, context = {}) {
|
|
371
|
+
const receipt = new Receipt();
|
|
372
|
+
receipt.toolName = toolName;
|
|
373
|
+
receipt.args = args;
|
|
374
|
+
receipt.sessionId = context.sessionId ?? null;
|
|
375
|
+
receipt.dryRun = context.dryRun ?? false;
|
|
376
|
+
|
|
377
|
+
const callStart = Date.now();
|
|
378
|
+
|
|
379
|
+
// ── 1. Permission check ──────────────────────────────────────────────────
|
|
380
|
+
const permResult = await this.permissions.check(toolName, args, context);
|
|
381
|
+
receipt.permissionLevel = permResult.level ?? "auto";
|
|
382
|
+
|
|
383
|
+
if (!permResult.allowed) {
|
|
384
|
+
receipt.approved = false;
|
|
385
|
+
receipt.success = false;
|
|
386
|
+
receipt.error = permResult.reason ?? "Permission denied";
|
|
387
|
+
receipt.duration = Date.now() - callStart;
|
|
388
|
+
|
|
389
|
+
this.audit.log({
|
|
390
|
+
type: EVENT_TYPES.APPROVAL_DENIED,
|
|
391
|
+
sessionId: context.sessionId,
|
|
392
|
+
tool: toolName,
|
|
393
|
+
args,
|
|
394
|
+
}).catch(() => {});
|
|
395
|
+
|
|
396
|
+
this.emit("tool:denied", { toolName, args, receipt, context });
|
|
397
|
+
|
|
398
|
+
return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (permResult.needsApproval) {
|
|
402
|
+
receipt.approved = permResult.approved;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── 2. Dry-run mode ──────────────────────────────────────────────────────
|
|
406
|
+
if (receipt.dryRun) {
|
|
407
|
+
const preview = simulateDryRun(toolName, args);
|
|
408
|
+
|
|
409
|
+
// For file edits in dry-run, compute the diff
|
|
410
|
+
if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
|
|
411
|
+
const filePath = getFilePath(toolName, args);
|
|
412
|
+
if (filePath) {
|
|
413
|
+
const resolved = resolvePath(filePath);
|
|
414
|
+
let before = "";
|
|
415
|
+
try { before = await readFile(resolved, "utf8"); } catch {}
|
|
416
|
+
|
|
417
|
+
let after = before;
|
|
418
|
+
if (toolName === "write_file") {
|
|
419
|
+
after = args.content ?? "";
|
|
420
|
+
} else if (toolName === "file_edit") {
|
|
421
|
+
after = before.replace(args.old_text ?? "", args.new_text ?? "");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const unified = computeUnifiedDiff(before, after, filePath);
|
|
425
|
+
receipt.diff = { before, after, unified };
|
|
426
|
+
preview.diff = receipt.diff;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
receipt.success = true;
|
|
431
|
+
receipt.result = preview;
|
|
432
|
+
receipt.duration = Date.now() - callStart;
|
|
433
|
+
|
|
434
|
+
this.emit("tool:dryrun", { toolName, args, preview, receipt, context });
|
|
435
|
+
|
|
436
|
+
return new HarnessResult({ result: preview, receipt, dryRun: true });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── 3. Pre-snapshot ──────────────────────────────────────────────────────
|
|
440
|
+
let beforeContent = null;
|
|
441
|
+
if (FILE_SNAPSHOT_TOOLS.has(toolName)) {
|
|
442
|
+
const filePath = getFilePath(toolName, args);
|
|
443
|
+
if (filePath) {
|
|
444
|
+
const resolved = resolvePath(filePath);
|
|
445
|
+
try { beforeContent = await readFile(resolved, "utf8"); } catch { beforeContent = ""; }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── 4. Emit tool:start ───────────────────────────────────────────────────
|
|
450
|
+
this.emit("tool:start", { toolName, args, context });
|
|
451
|
+
|
|
452
|
+
this.audit.log({
|
|
453
|
+
type: EVENT_TYPES.TOOL_CALL,
|
|
454
|
+
sessionId: context.sessionId,
|
|
455
|
+
tool: toolName,
|
|
456
|
+
args,
|
|
457
|
+
permissionLevel: receipt.permissionLevel,
|
|
458
|
+
}).catch(() => {});
|
|
459
|
+
|
|
460
|
+
// ── 5. Execute ───────────────────────────────────────────────────────────
|
|
461
|
+
let result;
|
|
462
|
+
try {
|
|
463
|
+
if (context.executeToolFn) {
|
|
464
|
+
result = await context.executeToolFn(toolName, args);
|
|
465
|
+
} else {
|
|
466
|
+
result = await this.tools.execute(toolName, args);
|
|
467
|
+
}
|
|
468
|
+
receipt.success = result?.success !== false;
|
|
469
|
+
receipt.result = result;
|
|
470
|
+
} catch (err) {
|
|
471
|
+
receipt.success = false;
|
|
472
|
+
receipt.error = err.message;
|
|
473
|
+
receipt.duration = Date.now() - callStart;
|
|
474
|
+
|
|
475
|
+
this.audit.log({
|
|
476
|
+
type: EVENT_TYPES.ERROR,
|
|
477
|
+
sessionId: context.sessionId,
|
|
478
|
+
tool: toolName,
|
|
479
|
+
message: err.message,
|
|
480
|
+
duration: receipt.duration,
|
|
481
|
+
}).catch(() => {});
|
|
482
|
+
|
|
483
|
+
this.emit("tool:error", { toolName, args, error: err, receipt, context });
|
|
484
|
+
|
|
485
|
+
return new HarnessResult({ result: { success: false, error: err.message }, receipt });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 6. Post-snapshot ─────────────────────────────────────────────────────
|
|
489
|
+
if (FILE_SNAPSHOT_TOOLS.has(toolName) && receipt.success) {
|
|
490
|
+
const filePath = getFilePath(toolName, args);
|
|
491
|
+
if (filePath) {
|
|
492
|
+
const resolved = resolvePath(filePath);
|
|
493
|
+
let afterContent = "";
|
|
494
|
+
try { afterContent = await readFile(resolved, "utf8"); } catch {}
|
|
495
|
+
|
|
496
|
+
const unified = computeUnifiedDiff(beforeContent ?? "", afterContent, filePath);
|
|
497
|
+
receipt.diff = { before: beforeContent ?? "", after: afterContent, unified };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── 7. Duration ──────────────────────────────────────────────────────────
|
|
502
|
+
receipt.duration = Date.now() - callStart;
|
|
503
|
+
|
|
504
|
+
// ── 8. Audit ─────────────────────────────────────────────────────────────
|
|
505
|
+
this.audit.log({
|
|
506
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
507
|
+
sessionId: context.sessionId,
|
|
508
|
+
tool: toolName,
|
|
509
|
+
result: JSON.stringify(result).slice(0, 500),
|
|
510
|
+
duration: receipt.duration,
|
|
511
|
+
}).catch(() => {});
|
|
512
|
+
|
|
513
|
+
// ── 9. Emit tool:complete ────────────────────────────────────────────────
|
|
514
|
+
this.emit("tool:complete", { toolName, args, result, receipt, context });
|
|
515
|
+
|
|
516
|
+
return new HarnessResult({ result, receipt });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Set sandbox mode for a tool.
|
|
521
|
+
* @param {string} toolName
|
|
522
|
+
* @param {"preview"|"diff"|null} mode
|
|
523
|
+
*/
|
|
524
|
+
setSandboxMode(toolName, mode) {
|
|
525
|
+
this._sandboxModes[toolName] = mode;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
getSandboxMode(toolName) {
|
|
529
|
+
return this._sandboxModes[toolName] ?? null;
|
|
530
|
+
}
|
|
531
|
+
}
|
package/core/index.mjs
CHANGED
|
@@ -17,3 +17,5 @@ export { PermissionManager, DEFAULT_POLICIES, BUILT_IN_SCOPES } from "./permissi
|
|
|
17
17
|
export { AuditLog, EVENT_TYPES, getAuditLog } from "./audit.mjs";
|
|
18
18
|
export { WispyServer } from "./server.mjs";
|
|
19
19
|
export { NodeManager, CAPABILITIES } from "./nodes.mjs";
|
|
20
|
+
export { Harness, Receipt, HarnessResult, computeUnifiedDiff } from "./harness.mjs";
|
|
21
|
+
export { DeployManager } from "./deploy.mjs";
|