@mjasnikovs/pi-task 0.16.5 → 0.17.1

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.
@@ -21,12 +21,12 @@ const ITEMS = [
21
21
  {
22
22
  id: 'verifyWork',
23
23
  label: 'verify work',
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)"
24
+ description: "After each /task (and /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). Enabling it makes /task wait for the implementation"
25
25
  },
26
26
  {
27
27
  id: 'enforceGuidelines',
28
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"
29
+ description: "Check each /task and /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. Enabling it makes /task wait for the implementation"
30
30
  }
31
31
  ];
32
32
  function makeTheme(theme) {
@@ -1,74 +1,13 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
- import type { RunSingleTaskResult } from './orchestrator.js';
3
- import { type CommitResult } from './auto-commit.js';
4
- import { type EnforceOutcome } from './enforce-guidelines.js';
5
- import { type VerifyOutcome } from './verify-work.js';
6
- import { type ResolutionOutcome } from './verify-resolution.js';
2
+ import { type GateDeps } from './task-gates.js';
7
3
  /**
8
4
  * Injectable seams so the planner and loop are testable without spawning pi.
9
- * `runChild` is used by planAuto; `runTask` is used by runAutoLoop.
5
+ * `runChild` is the planning-only seam used by planAuto; everything else (runTask,
6
+ * commit, verify, enforce, recommend, revert) is the shared post-implementation
7
+ * gate surface defined by {@link GateDeps} and built by {@link buildGateDeps}.
10
8
  */
11
- export interface AutoDeps {
9
+ export interface AutoDeps extends GateDeps {
12
10
  runChild: (name: string, tools: string, prompt: string) => Promise<string>;
13
- runTask: (ctx: ExtensionCommandContext, cwd: string, title: string, opts?: {
14
- /** Resume this inner task id instead of allocating a fresh one. */
15
- resumeId?: string;
16
- /** Called with the inner task id once its file exists, before phases. */
17
- onStart?: (taskId: string) => void | Promise<void>;
18
- /** Scope fence naming the sibling steps, forwarded into refine. */
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;
23
- }) => Promise<RunSingleTaskResult>;
24
- /** Snapshot the working tree into one commit after a task passes. */
25
- commit: (cwd: string, message: string) => Promise<CommitResult>;
26
- /**
27
- * Verify the just-finished task's work against the project's AGENTS.md /
28
- * CLAUDE.md guidelines (and auto-fix violations) BEFORE it is committed.
29
- * Optional: when absent (tests, or the feature disabled), the loop treats it
30
- * as a pass. ok === false blocks the commit and fails the task.
31
- *
32
- * Takes the loop's CURRENT ctx (not the one captured at defaultDeps time):
33
- * each task runs in a fresh session, so the captured ctx is stale by the time
34
- * enforcement runs and using it for the loader widget throws "stale ctx".
35
- */
36
- enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string,
37
- /**
38
- * `'edit'` (read,edit — fix in place) only when there is a clean
39
- * verification signal to guard the edits against; otherwise `'flag'`
40
- * (read-only, report don't fix). The loop picks the mode from whether the
41
- * verify gate produced a genuine clean pass.
42
- */
43
- mode: 'edit' | 'flag') => Promise<EnforceOutcome>;
44
- /**
45
- * Verify the just-finished task's work by RUNNING its composed spec's VERIFY
46
- * block in the real workspace (a fresh child of the same local model, with a
47
- * `bash` tool), then reporting a PASS/FAIL verdict. Optional: absent in tests
48
- * or when the `verifyWork` flag is off, in which case the loop treats it as a
49
- * pass. Runs BEFORE the task is checked off/committed and is a hard GATE: ok
50
- * === false stops the run and fails the task (left unchecked so resume re-runs
51
- * it). Needs the inner taskId to read that task's spec.
52
- */
53
- verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
54
- /**
55
- * After a verify FAIL, research whether to recommend AUTOFIX (re-do the work)
56
- * or ACCEPT (the gate misjudged a good artifact) — read-only, same local
57
- * model. Only sets which card the picker tints RECOMMENDED; the user always
58
- * makes the final call. Optional: absent in tests or when verify is off, in
59
- * which case the loop defaults the recommendation to AUTOFIX. Needs the inner
60
- * taskId (to read the spec) and the gate's failure reason.
61
- */
62
- recommend?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<ResolutionOutcome>;
63
- /**
64
- * Discard the working-tree edits an `'edit'` enforcement pass made, restoring
65
- * the verified task commit. The differential guard calls this when re-running
66
- * verification AFTER enforcement reports a regression: the guideline fixes are
67
- * thrown away and the verified implementation is kept. Optional: absent in
68
- * tests; the loop then skips the revert (and warns). Scoped to exclude the
69
- * .pi-tasks bookkeeping so task front matter is not rolled back.
70
- */
71
- revert?: (cwd: string) => Promise<void>;
72
11
  }
73
12
  /**
74
13
  * Expand any @file references in the feature text by appending each referenced
@@ -7,26 +7,21 @@
7
7
  */
8
8
  import * as fsp from 'node:fs/promises';
9
9
  import * as path from 'node:path';
10
- import { runSingleTask } from './orchestrator.js';
10
+ import { gateRunTask } from './orchestrator.js';
11
11
  import { parseClarifyList, deriveTitle } from './parsers.js';
12
12
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
13
13
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT } from './auto-prompts.js';
14
- import { getProjectSnapshot } from './file-inventory.js';
15
14
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
16
15
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
17
16
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
18
- import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
19
- import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
20
- import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
21
- import { researchResolution, resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
22
- import { runWorker } from '../workers/pi-worker-core.js';
23
17
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
24
- import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
18
+ import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
25
19
  import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
26
20
  import { pushNotify } from '../remote/push.js';
27
- import { getConfig } from '../config/config.js';
28
21
  import { startAutoLoader } from './widget.js';
29
22
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
23
+ import { buildGateDeps } from './gate-deps.js';
24
+ import { runGatesForTask } from './task-gates.js';
30
25
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
31
26
  // when the model emits NONE), but a model that never says NONE would otherwise
32
27
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -213,13 +208,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
213
208
  if (planPhantoms.length > 0) {
214
209
  logPlanDebug(cwd, `phantom specifiers rewritten in plan spec: ${planPhantoms.map(x => x.spec).join(', ')}`);
215
210
  }
216
- // Factual snapshot of the target directory (root files + top-level dirs).
217
- // Clarify only "may" read the repo; a model that skips the read assumes a
218
- // greenfield project and recommends bootstrapping config/manifest files that
219
- // already exist (or invents a path like /workspace). Handing it the facts
220
- // up front removes the guess. '' for a non-git/empty tree ⇒ prompt omits the
221
- // block and the model correctly treats the target as greenfield.
222
- const projectState = await getProjectSnapshot(cwd);
223
211
  const answers = [];
224
212
  // Plain text of every question already shown, for the duplicate backstop.
225
213
  const askedQuestions = [];
@@ -232,7 +220,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
232
220
  // Open-ended: keep asking until the model emits NONE or the user dismisses —
233
221
  // but never past MAX_CLARIFY_QUESTIONS distinct questions.
234
222
  while (askedQuestions.length < MAX_CLARIFY_QUESTIONS) {
235
- const qRaw = await deps.runChild('auto-clarify', 'read', prependHint(dupHint, AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n'), projectState)));
223
+ const qRaw = await deps.runChild('auto-clarify', 'read', prependHint(dupHint, AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n'))));
236
224
  const parsed = parseClarifyList(qRaw);
237
225
  if (parsed.length === 0)
238
226
  break; // NONE / nothing left to ask
@@ -348,66 +336,12 @@ const AUTO_PLAN_STEPS = {
348
336
  };
349
337
  const AUTO_PLAN_STEP_TOTAL = 2;
350
338
  function defaultDeps(ctx, cwd, signal, title) {
351
- // Captured by the loader's getState so the widget mirrors the child's latest
352
- // output line and context usage, exactly like the single-task phase widget.
339
+ // Captured by the planning loader's getState so the widget mirrors the child's
340
+ // latest output line and context usage, exactly like the single-task phase
341
+ // widget. (The gate children manage their own loaders inside buildGateDeps.)
353
342
  let lastLine;
354
343
  let contextUsage;
355
344
  const parentContextWindow = getParentContextWindow(ctx);
356
- // Shared runner for the per-task GATE children (verify + post-FAIL recommend).
357
- // Both are read-only passes of the same local model that must run to
358
- // completion: unguarded (no wall-clock timeout, exact-match loop guard only,
359
- // path-revisit disabled because re-running the same check is the job), with a
360
- // status widget and a per-gate debug log. Returns the closure
361
- // runWorkVerification / researchResolution expect as `runChild`.
362
- const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
363
- lastLine = undefined;
364
- contextUsage = undefined;
365
- const startedAt = Date.now();
366
- const logPath = path.join(tasksDir(cwd2), logFile);
367
- const log = (msg) => {
368
- void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
369
- };
370
- log(`=== ${kind} start: ${taskTitle} ===`);
371
- const stopLoader = startAutoLoader(gateCtx, () => ({
372
- title: taskTitle,
373
- kind,
374
- step: kind,
375
- stepNum: 1,
376
- stepTotal: 1,
377
- startedAt,
378
- lastLine,
379
- contextUsage
380
- }));
381
- try {
382
- const r = await runWorker({
383
- prompt,
384
- cwd: cwd2,
385
- signal: sig,
386
- tools,
387
- timeoutMs: 0,
388
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
389
- onLine: line => {
390
- lastLine = line;
391
- log(line);
392
- },
393
- onContextUsage: snapshot => {
394
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
395
- }
396
- });
397
- if (r.loopHit) {
398
- log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
399
- gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
400
- }
401
- const failure = classifyEnforceChildFailure(r);
402
- log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
403
- if (failure)
404
- throw new Error(failure);
405
- return r.text;
406
- }
407
- finally {
408
- stopLoader();
409
- }
410
- };
411
345
  const phaseDeps = {
412
346
  cwd,
413
347
  taskId: '',
@@ -420,6 +354,9 @@ function defaultDeps(ctx, cwd, signal, title) {
420
354
  }
421
355
  };
422
356
  return {
357
+ // Planning-only seam. The shared gate surface (runTask/commit/verify/
358
+ // enforce/recommend/revert) comes from buildGateDeps below — identical to
359
+ // what /task builds, so both commands gate the same way.
423
360
  runChild: async (name, tools, prompt) => {
424
361
  // Planning children are slow LLM calls with no UI of their own; show
425
362
  // the same status block as /task so this never goes silent until the
@@ -444,179 +381,10 @@ function defaultDeps(ctx, cwd, signal, title) {
444
381
  stopLoader();
445
382
  }
446
383
  },
447
- runTask: (c, cwd2, t, opts) => runSingleTask(c, cwd2, t, {
448
- waitForImplementation: true,
449
- resumeId: opts?.resumeId,
450
- onStart: opts?.onStart,
451
- planContext: opts?.planContext,
452
- fixInstruction: opts?.fixInstruction
453
- }),
454
- commit: (cwd2, message) => getConfig().autoCommit ?
455
- gitCommitAll(cwd2, message, signal)
456
- : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
457
- revert: cwd2 => gitDropLastCommit(cwd2, signal),
458
- enforce: (enforceCtx, cwd2, taskTitle, mode) => {
459
- if (!getConfig().enforceGuidelines) {
460
- return Promise.resolve({ ok: true, reason: 'disabled' });
461
- }
462
- return runGuidelineEnforcement({
463
- cwd: cwd2,
464
- signal,
465
- mode,
466
- // Run the worker child UNGUARDED: no loop detector, no wall-clock
467
- // timeout. This pass's job is to rework files in place until every
468
- // violation is fixed, and it legitimately reads and edits the same
469
- // file many times — the research-worker guards mislabel that as a
470
- // runaway and kill good work (proven on mx5 TASK_0002). Let it
471
- // run until the model finishes; classifyEnforceChildFailure still
472
- // blocks the commit on a real failure (non-zero exit, leaked tool
473
- // call) or a user cancel.
474
- runChild: async (tools, prompt, sig) => {
475
- // The enforcement child is a slow local-model pass with edit
476
- // tools; show the same /task-auto status block as planning so
477
- // it isn't silent — head · enforcing guidelines/elapsed ·
478
- // tokens/window [bar] · ↳ last line. The worker runs the same
479
- // `--mode json` stream as the phase children, so it emits
480
- // context_usage; forward it (onContextUsage below) so the bar
481
- // renders here exactly like every other phase widget.
482
- lastLine = undefined;
483
- contextUsage = undefined;
484
- const startedAt = Date.now();
485
- // Per-pass debug log. The enforce child is otherwise
486
- // unobservable (it has no per-task file like the impl runner).
487
- // One rolling file under .pi-tasks/; each pass brackets its
488
- // tool/text lines with start/end markers naming the task and
489
- // the terminal outcome, so a stop (a real failure or a found
490
- // violation) is diagnosable. Fire-and-forget.
491
- const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
492
- const logEnforce = (msg) => {
493
- void fsp
494
- .appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
495
- .catch(() => { });
496
- };
497
- logEnforce(`=== enforce start: ${taskTitle} ===`);
498
- const stopLoader = startAutoLoader(enforceCtx, () => ({
499
- title: taskTitle,
500
- kind: 'enforce',
501
- step: 'guidelines',
502
- stepNum: 1,
503
- stepTotal: 1,
504
- startedAt,
505
- lastLine,
506
- contextUsage
507
- }));
508
- try {
509
- const r = await runWorker({
510
- prompt,
511
- cwd: cwd2,
512
- signal: sig,
513
- tools,
514
- timeoutMs: 0, // no wall-clock timeout — run to completion
515
- // Exact-match loop guard only: pathThreshold Infinity
516
- // disables the path-revisit heuristic, so revisiting one
517
- // file (which IS this pass's job) never trips — only a
518
- // literally-identical call repeated past threshold does
519
- // (e.g. the same read or edit repeated hundreds of times).
520
- // A hit nudges via the normal restart-with-hint; a loop
521
- // that survives the nudges is WARNED, not blocked (see
522
- // r.loopHit handling below and classifyEnforceChildFailure).
523
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
524
- onLine: line => {
525
- lastLine = line;
526
- logEnforce(line);
527
- },
528
- onContextUsage: snapshot => {
529
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
530
- }
531
- });
532
- // A loop that survived the restart-with-hint nudges is a
533
- // warning, not a failure: log it and tell the user, but let
534
- // the verdict gate (below) be the only thing that can block.
535
- if (r.loopHit) {
536
- logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
537
- enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
538
- }
539
- const failure = classifyEnforceChildFailure(r);
540
- logEnforce(failure ?
541
- `=== enforce end: FAIL — ${failure} ===`
542
- : '=== enforce end: verdict captured ===');
543
- if (failure)
544
- throw new Error(failure);
545
- return r.text;
546
- }
547
- finally {
548
- stopLoader();
549
- }
550
- }
551
- });
552
- },
553
- verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
554
- if (!getConfig().verifyWork) {
555
- return { ok: true, reason: 'disabled' };
556
- }
557
- // The spec to verify against is the composed spec committed in the
558
- // task file. A task that never reached compose has no spec section —
559
- // runWorkVerification treats a null spec as a no-op pass.
560
- let spec;
561
- try {
562
- const { body } = await readTaskFile(cwd2, taskId);
563
- spec = extractSpecForVerification(body);
564
- }
565
- catch {
566
- spec = null;
567
- }
568
- return runWorkVerification({
569
- cwd: cwd2,
570
- signal,
571
- spec,
572
- runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
573
- });
574
- },
575
- recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
576
- // Read the same composed spec the verify gate judged against, so the
577
- // recommendation reasons over the real contract (a missing spec is
578
- // unusual here — verify already ran — but degrade to the bare title).
579
- let spec;
580
- try {
581
- const { body } = await readTaskFile(cwd2, taskId);
582
- spec = extractSpecForVerification(body) ?? taskTitle;
583
- }
584
- catch {
585
- spec = taskTitle;
586
- }
587
- return researchResolution({
588
- cwd: cwd2,
589
- signal,
590
- spec,
591
- failReason,
592
- runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
593
- });
594
- }
384
+ ...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask })
595
385
  };
596
386
  }
597
387
  // ─── Loop ────────────────────────────────────────────────────────────────────
598
- /**
599
- * Show the boxed two-choice picker after a verify FAIL and return what the user
600
- * decided. The model-recommended card is placed first so the renderer tints it
601
- * green; the user ALWAYS makes the final call (there is no auto-pick). Mirrors the
602
- * clarify/grill dialog: the same SessionUI.ask races the local boxed picker
603
- * against a remote answer, with the two actions also surfaced as remote buttons.
604
- */
605
- async function askVerifyResolution(ctx, title, failReason, rec) {
606
- const options = resolutionOptions(rec.recommend);
607
- const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
608
- + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
609
- const answer = await new SessionUI(ctx).ask({
610
- localTitle: 'Verification failed — how should pi proceed?',
611
- displayQuestion: question,
612
- question,
613
- recommended: options[0].label,
614
- recommended2: options[1].label,
615
- allowSkip: false,
616
- options
617
- });
618
- return classifyResolutionAnswer(answer);
619
- }
620
388
  let cancelRequested = false;
621
389
  let autoRunning = false;
622
390
  export function requestAutoCancel() {
@@ -723,162 +491,46 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
723
491
  return;
724
492
  }
725
493
  // GATE: actually RUN the task's verification against the just-finished
726
- // work BEFORE it is checked off or committed. The composed spec ships a
727
- // VERIFY block, but nothing in the pipeline ever executed it a task
728
- // that did not work was checked off identically to one that did. Run it
729
- // now in the real workspace; a FAIL stops the whole run exactly like an
730
- // implementation failure the task is left UNCHECKED and UNCOMMITTED so
731
- // /task-auto-resume re-runs it (rather than blessing broken work). Off
732
- // by default (deps.verify returns a disabled no-op) and a no-op when the
733
- // task has no composed spec to verify against.
734
- //
735
- // Whether this gate produced a GENUINE clean pass (a real signal ran
736
- // and the work met it) also decides how the enforce pass below is
737
- // allowed to behave: only a genuine pass gives a signal to revert
738
- // against, so only then may enforce edit in place (see the enforce
739
- // block). A no-op pass (no spec), a disabled gate, or a user
740
- // accept-override leaves this false enforce runs flag-only.
741
- let verifyCleanPass = false;
742
- if (deps.verify) {
743
- active.ui.notify(`${id}: verifying "${next.title}"…`, 'info');
744
- let verified = await deps.verify(active, cwd, next.title, res.taskId);
745
- // A FAIL no longer dead-stops the run. The user is offered a boxed
746
- // two-choice picker — AUTOFIX (re-run the implementation turn against
747
- // the failure, then re-verify) or ACCEPT (the gate misjudged a good
748
- // artifact; override and proceed). A read-only research pass picks
749
- // which card is recommended, but the USER always decides — there is
750
- // no attempt cap: AUTOFIX loops straight back to the gate as many
751
- // times as the user keeps choosing it. The run only leaves the loop
752
- // when the work verifies, the user ACCEPTs, or the user dismisses
753
- // the picker (which pauses the run, resumable).
754
- while (!verified.ok) {
755
- const failReason = verified.reason ?? 'did not verify';
756
- const rec = deps.recommend ?
757
- await deps.recommend(active, cwd, next.title, res.taskId, failReason)
758
- : { recommend: 'autofix', rationale: failReason };
759
- const choice = await askVerifyResolution(active, next.title, failReason, rec);
760
- if (choice.action === 'cancel') {
761
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
762
- announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
763
- return;
764
- }
765
- if (choice.action === 'accept') {
766
- active.ui.notify(`${id}: accepted "${next.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
767
- break;
768
- }
769
- // AUTOFIX: re-run the implementation turn with the failure (and
770
- // any typed guidance) prepended as a RE-ATTEMPT banner, then
771
- // re-verify — looping back to the gate. No cap: the user decides
772
- // when to stop (by accepting or dismissing the next picker).
773
- active.ui.notify(`${id}: autofixing "${next.title}"…`, 'info');
774
- const fixInstruction = choice.guidance ?
775
- `${failReason}\n\nUser guidance: ${choice.guidance}`
776
- : failReason;
777
- const fixRes = await deps.runTask(active, cwd, next.title, {
778
- resumeId: res.taskId,
779
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
780
- fixInstruction
781
- });
782
- active = fixRes.ctx ?? active;
783
- if (fixRes.sessionCancelled) {
784
- announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
785
- return;
786
- }
787
- if (fixRes.interrupted) {
788
- announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
789
- return;
790
- }
791
- if (!fixRes.ok) {
792
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
793
- const why = fixRes.reason ? ` — ${fixRes.reason.slice(0, 160)}` : '';
794
- announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
795
- return;
796
- }
797
- // Resume reuses the same inner task id, so res.taskId is stable.
798
- verified = await deps.verify(active, cwd, next.title, res.taskId);
799
- }
800
- // Loop exited because the work verified OR the user accepted the
801
- // artifact — either way fall through to check-off/commit/enforce.
802
- // A genuine clean pass is ok===true with NO reason; a no-op pass
803
- // ('no spec to verify') or an accept-override (verified.ok still
804
- // false at break) does NOT count as a guardable signal.
805
- verifyCleanPass = verified.ok && !verified.reason;
494
+ // work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
495
+ // verbatim with /task so both commands gate identicallysee
496
+ // runGatesForTask. `done` means the work verified (or was accepted),
497
+ // was checked off + committed, and enforcement ran; every other kind is
498
+ // a terminal stop this loop announces with its own /task-auto-resume
499
+ // wording (the shared gate is command-agnostic).
500
+ const gate = await runGatesForTask(active, deps, {
501
+ cwd,
502
+ taskId: res.taskId,
503
+ title: next.title,
504
+ tag: id,
505
+ // Fence an AUTOFIX re-run against re-expanding the whole spec.
506
+ planContext: buildScopeFence(entries.map(e => e.title), next.index),
507
+ // res.ok === true means runner.run() completed, so res.taskId is the
508
+ // allocated TASK_NNNN id (never empty here). The parent task-list
509
+ // check-off runs after verify passes/accepts and before the commit,
510
+ // so the commit captures the checked box too.
511
+ onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
512
+ });
513
+ active = gate.ctx;
514
+ if (gate.kind === 'paused') {
515
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
516
+ announceDone(active, `${id} paused at "${next.title}" verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
517
+ return;
806
518
  }
807
- // res.ok === true means runner.run() completed, so res.taskId is the
808
- // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
809
- // empty id by writing a plain checked line, but that path is unreachable.
810
- await checkOffTask(cwd, id, next.index, res.taskId, next.title);
811
- // Commit the task's work (and the just-written check-off) as one
812
- // snapshot FIRST — before guideline enforcement — so a passing task is
813
- // durably recorded no matter what enforcement later finds. Best-effort:
814
- // a failed/empty commit only warns; the task already passed.
815
- const message = `task: ${next.title} (${res.taskId})`;
816
- const commit = await deps.commit(cwd, message);
817
- if (commit.committed) {
818
- active.ui.notify(`${id}: committed "${next.title}".`, 'info');
519
+ if (gate.kind === 'session-cancelled') {
520
+ announceDone(active, `${id} paused could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
521
+ return;
819
522
  }
820
- else {
821
- active.ui.notify(`${id}: not committed (${commit.reason ?? 'unknown'})continuing.`, 'warning');
523
+ if (gate.kind === 'interrupted') {
524
+ announceDone(active, `${id} paused at "${next.title}"resume with /task-auto-resume.`, 'warning');
525
+ return;
822
526
  }
823
- // With the task committed, hold its work to the project's AGENTS.md /
824
- // CLAUDE.md rules but as a step INSIDE the validation gate, never the
825
- // blind post-commit write pass it used to be. A bare read,edit enforce
826
- // pass trashes working code: A/B-proven, it degraded a clean build in
827
- // 4/5 runs while declaring CLEAN. So enforcement is now gated by the
828
- // verify signal:
829
- //
830
- // - 'edit' mode (read,edit — fix in place) runs ONLY when the verify
831
- // gate just produced a genuine clean pass. That pass is the
832
- // differential SIGNAL: enforce commits its fixes, then the SAME
833
- // signal is re-run against the enforced tree. A regression means the
834
- // pass made the artifact worse than it found it — its commit is
835
- // dropped and the verified implementation is kept (A/B: 5/5 clean).
836
- // - 'flag' mode (read-only — report don't fix) runs when there is no
837
- // such signal (verify off, no spec, or an accept-override): with
838
- // nothing to revert against, the pass may not rewrite logic, only
839
- // surface violations as a warning ("no signal ⇒ no license"; A/B:
840
- // 5/5 clean, 5/5 caught the real violation).
841
- //
842
- // Skipped when nothing was committed this round (autoCommit off or an
843
- // empty commit), when the feature is off, when there are no guideline
844
- // files, or in tests with no enforce dep.
845
- if (deps.enforce && commit.committed) {
846
- const mode = verifyCleanPass ? 'edit' : 'flag';
847
- active.ui.notify(mode === 'edit' ?
848
- `${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`
849
- : `${id}: reviewing "${next.title}" against AGENTS.md/CLAUDE.md (no verify signal — report only)…`, 'info');
850
- const verdict = await deps.enforce(active, cwd, next.title, mode);
851
- if (!verdict.ok) {
852
- active.ui.notify(`${id}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
853
- }
854
- if (mode === 'edit') {
855
- // Commit whatever the pass fixed as its own snapshot. A no-op
856
- // when it made no edits (nothing to commit) — and then there is
857
- // nothing to re-verify or revert, so a clean task skips the
858
- // second verify entirely.
859
- const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
860
- if (enforceCommit.committed) {
861
- // Differential guard: re-run the verify signal against the
862
- // enforced tree. verifyCleanPass implies deps.verify exists,
863
- // so this is a real check. A regression ⇒ drop the enforce
864
- // commit, keep the verified task commit underneath it.
865
- const after = deps.verify ?
866
- await deps.verify(active, cwd, next.title, res.taskId)
867
- : { ok: true };
868
- if (!after.ok) {
869
- // Regression: drop the enforce commit if we can, and
870
- // never report it as a kept fix.
871
- if (deps.revert)
872
- await deps.revert(cwd);
873
- active.ui.notify(`${id}: guideline fixes regressed verification on "${next.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
874
- }
875
- else {
876
- active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
877
- }
878
- }
879
- }
880
- // 'flag' mode makes no edits — nothing to commit or revert.
527
+ if (gate.kind === 'failed') {
528
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
529
+ const why = gate.reason ? ` ${gate.reason.slice(0, 160)}` : '';
530
+ announceDone(active, `${id} stopped at "${next.title}"${why} fix and run /task-auto-resume.`, 'error');
531
+ return;
881
532
  }
533
+ // gate.kind === 'done' → fall through to the next task.
882
534
  }
883
535
  }
884
536
  catch (err) {
@@ -9,7 +9,7 @@
9
9
  * when no clarification remains. priorQA carries the questions already answered so
10
10
  * each next question adapts to them.
11
11
  */
12
- export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string, projectState?: string) => string;
12
+ export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) => string;
13
13
  /**
14
14
  * Decompose: output a markdown checkbox list of task titles (one line each).
15
15
  */