gentle-pi 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +352 -128
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { formatImportPiReport, importPi, nodeImportPiFs } from "../lib/gentle-shell-import-pi.ts";
8
+
9
+ // `gentle-shell home import-pi` core: real temp directories, never the real
10
+ // ~/.pi or ~/.gentle-shell. Credential contents never appear in assertions.
11
+
12
+ const AUTH = JSON.stringify({ anthropic: { type: "oauth", refresh: "dummy-refresh", access: "dummy-access" } });
13
+ const MCP = JSON.stringify({ mcpServers: { demo: { command: "demo" } } }, null, 2);
14
+
15
+ function fixture(t: test.TestContext) {
16
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-import-pi-"));
17
+ t.after(() => rmSync(root, { recursive: true, force: true }));
18
+ const source = join(root, "pi-agent");
19
+ const target = join(root, "gentle-agent");
20
+ mkdirSync(source, { recursive: true });
21
+ mkdirSync(target, { recursive: true });
22
+ return { root, source, target };
23
+ }
24
+
25
+ function seedSource(source: string) {
26
+ writeFileSync(join(source, "auth.json"), AUTH, { mode: 0o644 });
27
+ chmodSync(join(source, "auth.json"), 0o644);
28
+ writeFileSync(join(source, "mcp.json"), MCP);
29
+ chmodSync(join(source, "mcp.json"), 0o640);
30
+ writeFileSync(join(source, "settings.json"), `${JSON.stringify({
31
+ defaultProvider: "anthropic",
32
+ defaultModel: "claude-opus-5-5",
33
+ defaultThinkingLevel: "high",
34
+ packages: ["npm:foreign"],
35
+ extensions: ["./x.ts"],
36
+ theme: "dark",
37
+ npmCommand: ["npm"],
38
+ }, null, 2)}\n`);
39
+ writeFileSync(join(source, "mcp-cache.json"), "{}");
40
+ writeFileSync(join(source, "mcp-npx-cache.json"), "{}");
41
+ writeFileSync(join(source, "AGENTS.md"), "# agents\n");
42
+ mkdirSync(join(source, "sessions"));
43
+ }
44
+
45
+ function snapshot(dir: string) {
46
+ return Object.fromEntries(readdirSync(dir).sort().map((name) => {
47
+ const stat = statSync(join(dir, name));
48
+ const hash = stat.isFile() ? createHash("sha256").update(readFileSync(join(dir, name))).digest("hex") : "dir";
49
+ return [name, { hash, mode: stat.mode, mtimeMs: stat.mtimeMs }];
50
+ }));
51
+ }
52
+
53
+ function statusOf(result: ReturnType<typeof importPi>, item: string) {
54
+ return result.items.find((entry) => entry.item === item)?.status;
55
+ }
56
+
57
+ const modeOf = (path: string) => statSync(path).mode & 0o777;
58
+
59
+ test("copies auth.json (0600) and mcp.json (source mode) only when absent in the target", (t) => {
60
+ const f = fixture(t);
61
+ seedSource(f.source);
62
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
63
+ assert.equal(result.failed, false);
64
+ assert.equal(statusOf(result, "auth.json"), "copied");
65
+ assert.equal(statusOf(result, "mcp.json"), "copied");
66
+ assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === AUTH, "auth.json bytes copied verbatim");
67
+ assert.ok(readFileSync(join(f.target, "mcp.json"), "utf8") === MCP);
68
+ if (process.platform !== "win32") {
69
+ assert.equal(modeOf(join(f.target, "auth.json")), 0o600);
70
+ assert.equal(modeOf(join(f.target, "mcp.json")), 0o640);
71
+ }
72
+ });
73
+
74
+ test("never overwrites a target file that already exists and is idempotent", (t) => {
75
+ const f = fixture(t);
76
+ seedSource(f.source);
77
+ writeFileSync(join(f.target, "auth.json"), "{\"mine\":true}");
78
+ const first = importPi({ source: f.source, target: f.target, dryRun: false });
79
+ assert.equal(statusOf(first, "auth.json"), "present");
80
+ assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === "{\"mine\":true}", "existing target auth.json kept");
81
+ const before = snapshot(f.target);
82
+ const second = importPi({ source: f.source, target: f.target, dryRun: false });
83
+ assert.equal(second.failed, false);
84
+ assert.ok(second.items.every((entry) => entry.status !== "copied"), "a second run copies nothing");
85
+ assert.deepEqual(snapshot(f.target), before);
86
+ });
87
+
88
+ test("never modifies the source: content, mode and mtime stay the same", (t) => {
89
+ const f = fixture(t);
90
+ seedSource(f.source);
91
+ const before = snapshot(f.source);
92
+ importPi({ source: f.source, target: f.target, dryRun: false });
93
+ assert.deepEqual(snapshot(f.source), before);
94
+ });
95
+
96
+ test("never copies caches, AGENTS.md, sessions or any other file", (t) => {
97
+ const f = fixture(t);
98
+ seedSource(f.source);
99
+ importPi({ source: f.source, target: f.target, dryRun: false });
100
+ assert.deepEqual(readdirSync(f.target).sort(), ["auth.json", "mcp.json", "settings.json"]);
101
+ });
102
+
103
+ test("missing source files are reported as not found, not errors", (t) => {
104
+ const f = fixture(t);
105
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
106
+ assert.equal(result.failed, false);
107
+ assert.equal(statusOf(result, "auth.json"), "not found");
108
+ assert.equal(statusOf(result, "mcp.json"), "not found");
109
+ assert.equal(statusOf(result, "settings.json"), "not found");
110
+ assert.deepEqual(readdirSync(f.target), []);
111
+ });
112
+
113
+ test("skips a symlinked or non-regular source file", { skip: process.platform === "win32" }, (t) => {
114
+ const f = fixture(t);
115
+ writeFileSync(join(f.root, "elsewhere.json"), AUTH);
116
+ symlinkSync(join(f.root, "elsewhere.json"), join(f.source, "auth.json"));
117
+ mkdirSync(join(f.source, "mcp.json"));
118
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
119
+ assert.equal(statusOf(result, "auth.json"), "skipped");
120
+ assert.equal(statusOf(result, "mcp.json"), "skipped");
121
+ assert.equal(existsSync(join(f.target, "auth.json")), false);
122
+ assert.equal(existsSync(join(f.target, "mcp.json")), false);
123
+ });
124
+
125
+ test("settings: copies only the default provider/model/thinking keys absent in the target, preserving every target key and order", (t) => {
126
+ const f = fixture(t);
127
+ seedSource(f.source);
128
+ writeFileSync(join(f.target, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"] }, null, 2)}\n`);
129
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
130
+ assert.equal(result.failed, false);
131
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "copied");
132
+ assert.equal(statusOf(result, "settings.json defaultModel"), "present");
133
+ assert.equal(statusOf(result, "settings.json defaultThinkingLevel"), "copied");
134
+ const text = readFileSync(join(f.target, "settings.json"), "utf8");
135
+ assert.equal(text, `${JSON.stringify({
136
+ tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"],
137
+ defaultProvider: "anthropic", defaultThinkingLevel: "high",
138
+ }, null, 2)}\n`);
139
+ });
140
+
141
+ test("settings: the target keeps its own indentation and trailing-newline style", (t) => {
142
+ const f = fixture(t);
143
+ seedSource(f.source);
144
+ writeFileSync(join(f.target, "settings.json"), `{\n\t"tuiMode": "fullscreen",\n\t"zeta": 1\n}`);
145
+ importPi({ source: f.source, target: f.target, dryRun: false });
146
+ assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), JSON.stringify({
147
+ tuiMode: "fullscreen", zeta: 1, defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
148
+ }, null, "\t"));
149
+ });
150
+
151
+ test("settings: an unreadable settings.json is reported per item while the other items still run", (t) => {
152
+ if (process.platform === "win32" || process.getuid?.() === 0) return t.skip("needs POSIX permissions as a non-root user");
153
+ const f = fixture(t);
154
+ seedSource(f.source);
155
+ const settings = join(f.target, "settings.json");
156
+ writeFileSync(settings, "{}\n");
157
+ chmodSync(settings, 0o000);
158
+ try {
159
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
160
+ assert.equal(statusOf(result, "auth.json"), "copied");
161
+ assert.equal(statusOf(result, "settings.json"), "failed");
162
+ assert.equal(result.failed, true);
163
+ assert.match(formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false }).join("\n"), /settings\.json: failed/);
164
+ } finally {
165
+ chmodSync(settings, 0o600);
166
+ }
167
+ });
168
+
169
+ test("a destination that cannot be classified is one failed item while the other items still run", (t) => {
170
+ const f = fixture(t);
171
+ seedSource(f.source);
172
+ const blocked = join(f.target, "auth.json");
173
+ const fs = { ...nodeImportPiFs, kind: (path: string) => {
174
+ if (path === blocked) throw Object.assign(new Error(`ENOTDIR: not a directory, lstat '${path}'`), { code: "ENOTDIR" });
175
+ return nodeImportPiFs.kind(path);
176
+ } };
177
+ for (const dryRun of [true, false]) {
178
+ const result = importPi({ source: f.source, target: f.target, dryRun, fs });
179
+ assert.equal(statusOf(result, "auth.json"), "failed", `dryRun=${dryRun}`);
180
+ assert.equal(statusOf(result, "mcp.json"), dryRun ? "would copy" : "copied");
181
+ assert.equal(result.failed, true);
182
+ }
183
+ });
184
+
185
+ test("settings: a missing target settings.json gets only the allowlisted keys", (t) => {
186
+ const f = fixture(t);
187
+ seedSource(f.source);
188
+ importPi({ source: f.source, target: f.target, dryRun: false });
189
+ assert.deepEqual(JSON.parse(readFileSync(join(f.target, "settings.json"), "utf8")), {
190
+ defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
191
+ });
192
+ });
193
+
194
+ test("settings: nothing to add leaves the target settings.json untouched", (t) => {
195
+ const f = fixture(t);
196
+ writeFileSync(join(f.source, "settings.json"), JSON.stringify({ packages: ["npm:x"] }));
197
+ writeFileSync(join(f.target, "settings.json"), "{\"tuiMode\":\"fullscreen\"}");
198
+ const before = snapshot(f.target);
199
+ importPi({ source: f.source, target: f.target, dryRun: false });
200
+ assert.deepEqual(snapshot(f.target), before);
201
+ });
202
+
203
+ test("settings: malformed source or target JSON is reported and nothing is written", (t) => {
204
+ const f = fixture(t);
205
+ writeFileSync(join(f.source, "settings.json"), "{ not json");
206
+ const badSource = importPi({ source: f.source, target: f.target, dryRun: false });
207
+ assert.equal(statusOf(badSource, "settings.json"), "skipped");
208
+ assert.equal(badSource.failed, false);
209
+ assert.equal(existsSync(join(f.target, "settings.json")), false);
210
+
211
+ writeFileSync(join(f.source, "settings.json"), JSON.stringify({ defaultProvider: "anthropic" }));
212
+ writeFileSync(join(f.target, "settings.json"), "[1, 2]");
213
+ const badTarget = importPi({ source: f.source, target: f.target, dryRun: false });
214
+ assert.equal(statusOf(badTarget, "settings.json"), "skipped");
215
+ assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), "[1, 2]");
216
+ });
217
+
218
+ test("dry run reports the same plan and writes nothing, not even the target directory", (t) => {
219
+ const f = fixture(t);
220
+ seedSource(f.source);
221
+ const target = join(f.root, "not-created", "agent");
222
+ const result = importPi({ source: f.source, target, dryRun: true });
223
+ assert.equal(result.failed, false);
224
+ assert.equal(statusOf(result, "auth.json"), "would copy");
225
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "would copy");
226
+ assert.equal(existsSync(join(f.root, "not-created")), false);
227
+ });
228
+
229
+ test("a write failure is reported per item and keeps what was already copied", { skip: process.platform === "win32" }, (t) => {
230
+ const f = fixture(t);
231
+ seedSource(f.source);
232
+ // A read-only target makes every write fail; nothing is half-written.
233
+ writeFileSync(join(f.target, "settings.json"), "{}");
234
+ chmodSync(f.target, 0o500);
235
+ let result;
236
+ try {
237
+ result = importPi({ source: f.source, target: f.target, dryRun: false });
238
+ } finally {
239
+ chmodSync(f.target, 0o700); // before the fixture's own cleanup
240
+ }
241
+ assert.equal(result.failed, true);
242
+ assert.equal(statusOf(result, "auth.json"), "failed");
243
+ assert.equal(statusOf(result, "settings.json defaultProvider"), "failed");
244
+ });
245
+
246
+ test("the report has one line per item and a summary, and never prints credential contents", (t) => {
247
+ const f = fixture(t);
248
+ seedSource(f.source);
249
+ const result = importPi({ source: f.source, target: f.target, dryRun: false });
250
+ const lines = formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false });
251
+ const text = lines.join("\n");
252
+ assert.match(text, /auth\.json: copied \(0600\)/);
253
+ assert.match(text, /mcp\.json: copied/);
254
+ assert.match(text, /settings\.json defaultModel: copied/);
255
+ assert.match(text, /Imported 5 item\(s\)/);
256
+ assert.doesNotMatch(text, /dummy-refresh|dummy-access/);
257
+
258
+ const again = formatImportPiReport(importPi({ source: f.source, target: f.target, dryRun: true }), { source: f.source, target: f.target, dryRun: true });
259
+ assert.match(again.join("\n"), /auth\.json: already present, kept/);
260
+ assert.match(again.join("\n"), /Dry run/);
261
+ assert.match(again.join("\n"), /Nothing to import/);
262
+ });
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
5
5
  import test from "node:test";
