@mjasnikovs/pi-task 0.39.4 → 0.40.0

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 (45) hide show
  1. package/README.md +20 -5
  2. package/dist/task/auto-orchestrator.d.ts +36 -0
  3. package/dist/task/auto-orchestrator.js +43 -6
  4. package/dist/task/cancel-points.d.ts +34 -6
  5. package/dist/task/cancel-points.js +62 -10
  6. package/dist/task/child-status.js +13 -1
  7. package/dist/task/context-attribution.js +18 -6
  8. package/dist/task/external-context.d.ts +8 -1
  9. package/dist/task/external-context.js +52 -4
  10. package/dist/task/orchestrator.js +47 -5
  11. package/dist/task/phases.js +2 -1
  12. package/dist/task/plan-orchestrator.js +10 -1
  13. package/dist/task/prompts.d.ts +7 -1
  14. package/dist/task/prompts.js +14 -4
  15. package/dist/task/research-worker.js +11 -0
  16. package/dist/task/run-bracket.js +13 -1
  17. package/dist/task/task-gates.js +34 -0
  18. package/dist/workers/docs-cache.js +50 -3
  19. package/dist/workers/docs-chunk.d.ts +6 -3
  20. package/dist/workers/docs-chunk.js +8 -5
  21. package/dist/workers/docs-core.d.ts +27 -3
  22. package/dist/workers/docs-core.js +104 -41
  23. package/dist/workers/docs-ecosystems.d.ts +173 -0
  24. package/dist/workers/docs-ecosystems.js +449 -0
  25. package/dist/workers/docs-index.d.ts +2 -1
  26. package/dist/workers/docs-index.js +55 -27
  27. package/dist/workers/docs-project.d.ts +10 -0
  28. package/dist/workers/docs-project.js +86 -24
  29. package/dist/workers/docs-resolve.d.ts +6 -1
  30. package/dist/workers/docs-resolve.js +4 -3
  31. package/dist/workers/docs-retrieve.d.ts +2 -0
  32. package/dist/workers/docs-retrieve.js +11 -11
  33. package/dist/workers/eco-cargo.d.ts +115 -0
  34. package/dist/workers/eco-cargo.js +793 -0
  35. package/dist/workers/eco-hackage.d.ts +93 -0
  36. package/dist/workers/eco-hackage.js +508 -0
  37. package/dist/workers/npm-version.d.ts +5 -3
  38. package/dist/workers/npm-version.js +6 -4
  39. package/dist/workers/pi-worker-docs.d.ts +18 -4
  40. package/dist/workers/pi-worker-docs.js +57 -19
  41. package/dist/workers/research-cache.d.ts +2 -13
  42. package/dist/workers/research-cache.js +22 -46
  43. package/dist/workers/shared.d.ts +16 -5
  44. package/dist/workers/shared.js +0 -0
  45. package/package.json +1 -1
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
 
