@ferris1225/pi-subagents 4.1.13 → 4.1.16

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.
@@ -164,31 +164,3 @@ export function sweepOrphanTempDirs(
164
164
  return removed;
165
165
  }
166
166
 
167
- /** Remove state-root directories no manifest record references. Fresh
168
- * directories (a run just created but not yet recorded) are protected by the
169
- * age cap, since the sweep only runs at extension load before new work. */
170
- export function sweepUnreferencedState(
171
- stateRoot: string,
172
- referencedPaths: ReadonlySet<string>,
173
- options: SweepOptions = {},
174
- ): number {
175
- const now = options.now ?? Date.now();
176
- const maxAgeMs = options.unmarkedMaxAgeMs ?? UNREFERENCED_STATE_MAX_AGE_MS;
177
- const pathKey = (path: string): string => (process.platform === "win32" ? path.toLowerCase() : path);
178
- const referenced = new Set([...referencedPaths].map(pathKey));
179
- let entries: Dirent[];
180
- try {
181
- entries = readdirSync(stateRoot, { withFileTypes: true });
182
- } catch {
183
- return 0;
184
- }
185
- let removed = 0;
186
- for (const entry of entries) {
187
- if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
188
- const path = join(stateRoot, entry.name);
189
- if (referenced.has(pathKey(path))) continue;
190
- const ageMs = directoryAgeMs(entry, stateRoot, now);
191
- if (ageMs !== undefined && ageMs > maxAgeMs && removeDir(path)) removed++;
192
- }
193
- return removed;
194
- }
@@ -12,7 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { existsSync } from "node:fs";
13
13
  import { rm } from "node:fs/promises";
14
14
  import { realpath } from "node:fs/promises";