6
6
  import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, parseChildPackageInjection } from "../lib/child-package-injection.ts";
7
7
  import {
8
+ GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV,
8
9
  MIN_PI_VERSION,
10
+ withLauncherPackagesExcluded,
9
11
  MIN_SETUP_GENTLE_AI_VERSION,
10
12
  PI_SUBCOMMANDS,
11
13
  buildPiInvocation,
@@ -37,6 +39,7 @@ import {
37
39
  userPiHome,
38
40
  resolvePiRuntime,
39
41
  restoreJsonField,
42
+ separateHomeHint,
40
43
  settingsDeclareGentlePi,
41
44
  shellQuote,
42
45
  type PackageJsonPeerShape,
@@ -66,6 +69,20 @@ test("parseLauncherArgs returns all-false defaults for an empty argv", () => {
66
69
  });
67
70
  });
68
71
 
72
+ test("parseLauncherArgs reserves explicit memory recovery without consuming prior commands or escaped prompts", () => {
73
+ const memory = parseLauncherArgs(["memory", "migrate", "--confirm-quiescent"]);
74
+ assert.equal(memory.command, "memory");
75
+ assert.deepEqual(memory.commandArgs, ["migrate", "--confirm-quiescent"]);
76
+ assert.deepEqual(memory.passthrough, []);
77
+ for (const command of ["home", "upgrade", "self-uninstall"] as const) {
78
+ const parsed = parseLauncherArgs([command, "--help"]);
79
+ assert.equal(parsed.command, command);
80
+ assert.deepEqual(parsed.commandArgs, ["--help"]);
81
+ }
82
+ assert.deepEqual(parseLauncherArgs(["--", "memory"]).passthrough, ["memory"]);
83
+ assert.match(helpText(), /gentle-shell memory migrate --confirm-quiescent/);
84
+ });
85
+
69
86
  test("parseLauncherArgs captures a --package-root value from the next argument", () => {
70
87
  assert.equal(parseLauncherArgs(["--package-root", "/custom/root"]).packageRoot, "/custom/root");
71
88
  });
