nightralph 0.0.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/.claude/settings.local.json +5 -0
- package/AGENTS.md +60 -0
- package/CLAUDE.md +1 -0
- package/PROMPT.md +95 -0
- package/README.md +138 -0
- package/WIP.md +5 -0
- package/dist/docs-templates/domain.md +36 -0
- package/dist/docs-templates/issue-tracker-github.md +228 -0
- package/dist/docs-templates/issue-tracker.md +30 -0
- package/dist/docs-templates/triage-labels.md +15 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +7 -0
- package/dist/meta.json +207 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +7 -0
- package/dist/resolve.js +71 -0
- package/dist/resolve.js.map +7 -0
- package/dist/setup.js +151 -0
- package/dist/setup.js.map +7 -0
- package/dist/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/dist/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/dist/skills/domain-modeling/SKILL.md +74 -0
- package/dist/skills/domain-modeling/agents/openai.yaml +3 -0
- package/dist/skills/grill/SKILL.md +7 -0
- package/dist/skills/grill/agents/openai.yaml +5 -0
- package/dist/skills/grilling/SKILL.md +28 -0
- package/dist/skills/grilling/agents/openai.yaml +3 -0
- package/dist/skills/tdd/SKILL.md +38 -0
- package/dist/skills/tdd/agents/openai.yaml +3 -0
- package/dist/skills/tdd/mocking.md +59 -0
- package/dist/skills/tdd/tests.md +77 -0
- package/dist/skills/to-spec/SKILL.md +75 -0
- package/dist/skills/to-spec/agents/openai.yaml +5 -0
- package/dist/skills/to-tickets/SKILL.md +105 -0
- package/dist/skills/to-tickets/agents/openai.yaml +5 -0
- package/dist/skills/upstream.json +5 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/orchestrator.d.ts +31 -0
- package/dist/src/orchestrator.d.ts.map +1 -0
- package/dist/src/resolve.d.ts +10 -0
- package/dist/src/resolve.d.ts.map +1 -0
- package/dist/src/setup.d.ts +13 -0
- package/dist/src/setup.d.ts.map +1 -0
- package/dist/test/integration.test.js +848 -0
- package/dist/test/integration.test.js.map +7 -0
- package/dist/test/orchestrator.test.js +749 -0
- package/dist/test/orchestrator.test.js.map +7 -0
- package/dist/test/setup.test.js +574 -0
- package/dist/test/setup.test.js.map +7 -0
- package/docs/README.md +12 -0
- package/docs/agents/domain.md +36 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase-2-future-work.md +144 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_01.md +292 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_02.md +212 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/test-requirements.md +203 -0
- package/docs/skills.md +58 -0
- package/docs/test-plans/2026-08-27-issue-tracker-choice.md +95 -0
- package/eslint.config.js +142 -0
- package/lib.es5.d.ts +19 -0
- package/package.json +36 -0
- package/progress.log +190 -0
- package/ralph_claude.sh +263 -0
- package/scripts/clone-skills.sh +52 -0
- package/specs/prd.json +172 -0
- package/src/docs-templates/domain.md +36 -0
- package/src/docs-templates/issue-tracker-github.md +228 -0
- package/src/docs-templates/issue-tracker.md +30 -0
- package/src/docs-templates/triage-labels.md +15 -0
- package/src/index.ts +184 -0
- package/src/orchestrator.ts +324 -0
- package/src/resolve.ts +73 -0
- package/src/setup.ts +157 -0
- package/src/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/src/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/src/skills/domain-modeling/SKILL.md +74 -0
- package/src/skills/domain-modeling/agents/openai.yaml +3 -0
- package/src/skills/grill/SKILL.md +7 -0
- package/src/skills/grill/agents/openai.yaml +5 -0
- package/src/skills/grilling/SKILL.md +28 -0
- package/src/skills/grilling/agents/openai.yaml +3 -0
- package/src/skills/tdd/SKILL.md +38 -0
- package/src/skills/tdd/agents/openai.yaml +3 -0
- package/src/skills/tdd/mocking.md +59 -0
- package/src/skills/tdd/tests.md +77 -0
- package/src/skills/to-spec/SKILL.md +75 -0
- package/src/skills/to-spec/agents/openai.yaml +5 -0
- package/src/skills/to-tickets/SKILL.md +105 -0
- package/src/skills/to-tickets/agents/openai.yaml +5 -0
- package/src/skills/upstream.json +5 -0
- package/tasks/prd-integration-test-coverage.md +243 -0
- package/test/integration.test.ts +636 -0
- package/test/mock-agent-echo-args.js +4 -0
- package/test/mock-agent-fail.js +3 -0
- package/test/mock-agent-pass.js +3 -0
- package/test/orchestrator.test.ts +609 -0
- package/test/setup.test.ts +417 -0
- package/tsconfig.build.json +7 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// test/orchestrator.test.ts
|
|
2
|
+
import { test } from "@substrate-system/tapzero";
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync as mkdirSync2,
|
|
6
|
+
writeFileSync as writeFileSync2,
|
|
7
|
+
readFileSync as readFileSync2,
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync as readdirSync2
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join as join2 } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
|
|
14
|
+
// src/orchestrator.ts
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import {
|
|
17
|
+
createWriteStream,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
readdirSync
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
function formatErrorMessage(error) {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
26
|
+
}
|
|
27
|
+
var TICKET_RE = /^(\d+)-(.+)\.md$/;
|
|
28
|
+
function parseTicketFilename(filename) {
|
|
29
|
+
const m = filename.match(TICKET_RE);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
return { num: Number(m[1]), slug: m[2] };
|
|
32
|
+
}
|
|
33
|
+
function parseStatus(body) {
|
|
34
|
+
const m = body.match(/\*\*Status:\*\*\s*(.+)/i);
|
|
35
|
+
return m ? m[1].trim().toLowerCase() : "";
|
|
36
|
+
}
|
|
37
|
+
function parseBlockers(body) {
|
|
38
|
+
const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i);
|
|
39
|
+
if (!m) return [];
|
|
40
|
+
const text = m[1];
|
|
41
|
+
if (/none/i.test(text)) return [];
|
|
42
|
+
const nums = [];
|
|
43
|
+
const re = /(\d+)/g;
|
|
44
|
+
let match;
|
|
45
|
+
while (match = re.exec(text)) {
|
|
46
|
+
nums.push(Number(match[1]));
|
|
47
|
+
}
|
|
48
|
+
return nums;
|
|
49
|
+
}
|
|
50
|
+
function scanTickets(dir) {
|
|
51
|
+
const files = readdirSync(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
|
|
52
|
+
b,
|
|
53
|
+
void 0,
|
|
54
|
+
{ numeric: true, sensitivity: "base" }
|
|
55
|
+
));
|
|
56
|
+
return files.map((filename) => {
|
|
57
|
+
const parsed = parseTicketFilename(filename);
|
|
58
|
+
const filepath = join(dir, filename);
|
|
59
|
+
const body = readFileSync(filepath, "utf8");
|
|
60
|
+
return {
|
|
61
|
+
num: parsed.num,
|
|
62
|
+
slug: parsed.slug,
|
|
63
|
+
filename,
|
|
64
|
+
filepath,
|
|
65
|
+
body,
|
|
66
|
+
status: parseStatus(body),
|
|
67
|
+
blockers: parseBlockers(body)
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function findReadyTickets(tickets) {
|
|
72
|
+
const doneNums = new Set(
|
|
73
|
+
tickets.filter((t) => t.status !== "ready-for-agent").map((t) => t.num)
|
|
74
|
+
);
|
|
75
|
+
return tickets.filter((t) => {
|
|
76
|
+
if (t.status !== "ready-for-agent") return false;
|
|
77
|
+
return t.blockers.every((b) => doneNums.has(b));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function markDone(ticket) {
|
|
81
|
+
const updated = ticket.body.replace(
|
|
82
|
+
/\*\*Status:\*\*\s*.+/i,
|
|
83
|
+
"**Status:** done"
|
|
84
|
+
);
|
|
85
|
+
writeFileSync(ticket.filepath, updated);
|
|
86
|
+
ticket.status = "done";
|
|
87
|
+
ticket.body = updated;
|
|
88
|
+
}
|
|
89
|
+
function renderPrompt(specBody, ticket) {
|
|
90
|
+
return [
|
|
91
|
+
"---- SPEC CONTEXT ----",
|
|
92
|
+
specBody,
|
|
93
|
+
"",
|
|
94
|
+
"---- TICKET ----",
|
|
95
|
+
ticket.filename,
|
|
96
|
+
ticket.body,
|
|
97
|
+
"",
|
|
98
|
+
"---- AGENT INSTRUCTIONS ----",
|
|
99
|
+
"You are an autonomous coding agent. Implement the ticket.",
|
|
100
|
+
""
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
function spawnAgent(opts) {
|
|
104
|
+
const args = [];
|
|
105
|
+
if (opts.model) {
|
|
106
|
+
args.push("--model", opts.model);
|
|
107
|
+
}
|
|
108
|
+
const logStream = createWriteStream(
|
|
109
|
+
opts.logPath,
|
|
110
|
+
{ flags: "w" }
|
|
111
|
+
);
|
|
112
|
+
const proc = spawn(opts.agentCmd, args, {
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
114
|
+
});
|
|
115
|
+
proc.stdout.pipe(logStream);
|
|
116
|
+
proc.stderr.pipe(logStream);
|
|
117
|
+
const timeout = setTimeout(() => {
|
|
118
|
+
if (!proc.killed) {
|
|
119
|
+
console.warn(
|
|
120
|
+
`Agent did not exit in ${opts.timeoutSecs}s; killing.`
|
|
121
|
+
);
|
|
122
|
+
proc.kill("SIGKILL");
|
|
123
|
+
}
|
|
124
|
+
}, opts.timeoutSecs * 1e3);
|
|
125
|
+
proc.stdin.write(opts.prompt);
|
|
126
|
+
proc.stdin.end();
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
proc.on("close", (code) => {
|
|
129
|
+
clearTimeout(timeout);
|
|
130
|
+
logStream.end();
|
|
131
|
+
resolve(code ?? 1);
|
|
132
|
+
});
|
|
133
|
+
proc.on("error", (err) => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
logStream.end();
|
|
136
|
+
console.error(
|
|
137
|
+
"Agent process error:",
|
|
138
|
+
formatErrorMessage(err)
|
|
139
|
+
);
|
|
140
|
+
resolve(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function runWaves(tickets, specBody, agentCmd, model, timeoutSecs, logsDir) {
|
|
145
|
+
mkdirSync(logsDir, { recursive: true });
|
|
146
|
+
let totalCompleted = 0;
|
|
147
|
+
let waveNum = 0;
|
|
148
|
+
while (true) {
|
|
149
|
+
const ready = findReadyTickets(tickets);
|
|
150
|
+
if (ready.length === 0) break;
|
|
151
|
+
waveNum++;
|
|
152
|
+
console.log(
|
|
153
|
+
`
|
|
154
|
+
Wave ${waveNum}: ` + ready.map((t) => t.filename).join(", ")
|
|
155
|
+
);
|
|
156
|
+
const results = await Promise.allSettled(
|
|
157
|
+
ready.map(async (t) => {
|
|
158
|
+
const prompt = renderPrompt(specBody, t);
|
|
159
|
+
const logPath = join(
|
|
160
|
+
logsDir,
|
|
161
|
+
t.filename.replace(/\.md$/, ".log")
|
|
162
|
+
);
|
|
163
|
+
console.log(` Starting ${t.filename}`);
|
|
164
|
+
const code = await spawnAgent({
|
|
165
|
+
prompt,
|
|
166
|
+
agentCmd,
|
|
167
|
+
model,
|
|
168
|
+
timeoutSecs,
|
|
169
|
+
logPath
|
|
170
|
+
});
|
|
171
|
+
return { ticket: t, exitCode: code };
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
let waveCompleted = 0;
|
|
175
|
+
for (const r of results) {
|
|
176
|
+
if (r.status === "rejected") continue;
|
|
177
|
+
const { ticket, exitCode } = r.value;
|
|
178
|
+
if (exitCode === 0) {
|
|
179
|
+
markDone(ticket);
|
|
180
|
+
waveCompleted++;
|
|
181
|
+
totalCompleted++;
|
|
182
|
+
console.log(
|
|
183
|
+
` ${ticket.filename}: done`
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
console.log(
|
|
187
|
+
` ${ticket.filename}: agent exited ${exitCode}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (waveCompleted === 0) {
|
|
192
|
+
console.log(
|
|
193
|
+
"\nNo tickets completed this wave. Stopping."
|
|
194
|
+
);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const remaining = tickets.filter(
|
|
199
|
+
(t) => t.status === "ready-for-agent"
|
|
200
|
+
);
|
|
201
|
+
console.log(
|
|
202
|
+
`
|
|
203
|
+
Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// test/orchestrator.test.ts
|
|
208
|
+
function tmp() {
|
|
209
|
+
return mkdtempSync(
|
|
210
|
+
join2(tmpdir(), "nightralph-orch-test-")
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
function makeTicketBody(opts) {
|
|
214
|
+
const lines = [
|
|
215
|
+
"# Test ticket",
|
|
216
|
+
"",
|
|
217
|
+
`**Status:** ${opts.status}`
|
|
218
|
+
];
|
|
219
|
+
if (opts.blockers && opts.blockers.length > 0) {
|
|
220
|
+
lines.push(
|
|
221
|
+
`**Blocked by:** ${opts.blockers.join(", ")}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
function makeTicket(overrides) {
|
|
227
|
+
return {
|
|
228
|
+
slug: overrides.filename.replace(
|
|
229
|
+
/^\d+-(.+)\.md$/,
|
|
230
|
+
"$1"
|
|
231
|
+
),
|
|
232
|
+
filepath: `/fake/${overrides.filename}`,
|
|
233
|
+
body: makeTicketBody({
|
|
234
|
+
status: overrides.status ?? "ready-for-agent"
|
|
235
|
+
}),
|
|
236
|
+
status: overrides.status ?? "ready-for-agent",
|
|
237
|
+
blockers: overrides.blockers ?? [],
|
|
238
|
+
...overrides
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
test("parseTicketFilename parses valid names", (t) => {
|
|
242
|
+
const r = parseTicketFilename("01-add-auth.md");
|
|
243
|
+
t.ok(r, "should return a result");
|
|
244
|
+
t.equal(r.num, 1, "num");
|
|
245
|
+
t.equal(r.slug, "add-auth", "slug");
|
|
246
|
+
});
|
|
247
|
+
test("parseTicketFilename with multi-digit number", (t) => {
|
|
248
|
+
const r = parseTicketFilename("123-foo-bar.md");
|
|
249
|
+
t.ok(r, "should return a result");
|
|
250
|
+
t.equal(r.num, 123, "num");
|
|
251
|
+
t.equal(r.slug, "foo-bar", "slug");
|
|
252
|
+
});
|
|
253
|
+
test("parseTicketFilename rejects non-matching names", (t) => {
|
|
254
|
+
t.equal(
|
|
255
|
+
parseTicketFilename("readme.md"),
|
|
256
|
+
null,
|
|
257
|
+
"no leading number"
|
|
258
|
+
);
|
|
259
|
+
t.equal(
|
|
260
|
+
parseTicketFilename("01-thing.txt"),
|
|
261
|
+
null,
|
|
262
|
+
"wrong extension"
|
|
263
|
+
);
|
|
264
|
+
t.equal(
|
|
265
|
+
parseTicketFilename("not-a-ticket"),
|
|
266
|
+
null,
|
|
267
|
+
"no extension"
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
test("parseStatus extracts status", (t) => {
|
|
271
|
+
t.equal(
|
|
272
|
+
parseStatus("**Status:** ready-for-agent"),
|
|
273
|
+
"ready-for-agent",
|
|
274
|
+
"basic"
|
|
275
|
+
);
|
|
276
|
+
t.equal(
|
|
277
|
+
parseStatus("**Status:** Done "),
|
|
278
|
+
"done",
|
|
279
|
+
"trims and lowercases"
|
|
280
|
+
);
|
|
281
|
+
t.equal(
|
|
282
|
+
parseStatus("no status here"),
|
|
283
|
+
"",
|
|
284
|
+
"missing status returns empty"
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
test("parseStatus works in multi-line body", (t) => {
|
|
288
|
+
const body = [
|
|
289
|
+
"# Ticket",
|
|
290
|
+
"",
|
|
291
|
+
"**Status:** ready-for-agent",
|
|
292
|
+
"**Blocked by:** 1, 2"
|
|
293
|
+
].join("\n");
|
|
294
|
+
t.equal(parseStatus(body), "ready-for-agent");
|
|
295
|
+
});
|
|
296
|
+
test("parseBlockers extracts blocker numbers", (t) => {
|
|
297
|
+
t.deepEqual(
|
|
298
|
+
parseBlockers("**Blocked by:** 1, 2, 3"),
|
|
299
|
+
[1, 2, 3],
|
|
300
|
+
"comma-separated"
|
|
301
|
+
);
|
|
302
|
+
t.deepEqual(
|
|
303
|
+
parseBlockers("**Blocked by:** #4 and #5"),
|
|
304
|
+
[4, 5],
|
|
305
|
+
"hash-prefixed"
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
test("parseBlockers returns empty for none", (t) => {
|
|
309
|
+
t.deepEqual(
|
|
310
|
+
parseBlockers("**Blocked by:** none"),
|
|
311
|
+
[],
|
|
312
|
+
"explicit none"
|
|
313
|
+
);
|
|
314
|
+
t.deepEqual(
|
|
315
|
+
parseBlockers("**Blocked by:** None"),
|
|
316
|
+
[],
|
|
317
|
+
"case-insensitive none"
|
|
318
|
+
);
|
|
319
|
+
t.deepEqual(
|
|
320
|
+
parseBlockers("no blockers field at all"),
|
|
321
|
+
[],
|
|
322
|
+
"missing field"
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
test("scanTickets reads and parses ticket files", (t) => {
|
|
326
|
+
const dir = tmp();
|
|
327
|
+
writeFileSync2(
|
|
328
|
+
join2(dir, "01-first.md"),
|
|
329
|
+
makeTicketBody({
|
|
330
|
+
status: "ready-for-agent",
|
|
331
|
+
blockers: []
|
|
332
|
+
})
|
|
333
|
+
);
|
|
334
|
+
writeFileSync2(
|
|
335
|
+
join2(dir, "02-second.md"),
|
|
336
|
+
makeTicketBody({
|
|
337
|
+
status: "ready-for-agent",
|
|
338
|
+
blockers: [1]
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
writeFileSync2(
|
|
342
|
+
join2(dir, "ignore-me.txt"),
|
|
343
|
+
"not a ticket"
|
|
344
|
+
);
|
|
345
|
+
const tickets = scanTickets(dir);
|
|
346
|
+
t.equal(tickets.length, 2, "finds 2 tickets");
|
|
347
|
+
t.equal(tickets[0].num, 1, "first ticket num");
|
|
348
|
+
t.equal(tickets[0].slug, "first", "first ticket slug");
|
|
349
|
+
t.equal(
|
|
350
|
+
tickets[0].status,
|
|
351
|
+
"ready-for-agent",
|
|
352
|
+
"first ticket status"
|
|
353
|
+
);
|
|
354
|
+
t.deepEqual(
|
|
355
|
+
tickets[0].blockers,
|
|
356
|
+
[],
|
|
357
|
+
"first has no blockers"
|
|
358
|
+
);
|
|
359
|
+
t.equal(tickets[1].num, 2, "second ticket num");
|
|
360
|
+
t.deepEqual(
|
|
361
|
+
tickets[1].blockers,
|
|
362
|
+
[1],
|
|
363
|
+
"second blocked by 1"
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
test("scanTickets returns sorted by number", (t) => {
|
|
367
|
+
const dir = tmp();
|
|
368
|
+
writeFileSync2(
|
|
369
|
+
join2(dir, "10-z.md"),
|
|
370
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
371
|
+
);
|
|
372
|
+
writeFileSync2(
|
|
373
|
+
join2(dir, "02-a.md"),
|
|
374
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
375
|
+
);
|
|
376
|
+
writeFileSync2(
|
|
377
|
+
join2(dir, "03-b.md"),
|
|
378
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
379
|
+
);
|
|
380
|
+
const tickets = scanTickets(dir);
|
|
381
|
+
t.deepEqual(
|
|
382
|
+
tickets.map((t2) => t2.num),
|
|
383
|
+
[2, 3, 10],
|
|
384
|
+
"numeric sort"
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
test("findReadyTickets returns unblocked ready tickets", (t) => {
|
|
388
|
+
const tickets = [
|
|
389
|
+
makeTicket({
|
|
390
|
+
num: 1,
|
|
391
|
+
filename: "01-a.md",
|
|
392
|
+
status: "ready-for-agent",
|
|
393
|
+
blockers: []
|
|
394
|
+
}),
|
|
395
|
+
makeTicket({
|
|
396
|
+
num: 2,
|
|
397
|
+
filename: "02-b.md",
|
|
398
|
+
status: "ready-for-agent",
|
|
399
|
+
blockers: [1]
|
|
400
|
+
}),
|
|
401
|
+
makeTicket({
|
|
402
|
+
num: 3,
|
|
403
|
+
filename: "03-c.md",
|
|
404
|
+
status: "done",
|
|
405
|
+
blockers: []
|
|
406
|
+
})
|
|
407
|
+
];
|
|
408
|
+
const ready = findReadyTickets(tickets);
|
|
409
|
+
t.equal(ready.length, 1, "only one is ready");
|
|
410
|
+
t.equal(ready[0].num, 1, "ticket 1 is ready");
|
|
411
|
+
});
|
|
412
|
+
test("findReadyTickets unblocks after deps done", (t) => {
|
|
413
|
+
const tickets = [
|
|
414
|
+
makeTicket({
|
|
415
|
+
num: 1,
|
|
416
|
+
filename: "01-a.md",
|
|
417
|
+
status: "done",
|
|
418
|
+
blockers: []
|
|
419
|
+
}),
|
|
420
|
+
makeTicket({
|
|
421
|
+
num: 2,
|
|
422
|
+
filename: "02-b.md",
|
|
423
|
+
status: "ready-for-agent",
|
|
424
|
+
blockers: [1]
|
|
425
|
+
}),
|
|
426
|
+
makeTicket({
|
|
427
|
+
num: 3,
|
|
428
|
+
filename: "03-c.md",
|
|
429
|
+
status: "ready-for-agent",
|
|
430
|
+
blockers: [1, 2]
|
|
431
|
+
})
|
|
432
|
+
];
|
|
433
|
+
const ready = findReadyTickets(tickets);
|
|
434
|
+
t.equal(ready.length, 1, "only ticket 2 is ready");
|
|
435
|
+
t.equal(
|
|
436
|
+
ready[0].num,
|
|
437
|
+
2,
|
|
438
|
+
"ticket 2 unblocked because 1 is done"
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
test("findReadyTickets returns nothing when all blocked", (t) => {
|
|
442
|
+
const tickets = [
|
|
443
|
+
makeTicket({
|
|
444
|
+
num: 1,
|
|
445
|
+
filename: "01-a.md",
|
|
446
|
+
status: "ready-for-agent",
|
|
447
|
+
blockers: [2]
|
|
448
|
+
}),
|
|
449
|
+
makeTicket({
|
|
450
|
+
num: 2,
|
|
451
|
+
filename: "02-b.md",
|
|
452
|
+
status: "ready-for-agent",
|
|
453
|
+
blockers: [1]
|
|
454
|
+
})
|
|
455
|
+
];
|
|
456
|
+
const ready = findReadyTickets(tickets);
|
|
457
|
+
t.equal(ready.length, 0, "circular dep deadlock");
|
|
458
|
+
});
|
|
459
|
+
test("markDone updates file and ticket object", (t) => {
|
|
460
|
+
const dir = tmp();
|
|
461
|
+
const filepath = join2(dir, "01-test.md");
|
|
462
|
+
const body = makeTicketBody({
|
|
463
|
+
status: "ready-for-agent"
|
|
464
|
+
});
|
|
465
|
+
writeFileSync2(filepath, body);
|
|
466
|
+
const ticket = {
|
|
467
|
+
num: 1,
|
|
468
|
+
slug: "test",
|
|
469
|
+
filename: "01-test.md",
|
|
470
|
+
filepath,
|
|
471
|
+
body,
|
|
472
|
+
status: "ready-for-agent",
|
|
473
|
+
blockers: []
|
|
474
|
+
};
|
|
475
|
+
markDone(ticket);
|
|
476
|
+
t.equal(ticket.status, "done", "in-memory status");
|
|
477
|
+
const onDisk = readFileSync2(filepath, "utf8");
|
|
478
|
+
t.ok(
|
|
479
|
+
onDisk.includes("**Status:** done"),
|
|
480
|
+
"file updated on disk"
|
|
481
|
+
);
|
|
482
|
+
t.ok(
|
|
483
|
+
!onDisk.includes("ready-for-agent"),
|
|
484
|
+
"old status removed"
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
test("renderPrompt includes spec and ticket", (t) => {
|
|
488
|
+
const ticket = makeTicket({
|
|
489
|
+
num: 1,
|
|
490
|
+
filename: "01-thing.md"
|
|
491
|
+
});
|
|
492
|
+
const prompt = renderPrompt("spec body here", ticket);
|
|
493
|
+
t.ok(
|
|
494
|
+
prompt.includes("spec body here"),
|
|
495
|
+
"contains spec"
|
|
496
|
+
);
|
|
497
|
+
t.ok(
|
|
498
|
+
prompt.includes("01-thing.md"),
|
|
499
|
+
"contains ticket filename"
|
|
500
|
+
);
|
|
501
|
+
t.ok(
|
|
502
|
+
prompt.includes("AGENT INSTRUCTIONS"),
|
|
503
|
+
"contains instructions"
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
var MOCK_PASS = join2(
|
|
507
|
+
process.cwd(),
|
|
508
|
+
"test",
|
|
509
|
+
"mock-agent-pass.js"
|
|
510
|
+
);
|
|
511
|
+
var MOCK_FAIL = join2(
|
|
512
|
+
process.cwd(),
|
|
513
|
+
"test",
|
|
514
|
+
"mock-agent-fail.js"
|
|
515
|
+
);
|
|
516
|
+
test("runWaves completes unblocked tickets", async (t) => {
|
|
517
|
+
const base = tmp();
|
|
518
|
+
const issuesDir = join2(base, "issues");
|
|
519
|
+
const logsDir = join2(base, "logs");
|
|
520
|
+
mkdirSync2(issuesDir);
|
|
521
|
+
writeFileSync2(
|
|
522
|
+
join2(issuesDir, "01-first.md"),
|
|
523
|
+
makeTicketBody({
|
|
524
|
+
status: "ready-for-agent"
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
writeFileSync2(
|
|
528
|
+
join2(issuesDir, "02-second.md"),
|
|
529
|
+
makeTicketBody({
|
|
530
|
+
status: "ready-for-agent"
|
|
531
|
+
})
|
|
532
|
+
);
|
|
533
|
+
const tickets = scanTickets(issuesDir);
|
|
534
|
+
await runWaves(
|
|
535
|
+
tickets,
|
|
536
|
+
"test spec",
|
|
537
|
+
MOCK_PASS,
|
|
538
|
+
null,
|
|
539
|
+
10,
|
|
540
|
+
logsDir
|
|
541
|
+
);
|
|
542
|
+
t.equal(
|
|
543
|
+
tickets[0].status,
|
|
544
|
+
"done",
|
|
545
|
+
"ticket 1 done"
|
|
546
|
+
);
|
|
547
|
+
t.equal(
|
|
548
|
+
tickets[1].status,
|
|
549
|
+
"done",
|
|
550
|
+
"ticket 2 done"
|
|
551
|
+
);
|
|
552
|
+
const onDisk = readFileSync2(
|
|
553
|
+
join2(issuesDir, "01-first.md"),
|
|
554
|
+
"utf8"
|
|
555
|
+
);
|
|
556
|
+
t.ok(
|
|
557
|
+
onDisk.includes("**Status:** done"),
|
|
558
|
+
"ticket 1 persisted to disk"
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
test("runWaves respects dependency order", async (t) => {
|
|
562
|
+
const base = tmp();
|
|
563
|
+
const issuesDir = join2(base, "issues");
|
|
564
|
+
const logsDir = join2(base, "logs");
|
|
565
|
+
mkdirSync2(issuesDir);
|
|
566
|
+
writeFileSync2(
|
|
567
|
+
join2(issuesDir, "01-base.md"),
|
|
568
|
+
makeTicketBody({
|
|
569
|
+
status: "ready-for-agent"
|
|
570
|
+
})
|
|
571
|
+
);
|
|
572
|
+
writeFileSync2(
|
|
573
|
+
join2(issuesDir, "02-depends-on-1.md"),
|
|
574
|
+
makeTicketBody({
|
|
575
|
+
status: "ready-for-agent",
|
|
576
|
+
blockers: [1]
|
|
577
|
+
})
|
|
578
|
+
);
|
|
579
|
+
writeFileSync2(
|
|
580
|
+
join2(issuesDir, "03-depends-on-2.md"),
|
|
581
|
+
makeTicketBody({
|
|
582
|
+
status: "ready-for-agent",
|
|
583
|
+
blockers: [2]
|
|
584
|
+
})
|
|
585
|
+
);
|
|
586
|
+
const tickets = scanTickets(issuesDir);
|
|
587
|
+
await runWaves(
|
|
588
|
+
tickets,
|
|
589
|
+
"test spec",
|
|
590
|
+
MOCK_PASS,
|
|
591
|
+
null,
|
|
592
|
+
10,
|
|
593
|
+
logsDir
|
|
594
|
+
);
|
|
595
|
+
for (const ticket of tickets) {
|
|
596
|
+
t.equal(
|
|
597
|
+
ticket.status,
|
|
598
|
+
"done",
|
|
599
|
+
`${ticket.filename} completed`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
test("runWaves stops dependents when agent fails", async (t) => {
|
|
604
|
+
const base = tmp();
|
|
605
|
+
const issuesDir = join2(base, "issues");
|
|
606
|
+
const logsDir = join2(base, "logs");
|
|
607
|
+
mkdirSync2(issuesDir);
|
|
608
|
+
writeFileSync2(
|
|
609
|
+
join2(issuesDir, "01-will-fail.md"),
|
|
610
|
+
makeTicketBody({
|
|
611
|
+
status: "ready-for-agent"
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
writeFileSync2(
|
|
615
|
+
join2(issuesDir, "02-blocked.md"),
|
|
616
|
+
makeTicketBody({
|
|
617
|
+
status: "ready-for-agent",
|
|
618
|
+
blockers: [1]
|
|
619
|
+
})
|
|
620
|
+
);
|
|
621
|
+
const tickets = scanTickets(issuesDir);
|
|
622
|
+
await runWaves(
|
|
623
|
+
tickets,
|
|
624
|
+
"test spec",
|
|
625
|
+
MOCK_FAIL,
|
|
626
|
+
null,
|
|
627
|
+
10,
|
|
628
|
+
logsDir
|
|
629
|
+
);
|
|
630
|
+
t.equal(
|
|
631
|
+
tickets[0].status,
|
|
632
|
+
"ready-for-agent",
|
|
633
|
+
"failed ticket keeps status"
|
|
634
|
+
);
|
|
635
|
+
t.equal(
|
|
636
|
+
tickets[1].status,
|
|
637
|
+
"ready-for-agent",
|
|
638
|
+
"dependent never ran"
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
test("runWaves writes log files", async (t) => {
|
|
642
|
+
const base = tmp();
|
|
643
|
+
const issuesDir = join2(base, "issues");
|
|
644
|
+
const logsDir = join2(base, "logs");
|
|
645
|
+
mkdirSync2(issuesDir);
|
|
646
|
+
writeFileSync2(
|
|
647
|
+
join2(issuesDir, "01-task.md"),
|
|
648
|
+
makeTicketBody({
|
|
649
|
+
status: "ready-for-agent"
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
const tickets = scanTickets(issuesDir);
|
|
653
|
+
await runWaves(
|
|
654
|
+
tickets,
|
|
655
|
+
"test spec",
|
|
656
|
+
MOCK_PASS,
|
|
657
|
+
null,
|
|
658
|
+
10,
|
|
659
|
+
logsDir
|
|
660
|
+
);
|
|
661
|
+
t.ok(
|
|
662
|
+
existsSync(join2(logsDir, "01-task.log")),
|
|
663
|
+
"log file created"
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
test("runWaves skips already-done tickets", async (t) => {
|
|
667
|
+
const base = tmp();
|
|
668
|
+
const issuesDir = join2(base, "issues");
|
|
669
|
+
const logsDir = join2(base, "logs");
|
|
670
|
+
mkdirSync2(issuesDir);
|
|
671
|
+
writeFileSync2(
|
|
672
|
+
join2(issuesDir, "01-done.md"),
|
|
673
|
+
makeTicketBody({ status: "done" })
|
|
674
|
+
);
|
|
675
|
+
writeFileSync2(
|
|
676
|
+
join2(issuesDir, "02-ready.md"),
|
|
677
|
+
makeTicketBody({
|
|
678
|
+
status: "ready-for-agent",
|
|
679
|
+
blockers: [1]
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
const tickets = scanTickets(issuesDir);
|
|
683
|
+
await runWaves(
|
|
684
|
+
tickets,
|
|
685
|
+
"test spec",
|
|
686
|
+
MOCK_PASS,
|
|
687
|
+
null,
|
|
688
|
+
10,
|
|
689
|
+
logsDir
|
|
690
|
+
);
|
|
691
|
+
t.equal(
|
|
692
|
+
tickets[0].status,
|
|
693
|
+
"done",
|
|
694
|
+
"pre-done ticket unchanged"
|
|
695
|
+
);
|
|
696
|
+
t.equal(
|
|
697
|
+
tickets[1].status,
|
|
698
|
+
"done",
|
|
699
|
+
"dependent ran and completed"
|
|
700
|
+
);
|
|
701
|
+
t.ok(
|
|
702
|
+
!existsSync(join2(logsDir, "01-done.log")),
|
|
703
|
+
"no log for skipped ticket"
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
test("runWaves handles parallel independent tickets", async (t) => {
|
|
707
|
+
const base = tmp();
|
|
708
|
+
const issuesDir = join2(base, "issues");
|
|
709
|
+
const logsDir = join2(base, "logs");
|
|
710
|
+
mkdirSync2(issuesDir);
|
|
711
|
+
writeFileSync2(
|
|
712
|
+
join2(issuesDir, "01-a.md"),
|
|
713
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
714
|
+
);
|
|
715
|
+
writeFileSync2(
|
|
716
|
+
join2(issuesDir, "02-b.md"),
|
|
717
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
718
|
+
);
|
|
719
|
+
writeFileSync2(
|
|
720
|
+
join2(issuesDir, "03-c.md"),
|
|
721
|
+
makeTicketBody({ status: "ready-for-agent" })
|
|
722
|
+
);
|
|
723
|
+
writeFileSync2(
|
|
724
|
+
join2(issuesDir, "04-final.md"),
|
|
725
|
+
makeTicketBody({
|
|
726
|
+
status: "ready-for-agent",
|
|
727
|
+
blockers: [1, 2, 3]
|
|
728
|
+
})
|
|
729
|
+
);
|
|
730
|
+
const tickets = scanTickets(issuesDir);
|
|
731
|
+
await runWaves(
|
|
732
|
+
tickets,
|
|
733
|
+
"test spec",
|
|
734
|
+
MOCK_PASS,
|
|
735
|
+
null,
|
|
736
|
+
10,
|
|
737
|
+
logsDir
|
|
738
|
+
);
|
|
739
|
+
for (const ticket of tickets) {
|
|
740
|
+
t.equal(
|
|
741
|
+
ticket.status,
|
|
742
|
+
"done",
|
|
743
|
+
`${ticket.filename} completed`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
const logs = readdirSync2(logsDir);
|
|
747
|
+
t.equal(logs.length, 4, "all four logs written");
|
|
748
|
+
});
|
|
749
|
+
//# sourceMappingURL=orchestrator.test.js.map
|