gsd-pi 2.37.1 → 2.38.0-dev.96dc7fb

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 (116) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +9 -0
  3. package/dist/extension-discovery.d.ts +5 -3
  4. package/dist/extension-discovery.js +14 -9
  5. package/dist/onboarding.js +1 -0
  6. package/dist/resources/extensions/browser-tools/package.json +3 -1
  7. package/dist/resources/extensions/cmux/index.js +55 -1
  8. package/dist/resources/extensions/context7/package.json +1 -1
  9. package/dist/resources/extensions/google-search/package.json +3 -1
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +67 -1
  11. package/dist/resources/extensions/gsd/auto-loop.js +7 -1
  12. package/dist/resources/extensions/gsd/auto-post-unit.js +14 -0
  13. package/dist/resources/extensions/gsd/auto-prompts.js +91 -2
  14. package/dist/resources/extensions/gsd/auto-recovery.js +37 -1
  15. package/dist/resources/extensions/gsd/auto-start.js +6 -1
  16. package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
  17. package/dist/resources/extensions/gsd/captures.js +9 -1
  18. package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
  19. package/dist/resources/extensions/gsd/commands.js +20 -1
  20. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  21. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  22. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  23. package/dist/resources/extensions/gsd/doctor-providers.js +35 -1
  24. package/dist/resources/extensions/gsd/doctor.js +184 -11
  25. package/dist/resources/extensions/gsd/files.js +41 -0
  26. package/dist/resources/extensions/gsd/observability-validator.js +24 -0
  27. package/dist/resources/extensions/gsd/package.json +1 -1
  28. package/dist/resources/extensions/gsd/preferences-types.js +2 -1
  29. package/dist/resources/extensions/gsd/preferences-validation.js +42 -0
  30. package/dist/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  31. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  32. package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
  33. package/dist/resources/extensions/gsd/templates/task-plan.md +11 -3
  34. package/dist/resources/extensions/gsd/worktree.js +35 -16
  35. package/dist/resources/extensions/subagent/index.js +12 -3
  36. package/dist/resources/extensions/universal-config/package.json +1 -1
  37. package/dist/welcome-screen.d.ts +12 -0
  38. package/dist/welcome-screen.js +53 -0
  39. package/package.json +2 -1
  40. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  41. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  42. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  43. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  44. package/packages/pi-ai/dist/models.generated.js +172 -0
  45. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  46. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  47. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  48. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  49. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  50. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  51. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  52. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  53. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  54. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  55. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  57. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  58. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  60. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  61. package/packages/pi-ai/dist/types.d.ts +2 -2
  62. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  63. package/packages/pi-ai/dist/types.js.map +1 -1
  64. package/packages/pi-ai/package.json +1 -0
  65. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  66. package/packages/pi-ai/src/models.generated.ts +172 -0
  67. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  68. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  69. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  70. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  71. package/packages/pi-ai/src/types.ts +2 -0
  72. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  74. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  75. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  76. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  77. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  78. package/packages/pi-coding-agent/package.json +1 -1
  79. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  80. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  81. package/pkg/package.json +1 -1
  82. package/src/resources/extensions/cmux/index.ts +57 -1
  83. package/src/resources/extensions/gsd/auto-dispatch.ts +93 -0
  84. package/src/resources/extensions/gsd/auto-loop.ts +13 -1
  85. package/src/resources/extensions/gsd/auto-post-unit.ts +14 -0
  86. package/src/resources/extensions/gsd/auto-prompts.ts +125 -3
  87. package/src/resources/extensions/gsd/auto-recovery.ts +42 -0
  88. package/src/resources/extensions/gsd/auto-start.ts +7 -1
  89. package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
  90. package/src/resources/extensions/gsd/captures.ts +10 -1
  91. package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
  92. package/src/resources/extensions/gsd/commands.ts +21 -1
  93. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  94. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  95. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  96. package/src/resources/extensions/gsd/doctor-providers.ts +38 -1
  97. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  98. package/src/resources/extensions/gsd/doctor.ts +177 -13
  99. package/src/resources/extensions/gsd/files.ts +45 -0
  100. package/src/resources/extensions/gsd/observability-validator.ts +27 -0
  101. package/src/resources/extensions/gsd/preferences-types.ts +5 -1
  102. package/src/resources/extensions/gsd/preferences-validation.ts +41 -0
  103. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  104. package/src/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  105. package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
  106. package/src/resources/extensions/gsd/templates/task-plan.md +11 -3
  107. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  108. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  109. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +108 -3
  110. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
  111. package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +511 -0
  112. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
  113. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  114. package/src/resources/extensions/gsd/types.ts +43 -0
  115. package/src/resources/extensions/gsd/worktree.ts +35 -15
  116. package/src/resources/extensions/subagent/index.ts +12 -3
