@longshot/cli 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/LICENSE +8 -0
- package/dist/agent.js +214 -0
- package/dist/cli.js +172 -0
- package/dist/git.js +291 -0
- package/dist/index.js +1250 -0
- package/dist/profile.js +79 -0
- package/dist/projects.js +337 -0
- package/dist/queue.js +868 -0
- package/dist/services.js +194 -0
- package/dist/store.js +612 -0
- package/dist/views/agent-progress.js +242 -0
- package/dist/views/branches.js +191 -0
- package/dist/views/chat.js +386 -0
- package/dist/views/diff.js +321 -0
- package/dist/views/history.js +124 -0
- package/dist/views/layout.js +121 -0
- package/dist/views/run.js +92 -0
- package/dist/views/services.js +230 -0
- package/dist/views/spec.js +18 -0
- package/dist/views/tasks.js +898 -0
- package/dist/views/verify.js +209 -0
- package/package.json +36 -0
- package/public/style.css +2088 -0
package/dist/queue.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import * as store from "./store.js";
|
|
2
|
+
import * as git from "./git.js";
|
|
3
|
+
import { spawnAgent } from "./agent.js";
|
|
4
|
+
import { getProjectRoot } from "./projects.js";
|
|
5
|
+
import { writeFile, readFile as fsReadFile, unlink } from "node:fs/promises";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
async function readAgentStatus() {
|
|
9
|
+
const path = join(getProjectRoot(), ".longshot", "status.json");
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const content = await fsReadFile(path, "utf-8");
|
|
14
|
+
return JSON.parse(content);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function clearAgentStatus() {
|
|
21
|
+
const path = join(getProjectRoot(), ".longshot", "status.json");
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
await unlink(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// --- CLAUDE.md generation ---
|
|
27
|
+
async function writeCLAUDEmd(spec, activeTask) {
|
|
28
|
+
const taskSection = activeTask
|
|
29
|
+
? `
|
|
30
|
+
## Active Task: #${activeTask.meta.id} — ${activeTask.meta.title}
|
|
31
|
+
|
|
32
|
+
Status: ${activeTask.meta.status}
|
|
33
|
+
|
|
34
|
+
Task spec:
|
|
35
|
+
---
|
|
36
|
+
${activeTask.spec}
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
When working on this task:
|
|
40
|
+
- Follow the task spec above
|
|
41
|
+
- When the task is complete, update .longshot/spec.md (the project spec) to reflect what was built
|
|
42
|
+
- Update the task spec file at .longshot/tasks/${String(activeTask.meta.id).padStart(3, "0")}-${activeTask.meta.slug}.md if the approach changes
|
|
43
|
+
`
|
|
44
|
+
: `
|
|
45
|
+
## No active task
|
|
46
|
+
|
|
47
|
+
The user is either discussing the project, writing the project spec, or creating a new task.
|
|
48
|
+
- If they describe features or architecture: update .longshot/spec.md
|
|
49
|
+
- If they want to start a specific piece of work: create a task spec in .longshot/tasks/ and add it to .longshot/tasks.json
|
|
50
|
+
`;
|
|
51
|
+
const claudeMd = `# Project: longshot managed project
|
|
52
|
+
|
|
53
|
+
## Project Spec (source of truth)
|
|
54
|
+
|
|
55
|
+
${spec || "(No project spec yet — the user will describe what they want.)"}
|
|
56
|
+
|
|
57
|
+
${taskSection}
|
|
58
|
+
|
|
59
|
+
## File structure for project management
|
|
60
|
+
|
|
61
|
+
- .longshot/spec.md — Top-level project spec. The "what this project IS" document.
|
|
62
|
+
- .longshot/tasks.json — Task index: array of {id, slug, title, status, created, completed?, checkpoint?}
|
|
63
|
+
- .longshot/tasks/NNN-slug.md — Individual task specs. Each task has its own spec with scope, approach, acceptance criteria.
|
|
64
|
+
- .longshot/history.jsonl — Append-only action log.
|
|
65
|
+
- .longshot/status.json — Write this after every action (see below).
|
|
66
|
+
|
|
67
|
+
## Task workflow
|
|
68
|
+
|
|
69
|
+
1. User describes work → you create a task spec file + add entry to tasks.json with status "drafting"
|
|
70
|
+
2. User reviews/amends → you update the task spec. When agreed, status becomes "ready"
|
|
71
|
+
3. User says go → status becomes "in_progress", you implement against the task spec
|
|
72
|
+
4. User reviews diff → approve means checkpoint + project spec updated + task marked "complete"
|
|
73
|
+
|
|
74
|
+
## Working style
|
|
75
|
+
|
|
76
|
+
- You are being driven by a mobile web UI. The user types short messages from their phone.
|
|
77
|
+
- Read the project spec and active task before doing anything.
|
|
78
|
+
- Keep responses concise — the user is reading on a small screen.
|
|
79
|
+
- When implementing: stay focused on the task spec. Don't scope-creep.
|
|
80
|
+
- Be decisive. State what you did, don't ask rhetorical questions. Never say "Would you like me to...", "Should I...", or "Do you want...". Just do it. If something is ambiguous, pick the best option and state your choice.
|
|
81
|
+
|
|
82
|
+
## IMPORTANT: Populate your task list immediately
|
|
83
|
+
|
|
84
|
+
Before doing any file reads, searches, or writes, your VERY FIRST action must be to use the TodoWrite tool to create a task list for what you plan to do. This lets the user see your plan immediately in the agent progress view. Update the list as you work — mark items in_progress when you start them and completed when done.
|
|
85
|
+
|
|
86
|
+
## After every action
|
|
87
|
+
|
|
88
|
+
Always write .longshot/status.json when you're done:
|
|
89
|
+
|
|
90
|
+
\`\`\`json
|
|
91
|
+
{
|
|
92
|
+
"summary": "One-line description of what you did",
|
|
93
|
+
"specUpdated": true or false,
|
|
94
|
+
"taskId": null or task number,
|
|
95
|
+
"filesChanged": ["list", "of", "files", "you", "modified"],
|
|
96
|
+
"actions": ["Short description of each action taken"]
|
|
97
|
+
}
|
|
98
|
+
\`\`\`
|
|
99
|
+
`;
|
|
100
|
+
await writeFile(join(getProjectRoot(), "CLAUDE.md"), claudeMd, "utf-8");
|
|
101
|
+
}
|
|
102
|
+
function getPendingDiffsPath() {
|
|
103
|
+
return join(getProjectRoot(), ".longshot", "pending-diffs.json");
|
|
104
|
+
}
|
|
105
|
+
function loadPendingDiffs() {
|
|
106
|
+
try {
|
|
107
|
+
const path = getPendingDiffsPath();
|
|
108
|
+
if (existsSync(path)) {
|
|
109
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
110
|
+
return new Map(Object.entries(data));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
return new Map();
|
|
115
|
+
}
|
|
116
|
+
function savePendingDiffs(diffs) {
|
|
117
|
+
const obj = Object.fromEntries(diffs);
|
|
118
|
+
writeFileSync(getPendingDiffsPath(), JSON.stringify(obj, null, 2), "utf-8");
|
|
119
|
+
}
|
|
120
|
+
// The shared pending diffs map — imported by index.ts
|
|
121
|
+
export const pendingDiffs = loadPendingDiffs();
|
|
122
|
+
export function reloadPendingDiffs() {
|
|
123
|
+
const fresh = loadPendingDiffs();
|
|
124
|
+
pendingDiffs.clear();
|
|
125
|
+
for (const [k, v] of fresh) {
|
|
126
|
+
pendingDiffs.set(k, v);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export { savePendingDiffs as persistPendingDiffs };
|
|
130
|
+
// --- Extract checks from run events ---
|
|
131
|
+
const TEST_COMMAND_PATTERNS = [
|
|
132
|
+
/\bnpm\s+test\b/,
|
|
133
|
+
/\bnpx\s+jest\b/,
|
|
134
|
+
/\bnpx\s+vitest\b/,
|
|
135
|
+
/\bpytest\b/,
|
|
136
|
+
/\bcargo\s+test\b/,
|
|
137
|
+
/\bgo\s+test\b/,
|
|
138
|
+
/\bmake\s+test\b/,
|
|
139
|
+
/\bnode\s+--test\b/,
|
|
140
|
+
/\bnpx\s+tsx\s+--test\b/,
|
|
141
|
+
];
|
|
142
|
+
async function extractChecksFromRun(runId) {
|
|
143
|
+
const events = await store.readRunLog(runId);
|
|
144
|
+
const checks = { testsRun: false };
|
|
145
|
+
// Track tool_use → tool_result pairs for test detection
|
|
146
|
+
const bashToolUses = new Map(); // id → command
|
|
147
|
+
let lastTodoItems;
|
|
148
|
+
for (const event of events) {
|
|
149
|
+
if (event.type === "tool_use" && event.name === "Bash" && event.input?.command) {
|
|
150
|
+
const cmd = event.input.command;
|
|
151
|
+
if (TEST_COMMAND_PATTERNS.some((p) => p.test(cmd))) {
|
|
152
|
+
bashToolUses.set(event.id, cmd);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (event.type === "tool_result" && bashToolUses.has(event.id)) {
|
|
156
|
+
checks.testsRun = true;
|
|
157
|
+
const output = event.content || "";
|
|
158
|
+
// Truncate test output for storage
|
|
159
|
+
checks.testOutput = output.slice(0, 2000);
|
|
160
|
+
// Heuristic: check for common failure indicators
|
|
161
|
+
const hasFailure = /\bfail(ed|ure|ing)?\b/i.test(output) &&
|
|
162
|
+
!/\bfail(ed|ure|s)?\s*(0|:?\s*0)\b/i.test(output) &&
|
|
163
|
+
!/0\s+fail/i.test(output);
|
|
164
|
+
const hasPass = /\b(pass(ed)?|ok|success)\b/i.test(output) ||
|
|
165
|
+
/0\s+fail/i.test(output) ||
|
|
166
|
+
/\bfail(ed|ure|s)?\s*(0|:?\s*0)\b/i.test(output);
|
|
167
|
+
checks.testsPassed = hasPass && !hasFailure;
|
|
168
|
+
}
|
|
169
|
+
if (event.type === "tool_use" && event.name === "TodoWrite" && event.input?.todos) {
|
|
170
|
+
lastTodoItems = event.input.todos.map((t) => ({ content: t.content, status: t.status }));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (lastTodoItems) {
|
|
174
|
+
checks.todoItems = lastTodoItems;
|
|
175
|
+
}
|
|
176
|
+
return checks;
|
|
177
|
+
}
|
|
178
|
+
// --- Queue state ---
|
|
179
|
+
let processing = false;
|
|
180
|
+
let currentRunId = null;
|
|
181
|
+
let agentRunning = false;
|
|
182
|
+
export function getQueueState() {
|
|
183
|
+
return { processing, currentRunId, agentRunning };
|
|
184
|
+
}
|
|
185
|
+
async function runAgent(prompt, label, activeTask, taskId, opts) {
|
|
186
|
+
const spec = await store.readSpec();
|
|
187
|
+
await writeCLAUDEmd(spec, activeTask);
|
|
188
|
+
await clearAgentStatus();
|
|
189
|
+
await git.ensureRepo(getProjectRoot());
|
|
190
|
+
const snapshotHash = await git.snapshot(getProjectRoot());
|
|
191
|
+
const runId = store.generateRunId();
|
|
192
|
+
await store.appendRunEvent(runId, {
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
type: "user_message",
|
|
195
|
+
content: prompt,
|
|
196
|
+
});
|
|
197
|
+
await store.appendHistory({
|
|
198
|
+
timestamp: new Date().toISOString(),
|
|
199
|
+
type: "agent_spawn",
|
|
200
|
+
summary: label,
|
|
201
|
+
details: `Run: ${runId}`,
|
|
202
|
+
});
|
|
203
|
+
agentRunning = true;
|
|
204
|
+
currentRunId = runId;
|
|
205
|
+
try {
|
|
206
|
+
const agent = spawnAgent(prompt, getProjectRoot());
|
|
207
|
+
let fullText = "";
|
|
208
|
+
for await (const event of agent.events) {
|
|
209
|
+
await store.appendRunEvent(runId, {
|
|
210
|
+
ts: new Date().toISOString(),
|
|
211
|
+
...event,
|
|
212
|
+
});
|
|
213
|
+
if (event.type === "text") {
|
|
214
|
+
fullText += event.content;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Save assistant response to conversation
|
|
218
|
+
if (fullText) {
|
|
219
|
+
const messages = await store.readConversation();
|
|
220
|
+
messages.push({
|
|
221
|
+
role: "assistant",
|
|
222
|
+
content: fullText,
|
|
223
|
+
timestamp: new Date().toISOString(),
|
|
224
|
+
runId,
|
|
225
|
+
});
|
|
226
|
+
await store.saveConversation(messages);
|
|
227
|
+
}
|
|
228
|
+
// Check for diff (excludes .longshot/)
|
|
229
|
+
const status = await readAgentStatus();
|
|
230
|
+
const diffContent = await git.diff(getProjectRoot(), snapshotHash);
|
|
231
|
+
const summary = status?.summary || label;
|
|
232
|
+
// For autoCommit runs (spec-update, draft, refine), also check for .longshot/ changes
|
|
233
|
+
// since diff() excludes them but they're the expected output
|
|
234
|
+
const hasSourceChanges = diffContent.trim().length > 0;
|
|
235
|
+
const hasAnyChanges = hasSourceChanges || (opts?.autoCommit && await git.hasUncommittedChanges(getProjectRoot()));
|
|
236
|
+
if (hasAnyChanges) {
|
|
237
|
+
if (opts?.skipDiff) {
|
|
238
|
+
// Keep file changes on disk but don't commit or create a pending diff
|
|
239
|
+
await store.appendRunEvent(runId, {
|
|
240
|
+
ts: new Date().toISOString(),
|
|
241
|
+
type: "agent_done_no_commit",
|
|
242
|
+
summary,
|
|
243
|
+
});
|
|
244
|
+
await store.appendHistory({
|
|
245
|
+
timestamp: new Date().toISOString(),
|
|
246
|
+
type: "agent_complete",
|
|
247
|
+
summary: `Agent completed (changes on disk): ${summary}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else if (opts?.autoCommit) {
|
|
251
|
+
const commitHash = await git.commit(getProjectRoot(), `checkpoint: ${summary}`);
|
|
252
|
+
await store.appendRunEvent(runId, {
|
|
253
|
+
ts: new Date().toISOString(),
|
|
254
|
+
type: "auto_committed",
|
|
255
|
+
summary,
|
|
256
|
+
commitHash,
|
|
257
|
+
});
|
|
258
|
+
await store.appendHistory({
|
|
259
|
+
timestamp: new Date().toISOString(),
|
|
260
|
+
type: "checkpoint",
|
|
261
|
+
summary: `Auto-committed: ${summary}`,
|
|
262
|
+
details: `commit: ${commitHash}, from: ${snapshotHash}`,
|
|
263
|
+
});
|
|
264
|
+
if (opts.onAutoCommit) {
|
|
265
|
+
await opts.onAutoCommit(commitHash);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Create pending diff and stash source changes for isolation
|
|
270
|
+
const diffId = crypto.randomUUID();
|
|
271
|
+
const stashRef = await git.stashSave(getProjectRoot(), `pending-${diffId}`);
|
|
272
|
+
// Capture agent summary and checks at diff-creation time
|
|
273
|
+
const agentSummary = status
|
|
274
|
+
? {
|
|
275
|
+
summary: status.summary || summary,
|
|
276
|
+
filesChanged: status.filesChanged || [],
|
|
277
|
+
actions: status.actions || [],
|
|
278
|
+
}
|
|
279
|
+
: undefined;
|
|
280
|
+
const checks = await extractChecksFromRun(runId);
|
|
281
|
+
pendingDiffs.set(diffId, {
|
|
282
|
+
cwd: getProjectRoot(),
|
|
283
|
+
snapshotHash,
|
|
284
|
+
task: summary,
|
|
285
|
+
taskId: taskId,
|
|
286
|
+
diffContent,
|
|
287
|
+
stashRef: stashRef || undefined,
|
|
288
|
+
agentSummary,
|
|
289
|
+
checks,
|
|
290
|
+
});
|
|
291
|
+
savePendingDiffs(pendingDiffs);
|
|
292
|
+
await store.appendRunEvent(runId, {
|
|
293
|
+
ts: new Date().toISOString(),
|
|
294
|
+
type: "diff_ready",
|
|
295
|
+
diffId,
|
|
296
|
+
summary,
|
|
297
|
+
});
|
|
298
|
+
await store.appendHistory({
|
|
299
|
+
timestamp: new Date().toISOString(),
|
|
300
|
+
type: "agent_complete",
|
|
301
|
+
summary: `Agent completed: ${summary}`,
|
|
302
|
+
details: `Diff: ${diffId}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await store.appendRunEvent(runId, {
|
|
308
|
+
ts: new Date().toISOString(),
|
|
309
|
+
type: "agent_done_no_changes",
|
|
310
|
+
summary: summary,
|
|
311
|
+
});
|
|
312
|
+
await store.appendHistory({
|
|
313
|
+
timestamp: new Date().toISOString(),
|
|
314
|
+
type: "agent_complete",
|
|
315
|
+
summary: `Agent completed (no file changes): ${summary}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return { runId };
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.error(`Agent error:`, err);
|
|
322
|
+
await store.appendRunEvent(runId, {
|
|
323
|
+
ts: new Date().toISOString(),
|
|
324
|
+
type: "error",
|
|
325
|
+
message: err.message,
|
|
326
|
+
});
|
|
327
|
+
return { runId, error: err.message };
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
agentRunning = false;
|
|
331
|
+
currentRunId = null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// --- Process a single queue item ---
|
|
335
|
+
async function processItem(item) {
|
|
336
|
+
await store.updateQueueItem(item.id, {
|
|
337
|
+
status: "running",
|
|
338
|
+
startedAt: new Date().toISOString(),
|
|
339
|
+
});
|
|
340
|
+
try {
|
|
341
|
+
let result;
|
|
342
|
+
switch (item.type) {
|
|
343
|
+
case "draft-task": {
|
|
344
|
+
const taskId = item.params.taskId;
|
|
345
|
+
const taskSlug = item.params.taskSlug;
|
|
346
|
+
const padId = taskId.padStart(3, "0");
|
|
347
|
+
const prompt = `${item.params.message}
|
|
348
|
+
|
|
349
|
+
Create a task spec for the above request. A task entry already exists in tasks.json (id: ${taskId}, slug: "${taskSlug}"). Update the task spec file at .longshot/tasks/${padId}-${taskSlug}.md with the full spec. Update the title in tasks.json if needed. Do not create a new entry.
|
|
350
|
+
|
|
351
|
+
Context available if needed:
|
|
352
|
+
- .longshot/spec.md — project spec
|
|
353
|
+
- .longshot/tasks.json — existing tasks
|
|
354
|
+
- .longshot/conversation.json — chat history`;
|
|
355
|
+
const activeTask = taskId ? await store.getTask(taskId) : undefined;
|
|
356
|
+
result = await runAgent(prompt, `Draft task: ${item.params.message.slice(0, 80)}`, activeTask, taskId, { autoCommit: true });
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case "refine-task": {
|
|
360
|
+
const task = await store.getTask(item.params.taskId);
|
|
361
|
+
if (!task) {
|
|
362
|
+
await store.updateQueueItem(item.id, {
|
|
363
|
+
status: "failed",
|
|
364
|
+
error: "Task not found",
|
|
365
|
+
completedAt: new Date().toISOString(),
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// Save "Refining..." message to task conversation before agent starts
|
|
370
|
+
const refineMessage = item.params.message || "Refine this task spec based on recent chat feedback.";
|
|
371
|
+
let taskConvBefore = await store.readTaskConversation(item.params.taskId);
|
|
372
|
+
taskConvBefore.push({
|
|
373
|
+
role: "assistant",
|
|
374
|
+
content: `Refining the spec based on your feedback...`,
|
|
375
|
+
timestamp: new Date().toISOString(),
|
|
376
|
+
});
|
|
377
|
+
taskConvBefore = await store.archiveTaskConversationIfNeeded(item.params.taskId, taskConvBefore);
|
|
378
|
+
await store.saveTaskConversation(item.params.taskId, taskConvBefore);
|
|
379
|
+
// Include task-local conversation context in the prompt
|
|
380
|
+
const taskConversation = await store.readTaskConversation(item.params.taskId);
|
|
381
|
+
const recentTaskChat = taskConversation.slice(-20);
|
|
382
|
+
const taskChatContext = recentTaskChat.length > 0
|
|
383
|
+
? `\nRecent task conversation:\n${recentTaskChat.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n\n")}\n`
|
|
384
|
+
: "";
|
|
385
|
+
const prompt = `${refineMessage}
|
|
386
|
+
${taskChatContext}
|
|
387
|
+
Refine the task spec for task #${item.params.taskId}: ${task.meta.title}. Update the task spec file. Keep the task in "drafting" status.
|
|
388
|
+
|
|
389
|
+
IMPORTANT: Be decisive. When the user gives feedback or direction, make decisions and state what you did. Do NOT ask rhetorical questions like "Would you like me to...", "Should I...", "Do you want me to...". The user is on mobile and wants you to act, not ask. If something is ambiguous, pick the most reasonable option, apply it, and state what you chose. The user can always refine further if they disagree.
|
|
390
|
+
|
|
391
|
+
If the user is asking a direct question, answer it concisely. If they're giving feedback or direction, update the spec accordingly and summarise what changed.
|
|
392
|
+
|
|
393
|
+
Context available if needed:
|
|
394
|
+
- .longshot/spec.md — project spec
|
|
395
|
+
- .longshot/tasks.json — existing tasks`;
|
|
396
|
+
result = await runAgent(prompt, `Refine task #${item.params.taskId}: ${task.meta.title}`, task, item.params.taskId, { skipDiff: true });
|
|
397
|
+
// Commit just the spec file so changes aren't orphaned on disk
|
|
398
|
+
if (!result.error) {
|
|
399
|
+
const padId = String(item.params.taskId).padStart(3, "0");
|
|
400
|
+
const specFile = `.longshot/tasks/${padId}-${task.meta.slug}.md`;
|
|
401
|
+
await git.commitFiles(getProjectRoot(), `refine task #${item.params.taskId}: ${task.meta.title}`, [specFile]);
|
|
402
|
+
// Save completion message to task conversation
|
|
403
|
+
const status = await readAgentStatus();
|
|
404
|
+
const summary = status?.summary || "Spec updated.";
|
|
405
|
+
let taskConvAfter = await store.readTaskConversation(item.params.taskId);
|
|
406
|
+
taskConvAfter.push({
|
|
407
|
+
role: "assistant",
|
|
408
|
+
content: summary,
|
|
409
|
+
timestamp: new Date().toISOString(),
|
|
410
|
+
});
|
|
411
|
+
taskConvAfter = await store.archiveTaskConversationIfNeeded(item.params.taskId, taskConvAfter);
|
|
412
|
+
await store.saveTaskConversation(item.params.taskId, taskConvAfter);
|
|
413
|
+
await store.updateTaskStatus(item.params.taskId, "drafting");
|
|
414
|
+
}
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
case "start-work": {
|
|
418
|
+
await store.updateTaskStatus(item.params.taskId, "in_progress");
|
|
419
|
+
const task = await store.getTask(item.params.taskId);
|
|
420
|
+
if (!task) {
|
|
421
|
+
await store.updateQueueItem(item.id, {
|
|
422
|
+
status: "failed",
|
|
423
|
+
error: "Task not found",
|
|
424
|
+
completedAt: new Date().toISOString(),
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const prompt = `Implement task #${item.params.taskId}: ${task.meta.title}\n\nTask spec:\n${task.spec}`;
|
|
429
|
+
result = await runAgent(prompt, `Task #${item.params.taskId}: ${task.meta.title}`, task, item.params.taskId);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case "spec-update": {
|
|
433
|
+
const taskIdForUpdate = item.params.taskId;
|
|
434
|
+
const task = await store.getTask(taskIdForUpdate);
|
|
435
|
+
if (!task) {
|
|
436
|
+
await store.updateQueueItem(item.id, {
|
|
437
|
+
status: "failed",
|
|
438
|
+
error: "Task not found",
|
|
439
|
+
completedAt: new Date().toISOString(),
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const padId = String(task.meta.id).padStart(3, "0");
|
|
444
|
+
const taskDir = `.longshot/tasks/${padId}-${task.meta.slug}`;
|
|
445
|
+
const specPrompt = `Task #${taskIdForUpdate} "${task.meta.title}" has just been implemented and approved.
|
|
446
|
+
|
|
447
|
+
Do TWO things:
|
|
448
|
+
|
|
449
|
+
1. Update .longshot/spec.md to reflect what was built. Read the task spec and the current spec, then update the spec to accurately describe the project as it now exists.
|
|
450
|
+
|
|
451
|
+
2. Generate a completion report and save it to ${taskDir}/report.md. The report should include:
|
|
452
|
+
- **Summary**: 1-2 sentence description of what was done
|
|
453
|
+
- **Changes**: list of files modified/added/deleted with brief descriptions
|
|
454
|
+
- **Diff from plan**: anything that diverged from the original task spec (scope changes, unexpected complications, things left out)
|
|
455
|
+
- **Commits**: the commit hash(es) associated with this task (check git log)
|
|
456
|
+
|
|
457
|
+
Context:
|
|
458
|
+
- .longshot/tasks/${padId}-${task.meta.slug}.md — the task spec
|
|
459
|
+
- .longshot/spec.md — current project spec to update
|
|
460
|
+
- Use git log and git diff to understand what changed`;
|
|
461
|
+
result = await runAgent(specPrompt, `Update spec for task #${taskIdForUpdate}`, task, taskIdForUpdate, {
|
|
462
|
+
autoCommit: true,
|
|
463
|
+
onAutoCommit: async (specCommitHash) => {
|
|
464
|
+
await store.updateTaskStatus(taskIdForUpdate, "complete", specCommitHash);
|
|
465
|
+
await store.appendHistory({
|
|
466
|
+
timestamp: new Date().toISOString(),
|
|
467
|
+
type: "task_complete",
|
|
468
|
+
summary: `Task #${taskIdForUpdate} completed`,
|
|
469
|
+
details: `commit: ${specCommitHash}`,
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case "fix-task": {
|
|
476
|
+
const task = await store.getTask(item.params.taskId);
|
|
477
|
+
if (!task) {
|
|
478
|
+
await store.updateQueueItem(item.id, {
|
|
479
|
+
status: "failed",
|
|
480
|
+
error: "Task not found",
|
|
481
|
+
completedAt: new Date().toISOString(),
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// Get the existing pending diff to include context
|
|
486
|
+
reloadPendingDiffs();
|
|
487
|
+
const existingDiff = item.params.diffId
|
|
488
|
+
? pendingDiffs.get(item.params.diffId)
|
|
489
|
+
: null;
|
|
490
|
+
// Preserve the original snapshot hash (from before the task started)
|
|
491
|
+
const originalSnapshotHash = existingDiff?.snapshotHash;
|
|
492
|
+
// Apply existing stash so fix agent can see current changes
|
|
493
|
+
if (existingDiff?.stashRef) {
|
|
494
|
+
await git.stashApply(getProjectRoot(), existingDiff.stashRef);
|
|
495
|
+
await git.stashDrop(getProjectRoot(), existingDiff.stashRef);
|
|
496
|
+
}
|
|
497
|
+
// Include task-local conversation context in the prompt
|
|
498
|
+
const taskConversation = await store.readTaskConversation(item.params.taskId);
|
|
499
|
+
const recentTaskChat = taskConversation.slice(-20);
|
|
500
|
+
const taskChatContext = recentTaskChat.length > 0
|
|
501
|
+
? `\nRecent conversation about this task:\n${recentTaskChat.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n\n")}\n`
|
|
502
|
+
: "";
|
|
503
|
+
const diffSection = existingDiff?.diffContent
|
|
504
|
+
? `\nCurrent diff (changes made so far):\n\`\`\`diff\n${existingDiff.diffContent.slice(0, 10000)}\n\`\`\`\n`
|
|
505
|
+
: "";
|
|
506
|
+
const fixPrompt = `You are fixing a targeted issue in task #${item.params.taskId}: ${task.meta.title}
|
|
507
|
+
|
|
508
|
+
Task spec:
|
|
509
|
+
${task.spec}
|
|
510
|
+
${diffSection}${taskChatContext}
|
|
511
|
+
The user has requested the following fix:
|
|
512
|
+
${item.params.message}
|
|
513
|
+
|
|
514
|
+
If the user is asking a question about the diff, answer it in the conversation and make no code changes. If they're requesting a change, apply the fix. Stay focused — only change what's needed to address this specific request.`;
|
|
515
|
+
// Delete the old pending diff before running the fix agent
|
|
516
|
+
if (item.params.diffId && pendingDiffs.has(item.params.diffId)) {
|
|
517
|
+
pendingDiffs.delete(item.params.diffId);
|
|
518
|
+
savePendingDiffs(pendingDiffs);
|
|
519
|
+
}
|
|
520
|
+
// Set task back to in_progress for the agent run
|
|
521
|
+
await store.updateTaskStatus(item.params.taskId, "in_progress");
|
|
522
|
+
// Run fix agent in main tree (runAgent will stash changes after)
|
|
523
|
+
result = await runAgent(fixPrompt, `Fix task #${item.params.taskId}: ${item.params.message.slice(0, 60)}`, task, item.params.taskId);
|
|
524
|
+
// After agent completes, update the new pending diff to use the original
|
|
525
|
+
// snapshot hash so the diff shows cumulative changes from task start
|
|
526
|
+
if (originalSnapshotHash && !result.error) {
|
|
527
|
+
reloadPendingDiffs();
|
|
528
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
529
|
+
if (diff.taskId === item.params.taskId && diff.snapshotHash !== originalSnapshotHash) {
|
|
530
|
+
// Unstash, recalculate cumulative diff, re-stash
|
|
531
|
+
if (diff.stashRef) {
|
|
532
|
+
await git.stashApply(getProjectRoot(), diff.stashRef);
|
|
533
|
+
}
|
|
534
|
+
const cumulativeDiff = await git.diff(getProjectRoot(), originalSnapshotHash);
|
|
535
|
+
if (cumulativeDiff.trim()) {
|
|
536
|
+
diff.snapshotHash = originalSnapshotHash;
|
|
537
|
+
diff.diffContent = cumulativeDiff;
|
|
538
|
+
}
|
|
539
|
+
if (diff.stashRef) {
|
|
540
|
+
await git.stashDrop(getProjectRoot(), diff.stashRef);
|
|
541
|
+
const newRef = await git.stashSave(getProjectRoot(), `pending-${diffId}`);
|
|
542
|
+
diff.stashRef = newRef || undefined;
|
|
543
|
+
}
|
|
544
|
+
savePendingDiffs(pendingDiffs);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case "verify": {
|
|
551
|
+
// Verification runs separately — they don't block the main queue
|
|
552
|
+
// For now, mark as done immediately and let the existing verify system handle it
|
|
553
|
+
result = { runId: `verify-${item.params.taskId}` };
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
default:
|
|
557
|
+
result = { runId: "" };
|
|
558
|
+
}
|
|
559
|
+
const failed = !!result.error;
|
|
560
|
+
await store.updateQueueItem(item.id, {
|
|
561
|
+
status: failed ? "failed" : "done",
|
|
562
|
+
runId: result.runId,
|
|
563
|
+
error: result.error,
|
|
564
|
+
completedAt: new Date().toISOString(),
|
|
565
|
+
});
|
|
566
|
+
// Revert transitional task status on failure
|
|
567
|
+
if (failed) {
|
|
568
|
+
await revertTaskStatusOnFailure(item);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
console.error("Queue item processing error:", err);
|
|
573
|
+
const isConflict = err.message?.includes("conflict") ||
|
|
574
|
+
err.message?.includes("CONFLICT");
|
|
575
|
+
if (isConflict && item.params.taskId) {
|
|
576
|
+
// Preserve conflict context for resolution UI
|
|
577
|
+
await store.updateQueueItem(item.id, {
|
|
578
|
+
status: "conflict",
|
|
579
|
+
error: err.message,
|
|
580
|
+
completedAt: new Date().toISOString(),
|
|
581
|
+
});
|
|
582
|
+
// Set task to conflict status (don't revert to in_progress)
|
|
583
|
+
await store.updateTaskStatus(item.params.taskId, "conflict");
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
await store.updateQueueItem(item.id, {
|
|
587
|
+
status: isConflict ? "conflict" : "failed",
|
|
588
|
+
error: err.message,
|
|
589
|
+
completedAt: new Date().toISOString(),
|
|
590
|
+
});
|
|
591
|
+
// Revert transitional task status on failure
|
|
592
|
+
await revertTaskStatusOnFailure(item);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function revertTaskStatusOnFailure(item) {
|
|
597
|
+
const taskId = item.params.taskId;
|
|
598
|
+
if (!taskId)
|
|
599
|
+
return;
|
|
600
|
+
// Don't revert terminal statuses — a completed/rejected task should stay that way
|
|
601
|
+
const task = await store.getTask(taskId);
|
|
602
|
+
if (task && (task.meta.status === "complete" || task.meta.status === "rejected"))
|
|
603
|
+
return;
|
|
604
|
+
switch (item.type) {
|
|
605
|
+
case "start-work": {
|
|
606
|
+
// If there's a pending diff from a previous run, stay in_progress
|
|
607
|
+
reloadPendingDiffs();
|
|
608
|
+
const hasDiff = [...pendingDiffs.values()].some((d) => d.taskId === taskId);
|
|
609
|
+
await store.updateTaskStatus(taskId, hasDiff ? "in_progress" : "ready");
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "refine-task":
|
|
613
|
+
await store.updateTaskStatus(taskId, "drafting");
|
|
614
|
+
break;
|
|
615
|
+
case "spec-update":
|
|
616
|
+
await store.updateTaskStatus(taskId, "in_progress");
|
|
617
|
+
break;
|
|
618
|
+
case "fix-task":
|
|
619
|
+
await store.updateTaskStatus(taskId, "in_progress");
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// --- Queue processor loop ---
|
|
624
|
+
async function processNext() {
|
|
625
|
+
if (processing)
|
|
626
|
+
return;
|
|
627
|
+
processing = true;
|
|
628
|
+
try {
|
|
629
|
+
while (true) {
|
|
630
|
+
const queue = await store.readQueue();
|
|
631
|
+
const next = queue.find((q) => q.status === "pending");
|
|
632
|
+
if (!next)
|
|
633
|
+
break;
|
|
634
|
+
await processItem(next);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
processing = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Generate a slug from a message (first few words, kebab-case)
|
|
642
|
+
function slugFromMessage(message) {
|
|
643
|
+
return message
|
|
644
|
+
.toLowerCase()
|
|
645
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
646
|
+
.trim()
|
|
647
|
+
.split(/\s+/)
|
|
648
|
+
.slice(0, 5)
|
|
649
|
+
.join("-")
|
|
650
|
+
.slice(0, 50)
|
|
651
|
+
.replace(/-$/, "") || "new-task";
|
|
652
|
+
}
|
|
653
|
+
// Generate a title from a message (first ~80 chars, sentence case)
|
|
654
|
+
function titleFromMessage(message) {
|
|
655
|
+
const first = message.split("\n")[0].trim();
|
|
656
|
+
return first.length > 80 ? first.slice(0, 77) + "..." : first;
|
|
657
|
+
}
|
|
658
|
+
// Enqueue an item and kick off processing
|
|
659
|
+
export async function enqueue(type, params) {
|
|
660
|
+
// Pre-create task entry for draft-task so it appears immediately
|
|
661
|
+
if (type === "draft-task" && params.message) {
|
|
662
|
+
const slug = slugFromMessage(params.message);
|
|
663
|
+
const title = titleFromMessage(params.message);
|
|
664
|
+
const task = await store.createTask(title, slug);
|
|
665
|
+
params.taskId = task.id;
|
|
666
|
+
params.taskSlug = task.slug;
|
|
667
|
+
}
|
|
668
|
+
const item = await store.addQueueItem({ type, params });
|
|
669
|
+
// Set transitional task status immediately on enqueue
|
|
670
|
+
if (type === "start-work" && params.taskId) {
|
|
671
|
+
await store.updateTaskStatus(params.taskId, "queued");
|
|
672
|
+
}
|
|
673
|
+
else if (type === "refine-task" && params.taskId) {
|
|
674
|
+
await store.updateTaskStatus(params.taskId, "refining");
|
|
675
|
+
}
|
|
676
|
+
else if (type === "spec-update" && params.taskId) {
|
|
677
|
+
await store.updateTaskStatus(params.taskId, "approved");
|
|
678
|
+
}
|
|
679
|
+
else if (type === "fix-task" && params.taskId) {
|
|
680
|
+
await store.updateTaskStatus(params.taskId, "fixing");
|
|
681
|
+
}
|
|
682
|
+
// Start processing if not already running
|
|
683
|
+
processNext().catch((err) => console.error("Queue processor error:", err));
|
|
684
|
+
return item;
|
|
685
|
+
}
|
|
686
|
+
// Start processing on boot (if there are pending items)
|
|
687
|
+
export async function startQueueProcessor() {
|
|
688
|
+
// Clean up any items stuck in "running" state from a crash
|
|
689
|
+
const queue = await store.readQueue();
|
|
690
|
+
for (const item of queue) {
|
|
691
|
+
if (item.status === "running") {
|
|
692
|
+
await store.updateQueueItem(item.id, {
|
|
693
|
+
status: "failed",
|
|
694
|
+
error: "Server restarted while running",
|
|
695
|
+
completedAt: new Date().toISOString(),
|
|
696
|
+
});
|
|
697
|
+
// Revert transitional task status
|
|
698
|
+
await revertTaskStatusOnFailure(item);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
await store.cleanOldQueueItems();
|
|
702
|
+
// Backward compat: drop any old pending diffs with worktreePath (pre-stash migration)
|
|
703
|
+
reloadPendingDiffs();
|
|
704
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
705
|
+
if (diff.worktreePath) {
|
|
706
|
+
console.log(`Dropping legacy worktree-based pending diff: ${diffId}`);
|
|
707
|
+
pendingDiffs.delete(diffId);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
savePendingDiffs(pendingDiffs);
|
|
711
|
+
// Process any pending items
|
|
712
|
+
processNext().catch((err) => console.error("Queue processor startup error:", err));
|
|
713
|
+
}
|
|
714
|
+
// Find the conflict queue item for a task
|
|
715
|
+
export function findConflictItem(queue, taskId) {
|
|
716
|
+
return queue.find((q) => q.status === "conflict" && q.params.taskId === taskId);
|
|
717
|
+
}
|
|
718
|
+
// Resolve conflict: restart task (drop stash, delete pending diff, reset to ready)
|
|
719
|
+
export async function resolveConflictRestart(taskId) {
|
|
720
|
+
const queue = await store.readQueue();
|
|
721
|
+
const conflictItem = findConflictItem(queue, taskId);
|
|
722
|
+
if (!conflictItem)
|
|
723
|
+
return false;
|
|
724
|
+
// Find and clean up the pending diff for this task
|
|
725
|
+
reloadPendingDiffs();
|
|
726
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
727
|
+
if (diff.taskId === taskId) {
|
|
728
|
+
if (diff.stashRef) {
|
|
729
|
+
await git.stashDrop(getProjectRoot(), diff.stashRef);
|
|
730
|
+
}
|
|
731
|
+
pendingDiffs.delete(diffId);
|
|
732
|
+
savePendingDiffs(pendingDiffs);
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Clean up conflict queue item
|
|
737
|
+
await store.updateQueueItem(conflictItem.id, {
|
|
738
|
+
status: "done",
|
|
739
|
+
completedAt: new Date().toISOString(),
|
|
740
|
+
});
|
|
741
|
+
// Reset task to ready
|
|
742
|
+
await store.updateTaskStatus(taskId, "ready");
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
// Resolve conflict: agent resolve (apply stash with conflicts, run fix agent)
|
|
746
|
+
export async function resolveConflictAgent(taskId) {
|
|
747
|
+
const queue = await store.readQueue();
|
|
748
|
+
const conflictItem = findConflictItem(queue, taskId);
|
|
749
|
+
if (!conflictItem)
|
|
750
|
+
return null;
|
|
751
|
+
// Find the pending diff
|
|
752
|
+
reloadPendingDiffs();
|
|
753
|
+
let diffId;
|
|
754
|
+
let pending;
|
|
755
|
+
for (const [id, diff] of pendingDiffs) {
|
|
756
|
+
if (diff.taskId === taskId) {
|
|
757
|
+
diffId = id;
|
|
758
|
+
pending = diff;
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (!pending || !diffId)
|
|
763
|
+
return null;
|
|
764
|
+
// Apply stash allowing conflicts
|
|
765
|
+
if (pending.stashRef) {
|
|
766
|
+
const { conflicts } = await git.stashApplyAllowConflicts(getProjectRoot(), pending.stashRef);
|
|
767
|
+
await git.stashDrop(getProjectRoot(), pending.stashRef);
|
|
768
|
+
// Remove the old pending diff
|
|
769
|
+
pendingDiffs.delete(diffId);
|
|
770
|
+
savePendingDiffs(pendingDiffs);
|
|
771
|
+
// Mark conflict queue item as done
|
|
772
|
+
await store.updateQueueItem(conflictItem.id, {
|
|
773
|
+
status: "done",
|
|
774
|
+
completedAt: new Date().toISOString(),
|
|
775
|
+
});
|
|
776
|
+
// Enqueue a fix-task to resolve the conflicts
|
|
777
|
+
const conflictFiles = conflicts.join(", ");
|
|
778
|
+
const item = await enqueue("fix-task", {
|
|
779
|
+
taskId,
|
|
780
|
+
message: `Resolve merge conflicts in the following files: ${conflictFiles}. Look for conflict markers (<<<<<<< / ======= / >>>>>>>) and resolve them by keeping the correct code. After resolving, ensure the code compiles and works correctly.`,
|
|
781
|
+
});
|
|
782
|
+
return item;
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
// Resolve conflict: manual (apply stash with conflicts, leave for user)
|
|
787
|
+
export async function resolveConflictManual(taskId) {
|
|
788
|
+
const queue = await store.readQueue();
|
|
789
|
+
const conflictItem = findConflictItem(queue, taskId);
|
|
790
|
+
if (!conflictItem)
|
|
791
|
+
return null;
|
|
792
|
+
// Find the pending diff
|
|
793
|
+
reloadPendingDiffs();
|
|
794
|
+
let diffId;
|
|
795
|
+
let pending;
|
|
796
|
+
for (const [id, diff] of pendingDiffs) {
|
|
797
|
+
if (diff.taskId === taskId) {
|
|
798
|
+
diffId = id;
|
|
799
|
+
pending = diff;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (!pending || !diffId)
|
|
804
|
+
return null;
|
|
805
|
+
let conflicts = [];
|
|
806
|
+
if (pending.stashRef) {
|
|
807
|
+
const result = await git.stashApplyAllowConflicts(getProjectRoot(), pending.stashRef);
|
|
808
|
+
conflicts = result.conflicts;
|
|
809
|
+
await git.stashDrop(getProjectRoot(), pending.stashRef);
|
|
810
|
+
}
|
|
811
|
+
// Remove old pending diff
|
|
812
|
+
pendingDiffs.delete(diffId);
|
|
813
|
+
savePendingDiffs(pendingDiffs);
|
|
814
|
+
// Mark conflict queue item as done
|
|
815
|
+
await store.updateQueueItem(conflictItem.id, {
|
|
816
|
+
status: "done",
|
|
817
|
+
completedAt: new Date().toISOString(),
|
|
818
|
+
});
|
|
819
|
+
// Set task to in_progress (manual resolution in progress)
|
|
820
|
+
await store.updateTaskStatus(taskId, "in_progress");
|
|
821
|
+
return { conflicts };
|
|
822
|
+
}
|
|
823
|
+
// After manual conflict resolution, create a new pending diff
|
|
824
|
+
export async function resolveConflictDone(taskId) {
|
|
825
|
+
const task = await store.getTask(taskId);
|
|
826
|
+
if (!task)
|
|
827
|
+
return null;
|
|
828
|
+
// Find the original snapshot hash from the queue item
|
|
829
|
+
const queue = await store.readQueue();
|
|
830
|
+
// Look for the most recent done conflict item for this task to get the snapshot
|
|
831
|
+
// We need the original snapshot — get it from the task's checkpoint or find it
|
|
832
|
+
// Take a diff from current state
|
|
833
|
+
await git.ensureRepo(getProjectRoot());
|
|
834
|
+
const currentHead = await git.snapshot(getProjectRoot());
|
|
835
|
+
const diffContent = await git.diff(getProjectRoot(), currentHead);
|
|
836
|
+
if (!diffContent.trim()) {
|
|
837
|
+
return null; // No changes to review
|
|
838
|
+
}
|
|
839
|
+
// Create a new pending diff
|
|
840
|
+
const newDiffId = crypto.randomUUID();
|
|
841
|
+
const stashRef = await git.stashSave(getProjectRoot(), `pending-${newDiffId}`);
|
|
842
|
+
pendingDiffs.set(newDiffId, {
|
|
843
|
+
cwd: getProjectRoot(),
|
|
844
|
+
snapshotHash: currentHead,
|
|
845
|
+
task: `Task #${taskId}: ${task.meta.title} (conflict resolved)`,
|
|
846
|
+
taskId,
|
|
847
|
+
diffContent,
|
|
848
|
+
stashRef: stashRef || undefined,
|
|
849
|
+
});
|
|
850
|
+
savePendingDiffs(pendingDiffs);
|
|
851
|
+
return newDiffId;
|
|
852
|
+
}
|
|
853
|
+
// Retry a failed/conflict item
|
|
854
|
+
export async function retryQueueItem(id) {
|
|
855
|
+
const queue = await store.readQueue();
|
|
856
|
+
const item = queue.find((q) => q.id === id);
|
|
857
|
+
if (!item || (item.status !== "failed" && item.status !== "conflict"))
|
|
858
|
+
return false;
|
|
859
|
+
await store.updateQueueItem(id, {
|
|
860
|
+
status: "pending",
|
|
861
|
+
startedAt: undefined,
|
|
862
|
+
completedAt: undefined,
|
|
863
|
+
runId: undefined,
|
|
864
|
+
error: undefined,
|
|
865
|
+
});
|
|
866
|
+
processNext().catch((err) => console.error("Queue processor retry error:", err));
|
|
867
|
+
return true;
|
|
868
|
+
}
|