gsd-pi 2.37.1-dev.7352347 → 2.37.1-dev.857ac92
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/dist/resources/extensions/gsd/auto-dispatch.js +54 -1
- 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/doctor-providers.js +35 -1
- package/dist/resources/extensions/gsd/files.js +41 -0
- package/dist/resources/extensions/gsd/observability-validator.js +24 -0
- 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/plan-slice.md +2 -1
- 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/templates/task-plan.md +11 -3
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +78 -0
- 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/doctor-providers.ts +38 -1
- package/src/resources/extensions/gsd/files.ts +45 -0
- package/src/resources/extensions/gsd/observability-validator.ts +27 -0
- 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/plan-slice.md +2 -1
- 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/templates/task-plan.md +11 -3
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +108 -3
- package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
- 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/types.ts +41 -0
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
import { loadFile, parsePlan, parseTaskPlanIO } from "./files.js";
|
|
12
|
+
import { resolveTasksDir, resolveTaskFiles } from "./paths.js";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
|
15
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
16
|
+
// ─── Graph Construction ───────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* Build a dependency graph from task IO signatures.
|
|
19
|
+
*
|
|
20
|
+
* A task T_b depends on T_a when any of T_b's inputFiles appear in T_a's
|
|
21
|
+
* outputFiles. Self-references are excluded.
|
|
22
|
+
*
|
|
23
|
+
* Tasks are returned in the same order as the input array.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveTaskGraph(tasks) {
|
|
26
|
+
// Build output → producer lookup
|
|
27
|
+
const outputToProducer = new Map();
|
|
28
|
+
for (const task of tasks) {
|
|
29
|
+
for (const outFile of task.outputFiles) {
|
|
30
|
+
const existing = outputToProducer.get(outFile);
|
|
31
|
+
if (existing) {
|
|
32
|
+
existing.push(task.id);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
outputToProducer.set(outFile, [task.id]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return tasks.map((task) => {
|
|
40
|
+
const deps = new Set();
|
|
41
|
+
for (const inFile of task.inputFiles) {
|
|
42
|
+
const producers = outputToProducer.get(inFile);
|
|
43
|
+
if (producers) {
|
|
44
|
+
for (const pid of producers) {
|
|
45
|
+
if (pid !== task.id)
|
|
46
|
+
deps.add(pid);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
...task,
|
|
52
|
+
dependsOn: [...deps].sort(),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// ─── Ready Set Resolution ─────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Return task IDs whose dependencies are all in `completed`.
|
|
59
|
+
* Excludes tasks that are already done or in-flight.
|
|
60
|
+
*/
|
|
61
|
+
export function getReadyTasks(graph, completed, inFlight) {
|
|
62
|
+
return graph
|
|
63
|
+
.filter((node) => {
|
|
64
|
+
if (node.done || completed.has(node.id) || inFlight.has(node.id))
|
|
65
|
+
return false;
|
|
66
|
+
return node.dependsOn.every((dep) => completed.has(dep));
|
|
67
|
+
})
|
|
68
|
+
.map((node) => node.id);
|
|
69
|
+
}
|
|
70
|
+
// ─── Conflict-Free Subset Selection ──────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Greedy selection of non-conflicting tasks up to `maxParallel`.
|
|
73
|
+
*
|
|
74
|
+
* Two tasks conflict if they share any outputFile. We also exclude tasks
|
|
75
|
+
* whose outputs overlap with `inFlightOutputs` (files being written by
|
|
76
|
+
* tasks currently in progress).
|
|
77
|
+
*/
|
|
78
|
+
export function chooseNonConflictingSubset(readyIds, graph, maxParallel, inFlightOutputs) {
|
|
79
|
+
const nodeMap = new Map(graph.map((n) => [n.id, n]));
|
|
80
|
+
const claimed = new Set(inFlightOutputs);
|
|
81
|
+
const selected = [];
|
|
82
|
+
for (const id of readyIds) {
|
|
83
|
+
if (selected.length >= maxParallel)
|
|
84
|
+
break;
|
|
85
|
+
const node = nodeMap.get(id);
|
|
86
|
+
if (!node)
|
|
87
|
+
continue;
|
|
88
|
+
// Check for output overlap with already-selected or in-flight
|
|
89
|
+
const conflicts = node.outputFiles.some((f) => claimed.has(f));
|
|
90
|
+
if (conflicts)
|
|
91
|
+
continue;
|
|
92
|
+
// Claim this task's outputs
|
|
93
|
+
for (const f of node.outputFiles)
|
|
94
|
+
claimed.add(f);
|
|
95
|
+
selected.push(id);
|
|
96
|
+
}
|
|
97
|
+
return selected;
|
|
98
|
+
}
|
|
99
|
+
// ─── Graph Quality Checks ─────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Returns true if any incomplete task has 0 inputFiles AND 0 outputFiles.
|
|
102
|
+
*
|
|
103
|
+
* An ambiguous graph means IO annotations are too sparse to derive reliable
|
|
104
|
+
* edges — the dispatcher should fall back to sequential execution.
|
|
105
|
+
*/
|
|
106
|
+
export function isGraphAmbiguous(graph) {
|
|
107
|
+
return graph.some((node) => !node.done &&
|
|
108
|
+
node.inputFiles.length === 0 &&
|
|
109
|
+
node.outputFiles.length === 0);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Detect deadlock: no tasks are ready and none are in-flight, yet incomplete
|
|
113
|
+
* tasks remain. This indicates a circular dependency or impossible state.
|
|
114
|
+
*/
|
|
115
|
+
export function detectDeadlock(graph, completed, inFlight) {
|
|
116
|
+
const incomplete = graph.filter((n) => !n.done && !completed.has(n.id) && !inFlight.has(n.id));
|
|
117
|
+
if (incomplete.length === 0)
|
|
118
|
+
return false; // all done
|
|
119
|
+
if (inFlight.size > 0)
|
|
120
|
+
return false; // something is running, wait for it
|
|
121
|
+
// Nothing in flight, but incomplete tasks remain — check if any are ready
|
|
122
|
+
const ready = getReadyTasks(graph, completed, inFlight);
|
|
123
|
+
return ready.length === 0;
|
|
124
|
+
}
|
|
125
|
+
// ─── Graph Metrics ────────────────────────────────────────────────────────
|
|
126
|
+
/** Compute summary metrics for logging. */
|
|
127
|
+
export function graphMetrics(graph) {
|
|
128
|
+
const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
|
|
129
|
+
const ready = getReadyTasks(graph, completed, new Set());
|
|
130
|
+
const edgeCount = graph.reduce((sum, n) => sum + n.dependsOn.length, 0);
|
|
131
|
+
return {
|
|
132
|
+
taskCount: graph.length,
|
|
133
|
+
edgeCount,
|
|
134
|
+
readySetSize: ready.length,
|
|
135
|
+
ambiguous: isGraphAmbiguous(graph),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// ─── IO Loader (async, filesystem) ────────────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* Load TaskIO for all tasks in a slice by reading the slice plan (for done
|
|
141
|
+
* status and task IDs) and individual task plan files (for IO sections).
|
|
142
|
+
*
|
|
143
|
+
* Returns [] when the slice plan or tasks directory doesn't exist.
|
|
144
|
+
*/
|
|
145
|
+
export async function loadSliceTaskIO(basePath, mid, sid) {
|
|
146
|
+
const { resolveSliceFile } = await import("./paths.js");
|
|
147
|
+
const slicePlanPath = resolveSliceFile(basePath, mid, sid, "PLAN");
|
|
148
|
+
const planContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
|
|
149
|
+
if (!planContent)
|
|
150
|
+
return [];
|
|
151
|
+
const plan = parsePlan(planContent);
|
|
152
|
+
const tDir = resolveTasksDir(basePath, mid, sid);
|
|
153
|
+
if (!tDir)
|
|
154
|
+
return [];
|
|
155
|
+
const results = [];
|
|
156
|
+
for (const taskEntry of plan.tasks) {
|
|
157
|
+
const planFiles = resolveTaskFiles(tDir, "PLAN");
|
|
158
|
+
const taskFileName = planFiles.find((f) => f.toUpperCase().startsWith(taskEntry.id.toUpperCase() + "-"));
|
|
159
|
+
if (!taskFileName) {
|
|
160
|
+
// Task plan file missing — include with empty IO (will trigger ambiguous)
|
|
161
|
+
results.push({
|
|
162
|
+
id: taskEntry.id,
|
|
163
|
+
title: taskEntry.title,
|
|
164
|
+
inputFiles: [],
|
|
165
|
+
outputFiles: [],
|
|
166
|
+
done: taskEntry.done,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const taskContent = await loadFile(join(tDir, taskFileName));
|
|
171
|
+
if (!taskContent) {
|
|
172
|
+
results.push({
|
|
173
|
+
id: taskEntry.id,
|
|
174
|
+
title: taskEntry.title,
|
|
175
|
+
inputFiles: [],
|
|
176
|
+
outputFiles: [],
|
|
177
|
+
done: taskEntry.done,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const io = parseTaskPlanIO(taskContent);
|
|
182
|
+
results.push({
|
|
183
|
+
id: taskEntry.id,
|
|
184
|
+
title: taskEntry.title,
|
|
185
|
+
inputFiles: io.inputFiles,
|
|
186
|
+
outputFiles: io.outputFiles,
|
|
187
|
+
done: taskEntry.done,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
// ─── State Persistence ────────────────────────────────────────────────────
|
|
193
|
+
function reactiveStatePath(basePath, mid, sid) {
|
|
194
|
+
return join(basePath, ".gsd", "runtime", `${mid}-${sid}-reactive.json`);
|
|
195
|
+
}
|
|
196
|
+
function isReactiveState(data) {
|
|
197
|
+
if (!data || typeof data !== "object")
|
|
198
|
+
return false;
|
|
199
|
+
const d = data;
|
|
200
|
+
return typeof d.sliceId === "string" && Array.isArray(d.completed);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Load persisted reactive execution state for a slice.
|
|
204
|
+
* Returns null when no state file exists or the file is invalid.
|
|
205
|
+
*/
|
|
206
|
+
export function loadReactiveState(basePath, mid, sid) {
|
|
207
|
+
return loadJsonFileOrNull(reactiveStatePath(basePath, mid, sid), isReactiveState);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Save reactive execution state to disk.
|
|
211
|
+
*/
|
|
212
|
+
export function saveReactiveState(basePath, mid, sid, state) {
|
|
213
|
+
saveJsonFile(reactiveStatePath(basePath, mid, sid), state);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Remove the reactive state file when a slice completes.
|
|
217
|
+
*/
|
|
218
|
+
export function clearReactiveState(basePath, mid, sid) {
|
|
219
|
+
const path = reactiveStatePath(basePath, mid, sid);
|
|
220
|
+
try {
|
|
221
|
+
if (existsSync(path))
|
|
222
|
+
unlinkSync(path);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Non-fatal
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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
|
-
<!--
|
|
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}}` — {{
|
|
60
|
+
- `{{filePath}}` — {{whatThisTaskCreatesOrModifies}}
|
package/package.json
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
buildRunUatPrompt,
|
|
39
39
|
buildReassessRoadmapPrompt,
|
|
40
40
|
buildRewriteDocsPrompt,
|
|
41
|
+
buildReactiveExecutePrompt,
|
|
41
42
|
checkNeedsReassessment,
|
|
42
43
|
checkNeedsRunUat,
|
|
43
44
|
} from "./auto-prompts.js";
|
|
@@ -309,6 +310,83 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
309
310
|
};
|
|
310
311
|
},
|
|
311
312
|
},
|
|
313
|
+
{
|
|
314
|
+
name: "executing → reactive-execute (parallel dispatch)",
|
|
315
|
+
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
|
316
|
+
if (state.phase !== "executing" || !state.activeTask) return null;
|
|
317
|
+
if (!state.activeSlice) return null; // fall through
|
|
318
|
+
|
|
319
|
+
// Only activate when reactive_execution is explicitly enabled
|
|
320
|
+
const reactiveConfig = prefs?.reactive_execution;
|
|
321
|
+
if (!reactiveConfig?.enabled) return null;
|
|
322
|
+
|
|
323
|
+
const sid = state.activeSlice.id;
|
|
324
|
+
const sTitle = state.activeSlice.title;
|
|
325
|
+
const maxParallel = reactiveConfig.max_parallel ?? 2;
|
|
326
|
+
|
|
327
|
+
// Dry-run mode: max_parallel=1 means graph is derived and logged but
|
|
328
|
+
// execution remains sequential
|
|
329
|
+
if (maxParallel <= 1) return null;
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const {
|
|
333
|
+
loadSliceTaskIO,
|
|
334
|
+
deriveTaskGraph,
|
|
335
|
+
isGraphAmbiguous,
|
|
336
|
+
getReadyTasks,
|
|
337
|
+
chooseNonConflictingSubset,
|
|
338
|
+
graphMetrics,
|
|
339
|
+
} = await import("./reactive-graph.js");
|
|
340
|
+
|
|
341
|
+
const taskIO = await loadSliceTaskIO(basePath, mid, sid);
|
|
342
|
+
if (taskIO.length < 2) return null; // single task, no point
|
|
343
|
+
|
|
344
|
+
const graph = deriveTaskGraph(taskIO);
|
|
345
|
+
|
|
346
|
+
// Ambiguous graph → fall through to sequential
|
|
347
|
+
if (isGraphAmbiguous(graph)) return null;
|
|
348
|
+
|
|
349
|
+
const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
|
|
350
|
+
const readyIds = getReadyTasks(graph, completed, new Set());
|
|
351
|
+
|
|
352
|
+
// Only activate reactive dispatch when >1 task is ready
|
|
353
|
+
if (readyIds.length <= 1) return null;
|
|
354
|
+
|
|
355
|
+
const selected = chooseNonConflictingSubset(
|
|
356
|
+
readyIds,
|
|
357
|
+
graph,
|
|
358
|
+
maxParallel,
|
|
359
|
+
new Set(),
|
|
360
|
+
);
|
|
361
|
+
if (selected.length <= 1) return null;
|
|
362
|
+
|
|
363
|
+
// Log graph metrics for observability
|
|
364
|
+
const metrics = graphMetrics(graph);
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
`gsd-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` +
|
|
367
|
+
`ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
action: "dispatch",
|
|
372
|
+
unitType: "reactive-execute",
|
|
373
|
+
unitId: `${mid}/${sid}/reactive`,
|
|
374
|
+
prompt: await buildReactiveExecutePrompt(
|
|
375
|
+
mid,
|
|
376
|
+
midTitle,
|
|
377
|
+
sid,
|
|
378
|
+
sTitle,
|
|
379
|
+
selected,
|
|
380
|
+
basePath,
|
|
381
|
+
),
|
|
382
|
+
};
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Non-fatal — fall through to sequential execution
|
|
385
|
+
process.stderr.write(`gsd-reactive: graph derivation failed: ${(err as Error).message}\n`);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
},
|
|
312
390
|
{
|
|
313
391
|
name: "executing → execute-task (recover missing task plan → plan-slice)",
|
|
314
392
|
match: async ({ state, mid, midTitle, basePath }) => {
|
|
@@ -217,6 +217,20 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// Reactive state cleanup on slice completion
|
|
221
|
+
if (s.currentUnit.type === "complete-slice") {
|
|
222
|
+
try {
|
|
223
|
+
const parts = s.currentUnit.id.split("/");
|
|
224
|
+
const [mid, sid] = parts;
|
|
225
|
+
if (mid && sid) {
|
|
226
|
+
const { clearReactiveState } = await import("./reactive-graph.js");
|
|
227
|
+
clearReactiveState(s.basePath, mid, sid);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
// Non-fatal
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
220
234
|
// Post-triage: execute actionable resolutions
|
|
221
235
|
if (s.currentUnit.type === "triage-captures") {
|
|
222
236
|
try {
|
|
@@ -1234,6 +1234,74 @@ export async function buildReassessRoadmapPrompt(
|
|
|
1234
1234
|
});
|
|
1235
1235
|
}
|
|
1236
1236
|
|
|
1237
|
+
// ─── Reactive Execute Prompt ──────────────────────────────────────────────
|
|
1238
|
+
|
|
1239
|
+
export async function buildReactiveExecutePrompt(
|
|
1240
|
+
mid: string, midTitle: string, sid: string, sTitle: string,
|
|
1241
|
+
readyTaskIds: string[], base: string,
|
|
1242
|
+
): Promise<string> {
|
|
1243
|
+
const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js");
|
|
1244
|
+
|
|
1245
|
+
// Build graph for context
|
|
1246
|
+
const taskIO = await loadSliceTaskIO(base, mid, sid);
|
|
1247
|
+
const graph = deriveTaskGraph(taskIO);
|
|
1248
|
+
const metrics = graphMetrics(graph);
|
|
1249
|
+
|
|
1250
|
+
// Build graph context section
|
|
1251
|
+
const graphLines: string[] = [];
|
|
1252
|
+
for (const node of graph) {
|
|
1253
|
+
const status = node.done ? "✅ done" : readyTaskIds.includes(node.id) ? "🟢 ready" : "⏳ waiting";
|
|
1254
|
+
const deps = node.dependsOn.length > 0 ? ` (depends on: ${node.dependsOn.join(", ")})` : "";
|
|
1255
|
+
graphLines.push(`- **${node.id}: ${node.title}** — ${status}${deps}`);
|
|
1256
|
+
if (node.outputFiles.length > 0) {
|
|
1257
|
+
graphLines.push(` - Outputs: ${node.outputFiles.map(f => `\`${f}\``).join(", ")}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const graphContext = [
|
|
1261
|
+
`Tasks: ${metrics.taskCount}, Edges: ${metrics.edgeCount}, Ready: ${metrics.readySetSize}`,
|
|
1262
|
+
"",
|
|
1263
|
+
...graphLines,
|
|
1264
|
+
].join("\n");
|
|
1265
|
+
|
|
1266
|
+
// Build individual subagent prompts for each ready task
|
|
1267
|
+
const subagentSections: string[] = [];
|
|
1268
|
+
const readyTaskListLines: string[] = [];
|
|
1269
|
+
|
|
1270
|
+
for (const tid of readyTaskIds) {
|
|
1271
|
+
const node = graph.find((n) => n.id === tid);
|
|
1272
|
+
const tTitle = node?.title ?? tid;
|
|
1273
|
+
readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
|
|
1274
|
+
|
|
1275
|
+
// Build a full execute-task prompt for this task (reuse existing builder)
|
|
1276
|
+
const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
|
|
1277
|
+
|
|
1278
|
+
subagentSections.push([
|
|
1279
|
+
`### ${tid}: ${tTitle}`,
|
|
1280
|
+
"",
|
|
1281
|
+
"Use this as the prompt for a `subagent` call:",
|
|
1282
|
+
"",
|
|
1283
|
+
"```",
|
|
1284
|
+
taskPrompt,
|
|
1285
|
+
"```",
|
|
1286
|
+
].join("\n"));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const inlinedTemplates = inlineTemplate("task-summary", "Task Summary");
|
|
1290
|
+
|
|
1291
|
+
return loadPrompt("reactive-execute", {
|
|
1292
|
+
workingDirectory: base,
|
|
1293
|
+
milestoneId: mid,
|
|
1294
|
+
milestoneTitle: midTitle,
|
|
1295
|
+
sliceId: sid,
|
|
1296
|
+
sliceTitle: sTitle,
|
|
1297
|
+
graphContext,
|
|
1298
|
+
readyTaskCount: String(readyTaskIds.length),
|
|
1299
|
+
readyTaskList: readyTaskListLines.join("\n"),
|
|
1300
|
+
subagentPrompts: subagentSections.join("\n\n---\n\n"),
|
|
1301
|
+
inlinedTemplates,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1237
1305
|
export async function buildRewriteDocsPrompt(
|
|
1238
1306
|
mid: string, midTitle: string,
|
|
1239
1307
|
activeSlice: { id: string; title: string } | null,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveSlicePath,
|
|
27
27
|
resolveSliceFile,
|
|
28
28
|
resolveTasksDir,
|
|
29
|
+
resolveTaskFiles,
|
|
29
30
|
relMilestoneFile,
|
|
30
31
|
relSliceFile,
|
|
31
32
|
relSlicePath,
|
|
@@ -110,6 +111,9 @@ export function resolveExpectedArtifactPath(
|
|
|
110
111
|
}
|
|
111
112
|
case "rewrite-docs":
|
|
112
113
|
return null;
|
|
114
|
+
case "reactive-execute":
|
|
115
|
+
// Reactive execute produces multiple task summaries — verified separately
|
|
116
|
+
return null;
|
|
113
117
|
default:
|
|
114
118
|
return null;
|
|
115
119
|
}
|
|
@@ -148,6 +152,20 @@ export function verifyExpectedArtifact(
|
|
|
148
152
|
return !content.includes("**Scope:** active");
|
|
149
153
|
}
|
|
150
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.
|
|
157
|
+
if (unitType === "reactive-execute") {
|
|
158
|
+
const parts = unitId.split("/");
|
|
159
|
+
const mid = parts[0];
|
|
160
|
+
const sid = parts[1];
|
|
161
|
+
if (!mid || !sid) return false;
|
|
162
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
163
|
+
if (!tDir) return false;
|
|
164
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
165
|
+
// At least one summary file should exist
|
|
166
|
+
return summaryFiles.length > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
152
170
|
// For unit types with no verifiable artifact (null path), the parent directory
|
|
153
171
|
// is missing on disk — treat as stale completion state so the key gets evicted (#313).
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { AuthStorage } from "@gsd/pi-coding-agent";
|
|
17
|
+
import { getEnvApiKey } from "@gsd/pi-ai";
|
|
17
18
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
18
19
|
import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
|
|
19
20
|
|
|
@@ -56,6 +57,7 @@ function modelToProviderId(model: string): string | null {
|
|
|
56
57
|
google: "google",
|
|
57
58
|
anthropic: "anthropic",
|
|
58
59
|
openai: "openai",
|
|
60
|
+
"github-copilot": "github-copilot",
|
|
59
61
|
};
|
|
60
62
|
if (prefixMap[prefix]) return prefixMap[prefix];
|
|
61
63
|
}
|
|
@@ -139,7 +141,15 @@ function resolveKey(providerId: string): KeyLookup {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
// Check environment variable
|
|
144
|
+
// Check environment variable using the authoritative env var resolution
|
|
145
|
+
// (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY,
|
|
146
|
+
// COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.)
|
|
147
|
+
if (getEnvApiKey(providerId)) {
|
|
148
|
+
return { found: true, source: "env", backedOff: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey
|
|
152
|
+
// (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7)
|
|
143
153
|
if (info?.envVar && process.env[info.envVar]) {
|
|
144
154
|
return { found: true, source: "env", backedOff: false };
|
|
145
155
|
}
|
|
@@ -149,6 +159,16 @@ function resolveKey(providerId: string): KeyLookup {
|
|
|
149
159
|
|
|
150
160
|
// ── Individual check groups ────────────────────────────────────────────────────
|
|
151
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Providers that can serve models normally associated with another provider.
|
|
164
|
+
* Key = the provider whose models can be served, Value = alternative providers to check.
|
|
165
|
+
* e.g. GitHub Copilot subscriptions can access Claude and GPT models.
|
|
166
|
+
*/
|
|
167
|
+
const PROVIDER_ROUTES: Record<string, string[]> = {
|
|
168
|
+
anthropic: ["github-copilot"],
|
|
169
|
+
openai: ["github-copilot"],
|
|
170
|
+
};
|
|
171
|
+
|
|
152
172
|
function checkLlmProviders(): ProviderCheckResult[] {
|
|
153
173
|
const required = collectConfiguredModelProviders();
|
|
154
174
|
const results: ProviderCheckResult[] = [];
|
|
@@ -159,6 +179,23 @@ function checkLlmProviders(): ProviderCheckResult[] {
|
|
|
159
179
|
const lookup = resolveKey(providerId);
|
|
160
180
|
|
|
161
181
|
if (!lookup.found) {
|
|
182
|
+
// Check if a cross-provider can serve this provider's models
|
|
183
|
+
const routes = PROVIDER_ROUTES[providerId];
|
|
184
|
+
const routeProvider = routes?.find(routeId => resolveKey(routeId).found);
|
|
185
|
+
if (routeProvider) {
|
|
186
|
+
const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider);
|
|
187
|
+
const routeLabel = routeInfo?.label ?? routeProvider;
|
|
188
|
+
results.push({
|
|
189
|
+
name: providerId,
|
|
190
|
+
label,
|
|
191
|
+
category: "llm",
|
|
192
|
+
status: "ok",
|
|
193
|
+
message: `${label} — available via ${routeLabel}`,
|
|
194
|
+
required: true,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
|
|
163
200
|
results.push({
|
|
164
201
|
name: providerId,
|
|
@@ -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 {
|