gentle-pi 4.3.0 → 4.5.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.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- 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.
|
|
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: {},
|
|
31
|
+
shell.classifyEngramStartup({ env: {}, endpoint: REFUSED, core: true, coreVersion: CORE, ...input });
|
|
32
32
|
|
|
33
|
-
test("
|
|
34
|
-
|
|
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("
|
|
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
|
|
44
|
-
for (const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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" } }).
|
|
171
|
+
assert.equal(classify({ env: { ENGRAM_STARTUP_POLICY: "allow" } }).spawn, true);
|
|
82
172
|
for (const value of ["", "disabled", "ALLOW", " allow ", "invalid"]) {
|
|
83
|
-
|
|
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
|
-
|
|
92
|
-
for (const
|
|
93
|
-
assert.
|
|
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.
|
|
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({
|
|
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({
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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({
|
|
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).
|
|
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
|
|
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
|
|