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
@@ -9,10 +9,6 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
9
9
  const AGENTS = join(ROOT, "assets", "agents");
10
10
  const ASSETS = join(ROOT, "assets");
11
11
 
12
- // SDD phase executors installed to ~/.pi/agent/agents/ by installSddAssets.
13
- // Each carries its own effective prompt; none inherits the parent workflow.
14
- const SDD_PREFIX = "sdd-";
15
-
16
12
  // Strict-output agents that must remain untouched by Key Learnings: the
17
13
  // Judgment Day roles (`jd-*`) and the review lens roles (`review-*`). Derived
18
14
  // from the agents actually on disk rather than hardcoded, so retiring one
@@ -52,53 +48,10 @@ function agentFiles(): string[] {
52
48
  return readdirSync(AGENTS).filter((f) => f.endsWith(".md")).sort();
53
49
  }
54
50
 
55
- function sddAgents(): string[] {
56
- return agentFiles().filter((f) => f.startsWith(SDD_PREFIX));
57
- }
58
-
59
51
  function strictJsonAgents(): string[] {
60
52
  return agentFiles().filter((f) => STRICT_JSON_PREFIX.test(f));
61
53
  }
62
54
 
63
- test("every SDD phase executor carries an effective `## Key Learnings Closing` section with full semantics", () => {
64
- const agents = sddAgents();
65
- assert.ok(agents.length >= 12, `expected >=12 SDD agents, found ${agents.length}`);
66
- const missing: string[] = [];
67
- const failed: string[] = [];
68
- for (const file of agents) {
69
- const source = readFileSync(join(AGENTS, file), "utf8");
70
- const section = readSection(source, "Key Learnings Closing");
71
- if (section === null) { missing.push(file); continue; }
72
- for (const [label, regex] of KL_SEMANTICS) {
73
- if (!regex.test(section)) failed.push(`${file}: ${label}`);
74
- }
75
- }
76
- assert.deepEqual(missing, [], "every SDD agent must carry a `## Key Learnings Closing` section");
77
- assert.deepEqual(failed, [], "every section must encode all canonical semantics");
78
- });
79
-
80
- test("no SDD phase executor infers Key Learnings through `standard phase envelope` alone", () => {
81
- for (const file of sddAgents()) {
82
- const source = readFileSync(join(AGENTS, file), "utf8");
83
- const section = readSection(source, "Key Learnings Closing");
84
- assert.ok(section, `${file} must have a direct Key Learnings Closing section`);
85
- }
86
- });
87
-
88
- test("SDD executor coverage is exhaustive against actual agent files", () => {
89
- const actual = sddAgents();
90
- // Allowlist: the 13 known phase executors. A new sdd-*.md without a
91
- // Key Learnings Closing section fails the first test; this test proves
92
- // the allowlist matches reality so coverage cannot silently drift.
93
- const expected = [
94
- "sdd-apply.md", "sdd-archive.md", "sdd-design.md", "sdd-explore.md",
95
- "sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-remediate.md", "sdd-research.md",
96
- "sdd-spec.md", "sdd-status.md", "sdd-tasks.md",
97
- "sdd-verify.md",
98
- ];
99
- assert.deepEqual(actual, expected, "SDD agent set must match the known allowlist");
100
- });
101
-
102
55
  test("generic delegation contract instructs the same `## Key Learnings` closing block", () => {
103
56
  const delegation = readFileSync(join(ASSETS, "orchestrator-delegation.md"), "utf8");
104
57
  const section = readSection(delegation, "Key Learnings closing block");
@@ -111,14 +64,6 @@ test("generic delegation contract instructs the same `## Key Learnings` closing
111
64
  assert.match(section, /layers on after/, "must state the block layers on after the envelope");
112
65
  });
113
66
 
114
- test("sdd-orchestrator-workflow documents routing, not executor authority", () => {
115
- const workflow = readFileSync(join(ASSETS, "sdd-orchestrator-workflow.md"), "utf8");
116
- const section = readSection(workflow, "Key Learnings closing block (routing)");
117
- assert.ok(section, "workflow must document Key Learnings routing");
118
- assert.match(section, /installed SDD phase executor agent.*carries the effective.*contract/i);
119
- assert.match(section, /documents routing only and is not the executor authority/);
120
- });
121
-
122
67
  test("provider ownership: no Pi TypeScript runtime parses Key Learnings or invokes passive-capture tools", () => {
123
68
  const roots = ["lib", "extensions", "scripts", "runtime"];
124
69
  const forbidden: Array<[string, RegExp]> = [
@@ -214,27 +159,6 @@ test("strict review and Judgment Day agents do not gain Key Learnings or trailin
214
159
  });
215
160
 
216
161
  test("the canonical Key Learnings heading has no trailing colon in any asset", () => {
217
- for (const file of sddAgents()) {
218
- const source = readFileSync(join(AGENTS, file), "utf8");
219
- assert.match(source, /`## Key Learnings`/, `${file} must reference the canonical heading`);
220
- assert.doesNotMatch(source, /`## Key Learnings:`/, `${file} must not use a trailing colon`);
221
- }
222
162
  const delegation = readFileSync(join(ASSETS, "orchestrator-delegation.md"), "utf8");
223
163
  assert.doesNotMatch(delegation, /`## Key Learnings:`/);
224
164
  });
225
-
226
- test("modified SDD agents are packaged and installed by the existing installer", () => {
227
- const verifier = readFileSync(join(ROOT, "scripts", "verify-package-files.mjs"), "utf8");
228
- const expected = [
229
- "assets/agents/sdd-apply.md", "assets/agents/sdd-archive.md",
230
- "assets/agents/sdd-design.md", "assets/agents/sdd-explore.md",
231
- "assets/agents/sdd-init.md", "assets/agents/sdd-onboard.md",
232
- "assets/agents/sdd-proposal.md", "assets/agents/sdd-research.md",
233
- "assets/agents/sdd-spec.md", "assets/agents/sdd-status.md",
234
- "assets/agents/sdd-tasks.md",
235
- "assets/agents/sdd-verify.md",
236
- ];
237
- for (const path of expected) {
238
- assert.match(verifier, new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${path} must be in the package verifier`);
239
- }
240
- });
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
4
+
5
+ const destructive = [
6
+ `psql -c 'DROP TABLE users'`,
7
+ `mysql --execute='TRUNCATE TABLE users'`,
8
+ `sqlite3 app.db 'DELETE FROM users;'`,
9
+ `psql -c 'DELETE FROM users WHERE 1=1'`,
10
+ `echo 'DROP DATABASE app' | psql`,
11
+ `printf '%s' 'TRUNCATE users' | mysql`,
12
+ `env MODE=test sudo -u postgres /usr/bin/psql -c 'DROP SCHEMA public CASCADE'`,
13
+ `sh -c "echo ready; psql -c 'DROP TABLE users'"`,
14
+ `echo ready && rm -fr -- ./data`,
15
+ `find ./data -type f -delete`,
16
+ `find ./data -type f -exec rm -rf {} +`,
17
+ `find ./data -print0 | xargs -0 rm -rf`,
18
+ `psql <<'SQL'\nDROP TABLE users;\nSQL`,
19
+ `sqlite3 app.db 'SELECT 1; /* maintenance */ DROP TABLE users'`,
20
+ `timeout 30 bash -lc 'rm -r ./data'`,
21
+ `echo ready || (rm -r ./data)`,
22
+ `rm -r /var/app/data`,
23
+ `git -C /repo reset --hard HEAD`,
24
+ `git clean -fd`,
25
+ `git push --force-with-lease origin main`,
26
+ `git branch -D old`,
27
+ `git restore .`,
28
+ String.raw`sh -c "psql -c \"DROP TABLE users\""`,
29
+ String.raw`bash -lc "rm -r \"./data\""`,
30
+ `psql -c 'DELETE FROM "where"'`,
31
+ `mysql -e 'DELETE FROM \`where\`'`,
32
+ `sqlite3 app.db 'DELETE FROM [where]'`,
33
+ `psql -c 'DROP TABLE "where"'`,
34
+ String.raw`find cache -exec echo {} \; -exec rm -r {} \;`,
35
+ String.raw`find cache -execdir echo {} \; -execdir rm -r {} \;`,
36
+ `sudo -n rm -r ./data`,
37
+ `command -p psql -c 'DROP TABLE users'`,
38
+ `env -u MODE sudo -u postgres psql -c 'DROP TABLE users'`,
39
+ ];
40
+ for (const command of destructive) {
41
+ test(`recognizes executable destruction: ${command}`, () => {
42
+ const matches = recognizeDestructiveCommands(command);
43
+ assert.ok(matches.length > 0);
44
+ assert.ok(matches.every((match) => match.triggerIndex >= 0 && match.triggerIndex < command.length));
45
+ });
46
+ }
47
+ for (const command of [
48
+ `pnpm test && pnpm run build`, `git status`, `git push origin main`,
49
+ `psql -c 'SELECT * FROM users'`, `sqlite3 app.db 'DELETE FROM users WHERE id=7'`,
50
+ `psql -c "SELECT 'DROP TABLE users'"`,
51
+ `echo 'rm -rf ./data; DROP TABLE users'`, `printf '%s' 'TRUNCATE TABLE users'`,
52
+ `rg 'DROP TABLE' docs`, `rm ./one-file`, `find . -name '*.ts'`,
53
+ `psql <<'SQL'\nSELECT 'DROP TABLE users';\nSQL`,
54
+ `git log --oneline`, `env X=1 echo 'rm -r ./data'`,
55
+ `find . -exec echo 'DROP TABLE users' {} +`,
56
+ `git push origin main && printf '%s' '-f'`,
57
+ `echo ';' rm -r cache`,
58
+ String.raw`echo \; rm -r cache`,
59
+ `printf '%s ' ';' rm -r ./data`,
60
+ `echo '|' rm -r cache`,
61
+ `echo '&&' rm -r cache`,
62
+ `echo ')' rm -r cache`,
63
+ `psql -c 'DELETE FROM "where" WHERE id=7'`,
64
+ String.raw`psql -c "SELECT \"where\", 'DROP TABLE users'"`,
65
+ String.raw`find cache -exec echo {} \; -exec echo 'rm -r data' {} \;`,
66
+ ]) {
67
+ test(`leaves ordinary commands and prose unchanged: ${command}`, () => {
68
+ assert.deepEqual(recognizeDestructiveCommands(command), []);
69
+ });
70
+ }
71
+
72
+ test("recognizes later compound destruction and shell wrappers without matching quoted prose", () => {
73
+ const command = `echo 'rm -rf /'; env X=1 sh -c 'rm --recursive ./data'`;
74
+ const matches = recognizeDestructiveCommands(command);
75
+ assert.equal(matches.length, 1);
76
+ assert.equal(matches[0].kind, "filesystem");
77
+ assert.equal(matches[0].hardDeny, false);
78
+ });
79
+
80
+ test("root/home recursive removal and destructive git retain hard denial", () => {
81
+ for (const command of [`rm -fr /`, `rm -r "$HOME"`, `git -C /repo reset --hard`, `git push -f`, `git push ';' -f main`, `rm -r ';' /`]) {
82
+ assert.equal(recognizeDestructiveCommands(command)[0]?.hardDeny, true, command);
83
+ }
84
+ });
@@ -1,10 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
- import { __testing } from "../../extensions/gentle-ai.ts";
7
+ import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
8
+ import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
9
+ import { __testing, createGentleAiExtension } from "../../extensions/gentle-ai.ts";
10
+ import { resolveSessionWorktree } from "../../lib/session-worktree-registry.ts";
8
11
  import { CandidateViewRegistry } from "../../lib/review-candidate-view.ts";
9
12
  import {
10
13
  NATIVE_REVIEW_ERROR_CODE,
@@ -138,6 +141,171 @@ function consentAnswerCall(calls: NativeCall[], answer: "granted" | "declined"):
138
141
  return matching[0]!;
139
142
  }
140
143
 
144
+ // Development-only counterpart of the published-pin SDK proof. A fixture-only
145
+ // provider drives an actual SDK write; no external model, START or download.
146
+ test("dev-binary: SDK non-Git bootstrap entry preserves files and metadata", { skip: !RUNNABLE }, async (t) => {
147
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-dev-bootstrap-")));
148
+ const home = join(root, "home");
149
+ const agentDir = join(home, "agent");
150
+ mkdirSync(agentDir, { recursive: true });
151
+ const previous: Record<string, string | undefined> = {};
152
+ const environment = {
153
+ HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"),
154
+ XDG_DATA_HOME: join(home, ".local", "share"), XDG_CACHE_HOME: join(home, ".cache"),
155
+ GENTLE_PI_AGENT_HOME: agentDir, PI_CODING_AGENT_DIR: agentDir,
156
+ GENTLE_PI_CONFIG_HOME: join(home, "pi-config"), PI_OFFLINE: "1",
157
+ // A poisoned ambient declaration must be replaced by the production
158
+ // adapter rather than accidentally furnishing the fixture's handshake.
159
+ GENTLE_PI_REVIEW_RELAY_CONTRACT: "invalid-ambient-relay",
160
+ };
161
+ // Disposable Git commands must not follow an ambient repository selector.
162
+ for (const key of Object.keys(process.env).filter((key) => key.startsWith("GIT_"))) {
163
+ previous[key] = process.env[key];
164
+ delete process.env[key];
165
+ }
166
+ for (const [key, value] of Object.entries(environment)) {
167
+ previous[key] = process.env[key];
168
+ process.env[key] = value;
169
+ }
170
+ t.after(() => {
171
+ for (const [key, value] of Object.entries(previous)) {
172
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
173
+ }
174
+ rmSync(root, { recursive: true, force: true });
175
+ });
176
+ const version = execFileSync(DEV_BINARY!, ["version"], { cwd: root, encoding: "utf8" }).trim();
177
+ t.diagnostic(`development binary=${DEV_BINARY}; version=${version}; published-pin parity is not established`);
178
+ const fixture = (name: string) => {
179
+ const cwd = join(root, name);
180
+ mkdirSync(cwd, { recursive: true });
181
+ writeFileSync(join(cwd, "candidate.txt"), "unchanged candidate bytes\n");
182
+ return cwd;
183
+ };
184
+ const modeRoot = fixture("mode-root");
185
+ git(modeRoot, "init", "--quiet");
186
+ enableGlobalReview(modeRoot);
187
+ const modelRuntime = await ModelRuntime.create({
188
+ authPath: join(agentDir, "empty-auth.json"), modelsPath: null,
189
+ modelsStorePath: join(agentDir, "models-store.json"), allowModelNetwork: false,
190
+ });
191
+ const invocations: Array<{ cwd: string; args: string[] }> = [];
192
+ const entry = async (cwd: string, operation: "startup" | "source" | "inspect" = "source", workspaceRoot?: string, enabled = true, inspectAfterStartup = false) => {
193
+ const before = invocations.length;
194
+ const wasVersioned = resolveSessionWorktree(workspaceRoot ?? cwd, cwd) !== undefined;
195
+ // A new native client per entry prevents a previous transport refusal
196
+ // cache from substituting for this scenario's actual invocation.
197
+ const adapter = createNodeExecFileAdapter();
198
+ const native = new NativeReviewCliV216(async (request) => {
199
+ invocations.push({ cwd: request.cwd, args: [...request.arguments] });
200
+ return adapter(request);
201
+ }, DEV_BINARY!);
202
+ const resourceLoader = new DefaultResourceLoader({
203
+ cwd, agentDir, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
204
+ extensionFactories: [pi => pi.registerProvider("fixture-source", {
205
+ baseUrl: "http://invalid.invalid", apiKey: "fixture-only", api: "fixture-source" as never,
206
+ models: [{ id: "write", name: "Offline source writer", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, maxTokens: 1024 }],
207
+ streamSimple(model, context) {
208
+ const stream = createAssistantMessageEventStream();
209
+ queueMicrotask(() => {
210
+ const write = context.messages.at(-1)?.role === "user";
211
+ const message: AssistantMessage = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: write ? "toolUse" : "stop", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
212
+ stream.push({ type: "start", partial: message });
213
+ if (write) {
214
+ const call = { type: "toolCall" as const, id: "fixture-source-write", name: "write", arguments: { path: "app.ts", content: "export const fixture = true;\n" } };
215
+ message.content.push(call);
216
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
217
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: call, partial: message });
218
+ } else message.content.push({ type: "text", text: "source written" });
219
+ stream.push({ type: "done", reason: write ? "toolUse" : "stop", message });
220
+ stream.end(message);
221
+ });
222
+ return stream;
223
+ },
224
+ }), createGentleAiExtension({ nativeReviewCli: native, candidateViews: null, processEnv: {} })],
225
+ });
226
+ await resourceLoader.reload();
227
+ assert.deepEqual(resourceLoader.getExtensions().errors, []);
228
+ const { session } = await createAgentSession({
229
+ cwd, agentDir, resourceLoader, modelRuntime, sessionManager: SessionManager.inMemory(cwd), tools: ["write", "gentle_review"],
230
+ settingsManager: SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }),
231
+ });
232
+ try {
233
+ await session.bindExtensions({ mode: "print" });
234
+ const beforeAction = invocations.slice(before);
235
+ assert.equal(beforeAction.some(call => call.args[0] === "review" && call.args[1] === "status"), false, "mere binding cannot prepare non-Git");
236
+ if (operation === "source") {
237
+ await session.setModel(modelRuntime.getModel("fixture-source", "write")!);
238
+ session.setActiveToolsByName(["write"]);
239
+ await session.prompt("Write the authorized fixture source app.ts.");
240
+ assert.ok(session.messages.some(message => message.role === "toolResult" && message.toolName === "write" && !message.isError), "actual SDK successful write result");
241
+ assert.equal(readFileSync(join(cwd, "app.ts"), "utf8"), "export const fixture = true;\n");
242
+ }
243
+ assert.ok(session.getAllTools().some((tool) => tool.name === "gentle_review"), "actual SDK registers the facade");
244
+ session.setActiveToolsByName(["gentle_review"]);
245
+ const tool = session.agent.state.tools.find((tool) => tool.name === "gentle_review");
246
+ assert.ok(tool, "actual SDK registers the facade");
247
+ if (operation === "inspect" || inspectAfterStartup) {
248
+ const result = await tool.execute("bootstrap-inspect", { operation: "inspect", ...(workspaceRoot === undefined ? {} : { workspaceRoot }) });
249
+ assert.notEqual((result.details as Record<string, unknown>).outcome, "pi-host-relay-transport-unavailable", "entry must negotiate production-compatible transport");
250
+ }
251
+ if (operation !== "source") assert.equal(session.messages.length, 0, "passive/review entry does not invoke a model");
252
+ } finally { await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); session.dispose(); }
253
+ const calls = invocations.slice(before);
254
+ if (operation !== "startup" && !wasVersioned) assert.ok(calls.some(({ args }) => args[0] === "review" && args[1] === "mode"), "every admitted scenario really queries native mode");
255
+ const statuses = calls.filter(({ args }) => args[0] === "review" && args[1] === "status");
256
+ assert.equal(statuses.length > 0, enabled, "each on scenario must really invoke native STATUS; off must not");
257
+ if (enabled) assert.equal(statuses[0]!.cwd, resolveSessionWorktree(workspaceRoot ?? cwd, cwd)?.root ?? workspaceRoot ?? cwd);
258
+ if (inspectAfterStartup) assert.equal(statuses.length, 2, "the same SDK session recognizes native bootstrap for subsequent facade inspect, without restart");
259
+ t.diagnostic(`${operation} target=${workspaceRoot ?? cwd}; mode calls=${calls.length - statuses.length}; STATUS calls=${statuses.length}`);
260
+ };
261
+ const pristine = (cwd: string) => {
262
+ assert.equal(readFileSync(join(cwd, "candidate.txt"), "utf8"), "unchanged candidate bytes\n");
263
+ assert.equal(git(cwd, "ls-files", "--others", "--exclude-standard"), existsSync(join(cwd, "app.ts")) ? "app.ts\ncandidate.txt" : "candidate.txt");
264
+ assert.throws(() => git(cwd, "rev-parse", "--verify", "HEAD"), "HEAD stays unborn");
265
+ assert.equal(git(cwd, "remote"), "");
266
+ assert.equal(existsSync(join(cwd, ".git", "gentle-ai", "reviews")), false, "entry creates no review lineage");
267
+ };
268
+ const startupRoot = fixture("startup");
269
+ await entry(startupRoot, "startup", undefined, false);
270
+ assert.equal(existsSync(join(startupRoot, ".git")), false);
271
+ await entry(startupRoot, "source", undefined, true, true);
272
+ assert.equal(existsSync(join(startupRoot, ".git")), true);
273
+ pristine(startupRoot);
274
+ const metadata = statSync(join(startupRoot, ".git"));
275
+ await entry(startupRoot, "inspect");
276
+ assert.equal(statSync(join(startupRoot, ".git")).ino, metadata.ino, "repeat entry reuses metadata");
277
+ pristine(startupRoot);
278
+ for (const explicit of [false, true]) {
279
+ const target = fixture(explicit ? "explicit-inspect" : "default-inspect");
280
+ await entry(explicit ? modeRoot : target, "inspect", explicit ? target : undefined);
281
+ assert.equal(existsSync(join(target, ".git")), true);
282
+ pristine(target);
283
+ }
284
+ const nested = join(startupRoot, "nested");
285
+ mkdirSync(nested);
286
+ await entry(nested, "inspect");
287
+ assert.equal(existsSync(join(nested, ".git")), false, "valid parent is reused");
288
+ const broken = fixture("broken");
289
+ writeFileSync(join(broken, ".git"), "gitdir: missing-metadata\n");
290
+ await entry(broken);
291
+ assert.equal(readFileSync(join(broken, ".git"), "utf8"), "gitdir: missing-metadata\n");
292
+ const brokenChild = join(broken, "child");
293
+ mkdirSync(brokenChild);
294
+ await entry(brokenChild);
295
+ assert.equal(existsSync(join(brokenChild, ".git")), false, "broken ancestor is not bypassed");
296
+ assert.equal(readFileSync(join(broken, ".git"), "utf8"), "gitdir: missing-metadata\n");
297
+ assert.equal(readFileSync(join(broken, "candidate.txt"), "utf8"), "unchanged candidate bytes\n");
298
+ const disabled = JSON.parse(execFileSync(DEV_BINARY!, ["review", "mode", "disable", "--scope", "global", "--cwd", modeRoot, "--json"], { cwd: modeRoot, encoding: "utf8" }));
299
+ assert.equal(disabled.status.effective, "off");
300
+ for (const operation of ["startup", "source", "inspect"] as const) {
301
+ const off = fixture(`off-${operation}`);
302
+ await entry(off, operation, undefined, false);
303
+ assert.equal(existsSync(join(off, ".git")), false);
304
+ assert.equal(readFileSync(join(off, "candidate.txt"), "utf8"), "unchanged candidate bytes\n");
305
+ }
306
+ assert.equal(invocations.some(({ args }) => args[0] === "review" && args[1] === "start"), false, "no START authority is requested");
307
+ });
308
+
141
309
  // ---------------------------------------------------------------------------
142
310
  // Kill switch round trip.
143
311
  // ---------------------------------------------------------------------------
@@ -0,0 +1,193 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { pathToFileURL, fileURLToPath } from "node:url";
7
+ import test from "node:test";
8
+ import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
9
+ import gentleAgents from "../../extensions/gentle-agents.ts";
10
+ import gentleShell from "../../extensions/gentle-shell.ts";
11
+ import { createGentleAiExtension } from "../../extensions/gentle-ai.ts";
12
+ import { createNodeExecFileAdapter, NativeReviewCliV216 } from "../../lib/native-review-cli.ts";
13
+ import { SESSION_WORKTREE_ENTRY } from "../../lib/session-worktree-registry.ts";
14
+ import { requireDevBinary } from "../support/native-binary-gate.ts";
15
+
16
+ const binary = process.env.GENTLE_AI_DEV_BINARY;
17
+ const gate = requireDevBinary({ devBinaryPath: binary, exists: !!binary && binary.startsWith("/") && existsSync(binary), env: process.env });
18
+ if ("reason" in gate) console.log(`non-git-subagent-bootstrap: ${gate.reason}`);
19
+
20
+ // This is a fixture-only provider, not a replacement RPC child. The installed
21
+ // Pi CLI still loads the provider, accepts RPC, runs its agent loop and settles.
22
+ // No HTTP transport, credentials, or paid model are involved.
23
+ function offlineProvider(aiModule: string): string {
24
+ return `import { createAssistantMessageEventStream } from ${JSON.stringify(aiModule)};
25
+ export default function (pi) {
26
+ globalThis.fetch = async () => { throw new Error("Network forbidden in bootstrap fixture"); };
27
+ pi.registerProvider("bootstrap-fixture", {
28
+ baseUrl: "http://invalid.invalid", apiKey: "fixture-only", api: "bootstrap-fixture",
29
+ models: [{ id: "deterministic", name: "Offline bootstrap fixture", reasoning: false,
30
+ input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
31
+ contextWindow: 32000, maxTokens: 1024 }],
32
+ streamSimple(model, _context, options) {
33
+ const stream = createAssistantMessageEventStream();
34
+ queueMicrotask(() => {
35
+ const message = { role: "assistant", api: model.api, provider: model.provider,
36
+ model: model.id, timestamp: Date.now(), content: [], stopReason: "stop",
37
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
38
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
39
+ if (options?.signal?.aborted) {
40
+ message.stopReason = "aborted"; message.errorMessage = "Fixture aborted";
41
+ stream.push({ type: "error", reason: "aborted", error: message }); stream.end(message); return;
42
+ }
43
+ const text = "OFFLINE_BOOTSTRAP_CHILD " + JSON.stringify({ pid: process.pid, cwd: process.cwd() });
44
+ stream.push({ type: "start", partial: message });
45
+ message.content.push({ type: "text", text: "" });
46
+ stream.push({ type: "text_start", contentIndex: 0, partial: message });
47
+ message.content[0].text = text;
48
+ stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: message });
49
+ stream.push({ type: "text_end", contentIndex: 0, content: text, partial: message });
50
+ stream.push({ type: "done", reason: "stop", message }); stream.end(message);
51
+ });
52
+ return stream;
53
+ }
54
+ });
55
+ }
56
+ `;
57
+ }
58
+
59
+ for (const explicit of [false, true]) {
60
+ test(`dev-binary: pre-bootstrap SDK session first ${explicit ? "explicit worker" : "implicit gentle-ai-worker"} dispatch completes a real Pi child`, { skip: !gate.run, timeout: 90000 }, async (t) => {
61
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-subagent-bootstrap-")));
62
+ const home = join(root, "home");
63
+ const agentDir = join(home, "agent");
64
+ const cwd = join(root, "project");
65
+ mkdirSync(join(agentDir, "agents"), { recursive: true });
66
+ mkdirSync(cwd);
67
+ writeFileSync(join(cwd, "candidate.txt"), "unchanged candidate bytes\n");
68
+ const role = explicit ? "worker" : "gentle-ai-worker";
69
+ mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
70
+ writeFileSync(join(cwd, ".pi", "agents", `${role}.md`), "---\ndescription: Offline bootstrap proof\nmodel: bootstrap-fixture/deterministic\ntools: [read]\n---\nReturn the offline fixture answer.\n");
71
+ writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ retry: { enabled: false }, compaction: { enabled: false }, packages: [] }));
72
+ const env: NodeJS.ProcessEnv = {
73
+ PATH: process.env.PATH, HOME: home, USERPROFILE: home,
74
+ XDG_CONFIG_HOME: join(home, ".config"), XDG_DATA_HOME: join(home, ".local", "share"), XDG_CACHE_HOME: join(home, ".cache"),
75
+ GENTLE_PI_AGENT_HOME: agentDir, PI_CODING_AGENT_DIR: agentDir,
76
+ GENTLE_PI_CONFIG_HOME: join(home, "pi-config"), PI_OFFLINE: "1",
77
+ GENTLE_PI_SHELL: "1", GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off",
78
+ GENTLE_PI_RUNTIME_METRICS: "off",
79
+ };
80
+ const previous = new Map<string, string | undefined>();
81
+ for (const key of new Set([...Object.keys(env), ...Object.keys(process.env).filter(key => key.startsWith("GIT_"))])) {
82
+ previous.set(key, process.env[key]);
83
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
84
+ }
85
+ const children: ChildProcess[] = [];
86
+ t.after(async () => {
87
+ for (const child of children) {
88
+ if (child.exitCode === null && child.signalCode === null) {
89
+ const exited = new Promise<void>(resolve => child.once("exit", () => resolve()));
90
+ child.kill("SIGTERM");
91
+ await exited;
92
+ }
93
+ }
94
+ for (const [key, value] of previous) {
95
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
96
+ }
97
+ rmSync(root, { recursive: true, force: true });
98
+ });
99
+ const sdkPackage = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..", "package.json");
100
+ const installed = JSON.parse(readFileSync(sdkPackage, "utf8"));
101
+ const cli = realpathSync(join(dirname(sdkPackage), installed.bin.pi));
102
+ const provider = join(root, "offline-provider.mjs");
103
+ writeFileSync(provider, offlineProvider(import.meta.resolve("@earendil-works/pi-ai")));
104
+ t.diagnostic(`installed Pi SDK/CLI=${installed.version}; cli=${cli}; native=${binary}`);
105
+ const modeRoot = join(root, "mode-root");
106
+ mkdirSync(modeRoot);
107
+ execFileSync("git", ["init", "--quiet", modeRoot], { env, stdio: "pipe" });
108
+ const enabled = JSON.parse(execFileSync(binary!, ["review", "mode", "enable", "--scope", "global", "--cwd", modeRoot, "--json"], { env, encoding: "utf8" }));
109
+ assert.equal(enabled.status.effective, "on");
110
+ const nativeCalls: Array<{ cwd: string; args: string[] }> = [];
111
+ let preparationCompleted = false;
112
+ const adapter = createNodeExecFileAdapter();
113
+ const native = new NativeReviewCliV216(async request => {
114
+ nativeCalls.push({ cwd: request.cwd, args: [...request.arguments] });
115
+ const result = await adapter(request);
116
+ if (request.arguments[0] === "review" && request.arguments[1] === "status") preparationCompleted = existsSync(join(cwd, ".git"));
117
+ return result;
118
+ }, binary!);
119
+ const frames: Array<Array<Record<string, unknown>>> = [];
120
+ const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "empty-auth.json"), modelsPath: null, modelsStorePath: join(agentDir, "models-store.json"), allowModelNetwork: false });
121
+ const manager = SessionManager.inMemory(cwd);
122
+ const id = manager.getSessionId();
123
+ const loader = new DefaultResourceLoader({
124
+ cwd, agentDir, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
125
+ extensionFactories: [
126
+ (await import(pathToFileURL(provider).href)).default,
127
+ pi => gentleAgents(pi, env, {
128
+ home, agentHome: agentDir, env,
129
+ pi: { command: process.execPath, args: [cli, "--offline", "--no-extensions", "--extension", provider, "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files"] },
130
+ // Observe real OS spawn and production RPC traffic without replacing it.
131
+ spawn(command, args, options) {
132
+ assert.equal(preparationCompleted, true, "native preparation completed BEFORE OS spawn");
133
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["pipe", "pipe", "pipe"], windowsHide: true, detached: options.detached });
134
+ children.push(child);
135
+ const records: Array<Record<string, unknown>> = [];
136
+ frames.push(records);
137
+ let buffer = "";
138
+ child.stdout!.on("data", chunk => {
139
+ buffer += chunk.toString();
140
+ const lines = buffer.split("\n"); buffer = lines.pop()!;
141
+ for (const line of lines) { try { records.push(JSON.parse(line)); } catch { /* production parser also ignores non-JSON lines */ } }
142
+ });
143
+ return child as never;
144
+ },
145
+ }),
146
+ pi => gentleShell(pi, env),
147
+ createGentleAiExtension({ nativeReviewCli: native, candidateViews: null, processEnv: {} }),
148
+ ],
149
+ });
150
+ await loader.reload();
151
+ assert.deepEqual(loader.getExtensions().errors, []);
152
+ assert.equal(existsSync(join(cwd, ".git")), false, "extensions load before bootstrap");
153
+ const { session } = await createAgentSession({ cwd, agentDir, resourceLoader: loader, modelRuntime, sessionManager: manager, noTools: "builtin", settingsManager: SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }) });
154
+ try {
155
+ await session.bindExtensions({ mode: "print" });
156
+ assert.equal(existsSync(join(cwd, ".git")), false, "registries are bound while the project is still non-Git");
157
+ assert.equal(nativeCalls.some(call => call.args[0] === "review" && call.args[1] === "status"), false, "binding never prepares");
158
+ session.setActiveToolsByName(["session_worktree_register", "subagent_run", "subagent_status"]);
159
+ const tool = (name: string) => {
160
+ const selected = session.agent.state.tools.find(tool => tool.name === name);
161
+ assert.ok(selected, `SDK tool registered: ${name}`);
162
+ return selected;
163
+ };
164
+ const registrations = () => manager.getEntries().filter(entry => entry.type === "custom" && entry.customType === SESSION_WORKTREE_ENTRY);
165
+ assert.equal(registrations().length, 0);
166
+ {
167
+ const result = await tool("subagent_run").execute(explicit ? "explicit" : "implicit", { agent: role, task: "Return offline bootstrap evidence\n## Allowed edit surfaces\nsrc/app.ts\n## Return\nReport", mode: "task", ...(explicit ? { workspace_root: cwd } : {}) });
168
+ const index = 0;
169
+ const child = children[index]!;
170
+ assert.ok(child.pid && child.pid !== process.pid, "real installed Pi child PID");
171
+ const details = (result.details as { gentleAgents: { status: string; cwd: string; taskId: string } }).gentleAgents;
172
+ assert.equal(details.status, "completed", JSON.stringify(result));
173
+ assert.equal(details.cwd, cwd);
174
+ const answer = result.content.find(part => part.type === "text") as { text: string };
175
+ assert.equal(answer.text, `OFFLINE_BOOTSTRAP_CHILD ${JSON.stringify({ pid: child.pid, cwd })}`);
176
+ assert.ok(frames[index]!.some(frame => frame.type === "response" && frame.command === "get_state" && frame.success), "production RPC readiness handshake");
177
+ assert.ok(frames[index]!.some(frame => frame.type === "agent_settled"), "actual CLI task settled");
178
+ assert.equal(manager.getSessionId(), id);
179
+ assert.equal(session.sessionManager, manager, "no manager replacement or Pi reload");
180
+ assert.equal(registrations().length, 1, "spawn registration dedupes durable entry");
181
+ t.diagnostic(`${explicit ? "explicit" : "implicit"} child pid=${child.pid}; RPC ready; task=${details.taskId} completed; canonical cwd=${details.cwd}; same session=${id}`);
182
+ }
183
+ assert.equal(nativeCalls.filter(call => call.cwd === cwd && call.args[0] === "review" && call.args[1] === "status").length, 1);
184
+ assert.equal(nativeCalls.some(call => call.args[0] === "review" && call.args[1] === "start"), false);
185
+ assert.equal(readFileSync(join(cwd, "candidate.txt"), "utf8"), "unchanged candidate bytes\n");
186
+ assert.equal(session.messages.length, 0, "parent never calls a model");
187
+ assert.equal(manager.getEntries().some(entry => entry.type === "custom" && /session-change|review-reminder/.test(entry.customType)), false, "registration is not Changes evidence");
188
+ } finally {
189
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
190
+ session.dispose();
191
+ }
192
+ });
193
+ }
@@ -0,0 +1,7 @@
1
+ {"type":"session","version":3,"id":"aaa","timestamp":"2026-09-28T10:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-09-28T10:00:01.000Z","provider":"claude-bridge","modelId":"claude-opus-5-5"}
3
+ {"type":"message","id":"u1","parentId":"m1","timestamp":"2026-09-28T10:00:30.000Z","message":{"role":"user","content":"hello"}}
4
+ {"type":"message","id":"a1","parentId":"u1","timestamp":"2026-09-28T10:01:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":100,"output":50,"cacheRead":1000,"cacheWrite":200,"totalTokens":1350,"cost":{"total":0.5}}}}
5
+ {not json at all
6
+ {"type":"message","id":"a2","parentId":"a1","timestamp":"2026-09-28T10:30:00.000Z","message":{"role":"assistant","provider":"openai-codex","model":"gpt-6.1-sol","usage":{"input":200,"output":100,"cacheRead":0,"cacheWrite":0,"totalTokens":300,"cost":{"total":0.25}}}}
7
+ {"type":"custom","customType":"gentle-pi.session-change/v1","id":"c1","parentId":"a2","timestamp":"2026-09-28T10:30:01.000Z","data":{}}
@@ -0,0 +1,3 @@
1
+ {"type":"session","version":3,"id":"bbb","timestamp":"2026-09-29T23:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"message","id":"a1","parentId":null,"timestamp":"2026-09-29T23:10:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":10,"output":20,"cacheRead":0,"cacheWrite":0,"totalTokens":30,"cost":{"total":0.1}}}}
3
+ {"type":"message","id":"a2","parentId":"a1","timestamp":"2026-09-30T01:00:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":0,"output":70,"cacheRead":0,"cacheWrite":0,"totalTokens":70,"cost":{"total":0.2}}}}
@@ -0,0 +1,2 @@
1
+ {"type":"session","version":3,"id":"ddd","timestamp":"2026-09-30T08:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"message","id":"u1","parentId":null,"timestamp":"2026-09-30T08:00:10.000Z","message":{"role":"user","content":"no reply yet"}}
@@ -0,0 +1,3 @@
1
+ garbage line
2
+
3
+ {"type":"message"
@@ -0,0 +1,2 @@
1
+ {"type":"session","version":3,"id":"nested","timestamp":"2026-09-30T09:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"message","id":"a1","parentId":null,"timestamp":"2026-09-30T09:01:00.000Z","message":{"role":"assistant","provider":"x","model":"nested-model","usage":{"input":999999,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":999999,"cost":{"total":99}}}}
@@ -0,0 +1,2 @@
1
+ {"type":"session","version":3,"id":"ccc","timestamp":"2026-09-01T12:00:00.000Z","cwd":"/work/beta"}
2
+ {"type":"message","id":"a1","parentId":null,"timestamp":"2026-09-01T12:05:00.000Z","message":{"role":"assistant","provider":"openai-codex","model":"gpt-5.5","usage":{"input":500,"output":500,"cacheRead":0,"cacheWrite":0,"cost":{"total":1}}}}
@@ -0,0 +1,2 @@
1
+ {"type":"session","version":3,"id":"aaa","timestamp":"2026-09-28T10:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"message","id":"a1","parentId":null,"timestamp":"2026-09-28T10:01:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":100,"output":50,"cacheRead":1000,"cacheWrite":200,"totalTokens":1350,"cost":{"total":0.5}}}}
@@ -0,0 +1,4 @@
1
+ {"type":"session","version":3,"id":"bbb","timestamp":"2026-09-29T23:00:00.000Z","cwd":"/work/alpha"}
2
+ {"type":"message","id":"a1","parentId":null,"timestamp":"2026-09-29T23:10:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":10,"output":20,"cacheRead":0,"cacheWrite":0,"totalTokens":30,"cost":{"total":0.1}}}}
3
+ {"type":"message","id":"a2","parentId":"a1","timestamp":"2026-09-30T01:00:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":0,"output":70,"cacheRead":0,"cacheWrite":0,"totalTokens":70,"cost":{"total":0.2}}}}
4
+ {"type":"message","id":"a3","parentId":"a2","timestamp":"2026-09-30T02:00:00.000Z","message":{"role":"assistant","provider":"claude-bridge","model":"claude-opus-5-5","usage":{"input":0,"output":100,"cacheRead":0,"cacheWrite":0,"totalTokens":100,"cost":{"total":0.3}}}}
@@ -0,0 +1,3 @@
1
+ {"type":"session","version":3,"id":"fff","timestamp":"2026-09-20T09:00:00.000Z","cwd":"/work/gamma"}
2
+ {"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-09-20T09:00:01.000Z","provider":"openai-codex","modelId":"gpt-5.3-codex"}
3
+ {"type":"message","id":"a1","parentId":"m1","timestamp":"2026-09-20T09:05:00.000Z","message":{"role":"assistant","provider":"openai-codex","model":"gpt-5.3-codex","usage":{"input":2000,"output":1000,"cacheRead":0,"cacheWrite":0,"totalTokens":3000,"cost":{"total":1.5}}}}