@mjasnikovs/pi-task 0.15.0 → 0.15.2

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.
@@ -18,15 +18,15 @@ const ITEMS = [
18
18
  label: 'orientation',
19
19
  description: 'Pre-supply the project core (manifest, types, schema…) to the read-heavy research workers'
20
20
  },
21
- {
22
- id: 'enforceGuidelines',
23
- label: 'enforce guidelines',
24
- description: 'Before each /task-auto commit, re-check the work against AGENTS.md/CLAUDE.md and fix drift'
25
- },
26
21
  {
27
22
  id: 'verifyWork',
28
23
  label: 'verify work',
29
- description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict"
24
+ description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict (also the signal that lets 'enforce guidelines' fix safely)"
25
+ },
26
+ {
27
+ id: 'enforceGuidelines',
28
+ label: 'enforce guidelines',
29
+ description: "Check each /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations"
30
30
  }
31
31
  ];
32
32
  function makeTheme(theme) {
@@ -60,13 +60,34 @@ export type { PhaseDeps };
60
60
  */
61
61
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
62
62
  export declare function formatLoopHint(hit: LoopHit): string;
63
+ /**
64
+ * Terminal hint for the degrade attempt: the model has thrashed through the whole
65
+ * strike budget re-reading files without converging, so we strip its tools and
66
+ * order it to emit the deliverable NOW from what it already has. Used only by
67
+ * read-only analysis phases (refine) whose output is a text rewrite that never
68
+ * strictly required a successful read — far better to ship a best-effort spec
69
+ * than to hard-fail the whole /task-auto run. See countRevisits / LoopExhausted.
70
+ */
71
+ export declare function formatDegradeHint(hit: LoopHit): string;
63
72
  export declare function prependHint(hint: string | null, prompt: string): string;
64
73
  /**
65
74
  * Run a phase child with loop detection. On a detected loop, kill and re-spawn
66
75
  * with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
67
76
  * the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
68
77
  */
69
- export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string): Promise<string>;
78
+ export interface LoopGuardOptions {
79
+ /**
80
+ * When the strike budget is exhausted by loops, do NOT fail the phase. Run
81
+ * ONE final attempt with NO tools and a terminal hint ordering the model to
82
+ * emit its output from what it already has. Only safe for phases whose
83
+ * deliverable is a pure text rewrite that never strictly required a read
84
+ * (refine) — a hard-fail there kills the whole /task-auto run for a model
85
+ * that simply over-explored. Research/location phases must NOT enable this:
86
+ * their output depends on real reads, so a no-tools fallback would fabricate.
87
+ */
88
+ degradeOnExhaustion?: boolean;
89
+ }
90
+ export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string, opts?: LoopGuardOptions): Promise<string>;
70
91
  /**
71
92
  * Run a child up to twice; the second attempt gets `emphasized=true` to escalate
72
93
  * the prompt. On success, return the validator's value; on two failures, throw
@@ -160,6 +160,22 @@ export function formatLoopHint(hit) {
160
160
  + `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
161
161
  + `work from memory or pick a different angle.]`);
162
162
  }
163
+ /**
164
+ * Terminal hint for the degrade attempt: the model has thrashed through the whole
165
+ * strike budget re-reading files without converging, so we strip its tools and
166
+ * order it to emit the deliverable NOW from what it already has. Used only by
167
+ * read-only analysis phases (refine) whose output is a text rewrite that never
168
+ * strictly required a successful read — far better to ship a best-effort spec
169
+ * than to hard-fail the whole /task-auto run. See countRevisits / LoopExhausted.
170
+ */
171
+ export function formatDegradeHint(hit) {
172
+ return (`[SYSTEM NOTE: You called ${hit.call.name}(${JSON.stringify(hit.call.args)}) `
173
+ + `repeatedly and made no forward progress — you are stuck re-reading the same files. `
174
+ + `You now have NO tools: you cannot read, grep, list, or open anything further. `
175
+ + `STOP exploring. Produce the COMPLETE required output IMMEDIATELY, using only the task `
176
+ + `description and what you have already seen. Emit the full structured result now — do not `
177
+ + `ask to read more, do not explain, just output the final answer.]`);
178
+ }
163
179
  export function prependHint(hint, prompt) {
164
180
  return hint === null ? prompt : `${hint}\n\n${prompt}`;
165
181
  }
