taskplane 0.24.22 → 0.24.24

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.
@@ -8,12 +8,11 @@
8
8
  * Tools:
9
9
  * - notify_supervisor: send a reply or acknowledgment to supervisor
10
10
  * - escalate_to_supervisor: escalate a blocker or ambiguity
11
+ * - request_segment_expansion: request runtime segment expansion via file IPC
11
12
  *
12
13
  * This extension is intentionally minimal and protocol-focused.
13
14
  * It does NOT own:
14
- * - review_step (deferred to TP-105+ lane-runner bridge work)
15
15
  * - wait_for_review (deferred to persistent reviewer work)
16
- * - request_segment_expansion (deferred to TP-086)
17
16
  *
18
17
  * File I/O only — writes to the agent's outbox directory.
19
18
  * The lane-runner or engine polls outbox and surfaces to supervisor.
@@ -28,6 +27,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkS
28
27
  import { join, dirname } from "path";
29
28
  import { spawn as nodeSpawn } from "child_process";
30
29
  import { randomBytes } from "crypto";
30
+ import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
31
31
 
32
32
  /**
33
33
  * Resolve the outbox directory from environment variables.
@@ -36,7 +36,15 @@ import { randomBytes } from "crypto";
36
36
  * with the bridge extension. Falls back to .pi/bridge-outbox/ in cwd.
37
37
  */
38
38
  function resolveOutboxDir(): string {
39
- return process.env.TASKPLANE_OUTBOX_DIR || join(process.cwd(), ".pi", "bridge-outbox");
39
+ if (process.env.TASKPLANE_OUTBOX_DIR) return process.env.TASKPLANE_OUTBOX_DIR;
40
+
41
+ const batchId = process.env.ORCH_BATCH_ID;
42
+ const agentId = process.env.TASKPLANE_AGENT_ID;
43
+ if (batchId && agentId) {
44
+ return join(process.cwd(), ".pi", "mailbox", batchId, agentId, "outbox");
45
+ }
46
+
47
+ return join(process.cwd(), ".pi", "bridge-outbox");
40
48
  }
41
49
 
