gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  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/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test, { type TestContext } from "node:test";
6
+ import { DESIGN_SOURCE_LIMITS, detectDesignSources, staleDesignSources } from "../lib/design-sources.ts";
7
+
8
+ // S13: the design sources a project has, found by a bounded deterministic
9
+ // walk, and the ones changed after design/system.md was written.
10
+
11
+ function scratch(t: TestContext): string {
12
+ const dir = mkdtempSync(join(tmpdir(), "gentle-design-sources-"));
13
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
14
+ return dir;
15
+ }
16
+
17
+ function write(root: string, path: string, content = ""): void {
18
+ mkdirSync(dirname(join(root, path)), { recursive: true });
19
+ writeFileSync(join(root, path), content);
20
+ }
21
+
22
+ test("each design source kind is detected, sorted, and plain files are not", (t) => {
23
+ const cwd = scratch(t);
24
+ write(cwd, "tailwind.config.ts", "export default {}");
25
+ write(cwd, "web/tailwind.config.cjs", "module.exports = {}");
26
+ write(cwd, "src/styles/globals.css", ":root {\n --brand: #0a84ff;\n}\n");
27
+ write(cwd, "src/styles/dark.scss", "[data-theme='dark'] { --surface: #111; }");
28
+ write(cwd, "src/app.pcss", "@import 'tailwindcss';\n@theme {\n --color-ink: oklch(20% 0 0);\n}\n");
29
+ write(cwd, "src/theme-entry.css", "@theme inline;");
30
+ write(cwd, "src/design-tokens.json", "{}");
31
+ write(cwd, "src/tokens/Tokens.ts", "export const x = 1;");
32
+ write(cwd, "src/theme.ts", "export const theme = { colors: { primary: '#000' } };");
33
+ write(cwd, "src/plain.css", ".btn { color: red; background: var(--brand); }");
34
+ write(cwd, "src/commented.css", "/* :root { --x: 1px; } */\n.a { margin: 0; }");
35
+ write(cwd, "src/nested.css", ".card { --pad: 4px; }");
36
+ write(cwd, "src/theme.js", "export const spacing = { sm: 4 };");
37
+ write(cwd, "src/tokens.md", "# tokens");
38
+ write(cwd, "tailwind.config.json", "{}");
39
+ assert.deepEqual(detectDesignSources(cwd), [
40
+ "src/app.pcss",
41
+ "src/design-tokens.json",
42
+ "src/styles/dark.scss",
43
+ "src/styles/globals.css",
44
+ "src/theme-entry.css",
45
+ "src/theme.ts",
46
+ "src/tokens/Tokens.ts",
47
+ "tailwind.config.ts",
48
+ "web/tailwind.config.cjs",
49
+ ]);
50
+ });
51
+
52
+ test("token files count only when named as design tokens, never auth or tokenizer files", (t) => {
53
+ const cwd = scratch(t);
54
+ write(cwd, "src/theme.tokens.css", ".a { margin: 0; }");
55
+ write(cwd, "src/tokens_v2.js", "export {};");
56
+ for (const path of ["src/auth/token.ts", "src/auth/refreshToken.ts", "src/csrf-token.js", "src/tokenizer.ts", "src/jwt-token.test.ts", "src/tokens.test.ts", "src/design-tokens.spec.js"]) write(cwd, path, "export {};");
57
+ assert.deepEqual(detectDesignSources(cwd), ["src/theme.tokens.css", "src/tokens_v2.js"]);
58
+ });
59
+
60
+ test("a project without design sources, or a missing cwd, has none", (t) => {
61
+ const cwd = scratch(t);
62
+ write(cwd, "src/index.ts", "export {};");
63
+ write(cwd, "src/plain.css", "body { margin: 0; }");
64
+ assert.deepEqual(detectDesignSources(cwd), []);
65
+ assert.deepEqual(detectDesignSources(join(cwd, "missing")), []);
66
+ });
67
+
68
+ test("the walk skips dependency, build, VCS, design and dot directories", (t) => {
69
+ const cwd = scratch(t);
70
+ const css = ":root { --x: 1px; }";
71
+ for (const dir of ["node_modules/pkg", ".git", "dist", "build", "out", "coverage", ".next", ".worktrees/other", "design", ".cache", "src/.hidden"]) {
72
+ write(cwd, `${dir}/tokens.json`, "{}");
73
+ write(cwd, `${dir}/vars.css`, css);
74
+ }
75
+ write(cwd, "packages/ui/design/tokens.json", "{}");
76
+ assert.deepEqual(detectDesignSources(cwd), ["packages/ui/design/tokens.json"], "only the root design/ is the design output");
77
+ });
78
+
79
+ test("symlinks are not followed, to files or directories", (t) => {
80
+ const cwd = scratch(t);
81
+ const outside = scratch(t);
82
+ write(outside, "shared/tokens.json", "{}");
83
+ write(outside, "vars.css", ":root { --x: 1px; }");
84
+ write(cwd, "src/index.ts", "");
85
+ symlinkSync(join(outside, "shared"), join(cwd, "src", "shared"));
86
+ symlinkSync(join(outside, "vars.css"), join(cwd, "src", "vars.css"));
87
+ symlinkSync(join(outside, "shared", "tokens.json"), join(cwd, "tokens.json"));
88
+ assert.deepEqual(detectDesignSources(cwd), []);
89
+ });
90
+
91
+ test("the depth cap holds: six directory levels are walked, the seventh is not", (t) => {
92
+ const cwd = scratch(t);
93
+ assert.equal(DESIGN_SOURCE_LIMITS.maxDepth, 6);
94
+ write(cwd, "a/b/c/d/e/f/tokens.json", "{}");
95
+ write(cwd, "a/b/c/d/e/f/g/tokens.json", "{}");
96
+ assert.deepEqual(detectDesignSources(cwd), ["a/b/c/d/e/f/tokens.json"]);
97
+ });
98
+
99
+ test("the file cap holds: the walk stops after 5000 files in name order", (t) => {
100
+ const cwd = scratch(t);
101
+ assert.equal(DESIGN_SOURCE_LIMITS.maxFiles, 5000);
102
+ for (let index = 0; index < 4999; index++) write(cwd, `a${String(index).padStart(4, "0")}.txt`);
103
+ write(cwd, "z-tokens.json", "{}");
104
+ assert.deepEqual(detectDesignSources(cwd), ["z-tokens.json"], "the 5000th file is still read");
105
+ write(cwd, "a4999.txt");
106
+ assert.deepEqual(detectDesignSources(cwd), [], "the 5001st file is not");
107
+ });
108
+
109
+ test("only the first 256 KiB of a stylesheet is read", (t) => {
110
+ const cwd = scratch(t);
111
+ assert.equal(DESIGN_SOURCE_LIMITS.maxBytes, 256 * 1024);
112
+ const filler = `/*${"x".repeat(256 * 1024)}*/\n`;
113
+ write(cwd, "late.css", `.a{}\n${filler}:root { --x: 1px; }`);
114
+ write(cwd, "early.css", `:root { --x: 1px; }\n${filler}`);
115
+ assert.deepEqual(detectDesignSources(cwd), ["early.css"]);
116
+ });
117
+
118
+ test("stale sources are the ones modified after design/system.md", (t) => {
119
+ const cwd = scratch(t);
120
+ write(cwd, "tailwind.config.js", "module.exports = {}");
121
+ write(cwd, "src/tokens.json", "{}");
122
+ const sources = detectDesignSources(cwd);
123
+ assert.deepEqual(staleDesignSources(cwd, sources), [], "no design/system.md, nothing is stale");
124
+ write(cwd, "design/system.md", "# Tokens");
125
+ utimesSync(join(cwd, "design", "system.md"), 2000, 2000);
126
+ utimesSync(join(cwd, "tailwind.config.js"), 1000, 1000);
127
+ utimesSync(join(cwd, "src", "tokens.json"), 3000, 3000);
128
+ assert.deepEqual(staleDesignSources(cwd, sources), ["src/tokens.json"]);
129
+ utimesSync(join(cwd, "src", "tokens.json"), 2000, 2000);
130
+ assert.deepEqual(staleDesignSources(cwd, sources), [], "an equal mtime is not newer");
131
+ assert.deepEqual(staleDesignSources(cwd, [...sources, "gone.css"]), [], "a source that vanished is skipped");
132
+ });
@@ -78,7 +78,91 @@ test("recognizes later compound destruction and shell wrappers without matching
78
78
  });
