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,30 @@
|
|
|
1
|
+
export const ORACLE_PROMPT = `<Role>
|
|
2
|
+
You are a strategic technical advisor. You handle architecture decisions, complex debugging, code review, and simplification. You enforce YAGNI. You are a senior dev who has seen every over-engineered mess and been paged at 3am for one.
|
|
3
|
+
</Role>
|
|
4
|
+
|
|
5
|
+
## Core principles
|
|
6
|
+
- **Deletion over addition.** Your first question is always: "what can we delete?"
|
|
7
|
+
- **Simplest thing that works.** Not cleverest. Not most flexible. Simplest.
|
|
8
|
+
- **YAGNI is law.** Speculative abstraction = technical debt, not foresight.
|
|
9
|
+
- **One line verdicts.** Your review output is: finding + fix suggestion. No essays.
|
|
10
|
+
|
|
11
|
+
## When you're called
|
|
12
|
+
- Architecture decisions with long-term impact
|
|
13
|
+
- Problems persisting after 2+ fix attempts
|
|
14
|
+
- High-risk refactors
|
|
15
|
+
- Costly trade-offs (performance vs maintainability)
|
|
16
|
+
- Complex debugging with unclear root cause
|
|
17
|
+
- Code review (load \`lazy/review\` for methodology)
|
|
18
|
+
- Simplification audit (load \`lazy/simplify\` for methodology)
|
|
19
|
+
|
|
20
|
+
## Output format
|
|
21
|
+
1. **Verdict** (one line): what's the call?
|
|
22
|
+
2. **Why** (max 3 lines): critical reasoning only
|
|
23
|
+
3. **What to do** (minimal diff): the change, not the explanation
|
|
24
|
+
|
|
25
|
+
## Anti-patterns you kill on sight
|
|
26
|
+
- Interface with one implementation
|
|
27
|
+
- Factory for one product
|
|
28
|
+
- Config for a value that never changes
|
|
29
|
+
- "We might need this later"
|
|
30
|
+
- Clever code that someone decodes at 3am`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
interface CouncilConfig {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
eligibility?: "guarded" | "always";
|
|
4
|
+
default_preset?: string;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
execution_mode?: "parallel" | "serial";
|
|
7
|
+
retries?: number;
|
|
8
|
+
maxCouncillors?: number;
|
|
9
|
+
presets?: Record<string, Record<string, {
|
|
10
|
+
model?: string;
|
|
11
|
+
prompt?: string;
|
|
12
|
+
}>>;
|
|
13
|
+
}
|
|
14
|
+
interface RequiredCouncilConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
eligibility: "guarded" | "always";
|
|
17
|
+
default_preset: string;
|
|
18
|
+
timeout: number;
|
|
19
|
+
execution_mode: "parallel" | "serial";
|
|
20
|
+
retries: number;
|
|
21
|
+
maxCouncillors: number;
|
|
22
|
+
presets: Record<string, Record<string, {
|
|
23
|
+
model: string;
|
|
24
|
+
prompt?: string;
|
|
25
|
+
}>>;
|
|
26
|
+
}
|
|
27
|
+
interface CouncillorResult {
|
|
28
|
+
name: string;
|
|
29
|
+
status: "success" | "error" | "timeout";
|
|
30
|
+
result?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
interface CouncilOutput {
|
|
34
|
+
success: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
councillorResults: CouncillorResult[];
|
|
37
|
+
formatted: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function defaultCouncilConfig(overrides?: CouncilConfig): RequiredCouncilConfig;
|
|
40
|
+
export declare function runCouncil(prompt: string, client: any, councilConfig: RequiredCouncilConfig, presetName?: string, parentSessionId?: string, abortSignal?: AbortSignal): Promise<CouncilOutput>;
|
|
41
|
+
export declare function formatResults(prompt: string, results: CouncillorResult[]): string;
|
|
42
|
+
export type { CouncilConfig, CouncillorResult, CouncilOutput, RequiredCouncilConfig };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
export function defaultCouncilConfig(overrides) {
|
|
2
|
+
const presets = {
|
|
3
|
+
default: {
|
|
4
|
+
councillor: { model: "", prompt: undefined },
|
|
5
|
+
},
|
|
6
|
+
};
|
|
7
|
+
if (overrides?.presets) {
|
|
8
|
+
for (const [name, councillors] of Object.entries(overrides.presets)) {
|
|
9
|
+
const resolved = {};
|
|
10
|
+
for (const [key, val] of Object.entries(councillors)) {
|
|
11
|
+
resolved[key] = { model: val.model ?? "", prompt: val.prompt };
|
|
12
|
+
}
|
|
13
|
+
presets[name] = resolved;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
enabled: overrides?.enabled ?? true,
|
|
18
|
+
eligibility: overrides?.eligibility ?? "guarded",
|
|
19
|
+
default_preset: overrides?.default_preset ?? "default",
|
|
20
|
+
timeout: overrides?.timeout ?? 180_000,
|
|
21
|
+
execution_mode: overrides?.execution_mode ?? "parallel",
|
|
22
|
+
retries: overrides?.retries ?? 2,
|
|
23
|
+
maxCouncillors: overrides?.maxCouncillors ?? 3,
|
|
24
|
+
presets,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function parseModel(modelStr) {
|
|
28
|
+
if (!modelStr)
|
|
29
|
+
return null;
|
|
30
|
+
const slash = modelStr.indexOf("/");
|
|
31
|
+
if (slash === -1)
|
|
32
|
+
return null;
|
|
33
|
+
return { providerID: modelStr.slice(0, slash), modelID: modelStr.slice(slash + 1) };
|
|
34
|
+
}
|
|
35
|
+
export async function runCouncil(prompt,
|
|
36
|
+
// deno-lint-ignore no-explicit-any
|
|
37
|
+
client, councilConfig, presetName, parentSessionId, abortSignal) {
|
|
38
|
+
if (abortSignal?.aborted) {
|
|
39
|
+
return { success: false, error: "Council aborted", councillorResults: [], formatted: "" };
|
|
40
|
+
}
|
|
41
|
+
if (!councilConfig.enabled) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: "Council is disabled by config",
|
|
45
|
+
councillorResults: [],
|
|
46
|
+
formatted: "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const presetNameResolved = presetName ?? councilConfig.default_preset;
|
|
50
|
+
const preset = councilConfig.presets[presetNameResolved];
|
|
51
|
+
if (!preset) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
error: `Council preset "${presetNameResolved}" not found`,
|
|
55
|
+
councillorResults: [],
|
|
56
|
+
formatted: "",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const entries = Object.entries(preset);
|
|
60
|
+
if (entries.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
error: "No councillors in preset",
|
|
64
|
+
councillorResults: [],
|
|
65
|
+
formatted: "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (entries.length > councilConfig.maxCouncillors) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error: `Council preset "${presetNameResolved}" has ${entries.length} councillors, exceeding maxCouncillors ${councilConfig.maxCouncillors}`,
|
|
72
|
+
councillorResults: [],
|
|
73
|
+
formatted: "",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const runOne = async (name, modelStr, SystemPrompt) => {
|
|
77
|
+
if (abortSignal?.aborted) {
|
|
78
|
+
return { name, status: "error", error: "Aborted" };
|
|
79
|
+
}
|
|
80
|
+
const system = SystemPrompt ??
|
|
81
|
+
"You are a member of a coding council. Provide independent analysis. Be concise. Cite evidence.";
|
|
82
|
+
const model = parseModel(modelStr);
|
|
83
|
+
let sessionId;
|
|
84
|
+
try {
|
|
85
|
+
const createRes = await client.session.create({
|
|
86
|
+
body: { parentID: parentSessionId, title: `council: ${name}` },
|
|
87
|
+
});
|
|
88
|
+
if (createRes.error) {
|
|
89
|
+
return {
|
|
90
|
+
name,
|
|
91
|
+
status: "error",
|
|
92
|
+
error: `Session creation failed: ${JSON.stringify(createRes.error)}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
sessionId = createRes.data.id;
|
|
96
|
+
const promptRes = await client.session.prompt({
|
|
97
|
+
body: {
|
|
98
|
+
agent: "lazy-councillor",
|
|
99
|
+
...(model ? { model } : {}),
|
|
100
|
+
...(system ? { system } : {}),
|
|
101
|
+
parts: [{ type: "text", text: prompt }],
|
|
102
|
+
tools: { read: true, glob: true, grep: true, list: true },
|
|
103
|
+
},
|
|
104
|
+
path: { id: sessionId },
|
|
105
|
+
});
|
|
106
|
+
if (promptRes.error) {
|
|
107
|
+
return { name, status: "error", error: `Prompt failed: ${JSON.stringify(promptRes.error)}` };
|
|
108
|
+
}
|
|
109
|
+
// deno-lint-ignore no-explicit-any
|
|
110
|
+
const textParts = (promptRes.data.parts ?? []).filter((p) => p.type === "text");
|
|
111
|
+
// deno-lint-ignore no-explicit-any
|
|
112
|
+
const result = textParts.map((p) => p.text).join("\n");
|
|
113
|
+
return { name, status: "success", result };
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
return { name, status: "error", error: message };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
if (sessionId) {
|
|
121
|
+
client.session.delete({ path: { id: sessionId } }).catch((err) => {
|
|
122
|
+
console.error("[council] failed to cleanup session:", err);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const startTime = Date.now();
|
|
128
|
+
const timeoutMs = councilConfig.timeout;
|
|
129
|
+
const runAll = async () => {
|
|
130
|
+
if (abortSignal?.aborted) {
|
|
131
|
+
return entries.map(([name]) => ({ name, status: "error", error: "Aborted" }));
|
|
132
|
+
}
|
|
133
|
+
if (councilConfig.execution_mode === "serial") {
|
|
134
|
+
const results = [];
|
|
135
|
+
for (const [name, config] of entries) {
|
|
136
|
+
if (abortSignal?.aborted) {
|
|
137
|
+
results.push({ name, status: "error", error: "Aborted" });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
141
|
+
results.push({ name, status: "timeout", error: "Serial execution timeout" });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
results.push(await runOne(name, config.model, config.prompt));
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
const promises = entries.map(([name, config]) => runOne(name, config.model, config.prompt));
|
|
149
|
+
let timeoutHandle;
|
|
150
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
151
|
+
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
152
|
+
});
|
|
153
|
+
const raceResult = await Promise.race([Promise.allSettled(promises), timeoutPromise]);
|
|
154
|
+
if (timeoutHandle)
|
|
155
|
+
clearTimeout(timeoutHandle);
|
|
156
|
+
if (raceResult === "timeout") {
|
|
157
|
+
return entries.map(([name]) => ({
|
|
158
|
+
name,
|
|
159
|
+
status: "timeout",
|
|
160
|
+
error: "Council timeout exceeded",
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return raceResult.map((r, i) => {
|
|
164
|
+
if (r.status === "fulfilled")
|
|
165
|
+
return r.value;
|
|
166
|
+
const entry = entries[i];
|
|
167
|
+
if (!entry) {
|
|
168
|
+
return { name: "unknown", status: "error", error: "Unknown error" };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
name: entry[0],
|
|
172
|
+
status: "error",
|
|
173
|
+
error: r.reason?.message ?? "Unknown error",
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
const results = await runAll();
|
|
178
|
+
for (let attempt = 0; attempt < councilConfig.retries; attempt++) {
|
|
179
|
+
if (abortSignal?.aborted)
|
|
180
|
+
break;
|
|
181
|
+
const failed = [];
|
|
182
|
+
for (let i = 0; i < results.length; i++) {
|
|
183
|
+
const r = results[i];
|
|
184
|
+
if (r.status !== "success" || !r.result?.trim()) {
|
|
185
|
+
failed.push(i);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (failed.length === 0)
|
|
189
|
+
break;
|
|
190
|
+
if (Date.now() - startTime >= timeoutMs)
|
|
191
|
+
break;
|
|
192
|
+
const retryEntries = failed.map((i) => entries[i]);
|
|
193
|
+
const retryPromises = retryEntries.map(([name, config]) => runOne(name, config.model, config.prompt));
|
|
194
|
+
const retryResults = await Promise.allSettled(retryPromises);
|
|
195
|
+
for (let j = 0; j < failed.length; j++) {
|
|
196
|
+
const rr = retryResults[j];
|
|
197
|
+
if (rr.status === "fulfilled")
|
|
198
|
+
results[failed[j]] = rr.value;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const formatted = formatResults(prompt, results);
|
|
202
|
+
const allSuccess = results.every((r) => r.status === "success");
|
|
203
|
+
return { success: allSuccess, councillorResults: results, formatted };
|
|
204
|
+
}
|
|
205
|
+
export function formatResults(prompt, results) {
|
|
206
|
+
const lines = [
|
|
207
|
+
`# Council Results\n`,
|
|
208
|
+
`Councillors: ${results.length}`,
|
|
209
|
+
`Estimated model calls: ${results.length}\n`,
|
|
210
|
+
`## Question\n${prompt}\n`,
|
|
211
|
+
];
|
|
212
|
+
for (const r of results) {
|
|
213
|
+
lines.push(`## ${r.name}`);
|
|
214
|
+
lines.push(`Status: ${r.status}`);
|
|
215
|
+
if (r.error)
|
|
216
|
+
lines.push(`Error: ${r.error}`);
|
|
217
|
+
if (r.result)
|
|
218
|
+
lines.push(r.result);
|
|
219
|
+
lines.push("");
|
|
220
|
+
}
|
|
221
|
+
lines.push("## Synthesis Required\nReview each councillor's response above and synthesize a final recommendation.");
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defaultCouncilConfig, formatResults, runCouncil } from "./council-manager.js";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hunk offset fixup — rescues apply_patch when the LLM hallucinates line numbers.
|
|
3
|
+
* ponytail: prefix/suffix context matching via substring containment, no full diff parser.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
export function createApplyPatchRescueHook() {
|
|
8
|
+
return async (input, output) => {
|
|
9
|
+
if (input.tool !== "apply_patch")
|
|
10
|
+
return;
|
|
11
|
+
const args = output.args;
|
|
12
|
+
const filePath = args?.file_path;
|
|
13
|
+
const patchContent = args?.content;
|
|
14
|
+
if (!filePath || !patchContent)
|
|
15
|
+
return;
|
|
16
|
+
if (!existsSync(filePath))
|
|
17
|
+
return;
|
|
18
|
+
let fileContents;
|
|
19
|
+
try {
|
|
20
|
+
fileContents = await readFile(filePath, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const fileLines = fileContents.split("\n");
|
|
26
|
+
const fixedPatch = fixHunkOffsets(patchContent, fileLines);
|
|
27
|
+
if (fixedPatch !== patchContent) {
|
|
28
|
+
output.args = { ...args, content: fixedPatch };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Hunk offset fixup
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function fixHunkOffsets(patchContent, fileLines) {
|
|
36
|
+
const lines = patchContent.split("\n");
|
|
37
|
+
const hunkHeaders = lines.filter((line) => line.startsWith("@@")).length;
|
|
38
|
+
if (hunkHeaders !== 1)
|
|
39
|
+
return patchContent;
|
|
40
|
+
const result = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < lines.length) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
const hunkMatch = line.match(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)/);
|
|
45
|
+
if (!hunkMatch) {
|
|
46
|
+
result.push(line);
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
51
|
+
const oldCount = hunkMatch[2];
|
|
52
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
53
|
+
const newCount = hunkMatch[4];
|
|
54
|
+
const section = hunkMatch[5];
|
|
55
|
+
if (oldStart !== newStart)
|
|
56
|
+
return patchContent;
|
|
57
|
+
// Collect hunk body — lines between header and next @@ or EOF
|
|
58
|
+
const bodyLines = [];
|
|
59
|
+
i++;
|
|
60
|
+
while (i < lines.length && !lines[i].startsWith("@@")) {
|
|
61
|
+
bodyLines.push(lines[i]);
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
if (bodyLines.length === 0) {
|
|
65
|
+
result.push(line);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const adjustedStart = findBestMatch(fileLines, bodyLines, oldStart);
|
|
69
|
+
if (adjustedStart === -1) {
|
|
70
|
+
// No match — leave hunk alone
|
|
71
|
+
result.push(line);
|
|
72
|
+
result.push(...bodyLines);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const header = `@@ -${adjustedStart},${oldCount} +${adjustedStart},${newCount} @@` +
|
|
76
|
+
(section ? ` ${section}` : "");
|
|
77
|
+
result.push(header);
|
|
78
|
+
result.push(...bodyLines);
|
|
79
|
+
}
|
|
80
|
+
return result.join("\n");
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Context matching
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
function findBestMatch(fileLines, hunkBody, estLine) {
|
|
86
|
+
const prefix = getFirstContextLine(hunkBody);
|
|
87
|
+
const suffix = getLastContextLine(hunkBody);
|
|
88
|
+
// Try prefix first
|
|
89
|
+
if (prefix) {
|
|
90
|
+
const match = findLineNear(fileLines, prefix, estLine);
|
|
91
|
+
if (match !== -1)
|
|
92
|
+
return match;
|
|
93
|
+
}
|
|
94
|
+
// Try suffix if different from prefix
|
|
95
|
+
if (suffix && suffix !== prefix) {
|
|
96
|
+
const match = findLineNear(fileLines, suffix, estLine);
|
|
97
|
+
if (match !== -1)
|
|
98
|
+
return match;
|
|
99
|
+
}
|
|
100
|
+
return -1;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* First non-empty line in hunk body that is context (not + or - prefixed).
|
|
104
|
+
*/
|
|
105
|
+
function getFirstContextLine(body) {
|
|
106
|
+
for (const line of body) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (!trimmed)
|
|
109
|
+
continue;
|
|
110
|
+
if (trimmed.startsWith("+") || trimmed.startsWith("-"))
|
|
111
|
+
continue;
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Last non-empty line in hunk body that is context (not + or - prefixed).
|
|
118
|
+
*/
|
|
119
|
+
function getLastContextLine(body) {
|
|
120
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
121
|
+
const trimmed = body[i].trim();
|
|
122
|
+
if (!trimmed)
|
|
123
|
+
continue;
|
|
124
|
+
if (trimmed.startsWith("+") || trimmed.startsWith("-"))
|
|
125
|
+
continue;
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Search +/- 50 lines of estLine (1-indexed) for a line containing `search`.
|
|
132
|
+
* Returns 1-indexed line number or -1.
|
|
133
|
+
*/
|
|
134
|
+
function findLineNear(fileLines, search, estLine) {
|
|
135
|
+
const estLine0 = estLine - 1;
|
|
136
|
+
const searchStart = Math.max(0, estLine0 - 50);
|
|
137
|
+
const searchEnd = Math.min(fileLines.length, estLine0 + 50);
|
|
138
|
+
let bestIndex = -1;
|
|
139
|
+
let bestDist = Infinity;
|
|
140
|
+
for (let i = searchStart; i < searchEnd; i++) {
|
|
141
|
+
if (fileLines[i].trim() === search.trim() || fileLines[i].includes(search)) {
|
|
142
|
+
const dist = Math.abs(i - estLine0);
|
|
143
|
+
if (dist < bestDist) {
|
|
144
|
+
bestDist = dist;
|
|
145
|
+
bestIndex = i;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return bestIndex === -1 ? -1 : bestIndex + 1;
|
|
150
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundJobBoard — in-memory state machine for subagent task tracking.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: global singletons, no DI. Upgrade: per-session isolation if starvation occurs.
|
|
5
|
+
*/
|
|
6
|
+
export type JobState = "running" | "completed" | "error" | "cancelled" | "reconciled" | "stale";
|
|
7
|
+
export interface ContextFile {
|
|
8
|
+
path: string;
|
|
9
|
+
lineCount: number;
|
|
10
|
+
}
|
|
11
|
+
export interface BackgroundJobRecord {
|
|
12
|
+
taskID: string;
|
|
13
|
+
parentSessionID: string;
|
|
14
|
+
agent: string;
|
|
15
|
+
state: JobState;
|
|
16
|
+
terminalUnreconciled: boolean;
|
|
17
|
+
timedOut: boolean;
|
|
18
|
+
cancellationRequested: boolean;
|
|
19
|
+
alias: string;
|
|
20
|
+
callID: string;
|
|
21
|
+
resultSummary?: string;
|
|
22
|
+
contextFiles: ContextFile[];
|
|
23
|
+
launchedAt: number;
|
|
24
|
+
lastLaunchedAt: number;
|
|
25
|
+
lastUsedAt: number;
|
|
26
|
+
updatedAt: number;
|
|
27
|
+
completedAt: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class BackgroundJobBoard {
|
|
30
|
+
private jobs;
|
|
31
|
+
private pendingCalls;
|
|
32
|
+
private agentCounter;
|
|
33
|
+
private processedCompletions;
|
|
34
|
+
private injectedCompletionsSeen;
|
|
35
|
+
private maxReusablePerAgent;
|
|
36
|
+
private dirty;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
maxReusablePerAgent?: number;
|
|
39
|
+
});
|
|
40
|
+
configure(options: {
|
|
41
|
+
maxReusablePerAgent?: number;
|
|
42
|
+
}): void;
|
|
43
|
+
registerLaunch(parentSessionID: string, agent: string, callID: string): BackgroundJobRecord;
|
|
44
|
+
findJobByCallID(callID: string): BackgroundJobRecord | undefined;
|
|
45
|
+
findJobByTaskID(taskID: string): BackgroundJobRecord | undefined;
|
|
46
|
+
findJobByAlias(alias: string): BackgroundJobRecord | undefined;
|
|
47
|
+
updateStatus(callID: string, taskID: string, state: JobState, resultSummary?: string): void;
|
|
48
|
+
addContext(taskID: string, file: ContextFile): void;
|
|
49
|
+
markReconciled(taskID: string): void;
|
|
50
|
+
trimReusable(taskID: string): void;
|
|
51
|
+
getTerminalUnreconciledJobs(parentSessionID: string): BackgroundJobRecord[];
|
|
52
|
+
getRunningJobs(parentSessionID: string): BackgroundJobRecord[];
|
|
53
|
+
getStaleJobs(parentSessionID: string): BackgroundJobRecord[];
|
|
54
|
+
getReusableJobs(parentSessionID: string): BackgroundJobRecord[];
|
|
55
|
+
resolveReusable(parentSessionID: string, agent: string): BackgroundJobRecord | undefined;
|
|
56
|
+
getReusableJob(taskID: string): BackgroundJobRecord | undefined;
|
|
57
|
+
getActiveCount(parentSessionID: string, agent: string): number;
|
|
58
|
+
isLateCancelledTaskError(callID: string): boolean;
|
|
59
|
+
cancelJob(id: string): void;
|
|
60
|
+
formatForPrompt(parentSessionID: string): string | null;
|
|
61
|
+
isDirty(): boolean;
|
|
62
|
+
markClean(): void;
|
|
63
|
+
formatMini(parentSessionID: string): string | null;
|
|
64
|
+
isInjectedCompletionProcessed(id: string): boolean;
|
|
65
|
+
markInjectedCompletionSeen(taskID: string): void;
|
|
66
|
+
dropSession(sessionID: string): void;
|
|
67
|
+
clear(): void;
|
|
68
|
+
snapshot(): {
|
|
69
|
+
jobs: BackgroundJobRecord[];
|
|
70
|
+
pendingCalls: Array<{
|
|
71
|
+
callID: string;
|
|
72
|
+
sessionID: string;
|
|
73
|
+
alias?: string;
|
|
74
|
+
}>;
|
|
75
|
+
agentCounter: Array<[string, number]>;
|
|
76
|
+
processedCompletions: string[];
|
|
77
|
+
injectedCompletionsSeen: string[];
|
|
78
|
+
};
|
|
79
|
+
restore(snapshot: {
|
|
80
|
+
jobs?: BackgroundJobRecord[];
|
|
81
|
+
pendingCalls?: Array<{
|
|
82
|
+
callID: string;
|
|
83
|
+
sessionID: string;
|
|
84
|
+
alias?: string;
|
|
85
|
+
}>;
|
|
86
|
+
agentCounter?: Array<[string, number]>;
|
|
87
|
+
processedCompletions?: string[];
|
|
88
|
+
injectedCompletionsSeen?: string[];
|
|
89
|
+
}): void;
|
|
90
|
+
get size(): number;
|
|
91
|
+
}
|
|
92
|
+
export declare const jobBoard: BackgroundJobBoard;
|