@mjasnikovs/pi-task 0.38.11 → 0.38.13

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 (59) hide show
  1. package/README.md +8 -5
  2. package/dist/config/config.d.ts +0 -1
  3. package/dist/config/config.js +0 -1
  4. package/dist/config/register.js +0 -2
  5. package/dist/index.js +0 -2
  6. package/dist/shared/child-process.d.ts +8 -0
  7. package/dist/shared/command-watchdog.d.ts +1 -1
  8. package/dist/shared/command-watchdog.js +1 -1
  9. package/dist/task/accept-debt.d.ts +47 -0
  10. package/dist/task/accept-debt.js +127 -28
  11. package/dist/task/auto-orchestrator.js +91 -114
  12. package/dist/task/child-runner.d.ts +39 -25
  13. package/dist/task/child-runner.js +59 -31
  14. package/dist/task/child-status.d.ts +95 -0
  15. package/dist/task/child-status.js +99 -0
  16. package/dist/task/command-run.d.ts +36 -0
  17. package/dist/task/command-run.js +48 -1
  18. package/dist/task/command-watchdog.js +1 -1
  19. package/dist/task/context-usage.d.ts +4 -3
  20. package/dist/task/context-usage.js +4 -3
  21. package/dist/task/contracts.js +18 -35
  22. package/dist/task/deep-render-check.d.ts +47 -0
  23. package/dist/task/deep-render-check.js +110 -65
  24. package/dist/task/env-notes.d.ts +3 -3
  25. package/dist/task/env-notes.js +24 -35
  26. package/dist/task/final-gate-fix.d.ts +1 -1
  27. package/dist/task/final-gate-fix.js +1 -1
  28. package/dist/task/final-gate.d.ts +5 -151
  29. package/dist/task/final-gate.js +81 -379
  30. package/dist/task/gate-child.d.ts +8 -10
  31. package/dist/task/gate-child.js +15 -19
  32. package/dist/task/gate-deps.d.ts +29 -0
  33. package/dist/task/gate-deps.js +192 -206
  34. package/dist/task/gate-tally.d.ts +189 -0
  35. package/dist/task/gate-tally.js +249 -0
  36. package/dist/task/implementation-turn.d.ts +201 -0
  37. package/dist/task/implementation-turn.js +263 -0
  38. package/dist/task/launch-contract.js +27 -43
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +67 -294
  45. package/dist/task/plan-orchestrator.js +34 -33
  46. package/dist/task/requirements.d.ts +1 -1
  47. package/dist/task/requirements.js +50 -66
  48. package/dist/task/root-cause-repair.js +20 -32
  49. package/dist/task/run-bracket.d.ts +75 -0
  50. package/dist/task/run-bracket.js +41 -0
  51. package/dist/task/stall-detector.d.ts +110 -0
  52. package/dist/task/stall-detector.js +159 -0
  53. package/dist/task/verify-work.d.ts +53 -67
  54. package/dist/task/verify-work.js +15 -11
  55. package/dist/workers/single-read-extension.d.ts +1 -1
  56. package/dist/workers/single-read-extension.js +5 -4
  57. package/dist/workers/single-read-guard.d.ts +32 -10
  58. package/dist/workers/single-read-guard.js +67 -16
  59. package/package.json +1 -1
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Implementation-turn supervision — what happens between "the spec was delivered
3
+ * to the main session" and "we know how the implementation turn REALLY ended".
4
+ *
5
+ * A single `waitForIdle` resolves for four different reasons, and three of them
6
+ * are not "the model finished":
7
+ * • `aborted` — a user ESC (or the command watchdog) cut the turn short;
8
+ * • `compaction` — a threshold auto-compaction parked the turn at idle without
9
+ * auto-continuing (the runtime expects a manual continue);
10
+ * • `error` — the model/provider died mid-turn after pi's own retries;
11
+ * • `stop` — genuine completion.
12
+ * `classifyTurnEnd` reads the session entries and names ONE of those, in the
13
+ * precedence the supervision sequence needs; `superviseImplementation` then
14
+ * resumes across compactions, lets the user steer after an interrupt, and reports
15
+ * the terminal outcome. The orchestrator calls it once.
16
+ */
17
+ import { SessionUI } from '../remote/bridge.js';
18
+ import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
19
+ const isAssistant = (e) => e.message !== undefined && e.message.role === 'assistant';
20
+ /** Index of the last assistant message and of the last compaction boundary. */
21
+ function tailPositions(entries) {
22
+ let lastAssistant = -1;
23
+ let lastCompaction = -1;
24
+ for (let i = 0; i < entries.length; i++) {
25
+ const e = entries[i];
26
+ if (isAssistant(e))
27
+ lastAssistant = i;
28
+ else if (e.type === 'compaction')
29
+ lastCompaction = i;
30
+ }
31
+ return { lastAssistant, lastCompaction };
32
+ }
33
+ /**
34
+ * Classify how the most recent turn ended, from the session entries alone.
35
+ *
36
+ * Precedence, when several signals are present at once (this is the order the
37
+ * supervision sequence has always applied, now stated in one place):
38
+ * 1. `aborted` — the last assistant message has stopReason "aborted". A user
39
+ * ESC (or watchdog abort) wins over everything: it is not a
40
+ * compaction pause, and the steer loop owns it.
41
+ * 2. `compaction` — a `compaction` entry sits AFTER the last assistant message.
42
+ * Position-based, not timestamp-based: the runtime appends the
43
+ * boundary to the tail of the branch after the message that
44
+ * triggered it (`appendCompaction` → `_appendEntry` push), so a
45
+ * trailing compaction means we are parked with no continuation.
46
+ * A finished turn ends on an assistant message; an *overflow*
47
+ * compaction self-retries and never leaves us idle here.
48
+ * 3. `error` — the last assistant message has stopReason "error": the
49
+ * model/provider died (context-overflow 400, disconnect, 5xx)
50
+ * after pi exhausted its own retries.
51
+ * 4. `stop` — anything else, including a session with no assistant turn.
52
+ */
53
+ export function classifyTurnEnd(entries) {
54
+ const { lastAssistant, lastCompaction } = tailPositions(entries);
55
+ const last = lastAssistant >= 0 ? entries[lastAssistant].message : undefined;
56
+ if (last?.stopReason === 'aborted')
57
+ return 'aborted';
58
+ if (lastCompaction > lastAssistant)
59
+ return 'compaction';
60
+ if (last?.stopReason === 'error')
61
+ return 'error';
62
+ return 'stop';
63
+ }
64
+ /**
65
+ * The provider's error cause for an `error` turn end — the message the run's
66
+ * "stopped at …" line quotes so the real cause is not lost. Undefined unless the
67
+ * last assistant message ended with stopReason "error".
68
+ */
69
+ export function turnErrorMessage(entries) {
70
+ const { lastAssistant } = tailPositions(entries);
71
+ const last = lastAssistant >= 0 ? entries[lastAssistant].message : undefined;
72
+ if (last?.stopReason !== 'error')
73
+ return undefined;
74
+ return last.errorMessage ?? 'model error';
75
+ }
76
+ /** True when the last assistant turn was aborted (ESC / watchdog). */
77
+ const wasInterrupted = (entries) => classifyTurnEnd(entries) === 'aborted';
78
+ /**
79
+ * True when the watchdog's reminder follow-up has been DELIVERED into the session
80
+ * after the aborted assistant turn but its own turn has not finished yet — the
81
+ * artifact that confirms a pending watchdog recovery. Scoped after the LAST
82
+ * assistant entry so an earlier fire's reminder (already answered by its own
83
+ * turn) never matches.
84
+ */
85
+ export function watchdogReminderDelivered(entries) {
86
+ const { lastAssistant } = tailPositions(entries);
87
+ for (let i = lastAssistant + 1; i < entries.length; i++) {
88
+ const m = entries[i].message;
89
+ if (m === undefined || m.role !== 'user')
90
+ continue;
91
+ const content = m.content;
92
+ const text = typeof content === 'string' ? content
93
+ : Array.isArray(content) ?
94
+ content
95
+ .map(b => b !== null && typeof b === 'object' && 'text' in b ?
96
+ String(b.text)
97
+ : '')
98
+ .join(' ')
99
+ : '';
100
+ if (text.includes(WATCHDOG_CANCEL_MARKER))
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ const STEER_WATCHDOG_DEFAULTS = {
106
+ consume: consumeWatchdogAbort,
107
+ graceMs: 10_000,
108
+ pollMs: 100
109
+ };
110
+ /** Dialog copy for the post-interrupt steering prompt. */
111
+ const STEER_TITLE = 'Paused — steer the model';
112
+ const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
113
+ /** Remote-card copy for the same prompt. The browser has no placeholder ghost
114
+ * text, so the pause affordance must be spelled out in the question itself
115
+ * (Skip = empty answer = pause, same as an empty local submit). */
116
+ const STEER_QUESTION = 'Paused — the implementation was interrupted.\n'
117
+ + 'Type guidance to continue this task, or Skip to pause the run.';
118
+ /** Bind the supervision deps to a live session context. */
119
+ export function turnDepsFor(ctx, opts = {}) {
120
+ // Fan the prompt out through the bridge (local TUI input + remote browser
121
+ // card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
122
+ // come from the remote Stop button just as well as a terminal ESC, and a
123
+ // terminal-only dialog leaves the remote viewer staring at a silently
124
+ // paused run. Remote Skip returns '' → same pause path as an empty local
125
+ // submit.
126
+ const ask = opts.promptSteer
127
+ ?? (c => new SessionUI(c).ask({
128
+ localTitle: STEER_TITLE,
129
+ localPlaceholder: STEER_PLACEHOLDER,
130
+ question: STEER_QUESTION,
131
+ allowSkip: true
132
+ }));
133
+ return {
134
+ entries: () => ctx.sessionManager.getEntries(),
135
+ send: text => ctx.sendUserMessage(text, { deliverAs: 'followUp' }),
136
+ waitForIdle: () => ctx.waitForIdle(),
137
+ ask: () => ask(ctx),
138
+ watchdog: { ...STEER_WATCHDOG_DEFAULTS, ...opts.watchdog },
139
+ log: opts.log
140
+ };
141
+ }
142
+ // ─── Resume across compactions ───────────────────────────────────────────────
143
+ /**
144
+ * Nudge that resumes an implementation turn the runtime parked at a compaction
145
+ * boundary. It must let a turn that was genuinely finished (then tipped over the
146
+ * threshold by its own final message) confirm completion without inventing busywork
147
+ * — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
148
+ * from the boundary alone, so the wording lets a done turn end in one line.
149
+ */
150
+ export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compacted. Continue implementing this task from '
151
+ + 'exactly where you left off, and keep going until it is fully done. If the '
152
+ + 'implementation is already complete, say so in one line and stop — do not invent '
153
+ + 'extra work or restart the task.';
154
+ /**
155
+ * Safety cap on compaction-driven resumes for a single implementation turn. Each
156
+ * resume follows a real compaction (which only fires after the model produced a
157
+ * turn large enough to cross the threshold), so a legitimately large task may
158
+ * resume a handful of times; the cap exists only to stop a pathological loop from
159
+ * auto-sending forever with no user in the loop. Hitting it stops resuming and lets
160
+ * the verify gate / `/task-auto-resume` catch any leftover incompleteness.
161
+ */
162
+ export const MAX_COMPACTION_RESUMES = 20;
163
+ /**
164
+ * Resume an implementation turn that went idle at a threshold-compaction boundary.
165
+ * The runtime compacts and parks at idle without auto-continuing; we send a
166
+ * continue and wait again, repeating across successive compactions until the turn
167
+ * ends on a real assistant message (genuine completion). A user ESC takes priority
168
+ * (`classifyTurnEnd` ranks `aborted` above `compaction`, so the steer loop handles
169
+ * it), and the safety cap bounds a runaway. Returns the number of resumes
170
+ * performed (0 when the turn did not end on a compaction).
171
+ */
172
+ export async function resumeAcrossCompactions(deps) {
173
+ let resumes = 0;
174
+ while (resumes < MAX_COMPACTION_RESUMES && classifyTurnEnd(deps.entries()) === 'compaction') {
175
+ deps.log?.(`implementation: parked at a compaction boundary — resume ${resumes + 1}`);
176
+ await deps.send(CONTINUE_AFTER_COMPACTION);
177
+ await deps.waitForIdle();
178
+ resumes++;
179
+ }
180
+ return resumes;
181
+ }
182
+ // ─── Steer until done ────────────────────────────────────────────────────────
183
+ /**
184
+ * Wait for a watchdog abort's queued follow-up turn instead of prompting. The
185
+ * abort and the reminder follow-up are two separate steps in the watchdog's
186
+ * onFire, so the steer loop can observe the aborted turn before the reminder is
187
+ * delivered — poll (bounded) until it lands or the follow-up turn has already
188
+ * completed. True = recovery observed, re-check the loop; false = grace expired
189
+ * with no reminder (stale flag) — fall back to the human prompt.
190
+ */
191
+ async function awaitWatchdogFollowUp(deps) {
192
+ const wd = deps.watchdog;
193
+ const deadline = Date.now() + wd.graceMs;
194
+ for (;;) {
195
+ if (!wasInterrupted(deps.entries()))
196
+ return true; // follow-up turn already completed
197
+ if (watchdogReminderDelivered(deps.entries())) {
198
+ await deps.waitForIdle(); // let the follow-up turn run to completion
199
+ return true;
200
+ }
201
+ if (Date.now() >= deadline)
202
+ return false;
203
+ await new Promise(r => setTimeout(r, wd.pollMs));
204
+ }
205
+ }
206
+ /**
207
+ * After the implementation turn settles, honour a user ESC by letting them steer.
208
+ *
209
+ * `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
210
+ * the turn → idle). When the last turn was aborted, the host's main input loop is
211
+ * blocked inside our command handler, so a message typed in the editor would only
212
+ * queue, never run (interactive-mode routes idle input through onInputCallback,
213
+ * which is unset while we hold the loop). We therefore solicit the steering text
214
+ * ourselves and feed it back as another turn via sendUserMessage — which runs to
215
+ * completion when the session is idle. Repeat until a turn finishes uninterrupted.
216
+ *
217
+ * A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
218
+ * from a human ESC by the session entries alone at that instant. The watchdog
219
+ * queues its own recovery follow-up, so prompting there would show a steering
220
+ * dialog to an empty room and wedge an unattended run on the race. The one-shot
221
+ * flag (set synchronously before the abort) routes that case to
222
+ * {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
223
+ * followed by the ordinary prompt, never to a suppressed one.
224
+ *
225
+ * Returns true when the user declined to steer (empty/cancelled) and the run
226
+ * should pause; false when the implementation completed (steered or not).
227
+ */
228
+ export async function steerUntilDone(deps) {
229
+ while (wasInterrupted(deps.entries())) {
230
+ if (deps.watchdog.consume() && (await awaitWatchdogFollowUp(deps))) {
231
+ deps.log?.('implementation: watchdog abort recovered by its own follow-up');
232
+ continue;
233
+ }
234
+ const steer = await deps.ask();
235
+ if (steer === undefined || steer.trim().length === 0) {
236
+ deps.log?.('implementation: interrupted, user declined to steer — pausing');
237
+ return true; // pause
238
+ }
239
+ deps.log?.('implementation: interrupted, steering with user text');
240
+ await deps.send(steer);
241
+ await deps.waitForIdle();
242
+ }
243
+ return false;
244
+ }
245
+ /**
246
+ * Supervise an implementation turn from the first idle after spec delivery until
247
+ * its REAL end: resume across any compaction boundaries first (so steering and
248
+ * error inspection see the turn's actual end, not a compaction pause), then let
249
+ * the user steer across interrupts, then read how the final turn ended. The
250
+ * caller has already awaited the first idle.
251
+ */
252
+ export async function superviseImplementation(ctx, opts = {}) {
253
+ return superviseWith(turnDepsFor(ctx, opts));
254
+ }
255
+ /** {@link superviseImplementation} over an already-bound deps object (tests). */
256
+ export async function superviseWith(deps) {
257
+ const resumes = await resumeAcrossCompactions(deps);
258
+ const interrupted = await steerUntilDone(deps);
259
+ // A user-declined steer (interrupted) is its own paused path; otherwise
260
+ // inspect how the turn actually ended.
261
+ const error = interrupted ? undefined : turnErrorMessage(deps.entries());
262
+ return { interrupted, error, resumes };
263
+ }
@@ -23,16 +23,36 @@
23
23
  * naming any declared script the manifest is missing. FP-safe by construction: an
24
24
  * empty/ungrounded list (a design that never backticks a script name) yields no check.
25
25
  */
26
- import * as fsp from 'node:fs/promises';
27
- import * as path from 'node:path';
28
- import { tasksDir } from './task-io.js';
26
+ import { makeLedger } from './ledger.js';
29
27
  const LAUNCH_CONTRACT_FILE = 'launch-contract.md';
30
28
  /** Cap kept entries so a noisy extraction cannot grow the artifact unboundedly. */
31
29
  const MAX_SCRIPTS = 40;
32
30
  /** npm/package script names are short kebab/colon tokens; reject anything unscript-like. */
33
31
  const SCRIPT_NAME_RE = /^[a-z0-9][a-z0-9:_-]{0,39}$/i;
32
+ /** Stored one name per line; a line that is not a script name is skipped on read. */
33
+ const ledger = makeLedger({
34
+ file: LAUNCH_CONTRACT_FILE,
35
+ max: MAX_SCRIPTS,
36
+ key: n => n.toLowerCase(),
37
+ serialize: n => n,
38
+ parse: raw => {
39
+ const seen = new Set();
40
+ const out = [];
41
+ for (const line of raw.split('\n')) {
42
+ const n = line.trim();
43
+ if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
44
+ continue;
45
+ const key = n.toLowerCase();
46
+ if (seen.has(key))
47
+ continue;
48
+ seen.add(key);
49
+ out.push(n);
50
+ }
51
+ return out;
52
+ }
53
+ });
34
54
  export function launchContractFile(cwd) {
35
- return path.join(tasksDir(cwd), LAUNCH_CONTRACT_FILE);
55
+ return ledger.path(cwd);
36
56
  }
37
57
  /**
38
58
  * Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
@@ -115,51 +135,15 @@ export function enumerateScriptCandidates(sourceDoc) {
115
135
  }
116
136
  /** The stored declared-script list ('' when none recorded). */
117
137
  export async function readLaunchContractRaw(cwd) {
118
- try {
119
- return (await fsp.readFile(launchContractFile(cwd), 'utf8')).trim();
120
- }
121
- catch {
122
- return '';
123
- }
138
+ return ledger.readRaw(cwd);
124
139
  }
125
140
  /** The declared script names recorded for this run (deduped, order preserved). */
126
141
  export async function readDeclaredScripts(cwd) {
127
- const raw = await readLaunchContractRaw(cwd);
128
- const seen = new Set();
129
- const out = [];
130
- for (const line of raw.split('\n')) {
131
- const n = line.trim();
132
- if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
133
- continue;
134
- const key = n.toLowerCase();
135
- if (seen.has(key))
136
- continue;
137
- seen.add(key);
138
- out.push(n);
139
- }
140
- return out;
142
+ return ledger.read(cwd);
141
143
  }
142
144
  /** Append grounded script names, deduped against what is stored, keeping newest MAX. */
143
145
  export async function appendDeclaredScripts(cwd, names) {
144
- if (names.length === 0)
145
- return;
146
- try {
147
- const existing = await readDeclaredScripts(cwd);
148
- const seen = new Set(existing.map(n => n.toLowerCase()));
149
- const merged = [...existing];
150
- for (const n of names) {
151
- if (seen.has(n.toLowerCase()))
152
- continue;
153
- seen.add(n.toLowerCase());
154
- merged.push(n);
155
- }
156
- const kept = merged.slice(-MAX_SCRIPTS);
157
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
158
- await fsp.writeFile(launchContractFile(cwd), kept.join('\n') + '\n', 'utf8');
159
- }
160
- catch {
161
- // best-effort artifact
162
- }
146
+ await ledger.append(cwd, names);
163
147
  }
164
148
  /**
165
149
  * Boot-class script names: long-running serve/watch shapes the gate's BOOT check
@@ -0,0 +1,38 @@
1
+ export interface LedgerSpec<T> {
2
+ /** File name under `.pi-tasks/` (e.g. `contracts.md`). */
3
+ file: string;
4
+ /**
5
+ * Keep the newest `max` records on append (oldest dropped). Absent = uncapped
6
+ * (a ledger that is only ever overwritten whole, like requirements-owned).
7
+ */
8
+ max?: number;
9
+ /** Dedupe key — two records with equal keys are one record. */
10
+ key: (item: T) => string;
11
+ /** One record → one stored line (must not contain '\n'). */
12
+ serialize: (item: T) => string;
13
+ /** Stored text → records; skips what it cannot read rather than throwing. */
14
+ parse: (raw: string) => T[];
15
+ /**
16
+ * What `append` does when every incoming item was already stored:
17
+ * 'rewrite' (default) — write the merged list back anyway, which re-caps an
18
+ * over-long file and canonicalises lines the parser normalised or dropped;
19
+ * 'skip' — leave the file untouched (a single-record "no double-record"
20
+ * ledger: a duplicate is a return, not a write).
21
+ * The two are observably different when the file has drifted from what the
22
+ * writer produces, so it is an option, not a unification.
23
+ */
24
+ onNoop?: 'rewrite' | 'skip';
25
+ }
26
+ export interface Ledger<T> {
27
+ /** Absolute path of the ledger file for this cwd. */
28
+ path(cwd: string): string;
29
+ /** Trimmed stored text; '' when absent or on any read error. */
30
+ readRaw(cwd: string): Promise<string>;
31
+ /** Parsed records; [] when absent or on any read error. */
32
+ read(cwd: string): Promise<T[]>;
33
+ /** Merge new records in (deduped, capped) and write back. Never throws. */
34
+ append(cwd: string, items: T[]): Promise<void>;
35
+ /** Overwrite with exactly these records. Never throws. */
36
+ write(cwd: string, items: T[]): Promise<void>;
37
+ }
38
+ export declare function makeLedger<T>(spec: LedgerSpec<T>): Ledger<T>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ledger — the ONE read-modify-write ritual behind every run-level line file under
3
+ * `.pi-tasks/` (contracts, launch-contract, env-notes, accept-debt, repair-queue,
4
+ * requirements, requirements-owned).
5
+ *
6
+ * Six modules each kept their own copy of the same seven steps: read the file
7
+ * (ANY error → ''), parse it into records, key the records, drop an incoming item
8
+ * whose key is already present, cap to the newest MAX (oldest dropped), mkdir the
9
+ * tasks dir, write the whole file back (`lines.join('\n') + '\n'`, plain
10
+ * `writeFile`, NOT atomic), and swallow every fault — a ledger is a sharpener or
11
+ * an auditing aid, never a blocker of the phase or gate that calls it. What
12
+ * genuinely varied per site is the DATA SHAPE (file name, cap, key, line format,
13
+ * parser) and exactly one RULE — what an append does when it adds nothing new
14
+ * (see `onNoop`). Everything else here is the ritual, so a module that keeps a
15
+ * ledger is an ADAPTER: it declares its ledger and calls read/append/write.
16
+ *
17
+ * Contract details a caller can rely on:
18
+ * • `readRaw` is the trimmed file text ('' when absent or unreadable). Prompt-block
19
+ * builders take this string.
20
+ * • `read` is `parse(readRaw)`; every parser skips blank lines and lines it cannot
21
+ * read, so a corrupt line is dropped, never thrown on.
22
+ * • `append` with an empty batch is a no-op (no read, no write). Within a batch
23
+ * the first item with a key wins; a key already stored wins over the batch.
24
+ * • `write` overwrites with exactly these records; an empty list writes an empty
25
+ * file (this is how a drained queue and a fully-resolved debt ledger look).
26
+ * • Neither `append` nor `write` ever throws.
27
+ */
28
+ import * as fsp from 'node:fs/promises';
29
+ import * as path from 'node:path';
30
+ import { tasksDir } from './task-io.js';
31
+ export function makeLedger(spec) {
32
+ const { file, max, key, serialize, parse } = spec;
33
+ const onNoop = spec.onNoop ?? 'rewrite';
34
+ const filePath = (cwd) => path.join(tasksDir(cwd), file);
35
+ async function readRaw(cwd) {
36
+ try {
37
+ return (await fsp.readFile(filePath(cwd), 'utf8')).trim();
38
+ }
39
+ catch {
40
+ return '';
41
+ }
42
+ }
43
+ async function read(cwd) {
44
+ return parse(await readRaw(cwd));
45
+ }
46
+ async function persist(cwd, items) {
47
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
48
+ const content = items.length === 0 ? '' : items.map(serialize).join('\n') + '\n';
49
+ await fsp.writeFile(filePath(cwd), content, 'utf8');
50
+ }
51
+ async function append(cwd, items) {
52
+ if (items.length === 0)
53
+ return;
54
+ try {
55
+ const existing = await read(cwd);
56
+ const seen = new Set(existing.map(key));
57
+ const merged = [...existing];
58
+ for (const item of items) {
59
+ const k = key(item);
60
+ if (seen.has(k))
61
+ continue;
62
+ seen.add(k);
63
+ merged.push(item);
64
+ }
65
+ if (merged.length === existing.length && onNoop === 'skip')
66
+ return;
67
+ const kept = max === undefined ? merged : merged.slice(-max);
68
+ await persist(cwd, kept);
69
+ }
70
+ catch {
71
+ // best-effort ledger
72
+ }
73
+ }
74
+ async function write(cwd, items) {
75
+ try {
76
+ await persist(cwd, items);
77
+ }
78
+ catch {
79
+ // best-effort ledger
80
+ }
81
+ }
82
+ return { path: filePath, readRaw, read, append, write };
83
+ }
@@ -6,9 +6,9 @@
6
6
  * 1. EXACT repeats — the same `(toolName, stable-stringified args)` key appears
7
7
  * `threshold` times within the last `window` events.
8
8
  * 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
9
- * times within the window WITHOUT the read offset advancing, which the exact
10
- * key misses when `offset/limit` vary call-to-call. Strictly-forward paging
11
- * never trips (see countRevisits).
9
+ * times within the window over lines it has ALREADY covered, which the exact
10
+ * key misses when `offset/limit` vary call-to-call. Forward paging never
11
+ * trips (see countRevisits).
12
12
  *
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
@@ -45,11 +45,17 @@ export declare class LoopDetector {
45
45
  /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
46
46
  record(call: ToolCall): LoopHit | null;
47
47
  /**
48
- * Count same-path calls in the window that are "revisits" — accesses whose
49
- * offset does not advance past the furthest offset already seen for that
50
- * path. Linear paging (strictly increasing offsets) yields zero revisits and
51
- * never trips; repeated whole-file re-reads (offset 0 each time), backward
52
- * jumps, and path-targeting greps (offset 0) accumulate.
48
+ * Count same-path calls in the window that are "revisits" — accesses that end
49
+ * no further into the file than the furthest line already covered for that
50
+ * path. Paging yields zero revisits and never trips; repeated whole-file
51
+ * re-reads, backward jumps and path-targeting greps accumulate.
52
+ *
53
+ * The comparison is on the RANGE, not the offset. Offset alone scored a page
54
+ * that re-asks from the same start with a bigger limit — `{offset:80,
55
+ * limit:400}` after `{offset:80, limit:300}`, real reads from a measured
56
+ * decompose run — as a revisit, even though it covers 100 lines the child had
57
+ * never seen. That is the same mistake SingleReadGuard made by keying on the
58
+ * path alone, and it is corrected the same way.
53
59
  */
54
60
  private countRevisits;
55
61
  }
@@ -6,9 +6,9 @@
6
6
  * 1. EXACT repeats — the same `(toolName, stable-stringified args)` key appears
7
7
  * `threshold` times within the last `window` events.
8
8
  * 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
9
- * times within the window WITHOUT the read offset advancing, which the exact
10
- * key misses when `offset/limit` vary call-to-call. Strictly-forward paging
11
- * never trips (see countRevisits).
9
+ * times within the window over lines it has ALREADY covered, which the exact
10
+ * key misses when `offset/limit` vary call-to-call. Forward paging never
11
+ * trips (see countRevisits).
12
12
  *
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
@@ -52,6 +52,23 @@ function readOffset(args) {
52
52
  const o = args.offset;
53
53
  return typeof o === 'number' && Number.isFinite(o) ? o : 0;
54
54
  }
55
+ /** Default `limit` pi's read tool applies when the call names none. */
56
+ const DEFAULT_READ_LIMIT = 2000;
57
+ /**
58
+ * The last line a call reaches, so a page can be told from a re-read. Absent,
59
+ * junk, or at-the-tool-default `limit` all mean "to the end of the file": at this
60
+ * layer they are indistinguishable, and the generous reading is the safe one —
61
+ * scoring an honest page as a revisit is the failure this rule had.
62
+ */
63
+ function readEnd(args, offset) {
64
+ if (!args || typeof args !== 'object')
65
+ return Infinity;
66
+ const l = args.limit;
67
+ if (typeof l !== 'number' || !Number.isFinite(l) || l <= 0 || l >= DEFAULT_READ_LIMIT) {
68
+ return Infinity;
69
+ }
70
+ return offset + Math.floor(l) - 1;
71
+ }
55
72
  export class LoopDetector {
56
73
  window;
57
74
  threshold;
@@ -67,7 +84,8 @@ export class LoopDetector {
67
84
  /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
68
85
  record(call) {
69
86
  const key = `${call.name}\x00${stableStringify(call.args)}`;
70
- this.buf.push({ key, path: primaryPath(call.args), offset: readOffset(call.args) });
87
+ const offset = readOffset(call.args);
88
+ this.buf.push({ key, path: primaryPath(call.args), offset, end: readEnd(call.args, offset) });
71
89
  if (this.buf.length > this.window)
72
90
  this.buf.shift();
73
91
  // 1. Exact-match loop: identical (name, args) repeated past threshold.
@@ -91,20 +109,26 @@ export class LoopDetector {
91
109
  return null;
92
110
  }
93
111
  /**
94
- * Count same-path calls in the window that are "revisits" — accesses whose
95
- * offset does not advance past the furthest offset already seen for that
96
- * path. Linear paging (strictly increasing offsets) yields zero revisits and
97
- * never trips; repeated whole-file re-reads (offset 0 each time), backward
98
- * jumps, and path-targeting greps (offset 0) accumulate.
112
+ * Count same-path calls in the window that are "revisits" — accesses that end
113
+ * no further into the file than the furthest line already covered for that
114
+ * path. Paging yields zero revisits and never trips; repeated whole-file
115
+ * re-reads, backward jumps and path-targeting greps accumulate.
116
+ *
117
+ * The comparison is on the RANGE, not the offset. Offset alone scored a page
118
+ * that re-asks from the same start with a bigger limit — `{offset:80,
119
+ * limit:400}` after `{offset:80, limit:300}`, real reads from a measured
120
+ * decompose run — as a revisit, even though it covers 100 lines the child had
121
+ * never seen. That is the same mistake SingleReadGuard made by keying on the
122
+ * path alone, and it is corrected the same way.
99
123
  */
100
124
  countRevisits(path) {
101
- let maxOffset = -1;
125
+ let maxEnd = -1;
102
126
  let revisits = 0;
103
127
  for (const e of this.buf) {
104
128
  if (e.path !== path)
105
129
  continue;
106
- if (e.offset > maxOffset)
107
- maxOffset = e.offset; // progress: new ground
130
+ if (e.end > maxEnd)
131
+ maxEnd = e.end; // progress: new ground
108
132
  else
109
133
  revisits++; // revisit: already-covered ground
110
134
  }