pi-long-task 0.3.10 → 0.3.12

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/README.md CHANGED
@@ -280,7 +280,7 @@ Use `pi_goal_task` when you have a high-level outcome instead of a ready TODO pl
280
280
  Use pi_goal_task with goal "modernize the settings page, add tests, and update docs" and commit true.
281
281
  ```
282
282
 
283
- Goal loops default to `commit true`, `maxIterations 50`, a 48-hour total timeout, 3 hours per implementation iteration, and 30 minutes per reviewer pass. Override limits when you want a larger or smaller loop.
283
+ Goal loops default to `commit true`, `minIterations 1`, `maxIterations 50`, a 48-hour total timeout, 3 hours per implementation iteration, and 30 minutes per reviewer pass. Override limits when you want a larger or smaller loop. When you explicitly set `maxIterations` without `minIterations`, that number also becomes the minimum target, so the loop will not stop early just because a reviewer found one pass complete.
284
284
 
285
285
  Examples:
286
286
 
@@ -289,14 +289,14 @@ Run a goal task with commits for goal: build a full Slack alternative chat app f
289
289
  ```
290
290
 
291
291
  ```text
292
- Use pi_goal_task with goal "build a full Slack alternative chat app focused on speed" and commit true and maxIterations 100.
292
+ Use pi_goal_task with goal "build a full Slack alternative chat app focused on speed" and commit true and minIterations 100 and maxIterations 100.
293
293
  ```
294
294
 
295
295
  ```text
296
296
  Use pi_goal_task with goal "ship a polished analytics dashboard with onboarding, tests, docs, and launch notes" and commit false and maxIterations 20.
297
297
  ```
298
298
 
299
- For broad software-product goals like the Slack example, `pi_goal_task` first creates a software/product specification, then generates implementation TODOs from that spec, runs them, reviews completion, and repeats until the spec is complete or safety limits stop the loop.
299
+ For broad software-product goals like the Slack example, `pi_goal_task` first creates a software/product specification, then generates implementation TODOs from that spec, runs them, reviews completion, and repeats until the spec is complete and the minimum iteration target is reached, or until safety limits stop the loop.
300
300
 
301
301
  ### Discovery for vague software goals
302
302
 
@@ -335,7 +335,8 @@ Goal-loop artifacts are stored under `tmp/pi-goal-task/<goal-run-id>/`, includin
335
335
 
336
336
  Safety controls:
337
337
 
338
- - `maxIterations` stops retry loops when the reviewer keeps finding remaining work; default is `50`.
338
+ - `minIterations` prevents early success before the requested number of loops; default is `1`.
339
+ - `maxIterations` stops retry loops when the reviewer keeps finding remaining work; default is `50`. If explicitly provided without `minIterations`, it is also used as the minimum target.
339
340
  - `timeoutMs` caps the overall goal loop; default is `172800000` ms (48 hours).
340
341
  - `iterationTimeoutMs` caps each generated TODO worker iteration; default is `10800000` ms (3 hours).
341
342
  - `reviewerTimeoutMs` caps each reviewer session; default is `1800000` ms (30 minutes).