@@ -15,6 +15,7 @@ import type {
15
15
  Summary, SummaryFrontmatter, SummaryRequires, FileModified,
16
16
  Continue, ContinueFrontmatter, ContinueStatus,
17
17
  RequirementCounts,
18
+ TaskIO,
18
19
  SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
19
20
  ManifestStatus,
20
21
  } from './types.js';
@@ -724,6 +725,50 @@ export function countMustHavesMentionedInSummary(
724
725
  return count;
725
726
  }
726
727
 
728
+ // ─── Task Plan IO Extractor ────────────────────────────────────────────────
729
+
730
+ /**
731
+ * Extract input and output file paths from a task plan's `## Inputs` and
732
+ * `## Expected Output` sections. Looks for backtick-wrapped file paths on
733
+ * each line (e.g. `` `src/foo.ts` ``).
734
+ *
735
+ * Returns empty arrays for missing/empty sections — callers should treat
736
+ * tasks with no IO as ambiguous (sequential fallback trigger).
737
+ */
738
+ export function parseTaskPlanIO(content: string): { inputFiles: string[]; outputFiles: string[] } {
739
+ const backtickPathRegex = /`([^`]+)`/g;
740
+
741
+ function extractPaths(sectionText: string | null): string[] {
742
+ if (!sectionText) return [];
743
+ const paths: string[] = [];
744
+ for (const line of sectionText.split("\n")) {
745
+ const trimmed = line.trim();
746
+ if (!trimmed || trimmed.startsWith("#")) continue;
747
+ let match: RegExpExecArray | null;
748
+ backtickPathRegex.lastIndex = 0;
749
+ while ((match = backtickPathRegex.exec(trimmed)) !== null) {
750
+ const candidate = match[1];
751
+ // Filter out things that look like code tokens rather than file paths
752
+ // (e.g. `true`, `false`, `npm run test`). A file path has at least one
753
+ // dot or slash.
754
+ if (candidate.includes("/") || candidate.includes(".")) {
755
+ paths.push(candidate);
756
+ }
757
+ }
758
+ }
759
+ return paths;
760
+ }
761
+
762
+ const [, body] = splitFrontmatter(content);
763
+ const inputSection = extractSection(body, "Inputs");
764
+ const outputSection = extractSection(body, "Expected Output");
765
+
766
+ return {
767
+ inputFiles: extractPaths(inputSection),
768
+ outputFiles: extractPaths(outputSection),
769
+ };
770
+ }
771
+
727
772
  // ─── UAT Type Extractor ────────────────────────────────────────────────────
728
773
 
729
774
  /**
@@ -235,6 +235,33 @@ export function validateTaskPlanContent(file: string, content: string): Validati
235
235
  }
236
236
  }
237
237
 
238
+ // Rule: Inputs and Expected Output should contain backtick-wrapped file paths
239
+ const inputsSection = getSection(content, "Inputs", 2);
240
+ const outputSection = getSection(content, "Expected Output", 2);
241
+ const backtickPathPattern = /`[^`]*[./][^`]*`/;
242
+
243
+ if (outputSection === null || !backtickPathPattern.test(outputSection)) {
244
+ issues.push({
245
+ severity: "warning",
246
+ scope: "task-plan",
247
+ file,
248
+ ruleId: "missing_output_file_paths",
249
+ message: "Task plan `## Expected Output` is missing or has no backtick-wrapped file paths.",
250
+ suggestion: "List concrete output file paths in backticks (e.g. `src/types.ts`). These are machine-parsed to derive task dependencies.",
251
+ });
252
+ }
253
+
254
+ if (inputsSection !== null && inputsSection.trim().length > 0 && !backtickPathPattern.test(inputsSection)) {
255
+ issues.push({
256
+ severity: "info",
257
+ scope: "task-plan",
258
+ file,
259
+ ruleId: "missing_input_file_paths",
260
+ message: "Task plan `## Inputs` has content but no backtick-wrapped file paths.",
261
+ suggestion: "List input file paths in backticks (e.g. `src/config.json`). These are machine-parsed to derive task dependencies.",
262
+ });
263
+ }
264
+
238
265
  // ── Observability rules (gated by runtime relevance) ──