@@ -172,12 +188,7 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
172
188
  const next = existing ? `${existing}\n${line}` : line;
173
189
  await setTaskSection(cwd, taskId, 'loop events', next);
174
190
  }
175
- /**
176
- * Run a phase child with loop detection. On a detected loop, kill and re-spawn
177
- * with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
178
- * the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
179
- */
180
- export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
191
+ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts = {}) {
181
192
  const loopHistory = [];
182
193
  // Carries the correction hint (loop OR leaked-tool-call) into the next strike.
183
194
  let nextHint = null;
@@ -192,9 +203,14 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
192
203
  if (r.loopHit) {
193
204
  const isLastStrike = strike === MAX_LOOP_RESTARTS;
194
205
  loopHistory.push(r.loopHit);
195
- await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? 'phase failed' : 'restarted with hint');
196
- if (isLastStrike)
206
+ const lastOutcome = opts.degradeOnExhaustion ? 'degraded no-tools final attempt' : 'phase failed';
207
+ await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? lastOutcome : 'restarted with hint');
208
+ if (isLastStrike) {
209
+ if (opts.degradeOnExhaustion) {
210
+ return await runDegradedFinalAttempt(deps, name, buildPrompt, r.loopHit, loopHistory);
211
+ }
197
212
  throw new LoopExhaustedError(name, loopHistory);
213
+ }
198
214
  nextHint = formatLoopHint(r.loopHit);
199
215
  continue;
200
216
  }
@@ -236,6 +252,24 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
236
252
  }
237
253
  throw new LoopExhaustedError(name, loopHistory);
238
254
  }
255
+ /**
256
+ * Final degrade attempt after the loop budget is spent: re-spawn the child with
257
+ * NO tools and a terminal hint, so a model that thrashed re-reading files is
258
+ * forced to emit its deliverable from what it already has. With no tools there
259
+ * are no tool calls, so no loop can recur — the only failure modes left are a
260
+ * dead turn (non-zero exit, model error) or empty output, any of which fall back
261
+ * to the original LoopExhaustedError so the phase still fails honestly when even
262
+ * the degrade produces nothing.
263
+ */
264
+ async function runDegradedFinalAttempt(deps, name, buildPrompt, hit, loopHistory) {
265
+ deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
266
+ const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
267
+ buildPrompt(formatDegradeHint(hit)), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
268
+ if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
269
+ throw new LoopExhaustedError(name, loopHistory);
270
+ }
271
+ return r.text;
272
+ }
239
273
  /**
240
274
  * Run a child up to twice; the second attempt gets `emphasized=true` to escalate
241
275
  * the prompt. On success, return the validator's value; on two failures, throw
@@ -62,7 +62,15 @@ export function replaceToolingWithVerified(research, verifiedCommands) {
62
62
  return replaced;
63
63
  }
64
64
  // ─── Phase functions ─────────────────────────────────────────────────────────
65
- export const phaseRefine = (deps, raw, planContext) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext))));
65
+ export const phaseRefine = (deps, raw, planContext) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext))),
66
+ // refine's deliverable is a 4-section text rewrite that never strictly
67
+ // needs a successful read — on a test-writing task against a large
68
+ // existing codebase the model over-explores (re-reads source hunting for
69
+ // the impl) and burns the loop budget. Degrade to a no-tools final
70
+ // attempt instead of hard-failing the whole run. See TASK_0016 (mx5):
71
+ // refine looped 3×/resume forever; the deliverable was always producible
72
+ // from the title + design doc alone.
73
+ { degradeOnExhaustion: true });
66
74
  export async function phaseVerifyTooling(deps, research) {
67
75
  const commands = extractToolingCommands(research);
68
76
  if (!commands || commands.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",