pi-crew 0.9.66 → 0.9.68

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 (52) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -3
  3. package/agents/executor.md +1 -1
  4. package/agents/test-engineer.md +1 -1
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +4451 -4092
  7. package/package.json +12 -8
  8. package/scripts/analyze-run.mjs +1333 -0
  9. package/scripts/resource-sampler.mjs +482 -0
  10. package/skills/real-test-pi-crew/SKILL.md +10 -9
  11. package/src/agents/agent-config.ts +35 -14
  12. package/src/config/role-tools.ts +2 -2
  13. package/src/extension/knowledge-injection.ts +17 -0
  14. package/src/extension/pi-api.ts +0 -16
  15. package/src/extension/team-tool/api/agent-control.ts +358 -0
  16. package/src/extension/team-tool/api/handler-context.ts +57 -0
  17. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  18. package/src/extension/team-tool/api/mailbox.ts +242 -0
  19. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  20. package/src/extension/team-tool/api/read.ts +443 -0
  21. package/src/extension/team-tool/api/task-claims.ts +207 -0
  22. package/src/extension/team-tool/api.ts +56 -1301
  23. package/src/extension/team-tool/cancel.ts +15 -4
  24. package/src/extension/team-tool/goal-wrap.ts +2 -2
  25. package/src/extension/team-tool/goal.ts +4 -4
  26. package/src/extension/team-tool/lifecycle-actions.ts +5 -5
  27. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  28. package/src/extension/team-tool/respond.ts +11 -3
  29. package/src/extension/team-tool/run-intent.ts +357 -0
  30. package/src/extension/team-tool/run.ts +8 -285
  31. package/src/extension/team-tool/status.ts +56 -21
  32. package/src/extension/team-tool.ts +10 -8
  33. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  34. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  35. package/src/runtime/child-pi/child-pi.ts +22 -144
  36. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  37. package/src/runtime/crew-agent-records.ts +18 -1
  38. package/src/runtime/scratchpad/README.md +11 -0
  39. package/src/runtime/scratchpad/guest.ts +157 -7
  40. package/src/runtime/scratchpad/protocol.ts +13 -1
  41. package/src/runtime/scratchpad/transform.ts +206 -12
  42. package/src/runtime/supervisor-contact.ts +0 -16
  43. package/src/runtime/task-runner/child-executor.ts +1 -0
  44. package/src/runtime/task-runner/state-helpers.ts +9 -1
  45. package/src/runtime/team-runner.ts +39 -2
  46. package/src/state/contracts.ts +3 -0
  47. package/src/state/event-log/event-log.ts +19 -27
  48. package/src/state/gitignore-manager.ts +61 -7
  49. package/src/utils/glob-match.ts +29 -0
  50. package/types/dwf.d.ts +1 -1
  51. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -161
  52. package/src/types/new-api-types.ts +0 -35
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { AsyncLocalStorage } from "node:async_hooks";
29
+ import { execFile } from "node:child_process";
29
30
  import { writeSync } from "node:fs";
30
31
  import { createInterface } from "node:readline";
31
32
  import { format, inspect } from "node:util";
@@ -179,14 +180,107 @@ console.error = consoleErr;
179
180
  console.trace = consoleErr;
180
181
 
181
182
  // ── bootstrap bindings ───────────────────────────────────────────────────────
182
- // pi-rlm mounts rlm/tools/Bun handles here. The spike has no host bridge, so
183
- // nothing is installed — but the hook is kept (and re-run after restore) for
184
- // fidelity with the port plan: a later phase wires real bindings here.
183
+ // pi-rlm mounts rlm/tools/Bun handles here. Pattern 12 (shell guard): the
184
+ // guest-local `sh` helper narrows raw child_process — it refuses null/undefined
185
+ // arguments (the `rm -rf undefined` class bug) and returns a value, not a
186
+ // string, so cell 2 can reuse cell 1's result. Advisory guard only — cells run
187
+ // at full worker trust (no VM sandbox), so `sh` is a nudge, not a boundary.
185
188
 
186
189
  const INTERNAL_BINDINGS = new Map<string, unknown>();
187
190
 
