gsd-pi 2.37.1-dev.857ac92 → 2.37.1-dev.d3ace49

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.
@@ -262,10 +262,23 @@ const DISPATCH_RULES = [
262
262
  const metrics = graphMetrics(graph);
263
263
  process.stderr.write(`gsd-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` +
264
264
  `ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`);
265
+ // Persist dispatched batch so verification and recovery can check
266
+ // exactly which tasks were sent.
267
+ const { saveReactiveState } = await import("./reactive-graph.js");
268
+ saveReactiveState(basePath, mid, sid, {
269
+ sliceId: sid,
270
+ completed: [...completed],
271
+ dispatched: selected,
272
+ graphSnapshot: metrics,
273
+ updatedAt: new Date().toISOString(),
274
+ });
275
+ // Encode selected task IDs in unitId for artifact verification.
276
+ // Format: M001/S01/reactive+T02,T03
277
+ const batchSuffix = selected.join(",");
265
278
  return {
266
279
  action: "dispatch",
267
280
  unitType: "reactive-execute",
268
- unitId: `${mid}/${sid}/reactive`,
281
+ unitId: `${mid}/${sid}/reactive+${batchSuffix}`,
269
282
  prompt: await buildReactiveExecutePrompt(mid, midTitle, sid, sTitle, selected, basePath),
270
283
  };
271
284
  }
@@ -414,6 +414,35 @@ export async function getPriorTaskSummaryPaths(mid, sid, currentTid, base) {
414
414
  })
415
415
  .map(f => `${sRel}/tasks/${f}`);
416
416
  }
417
+ /**
418
+ * Get carry-forward summary paths scoped to a task's derived dependencies.
419
+ *
420
+ * Instead of all prior tasks (order-based), returns only summaries for task
421
+ * IDs in `dependsOn`. Used by reactive-execute to give each subagent only
422
+ * the context it actually needs — not sibling tasks from a parallel batch.
423
+ *
424
+ * Falls back to order-based when dependsOn is empty (root tasks still get
425
+ * any available prior summaries for continuity).
426
+ */
427
+ export async function getDependencyTaskSummaryPaths(mid, sid, currentTid, dependsOn, base) {
428
+ // If no dependencies, fall back to order-based for root tasks
429
+ if (dependsOn.length === 0) {
430
+ return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
431
+ }
432
+ const tDir = resolveTasksDir(base, mid, sid);
433
+ if (!tDir)
434
+ return [];
435
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
436
+ const sRel = relSlicePath(base, mid, sid);
437
+ const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
438
+ return summaryFiles
439
+ .filter((f) => {
440
+ // Extract task ID from filename: "T02-SUMMARY.md" → "T02"
441
+ const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
442
+ return depSet.has(tid);
443
+ })
444
+ .map((f) => `${sRel}/tasks/${f}`);
445
+ }
417
446
  // ─── Adaptive Replanning Checks ────────────────────────────────────────────