@@ -205,6 +222,40 @@ test("parseLauncherArgs treats home as a plain passthrough token when it is not
205
222
  assert.deepEqual(parsed.passthrough, ["home"]);
206
223
  });
207
224
 
225
+ test("parseLauncherArgs accepts home selectors ahead of home import-pi only", () => {
226
+ const link = parseLauncherArgs(["--link", "home", "import-pi"]);
227
+ assert.equal(link.link, true);
228
+ assert.equal(link.command, "home");
229
+ assert.deepEqual(link.commandArgs, ["import-pi"]);
230
+ assert.deepEqual(link.passthrough, []);
231
+ const path = parseLauncherArgs(["--home", "/x", "home", "import-pi", "--dry-run"]);
232
+ assert.equal(path.home, "/x");
233
+ assert.equal(path.command, "home");
234
+ assert.deepEqual(path.commandArgs, ["import-pi", "--dry-run"]);
235
+ const first = parseLauncherArgs(["home", "import-pi", "--dry-run"]);
236
+ assert.equal(first.command, "home");
237
+ assert.deepEqual(first.commandArgs, ["import-pi", "--dry-run"]);
238
+ // Anything else after a selector stays pi's, as before.
239
+ assert.deepEqual(parseLauncherArgs(["--link", "home", "link"]).passthrough, ["home", "link"]);
240
+ assert.deepEqual(parseLauncherArgs(["-p", "home", "import-pi"]).passthrough, ["-p", "home", "import-pi"]);
241
+ });
242
+
243
+ test("help text documents gentle-shell home import-pi", () => {
244
+ const text = helpText();
245
+ assert.ok(text.includes(" gentle-shell [home selectors] home import-pi [--dry-run]"));
246
+ assert.match(text, /import-pi\s+Copy your pi sign-ins \(auth\.json\) and MCP servers \(mcp\.json\)/);
247
+ });
248
+
249
+ test("separateHomeHint mentions home import-pi only when the user's pi home has sign-ins", () => {
250
+ const isolated: ResolvedHome = { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" };
251
+ const without = separateHomeHint(isolated, { piSignIns: false });
252
+ assert.equal(without, "gentle-shell: using a separate home at /h/.gentle-shell/agent. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.");
253
+ const withSignIns = separateHomeHint(isolated, { piSignIns: true });
254
+ assert.match(withSignIns, /Run 'gentle-shell --link' to reuse your pi sign-ins and chats, or 'gentle-shell home import-pi' to copy your sign-ins and MCP servers into this home\.$/);
255
+ const path = separateHomeHint({ mode: "path", dir: "/custom", source: "flag" }, { piSignIns: true });
256
+ assert.match(path, /'gentle-shell --home \/custom home import-pi'/);
257
+ });
258
+
208
259
  // --- setup subcommand ----------------------------------------------------
209
260
  //
210
261
  // Unlike `home`, `setup` is not restricted to argv[0]: it accepts the home
@@ -293,6 +344,19 @@ test("parseLauncherArgs recognises a pi subcommand as the first token after --",
293
344
  assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
294
345
  });