239
266
 
240
267
  const relevant = textSuggestsObservabilityRelevant(content);
@@ -18,6 +18,7 @@ import type {
18
18
  ParallelConfig,
19
19
  CompressionStrategy,
20
20
  ContextSelectionMode,
21
+ ReactiveExecutionConfig,
21
22
  } from "./types.js";
22
23
  import type { DynamicRoutingConfig } from "./model-router.js";
23
24
 
@@ -86,12 +87,13 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
86
87
  "compression_strategy",
87
88
  "context_selection",
88
89
  "widget_mode",
90
+ "reactive_execution",
89
91
  ]);
90
92
 
91
93
  /** Canonical list of all dispatch unit types. */
92
94
  export const KNOWN_UNIT_TYPES = [
93
95
  "research-milestone", "plan-milestone", "research-slice", "plan-slice",
94
- "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
96
+ "execute-task", "reactive-execute", "complete-slice", "replan-slice", "reassess-roadmap",
95
97
  "run-uat", "complete-milestone",
96
98
  ] as const;
97
99
  export type UnitType = (typeof KNOWN_UNIT_TYPES)[number];
@@ -215,6 +217,8 @@ export interface GSDPreferences {
215
217
  context_selection?: ContextSelectionMode;
216
218
  /** Default widget display mode for auto-mode dashboard. "full" | "small" | "min" | "off". Default: "full". */
217
219
  widget_mode?: "full" | "small" | "min" | "off";
220
+ /** Reactive (graph-derived parallel) task execution within slices. Disabled by default. */
221
+ reactive_execution?: ReactiveExecutionConfig;
218
222
  }
219
223
 
220
224
  export interface LoadedGSDPreferences {
@@ -496,6 +496,47 @@ export function validatePreferences(preferences: GSDPreferences): {
496
496
  }
497
497
  }
498
498
 
499
+ // ─── Reactive Execution ─────────────────────────────────────────────────
500
+ if (preferences.reactive_execution !== undefined) {
501
+ if (typeof preferences.reactive_execution === "object" && preferences.reactive_execution !== null) {
502
+ const re = preferences.reactive_execution as unknown as Record<string, unknown>;
503
+ const validRe: Record<string, unknown> = {};
504
+
505
+ if (re.enabled !== undefined) {
506
+ if (typeof re.enabled === "boolean") validRe.enabled = re.enabled;
507
+ else errors.push("reactive_execution.enabled must be a boolean");
508
+ }
509
+ if (re.max_parallel !== undefined) {
510
+ const mp = typeof re.max_parallel === "number" ? re.max_parallel : Number(re.max_parallel);
511
+ if (Number.isFinite(mp) && mp >= 1 && mp <= 8) {
512
+ validRe.max_parallel = Math.floor(mp);
513
+ } else {
514
+ errors.push("reactive_execution.max_parallel must be a number between 1 and 8");
515
+ }
516
+ }
517
+ if (re.isolation_mode !== undefined) {
518
+ if (re.isolation_mode === "same-tree") {
519
+ validRe.isolation_mode = "same-tree";
520
+ } else {
521
+ errors.push('reactive_execution.isolation_mode must be "same-tree"');
522
+ }
523
+ }
524
+
525
+ const knownReKeys = new Set(["enabled", "max_parallel", "isolation_mode"]);
526
+ for (const key of Object.keys(re)) {
527
+ if (!knownReKeys.has(key)) {
528
+ warnings.push(`unknown reactive_execution key "${key}" — ignored`);
529
+ }
530
+ }
531
+
532
+ if (Object.keys(validRe).length > 0) {
533
+ validated.reactive_execution = validRe as unknown as import("./types.js").ReactiveExecutionConfig;
534
+ }
535
+ } else {
536
+ errors.push("reactive_execution must be an object");
537
+ }
538
+ }
539
+
499
540
  // ─── Verification Preferences ───────────────────────────────────────────