418
447
  /**
419
448
  * Check if the most recently completed slice needs reassessment.
@@ -688,8 +717,11 @@ export async function buildPlanSlicePrompt(mid, _midTitle, sid, sTitle, base, le
688
717
  });
689
718
  }
690
719
  export async function buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base, level) {
691
- const inlineLevel = level ?? resolveInlineLevel();
692
- const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
720
+ const opts = typeof level === "object" && level !== null && !Array.isArray(level)
721
+ ? level
722
+ : { level: level };
723
+ const inlineLevel = opts.level ?? resolveInlineLevel();
724
+ const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base);
693
725
  const priorLines = priorSummaries.length > 0
694
726
  ? priorSummaries.map(p => `- \`${p}\``).join("\n")
695
727
  : "- (no prior tasks)";
@@ -1119,8 +1151,10 @@ export async function buildReactiveExecutePrompt(mid, midTitle, sid, sTitle, rea
1119
1151
  const node = graph.find((n) => n.id === tid);
1120
1152
  const tTitle = node?.title ?? tid;
1121
1153
  readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
1122
- // Build a full execute-task prompt for this task (reuse existing builder)
1123
- const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
1154
+ // Build dependency-scoped carry-forward paths for this task
1155
+ const depPaths = await getDependencyTaskSummaryPaths(mid, sid, tid, node?.dependsOn ?? [], base);
1156
+ // Build a full execute-task prompt with dependency-based carry-forward
1157
+ const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base, { carryForwardPaths: depPaths });
1124
1158
  subagentSections.push([
1125
1159
  `### ${tid}: ${tTitle}`,
1126
1160
  "",
@@ -108,20 +108,38 @@ export function verifyExpectedArtifact(unitType, unitId, base) {
108
108
  const content = readFileSync(overridesPath, "utf-8");
109
109
  return !content.includes("**Scope:** active");
110
110
  }
111
- // Reactive-execute: verify that at least one new task summary was written.
112
- // The unitId is "{mid}/{sid}/reactive" — extract mid and sid to check.
111
+ // Reactive-execute: verify that each dispatched task's summary exists.
112
+ // The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03"
113
113
  if (unitType === "reactive-execute") {
114
114
  const parts = unitId.split("/");
115
115
  const mid = parts[0];
116
- const sid = parts[1];
117
- if (!mid || !sid)
116
+ const sidAndBatch = parts[1];
117
+ const batchPart = parts[2]; // "reactive+T02,T03"
118
+ if (!mid || !sidAndBatch || !batchPart)
119
+ return false;
120
+ const sid = sidAndBatch;
121
+ const plusIdx = batchPart.indexOf("+");
122
+ if (plusIdx === -1) {
123
+ // Legacy format "reactive" without batch IDs — fall back to "any summary"
124
+ const tDir = resolveTasksDir(base, mid, sid);
125
+ if (!tDir)
126
+ return false;
127
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
128
+ return summaryFiles.length > 0;
129
+ }
130
+ const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
131
+ if (batchIds.length === 0)
118
132
  return false;
119
133
  const tDir = resolveTasksDir(base, mid, sid);
120
134
  if (!tDir)
121
135
  return false;
122
- const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
123
- // At least one summary file should exist
124
- return summaryFiles.length > 0;
136
+ const existingSummaries = new Set(resolveTaskFiles(tDir, "SUMMARY").map((f) => f.replace(/-SUMMARY\.md$/i, "").toUpperCase()));
137
+ // Every dispatched task must have a summary file
138
+ for (const tid of batchIds) {
139
+ if (!existingSummaries.has(tid.toUpperCase()))
140
+ return false;
141
+ }
142
+ return true;
125
143
  }
126
144
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
127
145
  // For unit types with no verifiable artifact (null path), the parent directory
@@ -197,7 +197,7 @@ function isReactiveState(data) {
197
197
  if (!data || typeof data !== "object")
198
198
  return false;
199
199
  const d = data;
200
- return typeof d.sliceId === "string" && Array.isArray(d.completed);
200
+ return typeof d.sliceId === "string" && Array.isArray(d.completed) && Array.isArray(d.dispatched);
201
201
  }
202
202
  /**
203
203
  * Load persisted reactive execution state for a slice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.37.1-dev.857ac92",
3
+ "version": "2.37.1-dev.d3ace49",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -367,10 +367,25 @@ const DISPATCH_RULES: DispatchRule[] = [
367
367
  `ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`,
368
368
  );
369
369
 
370
+ // Persist dispatched batch so verification and recovery can check
371
+ // exactly which tasks were sent.
372
+ const { saveReactiveState } = await import("./reactive-graph.js");
373
+ saveReactiveState(basePath, mid, sid, {
374
+ sliceId: sid,
375
+ completed: [...completed],
376
+ dispatched: selected,
377
+ graphSnapshot: metrics,
378
+ updatedAt: new Date().toISOString(),
379
+ });
380
+
381
+ // Encode selected task IDs in unitId for artifact verification.
382
+ // Format: M001/S01/reactive+T02,T03
383
+ const batchSuffix = selected.join(",");
384
+
370
385
  return {
371
386
  action: "dispatch",
372
387
  unitType: "reactive-execute",
373
- unitId: `${mid}/${sid}/reactive`,
388
+ unitId: `${mid}/${sid}/reactive+${batchSuffix}`,
374
389
  prompt: await buildReactiveExecutePrompt(
375
390
  mid,
376
391
  midTitle,
@@ -485,6 +485,41 @@ export async function getPriorTaskSummaryPaths(
485
485
  .map(f => `${sRel}/tasks/${f}`);
486
486
  }
487
487
 
488
+ /**
489
+ * Get carry-forward summary paths scoped to a task's derived dependencies.
490
+ *
491
+ * Instead of all prior tasks (order-based), returns only summaries for task
492
+ * IDs in `dependsOn`. Used by reactive-execute to give each subagent only
493
+ * the context it actually needs — not sibling tasks from a parallel batch.
494
+ *
495
+ * Falls back to order-based when dependsOn is empty (root tasks still get
496
+ * any available prior summaries for continuity).
497
+ */
498
+ export async function getDependencyTaskSummaryPaths(
499
+ mid: string, sid: string, currentTid: string,
500
+ dependsOn: string[], base: string,
501
+ ): Promise<string[]> {
502
+ // If no dependencies, fall back to order-based for root tasks
503
+ if (dependsOn.length === 0) {
504
+ return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
505
+ }
506
+
507
+ const tDir = resolveTasksDir(base, mid, sid);
508
+ if (!tDir) return [];
509
+
510
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
511
+ const sRel = relSlicePath(base, mid, sid);
512
+ const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
513
+
514
+ return summaryFiles
515
+ .filter((f) => {
516
+ // Extract task ID from filename: "T02-SUMMARY.md" → "T02"
517
+ const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
518
+ return depSet.has(tid);
519
+ })
520
+ .map((f) => `${sRel}/tasks/${f}`);
521
+ }
522
+
488
523
  // ─── Adaptive Replanning Checks ────────────────────────────────────────────
