@mjasnikovs/pi-task 0.17.0 → 0.17.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.
@@ -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,25 +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
14
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
15
15
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
16
16
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
17
- import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
18
- import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
19
- import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
20
- import { researchResolution, resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
21
- import { runWorker } from '../workers/pi-worker-core.js';
22
17
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
23
- import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
18
+ import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
24
19
  import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
25
20
  import { pushNotify } from '../remote/push.js';
26
- import { getConfig } from '../config/config.js';
27
21
  import { startAutoLoader } from './widget.js';
28
22
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
23
+ import { buildGateDeps } from './gate-deps.js';
24
+ import { runGatesForTask } from './task-gates.js';
29
25
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
30
26
  // when the model emits NONE), but a model that never says NONE would otherwise
31
27
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -340,66 +336,12 @@ const AUTO_PLAN_STEPS = {
340
336
  };
341
337
  const AUTO_PLAN_STEP_TOTAL = 2;
342
338
  function defaultDeps(ctx, cwd, signal, title) {
343
- // Captured by the loader's getState so the widget mirrors the child's latest
344
- // 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.)
345
342
  let lastLine;
346
343
  let contextUsage;
347
344
  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
- };
403
345
  const phaseDeps = {
404
346
  cwd,
405
347
  taskId: '',
@@ -412,6 +354,9 @@ function defaultDeps(ctx, cwd, signal, title) {
412
354
  }
413
355
  };
414
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.
415
360
  runChild: async (name, tools, prompt) => {
416
361
  // Planning children are slow LLM calls with no UI of their own; show
417
362
  // the same status block as /task so this never goes silent until the
@@ -436,179 +381,10 @@ function defaultDeps(ctx, cwd, signal, title) {
436
381
  stopLoader();
437
382
  }
438
383
  },
439
- runTask: (c, cwd2, t, opts) => runSingleTask(c, cwd2, t, {
440
- waitForImplementation: true,
441
- resumeId: opts?.resumeId,
442
- onStart: opts?.onStart,
443
- planContext: opts?.planContext,
444
- fixInstruction: opts?.fixInstruction
445
- }),
446
- commit: (cwd2, message) => getConfig().autoCommit ?
447
- gitCommitAll(cwd2, message, signal)
448
- : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
449
- revert: cwd2 => gitDropLastCommit(cwd2, signal),
450
- enforce: (enforceCtx, cwd2, taskTitle, mode) => {
451
- if (!getConfig().enforceGuidelines) {
452
- return Promise.resolve({ ok: true, reason: 'disabled' });
453
- }
454
- return runGuidelineEnforcement({
455
- cwd: cwd2,
456
- signal,
457
- mode,
458
- // Run the worker child UNGUARDED: no loop detector, no wall-clock
459
- // timeout. This pass's job is to rework files in place until every
460
- // violation is fixed, and it legitimately reads and edits the same
461
- // file many times — the research-worker guards mislabel that as a
462
- // runaway and kill good work (proven on mx5 TASK_0002). Let it
463
- // run until the model finishes; classifyEnforceChildFailure still
464
- // blocks the commit on a real failure (non-zero exit, leaked tool
465
- // call) or a user cancel.
466
- runChild: async (tools, prompt, sig) => {
467
- // The enforcement child is a slow local-model pass with edit
468
- // tools; show the same /task-auto status block as planning so
469
- // it isn't silent — head · enforcing guidelines/elapsed ·
470
- // tokens/window [bar] · ↳ last line. The worker runs the same
471
- // `--mode json` stream as the phase children, so it emits
472
- // context_usage; forward it (onContextUsage below) so the bar
473
- // renders here exactly like every other phase widget.
474
- lastLine = undefined;
475
- contextUsage = undefined;
476
- const startedAt = Date.now();
477
- // Per-pass debug log. The enforce child is otherwise
478
- // unobservable (it has no per-task file like the impl runner).
479
- // One rolling file under .pi-tasks/; each pass brackets its
480
- // tool/text lines with start/end markers naming the task and
481
- // the terminal outcome, so a stop (a real failure or a found
482
- // violation) is diagnosable. Fire-and-forget.
483
- const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
484
- const logEnforce = (msg) => {
485
- void fsp
486
- .appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
487
- .catch(() => { });
488
- };
489
- logEnforce(`=== enforce start: ${taskTitle} ===`);
490
- const stopLoader = startAutoLoader(enforceCtx, () => ({
491
- title: taskTitle,
492
- kind: 'enforce',
493
- step: 'guidelines',
494
- stepNum: 1,
495
- stepTotal: 1,
496
- startedAt,
497
- lastLine,
498
- contextUsage
499
- }));
500
- try {
501
- const r = await runWorker({
502
- prompt,
503
- cwd: cwd2,
504
- signal: sig,
505
- tools,
506
- timeoutMs: 0, // no wall-clock timeout — run to completion
507
- // Exact-match loop guard only: pathThreshold Infinity
508
- // disables the path-revisit heuristic, so revisiting one
509
- // file (which IS this pass's job) never trips — only a
510
- // literally-identical call repeated past threshold does
511
- // (e.g. the same read or edit repeated hundreds of times).
512
- // A hit nudges via the normal restart-with-hint; a loop
513
- // that survives the nudges is WARNED, not blocked (see
514
- // r.loopHit handling below and classifyEnforceChildFailure).
515
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
516
- onLine: line => {
517
- lastLine = line;
518
- logEnforce(line);
519
- },
520
- onContextUsage: snapshot => {
521
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
522
- }
523
- });
524
- // A loop that survived the restart-with-hint nudges is a
525
- // warning, not a failure: log it and tell the user, but let
526
- // the verdict gate (below) be the only thing that can block.
527
- if (r.loopHit) {
528
- logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
529
- enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
530
- }
531
- const failure = classifyEnforceChildFailure(r);
532
- logEnforce(failure ?
533
- `=== enforce end: FAIL — ${failure} ===`
534
- : '=== enforce end: verdict captured ===');
535
- if (failure)
536
- throw new Error(failure);
537
- return r.text;
538
- }
539
- finally {
540
- stopLoader();
541
- }
542
- }
543
- });
544
- },
545
- verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
546
- if (!getConfig().verifyWork) {
547
- return { ok: true, reason: 'disabled' };
548
- }
549
- // The spec to verify against is the composed spec committed in the
550
- // task file. A task that never reached compose has no spec section —
551
- // runWorkVerification treats a null spec as a no-op pass.
552
- let spec;
553
- try {
554
- const { body } = await readTaskFile(cwd2, taskId);
555
- spec = extractSpecForVerification(body);
556
- }
557
- catch {
558
- spec = null;
559
- }
560
- return runWorkVerification({
561
- cwd: cwd2,
562
- signal,
563
- spec,
564
- runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
565
- });
566
- },
567
- recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
568
- // Read the same composed spec the verify gate judged against, so the
569
- // recommendation reasons over the real contract (a missing spec is
570
- // unusual here — verify already ran — but degrade to the bare title).
571
- let spec;
572
- try {
573
- const { body } = await readTaskFile(cwd2, taskId);
574
- spec = extractSpecForVerification(body) ?? taskTitle;
575
- }
576
- catch {
577
- spec = taskTitle;
578
- }
579
- return researchResolution({
580
- cwd: cwd2,
581
- signal,
582
- spec,
583
- failReason,
584
- runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
585
- });
586
- }
384
+ ...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask })
587
385
  };