79
79
 
80
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 ';' /`]) {
81
+ for (const command of [`rm -fr /`, `rm -r "$HOME"`, `git -C /repo reset --hard`, `git push -f`, `git push ';' -f main`, `rm -r ';' /`, `rm -rf /tmp/tmp.abcdef1234 ~/foo`, `rm -rf /tmp/tmp.abcdef1234 /`]) {
82
82
  assert.equal(recognizeDestructiveCommands(command)[0]?.hardDeny, true, command);
83
83
  }
84
84
  });
85
+
86
+ // gentle-pi#2285: a reserved word that opens a compound body is not the
87
+ // segment head, and backtick and literal `eval` payloads run like `$(...)`.
88
+ const compoundRemovals = [
89
+ `if true; then rm -rf data; fi`,
90
+ `for d in a b; do rm -rf "$d"; done`,
91
+ "echo `rm -rf data`",
92
+ `eval 'rm -rf data'`,
93
+ `if false; then :; else rm -rf data; fi`,
94
+ `if false; then :; elif rm -rf data; then :; fi`,
95
+ `while rm -rf data; do :; done`,
96
+ `until rm -rf data; do :; done`,
97
+ `if rm -rf data; then :; fi`,
98
+ `{ rm -rf data; }`,
99
+ `! rm -rf data`,
100
+ `if true; then sudo rm -rf data; fi`,
101
+ "echo \"now: `rm -rf data`\"",
102
+ "echo `true; rm -rf data`",
103
+ `eval "rm -rf data"`,
104
+ `eval rm -rf data`,
105
+ `eval -- 'rm -rf data'`,
106
+ `command eval 'rm -rf data'`,
107
+ `eval 'if true; then rm -rf data; fi'`,
108
+ `eval 'git reset --hard'`,
109
+ ];
110
+ for (const command of compoundRemovals) {
111
+ test(`recognizes destruction after reserved words, in backticks and in eval: ${command}`, () => {
112
+ const matches = recognizeDestructiveCommands(command);
113
+ assert.ok(matches.length > 0, command);
114
+ assert.ok(matches.every((match) => match.triggerIndex >= 0 && match.triggerIndex < command.length), command);
115
+ assert.ok(matches.every((match) => match.removalTargets === undefined), command);
116
+ });
117
+ }
118
+
119
+ test("issue #2285 examples are recursive filesystem data loss like a plain rm", () => {
120
+ for (const command of [`if true; then rm -rf data; fi`, `for d in a b; do rm -rf "$d"; done`, "echo `rm -rf data`", `eval 'rm -rf data'`]) {
121
+ const matches = recognizeDestructiveCommands(command);
122
+ assert.ok(matches.some((match) => match.kind === "filesystem"), command);
123
+ assert.ok(matches.every((match) => !match.hardDeny), command);
124
+ }
125
+ });
126
+
127
+ test("root/home recursive removal behind reserved words, backticks or eval keeps its hard denial", () => {
128
+ for (const command of [
129
+ `if true; then rm -rf ~; fi`, `for d in a; do rm -rf /; done`, `{ rm -rf "$HOME"; }`, `! rm -rf ..`,
130
+ "echo `rm -rf ~`", `eval 'rm -rf /'`, `while true; do rm -rf ~/foo; done`,
131
+ ]) {
132
+ assert.ok(recognizeDestructiveCommands(command).some((match) => match.hardDeny), command);
133
+ }
134
+ });
135
+
136
+ for (const command of [
137
+ `if [ -f x ]; then echo ok; fi`, `for f in *; do cat "$f"; done`, "echo `date`", `eval "$(ssh-agent -s)"`,
138
+ `grep "then rm -rf" x`, `echo 'then rm -rf data'`, "echo '`rm -rf data`'", "echo \\`rm -rf data\\`",
139
+ `eval 'echo rm -rf data'`, `eval "$cmd"`, `while read -r line; do echo "$line"; done < x`,
140
+ `if git diff --quiet; then git push origin main; fi`, "echo 'unterminated `rm -rf data'",
141
+ ]) {
142
+ test(`leaves ordinary compound, backtick and eval commands unchanged: ${command}`, () => {
143
+ assert.deepEqual(recognizeDestructiveCommands(command), []);
144
+ });
145
+ }
146
+
147
+ // Only a direct, unwrapped, unquoted `rm` exposes its literal targets; the
148
+ // YOLO temp-scratch waiver has nothing to inspect anywhere else.
149
+ test("exposes literal removal targets only for a direct top-level rm that runs before every other segment", () => {
150
+ assert.deepEqual(recognizeDestructiveCommands(`rm -rf /tmp/tmp.abcdef1234 /tmp/x.log; git log -1`)[0]?.removalTargets, ["/tmp/tmp.abcdef1234", "/tmp/x.log"]);
151
+ assert.deepEqual(recognizeDestructiveCommands(`rm -r -f -- /tmp/tmp.abcdef1234`)[0]?.removalTargets, ["/tmp/tmp.abcdef1234"]);
152
+ assert.deepEqual(recognizeDestructiveCommands(`rm -rf /tmp/a && rm -r /tmp/b\n\nrm -r /tmp/c || true`).map((match) => match.removalTargets), [["/tmp/a"], ["/tmp/b"], ["/tmp/c"]]);
153
+ assert.deepEqual(recognizeDestructiveCommands(`rm -rf /tmp/a; mv x /tmp/b; rm -rf /tmp/b`).map((match) => match.removalTargets), [["/tmp/a"], undefined]);
154
+ for (const command of [
155
+ `sudo rm -rf /tmp/tmp.abcdef1234`, `env rm -rf /tmp/tmp.abcdef1234`, `X=1 rm -rf /tmp/tmp.abcdef1234`,
156
+ `xargs rm -rf /tmp/tmp.abcdef1234`, `/bin/rm -rf /tmp/tmp.abcdef1234`, `sh -c 'rm -rf /tmp/tmp.abcdef1234'`,
157
+ `find /tmp -name tmp.abcdef1234 -exec rm -rf {} +`, `rm -rf "$TMPDIR/x"`, `rm -rf '/tmp/tmp.abcdef1234'`,
158
+ `rm -rf --no-preserve-root /tmp/tmp.abcdef1234`, `rm -rf`,
159
+ `mv x /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.abcdef1234`, `cp -r x /tmp/tmp.abcdef1234; rm -rf /tmp/tmp.abcdef1234`,
160
+ `(rm -rf /tmp/tmp.abcdef1234)`, `rm -rf /tmp/tmp.abcdef1234 & true`, `rm -rf /tmp/tmp.abcdef1234 | cat`,
161
+ `{ rm -rf /tmp/tmp.abcdef1234; }`, `! rm -rf /tmp/tmp.abcdef1234`, `if true; then rm -rf /tmp/tmp.abcdef1234; fi`,
162
+ "echo `rm -rf /tmp/tmp.abcdef1234`", `eval 'rm -rf /tmp/tmp.abcdef1234'`, `eval rm -rf /tmp/tmp.abcdef1234`,
163
+ ]) {
164
+ const matches = recognizeDestructiveCommands(command);
165
+ assert.ok(matches.length > 0, command);
166
+ assert.ok(matches.every((match) => match.removalTargets === undefined), command);
167
+ }
168
+ });
@@ -16,7 +16,7 @@ const OTHER_ID = "fedcba9876543210fedcba9876543210";
16
16
  const READY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