191
+ /**
192
+ * Protected Node globals — must never be persistently shadowed by a cell.
193
+ *
194
+ * Because the scope proxy's `get` trap checks the namespace BEFORE globalThis,
195
+ * a cell writing `const process = 'poisoned'` (transformed to a namespace
196
+ * assignment) would otherwise poison EVERY later cell in the same engine
197
+ * (the namespace is a module-level singleton) AND survive snapshot→restore
198
+ * (a serializable shadow revives). Result: silent corruption — e.g.
199
+ * `typeof process.env` becomes 'undefined' with no error.
200
+ *
201
+ * We register the LIVE global for each protected name in INTERNAL_BINDINGS +
202
+ * the namespace, so (a) restore's re-install overwrites any revived shadow,
203
+ * and (b) each cell starts with the real global. Within-cell shadowing still
204
+ * works (the cell's own write wins for that cell only); it just cannot leak
205
+ * to other cells or across restarts. Verified: probe reproduced the bug
206
+ * pre-fix; see test/unit/runtime/scratchpad/guest-global-shadow.test.ts.
207
+ */
208
+ const PROTECTED_GLOBALS: ReadonlyArray<string> = [
209
+ "process",
210
+ "Buffer",
211
+ "console",
212
+ "setTimeout",
213
+ "clearTimeout",
214
+ "setInterval",
215
+ "clearInterval",
216
+ "setImmediate",
217
+ "clearImmediate",
218
+ "queueMicrotask",
219
+ "structuredClone",
220
+ "AbortController",
221
+ "globalThis",
222
+ ];
223
+
224
+ function resetProtectedGlobals(): void {
225
+ const g = globalThis as Record<string, unknown>;
226
+ for (const name of PROTECTED_GLOBALS) {
227
+ const live = g[name];
228
+ if (live === undefined) continue;
229
+ INTERNAL_BINDINGS.set(name, live);
230
+ namespace[name] = live;
231
+ }
232
+ }
233
+
234
+ /** Pattern-12 nullish guard: refuse undefined/null BEFORE spawning. */
235
+ function assertShellArgNotNullish(name: string, value: unknown): void {
236
+ if (value === undefined || value === null) {
237
+ throw new Error(
238
+ `sh: argument '${name}' is null/undefined — a missing variable would stringify to the literal 'undefined' in the command. Re-verify variables after a restore before using them in shell commands.`,
239
+ );
240
+ }
241
+ }
242
+
243
+ /**
244
+ * sh(cmd, args[]) — pattern-12 shell interpolation guard.
245
+ * Runs via execFile (shell:false, args as an array — no shell interpolation
246
+ * of the command string), refuses nullish args, and returns a value:
247
+ * `{ exitCode, stdout, stderr }` (pi-rlm "shell as value"). Non-zero exit does
248
+ * NOT throw — it is returned in `exitCode`.
249
+ */
250
+ function makeSh(): (cmd: string, args?: unknown[]) => Promise<{ exitCode: number; stdout: string; stderr: string }> {
251
+ const execFileAsync = (cmd: string, args: string[]) =>
252
+ new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve, reject) => {
253
+ execFile(cmd, args, { shell: false, encoding: "utf8" }, (error, stdout, stderr) => {
254
+ if (error) {
255
+ // error.code is the exit code for a non-zero exit; otherwise a real
256
+ // spawn failure (ENOENT etc.) — surface it as exitCode 127-like.
257
+ const exitCode = typeof error.code === "number" ? error.code : 127;
258
+ resolve({ exitCode, stdout: String(stdout), stderr: String(stderr || error.message) });
259
+ return;
260
+ }
261
+ resolve({ exitCode: 0, stdout: String(stdout), stderr: String(stderr) });
262
+ });
263
+ });
264
+ return async (cmd: string, args?: unknown[]) => {
265
+ assertShellArgNotNullish("cmd", cmd);
266
+ const stringArgs = (args ?? []).map((a, i) => {
267
+ assertShellArgNotNullish(`arg[${i}]`, a);
268
+ return String(a);
269
+ });
270
+ return execFileAsync(cmd, stringArgs);
271
+ };
272
+ }
273
+
188
274
  function installBootstrapBindings(): void {
189
- // Spike: no host bridge, no Bun — nothing to mount.
275
+ const sh = makeSh();
276
+ // Register the SAME function reference in INTERNAL_BINDINGS and the
277
+ // namespace so snapshotNamespace()'s identity skip (===) excludes it from
278
+ // serialization, and so cells can call `sh(...)` directly.
279
+ INTERNAL_BINDINGS.set("sh", sh);
280
+ namespace.sh = sh;
281
+ // Overwrite any protected-global shadow (e.g. a revived `process='x'` from
282
+ // restore) with the live global — fixes the global shadow poisoning bug.
283
+ resetProtectedGlobals();
190
284
  }