588
386
  }
589
387
  // ─── Loop ────────────────────────────────────────────────────────────────────
590
- /**
591
- * Show the boxed two-choice picker after a verify FAIL and return what the user
592
- * decided. The model-recommended card is placed first so the renderer tints it
593
- * green; the user ALWAYS makes the final call (there is no auto-pick). Mirrors the
594
- * clarify/grill dialog: the same SessionUI.ask races the local boxed picker
595
- * against a remote answer, with the two actions also surfaced as remote buttons.
596
- */
597
- async function askVerifyResolution(ctx, title, failReason, rec) {
598
- const options = resolutionOptions(rec.recommend);
599
- const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
600
- + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
601
- const answer = await new SessionUI(ctx).ask({
602
- localTitle: 'Verification failed — how should pi proceed?',
603
- displayQuestion: question,
604
- question,
605
- recommended: options[0].label,
606
- recommended2: options[1].label,
607
- allowSkip: false,
608
- options
609
- });
610
- return classifyResolutionAnswer(answer);
611
- }
612
388
  let cancelRequested = false;
613
389
  let autoRunning = false;
614
390
  export function requestAutoCancel() {
@@ -715,162 +491,46 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
715
491
  return;
716
492
  }
717
493
  // GATE: actually RUN the task's verification against the just-finished
