@mjasnikovs/pi-task 0.39.4 → 0.39.5

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.
package/README.md CHANGED
@@ -68,10 +68,10 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
68
68
  | `/task-plan <prompt>` | Plan one task with the model — it asks, you answer, ask it something back, or proceed — then run it through `/task`. |
69
69
  | `/task-list` | Open the task list in an editor dialog. |
70
70
  | `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
71
- | `/task-cancel` | Cancel the running task (soft-terminal still resumable). |
71
+ | `/task-cancel` | Stop the running task at the next safe checkpoint (still resumable). Mid-phase it kills the running child; during the implementation turn it lets the turn finish and stops before the gates. |
72
72
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
73
73
  | `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
74
- | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
74
+ | `/task-auto-cancel` | Stop the `/task-auto` loop at the next safe checkpoint — the end of the current phase, research worker, implementation turn or gate, not the end of the task (still resumable). During planning it abandons the plan, which is not yet written. |
75
75
  | `/task-config` | Toggle pi-task settings in an editor dialog: remote control, auto-commit, verify work, enforce guidelines, project tour, parallel research, research cache, search engine, command timeout, stuck reply retry, yolo mode, debug logs, one `watch:` toggle per live tool, and one `ext:` toggle per installed host extension. |
76
76
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
77
77
 
@@ -162,6 +162,42 @@ export interface CoveredPlan {
162
162
  export declare function coverPlan(ctx: ExtensionCommandContext, cwd: string, deps: AutoDeps, oriented: OrientedFeature, clarifications: string, decomposed: DecomposedPlan, specDangling: DanglingRef[]): Promise<CoveredPlan>;
163
163
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
164
164
  export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
165
+ /**
166
+ * One best-effort, HOST-GROUNDED extraction: ask a child to emit lines, drop
167
+ * every line the design does not literally contain, log the kept/emitted split,
168
+ * and append what survives to a run-level artifact.
169
+ *
170
+ * The grounding step is the reason this shape exists rather than a plain child
171
+ * call. A child asked for interface facts will paraphrase and occasionally invent
172
+ * them, and an invented fact in a run-level registry is read as
173
+ * authoritative by every downstream refine/compose/verify. So nothing the child
174
+ * says is trusted: `ground` re-checks each emitted line against the design text
175
+ * host-side, and only substrings survive.
176
+ *
177
+ * Best-effort by contract. These artifacts SHARPEN planning; none of them gates
178
+ * it, so a fault here is swallowed rather than failing a run that is otherwise
179
+ * fine — which is why the whole body sits in one `catch {}`.
180
+ *
181
+ * The two call sites (contracts, launch scripts) were byte-identical apart from
182
+ * the four values this row carries, and the contracts copy parsed its child's
183
+ * output twice — once for the artifact and once for the log count — because the
184
+ * duplication made the second parse easy to miss.
185
+ */
186
+ export declare function runGroundedExtraction<T>(row: {
187
+ cwd: string;
188
+ runChild: (name: string, tools: string, prompt: string) => Promise<string>;
189
+ /** Child name — also the key AUTO_PLAN_STEPS renders in the loader. */
190
+ child: string;
191
+ /** Singular noun for the log line ("contract", "script"). */
192
+ noun: string;
193
+ /** Log prefix naming the step. */
194
+ label: string;
195
+ prompt: string;
196
+ parse: (raw: string) => T[];
197
+ /** Keep only what the design itself backs. Runs host-side, never the child. */
198
+ ground: (emitted: T[]) => T[];
199
+ append: (cwd: string, kept: T[]) => Promise<void>;
200
+ }): Promise<void>;
165
201
  export declare function requestAutoCancel(): void;
166
202
  export declare function runAutoLoop(ctx: ExtensionCommandContext, cwd: string, id: string, deps: AutoDeps): Promise<void>;
167
203
  export declare function registerTaskAuto(pi: ExtensionAPI): void;
@@ -773,7 +773,8 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
773
773
  try {
774
774
  verdict = parseCoverageVerdict(await deps.runChild('decompose-coverage', '', DECOMPOSE_COVERAGE_PROMPT(featureForModel, clarifications, titles)));
775
775
  }
776
- catch {
776
+ catch (err) {
777
+ rethrowIfCancelled(err);
777
778
  verdict = null;
778
779
  }
779
780
  const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
@@ -796,7 +797,8 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
796
797
  + `${acc.crossCutting.length} cross-cutting, ${acc.unmapped.length} unmapped; `
797
798
  + `${covered.size} requirement(s) title-grounded`);
798
799
  }
