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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import { rejectUndocumentedWriterLaunch } from "../lib/bounded-writer-admission.ts";
|
|
9
|
+
import { CONTEXT_BACKSTOP_TOKENS_ENV } from "../lib/context-backstop.ts";
|
|
10
|
+
|
|
11
|
+
// Pain-point iteration S16. Prompt rules alone did not stop small-task delegation: on a two-feature request the
|
|
12
|
+
// orchestrator still started two writers for ~35-line units (USD 0.36 vs 0.20 inline). A generic writer launch is
|
|
13
|
+
// now admitted only for a task of an existing ODD feature document, or when the parent's measured context reaches
|
|
14
|
+
// the context backstop.
|
|
15
|
+
|
|
16
|
+
const surfaces = "\n\n## Allowed edit surfaces\nsrc/budget.ts\n";
|
|
17
|
+
|
|
18
|
+
function workspace(withDocument: boolean): string {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "writer-admission-"));
|
|
20
|
+
if (withDocument) {
|
|
21
|
+
mkdirSync(join(root, "odd", "tasks"), { recursive: true });
|
|
22
|
+
writeFileSync(join(root, "odd", "tasks", "ledger.md"), "# Ledger\n");
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("S16: a writer launch without a feature-document task is rejected with an inline instruction", (t) => {
|
|
28
|
+
const root = workspace(false);
|
|
29
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
30
|
+
const denied = rejectUndocumentedWriterLaunch({ agent: "gentle-ai-worker", task: `Implement annual budgets.${surfaces}` }, { root, contextUsage: { tokens: 20_000, contextWindow: 400_000 } });
|
|
31
|
+
assert.ok(denied?.block, "the launch must be blocked");
|
|
32
|
+
assert.match(denied.reason, /write this unit inline yourself/);
|
|
33
|
+
assert.match(denied.reason, /do not retry/);
|
|
34
|
+
assert.match(denied.reason, /never create a feature document only to launch writers/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("S16: a writer naming a task of an existing feature document is admitted", (t) => {
|
|
38
|
+
const root = workspace(true);
|
|
39
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
40
|
+
const input = { agent: "gentle-ai-worker", task: `Spec: odd/tasks/ledger.md, T2, S3-S4. Read until ## Log.${surfaces}` };
|
|
41
|
+
assert.equal(rejectUndocumentedWriterLaunch(input, { root, contextUsage: { tokens: 20_000, contextWindow: 400_000 } }), undefined);
|
|
42
|
+
const viaContext = { agent: "worker", task: `Implement it.${surfaces}`, context: "Spec: odd/tasks/ledger.md, T1" };
|
|
43
|
+
assert.equal(rejectUndocumentedWriterLaunch(viaContext, { root, contextUsage: { tokens: 20_000, contextWindow: 400_000 } }), undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("S16: a Spec reference to a missing document, or one escaping the root, is rejected", (t) => {
|
|
47
|
+
const root = workspace(false);
|
|
48
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
49
|
+
for (const spec of ["odd/tasks/ledger.md", "odd/tasks/../../etc/passwd.md"]) {
|
|
50
|
+
const denied = rejectUndocumentedWriterLaunch({ agent: "gentle-ai-worker", task: `Spec: ${spec}, T1${surfaces}` }, { root, contextUsage: { tokens: 20_000, contextWindow: 400_000 } });
|
|
51
|
+
assert.ok(denied?.block, `${spec} must not admit a writer`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("S16: the measured context backstop admits a writer without a document", (t) => {
|
|
56
|
+
const root = workspace(false);
|
|
57
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
58
|
+
const input = { agent: "gentle-ai-worker", task: `Implement annual budgets.${surfaces}` };
|
|
59
|
+
assert.equal(rejectUndocumentedWriterLaunch(input, { root, contextUsage: { tokens: 150_000, contextWindow: 1_000_000 } }), undefined);
|
|
60
|
+
assert.ok(rejectUndocumentedWriterLaunch(input, { root, contextUsage: { tokens: 149_999, contextWindow: 1_000_000 } })?.block, "below the backstop does not admit");
|
|
61
|
+
assert.ok(rejectUndocumentedWriterLaunch(input, { root, contextUsage: { tokens: null, contextWindow: 400_000 } })?.block, "unknown context usage does not admit");
|
|
62
|
+
assert.ok(rejectUndocumentedWriterLaunch(input, { root, contextUsage: undefined })?.block, "missing context usage does not admit");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Harness roadmap R1: the admission uses the same threshold as the runtime notice, min(absolute cap, fraction x
|
|
66
|
+
// window), so a small context window reaches the backstop at 15% of its size instead of a fixed 150,000 tokens.
|
|
67
|
+
test("S16: the admission backstop scales with the measured context window and honors the environment", (t) => {
|
|
68
|
+
const root = workspace(false);
|
|
69
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
70
|
+
const input = { agent: "gentle-ai-worker", task: `Implement annual budgets.${surfaces}` };
|
|
71
|
+
const decide = (tokens: number, contextWindow: number, env?: NodeJS.ProcessEnv) => rejectUndocumentedWriterLaunch(input, { root, contextUsage: { tokens, contextWindow }, env })?.block ?? false;
|
|
72
|
+
assert.equal(decide(19_200, 128_000), false, "15% of a small window admits");
|
|
73
|
+
assert.equal(decide(19_199, 128_000), true);
|
|
74
|
+
assert.equal(decide(150_000, 1_000_000), false, "a large window keeps the absolute cap");
|
|
75
|
+
assert.equal(decide(149_999, 1_000_000), true);
|
|
76
|
+
assert.equal(decide(120_000, 1_000_000), true);
|
|
77
|
+
assert.equal(decide(120_000, 1_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "100000" }), false, "the cap is overridable");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("S16: previous behavior holds for non-writers and Judgment Day fix agents", (t) => {
|
|
81
|
+
const root = workspace(false);
|
|
82
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
83
|
+
for (const agent of ["gentle-ai-explore", "gentle-ai-verify", "jd-fix-agent"]) {
|
|
84
|
+
assert.equal(rejectUndocumentedWriterLaunch({ agent, task: "Read src/budget.ts" }, { root, contextUsage: { tokens: 0, contextWindow: 400_000 } }), undefined, agent);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
type ToolCallHandler = (event: { toolName: string; input: unknown }, ctx: ExtensionContext) => Promise<{ block?: boolean; reason?: string } | undefined>;
|
|
89
|
+
|
|
90
|
+
function launchThroughHook(input: Record<string, unknown>, cwd: string, tokens: number, processEnv: NodeJS.ProcessEnv = {}, contextWindow = 272_000) {
|
|
91
|
+
const handlers = new Map<string, ToolCallHandler>();
|
|
92
|
+
const pi = { on(name: string, handler: ToolCallHandler) { handlers.set(name, handler); }, events: { emit() {} }, registerCommand() {}, registerTool() {} } as unknown as ExtensionAPI;
|
|
93
|
+
createGentleAiExtension({ nativeReviewCli: null, processEnv })(pi);
|
|
94
|
+
return handlers.get("tool_call")!({ toolName: "subagent_run", input }, {
|
|
95
|
+
cwd, hasUI: false, ui: { confirm: async () => true },
|
|
96
|
+
getContextUsage: () => ({ tokens, contextWindow, percent: Math.round((tokens / contextWindow) * 100) }),
|
|
97
|
+
} as unknown as ExtensionContext);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("S16: the orchestrator tool_call hook blocks an undocumented writer and admits a documented one", async (t) => {
|
|
101
|
+
const bare = workspace(false);
|
|
102
|
+
const documented = workspace(true);
|
|
103
|
+
t.after(() => { rmSync(bare, { recursive: true, force: true }); rmSync(documented, { recursive: true, force: true }); });
|
|
104
|
+
const blocked = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", task: `Implement annual budgets.${surfaces}` }, bare, 20_000);
|
|
105
|
+
assert.equal(blocked?.block, true);
|
|
106
|
+
assert.match(blocked?.reason ?? "", /write this unit inline yourself/);
|
|
107
|
+
const admitted = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", task: `Spec: odd/tasks/ledger.md, T1. Read until ## Log.${surfaces}` }, documented, 20_000);
|
|
108
|
+
assert.equal(admitted?.block, undefined);
|
|
109
|
+
const explorer = await launchThroughHook({ agent: "gentle-ai-explore", mode: "task", task: "Map src/budget.ts" }, bare, 20_000);
|
|
110
|
+
assert.equal(explorer?.block, undefined, "read-only subagents are unaffected");
|
|
111
|
+
// The hook passes the measured window, not only the tokens: 19.2k of a 128k window is at the backstop.
|
|
112
|
+
const smallWindow = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", task: `Implement annual budgets.${surfaces}` }, bare, 19_200, {}, 128_000);
|
|
113
|
+
assert.equal(smallWindow?.block, undefined, "15% of the measured window admits");
|
|
114
|
+
const envCap = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", task: `Implement annual budgets.${surfaces}` }, bare, 19_200, { GENTLE_PI_CONTEXT_BACKSTOP_FRACTION: "0.9" }, 128_000);
|
|
115
|
+
assert.equal(envCap?.block, true, "the hook reads the session environment");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Verifier B1: the delegation card tells the parent to write `Spec: odd/tasks/<feature>.md (read until "## Log").
|
|
119
|
+
// Do T#; S#.`, and a live x6 run wrote `Spec: ... .md T1 S1;`. Both name a document task and must be admitted.
|
|
120
|
+
test("S16: the delegation card format and a comma-free task reference are admitted", (t) => {
|
|
121
|
+
const root = workspace(true);
|
|
122
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
123
|
+
for (const line of [
|
|
124
|
+
'Spec: odd/tasks/ledger.md (read until "## Log"). Do T2; S3.',
|
|
125
|
+
"Spec: odd/tasks/ledger.md T1 S1; read through Tasks before Log.",
|
|
126
|
+
"- Spec: `odd/tasks/ledger.md`, T4",
|
|
127
|
+
]) {
|
|
128
|
+
assert.equal(rejectUndocumentedWriterLaunch({ agent: "gentle-ai-worker", task: `${line}\nImplement it.${surfaces}` }, { root, contextUsage: { tokens: 0, contextWindow: 400_000 } }), undefined, line);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("S16: a Spec mention inside prose, outside odd/tasks, or through a symlink leaving the root is rejected", (t) => {
|
|
133
|
+
const root = workspace(true);
|
|
134
|
+
const outside = mkdtempSync(join(tmpdir(), "writer-admission-outside-"));
|
|
135
|
+
t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); });
|
|
136
|
+
writeFileSync(join(root, "README.md"), "# readme\n");
|
|
137
|
+
writeFileSync(join(outside, "elsewhere.md"), "# elsewhere\n");
|
|
138
|
+
symlinkSync(join(outside, "elsewhere.md"), join(root, "odd", "tasks", "link-out.md"));
|
|
139
|
+
for (const task of [
|
|
140
|
+
"Do not use Spec: odd/tasks/ledger.md, T1 here.",
|
|
141
|
+
"Spec: odd/tasks/../../README.md, T1",
|
|
142
|
+
"Spec: odd/tasks/link-out.md, T1",
|
|
143
|
+
]) {
|
|
144
|
+
assert.ok(rejectUndocumentedWriterLaunch({ agent: "gentle-ai-worker", task: `${task}${surfaces}` }, { root, contextUsage: { tokens: 0, contextWindow: 400_000 } })?.block, task);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("S16: child sessions are exempt and an empty workspace_root falls back to the session cwd", async (t) => {
|
|
149
|
+
const bare = workspace(false);
|
|
150
|
+
const documented = workspace(true);
|
|
151
|
+
t.after(() => { rmSync(bare, { recursive: true, force: true }); rmSync(documented, { recursive: true, force: true }); });
|
|
152
|
+
const child = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", task: `Implement it.${surfaces}` }, bare, 0, { GENTLE_PI_AGENTS_CHILD: "1" });
|
|
153
|
+
assert.equal(child?.block, undefined, "a child session is not gated");
|
|
154
|
+
const emptyRoot = await launchThroughHook({ agent: "gentle-ai-worker", mode: "background", workspace_root: "", task: `Spec: odd/tasks/ledger.md, T1${surfaces}` }, documented, 0);
|
|
155
|
+
assert.equal(emptyRoot?.block, undefined, "an empty workspace_root means the session cwd");
|
|
156
|
+
});
|
|
@@ -116,12 +116,15 @@ test("Customize selects Auto for this session and saves either elevated default
|
|
|
116
116
|
view.handleInput("j");view.handleInput("j");view.render(140);
|
|
117
117
|
assert.match(text(view),/Session permissions: AUTO/);
|
|
118
118
|
await h.apply("\r");assert.equal(await h.controller().mode(h.ctx),"auto");
|
|
119
|
-
view.handleInput("j");view.handleInput("j");view.handleInput("j");view.render(140);
|
|
119
|
+
view.handleInput("j");view.handleInput("j");view.handleInput("j");view.handleInput("j");view.render(140);
|
|
120
120
|
assert.match(text(view),/Default permissions: AUTO/);
|
|
121
121
|
await h.apply("\r");assert.equal(h.controller().defaultMode(),"auto");assert.equal(await h.controller().mode(h.ctx),"auto");
|
|
122
122
|
view.handleInput("j");view.render(140);await h.apply("\r");
|
|
123
123
|
assert.equal(h.controller().defaultMode(),"yolo");assert.equal(await h.controller().mode(h.ctx),"auto");
|
|
124
124
|
assert.equal(JSON.parse(readFileSync(join(h.home,"permission-mode.json"),"utf8")).mode,"yolo");
|
|
125
|
+
view.handleInput("j");view.render(140);await h.apply("\r");
|
|
126
|
+
assert.equal(h.controller().defaultMode(),"super-yolo");assert.equal(await h.controller().mode(h.ctx),"auto");
|
|
127
|
+
assert.equal(JSON.parse(readFileSync(join(h.home,"permission-mode.json"),"utf8")).mode,"super-yolo");
|
|
125
128
|
for(const width of [1,20,60,140])assert.ok(view.render(width).every(line=>visibleWidth(line)<=width));
|
|
126
129
|
open.close();await open.result;
|
|
127
130
|
});
|
|
@@ -269,3 +272,4 @@ test("real Theme and existing component obey narrow dimensions with invisible co
|
|
|
269
272
|
assert.equal(await h.controller().active(h.ctx), false);
|
|
270
273
|
open.close(); await open.result;
|
|
271
274
|
});
|
|
275
|
+
|
package/tests/yolo-mode.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { YOLO_STATUS_TEXT } from "../lib/yolo-session-policy.ts";
|
|
|
11
11
|
const ON_TEXT = "🚀 YOLO ON 🔥 — destructive confirmations remain";
|
|
12
12
|
const USAGE = "Use /gentle:yolo enable|disable|status. State unchanged.";
|
|
13
13
|
const STALE_MENU = "YOLO unchanged — the session changed while the menu was open.";
|
|
14
|
-
const menuTitle = (state: "ON" | "OFF") => `🚀 Gentle YOLO 🔥 —
|
|
14
|
+
const menuTitle = (state: "ON" | "OFF") => `🚀 Gentle YOLO 🔥 — standing permission (current: ${state})`;
|
|
15
15
|
|
|
16
16
|
// An empty env would resolve the developer's real ~/.pi/gentle-ai and inherit its persisted mode default.
|
|
17
17
|
const isolatedConfigHomes: string[] = [];
|
|
@@ -203,6 +203,24 @@ test("active YOLO waives only ordinary push after full destructive/guard evaluat
|
|
|
203
203
|
await h.command("disable"); assert.ok(await call("git push origin main"));
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
test("active YOLO waives a push with harmless redirections and a read-only filter; Normal and children still ask", async () => {
|
|
207
|
+
const command = "git push -u origin feat/x 2>&1 | tail -5";
|
|
208
|
+
const call = (h: ReturnType<typeof harness>, input: string) => h.emit("tool_call", { toolName: "bash", input: { command: input } });
|
|
209
|
+
const h = harness();
|
|
210
|
+
assert.ok(await call(h, "git push origin main 2>&1"), "Normal mode still confirms");
|
|
211
|
+
assert.equal(h.confirmations(), 1);
|
|
212
|
+
await h.command("enable");
|
|
213
|
+
assert.equal(await call(h, command), undefined);
|
|
214
|
+
assert.equal(await call(h, "git push origin main 2>&1"), undefined);
|
|
215
|
+
assert.equal(h.confirmations(), 1, "waived pushes never reach the confirmation dialog");
|
|
216
|
+
for (const asks of ["git push origin main | sh", "git push origin main > out.txt", "git push origin main 2>&1 | tee out"]) {
|
|
217
|
+
assert.ok(await call(h, asks), asks);
|
|
218
|
+
}
|
|
219
|
+
const child = harness({ GENTLE_PI_AGENTS_CHILD: "1" });
|
|
220
|
+
await child.command("enable");
|
|
221
|
+
assert.ok(await call(child, command), "children never receive the waiver");
|
|
222
|
+
});
|
|
223
|
+
|
|
206
224
|
test("active command path preserves configured push confirmations and blocks, including legacy env override", async () => {
|
|
207
225
|
const configHome = mkdtempSync(join(tmpdir(), "gentle-yolo-config-"));
|
|
208
226
|
const previousHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { registerYoloSessionPolicy } from "../lib/yolo-session-policy.ts";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
// Identity is re-captured for every tool admission: freshness is a security
|
|
10
|
+
// invariant, so cross-admission reuse is never allowed. Within one admission
|
|
11
|
+
// the redundant mode() lookups share at most ONE capture, keyed by that
|
|
12
|
+
// admission's tool call id; a different or absent id re-captures. Generation
|
|
13
|
+
// bumps and cwd changes still invalidate a shared slot.
|
|
14
|
+
|
|
15
|
+
const isolatedConfigHomes: string[] = [];
|
|
16
|
+
process.once("exit", () => { for (const home of isolatedConfigHomes) rmSync(home, { recursive: true, force: true }); });
|
|
17
|
+
|
|
18
|
+
function harness() {
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), "yolo-capture-config-"));
|
|
20
|
+
isolatedConfigHomes.push(home);
|
|
21
|
+
let resolveCalls = 0, statusCalls = 0, widgetCalls = 0;
|
|
22
|
+
const commands = new Map<string, { handler: (args: string, ctx: ExtensionContext) => Promise<void> }>();
|
|
23
|
+
const pi = {
|
|
24
|
+
registerCommand: (name: string, command: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => commands.set(name, command),
|
|
25
|
+
events: { on: () => () => {} },
|
|
26
|
+
} as unknown as ExtensionAPI;
|
|
27
|
+
const controller = registerYoloSessionPolicy(pi, { GENTLE_PI_CONFIG_HOME: home }, {
|
|
28
|
+
// Test seam: observe identity resolution without spawning Git.
|
|
29
|
+
resolveIdentity: async () => { resolveCalls += 1; return undefined; },
|
|
30
|
+
});
|
|
31
|
+
const ctx = {
|
|
32
|
+
cwd: process.cwd(), mode: "tui", hasUI: true,
|
|
33
|
+
sessionManager: { getSessionId: () => "capture-test" },
|
|
34
|
+
ui: {
|
|
35
|
+
notify() {},
|
|
36
|
+
setStatus: () => { statusCalls += 1; },
|
|
37
|
+
setWidget: () => { widgetCalls += 1; },
|
|
38
|
+
},
|
|
39
|
+
} as unknown as ExtensionContext;
|
|
40
|
+
return {
|
|
41
|
+
controller, ctx, commands,
|
|
42
|
+
resolveCalls: () => resolveCalls,
|
|
43
|
+
uiCalls: () => ({ status: statusCalls, widget: widgetCalls }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("lookups of one tool admission share one capture; a second admission re-captures", async () => {
|
|
48
|
+
const h = harness();
|
|
49
|
+
assert.equal(await h.controller.mode(h.ctx, { toolCallId: "call-1" }), "normal");
|
|
50
|
+
assert.equal(h.resolveCalls(), 1, "the first admission captures");
|
|
51
|
+
await h.controller.mode(h.ctx, { toolCallId: "call-1" });
|
|
52
|
+
assert.equal(h.resolveCalls(), 1, "lookups of one tool call share one capture");
|
|
53
|
+
await h.controller.mode(h.ctx, { toolCallId: "call-2" });
|
|
54
|
+
assert.equal(h.resolveCalls(), 2, "a second admission re-captures even with unchanged generation and cwd");
|
|
55
|
+
await h.controller.mode(h.ctx);
|
|
56
|
+
assert.equal(h.resolveCalls(), 3, "an absent admission id keeps the uncached capture path");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The tool admission key never outranks the generation: a bump mid-admission
|
|
60
|
+
// (a reselected mode, a revocation) must force a fresh capture, never replay.
|
|
61
|
+
test("a generation bump invalidates a shared admission slot", async () => {
|
|
62
|
+
const h = harness();
|
|
63
|
+
assert.equal(await h.controller.mode(h.ctx, { toolCallId: "call-1" }), "normal");
|
|
64
|
+
assert.equal(await h.controller.setMode("yolo", h.ctx), true);
|
|
65
|
+
const before = h.resolveCalls();
|
|
66
|
+
assert.equal(await h.controller.mode(h.ctx, { toolCallId: "call-1" }), "yolo", "the same admission id never answers a mode captured before the bump");
|
|
67
|
+
assert.equal(h.resolveCalls(), before + 1, "the bump forced a fresh capture");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("after a mode change the next mode() re-captures and never answers the stale mode", async () => {
|
|
71
|
+
const h = harness();
|
|
72
|
+
assert.equal(await h.controller.mode(h.ctx), "normal");
|
|
73
|
+
assert.equal(await h.controller.setMode("yolo", h.ctx), true);
|
|
74
|
+
const resolveBefore = h.resolveCalls();
|
|
75
|
+
assert.equal(await h.controller.mode(h.ctx), "yolo", "a generation bump re-reads the live mode");
|
|
76
|
+
assert.equal(h.resolveCalls(), resolveBefore + 1, "the invalidated cache is re-captured, not reused");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("publish writes UI only when the rendered strings change", async () => {
|
|
80
|
+
const h = harness();
|
|
81
|
+
await h.controller.mode(h.ctx);
|
|
82
|
+
const first = h.uiCalls();
|
|
83
|
+
assert.ok(first.status > 0 && first.widget > 0, "the first publication paints the status and widget");
|
|
84
|
+
await h.controller.mode(h.ctx);
|
|
85
|
+
assert.deepEqual(h.uiCalls(), first, "an unchanged mode() costs zero UI writes");
|
|
86
|
+
await h.controller.setMode("yolo", h.ctx);
|
|
87
|
+
const second = h.uiCalls();
|
|
88
|
+
assert.ok(second.status > first.status && second.widget > first.widget, "an actual change still publishes");
|
|
89
|
+
await h.controller.mode(h.ctx);
|
|
90
|
+
assert.deepEqual(h.uiCalls(), second, "reading the settled mode does not repaint");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a cwd change re-captures and revokes instead of answering a stale workspace mode", async () => {
|
|
94
|
+
const h = harness();
|
|
95
|
+
assert.equal(await h.controller.setMode("yolo", h.ctx), true);
|
|
96
|
+
const other = realpathSync(mkdtempSync(join(tmpdir(), "yolo-capture-cwd-")));
|
|
97
|
+
try {
|
|
98
|
+
Object.assign(h.ctx, { cwd: other });
|
|
99
|
+
assert.equal(await h.controller.mode(h.ctx), "normal", "the new workspace never inherits the old binding");
|
|
100
|
+
assert.equal(h.resolveCalls(), 2, "the cwd change forced a fresh capture");
|
|
101
|
+
await h.controller.mode(h.ctx);
|
|
102
|
+
assert.equal(h.resolveCalls(), 3, "a call without an admission id keeps the uncached capture path");
|
|
103
|
+
} finally { rmSync(other, { recursive: true, force: true }); }
|
|
104
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { defaultTempScratchFs, isTempScratchTarget, type TempScratchFs } from "../lib/yolo-temp-scratch.ts";
|
|
7
|
+
|
|
8
|
+
const ownDir = { uid: 1000, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false };
|
|
9
|
+
const mock = (overrides: Partial<TempScratchFs> = {}): TempScratchFs => ({
|
|
10
|
+
uid: 1000, tmpdir: "/tmp", home: "/home/user",
|
|
11
|
+
lstat: () => ownDir,
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("temp roots are /tmp, /var/tmp and the resolved tmpdir, never the root itself", () => {
|
|
16
|
+
assert.equal(isTempScratchTarget("/tmp/tmp.abcdef", mock()), true);
|
|
17
|
+
assert.equal(isTempScratchTarget("/var/tmp/tmp.abcdef", mock()), true);
|
|
18
|
+
assert.equal(isTempScratchTarget("/run/user/1000/tmp.abcdef", mock({ tmpdir: "/run/user/1000" })), true);
|
|
19
|
+
for (const target of ["/tmp", "/var/tmp", "/run/user/1000/tmp.abcdef", "/", "/tmp.abcdef"]) {
|
|
20
|
+
assert.equal(isTempScratchTarget(target, mock()), false, target);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a tmpdir that is /, home, an ancestor or a descendant of home is not a temp root", () => {
|
|
25
|
+
for (const tmpdir of ["/", "/home/user", "/home", "/home/user/work/repo"]) {
|
|
26
|
+
const fs = mock({ tmpdir });
|
|
27
|
+
for (const target of ["/tmp.abcdef", "/home/user/tmp.abcdef", "/home/tmp.abcdef", "/home/user/work/repo/tmp.abcdef"]) {
|
|
28
|
+
assert.equal(isTempScratchTarget(target, fs), false, `${tmpdir}: ${target}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const ownFile = { uid: 1000, isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false };
|
|
32
|
+
assert.equal(isTempScratchTarget("/home/gentleman/work/repo/README.md", mock({ home: "/home/gentleman", tmpdir: "/home/gentleman/work/repo", lstat: () => ownFile })), false);
|
|
33
|
+
assert.equal(isTempScratchTarget("/home/gentleman-tmp/x.log", mock({ home: "/home/gentleman", tmpdir: "/home/gentleman-tmp", lstat: () => ownFile })), true, "sibling prefix is not a descendant");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("only mktemp-named own directories qualify; files need ownership, symlinks and missing paths always qualify", () => {
|
|
37
|
+
const dir = (uid: number) => ({ uid, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false });
|
|
38
|
+
assert.equal(isTempScratchTarget("/tmp/tmp.ab12CD", mock({ lstat: () => dir(1000) })), true);
|
|
39
|
+
assert.equal(isTempScratchTarget("/tmp/tmp.ab12C", mock({ lstat: () => dir(1000) })), false, "short suffix");
|
|
40
|
+
assert.equal(isTempScratchTarget("/tmp/cache", mock({ lstat: () => dir(1000) })), false, "non-mktemp name");
|
|
41
|
+
assert.equal(isTempScratchTarget("/tmp/tmp.ab12CD", mock({ lstat: () => dir(0) })), false, "other uid");
|
|
42
|
+
const other = { uid: 0, isDirectory: () => false, isFile: () => false, isSymbolicLink: () => false };
|
|
43
|
+
assert.equal(isTempScratchTarget("/tmp/fifo", mock({ lstat: () => other })), false, "special file");
|
|
44
|
+
assert.equal(isTempScratchTarget("/tmp/x.log", mock({ lstat: () => ({ ...other, uid: 1000, isFile: () => true }) })), true);
|
|
45
|
+
assert.equal(isTempScratchTarget("/tmp/x.log", mock({ lstat: () => ({ ...other, isFile: () => true }) })), false);
|
|
46
|
+
assert.equal(isTempScratchTarget("/tmp/link", mock({ lstat: () => ({ ...other, isSymbolicLink: () => true }) })), true);
|
|
47
|
+
const missing = () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); };
|
|
48
|
+
assert.equal(isTempScratchTarget("/tmp/whatever", mock({ lstat: missing })), true);
|
|
49
|
+
const denied = () => { throw Object.assign(new Error("EACCES"), { code: "EACCES" }); };
|
|
50
|
+
assert.equal(isTempScratchTarget("/tmp/whatever", mock({ lstat: denied })), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the production probe recognizes a real mktemp directory, own file and symlink", () => {
|
|
54
|
+
const fs = defaultTempScratchFs();
|
|
55
|
+
if (fs.uid === undefined) return;
|
|
56
|
+
const root = realpathSync(tmpdir());
|
|
57
|
+
const scratch = mkdtempSync(join(root, "tmp."));
|
|
58
|
+
const file = join(root, `gentle-scratch-${process.pid}.log`);
|
|
59
|
+
const link = join(root, `gentle-scratch-${process.pid}.link`);
|
|
60
|
+
const named = mkdtempSync(join(root, "gentle-scratch-"));
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(file, "log");
|
|
63
|
+
symlinkSync(named, link);
|
|
64
|
+
assert.equal(isTempScratchTarget(scratch, fs), true, scratch);
|
|
65
|
+
assert.equal(isTempScratchTarget(file, fs), true, file);
|
|
66
|
+
assert.equal(isTempScratchTarget(link, fs), true, link);
|
|
67
|
+
assert.equal(isTempScratchTarget(named, fs), false, named);
|
|
68
|
+
} finally {
|
|
69
|
+
for (const path of [scratch, file, link, named]) rmSync(path, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// ODD routing drift ratchet — canonical fixture regeneration.
|
|
3
|
-
//
|
|
4
|
-
// gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
|
|
5
|
-
// `internal/components/agentguidance/routing.go` (RenderRouting). There is no
|
|
6
|
-
// automated sync, so a canonical change leaves the pi mirror stale with nothing
|
|
7
|
-
// catching it. This script renders the canonical block from a LOCAL gentle-ai
|
|
8
|
-
// checkout and vendors it into `fixtures/odd-routing-canonical.md` with managed
|
|
9
|
-
// provenance, where `tests/odd-routing-canonical-ratchet.test.ts` turns drift
|
|
10
|
-
// into a failing test.
|
|
11
|
-
//
|
|
12
|
-
// This script NEVER fetches anything from the network (GOPROXY=off), NEVER
|
|
13
|
-
// modifies the gentle-ai checkout beyond a transient temporary Go entrypoint
|
|
14
|
-
// that it always deletes, and NEVER touches the installer release pin
|
|
15
|
-
// (scripts/gentle-ai-installer.mjs INSTALLER_VERSION). Regeneration writes ONLY
|
|
16
|
-
// the fixture; it never auto-rewrites the pi mirror assets.
|
|
17
|
-
//
|
|
18
|
-
// Usage:
|
|
19
|
-
// node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]
|
|
20
|
-
|
|
21
|
-
import { execFileSync } from "node:child_process";
|
|
22
|
-
import { createHash } from "node:crypto";
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
-
import { dirname, join, resolve } from "node:path";
|
|
25
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
-
|
|
27
|
-
export const ODD_ROUTING_FIXTURE_RELATIVE = "fixtures/odd-routing-canonical.md";
|
|
28
|
-
export const ODD_ROUTING_SOURCE_REPO = "https://github.com/Gentleman-Programming/gentle-ai";
|
|
29
|
-
export const ODD_ROUTING_SOURCE_PATH = "internal/components/agentguidance/routing.go";
|
|
30
|
-
export const ODD_ROUTING_HEADER_OPEN = "<!-- gentle-pi:managed-odd-routing-canonical\n";
|
|
31
|
-
export const ODD_ROUTING_HEADER_CLOSE = "\n-->\n";
|
|
32
|
-
export const ODD_ROUTING_TEMP_DIR = "tmp-odd-routing-dump";
|
|
33
|
-
export const ODD_ROUTING_BLOCK_PREFIX = "## Implementation Routing";
|
|
34
|
-
|
|
35
|
-
const GO_ENTRYPOINT = `package main
|
|
36
|
-
|
|
37
|
-
import (
|
|
38
|
-
"fmt"
|
|
39
|
-
"os"
|
|
40
|
-
|
|
41
|
-
"github.com/gentleman-programming/gentle-ai/v4/internal/components/agentguidance"
|
|
42
|
-
"github.com/gentleman-programming/gentle-ai/v4/internal/model"
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
func main() {
|
|
46
|
-
rendered, err := agentguidance.RenderRouting(model.AgentPi)
|
|
47
|
-
if err != nil {
|
|
48
|
-
fmt.Fprintln(os.Stderr, err)
|
|
49
|
-
os.Exit(1)
|
|
50
|
-
}
|
|
51
|
-
fmt.Print(rendered)
|
|
52
|
-
}
|
|
53
|
-
`;
|
|
54
|
-
|
|
55
|
-
export function sha256Hex(value) {
|
|
56
|
-
return createHash("sha256").update(value).digest("hex");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function parseOddRoutingFixture(raw) {
|
|
60
|
-
if (!raw.startsWith(ODD_ROUTING_HEADER_OPEN)) {
|
|
61
|
-
throw new Error(`fixture does not start with the managed header ${JSON.stringify(ODD_ROUTING_HEADER_OPEN)}`);
|
|
62
|
-
}
|
|
63
|
-
const headerEnd = raw.indexOf(ODD_ROUTING_HEADER_CLOSE, ODD_ROUTING_HEADER_OPEN.length);
|
|
64
|
-
if (headerEnd === -1) {
|
|
65
|
-
throw new Error(`fixture managed header is not terminated by ${JSON.stringify(ODD_ROUTING_HEADER_CLOSE)}`);
|
|
66
|
-
}
|
|
67
|
-
const headerText = raw.slice(ODD_ROUTING_HEADER_OPEN.length, headerEnd);
|
|
68
|
-
const body = raw.slice(headerEnd + ODD_ROUTING_HEADER_CLOSE.length);
|
|
69
|
-
const header = {};
|
|
70
|
-
for (const line of headerText.split("\n")) {
|
|
71
|
-
const separator = line.indexOf(": ");
|
|
72
|
-
if (separator === -1) continue;
|
|
73
|
-
header[line.slice(0, separator)] = line.slice(separator + 2);
|
|
74
|
-
}
|
|
75
|
-
return { header, body };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function renderOddRoutingFixture(block, { sourceCommit, generatedAt }) {
|
|
79
|
-
const body = block.endsWith("\n") ? block : `${block}\n`;
|
|
80
|
-
const header = [
|
|
81
|
-
ODD_ROUTING_HEADER_OPEN.trimEnd(),
|
|
82
|
-
`source_repo: ${ODD_ROUTING_SOURCE_REPO}`,
|
|
83
|
-
`source_path: ${ODD_ROUTING_SOURCE_PATH}`,
|
|
84
|
-
`source_commit: ${sourceCommit}`,
|
|
85
|
-
`generated_at: ${generatedAt}`,
|
|
86
|
-
`block_sha256: ${sha256Hex(body)}`,
|
|
87
|
-
"-->",
|
|
88
|
-
].join("\n");
|
|
89
|
-
return `${header}\n${body}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// `git status --porcelain --untracked-files=no` emits one `XY PATH` line per
|
|
93
|
-
// modified tracked file. Extract just the repository-relative path so the
|
|
94
|
-
// fail-closed error can name what would make provenance unverifiable.
|
|
95
|
-
export function parsePorcelainPaths(porcelainOutput) {
|
|
96
|
-
return porcelainOutput
|
|
97
|
-
.split("\n")
|
|
98
|
-
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
99
|
-
.filter((line) => line.length > 0)
|
|
100
|
-
.map((line) => line.slice(3).trim())
|
|
101
|
-
.filter((path) => path.length > 0);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Fail closed when the checkout carries uncommitted changes to tracked files.
|
|
105
|
-
// The fixture header asserts the rendered block came from the committed tree;
|
|
106
|
-
// rendering a dirty tree under that claim would make the provenance a lie.
|
|
107
|
-
// Untracked files never reach this guard because the git call excludes them.
|
|
108
|
-
export function assertCleanGentleAiCheckout(porcelainOutput) {
|
|
109
|
-
const dirtyPaths = parsePorcelainPaths(porcelainOutput);
|
|
110
|
-
if (dirtyPaths.length > 0) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`gentle-ai checkout is dirty; provenance would be unverifiable: ${dirtyPaths.join(", ")}. Commit or stash first.`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function parseArguments(argv) {
|
|
118
|
-
const values = { gentleAi: undefined };
|
|
119
|
-
for (let index = 0; index < argv.length; index += 2) {
|
|
120
|
-
const flag = argv[index];
|
|
121
|
-
const value = argv[index + 1];
|
|
122
|
-
if (flag !== "--gentle-ai" || value === undefined) {
|
|
123
|
-
throw new Error("usage: node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]");
|
|
124
|
-
}
|
|
125
|
-
if (values.gentleAi !== undefined) throw new Error("duplicate --gentle-ai argument");
|
|
126
|
-
values.gentleAi = value;
|
|
127
|
-
}
|
|
128
|
-
return values;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function requireGentleAiCheckout(gentleAiRoot) {
|
|
132
|
-
if (!existsSync(gentleAiRoot)) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
`no gentle-ai checkout at ${gentleAiRoot}; pass --gentle-ai <path> or clone ${ODD_ROUTING_SOURCE_REPO} as a sibling of gentle-pi`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
const routingPath = join(gentleAiRoot, ...ODD_ROUTING_SOURCE_PATH.split("/"));
|
|
138
|
-
if (!existsSync(routingPath)) {
|
|
139
|
-
throw new Error(`not a gentle-ai checkout: missing ${routingPath}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function renderCanonicalRouting(gentleAiRoot) {
|
|
144
|
-
const tempRoot = join(gentleAiRoot, ODD_ROUTING_TEMP_DIR);
|
|
145
|
-
// Clear any stale temp entrypoint from a previous crash so the render is clean.
|
|
146
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
147
|
-
mkdirSync(tempRoot, { recursive: true });
|
|
148
|
-
try {
|
|
149
|
-
writeFileSync(join(tempRoot, "main.go"), GO_ENTRYPOINT, { encoding: "utf8" });
|
|
150
|
-
const rendered = execFileSync("go", ["run", `./${ODD_ROUTING_TEMP_DIR}`], {
|
|
151
|
-
cwd: gentleAiRoot,
|
|
152
|
-
encoding: "utf8",
|
|
153
|
-
// GOPROXY=off fails closed instead of downloading: this script must
|
|
154
|
-
// never touch the network.
|
|
155
|
-
env: { ...process.env, GOPROXY: "off" },
|
|
156
|
-
stdio: ["ignore", "pipe", "inherit"],
|
|
157
|
-
});
|
|
158
|
-
if (!rendered.startsWith(ODD_ROUTING_BLOCK_PREFIX)) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`RenderRouting(model.AgentPi) did not return the expected routing block (starts with ${JSON.stringify(ODD_ROUTING_BLOCK_PREFIX)})`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
return rendered;
|
|
164
|
-
} finally {
|
|
165
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Resolve fixture provenance from the committed gentle-ai tree. `generatedAt`
|
|
170
|
-
// is the committer date, not wall-clock time, so rendering the same commit
|
|
171
|
-
// twice yields byte-identical fixture files. `execGit` is injectable for tests.
|
|
172
|
-
export function resolveOddRoutingProvenance(gentleAiRoot, execGit) {
|
|
173
|
-
const git = execGit ?? ((args) => execFileSync("git", args, { cwd: gentleAiRoot, encoding: "utf8" }));
|
|
174
|
-
assertCleanGentleAiCheckout(git(["status", "--porcelain", "--untracked-files=no"]));
|
|
175
|
-
return {
|
|
176
|
-
sourceCommit: git(["rev-parse", "HEAD"]).trim(),
|
|
177
|
-
generatedAt: git(["show", "-s", "--format=%cI", "HEAD"]).trim(),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function mirrorOddRouting(packageRoot, gentleAiRoot) {
|
|
182
|
-
requireGentleAiCheckout(gentleAiRoot);
|
|
183
|
-
// Resolve (and fail closed on dirty) provenance BEFORE rendering: the
|
|
184
|
-
// transient Go entrypoint must not run against a tree whose commit hash the
|
|
185
|
-
// fixture would misrepresent.
|
|
186
|
-
const provenance = resolveOddRoutingProvenance(gentleAiRoot);
|
|
187
|
-
const block = renderCanonicalRouting(gentleAiRoot);
|
|
188
|
-
const contents = renderOddRoutingFixture(block, provenance);
|
|
189
|
-
|
|
190
|
-
const fixturePath = join(resolve(packageRoot), ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
|
|
191
|
-
const previous = readPreviousFixture(fixturePath);
|
|
192
|
-
mkdirSync(dirname(fixturePath), { recursive: true });
|
|
193
|
-
|
|
194
|
-
// Atomic overwrite: write a sibling temp file, then rename it into place.
|
|
195
|
-
const stagingPath = `${fixturePath}.tmp`;
|
|
196
|
-
try {
|
|
197
|
-
writeFileSync(stagingPath, contents, { encoding: "utf8" });
|
|
198
|
-
renameSync(stagingPath, fixturePath);
|
|
199
|
-
} finally {
|
|
200
|
-
rmSync(stagingPath, { force: true });
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const next = parseOddRoutingFixture(contents);
|
|
204
|
-
return {
|
|
205
|
-
fixturePath,
|
|
206
|
-
sourceCommit: next.header.source_commit,
|
|
207
|
-
blockSha256: next.header.block_sha256,
|
|
208
|
-
changed: previous === undefined || previous.body !== next.body || previous.header.source_commit !== next.header.source_commit,
|
|
209
|
-
previous,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function readPreviousFixture(fixturePath) {
|
|
214
|
-
if (!existsSync(fixturePath)) return undefined;
|
|
215
|
-
const raw = readFileSync(fixturePath, "utf8");
|
|
216
|
-
// Verify the existing fixture before clobbering it: an unparseable file is a
|
|
217
|
-
// signal to inspect, not to silently overwrite.
|
|
218
|
-
const parsed = parseOddRoutingFixture(raw);
|
|
219
|
-
const recorded = parsed.header.block_sha256;
|
|
220
|
-
const actual = sha256Hex(parsed.body);
|
|
221
|
-
if (recorded !== undefined && recorded !== actual) {
|
|
222
|
-
console.warn(`warning: existing fixture body does not match its recorded digest (${recorded}); regenerating from canonical`);
|
|
223
|
-
}
|
|
224
|
-
return parsed;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function main() {
|
|
228
|
-
const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
229
|
-
const { gentleAi } = parseArguments(process.argv.slice(2));
|
|
230
|
-
const gentleAiRoot = resolve(gentleAi ?? join(packageRoot, "..", "gentle-ai"));
|
|
231
|
-
const result = mirrorOddRouting(packageRoot, gentleAiRoot);
|
|
232
|
-
console.log(`${result.changed ? "Wrote" : "Refreshed"} ${result.fixturePath}`);
|
|
233
|
-
console.log(`source commit ${result.sourceCommit}; block sha256 ${result.blockSha256}`);
|
|
234
|
-
if (result.previous !== undefined) {
|
|
235
|
-
console.log(`previous commit ${result.previous.header.source_commit}; previous block sha256 ${result.previous.header.block_sha256}`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
240
|
-
if (isMainModule) {
|
|
241
|
-
await main();
|
|
242
|
-
}
|
/package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md
RENAMED
|
File without changes
|
/package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json
RENAMED
|
File without changes
|
/package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json
RENAMED
|
File without changes
|
|
File without changes
|