@mjasnikovs/pi-task 0.38.11 → 0.38.12

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 (55) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +91 -114
  8. package/dist/task/child-runner.d.ts +39 -25
  9. package/dist/task/child-runner.js +59 -31
  10. package/dist/task/child-status.d.ts +95 -0
  11. package/dist/task/child-status.js +99 -0
  12. package/dist/task/command-run.d.ts +36 -0
  13. package/dist/task/command-run.js +48 -1
  14. package/dist/task/command-watchdog.js +1 -1
  15. package/dist/task/context-usage.d.ts +4 -3
  16. package/dist/task/context-usage.js +4 -3
  17. package/dist/task/contracts.js +18 -35
  18. package/dist/task/deep-render-check.d.ts +47 -0
  19. package/dist/task/deep-render-check.js +110 -65
  20. package/dist/task/env-notes.d.ts +3 -3
  21. package/dist/task/env-notes.js +24 -35
  22. package/dist/task/final-gate-fix.d.ts +1 -1
  23. package/dist/task/final-gate-fix.js +1 -1
  24. package/dist/task/final-gate.d.ts +5 -151
  25. package/dist/task/final-gate.js +81 -379
  26. package/dist/task/gate-child.d.ts +8 -10
  27. package/dist/task/gate-child.js +15 -19
  28. package/dist/task/gate-deps.d.ts +29 -0
  29. package/dist/task/gate-deps.js +192 -206
  30. package/dist/task/gate-tally.d.ts +189 -0
  31. package/dist/task/gate-tally.js +249 -0
  32. package/dist/task/implementation-turn.d.ts +201 -0
  33. package/dist/task/implementation-turn.js +263 -0
  34. package/dist/task/launch-contract.js +27 -43
  35. package/dist/task/ledger.d.ts +38 -0
  36. package/dist/task/ledger.js +83 -0
  37. package/dist/task/loop-detector.d.ts +14 -8
  38. package/dist/task/loop-detector.js +36 -12
  39. package/dist/task/orchestrator.d.ts +61 -126
  40. package/dist/task/orchestrator.js +67 -294
  41. package/dist/task/plan-orchestrator.js +34 -33
  42. package/dist/task/requirements.d.ts +1 -1
  43. package/dist/task/requirements.js +50 -66
  44. package/dist/task/root-cause-repair.js +20 -32
  45. package/dist/task/run-bracket.d.ts +75 -0
  46. package/dist/task/run-bracket.js +41 -0
  47. package/dist/task/stall-detector.d.ts +110 -0
  48. package/dist/task/stall-detector.js +159 -0
  49. package/dist/task/verify-work.d.ts +53 -67
  50. package/dist/task/verify-work.js +15 -11
  51. package/dist/workers/single-read-extension.d.ts +1 -1
  52. package/dist/workers/single-read-extension.js +5 -4
  53. package/dist/workers/single-read-guard.d.ts +32 -10
  54. package/dist/workers/single-read-guard.js +67 -16
  55. package/package.json +1 -1
@@ -55,9 +55,10 @@ export declare function extractSpecForVerification(taskBody: string): string | n
55
55
  * three of them).
56
56
  *
57
57
  * Each probe is now an ADAPTER: one row carrying only what is specific to it —
