gsd-pi 2.37.1-dev.193bd3d → 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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/onboarding.js +1 -0
  3. package/dist/resources/extensions/gsd/auto-dispatch.js +54 -1
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +14 -0
  5. package/dist/resources/extensions/gsd/auto-prompts.js +55 -0
  6. package/dist/resources/extensions/gsd/auto-recovery.js +19 -1
  7. package/dist/resources/extensions/gsd/files.js +41 -0
  8. package/dist/resources/extensions/gsd/preferences-types.js +2 -1
  9. package/dist/resources/extensions/gsd/preferences-validation.js +42 -0
  10. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  11. package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
  12. package/package.json +2 -1
  13. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  14. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  15. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  16. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  17. package/packages/pi-ai/dist/models.generated.js +172 -0
  18. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  19. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  20. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  21. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  22. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  23. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  24. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  25. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  26. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  27. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  28. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  29. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  30. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  31. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  32. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  33. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  34. package/packages/pi-ai/dist/types.d.ts +2 -2
  35. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  36. package/packages/pi-ai/dist/types.js.map +1 -1
  37. package/packages/pi-ai/package.json +1 -0
  38. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  39. package/packages/pi-ai/src/models.generated.ts +172 -0
  40. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  41. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  42. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  43. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  44. package/packages/pi-ai/src/types.ts +2 -0
  45. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  46. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  47. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  48. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  49. package/src/resources/extensions/gsd/auto-dispatch.ts +78 -0
  50. package/src/resources/extensions/gsd/auto-post-unit.ts +14 -0
  51. package/src/resources/extensions/gsd/auto-prompts.ts +68 -0
  52. package/src/resources/extensions/gsd/auto-recovery.ts +18 -0
  53. package/src/resources/extensions/gsd/files.ts +45 -0
  54. package/src/resources/extensions/gsd/preferences-types.ts +5 -1
  55. package/src/resources/extensions/gsd/preferences-validation.ts +41 -0
  56. package/src/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  57. package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
  58. package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +367 -0
  59. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
  60. package/src/resources/extensions/gsd/types.ts +41 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Reactive Task Graph — derives dependency edges from task plan IO signatures.
