@wrongstack/sdd 0.284.0
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 +21 -0
- package/dist/index.d.ts +1864 -0
- package/dist/index.js +4229 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4229 @@
|
|
|
1
|
+
import { SddError, ERROR_CODES, ensureDir, atomicWrite, toErrorMessage, assignNickname, expectDefined, WorktreeManager, truncate, assertNever, parseModelRef, renderInstructionTemplate, readBundledInstructionText } from '@wrongstack/core';
|
|
2
|
+
import { DefaultTaskStore, TaskTracker } from '@wrongstack/core/tasking';
|
|
3
|
+
export { DefaultTaskStore, TaskTracker } from '@wrongstack/core/tasking';
|
|
4
|
+
import * as fsp3 from 'fs/promises';
|
|
5
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
6
|
+
import * as path3 from 'path';
|
|
7
|
+
import { isAbsolute, join } from 'path';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import { computeTaskProgress, topologicalSort } from '@wrongstack/core/types';
|
|
10
|
+
import { DefaultMultiAgentCoordinator, withDisabledToolFiltering, makeAgentSubagentRunner } from '@wrongstack/core/coordination';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
|
|
13
|
+
// src/spec-parser.ts
|
|
14
|
+
var SpecParser = class {
|
|
15
|
+
parse(content) {
|
|
16
|
+
const lines = content.split("\n");
|
|
17
|
+
const sections = this.extractSections(lines);
|
|
18
|
+
const requirements = this.extractRequirements(lines);
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
return {
|
|
21
|
+
id: crypto.randomUUID(),
|
|
22
|
+
title: this.extractTitle(lines),
|
|
23
|
+
version: this.extractVersion(lines),
|
|
24
|
+
status: "draft",
|
|
25
|
+
overview: this.extractOverview(lines),
|
|
26
|
+
sections,
|
|
27
|
+
requirements,
|
|
28
|
+
createdAt: now,
|
|
29
|
+
updatedAt: now
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
extractTitle(lines) {
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const m = /^#\s+(.+)/.exec(line.trim());
|
|
35
|
+
if (m?.[1]) return m[1];
|
|
36
|
+
}
|
|
37
|
+
return "Untitled Specification";
|
|
38
|
+
}
|
|
39
|
+
extractVersion(lines) {
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const m = /version[:\s]+(\d+\.\d+\.\d+)/i.exec(line.trim());
|
|
42
|
+
if (m?.[1]) return m[1];
|
|
43
|
+
}
|
|
44
|
+
return "0.0.1";
|
|
45
|
+
}
|
|
46
|
+
extractOverview(lines) {
|
|
47
|
+
const overviewLines = [];
|
|
48
|
+
let inOverview = false;
|
|
49
|
+
let foundHeading = false;
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (/^##\s+Overview/i.test(line.trim())) {
|
|
52
|
+
inOverview = true;
|
|
53
|
+
foundHeading = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (foundHeading && /^##\s+/.test(line.trim())) break;
|
|
57
|
+
if (inOverview) overviewLines.push(line);
|
|
58
|
+
}
|
|
59
|
+
return overviewLines.join("\n").trim() || "No overview provided";
|
|
60
|
+
}
|
|
61
|
+
extractSections(lines) {
|
|
62
|
+
const sections = [];
|
|
63
|
+
let currentSection = null;
|
|
64
|
+
let currentLines = [];
|
|
65
|
+
let depth = 1;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
const h2 = /^##\s+(.+)/.exec(line.trim());
|
|
68
|
+
const h3 = /^###\s+(.+)/.exec(line.trim());
|
|
69
|
+
if (h2) {
|
|
70
|
+
if (currentSection && currentLines.length > 0) {
|
|
71
|
+
sections.push({
|
|
72
|
+
type: this.mapSectionType(currentSection.title ?? "unknown"),
|
|
73
|
+
title: currentSection.title ?? "Unknown",
|
|
74
|
+
level: depth,
|
|
75
|
+
content: currentLines.join("\n").trim()
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
currentSection = { title: h2[1] ?? "Unknown" };
|
|
79
|
+
currentLines = [];
|
|
80
|
+
depth = 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (h3) {
|
|
84
|
+
currentLines.push(line);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (currentSection) {
|
|
88
|
+
currentLines.push(line);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (currentSection && currentLines.length > 0) {
|
|
92
|
+
sections.push({
|
|
93
|
+
type: this.mapSectionType(currentSection.title ?? "unknown"),
|
|
94
|
+
title: currentSection.title ?? "Unknown",
|
|
95
|
+
level: depth,
|
|
96
|
+
content: currentLines.join("\n").trim()
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return sections;
|
|
100
|
+
}
|
|
101
|
+
extractRequirements(lines) {
|
|
102
|
+
const requirements = [];
|
|
103
|
+
let inRequirements = false;
|
|
104
|
+
let idCounter = 0;
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (/^##\s+Requirements/i.test(line.trim())) {
|
|
107
|
+
inRequirements = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (inRequirements && /^##\s+/.test(line.trim())) break;
|
|
111
|
+
if (inRequirements) {
|
|
112
|
+
const req = this.parseRequirementLine(line, `REQ-${++idCounter}`);
|
|
113
|
+
if (req) requirements.push(req);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return requirements;
|
|
117
|
+
}
|
|
118
|
+
parseRequirementLine(line, id) {
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
121
|
+
const lower = trimmed.toLowerCase();
|
|
122
|
+
const types = [
|
|
123
|
+
"functional",
|
|
124
|
+
"non-functional",
|
|
125
|
+
"security",
|
|
126
|
+
"performance",
|
|
127
|
+
"ux"
|
|
128
|
+
];
|
|
129
|
+
let type = "functional";
|
|
130
|
+
for (const t of types) {
|
|
131
|
+
if (lower.includes(`[${t}]`)) type = t;
|
|
132
|
+
}
|
|
133
|
+
let priority = "medium";
|
|
134
|
+
if (trimmed.includes("[critical]") || trimmed.includes("[prio:high]")) {
|
|
135
|
+
priority = "critical";
|
|
136
|
+
} else if (trimmed.includes("[high]")) {
|
|
137
|
+
priority = "high";
|
|
138
|
+
} else if (trimmed.includes("[low]")) {
|
|
139
|
+
priority = "low";
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
type,
|
|
144
|
+
priority,
|
|
145
|
+
description: trimmed.replace(/\[[^\]]+\]/g, "").trim(),
|
|
146
|
+
acceptanceCriteria: []
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
mapSectionType(title) {
|
|
150
|
+
const t = title.toLowerCase();
|
|
151
|
+
if (t.includes("overview")) return "overview";
|
|
152
|
+
if (t.includes("requirement")) return "requirements";
|
|
153
|
+
if (t.includes("architect")) return "architecture";
|
|
154
|
+
if (t.includes("api")) return "api";
|
|
155
|
+
if (t.includes("data")) return "data";
|
|
156
|
+
if (t.includes("security")) return "security";
|
|
157
|
+
if (t.includes("acceptance")) return "acceptance";
|
|
158
|
+
return "overview";
|
|
159
|
+
}
|
|
160
|
+
analyze(spec) {
|
|
161
|
+
const gaps = [];
|
|
162
|
+
const suggestions = [];
|
|
163
|
+
const risks = [];
|
|
164
|
+
const hasOverview = spec.sections.some((s) => s.type === "overview");
|
|
165
|
+
const hasRequirements = spec.sections.some((s) => s.type === "requirements");
|
|
166
|
+
const hasAcceptance = spec.sections.some((s) => s.type === "acceptance");
|
|
167
|
+
if (!hasOverview) gaps.push("Missing Overview section");
|
|
168
|
+
if (!hasRequirements) gaps.push("Missing Requirements section");
|
|
169
|
+
if (!hasAcceptance) gaps.push("Missing Acceptance Criteria section");
|
|
170
|
+
if (spec.requirements.length === 0) {
|
|
171
|
+
gaps.push("No requirements defined");
|
|
172
|
+
suggestions.push("Add specific functional and non-functional requirements");
|
|
173
|
+
}
|
|
174
|
+
const unverifiedReqs = spec.requirements.filter((r) => r.acceptanceCriteria.length === 0);
|
|
175
|
+
if (unverifiedReqs.length > 0) {
|
|
176
|
+
gaps.push(`${unverifiedReqs.length} requirements without acceptance criteria`);
|
|
177
|
+
suggestions.push("Define clear acceptance criteria for each requirement");
|
|
178
|
+
}
|
|
179
|
+
const criticalUnresolved = spec.requirements.filter(
|
|
180
|
+
(r) => r.priority === "critical" && r.blockedBy && r.blockedBy.length > 0
|
|
181
|
+
);
|
|
182
|
+
for (const req of criticalUnresolved) {
|
|
183
|
+
risks.push({
|
|
184
|
+
requirement: req.id,
|
|
185
|
+
risk: `Critical requirement blocked by ${req.blockedBy?.length} other requirements`,
|
|
186
|
+
severity: "high"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const completeness = Math.round(
|
|
190
|
+
((hasOverview ? 1 : 0) + (hasRequirements ? 1 : 0) + (hasAcceptance ? 1 : 0) + (spec.requirements.length > 0 ? 1 : 0) + (spec.sections.length > 3 ? 1 : 0)) / 5 * 100
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
specId: spec.id,
|
|
194
|
+
completeness,
|
|
195
|
+
coverage: {
|
|
196
|
+
requirements: spec.requirements.length,
|
|
197
|
+
apiEndpoints: spec.apiEndpoints?.length ?? 0,
|
|
198
|
+
edgeCases: 0,
|
|
199
|
+
errorHandling: 0
|
|
200
|
+
},
|
|
201
|
+
gaps,
|
|
202
|
+
risks,
|
|
203
|
+
suggestions
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
validate(spec) {
|
|
207
|
+
const errors = [];
|
|
208
|
+
const warnings = [];
|
|
209
|
+
if (!spec.title.trim()) {
|
|
210
|
+
errors.push({ path: "title", message: "Title is required" });
|
|
211
|
+
}
|
|
212
|
+
if (!spec.version.trim()) {
|
|
213
|
+
errors.push({ path: "version", message: "Version is required" });
|
|
214
|
+
}
|
|
215
|
+
for (const req of spec.requirements) {
|
|
216
|
+
if (!req.description.trim()) {
|
|
217
|
+
errors.push({ path: `requirement.${req.id}`, message: "Requirement description is empty" });
|
|
218
|
+
}
|
|
219
|
+
if (req.acceptanceCriteria.length === 0) {
|
|
220
|
+
warnings.push({ path: `requirement.${req.id}`, message: "No acceptance criteria defined" });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const reqIds = new Set(spec.requirements.map((r) => r.id));
|
|
224
|
+
const blockedByIds = new Set(spec.requirements.flatMap((r) => r.blockedBy ?? []));
|
|
225
|
+
for (const id of blockedByIds) {
|
|
226
|
+
if (!reqIds.has(id)) {
|
|
227
|
+
errors.push({
|
|
228
|
+
path: "requirements",
|
|
229
|
+
message: `BlockedBy references non-existent requirement: ${id}`
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
valid: errors.length === 0,
|
|
235
|
+
errors,
|
|
236
|
+
warnings
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
function extractVerificationCommand(criteria) {
|
|
241
|
+
const marker = /^\s*(?:\$\s+|(?:run|verify|cmd)\s*:\s*)(.+\S)\s*$/i;
|
|
242
|
+
for (const c of criteria) {
|
|
243
|
+
const m = marker.exec(c);
|
|
244
|
+
if (m?.[1]) return m[1].trim();
|
|
245
|
+
}
|
|
246
|
+
return void 0;
|
|
247
|
+
}
|
|
248
|
+
var TaskGenerator = class {
|
|
249
|
+
constructor(opts) {
|
|
250
|
+
this.opts = opts;
|
|
251
|
+
}
|
|
252
|
+
opts;
|
|
253
|
+
async generateFromSpec(spec) {
|
|
254
|
+
const graph = await this.opts.taskTracker.createGraph(spec.id, spec.title);
|
|
255
|
+
return graph;
|
|
256
|
+
}
|
|
257
|
+
async generateSubtasks(parentTaskId, spec) {
|
|
258
|
+
const reqId = this.opts.taskTracker.getNode(parentTaskId)?.specRequirementId;
|
|
259
|
+
if (!reqId) return;
|
|
260
|
+
const req = spec.requirements.find((r) => r.id === reqId);
|
|
261
|
+
if (!req) return;
|
|
262
|
+
if (req.acceptanceCriteria.length > 0) {
|
|
263
|
+
for (const criterion of req.acceptanceCriteria) {
|
|
264
|
+
this.opts.taskTracker.addNode({
|
|
265
|
+
title: criterion,
|
|
266
|
+
description: `Verify: ${criterion}`,
|
|
267
|
+
type: "test",
|
|
268
|
+
priority: "medium",
|
|
269
|
+
status: "pending",
|
|
270
|
+
parentId: parentTaskId
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
var TaskFlow = class {
|
|
277
|
+
constructor(opts) {
|
|
278
|
+
this.opts = opts;
|
|
279
|
+
this.setPhase("idle");
|
|
280
|
+
}
|
|
281
|
+
opts;
|
|
282
|
+
phase = "idle";
|
|
283
|
+
spec = null;
|
|
284
|
+
graph = null;
|
|
285
|
+
stopped = false;
|
|
286
|
+
emit(event, payload) {
|
|
287
|
+
this.opts.events.emit(event, payload);
|
|
288
|
+
}
|
|
289
|
+
async fromSpec(specContent) {
|
|
290
|
+
this.setPhase("parsing");
|
|
291
|
+
const parser = new SpecParser();
|
|
292
|
+
this.spec = parser.parse(specContent);
|
|
293
|
+
this.setPhase("analyzing");
|
|
294
|
+
const analysis = parser.analyze(this.spec);
|
|
295
|
+
this.emit("spec.analyzed", { analysis });
|
|
296
|
+
if (analysis.completeness < 50) {
|
|
297
|
+
const err = new SddError({
|
|
298
|
+
message: `Spec completeness too low: ${analysis.completeness}%`,
|
|
299
|
+
code: ERROR_CODES.SDD_VALIDATION_FAILED,
|
|
300
|
+
context: { completeness: analysis.completeness }
|
|
301
|
+
});
|
|
302
|
+
this.emit("error", { phase: "analyzing", error: err });
|
|
303
|
+
this.setPhase("failed");
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
this.setPhase("generating");
|
|
307
|
+
const generator = new TaskGenerator({
|
|
308
|
+
taskTracker: this.opts.tracker,
|
|
309
|
+
verificationFromAcceptance: process.env["WRONGSTACK_SDD_VERIFY_FROM_ACCEPTANCE"] === "1"
|
|
310
|
+
});
|
|
311
|
+
this.graph = await generator.generateFromSpec(this.spec);
|
|
312
|
+
return this.graph;
|
|
313
|
+
}
|
|
314
|
+
async execute(ctx) {
|
|
315
|
+
if (!this.graph) throw new SddError({
|
|
316
|
+
message: "No graph loaded. Call fromSpec first.",
|
|
317
|
+
code: ERROR_CODES.SDD_INVALID_STATE,
|
|
318
|
+
context: { phase: this.phase }
|
|
319
|
+
});
|
|
320
|
+
this.setPhase("executing");
|
|
321
|
+
this.stopped = false;
|
|
322
|
+
const pendingTasks = this.getExecutableTasks();
|
|
323
|
+
const maxConcurrent = this.opts.maxConcurrent ?? 2;
|
|
324
|
+
while (pendingTasks.length > 0 && !this.stopped) {
|
|
325
|
+
const batch = pendingTasks.splice(0, maxConcurrent);
|
|
326
|
+
const results = await Promise.allSettled(
|
|
327
|
+
batch.map((task) => this.executeSingleTask(task, ctx))
|
|
328
|
+
);
|
|
329
|
+
for (let i = 0; i < results.length; i++) {
|
|
330
|
+
const result = results[i];
|
|
331
|
+
const task = batch[i];
|
|
332
|
+
if (!result || !task) continue;
|
|
333
|
+
if (result.status === "rejected") {
|
|
334
|
+
const reason = result.reason;
|
|
335
|
+
this.opts.tracker.updateNodeStatus(task.id, "failed", reason?.message);
|
|
336
|
+
this.emit("task.failed", { taskId: task.id, error: reason?.message ?? "unknown" });
|
|
337
|
+
ctx.onTaskFail?.(task, reason);
|
|
338
|
+
} else {
|
|
339
|
+
this.opts.tracker.updateNodeStatus(task.id, "completed");
|
|
340
|
+
this.emit("task.completed", { taskId: task.id, result: result.value });
|
|
341
|
+
ctx.onTaskComplete?.(task, result.value);
|
|
342
|
+
}
|
|
343
|
+
this.emitProgress();
|
|
344
|
+
}
|
|
345
|
+
const stillPending = this.getExecutableTasks();
|
|
346
|
+
pendingTasks.length = 0;
|
|
347
|
+
pendingTasks.push(...stillPending);
|
|
348
|
+
if (this.checkDoneCondition()) {
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.setPhase("completing");
|
|
353
|
+
this.emit("done", { graph: this.graph });
|
|
354
|
+
this.setPhase("done");
|
|
355
|
+
return this.graph;
|
|
356
|
+
}
|
|
357
|
+
async reviewTask(taskId, approved, comment) {
|
|
358
|
+
const task = this.opts.tracker.getNode(taskId);
|
|
359
|
+
if (!task) throw new SddError({
|
|
360
|
+
message: `Task ${taskId} not found`,
|
|
361
|
+
code: ERROR_CODES.SDD_NOT_READY,
|
|
362
|
+
context: { taskId }
|
|
363
|
+
});
|
|
364
|
+
if (approved) {
|
|
365
|
+
this.opts.tracker.updateNodeStatus(taskId, "completed", comment);
|
|
366
|
+
this.emit("task.completed", { taskId });
|
|
367
|
+
} else {
|
|
368
|
+
this.opts.tracker.updateNodeStatus(taskId, "in_progress", comment ?? "Needs revision");
|
|
369
|
+
this.emit("task.review", { taskId });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
stop() {
|
|
373
|
+
this.stopped = true;
|
|
374
|
+
}
|
|
375
|
+
getPhase() {
|
|
376
|
+
return this.phase;
|
|
377
|
+
}
|
|
378
|
+
getGraph() {
|
|
379
|
+
return this.graph;
|
|
380
|
+
}
|
|
381
|
+
getSpec() {
|
|
382
|
+
return this.spec;
|
|
383
|
+
}
|
|
384
|
+
setPhase(phase) {
|
|
385
|
+
const from = this.phase;
|
|
386
|
+
this.phase = phase;
|
|
387
|
+
this.emit("phase.change", { from, to: phase });
|
|
388
|
+
}
|
|
389
|
+
getExecutableTasks() {
|
|
390
|
+
return this.opts.tracker.getAllNodes({ status: ["pending", "blocked"] }).filter((n) => n.status === "pending" && this.opts.tracker.canStart(n.id)).sort((a, b) => {
|
|
391
|
+
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
392
|
+
return (priorityOrder[a.priority] ?? 4) - (priorityOrder[b.priority] ?? 4);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async executeSingleTask(task, ctx) {
|
|
396
|
+
this.opts.tracker.updateNodeStatus(task.id, "in_progress");
|
|
397
|
+
this.emit("task.started", { taskId: task.id });
|
|
398
|
+
return ctx.executeTask(task);
|
|
399
|
+
}
|
|
400
|
+
checkDoneCondition() {
|
|
401
|
+
const condition = this.opts.doneCondition;
|
|
402
|
+
if (!condition) {
|
|
403
|
+
const progress = this.opts.tracker.getProgress();
|
|
404
|
+
return progress.percentComplete === 100;
|
|
405
|
+
}
|
|
406
|
+
switch (condition.type) {
|
|
407
|
+
case "all_tasks_done": {
|
|
408
|
+
const progress = this.opts.tracker.getProgress();
|
|
409
|
+
return progress.pending === 0 && progress.inProgress === 0;
|
|
410
|
+
}
|
|
411
|
+
case "iterations":
|
|
412
|
+
return false;
|
|
413
|
+
// Not tracked here
|
|
414
|
+
case "tool_calls":
|
|
415
|
+
return false;
|
|
416
|
+
default:
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
emitProgress() {
|
|
421
|
+
const progress = this.opts.tracker.getProgress();
|
|
422
|
+
this.emit("progress", {
|
|
423
|
+
percent: progress.percentComplete,
|
|
424
|
+
message: `${progress.completed}/${progress.total} tasks completed`
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
var SpecDrivenDev = class {
|
|
429
|
+
store;
|
|
430
|
+
tracker;
|
|
431
|
+
events;
|
|
432
|
+
flows = /* @__PURE__ */ new Map();
|
|
433
|
+
constructor(opts) {
|
|
434
|
+
this.store = new DefaultTaskStore();
|
|
435
|
+
this.tracker = new TaskTracker({ store: this.store });
|
|
436
|
+
this.events = opts.events;
|
|
437
|
+
}
|
|
438
|
+
async createFlow(specContent, options) {
|
|
439
|
+
const flow = new TaskFlow({
|
|
440
|
+
tracker: this.tracker,
|
|
441
|
+
events: this.events,
|
|
442
|
+
...options
|
|
443
|
+
});
|
|
444
|
+
const graph = await flow.fromSpec(specContent);
|
|
445
|
+
this.flows.set(graph.id, flow);
|
|
446
|
+
return flow;
|
|
447
|
+
}
|
|
448
|
+
getTracker() {
|
|
449
|
+
return this.tracker;
|
|
450
|
+
}
|
|
451
|
+
getFlow(graphId) {
|
|
452
|
+
return this.flows.get(graphId);
|
|
453
|
+
}
|
|
454
|
+
listFlows() {
|
|
455
|
+
return Array.from(this.flows.entries()).map(([id, flow]) => ({
|
|
456
|
+
id,
|
|
457
|
+
title: flow.getGraph()?.title ?? "Untitled",
|
|
458
|
+
phase: flow.getPhase()
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
var SpecStore = class {
|
|
463
|
+
baseDir;
|
|
464
|
+
indexPath;
|
|
465
|
+
constructor(opts) {
|
|
466
|
+
this.baseDir = opts.baseDir;
|
|
467
|
+
this.indexPath = path3.join(this.baseDir, "_index.json");
|
|
468
|
+
}
|
|
469
|
+
async save(spec) {
|
|
470
|
+
await ensureDir(this.baseDir);
|
|
471
|
+
const filePath = this.filePath(spec.id);
|
|
472
|
+
await atomicWrite(filePath, JSON.stringify(spec, null, 2), { mode: 384 });
|
|
473
|
+
await this.updateIndex(spec);
|
|
474
|
+
}
|
|
475
|
+
async load(id) {
|
|
476
|
+
try {
|
|
477
|
+
const raw = await fsp3.readFile(this.filePath(id), "utf8");
|
|
478
|
+
return JSON.parse(raw);
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async list() {
|
|
484
|
+
const index = await this.readIndex();
|
|
485
|
+
return index.entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
486
|
+
}
|
|
487
|
+
async delete(id) {
|
|
488
|
+
try {
|
|
489
|
+
await fsp3.unlink(this.filePath(id));
|
|
490
|
+
await this.removeFromIndex(id);
|
|
491
|
+
return true;
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async exists(id) {
|
|
497
|
+
try {
|
|
498
|
+
await fsp3.access(this.filePath(id));
|
|
499
|
+
return true;
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Create a new spec with defaults, assign ID, and persist. */
|
|
505
|
+
async createDraft(title, overview) {
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
const spec = {
|
|
508
|
+
id: randomUUID(),
|
|
509
|
+
title,
|
|
510
|
+
version: "0.1.0",
|
|
511
|
+
status: "draft",
|
|
512
|
+
overview: overview ?? "",
|
|
513
|
+
sections: [],
|
|
514
|
+
requirements: [],
|
|
515
|
+
createdAt: now,
|
|
516
|
+
updatedAt: now
|
|
517
|
+
};
|
|
518
|
+
await this.save(spec);
|
|
519
|
+
return spec;
|
|
520
|
+
}
|
|
521
|
+
/** Update spec fields and persist. */
|
|
522
|
+
async update(id, patch) {
|
|
523
|
+
const spec = await this.load(id);
|
|
524
|
+
if (!spec) return null;
|
|
525
|
+
const updated = {
|
|
526
|
+
...spec,
|
|
527
|
+
...patch,
|
|
528
|
+
id: spec.id,
|
|
529
|
+
createdAt: spec.createdAt,
|
|
530
|
+
updatedAt: Date.now()
|
|
531
|
+
};
|
|
532
|
+
await this.save(updated);
|
|
533
|
+
return updated;
|
|
534
|
+
}
|
|
535
|
+
filePath(id) {
|
|
536
|
+
return path3.join(this.baseDir, `${id}.json`);
|
|
537
|
+
}
|
|
538
|
+
async readIndex() {
|
|
539
|
+
try {
|
|
540
|
+
const raw = await fsp3.readFile(this.indexPath, "utf8");
|
|
541
|
+
const parsed = JSON.parse(raw);
|
|
542
|
+
if (parsed?.version === 1) return parsed;
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
return { version: 1, entries: [] };
|
|
546
|
+
}
|
|
547
|
+
async updateIndex(spec) {
|
|
548
|
+
const index = await this.readIndex();
|
|
549
|
+
const entry = {
|
|
550
|
+
id: spec.id,
|
|
551
|
+
title: spec.title,
|
|
552
|
+
version: spec.version,
|
|
553
|
+
status: spec.status,
|
|
554
|
+
updatedAt: spec.updatedAt,
|
|
555
|
+
filePath: this.filePath(spec.id)
|
|
556
|
+
};
|
|
557
|
+
const idx = index.entries.findIndex((e) => e.id === spec.id);
|
|
558
|
+
if (idx >= 0) {
|
|
559
|
+
index.entries[idx] = entry;
|
|
560
|
+
} else {
|
|
561
|
+
index.entries.push(entry);
|
|
562
|
+
}
|
|
563
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
564
|
+
}
|
|
565
|
+
async removeFromIndex(id) {
|
|
566
|
+
const index = await this.readIndex();
|
|
567
|
+
index.entries = index.entries.filter((e) => e.id !== id);
|
|
568
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
function graphToJSON(graph) {
|
|
572
|
+
const serialisable = {
|
|
573
|
+
...graph,
|
|
574
|
+
nodes: Array.from(graph.nodes.entries())
|
|
575
|
+
};
|
|
576
|
+
return JSON.stringify(serialisable, null, 2);
|
|
577
|
+
}
|
|
578
|
+
function graphFromJSON(raw) {
|
|
579
|
+
const parsed = JSON.parse(raw);
|
|
580
|
+
return {
|
|
581
|
+
...parsed,
|
|
582
|
+
nodes: new Map(parsed.nodes)
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
var TaskGraphStore = class {
|
|
586
|
+
baseDir;
|
|
587
|
+
indexPath;
|
|
588
|
+
constructor(opts) {
|
|
589
|
+
this.baseDir = opts.baseDir;
|
|
590
|
+
this.indexPath = path3.join(this.baseDir, "_index.json");
|
|
591
|
+
}
|
|
592
|
+
async save(graph) {
|
|
593
|
+
await ensureDir(this.baseDir);
|
|
594
|
+
const filePath = this.filePath(graph.id);
|
|
595
|
+
await atomicWrite(filePath, graphToJSON(graph), { mode: 384 });
|
|
596
|
+
await this.updateIndex(graph);
|
|
597
|
+
}
|
|
598
|
+
async load(id) {
|
|
599
|
+
try {
|
|
600
|
+
const raw = await fsp3.readFile(this.filePath(id), "utf8");
|
|
601
|
+
return graphFromJSON(raw);
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async list() {
|
|
607
|
+
const index = await this.readIndex();
|
|
608
|
+
return index.entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
609
|
+
}
|
|
610
|
+
async delete(id) {
|
|
611
|
+
try {
|
|
612
|
+
await fsp3.unlink(this.filePath(id));
|
|
613
|
+
await this.removeFromIndex(id);
|
|
614
|
+
return true;
|
|
615
|
+
} catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async exists(id) {
|
|
620
|
+
try {
|
|
621
|
+
await fsp3.access(this.filePath(id));
|
|
622
|
+
return true;
|
|
623
|
+
} catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
filePath(id) {
|
|
628
|
+
return path3.join(this.baseDir, `${id}.json`);
|
|
629
|
+
}
|
|
630
|
+
async readIndex() {
|
|
631
|
+
try {
|
|
632
|
+
const raw = await fsp3.readFile(this.indexPath, "utf8");
|
|
633
|
+
const parsed = JSON.parse(raw);
|
|
634
|
+
if (parsed?.version === 1) return parsed;
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
return { version: 1, entries: [] };
|
|
638
|
+
}
|
|
639
|
+
async updateIndex(graph) {
|
|
640
|
+
const index = await this.readIndex();
|
|
641
|
+
const completedCount = Array.from(graph.nodes.values()).filter(
|
|
642
|
+
(n) => n.status === "completed"
|
|
643
|
+
).length;
|
|
644
|
+
const entry = {
|
|
645
|
+
id: graph.id,
|
|
646
|
+
specId: graph.specId,
|
|
647
|
+
title: graph.title,
|
|
648
|
+
nodeCount: graph.nodes.size,
|
|
649
|
+
completedCount,
|
|
650
|
+
updatedAt: graph.updatedAt,
|
|
651
|
+
filePath: this.filePath(graph.id)
|
|
652
|
+
};
|
|
653
|
+
const idx = index.entries.findIndex((e) => e.id === graph.id);
|
|
654
|
+
if (idx >= 0) {
|
|
655
|
+
index.entries[idx] = entry;
|
|
656
|
+
} else {
|
|
657
|
+
index.entries.push(entry);
|
|
658
|
+
}
|
|
659
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
660
|
+
}
|
|
661
|
+
async removeFromIndex(id) {
|
|
662
|
+
const index = await this.readIndex();
|
|
663
|
+
index.entries = index.entries.filter((e) => e.id !== id);
|
|
664
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
function shortIdMap(graph) {
|
|
668
|
+
const nodes = Array.from(graph.nodes.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
669
|
+
const m = /* @__PURE__ */ new Map();
|
|
670
|
+
nodes.forEach((n, i) => {
|
|
671
|
+
m.set(n.id, `t${String(i + 1).padStart(2, "0")}`);
|
|
672
|
+
});
|
|
673
|
+
return m;
|
|
674
|
+
}
|
|
675
|
+
function buildBoardTasks(graph) {
|
|
676
|
+
const nodes = Array.from(graph.nodes.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
677
|
+
const shortId = shortIdMap(graph);
|
|
678
|
+
const blockers = /* @__PURE__ */ new Map();
|
|
679
|
+
for (const n of nodes) blockers.set(n.id, []);
|
|
680
|
+
for (const e of graph.edges) {
|
|
681
|
+
if (e.type === "depends_on") blockers.get(e.to)?.push(e.from);
|
|
682
|
+
}
|
|
683
|
+
const statusOf = (id) => graph.nodes.get(id)?.status;
|
|
684
|
+
const depthCache = /* @__PURE__ */ new Map();
|
|
685
|
+
const depthOf = (id, seen = /* @__PURE__ */ new Set()) => {
|
|
686
|
+
const cached = depthCache.get(id);
|
|
687
|
+
if (cached !== void 0) return cached;
|
|
688
|
+
if (seen.has(id)) return 0;
|
|
689
|
+
seen.add(id);
|
|
690
|
+
const deps = blockers.get(id) ?? [];
|
|
691
|
+
const d = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((b) => depthOf(b, seen)));
|
|
692
|
+
depthCache.set(id, d);
|
|
693
|
+
return d;
|
|
694
|
+
};
|
|
695
|
+
const toTask = (n) => {
|
|
696
|
+
const deps = blockers.get(n.id) ?? [];
|
|
697
|
+
const allDepsDone = deps.every((b) => statusOf(b) === "completed");
|
|
698
|
+
const meta = n.metadata ?? {};
|
|
699
|
+
const cancelled = Boolean(meta["cancelled"]);
|
|
700
|
+
const displayStatus = cancelled ? "cancelled" : n.status === "pending" && deps.length > 0 && allDepsDone ? "queued" : n.status;
|
|
701
|
+
return {
|
|
702
|
+
id: n.id,
|
|
703
|
+
shortId: shortId.get(n.id) ?? n.id.slice(0, 6),
|
|
704
|
+
title: n.title,
|
|
705
|
+
description: n.description,
|
|
706
|
+
status: n.status,
|
|
707
|
+
displayStatus,
|
|
708
|
+
priority: n.priority,
|
|
709
|
+
type: n.type,
|
|
710
|
+
deps: deps.map((b) => shortId.get(b) ?? b.slice(0, 6)),
|
|
711
|
+
agentName: n.assignee,
|
|
712
|
+
worktreeBranch: typeof meta["worktreeBranch"] === "string" ? meta["worktreeBranch"] : void 0,
|
|
713
|
+
startedAt: n.startedAt,
|
|
714
|
+
completedAt: n.completedAt,
|
|
715
|
+
retries: typeof meta["retries"] === "number" ? meta["retries"] : 0,
|
|
716
|
+
model: typeof meta["model"] === "string" ? meta["model"] : void 0,
|
|
717
|
+
provider: typeof meta["provider"] === "string" ? meta["provider"] : void 0,
|
|
718
|
+
fallbackModels: Array.isArray(meta["fallbackModels"]) ? meta["fallbackModels"] : void 0,
|
|
719
|
+
verificationCommand: typeof meta["verificationCommand"] === "string" ? meta["verificationCommand"] : void 0
|
|
720
|
+
};
|
|
721
|
+
};
|
|
722
|
+
const tasks = nodes.map(toTask);
|
|
723
|
+
const byDepth = /* @__PURE__ */ new Map();
|
|
724
|
+
for (const n of nodes) {
|
|
725
|
+
const d = depthOf(n.id);
|
|
726
|
+
if (!byDepth.has(d)) byDepth.set(d, []);
|
|
727
|
+
byDepth.get(d)?.push(shortId.get(n.id) ?? n.id.slice(0, 6));
|
|
728
|
+
}
|
|
729
|
+
const columns = [...byDepth.keys()].sort((a, b) => a - b).map((d) => ({ label: d === 0 ? "Start" : `Phase ${d}`, taskIds: byDepth.get(d) ?? [] }));
|
|
730
|
+
return { tasks, columns };
|
|
731
|
+
}
|
|
732
|
+
function buildBoardSnapshot(graph, run, now) {
|
|
733
|
+
const { tasks, columns } = buildBoardTasks(graph);
|
|
734
|
+
return {
|
|
735
|
+
runId: run.runId,
|
|
736
|
+
specId: run.specId,
|
|
737
|
+
graphId: graph.id,
|
|
738
|
+
title: graph.title,
|
|
739
|
+
status: run.status,
|
|
740
|
+
startedAt: run.startedAt,
|
|
741
|
+
updatedAt: now,
|
|
742
|
+
progress: computeTaskProgress(graph),
|
|
743
|
+
wave: run.wave,
|
|
744
|
+
tasks,
|
|
745
|
+
columns,
|
|
746
|
+
diagnostics: run.deadlockChains?.length ? { deadlockChains: run.deadlockChains } : void 0,
|
|
747
|
+
defaultModel: run.defaultModel,
|
|
748
|
+
defaultProvider: run.defaultProvider,
|
|
749
|
+
fallbackModels: run.fallbackModels,
|
|
750
|
+
baseBranch: run.baseBranch,
|
|
751
|
+
mergedCommits: run.mergedCommits?.length ? run.mergedCommits : void 0
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
var SddBoardStore = class {
|
|
755
|
+
baseDir;
|
|
756
|
+
indexPath;
|
|
757
|
+
constructor(opts) {
|
|
758
|
+
this.baseDir = opts.baseDir;
|
|
759
|
+
this.indexPath = path3.join(this.baseDir, "_index.json");
|
|
760
|
+
}
|
|
761
|
+
snapshotPath(runId) {
|
|
762
|
+
return path3.join(this.baseDir, `${this.safe(runId)}.json`);
|
|
763
|
+
}
|
|
764
|
+
eventsPath(runId) {
|
|
765
|
+
return path3.join(this.baseDir, `${this.safe(runId)}.events.jsonl`);
|
|
766
|
+
}
|
|
767
|
+
controlPath(runId) {
|
|
768
|
+
return path3.join(this.baseDir, `${this.safe(runId)}.control.jsonl`);
|
|
769
|
+
}
|
|
770
|
+
async saveSnapshot(snapshot) {
|
|
771
|
+
await ensureDir(this.baseDir);
|
|
772
|
+
await atomicWrite(this.snapshotPath(snapshot.runId), JSON.stringify(snapshot, null, 2), {
|
|
773
|
+
mode: 384
|
|
774
|
+
});
|
|
775
|
+
await this.updateIndex(snapshot);
|
|
776
|
+
}
|
|
777
|
+
async load(runId) {
|
|
778
|
+
try {
|
|
779
|
+
const raw = await fsp3.readFile(this.snapshotPath(runId), "utf8");
|
|
780
|
+
return JSON.parse(raw);
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
async list() {
|
|
786
|
+
const index = await this.readIndex();
|
|
787
|
+
return index.entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
788
|
+
}
|
|
789
|
+
async loadLatestForSpec(specId) {
|
|
790
|
+
const entry = (await this.list()).find((e) => e.specId === specId);
|
|
791
|
+
return entry ? this.load(entry.runId) : null;
|
|
792
|
+
}
|
|
793
|
+
/** Append one line to the board's JSONL event log (best-effort, never throws). */
|
|
794
|
+
async appendEvent(runId, event) {
|
|
795
|
+
try {
|
|
796
|
+
await ensureDir(this.baseDir);
|
|
797
|
+
await fsp3.appendFile(this.eventsPath(runId), `${JSON.stringify(event)}
|
|
798
|
+
`, { mode: 384 });
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** Append a control command (used by readers to steer a CLI-owned run). */
|
|
803
|
+
async appendControl(runId, command) {
|
|
804
|
+
await ensureDir(this.baseDir);
|
|
805
|
+
await fsp3.appendFile(this.controlPath(runId), `${JSON.stringify(command)}
|
|
806
|
+
`, { mode: 384 });
|
|
807
|
+
}
|
|
808
|
+
/** Read + truncate the control queue (the run drains it). Returns parsed commands. */
|
|
809
|
+
async drainControl(runId) {
|
|
810
|
+
const p = this.controlPath(runId);
|
|
811
|
+
let raw;
|
|
812
|
+
try {
|
|
813
|
+
raw = await fsp3.readFile(p, "utf8");
|
|
814
|
+
} catch {
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
await fsp3.writeFile(p, "", { mode: 384 });
|
|
819
|
+
} catch {
|
|
820
|
+
}
|
|
821
|
+
return raw.split("\n").filter((l) => l.trim()).map((l) => {
|
|
822
|
+
try {
|
|
823
|
+
return JSON.parse(l);
|
|
824
|
+
} catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}).filter((c) => c !== null);
|
|
828
|
+
}
|
|
829
|
+
async delete(runId) {
|
|
830
|
+
await Promise.allSettled([
|
|
831
|
+
fsp3.unlink(this.snapshotPath(runId)),
|
|
832
|
+
fsp3.unlink(this.eventsPath(runId)),
|
|
833
|
+
fsp3.unlink(this.controlPath(runId))
|
|
834
|
+
]);
|
|
835
|
+
await this.removeFromIndex(runId);
|
|
836
|
+
}
|
|
837
|
+
// ── internal ────────────────────────────────────────────────────────────
|
|
838
|
+
safe(runId) {
|
|
839
|
+
return runId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
840
|
+
}
|
|
841
|
+
async readIndex() {
|
|
842
|
+
try {
|
|
843
|
+
const raw = await fsp3.readFile(this.indexPath, "utf8");
|
|
844
|
+
const parsed = JSON.parse(raw);
|
|
845
|
+
if (parsed?.version === 1) return parsed;
|
|
846
|
+
} catch {
|
|
847
|
+
}
|
|
848
|
+
return { version: 1, entries: [] };
|
|
849
|
+
}
|
|
850
|
+
async updateIndex(snapshot) {
|
|
851
|
+
const index = await this.readIndex();
|
|
852
|
+
const entry = {
|
|
853
|
+
runId: snapshot.runId,
|
|
854
|
+
specId: snapshot.specId,
|
|
855
|
+
title: snapshot.title,
|
|
856
|
+
status: snapshot.status,
|
|
857
|
+
total: snapshot.progress.total,
|
|
858
|
+
completed: snapshot.progress.completed,
|
|
859
|
+
updatedAt: snapshot.updatedAt
|
|
860
|
+
};
|
|
861
|
+
const idx = index.entries.findIndex((e) => e.runId === snapshot.runId);
|
|
862
|
+
if (idx >= 0) index.entries[idx] = entry;
|
|
863
|
+
else index.entries.push(entry);
|
|
864
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
865
|
+
}
|
|
866
|
+
async removeFromIndex(runId) {
|
|
867
|
+
const index = await this.readIndex();
|
|
868
|
+
index.entries = index.entries.filter((e) => e.runId !== runId);
|
|
869
|
+
await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// src/sdd-board-projector.ts
|
|
874
|
+
var SddBoardProjector = class _SddBoardProjector {
|
|
875
|
+
o;
|
|
876
|
+
now;
|
|
877
|
+
throttleMs;
|
|
878
|
+
shortId;
|
|
879
|
+
status = "idle";
|
|
880
|
+
wave = 0;
|
|
881
|
+
startedAt;
|
|
882
|
+
deadlockChains = [];
|
|
883
|
+
/** Live activity feed, most recent first (capped). */
|
|
884
|
+
feed = [];
|
|
885
|
+
static FEED_CAP = 60;
|
|
886
|
+
finished = false;
|
|
887
|
+
runDeadlocked = false;
|
|
888
|
+
runStopped = false;
|
|
889
|
+
/** Squash commits the run landed on the base branch (for post-run rollback). */
|
|
890
|
+
mergedCommits = [];
|
|
891
|
+
/** Base branch reported by the run at start (overrides the constructor option). */
|
|
892
|
+
runBaseBranch;
|
|
893
|
+
dirty = false;
|
|
894
|
+
timer = null;
|
|
895
|
+
unsubs = [];
|
|
896
|
+
/** Tail of in-flight persistence, so callers can await a settled state. */
|
|
897
|
+
lastSave = Promise.resolve();
|
|
898
|
+
constructor(opts) {
|
|
899
|
+
this.o = opts;
|
|
900
|
+
this.now = opts.now ?? Date.now;
|
|
901
|
+
this.throttleMs = opts.throttleMs ?? 250;
|
|
902
|
+
this.shortId = shortIdMap(opts.graph);
|
|
903
|
+
this.startedAt = this.now();
|
|
904
|
+
this.unsubs.push(opts.tracker.subscribe(() => this.markDirty()));
|
|
905
|
+
this.onRun("sdd.run.started", (e) => {
|
|
906
|
+
this.status = "running";
|
|
907
|
+
this.startedAt = this.now();
|
|
908
|
+
if (e.baseBranch) this.runBaseBranch = e.baseBranch;
|
|
909
|
+
this.markDirty();
|
|
910
|
+
});
|
|
911
|
+
this.onRun("sdd.run.finished", (e) => {
|
|
912
|
+
this.finished = true;
|
|
913
|
+
this.runDeadlocked = e.deadlocked;
|
|
914
|
+
this.runStopped = e.stopped;
|
|
915
|
+
this.flush();
|
|
916
|
+
});
|
|
917
|
+
this.onRun("sdd.wave", (e) => {
|
|
918
|
+
this.wave = e.wave;
|
|
919
|
+
this.pushFeed({ ts: this.now(), kind: "wave", text: `Wave ${e.wave + 1} started \xB7 ${e.batchSize} task(s) in parallel` });
|
|
920
|
+
this.markDirty();
|
|
921
|
+
});
|
|
922
|
+
this.onRun("sdd.deadlock", (e) => {
|
|
923
|
+
this.deadlockChains = e.chains.map((c) => ({
|
|
924
|
+
blocked: this.shortId.get(c.blocked) ?? c.blocked.slice(0, 6),
|
|
925
|
+
blockedBy: c.blockedBy.map((b) => this.shortId.get(b) ?? b.slice(0, 6))
|
|
926
|
+
}));
|
|
927
|
+
this.pushFeed({ ts: this.now(), kind: "deadlock", text: `Deadlock \u2014 ${e.chains.length} task(s) blocked by failed work` });
|
|
928
|
+
this.markDirty();
|
|
929
|
+
});
|
|
930
|
+
this.onRun("sdd.task.started", (e) => {
|
|
931
|
+
const sid = this.shortId.get(e.taskId);
|
|
932
|
+
this.pushFeed({
|
|
933
|
+
ts: this.now(),
|
|
934
|
+
kind: "started",
|
|
935
|
+
taskShortId: sid,
|
|
936
|
+
agentName: e.agentName,
|
|
937
|
+
text: `${e.agentName || "a worker"} picked up ${sid ?? "a task"}${this.titleOf(e.taskId)}`
|
|
938
|
+
});
|
|
939
|
+
this.markDirty();
|
|
940
|
+
});
|
|
941
|
+
this.onRun("sdd.task.completed", (e) => {
|
|
942
|
+
const sid = this.shortId.get(e.taskId);
|
|
943
|
+
const agent = this.assigneeOf(e.taskId);
|
|
944
|
+
this.pushFeed({
|
|
945
|
+
ts: this.now(),
|
|
946
|
+
kind: "completed",
|
|
947
|
+
taskShortId: sid,
|
|
948
|
+
agentName: agent,
|
|
949
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} completed${agent ? ` by ${agent}` : ""} \xB7 ${(e.durationMs / 1e3).toFixed(1)}s`
|
|
950
|
+
});
|
|
951
|
+
this.markDirty();
|
|
952
|
+
});
|
|
953
|
+
this.onRun("sdd.task.failed", (e) => {
|
|
954
|
+
const sid = this.shortId.get(e.taskId);
|
|
955
|
+
this.pushFeed({
|
|
956
|
+
ts: this.now(),
|
|
957
|
+
kind: "failed",
|
|
958
|
+
taskShortId: sid,
|
|
959
|
+
agentName: this.assigneeOf(e.taskId),
|
|
960
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} failed \u2014 ${e.error}`
|
|
961
|
+
});
|
|
962
|
+
this.markDirty();
|
|
963
|
+
});
|
|
964
|
+
this.onRun("sdd.task.retrying", (e) => {
|
|
965
|
+
const sid = this.shortId.get(e.taskId);
|
|
966
|
+
this.pushFeed({
|
|
967
|
+
ts: this.now(),
|
|
968
|
+
kind: "retrying",
|
|
969
|
+
taskShortId: sid,
|
|
970
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} retrying (${e.attempt}/${e.maxRetries})`
|
|
971
|
+
});
|
|
972
|
+
this.markDirty();
|
|
973
|
+
});
|
|
974
|
+
this.onRun("sdd.task.verification_failed", (e) => {
|
|
975
|
+
const sid = this.shortId.get(e.taskId);
|
|
976
|
+
this.pushFeed({
|
|
977
|
+
ts: this.now(),
|
|
978
|
+
kind: "verification_failed",
|
|
979
|
+
taskShortId: sid,
|
|
980
|
+
agentName: this.assigneeOf(e.taskId),
|
|
981
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} failed verification \u2014 ${e.reason}`
|
|
982
|
+
});
|
|
983
|
+
this.markDirty();
|
|
984
|
+
});
|
|
985
|
+
this.onRun("sdd.task.conflict", (e) => {
|
|
986
|
+
const sid = this.shortId.get(e.taskId);
|
|
987
|
+
const files = e.conflictFiles.length;
|
|
988
|
+
this.pushFeed({
|
|
989
|
+
ts: this.now(),
|
|
990
|
+
kind: "conflict",
|
|
991
|
+
taskShortId: sid,
|
|
992
|
+
agentName: this.assigneeOf(e.taskId),
|
|
993
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} merge conflict \u2014 ${files} file(s)${files ? `: ${e.conflictFiles.slice(0, 3).join(", ")}${files > 3 ? "\u2026" : ""}` : ""}`
|
|
994
|
+
});
|
|
995
|
+
this.markDirty();
|
|
996
|
+
});
|
|
997
|
+
this.onRun("sdd.task.merged", (e) => {
|
|
998
|
+
const title = this.o.graph.nodes.get(e.taskId)?.title ?? "";
|
|
999
|
+
this.mergedCommits.push({ taskId: e.taskId, sha: e.sha, title });
|
|
1000
|
+
const sid = this.shortId.get(e.taskId);
|
|
1001
|
+
this.pushFeed({
|
|
1002
|
+
ts: this.now(),
|
|
1003
|
+
kind: "completed",
|
|
1004
|
+
taskShortId: sid,
|
|
1005
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} merged \u2192 ${this.runBaseBranch ?? this.o.baseBranch ?? "base"} (${e.sha.slice(0, 8)})`
|
|
1006
|
+
});
|
|
1007
|
+
this.markDirty();
|
|
1008
|
+
});
|
|
1009
|
+
this.onRun("sdd.task.split", (e) => {
|
|
1010
|
+
const sid = this.shortId.get(e.taskId);
|
|
1011
|
+
this.pushFeed({
|
|
1012
|
+
ts: this.now(),
|
|
1013
|
+
kind: "split",
|
|
1014
|
+
taskShortId: sid,
|
|
1015
|
+
text: `${sid ?? "task"}${this.titleOf(e.taskId)} split into ${e.subtaskIds.length} sub-task(s)`
|
|
1016
|
+
});
|
|
1017
|
+
this.markDirty();
|
|
1018
|
+
});
|
|
1019
|
+
this.onRun("sdd.supervisor.decision", (e) => {
|
|
1020
|
+
const sid = this.shortId.get(e.taskId);
|
|
1021
|
+
this.pushFeed({
|
|
1022
|
+
ts: this.now(),
|
|
1023
|
+
kind: "supervisor",
|
|
1024
|
+
taskShortId: sid,
|
|
1025
|
+
text: `supervisor \u2192 ${e.action} for ${sid ?? "task"}${this.titleOf(e.taskId)}${e.rationale ? ` (${e.rationale})` : ""}`
|
|
1026
|
+
});
|
|
1027
|
+
this.markDirty();
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
pushFeed(entry) {
|
|
1031
|
+
this.feed.unshift(entry);
|
|
1032
|
+
if (this.feed.length > _SddBoardProjector.FEED_CAP) this.feed.length = _SddBoardProjector.FEED_CAP;
|
|
1033
|
+
}
|
|
1034
|
+
/** ` (title…)` suffix for a feed line, or '' when the node/title is missing. */
|
|
1035
|
+
titleOf(taskId) {
|
|
1036
|
+
const t = this.o.graph.nodes.get(taskId)?.title;
|
|
1037
|
+
if (!t) return "";
|
|
1038
|
+
return ` (${t.length > 40 ? `${t.slice(0, 39)}\u2026` : t})`;
|
|
1039
|
+
}
|
|
1040
|
+
assigneeOf(taskId) {
|
|
1041
|
+
return this.o.graph.nodes.get(taskId)?.assignee;
|
|
1042
|
+
}
|
|
1043
|
+
/** Latest snapshot, built on demand (e.g. for a late-joining client). */
|
|
1044
|
+
snapshot() {
|
|
1045
|
+
return this.build();
|
|
1046
|
+
}
|
|
1047
|
+
/** Resolve once all in-flight snapshot persistence has settled. */
|
|
1048
|
+
async drain() {
|
|
1049
|
+
await this.lastSave;
|
|
1050
|
+
}
|
|
1051
|
+
/** Stop projecting and release subscriptions. */
|
|
1052
|
+
dispose() {
|
|
1053
|
+
if (this.timer) {
|
|
1054
|
+
clearTimeout(this.timer);
|
|
1055
|
+
this.timer = null;
|
|
1056
|
+
}
|
|
1057
|
+
for (const u of this.unsubs) u();
|
|
1058
|
+
this.unsubs.length = 0;
|
|
1059
|
+
}
|
|
1060
|
+
// ── internal ────────────────────────────────────────────────────────────
|
|
1061
|
+
/** Subscribe to a run event scoped to this run id; also append to JSONL. */
|
|
1062
|
+
onRun(event, handler) {
|
|
1063
|
+
const wrapped = (e) => {
|
|
1064
|
+
if (e.runId !== this.o.runId) return;
|
|
1065
|
+
void this.o.store?.appendEvent(this.o.runId, { ts: this.now(), type: event, payload: e });
|
|
1066
|
+
handler(e);
|
|
1067
|
+
};
|
|
1068
|
+
const off = this.o.events.on(event, wrapped);
|
|
1069
|
+
this.unsubs.push(off);
|
|
1070
|
+
}
|
|
1071
|
+
resolveStatus(completed, total) {
|
|
1072
|
+
if (!this.finished) return this.status;
|
|
1073
|
+
if (this.runDeadlocked) return "deadlocked";
|
|
1074
|
+
if (total > 0 && completed >= total) return "completed";
|
|
1075
|
+
if (this.runStopped) return "stopped";
|
|
1076
|
+
return "failed";
|
|
1077
|
+
}
|
|
1078
|
+
build() {
|
|
1079
|
+
const snap = buildBoardSnapshot(
|
|
1080
|
+
this.o.graph,
|
|
1081
|
+
{
|
|
1082
|
+
runId: this.o.runId,
|
|
1083
|
+
specId: this.o.specId,
|
|
1084
|
+
status: "running",
|
|
1085
|
+
startedAt: this.startedAt,
|
|
1086
|
+
wave: this.wave,
|
|
1087
|
+
deadlockChains: this.deadlockChains,
|
|
1088
|
+
defaultModel: this.o.defaultModel,
|
|
1089
|
+
defaultProvider: this.o.defaultProvider,
|
|
1090
|
+
fallbackModels: this.o.fallbackModels,
|
|
1091
|
+
baseBranch: this.runBaseBranch ?? this.o.baseBranch,
|
|
1092
|
+
mergedCommits: this.mergedCommits
|
|
1093
|
+
},
|
|
1094
|
+
this.now()
|
|
1095
|
+
);
|
|
1096
|
+
snap.status = this.resolveStatus(snap.progress.completed, snap.progress.total);
|
|
1097
|
+
snap.feed = this.feed.slice(0, _SddBoardProjector.FEED_CAP);
|
|
1098
|
+
return snap;
|
|
1099
|
+
}
|
|
1100
|
+
markDirty() {
|
|
1101
|
+
this.dirty = true;
|
|
1102
|
+
if (this.timer || this.finished) return;
|
|
1103
|
+
this.timer = setTimeout(() => {
|
|
1104
|
+
this.timer = null;
|
|
1105
|
+
if (this.dirty) this.flush();
|
|
1106
|
+
}, this.throttleMs);
|
|
1107
|
+
}
|
|
1108
|
+
flush() {
|
|
1109
|
+
this.dirty = false;
|
|
1110
|
+
if (this.timer) {
|
|
1111
|
+
clearTimeout(this.timer);
|
|
1112
|
+
this.timer = null;
|
|
1113
|
+
}
|
|
1114
|
+
const snap = this.build();
|
|
1115
|
+
const sessionId = this.currentSessionId();
|
|
1116
|
+
this.o.events.emit("sdd.board.snapshot", {
|
|
1117
|
+
...sessionId ? { sessionId } : {},
|
|
1118
|
+
runId: this.o.runId,
|
|
1119
|
+
snapshot: snap
|
|
1120
|
+
});
|
|
1121
|
+
if (this.o.store) {
|
|
1122
|
+
const store = this.o.store;
|
|
1123
|
+
this.lastSave = this.lastSave.then(() => store.saveSnapshot(snap)).catch(() => {
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
currentSessionId() {
|
|
1128
|
+
const value = typeof this.o.sessionId === "function" ? this.o.sessionId() : this.o.sessionId;
|
|
1129
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
// src/sdd-run-registry.ts
|
|
1134
|
+
var SddRunRegistry = class {
|
|
1135
|
+
current = null;
|
|
1136
|
+
register(control) {
|
|
1137
|
+
this.current = control;
|
|
1138
|
+
}
|
|
1139
|
+
clear(runId) {
|
|
1140
|
+
if (this.current?.runId === runId) this.current = null;
|
|
1141
|
+
}
|
|
1142
|
+
getActive() {
|
|
1143
|
+
return this.current;
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
function buildQuestioningPrompt(session, min, max) {
|
|
1147
|
+
const answered = session.answers.length;
|
|
1148
|
+
const remaining = Math.max(0, min - answered);
|
|
1149
|
+
const budget = max - answered;
|
|
1150
|
+
const lines = [
|
|
1151
|
+
`\u2550\u2550\u2550 SDD Spec Builder \u2550\u2550\u2550`,
|
|
1152
|
+
`Feature: "${session.title}"`,
|
|
1153
|
+
session.userIntent ? `Intent: ${session.userIntent}` : "",
|
|
1154
|
+
`Phase: Questioning (${answered} answered, ${budget} remaining budget)`,
|
|
1155
|
+
"",
|
|
1156
|
+
"**Instructions for AI:**",
|
|
1157
|
+
"",
|
|
1158
|
+
"You are conducting a specification interview. Your job is to ask the user",
|
|
1159
|
+
"intelligent, contextual questions to understand what they want to build.",
|
|
1160
|
+
"",
|
|
1161
|
+
`You have asked ${answered} questions so far.`
|
|
1162
|
+
];
|
|
1163
|
+
if (remaining > 0) {
|
|
1164
|
+
lines.push(`You MUST ask at least ${remaining} more question(s) before generating the spec.`);
|
|
1165
|
+
} else if (budget <= 0) {
|
|
1166
|
+
lines.push("You have reached the maximum question budget. Generate the spec NOW.");
|
|
1167
|
+
} else {
|
|
1168
|
+
lines.push(
|
|
1169
|
+
"You may ask more questions if needed, or generate the spec if you have enough information.",
|
|
1170
|
+
"Ask a question ONLY if it reveals something you genuinely need to know."
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
lines.push(
|
|
1174
|
+
"",
|
|
1175
|
+
"**Rules:**",
|
|
1176
|
+
"- Ask ONE question at a time",
|
|
1177
|
+
"- Questions must be specific and contextual \u2014 never generic",
|
|
1178
|
+
"- Adapt based on previous answers",
|
|
1179
|
+
"- Cover: scope, constraints, edge cases, integrations, security, performance as relevant",
|
|
1180
|
+
"- When you have enough info, respond with the full specification in JSON format",
|
|
1181
|
+
"- This is a planning interview: respond with TEXT ONLY (a question, or the spec JSON).",
|
|
1182
|
+
" Do NOT write or edit files, and do NOT run shell/terminal commands \u2014 the code is",
|
|
1183
|
+
" written later, after the plan is approved.",
|
|
1184
|
+
"",
|
|
1185
|
+
`**Question budget:** ${budget}/${max} remaining`,
|
|
1186
|
+
`**Minimum required:** ${remaining > 0 ? remaining : "met"}`
|
|
1187
|
+
);
|
|
1188
|
+
if (session.projectContext) {
|
|
1189
|
+
lines.push("", "**Project Context:**", "```", session.projectContext, "```");
|
|
1190
|
+
}
|
|
1191
|
+
if (answered > 0) {
|
|
1192
|
+
lines.push("", "**Conversation so far:**");
|
|
1193
|
+
for (let i = 0; i < answered; i++) {
|
|
1194
|
+
const a = expectDefined(session.answers[i]);
|
|
1195
|
+
lines.push(``, `Q${i + 1}: ${a.question}`, `A${i + 1}: ${a.answer}`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
lines.push(
|
|
1199
|
+
"",
|
|
1200
|
+
"---",
|
|
1201
|
+
"Now either:",
|
|
1202
|
+
`1. Ask your next question (if you need more info)`,
|
|
1203
|
+
`2. Generate the complete specification as JSON (if ready)`,
|
|
1204
|
+
"",
|
|
1205
|
+
"If generating spec, output JSON inside ```json code block with this structure:",
|
|
1206
|
+
"```json",
|
|
1207
|
+
"{",
|
|
1208
|
+
' "title": "...",',
|
|
1209
|
+
' "overview": "...",',
|
|
1210
|
+
' "sections": [{ "type": "overview|requirements|architecture|api|data|security|acceptance", "title": "...", "content": "...", "level": 1 }],',
|
|
1211
|
+
' "requirements": [{ "id": "REQ-1", "type": "functional|non-functional|security|performance|ux", "priority": "critical|high|medium|low", "description": "...", "acceptanceCriteria": ["..."] }]',
|
|
1212
|
+
"}",
|
|
1213
|
+
"```"
|
|
1214
|
+
);
|
|
1215
|
+
return lines.filter(Boolean).join("\n");
|
|
1216
|
+
}
|
|
1217
|
+
function buildSpecReviewPrompt(session) {
|
|
1218
|
+
const spec = session.spec;
|
|
1219
|
+
if (!spec) return "No spec generated yet.";
|
|
1220
|
+
const reqSummary = spec.requirements.map((r) => ` [${r.priority}] ${r.description}`).join("\n");
|
|
1221
|
+
return [
|
|
1222
|
+
`\u2550\u2550\u2550 Spec Review \u2550\u2550\u2550`,
|
|
1223
|
+
`Feature: "${spec.title}"`,
|
|
1224
|
+
`Requirements: ${spec.requirements.length}`,
|
|
1225
|
+
"",
|
|
1226
|
+
"**Specification:**",
|
|
1227
|
+
spec.overview,
|
|
1228
|
+
"",
|
|
1229
|
+
"**Requirements:**",
|
|
1230
|
+
reqSummary,
|
|
1231
|
+
"",
|
|
1232
|
+
"---",
|
|
1233
|
+
"Approve this spec? The AI will then generate an implementation plan and tasks.",
|
|
1234
|
+
'Say "approve" to proceed, or describe what needs to change.'
|
|
1235
|
+
].join("\n");
|
|
1236
|
+
}
|
|
1237
|
+
function buildImplementationPrompt(session) {
|
|
1238
|
+
const spec = session.spec;
|
|
1239
|
+
if (!spec) return "No spec to implement.";
|
|
1240
|
+
const reqList = spec.requirements.map((r) => ` - [${r.priority}] ${r.description}`).join("\n");
|
|
1241
|
+
return [
|
|
1242
|
+
`\u2550\u2550\u2550 Implementation Planning \u2550\u2550\u2550`,
|
|
1243
|
+
`Feature: "${spec.title}"`,
|
|
1244
|
+
`Requirements: ${spec.requirements.length}`,
|
|
1245
|
+
"",
|
|
1246
|
+
"**Requirements to implement:**",
|
|
1247
|
+
reqList,
|
|
1248
|
+
"",
|
|
1249
|
+
"**Instructions for AI:**",
|
|
1250
|
+
"Generate a detailed implementation plan for this specification.",
|
|
1251
|
+
"This is a PLANNING step \u2014 describe the plan and emit the task JSON as TEXT. Do NOT",
|
|
1252
|
+
"create or edit files and do NOT run shell/terminal commands here; the tasks you list",
|
|
1253
|
+
"are executed later, one by one, after you approve them.",
|
|
1254
|
+
"Include:",
|
|
1255
|
+
"1. Architecture decisions",
|
|
1256
|
+
"2. File structure changes",
|
|
1257
|
+
"3. Key implementation details",
|
|
1258
|
+
"4. Dependency requirements",
|
|
1259
|
+
"5. Testing strategy",
|
|
1260
|
+
"",
|
|
1261
|
+
"**IMPORTANT:** After the plan, you MUST generate executable tasks as a JSON array.",
|
|
1262
|
+
"Each task should be a concrete, actionable step. Output the JSON inside a ```json code block:",
|
|
1263
|
+
"```json",
|
|
1264
|
+
"[",
|
|
1265
|
+
" {",
|
|
1266
|
+
' "id": "t1",',
|
|
1267
|
+
' "title": "Create auth middleware",',
|
|
1268
|
+
' "description": "Implement JWT verification middleware for protected routes",',
|
|
1269
|
+
' "type": "feature",',
|
|
1270
|
+
' "priority": "critical",',
|
|
1271
|
+
' "estimateHours": 3,',
|
|
1272
|
+
' "dependsOn": [],',
|
|
1273
|
+
' "tags": ["auth", "middleware"]',
|
|
1274
|
+
" },",
|
|
1275
|
+
" {",
|
|
1276
|
+
' "id": "t2",',
|
|
1277
|
+
' "title": "Write auth tests",',
|
|
1278
|
+
' "description": "Unit and integration tests for authentication flow",',
|
|
1279
|
+
' "type": "test",',
|
|
1280
|
+
' "priority": "high",',
|
|
1281
|
+
' "estimateHours": 2,',
|
|
1282
|
+
' "dependsOn": ["t1"],',
|
|
1283
|
+
' "tags": ["test", "auth"]',
|
|
1284
|
+
" }",
|
|
1285
|
+
"]",
|
|
1286
|
+
"```",
|
|
1287
|
+
"",
|
|
1288
|
+
"Rules:",
|
|
1289
|
+
'- Give every task a short stable "id" (t1, t2, \u2026). Reference prerequisites in "dependsOn"',
|
|
1290
|
+
" as a list of those ids \u2014 this builds the real dependency graph that drives parallel vs",
|
|
1291
|
+
" sequential execution.",
|
|
1292
|
+
'- "dependsOn": [] means the task is independent and may run in parallel with other roots.',
|
|
1293
|
+
"- A task with dependsOn runs ONLY after every listed task completes. Model true ordering:",
|
|
1294
|
+
" tests depend on the feature they test, docs/integration depend on the parts they cover.",
|
|
1295
|
+
"- Do NOT create cycles (t1\u2192t2\u2192t1). Keep chains as shallow as correctness allows so",
|
|
1296
|
+
" independent work runs concurrently.",
|
|
1297
|
+
'- Use type: "feature" for code, "test" for tests, "docs" for documentation, "chore" for config',
|
|
1298
|
+
'- Use priority: "critical" for blockers, "high" for core features, "medium" for nice-to-haves, "low" for polish'
|
|
1299
|
+
].join("\n");
|
|
1300
|
+
}
|
|
1301
|
+
function buildTaskReviewPrompt(session) {
|
|
1302
|
+
return [
|
|
1303
|
+
`\u2550\u2550\u2550 Task Review \u2550\u2550\u2550`,
|
|
1304
|
+
`Feature: "${session.spec?.title ?? session.title}"`,
|
|
1305
|
+
"",
|
|
1306
|
+
session.implementation ?? "No implementation plan yet.",
|
|
1307
|
+
"",
|
|
1308
|
+
"---",
|
|
1309
|
+
'Ready to execute these tasks? Say "execute" to begin, or describe changes needed.'
|
|
1310
|
+
].join("\n");
|
|
1311
|
+
}
|
|
1312
|
+
function buildExecutingPrompt(session) {
|
|
1313
|
+
return [
|
|
1314
|
+
`\u2550\u2550\u2550 Task Execution \u2550\u2550\u2550`,
|
|
1315
|
+
`Feature: "${session.spec?.title ?? session.title}"`,
|
|
1316
|
+
"",
|
|
1317
|
+
"**Instructions for AI:**",
|
|
1318
|
+
"Execute the tasks one by one in the order shown in the task list above.",
|
|
1319
|
+
"",
|
|
1320
|
+
"For each task:",
|
|
1321
|
+
"1. Implement the code (create/modify files)",
|
|
1322
|
+
"2. Write tests if applicable",
|
|
1323
|
+
"3. After completing a task, tell the user to run: /sdd done <task number or title>",
|
|
1324
|
+
"4. Then move to the next task",
|
|
1325
|
+
"",
|
|
1326
|
+
"**Important:**",
|
|
1327
|
+
"- Focus on ONE task at a time",
|
|
1328
|
+
"- After completing each task, explicitly state what you did",
|
|
1329
|
+
'- Tell the user: "Run /sdd done <N> to mark this task complete"',
|
|
1330
|
+
"- Then proceed to the next task automatically",
|
|
1331
|
+
"- When ALL tasks are done, provide a summary of everything implemented",
|
|
1332
|
+
"",
|
|
1333
|
+
"Start executing the first pending task now."
|
|
1334
|
+
].join("\n");
|
|
1335
|
+
}
|
|
1336
|
+
var AISpecBuilder = class {
|
|
1337
|
+
session;
|
|
1338
|
+
store;
|
|
1339
|
+
minQuestions;
|
|
1340
|
+
maxQuestions;
|
|
1341
|
+
sessionPath;
|
|
1342
|
+
constructor(opts) {
|
|
1343
|
+
this.store = opts.store;
|
|
1344
|
+
this.minQuestions = opts.minQuestions ?? 2;
|
|
1345
|
+
this.maxQuestions = opts.maxQuestions ?? 10;
|
|
1346
|
+
this.sessionPath = opts.sessionPath;
|
|
1347
|
+
this.session = {
|
|
1348
|
+
id: crypto.randomUUID(),
|
|
1349
|
+
phase: "questioning",
|
|
1350
|
+
title: "",
|
|
1351
|
+
userIntent: "",
|
|
1352
|
+
projectContext: opts.projectContext ?? "",
|
|
1353
|
+
answers: [],
|
|
1354
|
+
questionCount: 0,
|
|
1355
|
+
approved: false,
|
|
1356
|
+
createdAt: Date.now(),
|
|
1357
|
+
updatedAt: Date.now()
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
// ── Session Persistence ──────────────────────────────────────────────────
|
|
1361
|
+
/** Save session state to disk. */
|
|
1362
|
+
async saveSession() {
|
|
1363
|
+
if (!this.sessionPath) return;
|
|
1364
|
+
try {
|
|
1365
|
+
const fsp5 = await import('fs/promises');
|
|
1366
|
+
const path4 = await import('path');
|
|
1367
|
+
const { atomicWrite: atomicWrite4 } = await import('@wrongstack/core');
|
|
1368
|
+
await fsp5.mkdir(path4.dirname(this.sessionPath), { recursive: true });
|
|
1369
|
+
await atomicWrite4(this.sessionPath, JSON.stringify(this.session, null, 2));
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
/** Load session state from disk. Returns true if a session was loaded. */
|
|
1374
|
+
async loadSession() {
|
|
1375
|
+
if (!this.sessionPath) return false;
|
|
1376
|
+
try {
|
|
1377
|
+
const fsp5 = await import('fs/promises');
|
|
1378
|
+
const raw = await fsp5.readFile(this.sessionPath, "utf8");
|
|
1379
|
+
const loaded = JSON.parse(raw);
|
|
1380
|
+
if (loaded?.id && loaded?.phase && loaded?.title) {
|
|
1381
|
+
this.session = loaded;
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
} catch {
|
|
1385
|
+
}
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
/** Delete saved session from disk. */
|
|
1389
|
+
async deleteSession() {
|
|
1390
|
+
if (!this.sessionPath) return;
|
|
1391
|
+
try {
|
|
1392
|
+
const fsp5 = await import('fs/promises');
|
|
1393
|
+
await fsp5.unlink(this.sessionPath);
|
|
1394
|
+
} catch {
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/** Auto-save helper — calls saveSession() but never throws.
|
|
1398
|
+
* Failures are surfaced via process.emitWarning so a persistent
|
|
1399
|
+
* ENOSPC / EACCES doesn't silently strand session edits in memory. */
|
|
1400
|
+
autoSave() {
|
|
1401
|
+
this.saveSession().catch((err) => {
|
|
1402
|
+
const detail = toErrorMessage(err);
|
|
1403
|
+
process.emitWarning(
|
|
1404
|
+
`SpecBuilder autoSave failed: ${detail}`,
|
|
1405
|
+
"SpecBuilderWarning"
|
|
1406
|
+
);
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
// ── Session Lifecycle ─────────────────────────────────────────────────────
|
|
1410
|
+
/** Start a new session with a title and optional intent. */
|
|
1411
|
+
startSession(title, intent) {
|
|
1412
|
+
this.session.title = title;
|
|
1413
|
+
this.session.userIntent = intent ?? "";
|
|
1414
|
+
this.session.phase = "questioning";
|
|
1415
|
+
this.session.updatedAt = Date.now();
|
|
1416
|
+
this.autoSave();
|
|
1417
|
+
}
|
|
1418
|
+
/** Get current session state (readonly). */
|
|
1419
|
+
getSession() {
|
|
1420
|
+
return { ...this.session };
|
|
1421
|
+
}
|
|
1422
|
+
/** Get the current phase. */
|
|
1423
|
+
getPhase() {
|
|
1424
|
+
return this.session.phase;
|
|
1425
|
+
}
|
|
1426
|
+
// ── AI Prompt Generation ──────────────────────────────────────────────────
|
|
1427
|
+
/**
|
|
1428
|
+
* Get the AI prompt for the current phase.
|
|
1429
|
+
* This prompt is injected into the conversation so the AI agent knows
|
|
1430
|
+
* what to do next (ask a question, generate a spec, etc.).
|
|
1431
|
+
*/
|
|
1432
|
+
getAIPrompt() {
|
|
1433
|
+
switch (this.session.phase) {
|
|
1434
|
+
case "questioning":
|
|
1435
|
+
return buildQuestioningPrompt(this.session, this.minQuestions, this.maxQuestions);
|
|
1436
|
+
case "spec_review":
|
|
1437
|
+
return buildSpecReviewPrompt(this.session);
|
|
1438
|
+
case "implementation":
|
|
1439
|
+
return buildImplementationPrompt(this.session);
|
|
1440
|
+
case "task_review":
|
|
1441
|
+
return buildTaskReviewPrompt(this.session);
|
|
1442
|
+
case "executing":
|
|
1443
|
+
return buildExecutingPrompt(this.session);
|
|
1444
|
+
case "done":
|
|
1445
|
+
return "All tasks completed. Specification is fully implemented.";
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// ── Answer Processing ─────────────────────────────────────────────────────
|
|
1449
|
+
/**
|
|
1450
|
+
* Record a question/answer pair from the AI conversation.
|
|
1451
|
+
* Call this when the AI asks a question and the user responds.
|
|
1452
|
+
*/
|
|
1453
|
+
addAnswer(question, answer) {
|
|
1454
|
+
this.session.answers.push({ question, answer, timestamp: Date.now() });
|
|
1455
|
+
this.session.questionCount++;
|
|
1456
|
+
this.session.updatedAt = Date.now();
|
|
1457
|
+
this.autoSave();
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Check if more questions should be asked.
|
|
1461
|
+
* Returns false if max reached or if the AI has signaled it has enough info.
|
|
1462
|
+
*/
|
|
1463
|
+
shouldContinueQuestioning() {
|
|
1464
|
+
return this.session.questionCount < this.maxQuestions;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Check if minimum questions have been asked.
|
|
1468
|
+
*/
|
|
1469
|
+
hasMetMinimumQuestions() {
|
|
1470
|
+
return this.session.questionCount >= this.minQuestions;
|
|
1471
|
+
}
|
|
1472
|
+
// ── Phase Transitions ─────────────────────────────────────────────────────
|
|
1473
|
+
/**
|
|
1474
|
+
* Set the generated specification and move to spec_review phase.
|
|
1475
|
+
*/
|
|
1476
|
+
setSpec(spec) {
|
|
1477
|
+
this.session.spec = spec;
|
|
1478
|
+
this.session.phase = "spec_review";
|
|
1479
|
+
this.session.updatedAt = Date.now();
|
|
1480
|
+
this.autoSave();
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Approve the current phase and advance to the next.
|
|
1484
|
+
* questioning → spec_review (requires spec to be set)
|
|
1485
|
+
* spec_review → implementation
|
|
1486
|
+
* implementation → task_review (requires implementation to be set)
|
|
1487
|
+
* task_review → executing
|
|
1488
|
+
* executing → done
|
|
1489
|
+
*/
|
|
1490
|
+
approve() {
|
|
1491
|
+
switch (this.session.phase) {
|
|
1492
|
+
case "questioning":
|
|
1493
|
+
if (!this.session.spec) {
|
|
1494
|
+
throw new SddError({
|
|
1495
|
+
message: "Cannot approve: no spec generated yet.",
|
|
1496
|
+
code: ERROR_CODES.SDD_INVALID_STATE,
|
|
1497
|
+
context: { phase: "questioning", sessionId: this.session.id }
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
this.session.phase = "spec_review";
|
|
1501
|
+
break;
|
|
1502
|
+
case "spec_review":
|
|
1503
|
+
this.session.phase = "implementation";
|
|
1504
|
+
break;
|
|
1505
|
+
case "implementation":
|
|
1506
|
+
this.session.phase = "task_review";
|
|
1507
|
+
break;
|
|
1508
|
+
case "task_review":
|
|
1509
|
+
this.session.phase = "executing";
|
|
1510
|
+
break;
|
|
1511
|
+
case "executing":
|
|
1512
|
+
this.session.phase = "done";
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
this.session.approved = true;
|
|
1516
|
+
this.session.updatedAt = Date.now();
|
|
1517
|
+
this.autoSave();
|
|
1518
|
+
return this.session.phase;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Set the implementation plan text.
|
|
1522
|
+
*/
|
|
1523
|
+
setImplementation(plan) {
|
|
1524
|
+
this.session.implementation = plan;
|
|
1525
|
+
this.session.phase = "task_review";
|
|
1526
|
+
this.session.updatedAt = Date.now();
|
|
1527
|
+
this.autoSave();
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Mark session as done.
|
|
1531
|
+
*/
|
|
1532
|
+
markDone() {
|
|
1533
|
+
this.session.phase = "done";
|
|
1534
|
+
this.session.updatedAt = Date.now();
|
|
1535
|
+
this.autoSave();
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Set the task graph ID for this session.
|
|
1539
|
+
*/
|
|
1540
|
+
setTaskGraphId(graphId) {
|
|
1541
|
+
this.session.taskGraphId = graphId;
|
|
1542
|
+
this.autoSave();
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Get the task graph ID for this session.
|
|
1546
|
+
*/
|
|
1547
|
+
getTaskGraphId() {
|
|
1548
|
+
return this.session.taskGraphId;
|
|
1549
|
+
}
|
|
1550
|
+
// ── Spec Persistence ──────────────────────────────────────────────────────
|
|
1551
|
+
/**
|
|
1552
|
+
* Save the current spec to the store.
|
|
1553
|
+
*/
|
|
1554
|
+
async saveSpec() {
|
|
1555
|
+
if (!this.session.spec) {
|
|
1556
|
+
throw new SddError({
|
|
1557
|
+
message: "No spec to save.",
|
|
1558
|
+
code: ERROR_CODES.SDD_NOT_READY,
|
|
1559
|
+
context: { sessionId: this.session.id }
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
await this.store.save(this.session.spec);
|
|
1563
|
+
return this.session.spec;
|
|
1564
|
+
}
|
|
1565
|
+
// ── Spec Generation Helpers ───────────────────────────────────────────────
|
|
1566
|
+
/**
|
|
1567
|
+
* Parse a spec from a JSON string (from AI output).
|
|
1568
|
+
* Validates and normalizes the structure.
|
|
1569
|
+
*/
|
|
1570
|
+
parseSpecFromJSON(jsonStr) {
|
|
1571
|
+
let parsed;
|
|
1572
|
+
try {
|
|
1573
|
+
parsed = JSON.parse(jsonStr);
|
|
1574
|
+
} catch (e) {
|
|
1575
|
+
throw new SddError({
|
|
1576
|
+
message: "Invalid JSON for spec",
|
|
1577
|
+
code: ERROR_CODES.SDD_PARSE_FAILED,
|
|
1578
|
+
cause: e,
|
|
1579
|
+
context: { detail: e instanceof Error ? e.message : "parse error" }
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1583
|
+
throw new SddError({
|
|
1584
|
+
message: "Spec JSON must be an object",
|
|
1585
|
+
code: ERROR_CODES.SDD_VALIDATION_FAILED,
|
|
1586
|
+
context: { actualType: typeof parsed }
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
const raw = parsed;
|
|
1590
|
+
const now = Date.now();
|
|
1591
|
+
const title = String(raw.title ?? this.session.title ?? "Untitled");
|
|
1592
|
+
const overview = String(raw.overview ?? "");
|
|
1593
|
+
if (!overview || overview === "undefined") {
|
|
1594
|
+
throw new SddError({
|
|
1595
|
+
message: "Spec must have an overview",
|
|
1596
|
+
code: ERROR_CODES.SDD_VALIDATION_FAILED,
|
|
1597
|
+
context: { field: "overview", title }
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
const rawSections = Array.isArray(raw.sections) ? raw.sections : [];
|
|
1601
|
+
const sections = rawSections.filter((s) => s && typeof s === "object").map((s) => ({
|
|
1602
|
+
type: ["overview", "requirements", "architecture", "api", "data", "security", "acceptance"].includes(String(s.type)) ? String(s.type) : "overview",
|
|
1603
|
+
title: String(s.title ?? ""),
|
|
1604
|
+
content: String(s.content ?? ""),
|
|
1605
|
+
level: Number(s.level) || 1
|
|
1606
|
+
}));
|
|
1607
|
+
const rawReqs = Array.isArray(raw.requirements) ? raw.requirements : [];
|
|
1608
|
+
const requirements = rawReqs.filter((r) => r && typeof r === "object").map((r, i) => ({
|
|
1609
|
+
id: String(r.id ?? `REQ-${i + 1}`),
|
|
1610
|
+
type: ["functional", "non-functional", "security", "performance", "ux"].includes(String(r.type)) ? String(r.type) : "functional",
|
|
1611
|
+
priority: ["critical", "high", "medium", "low"].includes(String(r.priority)) ? String(r.priority) : "medium",
|
|
1612
|
+
description: String(r.description ?? ""),
|
|
1613
|
+
acceptanceCriteria: Array.isArray(r.acceptanceCriteria) ? r.acceptanceCriteria.map(String) : []
|
|
1614
|
+
}));
|
|
1615
|
+
const spec = {
|
|
1616
|
+
id: crypto.randomUUID(),
|
|
1617
|
+
title,
|
|
1618
|
+
version: "0.1.0",
|
|
1619
|
+
status: "draft",
|
|
1620
|
+
overview,
|
|
1621
|
+
sections,
|
|
1622
|
+
requirements,
|
|
1623
|
+
createdAt: now,
|
|
1624
|
+
updatedAt: now,
|
|
1625
|
+
metadata: {
|
|
1626
|
+
generatedBy: "AISpecBuilder",
|
|
1627
|
+
sessionId: this.session.id
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
return spec;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Extract JSON from AI output (handles ```json blocks and raw JSON).
|
|
1634
|
+
*/
|
|
1635
|
+
extractJSON(text) {
|
|
1636
|
+
const codeBlockMatch = text.match(/```json\s*([\s\S]*?)```/);
|
|
1637
|
+
if (codeBlockMatch?.[1]) {
|
|
1638
|
+
return codeBlockMatch[1].trim();
|
|
1639
|
+
}
|
|
1640
|
+
const genericBlockMatch = text.match(/```\s*([\s\S]*?)```/);
|
|
1641
|
+
if (genericBlockMatch?.[1]) {
|
|
1642
|
+
const trimmed = genericBlockMatch[1].trim();
|
|
1643
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1644
|
+
return trimmed;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const jsonMatch = text.match(/(\{[\s\S]*\})/);
|
|
1648
|
+
if (jsonMatch?.[1]) {
|
|
1649
|
+
try {
|
|
1650
|
+
JSON.parse(jsonMatch[1]);
|
|
1651
|
+
return jsonMatch[1];
|
|
1652
|
+
} catch {
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Detect if AI output contains a spec (JSON block).
|
|
1659
|
+
*/
|
|
1660
|
+
hasSpecInOutput(text) {
|
|
1661
|
+
return this.extractJSON(text) !== null;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Try to parse a spec from AI output text.
|
|
1665
|
+
* Returns null if no valid spec found.
|
|
1666
|
+
*/
|
|
1667
|
+
tryParseSpecFromOutput(text) {
|
|
1668
|
+
const json = this.extractJSON(text);
|
|
1669
|
+
if (!json) return null;
|
|
1670
|
+
try {
|
|
1671
|
+
return this.parseSpecFromJSON(json);
|
|
1672
|
+
} catch {
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
// ── JSON Array Extraction (for tasks) ─────────────────────────────────────
|
|
1677
|
+
/**
|
|
1678
|
+
* Extract a JSON array from AI output (for task lists).
|
|
1679
|
+
*/
|
|
1680
|
+
extractJSONArray(text) {
|
|
1681
|
+
const codeBlockMatch = text.match(/```json\s*([\s\S]*?)```/);
|
|
1682
|
+
if (codeBlockMatch?.[1]) {
|
|
1683
|
+
const trimmed = codeBlockMatch[1].trim();
|
|
1684
|
+
if (trimmed.startsWith("[")) return trimmed;
|
|
1685
|
+
}
|
|
1686
|
+
const arrayMatch = text.match(/(\[[\s\S]*\])/);
|
|
1687
|
+
if (arrayMatch?.[1]) {
|
|
1688
|
+
try {
|
|
1689
|
+
const parsed = JSON.parse(arrayMatch[1]);
|
|
1690
|
+
if (Array.isArray(parsed)) return arrayMatch[1];
|
|
1691
|
+
} catch {
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
var SddInterviewDriver = class {
|
|
1698
|
+
builder;
|
|
1699
|
+
o;
|
|
1700
|
+
minQuestions;
|
|
1701
|
+
maxQuestions;
|
|
1702
|
+
tracker = null;
|
|
1703
|
+
graph = null;
|
|
1704
|
+
constructor(opts) {
|
|
1705
|
+
this.o = opts;
|
|
1706
|
+
this.minQuestions = opts.minQuestions ?? 2;
|
|
1707
|
+
this.maxQuestions = opts.maxQuestions ?? 10;
|
|
1708
|
+
this.builder = new AISpecBuilder({
|
|
1709
|
+
store: opts.specStore,
|
|
1710
|
+
sessionPath: opts.sessionPath,
|
|
1711
|
+
projectContext: opts.projectContext,
|
|
1712
|
+
minQuestions: this.minQuestions,
|
|
1713
|
+
maxQuestions: this.maxQuestions
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
/** Begin a fresh interview. Returns the first AI prompt (a question kickoff). */
|
|
1717
|
+
start(title, intent) {
|
|
1718
|
+
this.builder.startSession(title, intent);
|
|
1719
|
+
this.tracker = null;
|
|
1720
|
+
this.graph = null;
|
|
1721
|
+
return this.builder.getAIPrompt();
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Resume a previously-persisted interview from disk. Re-hydrates the task
|
|
1725
|
+
* graph too when one was already produced. Returns true if a session loaded.
|
|
1726
|
+
*/
|
|
1727
|
+
async loadExisting() {
|
|
1728
|
+
const loaded = await this.builder.loadSession();
|
|
1729
|
+
if (!loaded) return false;
|
|
1730
|
+
const graphId = this.builder.getTaskGraphId();
|
|
1731
|
+
if (graphId) {
|
|
1732
|
+
const graph = await this.o.graphStore.load(graphId);
|
|
1733
|
+
if (graph) {
|
|
1734
|
+
this.graph = graph;
|
|
1735
|
+
const tracker = new TaskTracker({ store: new DefaultTaskStore() });
|
|
1736
|
+
tracker.setGraph(graph);
|
|
1737
|
+
this.tracker = tracker;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return true;
|
|
1741
|
+
}
|
|
1742
|
+
phase() {
|
|
1743
|
+
return this.builder.getPhase();
|
|
1744
|
+
}
|
|
1745
|
+
currentPrompt() {
|
|
1746
|
+
return this.builder.getAIPrompt();
|
|
1747
|
+
}
|
|
1748
|
+
getTracker() {
|
|
1749
|
+
return this.tracker;
|
|
1750
|
+
}
|
|
1751
|
+
getGraph() {
|
|
1752
|
+
return this.graph;
|
|
1753
|
+
}
|
|
1754
|
+
/** Record a Q/A pair (the agent asked `question`, the user replied `answer`). */
|
|
1755
|
+
submitAnswer(question, answer) {
|
|
1756
|
+
this.builder.addAnswer(question, answer);
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Feed the agent's text output back into the interview. Detects, in order:
|
|
1760
|
+
* 1. a Specification JSON → setSpec (phase → spec_review) + persist to SpecStore
|
|
1761
|
+
* 2. an implementation plan (implementation phase) → setImplementation
|
|
1762
|
+
* 3. a task JSON array → build + persist a TaskGraph
|
|
1763
|
+
* Each step is independent and best-effort; a malformed payload is ignored
|
|
1764
|
+
* rather than thrown, so a chatty agent turn never breaks the interview.
|
|
1765
|
+
*/
|
|
1766
|
+
async ingestAgentOutput(text) {
|
|
1767
|
+
const result = {
|
|
1768
|
+
specDetected: false,
|
|
1769
|
+
implementationDetected: false,
|
|
1770
|
+
tasksDetected: false
|
|
1771
|
+
};
|
|
1772
|
+
if (!this.builder.getSession().spec) {
|
|
1773
|
+
const spec = this.builder.tryParseSpecFromOutput(text);
|
|
1774
|
+
if (spec) {
|
|
1775
|
+
this.builder.setSpec(spec);
|
|
1776
|
+
await this.persistSpec(spec);
|
|
1777
|
+
result.specDetected = true;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (this.builder.getPhase() === "implementation") {
|
|
1781
|
+
if (this.trySaveImplementationPlan(text)) result.implementationDetected = true;
|
|
1782
|
+
}
|
|
1783
|
+
const session = this.builder.getSession();
|
|
1784
|
+
if (session.spec) {
|
|
1785
|
+
const built = await this.tryBuildTasksFromOutput(text);
|
|
1786
|
+
if (built) {
|
|
1787
|
+
result.tasksDetected = true;
|
|
1788
|
+
result.graphId = built;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return result;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Advance to the next phase (mirrors `/sdd approve`). When moving into the
|
|
1795
|
+
* executing phase, guarantees a task graph exists — deterministically
|
|
1796
|
+
* generating one from the approved spec if the agent never emitted a valid
|
|
1797
|
+
* task array. Returns the new phase and its AI prompt.
|
|
1798
|
+
*/
|
|
1799
|
+
async approve() {
|
|
1800
|
+
const phase = this.builder.approve();
|
|
1801
|
+
if (phase === "executing") {
|
|
1802
|
+
await this.ensureTaskGraph();
|
|
1803
|
+
}
|
|
1804
|
+
return { phase, prompt: this.builder.getAIPrompt() };
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Ensure a TaskGraph exists for the approved spec. If the agent already
|
|
1808
|
+
* produced one (via `ingestAgentOutput`), returns it; otherwise builds a
|
|
1809
|
+
* deterministic graph from the spec's requirements via TaskGenerator. This is
|
|
1810
|
+
* the robustness backstop: a run can always start, even if the model never
|
|
1811
|
+
* emitted a parseable task array.
|
|
1812
|
+
*/
|
|
1813
|
+
async ensureTaskGraph() {
|
|
1814
|
+
if (this.graph) return this.graph;
|
|
1815
|
+
const spec = this.builder.getSession().spec;
|
|
1816
|
+
if (!spec) return null;
|
|
1817
|
+
const tracker = new TaskTracker({ store: new DefaultTaskStore() });
|
|
1818
|
+
const generator = new TaskGenerator({
|
|
1819
|
+
taskTracker: tracker,
|
|
1820
|
+
verificationFromAcceptance: process.env["WRONGSTACK_SDD_VERIFY_FROM_ACCEPTANCE"] === "1"
|
|
1821
|
+
});
|
|
1822
|
+
const graph = await generator.generateFromSpec(spec);
|
|
1823
|
+
this.tracker = tracker;
|
|
1824
|
+
this.graph = graph;
|
|
1825
|
+
await this.persistGraph(graph);
|
|
1826
|
+
this.builder.setTaskGraphId(graph.id);
|
|
1827
|
+
await this.builder.saveSession();
|
|
1828
|
+
return graph;
|
|
1829
|
+
}
|
|
1830
|
+
snapshot() {
|
|
1831
|
+
const s = this.builder.getSession();
|
|
1832
|
+
const spec = s.spec;
|
|
1833
|
+
return {
|
|
1834
|
+
sessionId: s.id,
|
|
1835
|
+
phase: s.phase,
|
|
1836
|
+
title: s.title,
|
|
1837
|
+
goal: s.userIntent || s.title,
|
|
1838
|
+
questionCount: s.questionCount,
|
|
1839
|
+
minQuestions: this.minQuestions,
|
|
1840
|
+
maxQuestions: this.maxQuestions,
|
|
1841
|
+
answers: s.answers.map((a) => ({ question: a.question, answer: a.answer })),
|
|
1842
|
+
spec: spec ? {
|
|
1843
|
+
id: spec.id,
|
|
1844
|
+
title: spec.title,
|
|
1845
|
+
overview: spec.overview,
|
|
1846
|
+
requirements: spec.requirements.map((r) => ({
|
|
1847
|
+
priority: r.priority,
|
|
1848
|
+
description: r.description
|
|
1849
|
+
}))
|
|
1850
|
+
} : void 0,
|
|
1851
|
+
graphId: s.taskGraphId,
|
|
1852
|
+
taskCount: this.graph ? this.graph.nodes.size : 0,
|
|
1853
|
+
board: this.graph ? buildBoardTasks(this.graph) : void 0,
|
|
1854
|
+
prompt: this.builder.getAIPrompt()
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
1858
|
+
async persistSpec(spec) {
|
|
1859
|
+
try {
|
|
1860
|
+
await this.o.specStore.save(spec);
|
|
1861
|
+
} catch {
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
async persistGraph(graph) {
|
|
1865
|
+
try {
|
|
1866
|
+
await this.o.graphStore.save(graph);
|
|
1867
|
+
} catch {
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Port of the CLI `trySaveImplementationPlan` operating on this driver's
|
|
1872
|
+
* builder. Captures the prose plan that precedes the task JSON block.
|
|
1873
|
+
*/
|
|
1874
|
+
trySaveImplementationPlan(text) {
|
|
1875
|
+
const current = this.builder.getSession().implementation ?? "";
|
|
1876
|
+
const jsonStart = text.match(/```json\s*\[/);
|
|
1877
|
+
if (jsonStart?.index && jsonStart.index > 0) {
|
|
1878
|
+
const plan = text.substring(0, jsonStart.index).trim();
|
|
1879
|
+
if (plan.length > 50 && plan !== current && !isExplanatoryText(plan)) {
|
|
1880
|
+
this.builder.setImplementation(plan);
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (text.length > 100 && !text.includes("```json") && text.trim() !== current && !isExplanatoryText(text)) {
|
|
1885
|
+
this.builder.setImplementation(text.trim());
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Port of the CLI `trySaveTasksFromAIOutput`: parse a task JSON array from the
|
|
1892
|
+
* agent output, build (or extend) the tracker + graph, persist to disk, and
|
|
1893
|
+
* link the graphId to the session. Returns the graphId on success.
|
|
1894
|
+
*/
|
|
1895
|
+
async tryBuildTasksFromOutput(text) {
|
|
1896
|
+
const json = this.builder.extractJSONArray(text);
|
|
1897
|
+
if (!json) return void 0;
|
|
1898
|
+
let tasks;
|
|
1899
|
+
try {
|
|
1900
|
+
tasks = JSON.parse(json);
|
|
1901
|
+
} catch {
|
|
1902
|
+
return void 0;
|
|
1903
|
+
}
|
|
1904
|
+
const valid = tasks.filter(
|
|
1905
|
+
(t) => t && typeof t === "object" && typeof t.title === "string" && t.title.length > 0
|
|
1906
|
+
);
|
|
1907
|
+
if (valid.length === 0) return void 0;
|
|
1908
|
+
const spec = this.builder.getSession().spec;
|
|
1909
|
+
if (!spec) return void 0;
|
|
1910
|
+
if (!this.tracker || !this.graph) {
|
|
1911
|
+
const tracker = new TaskTracker({ store: new DefaultTaskStore() });
|
|
1912
|
+
this.graph = await tracker.createGraph(spec.id, spec.title);
|
|
1913
|
+
this.tracker = tracker;
|
|
1914
|
+
}
|
|
1915
|
+
const refMap = /* @__PURE__ */ new Map();
|
|
1916
|
+
const created = [];
|
|
1917
|
+
valid.forEach((task, i) => {
|
|
1918
|
+
const node = addTaskToTracker(this.tracker, task);
|
|
1919
|
+
created.push({ nodeId: node.id, task });
|
|
1920
|
+
if (typeof task.id === "string" && task.id.trim()) {
|
|
1921
|
+
refMap.set(task.id.trim().toLowerCase(), node.id);
|
|
1922
|
+
}
|
|
1923
|
+
refMap.set(`t${i + 1}`, node.id);
|
|
1924
|
+
refMap.set(String(i + 1), node.id);
|
|
1925
|
+
refMap.set(normalizeTaskRef(String(task.title)), node.id);
|
|
1926
|
+
});
|
|
1927
|
+
for (const { nodeId, task } of created) {
|
|
1928
|
+
const deps = Array.isArray(task.dependsOn) ? task.dependsOn : [];
|
|
1929
|
+
for (const ref of deps) {
|
|
1930
|
+
const depId = refMap.get(normalizeTaskRef(String(ref)));
|
|
1931
|
+
if (depId && depId !== nodeId) this.tracker.addDependency(depId, nodeId);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
await this.persistGraph(this.graph);
|
|
1935
|
+
this.builder.setTaskGraphId(this.graph.id);
|
|
1936
|
+
await this.builder.saveSession();
|
|
1937
|
+
return this.graph.id;
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
var TASK_TYPES = ["feature", "bugfix", "refactor", "docs", "test", "chore"];
|
|
1941
|
+
var TASK_PRIORITIES = ["critical", "high", "medium", "low"];
|
|
1942
|
+
function normalizeTaskRef(ref) {
|
|
1943
|
+
return ref.trim().toLowerCase();
|
|
1944
|
+
}
|
|
1945
|
+
function addTaskToTracker(tracker, task) {
|
|
1946
|
+
return tracker.addNode({
|
|
1947
|
+
title: String(task.title),
|
|
1948
|
+
description: String(task.description ?? ""),
|
|
1949
|
+
type: TASK_TYPES.includes(String(task.type)) ? String(task.type) : "feature",
|
|
1950
|
+
priority: TASK_PRIORITIES.includes(String(task.priority)) ? String(task.priority) : "medium",
|
|
1951
|
+
status: "pending",
|
|
1952
|
+
estimateHours: Number(task.estimateHours) || 2,
|
|
1953
|
+
tags: Array.isArray(task.tags) ? task.tags.map(String) : []
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
function isExplanatoryText(text) {
|
|
1957
|
+
const lower = text.toLowerCase();
|
|
1958
|
+
return lower.startsWith("i'") || lower.startsWith("i will") || lower.startsWith("let me") || lower.startsWith("here's my") || lower.startsWith("here is my") || lower.startsWith("i'm going to") || lower.startsWith("first, let me") || lower.startsWith("sure") || lower.startsWith("of course") || lower.startsWith("okay") || lower.startsWith("ok,") || lower.startsWith("sounds good") || lower.startsWith("no problem") || text.split("\n").length < 3 && !text.includes(".");
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/sdd-task-decomposer.ts
|
|
1962
|
+
var SddTaskDecomposer = class {
|
|
1963
|
+
constructor(tracker, _graph, opts = {}) {
|
|
1964
|
+
this.tracker = tracker;
|
|
1965
|
+
this.slots = Math.min(16, Math.max(1, opts.parallelSlots ?? 4));
|
|
1966
|
+
}
|
|
1967
|
+
tracker;
|
|
1968
|
+
slots;
|
|
1969
|
+
wave = 0;
|
|
1970
|
+
// -------------------------------------------------------------------
|
|
1971
|
+
// Public API
|
|
1972
|
+
// -------------------------------------------------------------------
|
|
1973
|
+
/**
|
|
1974
|
+
* Return the next batch of runnable tasks.
|
|
1975
|
+
* Returns `allDone: true` when every node is completed.
|
|
1976
|
+
* Returns `deadlocked: true` when no batch can be produced because
|
|
1977
|
+
* all remaining tasks are blocked by failed nodes.
|
|
1978
|
+
*/
|
|
1979
|
+
nextBatch() {
|
|
1980
|
+
if (this.isDone()) {
|
|
1981
|
+
return { tasks: [], wave: this.wave, allDone: true, deadlocked: false };
|
|
1982
|
+
}
|
|
1983
|
+
const pending = this.pendingReadyNodes();
|
|
1984
|
+
if (pending.length === 0) {
|
|
1985
|
+
const hasBlockedTasks = this.hasAnyBlockedTasks();
|
|
1986
|
+
return { tasks: [], wave: this.wave, allDone: false, deadlocked: hasBlockedTasks };
|
|
1987
|
+
}
|
|
1988
|
+
const batch = pending.slice(0, this.slots);
|
|
1989
|
+
return { tasks: batch, wave: this.wave, allDone: false, deadlocked: false };
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Advance the wave counter after a batch completes.
|
|
1993
|
+
* Call this once per `nextBatch()` result that was fan-out.
|
|
1994
|
+
*/
|
|
1995
|
+
acknowledgeBatch(_completedTaskIds) {
|
|
1996
|
+
this.wave++;
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* True when every node in the graph is completed.
|
|
2000
|
+
* Use this to exit the fan-out loop after `isDone() || deadlocked`.
|
|
2001
|
+
*/
|
|
2002
|
+
isDone() {
|
|
2003
|
+
const progress = this.tracker.getProgress();
|
|
2004
|
+
return progress.total > 0 && progress.completed === progress.total;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Total waves produced so far.
|
|
2008
|
+
*/
|
|
2009
|
+
getWaveCount() {
|
|
2010
|
+
return this.wave;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* All ready (dependency-satisfied) pending tasks, priority-sorted — UNSLICED.
|
|
2014
|
+
* The continuous scheduler fills its own free slots from this list, so unlike
|
|
2015
|
+
* `nextBatch()` it does not cap at `slots`.
|
|
2016
|
+
*/
|
|
2017
|
+
readyNodes() {
|
|
2018
|
+
return this.pendingReadyNodes();
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* True when every node has reached a terminal state (completed or failed).
|
|
2022
|
+
* This — not `isDone()` (which requires ALL completed) — is the correct loop
|
|
2023
|
+
* exit for the continuous scheduler: a terminally-failed task must not keep
|
|
2024
|
+
* the run spinning to its backstop.
|
|
2025
|
+
*/
|
|
2026
|
+
isSettled() {
|
|
2027
|
+
const nodes = this.tracker.getAllNodes();
|
|
2028
|
+
return nodes.length > 0 && nodes.every((n) => n.status === "completed" || n.status === "failed");
|
|
2029
|
+
}
|
|
2030
|
+
// -------------------------------------------------------------------
|
|
2031
|
+
// Internal helpers
|
|
2032
|
+
// -------------------------------------------------------------------
|
|
2033
|
+
/**
|
|
2034
|
+
* Return pending nodes whose blockers are all completed.
|
|
2035
|
+
* Sorted by priority (critical first), then by creation time.
|
|
2036
|
+
*/
|
|
2037
|
+
pendingReadyNodes() {
|
|
2038
|
+
const allPending = this.tracker.getAllNodes({ status: ["pending"] });
|
|
2039
|
+
const ready = [];
|
|
2040
|
+
for (const node of allPending) {
|
|
2041
|
+
if (this.tracker.canStart(node.id)) {
|
|
2042
|
+
ready.push(node);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
const priorityRank = {
|
|
2046
|
+
critical: 0,
|
|
2047
|
+
high: 1,
|
|
2048
|
+
medium: 2,
|
|
2049
|
+
low: 3
|
|
2050
|
+
};
|
|
2051
|
+
ready.sort((a, b) => {
|
|
2052
|
+
const pr = priorityRank[a.priority] - priorityRank[b.priority];
|
|
2053
|
+
if (pr !== 0) return pr;
|
|
2054
|
+
return a.createdAt - b.createdAt;
|
|
2055
|
+
});
|
|
2056
|
+
return ready;
|
|
2057
|
+
}
|
|
2058
|
+
/** True when at least one non-completed, non-failed task is blocked. */
|
|
2059
|
+
hasAnyBlockedTasks() {
|
|
2060
|
+
const nodes = this.tracker.getAllNodes({
|
|
2061
|
+
status: ["pending", "in_progress", "blocked"]
|
|
2062
|
+
});
|
|
2063
|
+
return nodes.some((n) => n.status === "blocked");
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
// src/sdd-parallel-run.ts
|
|
2068
|
+
var SddParallelRun = class {
|
|
2069
|
+
constructor(opts) {
|
|
2070
|
+
this.opts = opts;
|
|
2071
|
+
this.slots = Math.min(16, Math.max(1, opts.parallelSlots ?? 2));
|
|
2072
|
+
this.timeoutMs = opts.taskTimeoutMs;
|
|
2073
|
+
this.idleTimeoutMs = Math.max(1, opts.taskIdleTimeoutMs ?? 6e5);
|
|
2074
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 3);
|
|
2075
|
+
this.maxSupervisorEscalations = Math.max(0, opts.maxSupervisorEscalations ?? 2);
|
|
2076
|
+
this.maxFailedSweeps = Math.max(0, opts.maxFailedRetrySweeps ?? 2);
|
|
2077
|
+
this.runId = opts.runId ?? `sdd-${randomUUID().slice(0, 8)}`;
|
|
2078
|
+
this.events = opts.events;
|
|
2079
|
+
this.sessionIdSource = opts.sessionId;
|
|
2080
|
+
this.maxTotalWaves = opts.maxTotalWaves ?? opts.graph.nodes.size * (this.maxRetries + 2) + 10;
|
|
2081
|
+
this.maxWallClockMs = opts.maxWallClockMs;
|
|
2082
|
+
this.maxRecoveryRounds = Math.max(0, opts.maxRecoveryRounds ?? 0);
|
|
2083
|
+
this.decomposer = new SddTaskDecomposer(opts.tracker, opts.graph, { parallelSlots: this.slots });
|
|
2084
|
+
}
|
|
2085
|
+
opts;
|
|
2086
|
+
slots;
|
|
2087
|
+
/** Opt-in hard wall-clock cap (undefined → no cap; idle reaper guards instead). */
|
|
2088
|
+
timeoutMs;
|
|
2089
|
+
/** Idle reaper window (ms) — resets on activity; reaps only a genuine stall. */
|
|
2090
|
+
idleTimeoutMs;
|
|
2091
|
+
maxRetries;
|
|
2092
|
+
/** Max supervisor rescues per task before it must terminal-fail (loop guard). */
|
|
2093
|
+
maxSupervisorEscalations;
|
|
2094
|
+
/** Per-task count of supervisor rescues used (resets nothing — bounds the loop). */
|
|
2095
|
+
supervisorEscalations = /* @__PURE__ */ new Map();
|
|
2096
|
+
/** Max end-of-run failed-task sweeps (see `maxFailedRetrySweeps`). */
|
|
2097
|
+
maxFailedSweeps;
|
|
2098
|
+
/** How many failed-task sweeps have run this `run()` so far. */
|
|
2099
|
+
failedSweeps = 0;
|
|
2100
|
+
/** Completed-count snapshot at the last sweep, to detect a no-progress sweep. */
|
|
2101
|
+
lastSweepCompleted = 0;
|
|
2102
|
+
decomposer;
|
|
2103
|
+
coordinator = null;
|
|
2104
|
+
stopRequested = false;
|
|
2105
|
+
retryMap = /* @__PURE__ */ new Map();
|
|
2106
|
+
runId;
|
|
2107
|
+
events;
|
|
2108
|
+
sessionIdSource;
|
|
2109
|
+
maxTotalWaves;
|
|
2110
|
+
maxWallClockMs;
|
|
2111
|
+
maxRecoveryRounds;
|
|
2112
|
+
recoveryRounds = 0;
|
|
2113
|
+
/** Per-run worker identities, so the board shows "who is on what". */
|
|
2114
|
+
usedNicknames = /* @__PURE__ */ new Set();
|
|
2115
|
+
/** Per-task git worktree cwd (Layer 2 worktree isolation; empty otherwise). */
|
|
2116
|
+
taskCwds = /* @__PURE__ */ new Map();
|
|
2117
|
+
/** Per-task git worktree branch, for board display. */
|
|
2118
|
+
taskBranches = /* @__PURE__ */ new Map();
|
|
2119
|
+
/** Live worktree handles keyed by task id (for commit/merge/release). */
|
|
2120
|
+
taskWorktrees = /* @__PURE__ */ new Map();
|
|
2121
|
+
/** Live subagent id per running task — lets cancelTask() abort exactly one. */
|
|
2122
|
+
taskSubagents = /* @__PURE__ */ new Map();
|
|
2123
|
+
/** Tasks the user cancelled mid-flight — skip retry, mark terminal-cancelled. */
|
|
2124
|
+
cancelledTasks = /* @__PURE__ */ new Set();
|
|
2125
|
+
/**
|
|
2126
|
+
* Base branch the run's squash commits land on (captured once at start when
|
|
2127
|
+
* worktrees are enabled). Anchors a later `rollback()`.
|
|
2128
|
+
*/
|
|
2129
|
+
baseBranch;
|
|
2130
|
+
/**
|
|
2131
|
+
* Squash-merge commits this run landed on the base branch, in landing order.
|
|
2132
|
+
* `rollback()` reverts these (newest → oldest). Persisted via the board
|
|
2133
|
+
* snapshot so a post-run rollback can read them off disk.
|
|
2134
|
+
*/
|
|
2135
|
+
mergedCommits = [];
|
|
2136
|
+
/** Monotonic dispatch counter (unique subagent ids) + dispatch-round counter. */
|
|
2137
|
+
dispatchSeq = 0;
|
|
2138
|
+
round = 0;
|
|
2139
|
+
/** Type-safe emit on the optional EventBus (no-op when unwired). */
|
|
2140
|
+
emit(event, payload) {
|
|
2141
|
+
const sessionId = this.currentSessionId();
|
|
2142
|
+
this.events?.emit(
|
|
2143
|
+
event,
|
|
2144
|
+
sessionId ? { ...payload, sessionId } : payload
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
currentSessionId() {
|
|
2148
|
+
const value = typeof this.sessionIdSource === "function" ? this.sessionIdSource() : this.sessionIdSource;
|
|
2149
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
2150
|
+
}
|
|
2151
|
+
// -------------------------------------------------------------------
|
|
2152
|
+
// Public API
|
|
2153
|
+
// -------------------------------------------------------------------
|
|
2154
|
+
paused = false;
|
|
2155
|
+
/** Trigger stop — causes run() to abort after the current wave. */
|
|
2156
|
+
stop() {
|
|
2157
|
+
this.stopRequested = true;
|
|
2158
|
+
this.paused = false;
|
|
2159
|
+
this.coordinator?.stopAll();
|
|
2160
|
+
}
|
|
2161
|
+
/** Pause: no new wave starts until resume() (the current wave finishes). */
|
|
2162
|
+
pause() {
|
|
2163
|
+
this.paused = true;
|
|
2164
|
+
}
|
|
2165
|
+
resume() {
|
|
2166
|
+
this.paused = false;
|
|
2167
|
+
}
|
|
2168
|
+
isPaused() {
|
|
2169
|
+
return this.paused;
|
|
2170
|
+
}
|
|
2171
|
+
isRunning() {
|
|
2172
|
+
return !this.stopRequested && !this.decomposer.isSettled();
|
|
2173
|
+
}
|
|
2174
|
+
/** Base branch the run's squash commits land on (undefined when worktrees off). */
|
|
2175
|
+
getBaseBranch() {
|
|
2176
|
+
return this.baseBranch;
|
|
2177
|
+
}
|
|
2178
|
+
/** Squash commits this run landed on the base branch, in landing order. */
|
|
2179
|
+
getMergedCommits() {
|
|
2180
|
+
return this.mergedCommits;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Remove every git worktree + branch this run (and any prior run) created.
|
|
2184
|
+
* Refuses while the run is still live — cleaning a checkout under an active
|
|
2185
|
+
* worker would corrupt it. Stop first. Returns the number of worktrees removed
|
|
2186
|
+
* (0 when worktrees are disabled). Idempotent.
|
|
2187
|
+
*/
|
|
2188
|
+
async cleanupWorktrees() {
|
|
2189
|
+
if (this.isRunning()) return 0;
|
|
2190
|
+
const wt = this.opts.worktrees;
|
|
2191
|
+
if (!wt) return 0;
|
|
2192
|
+
for (const [taskId, handle] of [...this.taskWorktrees]) {
|
|
2193
|
+
await wt.release(handle, { keep: false }).catch(() => {
|
|
2194
|
+
});
|
|
2195
|
+
this.forgetWorktree(taskId);
|
|
2196
|
+
}
|
|
2197
|
+
const { removed } = await wt.cleanupAllManaged();
|
|
2198
|
+
return removed;
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Undo the run's merged commits by reverting each on the base branch (history
|
|
2202
|
+
* preserving). Refuses while the run is still live (stop first). Returns the
|
|
2203
|
+
* revert outcome; a dirty tree or revert conflict surfaces as `ok:false`.
|
|
2204
|
+
*/
|
|
2205
|
+
async rollback() {
|
|
2206
|
+
if (this.isRunning()) return { ok: false, reverted: 0, reason: "run still active \u2014 stop it first" };
|
|
2207
|
+
const wt = this.opts.worktrees;
|
|
2208
|
+
if (!wt || !this.baseBranch) {
|
|
2209
|
+
return { ok: false, reverted: 0, reason: "no worktree run to roll back" };
|
|
2210
|
+
}
|
|
2211
|
+
return wt.revertCommits(
|
|
2212
|
+
this.baseBranch,
|
|
2213
|
+
this.mergedCommits.map((c) => c.sha)
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
/** Requeue a task to `pending` so the scheduler re-runs it (clears retries + cancel marker). */
|
|
2217
|
+
retryTask(taskId) {
|
|
2218
|
+
if (!this.opts.tracker.getNode(taskId)) return false;
|
|
2219
|
+
this.retryMap.delete(taskId);
|
|
2220
|
+
this.persistRetries(taskId, 0);
|
|
2221
|
+
this.cancelledTasks.delete(taskId);
|
|
2222
|
+
this.opts.tracker.patchMetadata(taskId, { cancelled: void 0 });
|
|
2223
|
+
this.opts.tracker.updateNodeStatus(taskId, "pending", "manual retry");
|
|
2224
|
+
return true;
|
|
2225
|
+
}
|
|
2226
|
+
/** Reassign a task to a specific agent name (reflected on the board). */
|
|
2227
|
+
reassignTask(taskId, agentName) {
|
|
2228
|
+
if (!this.opts.tracker.getNode(taskId)) return false;
|
|
2229
|
+
this.opts.tracker.updateNode(taskId, { assignee: agentName });
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Set/override a task's worker model (and optionally provider) — applied on its
|
|
2234
|
+
* NEXT dispatch (a running task must be cancelled + retried to take effect). The
|
|
2235
|
+
* assignment lives on node metadata so it survives crash → resume.
|
|
2236
|
+
*/
|
|
2237
|
+
setTaskModel(taskId, model, provider) {
|
|
2238
|
+
if (!this.opts.tracker.getNode(taskId)) return false;
|
|
2239
|
+
this.opts.tracker.patchMetadata(taskId, { model, ...provider !== void 0 ? { provider } : {} });
|
|
2240
|
+
return true;
|
|
2241
|
+
}
|
|
2242
|
+
/** Set/override a task's fallback model chain (applied on its next dispatch). */
|
|
2243
|
+
setTaskFallbacks(taskId, fallbackModels) {
|
|
2244
|
+
if (!this.opts.tracker.getNode(taskId)) return false;
|
|
2245
|
+
this.opts.tracker.patchMetadata(taskId, { fallbackModels });
|
|
2246
|
+
return true;
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Set/override a task's verification command (the completion gate runs it in
|
|
2250
|
+
* the task's cwd and only lets the task complete on exit 0). Empty/undefined
|
|
2251
|
+
* clears it. Applied on the task's next verification — i.e. its next dispatch.
|
|
2252
|
+
*/
|
|
2253
|
+
setTaskVerification(taskId, verificationCommand) {
|
|
2254
|
+
if (!this.opts.tracker.getNode(taskId)) return false;
|
|
2255
|
+
const cmd = verificationCommand?.trim();
|
|
2256
|
+
this.opts.tracker.patchMetadata(taskId, { verificationCommand: cmd ? cmd : void 0 });
|
|
2257
|
+
return true;
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Cancel a task. If it is currently running, abort its subagent and mark the
|
|
2261
|
+
* node terminally failed+cancelled (so the scheduler frees the slot and does
|
|
2262
|
+
* NOT retry it). If it has not started, it is simply marked cancelled. Use
|
|
2263
|
+
* `retryTask` to bring a cancelled task back. Returns false for an unknown task.
|
|
2264
|
+
*/
|
|
2265
|
+
async cancelTask(taskId) {
|
|
2266
|
+
const node = this.opts.tracker.getNode(taskId);
|
|
2267
|
+
if (!node) return false;
|
|
2268
|
+
this.cancelledTasks.add(taskId);
|
|
2269
|
+
this.opts.tracker.patchMetadata(taskId, { cancelled: true });
|
|
2270
|
+
this.opts.tracker.updateNodeStatus(taskId, "failed", "cancelled by user");
|
|
2271
|
+
this.emit("sdd.task.failed", { runId: this.runId, taskId, subagentId: "", error: "cancelled by user" });
|
|
2272
|
+
const subagentId = this.taskSubagents.get(taskId);
|
|
2273
|
+
if (subagentId && this.coordinator) {
|
|
2274
|
+
await this.coordinator.stop(subagentId).catch(() => {
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Delete a not-yet-started task from the graph (pending/blocked/failed only —
|
|
2281
|
+
* never a running task; cancel it first). Removes the node and every edge
|
|
2282
|
+
* touching it; dependents lose this blocker. Returns false if missing or running.
|
|
2283
|
+
*/
|
|
2284
|
+
deleteTask(taskId) {
|
|
2285
|
+
const node = this.opts.tracker.getNode(taskId);
|
|
2286
|
+
if (!node) return false;
|
|
2287
|
+
if (node.status === "in_progress" || this.taskSubagents.has(taskId)) return false;
|
|
2288
|
+
this.cancelledTasks.delete(taskId);
|
|
2289
|
+
this.retryMap.delete(taskId);
|
|
2290
|
+
return this.opts.tracker.removeNode(taskId);
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Split a task into sub-tasks and delegate them to separate workers. The new
|
|
2294
|
+
* leaves inherit the parent's blockers (so they don't start before the
|
|
2295
|
+
* parent's dependencies are met), every existing dependent is rewired to
|
|
2296
|
+
* depend on ALL leaves (so downstream work waits for the whole split), and the
|
|
2297
|
+
* parent becomes a `completed` container. Refuses a running task (cancel it
|
|
2298
|
+
* first) or empty subtask list. Returns the new leaf ids (empty on refusal).
|
|
2299
|
+
* The scheduler picks the new pending leaves up on its next dispatch pass.
|
|
2300
|
+
*/
|
|
2301
|
+
splitTask(taskId, subtasks) {
|
|
2302
|
+
const tracker = this.opts.tracker;
|
|
2303
|
+
const node = tracker.getNode(taskId);
|
|
2304
|
+
if (!node) return [];
|
|
2305
|
+
if (node.status === "in_progress" || this.taskSubagents.has(taskId)) return [];
|
|
2306
|
+
if (!subtasks.length) return [];
|
|
2307
|
+
const blockers = tracker.getBlockers(taskId);
|
|
2308
|
+
const dependents = tracker.getDependents(taskId);
|
|
2309
|
+
const leafIds = subtasks.map(
|
|
2310
|
+
(s) => tracker.addNode({
|
|
2311
|
+
title: s.title,
|
|
2312
|
+
description: s.description,
|
|
2313
|
+
type: s.type ?? node.type,
|
|
2314
|
+
priority: s.priority ?? node.priority,
|
|
2315
|
+
status: "pending",
|
|
2316
|
+
parentId: taskId
|
|
2317
|
+
}).id
|
|
2318
|
+
);
|
|
2319
|
+
for (const leaf of leafIds) {
|
|
2320
|
+
for (const b of blockers) tracker.addDependency(b, leaf);
|
|
2321
|
+
for (const dep of dependents) tracker.addDependency(leaf, dep);
|
|
2322
|
+
}
|
|
2323
|
+
this.retryMap.delete(taskId);
|
|
2324
|
+
this.persistRetries(taskId, 0);
|
|
2325
|
+
tracker.updateNodeStatus(taskId, "completed", `split into ${leafIds.length} subtasks`);
|
|
2326
|
+
this.emit("sdd.task.split", { runId: this.runId, taskId, subtaskIds: leafIds });
|
|
2327
|
+
return leafIds;
|
|
2328
|
+
}
|
|
2329
|
+
async waitWhilePaused() {
|
|
2330
|
+
while (this.paused && !this.stopRequested) {
|
|
2331
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Continuous dependency-driven execution. Unlike a wave-barrier loop (where a
|
|
2336
|
+
* whole batch must finish before the next starts), this fills free worker
|
|
2337
|
+
* slots the instant a task's dependencies are satisfied: a fast task's
|
|
2338
|
+
* dependent starts immediately rather than waiting for a slow sibling. Truly
|
|
2339
|
+
* independent tasks run in parallel; dependency chains run in order. Returns
|
|
2340
|
+
* the final summary when the graph settles, deadlocks, stops, or hits a backstop.
|
|
2341
|
+
*/
|
|
2342
|
+
async run() {
|
|
2343
|
+
this.stopRequested = false;
|
|
2344
|
+
this.restoreRetryMap();
|
|
2345
|
+
const startTime = Date.now();
|
|
2346
|
+
this.round = 0;
|
|
2347
|
+
this.dispatchSeq = 0;
|
|
2348
|
+
let totalDispatched = 0;
|
|
2349
|
+
this.buildCoordinator();
|
|
2350
|
+
if (this.opts.worktrees && !this.baseBranch) {
|
|
2351
|
+
const base = await this.opts.worktrees.currentBase().catch(() => null);
|
|
2352
|
+
if (base) this.baseBranch = base.branch;
|
|
2353
|
+
}
|
|
2354
|
+
this.emit("sdd.run.started", {
|
|
2355
|
+
runId: this.runId,
|
|
2356
|
+
graphId: this.opts.graph.id,
|
|
2357
|
+
specId: this.opts.graph.specId,
|
|
2358
|
+
total: this.opts.graph.nodes.size,
|
|
2359
|
+
baseBranch: this.baseBranch
|
|
2360
|
+
});
|
|
2361
|
+
this.recoveryRounds = 0;
|
|
2362
|
+
this.failedSweeps = 0;
|
|
2363
|
+
this.lastSweepCompleted = 0;
|
|
2364
|
+
let deadlocked = false;
|
|
2365
|
+
const running = /* @__PURE__ */ new Map();
|
|
2366
|
+
const dispatch = (task) => {
|
|
2367
|
+
totalDispatched++;
|
|
2368
|
+
const tracked = (async () => {
|
|
2369
|
+
try {
|
|
2370
|
+
return await this.executeOne(task);
|
|
2371
|
+
} catch (err) {
|
|
2372
|
+
this.opts.tracker.updateNodeStatus(task.id, "failed", `dispatch error: ${String(err)}`);
|
|
2373
|
+
this.emit("sdd.task.failed", { runId: this.runId, taskId: task.id, subagentId: "", error: String(err) });
|
|
2374
|
+
return { taskId: task.id, success: false };
|
|
2375
|
+
} finally {
|
|
2376
|
+
running.delete(task.id);
|
|
2377
|
+
}
|
|
2378
|
+
})();
|
|
2379
|
+
running.set(task.id, tracked);
|
|
2380
|
+
};
|
|
2381
|
+
while (!this.stopRequested) {
|
|
2382
|
+
if (totalDispatched >= this.maxTotalWaves) break;
|
|
2383
|
+
if (this.maxWallClockMs && Date.now() - startTime >= this.maxWallClockMs) break;
|
|
2384
|
+
await this.waitWhilePaused();
|
|
2385
|
+
if (this.stopRequested) break;
|
|
2386
|
+
let dispatchedThisRound = 0;
|
|
2387
|
+
if (running.size < this.slots) {
|
|
2388
|
+
const ready = this.decomposer.readyNodes().filter((t) => !running.has(t.id));
|
|
2389
|
+
for (const task of ready) {
|
|
2390
|
+
if (running.size >= this.slots) break;
|
|
2391
|
+
dispatch(task);
|
|
2392
|
+
dispatchedThisRound++;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (dispatchedThisRound > 0) {
|
|
2396
|
+
this.emit("sdd.wave", { runId: this.runId, wave: this.round, batchSize: dispatchedThisRound });
|
|
2397
|
+
this.round++;
|
|
2398
|
+
}
|
|
2399
|
+
if (running.size === 0) {
|
|
2400
|
+
if (this.decomposer.isSettled()) {
|
|
2401
|
+
const completed = this.opts.tracker.getProgress().completed;
|
|
2402
|
+
const madeProgress = this.failedSweeps === 0 || completed > this.lastSweepCompleted;
|
|
2403
|
+
if (this.failedSweeps < this.maxFailedSweeps && madeProgress && this.requeueFailedTasks() > 0) {
|
|
2404
|
+
this.lastSweepCompleted = completed;
|
|
2405
|
+
this.failedSweeps++;
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
break;
|
|
2409
|
+
}
|
|
2410
|
+
const chains = this.computeDeadlockChains();
|
|
2411
|
+
if (chains.length > 0) {
|
|
2412
|
+
this.emit("sdd.deadlock", { runId: this.runId, chains });
|
|
2413
|
+
if (this.recoveryRounds < this.maxRecoveryRounds && this.recoverFailedBlockers()) {
|
|
2414
|
+
this.recoveryRounds++;
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
deadlocked = true;
|
|
2418
|
+
}
|
|
2419
|
+
break;
|
|
2420
|
+
}
|
|
2421
|
+
const moreReadyNow = running.size < this.slots && this.decomposer.readyNodes().some((t) => !running.has(t.id));
|
|
2422
|
+
if (!moreReadyNow) {
|
|
2423
|
+
await Promise.race(running.values());
|
|
2424
|
+
this.opts.onProgress?.(this.buildProgress());
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
if (running.size > 0) await Promise.allSettled(running.values());
|
|
2428
|
+
if (this.stopRequested) await this.teardown();
|
|
2429
|
+
const finalProgress = this.opts.tracker.getProgress();
|
|
2430
|
+
this.emit("sdd.run.finished", {
|
|
2431
|
+
runId: this.runId,
|
|
2432
|
+
deadlocked,
|
|
2433
|
+
completed: finalProgress.completed,
|
|
2434
|
+
failed: finalProgress.failed,
|
|
2435
|
+
stopped: this.stopRequested
|
|
2436
|
+
});
|
|
2437
|
+
return {
|
|
2438
|
+
totalWaves: this.round,
|
|
2439
|
+
totalCompleted: finalProgress.completed,
|
|
2440
|
+
totalFailed: finalProgress.failed,
|
|
2441
|
+
totalDurationMs: Date.now() - startTime,
|
|
2442
|
+
deadlocked,
|
|
2443
|
+
stopRequested: this.stopRequested,
|
|
2444
|
+
finalProgress
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* Compute the blocking chains for a deadlock: every still-incomplete task and
|
|
2449
|
+
* the blockers (by node id) that are NOT completed. Failed blockers are
|
|
2450
|
+
* included since they're the usual deadlock cause once retries are exhausted.
|
|
2451
|
+
*/
|
|
2452
|
+
computeDeadlockChains() {
|
|
2453
|
+
const tracker = this.opts.tracker;
|
|
2454
|
+
const chains = [];
|
|
2455
|
+
for (const node of tracker.getAllNodes()) {
|
|
2456
|
+
if (node.status === "completed" || node.status === "failed") continue;
|
|
2457
|
+
const blockedBy = tracker.getBlockers(node.id).filter((id) => tracker.getNode(id)?.status !== "completed");
|
|
2458
|
+
if (blockedBy.length > 0) chains.push({ blocked: node.id, blockedBy });
|
|
2459
|
+
}
|
|
2460
|
+
return chains;
|
|
2461
|
+
}
|
|
2462
|
+
/** Requeue failed tasks that block an incomplete dependent. Returns true if any. */
|
|
2463
|
+
recoverFailedBlockers() {
|
|
2464
|
+
const tracker = this.opts.tracker;
|
|
2465
|
+
let recovered = false;
|
|
2466
|
+
for (const node of tracker.getAllNodes({ status: ["failed"] })) {
|
|
2467
|
+
const blocksIncomplete = tracker.getDependents(node.id).some((d) => {
|
|
2468
|
+
const s = tracker.getNode(d)?.status;
|
|
2469
|
+
return s !== "completed" && s !== "failed";
|
|
2470
|
+
});
|
|
2471
|
+
if (blocksIncomplete) {
|
|
2472
|
+
this.retryMap.delete(node.id);
|
|
2473
|
+
this.persistRetries(node.id, 0);
|
|
2474
|
+
tracker.updateNodeStatus(node.id, "pending", "deadlock recovery");
|
|
2475
|
+
recovered = true;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return recovered;
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Requeue every terminal-failed task that the user did NOT cancel, giving each
|
|
2482
|
+
* a fresh `maxRetries` budget. Shared by the automatic end-of-run sweep and
|
|
2483
|
+
* the manual "retry all failed" control. Returns the number requeued.
|
|
2484
|
+
*/
|
|
2485
|
+
requeueFailedTasks(reason = "retry failed sweep") {
|
|
2486
|
+
const tracker = this.opts.tracker;
|
|
2487
|
+
let n = 0;
|
|
2488
|
+
for (const node of tracker.getAllNodes({ status: ["failed"] })) {
|
|
2489
|
+
if (this.cancelledTasks.has(node.id) || node.metadata?.cancelled) continue;
|
|
2490
|
+
this.retryMap.delete(node.id);
|
|
2491
|
+
this.persistRetries(node.id, 0);
|
|
2492
|
+
tracker.updateNodeStatus(node.id, "pending", reason);
|
|
2493
|
+
this.emit("sdd.task.retrying", {
|
|
2494
|
+
runId: this.runId,
|
|
2495
|
+
taskId: node.id,
|
|
2496
|
+
attempt: 0,
|
|
2497
|
+
maxRetries: this.maxRetries
|
|
2498
|
+
});
|
|
2499
|
+
n++;
|
|
2500
|
+
}
|
|
2501
|
+
return n;
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Manually requeue all failed tasks to `pending` (board "Retry all failed").
|
|
2505
|
+
* Unlike the automatic sweep this also clears any `cancelled` marker, so a
|
|
2506
|
+
* user can bring cancelled tasks back in the same action — mirroring
|
|
2507
|
+
* `retryTask`. Picked up by the running scheduler on its next dispatch pass.
|
|
2508
|
+
* Returns the number of tasks requeued.
|
|
2509
|
+
*/
|
|
2510
|
+
retryAllFailed() {
|
|
2511
|
+
const failed = this.opts.tracker.getAllNodes({ status: ["failed"] });
|
|
2512
|
+
for (const node of failed) {
|
|
2513
|
+
this.cancelledTasks.delete(node.id);
|
|
2514
|
+
this.opts.tracker.patchMetadata(node.id, { cancelled: void 0 });
|
|
2515
|
+
}
|
|
2516
|
+
return this.requeueFailedTasks("manual retry all");
|
|
2517
|
+
}
|
|
2518
|
+
/** Restore per-task retry counts persisted in node metadata (resume support). */
|
|
2519
|
+
restoreRetryMap() {
|
|
2520
|
+
this.retryMap.clear();
|
|
2521
|
+
for (const node of this.opts.tracker.getAllNodes()) {
|
|
2522
|
+
const r = node.metadata?.retries;
|
|
2523
|
+
if (typeof r === "number" && r > 0) this.retryMap.set(node.id, r);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Reset orphaned `in_progress` tasks (no agent runs them after a crash) back
|
|
2528
|
+
* to `pending` so a fresh run re-executes them. Call before constructing a run
|
|
2529
|
+
* from a reloaded graph. Static so callers don't need a run instance.
|
|
2530
|
+
*/
|
|
2531
|
+
static resetOrphans(tracker) {
|
|
2532
|
+
let n = 0;
|
|
2533
|
+
for (const node of tracker.getAllNodes({ status: ["in_progress"] })) {
|
|
2534
|
+
tracker.updateNodeStatus(node.id, "pending", "resume: orphaned in_progress");
|
|
2535
|
+
n++;
|
|
2536
|
+
}
|
|
2537
|
+
return n;
|
|
2538
|
+
}
|
|
2539
|
+
/** Clean teardown after a stop: reset interrupted tasks + release worktrees. */
|
|
2540
|
+
async teardown() {
|
|
2541
|
+
for (const node of this.opts.tracker.getAllNodes({ status: ["in_progress"] })) {
|
|
2542
|
+
this.opts.tracker.updateNodeStatus(node.id, "pending", "run stopped");
|
|
2543
|
+
}
|
|
2544
|
+
const wt = this.opts.worktrees;
|
|
2545
|
+
if (wt) {
|
|
2546
|
+
for (const [taskId, handle] of [...this.taskWorktrees]) {
|
|
2547
|
+
await wt.release(handle, { keep: true }).catch(() => {
|
|
2548
|
+
});
|
|
2549
|
+
this.forgetWorktree(taskId);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
// -------------------------------------------------------------------
|
|
2554
|
+
// Internal
|
|
2555
|
+
// -------------------------------------------------------------------
|
|
2556
|
+
buildCoordinator() {
|
|
2557
|
+
const config = {
|
|
2558
|
+
coordinatorId: `sdd-parallel-${randomUUID().slice(0, 8)}`,
|
|
2559
|
+
maxConcurrent: this.slots,
|
|
2560
|
+
doneCondition: { type: "all_tasks_done" },
|
|
2561
|
+
// Default budget guard for every spawned worker: idle reaper (resets on
|
|
2562
|
+
// activity) plus the opt-in wall-clock cap when one was configured. This
|
|
2563
|
+
// ensures the reaper applies even if a per-spawn config path is bypassed.
|
|
2564
|
+
defaultBudget: {
|
|
2565
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
2566
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {}
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
this.coordinator = new DefaultMultiAgentCoordinator(config);
|
|
2570
|
+
const baseFactory = this.opts.subagentFactory ?? this.defaultFactory();
|
|
2571
|
+
const filteredFactory = withDisabledToolFiltering(baseFactory);
|
|
2572
|
+
const runner = makeAgentSubagentRunner({ factory: filteredFactory });
|
|
2573
|
+
this.coordinator.setRunner?.(runner);
|
|
2574
|
+
}
|
|
2575
|
+
defaultFactory() {
|
|
2576
|
+
return async (_config) => ({
|
|
2577
|
+
agent: this.opts.agent,
|
|
2578
|
+
events: this.opts.agent.events
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Execute a batch of tasks together. Retained as a thin wrapper over the
|
|
2583
|
+
* single-task primitive `executeOne` so the wave-oriented tests and any
|
|
2584
|
+
* batch callers keep working; the continuous scheduler in `run()` calls
|
|
2585
|
+
* `executeOne` directly. Throws if no coordinator is wired or a spawn fails
|
|
2586
|
+
* (surfaced from `executeOne`), preserving the original all-or-nothing contract.
|
|
2587
|
+
*/
|
|
2588
|
+
async executeWave(batch) {
|
|
2589
|
+
const waveStart = Date.now();
|
|
2590
|
+
const outcomes = await Promise.all(batch.tasks.map((task) => this.executeOne(task)));
|
|
2591
|
+
const results = outcomes.map((o) => o.result).filter((r) => Boolean(r));
|
|
2592
|
+
const successCount = outcomes.filter((o) => o.success).length;
|
|
2593
|
+
const failCount = outcomes.length - successCount;
|
|
2594
|
+
return {
|
|
2595
|
+
wave: batch.wave,
|
|
2596
|
+
batch,
|
|
2597
|
+
results,
|
|
2598
|
+
successCount,
|
|
2599
|
+
failCount,
|
|
2600
|
+
durationMs: Date.now() - waveStart,
|
|
2601
|
+
stopRequested: this.stopRequested
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* Execute one task end-to-end: assign a worker identity, allocate its worktree,
|
|
2606
|
+
* spawn + assign the subagent, await its result, then update tracker status
|
|
2607
|
+
* (success / retry / terminal-fail / cancelled) and resolve the worktree. This
|
|
2608
|
+
* is the unit the continuous scheduler dispatches into a free slot. Throws on a
|
|
2609
|
+
* missing coordinator or failed spawn so callers can enforce all-or-nothing.
|
|
2610
|
+
*/
|
|
2611
|
+
async executeOne(task) {
|
|
2612
|
+
const taskId = task.id;
|
|
2613
|
+
let agentName = task.assignee;
|
|
2614
|
+
if (!agentName) {
|
|
2615
|
+
const nick = assignNickname("executor", this.usedNicknames);
|
|
2616
|
+
this.usedNicknames.add(nick.key);
|
|
2617
|
+
agentName = nick.display.replace(/\s*\([^)]*\)\s*$/, "");
|
|
2618
|
+
this.opts.tracker.updateNode(taskId, { assignee: agentName });
|
|
2619
|
+
}
|
|
2620
|
+
this.opts.tracker.updateNodeStatus(taskId, "in_progress");
|
|
2621
|
+
await this.allocateWorktrees([task]);
|
|
2622
|
+
if (!this.coordinator) throw new SddError({
|
|
2623
|
+
message: "SDD parallel runner requires a coordinator",
|
|
2624
|
+
code: ERROR_CODES.SDD_INVALID_STATE
|
|
2625
|
+
});
|
|
2626
|
+
const coordinator = this.coordinator;
|
|
2627
|
+
const subagentId = `sdd-d${this.dispatchSeq++}`;
|
|
2628
|
+
const correlationId = randomUUID();
|
|
2629
|
+
const meta = task.metadata ?? {};
|
|
2630
|
+
const model = (typeof meta.model === "string" ? meta.model : void 0) ?? this.opts.defaultModel;
|
|
2631
|
+
const provider = (typeof meta.provider === "string" ? meta.provider : void 0) ?? this.opts.defaultProvider;
|
|
2632
|
+
const fallbackModels = Array.isArray(meta.fallbackModels) ? meta.fallbackModels : this.opts.fallbackModels;
|
|
2633
|
+
const spawnResult = await coordinator.spawn({
|
|
2634
|
+
id: subagentId,
|
|
2635
|
+
name: agentName ?? subagentId,
|
|
2636
|
+
role: "executor",
|
|
2637
|
+
// Idle reaper is always on; the hard wall-clock cap only when opted in.
|
|
2638
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
2639
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {},
|
|
2640
|
+
cwd: this.taskCwds.get(taskId),
|
|
2641
|
+
disabledTools: ["delegate"],
|
|
2642
|
+
...model ? { model } : {},
|
|
2643
|
+
...provider ? { provider } : {},
|
|
2644
|
+
...fallbackModels?.length ? { fallbackModels } : {}
|
|
2645
|
+
});
|
|
2646
|
+
if (!spawnResult.subagentId) {
|
|
2647
|
+
throw new SddError({
|
|
2648
|
+
message: "One or more subagent spawns failed",
|
|
2649
|
+
code: ERROR_CODES.SDD_INVALID_STATE
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
this.taskSubagents.set(taskId, subagentId);
|
|
2653
|
+
this.emit("sdd.task.started", {
|
|
2654
|
+
runId: this.runId,
|
|
2655
|
+
taskId,
|
|
2656
|
+
subagentId,
|
|
2657
|
+
agentName: agentName ?? "",
|
|
2658
|
+
worktreeBranch: this.taskBranches.get(taskId)
|
|
2659
|
+
});
|
|
2660
|
+
const directivePreamble = [
|
|
2661
|
+
"\u2550\u2550\u2550 SDD PARALLEL EXECUTION \u2550\u2550\u2550",
|
|
2662
|
+
"",
|
|
2663
|
+
`Graph: ${this.opts.graph.title}`,
|
|
2664
|
+
"",
|
|
2665
|
+
"\u2500\u2500 EXECUTION PROTOCOL \u2500\u2500",
|
|
2666
|
+
"\u2022 Execute the assigned SDD task end-to-end using multiple tool calls.",
|
|
2667
|
+
"\u2022 Mark the task [done] in the tracker when complete.",
|
|
2668
|
+
"\u2022 Do not ask before routine in-project tool use; if a permission gate appears, wait for that flow.",
|
|
2669
|
+
"\u2022 Keep output concise \u2014 summarize changes, do not transcribe files."
|
|
2670
|
+
].join("\n");
|
|
2671
|
+
await coordinator.assign({
|
|
2672
|
+
id: correlationId,
|
|
2673
|
+
description: [
|
|
2674
|
+
directivePreamble,
|
|
2675
|
+
"",
|
|
2676
|
+
`\u2500\u2500 TASK \u2500\u2500`,
|
|
2677
|
+
`[${task.priority.toUpperCase()}] ${task.title}`,
|
|
2678
|
+
"",
|
|
2679
|
+
task.description
|
|
2680
|
+
].join("\n"),
|
|
2681
|
+
subagentId,
|
|
2682
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {}
|
|
2683
|
+
});
|
|
2684
|
+
let result;
|
|
2685
|
+
try {
|
|
2686
|
+
const got = await coordinator.awaitTasks([correlationId]);
|
|
2687
|
+
result = expectDefined(got[0]);
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
result = {
|
|
2690
|
+
subagentId,
|
|
2691
|
+
taskId: correlationId,
|
|
2692
|
+
status: "failed",
|
|
2693
|
+
error: { kind: "unknown", message: String(err), retryable: false },
|
|
2694
|
+
iterations: 0,
|
|
2695
|
+
toolCalls: 0,
|
|
2696
|
+
durationMs: 0
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
this.taskSubagents.delete(taskId);
|
|
2700
|
+
if (this.cancelledTasks.has(taskId)) {
|
|
2701
|
+
await this.resolveWorktrees([task]);
|
|
2702
|
+
return { taskId, success: false, result };
|
|
2703
|
+
}
|
|
2704
|
+
let verificationFailReason;
|
|
2705
|
+
if (result.status === "success" && this.opts.verifyTask) {
|
|
2706
|
+
const cwd = this.taskCwds.get(taskId) ?? this.opts.projectRoot;
|
|
2707
|
+
try {
|
|
2708
|
+
const verdict = await this.opts.verifyTask({ task, result, cwd });
|
|
2709
|
+
if (!verdict.ok) {
|
|
2710
|
+
verificationFailReason = `verification failed: ${verdict.reason ?? "acceptance criteria not met"}`;
|
|
2711
|
+
}
|
|
2712
|
+
} catch (err) {
|
|
2713
|
+
verificationFailReason = `verification error: ${String(err)}`;
|
|
2714
|
+
}
|
|
2715
|
+
if (verificationFailReason) {
|
|
2716
|
+
this.emit("sdd.task.verification_failed", {
|
|
2717
|
+
runId: this.runId,
|
|
2718
|
+
taskId,
|
|
2719
|
+
reason: verificationFailReason
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
let success = false;
|
|
2724
|
+
if (result.status === "success" && !verificationFailReason) {
|
|
2725
|
+
const merged = await this.integrateWorktree(task, result);
|
|
2726
|
+
if (merged.ok) {
|
|
2727
|
+
success = true;
|
|
2728
|
+
this.opts.tracker.updateNodeStatus(taskId, "completed");
|
|
2729
|
+
this.retryMap.delete(taskId);
|
|
2730
|
+
this.persistRetries(taskId, 0);
|
|
2731
|
+
this.emit("sdd.task.completed", {
|
|
2732
|
+
runId: this.runId,
|
|
2733
|
+
taskId,
|
|
2734
|
+
subagentId,
|
|
2735
|
+
durationMs: result.durationMs
|
|
2736
|
+
});
|
|
2737
|
+
} else if (merged.reason) {
|
|
2738
|
+
this.emit("sdd.task.verification_failed", {
|
|
2739
|
+
runId: this.runId,
|
|
2740
|
+
taskId,
|
|
2741
|
+
reason: merged.reason
|
|
2742
|
+
});
|
|
2743
|
+
await this.applyTaskFailure(taskId, subagentId, merged.reason);
|
|
2744
|
+
} else {
|
|
2745
|
+
this.emit("sdd.task.conflict", {
|
|
2746
|
+
runId: this.runId,
|
|
2747
|
+
taskId,
|
|
2748
|
+
conflictFiles: merged.conflictFiles ?? []
|
|
2749
|
+
});
|
|
2750
|
+
const reason = `merge conflict${merged.conflictFiles?.length ? `: ${merged.conflictFiles.join(", ")}` : ""}`;
|
|
2751
|
+
await this.applyTaskFailure(taskId, subagentId, reason);
|
|
2752
|
+
}
|
|
2753
|
+
} else {
|
|
2754
|
+
const errMsg = verificationFailReason ?? (result.error?.kind ? `${result.error.kind}: ${result.error.message}` : result.error?.message ?? "unknown error");
|
|
2755
|
+
await this.applyTaskFailure(taskId, subagentId, errMsg);
|
|
2756
|
+
await this.resolveWorktrees([task]);
|
|
2757
|
+
}
|
|
2758
|
+
return { taskId, success, result };
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* Apply a task failure: retry (→ pending, bump retry count) while attempts
|
|
2762
|
+
* remain, else consult the optional supervisor (which can rescue via
|
|
2763
|
+
* retry/reassign/split), else terminal-fail (→ failed). Shared by the
|
|
2764
|
+
* worker-failure, verification-gate, and merge-conflict paths so all three
|
|
2765
|
+
* negotiate the same retry budget and emit the same events.
|
|
2766
|
+
*/
|
|
2767
|
+
async applyTaskFailure(taskId, subagentId, errMsg) {
|
|
2768
|
+
const currentRetries = this.retryMap.get(taskId) ?? 0;
|
|
2769
|
+
if (currentRetries < this.maxRetries) {
|
|
2770
|
+
this.retryMap.set(taskId, currentRetries + 1);
|
|
2771
|
+
this.persistRetries(taskId, currentRetries + 1);
|
|
2772
|
+
this.opts.tracker.updateNodeStatus(
|
|
2773
|
+
taskId,
|
|
2774
|
+
"pending",
|
|
2775
|
+
`Retry ${currentRetries + 1}/${this.maxRetries}: ${errMsg}`
|
|
2776
|
+
);
|
|
2777
|
+
this.emit("sdd.task.retrying", {
|
|
2778
|
+
runId: this.runId,
|
|
2779
|
+
taskId,
|
|
2780
|
+
attempt: currentRetries + 1,
|
|
2781
|
+
maxRetries: this.maxRetries
|
|
2782
|
+
});
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
if (await this.trySupervisorRescue(taskId, errMsg)) return;
|
|
2786
|
+
this.opts.tracker.updateNodeStatus(taskId, "failed", errMsg);
|
|
2787
|
+
this.emit("sdd.task.failed", { runId: this.runId, taskId, subagentId, error: errMsg });
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Consult `superviseFailure` for a task that has exhausted its retries.
|
|
2791
|
+
* Applies the verdict (retry / reassign+retry / split) and returns true when
|
|
2792
|
+
* the task was rescued (caller must NOT terminal-fail it). Bounded per task by
|
|
2793
|
+
* `maxSupervisorEscalations` so an always-"retry" supervisor can't loop forever.
|
|
2794
|
+
*/
|
|
2795
|
+
async trySupervisorRescue(taskId, errMsg) {
|
|
2796
|
+
const supervise = this.opts.superviseFailure;
|
|
2797
|
+
if (!supervise) return false;
|
|
2798
|
+
const used = this.supervisorEscalations.get(taskId) ?? 0;
|
|
2799
|
+
if (used >= this.maxSupervisorEscalations) return false;
|
|
2800
|
+
const node = this.opts.tracker.getNode(taskId);
|
|
2801
|
+
if (!node) return false;
|
|
2802
|
+
let verdict;
|
|
2803
|
+
try {
|
|
2804
|
+
verdict = await supervise({ task: node, error: errMsg, attempts: used });
|
|
2805
|
+
} catch {
|
|
2806
|
+
return false;
|
|
2807
|
+
}
|
|
2808
|
+
if (!verdict || verdict.action === "fail") return false;
|
|
2809
|
+
this.supervisorEscalations.set(taskId, used + 1);
|
|
2810
|
+
const requeue = (reason) => {
|
|
2811
|
+
this.retryMap.delete(taskId);
|
|
2812
|
+
this.persistRetries(taskId, 0);
|
|
2813
|
+
this.opts.tracker.updateNodeStatus(taskId, "pending", reason);
|
|
2814
|
+
};
|
|
2815
|
+
if (verdict.action === "reassign") {
|
|
2816
|
+
this.setTaskModel(taskId, verdict.model, verdict.provider);
|
|
2817
|
+
requeue(`supervisor reassign: ${verdict.model ?? "default"}`);
|
|
2818
|
+
this.emit("sdd.supervisor.decision", { runId: this.runId, taskId, action: "reassign" });
|
|
2819
|
+
return true;
|
|
2820
|
+
}
|
|
2821
|
+
if (verdict.action === "split") {
|
|
2822
|
+
const ids = this.splitTask(taskId, verdict.subtasks);
|
|
2823
|
+
if (ids.length === 0) return false;
|
|
2824
|
+
this.emit("sdd.supervisor.decision", { runId: this.runId, taskId, action: "split" });
|
|
2825
|
+
return true;
|
|
2826
|
+
}
|
|
2827
|
+
requeue("supervisor retry");
|
|
2828
|
+
this.emit("sdd.supervisor.decision", { runId: this.runId, taskId, action: "retry" });
|
|
2829
|
+
return true;
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Integrate a verified-successful task's worktree into the base branch.
|
|
2833
|
+
* Commits, squash-merges (optionally running `conflictResolver` first), and on
|
|
2834
|
+
* success releases the worktree. On an UNRESOLVED conflict it returns
|
|
2835
|
+
* `{ok:false}` with the conflicting files so the caller routes the task into
|
|
2836
|
+
* the failure path (a retry forks a fresh worktree off the now-advanced base,
|
|
2837
|
+
* which usually clears the conflict). No-op `{ok:true}` when worktrees are
|
|
2838
|
+
* disabled or none was allocated for this task. Never throws — a merge hiccup
|
|
2839
|
+
* degrades to a (retryable) failure rather than wedging the run.
|
|
2840
|
+
*/
|
|
2841
|
+
async integrateWorktree(task, result) {
|
|
2842
|
+
const wt = this.opts.worktrees;
|
|
2843
|
+
if (!wt) return { ok: true };
|
|
2844
|
+
const handle = this.taskWorktrees.get(task.id);
|
|
2845
|
+
if (!handle) return { ok: true };
|
|
2846
|
+
try {
|
|
2847
|
+
await wt.commitAll(handle, `sdd(${task.title}): ${task.id}`);
|
|
2848
|
+
const baseShaBefore = await wt.baseHead(handle);
|
|
2849
|
+
const baseSha = this.opts.conflictResolver ? baseShaBefore : null;
|
|
2850
|
+
const res = await wt.merge(handle, {
|
|
2851
|
+
squash: true,
|
|
2852
|
+
...this.opts.conflictResolver ? {
|
|
2853
|
+
resolve: (info) => this.opts.conflictResolver({ task, conflictFiles: info.conflictFiles, cwd: info.cwd })
|
|
2854
|
+
} : {}
|
|
2855
|
+
});
|
|
2856
|
+
if (res.ok) {
|
|
2857
|
+
if (res.resolved && this.opts.verifyTask && baseSha) {
|
|
2858
|
+
let regressed;
|
|
2859
|
+
try {
|
|
2860
|
+
const verdict = await this.opts.verifyTask({
|
|
2861
|
+
task,
|
|
2862
|
+
result: result ?? {},
|
|
2863
|
+
cwd: this.opts.projectRoot
|
|
2864
|
+
});
|
|
2865
|
+
if (!verdict.ok) regressed = verdict.reason ?? "verification failed after conflict resolution";
|
|
2866
|
+
} catch (err) {
|
|
2867
|
+
regressed = `verification error after conflict resolution: ${String(err)}`;
|
|
2868
|
+
}
|
|
2869
|
+
if (regressed) {
|
|
2870
|
+
await wt.revertBaseTo(handle, baseSha).catch(() => {
|
|
2871
|
+
});
|
|
2872
|
+
await wt.release(handle, { keep: false }).catch(() => {
|
|
2873
|
+
});
|
|
2874
|
+
this.forgetWorktree(task.id, { keepBranchLabel: true });
|
|
2875
|
+
return { ok: false, conflictFiles: [], reason: regressed };
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const baseShaAfter = await wt.baseHead(handle);
|
|
2879
|
+
if (baseShaAfter && baseShaAfter !== baseShaBefore) {
|
|
2880
|
+
this.mergedCommits.push({ taskId: task.id, sha: baseShaAfter, title: task.title });
|
|
2881
|
+
this.emit("sdd.task.merged", { runId: this.runId, taskId: task.id, sha: baseShaAfter });
|
|
2882
|
+
}
|
|
2883
|
+
await wt.release(handle, { keep: false });
|
|
2884
|
+
this.forgetWorktree(task.id);
|
|
2885
|
+
return { ok: true };
|
|
2886
|
+
}
|
|
2887
|
+
await wt.release(handle, { keep: false }).catch(() => {
|
|
2888
|
+
});
|
|
2889
|
+
this.forgetWorktree(task.id, { keepBranchLabel: true });
|
|
2890
|
+
return { ok: false, conflictFiles: res.conflictFiles ?? [] };
|
|
2891
|
+
} catch {
|
|
2892
|
+
this.forgetWorktree(task.id);
|
|
2893
|
+
return { ok: false, conflictFiles: [] };
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
/** Allocate a fresh git worktree per task in the batch (no-op without a manager). */
|
|
2897
|
+
async allocateWorktrees(tasks) {
|
|
2898
|
+
const wt = this.opts.worktrees;
|
|
2899
|
+
if (!wt) return;
|
|
2900
|
+
for (const task of tasks) {
|
|
2901
|
+
if (this.taskWorktrees.has(task.id)) continue;
|
|
2902
|
+
try {
|
|
2903
|
+
const handle = await wt.allocate(`sdd-${task.id}`, {
|
|
2904
|
+
slugHint: task.title,
|
|
2905
|
+
ownerLabel: task.title
|
|
2906
|
+
});
|
|
2907
|
+
if (handle.status === "active") {
|
|
2908
|
+
this.taskWorktrees.set(task.id, handle);
|
|
2909
|
+
this.taskCwds.set(task.id, handle.dir);
|
|
2910
|
+
this.taskBranches.set(task.id, handle.branch);
|
|
2911
|
+
const node = this.opts.tracker.getNode(task.id);
|
|
2912
|
+
if (node) node.metadata = { ...node.metadata, worktreeBranch: handle.branch };
|
|
2913
|
+
}
|
|
2914
|
+
} catch {
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Resolve each task's worktree after its result is known. Serialized merges
|
|
2920
|
+
* (one at a time) keep the base branch consistent; the wave structure already
|
|
2921
|
+
* guarantees dependency order (a task's blockers merged in an earlier wave).
|
|
2922
|
+
*/
|
|
2923
|
+
async resolveWorktrees(tasks) {
|
|
2924
|
+
const wt = this.opts.worktrees;
|
|
2925
|
+
if (!wt) return;
|
|
2926
|
+
for (const task of tasks) {
|
|
2927
|
+
const handle = this.taskWorktrees.get(task.id);
|
|
2928
|
+
if (!handle) continue;
|
|
2929
|
+
const node = this.opts.tracker.getNode(task.id);
|
|
2930
|
+
const status = node?.status;
|
|
2931
|
+
const cancelled = Boolean(node?.metadata?.cancelled);
|
|
2932
|
+
try {
|
|
2933
|
+
if (cancelled) {
|
|
2934
|
+
await wt.release(handle, { keep: false });
|
|
2935
|
+
this.forgetWorktree(task.id, { keepBranchLabel: false });
|
|
2936
|
+
} else if (status === "completed") {
|
|
2937
|
+
await wt.commitAll(handle, `sdd(${task.title}): ${task.id}`);
|
|
2938
|
+
await wt.merge(handle, { squash: true });
|
|
2939
|
+
await wt.release(handle, { keep: false });
|
|
2940
|
+
this.forgetWorktree(task.id);
|
|
2941
|
+
} else if (status === "failed") {
|
|
2942
|
+
await wt.release(handle, { keep: false });
|
|
2943
|
+
this.forgetWorktree(task.id, { keepBranchLabel: false });
|
|
2944
|
+
} else {
|
|
2945
|
+
await wt.release(handle, { keep: false });
|
|
2946
|
+
this.forgetWorktree(task.id, { keepBranchLabel: false });
|
|
2947
|
+
}
|
|
2948
|
+
} catch {
|
|
2949
|
+
this.forgetWorktree(task.id);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
forgetWorktree(taskId, opts = {}) {
|
|
2954
|
+
this.taskWorktrees.delete(taskId);
|
|
2955
|
+
this.taskCwds.delete(taskId);
|
|
2956
|
+
if (!opts.keepBranchLabel) this.taskBranches.delete(taskId);
|
|
2957
|
+
}
|
|
2958
|
+
/** Persist a task's retry count into node metadata (survives crash → resume). */
|
|
2959
|
+
persistRetries(taskId, retries) {
|
|
2960
|
+
const node = this.opts.tracker.getNode(taskId);
|
|
2961
|
+
if (node) node.metadata = { ...node.metadata, retries };
|
|
2962
|
+
}
|
|
2963
|
+
buildProgress() {
|
|
2964
|
+
const gp = this.opts.tracker.getProgress();
|
|
2965
|
+
const isDeadlocked = !this.decomposer.isDone() && this.decomposer.nextBatch().deadlocked;
|
|
2966
|
+
return {
|
|
2967
|
+
wave: this.decomposer.getWaveCount(),
|
|
2968
|
+
total: gp.total,
|
|
2969
|
+
completed: gp.completed,
|
|
2970
|
+
inProgress: gp.inProgress,
|
|
2971
|
+
failed: gp.failed,
|
|
2972
|
+
blocked: gp.blocked,
|
|
2973
|
+
pending: gp.pending,
|
|
2974
|
+
percent: gp.percentComplete,
|
|
2975
|
+
deadlocked: isDeadlocked
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
};
|
|
2979
|
+
|
|
2980
|
+
// src/start-sdd-run.ts
|
|
2981
|
+
function startSddRun(opts) {
|
|
2982
|
+
SddParallelRun.resetOrphans(opts.tracker);
|
|
2983
|
+
const run = new SddParallelRun({
|
|
2984
|
+
tracker: opts.tracker,
|
|
2985
|
+
graph: opts.graph,
|
|
2986
|
+
agent: opts.agent,
|
|
2987
|
+
projectRoot: opts.projectRoot,
|
|
2988
|
+
sessionId: opts.sessionId,
|
|
2989
|
+
parallelSlots: opts.parallelSlots,
|
|
2990
|
+
taskTimeoutMs: opts.taskTimeoutMs,
|
|
2991
|
+
taskIdleTimeoutMs: opts.taskIdleTimeoutMs,
|
|
2992
|
+
maxFailedRetrySweeps: opts.maxFailedRetrySweeps,
|
|
2993
|
+
verifyTask: opts.verifyTask,
|
|
2994
|
+
conflictResolver: opts.conflictResolver,
|
|
2995
|
+
superviseFailure: opts.superviseFailure,
|
|
2996
|
+
subagentFactory: opts.subagentFactory,
|
|
2997
|
+
events: opts.events,
|
|
2998
|
+
worktrees: opts.worktrees,
|
|
2999
|
+
maxRecoveryRounds: opts.maxRecoveryRounds ?? 1,
|
|
3000
|
+
onProgress: opts.onProgress,
|
|
3001
|
+
defaultModel: opts.defaultModel,
|
|
3002
|
+
defaultProvider: opts.defaultProvider,
|
|
3003
|
+
fallbackModels: opts.fallbackModels
|
|
3004
|
+
});
|
|
3005
|
+
const projector = new SddBoardProjector({
|
|
3006
|
+
runId: run.runId,
|
|
3007
|
+
graph: opts.graph,
|
|
3008
|
+
tracker: opts.tracker,
|
|
3009
|
+
events: opts.events,
|
|
3010
|
+
store: opts.boardStore,
|
|
3011
|
+
sessionId: opts.sessionId,
|
|
3012
|
+
specId: opts.graph.specId,
|
|
3013
|
+
defaultModel: opts.defaultModel,
|
|
3014
|
+
defaultProvider: opts.defaultProvider,
|
|
3015
|
+
fallbackModels: opts.fallbackModels
|
|
3016
|
+
});
|
|
3017
|
+
opts.registry?.register({
|
|
3018
|
+
runId: run.runId,
|
|
3019
|
+
specId: opts.graph.specId,
|
|
3020
|
+
pause: () => run.pause(),
|
|
3021
|
+
resume: () => run.resume(),
|
|
3022
|
+
stop: () => run.stop(),
|
|
3023
|
+
retryTask: (id) => run.retryTask(id),
|
|
3024
|
+
retryAllFailed: () => run.retryAllFailed(),
|
|
3025
|
+
reassignTask: (id, name) => run.reassignTask(id, name),
|
|
3026
|
+
setTaskModel: (id, model, provider) => run.setTaskModel(id, model, provider),
|
|
3027
|
+
setTaskFallbacks: (id, fb) => run.setTaskFallbacks(id, fb),
|
|
3028
|
+
setTaskVerification: (id, cmd) => run.setTaskVerification(id, cmd),
|
|
3029
|
+
cancelTask: (id) => run.cancelTask(id),
|
|
3030
|
+
deleteTask: (id) => run.deleteTask(id),
|
|
3031
|
+
splitTask: (id, subtasks) => run.splitTask(id, subtasks),
|
|
3032
|
+
cleanupWorktrees: () => run.cleanupWorktrees(),
|
|
3033
|
+
rollback: () => run.rollback(),
|
|
3034
|
+
getBaseBranch: () => run.getBaseBranch(),
|
|
3035
|
+
getMergedCommits: () => run.getMergedCommits(),
|
|
3036
|
+
snapshot: () => projector.snapshot(),
|
|
3037
|
+
isRunning: () => run.isRunning()
|
|
3038
|
+
});
|
|
3039
|
+
const drainMs = opts.controlDrainMs ?? 500;
|
|
3040
|
+
const controlTimer = setInterval(() => {
|
|
3041
|
+
void opts.boardStore.drainControl(run.runId).then((cmds) => {
|
|
3042
|
+
for (const c of cmds) {
|
|
3043
|
+
const p = c.payload ?? {};
|
|
3044
|
+
if (c.type === "pause") run.pause();
|
|
3045
|
+
else if (c.type === "resume") run.resume();
|
|
3046
|
+
else if (c.type === "stop") run.stop();
|
|
3047
|
+
else if (c.type === "retry" && p.taskId) run.retryTask(p.taskId);
|
|
3048
|
+
else if (c.type === "retry_all_failed") run.retryAllFailed();
|
|
3049
|
+
else if (c.type === "reassign" && p.taskId) run.reassignTask(p.taskId, p.agentName ?? "");
|
|
3050
|
+
else if (c.type === "set_task_model" && p.taskId) run.setTaskModel(p.taskId, p.model, p.provider);
|
|
3051
|
+
else if (c.type === "set_task_fallbacks" && p.taskId) run.setTaskFallbacks(p.taskId, p.fallbackModels);
|
|
3052
|
+
else if (c.type === "set_task_verification" && p.taskId)
|
|
3053
|
+
run.setTaskVerification(p.taskId, p.verificationCommand);
|
|
3054
|
+
else if (c.type === "cancel_task" && p.taskId) void run.cancelTask(p.taskId).catch(() => {
|
|
3055
|
+
});
|
|
3056
|
+
else if (c.type === "delete_task" && p.taskId) run.deleteTask(p.taskId);
|
|
3057
|
+
else if (c.type === "split_task" && p.taskId && p.subtasks?.length) run.splitTask(p.taskId, p.subtasks);
|
|
3058
|
+
else if (c.type === "cleanup_worktrees") void run.cleanupWorktrees().catch(() => {
|
|
3059
|
+
});
|
|
3060
|
+
else if (c.type === "rollback") void run.rollback().catch(() => {
|
|
3061
|
+
});
|
|
3062
|
+
}
|
|
3063
|
+
}).catch(() => {
|
|
3064
|
+
});
|
|
3065
|
+
}, drainMs);
|
|
3066
|
+
controlTimer.unref?.();
|
|
3067
|
+
const completion = (async () => {
|
|
3068
|
+
try {
|
|
3069
|
+
return await run.run();
|
|
3070
|
+
} finally {
|
|
3071
|
+
clearInterval(controlTimer);
|
|
3072
|
+
await projector.drain().catch(() => {
|
|
3073
|
+
});
|
|
3074
|
+
projector.dispose();
|
|
3075
|
+
opts.registry?.clear(run.runId);
|
|
3076
|
+
}
|
|
3077
|
+
})();
|
|
3078
|
+
return {
|
|
3079
|
+
run,
|
|
3080
|
+
runId: run.runId,
|
|
3081
|
+
projector,
|
|
3082
|
+
completion,
|
|
3083
|
+
stop: () => run.stop()
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
async function cleanupSddWorktrees(projectRoot) {
|
|
3087
|
+
const wt = new WorktreeManager({ projectRoot });
|
|
3088
|
+
return wt.cleanupAllManaged();
|
|
3089
|
+
}
|
|
3090
|
+
async function cleanupStaleWorktrees(projectRoot) {
|
|
3091
|
+
const wt = new WorktreeManager({ projectRoot });
|
|
3092
|
+
return wt.cleanupStale();
|
|
3093
|
+
}
|
|
3094
|
+
async function cleanupStaleSddWorktrees(opts) {
|
|
3095
|
+
const now = opts.now?.() ?? Date.now();
|
|
3096
|
+
try {
|
|
3097
|
+
const store = new SddBoardStore({ baseDir: opts.boardsDir });
|
|
3098
|
+
const latest = (await store.list())[0];
|
|
3099
|
+
if (latest) {
|
|
3100
|
+
const age = now - latest.updatedAt;
|
|
3101
|
+
if (latest.status === "running" && age < (opts.runningLiveMs ?? 12e4)) {
|
|
3102
|
+
return { swept: false, removed: 0, detected: 0, skippedReason: "a run appears live (running)" };
|
|
3103
|
+
}
|
|
3104
|
+
if (latest.status === "paused" && age < (opts.pausedLiveMs ?? 18e5)) {
|
|
3105
|
+
return { swept: false, removed: 0, detected: 0, skippedReason: "a run is paused" };
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
} catch {
|
|
3109
|
+
}
|
|
3110
|
+
try {
|
|
3111
|
+
const wt = new WorktreeManager({ projectRoot: opts.projectRoot });
|
|
3112
|
+
const { removed, detected } = await wt.cleanupStale();
|
|
3113
|
+
return { swept: detected > 0, removed, detected };
|
|
3114
|
+
} catch {
|
|
3115
|
+
return { swept: false, removed: 0, detected: 0 };
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
async function rollbackSddRunFromDisk(opts) {
|
|
3119
|
+
const store = new SddBoardStore({ baseDir: opts.boardsDir });
|
|
3120
|
+
const runId = opts.runId ?? (await store.list())[0]?.runId;
|
|
3121
|
+
if (!runId) return { ok: false, reverted: 0, reason: "no SDD board found to roll back" };
|
|
3122
|
+
const snap = await store.load(runId);
|
|
3123
|
+
if (!snap) return { ok: false, reverted: 0, reason: `board "${runId}" not found` };
|
|
3124
|
+
if (!snap.baseBranch) {
|
|
3125
|
+
return { ok: false, reverted: 0, reason: "this run did not record a base branch (no worktree run)" };
|
|
3126
|
+
}
|
|
3127
|
+
const shas = (snap.mergedCommits ?? []).map((c) => c.sha);
|
|
3128
|
+
if (shas.length === 0) {
|
|
3129
|
+
return { ok: false, reverted: 0, reason: "no merged commits recorded for this run" };
|
|
3130
|
+
}
|
|
3131
|
+
const wt = new WorktreeManager({ projectRoot: opts.projectRoot });
|
|
3132
|
+
return wt.revertCommits(snap.baseBranch, shas);
|
|
3133
|
+
}
|
|
3134
|
+
async function destroySddProject(opts) {
|
|
3135
|
+
let reverted = 0;
|
|
3136
|
+
let revertOk;
|
|
3137
|
+
let revertReason;
|
|
3138
|
+
if (opts.revertMerged) {
|
|
3139
|
+
const r = await rollbackSddRunFromDisk({
|
|
3140
|
+
projectRoot: opts.projectRoot,
|
|
3141
|
+
boardsDir: opts.paths.projectSddBoards,
|
|
3142
|
+
runId: opts.runId
|
|
3143
|
+
}).catch((err) => ({ ok: false, reverted: 0, reason: toReason(err) }));
|
|
3144
|
+
reverted = r.reverted;
|
|
3145
|
+
revertOk = r.ok;
|
|
3146
|
+
revertReason = r.reason;
|
|
3147
|
+
}
|
|
3148
|
+
const { removed } = await cleanupSddWorktrees(opts.projectRoot).catch(() => ({ removed: 0 }));
|
|
3149
|
+
const deleted = [];
|
|
3150
|
+
const rmDir = async (dir, label) => {
|
|
3151
|
+
try {
|
|
3152
|
+
await fsp3.rm(dir, { recursive: true, force: true });
|
|
3153
|
+
deleted.push(label);
|
|
3154
|
+
} catch {
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
const rmFile = async (file, label) => {
|
|
3158
|
+
try {
|
|
3159
|
+
await fsp3.unlink(file);
|
|
3160
|
+
deleted.push(label);
|
|
3161
|
+
} catch {
|
|
3162
|
+
}
|
|
3163
|
+
};
|
|
3164
|
+
await rmFile(opts.paths.projectSddSession, "session");
|
|
3165
|
+
await rmDir(opts.paths.projectSpecs, "specs");
|
|
3166
|
+
await rmDir(opts.paths.projectTaskGraphs, "task-graphs");
|
|
3167
|
+
await rmDir(opts.paths.projectSddBoards, "boards");
|
|
3168
|
+
return { worktreesRemoved: removed, deleted, reverted, revertOk, revertReason };
|
|
3169
|
+
}
|
|
3170
|
+
function toReason(err) {
|
|
3171
|
+
return err instanceof Error ? err.message : String(err);
|
|
3172
|
+
}
|
|
3173
|
+
async function applySddLifecycle(op, opts) {
|
|
3174
|
+
try {
|
|
3175
|
+
if (op === "cleanup_worktrees") {
|
|
3176
|
+
const { removed } = await cleanupSddWorktrees(opts.projectRoot);
|
|
3177
|
+
return { op, ok: true, removed };
|
|
3178
|
+
}
|
|
3179
|
+
if (op === "rollback") {
|
|
3180
|
+
const r2 = await rollbackSddRunFromDisk({
|
|
3181
|
+
projectRoot: opts.projectRoot,
|
|
3182
|
+
boardsDir: opts.paths.projectSddBoards,
|
|
3183
|
+
runId: opts.runId
|
|
3184
|
+
});
|
|
3185
|
+
return { op, ok: r2.ok, reverted: r2.reverted, reason: r2.reason };
|
|
3186
|
+
}
|
|
3187
|
+
const r = await destroySddProject({
|
|
3188
|
+
projectRoot: opts.projectRoot,
|
|
3189
|
+
paths: opts.paths,
|
|
3190
|
+
revertMerged: opts.revertMerged,
|
|
3191
|
+
runId: opts.runId
|
|
3192
|
+
});
|
|
3193
|
+
return {
|
|
3194
|
+
op,
|
|
3195
|
+
// The wipe itself is best-effort and always "ok"; a requested-but-refused
|
|
3196
|
+
// revert is surfaced via reason without failing the destroy.
|
|
3197
|
+
ok: true,
|
|
3198
|
+
removed: r.worktreesRemoved,
|
|
3199
|
+
reverted: r.reverted,
|
|
3200
|
+
deleted: r.deleted,
|
|
3201
|
+
reason: r.revertOk === false ? r.revertReason : void 0
|
|
3202
|
+
};
|
|
3203
|
+
} catch (err) {
|
|
3204
|
+
return { op, ok: false, reason: toReason(err) };
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// src/spec-templates.ts
|
|
3209
|
+
var SPEC_TEMPLATES = [
|
|
3210
|
+
{
|
|
3211
|
+
id: "feature",
|
|
3212
|
+
name: "New Feature",
|
|
3213
|
+
description: "Template for new feature development",
|
|
3214
|
+
sections: [
|
|
3215
|
+
{ type: "overview", title: "Overview", level: 2 },
|
|
3216
|
+
{ type: "requirements", title: "Requirements", level: 2 },
|
|
3217
|
+
{ type: "architecture", title: "Architecture", level: 2 },
|
|
3218
|
+
{ type: "api", title: "API Design", level: 2 },
|
|
3219
|
+
{ type: "data", title: "Data Model", level: 2 },
|
|
3220
|
+
{ type: "security", title: "Security", level: 2 },
|
|
3221
|
+
{ type: "acceptance", title: "Acceptance Criteria", level: 2 }
|
|
3222
|
+
],
|
|
3223
|
+
defaultRequirements: [
|
|
3224
|
+
{ type: "functional", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] },
|
|
3225
|
+
{ type: "non-functional", priority: "medium", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3226
|
+
]
|
|
3227
|
+
},
|
|
3228
|
+
{
|
|
3229
|
+
id: "bugfix",
|
|
3230
|
+
name: "Bug Fix",
|
|
3231
|
+
description: "Template for bug fix specifications",
|
|
3232
|
+
sections: [
|
|
3233
|
+
{ type: "overview", title: "Bug Description", level: 2 },
|
|
3234
|
+
{ type: "requirements", title: "Root Cause Analysis", level: 2 },
|
|
3235
|
+
{ type: "acceptance", title: "Fix Verification", level: 2 }
|
|
3236
|
+
],
|
|
3237
|
+
defaultRequirements: [
|
|
3238
|
+
{ type: "functional", priority: "critical", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3239
|
+
]
|
|
3240
|
+
},
|
|
3241
|
+
{
|
|
3242
|
+
id: "refactor",
|
|
3243
|
+
name: "Refactor",
|
|
3244
|
+
description: "Template for code refactoring",
|
|
3245
|
+
sections: [
|
|
3246
|
+
{ type: "overview", title: "Current State", level: 2 },
|
|
3247
|
+
{ type: "requirements", title: "Refactoring Goals", level: 2 },
|
|
3248
|
+
{ type: "architecture", title: "Target Architecture", level: 2 },
|
|
3249
|
+
{ type: "acceptance", title: "Verification", level: 2 }
|
|
3250
|
+
],
|
|
3251
|
+
defaultRequirements: [
|
|
3252
|
+
{ type: "non-functional", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3253
|
+
]
|
|
3254
|
+
},
|
|
3255
|
+
{
|
|
3256
|
+
id: "infra",
|
|
3257
|
+
name: "Infrastructure",
|
|
3258
|
+
description: "Template for infrastructure/tooling changes",
|
|
3259
|
+
sections: [
|
|
3260
|
+
{ type: "overview", title: "What and Why", level: 2 },
|
|
3261
|
+
{ type: "requirements", title: "Requirements", level: 2 },
|
|
3262
|
+
{ type: "architecture", title: "Design", level: 2 },
|
|
3263
|
+
{ type: "security", title: "Security Impact", level: 2 },
|
|
3264
|
+
{ type: "acceptance", title: "Rollout Plan", level: 2 }
|
|
3265
|
+
],
|
|
3266
|
+
defaultRequirements: [
|
|
3267
|
+
{ type: "functional", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] },
|
|
3268
|
+
{ type: "security", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3269
|
+
]
|
|
3270
|
+
},
|
|
3271
|
+
{
|
|
3272
|
+
id: "integration",
|
|
3273
|
+
name: "Integration",
|
|
3274
|
+
description: "Template for integrating external services or APIs",
|
|
3275
|
+
sections: [
|
|
3276
|
+
{ type: "overview", title: "Integration Overview", level: 2 },
|
|
3277
|
+
{ type: "requirements", title: "Integration Requirements", level: 2 },
|
|
3278
|
+
{ type: "api", title: "API Contract", level: 2 },
|
|
3279
|
+
{ type: "architecture", title: "Architecture", level: 2 },
|
|
3280
|
+
{ type: "security", title: "Auth & Security", level: 2 },
|
|
3281
|
+
{ type: "acceptance", title: "Testing Strategy", level: 2 }
|
|
3282
|
+
],
|
|
3283
|
+
defaultRequirements: [
|
|
3284
|
+
{ type: "functional", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] },
|
|
3285
|
+
{ type: "security", priority: "critical", acceptanceCriteria: [], blockedBy: [], implements: [] },
|
|
3286
|
+
{ type: "performance", priority: "medium", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3287
|
+
]
|
|
3288
|
+
},
|
|
3289
|
+
{
|
|
3290
|
+
id: "cli-command",
|
|
3291
|
+
name: "CLI Command",
|
|
3292
|
+
description: "Template for new CLI commands/slash commands",
|
|
3293
|
+
sections: [
|
|
3294
|
+
{ type: "overview", title: "Command Overview", level: 2 },
|
|
3295
|
+
{ type: "requirements", title: "Command Requirements", level: 2 },
|
|
3296
|
+
{ type: "api", title: "Command Interface", level: 2 },
|
|
3297
|
+
{ type: "acceptance", title: "Usage Examples", level: 2 }
|
|
3298
|
+
],
|
|
3299
|
+
defaultRequirements: [
|
|
3300
|
+
{ type: "ux", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] },
|
|
3301
|
+
{ type: "functional", priority: "high", acceptanceCriteria: [], blockedBy: [], implements: [] }
|
|
3302
|
+
]
|
|
3303
|
+
}
|
|
3304
|
+
];
|
|
3305
|
+
function getTemplate(id) {
|
|
3306
|
+
return SPEC_TEMPLATES.find((t) => t.id === id);
|
|
3307
|
+
}
|
|
3308
|
+
function listTemplates() {
|
|
3309
|
+
return SPEC_TEMPLATES.map((t) => ({ id: t.id, name: t.name, description: t.description }));
|
|
3310
|
+
}
|
|
3311
|
+
function templateToMarkdown(template, title) {
|
|
3312
|
+
const lines = [];
|
|
3313
|
+
lines.push(`# ${title ?? "Untitled Specification"}`);
|
|
3314
|
+
lines.push("Version: 0.1.0");
|
|
3315
|
+
lines.push("");
|
|
3316
|
+
for (const section of template.sections) {
|
|
3317
|
+
lines.push(`${"#".repeat(section.level + 1)} ${section.title}`);
|
|
3318
|
+
lines.push(`_<!-- ${section.type} section content -->_`);
|
|
3319
|
+
lines.push("");
|
|
3320
|
+
}
|
|
3321
|
+
return lines.join("\n");
|
|
3322
|
+
}
|
|
3323
|
+
var STATUS_ICON = {
|
|
3324
|
+
pending: "\u25CB",
|
|
3325
|
+
in_progress: "\u25D0",
|
|
3326
|
+
blocked: "\u2298",
|
|
3327
|
+
failed: "\u2717",
|
|
3328
|
+
review: "\u25D1",
|
|
3329
|
+
completed: "\u25CF"
|
|
3330
|
+
};
|
|
3331
|
+
var PRIORITY_ICON = {
|
|
3332
|
+
critical: "\u{1F534}",
|
|
3333
|
+
high: "\u{1F7E0}",
|
|
3334
|
+
medium: "\u{1F7E1}",
|
|
3335
|
+
low: "\u{1F7E2}"
|
|
3336
|
+
};
|
|
3337
|
+
var TYPE_ICON = {
|
|
3338
|
+
feature: "\u26A1",
|
|
3339
|
+
bugfix: "\u{1F41B}",
|
|
3340
|
+
refactor: "\u267B\uFE0F",
|
|
3341
|
+
docs: "\u{1F4DD}",
|
|
3342
|
+
test: "\u{1F9EA}",
|
|
3343
|
+
chore: "\u{1F527}"
|
|
3344
|
+
};
|
|
3345
|
+
function renderTaskGraph(graph, opts) {
|
|
3346
|
+
const lines = [];
|
|
3347
|
+
const compact = opts?.compact ?? false;
|
|
3348
|
+
lines.push(`\u256D\u2500 Task Graph: ${graph.title} \u2500\u256E`);
|
|
3349
|
+
lines.push(`\u2502 Spec: ${graph.specId.slice(0, 8)}... \u2502 Nodes: ${graph.nodes.size} \u2502 Edges: ${graph.edges.length} \u2502`);
|
|
3350
|
+
lines.push("\u2570" + "\u2500".repeat(Math.max(50, graph.title.length + 30)) + "\u256F");
|
|
3351
|
+
lines.push("");
|
|
3352
|
+
const progress = computeTaskProgress(graph);
|
|
3353
|
+
lines.push(renderProgress(progress));
|
|
3354
|
+
lines.push("");
|
|
3355
|
+
const childrenMap = /* @__PURE__ */ new Map();
|
|
3356
|
+
for (const edge of graph.edges) {
|
|
3357
|
+
if (edge.type === "depends_on") {
|
|
3358
|
+
const deps = childrenMap.get(edge.from) ?? [];
|
|
3359
|
+
deps.push(edge.to);
|
|
3360
|
+
childrenMap.set(edge.from, deps);
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
3364
|
+
const rootNodes = graph.rootNodes.filter((id) => graph.nodes.has(id));
|
|
3365
|
+
const startNodes = rootNodes.length > 0 ? rootNodes : Array.from(graph.nodes.keys()).filter((id) => {
|
|
3366
|
+
const deps = childrenMap.get(id);
|
|
3367
|
+
return !deps || deps.length === 0;
|
|
3368
|
+
});
|
|
3369
|
+
for (const rootId of startNodes) {
|
|
3370
|
+
renderNode(graph, rootId, lines, rendered, childrenMap, compact, "");
|
|
3371
|
+
}
|
|
3372
|
+
for (const [id] of graph.nodes) {
|
|
3373
|
+
if (!rendered.has(id)) {
|
|
3374
|
+
renderNode(graph, id, lines, rendered, childrenMap, compact, "");
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
lines.push("");
|
|
3378
|
+
lines.push("Legend: \u25CF done \u25D0 in-progress \u25CB pending \u2297 blocked \u2717 failed \u25D2 review");
|
|
3379
|
+
return lines.join("\n");
|
|
3380
|
+
}
|
|
3381
|
+
function renderNode(graph, nodeId, lines, rendered, childrenMap, compact, prefix) {
|
|
3382
|
+
if (rendered.has(nodeId)) return;
|
|
3383
|
+
rendered.add(nodeId);
|
|
3384
|
+
const node = graph.nodes.get(nodeId);
|
|
3385
|
+
if (!node) return;
|
|
3386
|
+
const icon = STATUS_ICON[node.status];
|
|
3387
|
+
const prioIcon = PRIORITY_ICON[node.priority];
|
|
3388
|
+
const typeIcon = TYPE_ICON[node.type];
|
|
3389
|
+
const title = compact ? truncate(node.title, 40) : node.title;
|
|
3390
|
+
const blockedBy = childrenMap.get(nodeId) ?? [];
|
|
3391
|
+
const depsStr = blockedBy.length > 0 ? ` \u2190 [${blockedBy.map((d) => graph.nodes.get(d)?.title?.slice(0, 12) ?? "?").join(", ")}]` : "";
|
|
3392
|
+
lines.push(`${prefix}${icon} ${typeIcon} ${prioIcon} ${title}${depsStr}`);
|
|
3393
|
+
if (!compact && node.description) {
|
|
3394
|
+
const descLines = node.description.split("\n").slice(0, 3);
|
|
3395
|
+
for (const dl of descLines) {
|
|
3396
|
+
lines.push(`${prefix} \u2514 ${truncate(dl, 60)}`);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
const dependents = graph.edges.filter((e) => e.type === "depends_on" && e.to === nodeId).map((e) => e.from).filter((id) => graph.nodes.has(id));
|
|
3400
|
+
for (const depId of dependents) {
|
|
3401
|
+
renderNode(graph, depId, lines, rendered, childrenMap, compact, prefix + " ");
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
function renderProgress(progress) {
|
|
3405
|
+
const barWidth = 30;
|
|
3406
|
+
const filled = Math.round(progress.percentComplete / 100 * barWidth);
|
|
3407
|
+
const empty = barWidth - filled;
|
|
3408
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
3409
|
+
return [
|
|
3410
|
+
`Progress: [${bar}] ${progress.percentComplete}%`,
|
|
3411
|
+
` ${progress.completed} done \u2502 ${progress.inProgress} active \u2502 ${progress.pending} pending \u2502 ${progress.blocked} blocked \u2502 ${progress.failed} failed`
|
|
3412
|
+
].join("\n");
|
|
3413
|
+
}
|
|
3414
|
+
function renderTaskList(graph) {
|
|
3415
|
+
const lines = [];
|
|
3416
|
+
const nodes = Array.from(graph.nodes.values());
|
|
3417
|
+
const groups = {
|
|
3418
|
+
in_progress: [],
|
|
3419
|
+
pending: [],
|
|
3420
|
+
blocked: [],
|
|
3421
|
+
review: [],
|
|
3422
|
+
failed: [],
|
|
3423
|
+
completed: []
|
|
3424
|
+
};
|
|
3425
|
+
for (const node of nodes) {
|
|
3426
|
+
groups[node.status]?.push(node);
|
|
3427
|
+
}
|
|
3428
|
+
for (const [status, group] of Object.entries(groups)) {
|
|
3429
|
+
if (group.length === 0) continue;
|
|
3430
|
+
const icon = STATUS_ICON[status];
|
|
3431
|
+
lines.push(`${icon} ${status.toUpperCase()} (${group.length})`);
|
|
3432
|
+
for (const node of group) {
|
|
3433
|
+
const prio = PRIORITY_ICON[node.priority];
|
|
3434
|
+
const type = TYPE_ICON[node.type];
|
|
3435
|
+
lines.push(` ${type} ${prio} ${node.title}`);
|
|
3436
|
+
}
|
|
3437
|
+
lines.push("");
|
|
3438
|
+
}
|
|
3439
|
+
return lines.join("\n");
|
|
3440
|
+
}
|
|
3441
|
+
function renderSpecAnalysis(spec, analysis) {
|
|
3442
|
+
const lines = [];
|
|
3443
|
+
lines.push(`\u256D\u2500 Spec Analysis: ${spec.title} \u2500\u256E`);
|
|
3444
|
+
lines.push("");
|
|
3445
|
+
const barWidth = 20;
|
|
3446
|
+
const filled = Math.round(analysis.completeness / 100 * barWidth);
|
|
3447
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
|
|
3448
|
+
lines.push(`Completeness: [${bar}] ${analysis.completeness}%`);
|
|
3449
|
+
lines.push("");
|
|
3450
|
+
if (analysis.gaps.length > 0) {
|
|
3451
|
+
lines.push("\u26A0 Gaps:");
|
|
3452
|
+
for (const gap of analysis.gaps) {
|
|
3453
|
+
lines.push(` \u2022 ${gap}`);
|
|
3454
|
+
}
|
|
3455
|
+
lines.push("");
|
|
3456
|
+
}
|
|
3457
|
+
if (analysis.risks.length > 0) {
|
|
3458
|
+
lines.push("\u{1F534} Risks:");
|
|
3459
|
+
for (const risk of analysis.risks) {
|
|
3460
|
+
lines.push(` \u2022 ${risk}`);
|
|
3461
|
+
}
|
|
3462
|
+
lines.push("");
|
|
3463
|
+
}
|
|
3464
|
+
if (analysis.suggestions.length > 0) {
|
|
3465
|
+
lines.push("\u{1F4A1} Suggestions:");
|
|
3466
|
+
for (const sug of analysis.suggestions) {
|
|
3467
|
+
lines.push(` \u2022 ${sug}`);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
return lines.join("\n");
|
|
3471
|
+
}
|
|
3472
|
+
function analyzeCriticalPath(graph) {
|
|
3473
|
+
const nodes = Array.from(graph.nodes.values());
|
|
3474
|
+
const topoOrder = topologicalSort(graph);
|
|
3475
|
+
const blockedByMap = /* @__PURE__ */ new Map();
|
|
3476
|
+
const blocksMap = /* @__PURE__ */ new Map();
|
|
3477
|
+
for (const edge of graph.edges) {
|
|
3478
|
+
if (edge.type === "depends_on") {
|
|
3479
|
+
if (!blockedByMap.has(edge.from)) blockedByMap.set(edge.from, /* @__PURE__ */ new Set());
|
|
3480
|
+
blockedByMap.get(edge.from)?.add(edge.to);
|
|
3481
|
+
if (!blocksMap.has(edge.to)) blocksMap.set(edge.to, /* @__PURE__ */ new Set());
|
|
3482
|
+
blocksMap.get(edge.to)?.add(edge.from);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
const readyTasks = [];
|
|
3486
|
+
const blockedTasks = [];
|
|
3487
|
+
for (const node of nodes) {
|
|
3488
|
+
if (node.status === "completed") continue;
|
|
3489
|
+
const blockers = blockedByMap.get(node.id);
|
|
3490
|
+
if (!blockers || blockers.size === 0) {
|
|
3491
|
+
readyTasks.push(node.id);
|
|
3492
|
+
} else {
|
|
3493
|
+
const allCompleted = Array.from(blockers).every((id) => {
|
|
3494
|
+
const n = graph.nodes.get(id);
|
|
3495
|
+
return n?.status === "completed";
|
|
3496
|
+
});
|
|
3497
|
+
if (allCompleted) {
|
|
3498
|
+
readyTasks.push(node.id);
|
|
3499
|
+
} else {
|
|
3500
|
+
blockedTasks.push(node.id);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
const bottlenecks = [];
|
|
3505
|
+
for (const node of nodes) {
|
|
3506
|
+
if (node.status === "completed") continue;
|
|
3507
|
+
const downstream = getTransitiveBlocked(graph, node.id, blocksMap);
|
|
3508
|
+
if (downstream.size > 0) {
|
|
3509
|
+
const blockedHours = Array.from(downstream).reduce((sum, id) => {
|
|
3510
|
+
const n = graph.nodes.get(id);
|
|
3511
|
+
return sum + (n?.estimateHours ?? 0);
|
|
3512
|
+
}, 0);
|
|
3513
|
+
bottlenecks.push({
|
|
3514
|
+
taskId: node.id,
|
|
3515
|
+
title: node.title,
|
|
3516
|
+
blockedCount: downstream.size,
|
|
3517
|
+
blockedHours,
|
|
3518
|
+
severity: Math.min(100, Math.round(downstream.size / nodes.length * 100))
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
bottlenecks.sort((a, b) => b.severity - a.severity);
|
|
3523
|
+
const criticalPath = computeCriticalPath(graph, topoOrder, blockedByMap);
|
|
3524
|
+
const totalHours = criticalPath.reduce((sum, id) => {
|
|
3525
|
+
const n = graph.nodes.get(id);
|
|
3526
|
+
return sum + (n?.estimateHours ?? 0);
|
|
3527
|
+
}, 0);
|
|
3528
|
+
const parallelGroups = computeParallelGroups(graph, blockedByMap);
|
|
3529
|
+
const executionOrder = topoOrder.filter((id) => {
|
|
3530
|
+
const n = graph.nodes.get(id);
|
|
3531
|
+
return n && n.status !== "completed";
|
|
3532
|
+
});
|
|
3533
|
+
return {
|
|
3534
|
+
criticalPath,
|
|
3535
|
+
totalHours,
|
|
3536
|
+
bottlenecks,
|
|
3537
|
+
parallelGroups,
|
|
3538
|
+
executionOrder,
|
|
3539
|
+
readyTasks,
|
|
3540
|
+
blockedTasks
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
function getTransitiveBlocked(_graph, taskId, blocksMap) {
|
|
3544
|
+
const visited = /* @__PURE__ */ new Set();
|
|
3545
|
+
const queue = [taskId];
|
|
3546
|
+
while (queue.length > 0) {
|
|
3547
|
+
const current = expectDefined(queue.shift());
|
|
3548
|
+
const blocked = blocksMap.get(current);
|
|
3549
|
+
if (!blocked) continue;
|
|
3550
|
+
for (const id of blocked) {
|
|
3551
|
+
if (!visited.has(id) && id !== taskId) {
|
|
3552
|
+
visited.add(id);
|
|
3553
|
+
queue.push(id);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
return visited;
|
|
3558
|
+
}
|
|
3559
|
+
function computeCriticalPath(graph, _topoOrder, blockedByMap) {
|
|
3560
|
+
const allIds = Array.from(graph.nodes.keys());
|
|
3561
|
+
if (allIds.length === 0) return [];
|
|
3562
|
+
const dist = /* @__PURE__ */ new Map();
|
|
3563
|
+
const prev = /* @__PURE__ */ new Map();
|
|
3564
|
+
for (const id of allIds) {
|
|
3565
|
+
dist.set(id, graph.nodes.get(id)?.estimateHours ?? 1);
|
|
3566
|
+
prev.set(id, null);
|
|
3567
|
+
}
|
|
3568
|
+
const blocksMap = /* @__PURE__ */ new Map();
|
|
3569
|
+
for (const [taskId, blockers] of blockedByMap) {
|
|
3570
|
+
for (const blockerId of blockers) {
|
|
3571
|
+
if (!blocksMap.has(blockerId)) blocksMap.set(blockerId, /* @__PURE__ */ new Set());
|
|
3572
|
+
blocksMap.get(blockerId)?.add(taskId);
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
const n = allIds.length;
|
|
3576
|
+
for (let i = 0; i < n - 1; i++) {
|
|
3577
|
+
let changed = false;
|
|
3578
|
+
for (const id of allIds) {
|
|
3579
|
+
const blocked = blocksMap.get(id);
|
|
3580
|
+
if (!blocked) continue;
|
|
3581
|
+
for (const blockedId of blocked) {
|
|
3582
|
+
const candidateDist = (dist.get(id) ?? 0) + (graph.nodes.get(blockedId)?.estimateHours ?? 1);
|
|
3583
|
+
if (candidateDist > (dist.get(blockedId) ?? 0)) {
|
|
3584
|
+
dist.set(blockedId, candidateDist);
|
|
3585
|
+
prev.set(blockedId, id);
|
|
3586
|
+
changed = true;
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
if (!changed) break;
|
|
3591
|
+
}
|
|
3592
|
+
let maxDist = 0;
|
|
3593
|
+
let maxId = expectDefined(allIds[0]);
|
|
3594
|
+
for (const id of allIds) {
|
|
3595
|
+
const d = dist.get(id) ?? 0;
|
|
3596
|
+
if (d > maxDist) {
|
|
3597
|
+
maxDist = d;
|
|
3598
|
+
maxId = id;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
const path4 = [];
|
|
3602
|
+
let current = maxId;
|
|
3603
|
+
const visited = /* @__PURE__ */ new Set();
|
|
3604
|
+
while (current && !visited.has(current)) {
|
|
3605
|
+
visited.add(current);
|
|
3606
|
+
path4.unshift(current);
|
|
3607
|
+
current = prev.get(current) ?? null;
|
|
3608
|
+
}
|
|
3609
|
+
return path4;
|
|
3610
|
+
}
|
|
3611
|
+
function computeParallelGroups(graph, blockedByMap) {
|
|
3612
|
+
const groups = [];
|
|
3613
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
3614
|
+
const nodes = Array.from(graph.nodes.values()).filter((n) => n.status !== "completed");
|
|
3615
|
+
const remaining = new Set(nodes.map((n) => n.id));
|
|
3616
|
+
while (remaining.size > 0) {
|
|
3617
|
+
const group = [];
|
|
3618
|
+
for (const id of remaining) {
|
|
3619
|
+
const blockers = blockedByMap.get(id);
|
|
3620
|
+
if (!blockers || blockers.size === 0) {
|
|
3621
|
+
group.push(id);
|
|
3622
|
+
} else {
|
|
3623
|
+
const allAssigned = Array.from(blockers).every((b) => assigned.has(b));
|
|
3624
|
+
if (allAssigned) {
|
|
3625
|
+
group.push(id);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
if (group.length === 0) {
|
|
3630
|
+
const first = Array.from(remaining)[0];
|
|
3631
|
+
if (first) group.push(first);
|
|
3632
|
+
}
|
|
3633
|
+
for (const id of group) {
|
|
3634
|
+
assigned.add(id);
|
|
3635
|
+
remaining.delete(id);
|
|
3636
|
+
}
|
|
3637
|
+
groups.push(group);
|
|
3638
|
+
}
|
|
3639
|
+
return groups;
|
|
3640
|
+
}
|
|
3641
|
+
var SpecVersioning = class {
|
|
3642
|
+
versions = /* @__PURE__ */ new Map();
|
|
3643
|
+
/** Record a new version of a spec. */
|
|
3644
|
+
recordVersion(spec, changeDescription) {
|
|
3645
|
+
const version = {
|
|
3646
|
+
version: spec.version,
|
|
3647
|
+
spec: { ...spec },
|
|
3648
|
+
timestamp: Date.now(),
|
|
3649
|
+
changeDescription
|
|
3650
|
+
};
|
|
3651
|
+
const history = this.versions.get(spec.id) ?? [];
|
|
3652
|
+
history.push(version);
|
|
3653
|
+
this.versions.set(spec.id, history);
|
|
3654
|
+
return version;
|
|
3655
|
+
}
|
|
3656
|
+
/** Get version history for a spec. */
|
|
3657
|
+
getHistory(specId) {
|
|
3658
|
+
return this.versions.get(specId) ?? [];
|
|
3659
|
+
}
|
|
3660
|
+
/** Get a specific version of a spec. */
|
|
3661
|
+
getVersion(specId, version) {
|
|
3662
|
+
const history = this.versions.get(specId) ?? [];
|
|
3663
|
+
return history.find((v) => v.version === version);
|
|
3664
|
+
}
|
|
3665
|
+
/** Get the latest version of a spec. */
|
|
3666
|
+
getLatest(specId) {
|
|
3667
|
+
const history = this.versions.get(specId) ?? [];
|
|
3668
|
+
return history[history.length - 1];
|
|
3669
|
+
}
|
|
3670
|
+
/** Compute diff between two versions of a spec. */
|
|
3671
|
+
diff(oldSpec, newSpec) {
|
|
3672
|
+
const oldReqs = new Map(oldSpec.requirements.map((r) => [r.id, r]));
|
|
3673
|
+
const newReqs = new Map(newSpec.requirements.map((r) => [r.id, r]));
|
|
3674
|
+
const added = [];
|
|
3675
|
+
const removed = [];
|
|
3676
|
+
const modified = [];
|
|
3677
|
+
for (const [id, newReq] of newReqs) {
|
|
3678
|
+
const oldReq = oldReqs.get(id);
|
|
3679
|
+
if (!oldReq) {
|
|
3680
|
+
added.push(newReq);
|
|
3681
|
+
} else {
|
|
3682
|
+
const changes = this.compareRequirements(oldReq, newReq);
|
|
3683
|
+
if (changes.length > 0) {
|
|
3684
|
+
modified.push({
|
|
3685
|
+
requirement: newReq,
|
|
3686
|
+
previousVersion: oldReq,
|
|
3687
|
+
changes
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
for (const [id, oldReq] of oldReqs) {
|
|
3693
|
+
if (!newReqs.has(id)) {
|
|
3694
|
+
removed.push(oldReq);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
const parts = [];
|
|
3698
|
+
if (added.length > 0) parts.push(`${added.length} added`);
|
|
3699
|
+
if (removed.length > 0) parts.push(`${removed.length} removed`);
|
|
3700
|
+
if (modified.length > 0) parts.push(`${modified.length} modified`);
|
|
3701
|
+
return {
|
|
3702
|
+
added,
|
|
3703
|
+
removed,
|
|
3704
|
+
modified,
|
|
3705
|
+
summary: parts.length > 0 ? parts.join(", ") : "No changes"
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
/**
|
|
3709
|
+
* Update a task graph incrementally based on spec changes.
|
|
3710
|
+
* - Added requirements → new tasks
|
|
3711
|
+
* - Removed requirements → remove tasks
|
|
3712
|
+
* - Modified requirements → update task descriptions
|
|
3713
|
+
* Returns the updated graph and list of changes made.
|
|
3714
|
+
*/
|
|
3715
|
+
updateTaskGraph(graph, oldSpec, newSpec) {
|
|
3716
|
+
const specDiff = this.diff(oldSpec, newSpec);
|
|
3717
|
+
const changes = [];
|
|
3718
|
+
const reqToTask = /* @__PURE__ */ new Map();
|
|
3719
|
+
for (const node of graph.nodes.values()) {
|
|
3720
|
+
if (node.specRequirementId) {
|
|
3721
|
+
reqToTask.set(node.specRequirementId, node);
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
for (const req of specDiff.removed) {
|
|
3725
|
+
const task = reqToTask.get(req.id);
|
|
3726
|
+
if (task) {
|
|
3727
|
+
graph.nodes.delete(task.id);
|
|
3728
|
+
graph.edges = graph.edges.filter((e) => e.from !== task.id && e.to !== task.id);
|
|
3729
|
+
changes.push(`Removed task: ${task.title}`);
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
for (const mod of specDiff.modified) {
|
|
3733
|
+
const task = reqToTask.get(mod.requirement.id);
|
|
3734
|
+
if (task) {
|
|
3735
|
+
task.title = mod.requirement.description;
|
|
3736
|
+
task.description = this.buildTaskDescription(mod.requirement);
|
|
3737
|
+
task.priority = mod.requirement.priority;
|
|
3738
|
+
task.updatedAt = Date.now();
|
|
3739
|
+
changes.push(`Updated task: ${task.title} (${mod.changes.join(", ")})`);
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
for (const req of specDiff.added) {
|
|
3743
|
+
const now = Date.now();
|
|
3744
|
+
const newTask = {
|
|
3745
|
+
id: crypto.randomUUID(),
|
|
3746
|
+
title: req.description,
|
|
3747
|
+
description: this.buildTaskDescription(req),
|
|
3748
|
+
type: this.mapReqType(req.type),
|
|
3749
|
+
priority: req.priority,
|
|
3750
|
+
status: "pending",
|
|
3751
|
+
specRequirementId: req.id,
|
|
3752
|
+
tags: [req.type, req.priority],
|
|
3753
|
+
createdAt: now,
|
|
3754
|
+
updatedAt: now
|
|
3755
|
+
};
|
|
3756
|
+
graph.nodes.set(newTask.id, newTask);
|
|
3757
|
+
graph.rootNodes.push(newTask.id);
|
|
3758
|
+
changes.push(`Added task: ${newTask.title}`);
|
|
3759
|
+
}
|
|
3760
|
+
graph.updatedAt = Date.now();
|
|
3761
|
+
return { graph, changes };
|
|
3762
|
+
}
|
|
3763
|
+
compareRequirements(old, current) {
|
|
3764
|
+
const changes = [];
|
|
3765
|
+
if (old.description !== current.description) changes.push("description");
|
|
3766
|
+
if (old.priority !== current.priority) changes.push("priority");
|
|
3767
|
+
if (old.type !== current.type) changes.push("type");
|
|
3768
|
+
if (JSON.stringify(old.acceptanceCriteria) !== JSON.stringify(current.acceptanceCriteria)) {
|
|
3769
|
+
changes.push("acceptance criteria");
|
|
3770
|
+
}
|
|
3771
|
+
if (JSON.stringify(old.blockedBy) !== JSON.stringify(current.blockedBy)) {
|
|
3772
|
+
changes.push("dependencies");
|
|
3773
|
+
}
|
|
3774
|
+
return changes;
|
|
3775
|
+
}
|
|
3776
|
+
buildTaskDescription(req) {
|
|
3777
|
+
const lines = [req.description, "", `**Type:** ${req.type}`, `**Priority:** ${req.priority}`];
|
|
3778
|
+
if (req.acceptanceCriteria.length > 0) {
|
|
3779
|
+
lines.push("", "**Acceptance Criteria:**");
|
|
3780
|
+
for (const ac of req.acceptanceCriteria) {
|
|
3781
|
+
lines.push(`- ${ac}`);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
return lines.join("\n");
|
|
3785
|
+
}
|
|
3786
|
+
mapReqType(type) {
|
|
3787
|
+
switch (type) {
|
|
3788
|
+
case "functional":
|
|
3789
|
+
return "feature";
|
|
3790
|
+
case "non-functional":
|
|
3791
|
+
return "feature";
|
|
3792
|
+
case "security":
|
|
3793
|
+
return "feature";
|
|
3794
|
+
case "performance":
|
|
3795
|
+
return "feature";
|
|
3796
|
+
case "ux":
|
|
3797
|
+
return "feature";
|
|
3798
|
+
default:
|
|
3799
|
+
return assertNever(type);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
};
|
|
3803
|
+
|
|
3804
|
+
// src/auto-executor.ts
|
|
3805
|
+
var AutoExecutor = class {
|
|
3806
|
+
opts;
|
|
3807
|
+
stopped = false;
|
|
3808
|
+
retryMap = /* @__PURE__ */ new Map();
|
|
3809
|
+
constructor(opts) {
|
|
3810
|
+
this.opts = opts;
|
|
3811
|
+
}
|
|
3812
|
+
/**
|
|
3813
|
+
* Execute all tasks in the graph, respecting dependencies.
|
|
3814
|
+
*/
|
|
3815
|
+
async execute(graph, spec) {
|
|
3816
|
+
this.stopped = false;
|
|
3817
|
+
this.retryMap.clear();
|
|
3818
|
+
const startTime = Date.now();
|
|
3819
|
+
const critical = analyzeCriticalPath(graph);
|
|
3820
|
+
let completed = 0;
|
|
3821
|
+
let failed = 0;
|
|
3822
|
+
const skipped = 0;
|
|
3823
|
+
let retried = 0;
|
|
3824
|
+
while (!this.stopped) {
|
|
3825
|
+
const readyTasks = this.getReadyTasks(graph);
|
|
3826
|
+
if (readyTasks.length === 0) {
|
|
3827
|
+
const allDone = Array.from(graph.nodes.values()).every(
|
|
3828
|
+
(n) => n.status === "completed" || n.status === "failed"
|
|
3829
|
+
);
|
|
3830
|
+
if (allDone) break;
|
|
3831
|
+
const hasDeadlock = this.detectDeadlock(graph);
|
|
3832
|
+
if (hasDeadlock) break;
|
|
3833
|
+
break;
|
|
3834
|
+
}
|
|
3835
|
+
const batch = readyTasks.slice(0, this.opts.maxConcurrent ?? 1);
|
|
3836
|
+
const results = await Promise.allSettled(
|
|
3837
|
+
batch.map((task) => this.executeTaskWithRetry(task, graph, spec))
|
|
3838
|
+
);
|
|
3839
|
+
for (let i = 0; i < results.length; i++) {
|
|
3840
|
+
const result = results[i];
|
|
3841
|
+
const task = batch[i];
|
|
3842
|
+
if (!result || !task) continue;
|
|
3843
|
+
if (result.status === "fulfilled") {
|
|
3844
|
+
const { result: execResult, retries } = result.value;
|
|
3845
|
+
if (execResult.success) {
|
|
3846
|
+
this.opts.tracker.updateNodeStatus(task.id, "completed");
|
|
3847
|
+
completed++;
|
|
3848
|
+
if (retries > 0) retried++;
|
|
3849
|
+
this.opts.onTaskComplete?.(task, execResult);
|
|
3850
|
+
} else if (execResult.retry) {
|
|
3851
|
+
retried++;
|
|
3852
|
+
} else {
|
|
3853
|
+
this.opts.tracker.updateNodeStatus(task.id, "failed", execResult.error);
|
|
3854
|
+
failed++;
|
|
3855
|
+
}
|
|
3856
|
+
} else {
|
|
3857
|
+
this.opts.tracker.updateNodeStatus(task.id, "failed", String(result.reason));
|
|
3858
|
+
failed++;
|
|
3859
|
+
this.opts.onTaskFail?.(task, result.reason, 0);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
const duration = Date.now() - startTime;
|
|
3864
|
+
const summary = {
|
|
3865
|
+
total: graph.nodes.size,
|
|
3866
|
+
completed,
|
|
3867
|
+
failed,
|
|
3868
|
+
skipped,
|
|
3869
|
+
retried,
|
|
3870
|
+
duration,
|
|
3871
|
+
criticalPath: critical.criticalPath
|
|
3872
|
+
};
|
|
3873
|
+
this.opts.onDone?.(summary);
|
|
3874
|
+
return summary;
|
|
3875
|
+
}
|
|
3876
|
+
/** Stop execution. */
|
|
3877
|
+
stop() {
|
|
3878
|
+
this.stopped = true;
|
|
3879
|
+
}
|
|
3880
|
+
/** Get tasks that are ready to execute (all dependencies completed). */
|
|
3881
|
+
getReadyTasks(graph) {
|
|
3882
|
+
const ready = [];
|
|
3883
|
+
for (const node of graph.nodes.values()) {
|
|
3884
|
+
if (node.status !== "pending") continue;
|
|
3885
|
+
const blockers = graph.edges.filter((e) => e.type === "depends_on" && e.from === node.id).map((e) => graph.nodes.get(e.to)).filter(Boolean);
|
|
3886
|
+
const allBlockersDone = blockers.every((b) => b.status === "completed");
|
|
3887
|
+
if (allBlockersDone) {
|
|
3888
|
+
ready.push(node);
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
3892
|
+
ready.sort((a, b) => (priorityOrder[a.priority] ?? 4) - (priorityOrder[b.priority] ?? 4));
|
|
3893
|
+
return ready;
|
|
3894
|
+
}
|
|
3895
|
+
/** Execute a single task with retry logic. */
|
|
3896
|
+
async executeTaskWithRetry(task, graph, spec) {
|
|
3897
|
+
const maxRetries = this.opts.maxRetries ?? 2;
|
|
3898
|
+
let retryCount = this.retryMap.get(task.id) ?? 0;
|
|
3899
|
+
while (retryCount <= maxRetries) {
|
|
3900
|
+
this.opts.tracker.updateNodeStatus(task.id, "in_progress");
|
|
3901
|
+
this.opts.onTaskStart?.(task);
|
|
3902
|
+
const dependencies = this.getTaskDependencies(task.id, graph);
|
|
3903
|
+
const dependents = this.getTaskDependents(task.id, graph);
|
|
3904
|
+
const context = {
|
|
3905
|
+
spec,
|
|
3906
|
+
graph,
|
|
3907
|
+
task,
|
|
3908
|
+
dependencies,
|
|
3909
|
+
dependents,
|
|
3910
|
+
retryCount
|
|
3911
|
+
};
|
|
3912
|
+
try {
|
|
3913
|
+
const result = await this.opts.executeTask(task, context);
|
|
3914
|
+
if (result.success) {
|
|
3915
|
+
const retriesForTask = this.retryMap.get(task.id) ?? 0;
|
|
3916
|
+
this.retryMap.delete(task.id);
|
|
3917
|
+
return { result, retries: retriesForTask };
|
|
3918
|
+
}
|
|
3919
|
+
if (result.retry && retryCount < maxRetries) {
|
|
3920
|
+
retryCount++;
|
|
3921
|
+
this.retryMap.set(task.id, retryCount);
|
|
3922
|
+
this.opts.tracker.updateNodeStatus(task.id, "pending");
|
|
3923
|
+
continue;
|
|
3924
|
+
}
|
|
3925
|
+
return { result, retries: retryCount };
|
|
3926
|
+
} catch (error) {
|
|
3927
|
+
if (retryCount < maxRetries) {
|
|
3928
|
+
retryCount++;
|
|
3929
|
+
this.retryMap.set(task.id, retryCount);
|
|
3930
|
+
this.opts.tracker.updateNodeStatus(task.id, "pending");
|
|
3931
|
+
this.opts.onTaskFail?.(task, error, retryCount);
|
|
3932
|
+
continue;
|
|
3933
|
+
}
|
|
3934
|
+
return {
|
|
3935
|
+
result: {
|
|
3936
|
+
success: false,
|
|
3937
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3938
|
+
},
|
|
3939
|
+
retries: retryCount
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return { result: { success: false, error: "Max retries exceeded" }, retries: retryCount };
|
|
3944
|
+
}
|
|
3945
|
+
/** Get tasks that this task depends on. */
|
|
3946
|
+
getTaskDependencies(taskId, graph) {
|
|
3947
|
+
return graph.edges.filter((e) => e.type === "depends_on" && e.from === taskId).map((e) => graph.nodes.get(e.to)).filter(Boolean);
|
|
3948
|
+
}
|
|
3949
|
+
/** Get tasks that depend on this task. */
|
|
3950
|
+
getTaskDependents(taskId, graph) {
|
|
3951
|
+
return graph.edges.filter((e) => e.type === "depends_on" && e.to === taskId).map((e) => graph.nodes.get(e.from)).filter(Boolean);
|
|
3952
|
+
}
|
|
3953
|
+
/** Detect deadlock: all remaining tasks are blocked by failed tasks. */
|
|
3954
|
+
detectDeadlock(graph) {
|
|
3955
|
+
const remaining = Array.from(graph.nodes.values()).filter(
|
|
3956
|
+
(n) => n.status === "pending" || n.status === "blocked"
|
|
3957
|
+
);
|
|
3958
|
+
if (remaining.length === 0) return false;
|
|
3959
|
+
return remaining.every((node) => {
|
|
3960
|
+
const blockers = graph.edges.filter((e) => e.type === "depends_on" && e.from === node.id).map((e) => graph.nodes.get(e.to)).filter(Boolean);
|
|
3961
|
+
return blockers.some((b) => b.status === "failed");
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
};
|
|
3965
|
+
function createAutoExecutor(opts) {
|
|
3966
|
+
return new AutoExecutor({
|
|
3967
|
+
tracker: opts.tracker,
|
|
3968
|
+
events: opts.events,
|
|
3969
|
+
executeTask: opts.executeTask,
|
|
3970
|
+
maxConcurrent: opts.maxConcurrent,
|
|
3971
|
+
maxRetries: opts.maxRetries
|
|
3972
|
+
});
|
|
3973
|
+
}
|
|
3974
|
+
var SddSupervisor = class {
|
|
3975
|
+
constructor(opts) {
|
|
3976
|
+
this.opts = opts;
|
|
3977
|
+
}
|
|
3978
|
+
opts;
|
|
3979
|
+
/**
|
|
3980
|
+
* Bind this as `SddParallelRunOptions.superviseFailure`. Returns a verdict the
|
|
3981
|
+
* run applies, or `undefined`/`{action:'fail'}` to let the task terminal-fail.
|
|
3982
|
+
*/
|
|
3983
|
+
superviseFailure = async (info) => {
|
|
3984
|
+
const { task, error, attempts } = info;
|
|
3985
|
+
const canReassign = (this.opts.reassignModels?.length ?? 0) > 0;
|
|
3986
|
+
const canSplit = Boolean(this.opts.generateSubtasks);
|
|
3987
|
+
const decision = await this.opts.brain.decide({
|
|
3988
|
+
id: `sdd-supervisor-${task.id}-${attempts}`,
|
|
3989
|
+
source: "system",
|
|
3990
|
+
question: `SDD task "${task.title}" exhausted its retries. How should the run proceed?`,
|
|
3991
|
+
context: `Error: ${error}
|
|
3992
|
+
Supervisor rescues already used: ${attempts}`,
|
|
3993
|
+
options: [
|
|
3994
|
+
{ id: "retry", label: "Retry the task as-is", recommended: true },
|
|
3995
|
+
...canReassign ? [{ id: "reassign", label: "Reassign to a different model" }] : [],
|
|
3996
|
+
...canSplit ? [{ id: "split", label: "Split into smaller sub-tasks" }] : [],
|
|
3997
|
+
{ id: "fail", label: "Give up and mark the task failed" }
|
|
3998
|
+
],
|
|
3999
|
+
// Higher risk once we've already rescued it once — pushes a wired LLM/human
|
|
4000
|
+
// toward a decisive verdict instead of looping retries.
|
|
4001
|
+
risk: attempts >= 1 ? "high" : "medium",
|
|
4002
|
+
// `continue` → policy answers in place (bounded retry, LLM never runs).
|
|
4003
|
+
// `ask_human` → policy escalates so the autonomous LLM layer can actually
|
|
4004
|
+
// pick reassign/split (see requestLlmVerdict's safety contract).
|
|
4005
|
+
fallback: this.opts.requestLlmVerdict ? "ask_human" : "continue"
|
|
4006
|
+
});
|
|
4007
|
+
if (decision.type === "deny") return { action: "fail" };
|
|
4008
|
+
if (decision.type !== "answer") return { action: "retry" };
|
|
4009
|
+
const choice = decision.optionId ?? "retry";
|
|
4010
|
+
if (choice === "fail") return { action: "fail" };
|
|
4011
|
+
if (choice === "reassign" && canReassign) {
|
|
4012
|
+
const models = this.opts.reassignModels;
|
|
4013
|
+
const ref = models[attempts % models.length];
|
|
4014
|
+
const parsed = ref ? parseModelRef(ref) : void 0;
|
|
4015
|
+
return { action: "reassign", model: parsed?.model, provider: parsed?.provider };
|
|
4016
|
+
}
|
|
4017
|
+
if (choice === "split" && this.opts.generateSubtasks) {
|
|
4018
|
+
const subtasks = await this.opts.generateSubtasks({ task, error }).catch(() => []);
|
|
4019
|
+
return subtasks.length ? { action: "split", subtasks } : { action: "retry" };
|
|
4020
|
+
}
|
|
4021
|
+
return { action: "retry" };
|
|
4022
|
+
};
|
|
4023
|
+
};
|
|
4024
|
+
function makeCommandVerifier(options = {}) {
|
|
4025
|
+
const metadataKey = options.metadataKey ?? "verificationCommand";
|
|
4026
|
+
const timeoutMs = options.timeoutMs ?? 18e4;
|
|
4027
|
+
return async function verifyTask(info) {
|
|
4028
|
+
const cmd = info.task.metadata?.[metadataKey];
|
|
4029
|
+
if (typeof cmd !== "string" || !cmd.trim()) return { ok: true };
|
|
4030
|
+
return await new Promise((resolve) => {
|
|
4031
|
+
const isWindows = process.platform === "win32";
|
|
4032
|
+
const [shell, ...shellArgs] = isWindows ? ["cmd", "/d", "/c"] : ["sh", "-c"];
|
|
4033
|
+
const child = spawn(shell, [...shellArgs, cmd], {
|
|
4034
|
+
cwd: info.cwd,
|
|
4035
|
+
shell: false,
|
|
4036
|
+
windowsHide: true,
|
|
4037
|
+
stdio: "ignore"
|
|
4038
|
+
});
|
|
4039
|
+
let timedOut = false;
|
|
4040
|
+
const timer = setTimeout(() => {
|
|
4041
|
+
timedOut = true;
|
|
4042
|
+
child.kill();
|
|
4043
|
+
resolve({ ok: false, reason: `verification timed out: ${cmd}` });
|
|
4044
|
+
}, timeoutMs);
|
|
4045
|
+
child.on("exit", (code) => {
|
|
4046
|
+
clearTimeout(timer);
|
|
4047
|
+
if (timedOut) return;
|
|
4048
|
+
resolve(
|
|
4049
|
+
code === 0 ? { ok: true } : { ok: false, reason: `verification failed (exit ${code}): ${cmd}` }
|
|
4050
|
+
);
|
|
4051
|
+
});
|
|
4052
|
+
child.on("error", (err) => {
|
|
4053
|
+
clearTimeout(timer);
|
|
4054
|
+
if (timedOut) return;
|
|
4055
|
+
resolve({ ok: false, reason: `verification spawn error: ${String(err)}` });
|
|
4056
|
+
});
|
|
4057
|
+
});
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
var TASK_TYPES2 = /* @__PURE__ */ new Set(["feature", "bugfix", "refactor", "docs", "test", "chore"]);
|
|
4061
|
+
var PRIORITIES = /* @__PURE__ */ new Set(["critical", "high", "medium", "low"]);
|
|
4062
|
+
function extractJsonArray(text) {
|
|
4063
|
+
const fence = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
|
|
4064
|
+
if (fence?.[1]) return fence[1].trim();
|
|
4065
|
+
const bare = text.match(/(\[[\s\S]*\])/);
|
|
4066
|
+
if (bare?.[1]) {
|
|
4067
|
+
try {
|
|
4068
|
+
if (Array.isArray(JSON.parse(bare[1]))) return bare[1];
|
|
4069
|
+
} catch {
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
return null;
|
|
4073
|
+
}
|
|
4074
|
+
function buildPrompt(task, error, min, max) {
|
|
4075
|
+
return renderInstructionTemplate(readBundledInstructionText("sdd/decompose-task.md"), {
|
|
4076
|
+
minSubtasks: String(min),
|
|
4077
|
+
maxSubtasks: String(max),
|
|
4078
|
+
title: task.title,
|
|
4079
|
+
description: task.description,
|
|
4080
|
+
error: error || "(none recorded)"
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
function makeLlmSubtaskGenerator(opts) {
|
|
4084
|
+
const min = Math.max(2, opts.minSubtasks ?? 2);
|
|
4085
|
+
const max = Math.max(min, opts.maxSubtasks ?? 4);
|
|
4086
|
+
return async function generateSubtasks(info) {
|
|
4087
|
+
let text;
|
|
4088
|
+
try {
|
|
4089
|
+
text = await opts.run(buildPrompt(info.task, info.error, min, max));
|
|
4090
|
+
} catch {
|
|
4091
|
+
return [];
|
|
4092
|
+
}
|
|
4093
|
+
const json = extractJsonArray(text ?? "");
|
|
4094
|
+
if (!json) return [];
|
|
4095
|
+
let raw;
|
|
4096
|
+
try {
|
|
4097
|
+
raw = JSON.parse(json);
|
|
4098
|
+
} catch {
|
|
4099
|
+
return [];
|
|
4100
|
+
}
|
|
4101
|
+
if (!Array.isArray(raw)) return [];
|
|
4102
|
+
const specs = [];
|
|
4103
|
+
for (const item of raw) {
|
|
4104
|
+
if (!item || typeof item !== "object") continue;
|
|
4105
|
+
const r = item;
|
|
4106
|
+
const title = typeof r["title"] === "string" ? r["title"].trim() : "";
|
|
4107
|
+
const description = typeof r["description"] === "string" ? r["description"].trim() : "";
|
|
4108
|
+
if (!title || !description) continue;
|
|
4109
|
+
const type = TASK_TYPES2.has(r["type"]) ? r["type"] : void 0;
|
|
4110
|
+
const priority = PRIORITIES.has(r["priority"]) ? r["priority"] : void 0;
|
|
4111
|
+
specs.push({ title, description, type, priority });
|
|
4112
|
+
if (specs.length >= max) break;
|
|
4113
|
+
}
|
|
4114
|
+
return specs.length >= min ? specs : [];
|
|
4115
|
+
};
|
|
4116
|
+
}
|
|
4117
|
+
var START = "<<<<<<<";
|
|
4118
|
+
var BASE = "|||||||";
|
|
4119
|
+
var SEP = "=======";
|
|
4120
|
+
var END = ">>>>>>>";
|
|
4121
|
+
function resolveConflictText(text, side) {
|
|
4122
|
+
const out = [];
|
|
4123
|
+
let state = "normal";
|
|
4124
|
+
for (const line of text.split("\n")) {
|
|
4125
|
+
const marker = line.slice(0, 7);
|
|
4126
|
+
if (state === "normal" && marker === START) {
|
|
4127
|
+
state = "ours";
|
|
4128
|
+
continue;
|
|
4129
|
+
}
|
|
4130
|
+
if (state !== "normal" && marker === BASE) {
|
|
4131
|
+
state = "base";
|
|
4132
|
+
continue;
|
|
4133
|
+
}
|
|
4134
|
+
if (state !== "normal" && marker === SEP) {
|
|
4135
|
+
state = "theirs";
|
|
4136
|
+
continue;
|
|
4137
|
+
}
|
|
4138
|
+
if (state !== "normal" && marker === END) {
|
|
4139
|
+
state = "normal";
|
|
4140
|
+
continue;
|
|
4141
|
+
}
|
|
4142
|
+
if (state === "normal") out.push(line);
|
|
4143
|
+
else if (state === "ours" && side === "base") out.push(line);
|
|
4144
|
+
else if (state === "theirs" && side === "incoming") out.push(line);
|
|
4145
|
+
}
|
|
4146
|
+
return out.join("\n");
|
|
4147
|
+
}
|
|
4148
|
+
function hasConflictMarkers(text) {
|
|
4149
|
+
return text.split("\n").some((l) => {
|
|
4150
|
+
const m = l.slice(0, 7);
|
|
4151
|
+
return m === START || m === SEP || m === END || m === BASE;
|
|
4152
|
+
});
|
|
4153
|
+
}
|
|
4154
|
+
function makePreferSideConflictResolver(side) {
|
|
4155
|
+
return async function conflictResolver(info) {
|
|
4156
|
+
if (info.conflictFiles.length === 0) return false;
|
|
4157
|
+
for (const rel of info.conflictFiles) {
|
|
4158
|
+
const abs = isAbsolute(rel) ? rel : join(info.cwd, rel);
|
|
4159
|
+
let content;
|
|
4160
|
+
try {
|
|
4161
|
+
content = await readFile(abs, "utf8");
|
|
4162
|
+
} catch {
|
|
4163
|
+
return false;
|
|
4164
|
+
}
|
|
4165
|
+
const resolved = resolveConflictText(content, side);
|
|
4166
|
+
if (hasConflictMarkers(resolved)) return false;
|
|
4167
|
+
try {
|
|
4168
|
+
await writeFile(abs, resolved, "utf8");
|
|
4169
|
+
} catch {
|
|
4170
|
+
return false;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
return true;
|
|
4174
|
+
};
|
|
4175
|
+
}
|
|
4176
|
+
function unfence(text) {
|
|
4177
|
+
const m = text.match(/^[\s\S]*?```[^\n]*\n([\s\S]*?)\n```[\s\S]*$/);
|
|
4178
|
+
return m?.[1] !== void 0 ? m[1] : text.trim();
|
|
4179
|
+
}
|
|
4180
|
+
function nonMarkerLineCount(text) {
|
|
4181
|
+
return text.split("\n").filter((l) => {
|
|
4182
|
+
const m = l.slice(0, 7);
|
|
4183
|
+
return m !== START && m !== SEP && m !== END && m !== BASE;
|
|
4184
|
+
}).length;
|
|
4185
|
+
}
|
|
4186
|
+
function makeLlmConflictResolver(opts) {
|
|
4187
|
+
const minFraction = opts.minRetainedFraction ?? 0.5;
|
|
4188
|
+
return async function conflictResolver(info) {
|
|
4189
|
+
if (info.conflictFiles.length === 0) return false;
|
|
4190
|
+
for (const rel of info.conflictFiles) {
|
|
4191
|
+
const abs = isAbsolute(rel) ? rel : join(info.cwd, rel);
|
|
4192
|
+
let content;
|
|
4193
|
+
try {
|
|
4194
|
+
content = await readFile(abs, "utf8");
|
|
4195
|
+
} catch {
|
|
4196
|
+
return false;
|
|
4197
|
+
}
|
|
4198
|
+
if (!hasConflictMarkers(content)) continue;
|
|
4199
|
+
const prompt = renderInstructionTemplate(
|
|
4200
|
+
readBundledInstructionText("sdd/merge-conflict-resolver.md"),
|
|
4201
|
+
{
|
|
4202
|
+
file: rel,
|
|
4203
|
+
content
|
|
4204
|
+
}
|
|
4205
|
+
);
|
|
4206
|
+
let out;
|
|
4207
|
+
try {
|
|
4208
|
+
out = await opts.run(prompt);
|
|
4209
|
+
} catch {
|
|
4210
|
+
return false;
|
|
4211
|
+
}
|
|
4212
|
+
const resolved = unfence(out ?? "");
|
|
4213
|
+
if (!resolved.trim() || hasConflictMarkers(resolved)) return false;
|
|
4214
|
+
if (resolved.split("\n").length < Math.floor(nonMarkerLineCount(content) * minFraction)) {
|
|
4215
|
+
return false;
|
|
4216
|
+
}
|
|
4217
|
+
try {
|
|
4218
|
+
await writeFile(abs, resolved, "utf8");
|
|
4219
|
+
} catch {
|
|
4220
|
+
return false;
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
return true;
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
export { AISpecBuilder, AutoExecutor, SPEC_TEMPLATES, SddBoardProjector, SddBoardStore, SddInterviewDriver, SddParallelRun, SddRunRegistry, SddSupervisor, SddTaskDecomposer, SpecDrivenDev, SpecParser, SpecStore, SpecVersioning, TaskFlow, TaskGenerator, TaskGraphStore, analyzeCriticalPath, applySddLifecycle, buildBoardSnapshot, buildBoardTasks, cleanupSddWorktrees, cleanupStaleSddWorktrees, cleanupStaleWorktrees, createAutoExecutor, destroySddProject, extractVerificationCommand, getTemplate, hasConflictMarkers, isExplanatoryText, listTemplates, makeCommandVerifier, makeLlmConflictResolver, makeLlmSubtaskGenerator, makePreferSideConflictResolver, renderProgress, renderSpecAnalysis, renderTaskGraph, renderTaskList, resolveConflictText, rollbackSddRunFromDisk, shortIdMap, startSddRun, templateToMarkdown };
|
|
4228
|
+
//# sourceMappingURL=index.js.map
|
|
4229
|
+
//# sourceMappingURL=index.js.map
|