500
541
  if (preferences.verification_commands !== undefined) {
501
542
  if (Array.isArray(preferences.verification_commands)) {
@@ -61,13 +61,14 @@ Then:
61
61
  - a concrete, action-oriented title
62
62
  - the inline task entry fields defined in the plan.md template (Why / Files / Do / Verify / Done when)
63
63
  - a matching task plan file with description, steps, must-haves, verification, inputs, and expected output
64
+ - **Inputs and Expected Output must list concrete backtick-wrapped file paths** (e.g. `` `src/types.ts` ``). These are machine-parsed to derive task dependencies — vague prose without paths breaks parallel execution. Every task must have at least one output file path.
64
65
  - Observability Impact section **only if the task touches runtime boundaries, async flows, or error paths** — omit it otherwise
65
66
  6. Write `{{outputPath}}`
66
67
  7. Write individual task plans in `{{slicePath}}/tasks/`: `T01-PLAN.md`, `T02-PLAN.md`, etc.
67
68
  8. **Self-audit the plan.** Walk through each check — if any fail, fix the plan files before moving on:
68
69
  - **Completion semantics:** If every task were completed exactly as written, the slice goal/demo should actually be true.
69
70
  - **Requirement coverage:** Every must-have in the slice maps to at least one task. No must-have is orphaned. If `REQUIREMENTS.md` exists, every Active requirement this slice owns maps to at least one task.
70
- - **Task completeness:** Every task has steps, must-haves, verification, inputs, and expected output — none are blank or vague.
71
+ - **Task completeness:** Every task has steps, must-haves, verification, inputs, and expected output — none are blank or vague. Inputs and Expected Output list backtick-wrapped file paths, not prose descriptions.
71
72
  - **Dependency correctness:** Task ordering is consistent. No task references work from a later task.
72
73
  - **Key links planned:** For every pair of artifacts that must connect, there is an explicit step that wires them.
73
74
  - **Scope sanity:** Target 2–5 steps and 3–8 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window.
@@ -0,0 +1,41 @@
1
+ # Reactive Task Execution — Parallel Dispatch
2
+
3
+ **Working directory:** `{{workingDirectory}}`
4
+ **Milestone:** {{milestoneId}} — {{milestoneTitle}}
5
+ **Slice:** {{sliceId}} — {{sliceTitle}}
6
+
7
+ ## Mission
8
+
9
+ You are executing **multiple tasks in parallel** for this slice. The task graph below shows which tasks are ready for simultaneous execution based on their input/output dependencies.
10
+
11
+ **Critical rule:** Use the `subagent` tool in **parallel mode** to dispatch all ready tasks simultaneously. Each subagent gets a self-contained execute-task prompt. After all subagents return, verify each task's outputs and write summaries.
12
+
13
+ ## Task Dependency Graph
14
+
15
+ {{graphContext}}
16
+
17
+ ## Ready Tasks for Parallel Dispatch
18
+
19
+ {{readyTaskCount}} tasks are ready for parallel execution:
20
+
21
+ {{readyTaskList}}
22
+
23
+ ## Execution Protocol
24
+
25
+ 1. **Dispatch all ready tasks** using `subagent` in parallel mode. Each subagent prompt is provided below.
26
+ 2. **Wait for all subagents** to complete.
27
+ 3. **Verify each task's outputs** — check that expected files were created/modified and that verification commands pass.
28
+ 4. **Write task summaries** for each completed task using the task-summary template.
29
+ 5. **Mark completed tasks** as done in the slice plan (checkbox `[x]`).
30
+ 6. **Commit** all changes with a clear message covering the parallel batch.
31
+
32
+ If any subagent fails:
33
+ - Write a summary for the failed task with `blocker_discovered: true`
34
+ - Continue marking the successful tasks as done
35
+ - The orchestrator will handle re-dispatch on the next iteration
36
+
37
+ ## Subagent Prompts
38
+
39
+ {{subagentPrompts}}
40
+
41
+ {{inlinedTemplates}}
@@ -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) && Array.isArray(d.dispatched);
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
+ }
@@ -42,11 +42,19 @@ estimated_files: {{estimatedFiles}}
42
42
 
43
43
  ## Inputs
44
44
 
45
+ <!-- Every input MUST be a backtick-wrapped file path. These paths are machine-parsed to
46
+ derive task dependencies — vague descriptions without paths break dependency detection.
47
+ For the first task in a slice with no prior task outputs, list the existing source files
48
+ this task reads or modifies. -->
49
+
45
50
  - `{{filePath}}` — {{whatThisTaskNeedsFromPriorWork}}
46
- - {{priorTaskSummaryInsight}}
47
51
 
48
52
  ## Expected Output
49
53
 