@@ -191,12 +191,25 @@ Fetches a URL, cleans HTML to markdown ([Readability](https://github.com/mozilla
191
191
  - The extraction child runs with `--no-tools` to mitigate visible-text prompt injection.
192
192
 
193
193
  ### `pi-worker-docs`
194
- Resolves an installed npm package, indexes its `.d.ts` files and README into a local SQLite cache, retrieves the most relevant chunks for your `query`, and passes them to an isolated child that extracts the focused answer. Version-pinned to whatever is in your `node_modules`.
194
+ Resolves an installed package, indexes its API surface and README into a local SQLite cache, retrieves the most relevant chunks for your `query`, and passes them to an isolated child that extracts the focused answer. Version-pinned to whatever the project actually resolved.
195
195
 
196
- - The package must be installed in the project's `node_modules`; otherwise a one-time auto-install into a dedicated cache dir is attempted.
197
- - The first call for a `(package, version)` pair pays a one-time ingestion cost; later calls are FTS-only.
196
+ **The manifest decides which registry, not the model.** `text`, `base`, `aeson`, `tokio` and `clap` are all real npm packages *and* real Rust/Haskell ones, so a name alone cannot say which was meant — and guessing npm returns a confident answer about an unrelated package. That is a wrong answer, not a miss.
197
+
198
+ | Ecosystem | Detected by | Surface read | Version comes from |
199
+ | --- | --- | --- | --- |
200
+ | `npm` | `package.json`, or a `node_modules/` directory | the `.d.ts` files the package ships, plus README | the installed `package.json` |
201
+ | `cargo` | `Cargo.toml`, at the directory or one level below it | `.rs` source reduced to public item heads, doc comments and attributes | `Cargo.lock` |
202
+ | `hackage` | `*.cabal`, `cabal.project`, `stack.yaml` or `package.yaml` | `.hs` source reduced to the export list, signatures and type declarations | `dist-newstyle/cache/plan.json`, then `cabal.project.freeze`, then `stack.yaml.lock` |
203
+
204
+ - **No manifest, no lookup.** In a directory with none of the above the tool refuses, spawns nothing and installs nothing, and points you at `pi-worker-search` / `pi-worker-fetch` instead.
205
+ - **Two manifests** (a Tauri app, say) are resolved by whichever registry already has the package on disk. If neither does, the call is refused as ambiguous and you pass `ecosystem: "cargo"` to say which.
206
+ - A package the project does not have is fetched once into a dedicated cache dir: `npm install --ignore-scripts` for npm, the `.crate` tarball for cargo, the Hackage tarball (or cabal's own cached copy) for hackage.
207
+ - A Haskell **module** name is refused by name — `Data.Aeson` is not a package, `aeson` is.
208
+ - The first call for a `(ecosystem, package, version)` triple pays a one-time ingestion cost; later calls are FTS-only.
198
209
  - Cache lives at `${XDG_CACHE_HOME:-~/.cache}/pi-worker/docs.sqlite` — delete it to reset.
199
210
 
211
+ **Known gaps.** The rest of pi-task is still npm-shaped: the final gate, repo-health and orientation read the working directory first-wins, so a Tauri repo gets cargo docs answers while the gate runs `bun run test`, not `cargo test`. Phantom-import checking, dependency-name extraction for research enrichment, and the `@types/…` redirect chain are npm concepts and are no-ops elsewhere. Adding an ecosystem is one row in `src/workers/docs-ecosystems.ts` plus its parsers — profiles live in code and arrive as pull requests with tests, never as user configuration.
212
+
200
213
  ## Settings — `/task-config`
201
214
 
202
215
  Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings persist to `~/.config/pi-task/config.json`.
@@ -226,6 +239,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
226
239
  | --- | --- | --- |
227
240
  | `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required only when the **Brave** search engine is selected in `/task-config`. |
228
241
  | `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
242
+ | `CARGO_HOME` | `pi-worker-docs` | Where crate source checkouts are read from (defaults to `~/.cargo`). |
243
+ | `CABAL_DIR` | `pi-worker-docs` | Where cabal's downloaded package tarballs are read from (also checks `~/.cabal/packages` and `${XDG_CACHE_HOME:-~/.cache}/cabal/packages`). |
229
244
  | `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
230
245
  | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
231
246
  | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
@@ -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 }),
@@ -154,17 +154,29 @@ export function splitBulletSpans(context) {
154
154
  export function splitBullets(context) {
155
155
  return splitBulletSpans(context).map(s => s.text);
156
156
  }
157
+ /**
158
+ * A version block is headed by its REGISTRY, so the heading is open-ended
159
+ * (`crates.io`, `hackage`). Anything else is one: the widened pattern also reads
160
+ * headings out of a retrieved README or page BODY, and calling those `docs` would
161
+ * let a package's own text forge the block that sources a semantics claim.
162
+ */
163
+ function blockKind(heading) {
164
+ if (heading === 'freshness-check')
165
+ return 'freshness-skipped';
166
+ if (heading === 'docs' || heading === 'url' || heading === 'service')
167
+ return heading;
168
+ return 'npm';
169
+ }
157
170
  /** Parse the `### npm:` / `### docs:` / `### url:` / `### service:` blocks out of an EXTERNAL CONTEXT header. */
158
171
  export function parseContextBlocks(externalContext) {
159
172
  const out = [];
160
- const re = /^###\s+(npm|docs|url|service|freshness-check)\s*:?\s*(.*)$/gim;
173
+ // A version block is headed by its REGISTRY, so `npm` is one of several
174
+ // (`crates.io`, `hackage`). Matching only the fixed words made every non-npm
175
+ // block invisible to the parser.
176
+ const re = /^###\s+([A-Za-z][\w.-]*)\s*:?\s*(.*)$/gim;
161
177
  let m;
162
178
  while ((m = re.exec(externalContext)) !== null) {
163
- const kind = m[1].toLowerCase();
164
- out.push({
165
- kind: kind === 'freshness-check' ? 'freshness-skipped' : kind,
166
- subject: m[2].trim()
167
- });
179
+ out.push({ kind: blockKind(m[1].toLowerCase()), subject: m[2].trim() });
168
180
  }
169
181
  return out;
170
182
  }
@@ -36,6 +36,8 @@ type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep' | 'docsRaw'
36
36
  export interface ExternalTargetResult {
37
37
  /** Emitted as an `### npm:` block ahead of every body. Absent for url targets. */
38
38
  npmVersion?: NpmVersionInfo | null;
39
+ /** Which registry the version block came from; npm when absent. */
40
+ registryLabel?: string;
39
41
  /**
40
42
  * The `### docs:`/`### url:` body. `undefined` means "this target contributes no
41
43
  * body block" — and the two call paths draw that line differently ON PURPOSE:
@@ -68,6 +70,11 @@ export interface ExternalContextLookups {
68
70
  * question, in front of a waiting user, while research is uncapped and trails its
69
71
  * sub-step.
70
72
  */
73
+ /** A live version answer and the registry it came from, for the block heading. */
74
+ export interface VersionBlock {
75
+ info: NpmVersionInfo;
76
+ label: string;
77
+ }
71
78
  export interface ExternalContextPolicy {
72
79
  /**
73
80
  * Max combined docs+url targets fanned out, packages first. Omit for uncapped
@@ -83,7 +90,7 @@ export interface ExternalContextPolicy {
83
90
  * version at all, and a "which version?" question falls back to whatever the
84
91
  * model remembers — which is how a dependency gets pinned to a stale major.
85
92
  */
86
- versionLookup?: (pkg: string) => Promise<NpmVersionInfo | null>;
93
+ versionLookup?: (pkg: string) => Promise<VersionBlock | null>;
87
94
  /** Sub-step label recorded via `deps.recordSubStep`. Omit to record nothing. */
88
95
  subStepLabel?: string;
89
96
  /**
@@ -22,6 +22,7 @@
22
22
  * Run against a mixed source, the emitted headings come out in exactly that order:
23
23
  * `### npm:` then `### docs:` then `### url:` then `### service:`.
24
24
  */
25
+ import { chooseEcosystem, defaultEcosystemIo } from '../workers/docs-ecosystems.js';
25
26
  import { docsRaw } from '../workers/docs-core.js';
26
27
  import { fetchRaw } from '../workers/fetch-core.js';
27
28
  import { formatNpmVersionSection, npmVersionLookup } from '../workers/npm-version.js';
@@ -61,6 +62,14 @@ export async function buildExternalContext(source, deps, lookups, policy = {}) {
61
62
  }).catch(() => null))),
62
63
  Promise.all(versionLookup ? extraVersionPkgs.map(pkg => versionLookup(pkg).catch(() => null)) : [])
63
64
  ]);
