@mjasnikovs/pi-task 0.14.14 → 0.14.16

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.
@@ -3,6 +3,7 @@ import type { RunSingleTaskResult } from './orchestrator.js';
3
3
  import { type CommitResult } from './auto-commit.js';
4
4
  import { type EnforceOutcome } from './enforce-guidelines.js';
5
5
  import { type VerifyOutcome } from './verify-work.js';
6
+ import { type ResolutionOutcome } from './verify-resolution.js';
6
7
  /**
7
8
  * Injectable seams so the planner and loop are testable without spawning pi.
8
9
  * `runChild` is used by planAuto; `runTask` is used by runAutoLoop.
@@ -16,6 +17,9 @@ export interface AutoDeps {
16
17
  onStart?: (taskId: string) => void | Promise<void>;
17
18
  /** Scope fence naming the sibling steps, forwarded into refine. */
18
19
  planContext?: string;
20
+ /** Verify-FAIL autofix re-attempt: prepended to the delivered spec as
21
+ * a RE-ATTEMPT banner so the re-run fixes that specific failure. */
22
+ fixInstruction?: string;
19
23
  }) => Promise<RunSingleTaskResult>;
20
24
  /** Snapshot the working tree into one commit after a task passes. */
21
25
  commit: (cwd: string, message: string) => Promise<CommitResult>;
@@ -40,6 +44,15 @@ export interface AutoDeps {
40
44
  * it). Needs the inner taskId to read that task's spec.
41
45
  */
42
46
  verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
47
+ /**
48
+ * After a verify FAIL, research whether to recommend AUTOFIX (re-do the work)
49
+ * or ACCEPT (the gate misjudged a good artifact) — read-only, same local
50
+ * model. Only sets which card the picker tints RECOMMENDED; the user always
51
+ * makes the final call. Optional: absent in tests or when verify is off, in
52
+ * which case the loop defaults the recommendation to AUTOFIX. Needs the inner
53
+ * taskId (to read the spec) and the gate's failure reason.
54
+ */
55
+ recommend?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<ResolutionOutcome>;
43
56
  }