50
- <!-- This task should produce a real increment toward making the slice goal/demo true. A full slice plan should not be able to mark every task complete while the claimed slice behavior still does not work at the stated proof level. -->
54
+ <!-- Every output MUST be a backtick-wrapped file path the specific files this task creates
55
+ or modifies. These paths are machine-parsed to derive task dependencies.
56
+ This task should produce a real increment toward making the slice goal/demo true. A full
57
+ slice plan should not be able to mark every task complete while the claimed slice behavior
58
+ still does not work at the stated proof level. -->
51
59
 
52
- - `{{filePath}}` — {{whatThisTaskShouldProduceOrModify}}
60
+ - `{{filePath}}` — {{whatThisTaskCreatesOrModifies}}
@@ -100,6 +100,99 @@ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
100
100
  assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
101
101
  });
102
102
 
103
+ describe("createGridLayout", () => {
104
+ // Create a mock CmuxClient that tracks createSplitFrom calls
105
+ function makeMockClient() {
106
+ let nextId = 1;
107
+ const calls: Array<{ source: string | undefined; direction: string }> = [];
108
+
109
+ const client = {
110
+ calls,
111
+ async createGridLayout(count: number) {
112
+ // Simulate the grid layout logic with a fake client
113
+ if (count <= 0) return [];
114
+ const surfaces: string[] = [];
115
+
116
+ const createSplitFrom = async (source: string | undefined, direction: string) => {
117
+ calls.push({ source, direction });
118
+ return `surface-${nextId++}`;
119
+ };
120
+
121
+ const rightCol = await createSplitFrom("gsd-surface", "right");
122
+ surfaces.push(rightCol);
123
+ if (count === 1) return surfaces;
124
+
125
+ const bottomRight = await createSplitFrom(rightCol, "down");
126
+ surfaces.push(bottomRight);
127
+ if (count === 2) return surfaces;
128
+
129
+ const bottomLeft = await createSplitFrom("gsd-surface", "down");
130
+ surfaces.push(bottomLeft);
131
+ if (count === 3) return surfaces;
132
+
133
+ let lastSurface = bottomRight;
134
+ for (let i = 3; i < count; i++) {
135
+ const next = await createSplitFrom(lastSurface, "down");
136
+ surfaces.push(next);
137
+ lastSurface = next;
138
+ }
139
+
140
+ return surfaces;
141
+ },
142
+ };
143
+ return client;
144
+ }
145
+
146
+ test("1 agent creates single right split", async () => {
147
+ const mock = makeMockClient();
148
+ const surfaces = await mock.createGridLayout(1);
149
+ assert.equal(surfaces.length, 1);
150
+ assert.deepEqual(mock.calls, [
151
+ { source: "gsd-surface", direction: "right" },
152
+ ]);
153
+ });
154
+
155
+ test("2 agents creates right column then splits it down", async () => {
156
+ const mock = makeMockClient();
157
+ const surfaces = await mock.createGridLayout(2);
158
+ assert.equal(surfaces.length, 2);
159
+ assert.deepEqual(mock.calls, [
160
+ { source: "gsd-surface", direction: "right" },
161
+ { source: "surface-1", direction: "down" },
162
+ ]);
163
+ });
164
+
165
+ test("3 agents creates 2x2 grid (gsd + 3 agent surfaces)", async () => {
166
+ const mock = makeMockClient();
167
+ const surfaces = await mock.createGridLayout(3);
168
+ assert.equal(surfaces.length, 3);
169
+ assert.deepEqual(mock.calls, [
170
+ { source: "gsd-surface", direction: "right" },
171
+ { source: "surface-1", direction: "down" },
172
+ { source: "gsd-surface", direction: "down" },
173
+ ]);
174
+ });
175
+
176
+ test("4 agents creates 2x2 grid with extra split", async () => {
177
+ const mock = makeMockClient();
178
+ const surfaces = await mock.createGridLayout(4);
179
+ assert.equal(surfaces.length, 4);
180
+ assert.deepEqual(mock.calls, [
181
+ { source: "gsd-surface", direction: "right" },
182
+ { source: "surface-1", direction: "down" },
183
+ { source: "gsd-surface", direction: "down" },
184
+ { source: "surface-2", direction: "down" },
185
+ ]);
186
+ });
187
+
188
+ test("0 agents returns empty", async () => {
189
+ const mock = makeMockClient();
190
+ const surfaces = await mock.createGridLayout(0);
191
+ assert.equal(surfaces.length, 0);
192
+ assert.equal(mock.calls.length, 0);
193
+ });
194
+ });
195
+
103
196
  describe("cmux extension discovery opt-out", () => {
104
197
  test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
105
198
  const cmuxDir = path.resolve(