65
+ // A docs target that came back WITHOUT a version — a refused ecosystem, a dead
66
+ // registry — was dropped from `extraVersionPkgs` on the assumption docs would
67
+ // supply it. Ask for it now, or its block is silently lost.
68
+ const missedVersionPkgs = versionLookup ?
69
+ enrichTargets.versionPackages.filter(p => docsTargets.has(p)
70
+ && !targets.some((t, i) => t.name === p && targetResults[i]?.npmVersion))
71
+ : [];
72
+ const missedVersionResults = await Promise.all(missedVersionPkgs.map(pkg => versionLookup(pkg).catch(() => null)));
64
73
  const sections = [];
65
74
  // npm version blocks lead the section so the model anchors on live version
66
75
  // data before reading any docs body. The docs-fetched packages carry their
@@ -69,11 +78,11 @@ export async function buildExternalContext(source, deps, lookups, policy = {}) {
69
78
  // named dep.
70
79
  for (const r of targetResults) {
71
80
  if (r?.npmVersion)
72
- sections.push(formatNpmVersionSection(r.npmVersion));
81
+ sections.push(formatNpmVersionSection(r.npmVersion, r.registryLabel));
73
82
  }
74
- for (const v of extraVersionResults) {
83
+ for (const v of [...extraVersionResults, ...missedVersionResults]) {
75
84
  if (v)
76
- sections.push(formatNpmVersionSection(v));
85
+ sections.push(formatNpmVersionSection(v.info, v.label));
77
86
  }
78
87
  for (let i = 0; i < targets.length; i++) {
79
88
  const body = targetResults[i]?.body;
@@ -118,6 +127,44 @@ export async function gatherExternalContext(refined, deps) {
118
127
  const fetchRawFn = deps.fetchRaw ?? fetchRaw;
119
128
  const npmVersionFn = deps.npmVersionLookup ?? npmVersionLookup;
120
129
  const docsQuery = refined.split('\n').find(l => l.trim()) ?? refined;
130
+ // Which registry to ask, decided PER PACKAGE.
131
+ //
132
+ // A cargo-only project's dependency names are crate names, and asking npm
133
+ // about them returns a real but unrelated package's versions — the exact
134
+ // confusion the docs tool refuses. But deciding once for the whole repo makes
135
+ // every polyglot project ambiguous, and then NO version block is emitted at
136
+ // all: worse than the old always-npm, because the prompts tell the model to
137
+ // quote a block that will never be there. Each name carries its own evidence,
138
+ // so each name gets its own answer.
139
+ const io = defaultEcosystemIo(deps.signal ? { signal: deps.signal } : {});
140
+ const askNpm = async (pkg) => {
141
+ const info = await npmVersionFn(pkg, { signal: deps.signal });
142
+ return info ? { info, label: 'npm' } : null;
143
+ };
144
+ const versionLookup = async (pkg) => {
145
+ const choice = chooseEcosystem({
146
+ cwd: deps.cwd,
147
+ declaresPackage: p => p.declaredRange(p.parentPackage(pkg), deps.cwd) !== null,
148
+ resolvesLocally: p => {
149
+ try {
150
+ p.resolve(pkg, deps.cwd, io);
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ });
158
+ // No manifest at all keeps npm, which is what a bare directory has always
159
+ // done. A name genuinely ambiguous between two registries gets no block —
160
+ // a version from the wrong registry is worse than none.
161
+ if (!choice.ok)
162
+ return choice.reason === 'none' ? askNpm(pkg) : null;
163
+ if (choice.profile.id === 'npm')
164
+ return askNpm(pkg);
165
+ const info = await choice.profile.latest(pkg, io);
166
+ return info ? { info, label: choice.profile.registryLabel } : null;
167
+ };
121
168
  return buildExternalContext(refined, deps, {
122
169
  docs: async (pkg) => {
123
170
  const r = await docsRawFn({
@@ -128,6 +175,7 @@ export async function gatherExternalContext(refined, deps) {
128
175
  });
129
176
  return {
130
177
  npmVersion: r.npmVersion,
178
+ ...(r.registryLabel ? { registryLabel: r.registryLabel } : {}),
131
179
  body: r.kind === 'ok' && r.chunks.length > 0 ?
132
180
  r.chunks
133
181
  .map(c => c.content)
@@ -142,7 +190,7 @@ export async function gatherExternalContext(refined, deps) {
142
190
  },
143
191
  search: deps.searchFn
144
192
  }, {
145
- versionLookup: pkg => npmVersionFn(pkg, { signal: deps.signal }),
193
+ versionLookup,
146
194
  subStepLabel: 'enrichment',
147
195
  earlyReturnOnNoTargets: true
148
196
  });
@@ -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) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
+ import { detectEcosystems } from '../workers/docs-ecosystems.js';
7
8
  import { fetchFocused } from '../workers/fetch-core.js';
8
9
  import { runWorker } from '../workers/pi-worker-core.js';
9
10
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
@@ -550,7 +551,7 @@ export async function phaseResearch(deps, refined) {
550
551
  // queries the FILES worker just answered.
551
552
  prompt: prior => orientation.block
552
553
  + promptHeader
553
- + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
554
+ + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined, detectEcosystems(deps.cwd))
554
555
  + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
555
556
  // Empty unless PI_TASK_PROJECT_DOCS_BUDGET is set. The tool-side
556
557
  // half lives in pi-worker-docs.ts; a budget enforced without being