42
50
  /**
@@ -75,6 +83,53 @@ function writeOutbox(type: "reply" | "escalate", content: string, replyTo?: stri
75
83
  return { id };
76
84
  }
77
85
 
86
+ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
87
+ const AUTONOMY_PATTERN = /^(interactive|supervised|autonomous)$/;
88
+
89
+ function resolveActiveSegmentId(): string | null {
90
+ const raw = (process.env.TASKPLANE_ACTIVE_SEGMENT_ID || process.env.TASKPLANE_SEGMENT_ID || "").trim();
91
+ if (!raw || raw === "null" || raw === "(none / whole-task execution)") return null;
92
+ return raw;
93
+ }
94
+
95
+ function resolveTaskId(fromSegmentId: string): string {
96
+ const envTaskId = process.env.TASKPLANE_TASK_ID?.trim();
97
+ if (envTaskId) return envTaskId;
98
+ const idx = fromSegmentId.indexOf("::");
99
+ if (idx > 0) return fromSegmentId.slice(0, idx);
100
+ const folder = process.env.TASKPLANE_TASK_FOLDER || "";
101
+ const name = folder.split(/[\\/]/).filter(Boolean).at(-1) || "";
102
+ const match = name.match(/^[A-Z]+-\d+/);
103
+ return match ? match[0] : "unknown";
104
+ }
105
+
106
+ function resolveSupervisorAutonomy(): "interactive" | "supervised" | "autonomous" {
107
+ const value = (process.env.TASKPLANE_SUPERVISOR_AUTONOMY || "autonomous").trim().toLowerCase();
108
+ if (AUTONOMY_PATTERN.test(value)) {
109
+ return value as "interactive" | "supervised" | "autonomous";
110
+ }
111
+ return "autonomous";
112
+ }
113
+
114
+ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string {
115
+ const outboxDir = resolveOutboxDir();
116
+ mkdirSync(outboxDir, { recursive: true });
117
+
118
+ const filename = `segment-expansion-${request.requestId}.json`;
119
+ const finalPath = join(outboxDir, filename);
120
+ const tempPath = `${finalPath}.tmp`;
121
+
122
+ try {
123
+ writeFileSync(tempPath, JSON.stringify(request, null, 2) + "\n", "utf-8");
124
+ renameSync(tempPath, finalPath);
125
+ } catch (err) {
126
+ try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ }
127
+ throw new Error(`Failed to write segment expansion request: ${err instanceof Error ? err.message : String(err)}`);
128
+ }
129
+
130
+ return finalPath;
131
+ }
132
+
78
133
  export default function (pi: ExtensionAPI) {
79
134
  pi.registerTool({
80
135
  name: "notify_supervisor",
@@ -158,6 +213,144 @@ export default function (pi: ExtensionAPI) {
158
213
  },
159
214
  });
160
215
 
216
+ const activeSegmentId = resolveActiveSegmentId();
217
+ if (activeSegmentId) {
218
+ /**
219
+ * Worker RPC for requesting runtime segment expansion.
220
+ *
221
+ * Contract summary:
222
+ * - accepts requested repo IDs + rationale (+ optional placement/edges)
223
+ * - validates request shape and repo ID rules
224
+ * - writes `.pi/mailbox/{batchId}/{agentId}/outbox/segment-expansion-{requestId}.json`
225
+ * - returns acknowledgment payload (`accepted`, `requestId`, `message`)
226
+ */
227
+ pi.registerTool({
228
+ name: "request_segment_expansion",
229
+ label: "Request Segment Expansion",
230
+ description:
231
+ "Request additional repository segments for the current task at runtime. " +
232
+ "Writes a request file to the worker outbox for engine processing.",
233
+ promptSnippet:
234
+ "request_segment_expansion(requestedRepoIds, rationale, placement?, edges?)",
235
+ promptGuidelines: [
236
+ "Use this when runtime discovery reveals additional repos are needed.",
237
+ "Do not wait for approval; continue current segment work after requesting.",
238
+ "requestedRepoIds must be non-empty, unique, and match /^[a-z0-9][a-z0-9._-]*$/.",
239
+ "In supervised/interactive autonomy, this tool returns accepted: false (V1 guard).",
240
+ ],
241
+ parameters: Type.Object({
242
+ requestedRepoIds: Type.Array(Type.String({ description: "Repo ID to add" }), {
243
+ description: "Repo IDs to add as new segments",
244
+ }),
245
+ rationale: Type.String({
246
+ description: "Why these repos are needed",
247
+ }),
248
+ placement: Type.Optional(Type.Union([
249
+ Type.Literal("after-current"),
250
+ Type.Literal("end"),
251
+ ], {
252
+ description: "Where to place new segments: after-current (default) or end",
253
+ })),
254
+ edges: Type.Optional(Type.Array(Type.Object({
255
+ from: Type.String({ description: "Source repo ID" }),
256
+ to: Type.String({ description: "Destination repo ID" }),
257
+ }), {
258
+ description: "Optional ordering edges between requested repos",
259
+ })),
260
+ }),
261
+ async execute(_toolCallId, params) {
262
+ const autonomy = resolveSupervisorAutonomy();
263
+ if (autonomy !== "autonomous") {
264
+ const rejected = {
265
+ accepted: false,
266
+ requestId: null,
267
+ message: "Segment expansion requires autonomous supervisor mode",
268
+ };
269
+ return {
270
+ content: [{ type: "text" as const, text: JSON.stringify(rejected) }],
271
+ details: rejected,
272
+ };
273
+ }
274
+
275
+ const requestedRepoIds = Array.isArray(params.requestedRepoIds)
276
+ ? params.requestedRepoIds.map((id) => String(id).trim()).filter(Boolean)
277
+ : [];
278
+ const rejections: Array<{ repoId: string; reason: string }> = [];
279
+
280
+ if (requestedRepoIds.length === 0) {
281
+ rejections.push({ repoId: "", reason: "requestedRepoIds must be a non-empty array" });
282
+ } else {
283
+ const seen = new Set<string>();
284
+ for (const repoId of requestedRepoIds) {
285
+ if (!REPO_ID_PATTERN.test(repoId)) {
286
+ rejections.push({ repoId, reason: "invalid repo ID format" });
287
+ continue;
288
+ }
289
+ if (seen.has(repoId)) {
290
+ rejections.push({ repoId, reason: "duplicate repo ID in request" });
291
+ continue;
292
+ }
293
+ seen.add(repoId);
294
+ }
295
+ }
296
+
297
+ if (rejections.length > 0) {
298
+ const rejected = {
299
+ accepted: false,
300
+ requestId: null,
301
+ message: "Segment expansion request rejected by tool validation",
302
+ rejections,
303
+ };
304
+ return {
305
+ content: [{ type: "text" as const, text: JSON.stringify(rejected) }],
306
+ details: rejected,
307
+ };
308
+ }
309
+
310
+ const requestId = buildExpansionRequestId();
311
+ const now = Date.now();
312
+ const request: SegmentExpansionRequest = {
313
+ requestId,
314
+ taskId: resolveTaskId(activeSegmentId),
315
+ fromSegmentId: activeSegmentId as SegmentExpansionRequest["fromSegmentId"],
316
+ requestedRepoIds,
317
+ rationale: String(params.rationale ?? "").trim(),
318
+ placement: params.placement === "end" ? "end" : "after-current",
319
+ edges: Array.isArray(params.edges)
320
+ ? params.edges
321
+ .filter((edge): edge is { from: string; to: string } => Boolean(edge && typeof edge.from === "string" && typeof edge.to === "string"))
322
+ .map((edge) => ({ from: edge.from.trim(), to: edge.to.trim() }))
323
+ .filter((edge) => edge.from.length > 0 && edge.to.length > 0)
324
+ : [],
325
+ timestamp: now,
326
+ };
327
+
328
+ try {
329
+ writeSegmentExpansionRequest(request);
330
+ const accepted = {
331
+ accepted: true,
332
+ requestId,
333
+ message: "Segment expansion request accepted",
334
+ };
335
+ return {
336
+ content: [{ type: "text" as const, text: JSON.stringify(accepted) }],
337
+ details: accepted,
338
+ };
339
+ } catch (err) {
340
+ const failed = {
341
+ accepted: false,
342
+ requestId: null,
343
+ message: err instanceof Error ? err.message : String(err),
344
+ };
345
+ return {
346
+ content: [{ type: "text" as const, text: JSON.stringify(failed) }],
347
+ details: failed,
348
+ };
349
+ }
350
+ },
351
+ });
352
+ }
353
+
161
354
  // ── review_step Tool (TP-117) ─────────────────────────────────────
162
355
  // Spawns a reviewer subprocess to evaluate work at step boundaries.
163
356
  // The reviewer runs as a separate Pi process, writes feedback to
@@ -107,6 +107,8 @@ export interface EngineWorkerData {
107
107
  agentRoot?: string;
108
108
  /** Force flag for resume */
109
109
  force?: boolean;
110
+ /** Supervisor autonomy mode propagated to worker bridge tools. */
111
+ supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
110
112
  }
111
113
 
112
114
  // ── Serialization helpers (used by both main thread and worker) ──────
@@ -332,6 +334,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
332
334
  data.agentRoot,
333
335
  data.force ?? false,
334
336
  onSupervisorAlert,
337
+ data.supervisorAutonomy ?? "autonomous",
335
338
  )
336
339
  : executeOrchBatch(
337
340
  data.args ?? "",
@@ -346,6 +349,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
346
349
  data.agentRoot,
347
350
  onEngineEvent,
348
351
  onSupervisorAlert,
352
+ data.supervisorAutonomy ?? "autonomous",
349
353
  );
350
354
 
351
355
  enginePromise