489
524
 
490
525
  /**
@@ -772,13 +807,24 @@ export async function buildPlanSlicePrompt(
772
807
  });
773
808
  }
774
809
 
810
+ /** Options for customizing execute-task prompt construction. */
811
+ export interface ExecuteTaskPromptOptions {
812
+ level?: InlineLevel;
813
+ /** Override carry-forward paths (dependency-based instead of order-based). */
814
+ carryForwardPaths?: string[];
815
+ }
816
+
775
817
  export async function buildExecuteTaskPrompt(
776
818
  mid: string, sid: string, sTitle: string,
777
- tid: string, tTitle: string, base: string, level?: InlineLevel,
819
+ tid: string, tTitle: string, base: string,
820
+ level?: InlineLevel | ExecuteTaskPromptOptions,
778
821
  ): Promise<string> {
779
- const inlineLevel = level ?? resolveInlineLevel();
822
+ const opts: ExecuteTaskPromptOptions = typeof level === "object" && level !== null && !Array.isArray(level)
823
+ ? level
824
+ : { level: level as InlineLevel | undefined };
825
+ const inlineLevel = opts.level ?? resolveInlineLevel();
780
826
 
781
- const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
827
+ const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base);
782
828
  const priorLines = priorSummaries.length > 0
783
829
  ? priorSummaries.map(p => `- \`${p}\``).join("\n")
784
830
  : "- (no prior tasks)";
