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
@@ -399,7 +399,7 @@ test("the review screen shows the notice about reused tools in folders another a
399
399
  });
400
400
 
401
401
  test("a recoverable stack is reviewed as completing setup, not as a reinstall", async () => {
402
- const view = await serverPlanView({ pi: { available: true, version: "1.0.0", usable: true },
402
+ const view = await serverPlanView({ pi: { available: true, version: "1.2.0", usable: true },
403
403
  shell: { available: true, version: requirements.shell, usable: true, global: true },
404
404
  gentleAi: { available: true, version: requirements.gentleAi, usable: true, compatible: true },
405
405
  setup: { available: true, recoverable: true } });
@@ -563,7 +563,7 @@ test("expectedSteps checks, updates and verifies an older Pi before any Gentle S
563
563
  test("a plan that only updates an older Pi is reviewed as a Pi update, with an Update Pi button", () => {
564
564
  const document = new FakeDocument();
565
565
  const model = wizard.planModel({ planId: "p", ready: false, channel: "release", blockers: [],
566
- actions: [{ id: "update-pi", description: "Update Pi 0.87.1 to 1.0.0 with npm." }, { id: "verify-readiness", description: "Verify." }] });
566
+ actions: [{ id: "update-pi", description: "Update Pi 0.87.1 to 1.2.0 with npm." }, { id: "verify-readiness", description: "Verify." }] });
567
567
  assert.equal(model.kind, "update-pi");
568
568
  const view = wizard.renderPlan(document, model, { install: () => {}, reload: () => {}, close: () => {} });
569
569
  assert.ok(headings(view)[0].textContent.includes("Update Pi"));
@@ -934,19 +934,19 @@ test("Go has no descriptor for other targets", () => {
934
934
  // The Engram release the bundled install ships (S9), from its checksums.txt and
935
935
  // the GitHub asset digests; every archive was downloaded and re-hashed.
936
936
  const engramPins = {
937
- "darwin-arm64": ["darwin_arm64.tar.gz", "3d6b4ac0055883ec23d5b0a04a3cda041653cdf4916fd7e98944692e62fd0441", 7465858],
938
- "darwin-x64": ["darwin_amd64.tar.gz", "3da8d7befe8500ff09aa44c48f493eda96513fd3f63c7e4bfb95e3ae85dfae52", 7952089],
939
- "linux-x64": ["linux_amd64.tar.gz", "8694d3817b63c5fc5607b5919e88aa929bffa0bafda330513364e10c8ab61445", 7817468],
940
- "linux-arm64": ["linux_arm64.tar.gz", "b4546b6e00f68e56da52064f1865c6cbae1d5b0633aeebc36e1a2f20b17683e7", 7168173],
941
- "win32-x64": ["windows_amd64.zip", "e00afe91d1baade484d217606d78c2fd91e26a4f9823b603d050c1e4be78233c", 8013889],
942
- "win32-arm64": ["windows_arm64.zip", "251d467017f88e4eb7fc186c635a9bf64db5aa39cfc251d35dd4035f38941454", 7224229],
937
+ "darwin-arm64": ["darwin_arm64.tar.gz", "d0aa59c9a83d17ae3fc7f5dd31fdab6a26fd8c00fc5e692195753cd0719294f7", 7470202],
938
+ "darwin-x64": ["darwin_amd64.tar.gz", "3c5bdc73d226af5629cf4f4a781e223142e37c1ebb443c4b596f2e284352e381", 7961674],
939
+ "linux-x64": ["linux_amd64.tar.gz", "715fda625556e46d65ddb4526bb7f88a3fee5339ca3c44ce44cc652f17fa57f1", 7821191],
940
+ "linux-arm64": ["linux_arm64.tar.gz", "3309e9c0bbb539d183fd5aa9f9a13cd7aa0e81ec2512b5bedefb8b2bdd35b3e5", 7176726],
941
+ "win32-x64": ["windows_amd64.zip", "3065736ec7ff8137eb3fc607275f542ea13bb743a5157318c8a8770f11ce3289", 8021225],
942
+ "win32-arm64": ["windows_arm64.zip", "7463872a85a5e5434cf958bdffc71dda1926bced30d3b6b38c80fec9bb98fbba", 7231641],
943
943
  } as const;
944
944
  test("fixed Engram descriptors are the GitHub release archives and checksums, and nothing else", () => {
945
945
  for (const [target, [suffix, sha256, size]] of Object.entries(engramPins)) {
946
946
  const [platform, arch] = target.split("-");
947
947
  const artifact = artifactFor("engram", platform, arch);
948
- assert.equal(artifact.version, "3.3.2");
949
- assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.3.2/engram_3.3.2_${suffix}`);
948
+ assert.equal(artifact.version, "3.4.0");
949
+ assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_${suffix}`);
950
950
  assert.equal(artifact.integrity, `sha256-${sha256}`);
951
951
  assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
952
952
  // GitHub serves release assets from its asset hosts.
@@ -253,7 +253,7 @@ test("a missing Pi is installed before an existing Gentle Shell is updated", ()
253
253
  });
254
254
 
255
255
  test("an existing compatible Pi from any installation is reused and only Gentle Shell is installed", () => {
256
- for (const pi of [tool("1.2.0"), { ...tool("1.0.4"), external: true }]) {
256
+ for (const pi of [tool("1.2.0"), { ...tool("1.2.4"), external: true }]) {
257
257
  const plan = planPreflight({ ...installed(), pi, shell: absent, gentleAi: absent, setup: false });
258
258
  assert.equal(plan.tools.pi.status, "reusable");
259
259
  assert.deepEqual(updateIds(plan), ["install-shell", "setup-shell", "verify-readiness"]);
@@ -261,7 +261,7 @@ test("an existing compatible Pi from any installation is reused and only Gentle
261
261
  });
262
262
 
263
263
  test("a pnpm Pi newer than the installer's pin never blocks: Gentle Shell is added or updated beside it", () => {
264
- const newer = { ...tool("1.1.0") };
264
+ const newer = { ...tool("1.2.1") };
265
265
  const install = planPreflight({ ...installed(), pi: newer, shell: absent, gentleAi: absent, setup: false });
266
266
  assert.deepEqual([install.blockers, updateIds(install)], [[], ["install-shell", "setup-shell", "verify-readiness"]]);
267
267
  const update = planPreflight({ ...installed(), pi: newer, shell: owned("3.9.0"), gentleAi: unchecked, setup: unchecked });
@@ -363,7 +363,7 @@ test("missing Windows Go is acquired before reprovisioning an existing Shell", (
363
363
  // A pinned stack this pnpm installed whose setup did not finish (setup probe: recoverable).
364
364
  const recoverable = { available: true, recoverable: true };
365
365
  function partial(change: object = {}) {
366
- return { ...installed(), pi: tool("1.0.0"), shell: { ...tool(requirements.shell), global: true }, setup: recoverable, ...change };
366
+ return { ...installed(), pi: tool("1.2.0"), shell: { ...tool(requirements.shell), global: true }, setup: recoverable, ...change };
367
367
  }
368
368
 
369
369
  test("a recoverable stack only reruns setup, with PATH setup when the global bin is off PATH", () => {
@@ -657,11 +657,11 @@ test("clean global list: Pi, Shell and Gentle AI absent and setup needed, from o
657
657
  });
658
658
 
659
659
  test("pnpm-global Pi and Shell report versions; verified package-native Gentle AI is compatible", async () => {
660
- const stdout = listing({ "@earendil-works/pi-coding-agent": { version: "1.0.0", path: `${PNPM_HOME}/global/v11/def/node_modules/pi` },
660
+ const stdout = listing({ "@earendil-works/pi-coding-agent": { version: PI_INSTALL_VERSION, path: `${PNPM_HOME}/global/v11/def/node_modules/pi` },
661
661
  "gentle-pi": { version: requirements.shell, path: SHELL_ROOT } });
662
662
  const files = [TOOLS_PNPM, `${BIN}/gentle-shell`, gentleAiBinaryPath(SHELL_ROOT, "linux")];
663
663
  const h = probes({ files, dirs: [HOME, PNPM_HOME, SHELL_ROOT], texts: besidePi(PI_INSTALL_VERSION), results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } });
664
- assert.deepEqual(await h.probes.pi(), { available: true, version: "1.0.0", usable: true });
664
+ assert.deepEqual(await h.probes.pi(), { available: true, version: PI_INSTALL_VERSION, usable: true });
665
665
  assert.deepEqual(await h.probes.shell(), { available: true, version: requirements.shell, usable: true, global: true, owner: "pnpm" });
666
666
  assert.deepEqual(await h.probes.gentleAi(), { available: true, version: requirements.gentleAi, usable: true, compatible: true });
667
667
  assert.deepEqual(h.integrityCalls, [{ packageRoot: SHELL_ROOT, platform: "linux", env: bootstrapEnv, home: HOME }]);
@@ -684,7 +684,7 @@ test("setup is recoverable only for the pinned Pi and Shell owned by one pnpm pr
684
684
  const persisted = { dependencies: { npm: { version: "11.19.0" }, pnpm: { version: "11.1.1" } } };
685
685
  assert.deepEqual(await setup(JSON.stringify([persisted, { dependencies: { [PI]: pi, "gentle-pi": shell } }])), recoverable);
686
686
  const foreign = [
687
- listing({ [PI]: { ...pi, version: "1.2.0" }, "gentle-pi": shell }),
687
+ listing({ [PI]: { ...pi, version: "1.2.1" }, "gentle-pi": shell }),
688
688
  listing({ [PI]: pi, "gentle-pi": { ...shell, version: "3.0.0" } }),
689
689
  listing({ "gentle-pi": shell }),
690
690
  JSON.stringify([{ dependencies: { [PI]: pi, "gentle-pi": shell } }, { dependencies: { [PI]: pi } }]),
@@ -698,7 +698,7 @@ test("setup is recoverable only for the pinned Pi and Shell owned by one pnpm pr
698
698
  assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { realpaths: { [SHELL_ROOT]: "/elsewhere/gentle-pi" } }),
699
699
  { available: null });
700
700
  // The Pi beside gentle-pi, which the launcher runs, may be pnpm's newer peer, but never below the minimum, unreadable or missing.
701
- assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { texts: besidePi("1.1.0") }), recoverable);
701
+ assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { texts: besidePi("1.2.1") }), recoverable);
702
702
  for (const texts of [besidePi("0.99.0"), { [BESIDE_PI]: "not json" }, { [`${PNPM_HOME}/elsewhere`]: "" }]) {
703
703
  assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { texts }), { available: null }, JSON.stringify(texts));
704
704
  }
@@ -741,7 +741,7 @@ test("a pnpm-global Shell from the main channel is present with its main version
741
741
 
742
742
  const NPM_ROOT = "/usr/local/lib/node_modules";
743
743
  const NPM_SHELL = `${NPM_ROOT}/gentle-pi`;
744
- const outside = (shellVersion: string, { linked = false, piOutput = "1.0.4\n" } = {}) => probes({
744
+ const outside = (shellVersion: string, { linked = false, piOutput = "1.2.4\n" } = {}) => probes({
745
745
  env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/local/bin:/usr/bin` },
746
746
  files: [TOOLS_PNPM, "/usr/local/bin/pi", "/usr/local/bin/gentle-shell", "/usr/local/bin/npm"],
747
747
  dirs: [HOME, NPM_ROOT, "/home/u/work/gentle-pi"],
@@ -752,7 +752,7 @@ const outside = (shellVersion: string, { linked = false, piOutput = "1.0.4\n" }
752
752
  });
753
753
 
754
754
  test("a Pi on PATH that pnpm does not manage is reused with the version it reports", async () => {
755
- assert.deepEqual(await outside("3.9.0").probes.pi(), { available: true, version: "1.0.4", usable: true, external: true });
755
+ assert.deepEqual(await outside("3.9.0").probes.pi(), { available: true, version: "1.2.4", usable: true, external: true });
756
756
  for (const piOutput of ["", "pi dev build\n"]) {
757
757
  assert.deepEqual(await outside("3.9.0", { piOutput }).probes.pi(), { available: null, outsidePnpm: true }, piOutput);
758
758
  }
@@ -791,13 +791,13 @@ test("an older pnpm-global Pi reports pnpm as its owner, and locatePi finds its
791
791
  });
792
792
 
793
793
  test("a pnpm-global Pi newer than the installer's pin reports no owner: it is reused as it is", async () => {
794
- for (const version of ["1.0.1", "1.1.0", "2.0.0"]) {
794
+ for (const version of ["1.2.1", "1.3.0", "2.0.0"]) {
795
795
  const newer = probes({ files: [TOOLS_PNPM], dirs: [HOME, PI_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0,
796
796
  stdout: listing({ [PI_PACKAGE]: { version, path: PI_ROOT } }) } } });
797
797
  assert.deepEqual(await newer.probes.pi(), { available: true, version, usable: true }, version);
798
798
  }
799
799
  // A newer Pi from elsewhere is reused as it is: pnpm does not own it.
800
- assert.deepEqual(await outsidePi("1.1.0").probes.pi(), { available: true, version: "1.1.0", usable: true, external: true });
800
+ assert.deepEqual(await outsidePi("1.2.1").probes.pi(), { available: true, version: "1.2.1", usable: true, external: true });
801
801
  });
802
802
 
803
803
  test("an older Pi in npm's global root reports npm as its owner; one elsewhere has no owner", async () => {
@@ -724,7 +724,7 @@ test("install nonzero, signal or deadline fails and reports completed steps", as
724
724
  test("an existing global Pi or gentle-pi blocks before installation regardless of the plan", async () => {
725
725
  const existing = [
726
726
  listing(),
727
- JSON.stringify([{ dependencies: { "@earendil-works/pi-coding-agent": { version: "1.2.0" } } }]),
727
+ JSON.stringify([{ dependencies: { "@earendil-works/pi-coding-agent": { version: "1.2.1" } } }]),
728
728
  JSON.stringify([{ dependencies: {} }, { dependencies: { "gentle-pi": { version: "3.0.0" } } }]),
729
729
  JSON.stringify([{ optionalDependencies: { "gentle-pi": { version: "4.0.0" } } }]),
730
730
  ];
@@ -1390,7 +1390,7 @@ test("setup recovery blocks when the installed stack changed between plan and ru
1390
1390
  const stack = JSON.parse(listing());
1391
1391
  const changed = [
1392
1392
  "[]",
1393
- listing("1.2.0"),
1393
+ listing("1.2.1"),
1394
1394
  listing(PI_INSTALL_VERSION, "3.0.0"),
1395
1395
  listing(PI_INSTALL_VERSION, requirements.shell, "/elsewhere/gentle-pi"),
1396
1396
  JSON.stringify([...stack, { dependencies: { "gentle-pi": { version: requirements.shell, path: PACKAGE_ROOT } } }]),
@@ -1640,7 +1640,7 @@ const NPM_SHELL_ROOT = "/usr/local/lib/node_modules/gentle-pi";
1640
1640
 
1641
1641
  test("with a compatible Pi already installed, only Gentle Shell is added and Pi is left as it is", async () => {
1642
1642
  // A pnpm Pi newer than PI_INSTALL_VERSION is kept too: gentle-pi gets its own pnpm group, nothing is replaced.
1643
- for (const version of ["1.2.0", PI_INSTALL_VERSION]) {
1643
+ for (const version of ["1.2.1", PI_INSTALL_VERSION]) {
1644
1644
  const h = harness({ results: { [LIST]: [piOnlyList(version), { code: 0, stdout: listing(version) }], [SHELL_ONLY_ADD]: { code: 0 } } });
1645
1645
  const result = await runStandardInstall({ plan: existingPlan({ pi: tool(version) }), consent: true }, h.adapters);
1646
1646
  assert.equal(result.outcome, "ready", version);
@@ -1652,7 +1652,7 @@ test("with a compatible Pi already installed, only Gentle Shell is added and Pi
1652
1652
  });
1653
1653
 
1654
1654
  test("adding only Gentle Shell still refuses a gentle-pi that pnpm already lists", async () => {
1655
- const h = harness({ results: { [LIST]: { code: 0, stdout: listing("1.2.0") } } });
1655
+ const h = harness({ results: { [LIST]: { code: 0, stdout: listing("1.2.1") } } });
1656
1656
  const result = await runStandardInstall({ plan: existingPlan({}), consent: true }, h.adapters);
1657
1657
  assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack"]);
1658
1658
  assert.equal(h.calls.some((call) => call.args[1] === "add"), false);
@@ -1674,19 +1674,20 @@ test("Pi and gentle-pi are added as separate pnpm groups, so a later `pi update`
1674
1674
 
1675
1675
  test("verify-global-list accepts the Pi the launcher runs beside gentle-pi at the minimum or newer, and reports one other than the pin", async () => {
1676
1676
  // pnpm's latest peer beside gentle-pi (or nested in it): Gentle Shell runs it, and the outcome says which.
1677
- for (const texts of [{ [besidePi(PACKAGE_ROOT)]: piPackage("1.1.0") }, { [nestedPi(PACKAGE_ROOT)]: piPackage("1.1.0") },
1677
+ for (const texts of [{ [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") }, { [nestedPi(PACKAGE_ROOT)]: piPackage("1.2.1") },
1678
1678
  { [besidePi(PACKAGE_ROOT)]: piPackage(requirements.pi) }]) {
1679
1679
  const h = harness({ texts });
1680
1680
  const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
1681
1681
  assert.equal(result.outcome, "ready", JSON.stringify(texts));
1682
- assert.equal(result.piVersion, Object.values(texts)[0].includes("1.1.0") ? "1.1.0" : requirements.pi);
1682
+ const selectedVersion = JSON.parse(Object.values(texts)[0]).version;
1683
+ assert.equal(result.piVersion, selectedVersion === PI_INSTALL_VERSION ? undefined : selectedVersion);
1683
1684
  }
1684
1685
  // The pin itself adds nothing to the outcome.
1685
1686
  assert.equal("piVersion" in (await runStandardInstall({ plan: plan(), consent: true }, harness().adapters)), false);
1686
1687
  const wrong = [
1687
1688
  { [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") },
1688
1689
  // A Pi nested in gentle-pi resolves first, even below the minimum.
1689
- { [nestedPi(PACKAGE_ROOT)]: piPackage("0.99.0"), [besidePi(PACKAGE_ROOT)]: piPackage("1.1.0") },
1690
+ { [nestedPi(PACKAGE_ROOT)]: piPackage("0.99.0"), [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") },
1690
1691
  { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.0-beta.1") },
1691
1692
  { [besidePi(PACKAGE_ROOT)]: JSON.stringify({ name: "not-pi", version: PI_INSTALL_VERSION }) },
1692
1693
  { [besidePi(PACKAGE_ROOT)]: "not json" },
@@ -1706,10 +1707,10 @@ test("verify-global-list accepts the Pi the launcher runs beside gentle-pi at th
1706
1707
  });
1707
1708
 
1708
1709
  test("the main Shell is added alone and its adjacent Pi is verified like the release one", async () => {
1709
- const h = mainHarness(undefined, { texts: { [besidePi(MAIN_ROOT)]: piPackage("1.1.0") } });
1710
+ const h = mainHarness(undefined, { texts: { [besidePi(MAIN_ROOT)]: piPackage("1.2.1") } });
1710
1711
  const result = await runStandardInstall({ plan: mainPlan(), consent: true }, h.adapters);
1711
1712
  assert.equal(result.outcome, "ready");
1712
- assert.equal(result.piVersion, "1.1.0");
1713
+ assert.equal(result.piVersion, "1.2.1");
1713
1714
  assert.deepEqual(h.calls.filter((call) => call.args[1] === "add").map((call) => call.args.slice(1)),
1714
1715
  [INSTALL.split(" "), MAIN_ADD.split(" ")]);
1715
1716
  const below = mainHarness(undefined, { texts: { [besidePi(MAIN_ROOT)]: piPackage("0.99.0") } });
@@ -1722,10 +1723,10 @@ test("a pnpm-owned Gentle Shell update is verified with a Pi at the minimum or n
1722
1723
  const fixed = existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "pnpm" },
1723
1724
  gentleAi: { available: null }, setup: { available: null } });
1724
1725
  const located = [{ root: OLD_ROOT, version: "3.9.0", owner: "pnpm" }, { root: PACKAGE_ROOT, version: requirements.shell, owner: "pnpm" }];
1725
- const h = updateHarness({ located, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.1.0") } });
1726
+ const h = updateHarness({ located, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") } });
1726
1727
  const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1727
1728
  assert.equal(result.outcome, "ready");
1728
- assert.equal(result.piVersion, "1.1.0");
1729
+ assert.equal(result.piVersion, "1.2.1");
1729
1730
  assert.deepEqual(result.completed.slice(3, 5), ["update-shell", "verify-updated-shell"]);
1730
1731
  for (const texts of [{ [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") }, { [nestedPi(PACKAGE_ROOT)]: piPackage("0.99.0") }]) {
1731
1732
  const wrong = updateHarness({ located, texts });
@@ -1740,7 +1741,7 @@ test("a pnpm-owned Gentle Shell update is verified with a Pi at the minimum or n
1740
1741
  });
1741
1742
 
1742
1743
  test("a setup recovery accepts a newer Pi beside gentle-pi but not one below the minimum", async () => {
1743
- const newer = harness({ results: installedList, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.1.0") } });
1744
+ const newer = harness({ results: installedList, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") } });
1744
1745
  assert.equal((await runStandardInstall({ plan: recoveryPlan(), consent: true }, newer.adapters)).outcome, "ready");
1745
1746
  const h = harness({ results: installedList, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") } });
1746
1747
  const result = await runStandardInstall({ plan: recoveryPlan(), consent: true }, h.adapters);
@@ -1942,7 +1943,7 @@ test("a Pi update is refused before any change when the installed Pi is not the
1942
1943
  const plan = existingPlan({ pi: olderPi("pnpm") });
1943
1944
  // Gone, unattributed, another version, another owner, or already current: never reinstalled, never downgraded.
1944
1945
  for (const before of [null, { ...piBefore, owner: null }, { ...piBefore, version: "0.90.0" }, { ...piBefore, owner: "npm" },
1945
- { ...piBefore, version: "1.2.0" }, { ...piBefore, version: PI_INSTALL_VERSION }, { ...piBefore, root: "relative/pi" }]) {
1946
+ { ...piBefore, version: "1.2.1" }, { ...piBefore, version: PI_INSTALL_VERSION }, { ...piBefore, root: "relative/pi" }]) {
1946
1947
  const h = locatingPi(harness({ results: { [LIST]: piOnlyList("0.87.1") } }), [before]);
1947
1948
  const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1948
1949
  assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack-unverified"], JSON.stringify(before));
@@ -298,7 +298,7 @@ test("/api/plan reports blockers with guidance and no profile change when alread
298
298
 
299
299
  test("/api/plan names the found and required version of an older Gentle Shell and how to resolve it", async () => {
300
300
  const { host, port, login } = await start({ collect: async () => collected({
301
- pi: { available: true, version: "1.0.4", usable: true },
301
+ pi: { available: true, version: "1.2.4", usable: true },
302
302
  shell: { available: true, version: "3.4.0", usable: false, global: true },
303
303
  gentleAi: { available: null }, setup: { available: null },
304
304
  }) });
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
306
306
  const view = await plan(port, await login());
307
307
  assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
308
308
  assert.equal(view.blockers[0].guidance,
309
- "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.1.0 or newer. Nothing was replaced. " +
310
- "Update it with `pnpm add -g gentle-pi@4.1.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
309
+ "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.2.0 or newer. Nothing was replaced. " +
310
+ "Update it with `pnpm add -g gentle-pi@4.2.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
311
311
  assert.deepEqual(view.actions, []);
312
312
  } finally {
313
313
  await host.close("test");
@@ -449,7 +449,7 @@ test("/api/plan explains a pnpm in $PNPM_HOME/bin that the installer will not re
449
449
 
450
450
  test("/api/plan explains a Gentle Shell that neither pnpm nor npm manages", async () => {
451
451
  const { host, port, login } = await start({ collect: async () => collected({
452
- pi: { available: true, version: "1.0.4", usable: true },
452
+ pi: { available: true, version: "1.2.4", usable: true },
453
453
  shell: { available: null, outsidePnpm: true }, gentleAi: { available: null }, setup: { available: null },
454
454
  }) });
455
455
  try {
@@ -514,7 +514,7 @@ test("the outcome says which Pi Gentle Shell runs only when it is not the instal
514
514
 
515
515
  test("/api/plan describes updating an existing Gentle Shell on either channel", async () => {
516
516
  const shell = { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" };
517
- const { host, port, login } = await start({ collect: async (channel) => collectedFor(channel, { pi: { available: true, version: "1.0.4", usable: true },
517
+ const { host, port, login } = await start({ collect: async (channel) => collectedFor(channel, { pi: { available: true, version: "1.2.4", usable: true },
518
518
  shell, go: { available: true, version: "1.26.0", usable: true }, gentleAi: { available: null }, setup: { available: null } }) });
519
519
  try {
520
520
  const cookie = await login();
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
8
+ import { requirements } from "../scripts/installer-preflight.mjs";
8
9
 
9
10
  const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
10
11
  const SHELL_SHA = "6e7e3a18f794223396527a54c7c36d19c7d236c6";
@@ -53,6 +54,9 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
53
54
  for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
54
55
  return { code: 0, stdout: "" };
55
56
  }
57
+ if (argv[0] === "version" && command === `${bin}/go`) {
58
+ return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
59
+ }
56
60
  if (argv[0] === "version") {
57
61
  const sha = command.split("/").at(-2) ?? "";
58
62
  return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
@@ -69,7 +73,9 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
69
73
  };
70
74
  const which = async (name: string) => (tools.includes(name) ? `${bin}/${name}${name === "go" ? "" : suffix}` : null);
71
75
  const upgrade = (args: string[], currentVersion = "4.0.0") => runUpgrade({ args, ctx, platform, packageRoot, currentVersion,
72
- adapters: { fetch, run, fs, which, ...(invocation ? { invocation } : {}), ...(migrate ? { migrate } : {}) }, out: (line: string) => lines.push(line) });
76
+ adapters: { fetch, run, fs, which,
77
+ acquireGo: async () => { throw new Error("Unexpected Go acquisition in sandboxed upgrade fixture"); },
78
+ ...(invocation ? { invocation } : {}), ...(migrate ? { migrate } : {}) }, out: (line: string) => lines.push(line) });
73
79
  const installs = () => calls.filter((call) => call.argv.some((arg) => ["add", "install"].includes(arg)) && !call.argv.some((arg) => arg.includes("cmd/gentle-ai@")))
74
80
  .map((call) => `${call.command} ${call.argv.join(" ")}`);
75
81
  const manager = (command?: string) => ownerManager({ ctx, platform, packageRoot, adapters: { fs, run, which, ...(invocation ? { invocation } : {}) },
@@ -17,7 +17,10 @@ test("a second direct file is not refused based on session write history", async
17
17
  events: { emit() {} }, registerCommand() {}, registerTool() {}, getFlag: () => undefined,
18
18
  getActiveTools: () => ["edit", "write"],
19
19
  } as unknown as ExtensionAPI;
20
- createGentleAiExtension({ nativeReviewCli: null, processEnv: {}, resolveTelemetryTriggerBinary: () => "/usr/bin/true", telemetryTriggerSpawn: (() => undefined) as never })(pi);
20
+ // An isolated config home keeps the host's permission mode (for example Auto)
21
+ // out of a test that only exercises the ODD write gate.
22
+ const processEnv = { GENTLE_PI_CONFIG_HOME: join(cwd, ".config-home") };
23
+ createGentleAiExtension({ nativeReviewCli: null, processEnv, resolveTelemetryTriggerBinary: () => "/usr/bin/true", telemetryTriggerSpawn: (() => undefined) as never })(pi);
21
24
  const ctx = {
22
25
  cwd, hasUI: false, mode: "interactive", ui: { notify() {} },
23
26
  sessionManager: { getSessionId: () => "odd-direct" },
@@ -133,13 +133,13 @@ test("public docs and metadata advertise ODD and review without retired phase wo
133
133
 
134
134
  test("technical reference declares the tested Pi minimum required for agent_settled", () => {
135
135
  const manifest = readPackageJson();
136
- assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.99.1");
137
- assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], ">=1.0.0");
136
+ assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=1.2.0");
137
+ assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], ">=1.2.0");
138
138
  assert.equal(manifest.peerDependenciesMeta?.["@earendil-works/pi-coding-agent"]?.optional, true);
139
139
  assert.equal(manifest.engines?.node, ">=22.19.0");
140
140
  for (const path of ["docs/readme-reference.md", "docs/gentle-shell.md"]) {
141
141
  const source = readFileSync(join(PACKAGE_ROOT, path), "utf8");
142
- assert.match(source, /Pi 0\.99\.1 or newer/, path);
142
+ assert.match(source, /Pi 1\.2\.0 or newer/, path);
143
143
  assert.match(source, /open `>=1\.0\.0` development range/, path);
144
144
  assert.doesNotMatch(source, /development tests pin Pi/, path);
145
145
  // Docs name exactly the audited Vim editor releases, never a future one.
@@ -162,7 +162,7 @@ test("packed runtime uses optional Pi host peers with one open development range
162
162
  // one exact release shared by every Pi host package.
163
163
  const installed = new Set<string>();
164
164
  for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
165
- assert.equal(manifest.devDependencies?.[name], ">=1.0.0", name);
165
+ assert.equal(manifest.devDependencies?.[name], ">=1.2.0", name);
166
166
  const metadata = JSON.parse(readFileSync(join(PACKAGE_ROOT, "node_modules", name, "package.json"), "utf8")) as { name: string; version: string };
167
167
  assert.equal(metadata.name, name);
168
168
  installed.add(metadata.version);
@@ -1789,9 +1789,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1789
1789
  assert.match(wrapper, /quietToolsEnabled/);
1790
1790
  });
1791
1791
 
1792
- test("Gentle Shell v4.1.0 package manifest declares the release version", () => {
1792
+ test("Gentle Shell v4.2.0 package manifest declares the release version", () => {
1793
1793
  const packageJson = readPackageJson();
1794
- assert.equal(packageJson.version, "4.1.0", "the release manifest must be explicitly pinned to v4.1.0");
1794
+ assert.equal(packageJson.version, "4.2.0", "the release manifest must be explicitly pinned to v4.2.0");
1795
1795
  assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
1796
1796
  assert.ok(packageJson.files?.includes("assets/"));
1797
1797
  assert.ok(packageJson.files?.includes("contracts/"));
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { test } from 'node:test';
4
+
5
+ const PI = '@earendil-works/pi-coding-agent';
6
+ // A virtual symlink filesystem: listed global links point into a pnpm store.
7
+ // The store's adjacent peer is a sibling, not Shell/node_modules/Pi.
8
+ function launch(args: string[], scenario = 'owned') {
9
+ const adjacent = scenario.startsWith('adjacent');
10
+ const npm = scenario.startsWith('npm-');
11
+ const nodeModules = '/global/v11/selected/node_modules';
12
+ const shellRoot = '/store/shell-peer/node_modules/gentle-pi';
13
+ const piRoot = adjacent ? '/store/shell-peer/node_modules/' + PI : '/store/pi/node_modules/' + PI;
14
+ const target = adjacent ? 'gentle-pi' : PI;
15
+ const dependencies: Record<string, object> = {[target]:{path: `${nodeModules}/${target}`,
16
+ ...(scenario === 'npm-link' ? {link:true} : {}),
17
+ ...(scenario === 'npm-file' ? {resolved:'file:/source/pi'} : {})}};
18
+ if (scenario.endsWith('shared')) dependencies['unrelated'] = {path:`${nodeModules}/unrelated`};
19
+ if (scenario === 'separate') dependencies['unrelated'] = {path:'/global/v11/other/node_modules/unrelated'};
20
+ const groups = [{path:'/global/v11',dependencies}, ...(scenario === 'ambiguous' ? [{path:'/global/other',dependencies}] : [])];
21
+ const listing = scenario === 'malformed' ? '{' : JSON.stringify(npm ? groups[0] : groups);
22
+ // pnpm links only the top-level package into the global group; the peer
23
+ // is reachable solely as the Shell package's store sibling.
24
+ const links = {[`${nodeModules}/${target}`]:adjacent ? shellRoot : piRoot};
25
+ const roots = [nodeModules, '/global/v11/other/node_modules', shellRoot, piRoot, `${piRoot}/dist/bundle/cli.js`];
26
+ const bootstrap = `import { registerHooks } from 'node:module';
27
+ const original = ${JSON.stringify(new URL('../bin/gentle-shell.mjs', import.meta.url).href)};
28
+ registerHooks({load(url, context, next) {
29
+ if (url === original) {
30
+ let source = next(url, context).source.toString();
31
+ source = source.replace('const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));', ${JSON.stringify(`const packageRoot = '${shellRoot}';`)});
32
+ source = source.replace('const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying });', 'const bundled = undefined;');
33
+ source = source.replace('runtime = resolvePiRuntime({', ${JSON.stringify(`runtime = ({ kind: '${adjacent ? 'bundled' : 'env'}', command: '${adjacent ? 'node' : piRoot + '/dist/bundle/cli.js'}', args: ${JSON.stringify(adjacent ? [piRoot + '/dist/bundle/cli.js'] : [])} }); /*`)}).replace('nodeExecPath: process.execPath,\\n\\t\\t});', 'nodeExecPath: process.execPath, */');
34
+ source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "1.2.0" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
35
+ source = source.replace('async function packageManagerAdapters() {', 'async function packageManagerAdapters() { return globalThis.fakeAdapters; } async function unusedAdapters() {');
36
+ source = source.replace('let homeHadContentBeforeBootstrap = false;', 'if (passthrough.length === 1) { console.error("error: pi cannot self-update this installation. This installation is not managed by a global pnpm install."); process.exitCode = 1; return; } console.log("FORWARD " + JSON.stringify(passthrough)); return; let homeHadContentBeforeBootstrap = false;');
37
+ return {format:'module', source, shortCircuit:true};
38
+ }
39
+ return next(url,context);
40
+ }});
41
+ const links = ${JSON.stringify(links)}, roots = ${JSON.stringify(roots)};
42
+ globalThis.fakeAdapters = {
43
+ fs: { realpath: async p => { if (links[p]) return links[p]; if (roots.includes(p)) return p; throw new Error('ENOENT ' + p); },
44
+ readFile: async p => { if (p === '${shellRoot}/package.json') return JSON.stringify({name:'gentle-pi',version:'4.1.0'}); if(p === '${piRoot}/package.json') return JSON.stringify({name:'${PI}',version:'1.2.0',bin:{pi:'dist/bundle/cli.js'}}); throw new Error('ENOENT ' + p); } },
45
+ which: async n => n === '${npm ? 'npm' : 'pnpm'}' ? '/fake/' + n : null,
46
+ run: async (cmd, args) => {
47
+ if(args.includes('list') || args.includes('ls')) return {code:0,stdout:${JSON.stringify(listing)}};
48
+ console.log('MUTATION ' + JSON.stringify(args)); return {code:0};
49
+ }
50
+ };
51
+ await import(original);`;
52
+ return spawnSync(process.execPath, ['--import', `data:text/javascript,${encodeURIComponent(bootstrap)}`, 'bin/gentle-shell.mjs', ...args], {encoding:'utf8'});
53
+ }
54
+
55
+ for (const scenario of ['owned','adjacent','separate']) test(`public linked-store update succeeds for ${scenario}`, () => {
56
+ const result = launch(['update'], scenario);
57
+ assert.equal(result.status, 0, result.stderr);
58
+ assert.match(result.stdout, scenario === 'adjacent' ? /MUTATION \["add","-g","gentle-pi@4\.1\.0","--allow-build=gentle-pi"\]/ : /MUTATION \["add","-g","@earendil-works\/pi-coding-agent@latest"\]/);
59
+ assert.doesNotMatch(result.stdout, /FORWARD/);
60
+ // The fake manager changes nothing, so the readback reports the same version and pnpm's release-age rule.
61
+ assert.match(result.stdout, /the selected Pi is still 1\.2\.0\./);
62
+ assert.match(result.stdout, /minimumReleaseAge/);
63
+ assert.equal(result.stderr, '');
64
+ });
65
+
66
+ test('public all updates only its owner and then forwards extension updates', () => {
67
+ const result = launch(['update', '--all']);
68
+ assert.equal(result.status, 0, result.stderr);
69
+ assert.match(result.stdout, /MUTATION .*add.*@earendil-works\/pi-coding-agent@latest/);
70
+ assert.match(result.stdout, /FORWARD \["update","--extensions"\]/);
71
+ assert.equal(result.stderr, '');
72
+ });
73
+
74
+ for (const scenario of ['ambiguous', 'malformed', 'npm-link', 'npm-file', 'shared', 'adjacent-shared']) test(`public update rejects ${scenario} ownership without mutation`, () => {
75
+ const result = launch(['update'], scenario);
76
+ assert.equal(result.status, 1, result.stdout);
77
+ assert.equal(result.stdout, '');
78
+ assert.match(result.stderr, /ownership/);
79
+ assert.doesNotMatch(result.stdout + result.stderr, /MUTATION/);
80
+ });
81
+
82
+ test('public extensions-only update preserves the forwarded command', () => {
83
+ const result = launch(['update', '--extensions-only']);
84
+ assert.equal(result.status, 0, result.stderr);
85
+ assert.equal(result.stdout, 'FORWARD ["update","--extensions-only"]\n');
86
+ assert.equal(result.stderr, '');
87
+ });