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.
Files changed (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. 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);
@@ -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 lists open work with the rules, and stays silent when there is nothing open", () => {
156
- const block = todoPromptBlock(seeded(), 0);
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.match(todoPromptBlock(seeded(), 2) ?? "", /stale: 2 turns without an update/);
165
- assert.equal(todoPromptBlock(emptyTodo(), 0), undefined);
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(todoPromptBlock(allDone, 0), undefined);
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(todoPromptBlock(state, 5), undefined);
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
- const block = todoPromptBlock(blockedOnly, 0) ?? "";
458
- assert.match(block, /2\. \[blocked\] Deploy — waiting for an admin/);
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
- let reverse = false;
648
- const selected: string[] = [];
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", () => {
@@ -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
- for (const command of ["git push origin main && rm -rf ./data", `sh -c "psql -c 'DROP TABLE users'"`, "git push origin main && git rebase main", "git push --force origin main", "git push origin main && npm publish", "git -C /other push origin main", "sh -c 'git push origin main'"]) {
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"));