44
57
  /**
45
58
  * Expand any @file references in the feature text by appending each referenced
@@ -17,6 +17,7 @@ import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasks
17
17
  import { gitCommitAll } from './auto-commit.js';
18
18
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
19
19
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
20
+ import { researchResolution, resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
20
21
  import { runWorker } from '../workers/pi-worker-core.js';
21
22
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
22
23
  import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
@@ -344,6 +345,61 @@ function defaultDeps(ctx, cwd, signal, title) {
344
345
  let lastLine;
345
346
  let contextUsage;
346
347
  const parentContextWindow = getParentContextWindow(ctx);
348
+ // Shared runner for the per-task GATE children (verify + post-FAIL recommend).
349
+ // Both are read-only passes of the same local model that must run to
350
+ // completion: unguarded (no wall-clock timeout, exact-match loop guard only,
351
+ // path-revisit disabled because re-running the same check is the job), with a
352
+ // status widget and a per-gate debug log. Returns the closure
353
+ // runWorkVerification / researchResolution expect as `runChild`.
354
+ const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
355
+ lastLine = undefined;
356
+ contextUsage = undefined;
357
+ const startedAt = Date.now();
358
+ const logPath = path.join(tasksDir(cwd2), logFile);
359
+ const log = (msg) => {
360
+ void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
361
+ };
362
+ log(`=== ${kind} start: ${taskTitle} ===`);
363
+ const stopLoader = startAutoLoader(gateCtx, () => ({
364
+ title: taskTitle,
365
+ kind,
366
+ step: kind,
367
+ stepNum: 1,
368
+ stepTotal: 1,
369
+ startedAt,
370
+ lastLine,
371
+ contextUsage
372
+ }));
373
+ try {
374
+ const r = await runWorker({
375
+ prompt,
376
+ cwd: cwd2,
377
+ signal: sig,
378
+ tools,
379
+ timeoutMs: 0,
380
+ loop: { pathThreshold: Number.POSITIVE_INFINITY },
381
+ onLine: line => {
382
+ lastLine = line;
383
+ log(line);
384
+ },
385
+ onContextUsage: snapshot => {
386
+ contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
387
+ }
388
+ });
389
+ if (r.loopHit) {
390
+ log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
391
+ gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
392
+ }
393
+ const failure = classifyEnforceChildFailure(r);
394
+ log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
395
+ if (failure)
396
+ throw new Error(failure);
397
+ return r.text;
398
+ }
399
+ finally {
400
+ stopLoader();
401
+ }
402
+ };
347
403
  const phaseDeps = {
348
404
  cwd,
349
405
  taskId: '',
@@ -384,7 +440,8 @@ function defaultDeps(ctx, cwd, signal, title) {
384
440
  waitForImplementation: true,
385
441
  resumeId: opts?.resumeId,
386
442
  onStart: opts?.onStart,
387
- planContext: opts?.planContext
443
+ planContext: opts?.planContext,
444
+ fixInstruction: opts?.fixInstruction
388
445
  }),
389
446
  commit: (cwd2, message) => getConfig().autoCommit ?
390
447
  gitCommitAll(cwd2, message, signal)
@@ -502,70 +559,54 @@ function defaultDeps(ctx, cwd, signal, title) {
502
559
  cwd: cwd2,
503
560
  signal,
504
561
  spec,
505
- // Same unguarded child as enforce: no wall-clock timeout (a build
506
- // or test suite legitimately takes minutes) and exact-match loop
507
- // guard only. Re-running the same VERIFY command is the job, so the
508
- // path-revisit heuristic is disabled; only a literally-identical
509
- // call repeated past threshold trips, and that warns rather than
510
- // blocks.
511
- runChild: async (tools, prompt, sig) => {
512
- lastLine = undefined;
513
- contextUsage = undefined;
514
- const startedAt = Date.now();
515
- const verifyLogPath = path.join(tasksDir(cwd2), 'verify-debug.log');
516
- const logVerify = (msg) => {
517
- void fsp
518
- .appendFile(verifyLogPath, `${new Date().toISOString()} ${msg}\n`)
519
- .catch(() => { });
520
- };
521
- logVerify(`=== verify start: ${taskTitle} ===`);
522
- const stopLoader = startAutoLoader(verifyCtx, () => ({
523
- title: taskTitle,
524
- kind: 'verify',
525
- step: 'verify',
526
- stepNum: 1,
527
- stepTotal: 1,
528
- startedAt,
529
- lastLine,
530
- contextUsage
531
- }));
532
- try {
533
- const r = await runWorker({
534
- prompt,
535
- cwd: cwd2,
536
- signal: sig,
537
- tools,
538
- timeoutMs: 0,
539
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
540
- onLine: line => {
541
- lastLine = line;
542
- logVerify(line);
543
- },
544
- onContextUsage: snapshot => {
545
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
546
- }
547
- });
548
- if (r.loopHit) {
549
- logVerify(`=== verify LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
550
- verifyCtx.ui.notify(`${taskTitle}: verify worker looped past the nudges — continuing (not blocked).`, 'warning');
551
- }
552
- const failure = classifyEnforceChildFailure(r);
553
- logVerify(failure ?
554
- `=== verify end: FAIL — ${failure} ===`
555
- : '=== verify end: verdict captured ===');
556
- if (failure)
557
- throw new Error(failure);
558
- return r.text;
559
- }
560
- finally {
561
- stopLoader();
562
- }
563
- }
562
+ runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
563
+ });
564
+ },
565
+ recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
566
+ // Read the same composed spec the verify gate judged against, so the
567
+ // recommendation reasons over the real contract (a missing spec is
568
+ // unusual here verify already ran — but degrade to the bare title).
569
+ let spec;
570
+ try {
571
+ const { body } = await readTaskFile(cwd2, taskId);
572
+ spec = extractSpecForVerification(body) ?? taskTitle;
573
+ }
574
+ catch {
575
+ spec = taskTitle;
576
+ }
577
+ return researchResolution({
578
+ cwd: cwd2,
579
+ signal,
580
+ spec,
581
+ failReason,
582
+ runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
564
583
  });
565
584
  }
566
585
  };
567
586
  }
568
587
  // ─── Loop ────────────────────────────────────────────────────────────────────
588
+ /**
589
+ * Show the boxed two-choice picker after a verify FAIL and return what the user
590
+ * decided. The model-recommended card is placed first so the renderer tints it
591
+ * green; the user ALWAYS makes the final call (there is no auto-pick). Mirrors the
592
+ * clarify/grill dialog: the same SessionUI.ask races the local boxed picker
593
+ * against a remote answer, with the two actions also surfaced as remote buttons.
594
+ */
595
+ async function askVerifyResolution(ctx, title, failReason, rec) {
596
+ const options = resolutionOptions(rec.recommend);
597
+ const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
598
+ + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
599
+ const answer = await new SessionUI(ctx).ask({
600
+ localTitle: 'Verification failed — how should pi proceed?',
601
+ displayQuestion: question,
602
+ question,
603
+ recommended: options[0].label,
604
+ recommended2: options[1].label,
605
+ allowSkip: false,
606
+ options
607
+ });
608
+ return classifyResolutionAnswer(answer);
609
+ }
569
610
  let cancelRequested = false;