15
- import { resolve } from "node:path";
15
+ import { join, resolve } from "node:path";
16
16
  import {
17
17
  discoverAgents,
18
18
  isWriteCapableAgent,
@@ -27,10 +27,9 @@ import {
27
27
  type ThinkingLevel,
28
28
  } from "./config.ts";
29
29
  import {
30
- getStateRoot,
31
30
  readThreadRecords,
32
- referencedDurablePaths,
33
31
  removeThreadRecord,
32
+ pruneStaleProjectRoots,
34
33
  pruneThreadRecords,
35
34
  restoredResultFromSummary,
36
35
  threadRecordFromThread,
@@ -42,6 +41,7 @@ import {
42
41
  failedStartResult,
43
42
  formatCompletionBlock,
44
43
  modelLevelTakeoverNote,
44
+ reviewFailFollowUpNote,
45
45
  queuedResult,
46
46
  } from "./format.ts";
47
47
  import {
@@ -66,6 +66,7 @@ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts"
66
66
  import { forkRetainedSession } from "./session-fork.ts";
67
67
  import {
68
68
  buildResumePrompt,
69
+ getProjectRoot,
69
70
  getResultOutput,
70
71
  RpcRunControl,
71
72
  isFailedResult,
@@ -81,7 +82,6 @@ import {
81
82
  isProcessAlive,
82
83
  killProcessTree,
83
84
  sweepOrphanTempDirs,
84
- sweepUnreferencedState,
85
85
  } from "./temp-hygiene.ts";
86
86
  import {
87
87
  createWorktreeIsolation,
@@ -220,16 +220,19 @@ export function ownsResumeReservation(
220
220
  );
221
221
  }
222
222
 
223
- /** Fire-and-forget durable checkpoint write. The live session keeps working
224
- * when the manifest is unwritable; only cross-reload resume is degraded. */
223
+ /** Fire-and-forget durable checkpoint. Parked threads stay resumable across
224
+ * reloads; a settled thread drops its record so the manifest only exists
225
+ * while unfinished work needs it. The live session keeps working when the
226
+ * manifest is unwritable; only cross-reload resume is degraded. */
225
227
  export function persistThreadCheckpoint(
226
228
  runtime: SubagentRuntime,
227
229
  thread: SubagentThread,
228
230
  state: "parked" | "completed" | "failed",
229
231
  ): void {
230
- void upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state)).catch(
231
- () => undefined,
232
- );
232
+ const write = state === "parked"
233
+ ? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
234
+ : removeThreadRecord(runtime.configPath, thread.id);
235
+ void write.catch(() => undefined);
233
236
  }
234
237
 
235
238
  const WORKTREE_ISOLATION_INSTRUCTIONS =
@@ -246,27 +249,6 @@ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
246
249
  return isWriteCapableAgent(agent);
247
250
  }
248
251
 
249
- /** A direct reviewer otherwise cannot infer enabled-role availability from its
250
- * isolated task. Managed internal gates receive the same contract in their
251
- * generated briefs. Advisory reviews still emit neither machine marker. */
252
- function withEnabledDocumenterReviewContract(agent: AgentConfig): AgentConfig {
253
- return {
254
- ...agent,
255
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: documenter is enabled. In gate reviews, documentation drift is non-gating: emit DOCUMENTATION: NEEDED with ## Documentation notes, or DOCUMENTATION: CLEAN when no sync is needed. Advisory reviews still emit neither VERDICT nor DOCUMENTATION markers.`.trim(),
256
- };
257
- }
258
-
259
- /** The dispatcher explicitly requested a report-only review: forbid the gate
260
- * markers at the source and (in dispatch) refuse to chain on them anyway. */
261
- function withAdvisoryReviewContract(agent: AgentConfig): AgentConfig {
262
- return {
263
- ...agent,
264
- systemPrompt: `${agent.systemPrompt.trimEnd()}
265
-
266
- Runtime workflow context: this dispatch is advisory. Report findings only; do not emit VERDICT or DOCUMENTATION markers — the runtime will not act on them.`.trim(),
267
- };
268
- }
269
-
270
252
  export interface DispatchEnvironment {
271
253
  ctx: ExtensionContext;
272
254
  config: SubagentsConfig;
@@ -296,7 +278,6 @@ export interface StartBackgroundOptions {
296
278
  environment?: DispatchEnvironment;
297
279
  seed?: SessionSeed;
298
280
  resumeReservation?: ResumeReservation;
299
- advisoryReview?: boolean;
300
281
  }
301
282
 
302
283
  export type StartBackgroundInternal = (
@@ -421,20 +402,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
421
402
  const runCtx = baseEnvironment.ctx;
422
403
  const runConfig = baseEnvironment.config;
423
404
  const runAgents = baseEnvironment.agents;
424
- const stateRoot = getStateRoot(runtime.configPath);
425
405
  const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
426
406
  if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
427
407
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
428
408
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
429
- const resolvedAgent = resolveLiveAgentTools(discoveredAgent);
430
- const advisoryReview = startOptions.advisoryReview ?? existingThread?.advisoryReview ?? false;
431
- const agent = agentName === "reviewer"
432
- ? advisoryReview
433
- ? withAdvisoryReviewContract(resolvedAgent)
434
- : runAgents.some((candidate) => candidate.name === "documenter")
435
- ? withEnabledDocumenterReviewContract(resolvedAgent)
436
- : resolvedAgent
437
- : resolvedAgent;
409
+ const agent = resolveLiveAgentTools(discoveredAgent);
438
410
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
439
411
  return {
440
412
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
@@ -443,6 +415,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
443
415
  }
444
416
 
445
417
  const originalCwd = resolve(cwd ?? runCtx.cwd);
418
+ const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
419
+ const sessionsRoot = join(projectRoot, "sessions");
420
+ const worktreesRoot = join(projectRoot, "worktrees");
446
421
  const previousWorktree = existingThread?.worktree;
447
422
  let worktree = seed?.worktree ?? previousWorktree;
448
423
  if (isolation === "worktree") {
@@ -455,7 +430,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
455
430
  }
456
431
  if (!worktree) {
457
432
  try {
458
- worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: stateRoot });
433
+ worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: worktreesRoot });
459
434
  } catch (error) {
460
435
  return {
461
436
  ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
@@ -528,7 +503,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
528
503
  thread.executionCwd = executionCwd;
529
504
  thread.thinkingLevel = thinkingLevel;
530
505
  thread.isolation = isolation;
531
- thread.advisoryReview = advisoryReview;
532
506
  thread.worktree = worktree;
533
507
  thread.state = "queued";
534
508
  thread.control = control;
@@ -552,7 +526,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
552
526
  executionCwd,
553
527
  thinkingLevel,
554
528
  isolation,
555
- advisoryReview,
556
529
  worktree,
557
530
  state: "queued",
558
531
  control,
@@ -576,10 +549,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
576
549
  const workflowAvailability = workflowAgentAvailability(runAgents);
577
550
  const reserveManagedLane =
578
551
  isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
579
- // Assigned once enqueue returns; only read after the generation's first
580
- // await, so the managed-continuation slot suspension always sees it.
581
- let generationController: AbortController | undefined;
582
- const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
552
+ const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
583
553
  if (runtime.threads.get(runId)?.generation !== generation) return;
584
554
  // Model/thinking config may have changed while this generation sat
585
555
  // queued behind the concurrency limit. Re-resolve the route at actual
@@ -615,7 +585,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
615
585
  control,
616
586
  makeDetails: makeDetails("single", true),
617
587
  idleTimeoutMs: activeIdleTimeoutMs,
618
- sessionRoot: stateRoot,
588
+ sessionRoot: sessionsRoot,
619
589
  ...(priorSessionId && priorSessionDir
620
590
  ? {
621
591
  sessionId: priorSessionId,
@@ -670,20 +640,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
670
640
  if (thread.lifecycleOperation === "stop") return;
671
641
 
672
642
  // A shutdown can win in the microtask gap after the top-level RPC
673
- // settles. Do not launch an obsolete documenter/reviewer or replace the
643
+ // settles. Do not launch an obsolete downstream gate or replace the
674
644
  // stable top-level session with an aborted downstream attempt.
675
645
  if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
676
646
 
677
647
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
678
648
  let workflowOutcome: ManagedWorkflowOutcome | undefined;
679
- const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability, thread.advisoryReview);
649
+ const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability);
680
650
  if (workflowPlan && runtime.sessionActive) {
681
651
  // The continuation is runtime-initiated (gate review,
682
652
  // documentation sync): release this generation's
683
653
  // concurrency slot so managed chains never starve manual
684
654
  // dispatches. Cancellation and quiescence guarantees are
685
655
  // unchanged — the task stays abortable and awaited.
686
- runtime.backgroundQueue.suspend(generationController);
656
+ runtime.backgroundQueue.suspend(controller);
687
657
  thread.state = "running";
688
658
  // The stable parent row now represents workflow ownership, not whichever
689
659
  // model stage ran most recently. Internal rows own their exact role/model/
@@ -799,7 +769,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
799
769
  : undefined;
800
770
  const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
801
771
  if (needsFullFinal) {
802
- block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
772
+ block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}`;
773
+ }
774
+ if (finalVerdict === "fail") {
775
+ block += `\n\n${reviewFailFollowUpNote()}`;
803
776
  }
804
777
  if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
805
778
  runtime.sendCompletionGroup([{
@@ -815,8 +788,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
815
788
  const completion: CompletionMessageItem = {
816
789
  agent: result.agent,
817
790
  block: modelLevel
818
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
819
- : formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd),
791
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result, { runId })}`
792
+ : result.agent === "reviewer" && reviewVerdict(getResultOutput(result)) === "fail"
793
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${reviewFailFollowUpNote()}`
794
+ : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
820
795
  triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
821
796
  usage: result.usage,
822
797
  };
@@ -837,13 +812,21 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
837
812
  }
838
813
  };
839
814
  const queuedGeneration = reserveManagedLane
840
- ? async (backgroundSignal: AbortSignal): Promise<void> => {
841
- await runInManagedRepositoryLane(
842
- originalCwd,
843
- () => runGeneration(backgroundSignal),
844
- backgroundSignal,
845
- );
846
- }
815
+ ? async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
816
+ // This task may spend its whole life waiting for the repository
817
+ // lane (every shared write-capable generation serializes on it).
818
+ // Holding a process slot while only waiting let a batch of shared
819
+ // writers park the entire pool and starve independent read-only
820
+ // dispatches that could have started immediately, so the slot is
821
+ // released up front; the lane itself still serializes same-repo
822
+ // writers, and abort/quiesce guarantees are unchanged.
823
+ runtime.backgroundQueue.suspend(controller);
824
+ await runInManagedRepositoryLane(
825
+ originalCwd,
826
+ () => runGeneration(backgroundSignal, controller),
827
+ backgroundSignal,
828
+ );
829
+ }
847
830
  : runGeneration;
848
831
  const queueController = runtime.backgroundQueue.enqueue(
849
832
  queuedGeneration,
@@ -915,7 +898,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
915
898
  runtime.sendCompletionGroup([
916
899
  {
917
900
  agent: crashed.agent,
918
- block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
901
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, crashed.projectCwd ?? originalCwd) }),
919
902
  triggerTurn: true,
920
903
  usage: crashed.usage,
921
904
  },
@@ -929,7 +912,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
929
912
  }
930
913
  },
931
914
  );
932
- generationController = queueController;
933
915
  thread.queueController = queueController;
934
916
  thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
935
917
  runtime.runControllers.set(runId, queueController);
@@ -946,7 +928,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
946
928
  export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
947
929
  const { runtime, startBackground } = deps;
948
930
  const runId = thread.id;
949
- const stateRoot = getStateRoot(runtime.configPath);
931
+ const projectRoot = getProjectRoot(runtime.configPath, thread.cwd);
932
+ const sessionsRoot = join(projectRoot, "sessions");
933
+ const worktreesRoot = join(projectRoot, "worktrees");
950
934
 
951
935
  thread.notifyIsolationFailure = (finalization) => {
952
936
  const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
@@ -1081,7 +1065,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
1081
1065
  return createWorktreeIsolation(thread.cwd, {
1082
1066
  seedCheckpoint,
1083
1067
  seedIsIntegrated,
1084
- tempBaseDir: stateRoot,
1068
+ tempBaseDir: worktreesRoot,
1085
1069
  });
1086
1070
  };
1087
1071
 
@@ -1169,7 +1153,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
1169
1153
  targetCwd: continuationWorktree.cwd,
1170
1154
  sessionDir: previousSessionDir,
1171
1155
  sessionId: previousSessionId,
1172
- targetRoot: stateRoot,
1156
+ targetRoot: sessionsRoot,
1173
1157
  });
1174
1158
  runtime.sessionDirs.add(clonedSession.sessionDir);
1175
1159
  if (!ownsResumeReservation(runtime, thread, reservation)) {
@@ -1275,6 +1259,11 @@ async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRec
1275
1259
  await removeThreadRecord(runtime.configPath, record.runId).catch(() => undefined);
1276
1260
  }
1277
1261
 
1262
+ /** Project-scoped <projectRoot>/results for a completion's artifacts. */
1263
+ export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
1264
+ return join(getProjectRoot(configPath, cwd), "results");
1265
+ }
1266
+
1278
1267
  function createRestoredThread(
1279
1268
  runtime: SubagentRuntime,
1280
1269
  record: ThreadRecord,
@@ -1290,7 +1279,6 @@ function createRestoredThread(
1290
1279
  executionCwd: record.executionCwd,
1291
1280
  ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
1292
1281
  isolation: record.isolation,
1293
- advisoryReview: false,
1294
1282
  worktree,
1295
1283
  state,
1296
1284
  control: new RpcRunControl(record.task, record.generation),
@@ -1320,15 +1308,20 @@ function createRestoredThread(
1320
1308
  return thread;
1321
1309
  }
1322
1310
 
1323
- /** Rebuild parked and settled threads from the durable manifest after a reload
1324
- * or restart. Orphaned children recorded by the previous process are killed
1325
- * first; records whose retained session vanished drop out with their
1326
- * artifacts. Returns the restored run ids. */
1311
+ /** Rebuild interrupted (parked) threads from the durable manifest after a
1312
+ * reload or restart. Orphaned children recorded by the previous process are
1313
+ * killed first; records whose retained session vanished and settled records
1314
+ * left by older versions, which hold no work worth resuming — drop out with
1315
+ * their artifacts. Returns the restored run ids. */
1327
1316
  export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
1328
1317
  const records = await readThreadRecords(runtime.configPath);
1329
1318
  const restoredIds: number[] = [];
1330
1319
  for (const record of records) {
1331
1320
  if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
1321
+ if (record.state !== "parked") {
1322
+ await discardRestoredRecord(runtime, record);
1323
+ continue;
1324
+ }
1332
1325
  // A child orphaned by reload/crash may still hold the retained session.
1333
1326
  // The on-disk session checkpoint is what survives; kill the writer.
1334
1327
  for (const pid of record.childPids) {
@@ -1348,33 +1341,29 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
1348
1341
  // A worktree thread whose isolated filesystem is gone cannot continue its
1349
1342
  // isolation invariant; surface it as failed instead of pretending.
1350
1343
  const state: ThreadState = worktree
1351
- ? record.state
1344
+ ? "parked"
1352
1345
  : record.isolation === "worktree" && record.worktree
1353
1346
  ? "failed"
1354
- : record.state;
1347
+ : "parked";
1355
1348
  const thread = createRestoredThread(runtime, record, worktree, state);
1356
1349
  runtime.threads.set(record.runId, thread);
1357
1350
  runtime.sessionDirs.add(record.sessionDir!);
1358
- if (state === "parked") {
1359
- monitor.restoreRun({
1360
- id: record.runId,
1361
- agent: record.agentName,
1362
- task: record.task,
1363
- status: "parked",
1364
- elapsedMs: record.elapsedMs,
1365
- isolation: record.isolation,
1366
- ...(record.worktree
1367
- ? {
1368
- integrationStatus: record.worktree.state === "active"
1369
- ? ("pending" as const)
1370
- : record.worktree.state,
1371
- ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1372
- }
1373
- : {}),
1374
- });
1375
- } else if (thread.lastResult) {
1376
- runtime.registerRunResult(record.runId, thread.lastResult);
1377
- }
1351
+ monitor.restoreRun({
1352
+ id: record.runId,
1353
+ agent: record.agentName,
1354
+ task: record.task,
1355
+ status: "parked",
1356
+ elapsedMs: record.elapsedMs,
1357
+ isolation: record.isolation,
1358
+ ...(record.worktree
1359
+ ? {
1360
+ integrationStatus: record.worktree.state === "active"
1361
+ ? ("pending" as const)
1362
+ : record.worktree.state,
1363
+ ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1364
+ }
1365
+ : {}),
1366
+ });
1378
1367
  restoredIds.push(record.runId);
1379
1368
  }
1380
1369
  return restoredIds;
@@ -1400,11 +1389,8 @@ export async function bootstrapDurableState(runtime: SubagentRuntime): Promise<v
1400
1389
  /* temp hygiene is best-effort */
1401
1390
  }
1402
1391
  try {
1403
- sweepUnreferencedState(
1404
- getStateRoot(runtime.configPath),
1405
- referencedDurablePaths(await readThreadRecords(runtime.configPath)),
1406
- );
1392
+ await pruneStaleProjectRoots(runtime.configPath);
1407
1393
  } catch {
1408
- /* state hygiene is best-effort */
1394
+ /* project-root hygiene is best-effort */
1409
1395
  }
1410
1396
  }