gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,253 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import test from "node:test";
4
+ import { createJiti } from "jiti";
5
+ import { initTheme } from "@earendil-works/pi-coding-agent";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ initTheme("dark");
9
+
10
+ test("a child process cannot overwrite the primary process phase even with the same session id", async () => {
11
+ const registry = (await createJiti(import.meta.url, { moduleCache: false }).import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
12
+ const sessionId = "process-isolation-test";
13
+ try {
14
+ registry.report(sessionId, "authorizing");
15
+ const output = execFileSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e",
16
+ `import { oddPhaseRegistry } from ${JSON.stringify(new URL("../lib/odd-phase.ts", import.meta.url).href)}; console.log(oddPhaseRegistry.get("process-isolation-test") ?? "empty"); oddPhaseRegistry.report("process-isolation-test", "closing");`], { encoding: "utf8" });
17
+ assert.equal(output.trim(), "empty");
18
+ assert.equal(registry.get(sessionId), "authorizing");
19
+ } finally { registry.clear(sessionId); }
20
+ });
21
+
22
+ // Pi gives each extension its own uncached jiti loader. Exercise the actual
23
+ // writer tool and reader prompt, not two imports through Node's module cache.
24
+ test("separate extension loaders share only the active session's ODD phase and redraw", async () => {
25
+ const aiLoader = createJiti(import.meta.url, { moduleCache: false });
26
+ const shellLoader = createJiti(import.meta.url, { moduleCache: false });
27
+ const { createGentleAiExtension } = await aiLoader.import<typeof import("../extensions/gentle-ai.ts")>("../extensions/gentle-ai.ts");
28
+ const { default: shell } = await shellLoader.import<typeof import("../extensions/gentle-shell.ts")>("../extensions/gentle-shell.ts");
29
+ const aiRegistry = (await aiLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
30
+ const shellRegistry = (await shellLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
31
+ const tools = new Map<string, {
32
+ execute: (...args: unknown[]) => Promise<unknown>;
33
+ renderShell?: string;
34
+ renderCall?: (args: unknown, theme: unknown, context: unknown) => { render(width: number): string[]; invalidate(): void };
35
+ renderResult?: (result: unknown, options: unknown, theme: unknown, context: unknown) => { render(width: number): string[]; invalidate(): void };
36
+ }>();
37
+ const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => unknown>>();
38
+ const pi = {
39
+ on(name: string, fn: (event: unknown, ctx: unknown) => unknown) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
40
+ registerTool(tool: { name: string; execute: (...args: unknown[]) => Promise<unknown> }) { tools.set(tool.name, tool); },
41
+ registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, appendEntry() {},
42
+ events: { on: () => () => {}, emit() {} },
43
+ exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
44
+ };
45
+ createGentleAiExtension({ nativeReviewCli: null } as never)(pi as never);
46
+ shell(pi as never, {}, { resolveWorktree: (path: string) => ({ root: path, commonDir: path }), gitRunner: () => async () => ({ stdout: "", stderr: "", code: 0, killed: false }), devBinary: () => undefined } as never);
47
+ let sessionId: string | undefined = "primary-loader-test";
48
+ let editorFactory: ((tui: unknown, theme: unknown, bindings: unknown) => { render(width: number): string[]; setAnimationPolicy(policy: string): void; dispose(): void }) | undefined;
49
+ let redraws = 0;
50
+ const ui = {
51
+ theme: { fg: (_color: string, text: string) => text, bold: (text: string) => text },
52
+ setFooter() {}, setWidget() {}, setWorkingVisible() {}, notify() {},
53
+ getEditorComponent: () => editorFactory,
54
+ setEditorComponent(factory: typeof editorFactory) { editorFactory = factory; },
55
+ };
56
+ const ctx = {
57
+ hasUI: true, mode: "tui", cwd: "/repo", ui,
58
+ sessionManager: { getSessionId: () => sessionId, getEntries: () => [], getCwd: () => "/repo" },
59
+ hasPendingMessages: () => false, isIdle: () => true,
60
+ modelRegistry: { isUsingOAuth: () => false },
61
+ };
62
+ const fire = async (name: string) => { for (const fn of handlers.get(name) ?? []) await fn({}, ctx); };
63
+ let editor: ReturnType<NonNullable<typeof editorFactory>> | undefined;
64
+ try {
65
+ await fire("session_start");
66
+ assert.ok(editorFactory, "Gentle Shell should install its prompt");
67
+ editor = editorFactory({ terminal: { rows: 40, columns: 120 }, requestRender() { redraws++; } }, { borderColor: (text: string) => text, selectList: {} }, { matches: () => false });
68
+ editor.setAnimationPolicy("potato");
69
+ await fire("agent_start");
70
+ const render = () => stripAnsi(editor!.render(60)[0]!);
71
+ assert.match(render(), /working…/);
72
+ assert.ok(tools.get("gentle_odd_phase"));
73
+ const report = (phase: string) => tools.get("gentle_odd_phase")!.execute("call", { phase }, undefined, undefined, ctx);
74
+ redraws = 0;
75
+ const phaseTool = tools.get("gentle_odd_phase")!;
76
+ const successful = await report("authorizing");
77
+ assert.equal(phaseTool.renderShell, "self", "Pi must not paint its default tool card");
78
+ const toolTheme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
79
+ const renderContext = (isError: boolean) => ({
80
+ args: { phase: "authorizing" }, toolCallId: "call", invalidate() {}, lastComponent: undefined,
81
+ state: {}, cwd: "/repo", executionStarted: true, argsComplete: true,
82
+ isPartial: false, expanded: false, showImages: false, isError,
83
+ });
84
+ const renderTool = (component: { render(width: number): string[]; invalidate(): void }) => component.render(80);
85
+ assert.ok(phaseTool.renderCall && phaseTool.renderResult);
86
+ assert.deepEqual(renderTool(phaseTool.renderCall({ phase: "authorizing" }, toolTheme, renderContext(false))), []);
87
+ assert.deepEqual(renderTool(phaseTool.renderResult(successful, { isPartial: false, expanded: false }, toolTheme, renderContext(false))), []);
88
+ assert.deepEqual(renderTool(phaseTool.renderResult(successful, { isPartial: false, expanded: true }, toolTheme, renderContext(false))), []);
89
+ assert.match(render(), /authorizing…/, "suppressing the transcript must not suppress the editor phase");
90
+ assert.ok(redraws > 0, "the phase must request redraw even without a pulse");
91
+ assert.equal(aiRegistry.get(sessionId), "authorizing");
92
+ assert.equal(shellRegistry.get(sessionId), "authorizing");
93
+ for (const [id, phase, expected] of [
94
+ ["primary-loader-test", "not-a-phase", /Invalid ODD phase/],
95
+ [undefined, "exploring", /No active session/],
96
+ ] as const) {
97
+ sessionId = id;
98
+ let message = "";
99
+ await assert.rejects(() => report(phase), (error: unknown) => {
100
+ assert.ok(error instanceof Error);
101
+ message = error.message;
102
+ assert.match(message, expected);
103
+ return true;
104
+ });
105
+ const failure = { content: [{ type: "text", text: message }], isError: true };
106
+ assert.match(renderTool(phaseTool.renderResult(failure, { isPartial: false, expanded: false }, toolTheme, renderContext(true))).join("\n"), expected);
107
+ assert.match(renderTool(phaseTool.renderResult(failure, { isPartial: false, expanded: true }, toolTheme, renderContext(true))).join("\n"), expected);
108
+ }
109
+ const callComponent = phaseTool.renderCall({ phase: "checking" }, toolTheme, renderContext(false));
110
+ const resultComponent = phaseTool.renderResult(successful, { isPartial: false, expanded: false }, toolTheme, renderContext(false));
111
+ for (const component of [callComponent, resultComponent]) {
112
+ assert.equal(typeof component.invalidate, "function");
113
+ component.invalidate();
114
+ assert.deepEqual(component.render(80), []);
115
+ }
116
+ sessionId = "primary-loader-test";
117
+ assert.match(render(), /authorizing…/, "a malformed report cannot erase the last valid phase");
118
+ const cleared = await report("clear");
119
+ assert.deepEqual(renderTool(phaseTool.renderResult(cleared, { isPartial: false, expanded: false }, toolTheme, renderContext(false))), []);
120
+ assert.match(render(), /working…/);
121
+ await report("authorizing");
122
+ sessionId = "child-session";
123
+ await report("exploring");
124
+ assert.equal(aiRegistry.get("primary-loader-test"), "authorizing");
125
+ sessionId = "primary-loader-test";
126
+ assert.match(render(), /authorizing…/);
127
+ await fire("agent_settled");
128
+ assert.equal(aiRegistry.get(sessionId), undefined);
129
+ await fire("agent_start");
130
+ assert.match(render(), /working…/);
131
+ await report("checking");
132
+ await fire("agent_start");
133
+ assert.match(render(), /working…/);
134
+ await report("closing");
135
+ await fire("session_shutdown");
136
+ assert.equal(aiRegistry.get(sessionId), undefined);
137
+ redraws = 0;
138
+ await report("planning");
139
+ assert.equal(redraws, 0, "shutdown must remove the old render callback");
140
+ } finally {
141
+ aiRegistry.clear("primary-loader-test");
142
+ aiRegistry.clear("child-session");
143
+ editor?.dispose();
144
+ }
145
+ });
146
+
147
+ // The label follows the primary session's own tool activity without any
148
+ // gentle_odd_phase call; an explicit report still refines it.
149
+ test("tool activity in the primary session drives the working label across extension loaders", async () => {
150
+ const aiLoader = createJiti(import.meta.url, { moduleCache: false });
151
+ const shellLoader = createJiti(import.meta.url, { moduleCache: false });
152
+ const { createGentleAiExtension } = await aiLoader.import<typeof import("../extensions/gentle-ai.ts")>("../extensions/gentle-ai.ts");
153
+ const { default: shell } = await shellLoader.import<typeof import("../extensions/gentle-shell.ts")>("../extensions/gentle-shell.ts");
154
+ const registry = (await aiLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
155
+ const tools = new Map<string, { execute: (...args: unknown[]) => Promise<unknown> }>();
156
+ const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => unknown>>();
157
+ const pi = {
158
+ on(name: string, fn: (event: unknown, ctx: unknown) => unknown) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
159
+ registerTool(tool: { name: string; execute: (...args: unknown[]) => Promise<unknown> }) { tools.set(tool.name, tool); },
160
+ registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, appendEntry() {},
161
+ events: { on: () => () => {}, emit() {} },
162
+ exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
163
+ };
164
+ createGentleAiExtension({ nativeReviewCli: null } as never)(pi as never);
165
+ shell(pi as never, {}, { resolveWorktree: (path: string) => ({ root: path, commonDir: path }), gitRunner: () => async () => ({ stdout: "", stderr: "", code: 0, killed: false }), devBinary: () => undefined } as never);
166
+ let sessionId = "tool-activity-loader-test";
167
+ let redraws = 0;
168
+ let editorFactory: ((tui: unknown, theme: unknown, bindings: unknown) => { render(width: number): string[]; setAnimationPolicy(policy: string): void; dispose(): void }) | undefined;
169
+ const ui = {
170
+ theme: { fg: (_color: string, text: string) => text, bold: (text: string) => text },
171
+ setFooter() {}, setWidget() {}, setWorkingVisible() {}, notify() {},
172
+ getEditorComponent: () => editorFactory,
173
+ setEditorComponent(factory: typeof editorFactory) { editorFactory = factory; },
174
+ };
175
+ const ctx = {
176
+ hasUI: true, mode: "tui", cwd: "/repo", ui,
177
+ sessionManager: { getSessionId: () => sessionId, getEntries: () => [], getCwd: () => "/repo" },
178
+ hasPendingMessages: () => false, isIdle: () => true,
179
+ modelRegistry: { isUsingOAuth: () => false },
180
+ };
181
+ const fire = async (name: string, event: unknown = {}, context: unknown = ctx) => { for (const fn of handlers.get(name) ?? []) await fn(event, context); };
182
+ const tool = (toolName: string, args: unknown = {}) => fire("tool_execution_start", { type: "tool_execution_start", toolCallId: `call-${toolName}`, toolName, args });
183
+ let editor: ReturnType<NonNullable<typeof editorFactory>> | undefined;
184
+ try {
185
+ await fire("session_start");
186
+ assert.ok(editorFactory, "Gentle Shell should install its prompt");
187
+ editor = editorFactory({ terminal: { rows: 40, columns: 120 }, requestRender() { redraws++; } }, { borderColor: (text: string) => text, selectList: {} }, { matches: () => false });
188
+ editor.setAnimationPolicy("potato");
189
+ await fire("agent_start");
190
+ const render = () => stripAnsi(editor!.render(60)[0]!);
191
+ assert.match(render(), /working…/);
192
+
193
+ await tool("read", { path: "lib/odd-phase.ts" });
194
+ assert.match(render(), /exploring…/, "reading a file shows exploring");
195
+ await tool("edit", { path: "lib/odd-phase.ts" });
196
+ assert.match(render(), /implementing…/, "editing shows implementing");
197
+ await tool("bash", { command: "pnpm test" });
198
+ assert.match(render(), /checking…/, "running tests shows checking");
199
+ await tool("ask_user_question", {});
200
+ assert.match(render(), /deciding…/, "asking the user shows deciding");
201
+ await tool("mcp__custom-tools__write", { path: "odd/tasks/feature.md" });
202
+ assert.match(render(), /planning…/, "MCP-prefixed names are normalized");
203
+ await tool("subagent_start", {});
204
+ await tool("bash", { command: "git push" });
205
+ await tool("unknown_tool", {});
206
+ assert.match(render(), /planning…/, "unknown tools and ambiguous commands leave the label unchanged");
207
+ await tool("subagent_run", { agent: "gentle-ai-worker", mode: "task", task: "implement" });
208
+ assert.match(render(), /implementing…/, "foreground worker launch replaces stale planning");
209
+ redraws = 0;
210
+ await tool("read", { path: "lib/odd-phase.ts" });
211
+ await tool("todo", {});
212
+ await tool("write", { path: "odd/tasks/feature.md" });
213
+ assert.match(render(), /implementing…/, "incidental reads and task bookkeeping cannot hide worker activity");
214
+ assert.equal(redraws, 0, "ignored signals do not redraw the editor");
215
+ await tool("subagent_run", { agent: "gentle-ai-verify", mode: "background", task: "verify" });
216
+ assert.match(render(), /checking…/, "background verifier launch replaces active implementation");
217
+ assert.equal(redraws, 1, "phase change redraws even under potato animation");
218
+ await tool("read", { path: "tests/odd-phase.test.ts" });
219
+ await tool("todo", {});
220
+ assert.match(render(), /checking…/);
221
+ await tool("subagent_run", { agent: "unknown", task: "gentle-ai-worker implementing" });
222
+ assert.match(render(), /checking…/, "unknown agent prose is not evidence");
223
+ await tool("subagent_run", { agent: "gentle-ai-explore", mode: "task" });
224
+ assert.match(render(), /exploring…/, "later delegated exploration is a real phase transition");
225
+ await tool("subagent_run", { agent: "gentle-ai-worker", mode: "background" });
226
+ assert.match(render(), /implementing…/, "background worker replaces stale exploring");
227
+ await tool("subagent_run", { agent: "gentle-ai-verify", mode: "task" });
228
+ assert.match(render(), /checking…/, "foreground verifier replaces stale work");
229
+ await tool("edit", { path: "lib/odd-phase.ts" });
230
+ assert.match(render(), /implementing…/, "a subsequent source edit can restart implementation");
231
+
232
+ await tools.get("gentle_odd_phase")!.execute("call", { phase: "researching" }, undefined, undefined, ctx);
233
+ await tool("gentle_odd_phase", { phase: "researching" });
234
+ await tool("grep", { pattern: "x" });
235
+ assert.match(render(), /researching…/, "an explicit phase survives a following grep");
236
+ await tool("edit", { path: "lib/odd-phase.ts" });
237
+ assert.match(render(), /implementing…/, "a stronger inferred phase overrides an explicit one");
238
+
239
+ await fire("agent_start");
240
+ assert.match(render(), /working…/, "a new turn starts unlabeled");
241
+ const childCtx = { ...ctx, mode: "rpc" };
242
+ await fire("tool_execution_start", { type: "tool_execution_start", toolCallId: "child", toolName: "subagent_run", args: { agent: "gentle-ai-worker" } }, childCtx);
243
+ assert.equal(registry.get(sessionId), undefined, "a headless RPC (subagent) process never infers a phase");
244
+ sessionId = "other-session";
245
+ await tool("subagent_run", { agent: "gentle-ai-verify" });
246
+ assert.equal(registry.get("tool-activity-loader-test"), undefined, "another session cannot relabel the primary session");
247
+ assert.equal(registry.get(sessionId), "checking");
248
+ } finally {
249
+ registry.clear("tool-activity-loader-test");
250
+ registry.clear("other-session");
251
+ editor?.dispose();
252
+ }
253
+ });
@@ -0,0 +1,307 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { isOddPhase, oddPhaseLabel, oddPhaseRegistry, ODD_PHASES, OddPhaseRegistry } from "../lib/odd-phase.ts";
5
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
6
+
7
+ // Bounded ODD phase signal for the Gentle prompt's working label: inferred
8
+ // from observed tool activity (lib/odd-phase-inference.ts) and refined by
9
+ // explicit orchestrator reports through gentle_odd_phase.
10
+
11
+ test("ODD_PHASES is the fixed orchestrator-reportable vocabulary covering every ODD protocol step (deciding covers both step 3 and step 4)", () => {
12
+ assert.deepEqual(ODD_PHASES, ["authorizing", "exploring", "researching", "deciding", "planning", "implementing", "checking", "closing"]);
13
+ });
14
+
15
+ test("isOddPhase accepts only the bounded vocabulary", () => {
16
+ for (const phase of ODD_PHASES) assert.equal(isOddPhase(phase), true);
17
+ assert.equal(isOddPhase("coding"), false);
18
+ assert.equal(isOddPhase(""), false);
19
+ assert.equal(isOddPhase(undefined), false);
20
+ assert.equal(isOddPhase(42), false);
21
+ });
22
+
23
+ test("oddPhaseLabel renders the working-style ellipsis label for every ODD_PHASES member", () => {
24
+ for (const phase of ODD_PHASES) assert.equal(oddPhaseLabel(phase), `${phase}…`);
25
+ });
26
+
27
+ test("OddPhaseRegistry.report stores a valid phase for a session and label() renders it", () => {
28
+ const registry = new OddPhaseRegistry();
29
+ assert.equal(registry.report("session-a", "exploring"), "exploring");
30
+ assert.equal(registry.get("session-a"), "exploring");
31
+ assert.equal(registry.label("session-a"), "exploring…");
32
+ });
33
+
34
+ test("OddPhaseRegistry.clear resets a session's phase directly", () => {
35
+ const registry = new OddPhaseRegistry();
36
+ registry.report("session-a", "checking");
37
+ registry.clear("session-a");
38
+ assert.equal(registry.get("session-a"), undefined);
39
+ assert.equal(registry.label("session-a"), undefined);
40
+ });
41
+
42
+ test("OddPhaseRegistry scopes phases per session; one session never sees another's report", () => {
43
+ const registry = new OddPhaseRegistry();
44
+ registry.report("session-a", "exploring");
45
+ registry.report("session-b", "checking");
46
+ assert.equal(registry.get("session-a"), "exploring");
47
+ assert.equal(registry.get("session-b"), "checking");
48
+ registry.clear("session-a");
49
+ assert.equal(registry.get("session-a"), undefined);
50
+ assert.equal(registry.get("session-b"), "checking", "clearing one session must not affect another");
51
+ });
52
+
53
+ test("OddPhaseRegistry ignores reports and reads without a session id", () => {
54
+ const registry = new OddPhaseRegistry();
55
+ assert.equal(registry.report(undefined, "exploring"), undefined);
56
+ assert.equal(registry.get(undefined), undefined);
57
+ assert.equal(registry.label(undefined), undefined);
58
+ registry.clear(undefined);
59
+ });
60
+
61
+ test("OddPhaseRegistry.report requests a redraw through the registered callback for that session", () => {
62
+ const registry = new OddPhaseRegistry();
63
+ let renders = 0;
64
+ registry.setRenderRequest("session-a", () => { renders += 1; });
65
+ registry.report("session-a", "authorizing");
66
+ assert.equal(renders, 1);
67
+ registry.report("session-a", "planning");
68
+ assert.equal(renders, 2);
69
+ });
70
+
71
+ test("OddPhaseRegistry.report without a session id never requests a redraw", () => {
72
+ const registry = new OddPhaseRegistry();
73
+ let renders = 0;
74
+ registry.setRenderRequest(undefined, () => { renders += 1; });
75
+ registry.report(undefined, "authorizing");
76
+ assert.equal(renders, 0);
77
+ });
78
+
79
+ test("OddPhaseRegistry.clear requests a redraw only when a phase actually existed", () => {
80
+ const registry = new OddPhaseRegistry();
81
+ let renders = 0;
82
+ registry.setRenderRequest("session-a", () => { renders += 1; });
83
+ registry.clear("session-a");
84
+ assert.equal(renders, 0, "clearing an already-empty phase must not request a needless redraw");
85
+ registry.report("session-a", "checking");
86
+ renders = 0;
87
+ registry.clear("session-a");
88
+ assert.equal(renders, 1);
89
+ });
90
+
91
+ test("OddPhaseRegistry never calls another session's render-request callback", () => {
92
+ const registry = new OddPhaseRegistry();
93
+ let rendersA = 0;
94
+ let rendersB = 0;
95
+ registry.setRenderRequest("session-a", () => { rendersA += 1; });
96
+ registry.setRenderRequest("session-b", () => { rendersB += 1; });
97
+ registry.report("session-a", "exploring");
98
+ assert.equal(rendersA, 1);
99
+ assert.equal(rendersB, 0);
100
+ });
101
+
102
+ test("OddPhaseRegistry.clearRenderRequest stops future redraw requests for that session", () => {
103
+ const registry = new OddPhaseRegistry();
104
+ let renders = 0;
105
+ registry.setRenderRequest("session-a", () => { renders += 1; });
106
+ registry.clearRenderRequest("session-a");
107
+ registry.report("session-a", "deciding");
108
+ assert.equal(renders, 0);
109
+ });
110
+
111
+ test("OddPhaseRegistry.infer sets the phase when nothing was reported", () => {
112
+ const registry = new OddPhaseRegistry();
113
+ assert.equal(registry.infer("session-a", "exploring"), "exploring");
114
+ assert.equal(registry.label("session-a"), "exploring…");
115
+ });
116
+
117
+ test("an inferred exploring never overrides an explicitly reported phase", () => {
118
+ const registry = new OddPhaseRegistry();
119
+ registry.report("session-a", "researching");
120
+ assert.equal(registry.infer("session-a", "exploring"), "researching");
121
+ assert.equal(registry.get("session-a"), "researching");
122
+ });
123
+
124
+ test("stronger inferred phases override an explicitly reported phase", () => {
125
+ for (const inferred of ["deciding", "planning", "implementing", "checking"] as const) {
126
+ const registry = new OddPhaseRegistry();
127
+ registry.report("session-a", "researching");
128
+ assert.equal(registry.infer("session-a", inferred), inferred);
129
+ assert.equal(registry.get("session-a"), inferred);
130
+ }
131
+ });
132
+
133
+ test("incidental reads and bookkeeping do not replace active implementation or verification", () => {
134
+ for (const active of ["implementing", "checking"] as const) {
135
+ const registry = new OddPhaseRegistry();
136
+ registry.report("session-a", "researching");
137
+ registry.infer("session-a", active);
138
+ assert.equal(registry.infer("session-a", "exploring"), active);
139
+ assert.equal(registry.infer("session-a", "planning"), active);
140
+ assert.equal(registry.get("session-a"), active);
141
+ }
142
+ });
143
+
144
+ test("inferring the phase already reported explicitly keeps it explicit", () => {
145
+ const registry = new OddPhaseRegistry();
146
+ registry.report("session-a", "implementing");
147
+ registry.infer("session-a", "implementing");
148
+ assert.equal(registry.infer("session-a", "exploring"), "implementing");
149
+ });
150
+
151
+ test("meaningful work transitions and explicit reports still change the phase", () => {
152
+ const registry = new OddPhaseRegistry();
153
+ registry.infer("session-a", "checking");
154
+ assert.equal(registry.infer("session-a", "implementing"), "implementing", "a later edit starts real work");
155
+ assert.equal(registry.infer("session-a", "checking"), "checking");
156
+ assert.equal(registry.infer("session-a", "exploring", "delegation"), "exploring", "a delegated exploration is deliberate");
157
+ assert.equal(registry.report("session-a", "closing"), "closing");
158
+ assert.equal(registry.infer("session-a", "exploring"), "closing");
159
+ registry.clear("session-a");
160
+ assert.equal(registry.infer("session-a", "exploring"), "exploring");
161
+ });
162
+
163
+ test("clear resets the explicit source so the next turn's inferred exploring applies", () => {
164
+ const registry = new OddPhaseRegistry();
165
+ registry.report("session-a", "researching");
166
+ registry.clear("session-a");
167
+ assert.equal(registry.infer("session-a", "exploring"), "exploring");
168
+ });
169
+
170
+ test("report with an inferred source behaves like infer-set state", () => {
171
+ const registry = new OddPhaseRegistry();
172
+ registry.report("session-a", "checking", "inferred");
173
+ assert.equal(registry.infer("session-a", "exploring"), "checking");
174
+ });
175
+
176
+ test("OddPhaseRegistry.infer redraws only when the label changes", () => {
177
+ const registry = new OddPhaseRegistry();
178
+ let renders = 0;
179
+ registry.setRenderRequest("session-a", () => { renders += 1; });
180
+ registry.infer("session-a", "exploring");
181
+ registry.infer("session-a", "exploring");
182
+ assert.equal(renders, 1, "repeated reads must not redraw on every tool call");
183
+ registry.report("session-a", "researching");
184
+ renders = 0;
185
+ registry.infer("session-a", "exploring");
186
+ assert.equal(renders, 0, "an ignored inference must not redraw");
187
+ registry.infer("session-a", "implementing");
188
+ assert.equal(renders, 1);
189
+ registry.infer("session-a", "planning");
190
+ registry.infer("session-a", "exploring");
191
+ assert.equal(renders, 1, "incidental signals never repaint active work");
192
+ registry.infer("session-a", "exploring", "delegation");
193
+ assert.equal(renders, 2, "a delegated exploration must repaint");
194
+ });
195
+
196
+ test("OddPhaseRegistry.infer without a session id is a no-op", () => {
197
+ const registry = new OddPhaseRegistry();
198
+ assert.equal(registry.infer(undefined, "exploring"), undefined);
199
+ assert.equal(registry.get(undefined), undefined);
200
+ });
201
+
202
+ test("OddPhaseRegistry report/clear without a registered render-request callback is a safe no-op", () => {
203
+ const registry = new OddPhaseRegistry();
204
+ assert.doesNotThrow(() => {
205
+ registry.report("session-a", "closing");
206
+ registry.clear("session-a");
207
+ });
208
+ });
209
+
210
+ interface RegisteredTool {
211
+ execute: (
212
+ toolCallId: string,
213
+ params: unknown,
214
+ signal: AbortSignal | undefined,
215
+ onUpdate: undefined,
216
+ ctx: ExtensionContext,
217
+ ) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
218
+ }
219
+
220
+ function gentleOddPhaseTool(): RegisteredTool {
221
+ const tools = new Map<string, RegisteredTool>();
222
+ createGentleAiExtension({ nativeReviewCli: null } as unknown as Parameters<typeof createGentleAiExtension>[0])({
223
+ on() {},
224
+ registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
225
+ registerCommand() {},
226
+ } as unknown as ExtensionAPI);
227
+ const tool = tools.get("gentle_odd_phase");
228
+ assert.ok(tool, "gentle_odd_phase must be registered");
229
+ return tool;
230
+ }
231
+
232
+ function toolContext(sessionId: string | undefined): ExtensionContext {
233
+ return { sessionManager: { getSessionId: () => sessionId } } as unknown as ExtensionContext;
234
+ }
235
+
236
+ test("gentle_odd_phase reports a valid phase for the calling session and returns details", async () => {
237
+ const tool = gentleOddPhaseTool();
238
+ const ctx = toolContext("tool-session-a");
239
+ try {
240
+ const result = await tool.execute("call-1", { phase: "researching" }, undefined, undefined, ctx);
241
+ assert.deepEqual((result as { details?: unknown }).details, { phase: "researching" });
242
+ assert.equal(oddPhaseRegistry.get("tool-session-a"), "researching");
243
+ } finally {
244
+ oddPhaseRegistry.clear("tool-session-a");
245
+ }
246
+ });
247
+
248
+ test("gentle_odd_phase 'clear' leaves the current phase mid-turn", async () => {
249
+ const tool = gentleOddPhaseTool();
250
+ const ctx = toolContext("tool-session-b");
251
+ try {
252
+ await tool.execute("call-1", { phase: "implementing" }, undefined, undefined, ctx);
253
+ const result = await tool.execute("call-2", { phase: "clear" }, undefined, undefined, ctx);
254
+ assert.deepEqual((result as { details?: unknown }).details, { phase: undefined });
255
+ assert.equal(oddPhaseRegistry.get("tool-session-b"), undefined);
256
+ } finally {
257
+ oddPhaseRegistry.clear("tool-session-b");
258
+ }
259
+ });
260
+
261
+ test("gentle_odd_phase throws (never falsely returns success) for a phase outside the bounded vocabulary", async () => {
262
+ // Pi's own contract: only a thrown error sets isError: true; a returned
263
+ // value never does, regardless of an isError property on it
264
+ // (docs/extensions.md "Signaling errors").
265
+ const tool = gentleOddPhaseTool();
266
+ const ctx = toolContext("tool-session-c");
267
+ try {
268
+ await assert.rejects(
269
+ () => tool.execute("call-1", { phase: "refactoring" }, undefined, undefined, ctx),
270
+ /Invalid ODD phase/,
271
+ );
272
+ assert.equal(oddPhaseRegistry.get("tool-session-c"), undefined);
273
+ } finally {
274
+ oddPhaseRegistry.clear("tool-session-c");
275
+ }
276
+ });
277
+
278
+ test("gentle_odd_phase preserves the prior reported phase when given an invalid token; only 'clear' resets it", async () => {
279
+ const tool = gentleOddPhaseTool();
280
+ const ctx = toolContext("tool-session-d");
281
+ try {
282
+ await tool.execute("call-1", { phase: "checking" }, undefined, undefined, ctx);
283
+ await assert.rejects(() => tool.execute("call-2", { phase: "refactoring" }, undefined, undefined, ctx));
284
+ assert.equal(oddPhaseRegistry.get("tool-session-d"), "checking", "an invalid token must never wipe the previously reported phase");
285
+ } finally {
286
+ oddPhaseRegistry.clear("tool-session-d");
287
+ }
288
+ });
289
+
290
+ test("gentle_odd_phase throws, not falsely succeeds, when there is no active session for a valid phase", async () => {
291
+ const tool = gentleOddPhaseTool();
292
+ const ctx = toolContext(undefined);
293
+ await assert.rejects(
294
+ () => tool.execute("call-1", { phase: "implementing" }, undefined, undefined, ctx),
295
+ (error: unknown) => {
296
+ assert.ok(error instanceof Error);
297
+ assert.doesNotMatch(error.message, /Invalid ODD phase/, "the phase itself was valid; the failure is the missing session, not an invalid token");
298
+ return true;
299
+ },
300
+ );
301
+ });
302
+
303
+ test("gentle_odd_phase throws for an invalid token even with no active session", async () => {
304
+ const tool = gentleOddPhaseTool();
305
+ const ctx = toolContext(undefined);
306
+ await assert.rejects(() => tool.execute("call-1", { phase: "refactoring" }, undefined, undefined, ctx));
307
+ });
@@ -86,9 +86,12 @@ const ANCHORS: readonly RoutingAnchor[] = [
86
86
  {
87
87
  label: "mapping trigger at 4 or more files",
88
88
  canonical: "**Mapping trigger:** when understanding the work requires 4 or more files",
89
+ // Gentle Shell intentionally leads the canon here: the local mirrors carry
90
+ // the measured evidence-budget rule instead of the 4-file count (tracked by
91
+ // gentle-ai#5139). The canonical anchor stays until gentle-ai follows.
89
92
  mirrors: [
90
- { surface: DELEGATION, includes: "**Mapping trigger (4-file rule):** when understanding the work requires 4 or more files" },
91
- { surface: CORE, includes: "**4-file rule** — 4+ files to understand" },
93
+ { surface: DELEGATION, includes: "**Mapping trigger (Evidence-budget rule):** read inline only when the evidence fits one parallel batch of at most 3 calls" },
94
+ { surface: CORE, includes: "**Evidence-budget rule** — read inline only if evidence fits one parallel batch (at most 3 calls, ~10k tokens" },
92
95
  ],
93
96
  },
94
97
  {
@@ -107,9 +110,11 @@ const ANCHORS: readonly RoutingAnchor[] = [
107
110
  {
108
111
  label: "long-session backstop",
109
112
  canonical: "**Long-session backstop:**",
113
+ // Gentle Shell intentionally leads the canon here: a parent-context token
114
+ // backstop replaces the tool-call count (tracked by gentle-ai#5139).
110
115
  mirrors: [
111
- { surface: DELEGATION, includes: "**Long-session backstop (Long-session rule):**" },
112
- { surface: CORE, includes: "**Long-session rule** — ~20 tool calls, 5 exploratory reads, or 2 non-mechanical edits without delegation" },
116
+ { surface: DELEGATION, includes: "**Context backstop:** when the parent context passes ~150k tokens" },
117
+ { surface: CORE, includes: "**Context backstop** — parent context past ~150k tokens" },
113
118
  ],
114
119
  },
115
120
  {
@@ -120,10 +125,10 @@ const ANCHORS: readonly RoutingAnchor[] = [
120
125
  ],
121
126
  },
122
127
  {
123
- label: "triggers never select SDD",
128
+ label: "triggers select only the ODD topology",
124
129
  canonical: "These triggers never select SDD and never create SDD artifacts",
125
130
  mirrors: [
126
- { surface: DELEGATION, includes: "These triggers never select SDD and never create SDD artifacts" },
131
+ { surface: DELEGATION, includes: "These triggers only choose between direct inline and delegated direct inside ODD" },
127
132
  ],
128
133
  },
129
134
  {