pi-crew 0.11.57 → 0.11.59

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.
@@ -46,6 +46,14 @@ export class HeartbeatWatcher {
46
46
  private consecutiveDead = new Map<string, number>();
47
47
  private lastSeen = new Map<string, number>(); // key → last time it was active
48
48
  private lastDeadletterTriggerAt = new Map<string, number>(); // 3.6 cooldown gate
49
+ /**
50
+ * Run-scoped metric label GC (FIX 2, 2026-11): runIds whose label values are
51
+ * still live — observed running in a previous tick and not yet released.
52
+ * Per-run label values are live only while the run is active; durable
53
+ * per-run detail lives in the run's own events.jsonl + health snapshot.
54
+ * See the release loop at the end of tickUnsafe().
55
+ */
56
+ private activeRunIds = new Set<string>();
49
57
  /** Max age (ms) to retain a stale key before garbage-collecting it. */
50
58
  private readonly maxKeyAgeMs = 600_000; // 10 minutes
51
59
  private readonly opts: HeartbeatWatcherOptions;
@@ -83,6 +91,8 @@ export class HeartbeatWatcher {
83
91
  const thresholds = this.opts.thresholds ?? DEFAULT_GRADIENT_THRESHOLDS;
84
92
  const tickThreshold = this.opts.deadletterTickThreshold ?? 3;
85
93
  const activeKeys = new Set<string>();
94
+ // runIds confirmed running on disk this tick (see activeRunIds above).
95
+ const runningRunIds = new Set<string>();
86
96
 
87
97
  for (const run of this.opts.manifestCache.list(50)) {
88
98
  if (run.status !== "running") continue;
@@ -96,6 +106,8 @@ export class HeartbeatWatcher {
96
106
  // This closes the false-positive alert loop when a run completes but the
97
107
  // manifestCache.list(50) still serves a pre-completion snapshot.
98
108
  if (loaded.manifest.status !== "running") continue;
109
+ // Confirmed running on disk → its run-scoped label values stay live.
110
+ runningRunIds.add(run.runId);
99
111
  for (const task of loaded.tasks) {
100
112
  if (task.status !== "running") continue;
101
113
  // Guest-child tasks (delegate subagents, agent === "delegate") have NO
@@ -244,6 +256,42 @@ export class HeartbeatWatcher {
244
256
  }
245
257
  }
246
258
 
259
+ // Run-scoped metric label GC. `crew.heartbeat.staleness_ms`
260
+ // ({runId,taskId}) and `crew.heartbeat.level_total` ({runId,level}) are the
261
+ // only run-scoped series; without this the registry's label Maps grow
262
+ // unbounded (the MAX_LABEL_COMBINATIONS=10_000 FIFO cap is never reached at
263
+ // realistic volumes — measured: 101 label values/day across 33 runs, the
264
+ // heartbeat pair = 58% of a 26.9 KB metrics snapshot). Release a run's
265
+ // label values once it stops being active: durable per-run detail lives in
266
+ // the run's own events.jsonl + health snapshot — the same tradeoff OBS-1
267
+ // already applied to event metrics (src/observability/event-to-metric.ts:
268
+ // "Using runId/taskId as METRIC labels would blow up cardinality").
269
+ // Re-check the manifest cache first: a run may have fallen out of the
270
+ // top-50 scan window while still active, and a BLOCKED run (parked on
271
+ // `ask`, resumable — blocked is terminal-for-UI but never released) must
272
+ // keep its labels. Only a run that is neither running nor blocked, or that
273
+ // has vanished from the cache entirely, is released. Still-active runs stay
274
+ // in the tracking set so their release fires once they truly stop.
275
+ //
276
+ // Robustness: this pass must NEVER abort the tick — the stale-key cleanup
277
+ // below is load-bearing (it drops the deadletter cooldown marker, M-8) — so
278
+ // it is guarded and tolerates caches/registries that omit the reader
279
+ // (partial test seams).
280
+ try {
281
+ const nextActiveRunIds = new Set(runningRunIds);
282
+ for (const runId of this.activeRunIds) {
283
+ if (runningRunIds.has(runId)) continue;
284
+ if (this.isRunStillActive(runId)) {
285
+ nextActiveRunIds.add(runId);
286
+ continue;
287
+ }
288
+ this.opts.registry.releaseLabels?.((labels) => labels.runId === runId);
289
+ }
290
+ this.activeRunIds = nextActiveRunIds;
291
+ } catch (error) {
292
+ logInternalError("heartbeat-watcher.label-gc", error);
293
+ }
294
+
247
295
  // Cleanup: drop keys that were NOT in this tick's active set AND
248
296
  // haven't been seen for > maxKeyAgeMs. This covers runs that
249
297
  // completed or fell out of the manifest cache's top-50 window.
@@ -262,6 +310,31 @@ export class HeartbeatWatcher {
262
310
  }
263
311
  }
264
312
 
313
+ /**
314
+ * Is the run still active per the manifest cache?
315
+ *
316
+ * `true` for "running" (it may simply have fallen out of the top-50 scan
317
+ * window) and for "blocked" (parked on `ask`, resumable — blocked is
318
+ * terminal-for-UI but must keep its labels). `false` when the run is gone or
319
+ * carries a non-active status — that is the release case.
320
+ *
321
+ * A cache that cannot answer (missing `get`, or a throw) resolves to `true`
322
+ * (keep): a wrongly released series belonging to a still-active run is
323
+ * re-added on the next tick, but a series kept for a vanished run is only a
324
+ * bounded leak — and the definitive release happens on the run-prune path.
325
+ */
326
+ private isRunStillActive(runId: string): boolean {
327
+ const getStatus = this.opts.manifestCache.get;
328
+ if (typeof getStatus !== "function") return true;
329
+ try {
330
+ const manifest = getStatus.call(this.opts.manifestCache, runId);
331
+ return manifest?.status === "running" || manifest?.status === "blocked";
332
+ } catch (error) {
333
+ logInternalError("heartbeat-watcher.label-gc.status", error, `runId=${runId}`);
334
+ return true;
335
+ }
336
+ }
337
+
265
338
  dispose(): void {
266
339
  if (this.timer) clearTimeout(this.timer);
267
340
  this.timer = undefined;
@@ -270,5 +343,8 @@ export class HeartbeatWatcher {
270
343
  this.lastSeen.clear();
271
344
  // M-8: clear the deadletter cooldown map too (was leaked before).
272
345
  this.lastDeadletterTriggerAt.clear();
346
+ // Label-GC bookkeeping only (the registry outlives the watcher; labels
347
+ // are released on run inactivity, not on watcher teardown).
348
+ this.activeRunIds.clear();
273
349
  }
274
350
  }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { DEFAULT_PATHS, DEFAULT_SUBAGENT } from "../config/defaults.ts";
5
+ import { getCrewEnv } from "../config/env-vars.ts";
5
6
  import type { PiTeamsToolResult } from "../extension/tool-result.ts";
6
7
  import { atomicWriteFile } from "../state/atomic-write.ts";
7
8
  import { loadRunManifestById } from "../state/stores/state-store.ts";
@@ -139,6 +140,128 @@ export function removePersistedSubagentRecord(cwd: string, id: string): boolean
139
140
  */
140
141
  export const MAX_TERMINAL_SUBAGENT_RECORDS = 50;
141
142
 
143
+ /** FIX 3: statuses whose persisted record may be TTL-pruned. Anything else
144
+ * (queued | running | blocked) is NEVER deleted — `blocked` especially, since
145
+ * blockedSignalFromSubagentDir (terminal-status-wiring.ts) derives terminal
146
+ * blocked-state from blocked records on disk; deleting one breaks detection. */
147
+ const TTL_PRUNABLE_STATUSES = new Set<SubagentStatus>(["completed", "failed", "cancelled", "error", "stopped"]);
148
+
149
+ /** Default bound for the TTL prune scan: files examined per call (1,229 files
150
+ * had accumulated on the dev box at discovery; 2,000 leaves headroom while
151
+ * keeping session-start I/O bounded). */
152
+ export const PRUNE_SUBAGENT_RECORDS_MAX_FILES = 2000;
153
+
154
+ /**
155
+ * FIX 3: resolve the subagent record TTL (days) from
156
+ * PI_CREW_SUBAGENT_RECORD_TTL_DAYS (default 7; 0 disables). Invalid/negative
157
+ * values fall back to 7 with a logged warning — mirrors resolveAutoPruneKeep.
158
+ */
159
+ export function resolveSubagentRecordTtlDays(env: (name: string) => string | undefined = getCrewEnv): number {
160
+ const raw = env("PI_CREW_SUBAGENT_RECORD_TTL_DAYS")?.trim();
161
+ if (raw === undefined || raw === "") return 7;
162
+ const parsed = Number.parseInt(raw, 10);
163
+ if (!Number.isFinite(parsed) || parsed < 0) {
164
+ logInternalError(
165
+ "subagent-manager.ttl-invalid",
166
+ new Error(`PI_CREW_SUBAGENT_RECORD_TTL_DAYS=${JSON.stringify(raw)} is not a non-negative integer; using 7`),
167
+ );
168
+ return 7;
169
+ }
170
+ return parsed;
171
+ }
172
+
173
+ export interface PrunePersistedSubagentRecordsOptions {
174
+ /** TTL in days. Defaults to resolveSubagentRecordTtlDays() (env-driven, 7).
175
+ * 0 disables the prune entirely. */
176
+ ttlDays?: number;
177
+ /** Always keep this many most-recent records regardless of age/status.
178
+ * Default 50 (matches MAX_TERMINAL_SUBAGENT_RECORDS). */
179
+ keepRecent?: number;
180
+ /** Bound on files scanned per call. Default PRUNE_SUBAGENT_RECORDS_MAX_FILES. */
181
+ maxFiles?: number;
182
+ /** Clock injection for tests. */
183
+ now?: number;
184
+ }
185
+
186
+ /**
187
+ * FIX 3: TTL-prune persisted TERMINAL subagent records under
188
+ * `<projectCrewRoot(cwd)>/state/subagents/*.json`. removePersistedSubagentRecord
189
+ * only fires on explicit cancel/stop, so terminal records accumulated
190
+ * indefinitely (1,229 files / 6.7 MB over Jun–Oct).
191
+ *
192
+ * Safety contract:
193
+ * - ONLY terminal statuses (see TTL_PRUNABLE_STATUSES) are eligible.
194
+ * queued/running/blocked are never deleted (blocked drives
195
+ * blockedSignalFromSubagentDir terminal-status derivation).
196
+ * - The `keepRecent` most-recent records (by completedAt ?? startedAt) are
197
+ * retained regardless of age or status (recent history stays inspectable).
198
+ * - Malformed JSON is skipped (kept) — a corrupt file is never a prune target.
199
+ * - Bounded scan + best-effort: never throws; per-file failures are kept.
200
+ */
201
+ export function prunePersistedSubagentRecords(
202
+ cwd: string,
203
+ opts: PrunePersistedSubagentRecordsOptions = {},
204
+ ): { removed: string[]; kept: number } {
205
+ const ttlDays = opts.ttlDays ?? resolveSubagentRecordTtlDays();
206
+ if (ttlDays === 0) return { removed: [], kept: 0 }; // disabled — no scan, no delete
207
+ const removed: string[] = [];
208
+ let kept = 0;
209
+ try {
210
+ const dir = path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir);
211
+ let files: string[];
212
+ try {
213
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
214
+ } catch {
215
+ return { removed, kept }; // no dir / unreadable → nothing to do
216
+ }
217
+ const maxFiles = opts.maxFiles ?? PRUNE_SUBAGENT_RECORDS_MAX_FILES;
218
+ const now = opts.now ?? Date.now();
219
+ const ttlMs = ttlDays * 24 * 60 * 60 * 1000;
220
+ // Parse every (bounded) file; malformed → keep, tracked as kept so the
221
+ // caller can see the sweep saw it.
222
+ const parsed: Array<{ id: string; ageMs: number; terminal: boolean }> = [];
223
+ for (const file of files.slice(0, maxFiles)) {
224
+ try {
225
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as unknown;
226
+ const record = sanitizePersistedRecord(raw);
227
+ if (!record) {
228
+ kept++;
229
+ continue;
230
+ }
231
+ const ageMs = now - (record.completedAt ?? record.startedAt);
232
+ parsed.push({ id: record.id, ageMs, terminal: TTL_PRUNABLE_STATUSES.has(record.status) });
233
+ } catch {
234
+ kept++; // unreadable/parse error → skip (keep)
235
+ }
236
+ }
237
+ // Keep-N shield: the keepRecent most-recent records survive regardless
238
+ // of age or status.
239
+ const keepRecent = opts.keepRecent ?? MAX_TERMINAL_SUBAGENT_RECORDS;
240
+ const shielded = new Set(
241
+ parsed
242
+ .slice()
243
+ .sort((a, b) => a.ageMs - b.ageMs) // smallest age = most recent first
244
+ .slice(0, Math.max(0, keepRecent))
245
+ .map((entry) => entry.id),
246
+ );
247
+ for (const entry of parsed) {
248
+ if (!entry.terminal || entry.ageMs <= ttlMs || shielded.has(entry.id)) {
249
+ kept++;
250
+ continue;
251
+ }
252
+ try {
253
+ fs.unlinkSync(path.join(dir, `${entry.id}.json`));
254
+ removed.push(entry.id);
255
+ } catch {
256
+ kept++; // unlink failed → still on disk
257
+ }
258
+ }
259
+ } catch (error) {
260
+ logInternalError("subagent-manager.prune-persisted", error, `cwd=${cwd}`);
261
+ }
262
+ return { removed, kept };
263
+ }
264
+
142
265
  /**
143
266
  * Predicate: should this record's persisted file be deleted on terminal status?
144
267
  * Only abnormal terminations (cancelled / stopped / terminated-flag) are wiped.
@@ -384,6 +384,22 @@ export function parseHandoffFromOutput(output: string): ParsedHandoff {
384
384
  };
385
385
  }
386
386
 
387
- export function renderTaskPacket(packet: TaskPacket): string {
388
- return ["# Task Packet", "", "```json", JSON.stringify(packet, null, 2), "```", ""].join("\n");
387
+ /** R3 (adaptive-04, 2026-11-04): render options for the prompt seam.
388
+ * `omitObjective` swaps the packet's `objective` field for a POINTER line —
389
+ * the worker prompt already carries the task body as the canonical `Task:`
390
+ * narrative (prompt-builder.ts taskAndHandoff), and the JSON re-embed
391
+ * duplicated it verbatim (measured ~0.5-1K est-tok/worker, the "task body
392
+ * ×3" finding). Default (no options) keeps the full objective so non-prompt
393
+ * consumers and the direct unit contract (task-handoff-template.test.ts)
394
+ * are unchanged. The substitution is render-only: `TaskPacket` state and
395
+ * `validateTaskPacket` still see the real objective. */
396
+ export interface RenderTaskPacketOptions {
397
+ omitObjective?: boolean;
398
+ }
399
+
400
+ const OMITTED_OBJECTIVE_POINTER = "(see the 'Task:' section below — task body rendered once, not re-embedded here)";
401
+
402
+ export function renderTaskPacket(packet: TaskPacket, options?: RenderTaskPacketOptions): string {
403
+ const body = options?.omitObjective ? { ...packet, objective: OMITTED_OBJECTIVE_POINTER } : packet;
404
+ return ["# Task Packet", "", "```json", JSON.stringify(body, null, 2), "```", ""].join("\n");
389
405
  }
@@ -152,9 +152,10 @@ export async function prepareTaskExecutionContext(
152
152
  // caller provided one) into the dep-context collection — cache hits reuse
153
153
  // the closeout's reads byte-identically; undefined keeps uncached behavior.
154
154
  const dependencyContext = collectDependencyOutputContext(manifest, input.tasks, input.task, input.step, input.resultReadCache);
155
- // Handoff budget: caps the dynamic.dependencyContext layer at an est-token
156
- // (chars/4) budget (env PI_CREW_HANDOFF_BUDGET_TOKENS > runtime config >
157
- // default 1800). IMPORTANT: only the RENDER path is budgeted — an explicit
155
+ // Handoff budget: caps the dynamic.dependencyContext layer at an
156
+ // effective-token budget (CJK-aware countTokens, utils/token-counter.ts)
157
+ // (env PI_CREW_HANDOFF_BUDGET_TOKENS > runtime config > default 1800).
158
+ // IMPORTANT: only the RENDER path is budgeted — an explicit
158
159
  // input.dependencyContextText override (leader-provided text) passes through
159
160
  // UNCHANGED.
160
161
  const dependencyContextText =
@@ -8,7 +8,7 @@ import { buildMemoryBlock } from "../agent-memory.ts";
8
8
  import { permissionForRole } from "../role-permission.ts";
9
9
  import { scrubDependencyContextBody } from "../task-output-context.ts";
10
10
  import { HANDOFF_TEMPLATE, renderTaskPacket, sanitizeTaskText } from "../task-packet.ts";
11
- import { buildWorkspaceTree } from "../workspace-tree.ts";
11
+ import { buildTaskWorkspaceTree } from "../workspace-tree.ts";
12
12
  import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orchestrator.ts";
13
13
 
14
14
  /**
@@ -159,23 +159,40 @@ export function renderOutputSchemaBlock(outputSchema: TaskOutputSchema): string
159
159
  * Expensive async sub-results that compose the stable prefix.
160
160
  * These are cached per (cwd, step.task, runId) so parallel siblings
161
161
  * in the same batch reuse them instead of recomputing.
162
+ *
163
+ * R2 (adaptive-04): the workspace tree is NO LONGER part of these
164
+ * components — it is now per-task (subtree around the task's working
165
+ * files), so caching it under the shared keys below would leak one task's
166
+ * subtree into its siblings. renderTaskPrompt builds the tree itself via
167
+ * buildTaskWorkspaceTree, which has its own per-root TTL cache.
162
168
  */
163
169
  export interface StableComponents {
164
- treeBlock: string;
165
170
  suggestedFilesBlock: string;
171
+ /**
172
+ * R2.2 (adaptive-03): the retrieval-suggested file paths (relative to
173
+ * the task cwd) behind `suggestedFilesBlock`. Carried alongside the
174
+ * rendered block so the per-task workspace-tree scoping in
175
+ * renderTaskPrompt can fall back to them when a step declares no
176
+ * `reads` — no second retrieval pass. Both cache read sites must return
177
+ * the SAME array the fresh compute produced: the keys already cover the
178
+ * retrieval inputs (per-run key = cwd+step+runId with a run-fixed goal;
179
+ * the cross-run io key includes the goal), so no key change is needed.
180
+ */
181
+ suggestedFiles: string[];
166
182
  knowledgeFragment: string;
167
183
  }
168
184
 
169
185
  const stableComponentCache = new Map<string, StableComponents>();
170
186
 
171
- // P9 (perf): cross-run cache for the I/O-heavy sub-results (workspace tree +
172
- // retrieval + knowledge). The tree and retrieval don't depend on runId, but
173
- // they DO depend on the run GOAL: `runRetrievalCycle(step.task, goal, cwd)`
174
- // and `buildKnowledgeFragment(cwd, { goal, taskText, role })` both take the
175
- // goal as a query signal. A short-lived (TTL-bounded) cross-run cache lets
176
- // sequential runs in the same session amortize the cost: run #2 in cwd X with
177
- // the same step text AND the same goal gets a cache hit instead of redoing
178
- // `buildWorkspaceTree` (which walks the FS) and `runRetrievalCycle`. The TTL
187
+ // P9 (perf): cross-run cache for the I/O-heavy sub-results (retrieval +
188
+ // knowledge; R2 moved the workspace tree out to a per-task scope, so it no
189
+ // longer rides these goal-scoped caches). Retrieval and knowledge don't
190
+ // depend on runId, but they DO depend on the run GOAL:
191
+ // `runRetrievalCycle(step.task, goal, cwd)` and `buildKnowledgeFragment(cwd,
192
+ // { goal, taskText, role })` both take the goal as a query signal. A
193
+ // short-lived (TTL-bounded) cross-run cache lets sequential runs in the same
194
+ // session amortize the cost: run #2 in cwd X with the same step text AND the
195
+ // same goal gets a cache hit instead of redoing `runRetrievalCycle`. The TTL
179
196
  // bounds staleness in long-lived sessions (e.g., the workspace may have
180
197
  // changed between runs); a mtime check on the .git/HEAD or workspace marker
181
198
  // would be overkill for an already-bounded perf win. The full per-run cache
@@ -195,8 +212,9 @@ const stableComponentCache = new Map<string, StableComponents>();
195
212
  // (src/extension/knowledge-injection.ts:61-68); if it ever starts scoring, the
196
213
  // key must gain it too.
197
214
  interface CachedStableIO {
198
- treeBlock: string;
199
215
  suggestedFilesBlock: string;
216
+ /** R2.2: retrieval file paths ride the cross-run cache with the block. */
217
+ suggestedFiles: string[];
200
218
  knowledgeFragment: string;
201
219
  at: number;
202
220
  }
@@ -229,7 +247,8 @@ export function clearStablePrefixCache(): void {
229
247
 
230
248
  /**
231
249
  * Compute (or return cached) expensive async sub-results that compose
232
- * the stable prefix: workspace tree, file retrieval, knowledge fragment.
250
+ * the stable prefix: file retrieval, knowledge fragment. (R2: the workspace
251
+ * tree left this cache — it is per-task now; see StableComponents.)
233
252
  * Parallel siblings with the same cwd/step/run reuse the cached result.
234
253
  */
235
254
  export async function computeStablePrefixComponents(
@@ -255,19 +274,22 @@ export async function computeStablePrefixComponents(
255
274
  const ioFresh = ioCached && now - ioCached.at < STABLE_IO_TTL_MS;
256
275
  if (ioFresh) {
257
276
  const components: StableComponents = {
258
- treeBlock: ioCached!.treeBlock,
259
277
  suggestedFilesBlock: ioCached!.suggestedFilesBlock,
278
+ // R2.2: reconstruct the retrieval paths too — a cross-run hit that
279
+ // dropped them would silently de-scope the caller's task tree back
280
+ // to the whole workspace.
281
+ suggestedFiles: ioCached!.suggestedFiles,
260
282
  knowledgeFragment: ioCached!.knowledgeFragment,
261
283
  };
262
284
  stableComponentCache.set(cacheKey, components);
263
285
  return components;
264
286
  }
265
287
 
266
- const tree = await buildWorkspaceTree(task.cwd);
267
- const treeBlock = tree.rendered ? `# Workspace Structure\n${tree.rendered}` : "";
268
-
269
288
  const retrieval = await runRetrievalCycle(step.task, manifest.goal, task.cwd);
270
289
  const suggestedFilesBlock = renderSuggestedFilesSection(retrieval);
290
+ // R2.2: keep the raw paths (relative to task.cwd) next to the rendered
291
+ // block so the tree scoping below can reuse the retrieval result.
292
+ const suggestedFiles = retrieval.files.map((file) => file.path);
271
293
 
272
294
  const knowledgeFragment = buildKnowledgeFragment(task.cwd, {
273
295
  goal: manifest.goal,
@@ -275,7 +297,7 @@ export async function computeStablePrefixComponents(
275
297
  role: step.role,
276
298
  });
277
299
 
278
- const components: StableComponents = { treeBlock, suggestedFilesBlock, knowledgeFragment };
300
+ const components: StableComponents = { suggestedFilesBlock, suggestedFiles, knowledgeFragment };
279
301
  stableComponentCache.set(cacheKey, components);
280
302
  // RR-021 WI-4.3f: same insertion-order eviction cap as the stableIOCache
281
303
  // sibling below — stableComponentCache had NO cap (unbounded across a
@@ -321,6 +343,28 @@ export function promptBreakdownEnabled(): boolean {
321
343
  return getCrewEnv("PI_CREW_PROMPT_BREAKDOWN") === "1";
322
344
  }
323
345
 
346
+ /**
347
+ * R2.2 (adaptive-03): pick the focus paths for the task-scoped workspace
348
+ * tree. `step.reads` (declared inputs) is the strongest signal and wins
349
+ * whenever it yields at least one non-empty string (`reads: false` means
350
+ * "no declared reads" — the same normalization used by task-packet.ts and
351
+ * task-output-context.ts — so it falls through, it does not opt out). When
352
+ * the step declares none, fall back to the retrieval-suggested files that
353
+ * already ride the (cached) StableComponents — zero extra I/O. Anything
354
+ * else (no reads AND no suggestions, junk-only entries) yields `undefined`
355
+ * so buildTaskWorkspaceTree keeps the whole-workspace fallback, the
356
+ * pre-R2.2 fail-safe. Out-of-workspace/nonexistent paths keep their
357
+ * existing NIT-2/dir-refinement semantics inside the resolver.
358
+ */
359
+ function treeFocusPaths(reads: WorkflowStep["reads"], suggestedFiles: readonly string[] | undefined): string[] | undefined {
360
+ const declared = Array.isArray(reads) ? reads.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "") : [];
361
+ if (declared.length > 0) return declared;
362
+ const retrieved = Array.isArray(suggestedFiles)
363
+ ? suggestedFiles.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
364
+ : [];
365
+ return retrieved.length > 0 ? retrieved : undefined;
366
+ }
367
+
324
368
  /**
325
369
  * SR-02 phase 2: worker-prompt skill injection mode. Default "index" injects
326
370
  * compact per-skill entries (name + description + Path pointer) and the worker
@@ -413,7 +457,13 @@ export async function renderTaskPrompt(
413
457
  `Step: ${step.id}`,
414
458
  `Role: ${step.role}`,
415
459
  ].join("\n");
416
- const taskPacketBlock = task.taskPacket ? renderTaskPacket(task.taskPacket) : "";
460
+ // R3 (adaptive-04): single-canonical task body. The packet JSON used to
461
+ // re-embed the full objective — the SAME text as the `Task:` narrative
462
+ // below — a second time ("task body ×3" finding; the third copy is the
463
+ // parent-context embed in child-pi.ts, outside this seam). The JSON block
464
+ // now carries a pointer; renderTaskPacket's default still embeds the
465
+ // objective for non-prompt consumers and the direct unit contract.
466
+ const taskPacketBlock = task.taskPacket ? renderTaskPacket(task.taskPacket, { omitObjective: true }) : "";
417
467
  const specContractBlock = task.taskPacket?.specSnapshots?.length
418
468
  ? renderSpecContractBlock(task.taskPacket, { verifier: step.role === "verifier" })
419
469
  : "";
@@ -427,16 +477,35 @@ export async function renderTaskPrompt(
427
477
  HANDOFF_TEMPLATE,
428
478
  ].join("\n");
429
479
 
430
- // Stable prefix: role instructions, coordination, workspace tree — rarely changes.
480
+ // R2 (adaptive-04): the workspace tree is scoped to a subtree AROUND this
481
+ // task's working files instead of the identical whole-workspace walk that
482
+ // every sibling worker used to carry. Per-task content must NOT ride the
483
+ // byte-stable shared prefix (ARCH-3), so the block lives at the END of the
484
+ // dynamic suffix; buildWorkspaceTree's per-root TTL cache keeps parallel
485
+ // siblings sharing a scope on one walk.
486
+ // R2.2 (adaptive-03): the focus signal is reads-first — step.reads when the
487
+ // step declares inputs, else the retrieval-suggested files from the stable
488
+ // components (the smoke-run steps declare no reads, so pre-R2.2 they always
489
+ // carried the whole tree). No signal / out-of-workspace / non-existent
490
+ // scope ⇒ legacy full tree (fail-safe unchanged).
491
+ const { tree: taskTree, scope: treeScope } = await buildTaskWorkspaceTree(
492
+ task.cwd,
493
+ treeFocusPaths(step.reads, stableComponents.suggestedFiles),
494
+ );
495
+ const treeBlock = taskTree.rendered
496
+ ? `# Workspace Structure${treeScope.relativePath === "." ? "" : ` (subtree: ${treeScope.relativePath})`}\n${taskTree.rendered}`
497
+ : "";
498
+
499
+ // Stable prefix: role instructions, coordination — rarely changes.
431
500
  // ARCH-3 (byte-stable worker prefix): per-task values (Task ID, Task cwd, mailbox
432
- // target) live in dynamicSuffix so siblings sharing a run+role produce a
433
- // byte-identical prefix and hit provider KV-cache across the batch.
501
+ // target, the R2 task-scoped workspace tree) live in dynamicSuffix so siblings
502
+ // sharing a run+role produce a byte-identical prefix and hit provider KV-cache
503
+ // across the batch.
434
504
  const stablePrefix = [
435
505
  headerBlock,
436
506
  protocolBlock,
437
507
  roleInstructions,
438
508
  coordination,
439
- stableComponents.treeBlock,
440
509
  stableComponents.suggestedFilesBlock,
441
510
  toolGuidance,
442
511
  // O4 (ARCH-2 corrected): project knowledge (.crew/knowledge.md). Builtin
@@ -451,8 +520,10 @@ export async function renderTaskPrompt(
451
520
  .filter(Boolean)
452
521
  .join("\n");
453
522
 
454
- // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
455
- const dynamicSuffix = [
523
+ // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task.
524
+ // R2: the task-scoped workspace tree is pinned LAST (the most task-variable
525
+ // block) so everything before it keeps the per-task layout stable.
526
+ const dynamicParts = [
456
527
  taskHeader,
457
528
  "",
458
529
  skillBlock,
@@ -465,7 +536,9 @@ export async function renderTaskPrompt(
465
536
  memoryBlock,
466
537
  outputSchemaBlock,
467
538
  taskAndHandoff,
468
- ].join("\n");
539
+ ];
540
+ if (treeBlock) dynamicParts.push("", treeBlock);
541
+ const dynamicSuffix = dynamicParts.join("\n");
469
542
 
470
543
  const full = [stablePrefix, "", dynamicSuffix].join("\n");
471
544
  const sections: Record<string, number> | undefined = promptBreakdownEnabled()
@@ -476,7 +549,11 @@ export async function renderTaskPrompt(
476
549
  "stable.protocol": protocolBlock.length,
477
550
  "stable.roleInstructions": roleInstructions.length,
478
551
  "stable.coordination": coordination.length,
479
- "stable.workspaceTree": stableComponents.treeBlock.length,
552
+ // R2 mapping for before/after comparison: pre-R2 key was
553
+ // "stable.workspaceTree" (identical whole-workspace walk ×N,
554
+ // riding the system-append channel); post-R2 the tree is a
555
+ // per-task subtree in the user message → "dynamic.workspaceTree".
556
+ "dynamic.workspaceTree": treeBlock.length,
480
557
  "stable.suggestedFiles": stableComponents.suggestedFilesBlock.length,
481
558
  "stable.toolGuidance": toolGuidance.length,
482
559
  "stable.knowledge": stableComponents.knowledgeFragment.length,
@@ -245,6 +245,138 @@ function applyLineCap(lines: RenderLine[], cap: number): { lines: RenderLine[];
245
245
  return { lines: kept, elided: removable.length };
246
246
  }
247
247
 
248
+ // ── Task-scoped subtree (R2, adaptive-04 2026-11-04) ───────────────────
249
+
250
+ export interface TaskTreeScope {
251
+ /** Absolute directory the walk roots at. */
252
+ rootPath: string;
253
+ /** Scope relative to the workspace root; "." when the walk covers the whole workspace. */
254
+ relativePath: string;
255
+ }
256
+
257
+ /** Normalize a native-separator relative path to forward slashes (no-op on POSIX). */
258
+ function toPosixRel(p: string): string {
259
+ return path.sep === "/" ? p : p.split(path.sep).join("/");
260
+ }
261
+
262
+ /** Longest shared directory prefix across relative dir paths ("." when none). */
263
+ function commonDirPrefix(dirs: string[]): string {
264
+ const split = dirs.map((d) => d.split(path.sep).filter((p) => p !== "" && p !== "."));
265
+ let prefix = split[0] ?? [];
266
+ for (let i = 1; i < split.length && prefix.length > 0; i++) {
267
+ const other = split[i]!;
268
+ const keep: string[] = [];
269
+ for (let j = 0; j < Math.min(prefix.length, other.length); j++) {
270
+ if (prefix[j] === other[j]) keep.push(prefix[j]!);
271
+ else break;
272
+ }
273
+ prefix = keep;
274
+ }
275
+ return prefix.length > 0 ? prefix.join(path.sep) : ".";
276
+ }
277
+
278
+ /**
279
+ * R2 (adaptive-04): resolve the walk root for a task-scoped workspace tree.
280
+ *
281
+ * The legacy prompt seam walked the ENTIRE workspace from `task.cwd` — in
282
+ * shared-workspace mode that is the same root for every sibling worker, so
283
+ * each prompt carried an identical whole-workspace tree (measured 26% of the
284
+ * 01_discover artifact, ~0.9K est-tok/worker). This resolves a subtree
285
+ * AROUND the task's working files instead: the deepest directory that is an
286
+ * ancestor of every in-workspace focus path (focus paths are the step's
287
+ * `reads`, which follow file-path semantics — a directory-valued read
288
+ * contributes its parent and therefore narrows nothing).
289
+ *
290
+ * Deterministic and pure (no FS access) so unit tests pin the scoping rule
291
+ * directly. Falls back to "." (whole workspace, legacy behavior) when there
292
+ * are no focus paths, when they all live outside the workspace, or when they
293
+ * share no directory below the root.
294
+ */
295
+ export function resolveTaskTreeScope(workspaceCwd: string, focusPaths?: readonly string[] | false | null): TaskTreeScope {
296
+ const rootPath = path.resolve(workspaceCwd);
297
+ if (!focusPaths || focusPaths.length === 0) return { rootPath, relativePath: "." };
298
+
299
+ const insideDirs: string[] = [];
300
+ for (const entry of focusPaths) {
301
+ if (typeof entry !== "string" || entry.trim() === "") continue;
302
+ const rel = path.relative(rootPath, path.resolve(rootPath, entry));
303
+ // Outside the workspace ("../x", absolute elsewhere) cannot scope it.
304
+ // Exact `..` or `..`+sep only — a literal "..foo" sibling stays inside.
305
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
306
+ insideDirs.push(path.dirname(rel));
307
+ }
308
+ if (insideDirs.length === 0) return { rootPath, relativePath: "." };
309
+
310
+ const common = commonDirPrefix(insideDirs);
311
+ if (common === ".") return { rootPath, relativePath: "." };
312
+ // relativePath is a POSIX-style contract (prompt header, breakdown, tests) —
313
+ // normalize Windows separators at the boundary; rootPath stays native for FS.
314
+ return { rootPath: path.join(rootPath, common), relativePath: toPosixRel(common) };
315
+ }
316
+
317
+ export interface TaskWorkspaceTree {
318
+ tree: WorkspaceTree;
319
+ scope: TaskTreeScope;
320
+ }
321
+
322
+ /**
323
+ * R2 (adaptive-04): build the workspace tree scoped to a subtree around the
324
+ * task's working files. Options/defaults are the legacy `buildWorkspaceTree`
325
+ * ones (depth 3 / dirLimit 12 / lineCap 120) — only the walk ROOT narrows.
326
+ *
327
+ * Fail-safe: a focus path pointing at a directory that does not exist (typo,
328
+ * glob pattern, stale path) must not strip the worker's tree — the walk falls back to the whole workspace, byte-identical to pre-R2.
329
+ * Per-root walks reuse `buildWorkspaceTree`'s TTL cache, so parallel
330
+ * siblings that share a scope do one FS walk.
331
+ */
332
+ export async function buildTaskWorkspaceTree(
333
+ workspaceCwd: string,
334
+ focusPaths?: readonly string[] | false | null,
335
+ options?: WorkspaceTreeOptions,
336
+ ): Promise<TaskWorkspaceTree> {
337
+ const scope = resolveTaskTreeScope(workspaceCwd, focusPaths);
338
+ // NIT-2 (review follow-up): a top-level directory-valued focus path
339
+ // (e.g. reads: ["pi-crew"]) contributes dirname "." in the PURE resolver —
340
+ // file-path semantics — and de-scopes to the whole workspace even though
341
+ // the task plainly scopes to that directory. Refine HERE (the FS layer;
342
+ // resolveTaskTreeScope stays FS-free): on the fallback path only, stat each
343
+ // inside-workspace focus path and treat an EXISTING directory as its own
344
+ // scope root. Nonexistent paths keep file-path semantics (dirname), so a
345
+ // typo/glob still falls back fail-safe; a directory mixed with a root-level
346
+ // file still resolves to the whole workspace (conservative, no narrowing).
347
+ let effective = scope;
348
+ if (scope.relativePath === "." && Array.isArray(focusPaths) && focusPaths.length > 0) {
349
+ const rootPath = path.resolve(workspaceCwd);
350
+ const candidates: string[] = [];
351
+ for (const entry of focusPaths) {
352
+ if (typeof entry !== "string" || entry.trim() === "") continue;
353
+ const abs = path.resolve(rootPath, entry);
354
+ const rel = path.relative(rootPath, abs);
355
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
356
+ let isDir = false;
357
+ try {
358
+ isDir = (await fs.stat(abs)).isDirectory();
359
+ } catch {
360
+ isDir = false;
361
+ }
362
+ candidates.push(isDir ? rel : path.dirname(rel));
363
+ }
364
+ if (candidates.length > 0) {
365
+ const common = commonDirPrefix(candidates);
366
+ if (common !== ".") effective = { rootPath: path.join(rootPath, common), relativePath: toPosixRel(common) };
367
+ }
368
+ }
369
+ if (effective.relativePath !== ".") {
370
+ try {
371
+ await fs.stat(effective.rootPath);
372
+ } catch {
373
+ const rootPath = path.resolve(workspaceCwd);
374
+ return { tree: await buildWorkspaceTree(rootPath, options), scope: { rootPath, relativePath: "." } };
375
+ }
376
+ }
377
+ return { tree: await buildWorkspaceTree(effective.rootPath, options), scope: effective };
378
+ }
379
+
248
380
  // ── Public API ────────────────────────────────────────────────────────
249
381
 
250
382
  const emptyResult = (rootPath: string): WorkspaceTree => ({