gentle-pi 4.1.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 (249) hide show
  1. package/README.md +352 -128
  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/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -17,7 +17,7 @@ When asked who or what you are, answer as el Gentleman: a Pi-specific coding-age
17
17
  - Size every task by the orchestrator's Task Size section: understood, contained risk, and resumable from the original request plus `git diff`. Counts of files, commands, tests, or fixes never decide it. Track only large work in its task document and mirror.
18
18
  - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, relevant alternate cases, then REFACTOR and record evidence. Test presence alone does not establish applicability. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, state why and run proportionate ordinary functional or structural verification. Never invent RED/GREEN, skip checks, or require a chat/TUI toggle.
19
19
  - Keep one parent session responsible for orchestration; child subagents should receive concrete phase work and must not spawn more subagents.
20
- - Parent-only delegation triggers fire one mechanism at a time: an open decision (ask), understanding beyond the evidence budget (explore), high risk (independent verifier), a large task (track), a writer reason (parallel units or context), tooling/worktree incidents, or a parent context past the context backstop.
20
+ - Parent-only delegation triggers fire one mechanism at a time: an open decision (ask), understanding beyond the evidence budget (explore), a named high-risk item (independent verifier, through assess `escalate`), a large task (track), a writer reason (parallel units or context), tooling/worktree incidents, or a parent context past the context backstop.
21
21
  - Parallel writers only with disjoint Allowed edit surfaces (runtime-enforced) or isolated worktrees.
22
22
  - Forecast review workload before large changes; ask before producing oversized or multi-area diffs.
23
23
  - Keep dangerous-command safety independent and authoritative.
@@ -45,9 +45,9 @@ Hard delegation triggers:
45
45
 
46
46
  - **Evidence-budget rule**: read inline only when the evidence fits one parallel batch of at most 3 calls, ~10k tokens (grep and line ranges, never whole large files). When understanding needs more reading or more than ~5 sequential lookups, delegate one explorer that returns a handoff of at most ~2k tokens with `path:line` evidence. Never force delegation for a small targeted question; do not re-read what the handoff covered beyond one spot check.
47
47
  - **Writer rule**: a writer only for a reason (parallel units launched together, or context); size and file count never fire it.
48
- - **Verification rule**: a high-risk change gets an independent verifier; otherwise the change's own focused test and suite run inline.
48
+ - **Verification rule**: a change that touches a named high-risk item gets an independent verifier (assess `escalate`); otherwise the change's own focused test and suite run inline.
49
49
  - **Incident rule**: after wrong cwd, accidental worktree/repo mutation, merge recovery, confusing test command, or environment workaround, diagnose separately.