@@ -1272,8 +1318,16 @@ export async function buildReactiveExecutePrompt(
1272
1318
  const tTitle = node?.title ?? tid;
1273
1319
  readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
1274
1320
 
1275
- // Build a full execute-task prompt for this task (reuse existing builder)
1276
- const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
1321
+ // Build dependency-scoped carry-forward paths for this task
1322
+ const depPaths = await getDependencyTaskSummaryPaths(
1323
+ mid, sid, tid, node?.dependsOn ?? [], base,
1324
+ );
1325
+
1326
+ // Build a full execute-task prompt with dependency-based carry-forward
1327
+ const taskPrompt = await buildExecuteTaskPrompt(
1328
+ mid, sid, sTitle, tid, tTitle, base,
1329
+ { carryForwardPaths: depPaths },
1330
+ );
1277
1331
 
1278
1332
  subagentSections.push([
1279
1333
  `### ${tid}: ${tTitle}`,
@@ -152,18 +152,42 @@ export function verifyExpectedArtifact(
152
152
  return !content.includes("**Scope:** active");
153
153
  }
154
154
 
155
- // Reactive-execute: verify that at least one new task summary was written.
156
- // The unitId is "{mid}/{sid}/reactive" — extract mid and sid to check.
155
+ // Reactive-execute: verify that each dispatched task's summary exists.
156
+ // The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03"
157
157
  if (unitType === "reactive-execute") {
158
158
  const parts = unitId.split("/");
159
159
  const mid = parts[0];
160
- const sid = parts[1];
161
- if (!mid || !sid) return false;
160
+ const sidAndBatch = parts[1];
161
+ const batchPart = parts[2]; // "reactive+T02,T03"
162
+ if (!mid || !sidAndBatch || !batchPart) return false;
163
+
164
+ const sid = sidAndBatch;
165
+ const plusIdx = batchPart.indexOf("+");
166
+ if (plusIdx === -1) {
167
+ // Legacy format "reactive" without batch IDs — fall back to "any summary"
168
+ const tDir = resolveTasksDir(base, mid, sid);
169
+ if (!tDir) return false;
170
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
171
+ return summaryFiles.length > 0;
172
+ }
173
+
174
+ const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
175
+ if (batchIds.length === 0) return false;
176
+
162
177
  const tDir = resolveTasksDir(base, mid, sid);
163
178
  if (!tDir) return false;
164
- const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
165
- // At least one summary file should exist
166
- return summaryFiles.length > 0;
179
+
180
+ const existingSummaries = new Set(
181
+ resolveTaskFiles(tDir, "SUMMARY").map((f) =>
182
+ f.replace(/-SUMMARY\.md$/i, "").toUpperCase(),
183
+ ),
184
+ );
185
+
186
+ // Every dispatched task must have a summary file
187
+ for (const tid of batchIds) {
188
+ if (!existingSummaries.has(tid.toUpperCase())) return false;
189
+ }
190
+ return true;
167
191
  }
168
192
 
169
193
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
@@ -245,7 +245,7 @@ function reactiveStatePath(basePath: string, mid: string, sid: string): string {
245
245
  function isReactiveState(data: unknown): data is ReactiveExecutionState {
246
246
  if (!data || typeof data !== "object") return false;
247
247
  const d = data as Record<string, unknown>;
248
- return typeof d.sliceId === "string" && Array.isArray(d.completed);
248
+ return typeof d.sliceId === "string" && Array.isArray(d.completed) && Array.isArray(d.dispatched);
249
249
  }
250
250
 
251
251
  /**
@@ -266,6 +266,7 @@ test("saveReactiveState and loadReactiveState round-trip", () => {
266
266
  const state: ReactiveExecutionState = {
267
267
  sliceId: "S01",
268
268
  completed: ["T01", "T02"],
269
+ dispatched: ["T03"],
269
270
  graphSnapshot: { taskCount: 4, edgeCount: 2, readySetSize: 1, ambiguous: false },
270
271
  updatedAt: "2025-01-01T00:00:00Z",
271
272
  };
@@ -285,6 +286,7 @@ test("clearReactiveState removes the file", () => {
285
286
  const state: ReactiveExecutionState = {
286
287
  sliceId: "S01",
287
288
  completed: [],
289
+ dispatched: ["T01", "T02"],
288
290
  graphSnapshot: { taskCount: 2, edgeCount: 0, readySetSize: 2, ambiguous: false },
289
291
  updatedAt: "2025-01-01T00:00:00Z",
290
292
  };
@@ -365,3 +367,145 @@ test("completed tasks are not re-dispatched on next iteration", async () => {
365
367
  rmSync(repo, { recursive: true, force: true });
366
368
  }
367
369
  });
370
+
371
+ // ─── Batch Verification ───────────────────────────────────────────────────
372
+
373
+ test("verifyExpectedArtifact: reactive-execute passes when all dispatched summaries exist", async () => {
374
+ const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
375
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-pass-"));
376
+ try {
377
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
378
+ mkdirSync(tasksDir, { recursive: true });
379
+ writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02: Done\n");
380
+ writeFileSync(join(tasksDir, "T03-SUMMARY.md"), "---\nid: T03\n---\n# T03: Done\n");
381
+
382
+ const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
383
+ assert.equal(result, true, "Should pass when all dispatched task summaries exist");
384
+ } finally {
385
+ rmSync(repo, { recursive: true, force: true });
386
+ }
387
+ });
388
+
389
+ test("verifyExpectedArtifact: reactive-execute fails when a dispatched summary is missing", async () => {
390
+ const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
391
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-fail-"));
392
+ try {
393
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
394
+ mkdirSync(tasksDir, { recursive: true });
395
+ // Only T02 has a summary, T03 does not
396
+ writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02: Done\n");
397
+
398
+ const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
399
+ assert.equal(result, false, "Should fail when dispatched task T03 summary is missing");
400
+ } finally {
401
+ rmSync(repo, { recursive: true, force: true });
402
+ }
403
+ });
404
+
405
+ test("verifyExpectedArtifact: reactive-execute fails even with pre-existing summaries from other tasks", async () => {
406
+ const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
407
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-preexisting-"));
408
+ try {
409
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
410
+ mkdirSync(tasksDir, { recursive: true });
411
+ // T01 summary exists from before, but T02 and T03 were dispatched
412
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01: Prior\n");
413
+
414
+ const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
415
+ assert.equal(result, false, "Pre-existing T01 summary should not satisfy T02,T03 batch");
416
+ } finally {
417
+ rmSync(repo, { recursive: true, force: true });
418
+ }
419
+ });
420
+
421
+ test("verifyExpectedArtifact: reactive-execute legacy format (no batch IDs) falls back", async () => {
422
+ const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
423
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-legacy-"));
424
+ try {
425
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
426
+ mkdirSync(tasksDir, { recursive: true });
427
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
428
+
429
+ // Legacy format without +batch suffix
430
+ const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive", repo);
431
+ assert.equal(result, true, "Legacy format should fall back to any-summary check");
432
+ } finally {
433
+ rmSync(repo, { recursive: true, force: true });
434
+ }
435
+ });
436
+
437
+ test("unitId batch encoding round-trips correctly", () => {
438
+ const mid = "M001";
439
+ const sid = "S01";
440
+ const selected = ["T02", "T03", "T05"];
441
+ const unitId = `${mid}/${sid}/reactive+${selected.join(",")}`;
442
+
443
+ // Parse it back
444
+ const parts = unitId.split("/");
445
+ assert.equal(parts[0], "M001");
446
+ assert.equal(parts[1], "S01");
447
+ const batchPart = parts[2];
448
+ const plusIdx = batchPart.indexOf("+");
449
+ assert.ok(plusIdx > 0, "Should have + separator");
450
+ const batchIds = batchPart.slice(plusIdx + 1).split(",");
451
+ assert.deepEqual(batchIds, ["T02", "T03", "T05"]);
452
+ });
453
+
454
+ // ─── Dependency-Based Carry-Forward ───────────────────────────────────────
455
+
456
+ test("getDependencyTaskSummaryPaths returns only dependency summaries", async () => {
457
+ const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
458
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-"));
459
+ try {
460
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
461
+ mkdirSync(tasksDir, { recursive: true });
462
+ // T01, T02, T03 all have summaries
463
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
464
+ writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02\n");
465
+ writeFileSync(join(tasksDir, "T03-SUMMARY.md"), "---\nid: T03\n---\n# T03\n");
466
+
467
+ // T04 depends only on T01 and T03 — should NOT get T02
468
+ const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T04", ["T01", "T03"], repo);
469
+ assert.equal(paths.length, 2, "Should get exactly 2 dependency summaries");
470
+ assert.ok(paths.some((p) => p.includes("T01-SUMMARY")), "Should include T01");
471
+ assert.ok(paths.some((p) => p.includes("T03-SUMMARY")), "Should include T03");
472
+ assert.ok(!paths.some((p) => p.includes("T02-SUMMARY")), "Should NOT include T02");
473
+ } finally {
474
+ rmSync(repo, { recursive: true, force: true });
475
+ }
476
+ });
477
+
478
+ test("getDependencyTaskSummaryPaths falls back to order-based for root tasks", async () => {
479
+ const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
480
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-root-"));
481
+ try {
482
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
483
+ mkdirSync(tasksDir, { recursive: true });
484
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
485
+
486
+ // T02 has no dependencies (root task) — should fall back to order-based
487
+ const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T02", [], repo);
488
+ assert.equal(paths.length, 1, "Root task should get order-based prior summaries");
489
+ assert.ok(paths[0].includes("T01-SUMMARY"), "Should include T01 via order fallback");
490
+ } finally {
491
+ rmSync(repo, { recursive: true, force: true });
492
+ }
493
+ });
494
+
495
+ test("getDependencyTaskSummaryPaths handles missing dependency summaries gracefully", async () => {
496
+ const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
497
+ const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-missing-"));
498
+ try {
499
+ const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
500
+ mkdirSync(tasksDir, { recursive: true });
501
+ // Only T01 has a summary, T02 does not
502
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
503
+
504
+ // T03 depends on T01 and T02, but T02 summary doesn't exist
505
+ const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T03", ["T01", "T02"], repo);
506
+ assert.equal(paths.length, 1, "Should only return existing dependency summaries");
507
+ assert.ok(paths[0].includes("T01-SUMMARY"), "Should include T01 (exists)");
508
+ } finally {
509
+ rmSync(repo, { recursive: true, force: true });
510
+ }
511
+ });
@@ -468,6 +468,8 @@ export interface ReactiveExecutionState {
468
468
  sliceId: string;
469
469
  /** Task IDs that have been verified as completed. */
470
470
  completed: string[];
471
+ /** Task IDs dispatched in the current/most recent reactive batch. */
472
+ dispatched: string[];
471
473
  /** Snapshot of the graph at last dispatch. */
472
474
  graphSnapshot: {
473
475
  taskCount: number;