@mjasnikovs/pi-task 0.38.2 → 0.38.4

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.
Files changed (49) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/deep-render-check.d.ts +30 -0
  14. package/dist/task/deep-render-check.js +19 -11
  15. package/dist/task/enforce-guidelines.d.ts +14 -17
  16. package/dist/task/enforce-guidelines.js +44 -31
  17. package/dist/task/final-gate.d.ts +8 -10
  18. package/dist/task/final-gate.js +36 -74
  19. package/dist/task/gate-child.d.ts +104 -0
  20. package/dist/task/gate-child.js +177 -0
  21. package/dist/task/gate-deps.d.ts +13 -0
  22. package/dist/task/gate-deps.js +72 -208
  23. package/dist/task/orchestrator.js +13 -22
  24. package/dist/task/phases.js +109 -182
  25. package/dist/task/plan-session.d.ts +4 -22
  26. package/dist/task/plan-session.js +4 -33
  27. package/dist/task/question-dialog.d.ts +71 -0
  28. package/dist/task/question-dialog.js +89 -0
  29. package/dist/task/terminal-outcome.d.ts +67 -0
  30. package/dist/task/terminal-outcome.js +76 -0
  31. package/dist/task/type-only-answer.js +2 -3
  32. package/dist/workers/abstention.d.ts +71 -0
  33. package/dist/workers/abstention.js +108 -0
  34. package/dist/workers/docs-chunk.d.ts +74 -0
  35. package/dist/workers/docs-chunk.js +143 -0
  36. package/dist/workers/docs-core.d.ts +10 -1
  37. package/dist/workers/docs-core.js +22 -19
  38. package/dist/workers/docs-index.js +2 -69
  39. package/dist/workers/docs-project.d.ts +15 -1
  40. package/dist/workers/docs-project.js +27 -66
  41. package/dist/workers/fetch-core.d.ts +1 -1
  42. package/dist/workers/fetch-core.js +2 -1
  43. package/dist/workers/pi-worker-core.js +157 -86
  44. package/dist/workers/pi-worker-docs.js +5 -10
  45. package/dist/workers/pi-worker-fetch.js +8 -1
  46. package/dist/workers/typeonly-log.js +2 -10
  47. package/dist/workers/worker-failure.d.ts +91 -0
  48. package/dist/workers/worker-failure.js +82 -0
  49. package/package.json +1 -1
@@ -18,7 +18,7 @@ import * as fsp from 'node:fs/promises';
18
18
  import * as path from 'node:path';
19
19
  import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
20
20
  import { gitCommitAll, gitDropLastCommit, git } from './auto-commit.js';
21
- import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
21
+ import { runGuidelineEnforcement } from './enforce-guidelines.js';
22
22
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
23
23
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
@@ -41,11 +41,11 @@ import { findScriptEscapesInManifest, scriptEscapeVerifyFindings } from './scrip
41
41
  import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
42
42
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
43
43
  import { runWorker } from '../workers/pi-worker-core.js';
44
- import { formatLoopHint } from './child-runner.js';
45
44
  import { getConfig } from '../config/config.js';
46
45
  import { makeDebugAppender } from './debug-log.js';
47
46
  import { startAutoLoader } from './widget.js';
48
47
  import { resolveContextUsage } from './context-usage.js';
48
+ import { makeGateChild } from './gate-child.js';
49
49
  /** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
50
50
  const TOOL_RESULT_LOG_LIMIT = 300;
51
51
  /**
@@ -61,6 +61,18 @@ export function truncateToolResult(text, limit = TOOL_RESULT_LOG_LIMIT) {
61
61
  }
62
62
  /** Keep the gate machinery's own artifacts out of every git pathspec below. */
63
63
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
64
+ /**
65
+ * Pin the diff header prefixes on any command whose output we PARSE for paths.
66
+ *
67
+ * `parseAddedLines` reads the file out of the `+++ b/…` header, but the prefix is
68
+ * user-configurable: `diff.mnemonicPrefix=true` emits `i/` and `w/` instead of
69
+ * `a/` and `b/`, and `diff.noprefix=true` emits none. Both are common developer
70
+ * settings, and under either one every added line was attributed to a path like
71
+ * `w/src/app.ts`, which exists nowhere — so the probe-gaming and sandbox-path
72
+ * probes silently read a diff of files that are not in the repo. Passing the
73
+ * prefixes explicitly makes the output independent of the host's git config.
74
+ */
75
+ const DIFF_PREFIX_ARGS = ['--src-prefix=a/', '--dst-prefix=b/'];
64
76
  const splitLines = (s) => s
