lazyopencode-core 0.0.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/ATTRIBUTION.md +38 -0
- package/LICENSE +21 -0
- package/README.md +357 -0
- package/dist/agents/councillor.d.ts +1 -0
- package/dist/agents/councillor.js +14 -0
- package/dist/agents/designer.d.ts +1 -0
- package/dist/agents/designer.js +31 -0
- package/dist/agents/explorer.d.ts +1 -0
- package/dist/agents/explorer.js +15 -0
- package/dist/agents/fixer.d.ts +1 -0
- package/dist/agents/fixer.js +23 -0
- package/dist/agents/index.d.ts +2 -0
- package/dist/agents/index.js +55 -0
- package/dist/agents/lazy.d.ts +1 -0
- package/dist/agents/lazy.js +3 -0
- package/dist/agents/librarian.d.ts +1 -0
- package/dist/agents/librarian.js +26 -0
- package/dist/agents/observer.d.ts +1 -0
- package/dist/agents/observer.js +20 -0
- package/dist/agents/oracle.d.ts +1 -0
- package/dist/agents/oracle.js +30 -0
- package/dist/council/council-manager.d.ts +42 -0
- package/dist/council/council-manager.js +223 -0
- package/dist/council/index.d.ts +2 -0
- package/dist/council/index.js +1 -0
- package/dist/hooks/apply-patch-rescue.d.ts +7 -0
- package/dist/hooks/apply-patch-rescue.js +150 -0
- package/dist/hooks/background-job-board.d.ts +92 -0
- package/dist/hooks/background-job-board.js +452 -0
- package/dist/hooks/chat-params.d.ts +16 -0
- package/dist/hooks/chat-params.js +30 -0
- package/dist/hooks/deepwork.d.ts +9 -0
- package/dist/hooks/deepwork.js +55 -0
- package/dist/hooks/error-recovery.d.ts +21 -0
- package/dist/hooks/error-recovery.js +216 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +61 -0
- package/dist/hooks/lazy-command.d.ts +16 -0
- package/dist/hooks/lazy-command.js +178 -0
- package/dist/hooks/messages-transform.d.ts +40 -0
- package/dist/hooks/messages-transform.js +358 -0
- package/dist/hooks/permission-guard.d.ts +5 -0
- package/dist/hooks/permission-guard.js +38 -0
- package/dist/hooks/runtime.d.ts +169 -0
- package/dist/hooks/runtime.js +653 -0
- package/dist/hooks/session-events.d.ts +16 -0
- package/dist/hooks/session-events.js +65 -0
- package/dist/hooks/system-transform.d.ts +8 -0
- package/dist/hooks/system-transform.js +113 -0
- package/dist/hooks/task-session.d.ts +32 -0
- package/dist/hooks/task-session.js +177 -0
- package/dist/hooks/workflow-classifier.d.ts +17 -0
- package/dist/hooks/workflow-classifier.js +170 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +85 -0
- package/dist/opencode-control-plane.d.ts +20 -0
- package/dist/opencode-control-plane.js +95 -0
- package/dist/ponytail.d.ts +1 -0
- package/dist/ponytail.js +33 -0
- package/dist/skills/index.d.ts +5 -0
- package/dist/skills/index.js +10 -0
- package/dist/skills/lazy/build/SKILL.md +62 -0
- package/dist/skills/lazy/debug/SKILL.md +17 -0
- package/dist/skills/lazy/grill/SKILL.md +54 -0
- package/dist/skills/lazy/plan/SKILL.md +52 -0
- package/dist/skills/lazy/review/SKILL.md +29 -0
- package/dist/skills/lazy/security/SKILL.md +29 -0
- package/dist/skills/lazy/simplify/SKILL.md +52 -0
- package/dist/skills/lazy/specify/SKILL.md +62 -0
- package/dist/skills/lazy/worktree/SKILL.md +66 -0
- package/dist/tools/cancel-task.d.ts +3 -0
- package/dist/tools/cancel-task.js +37 -0
- package/dist/tools/council.d.ts +6 -0
- package/dist/tools/council.js +41 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +2 -0
- package/dist/v2.d.ts +1 -0
- package/dist/v2.js +42 -0
- package/docs/architecture.md +47 -0
- package/docs/council.md +200 -0
- package/docs/desktop-distribution.md +36 -0
- package/docs/opencode-integration.md +54 -0
- package/docs/positioning.md +44 -0
- package/docs/product-audit.md +187 -0
- package/docs/product-plan.md +56 -0
- package/docs/state-machine.md +35 -0
- package/docs/user-manual.md +439 -0
- package/docs/work-plan.md +190 -0
- package/package.json +44 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { BackgroundJobBoard } from "./background-job-board.js";
|
|
7
|
+
import { defaultCouncilConfig } from "../council/index.js";
|
|
8
|
+
import { getSkillsDir } from "../skills/index.js";
|
|
9
|
+
export function createLazyRuntime(ctx = {}) {
|
|
10
|
+
const scope = createScope(ctx);
|
|
11
|
+
const config = resolveLazyConfig(undefined, scope);
|
|
12
|
+
const jobBoard = new BackgroundJobBoard({
|
|
13
|
+
maxReusablePerAgent: config.maxSessionsPerAgent,
|
|
14
|
+
});
|
|
15
|
+
const sessionAgentMap = new Map();
|
|
16
|
+
const sessionDepth = new Map();
|
|
17
|
+
const workflow = createEmptyTrace();
|
|
18
|
+
let contextStats = createEmptyContextStats(config.maxMessages);
|
|
19
|
+
let closeReport = createEmptyCloseReport();
|
|
20
|
+
let openCodeSnapshot = createEmptyOpenCodeSnapshot(scope.worktree);
|
|
21
|
+
let doctor = createDoctorState(config);
|
|
22
|
+
let recoveryMessage = null;
|
|
23
|
+
let controlPlane = null;
|
|
24
|
+
const getStatePath = () => {
|
|
25
|
+
if (config.persistence === false)
|
|
26
|
+
return null;
|
|
27
|
+
return config.persistence.path;
|
|
28
|
+
};
|
|
29
|
+
const load = async () => {
|
|
30
|
+
const path = getStatePath();
|
|
31
|
+
if (!path)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
await access(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(path, "utf8");
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
workflow.stage = parsed.trace?.stage ?? "idle";
|
|
43
|
+
workflow.lastDecision = parsed.trace?.lastDecision;
|
|
44
|
+
workflow.recentEvents = parsed.trace?.recentEvents ?? [];
|
|
45
|
+
contextStats = normalizeContextStats(parsed.contextStats, config.maxMessages);
|
|
46
|
+
closeReport = normalizeCloseReport(parsed.closeReport);
|
|
47
|
+
openCodeSnapshot = normalizeOpenCodeSnapshot(parsed.openCodeSnapshot, scope.worktree);
|
|
48
|
+
doctor = normalizeDoctorState(parsed.doctor, config);
|
|
49
|
+
jobBoard.restore(parsed.jobBoard ?? {});
|
|
50
|
+
recoveryMessage = null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
recoveryMessage = "State file was corrupt and ignored.";
|
|
54
|
+
workflow.stage = "idle";
|
|
55
|
+
workflow.lastDecision = undefined;
|
|
56
|
+
workflow.recentEvents = [];
|
|
57
|
+
contextStats = createEmptyContextStats(config.maxMessages);
|
|
58
|
+
closeReport = createEmptyCloseReport();
|
|
59
|
+
openCodeSnapshot = createEmptyOpenCodeSnapshot(scope.worktree);
|
|
60
|
+
doctor = createDoctorState(config);
|
|
61
|
+
jobBoard.clear();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
let saveLock = null;
|
|
65
|
+
const save = async () => {
|
|
66
|
+
const current = saveLock;
|
|
67
|
+
const promise = (async () => {
|
|
68
|
+
if (current)
|
|
69
|
+
await current;
|
|
70
|
+
const path = getStatePath();
|
|
71
|
+
if (!path)
|
|
72
|
+
return;
|
|
73
|
+
const state = {
|
|
74
|
+
version: 1,
|
|
75
|
+
trace: {
|
|
76
|
+
stage: workflow.stage,
|
|
77
|
+
lastDecision: workflow.lastDecision,
|
|
78
|
+
recentEvents: workflow.recentEvents,
|
|
79
|
+
},
|
|
80
|
+
jobBoard: jobBoard.snapshot(),
|
|
81
|
+
contextStats,
|
|
82
|
+
closeReport,
|
|
83
|
+
openCodeSnapshot,
|
|
84
|
+
doctor,
|
|
85
|
+
};
|
|
86
|
+
const dir = dirname(path);
|
|
87
|
+
await mkdir(dir, { recursive: true });
|
|
88
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
89
|
+
})();
|
|
90
|
+
saveLock = promise;
|
|
91
|
+
await promise;
|
|
92
|
+
};
|
|
93
|
+
const reset = async () => {
|
|
94
|
+
workflow.stage = "idle";
|
|
95
|
+
workflow.lastDecision = undefined;
|
|
96
|
+
workflow.recentEvents = [];
|
|
97
|
+
contextStats = createEmptyContextStats(config.maxMessages);
|
|
98
|
+
closeReport = createEmptyCloseReport();
|
|
99
|
+
openCodeSnapshot = createEmptyOpenCodeSnapshot(scope.worktree);
|
|
100
|
+
doctor = createDoctorState(config);
|
|
101
|
+
jobBoard.clear();
|
|
102
|
+
recordEvent("reset", "Runtime state reset.");
|
|
103
|
+
const path = getStatePath();
|
|
104
|
+
if (path) {
|
|
105
|
+
try {
|
|
106
|
+
await access(path);
|
|
107
|
+
await rm(path);
|
|
108
|
+
}
|
|
109
|
+
catch { /* ok */ }
|
|
110
|
+
}
|
|
111
|
+
await save();
|
|
112
|
+
};
|
|
113
|
+
const setMode = async (mode) => {
|
|
114
|
+
config.mode = mode;
|
|
115
|
+
recordEvent("command", `Mode set to ${mode}.`);
|
|
116
|
+
await save();
|
|
117
|
+
};
|
|
118
|
+
const setStage = (stage) => {
|
|
119
|
+
workflow.stage = stage;
|
|
120
|
+
recordEvent("stage", `Stage set to ${stage}.`);
|
|
121
|
+
};
|
|
122
|
+
const recordDecision = async (decision) => {
|
|
123
|
+
workflow.lastDecision = decision;
|
|
124
|
+
if (decision.bypassedByUser) {
|
|
125
|
+
recordEvent("bypass", `${decision.level}: ${decision.reason}`);
|
|
126
|
+
}
|
|
127
|
+
else if (decision.action === "block" || decision.action === "nudge") {
|
|
128
|
+
recordEvent("gate", `${decision.action} ${decision.level}: ${decision.reason}`);
|
|
129
|
+
}
|
|
130
|
+
await save();
|
|
131
|
+
};
|
|
132
|
+
const recordPruning = async (before, after) => {
|
|
133
|
+
if (after >= before)
|
|
134
|
+
return;
|
|
135
|
+
contextStats.maxMessages = config.maxMessages;
|
|
136
|
+
contextStats.lastBefore = before;
|
|
137
|
+
contextStats.lastAfter = after;
|
|
138
|
+
contextStats.lastPrunedAt = Date.now();
|
|
139
|
+
contextStats.totalPruned += before - after;
|
|
140
|
+
await save();
|
|
141
|
+
};
|
|
142
|
+
const refreshOpenCodeSnapshot = async (sessionID) => {
|
|
143
|
+
if (!controlPlane)
|
|
144
|
+
return;
|
|
145
|
+
const snapshot = await controlPlane.snapshot(sessionID);
|
|
146
|
+
openCodeSnapshot = {
|
|
147
|
+
pendingPermissions: snapshot.pendingPermissions,
|
|
148
|
+
todos: snapshot.todos,
|
|
149
|
+
diffSummary: snapshot.diffSummary,
|
|
150
|
+
worktree: snapshot.worktree === "unknown" ? scope.worktree : snapshot.worktree,
|
|
151
|
+
sessionStatus: snapshot.sessionStatus,
|
|
152
|
+
capabilities: snapshot.capabilities,
|
|
153
|
+
lastUpdatedAt: Date.now(),
|
|
154
|
+
};
|
|
155
|
+
if (snapshot.diffSummary !== "not collected") {
|
|
156
|
+
closeReport.behaviorChanges = appendUniqueLimited(closeReport.behaviorChanges, `Diff summary: ${snapshot.diffSummary}`, config.closeReport.maxItems);
|
|
157
|
+
closeReport.updatedAt = Date.now();
|
|
158
|
+
}
|
|
159
|
+
recordEvent("command", "OpenCode control-plane snapshot refreshed.");
|
|
160
|
+
await save();
|
|
161
|
+
};
|
|
162
|
+
const recordOpenCodeEvent = async (event) => {
|
|
163
|
+
const type = String(event.type ?? event.kind ?? "event");
|
|
164
|
+
const value = event.value;
|
|
165
|
+
if (type === "permission" || type === "permissions") {
|
|
166
|
+
openCodeSnapshot.pendingPermissions = Number(value ?? event.count ?? 0);
|
|
167
|
+
}
|
|
168
|
+
else if (type === "todo" || type === "todos") {
|
|
169
|
+
openCodeSnapshot.todos = Number(value ?? event.count ?? 0);
|
|
170
|
+
}
|
|
171
|
+
else if (type === "diff") {
|
|
172
|
+
openCodeSnapshot.diffSummary = String(value ?? event.summary ?? "available");
|
|
173
|
+
}
|
|
174
|
+
else if (type === "worktree") {
|
|
175
|
+
openCodeSnapshot.worktree = String(value ?? event.path ?? scope.worktree);
|
|
176
|
+
}
|
|
177
|
+
else if (type === "session") {
|
|
178
|
+
openCodeSnapshot.sessionStatus = String(value ?? event.status ?? "unknown");
|
|
179
|
+
}
|
|
180
|
+
else if (type === "capability") {
|
|
181
|
+
const capability = String(value ?? event.name ?? "");
|
|
182
|
+
if (capability && !openCodeSnapshot.capabilities.includes(capability)) {
|
|
183
|
+
openCodeSnapshot.capabilities.push(capability);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
openCodeSnapshot.lastUpdatedAt = Date.now();
|
|
187
|
+
recordEvent("command", `OpenCode ${type} snapshot updated.`);
|
|
188
|
+
await save();
|
|
189
|
+
};
|
|
190
|
+
const recordToolEvidence = async (input, output) => {
|
|
191
|
+
if (!config.closeReport.autoCollect)
|
|
192
|
+
return;
|
|
193
|
+
const toolName = String(input.tool ?? input.name ?? input.toolID ?? "");
|
|
194
|
+
const args = (input.arguments ?? input.args ?? {});
|
|
195
|
+
const text = stringifyEvidence(output.output ?? output.result ?? output);
|
|
196
|
+
const command = String(args.command ?? args.cmd ?? "");
|
|
197
|
+
const max = config.closeReport.maxItems;
|
|
198
|
+
if (/bash|shell|terminal/i.test(toolName) && command) {
|
|
199
|
+
if (looksLikeTestCommand(command)) {
|
|
200
|
+
closeReport.testRuns = appendLimited(closeReport.testRuns, {
|
|
201
|
+
command,
|
|
202
|
+
result: looksLikeFailure(text) ? "fail" : "pass",
|
|
203
|
+
}, max);
|
|
204
|
+
}
|
|
205
|
+
if (/npm run verify|deno task verify|pnpm verify|yarn verify/.test(command)) {
|
|
206
|
+
closeReport.verificationResult = looksLikeFailure(text) ? "fail" : "pass";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (/edit|write|patch/i.test(toolName)) {
|
|
210
|
+
const path = String(args.filePath ?? args.file ?? args.path ?? "");
|
|
211
|
+
if (path) {
|
|
212
|
+
closeReport.behaviorChanges = appendUniqueLimited(closeReport.behaviorChanges, `Touched ${path}`, max);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (text && /delete|remove|simplif/i.test(text)) {
|
|
216
|
+
closeReport.deletions = appendUniqueLimited(closeReport.deletions, firstLine(text), max);
|
|
217
|
+
}
|
|
218
|
+
closeReport.updatedAt = Date.now();
|
|
219
|
+
await save();
|
|
220
|
+
};
|
|
221
|
+
const recordCloseEvidence = async (kind, payload) => {
|
|
222
|
+
const max = config.closeReport.maxItems;
|
|
223
|
+
const text = typeof payload === "string" ? payload.trim() : stringifyEvidence(payload).trim();
|
|
224
|
+
if (!text)
|
|
225
|
+
return;
|
|
226
|
+
if (kind === "behavior") {
|
|
227
|
+
closeReport.behaviorChanges = appendUniqueLimited(closeReport.behaviorChanges, text, max);
|
|
228
|
+
}
|
|
229
|
+
else if (kind === "risk") {
|
|
230
|
+
closeReport.remainingRisks = appendUniqueLimited(closeReport.remainingRisks, text, max);
|
|
231
|
+
}
|
|
232
|
+
else if (kind === "deletion") {
|
|
233
|
+
closeReport.deletions = appendUniqueLimited(closeReport.deletions, text, max);
|
|
234
|
+
}
|
|
235
|
+
else if (kind === "verification") {
|
|
236
|
+
if (text === "pass" || text === "fail" || text === "pending") {
|
|
237
|
+
closeReport.verificationResult = text;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (kind === "test") {
|
|
241
|
+
closeReport.testRuns = appendLimited(closeReport.testRuns, {
|
|
242
|
+
command: text,
|
|
243
|
+
result: "unknown",
|
|
244
|
+
}, max);
|
|
245
|
+
}
|
|
246
|
+
closeReport.updatedAt = Date.now();
|
|
247
|
+
await save();
|
|
248
|
+
};
|
|
249
|
+
const recordEvent = (type, summary) => {
|
|
250
|
+
workflow.recentEvents.push({ ts: Date.now(), type, summary });
|
|
251
|
+
workflow.recentEvents = workflow.recentEvents.slice(-50);
|
|
252
|
+
};
|
|
253
|
+
const formatStatus = (sessionID = "") => {
|
|
254
|
+
const lines = ["LazyOpenCode Governed Team Runtime", ""];
|
|
255
|
+
lines.push(`Mode: ${config.mode}`);
|
|
256
|
+
lines.push(`Stage: ${workflow.stage}`);
|
|
257
|
+
lines.push(`Persistence: ${config.persistence === false ? "off" : config.persistence.path}`);
|
|
258
|
+
if (recoveryMessage)
|
|
259
|
+
lines.push(`Recovery: ${recoveryMessage}`);
|
|
260
|
+
if (workflow.lastDecision) {
|
|
261
|
+
const d = workflow.lastDecision;
|
|
262
|
+
lines.push(`Last decision: ${d.action} ${d.level} — ${d.reason}`);
|
|
263
|
+
}
|
|
264
|
+
lines.push("", formatInstallHealth());
|
|
265
|
+
lines.push("", formatTokenControl(contextStats));
|
|
266
|
+
lines.push("", formatOpenCodeSnapshot(openCodeSnapshot));
|
|
267
|
+
const board = sessionID ? jobBoard.formatForPrompt(sessionID) : null;
|
|
268
|
+
lines.push("", board ?? "[Background Job Board]\n No jobs for this session.");
|
|
269
|
+
if (workflow.recentEvents.length > 0) {
|
|
270
|
+
lines.push("", "Recent gate decisions:");
|
|
271
|
+
for (const event of workflow.recentEvents.slice(-8)) {
|
|
272
|
+
lines.push(`- ${event.type}: ${event.summary}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
lines.push("", "Recent gate decisions: none");
|
|
277
|
+
}
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
};
|
|
280
|
+
const formatIsolationAdvice = (decision = workflow.lastDecision) => {
|
|
281
|
+
if (!decision)
|
|
282
|
+
return null;
|
|
283
|
+
if (config.opencode.worktreeIsolation === "off")
|
|
284
|
+
return null;
|
|
285
|
+
const risky = decision.level === "high_risk" || decision.level === "ambiguous";
|
|
286
|
+
if (config.opencode.worktreeIsolation === "risky-only" && !risky)
|
|
287
|
+
return null;
|
|
288
|
+
const hasWorktree = openCodeSnapshot.capabilities.includes("worktree") ||
|
|
289
|
+
openCodeSnapshot.capabilities.includes("projectWorktree");
|
|
290
|
+
const hasRevert = openCodeSnapshot.capabilities.includes("revert") ||
|
|
291
|
+
openCodeSnapshot.capabilities.includes("revertCheckpoint");
|
|
292
|
+
return [
|
|
293
|
+
"Workspace isolation",
|
|
294
|
+
hasWorktree
|
|
295
|
+
? "- available: use isolated OpenCode worktree/project copy before build"
|
|
296
|
+
: "- degraded: OpenCode worktree/project-copy capability not detected",
|
|
297
|
+
hasRevert ? "- revert checkpoints: available" : "- revert checkpoints: not detected",
|
|
298
|
+
"- policy: isolate high-risk or ambiguous work before implementation",
|
|
299
|
+
].join("\n");
|
|
300
|
+
};
|
|
301
|
+
const formatCloseReport = (sessionID = "") => {
|
|
302
|
+
const running = sessionID ? jobBoard.getRunningJobs(sessionID) : [];
|
|
303
|
+
const terminal = sessionID ? jobBoard.getTerminalUnreconciledJobs(sessionID) : [];
|
|
304
|
+
const reusable = sessionID ? jobBoard.getReusableJobs(sessionID) : [];
|
|
305
|
+
const stale = sessionID ? jobBoard.getStaleJobs(sessionID) : [];
|
|
306
|
+
const decision = workflow.lastDecision;
|
|
307
|
+
return [
|
|
308
|
+
"LAZY CLOSE REPORT",
|
|
309
|
+
terminal.length > 0
|
|
310
|
+
? "Close blocked: reconcile terminal jobs first."
|
|
311
|
+
: "Close ready: no terminal unreconciled jobs.",
|
|
312
|
+
"",
|
|
313
|
+
"Workflow",
|
|
314
|
+
`- Stage: ${workflow.stage}`,
|
|
315
|
+
`- Last decision: ${decision ? `${decision.action} ${decision.level} - ${decision.reason}` : "none"}`,
|
|
316
|
+
"",
|
|
317
|
+
"Job summary",
|
|
318
|
+
`- Running jobs: ${running.length}`,
|
|
319
|
+
`- Terminal unreconciled jobs: ${terminal.length}`,
|
|
320
|
+
`- Reusable sessions: ${reusable.length}`,
|
|
321
|
+
`- Stale sessions: ${stale.length}`,
|
|
322
|
+
"",
|
|
323
|
+
"Changed behavior",
|
|
324
|
+
...formatStringList(closeReport.behaviorChanges),
|
|
325
|
+
"Tests run",
|
|
326
|
+
...formatTestRuns(closeReport.testRuns),
|
|
327
|
+
`Verification result: ${closeReport.verificationResult ?? "pending"}`,
|
|
328
|
+
`Terminal jobs reconciled: ${terminal.length === 0 ? "yes" : "no"}`,
|
|
329
|
+
"Remaining risks",
|
|
330
|
+
...formatStringList(closeReport.remainingRisks),
|
|
331
|
+
"Simplifications/deletions",
|
|
332
|
+
...formatStringList(closeReport.deletions),
|
|
333
|
+
].join("\n");
|
|
334
|
+
};
|
|
335
|
+
const formatInstallHealth = () => {
|
|
336
|
+
return [
|
|
337
|
+
"Install health",
|
|
338
|
+
"- agents registered: 8 lazy agents",
|
|
339
|
+
"- skills path registered: yes",
|
|
340
|
+
`- council: ${config.council.enabled ? config.council.eligibility : "disabled"}`,
|
|
341
|
+
`- permission guard: ${config.permissionGuard ? "enabled" : "disabled"}`,
|
|
342
|
+
`- token control: maxMessages ${config.maxMessages}`,
|
|
343
|
+
`- sdk: ${config.sdk.mode} + legacy hooks ${config.sdk.legacyHookAdapter ? "enabled" : "disabled"}`,
|
|
344
|
+
].join("\n");
|
|
345
|
+
};
|
|
346
|
+
const formatDoctorReport = () => {
|
|
347
|
+
const skillsDir = getSkillsDir();
|
|
348
|
+
const distDir = dirname(fileURLToPath(import.meta.url));
|
|
349
|
+
const pluginPackageJson = findUp("package.json", distDir);
|
|
350
|
+
const staleAgents = ["orchestrator", "council-master"].filter((name) => existsSync(join(distDir, "..", "agents", `${name}.js`)) ||
|
|
351
|
+
existsSync(join(distDir, "..", "agents", `${name}.d.ts`)));
|
|
352
|
+
doctor = {
|
|
353
|
+
...doctor,
|
|
354
|
+
v2Registration: config.sdk.mode === "v2",
|
|
355
|
+
legacyHookAdapter: config.sdk.legacyHookAdapter,
|
|
356
|
+
skills: existsSync(skillsDir),
|
|
357
|
+
commands: config.commands.lazy,
|
|
358
|
+
packageReady: pluginPackageJson !== null,
|
|
359
|
+
desktopConfig: findUp(join("apps", "lazyopencode-desktop", "lazyopencode.default.jsonc"), distDir) !==
|
|
360
|
+
null,
|
|
361
|
+
lastCheckedAt: Date.now(),
|
|
362
|
+
};
|
|
363
|
+
const warnings = [...doctor.warnings];
|
|
364
|
+
if (!config.sdk.legacyHookAdapter)
|
|
365
|
+
warnings.push("legacy hook adapter disabled");
|
|
366
|
+
if (!doctor.skills)
|
|
367
|
+
warnings.push(`skills path missing: ${skillsDir}`);
|
|
368
|
+
if (!doctor.packageReady)
|
|
369
|
+
warnings.push("plugin package.json not found");
|
|
370
|
+
if (staleAgents.length > 0)
|
|
371
|
+
warnings.push(`stale agent files: ${staleAgents.join(", ")}`);
|
|
372
|
+
if (config.council.enabled && config.council.eligibility === "always") {
|
|
373
|
+
warnings.push("council eligibility is always; guarded escalation is disabled");
|
|
374
|
+
}
|
|
375
|
+
return [
|
|
376
|
+
"LAZY DOCTOR",
|
|
377
|
+
`- v2 registration: ${doctor.v2Registration ? "ok" : "missing"}`,
|
|
378
|
+
`- legacy hooks: ${doctor.legacyHookAdapter ? "ok" : "disabled"}`,
|
|
379
|
+
`- skills: ${doctor.skills ? "ok" : "missing"}`,
|
|
380
|
+
`- commands: ${doctor.commands ? "ok" : "disabled"}`,
|
|
381
|
+
`- permissions: ${config.permissionGuard ? "guarded" : "unguarded"}`,
|
|
382
|
+
`- package: ${doctor.packageReady ? "ready" : "unknown"}`,
|
|
383
|
+
`- desktop config: ${doctor.desktopConfig ? "detected" : "not detected"}`,
|
|
384
|
+
`- warnings: ${warnings.length === 0 ? "none" : warnings.join("; ")}`,
|
|
385
|
+
].join("\n");
|
|
386
|
+
};
|
|
387
|
+
return {
|
|
388
|
+
get config() {
|
|
389
|
+
return config;
|
|
390
|
+
},
|
|
391
|
+
get scope() {
|
|
392
|
+
return scope;
|
|
393
|
+
},
|
|
394
|
+
get jobBoard() {
|
|
395
|
+
return jobBoard;
|
|
396
|
+
},
|
|
397
|
+
get sessionAgentMap() {
|
|
398
|
+
return sessionAgentMap;
|
|
399
|
+
},
|
|
400
|
+
get sessionDepth() {
|
|
401
|
+
return sessionDepth;
|
|
402
|
+
},
|
|
403
|
+
get workflow() {
|
|
404
|
+
return workflow;
|
|
405
|
+
},
|
|
406
|
+
get contextStats() {
|
|
407
|
+
return contextStats;
|
|
408
|
+
},
|
|
409
|
+
get closeReport() {
|
|
410
|
+
return closeReport;
|
|
411
|
+
},
|
|
412
|
+
get openCodeSnapshot() {
|
|
413
|
+
return openCodeSnapshot;
|
|
414
|
+
},
|
|
415
|
+
get doctor() {
|
|
416
|
+
return doctor;
|
|
417
|
+
},
|
|
418
|
+
get recoveryMessage() {
|
|
419
|
+
return recoveryMessage;
|
|
420
|
+
},
|
|
421
|
+
setControlPlane: (next) => {
|
|
422
|
+
controlPlane = next;
|
|
423
|
+
},
|
|
424
|
+
configure: (input) => {
|
|
425
|
+
Object.assign(config, resolveLazyConfig(input, scope));
|
|
426
|
+
contextStats.maxMessages = config.maxMessages;
|
|
427
|
+
doctor = createDoctorState(config);
|
|
428
|
+
jobBoard.configure({
|
|
429
|
+
maxReusablePerAgent: config.maxSessionsPerAgent,
|
|
430
|
+
});
|
|
431
|
+
},
|
|
432
|
+
load,
|
|
433
|
+
save,
|
|
434
|
+
reset,
|
|
435
|
+
setMode,
|
|
436
|
+
setStage,
|
|
437
|
+
recordDecision,
|
|
438
|
+
recordPruning,
|
|
439
|
+
refreshOpenCodeSnapshot,
|
|
440
|
+
recordOpenCodeEvent,
|
|
441
|
+
recordToolEvidence,
|
|
442
|
+
recordCloseEvidence,
|
|
443
|
+
recordEvent,
|
|
444
|
+
formatIsolationAdvice,
|
|
445
|
+
formatStatus,
|
|
446
|
+
formatCloseReport,
|
|
447
|
+
formatInstallHealth,
|
|
448
|
+
formatDoctorReport,
|
|
449
|
+
getReferenceSnapshot: () => ({
|
|
450
|
+
scope,
|
|
451
|
+
workflow,
|
|
452
|
+
contextStats,
|
|
453
|
+
closeReport,
|
|
454
|
+
openCodeSnapshot,
|
|
455
|
+
doctor,
|
|
456
|
+
}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
export function resolveLazyConfig(input, scope) {
|
|
460
|
+
const persistence = input?.persistence === false ? false : {
|
|
461
|
+
path: input?.persistence?.path ??
|
|
462
|
+
join(homedir() || tmpdir(), ".lazyopencode", "state", `${scope.scopeID}.json`),
|
|
463
|
+
};
|
|
464
|
+
return {
|
|
465
|
+
sdk: {
|
|
466
|
+
mode: "v2",
|
|
467
|
+
legacyHookAdapter: input?.sdk?.legacyHookAdapter ?? true,
|
|
468
|
+
},
|
|
469
|
+
takeover: input?.takeover ?? "governed",
|
|
470
|
+
opencode: {
|
|
471
|
+
sessionStatus: input?.opencode?.sessionStatus ?? true,
|
|
472
|
+
vcsDiff: input?.opencode?.vcsDiff ?? true,
|
|
473
|
+
todos: input?.opencode?.todos ?? true,
|
|
474
|
+
permissions: input?.opencode?.permissions ?? true,
|
|
475
|
+
worktreeIsolation: input?.opencode?.worktreeIsolation ?? "risky-only",
|
|
476
|
+
revertCheckpoints: input?.opencode?.revertCheckpoints ?? true,
|
|
477
|
+
},
|
|
478
|
+
closeReport: {
|
|
479
|
+
autoCollect: input?.closeReport?.autoCollect ?? true,
|
|
480
|
+
maxItems: input?.closeReport?.maxItems ?? 5,
|
|
481
|
+
},
|
|
482
|
+
mode: input?.mode ?? "governor",
|
|
483
|
+
maxSessionsPerAgent: input?.maxSessionsPerAgent ?? 2,
|
|
484
|
+
maxActiveTaskDepth: input?.maxActiveTaskDepth ?? 4,
|
|
485
|
+
maxMessages: input?.maxMessages ?? 80,
|
|
486
|
+
permissionGuard: input?.permissionGuard ?? true,
|
|
487
|
+
persistence,
|
|
488
|
+
workflowGate: input?.workflowGate ?? true,
|
|
489
|
+
ponytailMode: input?.ponytailMode ?? true,
|
|
490
|
+
commands: {
|
|
491
|
+
lazy: input?.commands?.lazy ?? true,
|
|
492
|
+
deepworkAlias: input?.commands?.deepworkAlias ?? true,
|
|
493
|
+
},
|
|
494
|
+
council: defaultCouncilConfig(input?.council),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/** ponytail: simple string hash for scope isolation, not crypto. Upgrade: crypto.subtle when scopeID used for security. */
|
|
498
|
+
function hashScopeID(input) {
|
|
499
|
+
let h = 0;
|
|
500
|
+
for (let i = 0; i < input.length; i++) {
|
|
501
|
+
h = ((h << 5) - h + input.charCodeAt(i)) | 0;
|
|
502
|
+
}
|
|
503
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
504
|
+
}
|
|
505
|
+
function createScope(ctx) {
|
|
506
|
+
const projectRoot = ctx.project?.root ?? ctx.directory ?? process.cwd();
|
|
507
|
+
const worktree = ctx.worktree ?? ctx.project?.worktree ?? ctx.directory ?? projectRoot;
|
|
508
|
+
const scopeID = hashScopeID(`${projectRoot}::${worktree}`);
|
|
509
|
+
return { projectRoot, worktree, scopeID };
|
|
510
|
+
}
|
|
511
|
+
function createEmptyTrace() {
|
|
512
|
+
return { stage: "idle", recentEvents: [] };
|
|
513
|
+
}
|
|
514
|
+
function createEmptyContextStats(maxMessages) {
|
|
515
|
+
return { maxMessages, totalPruned: 0 };
|
|
516
|
+
}
|
|
517
|
+
function createEmptyCloseReport() {
|
|
518
|
+
return {
|
|
519
|
+
behaviorChanges: [],
|
|
520
|
+
testRuns: [],
|
|
521
|
+
remainingRisks: [],
|
|
522
|
+
deletions: [],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function createEmptyOpenCodeSnapshot(worktree) {
|
|
526
|
+
return {
|
|
527
|
+
pendingPermissions: 0,
|
|
528
|
+
todos: 0,
|
|
529
|
+
diffSummary: "not collected",
|
|
530
|
+
worktree,
|
|
531
|
+
sessionStatus: "unknown",
|
|
532
|
+
capabilities: [],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function createDoctorState(config) {
|
|
536
|
+
return {
|
|
537
|
+
v2Registration: config.sdk.mode === "v2",
|
|
538
|
+
legacyHookAdapter: config.sdk.legacyHookAdapter,
|
|
539
|
+
skills: true,
|
|
540
|
+
commands: config.commands.lazy,
|
|
541
|
+
desktopConfig: false,
|
|
542
|
+
packageReady: true,
|
|
543
|
+
warnings: [],
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function normalizeContextStats(input, maxMessages) {
|
|
547
|
+
return {
|
|
548
|
+
maxMessages: input?.maxMessages ?? maxMessages,
|
|
549
|
+
lastBefore: input?.lastBefore,
|
|
550
|
+
lastAfter: input?.lastAfter,
|
|
551
|
+
lastPrunedAt: input?.lastPrunedAt,
|
|
552
|
+
totalPruned: input?.totalPruned ?? 0,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function normalizeCloseReport(input) {
|
|
556
|
+
return {
|
|
557
|
+
behaviorChanges: input?.behaviorChanges ?? [],
|
|
558
|
+
testRuns: input?.testRuns ?? [],
|
|
559
|
+
verificationResult: input?.verificationResult,
|
|
560
|
+
remainingRisks: input?.remainingRisks ?? [],
|
|
561
|
+
deletions: input?.deletions ?? [],
|
|
562
|
+
updatedAt: input?.updatedAt,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function normalizeOpenCodeSnapshot(input, worktree) {
|
|
566
|
+
return {
|
|
567
|
+
pendingPermissions: input?.pendingPermissions ?? 0,
|
|
568
|
+
todos: input?.todos ?? 0,
|
|
569
|
+
diffSummary: input?.diffSummary ?? "not collected",
|
|
570
|
+
worktree: input?.worktree ?? worktree,
|
|
571
|
+
sessionStatus: input?.sessionStatus ?? "unknown",
|
|
572
|
+
capabilities: input?.capabilities ?? [],
|
|
573
|
+
lastUpdatedAt: input?.lastUpdatedAt,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function normalizeDoctorState(input, config) {
|
|
577
|
+
return {
|
|
578
|
+
...createDoctorState(config),
|
|
579
|
+
...input,
|
|
580
|
+
warnings: input?.warnings ?? [],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function formatTokenControl(stats) {
|
|
584
|
+
const lastPrune = stats.lastBefore !== undefined && stats.lastAfter !== undefined
|
|
585
|
+
? `${stats.lastBefore} -> ${stats.lastAfter}`
|
|
586
|
+
: "none";
|
|
587
|
+
return [
|
|
588
|
+
"Token control",
|
|
589
|
+
`- maxMessages: ${stats.maxMessages}`,
|
|
590
|
+
`- last prune: ${lastPrune}`,
|
|
591
|
+
`- total pruned: ${stats.totalPruned}`,
|
|
592
|
+
"- job board mode: full when dirty, mini when clean",
|
|
593
|
+
].join("\n");
|
|
594
|
+
}
|
|
595
|
+
function formatOpenCodeSnapshot(snapshot) {
|
|
596
|
+
return [
|
|
597
|
+
"OpenCode",
|
|
598
|
+
`- session: ${snapshot.sessionStatus}`,
|
|
599
|
+
`- pending permissions: ${snapshot.pendingPermissions}`,
|
|
600
|
+
`- todos: ${snapshot.todos}`,
|
|
601
|
+
`- diff: ${snapshot.diffSummary}`,
|
|
602
|
+
`- worktree: ${snapshot.worktree}`,
|
|
603
|
+
`- capabilities: ${snapshot.capabilities.length > 0 ? snapshot.capabilities.join(", ") : "not collected"}`,
|
|
604
|
+
].join("\n");
|
|
605
|
+
}
|
|
606
|
+
function findUp(filename, startDir) {
|
|
607
|
+
let current = startDir;
|
|
608
|
+
for (let i = 0; i < 8; i++) {
|
|
609
|
+
const candidate = join(current, filename);
|
|
610
|
+
if (existsSync(candidate))
|
|
611
|
+
return candidate;
|
|
612
|
+
const parent = dirname(current);
|
|
613
|
+
if (parent === current)
|
|
614
|
+
break;
|
|
615
|
+
current = parent;
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
function formatStringList(items) {
|
|
620
|
+
if (items.length === 0)
|
|
621
|
+
return ["- none recorded"];
|
|
622
|
+
return items.map((item) => `- ${item}`);
|
|
623
|
+
}
|
|
624
|
+
function formatTestRuns(items) {
|
|
625
|
+
if (items.length === 0)
|
|
626
|
+
return ["- none recorded"];
|
|
627
|
+
return items.map((item) => `- ${item.command}: ${item.result}`);
|
|
628
|
+
}
|
|
629
|
+
function appendLimited(items, item, max) {
|
|
630
|
+
return [...items, item].slice(-Math.max(1, max));
|
|
631
|
+
}
|
|
632
|
+
function appendUniqueLimited(items, item, max) {
|
|
633
|
+
return appendLimited(items.filter((existing) => existing !== item), item, max);
|
|
634
|
+
}
|
|
635
|
+
function stringifyEvidence(value) {
|
|
636
|
+
if (typeof value === "string")
|
|
637
|
+
return value;
|
|
638
|
+
try {
|
|
639
|
+
return JSON.stringify(value);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return String(value);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function looksLikeTestCommand(command) {
|
|
646
|
+
return /\b(test|check|verify|lint|fmt)\b/.test(command);
|
|
647
|
+
}
|
|
648
|
+
function looksLikeFailure(text) {
|
|
649
|
+
return /\b(error|failed|failure|exception|not ok)\b/i.test(text);
|
|
650
|
+
}
|
|
651
|
+
function firstLine(text) {
|
|
652
|
+
return text.split(/\r?\n/).find((line) => line.trim())?.trim() ?? text.trim();
|
|
653
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle hooks — cleanup, reconciliation, and state tracking.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: minimal lifecycle. Only track what affects job board integrity.
|
|
5
|
+
*/
|
|
6
|
+
import type { LazyRuntime } from "./runtime.js";
|
|
7
|
+
/**
|
|
8
|
+
* Combined session event hook — dispatches on event.type.
|
|
9
|
+
* SDK only supports "event" as a single hook, not per-event-type hooks.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createSessionEventsHook(rememberFn: (sid: string) => string[], runtime?: LazyRuntime): (input: {
|
|
12
|
+
event: {
|
|
13
|
+
type: string;
|
|
14
|
+
properties?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
}) => Promise<void>;
|