pi-long-task 0.5.0 → 0.7.0

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.
@@ -0,0 +1,156 @@
1
+ import type { PlannerBudget, PlannerComplexitySignal } from "./planner_config.ts";
2
+
3
+ export type PlannerProgressState = "started" | "active" | "grace";
4
+
5
+ /**
6
+ * One user-facing planner timing update. Millisecond fields remain available to
7
+ * integrations while `message` consistently uses friendly duration wording.
8
+ */
9
+ export interface PlannerProgressEvent {
10
+ state: PlannerProgressState;
11
+ message: string;
12
+ budgetMs: number;
13
+ gracePeriodMs: number;
14
+ elapsedMs: number;
15
+ remainingMs: number;
16
+ graceRemainingMs?: number;
17
+ budget: Readonly<PlannerBudget>;
18
+ }
19
+
20
+ export type PlannerProgressHandler = (event: Readonly<PlannerProgressEvent>) => void;
21
+
22
+ /** Formats a duration for people rather than exposing raw millisecond counts. */
23
+ export function formatFriendlyDuration(durationMs: number): string {
24
+ const milliseconds = Math.max(0, Number.isFinite(durationMs) ? durationMs : 0);
25
+ if (milliseconds > 0 && milliseconds < 1_000) {
26
+ return "less than 1 second";
27
+ }
28
+
29
+ const totalSeconds = Math.round(milliseconds / 1_000);
30
+ if (totalSeconds < 60) {
31
+ return plural(totalSeconds, "second");
32
+ }
33
+
34
+ const totalMinutes = Math.floor(totalSeconds / 60);
35
+ const seconds = totalSeconds % 60;
36
+ if (totalMinutes < 60) {
37
+ return joinDurationParts(plural(totalMinutes, "minute"), seconds > 0 ? plural(seconds, "second") : undefined);
38
+ }
39
+
40
+ const totalHours = Math.floor(totalMinutes / 60);
41
+ const minutes = totalMinutes % 60;
42
+ if (totalHours < 24) {
43
+ return joinDurationParts(plural(totalHours, "hour"), minutes > 0 ? plural(minutes, "minute") : undefined);
44
+ }
45
+
46
+ const days = Math.floor(totalHours / 24);
47
+ const hours = totalHours % 24;
48
+ return joinDurationParts(plural(days, "day"), hours > 0 ? plural(hours, "hour") : undefined);
49
+ }
50
+
51
+ export function createPlannerStartedProgress(
52
+ budget: Readonly<PlannerBudget>,
53
+ gracePeriodMs: number,
54
+ ): PlannerProgressEvent {
55
+ const budgetText = formatFriendlyDuration(budget.timeoutMs);
56
+ const sourceText = budget.source === "explicit" ? " (explicitly configured)" : "";
57
+ const adaptiveText = adaptiveExtensionExplanation(budget);
58
+ const graceText =
59
+ gracePeriodMs > 0
60
+ ? ` A ${formatFriendlyDurationModifier(gracePeriodMs)} graceful-shutdown period is available afterward.`
61
+ : " No graceful-shutdown period is configured.";
62
+
63
+ return {
64
+ state: "started",
65
+ message: `Creating TODO plan. Effective planning budget: ${budgetText}${sourceText}.${adaptiveText}${graceText}`,
66
+ budgetMs: budget.timeoutMs,
67
+ gracePeriodMs,
68
+ elapsedMs: 0,
69
+ remainingMs: budget.timeoutMs,
70
+ budget,
71
+ };
72
+ }
73
+
74
+ export function createPlannerActiveProgress(
75
+ budget: Readonly<PlannerBudget>,
76
+ gracePeriodMs: number,
77
+ elapsedMs: number,
78
+ ): PlannerProgressEvent {
79
+ const elapsed = clamp(elapsedMs, 0, budget.timeoutMs);
80
+ const remaining = Math.max(0, budget.timeoutMs - elapsed);
81
+ return {
82
+ state: "active",
83
+ message: `Still planning: ${formatFriendlyDuration(elapsed)} elapsed; about ${formatFriendlyDuration(remaining)} remaining in the ${formatFriendlyDuration(budget.timeoutMs)} budget.`,
84
+ budgetMs: budget.timeoutMs,
85
+ gracePeriodMs,
86
+ elapsedMs: elapsed,
87
+ remainingMs: remaining,
88
+ budget,
89
+ };
90
+ }
91
+
92
+ export function createPlannerGraceProgress(
93
+ budget: Readonly<PlannerBudget>,
94
+ gracePeriodMs: number,
95
+ ): PlannerProgressEvent {
96
+ const grace = Math.max(0, gracePeriodMs);
97
+ return {
98
+ state: "grace",
99
+ message: `Planning budget reached after ${formatFriendlyDuration(budget.timeoutMs)}; entering a ${formatFriendlyDurationModifier(grace)} graceful-shutdown period to finish a valid plan.`,
100
+ budgetMs: budget.timeoutMs,
101
+ gracePeriodMs: grace,
102
+ elapsedMs: budget.timeoutMs,
103
+ remainingMs: 0,
104
+ graceRemainingMs: grace,
105
+ budget,
106
+ };
107
+ }
108
+
109
+ /** Three bounded checkpoints provide useful timing without per-second noise. */
110
+ export function plannerProgressCheckpoints(budgetMs: number): number[] {
111
+ const budget = Math.max(0, Math.floor(budgetMs));
112
+ if (budget <= 1) {
113
+ return [];
114
+ }
115
+ return [...new Set([0.25, 0.5, 0.75].map((fraction) => Math.max(1, Math.floor(budget * fraction))))].filter(
116
+ (checkpoint) => checkpoint < budget,
117
+ );
118
+ }
119
+
120
+ function formatFriendlyDurationModifier(durationMs: number): string {
121
+ return formatFriendlyDuration(durationMs).replace(
122
+ /(\d[\d,]*) (seconds?|minutes?|hours?|days?)/g,
123
+ (_match, value: string, unit: string) => `${value}-${unit.replace(/s$/, "")}`,
124
+ );
125
+ }
126
+
127
+ function adaptiveExtensionExplanation(budget: Readonly<PlannerBudget>): string {
128
+ if (!budget.extensionApplied || budget.extensionMs <= 0 || !budget.trigger) {
129
+ return "";
130
+ }
131
+ return ` Adaptive extension: ${formatFriendlyDuration(budget.extensionMs)} because the request includes ${complexitySignalText(budget.trigger)}.`;
132
+ }
133
+
134
+ function complexitySignalText(signal: Readonly<PlannerComplexitySignal>): string {
135
+ switch (signal.kind) {
136
+ case "separately_planned_tasks":
137
+ return `${signal.itemCount} separately planned ${signal.itemCount === 1 ? "task" : "tasks"}`;
138
+ case "explicit_item_count":
139
+ return `an explicit count of ${signal.itemCount} ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
140
+ case "enumerated_deliverables":
141
+ return `${signal.itemCount} enumerated ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
142
+ }
143
+ }
144
+
145
+ function plural(value: number, unit: string): string {
146
+ return `${value.toLocaleString("en-US")} ${unit}${value === 1 ? "" : "s"}`;
147
+ }
148
+
149
+ function joinDurationParts(first: string, second: string | undefined): string {
150
+ return second ? `${first} ${second}` : first;
151
+ }
152
+
153
+ function clamp(value: number, minimum: number, maximum: number): number {
154
+ const finite = Number.isFinite(value) ? value : minimum;
155
+ return Math.min(maximum, Math.max(minimum, Math.round(finite)));
156
+ }
package/src/render.ts CHANGED
@@ -4,6 +4,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
4
4
  import type { GoalLoopState, GoalLoopStatus } from "./goal_loop.ts";