65
77
  .split('\n')
66
78
  .map(l => l.trim())
@@ -110,7 +122,7 @@ export async function collectChangedFiles(cwd, signal) {
110
122
  * never a blocker. The `.pi-tasks/` bookkeeping is excluded from every git command.
111
123
  */
112
124
  export async function collectAddedLines(cwd, signal) {
113
- const tracked = await git(cwd, ['diff', 'HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
125
+ const tracked = await git(cwd, ['diff', ...DIFF_PREFIX_ARGS, 'HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
114
126
  const lines = tracked.exitCode === 0 ? parseAddedLines(tracked.stdout) : [];
115
127
  const untracked = await git(cwd, ['ls-files', '--others', '--exclude-standard', '--', '.', EXCLUDE_TASKS_DIR], signal);
116
128
  for (const name of splitLines(untracked.exitCode === 0 ? untracked.stdout : '')) {
@@ -124,7 +136,7 @@ export async function collectAddedLines(cwd, signal) {
124
136
  }
125
137
  }
126
138
  if (lines.length === 0) {
127
- const last = await git(cwd, ['diff', 'HEAD~1..HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
139
+ const last = await git(cwd, ['diff', ...DIFF_PREFIX_ARGS, 'HEAD~1..HEAD', '--', '.', EXCLUDE_TASKS_DIR], signal);
128
140
  return last.exitCode === 0 ? parseAddedLines(last.stdout) : [];
129
141
  }
130
142
  return lines;
@@ -141,7 +153,7 @@ export async function collectAddedLines(cwd, signal) {
141
153
  * cannot repair still reaches the child under rule 4e. Failures degrade to no
142
154
  * findings — a sharpener, never a blocker.
143
155
  */
144
- async function collectForeignPathFindings(cwd, signal, logDebug) {
156
+ export async function collectForeignPathFindings(cwd, signal, logDebug) {
145
157
  const lines = await collectAddedLines(cwd, signal);
146
158
  if (lines.length === 0)
147
159
  return [];
@@ -474,136 +486,51 @@ export function buildGateDeps(params) {
474
486
  await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
475
487
  await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
476
488
  };
477
- // Shared runner for the per-task GATE children (verify + post-FAIL recommend).
478
- // Both are read-only passes of the same local model that must run to completion:
479
- // unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
480
- // disabled because re-running the same check is the job), with a status widget
481
- // and a per-gate debug log. Returns the closure runWorkVerification /
482
- // researchResolution expect as `runChild`.
483
- const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile,
484
- /** `loader: false` when the CALLER already renders a loader that spans
485
- * this child (the verify gate does — see its dead-air note). Two
486
- * loaders on one widget key only fight each other. */
487
- opts = {}) => async (tools, prompt, sig) => {
488
- lastLine = undefined;
489
- contextUsage = undefined;
490
- lastGuardReconcile = null;
491
- const startedAt = Date.now();
492
- // `kind` defaults to 'event': every marker below (start/end, the
493
- // git-state guard's restore, the loop warning, a write-capable child's
494
- // tree changes) is a guard record that survives at the default level.
495
- // Only the child's own stdout and its tool results pass 'stream'.
496
- const log = makeDebugAppender(path.join(tasksDir(cwd2), logFile));
497
- log(`=== ${kind} start: ${taskTitle} ===`);
498
- // GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
499
- // contract is prompt-level and the live model breaks it (mx5 run 6: the
500
- // verify child `git stash`ed the task's uncommitted work and never popped
501
- // — the impl was destroyed and the orphan stash detonated 2 days later).
502
- // Snapshot before, deterministically restore after; lint-fix is excluded
503
- // because editing is its job (it carries its own revert guard).
504
- const guardSnapshot = kind === 'verify' || kind === 'recommend' ? await captureGitState(cwd2, sig) : null;
505
- const stopLoader = opts.loader === false ?
506
- () => { }
507
- : startAutoLoader(gateCtx, () => ({
508
- title: taskTitle,
509
- kind,
510
- step: kind,
511
- stepNum: 1,
512
- stepTotal: 1,
513
- startedAt,
514
- lastLine,
515
- contextUsage
516
- }));
517
- try {
518
- let r;
519
- try {
520
- r = await runWorker({
521
- prompt,
522
- cwd: cwd2,
523
- signal: sig,
524
- tools,
525
- timeoutMs: 0,
526
- // The gate child runs to completion (timeoutMs 0), but a
527
- // single command inside it must still be bounded: pi's bash
528
- // tool has no default timeout, so a `bun run dev` / hung
529
- // check the model forgot to bound wedges the gate forever.
530
- // The stall guard cannot see it — a reachable model endpoint
531
- // reads as proof of life while the command blocks. Same
532
- // ceiling the main session uses, so one /task-config knob
533
- // covers implementation and gates alike.
534
- commandTimeoutMs: getConfig().requestTimeoutMs,
535
- // Same reasoning one level up: a gate child with no
536
- // wall-clock cap also needs the HUNG-STREAM bound, which
537
- // the probe-based stall guard structurally cannot supply
538
- // (a healthy endpoint reads as proof of life).
539
- streamInactivityMs: getConfig().streamInactivityMs,
540
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
541
- // A discarded attempt is otherwise invisible here too: the
542
- // returned exitCode/text describe the FINAL attempt, so a
543
- // gate child that burned two attempts and its wall clock
544
- // reads exactly like one that ran clean.
545
- onRestart: rs => log(`=== ${kind} RESTART (attempt ${rs.attempt} discarded)`
546
- + ` reason=${rs.reason} wall=${rs.wallMs}ms`
547
- + (rs.detail ? ` — ${rs.detail}` : '')
548
- + ' ==='),
549
- onLine: line => {
550
- // `lastLine` feeds the LIVE status widget and is not
551
- // logging — it stays outside the gate, or a quiet
552
- // trail would also blank the progress display.
553
- lastLine = line;
554
- log(line, 'stream');
555
- },
556
- // Log tool OUTPUTS, not just the command (mx5 run 10 item 6):
557
- // without the result "verify claimed curl PASS on a server that
558
- // cannot serve" is undecidable from the log. Truncated, tail-kept
559
- // (a bind failure / status usually lands at the end), error-flagged.
560
- onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`, 'stream'),
561
- onContextUsage: snapshot => {
562
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
563
- }
564
- });
565
- }
566
- finally {
567
- // Restore whatever the child moved BEFORE any verdict/failure is
568
- // acted on — a crashed child must not skip the restore either.
569
- if (guardSnapshot) {
570
- const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
571
- lastGuardReconcile = rec;
572
- if (rec.mutated) {
573
- // Distinguish the two outcomes in the trail: a tainting
574
- // mutation (graded work altered → verdict will be
575
- // discarded) vs benign cleanup (test-runner output the
576
- // child left behind → verdict stands).
577
- const label = rec.verdictTainted ?
578
- 'child mutated graded state (verdict discarded)'
579
- : 'cleaned child test-runner artifacts (verdict kept)';
580
- log(`=== ${kind} GIT-STATE GUARD — ${label}; restored: ${rec.actions.join('; ')} ===`);
581
- if (rec.verdictTainted) {
582
- gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
583
- }
584
- }
585
- }
586
- }
587
- if (r.loopHit) {
588
- log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
589
- gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
489
+ // Adapter onto the shared gate-child runner (gate-child.ts). What survives
490
+ // here is WIRING which context, which log file, which config knobs, and
491
+ // where the live-widget state lives; the ritual and the per-kind policy are
492
+ // the table's. The enforce child below goes through the same call.
493
+ const gateChild = (gateCtx, cwd2, taskTitle, kind, logFile, opts = {}) => {
494
+ // An accessor box, not a copy: the runner writes these fields and the
495
+ // loader snapshot reads them on every tick, so both must see the same
496
+ // closure state the rest of buildGateDeps already shares.
497
+ const widget = {
498
+ get lastLine() {
499
+ return lastLine;
500
+ },
501
+ set lastLine(v) {
502
+ lastLine = v;
503
+ },
504
+ get contextUsage() {
505
+ return contextUsage;
506
+ },
507
+ set contextUsage(v) {
508
+ contextUsage = v;
590
509
  }
591
- const failure = classifyEnforceChildFailure(r);
592
- log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
593
- if (failure)
594
- throw new Error(failure);
595
- // CAPABILITY-LEVEL diff capture (mx5 run 11): any WRITE-capable
596
- // child — decided by its tools, not by which phase spawned it —
597
- // gets its tree changes logged, so a future write-capable kind
598
- // cannot run invisibly the way the final-fix child's `rm` did.
599
- if (/\b(?:edit|bash|write)\b/.test(tools)) {
600
- log(`=== ${kind} tree changes: ${formatTreeChanges(await collectTreeChanges(cwd2, sig))} ===`);
510
+ };
511
+ return makeGateChild({
512
+ ctx: gateCtx,
513
+ cwd: cwd2,
514
+ taskTitle,
515
+ kind,
516
+ logPath: path.join(tasksDir(cwd2), logFile),
517
+ ...(opts.loader === undefined ? {} : { loader: opts.loader }),
518
+ commandTimeoutMs: getConfig().requestTimeoutMs,
519
+ streamInactivityMs: getConfig().streamInactivityMs,
520
+ parentContextWindow,
521
+ runWorker,
522
+ makeDebugAppender,
523
+ startAutoLoader,
524
+ captureGitState,
525
+ reconcileGitState,
526
+ describeTreeChanges: async (c, sig) => formatTreeChanges(await collectTreeChanges(c, sig)),
527
+ resolveContextUsage,
528
+ truncateToolResult,
529
+ widget,
530
+ onReconcile: rec => {
531
+ lastGuardReconcile = rec;
601
532
  }
602
- return r.text;
603
- }
604
- finally {
605
- stopLoader();
606
- }
533
+ });
607
534
  };
608
535
  return {
609
536
  runTask,
@@ -710,77 +637,14 @@ export function buildGateDeps(params) {
710
637
  // research-worker guards mislabel that as a runaway and kill good work
711
638
  // (proven on mx5 TASK_0002). classifyEnforceChildFailure still blocks
712
639
  // on a real failure (non-zero exit, leaked tool call) or a user cancel.
713
- runChild: async (tools, prompt, sig) => {
714
- lastLine = undefined;
715
- contextUsage = undefined;
716
- const startedAt = Date.now();
717
- // Per-pass debug log; the enforce child is otherwise unobservable.
718
- const logEnforce = makeDebugAppender(path.join(tasksDir(cwd2), 'enforce-debug.log'));
719
- logEnforce(`=== enforce start: ${taskTitle} ===`);
720
- const stopLoader = startAutoLoader(enforceCtx, () => ({
721
- title: taskTitle,
722
- kind: 'enforce',
723
- step: 'guidelines',
724
- stepNum: 1,
725
- stepTotal: 1,
726
- startedAt,
727
- lastLine,
728
- contextUsage
729
- }));
730
- try {
731
- const r = await runWorker({
732
- prompt,
733
- cwd: cwd2,
734
- signal: sig,
735
- tools,
736
- timeoutMs: 0, // no wall-clock timeout — run to completion
737
- // …but still bound any SINGLE command (see makeGateChild).
738
- // enforce is read,edit today, so nothing here can hang on
739
- // bash — wired anyway so a future tool grant can't quietly
740
- // re-open the hole.
741
- commandTimeoutMs: getConfig().requestTimeoutMs,
742
- // Unbounded wall clock here too — the hung-stream
743
- // bound is the only thing that ends a dead stream.
744
- streamInactivityMs: getConfig().streamInactivityMs,
745
- // Exact-match loop guard only: pathThreshold Infinity
746
- // disables the path-revisit heuristic, so revisiting one
747
- // file (which IS this pass's job) never trips — only a
748
- // literally-identical call repeated past threshold does.
749
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
750
- // Same reasoning as the gate child: without this a
751
- // discarded attempt leaves no trace anywhere.
752
- onRestart: rs => logEnforce(`=== enforce RESTART (attempt ${rs.attempt} discarded)`
753
- + ` reason=${rs.reason} wall=${rs.wallMs}ms`
754
- + (rs.detail ? ` — ${rs.detail}` : '')
755
- + ' ==='),
756
- onLine: line => {
757
- // `lastLine` drives the live widget, not the trail.
758
- lastLine = line;
759
- logEnforce(line, 'stream');
760
- },
761
- onContextUsage: snapshot => {
762
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
763
- }
764
- });
765
- // A loop that survived the restart-with-hint nudges is a
766
- // warning, not a failure: log it and tell the user, but let the
767
- // verdict gate be the only thing that can block.
768
- if (r.loopHit) {
769
- logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
770
- enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
771
- }
772
- const failure = classifyEnforceChildFailure(r);
773
- logEnforce(failure ?
774
- `=== enforce end: FAIL — ${failure} ===`
775
- : '=== enforce end: verdict captured ===');
776
- if (failure)
777
- throw new Error(failure);
778
- return r.text;
779
- }
780
- finally {
781
- stopLoader();
782
- }
783
- }
640
+ //
641
+ // This used to be an inline ~85-line copy of the gate-child ritual,
642
+ // differing only in the four things GATE_CHILD_KINDS now carries as
643
+ // row data: no git-state guard (editing is this pass's job), no
644
+ // tool-result logging, no tree-change capture, and its own end
645
+ // marker. Its own debug log stays — the enforce child is otherwise
646
+ // unobservable.
647
+ runChild: gateChild(enforceCtx, cwd2, taskTitle, 'enforce', 'enforce-debug.log')
784
648
  });
785
649
  },
786
650
  verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
@@ -836,7 +700,7 @@ export function buildGateDeps(params) {
836
700
  // The child renders no loader of its own: the gate-wide one above is
837
701
  // already live and reads the same `lastLine`/`contextUsage` the child
838
702
  // feeds, so a second widget on the same key would only fight it.
839
- runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log', {
703
+ runChild: gateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log', {
840
704
  loader: deadAirBaseline
841
705
  }),
842
706
  // Names the deterministic step in the live status line.
@@ -956,7 +820,7 @@ export function buildGateDeps(params) {
956
820
  cwd: cwd2,
957
821
  signal,
958
822
  failReason,
959
- runChild: makeGateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
823
+ runChild: gateChild(fixCtx, cwd2, taskTitle, 'lint-fix', 'verify-debug.log'),
960
824
  repoHealth: () => runRepoHealthCheckAsync(cwd2, { signal }),
961
825
  git: async (args) => {
962
826
  const r = await git(cwd2, args, signal);
@@ -1005,7 +869,7 @@ export function buildGateDeps(params) {
1005
869
  cwd: cwd2,
1006
870
  signal,
1007
871
  failReason,
1008
- runChild: makeGateChild(fixCtx, cwd2, 'final integration gate', 'final-fix', 'final-gate-debug.log'),
872
+ runChild: gateChild(fixCtx, cwd2, 'final integration gate', 'final-fix', 'final-gate-debug.log'),
1009
873
  // The gate re-run is the only arbiter of convergence, and the
1010
874
  // shrink guard's discovery is the gate's own (see final-gate.ts).
1011
875
  gate: c => runFinalIntegrationGate(c),
@@ -1055,7 +919,7 @@ export function buildGateDeps(params) {
1055
919
  signal,
1056
920
  spec,
1057
921
  failReason,
1058
- runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
922
+ runChild: gateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
1059
923
  });
1060
924
  }
1061
925
  };
@@ -42,6 +42,7 @@ import { beginRun, endRun, takeHeldInput } from './mid-run-input.js';
42
42
  import { reportDroppedInput } from './dropped-input.js';
43
43
  import { formatTimings } from './timings.js';
44
44
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
45
+ import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
45
46
  // ─── Module-level state ──────────────────────────────────────────────────────
46
47
  let activeTask = null;
47
48
  /** Set the module-level active task (avoids `this` aliasing in TaskRunner.run). */
@@ -792,28 +793,18 @@ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
792
793
  // No sibling plan → no scope fence; no parent list → no check-off.
793
794
  });
794
795
  active = gate.ctx;
795
- switch (gate.kind) {
796
- case 'paused':
797
- await markResumable(cwd, res.taskId);
798
- announce(`${tag} paused verification failed and you dismissed the choice; resume with /task-resume.`, 'warning');
799
- return;
800
- case 'session-cancelled':
801
- announce(`${tag} paused — could not start a session for autofix. Run /task-resume to retry.`, 'warning');
802
- return;
803
- case 'interrupted':
804
- await markResumable(cwd, res.taskId);
805
- announce(`${tag} paused — resume with /task-resume.`, 'warning');
806
- return;
807
- case 'failed': {
808
- await markResumable(cwd, res.taskId);
809
- const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
810
- announce(`${tag} stopped${why} — fix and run /task-resume.`, 'error');
811
- return;
812
- }
813
- case 'done':
814
- announce(`${tag} complete — verified.`, 'info');
815
- return;
816
- }
796
+ // What each outcome means for persistence and for the user is stated once, in
797
+ // TERMINAL_OUTCOMES, and shared with /task-auto's loop. `failParent` is
798
+ // ignored here: /task runs one task and has no parent run file to fail.
799
+ const outcome = TERMINAL_OUTCOMES[gate.kind];
800
+ if (outcome.markResumable)
801
+ await markResumable(cwd, res.taskId);
802
+ announce(outcome.message({
803
+ tag,
804
+ at: formatAt(),
805
+ why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
806
+ resumeCmd: '/task-resume'
807
+ }), outcome.level);
817
808
  }
818
809
  // ─── Command handlers ────────────────────────────────────────────────────────
819
810
  async function handleTask(args, ctx) {