17
17
  instance_id: ID, runtime_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", capability: "startup-verified-store-v1" };
18
18
  const REFUSED: shell.EngramProbe = { kind: "refused" };
19
- const CORE = "3.4.0";
19
+ const CORE = "3.5.2";
20
20
  const listening = (compatible: boolean, healthInstanceId?: string, healthVersion = CORE): shell.EngramProbe =>
21
21
  ({ kind: "listening", compatible, healthInstanceId, healthVersion: healthInstanceId === undefined ? undefined : healthVersion });
22
22
 
@@ -28,59 +28,152 @@ const clean = (extra: Record<string, string> = {}) => {
28
28
  return env;
29
29
  };
30
30
  const classify = (input: Partial<Parameters<typeof shell.classifyEngramStartup>[0]> = {}) =>
31
- shell.classifyEngramStartup({ env: {}, store: "absent", endpoint: REFUSED, core: true, coreVersion: CORE, ...input });
31
+ shell.classifyEngramStartup({ env: {}, endpoint: REFUSED, core: true, coreVersion: CORE, ...input });
32
32
 
33
- test("fresh state with a verified bundled core allows memory and marks the run as provisioning", () => {
34
- assert.deepEqual(classify(), { policy: "allow", fresh: true });
33
+ test("shared Engram environment drops only session scratch temp paths", () => {
34
+ for (const platform of ["win32", "linux"]) {
35
+ for (const prefix of ["gp", "ga"]) {
36
+ const scratch = platform === "win32" ? `C:\\Temp\\${prefix}Ab12Z9\\` : `/tmp/${prefix}Ab12Z9/`;
37
+ const base = { TEMP: scratch, TMP: scratch, TMPDIR: scratch, PATH: "keep", OTHER: scratch };
38
+ assert.deepEqual(shell.sharedEngramEnv(base, platform), { ...(platform === "win32" ? { TEMP: "C:\\Temp", TMP: "C:\\Temp", TMPDIR: "C:\\Temp" } : {}), PATH: "keep", OTHER: scratch, ENGRAM_NO_UPDATE_CHECK: "1" });
39
+ assert.equal(base.TEMP, scratch, "does not mutate parent");
40
+ }
41
+ for (const chosen of ["/tmp/custom", "/tmp/gp12345", "/tmp/ga1234567", "/tmp/gp123456/child", "/tmp/gp12-456"]) {
42
+ assert.equal(shell.sharedEngramEnv({ TEMP: chosen }, platform).TEMP, chosen);
43
+ }
44
+ }
45
+ assert.deepEqual(shell.sharedEngramEnv({ Temp: "C:\\Temp\\gp123456\\gaAb12Z9\\", tMp: "D:/custom", tmpdir: "C:/chosen" }, "win32"),
46
+ { Temp: "C:\\Temp", tMp: "D:/custom", tmpdir: "C:/chosen", ENGRAM_NO_UPDATE_CHECK: "1" });
47
+ for (const key of ["TMP", "TMPDIR"]) {
48
+ assert.deepEqual(shell.sharedEngramEnv({ [key]: "C:\\Temp\\gp123456" }, "win32"),
49
+ { [key]: "C:\\Temp", TEMP: "C:\\Temp", ENGRAM_NO_UPDATE_CHECK: "1" });
50
+ assert.equal(shell.sharedEngramEnv({ [key]: "C:\\Temp\\gp123456", Temp: "D:\\chosen" }, "win32").Temp, "D:\\chosen");
51
+ }
52
+ for (const invalid of ["gp123456", "relative\\ga123456", "C:gp123456", "\\gp123456"]) {
53
+ assert.throws(() => shell.sharedEngramEnv({ TEMP: invalid }, "win32"), /absolute.*scratch/i);
54
+ }
55
+ assert.equal(shell.sharedEngramEnv({ tmp: "/tmp/gp123456" }, "linux").tmp, "/tmp/gp123456");
56
+ });
57
+
58
+ test("shared Engram environment detects Windows scratch after a trailing dot", () => {
59
+ assert.equal(shell.sharedEngramEnv({ TEMP: "C:\\Temp\\gp123456\\." }, "win32").TEMP, "C:\\Temp");
60
+ });
61
+
62
+ test("shared Engram environment detects POSIX scratch after child/..", () => {
63
+ assert.deepEqual(shell.sharedEngramEnv({ TMPDIR: "/tmp/gp123456/child/.." }, "linux"), { ENGRAM_NO_UPDATE_CHECK: "1" });
35
64
  });
36
65
 
37
- test("a fresh state without the verified bundled core is disabled, never a PATH fallback", () => {
66
+ test("shared Engram environment refuses original Windows .. even when normalization hides it", () => {
67
+ assert.throws(() => shell.sharedEngramEnv({ TEMP: "C:\\Temp\\gp123456\\child\\.." }, "win32"), /absolute.*scratch/i);
68
+ });
69
+
70
+ test("shared Engram environment refuses scratch-shaped UNC share roots without looping", () => {
71
+ const script = `import assert from 'node:assert/strict';
72
+ import { sharedEngramEnv } from './lib/gentle-shell-bundled.ts';
73
+ assert.throws(() => sharedEngramEnv({ TEMP: ${JSON.stringify("\\\\server\\gp123456\\")} }, 'win32'), /absolute.*scratch/i);`;
74
+ const result = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", "--experimental-strip-types", "--input-type=module", "-e", script], { cwd: repository, encoding: "utf8", timeout: 3000 });
75
+ assert.equal(result.error, undefined);
76
+ assert.equal(result.status, 0);
77
+ assert.equal(result.stdout, "");
78
+ assert.equal(result.stderr, "");
79
+ assert.equal(shell.sharedEngramEnv({ TEMP: "\\\\server\\share\\gp123456\\gaAb12Z9\\" }, "win32").TEMP, "\\\\server\\share\\");
80
+ assert.equal(shell.sharedEngramEnv({ TEMP: "C:\\gp123456\\" }, "win32").TEMP, "C:\\");
81
+ });
82
+
83
+ test("shared Engram environment reaches an actual Windows Node child with explicit stable TEMP", { skip: process.platform !== "win32" }, () => {
84
+ const stable = "C:\\chosen-temp";
85
+ const scratch = `${stable}\\gp123456\\gaAb12Z9`;
86
+ const probe = `const os = require('node:os'); process.stdout.write(JSON.stringify({TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR, tmpdir: os.tmpdir()}));`;
87
+ // Set known ambient scratch only inside an isolated parent process. No files
88
+ // are created: neither chosen path needs to exist for this zero-write probe.
89
+ const script = `import { sharedEngramEnv } from './lib/gentle-shell-bundled.ts';
90
+ import { spawnSync } from 'node:child_process';
91
+ process.env.TEMP = ${JSON.stringify(scratch)};
92
+ const env = sharedEngramEnv({ TMPDIR: ${JSON.stringify(scratch)} });
93
+ const child = spawnSync(process.execPath, ['-e', ${JSON.stringify(probe)}], {env, encoding: 'utf8'});
94
+ process.stdout.write(child.stdout); process.stderr.write(child.stderr); process.exit(child.status ?? 99);`;
95
+ const result = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", "--experimental-strip-types", "--input-type=module", "-e", script], { cwd: repository, encoding: "utf8", timeout: 10000 });
96
+ assert.equal(result.error, undefined);
97
+ assert.equal(result.status, 0);
98
+ assert.equal(result.stderr, "");
99
+ assert.equal(result.stdout, JSON.stringify({ TEMP: stable, TMPDIR: stable, tmpdir: stable }));
100
+ });
101
+
102
+ test("shared Engram environment retains sanitization and detached spawn wiring", () => {
103
+ assert.deepEqual(shell.sharedEngramEnv({ ENGRAM_BIN: "chosen", ENGRAM_OTHER: "value", ENGRAM_NO_UPDATE_CHECK: "0", GH_TOKEN: "token", GITHUB_TOKEN: "token", HOME: "home", TEMP: "chosen" }),
104
+ { HOME: "home", TEMP: "chosen", ENGRAM_NO_UPDATE_CHECK: "1" });
105
+ const launcher = readFileSync(join(repository, "bin", "gentle-shell.mjs"), "utf8");
106
+ assert.match(launcher, /const env = sharedEngramEnv\(process\.env\);/);
107
+ assert.match(launcher, /spawn\(core, \["serve"\], \{ cwd: homedir\(\), env, detached: true, stdio: "ignore", windowsHide: true \}\)/);
108
+ });
109
+
110
+ test("nothing listening with a verified bundled core starts the shared server and reuses it through the default URL", () => {
111
+ assert.deepEqual(classify(), { policy: "allow", url: "http://127.0.0.1:7437", spawn: true });
112
+ });
113
+
114
+ test("nothing listening without the verified bundled core is disabled, never a PATH fallback", () => {
38
115
  const result = classify({ core: false });
39
116
  assert.equal(result.policy, "disabled");
117
+ assert.equal(result.spawn, undefined);
40
118
  assert.match(result.notice!, /bundled Engram core/);
41
119
  });
42
120
 
43
- test("existing, unreadable or foreign stores without matching provenance are disabled", () => {
44
- for (const store of ["present", "unknown"] as const) {
45
- const result = classify({ store });
46
- assert.equal(result.policy, "disabled", store);
121
+ test("an existing store no longer disables memory by itself; only a newer schema header keeps the server stopped", () => {
122
+ for (const userVersion of [undefined, 0, 1]) {
123
+ assert.deepEqual(classify({ userVersion }), { policy: "allow", url: "http://127.0.0.1:7437", spawn: true }, String(userVersion));
124
+ }
125
+ const newer = classify({ userVersion: 2 });
126
+ assert.deepEqual([newer.policy, newer.spawn, newer.url], ["disabled", undefined, undefined]);
127
+ assert.equal(newer.notice, shell.ENGRAM_NEWER_SCHEMA_NOTICE);
128
+ assert.match(newer.notice!, /newer Engram.*Back up/s);
129
+ // A pin without a verified schema cannot rule out a newer store: never started.
130
+ assert.equal(classify({ coreVersion: "9.9.9", userVersion: 1 }).policy, "disabled");
131
+ assert.equal(classify({ coreVersion: "9.9.9" }).spawn, true);
132
+ });
133
+
134
+ test("a listener that does not verify keeps memory disabled and is never replaced", () => {
135
+ for (const endpoint of [listening(false), listening(false, ID), { kind: "unknown" } as shell.EngramProbe]) {
136
+ const result = classify({ endpoint });
137
+ assert.deepEqual([result.policy, result.spawn, result.url], ["disabled", undefined, undefined], JSON.stringify(endpoint));
47
138
  assert.match(result.notice!, /could not verify.*compatibility.*Back up/is);
48
- assert.doesNotMatch(result.notice!, /update Engram and restart/i);
49
139
  }
50
- assert.equal(classify({ store: "present", endpoint: listening(false, ID) }).policy, "disabled");
140
+ // Not even the bundled core version on /health stands in for /compatibility.
141
+ assert.equal(classify({ endpoint: listening(false, ID, CORE) }).policy, "disabled");
51
142
  });
52
143
 
53
144
  test("a running server older than the first compatibility-capable core is named in the notice, without changing the decision", () => {
54
145
  for (const version of ["3.3.2", "v3.3.2", "2.9.0"]) {
55
- const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
146
+ const result = classify({ endpoint: listening(false, OTHER_ID, version) });
56
147
  assert.equal(result.policy, "disabled", version);
57
148
  assert.equal(result.url, undefined, version);
58
149
  assert.match(result.notice!, new RegExp(`reports version ${version.replace(/^v/, "").replaceAll(".", "\\.")}`), version);
59
150
  assert.match(result.notice!, /update that server to 3\.4\.0 or later.*restart it/s, version);
60
151
  assert.match(result.notice!, /Back up/, version);
61
152
  }
62
- // Shell's own store behind an older server gets the same guidance and stays disabled.
63
- const own = classify({ store: "present", provenance: { instanceId: ID }, endpoint: listening(false, ID, "3.3.2") });
64
- assert.equal(own.policy, "disabled");
65
- assert.match(own.notice!, /reports version 3\.3\.2/);
66
153
  });
67
154
 
68
155
  test("a current, unparsable or hostile server version keeps the generic notice and is never echoed", () => {
69
156
  for (const version of ["3.4.0", "3.10.0", "4.0.0", "dev", "", "3.3.2\u001b[31m", "3.3.2 update now", "03.3.2", "3.03.2", "3.3.02", "v03.3.2"]) {
70
- const result = classify({ store: "present", endpoint: listening(false, OTHER_ID, version) });
157
+ const result = classify({ endpoint: listening(false, OTHER_ID, version) });
71
158
  assert.equal(result.policy, "disabled", JSON.stringify(version));
72
159
  assert.equal(result.notice, shell.ENGRAM_RECOVERY_NOTICE, JSON.stringify(version));
73
160
  }
74
- assert.equal(classify({ store: "present", endpoint: listening(false) }).notice, shell.ENGRAM_RECOVERY_NOTICE);
161
+ assert.equal(classify({ endpoint: listening(false) }).notice, shell.ENGRAM_RECOVERY_NOTICE);
75
162
  });
76
163
 
77
164
  test("explicit selections and invalid policies are disabled without depending on probes", () => {
78
- for (const key of ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"]) {
79
- assert.equal(classify({ env: { [key]: "chosen" }, endpoint: listening(true) }).policy, "disabled", key);
165
+ for (const key of ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"]) {
166
+ for (const endpoint of [listening(true), REFUSED]) {
167
+ const result = classify({ env: { [key]: "chosen" }, endpoint });
168
+ assert.deepEqual([result.policy, result.spawn], ["disabled", undefined], key);
169
+ }
80
170
  }
81
- assert.equal(classify({ env: { ENGRAM_STARTUP_POLICY: "allow" } }).policy, "allow");
171
+ assert.equal(classify({ env: { ENGRAM_STARTUP_POLICY: "allow" } }).spawn, true);
82
172
  for (const value of ["", "disabled", "ALLOW", " allow ", "invalid"]) {
83
- assert.equal(classify({ env: { ENGRAM_STARTUP_POLICY: value }, endpoint: listening(true) }).policy, "disabled", value);
173
+ for (const endpoint of [listening(true), REFUSED]) {
174
+ const result = classify({ env: { ENGRAM_STARTUP_POLICY: value }, endpoint });
175
+ assert.deepEqual([result.policy, result.spawn], ["disabled", undefined], value);
176
+ }
84
177
  }
85
178
  assert.equal(shell.engramProbeAllowed({ ENGRAM_URL: "https://chosen.invalid" }), false);
86
179
  assert.equal(shell.engramProbeAllowed({ ENGRAM_STARTUP_POLICY: "" }), false);
@@ -88,25 +181,12 @@ test("explicit selections and invalid policies are disabled without depending on
88
181
  });
89
182
 
90
183
  test("a verified /compatibility contract reuses the running server through an explicit URL", () => {
91
- assert.deepEqual(classify({ store: "present", endpoint: listening(true) }), { policy: "allow", url: "http://127.0.0.1:7437" });
92
- for (const endpoint of [listening(false), { kind: "unknown" } as shell.EngramProbe]) {
93
- assert.equal(classify({ store: "present", endpoint }).policy, "disabled");
184
+ // Reused unchanged, whatever the store header or the bundled core.
185
+ for (const input of [{}, { userVersion: 2 }, { core: false }]) {
186
+ assert.deepEqual(classify({ ...input, endpoint: listening(true) }), { policy: "allow", url: "http://127.0.0.1:7437" }, JSON.stringify(input));
94
187
  }
95
188
  });
96
189
 
97
- test("Shell provenance allows its own store only for the same instance and the same core binary", () => {
98
- const provenance = { instanceId: ID };
99
- assert.deepEqual(classify({ store: "present", provenance }), { policy: "allow" });
100
- assert.deepEqual(classify({ store: "present", provenance, endpoint: listening(false, ID) }), { policy: "allow" });
101
- assert.equal(classify({ store: "present", provenance, endpoint: listening(false, OTHER_ID) }).policy, "disabled");
102
- for (const version of ["3.3.3", "v3.4.0", "dev", ""]) {
103
- assert.equal(classify({ store: "present", provenance, endpoint: listening(false, ID, version) }).policy, "disabled", version);
104
- }
105
- assert.equal(classify({ store: "present", provenance, endpoint: listening(false) }).policy, "disabled");
106
- assert.equal(classify({ store: "present", provenance, endpoint: { kind: "unknown" } }).policy, "disabled");
107
- assert.equal(classify({ store: "present", provenance, core: false }).policy, "disabled");
108
- });
109
-
110
190
  test("the compatibility contract must match exactly", () => {
111
191
  assert.equal(shell.isReadyCompatibility(JSON.stringify(READY)), true);
112
192
  assert.equal(shell.isReadyCompatibility(JSON.stringify({ ...READY, offline_automatic_opening_authorized: undefined })), true);
@@ -161,20 +241,11 @@ test("the SQLite header user_version is read from raw bytes and malformed header
161
241
  }
162
242
  });
163
243
 
164
- test("provenance matches only the same directory, instance and core digest", () => {
165
- const record = { schema: 1 as const, dataDir: "/home/ana/.engram", instanceId: ID, coreSha256: "a".repeat(64) };
166
- const observed = { dataDir: record.dataDir, instanceId: ID, coreSha256: record.coreSha256 };
167
- assert.equal(shell.engramProvenanceMatches(record, observed), true);
168
- for (const change of [{ dataDir: "/elsewhere/.engram" }, { instanceId: OTHER_ID }, { coreSha256: "b".repeat(64) }, { instanceId: undefined }, { coreSha256: undefined }]) {
169
- assert.equal(shell.engramProvenanceMatches(record, { ...observed, ...change }), false, JSON.stringify(change));
170
- }
171
- assert.equal(shell.engramProvenanceMatches(undefined, observed), false);
172
- });
173
-
174
244
  test("the header is a newer-schema tripwire against the verified bundled schema, never a compatibility proof", () => {
175
245
  // v3.3.2:internal/store/store.go declares schemaVersion = 1.
176
246
  assert.equal(shell.bundledEngramSchema("3.3.2"), 1);
177
247
  assert.equal(shell.bundledEngramSchema("3.4.0"), 1);
248
+ assert.equal(shell.bundledEngramSchema("3.5.2"), 1);
178
249
  assert.equal(shell.bundledEngramSchema("3.3.3"), undefined);
179
250
  for (const userVersion of [0, 1]) assert.equal(shell.engramHeaderAllows(userVersion, 1), true, String(userVersion));
180
251
  assert.equal(shell.engramHeaderAllows(2, 1), false);
@@ -241,10 +312,10 @@ test("probe reports refused for a closed port and unknown for a silent listener"
241
312
  assert.ok(Date.now() - started < 2000);
242
313
  });
243
314
 
244
- const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.4.0/engram", guard: GUARD };
315
+ const base = { node: "/node", cli: "/pi", prefix: "/prefix", engram: "/prefix/runtime/engram-3.5.2/engram", guard: GUARD };
245
316
 
246
317
  test("disabled memory without explicit selections points ENGRAM_BIN at the guard and keeps the policy", () => {
247
- const memory = classify({ store: "present" });
318
+ const memory = classify({ endpoint: listening(false) });
248
319
  const env = shell.bundledChildEnv({ ENGRAM_STARTUP_NOTICE: "display only" }, { ...base, memory });
249
320
  assert.equal(env.ENGRAM_BIN, GUARD);
250
321
  assert.equal(env.ENGRAM_URL, undefined);
@@ -254,7 +325,7 @@ test("disabled memory without explicit selections points ENGRAM_BIN at the guard
254
325
  });
255
326
 
256
327
  test("explicit ENGRAM_BIN or ENGRAM_URL are never replaced or removed", () => {
257
- const memory = classify({ store: "present" });
328
+ const memory = classify({ endpoint: listening(false) });
258
329
  for (const selected of [{ ENGRAM_BIN: "/explicit" }, { ENGRAM_URL: "https://chosen.invalid" }, { ENGRAM_BIN: "/explicit", ENGRAM_URL: "https://chosen.invalid" }]) {
259
330
  const before = { ...selected };
260
331
  const env = shell.bundledChildEnv(selected, { ...base, memory });
@@ -267,11 +338,15 @@ test("explicit ENGRAM_BIN or ENGRAM_URL are never replaced or removed", () => {
267
338
  assert.equal(shell.bundledChildEnv({ ENGRAM_URL: "https://chosen.invalid" }, { ...base, engram: undefined, memory }).ENGRAM_BIN, GUARD);
268
339
  });
269
340
 
270
- test("allowed modes select the core, the guard plus a reuse URL, or the guard when the core is missing", () => {
271
- assert.equal(shell.bundledChildEnv({}, { ...base, memory: classify() }).ENGRAM_BIN, base.engram);
272
- assert.equal(shell.bundledChildEnv({}, { ...base, memory: classify({ store: "present", provenance: { instanceId: ID } }) }).ENGRAM_BIN, base.engram);
273
- const reuse = shell.bundledChildEnv({}, { ...base, memory: classify({ store: "present", endpoint: listening(true) }) });
274
- assert.deepEqual([reuse.ENGRAM_URL, reuse.ENGRAM_BIN, reuse.ENGRAM_STARTUP_POLICY], ["http://127.0.0.1:7437", GUARD, "allow"]);
341
+ test("allowed memory always reaches the shared server through the reuse URL and the guard, never a session-owned server", () => {
342
+ for (const memory of [classify(), classify({ endpoint: listening(true) })]) {
343
+ const env = shell.bundledChildEnv({}, { ...base, memory });
344
+ assert.deepEqual([env.ENGRAM_URL, env.ENGRAM_BIN, env.ENGRAM_STARTUP_POLICY, env.ENGRAM_SERVER_MODE], ["http://127.0.0.1:7437", GUARD, "allow", undefined]);
345
+ // The user's own server mode, if any, is passed through untouched.
346
+ assert.equal(shell.bundledChildEnv({ ENGRAM_SERVER_MODE: "shared" }, { ...base, memory }).ENGRAM_SERVER_MODE, "shared");
347
+ }
348
+ assert.equal("ENGRAM_PRIVATE_REQUIRED_NOTICE" in shell, false);
349
+ assert.equal("supportsEngramSessionOwned" in shell, false);
275
350
  const missing = shell.bundledChildEnv({}, { ...base, engram: undefined, memory: classify({ core: false }) });
276
351
  assert.equal(missing.ENGRAM_BIN, GUARD);
277
352
  assert.throws(() => shell.bundledChildEnv({}, { ...base, guard: undefined, memory: classify() }), /guard/);
@@ -309,7 +384,7 @@ test("the guard fails non-zero, reports the notice and leaves Engram data byte-i
309
384
  });
310
385
 
311
386
  test("a provider-style instance-id spawn against the environment Shell builds fails closed", { skip: process.platform === "win32" }, () => {
312
- const env = shell.bundledChildEnv(clean(), { ...base, memory: classify({ store: "present" }) });
387
+ const env = shell.bundledChildEnv(clean(), { ...base, memory: classify({ endpoint: listening(false) }) });
313
388
  const result = spawnSync(env.ENGRAM_BIN!, ["instance-id"], { env, encoding: "utf8", timeout: 5000 });
314
389
  assert.notEqual(result.status, 0);
315
390
  assert.doesNotMatch(result.stdout, /^[a-f0-9]{32}$/m);
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+ import test from "node:test";
6
+ import { openInExternalEditor } from "../lib/external-editor.ts";
7
+
8
+ function fixture(result: Partial<ReturnType<typeof spawnSync>> = { status: 0 }) {
9
+ const events: string[] = [];
10
+ const host = { stop: () => events.push("stop"), start: () => events.push("start"), requestRender: (force?: boolean) => events.push(`render:${force}`) };
11
+ type Options = { cwd?: string; stdio: string; shell: boolean };
12
+ const calls: { command: string; args: string[]; options: Options }[] = [];
13
+ const spawn = ((command: string, args: string[], options: Options) => {
14
+ events.push("spawn");
15
+ calls.push({ command, args, options });
16
+ return result;
17
+ }) as typeof spawnSync;
18
+ return { host, spawn, calls, events };
19
+ }
20
+
21
+ test("editor paths remain single arguments without a shell", () => {
22
+ const f = fixture();
23
+ const path = "a&echo GS_2426_EXECUTED&rem tail.txt";
24
+ assert.equal(openInExternalEditor(f.host, path, { VISUAL: "editor.exe -w", EDITOR: "ignored" }, f.spawn, "/linked"), true);
25
+ assert.deepEqual(f.calls, [{ command: "editor.exe", args: ["-w", path], options: { cwd: "/linked", stdio: "inherit", shell: false } }]);
26
+ assert.deepEqual(f.events, ["stop", "spawn", "start", "render:true"]);
27
+ });
28
+
29
+ test("Windows batch editors are refused before spawning and name a direct executable alternative", { skip: process.platform !== "win32" }, () => {
30
+ for (const editor of ["editor.cmd", "editor.BAT"]) {
31
+ const f = fixture();
32
+ assert.throws(() => openInExternalEditor(f.host, "safe.txt", { EDITOR: editor }, f.spawn), /executable.*\.exe/i);
33
+ assert.deepEqual(f.calls, []);
34
+ assert.deepEqual(f.events, []);
35
+ }
36
+ });
37
+
38
+ test("editor launch failures are reported after restoring the TUI", () => {
39
+ for (const result of [{ error: new Error("ENOENT"), status: null }, { status: 7 }, { status: null, signal: "SIGTERM" as const }]) {
40
+ const f = fixture(result);
41
+ assert.throws(() => openInExternalEditor(f.host, "safe.txt", { EDITOR: "editor.exe" }, f.spawn), /Could not open editor/);
42
+ assert.deepEqual(f.events, ["stop", "spawn", "start", "render:true"]);
43
+ }
44
+ });
45
+
46
+ test("editor retains VISUAL precedence, flags, cwd, and missing-editor behavior", () => {
47
+ const f = fixture();
48
+ assert.equal(openInExternalEditor(f.host, "lib/a.ts", { VISUAL: "nvim -u none", EDITOR: "vi" }, f.spawn, "/linked"), true);
49
+ assert.equal(f.calls[0].command, "nvim");
50
+ assert.deepEqual(f.calls[0].args, ["-u", "none", "lib/a.ts"]);
51
+ assert.equal(f.calls[0].options.cwd, "/linked");
52
+ assert.equal(openInExternalEditor(f.host, "lib/a.ts", {}, f.spawn), false);
53
+ assert.deepEqual(f.events, ["stop", "spawn", "start", "render:true"]);
54
+ });
55
+
56
+ test("a real editor process receives a metacharacter filename unchanged", () => {
57
+ const root = mkdtempSync(join(process.cwd(), ".editor-test-"));
58
+ try {
59
+ const script = join(root, "editor.cjs");
60
+ writeFileSync(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)))");
61
+ const path = join(root, "a&echo GS_2426_EXECUTED&rem tail.txt");
62
+ writeFileSync(path, "unchanged");
63
+ const f = fixture();
64
+ let child: ReturnType<typeof spawnSync> | undefined;
65
+ const spawn = ((command: string, args: string[], options: object) => {
66
+ child = spawnSync(command, args, { ...options, stdio: "pipe", encoding: "utf8", timeout: 3000 });
67
+ return child;
68
+ }) as typeof spawnSync;
69
+ assert.equal(openInExternalEditor(f.host, path, { EDITOR: `node ${relative(process.cwd(), script)}` }, spawn, process.cwd()), true);
70
+ assert.equal(child?.status, 0);
71
+ assert.equal(child?.stdout, JSON.stringify([path]));
72
+ assert.equal(child?.stderr, "");
73
+ assert.deepEqual(f.events, ["stop", "start", "render:true"]);
74
+ } finally {
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test("synchronous spawn exceptions restore the TUI before reporting failure", () => {
80
+ const f = fixture();
81
+ const spawn = (() => { throw new Error("spawn failed"); }) as typeof spawnSync;
82
+ assert.throws(() => openInExternalEditor(f.host, "safe.txt", { EDITOR: "editor.exe" }, spawn), { message: "Could not open editor: spawn failed" });
83
+ assert.deepEqual(f.events, ["stop", "start", "render:true"]);
84
+ });