718
- // work BEFORE it is checked off or committed. The composed spec ships a
719
- // VERIFY block, but nothing in the pipeline ever executed it a task
720
- // that did not work was checked off identically to one that did. Run it
721
- // now in the real workspace; a FAIL stops the whole run exactly like an
722
- // implementation failure the task is left UNCHECKED and UNCOMMITTED so
723
- // /task-auto-resume re-runs it (rather than blessing broken work). Off
724
- // by default (deps.verify returns a disabled no-op) and a no-op when the
725
- // task has no composed spec to verify against.
726
- //
727
- // Whether this gate produced a GENUINE clean pass (a real signal ran
728
- // and the work met it) also decides how the enforce pass below is
729
- // allowed to behave: only a genuine pass gives a signal to revert
730
- // against, so only then may enforce edit in place (see the enforce
731
- // block). A no-op pass (no spec), a disabled gate, or a user
732
- // accept-override leaves this false enforce runs flag-only.
733
- let verifyCleanPass = false;
734
- if (deps.verify) {
735
- active.ui.notify(`${id}: verifying "${next.title}"…`, 'info');
736
- let verified = await deps.verify(active, cwd, next.title, res.taskId);
737
- // A FAIL no longer dead-stops the run. The user is offered a boxed
738
- // two-choice picker — AUTOFIX (re-run the implementation turn against
739
- // the failure, then re-verify) or ACCEPT (the gate misjudged a good
740
- // artifact; override and proceed). A read-only research pass picks
741
- // which card is recommended, but the USER always decides — there is
742
- // no attempt cap: AUTOFIX loops straight back to the gate as many
743
- // times as the user keeps choosing it. The run only leaves the loop
744
- // when the work verifies, the user ACCEPTs, or the user dismisses
745
- // the picker (which pauses the run, resumable).
746
- while (!verified.ok) {
747
- const failReason = verified.reason ?? 'did not verify';
748
- const rec = deps.recommend ?
749
- await deps.recommend(active, cwd, next.title, res.taskId, failReason)
750
- : { recommend: 'autofix', rationale: failReason };
751
- const choice = await askVerifyResolution(active, next.title, failReason, rec);
752
- if (choice.action === 'cancel') {
753
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
754
- announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
755
- return;
756
- }
757
- if (choice.action === 'accept') {
758
- active.ui.notify(`${id}: accepted "${next.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
759
- break;
760
- }
761
- // AUTOFIX: re-run the implementation turn with the failure (and
762
- // any typed guidance) prepended as a RE-ATTEMPT banner, then
763
- // re-verify — looping back to the gate. No cap: the user decides
764
- // when to stop (by accepting or dismissing the next picker).
765
- active.ui.notify(`${id}: autofixing "${next.title}"…`, 'info');
766
- const fixInstruction = choice.guidance ?
767
- `${failReason}\n\nUser guidance: ${choice.guidance}`
768
- : failReason;
769
- const fixRes = await deps.runTask(active, cwd, next.title, {
770
- resumeId: res.taskId,
771
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
772
- fixInstruction
773
- });
774
- active = fixRes.ctx ?? active;
775
- if (fixRes.sessionCancelled) {
776
- announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
777
- return;
778
- }
779
- if (fixRes.interrupted) {
780
- announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
781
- return;
782
- }
783
- if (!fixRes.ok) {
784
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
785
- const why = fixRes.reason ? ` — ${fixRes.reason.slice(0, 160)}` : '';
786
- announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
787
- return;
788
- }
789
- // Resume reuses the same inner task id, so res.taskId is stable.
790
- verified = await deps.verify(active, cwd, next.title, res.taskId);
791
- }
792
- // Loop exited because the work verified OR the user accepted the
793
- // artifact — either way fall through to check-off/commit/enforce.
794
- // A genuine clean pass is ok===true with NO reason; a no-op pass
795
- // ('no spec to verify') or an accept-override (verified.ok still
796
- // false at break) does NOT count as a guardable signal.
797
- 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;
798
518
  }
799
- // res.ok === true means runner.run() completed, so res.taskId is the
800
- // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
801
- // empty id by writing a plain checked line, but that path is unreachable.
802
- await checkOffTask(cwd, id, next.index, res.taskId, next.title);
803
- // Commit the task's work (and the just-written check-off) as one
804
- // snapshot FIRST — before guideline enforcement — so a passing task is
805
- // durably recorded no matter what enforcement later finds. Best-effort:
806
- // a failed/empty commit only warns; the task already passed.
807
- const message = `task: ${next.title} (${res.taskId})`;
808
- const commit = await deps.commit(cwd, message);
809
- if (commit.committed) {
810
- 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;
811
522
  }
812
- else {
813
- 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;
814
526
  }
815
- // With the task committed, hold its work to the project's AGENTS.md /
816
- // CLAUDE.md rules but as a step INSIDE the validation gate, never the
817
- // blind post-commit write pass it used to be. A bare read,edit enforce
818
- // pass trashes working code: A/B-proven, it degraded a clean build in
819
- // 4/5 runs while declaring CLEAN. So enforcement is now gated by the
820
- // verify signal:
821
- //
822
- // - 'edit' mode (read,edit — fix in place) runs ONLY when the verify
823
- // gate just produced a genuine clean pass. That pass is the
824
- // differential SIGNAL: enforce commits its fixes, then the SAME
825
- // signal is re-run against the enforced tree. A regression means the
826
- // pass made the artifact worse than it found it — its commit is
827
- // dropped and the verified implementation is kept (A/B: 5/5 clean).
828
- // - 'flag' mode (read-only — report don't fix) runs when there is no
829
- // such signal (verify off, no spec, or an accept-override): with
830
- // nothing to revert against, the pass may not rewrite logic, only
831
- // surface violations as a warning ("no signal ⇒ no license"; A/B:
832
- // 5/5 clean, 5/5 caught the real violation).
833
- //
834
- // Skipped when nothing was committed this round (autoCommit off or an
835
- // empty commit), when the feature is off, when there are no guideline
836
- // files, or in tests with no enforce dep.
837
- if (deps.enforce && commit.committed) {
838
- const mode = verifyCleanPass ? 'edit' : 'flag';
839
- active.ui.notify(mode === 'edit' ?
840
- `${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`
841
- : `${id}: reviewing "${next.title}" against AGENTS.md/CLAUDE.md (no verify signal — report only)…`, 'info');
842
- const verdict = await deps.enforce(active, cwd, next.title, mode);
843
- if (!verdict.ok) {
844
- active.ui.notify(`${id}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
845
- }
846
- if (mode === 'edit') {
847
- // Commit whatever the pass fixed as its own snapshot. A no-op
848
- // when it made no edits (nothing to commit) — and then there is
849
- // nothing to re-verify or revert, so a clean task skips the
850
- // second verify entirely.
851
- const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
852
- if (enforceCommit.committed) {
853
- // Differential guard: re-run the verify signal against the
854
- // enforced tree. verifyCleanPass implies deps.verify exists,
855
- // so this is a real check. A regression ⇒ drop the enforce
856
- // commit, keep the verified task commit underneath it.
857
- const after = deps.verify ?
858
- await deps.verify(active, cwd, next.title, res.taskId)
859
- : { ok: true };
860
- if (!after.ok) {
861
- // Regression: drop the enforce commit if we can, and
862
- // never report it as a kept fix.
863
- if (deps.revert)
864
- await deps.revert(cwd);
865
- 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');
866
- }
867
- else {
868
- active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
869
- }
870
- }
871
- }
872
- // '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;
873
532
  }
533
+ // gate.kind === 'done' → fall through to the next task.
874
534
  }
875
535
  }
876
536
  catch (err) {
@@ -0,0 +1,16 @@
1
+ import type { GateDeps } from './task-gates.js';
2
+ /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
3
+ * command so this module stays free of the orchestrators (avoids an import cycle). */
4
+ export type RunTaskFn = GateDeps['runTask'];
5
+ /**
6
+ * Build the gate deps for one command run. `runTask` is the orchestrator's
7
+ * implementation re-runner, injected by the caller. The returned object also drives
8
+ * a shared status widget: `getLastLine`/`getContextUsage` expose the children's
9
+ * latest stream line and context usage so the caller can mirror them in its own
10
+ * loader if it wants (the gate closures already render their own loaders).
11
+ */
12
+ export declare function buildGateDeps(params: {
13
+ signal: AbortSignal;
14
+ parentContextWindow: number;
15
+ runTask: RunTaskFn;
16
+ }): GateDeps;