570
611
  let autoRunning = false;
571
612
  export function requestAutoCancel() {
@@ -678,12 +719,64 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
678
719
  // task has no composed spec to verify against.
679
720
  if (deps.verify) {
680
721
  active.ui.notify(`${id}: verifying "${next.title}"…`, 'info');
681
- const verified = await deps.verify(active, cwd, next.title, res.taskId);
682
- if (!verified.ok) {
683
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
684
- announceDone(active, `${id} stopped at "${next.title}" ${verified.reason ?? 'did not verify'} — fix and run /task-auto-resume.`, 'error');
685
- return;
722
+ let verified = await deps.verify(active, cwd, next.title, res.taskId);
723
+ // A FAIL no longer dead-stops the run. The user is offered a boxed
724
+ // two-choice picker — AUTOFIX (re-run the implementation turn against
725
+ // the failure, then re-verify) or ACCEPT (the gate misjudged a good
726
+ // artifact; override and proceed). A read-only research pass picks
727
+ // which card is recommended, but the USER always decides — there is
728
+ // no attempt cap: AUTOFIX loops straight back to the gate as many
729
+ // times as the user keeps choosing it. The run only leaves the loop
730
+ // when the work verifies, the user ACCEPTs, or the user dismisses
731
+ // the picker (which pauses the run, resumable).
732
+ while (!verified.ok) {
733
+ const failReason = verified.reason ?? 'did not verify';
734
+ const rec = deps.recommend ?
735
+ await deps.recommend(active, cwd, next.title, res.taskId, failReason)
736
+ : { recommend: 'autofix', rationale: failReason };
737
+ const choice = await askVerifyResolution(active, next.title, failReason, rec);
738
+ if (choice.action === 'cancel') {
739
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
740
+ announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
741
+ return;
742
+ }
743
+ if (choice.action === 'accept') {
744
+ active.ui.notify(`${id}: accepted "${next.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
745
+ break;
746
+ }
747
+ // AUTOFIX: re-run the implementation turn with the failure (and
748
+ // any typed guidance) prepended as a RE-ATTEMPT banner, then
749
+ // re-verify — looping back to the gate. No cap: the user decides
750
+ // when to stop (by accepting or dismissing the next picker).
751
+ active.ui.notify(`${id}: autofixing "${next.title}"…`, 'info');
752
+ const fixInstruction = choice.guidance ?
753
+ `${failReason}\n\nUser guidance: ${choice.guidance}`
754
+ : failReason;
755
+ const fixRes = await deps.runTask(active, cwd, next.title, {
756
+ resumeId: res.taskId,
757
+ planContext: buildScopeFence(entries.map(e => e.title), next.index),
758
+ fixInstruction
759
+ });
760
+ active = fixRes.ctx ?? active;
761
+ if (fixRes.sessionCancelled) {
762
+ announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
763
+ return;
764
+ }
765
+ if (fixRes.interrupted) {
766
+ announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
767
+ return;
768
+ }
769
+ if (!fixRes.ok) {
770
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
771
+ const why = fixRes.reason ? ` — ${fixRes.reason.slice(0, 160)}` : '';
772
+ announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
773
+ return;
774
+ }
775
+ // Resume reuses the same inner task id, so res.taskId is stable.
776
+ verified = await deps.verify(active, cwd, next.title, res.taskId);
686
777
  }
778
+ // Loop exited because the work verified OR the user accepted the
779
+ // artifact — either way fall through to check-off/commit/enforce.
687
780
  }
688
781
  // res.ok === true means runner.run() completed, so res.taskId is the
689
782
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
@@ -27,6 +27,7 @@ export declare class TaskRunner {
27
27
  private readonly _sendSpec;
28
28
  private readonly _onStart;
29
29
  private readonly _planContext;
30
+ private readonly _fixInstruction;
30
31
  private readonly _abort;
31
32
  private readonly _startedAt;
32
33
  private readonly _widgetState;
@@ -42,7 +43,7 @@ export declare class TaskRunner {
42
43
  */
43
44
  private readonly _timings;
44
45
  private _currentPhaseChildren;
45
- constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string);
46
+ constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string, fixInstruction?: string);
46
47
  get taskId(): string;
47
48
  get signal(): AbortSignal;
48
49
  /** Return the current widget state, or null if not started. */
@@ -102,6 +103,15 @@ export interface RunSingleTaskOptions {
102
103
  * a bare /task leaves it undefined and the refine prompt is unchanged.
103
104
  */
104
105
  planContext?: string;
106
+ /**
107
+ * Marks this run as a verify-FAIL re-attempt. When set (only by /task-auto's
108
+ * autofix path, with `resumeId` pointing at the already-composed task), the
109
+ * text — the verify gate's failure reason plus any guidance the user typed —
110
+ * is prepended to the delivered spec as a RE-ATTEMPT banner, so the
111
+ * implementer fixes the specific failure and re-satisfies the VERIFY block
112
+ * rather than blindly redoing the task. Empty/undefined on a first attempt.
113
+ */
114
+ fixInstruction?: string;
105
115
  }
106
116
  export interface RunSingleTaskResult {
107
117
  taskId: string;
@@ -53,6 +53,7 @@ export class TaskRunner {
53
53
  _sendSpec;
54
54
  _onStart;
55
55
  _planContext;
56
+ _fixInstruction;
56
57
  _abort = new AbortController();
57
58
  _startedAt;
58
59
  _widgetState;
@@ -68,7 +69,7 @@ export class TaskRunner {
68
69
  */
69
70
  _timings = [];
70
71
  _currentPhaseChildren = null;
71
- constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext) {
72
+ constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction) {
72
73
  this._ctx = ctx;
73
74
  this._cwd = cwd;
74
75
  this._rawPrompt = rawPrompt;
@@ -76,6 +77,7 @@ export class TaskRunner {
76
77
  this._sendSpec = sendSpec;
77
78
  this._onStart = onStart;
78
79
  this._planContext = planContext;
80
+ this._fixInstruction = fixInstruction;
79
81
  this._startedAt = Date.now();
80
82
  // We'll populate id/title/phase lazily in run().
81
83
  // Placeholder — real values set in run().
@@ -288,11 +290,25 @@ export class TaskRunner {
288
290
  */
289
291
  _specForDelivery() {
290
292
  const phantoms = findDeliveryPhantoms(this._pc.spec, this._cwd);
291
- const banner = formatApiOverrideBanner(phantoms);
292
- if (!banner)
293
- return this._pc.spec;
294
- this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
295
- return `${banner}\n\n${this._pc.spec}`;
293
+ const apiBanner = formatApiOverrideBanner(phantoms);
294
+ if (apiBanner) {
295
+ this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
296
+ }
297
+ // A verify-FAIL re-attempt: the work was already implemented once and the
298
+ // verification gate rejected it. Lead with WHY so the model fixes that
299
+ // specific failure and re-satisfies the VERIFY block, rather than redoing
300
+ // the task from scratch (or repeating the same mistake).
301
+ let fixBanner = '';
302
+ if (this._fixInstruction && this._fixInstruction.trim().length > 0) {
303
+ this._deps.logDebug?.('impl-handoff RE-ATTEMPT banner prepended (verify FAIL fix)');
304
+ fixBanner =
305
+ 'RE-ATTEMPT — your previous implementation of this task FAILED verification.\n'
306
+ + "Fix the cause below, then make the spec's VERIFY block pass. Do NOT start over;\n"
307
+ + 'change only what is needed to resolve the failure.\n\n'
308
+ + `VERIFICATION FAILURE:\n${this._fixInstruction.trim()}`;
309
+ }
310
+ const banners = [fixBanner, apiBanner].filter(b => b && b.length > 0).join('\n\n');
311
+ return banners ? `${banners}\n\n${this._pc.spec}` : this._pc.spec;
296
312
  }
297
313
  }
298
314
  /** Dialog copy for the post-interrupt steering prompt. */
@@ -394,7 +410,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
394
410
  if (!interrupted)
395
411
  implError = implementationError(newCtx);
396
412
  }
397
- }, opts.spawnFn, opts.onStart, opts.planContext);
413
+ }, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction);
398
414
  await runner.run();
399
415
  taskId = runner.taskId;
400
416
  }
@@ -0,0 +1,75 @@
1
+ /** Same read-only contract as the verify pass: observe, never edit. */
2
+ declare const RESOLUTION_TOOLS = "read,bash";
3
+ /** Which card the picker tints RECOMMENDED. */
4
+ export type ResolutionRecommendation = 'autofix' | 'accept';
5
+ export interface ResolutionOutcome {
6
+ /** The card to recommend. Defaults to 'autofix' (conservative: re-do, don't
7
+ * bless) when the research could not run or emitted no verdict. */
8
+ recommend: ResolutionRecommendation;
9
+ /** Short, human-readable rationale shown under the picker. Always set. */
10
+ rationale: string;
11
+ }
12
+ /**
13
+ * Build the resolution research child's prompt. Pure, so the wording is
14
+ * unit-tested without spawning pi. The contract: investigate the real workspace
15
+ * and end on exactly one RESOLUTION verdict line.
16
+ */
17
+ export declare function buildResolutionPrompt(spec: string, failReason: string): string;
18
+ /**
19
+ * Parse the child's recommendation. Scans for the LAST
20
+ * `VERIFY-RESOLUTION: AUTOFIX|ACCEPT` marker (the model reasons before
21
+ * concluding, and bash output may echo these words, so a distinct token and
22
+ * last-match-wins both matter).
23
+ *
24
+ * No marker → recommend AUTOFIX (conservative: re-do rather than bless), with a
25
+ * rationale that says the research was inconclusive.
26
+ */
27
+ export declare function parseResolutionVerdict(text: string): ResolutionOutcome;
28
+ export declare const ACCEPT_VALUE = "accept";
29
+ export declare const AUTOFIX_VALUE = "autofix";
30
+ export declare const ACCEPT_LABEL = "Accept \u2014 keep the artifact as-is and continue";
31
+ export declare const AUTOFIX_LABEL = "Autofix \u2014 re-run the task to fix it, then verify again";
32
+ export interface ResolutionChoice {
33
+ /** What the user decided. 'cancel' = dismissed the picker (pause, resumable). */
34
+ action: 'accept' | 'autofix' | 'cancel';
35
+ /** Free-text guidance the user typed instead of picking a card. Folded into
36
+ * the autofix instruction so a re-run can act on it. Only set with 'autofix'. */
37
+ guidance?: string;
38
+ }
39
+ /**
40
+ * The two cards for the picker, with the model-recommended one FIRST so the boxed
41
+ * renderer tints it green (it marks index 0 as recommended). Values are the bare
42
+ * tokens; remote buttons use the labels — {@link classifyResolutionAnswer}
43
+ * accepts both.
44
+ */
45
+ export declare function resolutionOptions(recommend: ResolutionRecommendation): {
46
+ label: string;
47
+ value: string;
48
+ recommended: boolean;
49
+ }[];
50
+ /**
51
+ * Map a picker answer to an action. Handles the card value tokens, the remote
52
+ * button labels, an empty/undefined dismissal (cancel), and arbitrary free text
53
+ * typed via the fallback — which becomes an AUTOFIX with that text as guidance
54
+ * (the user is steering the re-run rather than blessing the artifact).
55
+ */
56
+ export declare function classifyResolutionAnswer(answer: string | undefined): ResolutionChoice;
57
+ export interface ResolutionDeps {
58
+ cwd: string;
59
+ signal?: AbortSignal;
60
+ /** The composed spec the task was verified against. */
61
+ spec: string;
62
+ /** The verify gate's FAIL reason (VerifyOutcome.reason). */
63
+ failReason: string;
64
+ /** Runs the research child and returns its assistant text. Injected so the
65
+ * orchestrator wires the real worker and tests use a fake. */
66
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
67
+ }
68
+ /**
69
+ * Research a verify FAIL and return which action to recommend. Never throws
70
+ * (except a user cancel, which propagates so the loop's USER_CANCELLED handler
71
+ * reports a clean "cancelled — resume"). A child crash defaults to recommending
72
+ * AUTOFIX — the picker is still shown, only the default tint changes.
73
+ */
74
+ export declare function researchResolution(deps: ResolutionDeps): Promise<ResolutionOutcome>;
75
+ export { RESOLUTION_TOOLS };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Verify-FAIL resolution research for /task-auto.
3
+ *
4
+ * When the work-verification gate (verify-work.ts) returns a FAIL, the run no
5
+ * longer dead-stops with "fix and run /task-auto-resume". Instead the user is
6
+ * shown a boxed two-choice picker:
7
+ *
8
+ * • Autofix — re-run the task's implementation turn against the failure, then
9
+ * re-enter the verify gate.
10
+ * • Accept — treat the artifact as good, override the gate, and proceed
11
+ * (check off + commit) exactly as a PASS would.
12
+ *
13
+ * The user ALWAYS makes the final call. This module only computes which of the
14
+ * two cards is tinted RECOMMENDED, by handing the FAIL reason + the task spec to
15
+ * a fresh read+bash child of the SAME local model and letting it INVESTIGATE the
16
+ * real workspace: is the FAIL a genuine defect in the work (→ recommend AUTOFIX),
17
+ * or a false alarm / an acceptable artifact the gate misjudged (→ recommend
18
+ * ACCEPT)? This is the same shape of judgement a human reviewer makes when a CI
19
+ * check is red — sometimes the code is broken, sometimes the check is wrong.
20
+ *
21
+ * Tools: `read` + `bash` only — identical to the verify pass. The recommendation
22
+ * observes; it never edits. (Autofix, when chosen, is what edits — and it goes
23
+ * through the full implementation runner, not this pass.) A read-only research
24
+ * step can never itself change the tree it is judging.
25
+ *
26
+ * The verdict is advisory: a missing/garbled verdict defaults to recommending
27
+ * AUTOFIX, the conservative choice (re-do the work rather than bless it). The
28
+ * picker is shown regardless; this only moves the green tint.
29
+ */
30
+ import { USER_CANCELLED } from './child-runner.js';
31
+ /** Same read-only contract as the verify pass: observe, never edit. */
32
+ const RESOLUTION_TOOLS = 'read,bash';
33
+ /**
34
+ * Build the resolution research child's prompt. Pure, so the wording is
35
+ * unit-tested without spawning pi. The contract: investigate the real workspace
36
+ * and end on exactly one RESOLUTION verdict line.
37
+ */
38
+ export function buildResolutionPrompt(spec, failReason) {
39
+ return [
40
+ "A strict verification gate just FAILED an AI coding agent's task and the",
41
+ 'run paused. You are the reviewer who decides what to recommend next. The',
42
+ 'agent will NOT act on your verdict automatically — a human picks the final',
43
+ 'action — but your recommendation sets the default, so be right.',
44
+ '',
45
+ 'You have a `read` tool and a `bash` tool — nothing else. Investigate the',
46
+ 'REAL files in this workspace; you CANNOT edit or create anything.',
47
+ '',
48
+ 'THE TASK SPEC (its ACCEPTANCE criteria and VERIFY block were the contract):',
49
+ spec.trim(),
50
+ '',
51
+ "THE GATE'S FAILURE REASON:",
52
+ failReason.trim(),
53
+ '',
54
+ 'Your job: decide which of these two is the right next step.',
55
+ ' AUTOFIX — the work is genuinely wrong: a real defect, a missing or broken',
56
+ ' artifact, behaviour that truly contradicts an ACCEPTANCE criterion.',
57
+ ' The task should be re-done to fix it.',
58
+ ' ACCEPT — the work is actually fine: the gate misjudged it (a false alarm,',
59
+ ' an over-strict or factually-wrong check, an environment gap, a',
60
+ ' difference in FORM that still satisfies the intent). The artifact',
61
+ ' is good as-is and the FAIL should be overridden.',
62
+ '',
63
+ 'CRITICAL — judge by FUNCTION, not by literal text. Verification gates (and',
64
+ 'even the spec wording above) are frequently OVER-LITERAL. A FAIL that',
65
+ 'complains that something is written in the wrong FORM, SHAPE, SYNTAX, SECTION',
66
+ 'or WORDING — rather than that it BEHAVES wrong — is very often a false alarm:',
67
+ 'the artifact reaches the same result through an equivalent form. The spec',
68
+ 'wording is intent, not a string to diff against. Example: a config value',
69
+ 'written as a nested table header vs. a dotted key are the same value to the',
70
+ 'tool that reads it; demanding one exact spelling is a false alarm if the tool',
71
+ 'accepts both and the intended setting is present.',
72
+ '',
73
+ 'How to decide:',
74
+ '1. Do NOT decide from the FAIL text alone. Read the actual file(s) it points',
75
+ ' at. When the check is runnable, RUN the real tool/command that consumes',
76
+ ' that artifact (the build, the parser, the test) with `bash` and observe',
77
+ ' whether it actually works. Reality overrules both the gate and the spec',
78
+ ' wording.',
79
+ '2. If the failure is about HOW something is written and you confirm the',
80
+ ' artifact still FUNCTIONALLY does what the ACCEPTANCE intends (the tool',
81
+ ' accepts it, the value/element is present and effective, the build runs) —',
82
+ " that is ACCEPT, even though the text does not literally match the gate's or",
83
+ " the spec's phrasing.",
84
+ '3. If an intended element is genuinely ABSENT or the artifact actually fails',
85
+ ' when you run it — that is AUTOFIX. When you cannot positively confirm the',
86
+ ' artifact works, recommend AUTOFIX: re-doing the work is the safe default;',
87
+ ' blessing broken work is the failure mode this whole gate exists to stop.',
88
+ '',
89
+ 'When done, output EXACTLY ONE of these as the final line:',
90
+ ' VERIFY-RESOLUTION: AUTOFIX <one sentence why the work must be re-done>',
91
+ ' VERIFY-RESOLUTION: ACCEPT <one sentence why the artifact is good as-is>',
92
+ 'Output the verdict line verbatim — it is parsed mechanically.'
93
+ ].join('\n');
94
+ }
95
+ /**
96
+ * Parse the child's recommendation. Scans for the LAST
97
+ * `VERIFY-RESOLUTION: AUTOFIX|ACCEPT` marker (the model reasons before
98
+ * concluding, and bash output may echo these words, so a distinct token and
99
+ * last-match-wins both matter).
100
+ *
101
+ * No marker → recommend AUTOFIX (conservative: re-do rather than bless), with a
102
+ * rationale that says the research was inconclusive.
103
+ */
104
+ export function parseResolutionVerdict(text) {
105
+ const re = /VERIFY-RESOLUTION:\s*(AUTOFIX|ACCEPT)\b[ \t]*(.*)/gi;
106
+ let last = null;
107
+ for (let m = re.exec(text); m !== null; m = re.exec(text))
108
+ last = m;
109
+ if (!last) {
110
+ return {
111
+ recommend: 'autofix',
112
+ rationale: 'research inconclusive — defaulting to re-doing the work'
113
+ };
114
+ }
115
+ const recommend = last[1].toUpperCase() === 'ACCEPT' ? 'accept' : 'autofix';
116
+ const rationale = last[2].trim()
117
+ || (recommend === 'accept' ? 'artifact judged acceptable' : 'work judged to need fixing');
118
+ return { recommend, rationale };
119
+ }
120
+ // ─── Picker plumbing ─────────────────────────────────────────────────────────
121
+ //
122
+ // The user ALWAYS makes the final call on a verify FAIL. These pure helpers shape
123
+ // the boxed two-choice picker (verify-resolution → SessionUI.ask in the loop) and
124
+ // classify whatever comes back — a card value, a remote button label, or free
125
+ // text typed via the "type a different answer" fallback — so the orchestration
126
+ // stays mechanical and unit-tested.
127
+ export const ACCEPT_VALUE = 'accept';
128
+ export const AUTOFIX_VALUE = 'autofix';
129
+ export const ACCEPT_LABEL = 'Accept — keep the artifact as-is and continue';
130
+ export const AUTOFIX_LABEL = 'Autofix — re-run the task to fix it, then verify again';
131
+ /**
132
+ * The two cards for the picker, with the model-recommended one FIRST so the boxed
133
+ * renderer tints it green (it marks index 0 as recommended). Values are the bare
134
+ * tokens; remote buttons use the labels — {@link classifyResolutionAnswer}
135
+ * accepts both.
136
+ */
137
+ export function resolutionOptions(recommend) {
138
+ const accept = { label: ACCEPT_LABEL, value: ACCEPT_VALUE };
139
+ const autofix = { label: AUTOFIX_LABEL, value: AUTOFIX_VALUE };
140
+ return recommend === 'accept' ?
141
+ [
142
+ { ...accept, recommended: true },
143
+ { ...autofix, recommended: false }
144
+ ]
145
+ : [
146
+ { ...autofix, recommended: true },
147
+ { ...accept, recommended: false }
148
+ ];
149
+ }
150
+ /**
151
+ * Map a picker answer to an action. Handles the card value tokens, the remote
152
+ * button labels, an empty/undefined dismissal (cancel), and arbitrary free text
153
+ * typed via the fallback — which becomes an AUTOFIX with that text as guidance
154
+ * (the user is steering the re-run rather than blessing the artifact).
155
+ */
156
+ export function classifyResolutionAnswer(answer) {
157
+ if (answer === undefined)
158
+ return { action: 'cancel' };
159
+ const t = answer.trim();
160
+ if (t.length === 0)
161
+ return { action: 'cancel' };
162
+ if (t === ACCEPT_VALUE || /^accept\b/i.test(t))
163
+ return { action: 'accept' };
164
+ if (t === AUTOFIX_VALUE || /^autofix\b/i.test(t))
165
+ return { action: 'autofix' };
166
+ // Free-text override → re-run the task with the typed text as fix guidance.
167
+ return { action: 'autofix', guidance: t };
168
+ }
169
+ /**
170
+ * Research a verify FAIL and return which action to recommend. Never throws
171
+ * (except a user cancel, which propagates so the loop's USER_CANCELLED handler
172
+ * reports a clean "cancelled — resume"). A child crash defaults to recommending
173
+ * AUTOFIX — the picker is still shown, only the default tint changes.
174
+ */
175
+ export async function researchResolution(deps) {
176
+ let text;
177
+ try {
178
+ text = await deps.runChild(RESOLUTION_TOOLS, buildResolutionPrompt(deps.spec, deps.failReason), deps.signal);
179
+ }
180
+ catch (err) {
181
+ if (err instanceof Error && err.message === USER_CANCELLED)
182
+ throw err;
183
+ return {
184
+ recommend: 'autofix',
185
+ rationale: 'resolution research could not run — defaulting to re-doing the work'
186
+ };
187
+ }
188
+ return parseResolutionVerdict(text);
189
+ }
190
+ export { RESOLUTION_TOOLS };
@@ -52,8 +52,9 @@ export interface AutoLoaderState {
52
52
  /** Which /task-auto stage this loader is for. Defaults to 'planning' (the
53
53
  * numbered clarify/decompose steps); 'enforce' is the per-task guideline
54
54
  * pass and 'verify' is the per-task work-verification pass, neither of which
55
- * has step numbering. */
56
- kind?: 'planning' | 'enforce' | 'verify';
55
+ * has step numbering. 'recommend' is the read-only research that picks the
56
+ * recommended action after a verify FAIL. */
57
+ kind?: 'planning' | 'enforce' | 'verify' | 'recommend';
57
58
  }
58
59
  export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetTheme): string[];
59
60
  /**
@@ -123,7 +123,8 @@ export function buildAutoLoaderLines(s, theme) {
123
123
  const head = `/task-auto · ${s.title}`;
124
124
  let detail = s.kind === 'enforce' ? `enforcing guidelines · ${elapsed}`
125
125
  : s.kind === 'verify' ? `verifying work · ${elapsed}`
126
- : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
126
+ : s.kind === 'recommend' ? `assessing the failure · ${elapsed}`
127
+ : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
127
128
  if (s.contextUsage) {
128
129
  const ctxDetail = formatContextDetail(s.contextUsage, theme);
129
130
  if (ctxDetail)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * One-line startup hint shown when no Brave Search key is configured.
3
+ *
4
+ * pi-task's web-search worker needs BRAVE_SEARCH_API_KEY (or BRAVE_API_KEY).
5
+ * When neither is set we surface a single, unobtrusive widget line on session
6
+ * start so the user knows search is disabled — it never blocks work and clears
7
+ * itself on the first interaction (any keystroke).
8
+ */
9
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
+ export declare function registerBraveKeyWarning(pi: ExtensionAPI): void;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One-line startup hint shown when no Brave Search key is configured.
3
+ *
4
+ * pi-task's web-search worker needs BRAVE_SEARCH_API_KEY (or BRAVE_API_KEY).
5
+ * When neither is set we surface a single, unobtrusive widget line on session
6
+ * start so the user knows search is disabled — it never blocks work and clears
7
+ * itself on the first interaction (any keystroke).
8
+ */
9
+ const WIDGET_KEY = 'pi-task-brave-warning';
10
+ const WARNING = '⚠ pi-task: BRAVE_SEARCH_API_KEY not set — web search is disabled. '
11
+ + 'Get a free key at https://api.search.brave.com/app/keys';
12
+ /** Mirrors the lookup in search-core so the hint matches what the worker reads. */
13
+ function hasBraveKey() {
14
+ return Boolean(process.env.BRAVE_SEARCH_API_KEY ?? process.env.BRAVE_API_KEY);
15
+ }
16
+ export function registerBraveKeyWarning(pi) {
17
+ pi.on('session_start', (_event, ctx) => {
18
+ // Terminal-only hint: needs an interactive TUI to render and to catch the
19
+ // keystroke that dismisses it. Skip when a key is already present.
20
+ if (ctx.mode !== 'tui' || hasBraveKey())
21
+ return;
22
+ let unsubscribe = null;
23
+ const clear = () => {
24
+ try {
25
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
26
+ }
27
+ catch {
28
+ /* stale ctx after a session switch — nothing to clear */
29
+ }
30
+ unsubscribe?.();
31
+ unsubscribe = null;
32
+ };
33
+ try {
34
+ ctx.ui.setWidget(WIDGET_KEY, [ctx.ui.theme.fg('warning', WARNING)]);
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ // Disappear on any interaction — the first raw keystroke clears it.
40
+ // Returning undefined leaves the input untouched (we only observe it).
41
+ unsubscribe = ctx.ui.onTerminalInput(() => {
42
+ clear();
43
+ return undefined;
44
+ });
45
+ });
46
+ }
@@ -2,9 +2,11 @@ import { registerPiWorker } from './pi-worker.js';
2
2
  import { registerPiWorkerSearch } from './pi-worker-search.js';
3
3
  import { registerPiWorkerFetch } from './pi-worker-fetch.js';
4
4
  import { registerPiWorkerDocs } from './pi-worker-docs.js';
5
+ import { registerBraveKeyWarning } from './brave-warning.js';
5
6
  export function registerWorkers(pi) {
6
7
  registerPiWorker(pi);
7
8
  registerPiWorkerSearch(pi);
8
9
  registerPiWorkerFetch(pi);
9
10
  registerPiWorkerDocs(pi);
11
+ registerBraveKeyWarning(pi);
10
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.14",
3
+ "version": "0.14.16",
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",