gentle-pi 4.2.0 → 4.3.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 +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -8,7 +8,8 @@ const source = readFileSync(join(root, "extensions/gentle-agents.ts"), "utf8");
|
|
|
8
8
|
const match = source.match(/name: "orchestrator_session_id",\s*label: "[^"]+",\s*description: ("(?:[^"\\]|\\.)*")/);
|
|
9
9
|
assert.ok(match, "Registered session tool must expose a public description");
|
|
10
10
|
const description: string = JSON.parse(match[1]);
|
|
11
|
-
|
|
11
|
+
// S11 T2: the session subject rules moved from the delegation card to its reference.
|
|
12
|
+
const detail = readFileSync(join(root, "assets/orchestrator-delegation-reference.md"), "utf8")
|
|
12
13
|
.split("## Session subject and display identity\n")[1].split("### Publish and find classified work")[0];
|
|
13
14
|
const docs = readFileSync(join(root, "docs/gentle-agents-activity.md"), "utf8")
|
|
14
15
|
.split("### Declare a recognizable subject\n")[1].split("### Publish curated state")[0];
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -219,6 +219,13 @@ test("renderShellBar renders one line with the segments in order", () => {
|
|
|
219
219
|
);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
test("renderShellBar paints the context gauge with the given gauge roles", () => {
|
|
223
|
+
const [line] = renderShellBar(model(), taggedTheme, 400, undefined, { ok: "gaugeOk", empty: "gaugeEmpty" });
|
|
224
|
+
assert.match(line, /<gaugeOk>▰▰▰▰<\/gaugeOk><gaugeEmpty>▱▱▱▱<\/gaugeEmpty>/);
|
|
225
|
+
const [fallback] = renderShellBar(model(), taggedTheme, 400);
|
|
226
|
+
assert.match(fallback, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
|
|
227
|
+
});
|
|
228
|
+
|
|
222
229
|
test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
|
|
223
230
|
const [line] = renderShellBar(model(), taggedTheme, 400);
|
|
224
231
|
assert.match(line, /<accent>✿ gentle shell<\/accent>/);
|
|
@@ -4,6 +4,7 @@ import { TuiAltScreen, visibleWidth, type Terminal, type TuiMouseEvent } from "@
|
|
|
4
4
|
import { CHANGE_STATUS, DIFF_TOO_LARGE, changesModel, type ChangedFile } from "../lib/shell-changes.ts";
|
|
5
5
|
import { WorktreeChangesView, ChangesView, colorDiff, type ChangesViewDeps } from "../lib/shell-changes-view.ts";
|
|
6
6
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
7
8
|
|
|
8
9
|
// The changes overlay: files on the left, the selected file's diff on the
|
|
9
10
|
// right, keys at the bottom. Rendering is pure; git access is injected.
|
|
@@ -458,7 +459,7 @@ const statusCases = [
|
|
|
458
459
|
[CHANGE_STATUS.ADDED, "A", 3, 0],
|
|
459
460
|
[CHANGE_STATUS.DELETED, "D", 0, 4],
|
|
460
461
|
[CHANGE_STATUS.RENAMED, "R", 0, 0],
|
|
461
|
-
[CHANGE_STATUS.UNTRACKED, "
|
|
462
|
+
[CHANGE_STATUS.UNTRACKED, "U", 5, 0],
|
|
462
463
|
] as const;
|
|
463
464
|
|
|
464
465
|
for (const [status, code, added, deleted] of statusCases) {
|
|
@@ -757,3 +758,138 @@ test("ChangesView.update keeps the selected file, reloads moved diffs, and survi
|
|
|
757
758
|
component.handleInput("j");
|
|
758
759
|
component.handleInput("o");
|
|
759
760
|
});
|
|
761
|
+
|
|
762
|
+
// Diff-only mode (F): the file column and its separator leave so the diff
|
|
763
|
+
// spans the whole inner width; j/k still walk files, esc returns first.
|
|
764
|
+
function diffOnlyView(overrides: { rows?: number; loadDiff?: (root: string, target: ChangedFile) => Promise<string> } = {}) {
|
|
765
|
+
const trees = [
|
|
766
|
+
{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) },
|
|
767
|
+
{ root: "/linked", branch: "agent", model: changesModel([file("c.ts", 1, 0)]) },
|
|
768
|
+
];
|
|
769
|
+
const events: string[] = [];
|
|
770
|
+
const component = new WorktreeChangesView(trees, {
|
|
771
|
+
theme: plainTheme, rows: overrides.rows ?? 10,
|
|
772
|
+
loadDiff: overrides.loadDiff ?? (async (root, target) => `+${root}:${target.path} ${"x".repeat(80)}`),
|
|
773
|
+
onOpen: (root, target) => { events.push(`open:${root}:${target.path}`); },
|
|
774
|
+
onClose: () => { events.push("close"); },
|
|
775
|
+
onRefresh: () => { events.push("refresh"); },
|
|
776
|
+
requestRender() {},
|
|
777
|
+
});
|
|
778
|
+
return { component, events };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
test("F hides the file list and its separator so the diff spans the full inner width", async () => {
|
|
782
|
+
const { component } = diffOnlyView();
|
|
783
|
+
component.handleInput("\r");
|
|
784
|
+
component.handleInput("j");
|
|
785
|
+
await settle();
|
|
786
|
+
const normal = component.render(100);
|
|
787
|
+
assert.match(normal[1]!, /^│ +▾ main · main +│ M a\.ts +│$/, "the normal view keeps the two-pane layout");
|
|
788
|
+
assert.match(stripAnsi(normal[2]!), /^│ ▸ M a\.ts .*│ \+\/main:a\.ts x+…│$/, "the narrow diff pane clips a long line");
|
|
789
|
+
assert.match(normal[8]!, /F diff only/);
|
|
790
|
+
component.handleInput("F");
|
|
791
|
+
await settle();
|
|
792
|
+
const wide = component.render(100);
|
|
793
|
+
assert.equal(wide.length, 10);
|
|
794
|
+
for (const line of wide) assert.equal(visibleWidth(line), 100);
|
|
795
|
+
assert.match(wide[1]!, /^│ M a\.ts +│$/, "the path header starts at the frame edge");
|
|
796
|
+
assert.equal(wide[2]!, `│ ${`+/main:a.ts ${"x".repeat(80)}`.padEnd(96)} │`, "the diff uses the full inner width");
|
|
797
|
+
assert.doesNotMatch(wide.slice(1, 8).join("\n"), /main · main|│.*│.*│/, "no list column and no separator remain");
|
|
798
|
+
assert.match(wide[8]!, /F files/);
|
|
799
|
+
component.handleInput("F");
|
|
800
|
+
assert.match(component.render(100)[1]!, /▾ main · main/, "F toggles back to the file list");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("j/k in diff-only mode walk files across worktrees and the path header follows", async () => {
|
|
804
|
+
const { component } = diffOnlyView();
|
|
805
|
+
component.handleInput("F");
|
|
806
|
+
await settle();
|
|
807
|
+
assert.match(component.render(100)[1]!, /^│ M a\.ts +│$/, "entering on a group header jumps to its first file");
|
|
808
|
+
component.handleInput("j");
|
|
809
|
+
await settle();
|
|
810
|
+
assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
|
|
811
|
+
component.handleInput("j");
|
|
812
|
+
await settle();
|
|
813
|
+
assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "j crosses into the next worktree's files");
|
|
814
|
+
assert.match(component.render(100)[2]!, /\+\/linked:c\.ts/);
|
|
815
|
+
component.handleInput("j");
|
|
816
|
+
assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "the last file stays selected");
|
|
817
|
+
component.handleInput("k");
|
|
818
|
+
await settle();
|
|
819
|
+
assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
|
|
820
|
+
component.handleInput("j");
|
|
821
|
+
component.handleInput("\x1b");
|
|
822
|
+
assert.match(component.render(100).join("\n"), /▸ M c\.ts/, "the files view shows the file chosen in diff-only mode");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("esc leaves diff-only mode before closing; q, o and r keep working there", async () => {
|
|
826
|
+
const { component, events } = diffOnlyView();
|
|
827
|
+
component.handleInput("F");
|
|
828
|
+
component.handleInput("\x1b");
|
|
829
|
+
assert.deepEqual(events, [], "the first esc only returns to the file list");
|
|
830
|
+
assert.match(component.render(100)[1]!, /main · main/);
|
|
831
|
+
component.handleInput("\x1b");
|
|
832
|
+
assert.deepEqual(events, ["close"]);
|
|
833
|
+
component.handleInput("F");
|
|
834
|
+
component.handleInput("o");
|
|
835
|
+
component.handleInput("r");
|
|
836
|
+
component.handleInput("q");
|
|
837
|
+
assert.deepEqual(events, ["close", "open:/main:a.ts", "refresh", "close"]);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("diff-only mode has no list mouse zone; the wheel scrolls the widened diff", async () => {
|
|
841
|
+
const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
|
|
842
|
+
const { component } = diffOnlyView({ rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}` });
|
|
843
|
+
component.handleInput("F");
|
|
844
|
+
await settle();
|
|
845
|
+
component.render(80);
|
|
846
|
+
assert.equal(component.handleMouse(mouseButton("move", "none", 3, 2, 8)), undefined, "no hover target");
|
|
847
|
+
assert.equal(component.handleMouse(mouseButton("click", "left", 3, 2, 8)), undefined, "no click target");
|
|
848
|
+
assert.match(component.render(80)[1]!, /^│ M a\.ts +│$/, "a click where the list was does not select");
|
|
849
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 3, 2, 3, 8)), { handled: true, render: true });
|
|
850
|
+
assert.doesNotMatch(component.render(80)[2]!, /@@/, "the left edge now scrolls the diff");
|
|
851
|
+
component.handleInput("\x1b");
|
|
852
|
+
component.render(80);
|
|
853
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 3, undefined, 8)), { handled: true, render: true }, "leaving diff-only restores the list zone");
|
|
854
|
+
assert.match(component.render(80)[3]!, /▸ M b\.ts/);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("the native fullscreen wrapper keeps keyboard, hover, click and wheel on the changes view", async () => {
|
|
858
|
+
let onInput: ((data: string) => void) | undefined;
|
|
859
|
+
const terminal: Terminal = {
|
|
860
|
+
start(input: Parameters<Terminal["start"]>[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 8; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {},
|
|
861
|
+
};
|
|
862
|
+
const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
|
|
863
|
+
let renders = 0;
|
|
864
|
+
const trees = [{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }];
|
|
865
|
+
const component = new WorktreeChangesView(trees, {
|
|
866
|
+
theme: { fg: (color: string, text: string) => color === "warning" ? `\x1b[33m${text}\x1b[39m` : text }, rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}`,
|
|
867
|
+
onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
868
|
+
});
|
|
869
|
+
const interaction = createNativeFullscreenInteraction({ keyboardTarget: component, requestRender: () => { renders++; } });
|
|
870
|
+
interaction.addChild(component);
|
|
871
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
|
|
872
|
+
tui.setLayoutRoot(interaction);
|
|
873
|
+
tui.start();
|
|
874
|
+
interaction.handleInput("\r");
|
|
875
|
+
assert.ok(renders > 0, "keys reach the view and request a render");
|
|
876
|
+
tui.renderNow(true);
|
|
877
|
+
try {
|
|
878
|
+
onInput?.("\x1b[<35;4;4M");
|
|
879
|
+
assert.match(interaction.render(80)[3]!, /\x1b\[33mM b\.ts/, "hover paints the row under the pointer");
|
|
880
|
+
onInput?.("\x1b[<0;4;4M");
|
|
881
|
+
onInput?.("\x1b[<0;4;4m");
|
|
882
|
+
assert.match(stripAnsi(interaction.render(80)[3]!), /▸ M b\.ts/, "a click selects the file");
|
|
883
|
+
await settle();
|
|
884
|
+
tui.renderNow(true);
|
|
885
|
+
onInput?.("\x1b[<65;60;3M");
|
|
886
|
+
assert.doesNotMatch(stripAnsi(interaction.render(80)[2]!), /@@/, "the wheel scrolls the diff");
|
|
887
|
+
interaction.handleInput("F");
|
|
888
|
+
tui.renderNow(true);
|
|
889
|
+
onInput?.("\x1b[<0;4;3M");
|
|
890
|
+
onInput?.("\x1b[<0;4;3m");
|
|
891
|
+
assert.match(stripAnsi(interaction.render(80)[1]!), /^│ M b\.ts +│$/, "diff-only mode ignores clicks where the list was");
|
|
892
|
+
} finally {
|
|
893
|
+
tui.stop();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { paintGauge, renderGauge, type GaugePaint } from "../lib/shell-gauge.ts";
|
|
4
|
+
|
|
5
|
+
// Issue #2222: gauge-specific theme roles fall back to today's roles when a
|
|
6
|
+
// theme does not define them, so existing themes render byte-identically.
|
|
7
|
+
// (Fixtures here are unit-scoped stubs; Pi's theme schema is validated
|
|
8
|
+
// separately by the loader and was checked directly against ThemeJsonSchema.)
|
|
9
|
+
|
|
10
|
+
const taggedTheme = {
|
|
11
|
+
fg(color: string, text: string) {
|
|
12
|
+
return `<${color}>${text}</${color}>`;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("paintGauge falls back to today's roles when no gauge roles are given", () => {
|
|
17
|
+
assert.equal(paintGauge(45, taggedTheme), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
|
|
18
|
+
assert.equal(paintGauge(85, taggedTheme), `<warning>▰▰▰▰▰▰▰</warning><border>▱</border>`);
|
|
19
|
+
assert.equal(paintGauge(96, taggedTheme), `<error>▰▰▰▰▰▰▰▰</error><border></border>`);
|
|
20
|
+
assert.equal(paintGauge(null, taggedTheme), `<dim></dim><border>▱▱▱▱▱▱▱▱</border>`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("paintGauge uses the gauge-specific roles when the theme defines them", () => {
|
|
24
|
+
const roles: GaugePaint = { ok: "gaugeOk", warning: "gaugeWarning", error: "gaugeError", empty: "gaugeEmpty" };
|
|
25
|
+
assert.equal(paintGauge(45, taggedTheme, 8, roles), `<gaugeOk>▰▰▰▰</gaugeOk><gaugeEmpty>▱▱▱▱</gaugeEmpty>`);
|
|
26
|
+
assert.equal(paintGauge(85, taggedTheme, 8, roles), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><gaugeEmpty>▱</gaugeEmpty>`);
|
|
27
|
+
assert.equal(paintGauge(96, taggedTheme, 8, roles), `<gaugeError>▰▰▰▰▰▰▰▰</gaugeError><gaugeEmpty></gaugeEmpty>`);
|
|
28
|
+
// A null percentage stays dim: there is nothing to gauge yet.
|
|
29
|
+
assert.equal(paintGauge(null, taggedTheme, 8, roles), `<dim></dim><gaugeEmpty>▱▱▱▱▱▱▱▱</gaugeEmpty>`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("paintGauge falls back per role, not per block", () => {
|
|
33
|
+
// A theme that defines only gaugeWarning keeps accent for the ok range.
|
|
34
|
+
assert.equal(paintGauge(45, taggedTheme, 8, { warning: "gaugeWarning" }), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
|
|
35
|
+
assert.equal(paintGauge(85, taggedTheme, 8, { warning: "gaugeWarning" }), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><border>▱</border>`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("renderGauge uses theme-defined glyphs when given, defaults otherwise", () => {
|
|
39
|
+
assert.equal(renderGauge(50, 4, { filled: "█", empty: "░" }), "██░░");
|
|
40
|
+
assert.equal(renderGauge(50, 4), "▰▰▱▱");
|
|
41
|
+
assert.equal(renderGauge(50, 4, { filled: "█" }), "██▱▱");
|
|
42
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
6
|
+
|
|
7
|
+
// Pain-point iteration S15. Bench evidence (pps14 x5): one run read "Two unrelated wishes" as separate
|
|
8
|
+
// deliverables, classified a two-feature request as large (branch, feature document, session state), and
|
|
9
|
+
// started two writers for ~100-line units: USD 0.55 against 0.20 for the run that stayed inline. Blind round 4
|
|
10
|
+
// also found delivered tests that pin `git show <baseline commit>`, so the differential check had leaked into
|
|
11
|
+
// committed tests that fail in another clone. These are instruction-delivery contracts, not proof of adherence.
|
|
12
|
+
|
|
13
|
+
const read = (path: string): string => readFileSync(join(import.meta.dirname, "..", path), "utf8");
|
|
14
|
+
|
|
15
|
+
test("S15 (a): separate deliverables are ones the user wants apart, not several features asked at once", () => {
|
|
16
|
+
const core = read("assets/orchestrator.md");
|
|
17
|
+
assert.ok(
|
|
18
|
+
core.includes("deliverables the user wants apart (never several features asked at once)"),
|
|
19
|
+
"Task Size must define separate deliverables",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("S15 (b): parallel writers never fire for units of about 100 changed lines or less", () => {
|
|
24
|
+
const card = read("assets/orchestrator-delegation.md");
|
|
25
|
+
assert.ok(
|
|
26
|
+
card.includes("Units of about 100 changed lines or less always run inline: writing them costs less than starting workers."),
|
|
27
|
+
"the Writer trigger must keep small units inline",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("S15 (c): the differential check runs in scratch and is never committed as a test", () => {
|
|
32
|
+
const clause = "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";
|
|
33
|
+
const harness = __testing.buildGentlePrompt("neutral");
|
|
34
|
+
assert.ok(harness.includes(clause), "the always-on test-first policy must keep the differential check in scratch");
|
|
35
|
+
assert.ok(!harness.includes("(`git stash` or a copy of HEAD)"), "the harness must not suggest git stash or HEAD copies");
|
|
36
|
+
assert.ok(read("assets/agents/gentle-ai-worker.md").includes(clause), "the worker must keep the differential check in scratch");
|
|
37
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
9
|
+
import { gentleAiDevBinaryRegistrationPath, gentleAiIsolationEnv } from "../lib/gentle-ai-binary.ts";
|
|
10
|
+
import { GENTLE_SHELL_CONFIG_HOME_ENV, setBundledInstallForTesting } from "../lib/gentle-shell-bundled.ts";
|
|
11
|
+
import { buildPiInvocation, migrateStandaloneConfig, standaloneConfigHome, type ResolvedHome } from "../lib/gentle-shell-launcher.ts";
|
|
12
|
+
import { createActiveProfileReader } from "../extensions/gentle-shell.ts";
|
|
13
|
+
import { readBannerConfig } from "../extensions/startup-banner.ts";
|
|
14
|
+
|
|
15
|
+
// #2201 (S1, S1a): the standalone launcher's isolated home keeps Gentle Shell's
|
|
16
|
+
// config next to its agent dir (~/.gentle-shell/gentle-ai), not in the shared
|
|
17
|
+
// ~/.pi/gentle-ai that a Pi install with gentle-pi also uses.
|
|
18
|
+
|
|
19
|
+
const posixOnly = { skip: process.platform === "win32" };
|
|
20
|
+
const binPath = fileURLToPath(new URL("../bin/gentle-shell.mjs", import.meta.url));
|
|
21
|
+
const MARKER = "GENTLE_SHELL_CONFIG_HOME";
|
|
22
|
+
|
|
23
|
+
function scratch(): string {
|
|
24
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "standalone-config-home-")));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function launch(home: ResolvedHome, baseEnv: Record<string, string | undefined> = {}, bundled = false) {
|
|
28
|
+
return buildPiInvocation({
|
|
29
|
+
runtime: { kind: "path", command: "fake-pi", args: [] },
|
|
30
|
+
home,
|
|
31
|
+
packageRoot: "/pkg",
|
|
32
|
+
declaration: undefined,
|
|
33
|
+
takeOver: false,
|
|
34
|
+
otherPackagePaths: [],
|
|
35
|
+
passthrough: [],
|
|
36
|
+
baseEnv,
|
|
37
|
+
homedir: "/os-home",
|
|
38
|
+
cwd: "/cwd",
|
|
39
|
+
bundled,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isolated(dir = "/os-home/.gentle-shell/agent"): { home: ResolvedHome } {
|
|
44
|
+
return { home: { mode: "isolated", dir, source: "default" } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("the marker is exported under its launcher-owned name, never GENTLE_PI_CONFIG_HOME", () => {
|
|
48
|
+
assert.equal(GENTLE_SHELL_CONFIG_HOME_ENV, MARKER);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("config home precedence: explicit GENTLE_PI_CONFIG_HOME > launcher marker > ~/.pi/gentle-ai; empty counts as unset", () => {
|
|
52
|
+
const home = scratch();
|
|
53
|
+
const env = (extra: Record<string, string>) => ({ HOME: home, USERPROFILE: home, ...extra });
|
|
54
|
+
const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
|
|
55
|
+
process.env.HOME = home;
|
|
56
|
+
process.env.USERPROFILE = home;
|
|
57
|
+
try {
|
|
58
|
+
assert.equal(gentlePiConfigHome(env({})), join(home, ".pi", "gentle-ai"));
|
|
59
|
+
assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai" })), "/shell/gentle-ai");
|
|
60
|
+
assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "/explicit" })), "/explicit");
|
|
61
|
+
assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "" })), "/shell/gentle-ai");
|
|
62
|
+
assert.equal(gentlePiConfigHome(env({ [MARKER]: "" })), join(home, ".pi", "gentle-ai"));
|
|
63
|
+
assert.equal(gentlePiConfigHome(env({ [MARKER]: "relative/gentle-ai" })), join(home, ".pi", "gentle-ai"), "a relative marker is ignored");
|
|
64
|
+
} finally {
|
|
65
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
66
|
+
if (value === undefined) delete process.env[key];
|
|
67
|
+
else process.env[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a bundled prefix still outranks the explicit override and the marker", (t) => {
|
|
73
|
+
const prefix = scratch();
|
|
74
|
+
setBundledInstallForTesting({ root: prefix });
|
|
75
|
+
t.after(() => setBundledInstallForTesting(undefined));
|
|
76
|
+
assert.equal(
|
|
77
|
+
gentlePiConfigHome({ GENTLE_SHELL_PREFIX: prefix, GENTLE_PI_CONFIG_HOME: "/explicit", [MARKER]: "/shell/gentle-ai" }),
|
|
78
|
+
join(prefix, "config", "gentle-ai"),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the banner and profile readers follow the same resolver, including an empty GENTLE_PI_CONFIG_HOME", async (t) => {
|
|
83
|
+
const root = scratch();
|
|
84
|
+
const config = join(root, "shell", "gentle-ai");
|
|
85
|
+
mkdirSync(config, { recursive: true });
|
|
86
|
+
writeFileSync(join(config, "banner.json"), JSON.stringify({ showTextLogo: false }));
|
|
87
|
+
writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "fast", profiles: { fast: {} } }));
|
|
88
|
+
const previous = { marker: process.env[MARKER], explicit: process.env.GENTLE_PI_CONFIG_HOME };
|
|
89
|
+
process.env[MARKER] = config;
|
|
90
|
+
process.env.GENTLE_PI_CONFIG_HOME = "";
|
|
91
|
+
t.after(() => {
|
|
92
|
+
if (previous.marker === undefined) delete process.env[MARKER];
|
|
93
|
+
else process.env[MARKER] = previous.marker;
|
|
94
|
+
if (previous.explicit === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
95
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previous.explicit;
|
|
96
|
+
});
|
|
97
|
+
assert.equal((await readBannerConfig()).showTextLogo, false);
|
|
98
|
+
assert.equal(createActiveProfileReader({ [MARKER]: config, GENTLE_PI_CONFIG_HOME: "" })(), "fast");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("the launcher gives Pi the marker only in its isolated home", () => {
|
|
102
|
+
const isolatedLaunch = launch(isolated().home);
|
|
103
|
+
assert.equal(isolatedLaunch.env[MARKER], "/os-home/.gentle-shell/gentle-ai");
|
|
104
|
+
assert.equal(isolatedLaunch.env.GENTLE_PI_CONFIG_HOME, undefined, "GENTLE_PI_CONFIG_HOME is never exported to Pi");
|
|
105
|
+
assert.equal(standaloneConfigHome(isolated().home), "/os-home/.gentle-shell/gentle-ai");
|
|
106
|
+
// A custom GENTLE_SHELL_HOME's parent may be anyone's folder (~/myagent ->
|
|
107
|
+
// ~/gentle-ai), so only a .gentle-shell/agent home gets its own config folder.
|
|
108
|
+
assert.equal(launch(isolated("/custom/shell/agent").home).env[MARKER], undefined, "no marker beside a custom GENTLE_SHELL_HOME");
|
|
109
|
+
assert.equal(standaloneConfigHome(isolated("/os-home/myagent").home), undefined, "never the generic ~/gentle-ai");
|
|
110
|
+
assert.equal(standaloneConfigHome(isolated("/data/.gentle-shell/agent").home), "/data/.gentle-shell/gentle-ai");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("--link, a custom --home and a bundled launch keep today's config home and drop an inherited marker", () => {
|
|
114
|
+
const inherited = { [MARKER]: "/outer/gentle-ai" };
|
|
115
|
+
const link = launch({ mode: "link", dir: "/os-home/.pi/agent", source: "flag" }, inherited);
|
|
116
|
+
const path = launch({ mode: "path", dir: "/custom/home", source: "flag" }, inherited);
|
|
117
|
+
const bundled = launch(isolated().home, inherited, true);
|
|
118
|
+
for (const invocation of [link, path, bundled]) assert.equal(MARKER in invocation.env, false);
|
|
119
|
+
assert.equal(standaloneConfigHome({ mode: "link", dir: "/os-home/.pi/agent", source: "flag" }), undefined);
|
|
120
|
+
assert.equal(standaloneConfigHome({ mode: "path", dir: "/custom/home", source: "flag" }), undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("Gentle AI processes started from a standalone isolated session get the bundled-style config pair", () => {
|
|
124
|
+
const env = gentleAiIsolationEnv({ PATH: "/bin", [MARKER]: "/os-home/.gentle-shell/gentle-ai" });
|
|
125
|
+
assert.equal(env.GENTLE_AI_PI_CONFIG_DIR, "/os-home/.gentle-shell/gentle-ai");
|
|
126
|
+
assert.equal(env.GENTLE_PI_CONFIG_HOME, "/os-home/.gentle-shell", "Gentle AI 4.0.0 reads it as the parent of its gentle-ai folder");
|
|
127
|
+
assert.equal(env.PATH, "/bin");
|
|
128
|
+
assert.equal(env.GENTLE_AI_HOME, undefined, "standalone keeps Gentle AI's own state where it was");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("without the marker, or with an explicit GENTLE_PI_CONFIG_HOME, Gentle AI's environment is unchanged", () => {
|
|
132
|
+
const plain = { PATH: "/bin" };
|
|
133
|
+
assert.equal(gentleAiIsolationEnv(plain), plain);
|
|
134
|
+
const explicit = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: "/explicit", [MARKER]: "/os-home/.gentle-shell/gentle-ai" };
|
|
135
|
+
assert.equal(gentleAiIsolationEnv(explicit), explicit);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("the main-channel dev-binary registration ignores the marker and an empty GENTLE_PI_CONFIG_HOME", () => {
|
|
139
|
+
assert.equal(
|
|
140
|
+
gentleAiDevBinaryRegistrationPath({ env: { [MARKER]: "/os-home/.gentle-shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "" }, home: "/os-home" }),
|
|
141
|
+
join("/os-home", ".pi", "gentle-ai", "dev-binary.json"),
|
|
142
|
+
);
|
|
143
|
+
assert.equal(gentleAiDevBinaryRegistrationPath({ env: { GENTLE_PI_CONFIG_HOME: "/explicit" }, home: "/os-home" }), join("/explicit", "dev-binary.json"));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// --- migration -----------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
// The same real filesystem bin/gentle-shell.mjs gives migrateStandaloneConfig.
|
|
149
|
+
const fs = {
|
|
150
|
+
kind: (path: string) => {
|
|
151
|
+
try {
|
|
152
|
+
const stat = lstatSync(path);
|
|
153
|
+
return stat.isDirectory() ? "dir" as const : stat.isFile() ? "file" as const : "other" as const;
|
|
154
|
+
} catch {
|
|
155
|
+
return "missing" as const;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
list: (path: string) => readdirSync(path),
|
|
159
|
+
mkdirPrivate: (path: string) => { mkdirSync(path, { recursive: true, mode: 0o700 }); },
|
|
160
|
+
copyNew: (from: string, to: string) => copyFileSync(from, to, constants.COPYFILE_EXCL),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
function migrationWorld() {
|
|
164
|
+
const root = scratch();
|
|
165
|
+
const source = join(root, "home", ".pi", "gentle-ai");
|
|
166
|
+
const target = join(root, "home", ".gentle-shell", "gentle-ai");
|
|
167
|
+
mkdirSync(source, { recursive: true });
|
|
168
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
169
|
+
writeFileSync(join(source, "banner.json"), "{\"showTextLogo\":false}\n");
|
|
170
|
+
writeFileSync(join(source, "profiles.json"), "{\"active\":\"fast\"}\n");
|
|
171
|
+
writeFileSync(join(source, "dev-binary.json"), "{}\n");
|
|
172
|
+
writeFileSync(join(source, "channel.json"), "{}\n");
|
|
173
|
+
mkdirSync(join(source, "main", "gentle-ai"), { recursive: true });
|
|
174
|
+
writeFileSync(join(source, "main", "gentle-ai", "marker"), "x");
|
|
175
|
+
return { root, source, target };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function snapshotTree(dir: string): Record<string, string> {
|
|
179
|
+
const out: Record<string, string> = {};
|
|
180
|
+
for (const name of readdirSync(dir)) {
|
|
181
|
+
const path = join(dir, name);
|
|
182
|
+
const stat = lstatSync(path);
|
|
183
|
+
if (stat.isDirectory()) for (const [key, value] of Object.entries(snapshotTree(path))) out[join(name, key)] = value;
|
|
184
|
+
else out[name] = stat.isSymbolicLink() ? "symlink" : `${stat.mode}:${stat.mtimeMs}:${readFileSync(path, "utf8")}`;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
test("migration copies the regular files once into a new 0700 folder and never changes the source", posixOnly, () => {
|
|
190
|
+
const w = migrationWorld();
|
|
191
|
+
symlinkSync(join(w.source, "banner.json"), join(w.source, "linked.json"));
|
|
192
|
+
const before = snapshotTree(w.source);
|
|
193
|
+
const result = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
|
|
194
|
+
assert.deepEqual(readdirSync(w.target).sort(), ["banner.json", "profiles.json"]);
|
|
195
|
+
assert.equal(readFileSync(join(w.target, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
|
|
196
|
+
assert.equal(statSync(w.target).mode & 0o777, 0o700);
|
|
197
|
+
assert.deepEqual(snapshotTree(w.source), before);
|
|
198
|
+
assert.equal(result.lines.length, 1);
|
|
199
|
+
assert.match(result.lines[0], new RegExp(`${w.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*${w.target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("migration skips the main-channel files and does nothing once the folder exists", posixOnly, () => {
|
|
203
|
+
const w = migrationWorld();
|
|
204
|
+
migrateStandaloneConfig({ source: w.source, target: w.target, fs });
|
|
205
|
+
assert.equal(existsSync(join(w.target, "dev-binary.json")), false);
|
|
206
|
+
assert.equal(existsSync(join(w.target, "channel.json")), false);
|
|
207
|
+
assert.equal(existsSync(join(w.target, "main")), false);
|
|
208
|
+
writeFileSync(join(w.target, "banner.json"), "{\"color\":\"cyan\"}\n");
|
|
209
|
+
writeFileSync(join(w.source, "vim.json"), "{}\n");
|
|
210
|
+
const again = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
|
|
211
|
+
assert.deepEqual(again.lines, []);
|
|
212
|
+
assert.equal(readFileSync(join(w.target, "banner.json"), "utf8"), "{\"color\":\"cyan\"}\n", "never overwrites");
|
|
213
|
+
assert.equal(existsSync(join(w.target, "vim.json")), false, "runs once");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("without ~/.pi/gentle-ai the migration only creates the empty 0700 folder, silently", posixOnly, () => {
|
|
217
|
+
const root = scratch();
|
|
218
|
+
const target = join(root, ".gentle-shell", "gentle-ai");
|
|
219
|
+
const result = migrateStandaloneConfig({ source: join(root, ".pi", "gentle-ai"), target, fs });
|
|
220
|
+
assert.deepEqual(result.lines, []);
|
|
221
|
+
assert.deepEqual(readdirSync(target), []);
|
|
222
|
+
assert.equal(statSync(target).mode & 0o777, 0o700);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a failed copy warns and keeps whatever was copied; it never throws", posixOnly, () => {
|
|
226
|
+
const w = migrationWorld();
|
|
227
|
+
chmodSync(join(w.source, "profiles.json"), 0o000);
|
|
228
|
+
if (spawnSync("cat", [join(w.source, "profiles.json")]).status === 0) return; // running as root: nothing is unreadable
|
|
229
|
+
const result = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
|
|
230
|
+
chmodSync(join(w.source, "profiles.json"), 0o644);
|
|
231
|
+
assert.deepEqual(readdirSync(w.target), ["banner.json"]);
|
|
232
|
+
assert.equal(result.lines.length, 1);
|
|
233
|
+
assert.match(result.lines[0], /could not copy/);
|
|
234
|
+
assert.match(result.lines[0], /profiles\.json/);
|
|
235
|
+
const blocked = scratch();
|
|
236
|
+
writeFileSync(join(blocked, "not-a-dir"), "");
|
|
237
|
+
const failed = migrateStandaloneConfig({ source: w.source, target: join(blocked, "not-a-dir", "gentle-ai"), fs });
|
|
238
|
+
assert.equal(failed.lines.length, 1);
|
|
239
|
+
assert.match(failed.lines[0], /could not/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// --- the real launcher -----------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function writeFakePi(path: string) {
|
|
245
|
+
writeFileSync(path, [
|
|
246
|
+
"#!/usr/bin/env node",
|
|
247
|
+
"const args = process.argv.slice(2);",
|
|
248
|
+
"if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
249
|
+
`console.log(JSON.stringify({ marker: process.env.${MARKER} ?? null, explicit: process.env.GENTLE_PI_CONFIG_HOME ?? null }));`,
|
|
250
|
+
"",
|
|
251
|
+
].join("\n"));
|
|
252
|
+
chmodSync(path, 0o755);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writeFakeGentleAi(path: string) {
|
|
256
|
+
writeFileSync(path, [
|
|
257
|
+
"#!/usr/bin/env node",
|
|
258
|
+
"import { mkdirSync, writeFileSync } from 'node:fs';",
|
|
259
|
+
"import { homedir } from 'node:os';",
|
|
260
|
+
"import { join } from 'node:path';",
|
|
261
|
+
"for (const dir of [process.env.GENTLE_AI_PI_CONFIG_DIR, join(homedir(), '.pi', 'gentle-ai')]) {",
|
|
262
|
+
" if (!dir) continue;",
|
|
263
|
+
" mkdirSync(dir, { recursive: true });",
|
|
264
|
+
" writeFileSync(join(dir, 'persona.json'), JSON.stringify({ mode: 'gentleman' }));",
|
|
265
|
+
"}",
|
|
266
|
+
"process.stdout.write(JSON.stringify({ dir: process.env.GENTLE_AI_PI_CONFIG_DIR ?? null, parent: process.env.GENTLE_PI_CONFIG_HOME ?? null }) + '\\n');",
|
|
267
|
+
"",
|
|
268
|
+
].join("\n"));
|
|
269
|
+
chmodSync(path, 0o755);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function launcherWorld() {
|
|
273
|
+
const root = scratch();
|
|
274
|
+
const home = join(root, "home");
|
|
275
|
+
mkdirSync(join(home, ".pi", "gentle-ai"), { recursive: true });
|
|
276
|
+
writeFileSync(join(home, ".pi", "gentle-ai", "banner.json"), "{\"showTextLogo\":false}\n");
|
|
277
|
+
const pi = join(root, "fake-pi.mjs");
|
|
278
|
+
writeFakePi(pi);
|
|
279
|
+
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, GENTLE_SHELL_PI: pi, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
|
|
280
|
+
const run = (args: string[], extra: NodeJS.ProcessEnv = {}) => spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env: { ...env, ...extra } });
|
|
281
|
+
return { root, home, env, run, shellConfig: join(home, ".gentle-shell", "gentle-ai") };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
test("a standalone isolated launch migrates ~/.pi/gentle-ai and points Pi at ~/.gentle-shell/gentle-ai", posixOnly, () => {
|
|
285
|
+
const w = launcherWorld();
|
|
286
|
+
const result = w.run(["list"]);
|
|
287
|
+
assert.equal(result.status, 0, result.stderr);
|
|
288
|
+
assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)!), { marker: w.shellConfig, explicit: null });
|
|
289
|
+
assert.equal(readFileSync(join(w.shellConfig, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
|
|
290
|
+
assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
|
|
291
|
+
assert.equal(result.stderr.split("\n").filter((line) => line.includes(w.shellConfig) && line.includes("copied")).length, 1);
|
|
292
|
+
const second = w.run(["list"]);
|
|
293
|
+
assert.doesNotMatch(second.stderr, /copied/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("--link launches keep ~/.pi/gentle-ai: no marker and no migration", posixOnly, () => {
|
|
297
|
+
const w = launcherWorld();
|
|
298
|
+
mkdirSync(join(w.home, ".pi", "agent"), { recursive: true });
|
|
299
|
+
const result = w.run(["--link", "list"], { [MARKER]: "/outer/gentle-ai" });
|
|
300
|
+
assert.equal(result.status, 0, result.stderr);
|
|
301
|
+
assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)!), { marker: null, explicit: null });
|
|
302
|
+
assert.equal(existsSync(w.shellConfig), false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("an explicit GENTLE_PI_CONFIG_HOME still wins: it reaches Pi and no migration runs", posixOnly, () => {
|
|
306
|
+
const w = launcherWorld();
|
|
307
|
+
const result = w.run(["list"], { GENTLE_PI_CONFIG_HOME: join(w.root, "explicit") });
|
|
308
|
+
assert.equal(result.status, 0, result.stderr);
|
|
309
|
+
assert.equal(JSON.parse(result.stdout.trim().split("\n").at(-1)!).explicit, join(w.root, "explicit"));
|
|
310
|
+
assert.equal(existsSync(w.shellConfig), false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("standalone setup gives Gentle AI the config pair and keeps both persona files unchanged", posixOnly, () => {
|
|
314
|
+
const w = launcherWorld();
|
|
315
|
+
const gentleAi = join(w.root, "fake-gentle-ai.mjs");
|
|
316
|
+
writeFakeGentleAi(gentleAi);
|
|
317
|
+
writeFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "{\"mode\":\"neutral\"}");
|
|
318
|
+
const result = w.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: gentleAi });
|
|
319
|
+
assert.equal(result.status, 0, result.stderr);
|
|
320
|
+
const seen = JSON.parse(result.stdout.split("\n").find((line) => line.includes("\"dir\""))!);
|
|
321
|
+
assert.deepEqual(seen, { dir: w.shellConfig, parent: join(w.home, ".gentle-shell") });
|
|
322
|
+
assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "utf8"), "{\"mode\":\"neutral\"}");
|
|
323
|
+
assert.equal(readFileSync(join(w.shellConfig, "persona.json"), "utf8"), "{\"mode\":\"neutral\"}", "the migrated copy is restored too");
|
|
324
|
+
});
|
|
@@ -462,19 +462,20 @@ test("delegated children never paint the startup banner", async (t) => {
|
|
|
462
462
|
|
|
463
463
|
// S9: the config home each of these reads resolves, per case. The OS home is
|
|
464
464
|
// a temporary folder in every case, so no case can reach the real one.
|
|
465
|
-
// "unset" and "empty"
|
|
466
|
-
//
|
|
465
|
+
// "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
|
|
466
|
+
// counts as unset (#2201; it used to name the current folder). "bundled" stands in for this package running from
|
|
467
467
|
// a bundled install (testing overlay) whose prefix the launcher passed; the
|
|
468
468
|
// user's own GENTLE_PI_CONFIG_HOME is then not this install's.
|
|
469
469
|
type ConfigHomeCase = "unset" | "empty" | "bundled";
|
|
470
470
|
function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
|
|
471
471
|
const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
|
|
472
|
-
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
|
|
472
|
+
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
|
|
473
473
|
const cwd = process.cwd();
|
|
474
474
|
process.env.HOME = join(base, "home");
|
|
475
475
|
process.env.USERPROFILE = join(base, "home");
|
|
476
476
|
delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
477
477
|
delete process.env.GENTLE_SHELL_PREFIX;
|
|
478
|
+
delete process.env.GENTLE_SHELL_CONFIG_HOME;
|
|
478
479
|
t.after(() => {
|
|
479
480
|
process.chdir(cwd);
|
|
480
481
|
setBundledInstallForTesting(undefined);
|
|
@@ -489,7 +490,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
|
|
|
489
490
|
if (kind === "empty") {
|
|
490
491
|
mkdirSync(join(base, "cwd"));
|
|
491
492
|
process.chdir(join(base, "cwd"));
|
|
492
|
-
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "
|
|
493
|
+
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
|
|
493
494
|
}
|
|
494
495
|
const prefix = join(base, "prefix");
|
|
495
496
|
mkdirSync(prefix);
|