191
285
 
192
286
  installBootstrapBindings();
@@ -198,14 +292,69 @@ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) =>
198
292
 
199
293
  const liveCells = new Map<string, CellContext>();
200
294
 
295
+ // ── P6: remap transformed error-stack lines back to the cell's source ───────
296
+ // V8 reports a thrown line as `N = bodyLine + 2` (1-based) — the AsyncFunction
297
+ // wrapper adds 2 prefix lines (the anonymous function line + the `with (...)`
298
+ // line) before the cell body. `lineMap` (from transformCell) maps body lines
299
+ // that RECEIVED A REPLACEMENT back to the source line the model wrote. Lines
300
+ // between replacements (unreplaced spans — e.g. a bare `throw`) are identity:
301
+ // sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine), using the
302
+ // nearest replacement entry at or before the body line.
303
+ //
304
+ // SECURITY/robustness (P6 review finding): only lines that look like a real
305
+ // V8 frame (` at ... <anonymous>:N:C)`) are remapped. The `Error: <message>`
306
+ // line and any cell text containing `<anonymous>:N:C)` (e.g. an error message
307
+ // that echoes code) must be left byte-identical — a crafted message must never
308
+ // get its numbers rewritten.
309
+ function remapStackLines(stack: string, lineMap: { sourceLine: number; bodyLine: number }[]): string[] {
310
+ const lines = stack.split("\n");
311
+ const sorted = [...lineMap].sort((a, b) => a.bodyLine - b.bodyLine);
312
+ return lines.map((line) => {
313
+ // Real V8 frame only: ` at <anything> <anonymous>:N:C)` — anchored to
314
+ // the leading frame prefix and a parenthesized trailing position. The
315
+ // message line (`Error: ...`) does not start with ` at `, so it is
316
+ // never matched; a crafted message containing `<anonymous>:N:C)` is
317
+ // likewise safe because it lacks the frame prefix.
318
+ const m = line.match(/^\s+at .*<anonymous>:(\d+):(\d+)\)/);
319
+ if (!m) return line;
320
+ const reported = Number(m[1]);
321
+ const bodyLine = reported - 2; // wrapper prefix offset (verified by probe)
322
+ // nearest replacement entry at or before bodyLine (lower bound)
323
+ let lo = 0;
324
+ let hi = sorted.length - 1;
325
+ let entry: { sourceLine: number; bodyLine: number } | undefined;
326
+ while (lo <= hi) {
327
+ const mid = (lo + hi) >> 1;
328
+ if (sorted[mid].bodyLine <= bodyLine) {
329
+ entry = sorted[mid];
330
+ lo = mid + 1;
331
+ } else {
332
+ hi = mid - 1;
333
+ }
334
+ }
335
+ if (!entry) return line; // before any replacement — leave as-is
336
+ const sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine);
337
+ const col = m[2];
338
+ return line.replace(/<anonymous>:\d+:\d+/, `<anonymous>:${sourceLine}:${col}`);
339
+ });
340
+ }
341
+
201
342
  async function runCell(cellId: string, code: string): Promise<void> {
202
343
  const ctx = makeCellContext(cellId);
203
344
  activeCell = ctx;
204
345
  liveCells.set(cellId, ctx);
346
+ // Reset protected globals so a prior cell's shadow (e.g. `const process='x'`)
347
+ // cannot poison this cell — shadowing stays local to the cell that did it
348
+ // (the namespace is a module-level singleton shared across cells).
349
+ resetProtectedGlobals();
205
350
 
206
351
  let done: GuestToHostMessage;
352
+ // P6: lineMap from transformCell is needed in BOTH try (to build the
353
+ // wrapper) and catch (to remap the error stack), so hoist it here.
354
+ let lineMap: { sourceLine: number; bodyLine: number }[] = [];
207
355
  try {
208
- const { body } = transformCell(code, { ctxName: CTX_NAME });
356
+ const { body, lineMap: lm } = transformCell(code, { ctxName: CTX_NAME });
357
+ lineMap = lm;
209
358
  // Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
210
359
  // sloppy, even inside a strict ESM strip-types module); async for
211
360
  // top-level await.
@@ -223,7 +372,7 @@ async function runCell(cellId: string, code: string): Promise<void> {
223
372
  type: "done",
224
373
  cellId,
225
374
  status: ctx.aborted ? "aborted" : "error",
226
- error: { name: err.name, message: err.message, stack: (err.stack ?? "").split("\n") },
375
+ error: { name: err.name, message: err.message, stack: remapStackLines(err.stack ?? "", lineMap) },
227
376
  };
228
377
  } finally {
229
378
  if (activeCell === ctx) activeCell = undefined;
@@ -294,7 +443,8 @@ function restoreNamespace(vars: Record<string, string>): {
294
443
  }
295
444
  }
296
445
  // Bootstrap runs after restore: live handles would overwrite anything
297
- // revived. The spike installs nothing, but the hook is kept for fidelity.
446
+ // revived. `sh` is re-installed so a revived stale value cannot shadow the
447
+ // live handle (I6).
298
448
  installBootstrapBindings();
299
449
  return { restored, failed };
300
450
  }