@@ -365,6 +366,7 @@ Safety controls:
365
366
  {
366
367
  goal: string;
367
368
  commit?: boolean;
369
+ minIterations?: number;
368
370
  maxIterations?: number;
369
371
  timeoutMs?: number;
370
372
  iterationTimeoutMs?: number;
@@ -383,7 +385,7 @@ Use pi_goal_task with goal "build a full Slack alternative chat app focused on s
383
385
  ```
384
386
 
385
387
  ```text
386
- Use pi_goal_task with goal "build a full Slack alternative chat app focused on speed" and commit true and maxIterations 100 and timeoutMs 172800000.
388
+ Use pi_goal_task with goal "build a full Slack alternative chat app focused on speed" and commit true and minIterations 100 and maxIterations 100 and timeoutMs 1296000000.
387
389
  ```
388
390
 
389
391
  For natural-language requests, Pi Long Task routes phrases like "run a long task with commits" to the tool with commits enabled. Phrases like "with goal to have testing line coverage above 80%" are parsed into the `goal` option. If you ask for a long task without mentioning commits, commits stay disabled.
@@ -427,6 +429,8 @@ cd /path/to/pi-long-task
427
429
  npm run check
428
430
  ```
429
431
 
432
+ The development SDK tracks current Pi releases. Pi Long Task uses Pi's `ModelRuntime` worker API on Pi 0.80.8 and newer, with a legacy worker-model fallback for older compatible Pi releases.
433
+
430
434
  Check that Pi can load the extension:
431
435
 
432
436
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
@@ -38,9 +38,9 @@
38
38
  "typebox": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@earendil-works/pi-ai": "^0.74.2",
42
- "@earendil-works/pi-coding-agent": "^0.74.2",
43
- "@earendil-works/pi-tui": "^0.74.2",
41
+ "@earendil-works/pi-ai": "^0.81.1",
42
+ "@earendil-works/pi-coding-agent": "^0.81.1",
43
+ "@earendil-works/pi-tui": "^0.81.1",
44
44
  "@eslint/js": "^10.0.1",
45
45
  "@types/node": "^25.9.3",
46
46
  "eslint": "^10.5.0",
@@ -167,7 +167,7 @@ function buildScopedRequirements(goal: string): GoalScopedRequirements {
167
167
  {
168
168
  id: "REQ-1",
169
169
  title: "Primary product outcome",
170
- description: `Define and deliver the smallest coherent software/product slice that satisfies the user goal: ${goal}`,
170
+ description: `Define and deliver the complete scoped software/product outcome described by the user goal: ${goal}`,
171
171
  priority: "must",
172
172
  acceptanceCriterionIds: ["AC-1", "AC-2"],
173
173
  milestoneIds: ["MS-1", "MS-2"],
@@ -252,10 +252,10 @@ function buildScopedRequirements(goal: string): GoalScopedRequirements {
252
252
  "Implementation workers should use this persisted specification as the definition-of-done instead of relying only on the original vague goal.",
253
253
  ],
254
254
  openQuestions: [
255
- "Who are the primary and secondary users, and what job-to-be-done should the first implementation slice satisfy?",
255
+ "Who are the primary and secondary users, and what job-to-be-done should the complete scoped product satisfy?",
256
256
  "Which repository areas, platforms, integrations, or external services constrain the implementation?",
257
257
  "What measurable product or operational signal proves the delivered slice is successful?",
258
- "Which non-goals or edge cases must remain out of scope for the first implementation pass?",
258
+ "Which non-goals or edge cases must remain out of scope for this goal-oriented delivery?",
259
259
  ],
260
260
  };
261
261
  }
@@ -338,7 +338,7 @@ function buildAcceptanceCriteria(goal: string): GoalAcceptanceCriterion[] {
338
338
  {
339
339
  id: "AC-6",
340
340
  description:
341
- "Relevant launch, positioning, target segment, and growth metric context is available or explicitly deemed unnecessary for the first slice.",
341
+ "Relevant launch, positioning, target segment, and growth metric context is available or explicitly deemed unnecessary for the scoped goal.",
342
342
  requirementIds: ["REQ-6"],
343
343
  verificationGateIds: ["VG-5"],
344
344
  },
@@ -409,7 +409,7 @@ function buildDesignConstraints(goal: string): GoalDesignConstraints {
409
409
  uxPrinciples: [
410
410
  "Optimize for the primary user workflow before secondary or administrative flows.",
411
411
  "Prefer clear, reversible, and observable interactions over hidden automation.",
412
- "Keep first-slice UX simple enough for implementation workers to verify without stakeholder interviews.",
412
+ "Keep UX states explicit enough for implementation workers to verify without stakeholder interviews.",
413
413
  ],
414
414
  uiRequirements: [
415
415
  `Any user-facing UI for '${goal}' should use existing project components, layout conventions, and content tone where available.`,
@@ -433,7 +433,7 @@ function buildDesignConstraints(goal: string): GoalDesignConstraints {
433
433
  },
434
434
  {
435
435
  id: "DC-2",
436
- title: "Accessible first slice",
436
+ title: "Accessible scoped product",
437
437
  description:
438
438
  "Do not mark user-facing work complete without considering keyboard, screen-reader, and contrast needs.",
439
439
  },
@@ -457,8 +457,8 @@ function buildProductConstraints(goal: string): GoalProductConstraints {
457
457
  "Pi goal-task orchestration artifacts under the run directory.",
458
458
  ],
459
459
  businessRules: [
460
- "Only first-slice behavior required to satisfy the scoped goal should be implemented.",
461
- "Out-of-scope enhancements require a later goal or explicit stakeholder decision.",
460
+ "All behavior required to satisfy the scoped goal should be implemented, while unrelated expansion stays out of scope.",
461
+ "Out-of-scope enhancements require a later goal or explicit stakeholder decision",
462
462
  ],
463
463
  compliance: [
464
464
  "Respect existing authorization, privacy, data-retention, and safety constraints present in the repository.",
@@ -476,8 +476,9 @@ function buildProductConstraints(goal: string): GoalProductConstraints {
476
476
  constraints: [
477
477
  {
478
478
  id: "PC-1",
479
- title: "Scoped first release",
480
- description: "Deliver a coherent first slice rather than a broad product rewrite or speculative roadmap.",
479
+ title: "Scoped full-goal delivery",
480
+ description:
481
+ "Deliver the complete scoped goal rather than downgrading broad user intent to an MVP or speculative roadmap.",
481
482
  },
482
483
  {
483
484
  id: "PC-2",
@@ -497,11 +498,11 @@ function buildMarketingGrowthContext(goal: string): GoalMarketingGrowthContext {
497
498
  return {
498
499
  targetSegments: [
499
500
  `Users, teams, or operators whose workflow improves when '${goal}' is delivered.`,
500
- "Early internal adopters or reviewers who can validate the first implementation slice.",
501
+ "Early internal adopters or reviewers who can validate the delivered scoped product.",
501
502
  ],
502
503
  positioning: [
503
- "A scoped software improvement with a clear first-use outcome and reviewable definition-of-done.",
504
- "Avoid promising broad product transformation beyond the accepted first slice.",
504
+ "A scoped software product outcome with clear user value and reviewable definition-of-done.",
505
+ "Avoid downgrading explicit user intent to a partial MVP unless the user asked for an MVP.",
505
506
  ],
506
507
  acquisitionChannels: [
507
508
  "Release notes, README/update documentation, in-app messaging, or internal handoff notes as appropriate.",
@@ -525,10 +526,11 @@ function buildDiscoveryConsolidation(goal: string): GoalDiscoveryConsolidation {
525
526
  {
526
527
  role: "product_owner",
527
528
  title: "Product Owner",
528
- objective: "Define user value, first-slice scope, non-goals, and product acceptance for the vague goal.",
529
+ objective:
530
+ "Define user value, full scoped product requirements, non-goals, and product acceptance for the vague goal.",
529
531
  findings: [
530
532
  `The goal '${goal}' needs a primary user outcome before implementation begins.`,
531
- "Scope should emphasize one coherent product slice and preserve unresolved stakeholder questions.",
533
+ "Scope should preserve explicit user requirements as mandatory and clarify any unresolved stakeholder questions.",
532
534
  ],
533
535
  decisions: [
534
536
  "Use REQ-1 as the core product outcome and AC-1 as the primary acceptance criterion.",
@@ -573,7 +575,7 @@ function buildDiscoveryConsolidation(goal: string): GoalDiscoveryConsolidation {
573
575
  "Use REQ-3 and AC-4 to constrain technical design.",
574
576
  "Make architecture/regression review a required gate.",
575
577
  ],
576
- risks: ["New dependencies or rewrites could exceed the first-slice product scope."],
578
+ risks: ["New dependencies or rewrites could exceed the scoped product requirements."],
577
579
  requirementIds: ["REQ-3"],
578
580
  milestoneIds: ["MS-2"],
579
581
  acceptanceCriterionIds: ["AC-4", "AC-5"],
@@ -587,7 +589,7 @@ function buildDiscoveryConsolidation(goal: string): GoalDiscoveryConsolidation {
587
589
  "Translate vague product intent into workflow, UI state, accessibility, and design-system constraints.",
588
590
  findings: [
589
591
  "User-facing work needs explicit workflow states and accessibility expectations.",
590
- "Existing design conventions should guide any first-slice UI rather than a new visual direction.",
592
+ "Existing design conventions should guide product UI unless the discovered requirements call for a new visual direction.",
591
593
  ],
592
594
  decisions: [
593
595
  "Use REQ-4 and AC-3 to keep UX/design review in scope.",
@@ -655,7 +657,7 @@ function buildDefinitionOfDone(
655
657
  ): GoalDefinitionOfDone {
656
658
  return {
657
659
  summary:
658
- "Done when all must/should scoped requirements are implemented or explicitly deferred, acceptance criteria are satisfied, required verification gates pass or record justified blockers, and review evaluates the result against this persisted product definition.",
660
+ "Done when all must/should scoped requirements are implemented, acceptance criteria are satisfied, required verification gates pass or record justified blockers, and review evaluates the result against this persisted product definition. Do not treat a partial MVP as complete when the original goal requests a broader product.",
659
661
  requirementIds,
660
662
  acceptanceCriterionIds,
661
663
  verificationGateIds: requiredGateIds,
package/src/goal_loop.ts CHANGED
@@ -5,6 +5,7 @@ import type { CoordinatorStatus } from "./types.ts";
5
5
  export const GOAL_LOOP_STATE_SCHEMA_VERSION = 1;
6
6
 
7
7
  export const DEFAULT_GOAL_LOOP_LIMITS = {
8
+ minIterations: 1,
8
9
  maxIterations: 50,
9
10
  timeoutMs: 172_800_000,
10
11
  iterationTimeoutMs: 10_800_000,
@@ -35,6 +36,7 @@ export type GoalIterationStatus =
35
36
  export type GoalLoopStopKind = "cancelled" | "timeout" | "max_iterations" | "complete";
36
37
 
37
38
  export interface GoalLoopLimits {
39
+ minIterations: number;
38
40
  maxIterations: number;
39
41
  timeoutMs: number;
40
42
  iterationTimeoutMs: number;
@@ -42,6 +44,7 @@ export interface GoalLoopLimits {
42
44
  }
43
45
 
44
46
  export interface GoalLoopLimitInput {
47
+ minIterations?: number;
45
48
  maxIterations?: number;
46
49
  timeoutMs?: number;
47
50
  iterationTimeoutMs?: number;
@@ -169,8 +172,16 @@ export class GoalLoopStateError extends Error {
169
172
  }
170
173
 
171
174
  export function normalizeGoalLoopLimits(input: GoalLoopLimitInput = {}): GoalLoopLimits {
175
+ const explicitMaxIterations = optionalPositiveInteger(input.maxIterations);
176
+ const minIterations = positiveInteger(
177
+ input.minIterations,
178
+ explicitMaxIterations ?? DEFAULT_GOAL_LOOP_LIMITS.minIterations,
179
+ );
180
+ const maxIterations = Math.max(minIterations, explicitMaxIterations ?? DEFAULT_GOAL_LOOP_LIMITS.maxIterations);
181
+
172
182
  return {
173
- maxIterations: positiveInteger(input.maxIterations, DEFAULT_GOAL_LOOP_LIMITS.maxIterations),
183
+ minIterations,
184
+ maxIterations,
174
185
  timeoutMs: positiveMilliseconds(input.timeoutMs, DEFAULT_GOAL_LOOP_LIMITS.timeoutMs),
175
186
  iterationTimeoutMs: positiveMilliseconds(input.iterationTimeoutMs, DEFAULT_GOAL_LOOP_LIMITS.iterationTimeoutMs),
176
187
  reviewerTimeoutMs: positiveMilliseconds(input.reviewerTimeoutMs, DEFAULT_GOAL_LOOP_LIMITS.reviewerTimeoutMs),
@@ -442,6 +453,10 @@ export function validateGoalLoopState(value: unknown): GoalLoopState {
442
453
  if (!state.limits || typeof state.limits !== "object") {
443
454
  throw new GoalLoopStateError("Goal loop state is missing limits.");
444
455
  }
456
+ state.limits = normalizeGoalLoopLimits({
457
+ ...state.limits,
458
+ minIterations: state.limits.minIterations ?? DEFAULT_GOAL_LOOP_LIMITS.minIterations,
459
+ });
445
460
  if (!Array.isArray(state.iterations) || !Array.isArray(state.trace)) {
446
461
  throw new GoalLoopStateError("Goal loop state is missing iterations or trace arrays.");
447
462
  }
@@ -553,10 +568,14 @@ function withTrace(state: GoalLoopState, event: GoalLoopTraceEvent): GoalLoopSta
553
568
  }
554
569
 
555
570
  function positiveInteger(value: number | undefined, fallback: number): number {
571
+ return optionalPositiveInteger(value) ?? fallback;
572
+ }
573
+
574
+ function optionalPositiveInteger(value: number | undefined): number | undefined {
556
575
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
557
576
  return Math.floor(value);
558
577
  }
559
- return fallback;
578
+ return undefined;
560
579
  }
561
580
 
562
581
  function positiveMilliseconds(value: number | undefined, fallback: number): number {
@@ -52,6 +52,7 @@ export interface GoalLoopProgressUpdate {
52
52
  status: GoalLoopStatus;
53
53
  currentIteration: number;
54
54
  totalIterations: number;
55
+ minIterations: number;
55
56
  maxIterations: number;
56
57
  limits: GoalLoopLimits;
57
58
  resultPath: string;
@@ -125,6 +126,7 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
125
126
  goalRunId:
126
127
  options.goalRunId ?? `goal-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
127
128
  goalRunDir: options.goalRunDir,
129
+ minIterations: options.minIterations,
128
130
  maxIterations: options.maxIterations,
129
131
  timeoutMs: options.timeoutMs,
130
132
  iterationTimeoutMs: options.iterationTimeoutMs,
@@ -155,6 +157,7 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
155
157
  status: state.status,
156
158
  currentIteration: state.currentIteration,
157
159
  totalIterations: state.iterations.length,
160
+ minIterations: state.limits.minIterations,
158
161
  maxIterations: state.limits.maxIterations,
159
162
  limits: state.limits,
160
163
  resultPath: store.paths.resultPath,
@@ -180,6 +180,8 @@ export async function runGoalReviewSession(options: GoalReviewOptions): Promise<
180
180
  });
181
181
  }
182
182
 
183
+ reviewerResult = enforceMinimumIterationsBeforeCompletion(reviewerResult, state, iteration.iteration);
184
+
183
185
  state = recordReviewerResult(state, iteration.iteration, reviewerResult, { now: now() });
184
186
  await persistStateChange(store, previousTraceLength, state);
185
187
  const updatedIteration = currentIteration(state, iteration.iteration);
@@ -220,6 +222,11 @@ export function buildGoalReviewTaskPayload(options: {
220
222
  const reviewTargetInstruction = options.goalSpecification
221
223
  ? "Review whether the latest worker run satisfies the persisted goal specification and definition-of-done. Treat the persisted specification as the primary review target; keep the original high-level goal available only as traceability/context."
222
224
  : "Review whether the original high-level goal is complete after the latest worker run.";
225
+ const iterationPolicy = `Goal loop iteration policy:
226
+ - Current iteration: ${iteration.iteration}
227
+ - Minimum iterations before completion may stop the loop: ${state.limits.minIterations}
228
+ - Maximum iterations: ${state.limits.maxIterations}
229
+ - If current iteration is below the minimum, do not return "complete" even if the current implementation looks good. Return "incomplete" with concrete remainingWork for the next improvement pass, such as missing verification, hardening, UX polish, security, performance, docs, edge cases, or maintainability follow-up.`;
223
230
  const decisionRules = options.goalSpecification
224
231
  ? `- Use "complete" only when the persisted definition-of-done is satisfied, including in-scope requirements, milestones, acceptance criteria, required verification gates, and applicable design/product constraints.
225
232
  - Use "incomplete" when any required spec requirement, milestone, acceptance criterion, verification gate, artifact, or constraint still needs work and another TODO-generation iteration should be started.
@@ -242,6 +249,8 @@ ${specificationBlock}
242
249
  Goal run: ${state.goalRunId}
243
250
  Iteration: ${iteration.iteration}
244
251
  Generated TODO path: ${generatedTodo?.todoPath ?? "unknown"}
252
+
253
+ ${iterationPolicy}
245
254
  Worker result:
246
255
 
247
256
  ${markdownFence(JSON.stringify(workerResult ?? null, null, 2), "json")}
@@ -279,6 +288,38 @@ export function parseGoalReviewerOutput(
279
288
  };
280
289
  }
281
290
 
291
+ function enforceMinimumIterationsBeforeCompletion(
292
+ result: GoalReviewerResultState,
293
+ state: GoalLoopState,
294
+ iteration: number,
295
+ ): GoalReviewerResultState {
296
+ if (result.decision !== "complete" && !result.complete) {
297
+ return result;
298
+ }
299
+ if (iteration >= state.limits.minIterations) {
300
+ return result;
301
+ }
302
+
303
+ const remainingIterations = Math.max(1, state.limits.minIterations - iteration);
304
+ const remainingWork = result.remainingWork.length > 0 ? result.remainingWork : minimumIterationRemainingWork(state);
305
+ return {
306
+ ...result,
307
+ decision: "incomplete",
308
+ complete: false,
309
+ summary: `Minimum iteration target not reached (${iteration}/${state.limits.minIterations}); continuing goal loop.`,
310
+ rationale: `${result.rationale}\n\nReviewer completion was deferred because this goal loop requires at least ${state.limits.minIterations} iteration(s) before it may stop. ${remainingIterations} more iteration(s) are required, so the next pass should look for concrete improvements, hardening, verification, and polish rather than stopping early.`,
311
+ remainingWork,
312
+ };
313
+ }
314
+
315
+ function minimumIterationRemainingWork(state: GoalLoopState): string[] {
316
+ return [
317
+ `Continue toward the required minimum of ${state.limits.minIterations} goal-loop iterations before accepting completion.`,
318
+ "Run another pass focused on gaps the previous TODO did not cover: verification depth, edge cases, security, performance, UX polish, documentation, maintainability, and product completeness.",
319
+ "Generate concrete implementation or review tasks from the persisted goal specification and latest evidence instead of stopping after the first apparently complete pass.",
320
+ ];
321
+ }
322
+
282
323
  export async function runGoalReviewerSession(options: GoalReviewerRunnerOptions): Promise<GoalReviewerSessionResult> {
283
324
  const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
284
325
  const events: unknown[] = [];
package/src/goal_state.ts CHANGED
@@ -112,6 +112,7 @@ export class GoalStateStore {
112
112
  "",
113
113
  "## Safety limits",
114
114
  "",
115
+ `- Minimum iterations before completion: ${state.limits.minIterations}`,
115
116
  `- Max iterations: ${state.limits.maxIterations}`,
116
117
  `- Run timeout: ${state.limits.timeoutMs}ms`,
117
118
  `- Iteration timeout: ${state.limits.iterationTimeoutMs}ms`,
@@ -147,7 +147,7 @@ export async function runGoalTodoGenerationLongTask(
147
147
  }
148
148
 
149
149
  export function buildGoalTodoGenerationTaskPayload(options: {
150
- state: Pick<GoalLoopState, "goal" | "goalRunId">;
150
+ state: Pick<GoalLoopState, "goal" | "goalRunId" | "limits">;
151
151
  iteration: number;
152
152
  outputPath: string;
153
153
  additionalContext?: string;
@@ -162,6 +162,8 @@ export function buildGoalTodoGenerationTaskPayload(options: {
162
162
  "markdown",
163
163
  )}\n`
164
164
  : "";
165
+ const iterationPolicy = `- Goal-loop iteration target: generate implementation work for iteration ${options.iteration} of at least ${options.state.limits.minIterations} required iteration(s), with a hard maximum of ${options.state.limits.maxIterations}.
166
+ - Do not generate a no-op TODO merely because a previous reviewer believed the goal was complete before the minimum iteration target. Always look for the next concrete improvement, hardening, verification, UX, security, performance, docs, maintainability, or product-completeness pass until the minimum is reached.`;
165
167
 
166
168
  return `# Pi Long Task TODO
167
169
 
@@ -173,6 +175,7 @@ export function buildGoalTodoGenerationTaskPayload(options: {
173
175
  - Write the generated Pi Long Task-compatible TODO markdown to \`${options.outputPath}\`.
174
176
  - Do not wrap the generated file in a code fence and do not include commentary outside the TODO markdown in that file.
175
177
  - Keep generated tasks focused, independently assignable, and safe for separate worker sessions.
178
+ ${iterationPolicy}
176
179
  ${
177
180
  options.goalSpecification
178
181
  ? "- A persisted goal specification is available; derive implementation TODOs from that specification rather than only the original vague goal.\n- Ensure generated tasks explicitly cover relevant requirement IDs, milestones, acceptance criteria, verification gates, constraints, and definition-of-done items from the specification.\n- Include spec IDs (for example REQ-*, MS-*, AC-*, VG-*) in generated task goals/status/verification/done-when guidance wherever applicable.\n"
@@ -21,6 +21,8 @@ const COMMIT_TRUE_RE =
21
21
  const GOAL_RE = /\b(?:with\s+(?:the\s+)?goal|goal)\s*(?::|=|\b(?:to|of|for|that)\b)\s*([\s\S]+)$/i;
22
22
  const TRAILING_COMMIT_MODIFIER_RE =
23
23
  /(?:\s+(?:with|without|no|enable|enabled|disable|disabled)\s+commits?|\s+commits?\s*(?:true|false|on|off|enabled|disabled)|\s+commit\s*:\s*(?:true|false|on|off|yes|no))\s*$/i;
24
+ const TRAILING_ITERATION_MODIFIER_RE =
25
+ /(?:[.;,\s]+(?:(?:use|set)?\s*(?:min(?:imum)?|max(?:imum)?)\s+(?:iterations?|loops?|passes?|cycles?)\s*(?::|=|of|to)?\s*\d+|at\s+least\s+\d+\s*(?:iterations?|loops?|passes?|cycles?)|(?:i\s+want\s+it\s+to\s+)?run\s+(?:for|in)\s*\d+\s*(?:iterations?|loops?|passes?|cycles?)))[.!?]*\s*$/i;
24
26
 
25
27
  export interface ParsedLongTaskRequestOptions {
26
28
  commit: boolean;
@@ -28,6 +30,7 @@ export interface ParsedLongTaskRequestOptions {
28
30
  }
29
31
 
30
32
  export interface ParsedGoalTaskRequestOptions extends ParsedLongTaskRequestOptions {
33
+ minIterations?: number;
31
34
  maxIterations?: number;
32
35
  }
33
36
 
@@ -60,10 +63,13 @@ export function parseLongTaskRequestOptions(text: string): ParsedLongTaskRequest
60
63
  }
61
64
 
62
65
  export function parseGoalTaskRequestOptions(text: string): ParsedGoalTaskRequestOptions {
66
+ const maxIterations = inferMaxIterations(text);
67
+ const minIterations = inferMinIterations(text) ?? inferExactIterationCount(text) ?? maxIterations;
63
68
  return {
64
69
  commit: inferCommitSetting(text) ?? true,
65
70
  goal: inferGoalSetting(text) ?? inferGoalLoopText(text),
66
- maxIterations: inferMaxIterations(text),
71
+ minIterations,
72
+ maxIterations,
67
73
  };
68
74
  }
69
75
 
@@ -114,17 +120,38 @@ export function inferGoalSetting(text: string): string | undefined {
114
120
 
115
121
  function normalizeGoalText(text: string): string | undefined {
116
122
  let goal = text.trim();
117
- while (TRAILING_COMMIT_MODIFIER_RE.test(goal)) {
118
- goal = goal.replace(TRAILING_COMMIT_MODIFIER_RE, "").trim();
123
+ while (TRAILING_COMMIT_MODIFIER_RE.test(goal) || TRAILING_ITERATION_MODIFIER_RE.test(goal)) {
124
+ goal = goal.replace(TRAILING_COMMIT_MODIFIER_RE, "").replace(TRAILING_ITERATION_MODIFIER_RE, "").trim();
119
125
  }
120
126
  goal = goal.replace(/^["'“”‘’]+|["'“”‘’.,;:!?]+$/g, "").trim();
121
127
  return goal || undefined;
122
128
  }
123
129
 
124
130
  function inferMaxIterations(text: string): number | undefined {
125
- const match = /\bmax(?:imum)?\s+(?:iterations?|loops?|passes?)\s*(?::|=|of|to)?\s*(\d+)\b/i.exec(text);
126
- const value = match?.[1] ? Number.parseInt(match[1], 10) : undefined;
127
- return value && Number.isFinite(value) && value > 0 ? value : undefined;
131
+ const match = /\bmax(?:imum)?\s+(?:iterations?|loops?|passes?|cycles?)\s*(?::|=|of|to)?\s*(\d+)\b/i.exec(text);
132
+ return positiveIntegerMatch(match?.[1]);
133
+ }
134
+
135
+ function inferMinIterations(text: string): number | undefined {
136
+ const explicitMin = /\bmin(?:imum)?\s+(?:iterations?|loops?|passes?|cycles?)\s*(?::|=|of|to)?\s*(\d+)\b/i.exec(text);
137
+ if (explicitMin?.[1]) {
138
+ return positiveIntegerMatch(explicitMin[1]);
139
+ }
140
+ const atLeast = /\bat\s+least\s+(\d+)\s*(?:iterations?|loops?|passes?|cycles?)\b/i.exec(text);
141
+ return positiveIntegerMatch(atLeast?.[1]);
142
+ }
143
+
144
+ function inferExactIterationCount(text: string): number | undefined {
145
+ const match =
146
+ /\b(?:run|iterate|loop|cycle|spin)(?:\s+\w+){0,6}?\s+(?:for|in)?\s*(\d+)\s*(?:iterations?|loops?|passes?|cycles?)\b/i.exec(
147
+ text,
148
+ );
149
+ return positiveIntegerMatch(match?.[1]);
150
+ }
151
+
152
+ function positiveIntegerMatch(value: string | undefined): number | undefined {
153
+ const parsed = value ? Number.parseInt(value, 10) : undefined;
154
+ return parsed && Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
128
155
  }
129
156
 
130
157
  function inferGoalLoopText(text: string): string | undefined {
@@ -158,11 +185,13 @@ function buildGoalTaskToolPrompt(originalText: string, options: ParsedGoalTaskRe
158
185
  const goalLine = options.goal
159
186
  ? `Set goal to ${JSON.stringify(options.goal)}.`
160
187
  : "Set goal from the original request.";
188
+ const minIterationsLine = options.minIterations ? [`Set minIterations to ${options.minIterations}.`] : [];
161
189
  const maxIterationsLine = options.maxIterations ? [`Set maxIterations to ${options.maxIterations}.`] : [];
162
190
  return [
163
191
  "Use the pi_goal_task tool for this request.",
164
192
  goalLine,
165
193
  `Set commit to ${options.commit ? "true" : "false"}.`,
194
+ ...minIterationsLine,
166
195
  ...maxIterationsLine,
167
196
  "The goal loop will generate TODO markdown, execute it as a long task, review completion, and repeat until complete or stopped by limits.",
168
197
  "Do not perform the work directly outside pi_goal_task.",
package/src/render.ts CHANGED
@@ -126,6 +126,7 @@ export interface GoalTaskToolRenderDetails {
126
126
  status: GoalLoopStatus;
127
127
  currentIteration: number;
128
128
  totalIterations: number;
129
+ minIterations: number;
129
130
  maxIterations: number;
130
131
  resultPath: string;
131
132
  statePath?: string;
@@ -145,6 +146,7 @@ export function formatGoalLoopResultMessage(result: GoalLoopResultForRendering):
145
146
  `Pi Goal Task: ${state.status}`,
146
147
  `Goal: ${state.goal}`,
147
148
  `Iterations: ${state.iterations.length}/${state.limits.maxIterations}`,
149
+ `Minimum iterations: ${state.limits.minIterations}`,
148
150
  `Result file: ${result.resultPath}`,
149
151
  `State file: ${state.goalRunDir}/GOAL_STATE.json`,
150
152
  ];
@@ -174,6 +176,7 @@ export function goalTaskDetailsFromResult(result: GoalLoopResultForRendering): G
174
176
  status: result.state.status,
175
177
  currentIteration: result.state.currentIteration,
176
178
  totalIterations: result.state.iterations.length,
179
+ minIterations: result.state.limits.minIterations,
177
180
  maxIterations: result.state.limits.maxIterations,
178
181
  resultPath: result.resultPath,
179
182
  statePath: `${result.state.goalRunDir}/GOAL_STATE.json`,
@@ -188,12 +191,20 @@ export function goalTaskDetailsFromResult(result: GoalLoopResultForRendering): G
188
191
  }
189
192
 
190
193
  export function renderGoalTaskToolCall(
191
- args: { goal?: string; commit?: boolean; maxIterations?: number; timeoutMs?: number; reviewerTimeoutMs?: number },
194
+ args: {
195
+ goal?: string;
196
+ commit?: boolean;
197
+ minIterations?: number;
198
+ maxIterations?: number;
199
+ timeoutMs?: number;
200
+ reviewerTimeoutMs?: number;
201
+ },
192
202
  theme: Theme,
193
203
  ): Text {
194
204
  const commit = (args.commit ?? true) ? theme.fg("warning", "commit:on") : theme.fg("dim", "commit:off");
195
205
  const goal = oneLine(args.goal ?? "");
196
206
  const limits = [
207
+ args.minIterations ? `min:${args.minIterations}` : undefined,
197
208
  args.maxIterations ? `max:${args.maxIterations}` : undefined,
198
209
  args.timeoutMs ? `timeout:${args.timeoutMs}ms` : undefined,
199
210
  args.reviewerTimeoutMs ? `review:${args.reviewerTimeoutMs}ms` : undefined,
@@ -227,11 +238,13 @@ function renderGoalTaskProgress(details: Record<string, unknown> | undefined, fa
227
238
  const message = stringValue(details?.message) || firstLine(fallback) || "Pi Goal Task is running...";
228
239
  const phase = stringValue(details?.phase);
229
240
  const iteration = numberValue(details?.iteration) ?? numberValue(details?.currentIteration);
241
+ const minIterations = numberValue(details?.minIterations);
230
242
  const maxIterations = numberValue(details?.maxIterations);
231
243
  const status = stringValue(details?.status) || "running";
232
244
  const cost = numberValue(details?.totalCost);
233
245
  const meta = [
234
246
  iteration ? `iteration ${iteration}${maxIterations ? `/${maxIterations}` : ""}` : undefined,
247
+ minIterations && iteration && iteration < minIterations ? `min:${minIterations}` : undefined,
235
248
  status,
236
249
  cost && cost > 0 ? formatCost(cost) : undefined,
237
250
  ]
@@ -249,6 +262,7 @@ function renderGoalTaskSummary(details: GoalTaskToolRenderDetails, expanded: boo
249
262
  const summary = [
250
263
  `${theme.fg(statusStyle, icon)} ${theme.fg("toolTitle", theme.bold("Pi Goal Task"))} ${theme.fg(statusStyle, details.status)}`,
251
264
  theme.fg("muted", `${details.totalIterations}/${details.maxIterations} iterations`),
265
+ details.totalIterations < details.minIterations ? theme.fg("muted", `min:${details.minIterations}`) : undefined,
252
266
  details.latestReviewerDecision ? theme.fg("muted", `review:${details.latestReviewerDecision}`) : undefined,
253
267
  details.totalCost ? theme.fg("muted", `spend ${formatCost(details.totalCost)}`) : undefined,
254
268
  ].filter(Boolean);
@@ -295,6 +309,7 @@ function goalTaskDetails(details: Record<string, unknown> | undefined): GoalTask
295
309
  status,
296
310
  currentIteration: numberValue(details.currentIteration) ?? 0,
297
311
  totalIterations: numberValue(details.totalIterations) ?? 0,
312
+ minIterations: numberValue(details.minIterations) ?? 0,
298
313
  maxIterations: numberValue(details.maxIterations) ?? 0,
299
314
  resultPath,
300
315
  statePath: stringValue(details.statePath),
package/src/types.ts CHANGED
@@ -37,10 +37,18 @@ export const PiGoalTaskParams = Type.Object(
37
37
  "Whether worker long tasks may commit completed TODO work during the goal loop. Defaults to true for goal loops.",
38
38
  }),
39
39
  ),
40
+ minIterations: Type.Optional(
41
+ Type.Integer({
42
+ minimum: 1,
43
+ description:
44
+ "Minimum number of generate → execute → review iterations before a complete review may stop the loop. If omitted while maxIterations is provided, maxIterations is also used as the minimum target.",
45
+ }),
46
+ ),
40
47
  maxIterations: Type.Optional(
41
48
  Type.Integer({
42
49
  minimum: 1,
43
- description: "Maximum number of generate → execute → review iterations before stopping. Defaults to 50.",
50
+ description:
51
+ "Maximum number of generate → execute → review iterations before stopping. Defaults to 50; when explicitly provided without minIterations, it also acts as the minimum target.",
44
52
  }),
45
53
  ),
46
54
  timeoutMs: Type.Optional(
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+
1
3
  import { coverageGoalAction, coverageGoalVerification, parseCoverageGoal } from "./coverage_goal.ts";
2
4
  import { hasTaskResult, hasTaskResultStatus, isDoneStatus, parseReportedStatus } from "./result_writer.ts";
3
5
  import type { Task } from "./todo_parser.ts";
@@ -243,7 +245,10 @@ export interface CreateWorkerSessionOptions {
243
245
  model?: unknown;
244
246
  modelName?: string;
245
247
  thinkingLevel?: string;
248
+ modelRuntime?: unknown;
249
+ /** @deprecated Compatibility injection for Pi SDK versions before 0.80.8. */
246
250
  authStorage?: unknown;
251
+ /** @deprecated Compatibility injection for Pi SDK versions before 0.80.8. */
247
252
  modelRegistry?: unknown;
248
253
  settingsManager?: unknown;
249
254
  resourceLoader?: unknown;
@@ -306,14 +311,72 @@ remaining:
306
311
  - <remaining item or "none">`;
307
312
  }
308
313
 
314
+ interface WorkerPiSdkModelExports {
315
+ ModelRuntime?: {
316
+ create(options?: { authPath?: string; modelsPath?: string | null }): Promise<unknown>;
317
+ };
318
+ AuthStorage?: {
319
+ create(authPath?: string): unknown;
320
+ };
321
+ ModelRegistry?: {
322
+ create?(authStorage: unknown, modelsPath?: string): unknown;
323
+ };
324
+ }
325
+
326
+ export interface WorkerModelContext {
327
+ resolver: unknown;
328
+ sessionOptions: Record<string, unknown>;
329
+ api: "modelRuntime" | "legacy";
330
+ }
331
+
332
+ export async function createWorkerModelContext(
333
+ sdk: WorkerPiSdkModelExports,
334
+ options: Pick<CreateWorkerSessionOptions, "modelRuntime" | "authStorage" | "modelRegistry">,
335
+ agentDir: string,
336
+ ): Promise<WorkerModelContext> {
337
+ if (options.modelRuntime) {
338
+ return {
339
+ resolver: options.modelRuntime,
340
+ sessionOptions: { modelRuntime: options.modelRuntime },
341
+ api: "modelRuntime",
342
+ };
343
+ }
344
+
345
+ if (sdk.ModelRuntime?.create) {
346
+ const modelRuntime = await sdk.ModelRuntime.create({
347
+ authPath: path.join(agentDir, "auth.json"),
348
+ modelsPath: path.join(agentDir, "models.json"),
349
+ });
350
+ return {
351
+ resolver: modelRuntime,
352
+ sessionOptions: { modelRuntime },
353
+ api: "modelRuntime",
354
+ };
355
+ }
356
+
357
+ const authStorage = options.authStorage ?? sdk.AuthStorage?.create(path.join(agentDir, "auth.json"));
358
+ const modelRegistry =
359
+ options.modelRegistry ?? sdk.ModelRegistry?.create?.(authStorage, path.join(agentDir, "models.json"));
360
+ if (!authStorage || !modelRegistry) {
361
+ throw new Error(
362
+ "Pi Long Task cannot initialize worker model services: this Pi SDK exposes neither ModelRuntime.create() nor the legacy AuthStorage.create()/ModelRegistry.create() API.",
363
+ );
364
+ }
365
+
366
+ return {
367
+ resolver: modelRegistry,
368
+ sessionOptions: { authStorage, modelRegistry },
369
+ api: "legacy",
370
+ };
371
+ }
372
+
309
373
  export async function createIsolatedWorkerSession(
310
374
  options: CreateWorkerSessionOptions,
311
375
  ): Promise<WorkerSessionFactoryResult> {
312
376
  const pi = await import("@earendil-works/pi-coding-agent");
313
377
  const cwd = options.cwd;
314
378
  const agentDir = options.agentDir ?? pi.getAgentDir();
315
- const authStorage = options.authStorage ?? pi.AuthStorage.create();
316
- const modelRegistry = options.modelRegistry ?? pi.ModelRegistry.create(authStorage as never);
379
+ const modelContext = await createWorkerModelContext(pi as unknown as WorkerPiSdkModelExports, options, agentDir);
317
380
  const settingsManager = options.settingsManager ?? pi.SettingsManager.create(cwd, agentDir);
318
381
 
319
382
  applyWorkerSettingsDefaults(settingsManager);
@@ -330,12 +393,12 @@ export async function createIsolatedWorkerSession(
330
393
  await resourceLoader.reload();
331
394
 
332
395
  const model =
333
- options.model ?? (options.modelName ? await resolveWorkerModel(modelRegistry, options.modelName) : undefined);
396
+ options.model ??
397
+ (options.modelName ? await resolveWorkerModel(modelContext.resolver, options.modelName) : undefined);
334
398
  const createOptions: Record<string, unknown> = {
335
399
  cwd,
336
400
  agentDir,
337
- authStorage,
338
- modelRegistry,
401
+ ...modelContext.sessionOptions,
339
402
  settingsManager,
340
403
  resourceLoader,
341
404
  tools: [...(options.tools ?? DEFAULT_WORKER_TOOLS)],
@@ -672,24 +735,18 @@ function applyWorkerSettingsDefaults(settingsManager: unknown): void {
672
735
  }
673
736
  }
674
737
 
675
- async function resolveWorkerModel(modelRegistry: unknown, modelName: string): Promise<unknown> {
738
+ async function resolveWorkerModel(modelResolver: unknown, modelName: string): Promise<unknown> {
676
739
  const [provider, ...modelIdParts] = modelName.split("/");
677
740
  const modelId = modelIdParts.join("/");
678
- if (provider && modelId && isRecord(modelRegistry) && typeof modelRegistry.find === "function") {
679
- const registryModel = modelRegistry.find(provider, modelId);
680
- if (registryModel) {
681
- return registryModel;
682
- }
741
+ if (!provider || !modelId || !isRecord(modelResolver)) {
742
+ return undefined;
683
743
  }
684
744
 
685
- try {
686
- const ai = await import("@earendil-works/pi-ai");
687
- if (typeof ai.getModel === "function" && provider && modelId) {
688
- const getModel = ai.getModel as (providerName: string, modelId: string) => unknown;
689
- return getModel(provider, modelId);
690
- }
691
- } catch {
692
- // Optional peer resolution can fail in tests that inject a session factory.
745
+ if (typeof modelResolver.getModel === "function") {
746
+ return modelResolver.getModel(provider, modelId);
747
+ }
748
+ if (typeof modelResolver.find === "function") {
749
+ return modelResolver.find(provider, modelId);
693
750
  }
694
751
  return undefined;
695
752
  }