799
- catch {
800
+ catch (err) {
801
+ rethrowIfCancelled(err);
800
802
  // mapping fault — Fix A accounting degrades; the grounded owned-set
801
803
  // above still guards against drops.
802
804
  }
@@ -1080,7 +1082,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
1080
1082
  * output twice — once for the artifact and once for the log count — because the
1081
1083
  * duplication made the second parse easy to miss.
1082
1084
  */
1083
- async function runGroundedExtraction(row) {
1085
+ export async function runGroundedExtraction(row) {
1084
1086
  try {
1085
1087
  const emitted = row.parse(await row.runChild(row.child, '', row.prompt));
1086
1088
  const grounded = row.ground(emitted);
@@ -1088,10 +1090,32 @@ async function runGroundedExtraction(row) {
1088
1090
  + ` from ${emitted.length} emitted`);
1089
1091
  await row.append(row.cwd, grounded);
1090
1092
  }
1091
- catch {
1093
+ catch (err) {
1094
+ rethrowIfCancelled(err);
1092
1095
  // best-effort artifact — never a planning blocker
1093
1096
  }
1094
1097
  }
1098
+ /**
1099
+ * Let a cancel through a best-effort catch.
1100
+ *
1101
+ * Planning's degrade-quietly catches exist so one weak child cannot sink a plan,
1102
+ * and every one of them predates the `plan:` checkpoint. That checkpoint throws
1103
+ * USER_CANCELLED from inside `runPlanningChild`, so without this the throw reads
1104
+ * as "that extraction failed": planning carries on, writes the AUTO file, and
1105
+ * leaves a resumable run whose contracts silently lost the entries the cancelled
1106
+ * child would have grounded. Cancel means abandon the plan, so it is not a
1107
+ * degradable fault.
1108
+ *
1109
+ * The requirement-extraction catch already gets this right through
1110
+ * `isFatalChildCause`, which covers USER_CANCELLED as well as the fatal kills.
1111
+ * This is the narrow half of the same rule for the catches that guard only an
1112
+ * optional artifact, where promoting every fatal kill would be a behaviour change
1113
+ * this fix has no evidence for.
1114
+ */
1115
+ function rethrowIfCancelled(err) {
1116
+ if (err instanceof Error && err.message === USER_CANCELLED)
1117
+ throw err;
1118
+ }
1095
1119
  /** The two feature-level planning children, shown as steps in the loader. */
1096
1120
  const AUTO_PLAN_STEPS = {
1097
1121
  'auto-clarify': { step: 'clarify', stepNum: 1 },
@@ -1470,7 +1494,12 @@ async function handleTaskAuto(args, ctx) {
1470
1494
  catch (err) {
1471
1495
  const msg = err instanceof Error ? err.message : String(err);
1472
1496
  if (msg === USER_CANCELLED) {
1473
- announceDone(ctx, '/task-auto cancelled.', 'warning');
1497
+ // Say what was thrown away. Planning writes nothing until it
1498
+ // finishes, so there is no half-plan to resume and no
1499
+ // /task-auto-resume to offer — the user has to start over,
1500
+ // and a bare "cancelled" would leave them looking for one.
1501
+ announceDone(ctx, '/task-auto cancelled — the plan was discarded. Nothing was written; '
1502
+ + 'run /task-auto again to re-plan.', 'warning');
1474
1503
  return;
1475
1504
  }
1476
1505
  announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
@@ -1559,8 +1588,16 @@ async function handleTaskAutoCancel(_args, ctx) {
1559
1588
  * promise "after the current task": the request is now honoured at the next safe
1560
1589
  * checkpoint (see cancel-points.ts), which mid-spec-pipeline is the end of the
1561
1590
  * current phase, not the end of the task.
1591
+ *
1592
+ * It names the seam set rather than the one seam the run happens to be between,
1593
+ * because that seam is not knowable from here — the checkpoint trail says where
1594
+ * the run has BEEN. Naming the set is what stops "next safe checkpoint" reading
1595
+ * as "some time before the run ends", which is how a wait that is really one
1596
+ * research worker gets mistaken for a wait of the whole task.
1562
1597
  */
1563
- const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint';
1598
+ const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint — the next planning child, or the '
1599
+ + 'end of the current phase, research worker, implementation turn or gate. The model '
1600
+ + 'call already in flight finishes first.';
1564
1601
  /**
1565
1602
  * Deliver a /task-auto-cancel typed in the terminal while a run owns the main
1566
1603
  * loop — the run bracket's `onCancel`. The armed listener watches raw stdin, so
@@ -27,18 +27,42 @@
27
27
  * pre-final-gate (run-final-gate) every task is checked off and committed; the
28
28
  * whole-repo gate has not started. A resume re-enters the same
29
29
  * branch.
30
+ * plan:<child> (child-status, runPlanningChild — the one funnel BOTH
31
+ * /task-auto's planning and /task-plan go through) DURABLE BY
32
+ * DISCARD, and the one seam here that is not durable by writing.
33
+ * Nothing planning produces reaches disk until planAuto's final
34
+ * writeTaskFile, so there is no partial plan to resume and no
35
+ * half-state to repair — stopping abandons the whole plan. That
36
+ * is the point: the alternative measured at 13+ minutes of
37
+ * running after the user asked to stop.
38
+ * research:<w> (research-worker) after persistSection. The four workers run
39
+ * serially by default and each one's section is read back by
40
+ * readCached, so a resume skips every worker already on disk.
41
+ * Costs nothing and repeats nothing — the only seam here that is
42
+ * free in both directions.
43
+ * impl:post-turn (orchestrator) the implementation turn has ENDED and the spec
44
+ * sections are all on disk. The turn's edits are uncommitted, so
45
+ * a resume re-delivers the spec onto the partly-edited tree —
46
+ * identical to the shipped ESC-then-decline-steer ending.
47
+ * gate:post-commit (task-gates) the task is checked off and its snapshot is in
48
+ * HEAD. Only the enforce pass is skipped, and enforce is
49
+ * re-runnable.
50
+ * gate:pre-resolution (task-gates) at the top of the verify-resolution loop, before
51
+ * a round spends a lint fix, a research child or a whole
52
+ * implementation re-run. The task file is demoted there: it
53
+ * still reads `completed` from spec handoff, and the work is
54
+ * neither verified nor committed.
30
55
  *
31
- * DELIBERATELY NOT checkpoints — stopping here is not safe:
32
- * - mid implementation turn: uncommitted, half-applied edits. The user's ESC
56
+ * DELIBERATELY NOT a checkpoint — stopping here is not safe:
57
+ * - mid implementation turn. The turn is a host-session turn, not a child, so
58
+ * stopping it means abandoning a half-applied edit set with no commit behind
59
+ * it. `impl:post-turn` waits for the turn to end instead. The user's ESC
33
60
  * (declined steer) path already covers "stop now, I accept a partial tree".
34
- * - between the implementation turn and the gates, or inside the gates: the
35
- * work is written but unverified and uncommitted; the gates are what make it
36
- * durable. Cancel is observed on the far side, at loop-top.
37
61
  */
38
62
  /** Every place the cancel flag is polled. A closed union so the tests enumerate
39
63
  * the same set the loop does — and they do: cancel-points.test.ts asserts on the
40
64
  * recorded trail rather than on the loop's own bookkeeping. */
41
- export type CancelCheckpoint = 'loop-top' | 'pre-task' | 'pre-final-gate' | `phase:${string}`;
65
+ export type CancelCheckpoint = 'loop-top' | 'pre-task' | 'pre-final-gate' | 'impl:post-turn' | 'gate:post-commit' | 'gate:pre-resolution' | `phase:${string}` | `plan:${string}` | `research:${string}`;
42
66
  export declare function requestCancel(): void;
43
67
  export declare function isCancelRequested(): boolean;
44
68
  /**
@@ -61,5 +85,9 @@ export declare function resetCheckpointTrail(): void;
61
85
  * @returns true when the caller must stop here.
62
86
  */
63
87
  export declare function cancelCheckpoint(where: CancelCheckpoint): boolean;
88
+ /** Let only the seams this answers true for fire. Tests only. */
89
+ export declare function onlyCheckpoints(predicate: (where: CancelCheckpoint) => boolean): void;
90
+ /** Back to production: every seam fires. */
91
+ export declare function clearCheckpointSuppression(): void;
64
92
  /** Checkpoints crossed since the last reset. */
65
93
  export declare function checkpointsCrossed(): readonly CancelCheckpoint[];
@@ -27,13 +27,37 @@
27
27
  * pre-final-gate (run-final-gate) every task is checked off and committed; the
28
28
  * whole-repo gate has not started. A resume re-enters the same
29
29
  * branch.
30
+ * plan:<child> (child-status, runPlanningChild — the one funnel BOTH
31
+ * /task-auto's planning and /task-plan go through) DURABLE BY
32
+ * DISCARD, and the one seam here that is not durable by writing.
33
+ * Nothing planning produces reaches disk until planAuto's final
34
+ * writeTaskFile, so there is no partial plan to resume and no
35
+ * half-state to repair — stopping abandons the whole plan. That
36
+ * is the point: the alternative measured at 13+ minutes of
37
+ * running after the user asked to stop.
38
+ * research:<w> (research-worker) after persistSection. The four workers run
39
+ * serially by default and each one's section is read back by
40
+ * readCached, so a resume skips every worker already on disk.
41
+ * Costs nothing and repeats nothing — the only seam here that is
42
+ * free in both directions.
43
+ * impl:post-turn (orchestrator) the implementation turn has ENDED and the spec
44
+ * sections are all on disk. The turn's edits are uncommitted, so
45
+ * a resume re-delivers the spec onto the partly-edited tree —
46
+ * identical to the shipped ESC-then-decline-steer ending.
47
+ * gate:post-commit (task-gates) the task is checked off and its snapshot is in
48
+ * HEAD. Only the enforce pass is skipped, and enforce is
49
+ * re-runnable.
50
+ * gate:pre-resolution (task-gates) at the top of the verify-resolution loop, before
51
+ * a round spends a lint fix, a research child or a whole
52
+ * implementation re-run. The task file is demoted there: it
53
+ * still reads `completed` from spec handoff, and the work is
54
+ * neither verified nor committed.
30
55
  *
31
- * DELIBERATELY NOT checkpoints — stopping here is not safe:
32
- * - mid implementation turn: uncommitted, half-applied edits. The user's ESC
56
+ * DELIBERATELY NOT a checkpoint — stopping here is not safe:
57
+ * - mid implementation turn. The turn is a host-session turn, not a child, so
58
+ * stopping it means abandoning a half-applied edit set with no commit behind
59
+ * it. `impl:post-turn` waits for the turn to end instead. The user's ESC
33
60
  * (declined steer) path already covers "stop now, I accept a partial tree".
34
- * - between the implementation turn and the gates, or inside the gates: the
35
- * work is written but unverified and uncommitted; the gates are what make it
36
- * durable. Cancel is observed on the far side, at loop-top.
37
61
  */
38
62
  let requested = false;
39
63
  /** Checkpoints actually reached since the last reset, in order. Instrumentation
@@ -71,14 +95,42 @@ export function resetCheckpointTrail() {
71
95
  */
72
96
  export function cancelCheckpoint(where) {
73
97
  crossed.push(where);
74
- // CANCEL_AB_ARM=baseline collapses the checkpoint set back to loop-top alone,
75
- // so the two arms differ in exactly one thing. Nothing in src/ ever sets it;
76
- // the only writer in the tree is cancel-points.test.ts, which uses it to pin
77
- // that the extra checkpoints — and only they — are what the flag gates.
78
- if (process.env.CANCEL_AB_ARM === 'baseline' && where !== 'loop-top')
98
+ if (isSuppressed(where))
79
99
  return false;
80
100
  return requested;
81
101
  }
102
+ // ─── Suppression (negative controls) ─────────────────────────────────────────
103
+ /**
104
+ * Which seams may fire. `null` is production: all of them.
105
+ *
106
+ * This is what makes the seam matrix falsifiable, and a predicate rather than a
107
+ * name list because three of the seams are open-ended (`phase:`, `plan:`,
108
+ * `research:`) and a list could not name them all.
109
+ *
110
+ * The matrix asks one seam at a time — `onlyCheckpoints(w => w === 'pre-task')`
111
+ * for the assertion, `onlyCheckpoints(() => false)` for its control — so the two
112
+ * runs differ by exactly that seam and nothing else. Without the control, "the
113
+ * run stopped here" also passes against a loop that stops everywhere, or one that
114
+ * stopped for an unrelated reason: several seams see the same raised flag, and
115
+ * whichever comes first is the one that stops the run.
116
+ */
117
+ let allowed = null;
118
+ function isSuppressed(where) {
119
+ // CANCEL_AB_ARM=baseline collapses the set back to loop-top alone — the
120
+ // original two-arm control, kept because it is the coarse "does the extra
121
+ // checkpoint set do anything at all" question, which no per-seam control asks.
122
+ if (process.env.CANCEL_AB_ARM === 'baseline' && where !== 'loop-top')
123
+ return true;
124
+ return allowed !== null && !allowed(where);
125
+ }
126
+ /** Let only the seams this answers true for fire. Tests only. */
127
+ export function onlyCheckpoints(predicate) {
128
+ allowed = predicate;
129
+ }
130
+ /** Back to production: every seam fires. */
131
+ export function clearCheckpointSuppression() {
132
+ allowed = null;
133
+ }
82
134
  /** Checkpoints crossed since the last reset. */
83
135
  export function checkpointsCrossed() {
84
136
  return crossed;
@@ -19,9 +19,10 @@
19
19
  * gate-wide loader over a child that renders none (`frame: null`, reached when
20
20
  * `deps.loader === false` in gate-child), so both must see the same object.
21
21
  */
22
- import { runPhaseChild } from './child-runner.js';
22
+ import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
23
23
  import { resolveContextUsage } from './context-usage.js';
24
24
  import { startAutoLoader } from './widget.js';
25
+ import { cancelCheckpoint } from './cancel-points.js';
25
26
  export class ChildStatus {
26
27
  _lastLine;
27
28
  _contextUsage;
@@ -95,6 +96,17 @@ export class ChildStatus {
95
96
  */
96
97
  export async function runPlanningChild(opts) {
97
98
  const { ctx, status, phaseDeps, name, tools, prompt, loader } = opts;
99
+ // SAFE CHECKPOINT (planning): the one funnel every planning child goes through
100
+ // — orient, clarify, decompose, each coverage round, each grounded extraction,
101
+ // and /task-plan's children too. Safe by DISCARD rather than by writing:
102
+ // planning puts nothing on disk before planAuto's closing writeTaskFile, so
103
+ // there is no partial plan to resume and none to repair. Without it the flag
104
+ // was read only after planning RETURNED — measured at 13+ minutes of planning
105
+ // still running after the user asked to stop.
106
+ if (cancelCheckpoint(`plan:${name}`)) {
107
+ phaseDeps.logDebug?.(`cancel: abandoning the plan before ${name}`);
108
+ throw new Error(USER_CANCELLED);
109
+ }
98
110
  const startedAt = Date.now();
99
111
  return status.track(ctx, () => ({
100
112
  ...(loader.command === undefined ? {} : { command: loader.command }),
@@ -37,10 +37,10 @@ import { parseVerifyBlock } from './spec-validation.js';
37
37
  import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phantom-imports.js';
38
38
  import { titleForDisplay } from './parsers.js';
39
39
  import { USER_CANCELLED } from './child-runner.js';
40
- import { cancelCheckpoint } from './cancel-points.js';
40
+ import { cancelCheckpoint, requestCancel } from './cancel-points.js';
41
41
  import { holdImplementation, liveModelControl } from './implementation-hold.js';
42
42
  import { rearmCancelListener } from './cancel-input.js';
43
- import { takeHeldInput } from './mid-run-input.js';
43
+ import { takeHeldInput, isRunActive } from './mid-run-input.js';
44
44
  import { withRun, announceTerminal } from './run-bracket.js';
45
45
  import { RUN_END_POLICY, runSucceeded } from './run-end.js';
46
46
  import { formatTimings } from './timings.js';
@@ -334,6 +334,31 @@ export class TaskRunner {
334
334
  await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
335
335
  await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
336
336
  await this._deliverSpec(ctx);
337
+ // SAFE CHECKPOINT (post implementation turn): every phase section is
338
+ // on disk and the turn has ENDED. Its edits are uncommitted, so a
339
+ // resume re-delivers the spec onto the partly-edited tree — the same
340
+ // ending the ESC-then-decline-steer path already produces. Front
341
+ // matter reads `phase: done`, and PHASE_INDEX.done is past every row,
342
+ // so the resumed run restores all five sections and falls straight
343
+ // through to _deliverSpec.
344
+ //
345
+ // This is also what makes /task-cancel work here at all: the turn
346
+ // runs in the host session, not as a child, so aborting this runner's
347
+ // signal never reached it. The command raises the cooperative flag
348
+ // and this is where the flag is read.
349
+ //
350
+ // AWAITED ONLY. On the fire-and-forget /task path _deliverSpec returns
351
+ // as soon as the spec is sent, so the turn is STARTING, not finished,
352
+ // and firing here would write `cancelled` over a task the agent then
353
+ // goes on to implement in full — reported and recorded as stopped
354
+ // while it runs, and left in a resumable state that re-delivers the
355
+ // same spec on top of the finished work. Nothing is lost by staying
356
+ // out: that path has no gates and no loop to stop, the run just ends,
357
+ // and ESC is what interrupts the turn itself.
358
+ if (this._implAwaited && cancelCheckpoint('impl:post-turn')) {
359
+ this._deps.logDebug?.('cancel: stopping after the implementation turn');
360
+ throw new Error(USER_CANCELLED);
361
+ }
337
362
  return { kind: 'completed' };
338
363
  }
339
364
  catch (err) {
@@ -795,12 +820,29 @@ async function handleTaskResume(args, ctx) {
795
820
  }
796
821
  // eslint-disable-next-line @typescript-eslint/require-await
797
822
  async function handleTaskCancel(_args, ctx) {
798
- if (!activeTask) {
823
+ // `activeTask` covers the spec phases and the implementation turn — it is set
824
+ // in TaskRunner._run and cleared in that run's `finally`. The GATES run after
825
+ // that, so for the whole verify/autofix/enforce stretch there is no runner to
826
+ // abort and the honest answer is not "No task is running."
827
+ const runner = activeTask;
828
+ if (!runner && !isRunActive()) {
799
829
  notifyBoth(ctx, 'No task is running.', 'info');
800
830
  return;
801
831
  }
802
- activeTask.cancel();
803
- notifyBoth(ctx, `Cancelling ${activeTask.taskId}…`, 'warning');
832
+ // The cooperative flag is what gives the command one meaning wherever it is
833
+ // typed: stop at the next point the run can be resumed from. Without it a
834
+ // cancel during the gates, or during the implementation turn, had nothing to
835
+ // observe it. The run bracket scopes the flag to the run that raised it.
836
+ requestCancel();
837
+ if (!runner) {
838
+ notifyBoth(ctx, 'Stopping at the next safe checkpoint…', 'warning');
839
+ return;
840
+ }
841
+ // Abort as well: during the spec phases this kills the running child outright
842
+ // instead of waiting out its remaining minutes, and the phase already on disk
843
+ // is what a resume starts from.
844
+ runner.cancel();
845
+ notifyBoth(ctx, `Cancelling ${runner.taskId}…`, 'warning');
804
846
  }
805
847
  // ─── Entry point ─────────────────────────────────────────────────────────────
806
848
  export function registerTask(pi) {
@@ -277,8 +277,17 @@ async function runPlanCommand(ctx, cwd, planId, task, commandDeps) {
277
277
  }
278
278
  catch (err) {
279
279
  const msg = err instanceof Error ? err.message : String(err);
280
+ // A user stop is not a fault, and the sentinel is not a sentence. The
281
+ // cancel checkpoint in runPlanningChild throws USER_CANCELLED here, so
282
+ // without this branch a /task-cancel during planning reads as a red
283
+ // "PLAN_0001 stopped — __user_cancelled__".
284
+ if (msg === USER_CANCELLED) {
285
+ await updateTaskFrontMatter(cwd, planId, { state: 'cancelled' }).catch(() => { });
286
+ announceTerminal(ctx, `${planId} cancelled.`, 'warning', { push: false });
287
+ return;
288
+ }
280
289
  await updateTaskFrontMatter(cwd, planId, {
281
- state: msg === USER_CANCELLED ? 'cancelled' : 'failed',
290
+ state: 'failed',
282
291
  reason: msg.slice(0, 200)
283
292
  }).catch(() => { });
284
293
  // No push: a plan is a conversation, not a task; the run it hands off
@@ -20,6 +20,8 @@
20
20
  */
21
21
  import { classifyWorkerFailure } from '../workers/worker-failure.js';
22
22
  import { classifyContextSilence, countBullets } from './context-silence.js';
23
+ import { cancelCheckpoint } from './cancel-points.js';
24
+ import { USER_CANCELLED } from './child-runner.js';
23
25
  /**
24
26
  * Task-file heading under which a research worker's validated output is cached.
25
27
  * A resumed research phase reads these to skip workers that already succeeded,
@@ -403,5 +405,14 @@ export async function runResearchWorker(spec, run, prior = []) {
403
405
  // a truncated section can still carry a laundered claim.
404
406
  const sectionText = spec.postProcess ? spec.postProcess(rawText) : rawText;
405
407
  await run.persistSection(cacheHeading, sectionText);
408
+ // SAFE CHECKPOINT: this worker's section is on disk and the cache read at the
409
+ // top of this function is what a resume uses to skip it, so stopping between
410
+ // workers repeats nothing. Research is the phase's long pole and the workers
411
+ // run serially, which is what makes this the one seam that costs nothing in
412
+ // either direction. USER_CANCELLED reuses the phase's existing cancel path.
413
+ if (cancelCheckpoint(`research:${spec.section}`)) {
414
+ run.logDebug?.(`cancel: stopping after research worker ${spec.section}`);
415
+ throw new Error(USER_CANCELLED);
416
+ }
406
417
  return { name: spec.section, text: sectionText };
407
418
  }
@@ -1,8 +1,9 @@
1
1
  import { armCancelListener, disarmCancelListener } from './cancel-input.js';
2
- import { beginRun, endRun } from './mid-run-input.js';
2
+ import { beginRun, endRun, isRunActive } from './mid-run-input.js';
3
3
  import { reportDroppedInput } from './dropped-input.js';
4
4
  import { publishLifecycleNotice } from '../remote/bridge.js';
5
5
  import { pushNotify } from '../remote/push.js';
6
+ import { resetCancel } from './cancel-points.js';
6
7
  /**
7
8
  * Run `fn` as the owner of the session: hold mid-run input and arm the terminal
8
9
  * interception for exactly its duration, then release both — on return AND on
@@ -10,6 +11,15 @@ import { pushNotify } from '../remote/push.js';
10
11
  * Nests: an inner bracket neither re-renders the surfaces nor un-arms the outer.
11
12
  */
12
13
  export async function withRun(ctx, opts, fn) {
14
+ // A cancel is scoped to the run that was asked to stop. runAutoLoop already
15
+ // cleared the flag at both ends of its own loop; the bracket is what gives
16
+ // every OTHER entry point the same guarantee, so a /task-cancel raised on a
17
+ // bare /task cannot survive to stop the next run at its first phase.
18
+ // Outermost only: an inner bracket clearing it would drop the request the
19
+ // outer loop has not observed yet.
20
+ const outermost = !isRunActive();
21
+ if (outermost)
22
+ resetCancel();
13
23
  beginRun();
14
24
  armCancelListener(ctx, opts.onCancel);
15
25
  try {
@@ -17,6 +27,8 @@ export async function withRun(ctx, opts, fn) {
17
27
  }
18
28
  finally {
19
29
  disarmCancelListener();
30
+ if (outermost)
31
+ resetCancel();
20
32
  reportDroppedInput(endRun(), ctx);
21
33
  }
22
34
  }
@@ -10,6 +10,8 @@ import { attributeEnforceFailure } from './enforce-attribution.js';
10
10
  // re-check-side parser (extractDeletedDebtPath) have to move together.
11
11
  import { crossTaskDeletionReason } from './accept-debt.js';
12
12
  import { clampOutput } from './clamp-output.js';
13
+ import { cancelCheckpoint } from './cancel-points.js';
14
+ import { updateTaskFrontMatter } from './task-io.js';
13
15
  /**
14
16
  * How many times a verify FAIL may be auto-fixed UNATTENDED (the research
15
17
  * recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
@@ -114,6 +116,24 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
114
116
  // YOLO only: has the one-attempt rescue below already been spent on this task?
115
117
  let yoloRescueUsed = false;
116
118
  while (!verified.ok) {
119
+ // SAFE CHECKPOINT (before a resolution round): a round is a bounded
120
+ // lint fix, a research child and possibly a whole implementation
121
+ // re-run, so a cancel observed only INSIDE one buys all of that
122
+ // first. At the TOP of the loop because every later position is past
123
+ // something that already recorded itself — the unattended branch
124
+ // increments the counter, writes its `## gates` line and toasts
125
+ // "auto-fixing…" before it reaches any code below.
126
+ //
127
+ // The task file still reads `completed` here, written at spec handoff
128
+ // before any of this ran, and `completed` is not in RESUMABLE_STATES.
129
+ // Returning `cancelled` without this write would leave the announced
130
+ // "resume with /task-resume" pointing at a file /task-resume skips —
131
+ // the work is unverified AND uncommitted at this point, so that is the
132
+ // one place in the gate where a missed demotion loses it.
133
+ if (cancelCheckpoint('gate:pre-resolution')) {
134
+ await updateTaskFrontMatter(p.cwd, p.taskId, { state: 'cancelled' }).catch(() => { });
135
+ return { stop: { kind: 'cancelled', ctx: active } };
136
+ }
117
137
  const failReason = verified.reason ?? 'did not verify';
118
138
  // GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
119
139
  // bounded fix attempt before the picker — smallest tool first. Applied →
@@ -671,6 +691,20 @@ export async function runGatesForTask(ctxIn, deps, p) {
671
691
  else
672
692
  notifyBoth(active, line, 'warning');
673
693
  }
694
+ // SAFE CHECKPOINT (post task commit): the work verified, the parent entry is
695
+ // checked off and the snapshot is in HEAD. Only the enforce pass is skipped,
696
+ // and enforce is re-runnable.
697
+ //
698
+ // Returns `done`, not `cancelled`: this task IS done, and `cancelled` would
699
+ // announce "resume with /task-resume" over a task whose work is already
700
+ // committed — a resume there re-runs the whole spec pipeline to redo it. The
701
+ // flag stays raised, so /task-auto stops one step later at loop-top with the
702
+ // right wording and a ticked checkbox, and a bare /task simply ends. The trail
703
+ // line is what keeps the skip from being silent.
704
+ if (cancelCheckpoint('gate:post-commit')) {
705
+ await rec('enforce: skipped — cancel requested after the task snapshot committed');
706
+ return { kind: 'done', ctx: active };
707
+ }
674
708
  await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
675
709
  return { kind: 'done', ctx: active };
676
710
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.39.4",
3
+ "version": "0.39.5",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",