@@ -45,7 +45,19 @@ export interface GuestToHost {
45
45
  error?: { name: string; message: string; stack: string[] };
46
46
  };
47
47
  pong: { type: "pong"; id: string };
48
- host_request: { type: "host_request"; id: string; requestType: string; payload: Record<string, unknown> };
48
+ host_request: {
49
+ // RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
50
+ // J2): guest-side cells would request host services (tools.read,
51
+ // tools.grep) so data enters the namespace WITHOUT crossing the
52
+ // transcript — pi-rlm's core token-saving value proposition. Declared
53
+ // here so the protocol type is stable, but NOT YET WIRED: engine.ts and
54
+ // guest.ts have no host_request dispatcher/handler. Blocked on scratchpad
55
+ // adoption > 0 (do not add a host execution surface nobody calls).
56
+ type: "host_request";
57
+ id: string;
58
+ requestType: string;
59
+ payload: Record<string, unknown>;
60
+ };
49
61
  snapshot_result: {
50
62
  type: "snapshot_result";
51
63
  id: string;
@@ -31,6 +31,15 @@ export interface TransformedCell {
31
31
  body: string;
32
32
  /** Top-level names this cell binds into the namespace. */
33
33
  declaredNames: string[];
34
+ /**
35
+ * P6: line-based position map — for each body line (1-based), the
36
+ * corresponding SOURCE line (1-based) the model wrote. Used to remap the
37
+ * V8-reported error line (relative to `body`) back to the cell's original
38
+ * line so stack traces point at the source the user wrote, not the
39
+ * transformed body. Built WITHOUT a source-map dependency by tracking
40
+ * replacement newline shifts against the source (acorn `locations`).
41
+ */
42
+ lineMap: { sourceLine: number; bodyLine: number }[];
34
43
  }
35
44
 
36
45
  export interface TransformOptions {
@@ -41,8 +50,16 @@ export interface TransformOptions {
41
50
  // esbuild's transform strips types but never drops side-effect-free trailing
42
51
  // expressions (no DCE in transform mode) — the exact thing we capture as the
43
52
  // cell result.
44
- function stripTypes(code: string): string {
45
- return transformSync(code, { loader: "ts" }).code;
53
+ // P6: `sourcemap: 'inline'` so we can map the stripped `js` lines back to the
54
+ // `rewritten` (pre-strip) lines — esbuild does NOT preserve line numbers
55
+ // across type stripping (probe: 7 input lines → 5 output lines). The inline
56
+ // sourcemap is stripped from the output (it would otherwise leak into the cell
57
+ // body as a trailing comment); we only keep the decoded line map.
58
+ function stripTypes(code: string): { code: string; lineMap: number[] } {
59
+ const out = transformSync(code, { loader: "ts", sourcemap: "inline" });
60
+ const lineMap = esbuildLineMap(out.code);
61
+ const codeWithoutMap = out.code.replace(/\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/, "");
62
+ return { code: codeWithoutMap, lineMap };
46
63
  }
47
64
 
48
65
  // ── top-level import extraction ──────────────────────────────────────────────
@@ -271,14 +288,147 @@ function variableReplacement(decl: VariableDeclaration, source: string): string
271
288
  return statements.join(" ");
272
289
  }
273
290
 
291
+ // ── P6: line-based position map ─────────────────────────────────────────────
292
+ // V8 reports a cell error's line as 1-based relative to the TRANSFORMED `body`.
293
+ // The model wrote `code`; between them sit 3 transforms:
294
+ // 1. import pre-rewrite (acorn) — collapses multi-line imports to 1 line
295
+ // 2. esbuild strip-types — collapses type annotations/signatures
296
+ // 3. declaration → assignment + trailing-expr capture — may add/remove lines
297
+ // We build a map { sourceLine (1-based, in `code`) → bodyLine (1-based) } by
298
+ // tracking line deltas across each stage with acorn `locations` + newline
299
+ // counts. No source-map dependency (probe: esbuild strip-types does NOT
300
+ // preserve lines — 7→5 — so a naive single-layer sourcemap would be wrong).
301
+
302
+ function countNewlines(s: string): number {
303
+ let n = 0;
304
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
305
+ return n;
306
+ }
307
+
308
+ /** 1-based line of `pos` (char offset) in `text`. */
309
+ function lineAt(text: string, pos: number): number {
310
+ let line = 1;
311
+ for (let i = 0; i < pos && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
312
+ return line;
313
+ }
314
+
315
+ // ── P6: esbuild inline sourcemap → line map (js → rewritten) ───────────────
316
+ // esbuild `sourcemap: 'inline'` emits a base64 VLQ sourcemap whose `mappings`
317
+ // field encodes, per generated line, segments mapping back to source
318
+ // positions. We only need LINE fidelity (column is overkill for the bug), so
319
+ // we decode each generated line's first segment's source line. VLQ is the
320
+ // standard base64-variable-length encoding (no dependency needed).
321
+
322
+ const BASE64: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
323
+ const VLQ: Map<string, number> = new Map([...BASE64].map((c, i) => [c, i]));
324
+
325
+ function decodeVlqSegment(segment: string): number[] {
326
+ const values: number[] = [];
327
+ let shift = 0;
328
+ let value = 0;
329
+ for (let i = 0; i < segment.length; i++) {
330
+ const digit = VLQ.get(segment[i]);
331
+ if (digit === undefined) break;
332
+ value |= (digit & 31) << shift;
333
+ if (digit & 32) {
334
+ shift += 5;
335
+ } else {
336
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
337
+ value = 0;
338
+ shift = 0;
339
+ }
340
+ }
341
+ return values;
342
+ }
343
+
344
+ /**
345
+ * Decode an esbuild inline sourcemap's `mappings` into an array where
346
+ * `lineMapGenerated[generatedLine-1]` = the source LINE (1-based) that
347
+ * generated line originates from (first segment per generated line; falls back
348
+ * to the previous mapped line for continuation lines).
349
+ */
350
+ function esbuildLineMap(codeWithInlineMap: string): number[] {
351
+ const m = codeWithInlineMap.match(/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/);
352
+ if (!m) return [];
353
+ let sm: { mappings?: string };
354
+ try {
355
+ sm = JSON.parse(Buffer.from(m[1], "base64").toString("utf8")) as { mappings?: string };
356
+ } catch {
357
+ return [];
358
+ }
359
+ if (!sm.mappings) return [];
360
+ const result: number[] = [];
361
+ let srcLine = 1;
362
+ let lastSrcLine = 1;
363
+ for (const genLine of sm.mappings.split(";")) {
364
+ const segs = genLine.split(",").filter(Boolean);
365
+ if (segs.length > 0) {
366
+ const vals = decodeVlqSegment(segs[0]);
367
+ // values: [genColDelta, srcIdxDelta, srcLineDelta, srcColDelta, ...]
368
+ if (vals.length >= 3) {
369
+ srcLine += vals[2];
370
+ lastSrcLine = srcLine;
371
+ }
372
+ }
373
+ result.push(lastSrcLine);
374
+ }
375
+ return result;
376
+ }
377
+
378
+ /**
379
+ * Build a `code`-line → `js`-line map across the import pre-rewrite.
380
+ * For each import replacement, its `code` span [codeStart, codeEnd] collapses
381
+ * to `js` lines [jsStart, jsEnd] (usually 1). Lines outside any replacement
382
+ * are identity-shifted by the cumulative delta.
383
+ */
384
+ function buildImportLineMap(
385
+ code: string,
386
+ rewritten: string,
387
+ importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[],
388
+ ): (src: number) => number {
389
+ if (importSpans.length === 0) return (src) => src;
390
+ // P6 (final): map a `rewritten` (post-import) LINE to the original `code`
391
+ // line. `rewritten` = code with each import statement replaced by its
392
+ // (usually 1-line) dynamic-import fragment. We walk both strings tracking
393
+ // newline counts: between imports the line numbers advance identically;
394
+ // inside an import span, all its code lines map to the replacement's first
395
+ // rewritten line (collapsed).
396
+ const rewrittenLines: string[] = rewritten.split("\n");
397
+ const codeLines: string[] = code.split("\n");
398
+ const map = new Map<number, number>(); // rewrittenLine → codeLine (1-based)
399
+ let codeIdx = 1;
400
+ let rewIdx = 1;
401
+ for (const span of importSpans) {
402
+ // Advance both to the span start, emitting identity mapping for the
403
+ // untouched lines between the previous span end and this span start.
404
+ const spanStartCodeLine = lineAt(code, span.codeStart);
405
+ const spanStartRewLine = lineAt(rewritten, span.jsStart);
406
+ for (; codeIdx < spanStartCodeLine && rewIdx < spanStartRewLine; codeIdx++, rewIdx++) {
407
+ map.set(rewIdx, codeIdx);
408
+ }
409
+ // Lines inside the import span (in code) collapse to the replacement's
410
+ // single starting rewritten line.
411
+ const spanEndCodeLine = lineAt(code, span.codeEnd);
412
+ for (; codeIdx <= spanEndCodeLine; codeIdx++) {
413
+ map.set(spanStartRewLine, spanStartCodeLine);
414
+ }
415
+ rewIdx = spanStartRewLine + 1;
416
+ }
417
+ // Tail: identity from wherever we stopped.
418
+ for (; rewIdx <= rewrittenLines.length; rewIdx++, codeIdx++) {
419
+ map.set(rewIdx, Math.min(codeIdx, codeLines.length));
420
+ }
421
+ return (rewLine: number) => {
422
+ const mapped = map.get(rewLine);
423
+ return mapped ?? Math.min(rewLine, codeLines.length);
424
+ };
425
+ }
426
+
274
427
  export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
275
428
  const ctxName = options.ctxName ?? "__ctx";
276
-
277
- // Top-level imports are rewritten into awaited dynamic imports BEFORE type
278
- // stripping: esbuild elides unused imports, and the reference behaviour
279
- // binds every imported name into the namespace so it persists across cells.
280
429
  let importDeclared: string[] = [];
281
430
  let rewritten = code;
431
+ const importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[] = [];
282
432
  try {
283
433
  const tokens = lexTopLevel(code);
284
434
  const imports = findImportStatements(tokens);
@@ -286,11 +436,21 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
286
436
  importDeclared = [];
287
437
  const pieces: string[] = [];
288
438
  let cursor = 0;
439
+ let jsCursor = 0;
289
440
  for (const stmt of imports) {
290
- pieces.push(rewritten.slice(cursor, stmt.start));
441
+ const pre = rewritten.slice(cursor, stmt.start);
442
+ pieces.push(pre);
443
+ jsCursor += pre.length;
291
444
  const rewrite = rewriteImport(code, stmt, tokens);
292
445
  importDeclared.push(...rewrite.declaredNames);
293
446
  pieces.push(rewrite.replacement);
447
+ importSpans.push({
448
+ codeStart: stmt.start,
449
+ codeEnd: stmt.end,
450
+ jsStart: jsCursor,
451
+ jsEnd: jsCursor + rewrite.replacement.length - 1,
452
+ });
453
+ jsCursor += rewrite.replacement.length;
294
454
  cursor = stmt.end;
295
455
  }
296
456
  pieces.push(rewritten.slice(cursor));
@@ -301,8 +461,15 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
301
461
  // the pre-rewrite; type stripping below surfaces the error.
302
462
  }
303
463
 
304
- const js = stripTypes(rewritten);
305
- const program: Program = parse(js, { ecmaVersion: "latest", sourceType: "module", allowAwaitOutsideFunction: true });
464
+ const { code: js, lineMap: esbLineMap } = stripTypes(rewritten);
465
+ const rewToCode = buildImportLineMap(code, rewritten, importSpans);
466
+ const program: Program = parse(js, {
467
+ ecmaVersion: "latest",
468
+ sourceType: "module",
469
+ allowAwaitOutsideFunction: true,
470
+ // P6: locations needed to know each top-level node's source lines.
471
+ locations: true,
472
+ });
306
473
 
307
474
  const declaredNames: string[] = [];
308
475
  const replacements: { start: number; end: number; text: string }[] = [];
@@ -353,11 +520,38 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
353
520
  replacements.sort((a, b) => a.start - b.start);
354
521
  let body = "";
355
522
  let cursor = 0;
523
+ // P6: build bodyLine → sourceLine map while splicing. `body` is what V8
524
+ // reports error lines against; `js` is the esbuild-stripped source; the map
525
+ // composes: bodyLine → jsLine (via newline deltas) → rewrittenLine (via
526
+ // esbuild inline sourcemap) → codeLine (via import pre-rewrite).
527
+ const lineMap: { sourceLine: number; bodyLine: number }[] = [];
528
+ let bodyLine = 1;
529
+ let jsLine = 1;
530
+ // jsLineOfBodyStart maps the js line at each body boundary; we track the
531
+ // cumulative js-line delta as body lines are emitted.
356
532
  for (const replacement of replacements) {
357
- body += js.slice(cursor, replacement.start) + replacement.text;
533
+ const pre = js.slice(cursor, replacement.start);
534
+ body += pre;
535
+ bodyLine += countNewlines(pre);
536
+ jsLine += countNewlines(pre);
537
+ // The replacement's first body line maps from the js line at its start.
538
+ const repJsStartLine = lineAt(js, replacement.start);
539
+ // Source line = compose: js → rewritten (esbuild) → code (imports).
540
+ const rewLine = esbLineMap.length > 0 ? esbLineMap[Math.min(repJsStartLine - 1, esbLineMap.length - 1)] : repJsStartLine;
541
+ const srcLine = rewToCode(rewLine);
542
+ lineMap.push({ sourceLine: srcLine, bodyLine: bodyLine });
543
+ body += replacement.text;
544
+ bodyLine += countNewlines(replacement.text);
545
+ // js line after the replacement: replacements are in js coordinate, so
546
+ // jsLine advances by the replacement's text newlines too.
547
+ jsLine += countNewlines(replacement.text);
358
548
  cursor = replacement.end;
359
549
  }
360
- body += js.slice(cursor);
550
+ const tail = js.slice(cursor);
551
+ body += tail;
552
+ bodyLine += countNewlines(tail);
553
+ // Any body line not covered by a replacement maps identity.
554
+ lineMap.sort((a, b) => a.bodyLine - b.bodyLine);
361
555
 
362
- return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
556
+ return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])], lineMap };
363
557
  }
