gentle-pi 4.3.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 (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -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
+ });
@@ -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", url: "http://127.0.0.1:7437" });
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
+ });
@@ -46,7 +46,7 @@ Harness principles:
46
46
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
47
47
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
48
48
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
49
- - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Before finishing, run a differential check: for each existing command that shares the code you changed, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a `mktemp -d` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
49
+ - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Only when your diff changes shared code (options, parsers, helpers) that an existing command the request did not name also uses, run one differential check before finishing, as a single script: for each such command, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a `mktemp -d` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
50
50
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
51
51
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
52
52
 
@@ -88,9 +88,9 @@ A task is **small** when all three hold:
88
88
 
89
89
  The number of files, commands or tests, fixes, or a requested `todo` list never decides size. A task is **large** only when the resume test fails (several sessions, external waits, deliverables the user wants apart (never several features asked at once), requirements compaction could lose); large tasks get ODD tracking and workers only by the Writer rule, else inline.
90
90
 
91
- Small path: inline (parent; `bash_background` policy); observe RED inline before the fix; run the focused test and the suite inline, once each. No explore, worker, or verifier; no feature document, mirror, or commits unless the user asks; `todo` optional. It needs no lazy asset.
91
+ Small path: inline (parent; `bash_background` policy); read only what you change and its helpers, in one batch; observe RED inline before the fix; run the focused test and the suite inline in one command. No explore, worker, or verifier; no feature document, mirror, or commits unless the user asks; no `todo`. It needs no lazy asset.
92
92
 
93
- **High risk**: a mistake would be hard to detect, hard to undo, or reaches beyond the change: (1) data or irreversible effects (migrations, rewriting or deleting stored data, format changes, writing data without validation; not saving new records); (2) security (auth, permissions, credentials, secrets, guards, sandbox); (3) changing or removing contracts others already consume (public API, CLI flags, config formats, exports, mirrored prompts; not adding a flag, command or optional field; requested changes are not; unrequested breaks in shared code are); (4) concurrency; (5) delivery or environment (installers, release, CI, deploy, dependencies); (6) no test would catch a regression in what changes. Count "unclear" as high only when a bounded look cannot tell whether (1)-(5) apply. When RDD is on and native assess returns a tier, that tier wins.
93
+ **High risk**: a mistake would be hard to detect, hard to undo, or reaches beyond the change: (1) data or irreversible effects that cannot be rebuilt (migrations, rewriting or deleting stored data, format changes, writing data without validation; not saving new records, nor a dev or test database a seed or backup rebuilds); (2) security (auth, permissions, credentials, secrets, guards, sandbox); (3) changing or removing contracts others already consume (public API, CLI flags, config formats, exports, mirrored prompts; not adding a flag, command or optional field; requested changes are not; unrequested breaks in shared code are); (4) concurrency; (5) delivery or environment (installers, release, CI, deploy, dependencies); (6) no test would catch a regression in what changes. Count "unclear" as high only when a bounded look cannot tell whether (1)-(5) apply. When RDD is on and native assess returns a tier, that tier wins.
94
94
 
95
95
  **Risk line**: close every code change, small path or delegated, with `Risk: item N (reason)` or `Risk: none` per the list; any item → Verification rule.
96
96