gentle-pi 4.2.0 → 4.4.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 +340 -138
- 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/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +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 +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- 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 +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- 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-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/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 +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +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 +645 -21
- 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-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- 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 +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- 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-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/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 +70 -10
- 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 +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -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
|
@@ -589,6 +589,56 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
|
|
|
589
589
|
assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
|
|
590
590
|
});
|
|
591
591
|
|
|
592
|
+
test("a theme switch repaints rail sections whose digest did not change", (t) => {
|
|
593
|
+
const f = fixture();
|
|
594
|
+
// Pi hands extensions a live theme proxy and announces a switch only through
|
|
595
|
+
// ui.invalidate(), which never reaches the rail inside the layout tree.
|
|
596
|
+
let code = 31;
|
|
597
|
+
const live = { fg: (_color: string, text: string) => `\x1b[${code}m${text}\x1b[39m`, bold: (text: string) => text };
|
|
598
|
+
for (const key of ["footer", "agents", "todo"]) {
|
|
599
|
+
sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
|
|
600
|
+
digest: () => "stable",
|
|
601
|
+
render: () => [live.fg("accent", `${key} card`)],
|
|
602
|
+
invalidate() {},
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
t.after(installSidebar(f.tui, live));
|
|
606
|
+
|
|
607
|
+
const before = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
|
|
608
|
+
for (const key of ["footer", "agents", "todo"]) assert.ok(before.includes(`\x1b[31m${key} card`), `${key} paints with the first theme`);
|
|
609
|
+
|
|
610
|
+
code = 32;
|
|
611
|
+
const after = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
|
|
612
|
+
for (const key of ["footer", "agents", "todo"]) {
|
|
613
|
+
assert.ok(after.includes(`\x1b[32m${key} card`), `${key} repaints with the new theme`);
|
|
614
|
+
assert.ok(!after.includes(`\x1b[31m${key} card`), `${key} drops the old theme`);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("structural cache misses skip the whole-rail digest pass", (t) => {
|
|
619
|
+
const f = fixture();
|
|
620
|
+
let digests = 0;
|
|
621
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
|
|
622
|
+
digest: () => { digests++; return "stable"; },
|
|
623
|
+
render: () => [`Todo ${digests}`],
|
|
624
|
+
invalidate() {},
|
|
625
|
+
});
|
|
626
|
+
t.after(installSidebar(f.tui, theme));
|
|
627
|
+
|
|
628
|
+
// Every frame evaluates the digest exactly once: the whole-rail comparison
|
|
629
|
+
// reuses it for the section cache instead of calling each digest twice per
|
|
630
|
+
// prepare (issue #1681).
|
|
631
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
632
|
+
const prepared = digests;
|
|
633
|
+
assert.equal(prepared, 1, "preparing the rail evaluates the digest once");
|
|
634
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
635
|
+
assert.equal(digests, prepared + 1, "a cache hit compares the digest once");
|
|
636
|
+
renderLayoutFrame(f.root, 141, 20, () => {});
|
|
637
|
+
assert.equal(digests, prepared + 2, "a structural miss re-prepares with a single digest pass");
|
|
638
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
639
|
+
assert.equal(digests, prepared + 3, "returning to the cached structure stays on a single digest pass");
|
|
640
|
+
});
|
|
641
|
+
|
|
592
642
|
test("reuses final sidebar presentation until a relevant invalidation", (t) => {
|
|
593
643
|
const f = fixture();
|
|
594
644
|
sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
|
|
@@ -36,7 +36,7 @@ for (const [name, text] of Object.entries({ registeredDescription: background.de
|
|
|
36
36
|
test("inline still means the same parent, and the public background API stays asynchronous", () => {
|
|
37
37
|
assert.ok(core.includes("Small path: inline (parent; `bash_background` policy);"));
|
|
38
38
|
assert.ok(guide.includes("Inline means the same parent, not necessarily synchronous bash"));
|
|
39
|
-
assert.ok(core.includes("run the focused test and the suite inline
|
|
39
|
+
assert.ok(core.includes("run the focused test and the suite inline in one command"));
|
|
40
40
|
assert.match(background.description, /return a job id at once/);
|
|
41
41
|
assert.match(background.description, /notified once, automatically/);
|
|
42
42
|
assert.match(background.description, /do not poll, sleep, or delegate a subagent to wait/);
|
|
@@ -25,7 +25,9 @@ const PREFIXED_NAMES: Record<string, string> = {
|
|
|
25
25
|
"work-unit-commits": "gentle-ai-work-unit-commits",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// gentle-pi-owned skills that gentle-ai does not install keep their own
|
|
29
|
+
// gentle- namespace, so they need no gentle-ai- prefix to avoid a collision.
|
|
30
|
+
const UNPREFIXED_DIRS = ["gentle-ai", "design"];
|
|
29
31
|
|
|
30
32
|
for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
|
|
31
33
|
test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
|
|
@@ -16,7 +16,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import { syncBuiltinESMExports } from "node:module";
|
|
19
|
-
import { join } from "node:path";
|
|
19
|
+
import { join, sep } from "node:path";
|
|
20
20
|
const root = ${JSON.stringify(root)};
|
|
21
21
|
const cwd = join(root, "workspace");
|
|
22
22
|
const home = join(root, "home");
|
|
@@ -52,6 +52,8 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
52
52
|
});
|
|
53
53
|
const ctx = { cwd, hasUI: true, ui: { notify: (message, level) => notices.push({ message, level }) } };
|
|
54
54
|
const waitFor = async (predicate) => {
|
|
55
|
+
// Must stay well below the parent's spawnSync timeout so a missed
|
|
56
|
+
// outcome fails with this assertion instead of an opaque ETIMEDOUT.
|
|
55
57
|
const deadline = Date.now() + 5000;
|
|
56
58
|
while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
57
59
|
assert.ok(predicate(), "timed out waiting for watcher outcome");
|
|
@@ -66,18 +68,28 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
66
68
|
if (code === "ENOENT" && process.platform !== "darwin" && process.platform !== "win32") {
|
|
67
69
|
// Force the transient missing-directory race at Node's real recursive
|
|
68
70
|
// readdir boundary, after watcher creation, not in our refresh callback.
|
|
71
|
+
// Older Node rescans the parent folder; Node 24.22+ resolves new files
|
|
72
|
+
// via lstat and only readdirs a newly added directory, so trigger with
|
|
73
|
+
// a directory and fail whichever readdir lands under assets.
|
|
69
74
|
const nativeReaddir = fs.readdirSync;
|
|
70
75
|
let reachedRescan = false;
|
|
71
76
|
fs.readdirSync = function (path, options) {
|
|
72
|
-
if (path === assets) {
|
|
77
|
+
if (typeof path === "string" && (path === assets || path.startsWith(assets + sep))) {
|
|
73
78
|
fs.readdirSync = nativeReaddir;
|
|
74
79
|
reachedRescan = true;
|
|
75
80
|
throw Object.assign(new Error("ENOENT: transient skill assets disappeared"), { code, syscall: "scandir", path });
|
|
76
81
|
}
|
|
77
82
|
return nativeReaddir(path, options);
|
|
78
83
|
};
|
|
79
|
-
fs.
|
|
80
|
-
|
|
84
|
+
fs.mkdirSync(join(assets, "trigger"));
|
|
85
|
+
// The rescan race needs live recursive-watch events. Where the OS
|
|
86
|
+
// cannot deliver them (an exhausted inotify budget, some WSL2
|
|
87
|
+
// setups), waiting the full waitFor budget is pointless: settle
|
|
88
|
+
// briefly for the natural race, then exercise the public error
|
|
89
|
+
// listener directly, as the manual fallback below always did.
|
|
90
|
+
const raceDeadline = Date.now() + 2000;
|
|
91
|
+
while (!reachedRescan && Date.now() < raceDeadline) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
92
|
+
fs.readdirSync = nativeReaddir;
|
|
81
93
|
// Newer Node versions swallow rescan ENOENT themselves. Still
|
|
82
94
|
// exercise our public error listener without requiring Node's bug.
|
|
83
95
|
if (!notices.some(({ message, level }) => level === "warning" && message.includes(code))) {
|
|
@@ -112,7 +124,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
112
124
|
timeout: 15_000,
|
|
113
125
|
env: { ...process.env, GIT_CEILING_DIRECTORIES: root },
|
|
114
126
|
});
|
|
115
|
-
assert.equal(child.error, undefined, child.error?.message);
|
|
127
|
+
assert.equal(child.error, undefined, `${child.error?.message}\n${child.stderr}`);
|
|
116
128
|
assert.equal(child.status, 0, child.stderr);
|
|
117
129
|
assert.match(child.stdout, new RegExp(`SURVIVED ${code}`));
|
|
118
130
|
});
|
|
@@ -120,28 +120,61 @@ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", async
|
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
test("findSkillFiles scans
|
|
123
|
+
test("findSkillFiles scans flat skills and category-nested skills without descending further", async () => {
|
|
124
124
|
const root = join(tmpdir(), `gentle-pi-shallow-${Date.now()}`);
|
|
125
125
|
const skillPath = join(root, "docs", "SKILL.md");
|
|
126
126
|
const nestedSkillPath = join(root, "fixtures", "nested", "SKILL.md");
|
|
127
|
+
const deeperSkillPath = join(root, "fixtures", "nested", "deeper", "SKILL.md");
|
|
127
128
|
mkdirSync(dirname(skillPath), { recursive: true });
|
|
128
129
|
mkdirSync(dirname(nestedSkillPath), { recursive: true });
|
|
130
|
+
mkdirSync(dirname(deeperSkillPath), { recursive: true });
|
|
129
131
|
writeFileSync(skillPath, "---\nname: docs\ndescription: Docs.\n---\n");
|
|
130
132
|
writeFileSync(nestedSkillPath, "---\nname: nested\ndescription: Nested fixture.\n---\n");
|
|
133
|
+
writeFileSync(deeperSkillPath, "---\nname: deeper\ndescription: Deeper fixture.\n---\n");
|
|
131
134
|
|
|
132
|
-
assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
|
|
135
|
+
assert.deepEqual(await __testing.findSkillFiles(root), [skillPath, nestedSkillPath]);
|
|
133
136
|
});
|
|
134
137
|
|
|
135
|
-
test("findSkillFiles
|
|
138
|
+
test("findSkillFiles keeps direct skills ahead of alphabetically earlier nested collisions", async () => {
|
|
139
|
+
const cwd = join(tmpdir(), `gentle-pi-precedence-${Date.now()}`);
|
|
140
|
+
const directSkillPath = join(cwd, "skills", "zeta", "SKILL.md");
|
|
141
|
+
const nestedSkillPath = join(cwd, "skills", "alpha", "duplicate", "SKILL.md");
|
|
142
|
+
mkdirSync(dirname(directSkillPath), { recursive: true });
|
|
143
|
+
mkdirSync(dirname(nestedSkillPath), { recursive: true });
|
|
144
|
+
writeFileSync(directSkillPath, "---\nname: duplicate\ndescription: Direct skill.\n---\n");
|
|
145
|
+
writeFileSync(nestedSkillPath, "---\nname: duplicate\ndescription: Nested skill.\n---\n");
|
|
146
|
+
|
|
147
|
+
await __testing.regenerateRegistry(cwd, true);
|
|
148
|
+
const registry = readFileSync(join(cwd, ".atl", "skill-registry.md"), "utf8");
|
|
149
|
+
assert.match(registry, new RegExp(directSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
150
|
+
assert.doesNotMatch(registry, new RegExp(nestedSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("findSkillFiles does not descend into excluded categories", async () => {
|
|
154
|
+
const root = join(tmpdir(), `gentle-pi-excluded-category-${Date.now()}`);
|
|
155
|
+
for (const category of ["_shared", "skill-registry", "sdd-fixtures"]) {
|
|
156
|
+
const nestedSkillPath = join(root, category, "hidden", "SKILL.md");
|
|
157
|
+
mkdirSync(dirname(nestedSkillPath), { recursive: true });
|
|
158
|
+
writeFileSync(nestedSkillPath, "---\nname: hidden\ndescription: Hidden skill.\n---\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(await __testing.findSkillFiles(root), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("findSkillFiles follows direct symlinked skills but not symlinked categories", async (t) => {
|
|
136
165
|
const root = join(tmpdir(), `gentle-pi-symlink-root-${Date.now()}`);
|
|
137
166
|
const realSkillDir = join(tmpdir(), `gentle-pi-symlink-target-${Date.now()}`);
|
|
167
|
+
const categoryTarget = join(tmpdir(), `gentle-pi-symlink-category-${Date.now()}`);
|
|
138
168
|
const linkedSkillDir = join(root, "linked");
|
|
139
169
|
const skillPath = join(linkedSkillDir, "SKILL.md");
|
|
140
170
|
mkdirSync(root, { recursive: true });
|
|
141
171
|
mkdirSync(realSkillDir, { recursive: true });
|
|
172
|
+
mkdirSync(join(categoryTarget, "nested"), { recursive: true });
|
|
142
173
|
writeFileSync(join(realSkillDir, "SKILL.md"), "---\nname: linked\ndescription: Linked skill.\n---\n");
|
|
174
|
+
writeFileSync(join(categoryTarget, "nested", "SKILL.md"), "---\nname: nested\ndescription: Nested skill.\n---\n");
|
|
143
175
|
try {
|
|
144
176
|
symlinkSync(realSkillDir, linkedSkillDir, "dir");
|
|
177
|
+
symlinkSync(categoryTarget, join(root, "category"), "dir");
|
|
145
178
|
} catch (error) {
|
|
146
179
|
t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
147
180
|
return;
|
|
@@ -150,6 +183,12 @@ test("findSkillFiles follows symlinked skill directories", async (t) => {
|
|
|
150
183
|
assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
|
|
151
184
|
});
|
|
152
185
|
|
|
186
|
+
test("only missing skill-file errors allow category scanning", () => {
|
|
187
|
+
assert.equal(__testing.isMissingSkillFileError({ code: "ENOENT" }), true);
|
|
188
|
+
assert.equal(__testing.isMissingSkillFileError({ code: "ENOTDIR" }), true);
|
|
189
|
+
assert.equal(__testing.isMissingSkillFileError({ code: "EACCES" }), false);
|
|
190
|
+
});
|
|
191
|
+
|
|
153
192
|
test("skill registry watchers close on shutdown", async () => {
|
|
154
193
|
const root = join(tmpdir(), `gentle-pi-watchers-${Date.now()}`);
|
|
155
194
|
const skillPath = join(root, "skills", "docs", "SKILL.md");
|
|
@@ -453,6 +492,11 @@ test("registered watcher protects registry tracked after a cache hit", async (t)
|
|
|
453
492
|
while (runtime.notices.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
454
493
|
assert.equal(readFileSync(fixture.registry, "utf8"), before);
|
|
455
494
|
assert.ok(runtime.notices.some(({ message, level }) => level === "warning" && message.includes("/skill-registry:refresh")));
|
|
495
|
+
// The poll re-asks the same tracked-target state every interval: the
|
|
496
|
+
// warning stays a one-shot per unchanged state instead of repeating.
|
|
497
|
+
runtime.notices.length = 0;
|
|
498
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
499
|
+
assert.deepEqual(runtime.notices, []);
|
|
456
500
|
assert.ok(!runtime.notices.some(({ message }) => message.includes("refreshed")));
|
|
457
501
|
});
|
|
458
502
|
|
|
@@ -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);
|