@@ -61,19 +61,3 @@ export function supervisorContactFromEvent(event: unknown): Omit<SupervisorConta
61
61
  : undefined,
62
62
  };
63
63
  }
64
-
65
- /**
66
- * @deprecated The compact pipeline now passes `supervisor_contact` events through
67
- * to `onJsonEvent` with their full payload (P2-25 fix). Prefer
68
- * `supervisorContactFromEvent` on the parsed event. Kept for backward compat.
69
- */
70
- export function parseSupervisorContactFromLine(line: string): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
71
- if (!line.trim()) return undefined;
72
- let parsed: unknown;
73
- try {
74
- parsed = JSON.parse(line);
75
- } catch {
76
- return undefined;
77
- }
78
- return supervisorContactFromEvent(parsed);
79
- }
@@ -544,6 +544,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
544
544
  runId: manifest.runId,
545
545
  agentId: task.id,
546
546
  artifactsRoot: manifest.artifactsRoot,
547
+ eventsPath: manifest.eventsPath, // I5: scratchpad metric events from the worker
547
548
  attempt: i,
548
549
  steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
549
550
  onSpawn: (pid) => {
@@ -41,7 +41,15 @@ export function persistSingleTaskUpdate(
41
41
  checkpointPhase?: TaskCheckpointState["phase"],
42
42
  skipCoalesce: boolean = false,
43
43
  ): TeamTaskState[] {
44
- const MAX_CAS_ATTEMPTS = 100;
44
+ // H5 (2026-08-10): lowered from 100 → 10. Each retry does
45
+ // flushPendingAtomicWrites (global) + loadRunManifestById (stat + parse)
46
+ // + statSync, ~5ms each. The first attempt uses fallbackTasks directly
47
+ // (no disk read); retries only fire under real contention from best-effort
48
+ // writers that don't hold the run lock (async-notifier, crash-recovery).
49
+ // If 10 retries cannot converge, the system is in a pathological state
50
+ // where 100 would not help either — the explicit error below surfaces it
51
+ // instead of blocking the event loop for 500ms.
52
+ const MAX_CAS_ATTEMPTS = 10;
45
53
  let baseMtime = 0;
46
54
  try {
47
55
  baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
@@ -17,6 +17,7 @@ import {
17
17
  appendEventBuffered,
18
18
  appendEventFireAndForget,
19
19
  flushEventLogBuffer,
20
+ readEvents,
20
21
  } from "../state/event-log/event-log.ts";
21
22
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
22
23
  import { HealthStore } from "../state/stores/health-store.ts";
@@ -415,6 +416,27 @@ function runEffectivenessLines(
415
416
  );
416
417
  }
417
418
 
419
+ // I5 (plan): surface scratchpad adoption in the run summary — but ONLY when
420
+ // non-zero, so the 3 armed roles that never call it add no noise. Counts the
421
+ // metric events the workers appended to the run events log (fire-and-forget
422
+ // scratchpad.cell / scratchpad.restored). Silent when the feature is unused or
423
+ // the events path is missing (no throw).
424
+ function scratchpadSummaryLines(manifest: TeamRunManifest): string[] {
425
+ if (!manifest.eventsPath) return [];
426
+ const events = readEvents(manifest.eventsPath);
427
+ const cells = events.filter((e) => e.type === "scratchpad.cell");
428
+ const restores = events.filter((e) => e.type === "scratchpad.restored");
429
+ if (cells.length === 0 && restores.length === 0) return [];
430
+ return [
431
+ `## Scratchpad (RLM adoption) — I5 metric`,
432
+ `- cells executed: ${cells.length}`,
433
+ `- snapshot restores: ${restores.length}`,
434
+ ...(cells.length > 0
435
+ ? [`- total cell time: ${Math.round(cells.reduce((s, e) => s + ((e.data?.durationMs as number) ?? 0), 0))} ms`]
436
+ : []),
437
+ ];
438
+ }
439
+
418
440
  // P6 (perf): Cache the last-rendered progress content so we can skip the
419
441
  // artifact write + redaction + atomic write + size/hash read when nothing
420
442
  // material changed (rare between batches, but happens between idle heartbeats).
@@ -964,8 +986,22 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
964
986
  );
965
987
  manifest = updateRunStatus(manifest, "failed", `Unhandled error in team runner: ${message}`);
966
988
  await saveRunManifestAsync(manifest);
967
- } catch {
968
- // Best-effort — state write may also fail
989
+ } catch (error) {
990
+ // H8 (2026-08-10): surface the silent failure. This is the
991
+ // unhandled-error recovery branch — if the state save ALSO fails
992
+ // (disk full, run-dir gone, permissions), the manifest stays in
993
+ // its pre-failure state (possibly still "running") and crash
994
+ // recovery will treat it as stale. Previously the failure was
995
+ // swallowed with no signal; now it lands in the internal-error
996
+ // channel so operators can see when recovery itself failed.
997
+ // We do NOT re-throw — the run must still be reported as failed
998
+ // to the caller (rejectRunPromise below) regardless of state-save
999
+ // outcome.
1000
+ logInternalError(
1001
+ "team-runner.recovery-save-failed",
1002
+ error instanceof Error ? error : new Error(String(error)),
1003
+ `runId=${manifest.runId} — state may be inconsistent; crash-recovery will reconcile on next launch`,
1004
+ );
969
1005
  }
970
1006
  const result = { manifest, tasks };
971
1007
  rejectRunPromise(manifest.runId, error instanceof Error ? error : new Error(message));
@@ -2260,6 +2296,7 @@ async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunMa
2260
2296
  "## Policy decisions",
2261
2297
  ...(manifest.policyDecisions?.length ? summarizePolicyDecisions(manifest.policyDecisions) : ["- (none)"]),
2262
2298
  "",
2299
+ ...scratchpadSummaryLines(manifest),
2263
2300
  ].join("\n"),
2264
2301
  });
2265
2302
  // Build the complete manifest BEFORE acquiring the lock so the artifacts array
@@ -114,6 +114,9 @@ export const TEAM_EVENT_TYPES = [
114
114
  "dwf.failed",
115
115
  "dwf.trust_denied", // F-01: project .dwf.ts denied without PI_CREW_TRUST_PROJECT_DWF
116
116
  "dwf.log",
117
+ // RLM/scratchpad adoption metrics (plan I5)
118
+ "scratchpad.cell",
119
+ "scratchpad.restored",
117
120
  ] as const;
118
121
  export type TeamEventType = (typeof TEAM_EVENT_TYPES)[number];
119
122