gentle-pi 4.2.0 → 4.3.0

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 (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,13 +1,12 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, VERSION } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
- import * as os from "node:os";
5
4
  import { execFile } from "node:child_process";
6
5
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
6
  import { join } from "node:path";
8
7
  import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
9
8
  import { PI_SUBCOMMANDS } from "../lib/gentle-shell-launcher.ts";
10
- import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
9
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
11
10
 
12
11
 
13
12
  export type BannerColor = "pink" | "cyan" | "yellow" | "green";
@@ -68,7 +67,7 @@ function rgb(r: number, g: number, b: number, text: string): string {
68
67
  }
69
68
 
70
69
  function gentleAiConfigHome(): string {
71
- return bundledGentlePiConfigHome(process.env) ?? process.env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai");
70
+ return gentlePiConfigHome(process.env);
72
71
  }
73
72
 
74
73
  function bannerConfigPath(configHome = gentleAiConfigHome()): string {
package/lib/agent-home.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { bundledGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
3
+ import { resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
4
+
5
+ export { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts";
4
6
 
5
7
  // Pi Subagents resolves its global directory as `PI_CODING_AGENT_DIR || ~/.pi/agent`,
6
8
  // so an empty value must fall through here too or the two homes diverge again.
@@ -12,9 +14,12 @@ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env):
12
14
  // commands own lives here. It is defined once because the launch-time profile pin
13
15
  // resolver reads the same profiles store the `/gentle:profiles` panel writes, and
14
16
  // two spellings of the override would silently read two different stores.
15
- // A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome).
17
+ // A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome),
18
+ // the standalone launcher's isolated home next to its agent dir (#2201). The rule
19
+ // itself lives in resolveGentlePiConfigHome, a generated runtime module, so the
20
+ // launcher and the Gentle AI process environment share it too.
16
21
  export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string {
17
- return bundledGentlePiConfigHome(env) ?? (env.GENTLE_PI_CONFIG_HOME || join(homedir(), ".pi", "gentle-ai"));
22
+ return resolveGentlePiConfigHome(env, homedir());
18
23
  }
19
24
 
20
25
  // The agent home Pi runs with, only in a bundled Gentle Shell (S9): there the
@@ -127,6 +127,9 @@ export interface TaskRecord {
127
127
  startedAt: number | null;
128
128
  endedAt: number | null;
129
129
  model: string;
130
+ /** Launch-layer token recorded next to `model` (#2180): which profile layer won.
131
+ * Host-computed at the launch seam; absent for requests and records that never named one. */
132
+ modelSource?: string;
130
133
  thinking: string | undefined;
131
134
  sessionPath: string | null;
132
135
  error: string | null;
@@ -2,7 +2,7 @@ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-c
2
2
  import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4
4
  import { tmpdir, userInfo } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { Duplex, type Readable, type Writable } from "node:stream";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { withoutInteractiveHost } from "./rpc-host.ts";
@@ -12,6 +12,7 @@ import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, vali
12
12
  import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
13
13
  import { isFinished, normalizeRpcEvent, ToolArgumentProgress, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
14
14
  import { WriterSurfaceRegistry, writerSurfaceConflictMessage } from "./writer-surfaces.ts";
15
+ import { holdSubagentScratch, releaseSubagentScratch, SUBAGENT_SCRATCH_PREFIX } from "./session-scratch.ts";
15
16
 
16
17
  // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
17
18
  // the host never runs subagent work on the TUI thread. It writes JSON
@@ -133,6 +134,11 @@ export interface TaskRequest {
133
134
  // Host-provided only: the launcher's package injection signal (#1690) or
134
135
  // the curated fallback. Never derived from tool input or agent definitions.
135
136
  extensionPaths?: string[];
137
+ // Host-computed launch-layer token for the card (#2180): which profile layer
138
+ // supplied `model` (`binding`, `frozen:*`, `pin:*`, `declaration`, `profile`,
139
+ // `definition`, `default`). Same host-only provenance as `extensionPaths`:
140
+ // never derived from tool input or agent definitions.
141
+ modelSource?: string;
136
142
  // Same host-only provenance as extensionPaths (#1690).
137
143
  noExtensions?: boolean;
138
144
  // Parsed `## Allowed edit surfaces` of a bounded writer. While the task is
@@ -202,6 +208,8 @@ interface LiveTask {
202
208
  childExit: number | null | undefined;
203
209
  childExitSignal?: string | null;
204
210
  instructionsTransportDir?: string;
211
+ /** The child's own TMPDIR, removed when the child ends unless its exit is unconfirmed. */
212
+ scratchDir?: string;
205
213
  cleanupDeadlineAt: number | undefined;
206
214
  quarantined: boolean;
207
215
  nextId: number;
@@ -236,6 +244,7 @@ const DEFAULT_TOOLS: readonly string[] = [];
236
244
  const TERMINATION_GRACE_MS = 250;
237
245
  const GROUP_CONFIRM_MS = 25;
238
246
  const GROUP_CONFIRM_DEADLINE_MS = 1_000;
247
+ const QUARANTINE_PROBE_MS = 1_000;
239
248
  const QUERY_REJECTION_ERRORS = new Set([
240
249
  "invalid child IPC frame",
241
250
  "invalid child IPC correlation",
@@ -407,6 +416,8 @@ export class AgentRunner {
407
416
  startedAt: null,
408
417
  endedAt: null,
409
418
  model: formatModelRef(request.model),
419
+ // Copied, never recomputed: the layer token was resolved once at the seam (#2180).
420
+ ...(request.modelSource === undefined ? {} : { modelSource: request.modelSource }),
410
421
  thinking: request.thinking,
411
422
  sessionPath: request.resumeSessionPath ?? null,
412
423
  error: null,
@@ -562,6 +573,22 @@ export class AgentRunner {
562
573
  return;
563
574
  }
564
575
  }
576
+ // Harness roadmap T3: scratch the child makes with mktemp lands in its own
577
+ // TMPDIR, under the inherited one, so the runtime removes it at the end
578
+ // instead of asking the parent. Best effort: without it the child keeps
579
+ // the inherited TMPDIR.
580
+ let scratchDir: string | undefined;
581
+ try {
582
+ const inherited = request.env.TMPDIR?.trim();
583
+ // A relative TMPDIR would resolve against this process, not the child cwd.
584
+ // A short name and no real path: socket paths under TMPDIR are capped near 104 bytes.
585
+ scratchDir = mkdtempSync(join(inherited && isAbsolute(inherited) ? inherited : tmpdir(), SUBAGENT_SCRATCH_PREFIX));
586
+ // Session shutdown keeps it while it is held (extensions/session-scratch.ts).
587
+ holdSubagentScratch(scratchDir);
588
+ try { chmodSync(scratchDir, DIR_MODE); } catch { /* best effort */ }
589
+ env.TMPDIR = scratchDir;
590
+ if (this.processControl.platform === "win32") { env.TMP = scratchDir; env.TEMP = scratchDir; }
591
+ } catch { scratchDir = undefined; }
565
592
  let child: ChildLike;
566
593
  try {
567
594
  const pi = this.deps.resolvePi?.() ?? this.deps.pi;
@@ -573,15 +600,16 @@ export class AgentRunner {
573
600
  stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
574
601
  });
575
602
  } catch (error) {
576
- if (instructionsTransportDir) {
577
- try { rmSync(instructionsTransportDir, { recursive: true, force: true }); } catch { /* best effort */ }
603
+ for (const dir of [instructionsTransportDir, scratchDir]) {
604
+ if (dir) try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
578
605
  }
606
+ if (scratchDir) releaseSubagentScratch(scratchDir);
579
607
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
580
608
  this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
581
609
  return;
582
610
  }
583
611
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
584
- const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
612
+ const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, scratchDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
585
613
  if (request.prepareResponseObservations) {
586
614
  let ready = false;
587
615
  live.observationPreparation = () => ready;
@@ -997,7 +1025,8 @@ export class AgentRunner {
997
1025
  live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
998
1026
  }
999
1027
 
1000
- private cleanupLive(live: LiveTask): void {
1028
+ /** `exitProven` is true only in completeExit, after the child's exit was observed or its group is gone. */
1029
+ private cleanupLive(live: LiveTask, exitProven = false): void {
1001
1030
  live.permissionBroker?.close();
1002
1031
  this.closeIpc(live);
1003
1032
  live.cancelStall();
@@ -1006,6 +1035,13 @@ export class AgentRunner {
1006
1035
  try { rmSync(live.instructionsTransportDir, { recursive: true, force: true }); } catch { /* best effort */ }
1007
1036
  live.instructionsTransportDir = undefined;
1008
1037
  }
1038
+ // A quarantined child's exit is unconfirmed: a live descendant may still
1039
+ // hold files there, so its scratch stays until the exit is proven.
1040
+ if (live.scratchDir && (!live.quarantined || exitProven)) {
1041
+ try { rmSync(live.scratchDir, { recursive: true, force: true }); } catch { /* best effort */ }
1042
+ releaseSubagentScratch(live.scratchDir);
1043
+ live.scratchDir = undefined;
1044
+ }
1009
1045
  }
1010
1046
 
1011
1047
  private childError(id: string, error: Error): void {
@@ -1030,13 +1066,26 @@ export class AgentRunner {
1030
1066
  const exitDesc = formatChildExit(code, signal);
1031
1067
  if (this.groupExists(live)) {
1032
1068
  if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with ${exitDesc} before agent_settled${this.stderrSuffix(live)}`);
1069
+ else if (live.quarantined) live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
1070
+ return;
1071
+ }
1072
+ this.completeExit(id, live);
1073
+ }
1074
+
1075
+ // Quarantine stopped probing, and a descendant outlives the child's observed
1076
+ // exit: no later event reports the group's end, so keep probing until it is
1077
+ // gone, then release the slot and scratch.
1078
+ private awaitQuarantinedGroup(id: string, live: LiveTask): void {
1079
+ if (this.live.get(id) !== live) return;
1080
+ if (this.groupExists(live)) {
1081
+ live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
1033
1082
  return;
1034
1083
  }
1035
1084
  this.completeExit(id, live);
1036
1085
  }
1037
1086
 
1038
1087
  private completeExit(id: string, live: LiveTask): void {
1039
- this.cleanupLive(live);
1088
+ this.cleanupLive(live, true);
1040
1089
  this.live.delete(id);
1041
1090
  // Quarantine already notified completion, but its retained slot is now free.
1042
1091
  if (live.quarantined) {
@@ -143,7 +143,9 @@ function sameItemValue(a: unknown, b: unknown): boolean {
143
143
  }
144
144
 
145
145
  export function taskHeader(task: TaskRecord, now: number): string {
146
- const parts = [task.agent, task.status, task.model, task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? formatCost(task.cost) : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
146
+ // #2180: the winning profile layer is shown right after the model it supplied;
147
+ // records without one render exactly as before.
148
+ const parts = [task.agent, task.status, task.model, ...(task.modelSource === undefined ? [] : [task.modelSource]), task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? formatCost(task.cost) : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
147
149
  return parts.filter((part) => part.length > 0).join(" · ");
148
150
  }
149
151
 
@@ -1,6 +1,7 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, parse, relative, resolve } from "node:path";
4
+ import { contextBackstopReached, type MeasuredContext } from "./context-backstop.ts";
4
5
  import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
5
6
 
6
7
  const WRITER_NAMES = ["gentle-ai-worker", "worker", "jd-fix-agent"];
@@ -79,6 +80,56 @@ export function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: tr
79
80
  return { block: true, reason: `${WRITER_EDIT_SURFACE_REJECTION} ${parsed.problem} Resend the same task text unchanged except for that section; never shorten or re-summarize it.` };
80
81
  }
81
82
 
83
+ // Pain-point iteration S16: prompt rules alone did not stop small-task delegation (a two-feature request still
84
+ // started two writers for ~35-line units). A generic writer launch is admitted only for a task of an existing ODD
85
+ // feature document, or when the parent's measured context reaches the context backstop (the same threshold as the
86
+ // runtime notice, lib/context-backstop.ts). Judgment Day fix agents and continuations keep their own protocols.
87
+ // A line that starts with `Spec:` (optionally as a list item), names an odd/tasks document, and names a task
88
+ // `T<n>` later on the same line: covers the canon (`, T2, S3-S4`), the delegation card (`(read until "## Log").
89
+ // Do T2; S3.`), and comma-free forms (`.md T1 S1;`). A mention inside prose does not start the line.
90
+ const FEATURE_DOCUMENT_TASK = /^[ \t]*(?:[-*+][ \t]+)?Spec:[ \t]*`?(odd\/tasks\/[^\s,`()]+\.md)`?[^\n]*?\bT\d+\b/gm;
91
+ export const WRITER_FEATURE_DOCUMENT_REJECTION =
92
+ "Writers run only for a task of an existing ODD feature document (`Spec: odd/tasks/<feature>.md, T<n>` in the task) or when your measured context reaches the context backstop. This unit has neither: write this unit inline yourself now; do not retry the launch, and never create a feature document only to launch writers.";
93
+
94
+ function featureDocumentExists(root: string, relativePath: string): boolean {
95
+ // Resolve symlinks on both sides so a document linked from outside the root, or an odd/tasks directory linked
96
+ // elsewhere, never admits a writer; the document must be a regular file under <root>/odd/tasks.
97
+ try {
98
+ const realRoot = realpathSync(resolve(root));
99
+ const tasksDir = realpathSync(resolve(realRoot, "odd", "tasks"));
100
+ const target = realpathSync(resolve(realRoot, relativePath));
101
+ const inTasks = relative(tasksDir, target);
102
+ if (relative(realRoot, tasksDir).startsWith("..") || !inTasks || inTasks.startsWith("..") || isAbsolute(inTasks)) return false;
103
+ return statSync(target).isFile();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ // True when a string value has a `Spec:` task line naming an existing feature document under `root`. Shared by
110
+ // writer and verifier admission so both accept exactly the same task lines.
111
+ export function namesExistingFeatureDocumentTask(values: readonly unknown[], root: string): boolean {
112
+ for (const value of values) {
113
+ if (typeof value !== "string") continue;
114
+ for (const match of value.matchAll(FEATURE_DOCUMENT_TASK)) {
115
+ if (featureDocumentExists(root, match[1])) return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ export function rejectUndocumentedWriterLaunch(
122
+ input: unknown,
123
+ options: { root: string; contextUsage: MeasuredContext | undefined; env?: Readonly<Record<string, string | undefined>> },
124
+ ): { block: true; reason: string } | undefined {
125
+ if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
126
+ const record = input as Record<string, unknown>;
127
+ if (typeof record.agent !== "string" || !isGenericBoundedWriter(record.agent)) return undefined;
128
+ if (namesExistingFeatureDocumentTask([record.task, record.context], options.root)) return undefined;
129
+ if (contextBackstopReached(options.contextUsage, options.env)) return undefined;
130
+ return { block: true, reason: WRITER_FEATURE_DOCUMENT_REJECTION };
131
+ }
132
+
82
133
  // A continuation resumes the same delegated task, so a writer follow-up that
83
134
  // carries no section of its own inherits the surfaces its original launch was
84
135
  // admitted with. A follow-up that carries the heading is validated as written,
@@ -30,10 +30,6 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
30
30
  { command: "gentle:animations", label: "Animation mode" },
31
31
  { command: "gentle:vim", label: "Vim opt-in · Pi slash commands" },
32
32
  { command: "gentle:telemetry", label: "Telemetry" },
33
- { command: "gentle:banner", label: "Startup banner" },
34
- { command: "gentle:banner-color", label: "Banner color" },
35
- { command: "gentle:toggle-rose", label: "Toggle banner rose" },
36
- { command: "gentle:toggle-text-logo", label: "Toggle banner text logo" },
37
33
  { command: "gentle:dev-binary", label: "Gentle AI dev binary" },
38
34
  ],
39
35
  },
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { ToolResultEvent, ToolResultEventResult } from "@earendil-works/pi-coding-agent";
3
+ import { resolve } from "node:path";
4
+
5
+ // Harness roadmap T4 (R1): ODD slices delivery at about 400 authored changed
6
+ // lines, but the model had to estimate the count. After each parent commit the
7
+ // runtime reports it from Git, so the slicing decision reads a number.
8
+
9
+ export const SLICE_LINES = 400;
10
+
11
+ // Generated files that do not count as authored lines.
12
+ const GENERATED = [/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock|Gemfile\.lock)$/, /\.min\.(js|css)$/];
13
+
14
+ // `git [-C dir | -c key=value]... commit` at the start of a segment, after any variable assignments.
15
+ const COMMIT = /^\s*(\w+=\S*\s+)*git(\s+-[Cc]\s+\S+)*\s+commit(\s|$)/;
16
+
17
+ /** A commit is a git invocation that starts a shell segment; text that only mentions one (echo, a message) is not. */
18
+ export function isGitCommitCommand(command: string): boolean {
19
+ return command.split(/[;&|(\n]/).some((segment) => COMMIT.test(segment));
20
+ }
21
+
22
+ /** Shell segments joined by `;`, `&&`, `||`, or a newline. */
23
+ export const SHELL_SEGMENT_SEPARATOR = /;|&&|\|\||\n/;
24
+
25
+ const unquote = (value: string) => value.replace(/^["']|["']$/g, "");
26
+
27
+ /** The directory after a shell segment: `cd <dir>` resolved against `base`, else `base`. */
28
+ export function directoryAfterSegment(segment: string, base: string): string {
29
+ const cdDir = /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*$/.exec(segment)?.[1];
30
+ return cdDir ? resolve(base, unquote(cdDir)) : base;
31
+ }
32
+
33
+ /** The directory the commit runs in: `git -C <dir>` under every `cd <dir>` segment before the commit (joined by `;`, `&&`, `||`, or a newline), else the session cwd. */
34
+ export function commitTargetDirectory(command: string, cwd: string): string {
35
+ const gitDir = /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)\s+(?:-[Cc]\s+\S+\s+)*commit\b/.exec(command)?.[1];
36
+ let base = cwd;
37
+ for (const segment of command.split(SHELL_SEGMENT_SEPARATOR)) {
38
+ if (COMMIT.test(segment)) break;
39
+ base = directoryAfterSegment(segment, base);
40
+ }
41
+ return gitDir ? resolve(base, unquote(gitDir)) : base;
42
+ }
43
+
44
+ type Git = (args: string[], cwd: string) => Promise<string | undefined>;
45
+
46
+ // Asynchronous, so the parent stays responsive while git answers.
47
+ const runGit: Git = (args, cwd) => new Promise((resolve) => {
48
+ execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
49
+ });
50
+
51
+ async function defaultBranch(cwd: string, git: Git): Promise<string | undefined> {
52
+ const remote = await git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd);
53
+ if (remote) return remote;
54
+ for (const name of ["main", "master"]) {
55
+ if (await git(["rev-parse", "--verify", "-q", `refs/heads/${name}`], cwd) !== undefined) return name;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** One line for the parent after a commit on a feature branch, or undefined when there is no slice to report. */
61
+ export async function commitSliceReport(cwd: string, git: Git = runGit): Promise<string | undefined> {
62
+ const branch = await git(["symbolic-ref", "-q", "--short", "HEAD"], cwd);
63
+ const target = branch === undefined ? undefined : await defaultBranch(cwd, git);
64
+ if (!branch || !target || branch === target || `origin/${branch}` === target) return undefined;
65
+ const base = await git(["merge-base", "HEAD", target], cwd);
66
+ const commits = base ? Number(await git(["rev-list", "--count", `${base}..HEAD`], cwd)) : 0;
67
+ if (!base || !(commits > 0)) return undefined;
68
+ // A failed or timed-out diff is not an empty one: report nothing rather than 0.
69
+ // `-M` counts a move by what it changed; `-z` keeps paths unquoted for the generated-file filter.
70
+ const numstat = await git(["diff", "--numstat", "-z", "-M", base, "HEAD"], cwd);
71
+ if (numstat === undefined) return undefined;
72
+ let lines = 0;
73
+ const fields = numstat.split("\0");
74
+ for (let i = 0; i < fields.length; i++) {
75
+ const [added, deleted, inline] = fields[i].split("\t");
76
+ // A rename leaves the inline path empty and is followed by its old and new paths.
77
+ const path = inline === "" ? fields[i += 2] : inline;
78
+ if (!path || added === "-" || GENERATED.some((pattern) => pattern.test(path))) continue;
79
+ lines += Number(added) + Number(deleted);
80
+ }
81
+ const head = `Commit slice: ${lines} authored changed lines in ${commits} commit${commits === 1 ? "" : "s"} on ${branch} since ${target} (lock files excluded).`;
82
+ return lines > SLICE_LINES
83
+ ? `${head} Past the ~${SLICE_LINES}-line slice: apply the feature's delivery strategy before the next commit (record the slice boundary in the feature document).`
84
+ : `${head} ${SLICE_LINES - lines} lines left before the ~${SLICE_LINES}-line slice.`;
85
+ }
86
+
87
+ /**
88
+ * tool_result patch for a successful parent `git commit`: the slice line appended, structured content kept.
89
+ * Only foreground `bash`: a background job's result arrives before its commit exists.
90
+ */
91
+ export async function commitSliceToolResult(event: Pick<ToolResultEvent, "toolName" | "input" | "isError" | "content" | "structuredContent">, cwd: string, git: Git = runGit): Promise<ToolResultEventResult | undefined> {
92
+ const command = (event.input as { command?: unknown } | undefined)?.command;
93
+ if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string" || !isGitCommitCommand(command)) return undefined;
94
+ const report = await commitSliceReport(commitTargetDirectory(command, cwd), git);
95
+ if (!report) return undefined;
96
+ return {
97
+ content: [...event.content, { type: "text", text: `\n${report}` }],
98
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
99
+ };
100
+ }
@@ -0,0 +1,84 @@
1
+ import type { ToolResultEvent, ToolResultEventResult } from "@earendil-works/pi-coding-agent";
2
+
3
+ // Harness roadmap R1: the parent-context backstop was a fixed "~150k tokens" in
4
+ // prompt text, so the model had to estimate its own context. The runtime now
5
+ // measures it with one threshold for every model: carried context is paid on
6
+ // every remaining turn whatever the provider (absolute cap), and quality and
7
+ // headroom depend on how full the window is (fraction of the window).
8
+
9
+ export const CONTEXT_BACKSTOP_TOKENS = 150_000;
10
+ // Bench gate (harness roadmap T12): on the wide five-feature task, delegating at ~15% of a 272k window cost about
11
+ // 20-40% less and ran ~30% faster than never reaching the backstop, while small tasks never reached it.
12
+ export const CONTEXT_BACKSTOP_FRACTION = 0.15;
13
+ export const CONTEXT_BACKSTOP_TOKENS_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_TOKENS";
14
+ export const CONTEXT_BACKSTOP_FRACTION_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_FRACTION";
15
+
16
+ /** The part of Pi's `ctx.getContextUsage()` the backstop reads; null tokens are unknown (e.g. right after compaction). */
17
+ export interface MeasuredContext {
18
+ tokens: number | null | undefined;
19
+ contextWindow?: number | null;
20
+ }
21
+
22
+ type Env = Readonly<Record<string, string | undefined>>;
23
+
24
+ function positive(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
26
+ }
27
+
28
+ function envNumber(env: Env, name: string, valid: (value: number) => boolean, fallback: number): number {
29
+ const raw = env[name]?.trim();
30
+ const value = raw ? Number(raw) : Number.NaN;
31
+ return valid(value) ? value : fallback;
32
+ }
33
+
34
+ /** min(absolute cap, fraction x context window); an unknown window leaves the cap alone. Never 0: an empty context is not past it. */
35
+ export function contextBackstopThreshold(contextWindow: number | null | undefined, env: Env = process.env): number {
36
+ const cap = envNumber(env, CONTEXT_BACKSTOP_TOKENS_ENV, positive, CONTEXT_BACKSTOP_TOKENS);
37
+ const fraction = envNumber(env, CONTEXT_BACKSTOP_FRACTION_ENV, (value) => positive(value) && value <= 1, CONTEXT_BACKSTOP_FRACTION);
38
+ return positive(contextWindow) ? Math.min(cap, Math.max(1, Math.floor(fraction * contextWindow))) : cap;
39
+ }
40
+
41
+ /** Unknown tokens never count as reached. */
42
+ export function contextBackstopReached(usage: MeasuredContext | undefined, env: Env = process.env): boolean {
43
+ return typeof usage?.tokens === "number" && usage.tokens >= contextBackstopThreshold(usage.contextWindow, env);
44
+ }
45
+
46
+ const count = (value: number) => Math.round(value).toLocaleString("en-US");
47
+
48
+ function contextBackstopNotice(tokens: number, contextWindow: number | null | undefined, threshold: number): string {
49
+ const share = positive(contextWindow) ? ` (${Math.round((tokens / contextWindow) * 100)}% of the ${count(contextWindow)}-token context window)` : "";
50
+ return `Context backstop: the parent context measures ${count(tokens)} tokens${share}, at or past the ${count(threshold)}-token backstop: delegate the next bounded unit that needs several steps (its reads, edits, and checks) with a short handoff, while single small edits and state checks stay inline.`;
51
+ }
52
+
53
+ /**
54
+ * One notice per crossing for each session: the first measurement at or past the backstop returns it, and a
55
+ * measurement below the backstop re-arms it (for example after compaction). Unknown tokens change nothing.
56
+ */
57
+ export function createContextBackstopSignal(env: Env = process.env): (session: unknown, usage: MeasuredContext | undefined) => string | undefined {
58
+ const notified = new Set<unknown>();
59
+ return (session, usage) => {
60
+ if (typeof usage?.tokens !== "number") return undefined;
61
+ const threshold = contextBackstopThreshold(usage.contextWindow, env);
62
+ if (usage.tokens < threshold) {
63
+ notified.delete(session);
64
+ return undefined;
65
+ }
66
+ if (notified.has(session)) return undefined;
67
+ notified.add(session);
68
+ return contextBackstopNotice(usage.tokens, usage.contextWindow, threshold);
69
+ };
70
+ }
71
+
72
+ /** Appends the notice after the (possibly already patched) tool output, keeping structured content and the error flag. */
73
+ export function appendContextBackstopNotice(
74
+ event: Pick<ToolResultEvent, "content" | "structuredContent">,
75
+ patch: ToolResultEventResult | undefined,
76
+ notice: string,
77
+ ): ToolResultEventResult {
78
+ const structuredContent = patch?.structuredContent ?? event.structuredContent;
79
+ return {
80
+ ...patch,
81
+ content: [...(patch?.content ?? event.content), { type: "text", text: `\n${notice}` }],
82
+ ...(structuredContent === undefined ? {} : { structuredContent }),
83
+ };
84
+ }
@@ -0,0 +1,80 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import type { ToolCallEventResult } from "@earendil-works/pi-coding-agent";
3
+
4
+ // Harness roadmap T5 (R1): a delivered test must pass in any clone. Blind review
5
+ // found tests that read a fixed commit from git history (a differential check
6
+ // leaked into the suite) and orchestrators that pointed TMPDIR into the
7
+ // repository. The prompt rule did not hold, so the runtime blocks the write.
8
+ // Deliberately narrow: git used inside a throwaway repository (`git init`,
9
+ // `git stash`, `HEAD:`) stays allowed; only a literal commit id is history the
10
+ // test cannot own. Content written through bash is not seen here.
11
+
12
+ const TEST_PATH_PATTERNS = [
13
+ /(^|[\\/])(tests?|__tests__|spec)[\\/]/,
14
+ /\.(test|spec)\.[cm]?[jt]sx?$/,
15
+ /_test\.go$/,
16
+ /(^|[\\/])test_[^\\/]*\.py$/,
17
+ ];
18
+
19
+ const HISTORY_COMMAND = /\bgit\b[^\n]{0,40}?\b(show|checkout|diff|cat-file|archive)\b([^\n]{0,80})/g;
20
+ const COMMIT_ID = /\b[0-9a-f]{7,40}\b/g;
21
+ // `=` but not `==`/`===`: a comparison with the working directory is not an assignment.
22
+ const TMPDIR_ASSIGNMENT = /\bTMPDIR["']?\]?\s*(?::|=(?!=))\s*([^\n;,}]*)/g;
23
+ const WORKING_DIRECTORY = /\$PWD|\$\{PWD\}|\$\(pwd\)|process\.cwd\(\)|__dirname|import\.meta\.dirname|os\.getcwd\(\)/;
24
+ // A relative path resolves against the working directory too: `.tmp`, `./x`, `../x` or `tmp/x`, quoted or not.
25
+ // Absolute, `~`, `$` or `%` based values and a bare word (`scratch`, `os.tmpdir()`, a fixture value) stay allowed.
26
+ const RELATIVE_PATH = /^\s*["'`]?(?:\.{1,2}[\/\\\w-]|[\w.-]+[\/\\])/;
27
+
28
+ export function isTestFilePath(path: string): boolean {
29
+ return TEST_PATH_PATTERNS.some((pattern) => pattern.test(path));
30
+ }
31
+
32
+ /** A literal commit id mixes digits and letters; all-letter or all-digit words are not treated as one. */
33
+ function isCommitId(token: string): boolean {
34
+ return /[0-9]/.test(token) && /[a-f]/.test(token);
35
+ }
36
+
37
+ /** Why this test content cannot be delivered, or undefined when it can. */
38
+ export function deliveredTestViolation(content: string): string | undefined {
39
+ for (const match of content.matchAll(HISTORY_COMMAND)) {
40
+ const commit = [...match[2].matchAll(COMMIT_ID)].map((m) => m[0]).find(isCommitId);
41
+ if (commit) return `reads git history at a fixed commit (\`git ${match[1]} ${commit}\`), which fails in another clone`;
42
+ }
43
+ for (const match of content.matchAll(TMPDIR_ASSIGNMENT)) {
44
+ if (WORKING_DIRECTORY.test(match[1]) || RELATIVE_PATH.test(match[1])) return `points TMPDIR inside the working directory (\`TMPDIR${match[0].slice(6).trim()}\`), so test scratch enters the diff`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function writtenText(toolName: string, input: Record<string, unknown>): string | undefined {
50
+ if (toolName === "write") return typeof input.content === "string" ? input.content : undefined;
51
+ const parts: string[] = [];
52
+ if (typeof input.newText === "string") parts.push(input.newText);
53
+ if (Array.isArray(input.edits)) {
54
+ for (const edit of input.edits) {
55
+ if (edit && typeof edit === "object" && typeof (edit as { newText?: unknown }).newText === "string") parts.push((edit as { newText: string }).newText);
56
+ }
57
+ }
58
+ return parts.length > 0 ? parts.join("\n") : undefined;
59
+ }
60
+
61
+ /** An absolute path inside the session cwd is judged by its repository-relative part. */
62
+ function judgedPath(path: string, cwd: string | undefined): string {
63
+ if (!cwd || !isAbsolute(path)) return path;
64
+ const inside = relative(cwd, path);
65
+ return inside && !inside.startsWith("..") && !isAbsolute(inside) ? inside : path;
66
+ }
67
+
68
+ /** tool_call guard for write and edit, shared by the parent session and every child. */
69
+ export function evaluateDeliveredTestTool(toolName: string, input: unknown, cwd?: string): ToolCallEventResult | undefined {
70
+ if ((toolName !== "write" && toolName !== "edit") || !input || typeof input !== "object") return undefined;
71
+ const record = input as Record<string, unknown>;
72
+ if (typeof record.path !== "string" || !isTestFilePath(judgedPath(record.path, cwd))) return undefined;
73
+ const text = writtenText(toolName, record);
74
+ const violation = text === undefined ? undefined : deliveredTestViolation(text);
75
+ if (!violation) return undefined;
76
+ return {
77
+ block: true,
78
+ reason: `Gentle AI blocked this test write: ${record.path} ${violation}. Delivered tests must pass in any clone: build the previous behavior from a fixture instead of git history, and create scratch with mktemp outside the repository. Run differential checks against a scratch copy of the base and do not commit them as tests.`,
79
+ };
80
+ }