58
- * the dep field it reads, the empty value it degrades to, how its raw result
59
- * becomes finding lines, the notice block those lines produce, and the numbered
60
- * rule the notice routes the child to. The loop owns the ritual. (Same shape as
58
+ * its key (which names the thunk it reads from `deps.probes`), the empty value it
59
+ * degrades to, how its raw result becomes finding lines, the notice block those
60
+ * lines produce, and the numbered rule the notice routes the child to. The loop
61
+ * owns the ritual. (Same shape as
61
62
  * LOCKFILE_CHECKS in final-gate.ts, where a whole package ecosystem is one row.)
62
63
  *
63
64
  * THIS PROMPT IS A MEASURED ARTIFACT — its wording is A/B-tested on the live
@@ -71,10 +72,48 @@ export declare function extractSpecForVerification(taskBody: string): string | n
71
72
  * A row whose rule is woven into the numbered narrative elsewhere (3b inside the
72
73
  * substitution rules, 3f, 5c) carries `ruleId` for the reader and no `rule` text.
73
74
  */
75
+ /**
76
+ * What each probe channel's RAW probe returns — the one place a channel's shape
77
+ * is declared. `ProbeKey` is derived from it, so a channel exists exactly when it
78
+ * has a row here; delete one and the compiler names its table row and its single
79
+ * binding line in gate-deps' `buildVerifyProbes`.
80
+ */
81
+ export interface ProbeRaw {
82
+ substitution: string[];
83
+ prohibition: string[];
84
+ crossTaskDeletion: CrossTaskDeletion[];
85
+ probeGaming: string[];
86
+ skipEscape: string[];
87
+ foreignPath: string[];
88
+ scriptEscape: string[];
89
+ runnerGlob: string[];
90
+ testAssembly: string[];
91
+ }
74
92
  /** Stable identity of one probe channel: table row ↔ findings-bag key. */
75
- export type ProbeKey = 'substitution' | 'prohibition' | 'crossTaskDeletion' | 'probeGaming' | 'skipEscape' | 'foreignPath' | 'scriptEscape' | 'runnerGlob' | 'testAssembly';
93
+ export type ProbeKey = keyof ProbeRaw;
94
+ /**
95
+ * The channels a CALLER binds — every key but `skipEscape`, which is pure text
96
+ * analysis of `deps.spec` and is sourced inside its own table row, so it is never
97
+ * absent and never bound from outside.
98
+ */
99
+ export type BoundProbeKey = Exclude<ProbeKey, 'skipEscape'>;
100
+ /**
101
+ * The bound probes: one optional thunk per channel, typed to that channel's raw
102
+ * shape. Built in ONE place (gate-deps' `buildVerifyProbes`); an absent key means
103
+ * the row is skipped. Adding a probe is a `ProbeRaw` line, a table row, and a
104
+ * binding line — nothing else.
105
+ */
106
+ export type VerifyProbes = {
107
+ [K in BoundProbeKey]?: () => Promise<ProbeRaw[K]>;
108
+ };
76
109
  /** The finding lines each probe channel contributed. Absent/empty ⇒ no block. */
77
110
  export type ProbeFindings = Partial<Record<ProbeKey, string[]>>;
111
+ /**
112
+ * The channels a caller must bind — the table rows that read `deps.probes`, in
113
+ * table order. Exported so the one binder (gate-deps' `buildVerifyProbes`) can be
114
+ * checked against the table rather than against a hand-kept list.
115
+ */
116
+ export declare const BOUND_PROBE_KEYS: readonly BoundProbeKey[];
78
117
  /**
79
118
  * Build the verification child's prompt. Kept pure so the wording is unit-tested
80
119
  * without spawning pi. The contract: run the spec's own verification in the real
@@ -131,75 +170,22 @@ export interface VerificationDeps {
131
170
  reason: string;
132
171
  }>;
133
172
  /**
134
- * Progress hook for the DETERMINISTIC stage — the repo-health run plus the ten
135
- * probes below, all of which run BEFORE the child (and therefore before the
173
+ * Progress hook for the DETERMINISTIC stage — the repo-health run plus the
174
+ * `probes` below, all of which run BEFORE the child (and therefore before the
136
175
  * child's own status widget exists). Called with a short label as each step
137
176
  * starts, so the caller can keep a live line on screen through what was
138
177
  * otherwise the run's longest stretch of dead air (MEASURED at 15–69s per
139
178
  * repo-health run). ABSENT → no progress reporting, same behaviour as before. */
140
179
  onStage?: (stage: string) => void;
141
180
  /**
142
- * DETERMINISTIC substitution probe (see substitution-probe.ts): scans the task's
143
- * changed test files for test-the-copy shapes and returns finding lines to inject
144
- * into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
145
- * the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
146
- probe?: () => Promise<string[]>;
147
- /**
148
- * DETERMINISTIC prohibition probe (see prohibition-probe.ts): spec-forbidden
149
- * paths the task's diff modified anyway, injected as prompt findings under the
150
- * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
151
- * conditional prose. ABSENT or empty → no prohibition block. */
152
- prohibitionProbe?: () => Promise<string[]>;
153
- /**
154
- * DETERMINISTIC test-assembly probe (see test-assembly.ts): authored test files
155
- * that rebuild production WIRING — importing the leaf modules the shipped entry
156
- * composes and assembling their own copy instead of the real assembly — become
157
- * prompt findings under rule 3f (F4 test-the-copy, 3rd recurrence). Pure import-
158
- * graph shape; the child then drives the real assembly before trusting the copy.
159
- * ABSENT or empty → no test-assembly block. */
160
- testAssemblyProbe?: () => Promise<string[]>;
161
- /**
162
- * DETERMINISTIC probe-gaming probe (see probe-gaming.ts, run-8 F6): added lines
163
- * in the task's diff whose stated purpose is to make a CHECK pass instead of
164
- * meeting the requirement it stands for ("return 401 so the verification test
165
- * passes"). Injected as findings under rule 4c so the child confirms the
166
- * underlying requirement is genuinely met rather than trusting the green check.
167
- * Pure diff-text analysis; ABSENT or empty → no probe block. */
168
- probeGamingProbe?: () => Promise<string[]>;
169
- /**
170
- * DETERMINISTIC cross-task deletion probe (see task-provenance.ts, mx5 run 12
171
- * PROMPT 2): tracked files the task's diff DELETES whose introducing task (git
172
- * provenance) differs from the current task — a sibling's committed deliverable
173
- * destroyed, typically to green a check. Injected as prompt findings under rule
174
- * 4d (MANDATORY + verdict-gating, the A/B-proven shape — buried rules score
175
- * 0/5), and carried structurally on a FAIL outcome so an ACCEPT records each as
176
- * a durable debt. ABSENT or empty → no block. */
177
- crossTaskDeletionProbe?: () => Promise<CrossTaskDeletion[]>;
178
- /**
179
- * DETERMINISTIC sandbox-path-leak probe (see foreign-path.ts, mx5 run 13
180
- * PROMPT 4 item 1): absolute paths this task committed that exist only inside
181
- * the authoring child's own environment — `/workspace/src/shared` in a vite
182
- * alias — while the real file sits at `src/shared` here. The probe REPAIRS
183
- * what it can deterministically first; only leaks it could not repair reach
184
- * this hook, injected under rule 4e (MANDATORY + verdict-gating, the same
185
- * shape as 4d — a leak makes the affected command fail to BUILD, so the
186
- * checks that would notice never run at all). ABSENT or empty → no block. */
187
- foreignPathProbe?: () => Promise<string[]>;
188
- /**
189
- * DETERMINISTIC neutered-check-script probe (see script-escape.ts, mx5 run 13
190
- * PROMPT 4 item 4): check-class scripts in a manifest THIS task changed whose
191
- * exit status cannot be non-zero (`… || true`, an inverted-grep launder). The
192
- * damage is second-order — the script still "passes", so the gates that run it
193
- * report success without measuring anything — which is exactly why the child
194
- * cannot discover it by running the check. ABSENT or empty → no block. */
195
- scriptEscapeProbe?: () => Promise<string[]>;
196
- /**
197
- * DETERMINISTIC test-runner glob-collision probe (see runner-globs.ts, mx5 runs
198
- * 7 AND 13, PROMPT 4 item 2): the manifest declares two runners whose file sets
199
- * are not provably disjoint, so the scanning one imports the other's specs and
200
- * dies during COLLECTION. Injected under rule 4g. UNKNOWN (one runner, or
201
- * disjointness proven) yields nothing. ABSENT or empty → no block. */
202
- runnerGlobProbe?: () => Promise<string[]>;
181
+ * The DETERMINISTIC probes, one optional thunk per channel (see `PROBE_ADAPTERS`
182
+ * above for what each channel is and which rule its findings cite; the
183
+ * substitution, prohibition, test-assembly, probe-gaming, cross-task-deletion,
184
+ * foreign-path, script-escape and runner-glob probes all live here). Bound in
185
+ * ONE place — gate-deps' `buildVerifyProbes` — and consumed by the table: an
186
+ * absent key skips its row, a throwing thunk degrades to the row's empty value.
187
+ * ABSENT no probe blocks at all (tests / callers that wire none). */
188
+ probes?: VerifyProbes;
203
189
  /**
204
190
  * Result of the git-state guard for the MOST RECENT runChild call (see
205
191
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -122,15 +122,21 @@ export function extractSpecForVerification(taskBody) {
122
122
  */
123
123
  function probeAdapter(row) {
124
124
  const { key, stage, block, ruleId, rule } = row;
125
+ const source = row.source
126
+ ?? ((deps) =>
127
+ // One channel per key, so the bag's entry for this key IS this row's
128
+ // thunk; the cast only re-narrows what the mapped type already says.
129
+ deps.probes?.[key]);
125
130
  return {
126
131
  key,
132
+ bound: !row.source,
127
133
  stage,
128
134
  block,
129
135
  ruleId,
130
136
  rule,
131
137
  run: async (deps, onStage) => {
132
- const probe = row.dep(deps);
133
- // Probes are INDEPENDENTLY OPTIONAL: an absent dep is "skipped", and a
138
+ const probe = source(deps);
139
+ // Probes are INDEPENDENTLY OPTIONAL: an absent probe is "skipped", and a
134
140
  // probe that throws degrades to its own empty value — it is a sharpener,
135
141
  // never a blocker, so a fault in one can neither block the gate nor
136
142
  // leak into another row.
@@ -165,7 +171,6 @@ const PROBE_ADAPTERS = [
165
171
  probeAdapter({
166
172
  key: 'substitution',
167
173
  stage: 'substitution probe',
168
- dep: deps => deps.probe,
169
174
  empty: [],
170
175
  findings: asLines,
171
176
  ruleId: '3b',
@@ -198,7 +203,6 @@ const PROBE_ADAPTERS = [
198
203
  probeAdapter({
199
204
  key: 'prohibition',
200
205
  stage: 'prohibition probe',
201
- dep: deps => deps.prohibitionProbe,
202
206
  empty: [],
203
207
  findings: asLines,
204
208
  ruleId: '4b',
@@ -238,7 +242,6 @@ const PROBE_ADAPTERS = [
238
242
  probeAdapter({
239
243
  key: 'crossTaskDeletion',
240
244
  stage: 'cross-task deletion probe',
241
- dep: deps => deps.crossTaskDeletionProbe,
242
245
  empty: [],
243
246
  findings: crossTaskDeletionVerifyFindings,
244
247
  ruleId: '4d',
@@ -277,7 +280,6 @@ const PROBE_ADAPTERS = [
277
280
  probeAdapter({
278
281
  key: 'probeGaming',
279
282
  stage: 'probe-gaming probe',
280
- dep: deps => deps.probeGamingProbe,
281
283
  empty: [],
282
284
  findings: asLines,
283
285
  ruleId: '4c',
@@ -322,7 +324,7 @@ const PROBE_ADAPTERS = [
322
324
  */
323
325
  probeAdapter({
324
326
  key: 'skipEscape',
325
- dep: deps => () => Promise.resolve(skipEscapeVerifyFindings(findSkipEscapes(deps.spec ?? ''))),
327
+ source: deps => () => Promise.resolve(skipEscapeVerifyFindings(findSkipEscapes(deps.spec ?? ''))),
326
328
  empty: [],
327
329
  findings: asLines,
328
330
  ruleId: '5c',
@@ -349,7 +351,6 @@ const PROBE_ADAPTERS = [
349
351
  probeAdapter({
350
352
  key: 'foreignPath',
351
353
  stage: 'foreign-path probe',
352
- dep: deps => deps.foreignPathProbe,
353
354
  empty: [],
354
355
  findings: asLines,
355
356
  ruleId: '4e',
@@ -392,7 +393,6 @@ const PROBE_ADAPTERS = [
392
393
  probeAdapter({
393
394
  key: 'scriptEscape',
394
395
  stage: 'script-escape probe',
395
- dep: deps => deps.scriptEscapeProbe,
396
396
  empty: [],
397
397
  findings: asLines,
398
398
  ruleId: '4f',
@@ -431,7 +431,6 @@ const PROBE_ADAPTERS = [
431
431
  probeAdapter({
432
432
  key: 'runnerGlob',
433
433
  stage: 'runner-glob probe',
434
- dep: deps => deps.runnerGlobProbe,
435
434
  empty: [],
436
435
  findings: asLines,
437
436
  ruleId: '4g',
@@ -466,7 +465,6 @@ const PROBE_ADAPTERS = [
466
465
  probeAdapter({
467
466
  key: 'testAssembly',
468
467
  stage: 'test-assembly probe',
469
- dep: deps => deps.testAssemblyProbe,
470
468
  empty: [],
471
469
  findings: asLines,
472
470
  ruleId: '3f',
@@ -487,6 +485,12 @@ const PROBE_ADAPTERS = [
487
485
  ]
488
486
  })
489
487
  ];
488
+ /**
489
+ * The channels a caller must bind — the table rows that read `deps.probes`, in
490
+ * table order. Exported so the one binder (gate-deps' `buildVerifyProbes`) can be
491
+ * checked against the table rather than against a hand-kept list.
492
+ */
493
+ export const BOUND_PROBE_KEYS = PROBE_ADAPTERS.filter(a => a.bound).map(a => a.key);
490
494
  /**
491
495
  * Build the verification child's prompt. Kept pure so the wording is unit-tested
492
496
  * without spawning pi. The contract: run the spec's own verification in the real
@@ -4,7 +4,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
4
  * blocks, each returning a reason the model receives as an error tool result
5
5
  * (agent-loop: block → createErrorToolResult) so the worker continues instead of
6
6
  * looping:
7
- * - read: blocks any re-read of a file already read this run (path-keyed).
7
+ * - read: blocks a re-read of LINES already delivered; forward paging passes.
8
8
  * - grep/find/ls: blocks an identical repeat of the same call (args-keyed).
9
9
  * See single-read-guard.ts for why this is scoped to TOOLING.
10
10
  */
@@ -7,7 +7,7 @@ const DEDUP_TOOLS = new Set(['grep', 'find', 'ls']);
7
7
  * blocks, each returning a reason the model receives as an error tool result
8
8
  * (agent-loop: block → createErrorToolResult) so the worker continues instead of
9
9
  * looping:
10
- * - read: blocks any re-read of a file already read this run (path-keyed).
10
+ * - read: blocks a re-read of LINES already delivered; forward paging passes.
11
11
  * - grep/find/ls: blocks an identical repeat of the same call (args-keyed).
12
12
  * See single-read-guard.ts for why this is scoped to TOOLING.
13
13
  */
@@ -16,10 +16,11 @@ export default function (pi) {
16
16
  const calls = new RepeatedCallGuard();
17
17
  pi.on('tool_call', event => {
18
18
  if (event.toolName === 'read') {
19
- const path = event.input.path;
20
- if (typeof path !== 'string')
19
+ const input = event.input;
20
+ if (typeof input.path !== 'string')
21
21
  return;
22
- return reads.check(resolve(process.cwd(), path)) ?? undefined;
22
+ return (reads.check(resolve(process.cwd(), input.path), input.offset, input.limit)
23
+ ?? undefined);
23
24
  }
24
25
  if (DEDUP_TOOLS.has(event.toolName)) {
25
26
  return calls.check(event.toolName, event.input) ?? undefined;
@@ -6,10 +6,22 @@
6
6
  * as an error tool result, the worker continues). No kill, no restart: detect-
7
7
  * and-kill only re-spawns a model that deterministically re-thrashes.
8
8
  *
9
- * - SingleReadGuard: "read each file once". Validated against every recorded
10
- * mx5 run — a healthy TOOLING worker reads each file exactly once (max
11
- * same-file reads = 1 across 7 tasks). TASK_0017 re-read one file up to 50×.
12
- * Keyed on the resolved path so any re-read is blocked regardless of offset.
9
+ * - SingleReadGuard: "read each LINE of a file once". Validated against every
10
+ * recorded mx5 run — a healthy TOOLING worker reads each file exactly once
11
+ * (max same-file reads = 1 across 7 tasks). TASK_0017 re-read one file 50×.
12
+ *
13
+ * It used to key on the resolved path alone, blocking any second read
14
+ * "regardless of offset". That made a file bigger than one read into a trap.
15
+ * Measured 2026-08-17 on a captured auto-decompose request: the planner asked
16
+ * for `DESIGN/marketplace.html` with `limit: 80` — the first 80 lines of 743,
17
+ * deliberately paging — and its request for offset 80 was refused. It never
18
+ * reached the end of the file, and spent the rest of the run asking for the
19
+ * remainder: 197 of 200 tool calls were this guard's own refusal. The guard
20
+ * did not stop a thrash, it CAUSED one.
21
+ *
22
+ * So the unit is the line range, not the file. A request that extends past
23
+ * the furthest line already delivered is forward paging and passes; one that
24
+ * lies entirely within ground already delivered is a re-read and is blocked.
13
25
  *
14
26
  * - RepeatedCallGuard: "no identical search twice", for grep/find/ls. TASK_0017
15
27
  * also looped on grep({pattern:"^\\s*}",path:".../index.ts"}) ×5 — a path the
@@ -24,17 +36,27 @@ export interface ReadBlock {
24
36
  block: true;
25
37
  reason: string;
26
38
  }
27
- /** The error text the model receives in place of the re-read's contents. */
28
- export declare function singleReadReason(path: string): string;
39
+ /**
40
+ * The error text the model receives in place of the re-read's contents.
41
+ *
42
+ * It must say what to do NEXT, and the honest next move depends on whether there
43
+ * is any of the file left: with `covered` lines already delivered, asking for
44
+ * line `covered + 1` is always allowed, so the message says so. The old wording
45
+ * ("Do not read it again") was a dead end for a model that was mid-way through a
46
+ * file — it had nowhere legal to go and kept asking anyway.
47
+ */
48
+ export declare function singleReadReason(path: string, covered: number): string;
29
49
  export declare class SingleReadGuard {
30
- private readonly seen;
50
+ /** Furthest line already delivered per path; Infinity once a read hit EOF. */
51
+ private readonly covered;
31
52
  /**
32
- * Record a read of `resolvedPath`. Returns a ReadBlock the first time a path
33
- * is seen a second time (and every time after), else null on the first read.
53
+ * Record a read of `resolvedPath` over an optional line range. Returns a
54
+ * ReadBlock when the request lies entirely within lines already delivered,
55
+ * else null — which includes every first read and every forward page.
34
56
  * Callers pass an already-resolved/normalized path so `a.ts` and `./a.ts`
35
57
  * dedupe to one entry.
36
58
  */
37
- check(resolvedPath: string): ReadBlock | null;
59
+ check(resolvedPath: string, offset?: unknown, limit?: unknown): ReadBlock | null;
38
60
  }
39
61
  /** The error text the model receives in place of a repeated grep/find/ls call. */
40
62
  export declare function repeatedCallReason(toolName: string): string;
@@ -6,10 +6,22 @@
6
6
  * as an error tool result, the worker continues). No kill, no restart: detect-
7
7
  * and-kill only re-spawns a model that deterministically re-thrashes.
8
8
  *
9
- * - SingleReadGuard: "read each file once". Validated against every recorded
10
- * mx5 run — a healthy TOOLING worker reads each file exactly once (max
11
- * same-file reads = 1 across 7 tasks). TASK_0017 re-read one file up to 50×.
12
- * Keyed on the resolved path so any re-read is blocked regardless of offset.
9
+ * - SingleReadGuard: "read each LINE of a file once". Validated against every
10
+ * recorded mx5 run — a healthy TOOLING worker reads each file exactly once
11
+ * (max same-file reads = 1 across 7 tasks). TASK_0017 re-read one file 50×.
12
+ *
13
+ * It used to key on the resolved path alone, blocking any second read
14
+ * "regardless of offset". That made a file bigger than one read into a trap.
15
+ * Measured 2026-08-17 on a captured auto-decompose request: the planner asked
16
+ * for `DESIGN/marketplace.html` with `limit: 80` — the first 80 lines of 743,
17
+ * deliberately paging — and its request for offset 80 was refused. It never
18
+ * reached the end of the file, and spent the rest of the run asking for the
19
+ * remainder: 197 of 200 tool calls were this guard's own refusal. The guard
20
+ * did not stop a thrash, it CAUSED one.
21
+ *
22
+ * So the unit is the line range, not the file. A request that extends past
23
+ * the furthest line already delivered is forward paging and passes; one that
24
+ * lies entirely within ground already delivered is a re-read and is blocked.
13
25
  *
14
26
  * - RepeatedCallGuard: "no identical search twice", for grep/find/ls. TASK_0017
15
27
  * also looped on grep({pattern:"^\\s*}",path:".../index.ts"}) ×5 — a path the
@@ -21,25 +33,64 @@
21
33
  * Pure logic, no I/O — the extension does path resolution and tool routing.
22
34
  */
23
35
  import { stableStringify } from '../task/loop-detector.js';
24
- /** The error text the model receives in place of the re-read's contents. */
25
- export function singleReadReason(path) {
26
- return (`You already read ${path} earlier in this run — its contents are in your context. `
27
- + `Re-reading the same file is blocked. Do not read it again: use what you have already `
28
- + `gathered and write your final answer now.`);
36
+ /**
37
+ * The error text the model receives in place of the re-read's contents.
38
+ *
39
+ * It must say what to do NEXT, and the honest next move depends on whether there
40
+ * is any of the file left: with `covered` lines already delivered, asking for
41
+ * line `covered + 1` is always allowed, so the message says so. The old wording
42
+ * ("Do not read it again") was a dead end for a model that was mid-way through a
43
+ * file — it had nowhere legal to go and kept asking anyway.
44
+ */
45
+ export function singleReadReason(path, covered) {
46
+ if (!Number.isFinite(covered)) {
47
+ return (`You already read all of ${path} earlier in this run — its contents are in your `
48
+ + `context. Re-reading it is blocked. Use what you have already gathered and write `
49
+ + `your final answer now.`);
50
+ }
51
+ return (`You already read ${path} through line ${covered} earlier in this run — those lines are `
52
+ + `in your context. Re-reading them is blocked. To see more of this file, read it again `
53
+ + `starting at line ${covered + 1}; otherwise use what you have already gathered and `
54
+ + `write your final answer now.`);
55
+ }
56
+ /** Default `limit` pi's read tool applies when the call names none. */
57
+ const DEFAULT_READ_LIMIT = 2000;
58
+ /** The 1-based line a read starts at (`offset` absent or junk means line 1). */
59
+ function startLine(offset) {
60
+ return typeof offset === 'number' && Number.isFinite(offset) && offset >= 1 ?
61
+ Math.floor(offset)
62
+ : 1;
63
+ }
64
+ /**
65
+ * The last line a read reaches. A `limit` of exactly the tool default is treated
66
+ * as "no limit given" — indistinguishable at this layer, and the safe reading is
67
+ * the generous one, since blocking honest paging is the failure this guard had.
68
+ */
69
+ function endLine(start, limit) {
70
+ if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0)
71
+ return Infinity;
72
+ if (limit >= DEFAULT_READ_LIMIT)
73
+ return Infinity;
74
+ return start + Math.floor(limit) - 1;
29
75
  }
30
76
  export class SingleReadGuard {
31
- seen = new Set();
77
+ /** Furthest line already delivered per path; Infinity once a read hit EOF. */
78
+ covered = new Map();
32
79
  /**
33
- * Record a read of `resolvedPath`. Returns a ReadBlock the first time a path
34
- * is seen a second time (and every time after), else null on the first read.
80
+ * Record a read of `resolvedPath` over an optional line range. Returns a
81
+ * ReadBlock when the request lies entirely within lines already delivered,
82
+ * else null — which includes every first read and every forward page.
35
83
  * Callers pass an already-resolved/normalized path so `a.ts` and `./a.ts`
36
84
  * dedupe to one entry.
37
85
  */
38
- check(resolvedPath) {
39
- if (this.seen.has(resolvedPath)) {
40
- return { block: true, reason: singleReadReason(resolvedPath) };
86
+ check(resolvedPath, offset, limit) {
87
+ const seen = this.covered.get(resolvedPath);
88
+ const start = startLine(offset);
89
+ const end = endLine(start, limit);
90
+ if (seen !== undefined && end <= seen) {
91
+ return { block: true, reason: singleReadReason(resolvedPath, seen) };
41
92
  }
42
- this.seen.add(resolvedPath);
93
+ this.covered.set(resolvedPath, Math.max(seen ?? 0, end));
43
94
  return null;
44
95
  }
45
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.11",
3
+ "version": "0.38.12",
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",