5
5
  import type { TaskProgressModel } from "./task_progress.ts";
6
6
  import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
7
+ import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
7
8
 
8
9
  export interface CoordinatorResultForRendering {
9
10
  status: CoordinatorStatus;
@@ -19,6 +20,7 @@ export interface CoordinatorResultForRendering {
19
20
  remainingTasks?: CoordinatorRemainingTask[];
20
21
  taskProgress?: TaskProgressModel;
21
22
  workerCostTotal?: number;
23
+ capabilityWarnings?: readonly WorkerCapabilityWarning[];
22
24
  goal?: string;
23
25
  error?: string;
24
26
  }
@@ -56,6 +58,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
56
58
  lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
57
59
  }
58
60
 
61
+ if (result.capabilityWarnings?.length) {
62
+ lines.push("Worker capability warnings:", ...result.capabilityWarnings.map((warning) => `- ${warning.message}`));
63
+ }
64
+
59
65
  const commitLines = commits
60
66
  .filter((commit) => commit.hash || commit.error)
61
67
  .map((commit) => {
@@ -385,6 +391,9 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
385
391
  }
386
392
 
387
393
  const lines = [summary.join(" — "), theme.fg("muted", details.summary)];
394
+ for (const warning of details.capabilityWarnings ?? []) {
395
+ lines.push(theme.fg("warning", `Warning: ${warning.message}`));
396
+ }
388
397
  lines.push(theme.fg("dim", `Result: ${details.resultPath ?? details.taskResultPath ?? "unknown"}`));
389
398
  lines.push(theme.fg("dim", `TODO: ${details.todoPath}`));
390
399
 
@@ -450,12 +459,16 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
450
459
 
451
460
  function progressPhaseLabel(phase: string): string {
452
461
  switch (phase) {
462
+ case "capability_warning":
463
+ return "Warning";
453
464
  case "planning":
454
465
  case "planned":
455
466
  return "Thought";
456
467
  case "task_start":
457
468
  case "worker_tool":
458
469
  return "Build";
470
+ case "network_wait":
471
+ return "Network";
459
472
  case "task_done":
460
473
  return "Done";
461
474
  case "task_failed":
@@ -603,10 +616,35 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
603
616
  remainingTasks: remainingTaskSummaries(details.remainingTasks),
604
617
  taskProgress: taskProgressModel(details.taskProgress),
605
618
  workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
619
+ capabilityWarnings: capabilityWarningDetails(details.capabilityWarnings),
606
620
  error: stringValue(details.error),
607
621
  };
608
622
  }
609
623
 
624
+ function capabilityWarningDetails(value: unknown): WorkerCapabilityWarning[] {
625
+ if (!Array.isArray(value)) {
626
+ return [];
627
+ }
628
+ return value.flatMap((item) => {
629
+ const record = recordOrUndefined(item);
630
+ const code = stringValue(record?.code);
631
+ const message = stringValue(record?.message);
632
+ const planningConstraint = stringValue(record?.planningConstraint);
633
+ if (code !== "unavailable_browser_capability" || !message || !planningConstraint) {
634
+ return [];
635
+ }
636
+ return [
637
+ {
638
+ code,
639
+ message,
640
+ planningConstraint,
641
+ requestedCapabilities: stringArray(record?.requestedCapabilities),
642
+ availableTools: stringArray(record?.availableTools),
643
+ },
644
+ ];
645
+ });
646
+ }
647
+
610
648
  function commitSummaries(value: unknown): CoordinatorCommitSummary[] {
611
649
  if (!Array.isArray(value)) {
612
650
  return [];
@@ -15,14 +15,31 @@ export interface GuardedSessionPromptOptions {
15
15
  gracefulShutdownPrompt?: string;
16
16
  diagnostics?: string[];
17
17
  onEvent?: (event: unknown) => void;
18
+ /** Bounded elapsed-time checkpoints while the primary deadline is active. */
19
+ progressCheckpointsMs?: readonly number[];
20
+ onProgressCheckpoint?: (elapsedMs: number) => void;
21
+ /** Called exactly when a positive grace period begins. */
22
+ onGracePeriodStart?: (gracePeriodMs: number) => void;
18
23
  dispose?: boolean;
19
24
  }
20
25
 
21
26
  export interface GuardedSessionPromptResult {
22
27
  assistantText: string;
28
+ /** True when the primary prompt deadline elapsed, even if the prompt safely completed during grace. */
23
29
  timedOut: boolean;
30
+ /** True only when a timed-out prompt settled during the configured grace period. */
31
+ completedDuringGrace: boolean;
32
+ /** True when non-whitespace assistant output was observed before prompt termination. */
33
+ outputObserved: boolean;
34
+ /** True when the session had to be stopped because its grace period expired. */
35
+ graceExpired: boolean;
36
+ /** True when the session was stopped for any reason, including hard timeout. */
24
37
  aborted: boolean;
38
+ /** True only when the caller's AbortSignal cancelled the prompt. */
39
+ cancelled: boolean;
25
40
  error?: string;
41
+ /** Untouched prompt failure for coordinator-level provider/transport classification. */
42
+ failure?: unknown;
26
43
  diagnostics: string[];
27
44
  events: unknown[];
28
45
  sessionFile?: string;
@@ -37,13 +54,19 @@ export async function runGuardedSessionPrompt(
37
54
  const events: unknown[] = [];
38
55
  const timers = new Set<ReturnType<typeof setTimeout>>();
39
56
  let assistantText = "";
57
+ let currentAssistantText = "";
58
+ let outputObserved = false;
40
59
  let timedOut = false;
60
+ let graceExpired = false;
41
61
  let aborted = false;
62
+ let cancelled = false;
42
63
  let error: string | undefined;
64
+ let failure: unknown;
43
65
  let promptSettled = false;
44
66
  let finished = false;
45
67
  let unsubscribe: (() => void) | undefined;
46
68
  let complete: (() => void) | undefined;
69
+ const assistantTextAtStart = latestAssistantText(session, [], "");
47
70
 
48
71
  const completed = new Promise<void>((resolve) => {
49
72
  complete = resolve;
@@ -87,6 +110,14 @@ export async function runGuardedSessionPrompt(
87
110
  }
88
111
  };
89
112
 
113
+ const notifyTiming = (callback: (() => void) | undefined, label: string) => {
114
+ try {
115
+ callback?.();
116
+ } catch (exc) {
117
+ diagnostics.push(`${label} listener failed: ${errorMessage(exc)}`);
118
+ }
119
+ };
120
+
90
121
  const requestGracefulShutdown = () => {
91
122
  const message = options.gracefulShutdownPrompt?.trim();
92
123
  if (!message || finished || promptSettled || aborted) {
@@ -127,13 +158,18 @@ export async function runGuardedSessionPrompt(
127
158
  }
128
159
  timedOut = true;
129
160
  diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
130
- requestGracefulShutdown();
131
161
 
132
162
  const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
163
+ if (graceMs > 0) {
164
+ notifyTiming(() => options.onGracePeriodStart?.(graceMs), "grace-period progress");
165
+ }
166
+ requestGracefulShutdown();
167
+
133
168
  const hardAbort = () => {
134
169
  if (finished || promptSettled) {
135
170
  return;
136
171
  }
172
+ graceExpired = true;
137
173
  abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
138
174
  resolveCompleted();
139
175
  };
@@ -149,20 +185,34 @@ export async function runGuardedSessionPrompt(
149
185
  if (finished || promptSettled) {
150
186
  return;
151
187
  }
152
- abortSession(abortReason(options.abortSignal, "session prompt aborted by outer signal"));
188
+ cancelled = true;
189
+ abortSession(abortReason(options.abortSignal, "session prompt cancelled by outer signal"));
153
190
  resolveCompleted();
154
191
  };
155
192
 
156
193
  try {
157
194
  if (options.abortSignal?.aborted) {
158
195
  aborted = true;
159
- error = abortReason(options.abortSignal, "session prompt aborted before start");
196
+ cancelled = true;
197
+ error = abortReason(options.abortSignal, "session prompt cancelled before start");
160
198
  } else {
161
199
  unsubscribe = session.subscribe((event: unknown) => {
162
200
  events.push(event);
163
- const text = assistantTextFromEvent(event);
164
- if (text) {
165
- assistantText = text;
201
+ if (isAssistantMessageStart(event)) {
202
+ currentAssistantText = "";
203
+ }
204
+ const delta = assistantTextDeltaFromEvent(event);
205
+ if (delta !== undefined) {
206
+ currentAssistantText += delta;
207
+ assistantText = currentAssistantText || assistantText;
208
+ outputObserved ||= delta.trim().length > 0;
209
+ } else {
210
+ const text = assistantTextFromEvent(event);
211
+ if (text) {
212
+ currentAssistantText = text;
213
+ assistantText = text;
214
+ outputObserved ||= text.trim().length > 0;
215
+ }
166
216
  }
167
217
  try {
168
218
  options.onEvent?.(event);
@@ -180,6 +230,7 @@ export async function runGuardedSessionPrompt(
180
230
  },
181
231
  (exc: unknown) => {
182
232
  promptSettled = true;
233
+ failure ??= exc;
183
234
  error = error ?? errorMessage(exc);
184
235
  resolveCompleted();
185
236
  },
@@ -188,12 +239,20 @@ export async function runGuardedSessionPrompt(
188
239
 
189
240
  const limitMs = timeoutMs(options.timeoutMs);
190
241
  if (limitMs > 0) {
242
+ for (const checkpoint of normalizedProgressCheckpoints(options.progressCheckpointsMs, limitMs)) {
243
+ schedule(() => {
244
+ if (!finished && !promptSettled && !timedOut && !aborted) {
245
+ notifyTiming(() => options.onProgressCheckpoint?.(checkpoint), "timing progress");
246
+ }
247
+ }, checkpoint);
248
+ }
191
249
  schedule(triggerTimeout, limitMs);
192
250
  }
193
251
 
194
252
  await completed;
195
253
  }
196
254
  } catch (exc) {
255
+ failure ??= exc;
197
256
  error = error ?? errorMessage(exc);
198
257
  } finally {
199
258
  finished = true;
@@ -201,6 +260,7 @@ export async function runGuardedSessionPrompt(
201
260
  options.abortSignal?.removeEventListener("abort", abortListener);
202
261
  unsubscribe?.();
203
262
  assistantText = latestAssistantText(session, events, assistantText);
263
+ outputObserved ||= assistantText.trim().length > 0 && assistantText !== assistantTextAtStart;
204
264
  if (options.dispose !== false) {
205
265
  try {
206
266
  const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
@@ -215,7 +275,20 @@ export async function runGuardedSessionPrompt(
215
275
  }
216
276
  }
217
277
 
218
- return buildResult(session, events, assistantText, timedOut, aborted, error, diagnostics);
278
+ return buildResult(
279
+ session,
280
+ events,
281
+ assistantText,
282
+ timedOut,
283
+ timedOut && promptSettled && !graceExpired && !aborted && failure === undefined,
284
+ outputObserved,
285
+ graceExpired,
286
+ aborted,
287
+ cancelled,
288
+ error,
289
+ failure,
290
+ diagnostics,
291
+ );
219
292
  }
220
293
 
221
294
  function buildResult(
@@ -223,15 +296,25 @@ function buildResult(
223
296
  events: unknown[],
224
297
  assistantText: string,
225
298
  timedOut: boolean,
299
+ completedDuringGrace: boolean,
300
+ outputObserved: boolean,
301
+ graceExpired: boolean,
226
302
  aborted: boolean,
303
+ cancelled: boolean,
227
304
  error: string | undefined,
305
+ failure: unknown,
228
306
  diagnostics: string[],
229
307
  ): GuardedSessionPromptResult {
230
308
  return {
231
309
  assistantText: latestAssistantText(session, events, assistantText),
232
310
  timedOut,
311
+ completedDuringGrace,
312
+ outputObserved,
313
+ graceExpired,
233
314
  aborted,
315
+ cancelled,
234
316
  error,
317
+ ...(failure === undefined ? {} : { failure }),
235
318
  diagnostics: [...diagnostics],
236
319
  events: [...events],
237
320
  sessionFile: session.sessionFile,
@@ -259,6 +342,16 @@ function timeoutMs(value: number | undefined): number {
259
342
  return Math.max(0, value);
260
343
  }
261
344
 
345
+ function normalizedProgressCheckpoints(values: readonly number[] | undefined, limitMs: number): number[] {
346
+ if (!values) {
347
+ return [];
348
+ }
349
+ return [...new Set(values)]
350
+ .filter((value) => Number.isFinite(value) && value > 0 && value < limitMs)
351
+ .map((value) => Math.floor(value))
352
+ .sort((left, right) => left - right);
353
+ }
354
+
262
355
  function nonNegativeMilliseconds(value: number | undefined): number {
263
356
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
264
357
  return 0;
@@ -270,6 +363,26 @@ function formatMilliseconds(ms: number): string {
270
363
  return `${(ms / 1000).toFixed(3)}s`;
271
364
  }
272
365
 
366
+ function isAssistantMessageStart(event: unknown): boolean {
367
+ return (
368
+ isRecord(event) && event.type === "message_start" && isRecord(event.message) && event.message.role === "assistant"
369
+ );
370
+ }
371
+
372
+ function assistantTextDeltaFromEvent(event: unknown): string | undefined {
373
+ if (!isRecord(event) || event.type !== "message_update" || !isRecord(event.assistantMessageEvent)) {
374
+ return undefined;
375
+ }
376
+ const assistantEvent = event.assistantMessageEvent;
377
+ return assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string"
378
+ ? assistantEvent.delta
379
+ : undefined;
380
+ }
381
+
382
+ function isRecord(value: unknown): value is Record<string, unknown> {
383
+ return typeof value === "object" && value !== null;
384
+ }
385
+
273
386
  function abortReason(signal: AbortSignal | undefined, fallback: string): string {
274
387
  const reason = signal?.reason;
275
388
  if (reason === undefined) {
@@ -14,8 +14,8 @@ const NUMBERED_ITEM_RE = /^\s*\d+[.)]\s+(.+?)\s*$/;
14
14
  const FENCE_RE = /```(?:markdown|md)?\s*\n([\s\S]*?)\n```/gi;
15
15
 
16
16
  export class TodoGenerationError extends Error {
17
- constructor(message: string) {
18
- super(message);
17
+ constructor(message: string, options?: ErrorOptions) {
18
+ super(message, options);
19
19
  this.name = "TodoGenerationError";
20
20
  }
21
21
  }
@@ -133,7 +133,7 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
133
133
  return markdown;
134
134
  }
135
135
 
136
- let next = insertGlobalGoalInstructions(markdown, trimmedGoal);
136
+ let next = insertGlobalInstructions(markdown, goalInstructionLines(trimmedGoal));
137
137
  const coverageGoal = parseCoverageGoal(trimmedGoal);
138
138
  if (coverageGoal) {
139
139
  next = appendCoverageVerificationToTasks(next, coverageGoalVerifyBullet(coverageGoal));
@@ -142,14 +142,33 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
142
142
  return next;
143
143
  }
144
144
 
145
- function insertGlobalGoalInstructions(markdown: string, goal: string): string {
145
+ export function applyWorkerCapabilityConstraintsToTodoMarkdown(
146
+ markdown: string,
147
+ constraints: readonly string[],
148
+ ): string {
149
+ const additions = constraints
150
+ .map(oneLine)
151
+ .filter(Boolean)
152
+ .map((item) => `- ${item}`);
153
+ if (additions.length === 0) {
154
+ return markdown;
155
+ }
156
+ const next = insertGlobalInstructions(markdown, additions);
157
+ validateTodoMarkdown(next);
158
+ return next;
159
+ }
160
+
161
+ function goalInstructionLines(goal: string): string[] {
146
162
  const coverageGoal = parseCoverageGoal(goal);
147
163
  const additions = [`- Long task goal: ${goal}`];
148
164
  if (coverageGoal) {
149
165
  additions.push(`- Coverage goal: ${coverageGoalAction(coverageGoal)}`);
150
166
  additions.push(`- Coverage verification: ${coverageGoalVerification(coverageGoal)}`);
151
167
  }
168
+ return additions;
169
+ }
152
170
 
171
+ function insertGlobalInstructions(markdown: string, additions: readonly string[]): string {
153
172
  const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
154
173
  const progressIndex = lines.findIndex((line) => /^##\s+Progress\s*$/i.test(line.trim()));
155
174
  if (progressIndex < 0) {
@@ -229,9 +248,43 @@ function oneLine(value: string): string {
229
248
  return value.replace(/\s+/g, " ").trim();
230
249
  }
231
250
 
232
- export function buildTodoCreationPrompt(rawInput: string, goal?: string): string {
251
+ export function todoPlanningOnlyPromptBlock(capabilityConstraints: readonly string[] = []): string {
252
+ const capabilityBlock = capabilityConstraints.length
253
+ ? `\n\nWorker capability constraints (preserve these above ## Progress and in affected tasks):\n${capabilityConstraints
254
+ .map((constraint) => `- ${oneLine(constraint)}`)
255
+ .join("\n")}`
256
+ : "";
257
+ return `Planning-only boundary:
258
+ - Produce only a concise executable plan for future workers.
259
+ - Do not perform requested end work: do not implement or write code, execute research or report findings, create requested creative output (prose, stories, copy, designs, or assets), or produce any other final deliverable.
260
+ - Use future-worker action language; do not claim work is complete or invent results.
261
+ - Keep repeated task sections compact: use a one-sentence Goal and Done when, plus only the necessary Status and Verify bullets. Omit rationale, lengthy analysis, summaries, duplicated context, unrequested examples, and boilerplate.
262
+ - Preserve every instruction, constraint, required deliverable, and acceptance condition from the source request and supplied planning context. Put shared constraints above ## Progress and task-specific requirements in the relevant task.${capabilityBlock}`;
263
+ }
264
+
265
+ export function buildTodoCreationPrompt(
266
+ rawInput: string,
267
+ goal?: string,
268
+ capabilityConstraints: readonly string[] = [],
269
+ ): string {
233
270
  const goalBlock = todoGoalPromptBlock(goal);
234
- return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.\n\nRequirements:\n- Output only markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n${goalBlock}\nRaw input:\n\n${rawInput.trim()}\n`;
271
+ return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.
272
+
273
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
274
+
275
+ Required format:
276
+ - Output only markdown, with no commentary and no code fence.
277
+ - Start with exactly: # Pi Long Task TODO
278
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
279
+ - Include a --- separator before task sections.
280
+ - Create sequential sections named ## TODO N — Title.
281
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
282
+ - Keep tasks focused and independently assignable to worker sessions.
283
+ ${goalBlock}
284
+ Raw input:
285
+
286
+ ${rawInput.trim()}
287
+ `;
235
288
  }
236
289
 
237
290
  export function buildTodoRepairPrompt(
@@ -239,9 +292,33 @@ export function buildTodoRepairPrompt(
239
292
  invalidOutput: string,
240
293
  validationError: string,
241
294
  goal?: string,
295
+ capabilityConstraints: readonly string[] = [],
242
296
  ): string {
243
297
  const goalBlock = todoGoalPromptBlock(goal);
244
- return `Your previous response was not valid Pi Long Task TODO markdown. Correct it now.\n\nValidation/extraction error:\n${validationError.trim() || "Unknown validation error."}\n\nRequirements:\n- Output only corrected markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n${goalBlock}\nOriginal raw input:\n\n${rawInput.trim()}\n\nPrevious invalid output:\n\n${invalidOutput.trim()}\n`;
298
+ return `Repair the previous response into valid Pi Long Task TODO markdown. Correct its plan and format only; do not continue or perform any attempted end work.
299
+
300
+ ${todoPlanningOnlyPromptBlock(capabilityConstraints)}
301
+
302
+ Validation/extraction error:
303
+ ${validationError.trim() || "Unknown validation error."}
304
+
305
+ Required format:
306
+ - Output only corrected markdown, with no commentary and no code fence.
307
+ - Start with exactly: # Pi Long Task TODO
308
+ - Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
309
+ - Include a --- separator before task sections.
310
+ - Create sequential sections named ## TODO N — Title.
311
+ - Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
312
+ - Keep tasks focused and independently assignable to worker sessions.
313
+ ${goalBlock}
314
+ Original raw input:
315
+
316
+ ${rawInput.trim()}
317
+
318
+ Previous invalid output (repair its planning content; do not extend its end work):
319
+
320
+ ${invalidOutput.trim()}
321
+ `;
245
322
  }
246
323
 
247
324
  function todoGoalPromptBlock(goal: string | undefined): string {