@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.
- package/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +5 -151
- package/dist/task/final-gate.js +81 -379
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +67 -294
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- 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
|
|
59
|
-
* becomes finding lines, the notice block those
|
|
60
|
-
* rule the notice routes the child to. The loop
|
|
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 =
|
|
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
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
*
|
|
149
|
-
|
|
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
|
package/dist/task/verify-work.js
CHANGED
|
@@ -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 =
|
|
133
|
-
// Probes are INDEPENDENTLY OPTIONAL: an absent
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
10
|
-
* mx5 run — a healthy TOOLING worker reads each file exactly once
|
|
11
|
-
* same-file reads = 1 across 7 tasks). TASK_0017 re-read one file
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
*
|
|
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
|
|
10
|
-
* mx5 run — a healthy TOOLING worker reads each file exactly once
|
|
11
|
-
* same-file reads = 1 across 7 tasks). TASK_0017 re-read one file
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
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.
|
|
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.
|
|
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",
|