295
346
 
347
+ test("parseLauncherArgs keeps a separator that follows a pi subcommand (#2140)", () => {
348
+ const parsed = parseLauncherArgs(["mcp", "add", "demo", "--", "npx", "demo-server"]);
349
+ assert.equal(parsed.piSubcommand, "mcp");
350
+ assert.deepEqual(parsed.passthrough, ["mcp", "add", "demo", "--", "npx", "demo-server"]);
351
+ });
352
+
353
+ test("parseLauncherArgs still consumes a later separator when no pi subcommand started", () => {
354
+ const parsed = parseLauncherArgs(["hello", "--", "--link"]);
355
+ assert.equal(parsed.piSubcommand, undefined);
356
+ assert.equal(parsed.link, false);
357
+ assert.deepEqual(parsed.passthrough, ["hello", "--link"]);
358
+ });
359
+
296
360
  test("parseLauncherArgs errors when --link is combined with --isolated", () => {
297
361
  const parsed = parseLauncherArgs(["--link", "--isolated"]);
298
362
  assert.match(parsed.error ?? "", /--link/);
@@ -683,9 +747,10 @@ test("checkPeerVersionPin passes for a matching pin", () => {
683
747
  assert.deepEqual(result, { ok: true, pinned: ">=0.85.1" });
684
748
  });
685
749
 
686
- test("Pi 0.99.1 baseline and host peers follow the open development range policy", () => {
750
+ test("Pi 1.2.0 baseline and host peers follow the open development range policy", () => {
687
751
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
688
- assert.equal(MIN_PI_VERSION, "0.99.1");
752
+ assert.equal(MIN_PI_VERSION, "1.2.0");
753
+ assert.equal(pkg.peerDependencies["@earendil-works/pi-coding-agent"], ">=1.2.0");
689
754
  assert.equal(pkg.engines.node, ">=22.19.0");
690
755
  for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
691
756
  assert.equal(pkg.peerDependencies[name], "*");
@@ -695,34 +760,38 @@ test("Pi 0.99.1 baseline and host peers follow the open development range policy
695
760
  // Development ranges are policy specifiers; their floor never drops below
696
761
  // the runtime minimum the launcher enforces.
697
762
  for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
698
- assert.equal(pkg.devDependencies[name], ">=1.0.0", name);
763
+ assert.equal(pkg.devDependencies[name], ">=1.2.0", name);
699
764
  assert.equal(checkPiVersion(pkg.devDependencies[name].slice(2)).ok, true, name);
700
765
  }
701
766
  });
702
767
 
703
768
  test("checkPiVersion accepts a version equal to the minimum", () => {
704
- assert.deepEqual(checkPiVersion("0.99.1"), { ok: true, version: "0.99.1" });
769
+ assert.deepEqual(checkPiVersion("1.2.0"), { ok: true, version: "1.2.0" });
705
770
  });
706
771
 
707
772
  test("checkPiVersion accepts a version above the minimum", () => {
708
- assert.deepEqual(checkPiVersion("0.100.0"), { ok: true, version: "0.100.0" });
773
+ for (const version of ["1.2.1", "1.3.0", "2.0.0"]) {
774
+ assert.deepEqual(checkPiVersion(version), { ok: true, version });
775
+ }
709
776
  });
710
777
 
711
778
  test("checkPiVersion accepts a v-prefixed version", () => {
712
- assert.deepEqual(checkPiVersion("v0.99.1"), { ok: true, version: "0.99.1" });
779
+ assert.deepEqual(checkPiVersion("v1.2.0"), { ok: true, version: "1.2.0" });
713
780
  });
714
781
 
715
782
  test("checkPiVersion accepts a prerelease suffix at the minimum", () => {
716
- assert.deepEqual(checkPiVersion("pi version 0.99.1-rc.2"), { ok: true, version: "0.99.1" });
783
+ assert.deepEqual(checkPiVersion("pi version 1.2.0-rc.2"), { ok: true, version: "1.2.0" });
717
784
  });
718
785
 
719
786
  test("checkPiVersion rejects a version below the minimum and names both versions", () => {
720
- const result = checkPiVersion("0.85.0");
787
+ const result = checkPiVersion("1.1.0");
721
788
  assert.equal(result.ok, false);
722
789
  if (result.ok) throw new Error("expected a failing result");
723
- assert.equal(result.version, "0.85.0");
724
- assert.match(result.message, /0\.85\.0/);
725
- assert.match(result.message, /0\.99\.1/);
790
+ assert.equal(result.version, "1.1.0");
791
+ assert.match(result.message, /1\.1\.0/);
792
+ assert.match(result.message, /1\.2\.0/);
793
+ assert.equal(checkPiVersion("1.0.0").ok, false);
794
+ assert.equal(checkPiVersion("0.99.1").ok, false);
726
795
  });
727
796
 
728
797
  test("checkPiVersion rejects a prerelease below the minimum", () => {
@@ -736,7 +805,7 @@ test("checkPiVersion reports unparsable output with the raw text and the minimum
736
805
  if (result.ok) throw new Error("expected a failing result");
737
806
  assert.equal(result.version, undefined);
738
807
  assert.match(result.message, /not a version/);
739
- assert.match(result.message, /0\.99\.1/);
808
+ assert.match(result.message, /1\.2\.0/);
740
809
  });
741
810
 
742
811
  test("checkPiVersion accepts a custom minimum", () => {
@@ -2015,7 +2084,7 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
2015
2084
  const root = mkdtempSync(join(tmpdir(), "gentle-shell-umask-"));
2016
2085
  try {
2017
2086
  const piScript = join(root, "fake-pi.mjs");
2018
- writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('0.99.1');\n");
2087
+ writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('1.2.0');\n");
2019
2088
  chmodSync(piScript, 0o755);
2020
2089
  const binPath = fileURLToPath(new URL("../bin/gentle-shell.mjs", import.meta.url));
2021
2090
  const env = { HOME: root, USERPROFILE: root, PATH: process.env.PATH ?? "/usr/bin:/bin", GENTLE_SHELL_PI: piScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
@@ -2027,3 +2096,18 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
2027
2096
  rmSync(root, { recursive: true, force: true });
2028
2097
  }
2029
2098
  });
2099
+
2100
+ // #2143: Gentle AI installs npm:gentle-pi into the home only for the launcher
2101
+ // to remove it again; a Gentle AI with package exclusion (gentle-ai#5503)
2102
+ // skips it, and an older one ignores the variable.
2103
+ test("withLauncherPackagesExcluded asks Gentle AI to skip npm:gentle-pi, keeping a caller's own exclusions", () => {
2104
+ assert.equal(GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV, "GENTLE_AI_PI_EXCLUDE_PACKAGES");
2105
+ assert.equal(withLauncherPackagesExcluded({ PATH: "/bin" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
2106
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
2107
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:pi-btw,npm:gentle-pi");
2108
+ assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:gentle-pi, npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi,npm:pi-btw");
2109
+ const input = { PATH: "/bin" };
2110
+ assert.equal(withLauncherPackagesExcluded(input).PATH, "/bin");
2111
+ assert.deepEqual(input, { PATH: "/bin" }, "the caller's env is not mutated");
2112
+ });
2113
+
@@ -976,3 +976,33 @@ test("bundled on Windows: the user Path entry goes first, the running node.exe,
976
976
  assert.equal(existsSync(join(w.shared.agent, "auth.json")), true);
977
977
  } finally { w.cleanup(); }
978
978
  });
979
+
980
+ // #2201: the standalone isolated home keeps its config in ~/.gentle-shell/gentle-ai.
981
+ test("Gentle Shell's own config folder next to the isolated home is removed; the shared config home keeps its rule", async () => {
982
+ const w = world();
983
+ try {
984
+ const own = join(w.shell, "gentle-ai");
985
+ write(join(own, "profiles.json"));
986
+ write(join(own, "persona.json"));
987
+ assert.equal(await w.uninstall(["--dry-run"]), 0);
988
+ assert.ok(section(w.out, "Remove Gentle Shell data:").some((line) => line.includes(own)), w.out.join("\n"));
989
+ assert.equal(await w.uninstall(["--yes"]), 0);
990
+ assert.equal(existsSync(own), false);
991
+ assert.equal(existsSync(w.shell), false);
992
+ for (const name of ["profiles.json", "persona.json"]) assert.equal(existsSync(join(w.config, name)), true, name);
993
+ } finally { w.cleanup(); }
994
+ });
995
+
996
+ test("the gentle-ai folder next to a custom GENTLE_SHELL_HOME is kept: it may not be Gentle Shell's", async () => {
997
+ const w = world();
998
+ try {
999
+ const custom = join(w.root, "elsewhere", "agent");
1000
+ const sibling = join(w.root, "elsewhere", "gentle-ai");
1001
+ write(join(custom, ".gentle-shell-home"));
1002
+ write(join(sibling, "notes.json"));
1003
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom } }), 0);
1004
+ assert.equal(existsSync(custom), false);
1005
+ assert.equal(existsSync(join(sibling, "notes.json")), true);
1006
+ assert.ok(section(w.out, "Kept:").some((line) => line.includes(sibling)), w.out.join("\n"));
1007
+ } finally { w.cleanup(); }
1008
+ });