50
- - **Context backstop**: when the parent context passes ~150k tokens, pause and delegate the next bounded unit of work to a non-review subagent. Keep command output bounded (counts, `--stat`, `tail`).
50
+ - **Context backstop**: when the runtime signals the context backstop (measured parent context past the smaller of an absolute cap and a fraction of the model's context window), pause and delegate the next bounded unit that needs several steps to a non-review subagent; small edits stay inline. Keep command output bounded (counts, `--stat`, `tail`).
51
51
 
52
52
  ## Review Lens Selection
53
53
 
@@ -18,7 +18,7 @@ Use it for:
18
18
  - Turning a large change into chained or stacked PRs.
19
19
  - Keeping reviewer cognitive load healthy.
20
20
  - Applying SDD tasks without accidentally producing a PR above 400 changed lines.
21
- - Closing an ODD task with a work-unit commit.
21
+ - Committing ODD work in reviewable slices of about 400 authored changed lines.
22
22
 
23
23
  ## Critical Rules
24
24
 
@@ -80,11 +80,13 @@ Each SDD work unit should map cleanly to a commit or PR with:
80
80
 
81
81
  ## ODD Relationship
82
82
 
83
- Every ODD task closes with at least one work-unit commit:
83
+ ODD commits work units in reviewable slices of about 400 authored changed lines, not one commit per task:
84
84
 
85
+ - A slice holds one or more whole tasks with their tests and docs; the last slice closes the feature.
86
+ - A unit whose Risk line names a high-risk item gets its own commit, so native review takes it alone.
85
87
  - The native review candidate is that commit, or the PR slice it belongs to when review is deferred, evaluated against the previous reviewed boundary.
86
88
  - The running authored line count from work-unit commits feeds the same delivery-strategy vocabulary as SDD.
87
- - The feature document records the commit identity and, once a delivery strategy applies, the slice boundaries.
89
+ - The feature document records the commit that holds each task and, once a delivery strategy applies, the slice boundaries.
88
90
 
89
91
  ## Commands
90
92
 
@@ -5,15 +5,17 @@ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
5
5
  import { NATIVE_REVIEW_MODE_SOURCE, type NativeReviewCli } from "../lib/native-review-cli.ts";
6
6
  import {
7
7
  decodeAgentRiskEscalation,
8
- escalatedRisk,
9
8
  HIGH_RISK_ITEMS,
10
9
  REVIEW_ASSESSMENT_SCHEMA,
11
10
  VERIFICATION_TIER,
11
+ verificationRisk,
12
12
  } from "../lib/review-risk-assessment.ts";
13
13
 
14
14
  // gentle-shell#1494 S6: the agent may raise a candidate's risk to high by
15
15
  // citing one item of the shared high-risk list, never lower it. The raise
16
- // travels inside the existing assess call; no new operation.
16
+ // travels inside the existing assess call; no new operation. Verification by
17
+ // named risk: that escalation is now the only way to get the independent
18
+ // verifier; a native high or unassessable tier informs only.
17
19
 
18
20
  test("the high-risk list has exactly the six items of the always-on Task Size section", () => {
19
21
  assert.deepEqual(Object.keys(HIGH_RISK_ITEMS).map(Number), [1, 2, 3, 4, 5, 6]);
@@ -45,23 +47,25 @@ test("decodeAgentRiskEscalation rejects unknown items, empty or oversized reason
45
47
  }
46
48
  });
47
49
 
48
- test("escalatedRisk raises passive and medium to high and never lowers", () => {
50
+ // Replaces the escalatedRisk test: the plan risk is high only with an
51
+ // escalation; without one a native high or unassessable tier becomes medium.
52
+ test("verificationRisk makes every escalated tier high and maps the rest to passive or medium", () => {
49
53
  const escalation = { item: 3 as const, reason: "changes a published export" };
50
- assert.equal(escalatedRisk(VERIFICATION_TIER.PASSIVE, escalation), VERIFICATION_TIER.HIGH);
51
- assert.equal(escalatedRisk(VERIFICATION_TIER.MEDIUM, escalation), VERIFICATION_TIER.HIGH);
52
- assert.equal(escalatedRisk(VERIFICATION_TIER.HIGH, escalation), VERIFICATION_TIER.HIGH);
53
- assert.equal(escalatedRisk(VERIFICATION_TIER.UNASSESSABLE, escalation), VERIFICATION_TIER.UNASSESSABLE);
54
- for (const tier of Object.values(VERIFICATION_TIER)) assert.equal(escalatedRisk(tier, undefined), tier);
54
+ for (const tier of Object.values(VERIFICATION_TIER)) assert.equal(verificationRisk(tier, escalation), VERIFICATION_TIER.HIGH, tier);
55
+ assert.equal(verificationRisk(VERIFICATION_TIER.PASSIVE, undefined), VERIFICATION_TIER.PASSIVE);
56
+ for (const tier of [VERIFICATION_TIER.MEDIUM, VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE]) {
57
+ assert.equal(verificationRisk(tier, undefined), VERIFICATION_TIER.MEDIUM, tier);
58
+ }
55
59
  });
56
60
 
57
- function assessTool(): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details: unknown }> } {
61
+ function assessTool(nativeRisk: "passive" | "medium" | "high" = "medium"): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details: unknown }> } {
58
62
  const tools = new Map<string, any>();
59
63
  const pi = { on() {}, registerCommand() {}, registerTool(tool: { name: string }) { tools.set(tool.name, tool); } } as unknown as ExtensionAPI;
60
64
  const nativeReviewCli: Partial<NativeReviewCli> = {
61
65
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
62
66
  assess: async () => ({
63
67
  schema: REVIEW_ASSESSMENT_SCHEMA,
64
- risk: "medium",
68
+ risk: nativeRisk,
65
69
  reasons: [],
66
70
  changedPaths: 1,
67
71
  changedLines: 3,
@@ -121,3 +125,26 @@ test("gentle_review assess: agentEscalation says whether the escalation changed
121
125
  const { details } = await tool.execute("esc-5", { operation: "assess", input }, undefined, undefined, ctx);
122
126
  assert.equal((details as { agentEscalation: { applied: boolean } }).agentEscalation.applied, true, "medium raised to high is applied");
123
127
  });
128
+
129
+ test("gentle_review assess: a native high without an escalation informs only and adds no verifier", async () => {
130
+ const tool = assessTool("high");
131
+ for (const writer of [{ writerModelId: "openai/gpt-6.1-sol", writerEffort: "high" }, { writerModelId: "openai/gpt-5-mini", writerEffort: "low" }]) {
132
+ const { details } = await tool.execute("esc-6", { operation: "assess", input: JSON.stringify(writer) }, undefined, undefined, ctx);
133
+ const plan = details as { risk: string; nativeRisk: string; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
134
+ assert.equal(plan.nativeRisk, VERIFICATION_TIER.HIGH);
135
+ assert.equal(plan.risk, VERIFICATION_TIER.MEDIUM, writer.writerModelId);
136
+ assert.equal(plan.plan.writerSelfVerification, true);
137
+ assert.equal(plan.plan.independentVerifier, false, `${writer.writerModelId}: neither a native high nor the writer's model adds a verifier`);
138
+ }
139
+ });
140
+
141
+ test("gentle_review assess: an escalation over a native high is what adds the verifier", async () => {
142
+ const tool = assessTool("high");
143
+ const input = JSON.stringify({ escalate: { item: 1, reason: "rewrites stored records" } });
144
+ const { details } = await tool.execute("esc-7", { operation: "assess", input }, undefined, undefined, ctx);
145
+ const plan = details as { risk: string; nativeRisk: string; agentEscalation: { applied: boolean }; plan: { independentVerifier: boolean } };
146
+ assert.equal(plan.nativeRisk, VERIFICATION_TIER.HIGH);
147
+ assert.equal(plan.risk, VERIFICATION_TIER.HIGH);
148
+ assert.equal(plan.agentEscalation.applied, true, "without the escalation the plan would be medium");
149
+ assert.equal(plan.plan.independentVerifier, true);
150
+ });
@@ -2,8 +2,11 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import fs, { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { syncBuiltinESMExports } from "node:module";
5
- import { basename, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
6
7
  import { PassThrough } from "node:stream";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { createSessionScratchExtension } from "../extensions/session-scratch.ts";
7
10
  import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
8
11
  import { MISSING_TOOLS_NOTE_PREFIX, TASK_STATUS, TaskStore, THREAD_ITEM, type TaskRecord } from "../lib/agents-protocol.ts";
9
12
  import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
@@ -618,6 +621,21 @@ test("runner captures resolved model and effort, retaining omitted launch values
618
621
  h.runner.cancel(task.id);
619
622
  });
620
623
 
624
+ test("record creation copies the launch-layer model source next to the model (#2180)", () => {
625
+ const sourced = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
626
+ const sourcedTask = sourced.runner.run(request({ modelSource: "binding" }));
627
+ assert.equal(sourced.store.get(sourcedTask.id)?.modelSource, "binding", "the record keeps the host-computed layer next to the resolved model");
628
+ sourced.runner.cancel(sourcedTask.id);
629
+
630
+ // The absence contract: a request without a model source (older callers,
631
+ // peers) records no field at all, so the card renders exactly as before.
632
+ const plain = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
633
+ const plainTask = plain.runner.run(request());
634
+ assert.equal(plain.store.get(plainTask.id)?.modelSource, undefined);
635
+ assert.equal("modelSource" in (plain.store.get(plainTask.id) as TaskRecord), false, "no modelSource key is recorded when the launch did not name a layer");
636
+ plain.runner.cancel(plainTask.id);
637
+ });
638
+
621
639
  test("runner delivers each response combination once at finish, never attributing launch selection", async () => {
622
640
  const snapshots: NonNullable<Parameters<NonNullable<RunnerHooks["onFinish"]>>[1]>[] = [];
623
641
  const h = harness({ exitOnKill: false, state: { model: { provider: "anthropic", id: "launch" }, thinkingLevel: "max" },
@@ -1029,10 +1047,13 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
1029
1047
  await tick();
1030
1048
  const ownedIpc = launches[0]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
1031
1049
  assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the runner creates an opaque owned-IPC marker");
1050
+ const scratch = launches[0]?.options.env.TMPDIR;
1051
+ assert.match(basename(scratch ?? ""), /^ga[A-Za-z0-9]{6}$/, "the child gets its own TMPDIR");
1052
+ const scratchEnv = platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
1032
1053
  assert.deepEqual(launches, [{
1033
1054
  command: "pi-fixture",
1034
1055
  args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."],
1035
- options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
1056
+ options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
1036
1057
  }]);
1037
1058
  child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] });
1038
1059
  child.emit({ type: "agent_settled" });
@@ -1115,7 +1136,8 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
1115
1136
  const launch = spawnOptions[0];
1116
1137
  const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
1117
1138
  assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
1118
- assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
1139
+ assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", TMPDIR: launch?.env.TMPDIR });
1140
+ assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
1119
1141
  assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
1120
1142
  assert.equal(launch?.stdio?.length, 5);
1121
1143
  children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "channel checked" }], stopReason: "stop" }] });
