gentle-pi 4.1.0 → 4.2.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 +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
|
@@ -159,6 +159,24 @@ test("ChangesTracker reads the working tree on start and again on refresh", asyn
|
|
|
159
159
|
assert.deepEqual(calls[1], ["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
test("ChangesTracker skips untracked dependency backups but preserves tracked edits and legitimate new files", async () => {
|
|
163
|
+
const backup = "node_modules.backup-20261007-192802/pkg/index.js";
|
|
164
|
+
const nestedBackup = "packages/app/node_modules.backup-local/pkg/index.js";
|
|
165
|
+
const tracked = "node_modules.backup-local/tracked.ts";
|
|
166
|
+
const legitimate = ["src/new.ts", "node_modules.backup-notes.md", "node_modules.backup-/new.ts"];
|
|
167
|
+
const { git } = fakeGit(
|
|
168
|
+
[`4\t2\t${tracked}\n`],
|
|
169
|
+
[[`?? ${backup}`, `?? ${nestedBackup}`, ` M ${tracked}`, ...legitimate.map((path) => `?? ${path}`)].join("\0") + "\0"],
|
|
170
|
+
);
|
|
171
|
+
const counted: string[] = [];
|
|
172
|
+
const tracker = new ChangesTracker(git, async (path) => { counted.push(path); return 3; });
|
|
173
|
+
await tracker.start();
|
|
174
|
+
assert.deepEqual(counted.sort(), [...legitimate].sort());
|
|
175
|
+
assert.deepEqual(tracker.model, changesModel([
|
|
176
|
+
file(tracked, 4, 2), ...legitimate.map((path) => file(path, 3, 0, CHANGE_STATUS.UNTRACKED)),
|
|
177
|
+
]));
|
|
178
|
+
});
|
|
179
|
+
|
|
162
180
|
test("ChangesTracker stays quiet outside a git repository", async () => {
|
|
163
181
|
const { git, calls } = fakeGit([""], [""], 128);
|
|
164
182
|
const tracker = new ChangesTracker(git);
|
package/tests/shell-todo.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
staleTurns,
|
|
10
10
|
TODO_STATUS,
|
|
11
11
|
todoPromptBlock,
|
|
12
|
+
todoStateMessage,
|
|
12
13
|
todoSummary,
|
|
13
14
|
type TodoState,
|
|
14
15
|
} from "../lib/shell-todo.ts";
|
|
@@ -152,19 +153,21 @@ test("replayTodo rebuilds the latest state from Gentle and rpiv tool results ali
|
|
|
152
153
|
assert.deepEqual(replayTodo([]), emptyTodo());
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
test("todoPromptBlock
|
|
156
|
-
const block = todoPromptBlock(
|
|
157
|
-
assert.ok(block);
|
|
156
|
+
test("todoPromptBlock is the byte-stable rules block; todoStateMessage carries the open work (#1662)", () => {
|
|
157
|
+
const block = todoPromptBlock();
|
|
158
158
|
assert.match(block, /^## Todo list/m);
|
|
159
159
|
assert.match(block, /mark a task in_progress before starting/);
|
|
160
|
-
assert.match(block, /1\. \[done\] Add quiet tool rendering/);
|
|
161
|
-
assert.match(block, /2\. \[in_progress\] Fix quiet tools conflict — fixing conflict/);
|
|
162
|
-
assert.match(block, /3\. \[pending\] Show git bash tails/);
|
|
163
160
|
assert.doesNotMatch(block, /stale/);
|
|
164
|
-
assert.
|
|
165
|
-
|
|
161
|
+
assert.doesNotMatch(block, /Add quiet tool rendering/, "the rules block never contains task content");
|
|
162
|
+
const message = todoStateMessage(seeded());
|
|
163
|
+
assert.ok(message);
|
|
164
|
+
assert.match(message, /1\. \[done\] Add quiet tool rendering/);
|
|
165
|
+
assert.match(message, /2\. \[in_progress\] Fix quiet tools conflict — fixing conflict/);
|
|
166
|
+
assert.match(message, /3\. \[pending\] Show git bash tails/);
|
|
167
|
+
assert.doesNotMatch(message, /stale/);
|
|
168
|
+
assert.equal(todoStateMessage(emptyTodo()), undefined);
|
|
166
169
|
const allDone = applyTodo(seeded(), { action: "write", tasks: [{ title: "A", status: "done" }] }, 1).state;
|
|
167
|
-
assert.equal(
|
|
170
|
+
assert.equal(todoStateMessage(allDone), undefined);
|
|
168
171
|
});
|
|
169
172
|
|
|
170
173
|
test("collapsed long tasks occupy one body line, including stale state, without losing task data", (t) => {
|
|
@@ -446,7 +449,7 @@ test("applyTodo rejects blocked without a note and leaves the list unchanged", (
|
|
|
446
449
|
test("dropped tasks never count as open: a list of done and dropped is finished, silent, and never stale", () => {
|
|
447
450
|
const state = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "A", status: "done" }, { title: "B", status: "dropped" }, { title: "C", status: "dropped" }] }, 1).state;
|
|
448
451
|
assert.deepEqual(todoSummary(state), { done: 1, total: 3, open: 0 });
|
|
449
|
-
assert.equal(
|
|
452
|
+
assert.equal(todoStateMessage(state), undefined);
|
|
450
453
|
assert.equal(staleTurns(state, 9), 0);
|
|
451
454
|
});
|
|
452
455
|
|
|
@@ -454,8 +457,8 @@ test("blocked tasks stay open but alone never make the list stale; an actionable
|
|
|
454
457
|
const blockedOnly = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }] }, 1).state;
|
|
455
458
|
assert.deepEqual(todoSummary(blockedOnly), { done: 1, total: 2, open: 1 });
|
|
456
459
|
assert.equal(staleTurns(blockedOnly, 9), 0);
|
|
457
|
-
|
|
458
|
-
|
|
460
|
+
assert.match(todoStateMessage(blockedOnly) ?? "", /2\. \[blocked\] Deploy — waiting for an admin/);
|
|
461
|
+
const block = todoPromptBlock();
|
|
459
462
|
assert.match(block, /A blocked task waits on something outside the list: do not work on it until its condition holds/);
|
|
460
463
|
assert.match(block, /mark the tasks it made obsolete dropped instead of leaving them pending or marking them done/);
|
|
461
464
|
const mixed = applyTodo(blockedOnly, { action: "add", title: "Write docs" }, 1).state;
|
|
@@ -5,12 +5,17 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
|
-
import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { CURSOR_MARKER, Editor, TuiMainScreen, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import { AUDITED_PI_EDITOR_VERSIONS, createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
10
10
|
import { resolveVimRuntime, VIM_AGENT_INDEX_PATTERN, VIM_CLI_ENTRY_PATTERN } from "../extensions/gentle-shell.ts";
|
|
11
11
|
import { VimOperatorEngine } from "../lib/vim-operator-engine.ts";
|
|
12
12
|
import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
|
|
13
13
|
|
|
14
|
+
function resolveFakeCursors(rows: string[], hardware = false): string[] {
|
|
15
|
+
const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
|
|
16
|
+
return host.resolveFakeCursors.call({ showHardwareCursor: hardware }, rows);
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
function editor(): Editor {
|
|
15
20
|
return new Editor({ terminal: { rows: 24 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
|
|
16
21
|
}
|
|
@@ -20,6 +25,8 @@ function editor(): Editor {
|
|
|
20
25
|
function paintedCells(row: string): Array<{ char: string; inverse: boolean }> {
|
|
21
26
|
const cells: Array<{ char: string; inverse: boolean }> = [];
|
|
22
27
|
let inverse = false;
|
|
28
|
+
// Decode the actual host post-pass, including hardware cursor suppression.
|
|
29
|
+
row = resolveFakeCursors([row])[0]!;
|
|
23
30
|
const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
|
|
24
31
|
assert.equal(tokens.join(""), row, "only known editor escape sequences are present");
|
|
25
32
|
for (const token of tokens) {
|
|
@@ -270,12 +277,12 @@ test("resolveVimRuntime resolves local bundle cli entrypoint when provided", ()
|
|
|
270
277
|
});
|
|
271
278
|
|
|
272
279
|
test("audited Pi editor releases are one frozen exact allowlist that includes the installed TUI", () => {
|
|
273
|
-
assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0"]);
|
|
280
|
+
assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"]);
|
|
274
281
|
assert.equal(bundledAgent.VERSION, INSTALLED_PI, "bundled and local metadata must describe the same actual installed release");
|
|
275
282
|
assert.ok(Object.isFrozen(AUDITED_PI_EDITOR_VERSIONS));
|
|
276
283
|
assert.throws(() => (AUDITED_PI_EDITOR_VERSIONS as unknown as string[]).push("0.99.3"), TypeError);
|
|
277
284
|
assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `installed pi-tui ${INSTALLED_PI} must be audited`);
|
|
278
|
-
for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", undefined, 99.2]) {
|
|
285
|
+
for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", "1.2.1", "1.2.0-rc.1", "v1.2.0", ">=1.2.0", undefined, 99.2]) {
|
|
279
286
|
assert.equal(isAuditedPiEditorVersion(unknown), false, String(unknown));
|
|
280
287
|
}
|
|
281
288
|
});
|
|
@@ -309,6 +316,8 @@ test("bundled host admits each audited release and rejects unknown or mismatched
|
|
|
309
316
|
assert.equal(resolveVimRuntime(fabricatedPiCli(t, version), bundledAgent.CustomEditor), undefined, version);
|
|
310
317
|
}
|
|
311
318
|
assert.equal(resolveVimRuntime(fabricatedPiCli(t, "1.0.0", "1.0.0", "0.99.2"), bundledAgent.CustomEditor), undefined);
|
|
319
|
+
assert.equal(resolveVimRuntime(fabricatedPiCli(t, "1.2.1"), bundledAgent.CustomEditor), undefined);
|
|
320
|
+
assert.equal(resolveVimRuntime(fabricatedPiCli(t, "1.2.0", "1.2.0", "1.1.0"), bundledAgent.CustomEditor), undefined);
|
|
312
321
|
});
|
|
313
322
|
|
|
314
323
|
test("installed agent/TUI pair admits each audited release and skips unknown or mismatched pairs", (t) => {
|
|
@@ -343,6 +352,8 @@ test("adapter admits each verified audited release and rejects unknown or mismat
|
|
|
343
352
|
assert.throws(() => createVimEditorAdapter(editor(), version), /unsupported/i, version);
|
|
344
353
|
}
|
|
345
354
|
assert.throws(() => createVimEditorAdapter(e, "1.0.0", BundledEditor, "0.99.2"), /unsupported/i);
|
|
355
|
+
assert.throws(() => createVimEditorAdapter(e, "1.2.1", BundledEditor, "1.2.1"), /unsupported/i);
|
|
356
|
+
assert.throws(() => createVimEditorAdapter(e, "1.2.0", BundledEditor, "1.1.0"), /unsupported/i);
|
|
346
357
|
assert.equal(e.getText(), "untouched");
|
|
347
358
|
assert.deepEqual(e.getCursor(), cursor);
|
|
348
359
|
assert.deepEqual(e.render(30), frame);
|
|
@@ -638,24 +649,39 @@ test("operator insertion and registered marker guards keep one undo snapshot", (
|
|
|
638
649
|
assert.equal(paste.getExpandedText(), "z".repeat(1001));
|
|
639
650
|
});
|
|
640
651
|
|
|
652
|
+
test("Pi 1.2 selection preserves fake markers and host hardware-cursor suppression", () => {
|
|
653
|
+
const e = editor();
|
|
654
|
+
e.setText("abcd");
|
|
655
|
+
e.focused = true;
|
|
656
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
657
|
+
adapter.move({ line: 0, col: 1 });
|
|
658
|
+
const rows = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 });
|
|
659
|
+
assert.ok(rows[1]!.includes(`${CURSOR_MARKER}\x1b_pi:fc\x07`));
|
|
660
|
+
assert.ok(rows[1]!.includes("\x1b_pi:/fc\x07"));
|
|
661
|
+
for (const hardware of [false, true]) {
|
|
662
|
+
const resolved = resolveFakeCursors(rows, hardware);
|
|
663
|
+
assert.deepEqual(paintedCells(resolved[1]!).slice(0, 4), "abcd".split("").map(char => ({ char, inverse: true })));
|
|
664
|
+
assert.ok(resolved.every(row => visibleWidth(row) === 20));
|
|
665
|
+
}
|
|
666
|
+
const boundary = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 1 });
|
|
667
|
+
assert.ok(boundary[1]!.includes(`${CURSOR_MARKER}\x1b_pi:fc\x07`));
|
|
668
|
+
assert.deepEqual(selectedColumns(resolveFakeCursors(boundary, true)[1]!), [0]);
|
|
669
|
+
assert.deepEqual(selectedColumns(resolveFakeCursors(boundary, false)[1]!), [0, 1]);
|
|
670
|
+
const legacy = e.render(20);
|
|
671
|
+
legacy[1] = legacy[1]!.replaceAll("\x1b_pi:fc\x07", "\x1b[7m").replaceAll("\x1b_pi:/fc\x07", "\x1b[0m");
|
|
672
|
+
assert.deepEqual(selectedColumns(adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }, legacy)[1]!), [0, 1, 2, 3]);
|
|
673
|
+
const unknown = e.render(20).map(row => row.replace("\x1b_pi:fc\x07", "\x1b_pi:unknown\x07"));
|
|
674
|
+
assert.deepEqual(adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }, unknown), unknown);
|
|
675
|
+
});
|
|
676
|
+
|
|
641
677
|
test("selection survives fake cursor reset and retains surrounding color", () => {
|
|
642
678
|
const e = editor();
|
|
643
679
|
e.setText("abcd");
|
|
644
680
|
createVimEditorAdapter(e, INSTALLED_PI).move({ line: 0, col: 1 });
|
|
645
681
|
const frame = createVimEditorAdapter(e, INSTALLED_PI).renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 });
|
|
646
682
|
const row = frame.find((line) => line.includes("a")) ?? frame.join("");
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
for (const token of row.matchAll(/\x1b\[[0-9;]*m|[^\x1b]/g)) {
|
|
650
|
-
const value = token[0];
|
|
651
|
-
if (value.startsWith("\x1b[")) {
|
|
652
|
-
for (const code of value.slice(2, -1).split(";").map(Number)) {
|
|
653
|
-
if (code === 0 || code === 27) reverse = false;
|
|
654
|
-
if (code === 7) reverse = true;
|
|
655
|
-
}
|
|
656
|
-
} else if ("abcd".includes(value)) selected.push(reverse ? value : `!${value}`);
|
|
657
|
-
}
|
|
658
|
-
assert.deepEqual(selected, ["a", "b", "c", "d"], "every visible cell must have effective reverse SGR");
|
|
683
|
+
assert.deepEqual(paintedCells(row).slice(0, 4), "abcd".split("").map(char => ({ char, inverse: true })),
|
|
684
|
+
"every visible cell must have effective reverse SGR after the host post-pass");
|
|
659
685
|
});
|
|
660
686
|
|
|
661
687
|
test("truecolor channels do not change inverse state across selected and unselected cells", () => {
|
package/tests/yolo-mode.test.ts
CHANGED
|
@@ -192,7 +192,12 @@ test("active YOLO waives only ordinary push after full destructive/guard evaluat
|
|
|
192
192
|
await h.command("enable");
|
|
193
193
|
assert.equal(await call("git push origin main"), undefined);
|
|
194
194
|
assert.equal(h.confirmations(), 1);
|
|
195
|
-
|
|
195
|
+
// gentle-shell#2172: reversible compound delivery is waived too.
|
|
196
|
+
for (const command of ["git commit -m 'fix: x' && git push -u origin feature/x", "git push origin main && gh pr create --fill", "git -C /other push origin main", "git rebase origin/main && git push"]) {
|
|
197
|
+
assert.equal(await call(command), undefined, command);
|
|
198
|
+
}
|
|
199
|
+
assert.equal(h.confirmations(), 1, "waived commands never reach the confirmation dialog");
|
|
200
|
+
for (const command of ["git push origin main && rm -rf ./data", `sh -c "psql -c 'DROP TABLE users'"`, "git push origin main && git rebase -i main", "git push --force origin main", "git push origin main && npm publish", "git push origin :main", "sh -c 'git push origin main'"]) {
|
|
196
201
|
assert.ok(await call(command), command);
|
|
197
202
|
}
|
|
198
203
|
await h.command("disable"); assert.ok(await call("git push origin main"));
|