gsd-pi 2.37.0 → 2.37.1-dev.49503be
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/README.md +21 -20
- package/dist/onboarding.js +1 -0
- package/dist/resources/extensions/cmux/package.json +7 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +54 -1
- package/dist/resources/extensions/gsd/auto-loop.js +18 -4
- package/dist/resources/extensions/gsd/auto-post-unit.js +14 -0
- package/dist/resources/extensions/gsd/auto-prompts.js +55 -0
- package/dist/resources/extensions/gsd/auto-recovery.js +19 -1
- package/dist/resources/extensions/gsd/auto.js +42 -5
- package/dist/resources/extensions/gsd/commands.js +80 -33
- package/dist/resources/extensions/gsd/files.js +41 -0
- package/dist/resources/extensions/gsd/git-service.js +9 -1
- package/dist/resources/extensions/gsd/history.js +2 -1
- package/dist/resources/extensions/gsd/metrics.js +4 -2
- package/dist/resources/extensions/gsd/preferences-types.js +2 -1
- package/dist/resources/extensions/gsd/preferences-validation.js +42 -0
- package/dist/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
- package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
- package/dist/resources/extensions/gsd/session-lock.js +26 -6
- package/dist/resources/extensions/shared/format-utils.js +5 -41
- package/dist/resources/extensions/shared/layout-utils.js +46 -0
- package/dist/resources/extensions/shared/mod.js +2 -1
- package/package.json +2 -1
- package/packages/pi-ai/dist/env-api-keys.js +13 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +172 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +172 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +47 -764
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
- package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +2 -2
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/package.json +1 -0
- package/packages/pi-ai/src/env-api-keys.ts +14 -0
- package/packages/pi-ai/src/models.generated.ts +172 -0
- package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
- package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
- package/packages/pi-ai/src/providers/anthropic.ts +76 -868
- package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
- package/packages/pi-ai/src/types.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/package.json +7 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +78 -0
- package/src/resources/extensions/gsd/auto-loop.ts +24 -6
- package/src/resources/extensions/gsd/auto-post-unit.ts +14 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +68 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +18 -0
- package/src/resources/extensions/gsd/auto.ts +56 -5
- package/src/resources/extensions/gsd/commands.ts +85 -31
- package/src/resources/extensions/gsd/files.ts +45 -0
- package/src/resources/extensions/gsd/git-service.ts +12 -1
- package/src/resources/extensions/gsd/history.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +4 -2
- package/src/resources/extensions/gsd/preferences-types.ts +5 -1
- package/src/resources/extensions/gsd/preferences-validation.ts +41 -0
- package/src/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
- package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
- package/src/resources/extensions/gsd/session-lock.ts +41 -6
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +37 -1
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
- package/src/resources/extensions/gsd/tests/cmux.test.ts +25 -1
- package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +367 -0
- package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
- package/src/resources/extensions/gsd/types.ts +41 -0
- package/src/resources/extensions/shared/format-utils.ts +5 -44
- package/src/resources/extensions/shared/layout-utils.ts +49 -0
- package/src/resources/extensions/shared/mod.ts +7 -4
- package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
loadSliceTaskIO,
|
|
8
|
+
deriveTaskGraph,
|
|
9
|
+
isGraphAmbiguous,
|
|
10
|
+
getReadyTasks,
|
|
11
|
+
chooseNonConflictingSubset,
|
|
12
|
+
loadReactiveState,
|
|
13
|
+
saveReactiveState,
|
|
14
|
+
clearReactiveState,
|
|
15
|
+
} from "../reactive-graph.ts";
|
|
16
|
+
import { validatePreferences } from "../preferences-validation.ts";
|
|
17
|
+
import type { ReactiveExecutionState } from "../types.ts";
|
|
18
|
+
|
|
19
|
+
// ─── Preference Validation ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
test("reactive_execution validation accepts valid config", () => {
|
|
22
|
+
const result = validatePreferences({
|
|
23
|
+
reactive_execution: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
max_parallel: 4,
|
|
26
|
+
isolation_mode: "same-tree",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
assert.equal(result.errors.length, 0);
|
|
30
|
+
assert.deepEqual(result.preferences.reactive_execution, {
|
|
31
|
+
enabled: true,
|
|
32
|
+
max_parallel: 4,
|
|
33
|
+
isolation_mode: "same-tree",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("reactive_execution validation rejects max_parallel out of range", () => {
|
|
38
|
+
const result = validatePreferences({
|
|
39
|
+
reactive_execution: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
max_parallel: 10,
|
|
42
|
+
isolation_mode: "same-tree",
|
|
43
|
+
} as any,
|
|
44
|
+
});
|
|
45
|
+
assert.ok(result.errors.some((e) => e.includes("max_parallel")));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("reactive_execution validation rejects invalid isolation_mode", () => {
|
|
49
|
+
const result = validatePreferences({
|
|
50
|
+
reactive_execution: {
|
|
51
|
+
enabled: true,
|
|
52
|
+
max_parallel: 2,
|
|
53
|
+
isolation_mode: "separate-branch",
|
|
54
|
+
} as any,
|
|
55
|
+
});
|
|
56
|
+
assert.ok(result.errors.some((e) => e.includes("isolation_mode")));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reactive_execution validation warns on unknown keys", () => {
|
|
60
|
+
const result = validatePreferences({
|
|
61
|
+
reactive_execution: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
max_parallel: 2,
|
|
64
|
+
isolation_mode: "same-tree",
|
|
65
|
+
unknown_thing: true,
|
|
66
|
+
} as any,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(result.errors.length, 0);
|
|
69
|
+
assert.ok(result.warnings.some((w) => w.includes("unknown_thing")));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── Dispatch Rule Matching Logic ─────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test("reactive dispatch requires enabled config and multiple ready tasks", async () => {
|
|
75
|
+
// Build a minimal filesystem with a slice plan and task plans
|
|
76
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-dispatch-"));
|
|
77
|
+
try {
|
|
78
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
79
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
80
|
+
|
|
81
|
+
// Slice plan with 3 tasks
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(gsd, "S01-PLAN.md"),
|
|
84
|
+
[
|
|
85
|
+
"# S01: Test Slice",
|
|
86
|
+
"",
|
|
87
|
+
"**Goal:** Test reactive execution",
|
|
88
|
+
"**Demo:** All three tasks run in parallel",
|
|
89
|
+
"",
|
|
90
|
+
"## Tasks",
|
|
91
|
+
"",
|
|
92
|
+
"- [ ] **T01: First** `est:15m`",
|
|
93
|
+
" Create initial types",
|
|
94
|
+
"- [ ] **T02: Second** `est:15m`",
|
|
95
|
+
" Create models",
|
|
96
|
+
"- [ ] **T03: Third** `est:15m`",
|
|
97
|
+
" Create service layer",
|
|
98
|
+
"",
|
|
99
|
+
].join("\n"),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Task plans with non-overlapping IO (all independent)
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
105
|
+
[
|
|
106
|
+
"# T01: First",
|
|
107
|
+
"",
|
|
108
|
+
"## Description",
|
|
109
|
+
"Create types.",
|
|
110
|
+
"",
|
|
111
|
+
"## Inputs",
|
|
112
|
+
"",
|
|
113
|
+
"- `src/config.json` — Config schema",
|
|
114
|
+
"",
|
|
115
|
+
"## Expected Output",
|
|
116
|
+
"",
|
|
117
|
+
"- `src/types.ts` — Type definitions",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
writeFileSync(
|
|
122
|
+
join(gsd, "tasks", "T02-PLAN.md"),
|
|
123
|
+
[
|
|
124
|
+
"# T02: Second",
|
|
125
|
+
"",
|
|
126
|
+
"## Description",
|
|
127
|
+
"Create models.",
|
|
128
|
+
"",
|
|
129
|
+
"## Inputs",
|
|
130
|
+
"",
|
|
131
|
+
"- `src/schema.json` — Schema file",
|
|
132
|
+
"",
|
|
133
|
+
"## Expected Output",
|
|
134
|
+
"",
|
|
135
|
+
"- `src/models.ts` — Model definitions",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(gsd, "tasks", "T03-PLAN.md"),
|
|
141
|
+
[
|
|
142
|
+
"# T03: Third",
|
|
143
|
+
"",
|
|
144
|
+
"## Description",
|
|
145
|
+
"Create service.",
|
|
146
|
+
"",
|
|
147
|
+
"## Inputs",
|
|
148
|
+
"",
|
|
149
|
+
"- `src/api.json` — API spec",
|
|
150
|
+
"",
|
|
151
|
+
"## Expected Output",
|
|
152
|
+
"",
|
|
153
|
+
"- `src/service.ts` — Service layer",
|
|
154
|
+
].join("\n"),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Load IO and build graph
|
|
158
|
+
const basePath = repo;
|
|
159
|
+
const taskIO = await loadSliceTaskIO(basePath, "M001", "S01");
|
|
160
|
+
assert.equal(taskIO.length, 3);
|
|
161
|
+
|
|
162
|
+
const graph = deriveTaskGraph(taskIO);
|
|
163
|
+
assert.equal(isGraphAmbiguous(graph), false, "Graph should not be ambiguous");
|
|
164
|
+
|
|
165
|
+
// All independent → all should be ready
|
|
166
|
+
const ready = getReadyTasks(graph, new Set(), new Set());
|
|
167
|
+
assert.equal(ready.length, 3);
|
|
168
|
+
|
|
169
|
+
// Choose subset with max_parallel=2
|
|
170
|
+
const selected = chooseNonConflictingSubset(ready, graph, 2, new Set());
|
|
171
|
+
assert.equal(selected.length, 2);
|
|
172
|
+
assert.deepEqual(selected, ["T01", "T02"]);
|
|
173
|
+
} finally {
|
|
174
|
+
rmSync(repo, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("reactive dispatch falls back when graph is ambiguous (task without IO)", async () => {
|
|
179
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-ambiguous-"));
|
|
180
|
+
try {
|
|
181
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
182
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
183
|
+
|
|
184
|
+
writeFileSync(
|
|
185
|
+
join(gsd, "S01-PLAN.md"),
|
|
186
|
+
[
|
|
187
|
+
"# S01: Test",
|
|
188
|
+
"",
|
|
189
|
+
"**Goal:** Test",
|
|
190
|
+
"**Demo:** Test",
|
|
191
|
+
"",
|
|
192
|
+
"## Tasks",
|
|
193
|
+
"",
|
|
194
|
+
"- [ ] **T01: A** `est:15m`",
|
|
195
|
+
"- [ ] **T02: B** `est:15m`",
|
|
196
|
+
"",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// T01 has IO, T02 has NO IO sections → ambiguous
|
|
201
|
+
writeFileSync(
|
|
202
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
203
|
+
"# T01: A\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
|
204
|
+
);
|
|
205
|
+
writeFileSync(
|
|
206
|
+
join(gsd, "tasks", "T02-PLAN.md"),
|
|
207
|
+
"# T02: B\n\n## Description\n\nNo IO sections.\n",
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
|
211
|
+
const graph = deriveTaskGraph(taskIO);
|
|
212
|
+
assert.equal(isGraphAmbiguous(graph), true, "Graph should be ambiguous");
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(repo, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("single ready task falls through to sequential", async () => {
|
|
219
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-single-"));
|
|
220
|
+
try {
|
|
221
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
222
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
223
|
+
|
|
224
|
+
writeFileSync(
|
|
225
|
+
join(gsd, "S01-PLAN.md"),
|
|
226
|
+
[
|
|
227
|
+
"# S01: Linear",
|
|
228
|
+
"",
|
|
229
|
+
"**Goal:** Linear chain",
|
|
230
|
+
"**Demo:** Sequential",
|
|
231
|
+
"",
|
|
232
|
+
"## Tasks",
|
|
233
|
+
"",
|
|
234
|
+
"- [ ] **T01: First** `est:15m`",
|
|
235
|
+
"- [ ] **T02: Second** `est:15m`",
|
|
236
|
+
"",
|
|
237
|
+
].join("\n"),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
writeFileSync(
|
|
241
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
242
|
+
"# T01: First\n\n## Inputs\n\n- `src/config.json`\n\n## Expected Output\n\n- `src/a.ts`\n",
|
|
243
|
+
);
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(gsd, "tasks", "T02-PLAN.md"),
|
|
246
|
+
"# T02: Second\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
|
250
|
+
const graph = deriveTaskGraph(taskIO);
|
|
251
|
+
const ready = getReadyTasks(graph, new Set(), new Set());
|
|
252
|
+
// Only T01 is ready (T02 depends on T01)
|
|
253
|
+
assert.equal(ready.length, 1);
|
|
254
|
+
assert.deepEqual(ready, ["T01"]);
|
|
255
|
+
} finally {
|
|
256
|
+
rmSync(repo, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── State Persistence ────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
test("saveReactiveState and loadReactiveState round-trip", () => {
|
|
263
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-state-"));
|
|
264
|
+
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
|
265
|
+
try {
|
|
266
|
+
const state: ReactiveExecutionState = {
|
|
267
|
+
sliceId: "S01",
|
|
268
|
+
completed: ["T01", "T02"],
|
|
269
|
+
graphSnapshot: { taskCount: 4, edgeCount: 2, readySetSize: 1, ambiguous: false },
|
|
270
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
saveReactiveState(repo, "M001", "S01", state);
|
|
274
|
+
const loaded = loadReactiveState(repo, "M001", "S01");
|
|
275
|
+
assert.deepEqual(loaded, state);
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(repo, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("clearReactiveState removes the file", () => {
|
|
282
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-clear-"));
|
|
283
|
+
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
|
284
|
+
try {
|
|
285
|
+
const state: ReactiveExecutionState = {
|
|
286
|
+
sliceId: "S01",
|
|
287
|
+
completed: [],
|
|
288
|
+
graphSnapshot: { taskCount: 2, edgeCount: 0, readySetSize: 2, ambiguous: false },
|
|
289
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
saveReactiveState(repo, "M001", "S01", state);
|
|
293
|
+
assert.ok(existsSync(join(repo, ".gsd", "runtime", "M001-S01-reactive.json")));
|
|
294
|
+
|
|
295
|
+
clearReactiveState(repo, "M001", "S01");
|
|
296
|
+
assert.ok(!existsSync(join(repo, ".gsd", "runtime", "M001-S01-reactive.json")));
|
|
297
|
+
} finally {
|
|
298
|
+
rmSync(repo, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("loadReactiveState returns null when no file exists", () => {
|
|
303
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-nofile-"));
|
|
304
|
+
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
|
305
|
+
try {
|
|
306
|
+
const loaded = loadReactiveState(repo, "M001", "S01");
|
|
307
|
+
assert.equal(loaded, null);
|
|
308
|
+
} finally {
|
|
309
|
+
rmSync(repo, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("completed tasks are not re-dispatched on next iteration", async () => {
|
|
314
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-reentry-"));
|
|
315
|
+
try {
|
|
316
|
+
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
|
317
|
+
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
|
318
|
+
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
|
319
|
+
|
|
320
|
+
writeFileSync(
|
|
321
|
+
join(gsd, "S01-PLAN.md"),
|
|
322
|
+
[
|
|
323
|
+
"# S01: Reentry Test",
|
|
324
|
+
"",
|
|
325
|
+
"**Goal:** Test re-entry",
|
|
326
|
+
"**Demo:** Correct resumption",
|
|
327
|
+
"",
|
|
328
|
+
"## Tasks",
|
|
329
|
+
"",
|
|
330
|
+
"- [x] **T01: Done** `est:15m`",
|
|
331
|
+
"- [ ] **T02: Pending** `est:15m`",
|
|
332
|
+
"- [ ] **T03: Also Pending** `est:15m`",
|
|
333
|
+
"",
|
|
334
|
+
].join("\n"),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
writeFileSync(
|
|
338
|
+
join(gsd, "tasks", "T01-PLAN.md"),
|
|
339
|
+
"# T01: Done\n\n## Inputs\n\n- `src/config.json`\n\n## Expected Output\n\n- `src/a.ts`\n",
|
|
340
|
+
);
|
|
341
|
+
writeFileSync(
|
|
342
|
+
join(gsd, "tasks", "T02-PLAN.md"),
|
|
343
|
+
"# T02: Pending\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
|
344
|
+
);
|
|
345
|
+
writeFileSync(
|
|
346
|
+
join(gsd, "tasks", "T03-PLAN.md"),
|
|
347
|
+
"# T03: Also Pending\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/c.ts`\n",
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
|
351
|
+
const graph = deriveTaskGraph(taskIO);
|
|
352
|
+
|
|
353
|
+
// T01 is done, T02 and T03 depend on T01
|
|
354
|
+
const completed = new Set(["T01"]);
|
|
355
|
+
const ready = getReadyTasks(graph, completed, new Set());
|
|
356
|
+
// Both T02 and T03 should be ready (T01 is complete)
|
|
357
|
+
assert.deepEqual(ready, ["T02", "T03"]);
|
|
358
|
+
|
|
359
|
+
// Simulate T02 completes, re-derive
|
|
360
|
+
completed.add("T02");
|
|
361
|
+
const ready2 = getReadyTasks(graph, completed, new Set());
|
|
362
|
+
// Only T03 should be ready
|
|
363
|
+
assert.deepEqual(ready2, ["T03"]);
|
|
364
|
+
} finally {
|
|
365
|
+
rmSync(repo, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
deriveTaskGraph,
|
|
5
|
+
getReadyTasks,
|
|
6
|
+
chooseNonConflictingSubset,
|
|
7
|
+
isGraphAmbiguous,
|
|
8
|
+
detectDeadlock,
|
|
9
|
+
graphMetrics,
|
|
10
|
+
} from "../reactive-graph.ts";
|
|
11
|
+
import { parseTaskPlanIO } from "../files.ts";
|
|
12
|
+
import type { TaskIO, DerivedTaskNode } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
// ─── parseTaskPlanIO ──────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
test("parseTaskPlanIO extracts backtick-wrapped file paths from Inputs and Expected Output", () => {
|
|
17
|
+
const content = `---
|
|
18
|
+
estimated_steps: 3
|
|
19
|
+
estimated_files: 2
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# T01: Setup Models
|
|
23
|
+
|
|
24
|
+
**Slice:** S01 — Core Setup
|
|
25
|
+
**Milestone:** M001
|
|
26
|
+
|
|
27
|
+
## Description
|
|
28
|
+
|
|
29
|
+
Create the core data models.
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. Create types file
|
|
34
|
+
2. Create models file
|
|
35
|
+
|
|
36
|
+
## Must-Haves
|
|
37
|
+
|
|
38
|
+
- [ ] Type definitions complete
|
|
39
|
+
|
|
40
|
+
## Verification
|
|
41
|
+
|
|
42
|
+
- Run type checker
|
|
43
|
+
|
|
44
|
+
## Inputs
|
|
45
|
+
|
|
46
|
+
- \`src/types.ts\` — Existing type definitions from prior work
|
|
47
|
+
- \`src/config.json\` — Configuration schema
|
|
48
|
+
|
|
49
|
+
## Expected Output
|
|
50
|
+
|
|
51
|
+
- \`src/models.ts\` — New data model definitions
|
|
52
|
+
- \`src/models.test.ts\` — Unit tests for models
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const io = parseTaskPlanIO(content);
|
|
56
|
+
assert.deepEqual(io.inputFiles, ["src/types.ts", "src/config.json"]);
|
|
57
|
+
assert.deepEqual(io.outputFiles, ["src/models.ts", "src/models.test.ts"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parseTaskPlanIO returns empty arrays for missing sections", () => {
|
|
61
|
+
const content = `# T01: Something\n\n## Description\n\nNo IO sections here.\n`;
|
|
62
|
+
const io = parseTaskPlanIO(content);
|
|
63
|
+
assert.deepEqual(io.inputFiles, []);
|
|
64
|
+
assert.deepEqual(io.outputFiles, []);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parseTaskPlanIO ignores non-file-path backtick tokens", () => {
|
|
68
|
+
const content = `# T01: Test
|
|
69
|
+
|
|
70
|
+
## Inputs
|
|
71
|
+
|
|
72
|
+
- \`true\` — a boolean flag
|
|
73
|
+
- \`src/index.ts\` — main entry
|
|
74
|
+
- \`npm run test\` — a command, not a file
|
|
75
|
+
|
|
76
|
+
## Expected Output
|
|
77
|
+
|
|
78
|
+
- \`dist/bundle.js\` — compiled output
|
|
79
|
+
- \`false\` — not a file
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const io = parseTaskPlanIO(content);
|
|
83
|
+
assert.deepEqual(io.inputFiles, ["src/index.ts"]);
|
|
84
|
+
assert.deepEqual(io.outputFiles, ["dist/bundle.js"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parseTaskPlanIO handles multiple backtick tokens on one line", () => {
|
|
88
|
+
const content = `# T01: Multi
|
|
89
|
+
|
|
90
|
+
## Inputs
|
|
91
|
+
|
|
92
|
+
- \`src/a.ts\` and \`src/b.ts\` — both needed
|
|
93
|
+
|
|
94
|
+
## Expected Output
|
|
95
|
+
|
|
96
|
+
- \`src/c.ts\` — output
|
|
97
|
+
`;
|
|
98
|
+
const io = parseTaskPlanIO(content);
|
|
99
|
+
assert.deepEqual(io.inputFiles, ["src/a.ts", "src/b.ts"]);
|
|
100
|
+
assert.deepEqual(io.outputFiles, ["src/c.ts"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── deriveTaskGraph ──────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
test("deriveTaskGraph: linear chain T01→T02→T03", () => {
|
|
106
|
+
const tasks: TaskIO[] = [
|
|
107
|
+
{ id: "T01", title: "First", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
108
|
+
{ id: "T02", title: "Second", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
|
109
|
+
{ id: "T03", title: "Third", inputFiles: ["src/b.ts"], outputFiles: ["src/c.ts"], done: false },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const graph = deriveTaskGraph(tasks);
|
|
113
|
+
assert.deepEqual(graph[0].dependsOn, []);
|
|
114
|
+
assert.deepEqual(graph[1].dependsOn, ["T01"]);
|
|
115
|
+
assert.deepEqual(graph[2].dependsOn, ["T02"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("deriveTaskGraph: diamond dependency", () => {
|
|
119
|
+
const tasks: TaskIO[] = [
|
|
120
|
+
{ id: "T01", title: "Base", inputFiles: [], outputFiles: ["src/base.ts"], done: false },
|
|
121
|
+
{ id: "T02", title: "Left", inputFiles: ["src/base.ts"], outputFiles: ["src/left.ts"], done: false },
|
|
122
|
+
{ id: "T03", title: "Right", inputFiles: ["src/base.ts"], outputFiles: ["src/right.ts"], done: false },
|
|
123
|
+
{ id: "T04", title: "Merge", inputFiles: ["src/left.ts", "src/right.ts"], outputFiles: ["src/final.ts"], done: false },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const graph = deriveTaskGraph(tasks);
|
|
127
|
+
assert.deepEqual(graph[0].dependsOn, []);
|
|
128
|
+
assert.deepEqual(graph[1].dependsOn, ["T01"]);
|
|
129
|
+
assert.deepEqual(graph[2].dependsOn, ["T01"]);
|
|
130
|
+
assert.deepEqual(graph[3].dependsOn, ["T02", "T03"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("deriveTaskGraph: fully independent tasks", () => {
|
|
134
|
+
const tasks: TaskIO[] = [
|
|
135
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
136
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
|
137
|
+
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const graph = deriveTaskGraph(tasks);
|
|
141
|
+
assert.deepEqual(graph[0].dependsOn, []);
|
|
142
|
+
assert.deepEqual(graph[1].dependsOn, []);
|
|
143
|
+
assert.deepEqual(graph[2].dependsOn, []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("deriveTaskGraph: self-referencing output→input is excluded", () => {
|
|
147
|
+
const tasks: TaskIO[] = [
|
|
148
|
+
{ id: "T01", title: "Self", inputFiles: ["src/a.ts"], outputFiles: ["src/a.ts"], done: false },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const graph = deriveTaskGraph(tasks);
|
|
152
|
+
assert.deepEqual(graph[0].dependsOn, []);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ─── getReadyTasks ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
test("getReadyTasks: partially completed graph", () => {
|
|
158
|
+
const tasks: TaskIO[] = [
|
|
159
|
+
{ id: "T01", title: "Base", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
|
160
|
+
{ id: "T02", title: "Dep", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
|
161
|
+
{ id: "T03", title: "Blocked", inputFiles: ["src/b.ts"], outputFiles: ["src/c.ts"], done: false },
|
|
162
|
+
];
|
|
163
|
+
const graph = deriveTaskGraph(tasks);
|
|
164
|
+
const ready = getReadyTasks(graph, new Set(["T01"]), new Set());
|
|
165
|
+
assert.deepEqual(ready, ["T02"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("getReadyTasks: nothing complete → only root tasks ready", () => {
|
|
169
|
+
const tasks: TaskIO[] = [
|
|
170
|
+
{ id: "T01", title: "Root", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
171
|
+
{ id: "T02", title: "Dep", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
|
172
|
+
];
|
|
173
|
+
const graph = deriveTaskGraph(tasks);
|
|
174
|
+
const ready = getReadyTasks(graph, new Set(), new Set());
|
|
175
|
+
assert.deepEqual(ready, ["T01"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("getReadyTasks: all complete → empty", () => {
|
|
179
|
+
const tasks: TaskIO[] = [
|
|
180
|
+
{ id: "T01", title: "Done", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
|
181
|
+
];
|
|
182
|
+
const graph = deriveTaskGraph(tasks);
|
|
183
|
+
const ready = getReadyTasks(graph, new Set(["T01"]), new Set());
|
|
184
|
+
assert.deepEqual(ready, []);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("getReadyTasks: in-flight tasks excluded", () => {
|
|
188
|
+
const tasks: TaskIO[] = [
|
|
189
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
190
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
|
191
|
+
];
|
|
192
|
+
const graph = deriveTaskGraph(tasks);
|
|
193
|
+
const ready = getReadyTasks(graph, new Set(), new Set(["T01"]));
|
|
194
|
+
assert.deepEqual(ready, ["T02"]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ─── chooseNonConflictingSubset ───────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
test("chooseNonConflictingSubset: output conflicts", () => {
|
|
200
|
+
const tasks: TaskIO[] = [
|
|
201
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/shared.ts"], done: false },
|
|
202
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/shared.ts"], done: false },
|
|
203
|
+
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/other.ts"], done: false },
|
|
204
|
+
];
|
|
205
|
+
const graph = deriveTaskGraph(tasks);
|
|
206
|
+
const selected = chooseNonConflictingSubset(["T01", "T02", "T03"], graph, 3, new Set());
|
|
207
|
+
// T01 claims shared.ts, T02 conflicts, T03 is fine
|
|
208
|
+
assert.deepEqual(selected, ["T01", "T03"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("chooseNonConflictingSubset: respects maxParallel", () => {
|
|
212
|
+
const tasks: TaskIO[] = [
|
|
213
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
214
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
|
215
|
+
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
|
216
|
+
];
|
|
217
|
+
const graph = deriveTaskGraph(tasks);
|
|
218
|
+
const selected = chooseNonConflictingSubset(["T01", "T02", "T03"], graph, 2, new Set());
|
|
219
|
+
assert.deepEqual(selected, ["T01", "T02"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("chooseNonConflictingSubset: respects inFlightOutputs", () => {
|
|
223
|
+
const tasks: TaskIO[] = [
|
|
224
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
|
225
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
|
226
|
+
];
|
|
227
|
+
const graph = deriveTaskGraph(tasks);
|
|
228
|
+
const selected = chooseNonConflictingSubset(["T01", "T02"], graph, 4, new Set(["src/a.ts"]));
|
|
229
|
+
assert.deepEqual(selected, ["T02"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ─── isGraphAmbiguous ─────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
test("isGraphAmbiguous: task with no IO → ambiguous", () => {
|
|
235
|
+
const graph: DerivedTaskNode[] = [
|
|
236
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: [], done: false, dependsOn: [] },
|
|
237
|
+
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: [] },
|
|
238
|
+
];
|
|
239
|
+
assert.equal(isGraphAmbiguous(graph), true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("isGraphAmbiguous: all tasks have IO → not ambiguous", () => {
|
|
243
|
+
const graph: DerivedTaskNode[] = [
|
|
244
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false, dependsOn: [] },
|
|
245
|
+
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
|
246
|
+
];
|
|
247
|
+
assert.equal(isGraphAmbiguous(graph), false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("isGraphAmbiguous: done tasks with no IO are ignored", () => {
|
|
251
|
+
const graph: DerivedTaskNode[] = [
|
|
252
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: [], done: true, dependsOn: [] },
|
|
253
|
+
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false, dependsOn: [] },
|
|
254
|
+
];
|
|
255
|
+
assert.equal(isGraphAmbiguous(graph), false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ─── detectDeadlock ───────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
test("detectDeadlock: circular dependency detected", () => {
|
|
261
|
+
// T01 depends on T02, T02 depends on T01 — deadlock
|
|
262
|
+
const graph: DerivedTaskNode[] = [
|
|
263
|
+
{ id: "T01", title: "A", inputFiles: ["src/b.ts"], outputFiles: ["src/a.ts"], done: false, dependsOn: ["T02"] },
|
|
264
|
+
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
|
265
|
+
];
|
|
266
|
+
assert.equal(detectDeadlock(graph, new Set(), new Set()), true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("detectDeadlock: normal blocked-waiting-for-in-flight → not deadlock", () => {
|
|
270
|
+
const graph: DerivedTaskNode[] = [
|
|
271
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false, dependsOn: [] },
|
|
272
|
+
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
|
273
|
+
];
|
|
274
|
+
// T01 is in-flight, T02 is waiting → not deadlock
|
|
275
|
+
assert.equal(detectDeadlock(graph, new Set(), new Set(["T01"])), false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("detectDeadlock: all complete → not deadlock", () => {
|
|
279
|
+
const graph: DerivedTaskNode[] = [
|
|
280
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: true, dependsOn: [] },
|
|
281
|
+
];
|
|
282
|
+
assert.equal(detectDeadlock(graph, new Set(["T01"]), new Set()), false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ─── graphMetrics ─────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
test("graphMetrics computes correct values", () => {
|
|
288
|
+
const tasks: TaskIO[] = [
|
|
289
|
+
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
|
290
|
+
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
|
291
|
+
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
|
292
|
+
];
|
|
293
|
+
const graph = deriveTaskGraph(tasks);
|
|
294
|
+
const metrics = graphMetrics(graph);
|
|
295
|
+
assert.equal(metrics.taskCount, 3);
|
|
296
|
+
assert.equal(metrics.edgeCount, 1); // T02 depends on T01
|
|
297
|
+
assert.equal(metrics.readySetSize, 2); // T02 (T01 done) and T03 (no deps)
|
|
298
|
+
assert.equal(metrics.ambiguous, false);
|
|
299
|
+
});
|