3
+ *
4
+ * Pure functions that build a DAG from task IO intersections and resolve
5
+ * which tasks are currently ready for parallel dispatch. Used by the
6
+ * reactive-execute dispatch path (ADR-004).
7
+ *
8
+ * Graph derivation and resolution functions are pure (no filesystem access).
9
+ * The `loadSliceTaskIO` loader at the bottom is the only async/IO function.
10
+ */
11
+
12
+ import type { TaskIO, DerivedTaskNode, ReactiveExecutionState } from "./types.js";
13
+ import { loadFile, parsePlan, parseTaskPlanIO } from "./files.js";
14
+ import { resolveTasksDir, resolveTaskFiles } from "./paths.js";
15
+ import { join } from "node:path";
16
+ import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
17
+ import { existsSync, unlinkSync } from "node:fs";
18
+
19
+ // ─── Graph Construction ───────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Build a dependency graph from task IO signatures.
23
+ *
24
+ * A task T_b depends on T_a when any of T_b's inputFiles appear in T_a's
25
+ * outputFiles. Self-references are excluded.
26
+ *
27
+ * Tasks are returned in the same order as the input array.
28
+ */
29
+ export function deriveTaskGraph(tasks: TaskIO[]): DerivedTaskNode[] {
30
+ // Build output → producer lookup
31
+ const outputToProducer = new Map<string, string[]>();
32
+ for (const task of tasks) {
33
+ for (const outFile of task.outputFiles) {
34
+ const existing = outputToProducer.get(outFile);
35
+ if (existing) {
36
+ existing.push(task.id);
37
+ } else {
38
+ outputToProducer.set(outFile, [task.id]);
39
+ }
40
+ }
41
+ }
42
+
43
+ return tasks.map((task) => {
44
+ const deps = new Set<string>();
45
+ for (const inFile of task.inputFiles) {
46
+ const producers = outputToProducer.get(inFile);
47
+ if (producers) {
48
+ for (const pid of producers) {
49
+ if (pid !== task.id) deps.add(pid);
50
+ }
51
+ }
52
+ }
53
+ return {
54
+ ...task,
55
+ dependsOn: [...deps].sort(),
56
+ };
57
+ });
58
+ }
59
+
60
+ // ─── Ready Set Resolution ─────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Return task IDs whose dependencies are all in `completed`.
64
+ * Excludes tasks that are already done or in-flight.
65
+ */
66
+ export function getReadyTasks(
67
+ graph: DerivedTaskNode[],
68
+ completed: Set<string>,
69
+ inFlight: Set<string>,
70
+ ): string[] {
71
+ return graph
72
+ .filter((node) => {
73
+ if (node.done || completed.has(node.id) || inFlight.has(node.id)) return false;
74
+ return node.dependsOn.every((dep) => completed.has(dep));
75
+ })
76
+ .map((node) => node.id);
77
+ }
78
+
79
+ // ─── Conflict-Free Subset Selection ──────────────────────────────────────
80
+
81
+ /**
82
+ * Greedy selection of non-conflicting tasks up to `maxParallel`.
83
+ *
84
+ * Two tasks conflict if they share any outputFile. We also exclude tasks
85
+ * whose outputs overlap with `inFlightOutputs` (files being written by
86
+ * tasks currently in progress).
87
+ */
88
+ export function chooseNonConflictingSubset(
89
+ readyIds: string[],
90
+ graph: DerivedTaskNode[],
91
+ maxParallel: number,
92
+ inFlightOutputs: Set<string>,
93
+ ): string[] {
94
+ const nodeMap = new Map(graph.map((n) => [n.id, n]));
95
+ const claimed = new Set(inFlightOutputs);
96
+ const selected: string[] = [];
97
+
98
+ for (const id of readyIds) {
99
+ if (selected.length >= maxParallel) break;
100
+ const node = nodeMap.get(id);
101
+ if (!node) continue;
102
+
103
+ // Check for output overlap with already-selected or in-flight
104
+ const conflicts = node.outputFiles.some((f) => claimed.has(f));
105
+ if (conflicts) continue;
106
+
107
+ // Claim this task's outputs
108
+ for (const f of node.outputFiles) claimed.add(f);
109
+ selected.push(id);
110
+ }
111
+
112
+ return selected;
113
+ }
114
+
115
+ // ─── Graph Quality Checks ─────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Returns true if any incomplete task has 0 inputFiles AND 0 outputFiles.
119
+ *
120
+ * An ambiguous graph means IO annotations are too sparse to derive reliable
121
+ * edges — the dispatcher should fall back to sequential execution.
122
+ */
123
+ export function isGraphAmbiguous(graph: DerivedTaskNode[]): boolean {
124
+ return graph.some(
125
+ (node) =>
126
+ !node.done &&
127
+ node.inputFiles.length === 0 &&
128
+ node.outputFiles.length === 0,
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Detect deadlock: no tasks are ready and none are in-flight, yet incomplete
134
+ * tasks remain. This indicates a circular dependency or impossible state.
135
+ */
136
+ export function detectDeadlock(
137
+ graph: DerivedTaskNode[],
138
+ completed: Set<string>,
139
+ inFlight: Set<string>,
140
+ ): boolean {
141
+ const incomplete = graph.filter(
142
+ (n) => !n.done && !completed.has(n.id) && !inFlight.has(n.id),
143
+ );
144
+ if (incomplete.length === 0) return false; // all done
145
+ if (inFlight.size > 0) return false; // something is running, wait for it
146
+
147
+ // Nothing in flight, but incomplete tasks remain — check if any are ready
148
+ const ready = getReadyTasks(graph, completed, inFlight);
149
+ return ready.length === 0;
150
+ }
151
+
152
+ // ─── Graph Metrics ────────────────────────────────────────────────────────
153
+
154
+ /** Compute summary metrics for logging. */
155
+ export function graphMetrics(graph: DerivedTaskNode[]): {
156
+ taskCount: number;
157
+ edgeCount: number;
158
+ readySetSize: number;
159
+ ambiguous: boolean;
160
+ } {
161
+ const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
162
+ const ready = getReadyTasks(graph, completed, new Set());
163
+ const edgeCount = graph.reduce((sum, n) => sum + n.dependsOn.length, 0);
164
+
165
+ return {
166
+ taskCount: graph.length,
167
+ edgeCount,
168
+ readySetSize: ready.length,
169
+ ambiguous: isGraphAmbiguous(graph),
170
+ };
171
+ }
172
+
173
+ // ─── IO Loader (async, filesystem) ────────────────────────────────────────
174
+
175
+ /**
176
+ * Load TaskIO for all tasks in a slice by reading the slice plan (for done
177
+ * status and task IDs) and individual task plan files (for IO sections).
178
+ *
179
+ * Returns [] when the slice plan or tasks directory doesn't exist.
180
+ */
181
+ export async function loadSliceTaskIO(
182
+ basePath: string,
183
+ mid: string,
184
+ sid: string,
185
+ ): Promise<TaskIO[]> {
186
+ const { resolveSliceFile } = await import("./paths.js");
187
+ const slicePlanPath = resolveSliceFile(basePath, mid, sid, "PLAN");
188
+ const planContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
189
+ if (!planContent) return [];
190
+
191
+ const plan = parsePlan(planContent);
192
+ const tDir = resolveTasksDir(basePath, mid, sid);
193
+ if (!tDir) return [];
194
+
195
+ const results: TaskIO[] = [];
196
+
197
+ for (const taskEntry of plan.tasks) {
198
+ const planFiles = resolveTaskFiles(tDir, "PLAN");
199
+ const taskFileName = planFiles.find((f) =>
200
+ f.toUpperCase().startsWith(taskEntry.id.toUpperCase() + "-"),
201
+ );
202
+ if (!taskFileName) {
203
+ // Task plan file missing — include with empty IO (will trigger ambiguous)
204
+ results.push({
205
+ id: taskEntry.id,
206
+ title: taskEntry.title,
207
+ inputFiles: [],
208
+ outputFiles: [],
209
+ done: taskEntry.done,
210
+ });
211
+ continue;
212
+ }
213
+
214
+ const taskContent = await loadFile(join(tDir, taskFileName));
215
+ if (!taskContent) {
216
+ results.push({
217
+ id: taskEntry.id,
218
+ title: taskEntry.title,
219
+ inputFiles: [],
220
+ outputFiles: [],
221
+ done: taskEntry.done,
222
+ });
223
+ continue;
224
+ }
225
+
226
+ const io = parseTaskPlanIO(taskContent);
227
+ results.push({
228
+ id: taskEntry.id,
229
+ title: taskEntry.title,
230
+ inputFiles: io.inputFiles,
231
+ outputFiles: io.outputFiles,
232
+ done: taskEntry.done,
233
+ });
234
+ }
235
+
236
+ return results;
237
+ }
238
+
239
+ // ─── State Persistence ────────────────────────────────────────────────────
240
+
241
+ function reactiveStatePath(basePath: string, mid: string, sid: string): string {
242
+ return join(basePath, ".gsd", "runtime", `${mid}-${sid}-reactive.json`);
243
+ }
244
+
245
+ function isReactiveState(data: unknown): data is ReactiveExecutionState {
246
+ if (!data || typeof data !== "object") return false;
247
+ const d = data as Record<string, unknown>;
248
+ return typeof d.sliceId === "string" && Array.isArray(d.completed);
249
+ }
250
+
251
+ /**
252
+ * Load persisted reactive execution state for a slice.
253
+ * Returns null when no state file exists or the file is invalid.
254
+ */
255
+ export function loadReactiveState(
256
+ basePath: string,
257
+ mid: string,
258
+ sid: string,
259
+ ): ReactiveExecutionState | null {
260
+ return loadJsonFileOrNull(reactiveStatePath(basePath, mid, sid), isReactiveState);
261
+ }
262
+
263
+ /**
264
+ * Save reactive execution state to disk.
265
+ */
266
+ export function saveReactiveState(
267
+ basePath: string,
268
+ mid: string,
269
+ sid: string,
270
+ state: ReactiveExecutionState,
271
+ ): void {
272
+ saveJsonFile(reactiveStatePath(basePath, mid, sid), state);
273
+ }
274
+
275
+ /**
276
+ * Remove the reactive state file when a slice completes.
277
+ */
278
+ export function clearReactiveState(
279
+ basePath: string,
280
+ mid: string,
281
+ sid: string,
282
+ ): void {
283
+ const path = reactiveStatePath(basePath, mid, sid);
284
+ try {
285
+ if (existsSync(path)) unlinkSync(path);
286
+ } catch {
287
+ // Non-fatal
288
+ }
289
+ }
@@ -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
+ });