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
|
@@ -4,7 +4,8 @@ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
7
|
-
import test from "node:test";
|
|
7
|
+
import test, { after, before } from "node:test";
|
|
8
|
+
import { setWindowsAclAuthorityForTesting } from "../lib/review-candidate-view-owner.ts";
|
|
8
9
|
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
@@ -19,6 +20,16 @@ import {
|
|
|
19
20
|
presentReviewConsentUi,
|
|
20
21
|
} from "../lib/review-consent-ui.ts";
|
|
21
22
|
|
|
23
|
+
// Coordination tests use fixture authority; real Windows ACL coverage lives
|
|
24
|
+
// in review-candidate-view.test.ts, in a separate test process.
|
|
25
|
+
let fixtureAclChecks = 0;
|
|
26
|
+
before(() => {
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
setWindowsAclAuthorityForTesting(() => { fixtureAclChecks++; });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
after(() => setWindowsAclAuthorityForTesting());
|
|
32
|
+
|
|
22
33
|
function consentFixture(): Record<string, unknown> {
|
|
23
34
|
const path = join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json");
|
|
24
35
|
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
@@ -173,10 +184,12 @@ function siblingWorktree(t: test.TestContext, parentRoot: string): string {
|
|
|
173
184
|
|
|
174
185
|
function startStatus(cwd: string, intendedUntracked: readonly string[] = []): ReviewStatusV3 {
|
|
175
186
|
const views = new CandidateViewRegistry();
|
|
187
|
+
const checksBefore = fixtureAclChecks;
|
|
176
188
|
const candidate = views.create({
|
|
177
189
|
contributorRoot: cwd,
|
|
178
190
|
...(intendedUntracked.length === 0 ? {} : { intendedUntracked }),
|
|
179
191
|
});
|
|
192
|
+
if (process.platform === "win32") assert.ok(fixtureAclChecks > checksBefore, "coordination candidates must use the fixture ACL authority");
|
|
180
193
|
try {
|
|
181
194
|
return {
|
|
182
195
|
contract: "gentle-ai.review-integration/v2",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
3
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -100,6 +100,32 @@ test("review session Git identity lookups hide every Windows child process", asy
|
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("synchronous repository discovery stays silent outside Git", (t) => {
|
|
104
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-non-git-identity-")));
|
|
105
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
106
|
+
const moduleUrl = new URL("../lib/review-session-standing-permission.ts", import.meta.url).href;
|
|
107
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith("GIT_")));
|
|
108
|
+
const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", `
|
|
109
|
+
import { resolveCanonicalGitRepositoryIdentitySync } from ${JSON.stringify(moduleUrl)};
|
|
110
|
+
const identity = resolveCanonicalGitRepositoryIdentitySync(process.cwd());
|
|
111
|
+
console.log(JSON.stringify({ identity: identity ?? null }));
|
|
112
|
+
`], { cwd, env, encoding: "utf8", timeout: 10_000 });
|
|
113
|
+
assert.equal(child.error, undefined);
|
|
114
|
+
assert.equal(child.status, 0, child.stderr);
|
|
115
|
+
assert.deepEqual(JSON.parse(child.stdout), { identity: null });
|
|
116
|
+
assert.equal(child.stderr, "", "a caught Git failure must not write over the TUI editor");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("synchronous repository discovery preserves clone identity across worktrees", (t) => {
|
|
120
|
+
const root = repository(t);
|
|
121
|
+
const sibling = siblingWorktree(t, root);
|
|
122
|
+
const unrelated = repository(t);
|
|
123
|
+
const identity = resolveCanonicalGitRepositoryIdentitySync(root);
|
|
124
|
+
assert.match(identity ?? "", /^sha256:/);
|
|
125
|
+
assert.equal(resolveCanonicalGitRepositoryIdentitySync(sibling), identity);
|
|
126
|
+
assert.notEqual(resolveCanonicalGitRepositoryIdentitySync(unrelated), identity);
|
|
127
|
+
});
|
|
128
|
+
|
|
103
129
|
test("headless, child, empty-session, and non-Git contexts cannot offer or consume standing permission", async (t) => {
|
|
104
130
|
const root = repository(t);
|
|
105
131
|
const manager = {};
|
|
@@ -123,6 +123,8 @@ console.log("ENV_PROBE=" + JSON.stringify({
|
|
|
123
123
|
home,
|
|
124
124
|
files: home && existsSync(home) ? readdirSync(home) : null,
|
|
125
125
|
sentinel: process.env.TEST_ENV_SENTINEL,
|
|
126
|
+
tmp: process.env.TMPDIR ?? null,
|
|
127
|
+
tmpExists: process.env.TMPDIR ? existsSync(process.env.TMPDIR) : null,
|
|
126
128
|
}));
|
|
127
129
|
`);
|
|
128
130
|
writeFileSync(stagesPath, JSON.stringify([
|
|
@@ -200,3 +202,16 @@ test("direct invocation rejects a malformed stages file with a non-zero exit", (
|
|
|
200
202
|
assert.equal(result.status, 1);
|
|
201
203
|
assert.match(result.stderr ?? "", /stages file must be an array/);
|
|
202
204
|
});
|
|
205
|
+
|
|
206
|
+
// Harness roadmap T3: each subagent gets its own TMPDIR, and unit fixtures that
|
|
207
|
+
// leave a fake child unfinished would leave it behind; the stage owns them all.
|
|
208
|
+
test("unit stage gets a private TMPDIR removed after the stage; later stages keep the inherited one", (t) => {
|
|
209
|
+
const inherited = mkdtempSync(join(tmpdir(), "gentle-pi-caller-tmp-"));
|
|
210
|
+
t.after(() => rmSync(inherited, { recursive: true, force: true }));
|
|
211
|
+
const [unit, provider] = probeStageEnvironment(t, { TMPDIR: inherited });
|
|
212
|
+
assert.notEqual(unit.tmp, inherited);
|
|
213
|
+
assert.match(unit.tmp, /gentle-pi-unit-tmp-/);
|
|
214
|
+
assert.equal(unit.tmpExists, true, "the private TMPDIR exists while the stage runs");
|
|
215
|
+
assert.equal(existsSync(unit.tmp), false, "the private TMPDIR is removed after the stage");
|
|
216
|
+
assert.equal(provider.tmp, inherited);
|
|
217
|
+
});
|
|
@@ -604,9 +604,19 @@ async function run() {
|
|
|
604
604
|
assert.equal(writerDispatch.task, task, "writer guard must not mutate child input");
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
// S16: generic writers are admitted only for a task of an existing feature document (or at the measured
|
|
608
|
+
// context backstop); the accepted cases below name one, and an undocumented launch is told to work inline.
|
|
609
|
+
await mkdir(join(toolCwd, "odd", "tasks"), { recursive: true });
|
|
610
|
+
await writeFile(join(toolCwd, "odd", "tasks", "harness.md"), "# Harness\n");
|
|
611
|
+
const undocumentedWriter = await toolHook(
|
|
612
|
+
{ toolName: "subagent_run", input: { agent: "gentle-ai-worker", task: "Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts", mode: "task" } },
|
|
613
|
+
createCtx(toolCwd),
|
|
614
|
+
);
|
|
615
|
+
assert.equal(undocumentedWriter?.block, true, "an undocumented writer must be blocked before dispatch");
|
|
616
|
+
assert.match(undocumentedWriter?.reason ?? "", /write this unit inline yourself/);
|
|
607
617
|
const scopedWriterDispatch = {
|
|
608
618
|
agent: "gentle-ai-worker",
|
|
609
|
-
task: "Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs",
|
|
619
|
+
task: "Spec: odd/tasks/harness.md, T1. Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs",
|
|
610
620
|
mode: "task",
|
|
611
621
|
};
|
|
612
622
|
assert.equal(
|
|
@@ -620,7 +630,7 @@ async function run() {
|
|
|
620
630
|
toolName: "subagent_run",
|
|
621
631
|
input: {
|
|
622
632
|
agent: "worker",
|
|
623
|
-
task: "Implement the requested change.",
|
|
633
|
+
task: "Spec: odd/tasks/harness.md, T2. Implement the requested change.",
|
|
624
634
|
context: "## Allowed edit surfaces\n- assets/orchestrator.md",
|
|
625
635
|
mode: "task",
|
|
626
636
|
},
|
|
@@ -717,7 +727,7 @@ async function run() {
|
|
|
717
727
|
"valid scope followed by a repository-root scope",
|
|
718
728
|
{
|
|
719
729
|
agent: "gentle-ai-worker",
|
|
720
|
-
task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\n\n## Allowed edit surfaces\n.",
|
|
730
|
+
task: "Spec: odd/tasks/harness.md, T4.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\n\n## Allowed edit surfaces\n.",
|
|
721
731
|
mode: "task",
|
|
722
732
|
},
|
|
723
733
|
],
|
|
@@ -725,7 +735,7 @@ async function run() {
|
|
|
725
735
|
"valid task scope plus invalid context scope",
|
|
726
736
|
{
|
|
727
737
|
agent: "gentle-ai-worker",
|
|
728
|
-
task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
|
|
738
|
+
task: "Spec: odd/tasks/harness.md, T5.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts",
|
|
729
739
|
context: "## Allowed edit surfaces\n.",
|
|
730
740
|
mode: "task",
|
|
731
741
|
},
|
|
@@ -734,7 +744,7 @@ async function run() {
|
|
|
734
744
|
"conflicting valid task and context scopes",
|
|
735
745
|
{
|
|
736
746
|
agent: "gentle-ai-worker",
|
|
737
|
-
task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
|
|
747
|
+
task: "Spec: odd/tasks/harness.md, T6.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts",
|
|
738
748
|
context: "## Allowed edit surfaces\ntests/runtime-harness.mjs",
|
|
739
749
|
mode: "task",
|
|
740
750
|
},
|
|
@@ -749,7 +759,7 @@ async function run() {
|
|
|
749
759
|
toolName: "subagent_run",
|
|
750
760
|
input: {
|
|
751
761
|
agent: "gentle-ai-worker",
|
|
752
|
-
task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs\n\n## Allowed edit surfaces\n- `tests/runtime-harness.mjs`\n- `extensions/gentle-ai.ts`",
|
|
762
|
+
task: "Spec: odd/tasks/harness.md, T3.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs\n\n## Allowed edit surfaces\n- `tests/runtime-harness.mjs`\n- `extensions/gentle-ai.ts`",
|
|
753
763
|
mode: "task",
|
|
754
764
|
},
|
|
755
765
|
},
|
|
@@ -39,7 +39,7 @@ function fixture(entries: any[] = [], options: { root?: string; sessionId?: stri
|
|
|
39
39
|
for(let i=0;i<200&&!overlay.view;i++){ if(await Promise.race([opened.then(()=>true),new Promise(r=>setTimeout(()=>r(false),5))])) break; }
|
|
40
40
|
if(!overlay.view) return undefined;
|
|
41
41
|
const text=overlay.view.render(200).join("\n");
|
|
42
|
-
const trees=(overlay.view.trees as any[]).map(tree=>({root:tree.root as string,branch:tree.branch as string|undefined,files:tree.model.files.map((file:any)=>file.path) as string[],added:tree.model.added as number,deleted:tree.model.deleted as number}));
|
|
42
|
+
const trees=(overlay.view.children[0].trees as any[]).map(tree=>({root:tree.root as string,branch:tree.branch as string|undefined,files:tree.model.files.map((file:any)=>file.path) as string[],added:tree.model.added as number,deleted:tree.model.deleted as number}));
|
|
43
43
|
overlay.close!(); await opened;
|
|
44
44
|
return {text,trees,files:trees.flatMap(tree=>tree.files)};
|
|
45
45
|
};
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { SessionChanges, readChangeSnapshot, sessionChangeRoots, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
|
|
7
|
+
import { emptyChanges } from "../lib/shell-changes.ts";
|
|
7
8
|
import { SESSION_WORKTREE_ENTRY } from "../lib/session-worktree-registry.ts";
|
|
8
9
|
|
|
9
10
|
const text = (text: string) => ({ kind: "text" as const, text });
|
|
@@ -12,7 +13,7 @@ const evidence = (id = "a", root = "/repo", path = "file.ts", before = text("old
|
|
|
12
13
|
|
|
13
14
|
test("session changes starts empty without reading the worktree or its untracked contents", () => {
|
|
14
15
|
const changes = new SessionChanges("session");
|
|
15
|
-
assert.deepEqual(changes.model, {
|
|
16
|
+
assert.deepEqual(changes.model, { ...emptyChanges(), digest: emptyChanges().digest });
|
|
16
17
|
assert.deepEqual(changes.worktrees, []);
|
|
17
18
|
});
|
|
18
19
|
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// Fork lifecycle seams against public Pi SessionManager fixtures in temp dirs.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
createSessionProfileAppendController,
|
|
10
|
+
type SessionProfileAppendOptions,
|
|
11
|
+
type SessionProfileAppendOutcome,
|
|
12
|
+
type SessionProfileForkTarget,
|
|
13
|
+
} from "../lib/session-profile-append-controller.ts";
|
|
14
|
+
|
|
15
|
+
type Controller = ReturnType<typeof createSessionProfileAppendController>;
|
|
16
|
+
|
|
17
|
+
function fixture(memory = false) {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "profile-fork-"));
|
|
19
|
+
const manager = memory
|
|
20
|
+
? SessionManager.inMemory(root)
|
|
21
|
+
: SessionManager.create(root, join(root, "sessions"));
|
|
22
|
+
let fail = false,
|
|
23
|
+
calls = 0;
|
|
24
|
+
const controller = createSessionProfileAppendController(
|
|
25
|
+
manager,
|
|
26
|
+
(type, data) => {
|
|
27
|
+
calls++;
|
|
28
|
+
manager.appendCustomEntry(type, data);
|
|
29
|
+
if (fail) throw new Error("injected callback failure after public append");
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
return {
|
|
33
|
+
manager,
|
|
34
|
+
controller,
|
|
35
|
+
failing(value: boolean) {
|
|
36
|
+
fail = value;
|
|
37
|
+
},
|
|
38
|
+
calls: () => calls,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function flush(manager: SessionManager) {
|
|
42
|
+
manager.appendMessage({
|
|
43
|
+
role: "user",
|
|
44
|
+
content: "isolated fork fixture",
|
|
45
|
+
timestamp: 1,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function name(outcome: SessionProfileAppendOutcome, expected: string) {
|
|
49
|
+
const state = outcome.state as { status: string; binding?: { name: string } };
|
|
50
|
+
assert.equal(state.status, "bound");
|
|
51
|
+
assert.equal(state.binding?.name, expected);
|
|
52
|
+
}
|
|
53
|
+
function persistedCopy(manager: SessionManager, leaf: string) {
|
|
54
|
+
const sourceFile = manager.getSessionFile();
|
|
55
|
+
assert.ok(sourceFile);
|
|
56
|
+
const copy = SessionManager.open(sourceFile, manager.getSessionDir());
|
|
57
|
+
copy.createBranchedSession(leaf);
|
|
58
|
+
return copy;
|
|
59
|
+
}
|
|
60
|
+
// Mirrors the host order: capture, copy, detach with the preparation, then
|
|
61
|
+
// attach the destination with the handoff and its own public view. An
|
|
62
|
+
// in-memory fork rewrites the same manager, so it copies after detach.
|
|
63
|
+
function fork(
|
|
64
|
+
parent: Controller,
|
|
65
|
+
manager: SessionManager,
|
|
66
|
+
target: SessionProfileForkTarget,
|
|
67
|
+
copy: (prepared: boolean) => SessionManager,
|
|
68
|
+
options: SessionProfileAppendOptions = {},
|
|
69
|
+
) {
|
|
70
|
+
const captured = parent.captureForkEvidence(target, manager);
|
|
71
|
+
const previousSessionFile = manager.getSessionFile();
|
|
72
|
+
const inPlace = manager.getSessionFile() === undefined;
|
|
73
|
+
let child = inPlace ? manager : copy(captured.status === "captured");
|
|
74
|
+
const handoff = parent.detach({
|
|
75
|
+
reason: "fork",
|
|
76
|
+
targetSessionFile: inPlace ? undefined : child.getSessionFile(),
|
|
77
|
+
preparation:
|
|
78
|
+
captured.status === "captured" ? captured.evidence : undefined,
|
|
79
|
+
});
|
|
80
|
+
if (inPlace) child = copy(captured.status === "captured");
|
|
81
|
+
let appends = 0;
|
|
82
|
+
const append = (type: string, data: unknown) => {
|
|
83
|
+
appends++;
|
|
84
|
+
child.appendCustomEntry(type, data);
|
|
85
|
+
};
|
|
86
|
+
// The host keeps one controller per manager; an in-memory fork reuses it.
|
|
87
|
+
const controller =
|
|
88
|
+
child === manager
|
|
89
|
+
? parent
|
|
90
|
+
: createSessionProfileAppendController(child, append);
|
|
91
|
+
const attachment = controller.attach(
|
|
92
|
+
child,
|
|
93
|
+
append,
|
|
94
|
+
{
|
|
95
|
+
reason: "fork",
|
|
96
|
+
previousSessionFile,
|
|
97
|
+
...(handoff ? { fork: { handoff, publicView: child } } : {}),
|
|
98
|
+
},
|
|
99
|
+
options,
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
captured,
|
|
103
|
+
handoff,
|
|
104
|
+
child,
|
|
105
|
+
controller,
|
|
106
|
+
attachment,
|
|
107
|
+
outcome: attachment.refresh(),
|
|
108
|
+
appends: () => appends,
|
|
109
|
+
reattach: () =>
|
|
110
|
+
createSessionProfileAppendController(child, append).attach(child, append, {
|
|
111
|
+
reason: "fork",
|
|
112
|
+
previousSessionFile,
|
|
113
|
+
...(handoff ? { fork: { handoff, publicView: child } } : {}),
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("disk-first fork retains copied failure quarantine but does not inherit binding", () => {
|
|
119
|
+
const f = fixture();
|
|
120
|
+
f.failing(true);
|
|
121
|
+
assert.equal(f.controller.bind("failed", {}).status, "indeterminate");
|
|
122
|
+
f.failing(false);
|
|
123
|
+
const failed = f.manager.getLeafId();
|
|
124
|
+
assert.ok(failed);
|
|
125
|
+
assert.equal(existsSync(f.manager.getSessionFile()!), false);
|
|
126
|
+
// Genuine public first flush now writes the known-failed record as well.
|
|
127
|
+
flush(f.manager);
|
|
128
|
+
const forked = fork(
|
|
129
|
+
f.controller,
|
|
130
|
+
f.manager,
|
|
131
|
+
{ entryId: failed, position: "at" },
|
|
132
|
+
() => persistedCopy(f.manager, failed),
|
|
133
|
+
);
|
|
134
|
+
assert.equal(forked.captured.status, "captured");
|
|
135
|
+
assert.ok(forked.handoff);
|
|
136
|
+
// No conversation on the copied path: the child file need not exist yet.
|
|
137
|
+
assert.equal(forked.outcome.status, "indeterminate");
|
|
138
|
+
assert.equal(existsSync(forked.child.getSessionFile()!), false);
|
|
139
|
+
flush(forked.child);
|
|
140
|
+
assert.equal(forked.attachment.refresh().state.status, "absent");
|
|
141
|
+
assert.equal(forked.attachment.clear().status, "persisted");
|
|
142
|
+
// A consumed handoff cannot be replayed into a second attachment.
|
|
143
|
+
assert.equal(forked.reattach().refresh().status, "indeterminate");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("actual copied cut beyond the prepared ancestor fails closed", () => {
|
|
147
|
+
const f = fixture();
|
|
148
|
+
flush(f.manager);
|
|
149
|
+
assert.equal(f.controller.bind("trusted", {}).status, "persisted");
|
|
150
|
+
const preparedCut = f.manager.getLeafId();
|
|
151
|
+
assert.ok(preparedCut);
|
|
152
|
+
f.failing(true);
|
|
153
|
+
const failed = f.controller.bind("known-failed", {});
|
|
154
|
+
f.failing(false);
|
|
155
|
+
assert.equal(failed.status, "append-failed-preserved");
|
|
156
|
+
name(failed, "trusted");
|
|
157
|
+
const actualCut = f.manager.getLeafId();
|
|
158
|
+
assert.ok(actualCut);
|
|
159
|
+
assert.notEqual(actualCut, preparedCut);
|
|
160
|
+
const sourceFile = f.manager.getSessionFile()!;
|
|
161
|
+
const sourceEntries = JSON.stringify(f.manager.getEntries());
|
|
162
|
+
const sourceDisk = readFileSync(sourceFile, "utf8");
|
|
163
|
+
let childEntries = "",
|
|
164
|
+
childDisk = "";
|
|
165
|
+
const forked = fork(
|
|
166
|
+
f.controller,
|
|
167
|
+
f.manager,
|
|
168
|
+
{ entryId: preparedCut, position: "at" },
|
|
169
|
+
() => {
|
|
170
|
+
const child = persistedCopy(f.manager, actualCut);
|
|
171
|
+
assert.ok(child.getBranch().some((entry) => entry.id === preparedCut));
|
|
172
|
+
childEntries = JSON.stringify(child.getEntries());
|
|
173
|
+
childDisk = readFileSync(child.getSessionFile()!, "utf8");
|
|
174
|
+
assert.ok(childDisk.includes(actualCut), "public copy stores the failure");
|
|
175
|
+
return child;
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
assert.deepEqual(forked.outcome, {
|
|
179
|
+
status: "indeterminate",
|
|
180
|
+
state: { status: "indeterminate", reason: "ambiguous-append" },
|
|
181
|
+
});
|
|
182
|
+
assert.deepEqual(forked.attachment.refresh(), forked.outcome);
|
|
183
|
+
assert.equal(forked.appends(), 0);
|
|
184
|
+
assert.equal(f.calls(), 2);
|
|
185
|
+
assert.equal(JSON.stringify(forked.child.getEntries()), childEntries);
|
|
186
|
+
assert.equal(readFileSync(forked.child.getSessionFile()!, "utf8"), childDisk);
|
|
187
|
+
assert.equal(JSON.stringify(f.manager.getEntries()), sourceEntries);
|
|
188
|
+
assert.equal(readFileSync(sourceFile, "utf8"), sourceDisk);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (const position of ["before", "at"] as const) {
|
|
192
|
+
test(`cancelled ${position} capture leaves entries, activation and callbacks unchanged`, () => {
|
|
193
|
+
const f = fixture();
|
|
194
|
+
assert.equal(f.controller.bind("pending", {}).status, "not-persisted");
|
|
195
|
+
const leaf = f.manager.getLeafId();
|
|
196
|
+
assert.ok(leaf);
|
|
197
|
+
const before = JSON.stringify(f.manager.getEntries());
|
|
198
|
+
const captured = f.controller.captureForkEvidence(
|
|
199
|
+
{ entryId: position === "at" ? leaf : "missing", position },
|
|
200
|
+
f.manager,
|
|
201
|
+
);
|
|
202
|
+
assert.equal(
|
|
203
|
+
captured.status,
|
|
204
|
+
position === "at" ? "captured" : "indeterminate",
|
|
205
|
+
);
|
|
206
|
+
assert.equal(JSON.stringify(f.manager.getEntries()), before);
|
|
207
|
+
assert.equal(f.controller.refresh().status, "not-persisted");
|
|
208
|
+
assert.equal(f.controller.bind("next", {}).status, "not-persisted");
|
|
209
|
+
assert.equal(f.calls(), 2);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test("valid cancelled before-user capture leaves disk and explicit binding unchanged", () => {
|
|
214
|
+
const f = fixture();
|
|
215
|
+
flush(f.manager);
|
|
216
|
+
const user = f.manager.getLeafId();
|
|
217
|
+
assert.ok(user);
|
|
218
|
+
f.controller.bind("current", {});
|
|
219
|
+
const entries = JSON.stringify(f.manager.getEntries());
|
|
220
|
+
const disk = readFileSync(f.manager.getSessionFile()!, "utf8");
|
|
221
|
+
assert.equal(
|
|
222
|
+
f.controller.captureForkEvidence(
|
|
223
|
+
{ entryId: user, position: "before" },
|
|
224
|
+
f.manager,
|
|
225
|
+
).status,
|
|
226
|
+
"captured",
|
|
227
|
+
);
|
|
228
|
+
assert.equal(JSON.stringify(f.manager.getEntries()), entries);
|
|
229
|
+
assert.equal(readFileSync(f.manager.getSessionFile()!, "utf8"), disk);
|
|
230
|
+
name(f.controller.refresh(), "current");
|
|
231
|
+
assert.equal(f.calls(), 1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("capture rejects a public view other than the attached source", () => {
|
|
235
|
+
const f = fixture();
|
|
236
|
+
flush(f.manager);
|
|
237
|
+
const other = fixture();
|
|
238
|
+
assert.deepEqual(
|
|
239
|
+
f.controller.captureForkEvidence(
|
|
240
|
+
{ entryId: f.manager.getLeafId()!, position: "at" },
|
|
241
|
+
other.manager,
|
|
242
|
+
),
|
|
243
|
+
{ status: "indeterminate", reason: "unobservable-fork" },
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("label removal permits only the expected copied parentId rechain", () => {
|
|
248
|
+
const f = fixture();
|
|
249
|
+
f.controller.bind("base", {});
|
|
250
|
+
flush(f.manager);
|
|
251
|
+
const base = f.manager.getLeafId();
|
|
252
|
+
assert.ok(base);
|
|
253
|
+
f.manager.appendLabelChange(base, "fixture-label");
|
|
254
|
+
f.controller.bind("selected", {});
|
|
255
|
+
const leaf = f.manager.getLeafId();
|
|
256
|
+
assert.ok(leaf);
|
|
257
|
+
const original = f.manager.getEntry(leaf)!;
|
|
258
|
+
const forked = fork(
|
|
259
|
+
f.controller,
|
|
260
|
+
f.manager,
|
|
261
|
+
{ entryId: leaf, position: "at" },
|
|
262
|
+
() => persistedCopy(f.manager, leaf),
|
|
263
|
+
);
|
|
264
|
+
assert.notEqual(forked.child.getEntry(leaf)?.parentId, original.parentId);
|
|
265
|
+
name(forked.outcome, "selected");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
for (const position of ["before", "at"] as const) {
|
|
269
|
+
test(`fork boundary label supports ${position} without requiring its removed ID`, () => {
|
|
270
|
+
const f = fixture();
|
|
271
|
+
flush(f.manager);
|
|
272
|
+
f.controller.bind("retained", {});
|
|
273
|
+
const retained = f.manager.getLeafId();
|
|
274
|
+
assert.ok(retained);
|
|
275
|
+
f.manager.appendLabelChange(retained, "boundary");
|
|
276
|
+
const label = f.manager.getLeafId();
|
|
277
|
+
assert.ok(label);
|
|
278
|
+
let entryId = label;
|
|
279
|
+
if (position === "before") {
|
|
280
|
+
flush(f.manager);
|
|
281
|
+
entryId = f.manager.getLeafId()!;
|
|
282
|
+
}
|
|
283
|
+
const forked = fork(f.controller, f.manager, { entryId, position }, () => {
|
|
284
|
+
const copy = persistedCopy(f.manager, label);
|
|
285
|
+
assert.equal(copy.getEntry(label), undefined);
|
|
286
|
+
return copy;
|
|
287
|
+
});
|
|
288
|
+
assert.equal(forked.captured.status, "captured");
|
|
289
|
+
name(forked.outcome, "retained");
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test("actual fork path excludes failed sibling and never imports its quarantine", () => {
|
|
294
|
+
const f = fixture();
|
|
295
|
+
f.controller.bind("base", {});
|
|
296
|
+
flush(f.manager);
|
|
297
|
+
const base = f.manager.getLeafId();
|
|
298
|
+
assert.ok(base);
|
|
299
|
+
f.failing(true);
|
|
300
|
+
f.controller.bind("failed-left", {});
|
|
301
|
+
f.failing(false);
|
|
302
|
+
const failed = f.manager.getLeafId();
|
|
303
|
+
f.manager.branch(base);
|
|
304
|
+
f.controller.bind("right", {});
|
|
305
|
+
const right = f.manager.getLeafId();
|
|
306
|
+
assert.ok(right);
|
|
307
|
+
assert.notEqual(right, failed);
|
|
308
|
+
const forked = fork(
|
|
309
|
+
f.controller,
|
|
310
|
+
f.manager,
|
|
311
|
+
{ entryId: right, position: "at" },
|
|
312
|
+
() => persistedCopy(f.manager, right),
|
|
313
|
+
);
|
|
314
|
+
assert.equal(
|
|
315
|
+
forked.child.getBranch().some((entry) => entry.id === failed),
|
|
316
|
+
false,
|
|
317
|
+
);
|
|
318
|
+
name(forked.outcome, "right");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
for (const changed of ["data", "timestamp", "parentId", "extra"] as const) {
|
|
322
|
+
test(`copied same-ID ${changed} mutation fails closed without blessing destination disk`, () => {
|
|
323
|
+
const f = fixture();
|
|
324
|
+
flush(f.manager);
|
|
325
|
+
f.controller.bind("trusted", {});
|
|
326
|
+
const leaf = f.manager.getLeafId();
|
|
327
|
+
assert.ok(leaf);
|
|
328
|
+
let copy: SessionManager | undefined;
|
|
329
|
+
const forked = fork(
|
|
330
|
+
f.controller,
|
|
331
|
+
f.manager,
|
|
332
|
+
{ entryId: leaf, position: "at" },
|
|
333
|
+
() => {
|
|
334
|
+
copy = persistedCopy(f.manager, leaf);
|
|
335
|
+
const row = copy.getEntry(leaf)! as unknown as Record<string, any>;
|
|
336
|
+
if (changed === "data") row.data.name = "FAILED";
|
|
337
|
+
if (changed === "timestamp") row.timestamp = "changed";
|
|
338
|
+
if (changed === "parentId") row.parentId = "unexpected-parent";
|
|
339
|
+
if (changed === "extra") row.extra = { changed: true };
|
|
340
|
+
return copy;
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
// Inject matching disk content, not ordinary Pi ID reuse.
|
|
344
|
+
readFile: (path) =>
|
|
345
|
+
copy && path === copy.getSessionFile()
|
|
346
|
+
? [copy.getHeader(), ...copy.getEntries()]
|
|
347
|
+
.map((entry) => JSON.stringify(entry))
|
|
348
|
+
.join("\n")
|
|
349
|
+
: readFileSync(path, "utf8"),
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
assert.equal(forked.outcome.status, "indeterminate");
|
|
353
|
+
assert.equal(forked.attachment.clear().status, "persisted");
|
|
354
|
+
forked.child.branch(leaf);
|
|
355
|
+
assert.equal(forked.attachment.refresh().status, "indeterminate");
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
test("in-memory fork keeps same-manager evidence but never restores pending state", () => {
|
|
360
|
+
const f = fixture(true);
|
|
361
|
+
assert.equal(f.controller.bind("pending", {}).status, "not-persisted");
|
|
362
|
+
const leaf = f.manager.getLeafId();
|
|
363
|
+
assert.ok(leaf);
|
|
364
|
+
const forked = fork(
|
|
365
|
+
f.controller,
|
|
366
|
+
f.manager,
|
|
367
|
+
{ entryId: leaf, position: "at" },
|
|
368
|
+
() => {
|
|
369
|
+
f.manager.createBranchedSession(leaf);
|
|
370
|
+
return f.manager;
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
assert.equal(forked.captured.status, "captured");
|
|
374
|
+
assert.equal(forked.outcome.status, "indeterminate");
|
|
375
|
+
assert.equal(
|
|
376
|
+
forked.attachment.bind("new-explicit", {}).status,
|
|
377
|
+
"not-persisted",
|
|
378
|
+
);
|
|
379
|
+
// The parent attachment was revoked by detach and cannot append.
|
|
380
|
+
assert.equal(f.controller.clear().status, "indeterminate");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("fork before root creates empty child without parent binding", () => {
|
|
384
|
+
const f = fixture();
|
|
385
|
+
flush(f.manager);
|
|
386
|
+
const firstUser = f.manager.getLeafId();
|
|
387
|
+
assert.ok(firstUser);
|
|
388
|
+
f.controller.bind("parent", {});
|
|
389
|
+
const previousSessionFile = f.manager.getSessionFile();
|
|
390
|
+
const forked = fork(
|
|
391
|
+
f.controller,
|
|
392
|
+
f.manager,
|
|
393
|
+
{ entryId: firstUser, position: "before" },
|
|
394
|
+
() => {
|
|
395
|
+
const child = SessionManager.create(
|
|
396
|
+
f.manager.getCwd(),
|
|
397
|
+
f.manager.getSessionDir(),
|
|
398
|
+
);
|
|
399
|
+
child.newSession({ parentSession: previousSessionFile });
|
|
400
|
+
return child;
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
assert.equal(forked.captured.status, "captured");
|
|
404
|
+
assert.equal(forked.outcome.status, "indeterminate");
|
|
405
|
+
assert.equal(forked.attachment.bind("child", {}).status, "not-persisted");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("detach rejects a preparation captured by another controller", () => {
|
|
409
|
+
const f = fixture();
|
|
410
|
+
flush(f.manager);
|
|
411
|
+
const leaf = f.manager.getLeafId();
|
|
412
|
+
assert.ok(leaf);
|
|
413
|
+
const other = createSessionProfileAppendController(f.manager, () => {});
|
|
414
|
+
const captured = other.captureForkEvidence(
|
|
415
|
+
{ entryId: leaf, position: "at" },
|
|
416
|
+
f.manager,
|
|
417
|
+
);
|
|
418
|
+
assert.equal(captured.status, "captured");
|
|
419
|
+
assert.equal(
|
|
420
|
+
f.controller.detach({
|
|
421
|
+
reason: "fork",
|
|
422
|
+
targetSessionFile: join(f.manager.getSessionDir(), "child.jsonl"),
|
|
423
|
+
preparation: captured.status === "captured" ? captured.evidence : undefined,
|
|
424
|
+
}),
|
|
425
|
+
undefined,
|
|
426
|
+
);
|
|
427
|
+
// Even a rejected proof still revokes the old capability.
|
|
428
|
+
assert.equal(f.controller.refresh().status, "indeterminate");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("empty destination cannot claim a prepared non-empty copied path", () => {
|
|
432
|
+
const f = fixture();
|
|
433
|
+
flush(f.manager);
|
|
434
|
+
f.controller.bind("parent", {});
|
|
435
|
+
const leaf = f.manager.getLeafId();
|
|
436
|
+
assert.ok(leaf);
|
|
437
|
+
const previousSessionFile = f.manager.getSessionFile();
|
|
438
|
+
const forked = fork(
|
|
439
|
+
f.controller,
|
|
440
|
+
f.manager,
|
|
441
|
+
{ entryId: leaf, position: "at" },
|
|
442
|
+
() => {
|
|
443
|
+
const child = SessionManager.create(
|
|
444
|
+
f.manager.getCwd(),
|
|
445
|
+
f.manager.getSessionDir(),
|
|
446
|
+
);
|
|
447
|
+
child.newSession({ parentSession: previousSessionFile });
|
|
448
|
+
return child;
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
assert.equal(forked.captured.status, "captured");
|
|
452
|
+
assert.equal(forked.outcome.status, "indeterminate");
|
|
453
|
+
// A rejected destination cut cannot activate even a fresh explicit setup.
|
|
454
|
+
assert.deepEqual(forked.attachment.bind("child", {}), {
|
|
455
|
+
status: "indeterminate",
|
|
456
|
+
state: { status: "indeterminate", reason: "append-not-corroborated" },
|
|
457
|
+
});
|
|
458
|
+
});
|