@@ -1778,3 +1800,238 @@ test("temporary instructions transport file is cleaned up if child emits an earl
1778
1800
  assert.ok(!existsSync(capturedPromptPath), "temporary transport file must be cleaned up on early child error");
1779
1801
  assert.ok(!existsSync(dirname(capturedPromptPath)), "temporary transport directory must be cleaned up on early child error");
1780
1802
  });
1803
+
1804
+ // Harness roadmap T3 (R1): asking the parent to remove a child's scratch failed
1805
+ // in 3 of 6 bench cells, so each child gets its own TMPDIR that the runtime
1806
+ // removes when the child ends.
1807
+ function scratchRunner(spawnImpl: (env: NodeJS.ProcessEnv) => ChildLike) {
1808
+ let clock = 1000;
1809
+ const deps: RunnerDeps = {
1810
+ spawn: (_command, _args, options) => spawnImpl((options as { env: NodeJS.ProcessEnv }).env),
1811
+ now: () => (clock += 1),
1812
+ schedule: (_fn, _ms) => () => {},
1813
+ pi: { command: "pi", args: [] },
1814
+ };
1815
+ return new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, deps, { askUser: async () => ({ value: "yes" }) });
1816
+ }
1817
+
1818
+ test("each child gets its own TMPDIR under the parent's temp dir, removed with its contents on exit", async () => {
1819
+ const fake = fakeChild();
1820
+ let childTmp = "";
1821
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
1822
+ const task = runner.run(request({ env: { ...process.env, TMPDIR: tmpdir() } }));
1823
+ await tick();
1824
+ assert.ok(childTmp.startsWith(tmpdir()) && childTmp !== tmpdir(), `child TMPDIR ${childTmp} is a fresh dir under ${tmpdir()}`);
1825
+ assert.ok(statSync(childTmp).isDirectory());
1826
+ const scratch = fs.mkdtempSync(`${childTmp}/check-`);
1827
+ fs.writeFileSync(`${scratch}/state.json`, "{}");
1828
+ fake.exit(0);
1829
+ await runner.waitFor(task.id);
1830
+ assert.equal(existsSync(childTmp), false, "the child's TMPDIR and everything mktemp created in it are removed");
1831
+ });
1832
+
1833
+ test("a child TMPDIR is removed when spawn throws", async () => {
1834
+ let childTmp = "";
1835
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; throw new Error("spawn failed intentionally"); });
1836
+ const task = runner.run(request());
1837
+ await tick();
1838
+ assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
1839
+ assert.ok(childTmp, "the failed launch still received a child TMPDIR");
1840
+ assert.equal(existsSync(childTmp), false);
1841
+ });
1842
+
1843
+ test("a quarantined child keeps its TMPDIR, since a descendant may still use it", async () => {
1844
+ const timers: Array<{ fn: () => void; ms: number }> = [];
1845
+ let now = 1_000;
1846
+ let childTmp = "";
1847
+ let fake: FakeChild | undefined;
1848
+ const store = new TaskStore();
1849
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
1850
+ spawn: (_command, _args, options) => { childTmp = (options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? ""; return (fake = fakeChild({ exitOnKill: false, pid: 91 })).child; },
1851
+ now: () => now,
1852
+ schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
1853
+ pi: { command: "pi", args: [] },
1854
+ process: { platform: "win32", kill: () => {} },
1855
+ }, { askUser: async () => ({ value: "yes" }) });
1856
+ const task = runner.run(request());
1857
+ await tick();
1858
+ runner.cancel(task.id);
1859
+ timers.find((timer) => timer.ms === 250)!.fn();
1860
+ now = 5_000;
1861
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
1862
+ await tick();
1863
+ assert.match(store.get(task.id)?.error ?? "", /capacity quarantined/);
1864
+ assert.equal(existsSync(childTmp), true, "an unconfirmed exit must not delete files a live descendant may hold");
1865
+ fake!.exit(0);
1866
+ await tick();
1867
+ assert.equal(existsSync(childTmp), false, "the proven exit removes the quarantined child's TMPDIR");
1868
+ });
1869
+
1870
+ // R1 review advisory: a quarantined POSIX child whose own exit arrived while a
1871
+ // descendant still held its group was never probed again, so its TMPDIR and
1872
+ // slot stayed for the rest of the session after the group ended.
1873
+ test("a quarantined child whose exit is seen before its group ends frees its TMPDIR and slot once the group is gone", async () => {
1874
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
1875
+ let now = 1_000;
1876
+ let groupAlive = true;
1877
+ let launches = 0;
1878
+ const childTmps: string[] = [];
1879
+ const store = new TaskStore();
1880
+ let fake: FakeChild | undefined;
1881
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
1882
+ spawn: (_command, _args, options) => {
1883
+ childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
1884
+ return (fake = fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) })).child;
1885
+ },
1886
+ now: () => now,
1887
+ schedule: (fn, ms) => {
1888
+ const timer = { fn, ms, cancelled: false };
1889
+ timers.push(timer);
1890
+ return () => { timer.cancelled = true; };
1891
+ },
1892
+ pi: { command: "pi", args: [] },
1893
+ process: { platform: "linux", kill: (_pid, signal) => {
1894
+ if (signal === 0 && !groupAlive) throw Object.assign(new Error("group probe"), { code: "ESRCH" });
1895
+ } },
1896
+ }, { askUser: async () => ({ value: "yes" }) });
1897
+ const first = runner.run(request());
1898
+ const second = runner.run(request({ prompt: "queued" }));
1899
+ await tick();
1900
+ const quarantined = fake!;
1901
+ runner.cancel(first.id);
1902
+ timers.find((timer) => timer.ms === 250)!.fn();
1903
+ now = 5_000;
1904
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
1905
+ await tick();
1906
+ assert.match(store.get(first.id)?.error ?? "", /capacity quarantined/);
1907
+ quarantined.exit(0);
1908
+ await tick();
1909
+ assert.equal(existsSync(childTmps[0]), true, "a descendant still holds the group, so the TMPDIR stays");
1910
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "the slot stays quarantined while the group lives");
1911
+ groupAlive = false;
1912
+ const probe = timers.filter((timer) => !timer.cancelled && timer.ms === 1_000).at(-1);
1913
+ assert.ok(probe, "the observed exit keeps probing the remaining group");
1914
+ probe.fn();
1915
+ await tick();
1916
+ assert.equal(existsSync(childTmps[0]), false, "the gone group removes the quarantined child's TMPDIR");
1917
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING, "the freed slot starts queued work");
1918
+ assert.equal(store.get(first.id)?.error, `process cleanup unconfirmed after 1000ms; capacity quarantined`, "the recorded outcome is unchanged");
1919
+ runner.cancel(second.id);
1920
+ fake!.exit(0);
1921
+ await tick();
1922
+ });
1923
+
1924
+ test("a relative inherited TMPDIR falls back to the system temp dir, so the child's path is absolute", async () => {
1925
+ const fake = fakeChild();
1926
+ let childTmp = "";
1927
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
1928
+ const task = runner.run(request({ env: { TMPDIR: "reltmp" } }));
1929
+ await tick();
1930
+ assert.ok(childTmp.startsWith(join(tmpdir(), "ga")), childTmp);
1931
+ fake.exit(0);
1932
+ await runner.waitFor(task.id);
1933
+ assert.equal(existsSync(childTmp), false);
1934
+ });
1935
+
1936
+ // odd/tasks/session-scratch-ledger.md S9: TMPDIR nests the session scratch and the
1937
+ // subagent's own TMPDIR, and a Unix socket path (104 bytes with the NUL on macOS)
1938
+ // under it must still fit, as Python's `pymp-XXXXXXXX/listener-XXXXXXXX` (32 bytes).
1939
+ test("S9: under a 48-byte inherited TMPDIR, a subagent TMPDIR leaves room for a 32-byte socket name", async (t) => {
1940
+ const INHERITED_BYTES = 48;
1941
+ const SOCKET_NAME = "/pymp-XXXXXXXX/listener-XXXXXXXX";
1942
+ // An unresolved parent, like macOS `/var/folders/xx/<30 chars>/T`, padded to 48 bytes.
1943
+ const parent = [tmpdir(), "/tmp"].map((root) => { try { return fs.mkdtempSync(join(root, "b")); } catch { return undefined; } })
1944
+ .find((dir): dir is string => dir !== undefined && Buffer.byteLength(dir) + 2 <= INHERITED_BYTES);
1945
+ if (!parent) { t.skip("no temp directory short enough to build a 48-byte path"); return; }
1946
+ t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
1947
+ const inherited = join(parent, "x".repeat(INHERITED_BYTES - Buffer.byteLength(parent) - 1));
1948
+ fs.mkdirSync(inherited);
1949
+ assert.equal(Buffer.byteLength(inherited), INHERITED_BYTES);
1950
+ const env: NodeJS.ProcessEnv = { TMPDIR: inherited, GENTLE_PI_CONFIG_HOME: join(parent, "config") };
1951
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
1952
+ createSessionScratchExtension({ env })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
1953
+ const ctx = { cwd: parent, hasUI: false, sessionManager: { getSessionId: () => "a-session-id-much-longer-than-the-scratch-name" } };
1954
+ handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx);
1955
+ t.after(() => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx));
1956
+ assert.equal(dirname(env.TMPDIR!), inherited, "the session scratch keeps the inherited spelling");
1957
+ const fake = fakeChild();
1958
+ let childTmp = "";
1959
+ const runner = scratchRunner((childEnv) => { childTmp = childEnv.TMPDIR ?? ""; return fake.child; });
1960
+ const task = runner.run(request({ env: { TMPDIR: env.TMPDIR } }));
1961
+ await tick();
1962
+ assert.equal(dirname(childTmp), env.TMPDIR, "the subagent TMPDIR nests under the session scratch");
1963
+ const socketPath = Buffer.byteLength(`${childTmp}${SOCKET_NAME}`);
1964
+ assert.ok(socketPath <= 103, `${childTmp}${SOCKET_NAME} is ${socketPath} bytes`);
1965
+ fake.exit(0);
1966
+ await runner.waitFor(task.id);
1967
+ });
1968
+
1969
+ // odd/tasks/scratch-review-advisories.md S2 (native review advisories of #2300):
1970
+ // session shutdown removed the whole scratch, including the TMPDIR of a child
1971
+ // still running or quarantined, whose descendants may still hold files there.
1972
+ test("advisories S2: session shutdown keeps a running or quarantined subagent's TMPDIR, removes the rest, and leaves the scratch to the sweep", async (t) => {
1973
+ const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "gentle-pi-advisories-s2-")));
1974
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
1975
+ const base = join(root, "tmp");
1976
+ fs.mkdirSync(base);
1977
+ const configHome = join(root, "config");
1978
+ const env: NodeJS.ProcessEnv = { TMPDIR: base, GENTLE_PI_CONFIG_HOME: configHome };
1979
+ const scratchSession = (sessionId: string, options: { isProcessAlive?: (pid: number) => boolean; now?: () => number } = {}) => {
1980
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
1981
+ createSessionScratchExtension({ env, ...options })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
1982
+ const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => sessionId } };
1983
+ return {
1984
+ start: () => handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx),
1985
+ shutdown: () => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx),
1986
+ };
1987
+ };
1988
+ const parent = scratchSession("s-1");
1989
+ parent.start();
1990
+ const scratch = env.TMPDIR!;
1991
+ assert.equal(dirname(scratch), base);
1992
+ const timers: Array<{ fn: () => void; ms: number }> = [];
1993
+ let now = 1_000;
1994
+ const fakes: FakeChild[] = [];
1995
+ const childTmps: string[] = [];
1996
+ const store = new TaskStore();
1997
+ const runner = new AgentRunner(store, { maxConcurrency: 2, stallTimeoutMs: 10_000 }, {
1998
+ spawn: (_command, _args, options) => {
1999
+ childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
2000
+ const fake = fakeChild({ exitOnKill: false, pid: 80 + fakes.length });
2001
+ fakes.push(fake);
2002
+ return fake.child;
2003
+ },
2004
+ now: () => now,
2005
+ schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
2006
+ pi: { command: "pi", args: [] },
2007
+ process: { platform: "win32", kill: () => {} },
2008
+ }, { askUser: async () => ({ value: "yes" }) });
2009
+ const quarantined = runner.run(request({ env: { TMPDIR: scratch } }));
2010
+ const running = runner.run(request({ prompt: "running", env: { TMPDIR: scratch } }));
2011
+ await tick();
2012
+ runner.cancel(quarantined.id);
2013
+ timers.find((timer) => timer.ms === 250)!.fn();
2014
+ now = 5_000;
2015
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
2016
+ await tick();
2017
+ assert.match(store.get(quarantined.id)?.error ?? "", /capacity quarantined/);
2018
+ assert.deepEqual(childTmps.map((dir) => dirname(dir)), [scratch, scratch]);
2019
+ fs.writeFileSync(join(childTmps[0], "held.txt"), "x");
2020
+ fs.writeFileSync(join(scratch, "junk.txt"), "x");
2021
+ parent.shutdown();
2022
+ assert.ok(existsSync(join(childTmps[0], "held.txt")), "a quarantined child's TMPDIR stays");
2023
+ assert.ok(existsSync(childTmps[1]), "a running child's TMPDIR stays");
2024
+ assert.equal(existsSync(join(scratch, "junk.txt")), false, "the rest of the scratch is removed");
2025
+ assert.equal(env.TMPDIR, base, "the inherited TMPDIR is restored");
2026
+ const ledger = JSON.parse(readFileSync(join(configHome, "scratch", "s-1.json"), "utf8")) as { scratchDir: string | null };
2027
+ assert.equal(ledger.scratchDir, scratch, "the ledger keeps the scratch for a later sweep");
2028
+ fakes[0].exit(0);
2029
+ runner.cancel(running.id);
2030
+ fakes[1].exit(0);
2031
+ await tick();
2032
+ assert.ok(childTmps.every((dir) => !existsSync(dir)), "each proven exit removes its TMPDIR");
2033
+ const next = scratchSession("next", { isProcessAlive: () => false, now: () => Date.now() + 2 * 3_600_000 });
2034
+ next.start();
2035
+ assert.equal(existsSync(scratch), false, "the startup sweep removes the kept scratch once its owner ended");
2036
+ next.shutdown();
2037
+ });
@@ -115,8 +115,25 @@ test("both extensions land their block in appendSystemPrompt on one shared optio
115
115
 
116
116
  const appended = event.systemPromptOptions.appendSystemPrompt;
117
117
  assert.match(appended, /el Gentleman Identity and Harness/);
118
+ const continuityStart = appended.indexOf("## Authorized work continuity");
119
+ const coreStart = appended.indexOf("# el Gentleman Orchestrator");
120
+ assert.ok(continuityStart >= 0 && continuityStart < coreStart,
121
+ "continuity must be always injected in the identity/harness, outside the budgeted core");
122
+ assert.equal(appended.split("## Authorized work continuity").length - 1, 1);
123
+ const continuity = appended.slice(continuityStart, coreStart);
124
+ for (const clause of [
125
+ "necessary in-scope implementation, documentation and verification steps",
126
+ "Never end a turn merely announcing an immediately executable authorized next step; execute it instead",
127
+ "required explicit consent, a denied action, unavailable required proof, or the human's explicit stop",
128
+ "Do not infer permission for publishing, destructive actions, unrelated scope or native consent",
129
+ "Report a concrete blocker and the exact decision needed",
130
+ "launch the bounded action and end the turn when no independent in-scope work remains",
131
+ "Retain its identity and resume the already-authorized continuation automatically on its completion event",
132
+ "do not ask the human to say \"continue\", poll, or claim completion while checks remain pending",
133
+ ]) assert.ok(continuity.includes(clause), `missing continuity clause: ${clause}`);
118
134
  assert.match(appended, /## Todo list/);
119
- assert.match(appended, /1\. \[pending\] Fix the bug/);
135
+ // gentle-shell#1662: the list itself travels as a request-local message, never in the prompt.
136
+ assert.doesNotMatch(appended, /Fix the bug/);
120
137
  assert.ok(
121
138
  appended.indexOf("el Gentleman Identity and Harness") < appended.indexOf("## Todo list"),
122
139
  "gentle-ai's block must precede gentle-todo's, matching handler registration order",
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
7
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
8
+ import choiceExtension from "../extensions/ask-user-choice.ts";
9
+ import questionnaireExtension from "../extensions/ask-user-question.ts";
10
+
11
+ for (const toolName of ["ask_user_choice", "ask_user_question"] as const) {
12
+ test(`public ${toolName} UI answer unpauses Auto without synthetic input`, async t => {
13
+ const root=mkdtempSync(join(tmpdir(),"auto-human-answer-")), home=join(root,"config");
14
+ mkdirSync(home);t.after(()=>rmSync(root,{recursive:true,force:true}));
15
+ const manager=SessionManager.inMemory(root);
16
+ const hooks=new Map<string,any[]>(), commands=new Map<string,any>(), tools=new Map<string,any>();
17
+ const requests:any[]=[];
18
+ let allow=false;
19
+ const pi:any={
20
+ events:{on(){return ()=>{};},emit(){}},
21
+ on(name:string,fn:any){hooks.set(name,[...hooks.get(name)??[],fn]);},
22
+ registerTool(tool:any){tools.set(tool.name,tool);},
23
+ registerCommand(name:string,command:any){commands.set(name,command);},
24
+ registerFlag(){},registerMessageRenderer(){},getActiveTools(){return [];},appendEntry(){},
25
+ };
26
+ const ctx:any={cwd:root,mode:"tui",hasUI:true,sessionManager:manager,model:{id:"fixture",provider:"fixture"},
27
+ signal:new AbortController().signal,
28
+ ui:{notify(){},setStatus(){},setWidget(){},confirm:async()=>false,
29
+ custom:async()=>toolName==="ask_user_choice" ? {index:1,label:"Yes",value:"opaque-choice"} :
30
+ {cancelled:false,answers:[{questionIndex:0,question:"May I edit source.ts?",kind:"option",answer:"Yes"}]}},
31
+ };
32
+ createGentleAiExtension({processEnv:{GENTLE_PI_CONFIG_HOME:home},nativeReviewCli:null,candidateViews:null,
33
+ autoPermissionClassifier:{cancel(){},async run(request:any){requests.push(request);return {status:"available",decision:allow?"allow":"deny",reason:"Fixture scope"};}},
34
+ resolveTelemetryTriggerBinary:()=>{throw Error("native disabled in fixture");},
35
+ })(pi);
36
+ choiceExtension(pi);questionnaireExtension(pi);
37
+ const emit=async(name:string,event:any)=>{
38
+ let output:any;
39
+ for(const hook of hooks.get(name)??[]){const next=await hook(event,ctx);if(next!==undefined)output=next;}
40
+ return output;
41
+ };
42
+ await commands.get("gentle:mode").handler("auto",ctx);
43
+ await emit("input",{source:"interactive",text:"Inspect only; ask before editing."});
44
+ manager.appendMessage({role:"user",content:"Inspect only; ask before editing.",timestamp:1});
45
+ const action={toolName:"write",toolCallId:"edit",input:{path:join(root,"source.ts"),content:"x"}};
46
+ for(let i=0;i<3;i++)assert.equal((await emit("tool_call",action))?.block,true);
47
+ assert.match((await emit("tool_call",action)).reason,/paused/);
48
+ const question="May I edit source.ts?";
49
+ const options=[{label:"Yes",description:"Edit source.ts only; do not publish.",value:"opaque-choice"},
50
+ {label:"No",description:"Keep this task read-only.",value:"opaque-no"}];
51
+ const args=toolName==="ask_user_choice" ? {question,options} :
52
+ {questions:[{question,header:"Scope",options:options.map(({label,description})=>({label,description}))}]};
53
+ manager.appendMessage({role:"assistant",content:[{type:"toolCall",id:"question",name:toolName,arguments:args}],
54
+ api:"fixture" as any,provider:"fixture",model:"fixture",usage:{input:0,output:0,cacheRead:0,cacheWrite:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}},stopReason:"toolUse",timestamp:2});
55
+ assert.equal((await emit("tool_call",{toolName,toolCallId:"question",input:args}))?.block,undefined);
56
+ const result=await tools.get(toolName).execute("question",args,ctx.signal,undefined,ctx);
57
+ assert.ok(result.content.length>0,"the original question result is retained");
58
+ allow=true;
59
+ assert.equal((await emit("tool_call",action))?.block,undefined);
60
+ assert.deepEqual(requests.at(-1).trustedMessages,["Inspect only; ask before editing.","Yes"]);
61
+ const pair=requests.at(-1).answeredPrompts.find((item:any)=>item.reply==="Yes");
62
+ assert.match(pair.prompt,/Edit source.ts only; do not publish\./);
63
+ assert.ok(!pair.prompt.includes("opaque-choice"));
64
+ await emit("session_tree",{});
65
+ await emit("tool_call",action);
66
+ assert.deepEqual(requests.at(-1).trustedMessages,[],"tree reset cannot recover UI authority from result text");
67
+ });
68
+ }
@@ -29,19 +29,22 @@ function fixture(t: test.TestContext) {
29
29
  const manager = SessionManager.inMemory(root), anchor = manager.appendCustomEntry("probe-anchor", {});
30
30
  const commands = new Map<string, any>(), hooks = new Map<string, any[]>();
31
31
  const receipts: any[] = [], providerCalls: any[] = [], notices: string[] = [], statuses: any[] = [];
32
- let decision = "allow", pending = false, announce!: () => void, receiptChanged: (() => void) | undefined;
32
+ let decision = "allow", pending = false, inspectPath: string | undefined, announce!: () => void, receiptChanged: (() => void) | undefined;
33
33
  const started = new Promise<void>(resolve => { announce = resolve; });
34
34
  const registry: any = {
35
35
  streamSimple(selected: any, context: any, options: any) {
36
- assert.deepEqual(context.tools, []);
37
- assert.equal(options.toolChoice, "none");
36
+ const paths=JSON.parse(context.messages[0].content).evidencePaths ?? [];
37
+ assert.equal(options.toolChoice, paths.length ? "auto" : "none");
38
+ assert.deepEqual(context.tools.map((tool:any)=>tool.name),paths.length ? ["inspect_permission_file"] : []);
39
+ if(paths.length)assert.deepEqual(context.tools[0].parameters.properties.path.enum,paths);
38
40
  assert.equal(options.maxRetries, 0);
39
41
  let resolve!: (value: any) => void;
40
42
  const promise = new Promise(r => { resolve = r; });
41
43
  const call = { selected, context, options, resolve: () => resolve({
42
44
  role: "assistant", api: "fixture", provider: selected.provider, model: selected.id,
43
- stopReason: "stop", timestamp: 1,
44
- content: [{ type: "text", text: JSON.stringify({ decision, reason: decision === "deny"
45
+ stopReason: inspectPath && context.messages.length===1 ? "toolUse" : "stop", timestamp: 1,
46
+ content: inspectPath && context.messages.length===1 ? [{type:"toolCall",id:"inspect",name:"inspect_permission_file",arguments:{path:inspectPath}}] :
47
+ [{ type: "text", text: JSON.stringify({ decision, reason: decision === "deny"
45
48
  ? "Only inspection is authorized; do not change files." : "Local fixture action." }) }],
46
49
  usage: { input: 5, output: 3, cacheRead: 0, cacheWrite: 0, totalTokens: 8,
47
50
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.02 } },
@@ -81,6 +84,7 @@ function fixture(t: test.TestContext) {
81
84
  call: (toolName: string, input: any) => emit("tool_call", { toolName, input, toolCallId: "probe-call" }),
82
85
  enable: () => commands.get("gentle:mode").handler("auto", ctx),
83
86
  setDecision(value: string) { decision = value; }, setPending(value: boolean) { pending = value; },
87
+ inspect(path: string) { inspectPath=path; },
84
88
  started: () => deadline(started, "one inference started"),
85
89
  waitForReceipts(count: number) {
86
90
  if (receipts.length >= count) return Promise.resolve();
@@ -91,6 +95,20 @@ function fixture(t: test.TestContext) {
91
95
  };
92
96
  }
93
97
 
98
+ test("public hook supplies exact script evidence without executing or modifying the script", async t=>{
99
+ const f=fixture(t);mkdirSync(join(f.root,"scripts"));
100
+ const script=join(f.root,"scripts","check.mjs"), content="console.log('fixture-only');\n";
101
+ writeFileSync(script,content);
102
+ await f.enable();await f.input("Run the local checker after inspecting its effects.");
103
+ f.inspect("scripts/check.mjs");
104
+ const result=await f.call("bash",{command:"node scripts/check.mjs"});
105
+ assert.equal(result?.block,undefined);
106
+ assert.equal(f.providerCalls.length,2);
107
+ const evidence=f.providerCalls.at(-1).context.messages.find((message:any)=>message.role==="toolResult");
108
+ assert.deepEqual(JSON.parse(evidence.content[0].text).untrustedFileEvidence,{path:"scripts/check.mjs",content});
109
+ assert.equal(readFileSync(script,"utf8"),content,"inspection is read-only");
110
+ });
111
+
94
112
  function deadline<T>(event: Promise<T>, label: string): Promise<T> {
95
113
  return new Promise((resolve, reject) => {
96
114
  const timer = setTimeout(() => reject(new Error(`Deadline: ${label}`)), 1500);