taskplane 0.22.7 → 0.22.9
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/extensions/task-runner.ts +20 -0
- package/extensions/taskplane/discovery.ts +323 -1
- package/extensions/taskplane/types.ts +80 -1
- package/extensions/taskplane/waves.ts +196 -3
- package/package.json +1 -1
- package/templates/agents/task-merger.md +4 -5
- package/templates/agents/task-worker.md +5 -3
|
@@ -2854,6 +2854,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
2854
2854
|
}
|
|
2855
2855
|
}
|
|
2856
2856
|
|
|
2857
|
+
// ── Step transition: kill persistent reviewer for fresh context ──
|
|
2858
|
+
// When a step completes, the reviewer's context from that step is stale.
|
|
2859
|
+
// Kill it so the next step gets a clean reviewer session.
|
|
2860
|
+
if (newlyCompleted.length > 0 && state.persistentReviewerSession) {
|
|
2861
|
+
console.error(`[task-runner] step(s) completed — killing reviewer for fresh context`);
|
|
2862
|
+
logExecution(statusPath, "Reviewer cleanup",
|
|
2863
|
+
`killing persistent reviewer on step transition (${newlyCompleted.map(s => `Step ${s.number}`).join(", ")} completed)`);
|
|
2864
|
+
if (state.persistentReviewerKill) {
|
|
2865
|
+
try { state.persistentReviewerKill(); } catch {}
|
|
2866
|
+
}
|
|
2867
|
+
state.persistentReviewerSession = null;
|
|
2868
|
+
state.persistentReviewerKill = null;
|
|
2869
|
+
state.persistentReviewerSignalNum = 0;
|
|
2870
|
+
state.reviewerRespawnCount = 0;
|
|
2871
|
+
// Reset per-step code review counters for completed steps
|
|
2872
|
+
for (const step of newlyCompleted) {
|
|
2873
|
+
stepCodeReviewCounts.delete(step.number);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2857
2877
|
// Log iteration summary with progress delta and completed steps
|
|
2858
2878
|
const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
|
|
2859
2879
|
if (newlyCompleted.length > 0) {
|
|
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
|
|
|
6
6
|
import { join, dirname, basename, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
import { FATAL_DISCOVERY_CODES } from "./types.ts";
|
|
9
|
-
import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
|
|
9
|
+
import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, TaskArea, WorkspaceConfig } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
// ── PROMPT.md Parsing ────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -56,6 +56,301 @@ export function normalizeDependencyReference(raw: string): string {
|
|
|
56
56
|
return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
60
|
+
|
|
61
|
+
function normalizeSegmentRepoToken(raw: string): string {
|
|
62
|
+
let token = raw.trim();
|
|
63
|
+
token = token.replace(/^`(.+)`$/, "$1").trim();
|
|
64
|
+
token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim();
|
|
65
|
+
return token.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ParsedSegmentDagBody {
|
|
69
|
+
metadata: PromptSegmentDagMetadata | null;
|
|
70
|
+
error: DiscoveryError | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse optional explicit segment DAG metadata from `## Segment DAG`.
|
|
75
|
+
*
|
|
76
|
+
* Supported v1 syntax:
|
|
77
|
+
*
|
|
78
|
+
* ## Segment DAG
|
|
79
|
+
* Repos:
|
|
80
|
+
* - api
|
|
81
|
+
* - web-client
|
|
82
|
+
* Edges:
|
|
83
|
+
* - api -> web-client
|
|
84
|
+
*
|
|
85
|
+
* Notes:
|
|
86
|
+
* - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace.
|
|
87
|
+
* - Repo IDs are normalized to lowercase and validated against routing repo ID rules.
|
|
88
|
+
* - Unknown edge endpoints (not present in explicit repo list) fail fast.
|
|
89
|
+
* - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`.
|
|
90
|
+
*/
|
|
91
|
+
function parseSegmentDagMetadata(
|
|
92
|
+
content: string,
|
|
93
|
+
taskId: string,
|
|
94
|
+
promptPath: string,
|
|
95
|
+
): ParsedSegmentDagBody {
|
|
96
|
+
const headerMatch = content.match(/^##\s+Segment DAG\s*$/im);
|
|
97
|
+
if (!headerMatch || headerMatch.index === undefined) {
|
|
98
|
+
return { metadata: null, error: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const headerIndex = headerMatch.index;
|
|
102
|
+
const afterHeaderIndex = content.indexOf("\n", headerIndex);
|
|
103
|
+
if (afterHeaderIndex === -1) {
|
|
104
|
+
return { metadata: null, error: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rest = content.slice(afterHeaderIndex + 1);
|
|
108
|
+
const nextBoundary = rest.search(/^##\s|^---/m);
|
|
109
|
+
const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest;
|
|
110
|
+
|
|
111
|
+
const repoIds: string[] = [];
|
|
112
|
+
const repoSet = new Set<string>();
|
|
113
|
+
const edgePairs = new Set<string>();
|
|
114
|
+
const edges: Array<{ fromRepoId: string; toRepoId: string }> = [];
|
|
115
|
+
const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length;
|
|
116
|
+
|
|
117
|
+
let mode: "repos" | "edges" | null = null;
|
|
118
|
+
const lines = body.split(/\r?\n/);
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const rawLine = lines[i];
|
|
122
|
+
const trimmed = rawLine.trim();
|
|
123
|
+
if (!trimmed) continue;
|
|
124
|
+
|
|
125
|
+
if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) {
|
|
126
|
+
mode = "repos";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) {
|
|
130
|
+
mode = "edges";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!mode) {
|
|
135
|
+
return {
|
|
136
|
+
metadata: null,
|
|
137
|
+
error: {
|
|
138
|
+
code: "SEGMENT_DAG_INVALID",
|
|
139
|
+
message:
|
|
140
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
141
|
+
`expected a Repos: or Edges: subsection header before entries.`,
|
|
142
|
+
taskId,
|
|
143
|
+
taskPath: promptPath,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/);
|
|
149
|
+
if (!bulletMatch) {
|
|
150
|
+
return {
|
|
151
|
+
metadata: null,
|
|
152
|
+
error: {
|
|
153
|
+
code: "SEGMENT_DAG_INVALID",
|
|
154
|
+
message:
|
|
155
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
156
|
+
`expected a bullet entry ("- ...").`,
|
|
157
|
+
taskId,
|
|
158
|
+
taskPath: promptPath,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const entry = bulletMatch[1].trim();
|
|
164
|
+
if (!entry) continue;
|
|
165
|
+
|
|
166
|
+
if (mode === "repos") {
|
|
167
|
+
if (entry.includes("->")) {
|
|
168
|
+
return {
|
|
169
|
+
metadata: null,
|
|
170
|
+
error: {
|
|
171
|
+
code: "SEGMENT_DAG_INVALID",
|
|
172
|
+
message:
|
|
173
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
174
|
+
`repo list entries must be a single repo ID.`,
|
|
175
|
+
taskId,
|
|
176
|
+
taskPath: promptPath,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const repoId = normalizeSegmentRepoToken(entry);
|
|
181
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) {
|
|
182
|
+
return {
|
|
183
|
+
metadata: null,
|
|
184
|
+
error: {
|
|
185
|
+
code: "SEGMENT_DAG_INVALID",
|
|
186
|
+
message:
|
|
187
|
+
`Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
188
|
+
`Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
|
|
189
|
+
taskId,
|
|
190
|
+
taskPath: promptPath,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (!repoSet.has(repoId)) {
|
|
195
|
+
repoSet.add(repoId);
|
|
196
|
+
repoIds.push(repoId);
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/);
|
|
202
|
+
if (!edgeMatch) {
|
|
203
|
+
return {
|
|
204
|
+
metadata: null,
|
|
205
|
+
error: {
|
|
206
|
+
code: "SEGMENT_DAG_INVALID",
|
|
207
|
+
message:
|
|
208
|
+
`Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
209
|
+
`Expected format: <repo-a> -> <repo-b>.`,
|
|
210
|
+
taskId,
|
|
211
|
+
taskPath: promptPath,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]);
|
|
217
|
+
const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]);
|
|
218
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) {
|
|
219
|
+
return {
|
|
220
|
+
metadata: null,
|
|
221
|
+
error: {
|
|
222
|
+
code: "SEGMENT_DAG_INVALID",
|
|
223
|
+
message:
|
|
224
|
+
`Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
225
|
+
`Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
|
|
226
|
+
taskId,
|
|
227
|
+
taskPath: promptPath,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (fromRepoId === toRepoId) {
|
|
232
|
+
return {
|
|
233
|
+
metadata: null,
|
|
234
|
+
error: {
|
|
235
|
+
code: "SEGMENT_DAG_INVALID",
|
|
236
|
+
message:
|
|
237
|
+
`Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
|
|
238
|
+
taskId,
|
|
239
|
+
taskPath: promptPath,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const edgeKey = `${fromRepoId}->${toRepoId}`;
|
|
245
|
+
if (!edgePairs.has(edgeKey)) {
|
|
246
|
+
edgePairs.add(edgeKey);
|
|
247
|
+
edges.push({ fromRepoId, toRepoId });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (repoIds.length === 0 && edges.length === 0) {
|
|
252
|
+
return { metadata: null, error: null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const edge of edges) {
|
|
256
|
+
if (!repoSet.has(edge.fromRepoId)) {
|
|
257
|
+
return {
|
|
258
|
+
metadata: null,
|
|
259
|
+
error: {
|
|
260
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
261
|
+
message:
|
|
262
|
+
`Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
|
|
263
|
+
taskId,
|
|
264
|
+
taskPath: promptPath,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (!repoSet.has(edge.toRepoId)) {
|
|
269
|
+
return {
|
|
270
|
+
metadata: null,
|
|
271
|
+
error: {
|
|
272
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
273
|
+
message:
|
|
274
|
+
`Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
|
|
275
|
+
taskId,
|
|
276
|
+
taskPath: promptPath,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sortedEdges = [...edges].sort((a, b) => {
|
|
283
|
+
if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId);
|
|
284
|
+
return a.toRepoId.localeCompare(b.toRepoId);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const adjacency = new Map<string, string[]>();
|
|
288
|
+
for (const repoId of repoIds) {
|
|
289
|
+
adjacency.set(repoId, []);
|
|
290
|
+
}
|
|
291
|
+
for (const edge of sortedEdges) {
|
|
292
|
+
adjacency.get(edge.fromRepoId)!.push(edge.toRepoId);
|
|
293
|
+
}
|
|
294
|
+
for (const neighbors of adjacency.values()) {
|
|
295
|
+
neighbors.sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const visited = new Set<string>();
|
|
299
|
+
const stack = new Set<string>();
|
|
300
|
+
const path: string[] = [];
|
|
301
|
+
let cycle: string[] | null = null;
|
|
302
|
+
|
|
303
|
+
function dfs(repoId: string): void {
|
|
304
|
+
if (cycle) return;
|
|
305
|
+
visited.add(repoId);
|
|
306
|
+
stack.add(repoId);
|
|
307
|
+
path.push(repoId);
|
|
308
|
+
|
|
309
|
+
const neighbors = adjacency.get(repoId) || [];
|
|
310
|
+
for (const next of neighbors) {
|
|
311
|
+
if (cycle) return;
|
|
312
|
+
if (!visited.has(next)) {
|
|
313
|
+
dfs(next);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (stack.has(next)) {
|
|
317
|
+
const start = path.indexOf(next);
|
|
318
|
+
cycle = [...path.slice(start), next];
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
path.pop();
|
|
324
|
+
stack.delete(repoId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const repoId of [...repoIds].sort()) {
|
|
328
|
+
if (!visited.has(repoId)) dfs(repoId);
|
|
329
|
+
if (cycle) break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (cycle) {
|
|
333
|
+
return {
|
|
334
|
+
metadata: null,
|
|
335
|
+
error: {
|
|
336
|
+
code: "SEGMENT_DAG_INVALID",
|
|
337
|
+
message:
|
|
338
|
+
`Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
|
|
339
|
+
taskId,
|
|
340
|
+
taskPath: promptPath,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
metadata: {
|
|
347
|
+
repoIds,
|
|
348
|
+
edges: sortedEdges,
|
|
349
|
+
},
|
|
350
|
+
error: null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
59
354
|
/**
|
|
60
355
|
* Parse a PROMPT.md file and extract orchestrator-relevant metadata.
|
|
61
356
|
*
|
|
@@ -255,6 +550,16 @@ export function parsePromptForOrchestrator(
|
|
|
255
550
|
}
|
|
256
551
|
}
|
|
257
552
|
|
|
553
|
+
// ── Extract optional explicit segment DAG metadata ──────────
|
|
554
|
+
const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath));
|
|
555
|
+
if (segmentDagResult.error) {
|
|
556
|
+
return {
|
|
557
|
+
task: null,
|
|
558
|
+
error: segmentDagResult.error,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const explicitSegmentDag = segmentDagResult.metadata;
|
|
562
|
+
|
|
258
563
|
return {
|
|
259
564
|
task: {
|
|
260
565
|
taskId,
|
|
@@ -268,6 +573,7 @@ export function parsePromptForOrchestrator(
|
|
|
268
573
|
areaName,
|
|
269
574
|
status: "pending",
|
|
270
575
|
...(promptRepoId ? { promptRepoId } : {}),
|
|
576
|
+
...(explicitSegmentDag ? { explicitSegmentDag } : {}),
|
|
271
577
|
},
|
|
272
578
|
error: null,
|
|
273
579
|
};
|
|
@@ -893,6 +1199,22 @@ export function resolveTaskRouting(
|
|
|
893
1199
|
const strictMode = workspaceConfig.routing.strict === true;
|
|
894
1200
|
|
|
895
1201
|
for (const task of discovery.pending.values()) {
|
|
1202
|
+
// ── Explicit segment DAG repo validation (workspace IDs) ─
|
|
1203
|
+
if (task.explicitSegmentDag) {
|
|
1204
|
+
const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
|
|
1205
|
+
if (unknownRepos.length > 0) {
|
|
1206
|
+
errors.push({
|
|
1207
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
1208
|
+
message:
|
|
1209
|
+
`Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` +
|
|
1210
|
+
`Known repos: ${[...validRepoIds.keys()].join(", ")}`,
|
|
1211
|
+
taskId: task.taskId,
|
|
1212
|
+
taskPath: task.promptPath,
|
|
1213
|
+
});
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
896
1218
|
// ── Strict mode enforcement ──────────────────────────────
|
|
897
1219
|
// When strict routing is enabled, every task MUST declare an
|
|
898
1220
|
// explicit execution target in PROMPT.md. Area-level and
|
|
@@ -60,6 +60,26 @@ export interface OrchestratorConfig {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Stable segment identifier: `<taskId>::<repoId>` */
|
|
64
|
+
export type SegmentId = `${string}::${string}`;
|
|
65
|
+
|
|
66
|
+
/** How an intra-task segment edge was produced (for observability/debugging). */
|
|
67
|
+
export type SegmentEdgeProvenance = "explicit" | "inferred";
|
|
68
|
+
|
|
69
|
+
/** Repo-scoped edge parsed from optional `## Segment DAG` prompt metadata. */
|
|
70
|
+
export interface PromptSegmentDagEdge {
|
|
71
|
+
fromRepoId: string;
|
|
72
|
+
toRepoId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Optional explicit segment metadata parsed from PROMPT.md. */
|
|
76
|
+
export interface PromptSegmentDagMetadata {
|
|
77
|
+
/** Repo IDs participating in this task's segment graph, first-seen order. */
|
|
78
|
+
repoIds: string[];
|
|
79
|
+
/** Directed repo-level edges, sorted by `fromRepoId` then `toRepoId`. */
|
|
80
|
+
edges: PromptSegmentDagEdge[];
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
/** A parsed task from PROMPT.md, enriched for orchestrator use */
|
|
64
84
|
export interface ParsedTask {
|
|
65
85
|
taskId: string;
|
|
@@ -76,8 +96,61 @@ export interface ParsedTask {
|
|
|
76
96
|
promptRepoId?: string;
|
|
77
97
|
/** Resolved repo ID after routing precedence (workspace mode only). Undefined in repo mode. */
|
|
78
98
|
resolvedRepoId?: string;
|
|
99
|
+
/** Optional explicit segment DAG metadata from `## Segment DAG`. */
|
|
100
|
+
explicitSegmentDag?: PromptSegmentDagMetadata;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build a stable segment ID from task + repo identity (`<taskId>::<repoId>`). */
|
|
104
|
+
export function buildSegmentId(taskId: string, repoId: string): SegmentId {
|
|
105
|
+
return `${taskId}::${repoId}` as SegmentId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One repo-scoped segment node for a task. */
|
|
109
|
+
export interface TaskSegmentNode {
|
|
110
|
+
segmentId: SegmentId;
|
|
111
|
+
taskId: string;
|
|
112
|
+
repoId: string;
|
|
113
|
+
/**
|
|
114
|
+
* Deterministic segment order within a task (0-indexed).
|
|
115
|
+
* Stable tie-break: repoId lexical order.
|
|
116
|
+
*/
|
|
117
|
+
order: number;
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
/** Directed edge between two segment nodes in the same task. */
|
|
121
|
+
export interface TaskSegmentEdge {
|
|
122
|
+
fromSegmentId: SegmentId;
|
|
123
|
+
toSegmentId: SegmentId;
|
|
124
|
+
provenance: SegmentEdgeProvenance;
|
|
125
|
+
/** Optional explanation of why this edge exists (debug/telemetry aid). */
|
|
126
|
+
reason?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Deterministic segment plan for one task.
|
|
131
|
+
*
|
|
132
|
+
* Ordering contract:
|
|
133
|
+
* - `segments`: sorted by `order`, then `repoId`
|
|
134
|
+
* - `edges`: sorted by `fromSegmentId`, then `toSegmentId`
|
|
135
|
+
*/
|
|
136
|
+
export interface TaskSegmentPlan {
|
|
137
|
+
taskId: string;
|
|
138
|
+
segments: TaskSegmentNode[];
|
|
139
|
+
edges: TaskSegmentEdge[];
|
|
140
|
+
/**
|
|
141
|
+
* explicit-dag: parsed from prompt metadata
|
|
142
|
+
* inferred-sequential: deterministic fallback inference
|
|
143
|
+
* repo-singleton: repo mode fallback (`resolvedRepoId ?? "default"`)
|
|
144
|
+
*/
|
|
145
|
+
mode: "explicit-dag" | "inferred-sequential" | "repo-singleton";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* TaskId-keyed segment plans.
|
|
150
|
+
* Iteration order must be deterministic: sort task IDs lexicographically.
|
|
151
|
+
*/
|
|
152
|
+
export type TaskSegmentPlanMap = Map<string, TaskSegmentPlan>;
|
|
153
|
+
|
|
81
154
|
/** A wave: a group of tasks whose dependencies are all satisfied */
|
|
82
155
|
export interface WaveAssignment {
|
|
83
156
|
waveNumber: number;
|
|
@@ -403,7 +476,9 @@ export interface DiscoveryError {
|
|
|
403
476
|
| "DEP_SOURCE_FALLBACK"
|
|
404
477
|
| "TASK_REPO_UNRESOLVED"
|
|
405
478
|
| "TASK_REPO_UNKNOWN"
|
|
406
|
-
| "TASK_ROUTING_STRICT"
|
|
479
|
+
| "TASK_ROUTING_STRICT"
|
|
480
|
+
| "SEGMENT_DAG_INVALID"
|
|
481
|
+
| "SEGMENT_REPO_UNKNOWN";
|
|
407
482
|
message: string;
|
|
408
483
|
taskPath?: string;
|
|
409
484
|
taskId?: string;
|
|
@@ -425,6 +500,8 @@ export const FATAL_DISCOVERY_CODES: ReadonlyArray<DiscoveryError["code"]> = [
|
|
|
425
500
|
"TASK_REPO_UNRESOLVED",
|
|
426
501
|
"TASK_REPO_UNKNOWN",
|
|
427
502
|
"TASK_ROUTING_STRICT",
|
|
503
|
+
"SEGMENT_DAG_INVALID",
|
|
504
|
+
"SEGMENT_REPO_UNKNOWN",
|
|
428
505
|
] as const;
|
|
429
506
|
|
|
430
507
|
/** Result of the full discovery pipeline */
|
|
@@ -457,6 +534,8 @@ export interface GraphValidationResult {
|
|
|
457
534
|
export interface WaveComputationResult {
|
|
458
535
|
waves: WaveAssignment[];
|
|
459
536
|
errors: DiscoveryError[];
|
|
537
|
+
/** Optional task→segment planning map (TP-080, additive contract). */
|
|
538
|
+
segmentPlans?: TaskSegmentPlanMap;
|
|
460
539
|
}
|
|
461
540
|
|
|
462
541
|
|
|
@@ -6,8 +6,8 @@ import { join } from "path";
|
|
|
6
6
|
|
|
7
7
|
import { parseDependencyReference } from "./discovery.ts";
|
|
8
8
|
import { resolveOperatorId } from "./naming.ts";
|
|
9
|
-
import { AllocationError, getTaskDurationMinutes } from "./types.ts";
|
|
10
|
-
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
|
|
9
|
+
import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts";
|
|
10
|
+
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
|
|
11
11
|
import { getCurrentBranch } from "./git.ts";
|
|
12
12
|
import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
|
|
13
13
|
|
|
@@ -606,6 +606,196 @@ export function resolveBaseBranch(
|
|
|
606
606
|
}
|
|
607
607
|
|
|
608
608
|
|
|
609
|
+
// ── Segment Planning (TP-080) ───────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
612
|
+
const INFERRED_LINEAR_REASON = "inferred:first-appearance-linear-chain";
|
|
613
|
+
|
|
614
|
+
function normalizeRepoIdCandidate(raw: string): string | null {
|
|
615
|
+
const candidate = raw.trim().toLowerCase();
|
|
616
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(candidate)) return null;
|
|
617
|
+
return candidate;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function collectKnownRepoIds(pending: Map<string, ParsedTask>): Set<string> {
|
|
621
|
+
const known = new Set<string>();
|
|
622
|
+
for (const task of pending.values()) {
|
|
623
|
+
if (task.resolvedRepoId) {
|
|
624
|
+
const repoId = normalizeRepoIdCandidate(task.resolvedRepoId);
|
|
625
|
+
if (repoId) known.add(repoId);
|
|
626
|
+
}
|
|
627
|
+
if (task.explicitSegmentDag) {
|
|
628
|
+
for (const repoIdRaw of task.explicitSegmentDag.repoIds) {
|
|
629
|
+
const repoId = normalizeRepoIdCandidate(repoIdRaw);
|
|
630
|
+
if (repoId) known.add(repoId);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return known;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function extractRepoPrefixFromFileScope(fileScopeEntry: string): string | null {
|
|
638
|
+
const normalized = fileScopeEntry.replace(/\\/g, "/").trim();
|
|
639
|
+
if (!normalized) return null;
|
|
640
|
+
const firstSegment = normalized.split("/")[0]?.trim();
|
|
641
|
+
if (!firstSegment) return null;
|
|
642
|
+
return normalizeRepoIdCandidate(firstSegment);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface InferredRepoOrder {
|
|
646
|
+
repoIds: string[];
|
|
647
|
+
usedFallback: boolean;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Build deterministic repo ordering for inferred segment plans.
|
|
652
|
+
*
|
|
653
|
+
* Signal precedence:
|
|
654
|
+
* 1) file scope repo prefixes (first appearance)
|
|
655
|
+
* 2) dependency task repos (first appearance)
|
|
656
|
+
* 3) fallback to `resolvedRepoId`, then synthetic `default`
|
|
657
|
+
*/
|
|
658
|
+
export function inferTaskRepoOrder(
|
|
659
|
+
task: ParsedTask,
|
|
660
|
+
pending: Map<string, ParsedTask>,
|
|
661
|
+
knownRepoIds: Set<string>,
|
|
662
|
+
): InferredRepoOrder {
|
|
663
|
+
const firstAppearance = new Map<string, number>();
|
|
664
|
+
let cursor = 0;
|
|
665
|
+
|
|
666
|
+
function record(repoIdRaw: string, requireKnown = false): string | null {
|
|
667
|
+
const repoId = normalizeRepoIdCandidate(repoIdRaw);
|
|
668
|
+
if (!repoId) return null;
|
|
669
|
+
if (requireKnown && knownRepoIds.size > 0 && !knownRepoIds.has(repoId)) return null;
|
|
670
|
+
if (!firstAppearance.has(repoId)) {
|
|
671
|
+
firstAppearance.set(repoId, cursor++);
|
|
672
|
+
}
|
|
673
|
+
return repoId;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let hasPrimarySignal = false;
|
|
677
|
+
|
|
678
|
+
for (const scopeEntry of task.fileScope) {
|
|
679
|
+
if (knownRepoIds.size === 0) {
|
|
680
|
+
// Repo-mode guard: without known workspace repo IDs, fileScope prefixes like
|
|
681
|
+
// "src/" or "lib/" are ambiguous and should not create synthetic segments.
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const repoId = extractRepoPrefixFromFileScope(scopeEntry);
|
|
685
|
+
if (!repoId) continue;
|
|
686
|
+
if (record(repoId, true) !== null) {
|
|
687
|
+
hasPrimarySignal = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const depRaw of task.dependencies) {
|
|
692
|
+
const depId = parseDependencyReference(depRaw).taskId;
|
|
693
|
+
const depTask = pending.get(depId);
|
|
694
|
+
if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) {
|
|
695
|
+
hasPrimarySignal = true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!hasPrimarySignal) {
|
|
700
|
+
const fallback = normalizeRepoIdCandidate(task.resolvedRepoId ?? "") || "default";
|
|
701
|
+
return {
|
|
702
|
+
repoIds: [fallback],
|
|
703
|
+
usedFallback: true,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (task.resolvedRepoId) {
|
|
708
|
+
record(task.resolvedRepoId, true);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const repoIds = [...firstAppearance.entries()]
|
|
712
|
+
.sort((a, b) => {
|
|
713
|
+
if (a[1] !== b[1]) return a[1] - b[1];
|
|
714
|
+
return a[0].localeCompare(b[0]);
|
|
715
|
+
})
|
|
716
|
+
.map(([repoId]) => repoId);
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
repoIds,
|
|
720
|
+
usedFallback: false,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function sortSegmentEdges<T extends { fromSegmentId: string; toSegmentId: string }>(
|
|
725
|
+
edges: T[],
|
|
726
|
+
): T[] {
|
|
727
|
+
return [...edges].sort((a, b) => {
|
|
728
|
+
if (a.fromSegmentId !== b.fromSegmentId) return a.fromSegmentId.localeCompare(b.fromSegmentId);
|
|
729
|
+
return a.toSegmentId.localeCompare(b.toSegmentId);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function buildSegmentNodes(taskId: string, repoIds: string[]) {
|
|
734
|
+
const nodes = repoIds.map((repoId, order) => ({
|
|
735
|
+
segmentId: buildSegmentId(taskId, repoId),
|
|
736
|
+
taskId,
|
|
737
|
+
repoId,
|
|
738
|
+
order,
|
|
739
|
+
}));
|
|
740
|
+
return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function buildSegmentPlanForTask(
|
|
744
|
+
task: ParsedTask,
|
|
745
|
+
pending: Map<string, ParsedTask>,
|
|
746
|
+
knownRepoIds: Set<string>,
|
|
747
|
+
): TaskSegmentPlan {
|
|
748
|
+
if (task.explicitSegmentDag) {
|
|
749
|
+
const repoIds = [...task.explicitSegmentDag.repoIds];
|
|
750
|
+
const segments = buildSegmentNodes(task.taskId, repoIds);
|
|
751
|
+
const edges = sortSegmentEdges(
|
|
752
|
+
task.explicitSegmentDag.edges.map((edge) => ({
|
|
753
|
+
fromSegmentId: buildSegmentId(task.taskId, edge.fromRepoId),
|
|
754
|
+
toSegmentId: buildSegmentId(task.taskId, edge.toRepoId),
|
|
755
|
+
provenance: "explicit" as const,
|
|
756
|
+
reason: "prompt:segment-dag",
|
|
757
|
+
})),
|
|
758
|
+
);
|
|
759
|
+
return {
|
|
760
|
+
taskId: task.taskId,
|
|
761
|
+
segments,
|
|
762
|
+
edges,
|
|
763
|
+
mode: "explicit-dag",
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const inferred = inferTaskRepoOrder(task, pending, knownRepoIds);
|
|
768
|
+
const segments = buildSegmentNodes(task.taskId, inferred.repoIds);
|
|
769
|
+
const edges = sortSegmentEdges(
|
|
770
|
+
segments.slice(0, -1).map((segment, idx) => ({
|
|
771
|
+
fromSegmentId: segment.segmentId,
|
|
772
|
+
toSegmentId: segments[idx + 1].segmentId,
|
|
773
|
+
provenance: "inferred" as const,
|
|
774
|
+
reason: INFERRED_LINEAR_REASON,
|
|
775
|
+
})),
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
taskId: task.taskId,
|
|
780
|
+
segments,
|
|
781
|
+
edges,
|
|
782
|
+
mode: inferred.usedFallback ? "repo-singleton" : "inferred-sequential",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Build a deterministic taskId→segmentPlan map for the whole pending set. */
|
|
787
|
+
export function buildTaskSegmentPlans(pending: Map<string, ParsedTask>): TaskSegmentPlanMap {
|
|
788
|
+
const knownRepoIds = collectKnownRepoIds(pending);
|
|
789
|
+
const plans: TaskSegmentPlanMap = new Map();
|
|
790
|
+
for (const taskId of [...pending.keys()].sort()) {
|
|
791
|
+
const task = pending.get(taskId);
|
|
792
|
+
if (!task) continue;
|
|
793
|
+
plans.set(taskId, buildSegmentPlanForTask(task, pending, knownRepoIds));
|
|
794
|
+
}
|
|
795
|
+
return plans;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
609
799
|
// ── Lane Assignment ──────────────────────────────────────────────────
|
|
610
800
|
|
|
611
801
|
/**
|
|
@@ -1181,6 +1371,9 @@ export function computeWaveAssignments(
|
|
|
1181
1371
|
return { waves: [], errors: waveErrors };
|
|
1182
1372
|
}
|
|
1183
1373
|
|
|
1374
|
+
// Step 3.5: Build additive segment planning output (deterministic map)
|
|
1375
|
+
const segmentPlans = buildTaskSegmentPlans(pending);
|
|
1376
|
+
|
|
1184
1377
|
// Step 4: Assign tasks to lanes within each wave
|
|
1185
1378
|
const waveAssignments: WaveAssignment[] = [];
|
|
1186
1379
|
for (let i = 0; i < rawWaves.length; i++) {
|
|
@@ -1199,5 +1392,5 @@ export function computeWaveAssignments(
|
|
|
1199
1392
|
});
|
|
1200
1393
|
}
|
|
1201
1394
|
|
|
1202
|
-
return { waves: waveAssignments, errors };
|
|
1395
|
+
return { waves: waveAssignments, errors, segmentPlans };
|
|
1203
1396
|
}
|
package/package.json
CHANGED
|
@@ -70,12 +70,11 @@ Use the source branch and merge message from the merge request.
|
|
|
70
70
|
|
|
71
71
|
### Step 4: Verification
|
|
72
72
|
|
|
73
|
-
Run each verification command from the merge request
|
|
73
|
+
Run each verification command from the merge request's `## Verification Commands`
|
|
74
|
+
section **exactly as written**. Do NOT substitute your own test commands — the
|
|
75
|
+
merge request contains the project's actual test command.
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
npm test # Unit/integration checks
|
|
77
|
-
npm run build # Build/compile checks
|
|
78
|
-
```
|
|
77
|
+
If the verification section is empty, skip verification and proceed with the merge.
|
|
79
78
|
|
|
80
79
|
**If verification passes:** Write result with `status: "SUCCESS"` (or
|
|
81
80
|
`"CONFLICT_RESOLVED"` if conflicts were auto-resolved).
|
|
@@ -205,7 +205,8 @@ value.
|
|
|
205
205
|
- **APPROVE** → proceed to next step
|
|
206
206
|
- **RETHINK** → reconsider your plan approach, adjust, then implement
|
|
207
207
|
- **REVISE** → read the review file in `.reviews/` for detailed feedback,
|
|
208
|
-
address the issues, commit fixes, then
|
|
208
|
+
address the issues, commit fixes, then **call `review_step` again** for re-review.
|
|
209
|
+
The same reviewer evaluates whether your fixes address its concerns.
|
|
209
210
|
- **UNAVAILABLE** → reviewer failed, proceed with caution
|
|
210
211
|
|
|
211
212
|
**Example flow for a Review Level 2 task, Step 3:**
|
|
@@ -215,8 +216,9 @@ value.
|
|
|
215
216
|
4. Implement Step 3
|
|
216
217
|
5. Commit changes
|
|
217
218
|
6. Call `review_step(step=3, type="code", baseline="<saved SHA>")` → get code feedback
|
|
218
|
-
7. If REVISE: fix issues, commit again
|
|
219
|
-
8.
|
|
219
|
+
7. If REVISE: fix issues, commit, call `review_step(step=3, type="code")` again
|
|
220
|
+
8. Repeat 7 until APPROVE (max 2 code review cycles per step)
|
|
221
|
+
9. Move to Step 4
|
|
220
222
|
|
|
221
223
|
If the `review_step` tool is not available (e.g., non-orchestrated mode), skip
|
|
222
224
|
this protocol entirely — the task-runner handles reviews externally.
|