gentle-pi 3.7.0 → 4.0.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -28,6 +28,7 @@ type ToolCallHandler = (
|
|
|
28
28
|
interface Runtime {
|
|
29
29
|
controller: RegisteredTool;
|
|
30
30
|
toolCall: ToolCallHandler;
|
|
31
|
+
lifecycle(name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function runtime(
|
|
@@ -36,10 +37,12 @@ function runtime(
|
|
|
36
37
|
): Runtime {
|
|
37
38
|
const tools = new Map<string, RegisteredTool>();
|
|
38
39
|
let toolCall: ToolCallHandler | undefined;
|
|
40
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => unknown>();
|
|
39
41
|
const dependencies = { nativeReviewCli, candidateViews } as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
40
42
|
createGentleAiExtension(dependencies)({
|
|
41
43
|
on(name: string, handler: ToolCallHandler) {
|
|
42
44
|
if (name === "tool_call") toolCall = handler;
|
|
45
|
+
handlers.set(name, handler as unknown as (event: unknown, ctx: ExtensionContext) => unknown);
|
|
43
46
|
},
|
|
44
47
|
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
45
48
|
registerCommand() {},
|
|
@@ -47,7 +50,7 @@ function runtime(
|
|
|
47
50
|
const controller = tools.get("gentle_review");
|
|
48
51
|
assert.ok(controller);
|
|
49
52
|
assert.ok(toolCall);
|
|
50
|
-
return { controller, toolCall };
|
|
53
|
+
return { controller, toolCall, async lifecycle(name, event, ctx) { await handlers.get(name)?.(event, ctx); } };
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
function context(cwd: string): ExtensionContext {
|
|
@@ -69,6 +72,156 @@ function repository(t: test.TestContext, prefix = "gentle-pi-workspace-root-"):
|
|
|
69
72
|
return cwd;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
for (const operation of ["inspect", "start"] as const) {
|
|
76
|
+
for (const mode of ["on", "off", "unknown", "missing", "malformed", "throws"] as const) {
|
|
77
|
+
test(`non-Git ${operation} requires validated RDD on: ${mode}`, async () => {
|
|
78
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-target-")));
|
|
79
|
+
writeFileSync(join(cwd, "candidate.txt"), "unchanged candidate\n");
|
|
80
|
+
const requests: string[] = [];
|
|
81
|
+
const native = fakeNative({
|
|
82
|
+
reviewMode: mode === "missing" ? undefined : (async () => {
|
|
83
|
+
if (mode === "throws") throw new Error("mode unavailable");
|
|
84
|
+
return { operation: "status", scope: "clone", status: { global: "", cloneLocal: "", effective: mode === "malformed" ? "on" : mode, source: mode === "malformed" ? "invalid" : "global" } };
|
|
85
|
+
}) as NonNullable<NativeReviewCli["reviewMode"]>,
|
|
86
|
+
targetStatus: async (request) => {
|
|
87
|
+
requests.push(request.cwd);
|
|
88
|
+
// Stop at entry: this contract does not exercise START or consent.
|
|
89
|
+
throw new Error("native preparation reached");
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
for (const explicit of [false, true]) {
|
|
93
|
+
requests.length = 0;
|
|
94
|
+
const result = await __testing.executeReviewControllerOperation({
|
|
95
|
+
operation, ...(explicit ? { workspaceRoot: cwd } : {}),
|
|
96
|
+
...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}),
|
|
97
|
+
}, explicit ? process.cwd() : cwd, native);
|
|
98
|
+
assert.equal(result.status, "blocked");
|
|
99
|
+
assert.deepEqual(requests, mode === "on" ? [cwd] : []);
|
|
100
|
+
}
|
|
101
|
+
assert.equal(readFileSync(join(cwd, "candidate.txt"), "utf8"), "unchanged candidate\n");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const operation of ["inspect", "start"] as const) {
|
|
107
|
+
for (const transition of ["shutdown", "replacement", "same-manager-rebind", "changed-id", "cancelled", "live"] as const) {
|
|
108
|
+
test(`explicit non-Git ${operation} pins lifecycle across held mode: ${transition}`, async t => {
|
|
109
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-explicit-lifecycle-")));
|
|
110
|
+
const home = join(fixture, "home");
|
|
111
|
+
const project = join(fixture, "project");
|
|
112
|
+
mkdirSync(home); mkdirSync(project);
|
|
113
|
+
writeFileSync(join(project, "candidate.txt"), "unchanged candidate\n");
|
|
114
|
+
const previous = { HOME: process.env.HOME, GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
115
|
+
Object.assign(process.env, { HOME: home, GENTLE_PI_AGENT_HOME: join(fixture, "agent"), GENTLE_PI_CONFIG_HOME: join(fixture, "config") });
|
|
116
|
+
t.after(() => {
|
|
117
|
+
for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
|
118
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
let entered!: () => void;
|
|
121
|
+
const modeEntered = new Promise<void>(resolve => { entered = resolve; });
|
|
122
|
+
let release!: () => void;
|
|
123
|
+
const heldMode = new Promise<void>(resolve => { release = resolve; });
|
|
124
|
+
let hold = false;
|
|
125
|
+
let statuses = 0;
|
|
126
|
+
let starts = 0;
|
|
127
|
+
const native = fakeNative({
|
|
128
|
+
reviewMode: async () => {
|
|
129
|
+
if (hold) { entered(); await heldMode; }
|
|
130
|
+
return { operation: "status", scope: "global", status: { global: "on", cloneLocal: "", effective: "on", source: "global" } };
|
|
131
|
+
},
|
|
132
|
+
targetStatus: async request => {
|
|
133
|
+
statuses++;
|
|
134
|
+
assert.equal(request.cwd, project);
|
|
135
|
+
// Only the fake provider mutates this disposable project. Stop before consent/START.
|
|
136
|
+
execFileSync("git", ["init", "--quiet", project]);
|
|
137
|
+
throw new Error("fixture preparation completed");
|
|
138
|
+
},
|
|
139
|
+
start: async () => { starts++; throw new Error("unexpected START"); },
|
|
140
|
+
});
|
|
141
|
+
const h = runtime(native);
|
|
142
|
+
const session = { ...context(home), sessionManager: { getSessionId: () => "same-id", getCwd: () => home, getEntries: () => [], getBranch: () => [] } } as unknown as ExtensionContext;
|
|
143
|
+
await h.lifecycle("session_start", { reason: "startup" }, session);
|
|
144
|
+
assert.equal(statuses, 0);
|
|
145
|
+
hold = true;
|
|
146
|
+
const abort = new AbortController();
|
|
147
|
+
const pending = h.controller.execute("held-explicit", { operation, workspaceRoot: project, ...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}) }, abort.signal, undefined, session);
|
|
148
|
+
// Observe rejection immediately, so a revoked call cannot become unhandled.
|
|
149
|
+
const settled = pending.then(result => ({ result }), error => ({ error }));
|
|
150
|
+
await modeEntered;
|
|
151
|
+
if (transition === "shutdown") await h.lifecycle("session_shutdown", { reason: "quit" }, session);
|
|
152
|
+
if (transition === "replacement") {
|
|
153
|
+
const successor = { ...session, sessionManager: { getSessionId: () => "same-id", getCwd: () => home, getEntries: () => [], getBranch: () => [] } } as unknown as ExtensionContext;
|
|
154
|
+
assert.notEqual(successor.sessionManager, session.sessionManager);
|
|
155
|
+
await h.lifecycle("session_start", { reason: "new" }, successor);
|
|
156
|
+
}
|
|
157
|
+
if (transition === "same-manager-rebind") await h.lifecycle("session_start", { reason: "reload" }, session);
|
|
158
|
+
if (transition === "changed-id") session.sessionManager.getSessionId = () => "changed-id";
|
|
159
|
+
if (transition === "cancelled") abort.abort();
|
|
160
|
+
release();
|
|
161
|
+
await settled;
|
|
162
|
+
assert.equal(abort.signal.aborted, transition === "cancelled", "lifecycle revocation is independent of caller abort");
|
|
163
|
+
assert.equal(statuses, transition === "live" ? 1 : 0, "revoked original context cannot reach native bootstrap STATUS");
|
|
164
|
+
assert.equal(starts, 0);
|
|
165
|
+
assert.equal(existsSync(join(project, ".git")), transition === "live");
|
|
166
|
+
assert.equal(existsSync(join(home, ".git")), false, "explicit selection never initializes HOME");
|
|
167
|
+
assert.equal(readFileSync(join(project, "candidate.txt"), "utf8"), "unchanged candidate\n");
|
|
168
|
+
await h.lifecycle("session_shutdown", { reason: "quit" }, session);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("invalid bootstrap target selectors reject before native STATUS", async () => {
|
|
174
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-invalid-")));
|
|
175
|
+
let statusCalls = 0;
|
|
176
|
+
const native = fakeNative({
|
|
177
|
+
reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: "global" } }),
|
|
178
|
+
targetStatus: async () => { statusCalls += 1; throw new Error("must not reach STATUS"); },
|
|
179
|
+
});
|
|
180
|
+
for (const parameters of [
|
|
181
|
+
{ operation: "inspect", input: JSON.stringify({ baseRef: "HEAD" }) },
|
|
182
|
+
{ operation: "inspect", input: JSON.stringify({ baseRef: "missing-ref", committedOnly: true }) },
|
|
183
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary", focus: "invalid" }) },
|
|
184
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary", policyPath: "../outside" }) },
|
|
185
|
+
]) {
|
|
186
|
+
const result = await __testing.executeReviewControllerOperation(parameters, cwd, native);
|
|
187
|
+
assert.equal(result.status, "blocked");
|
|
188
|
+
}
|
|
189
|
+
for (const workspaceRoot of ["relative", join(cwd, "missing")]) {
|
|
190
|
+
await assert.rejects(__testing.executeReviewControllerOperation({ operation: "inspect", workspaceRoot }, cwd, native));
|
|
191
|
+
}
|
|
192
|
+
assert.equal(statusCalls, 0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("repository discovery ignores ambient Git root overrides", async (t) => {
|
|
196
|
+
const sessionRoot = repository(t);
|
|
197
|
+
const targetRoot = repository(t, "gentle-pi-isolated-target-");
|
|
198
|
+
const previous = { GIT_DIR: process.env.GIT_DIR, GIT_WORK_TREE: process.env.GIT_WORK_TREE };
|
|
199
|
+
t.after(() => {
|
|
200
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
201
|
+
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
process.env.GIT_DIR = join(sessionRoot, ".git");
|
|
205
|
+
process.env.GIT_WORK_TREE = sessionRoot;
|
|
206
|
+
const requests: string[] = [];
|
|
207
|
+
await __testing.executeReviewControllerOperation({ operation: "inspect", workspaceRoot: targetRoot }, sessionRoot,
|
|
208
|
+
fakeNative({ targetStatus: async (request) => { requests.push(request.cwd); throw new Error("entry only"); } }));
|
|
209
|
+
assert.deepEqual(requests, [targetRoot]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("non-Git bound and maintenance routes cannot use preparation entry", async () => {
|
|
213
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-bootstrap-bound-")));
|
|
214
|
+
let statusCalls = 0;
|
|
215
|
+
const native = fakeNative({ targetStatus: async () => { statusCalls += 1; throw new Error("must not reach STATUS"); } });
|
|
216
|
+
for (const parameters of [
|
|
217
|
+
{ operation: "status" },
|
|
218
|
+
{ operation: "inspect", lineageId: "bound" },
|
|
219
|
+
{ operation: "start", input: JSON.stringify({ mode: "judgment-day" }) },
|
|
220
|
+
{ operation: "reset", input: "{}" },
|
|
221
|
+
]) await assert.rejects(__testing.executeReviewControllerOperation(parameters, cwd, native));
|
|
222
|
+
assert.equal(statusCalls, 0);
|
|
223
|
+
});
|
|
224
|
+
|
|
72
225
|
function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
|
|
73
226
|
const parent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-workspace-worktrees-")));
|
|
74
227
|
t.after(() => {
|
|
@@ -83,7 +236,6 @@ function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
|
|
|
83
236
|
function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
|
|
84
237
|
return {
|
|
85
238
|
start: async () => ({ lineageId: "native-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 2, changedLines: 7, correctionBudget: 4, action: "created", lensesRequired: true }),
|
|
86
|
-
sddStatus: async () => ({ ready: false }),
|
|
87
239
|
reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: { schema: "gentle-ai.review-authority-status/v1", operation: "review/status", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [] } }),
|
|
88
240
|
targetStatus: async (request) => request.lineageId === undefined
|
|
89
241
|
? candidateStartTargetStatus(request)
|
|
@@ -487,7 +639,6 @@ test("workspaceRoot fails closed before any native call for invalid target paths
|
|
|
487
639
|
});
|
|
488
640
|
const { controller } = runtime(counting);
|
|
489
641
|
const rejected: Array<{ label: string; workspaceRoot: string }> = [
|
|
490
|
-
{ label: "non-git directory", workspaceRoot: nonGit },
|
|
491
642
|
{ label: "missing directory", workspaceRoot: join(nonGit, "missing") },
|
|
492
643
|
{ label: "file path", workspaceRoot: filePath },
|
|
493
644
|
{ label: "relative path", workspaceRoot: "relative/worktree" },
|
|
@@ -506,6 +657,14 @@ test("workspaceRoot fails closed before any native call for invalid target paths
|
|
|
506
657
|
);
|
|
507
658
|
}
|
|
508
659
|
}
|
|
660
|
+
for (const operation of ["inspect", "start"] as const) {
|
|
661
|
+
const result = await controller.execute(`non-git-${operation}`, {
|
|
662
|
+
operation, workspaceRoot: nonGit,
|
|
663
|
+
...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}),
|
|
664
|
+
}, undefined, undefined, context(sessionCwd));
|
|
665
|
+
assert.equal((result.details as Record<string, unknown>).outcome, "native-repository-bootstrap-not-authorized");
|
|
666
|
+
}
|
|
667
|
+
await assert.rejects(controller.execute("non-git-bound", { operation: "status", workspaceRoot: nonGit, lineageId: "bound" }, undefined, undefined, context(sessionCwd)), /workspaceRoot/);
|
|
509
668
|
assert.equal(nativeCalls, 0);
|
|
510
669
|
});
|
|
511
670
|
|
|
@@ -5,9 +5,15 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
9
|
+
import { localProfilePinPath, repoProfileDeclarationPath, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
|
|
10
|
+
import { createProfile, emptyProfilesFile, profilesFilePath, writeProfilesFileSync } from "../lib/agent-profiles.ts";
|
|
11
|
+
import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
|
|
12
|
+
import { GENTLE_AI_DEV_BINARY_ENV, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
8
13
|
import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
|
|
9
14
|
import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
|
|
10
15
|
import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
16
|
+
import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
|
|
11
17
|
|
|
12
18
|
// One-slot capture routing: the host relay runs only when the selected
|
|
13
19
|
// provider-returned collect input carries the --materialize token. Every
|
|
@@ -216,16 +222,133 @@ function nativeHarness(statuses: readonly ReviewStatusV3[], unachievableResponde
|
|
|
216
222
|
return harness;
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
|
|
225
|
+
async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }, modelRegistry?: InProcessReviewerRegistry): Promise<Record<string, unknown>> {
|
|
220
226
|
const selected = harness.statusQueue[0]?.nextTransition?.collect?.inputs[0];
|
|
221
227
|
if (selected === undefined) throw new Error("capture test requires one current collect input");
|
|
222
228
|
return await __testing.executeReviewCaptureOperation(
|
|
223
229
|
{ lineageId, collectBinding: JSON.stringify(selected), ...input },
|
|
224
230
|
cwd,
|
|
225
231
|
harness.native,
|
|
232
|
+
undefined, undefined, undefined, false, modelRegistry,
|
|
226
233
|
) as Record<string, unknown>;
|
|
227
234
|
}
|
|
228
235
|
|
|
236
|
+
// Fake STATUS/relay evidence exercises facade admission, not native-provider E2E.
|
|
237
|
+
test("capture route recovery revalidates a single forecast after route loss", async (t) => {
|
|
238
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
239
|
+
const cwd = repository(t), lineageId = "recovered-single", input = relayCollectInput(lineageId, "review-risk", 0);
|
|
240
|
+
const selections = new Map(), current = finalizeStatus(lineageId, [input]);
|
|
241
|
+
const harness = nativeHarness([current, current, current]);
|
|
242
|
+
let relays = 0;
|
|
243
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
244
|
+
relays += 1;
|
|
245
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
246
|
+
});
|
|
247
|
+
await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, harness.native, undefined, null, undefined, selections);
|
|
248
|
+
const parameters = { lineageId, collectBinding: JSON.stringify(input) };
|
|
249
|
+
const forecast = await __testing.executeReviewCaptureOperation(parameters, cwd, harness.native, undefined, null, selections, true);
|
|
250
|
+
assert.equal(forecast.outcome, "reviewer-model-run-forecast");
|
|
251
|
+
assert.equal(relays, 0);
|
|
252
|
+
selections.clear();
|
|
253
|
+
const result = await __testing.executeReviewCaptureOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
|
|
254
|
+
assert.equal(result.status, "captured");
|
|
255
|
+
assert.equal(relays, 1);
|
|
256
|
+
assert.equal(harness.statusCalls.length, 3);
|
|
257
|
+
assert.ok(harness.statusCalls.every((request) => request.agent === "pi"));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("capture route recovery revalidates a complete group after forecast eviction", async (t) => {
|
|
261
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
262
|
+
const cwd = repository(t), lineageId = "recovered-group", inputs = groupInputs(lineageId), selections = new Map();
|
|
263
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, inputs.slice(index))), finalizeStatus(lineageId)]);
|
|
264
|
+
let relays = 0, submissions = 0;
|
|
265
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { relays += requests.length; return requests.map(prepared); }, async () => {
|
|
266
|
+
submissions += 1;
|
|
267
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: "{}" };
|
|
268
|
+
});
|
|
269
|
+
await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, harness.native, undefined, null, undefined, selections);
|
|
270
|
+
const parameters = { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)) };
|
|
271
|
+
const forecast = await __testing.executeReviewCaptureGroupOperation(parameters, cwd, harness.native, undefined, null, selections, true);
|
|
272
|
+
assert.equal(forecast.outcome, "reviewer-model-run-forecast");
|
|
273
|
+
assert.equal(relays, 0);
|
|
274
|
+
selections.clear();
|
|
275
|
+
const result = await __testing.executeReviewCaptureGroupOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
|
|
276
|
+
assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
|
|
277
|
+
assert.equal(relays, 4);
|
|
278
|
+
assert.equal(submissions, 4);
|
|
279
|
+
assert.equal(harness.statusCalls.length, 8);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("capture route recovery preserves the committed group selector after partial route loss", async (t) => {
|
|
283
|
+
const candidateViews = new CandidateViewRegistry();
|
|
284
|
+
t.after(() => { candidateViews.cleanupAll(); __testing.setReviewHostRelayGroupRunnersForTesting(); });
|
|
285
|
+
const cwd = repository(t), lineageId = "recovered-committed-group", inputs = groupInputs(lineageId), selections = new Map();
|
|
286
|
+
const view = candidateViews.create({ contributorRoot: cwd, baseRef: execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(), committedOnly: true });
|
|
287
|
+
candidateViews.retain(view.token, lineageId);
|
|
288
|
+
const target = candidateViews.resolveProjection(lineageId, cwd), requests: Array<Record<string, unknown>> = [];
|
|
289
|
+
const statuses = [finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, inputs.slice(index))), finalizeStatus(lineageId)];
|
|
290
|
+
const native = { targetStatus: async (request: Record<string, unknown>) => { requests.push(request); return statuses.shift()!; } } as unknown as NativeReviewCli;
|
|
291
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => requests.map(prepared), async () => ({ promptByteLength: 64, resultByteLength: 32, submission: "{}" }));
|
|
292
|
+
const parameters = { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)) };
|
|
293
|
+
const forecast = await __testing.executeReviewCaptureGroupOperation(parameters, cwd, native, undefined, candidateViews, selections, true);
|
|
294
|
+
assert.equal(forecast.outcome, "reviewer-model-run-forecast");
|
|
295
|
+
// Leave only a later matching route, rather than assuming the first survives.
|
|
296
|
+
for (const key of [...selections.keys()].slice(0, 3)) selections.delete(key);
|
|
297
|
+
const result = await __testing.executeReviewCaptureGroupOperation({ ...parameters, reviewerRunAcknowledged: true }, cwd, native, undefined, candidateViews, selections, true);
|
|
298
|
+
assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
|
|
299
|
+
assert.equal(requests.length, 7);
|
|
300
|
+
assert.ok(requests.every((request) => request.baseRef === target.baseCommit && request.committedOnly === true && request.agent === "pi"));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("capture route recovery rejects known workspace, lineage and selector route mismatches", async (t) => {
|
|
304
|
+
const cwd = repository(t), other = repository(t), lineageId = "known-route-boundaries", inputs = groupInputs(lineageId), selections = new Map();
|
|
305
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs)]);
|
|
306
|
+
await __testing.executeReviewControllerOperation({ operation: "status", lineageId, input: JSON.stringify({ baseRef: "trusted-base", committedOnly: true }) }, cwd, harness.native, undefined, null, undefined, selections);
|
|
307
|
+
for (const parameters of [{ lineageId, workspaceRoot: other }, { lineageId: "foreign" }]) {
|
|
308
|
+
const single = await __testing.executeReviewCaptureOperation({ ...parameters, collectBinding: JSON.stringify(inputs[0]), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
|
|
309
|
+
assert.equal(single.outcome, "capture-binding-rejected");
|
|
310
|
+
const group = await __testing.executeReviewCaptureGroupOperation({ ...parameters, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
|
|
311
|
+
assert.equal(group.outcome, "capture-group-rejected");
|
|
312
|
+
}
|
|
313
|
+
const key = [...selections.keys()][1]!;
|
|
314
|
+
selections.set(key, { ...selections.get(key), baseRef: "wrong-base" });
|
|
315
|
+
const mixed = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, harness.native, undefined, null, selections, true);
|
|
316
|
+
assert.equal(mixed.outcome, "capture-group-rejected");
|
|
317
|
+
assert.equal(harness.statusCalls.length, 1, "known route mismatches never retarget native STATUS or launch capture");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("capture route recovery rejects fresh foreign, terminal, stale and malformed groups without relay", async (t) => {
|
|
321
|
+
t.after(() => { __testing.setReviewHostRelayRunnerForTesting(); __testing.setReviewHostRelayGroupRunnersForTesting(); });
|
|
322
|
+
const cwd = repository(t), lineageId = "recovery-negatives", inputs = groupInputs(lineageId);
|
|
323
|
+
let relays = 0;
|
|
324
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => { relays += 1; throw new Error("unexpected relay"); });
|
|
325
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async () => { relays += 1; throw new Error("unexpected group"); }, async () => { throw new Error("unexpected submission"); });
|
|
326
|
+
const current = finalizeStatus(lineageId, inputs);
|
|
327
|
+
for (const fresh of [
|
|
328
|
+
finalizeStatus(lineageId), finalizeStatus("foreign", inputs),
|
|
329
|
+
{ ...current, targetIdentity: `sha256:${"9".repeat(64)}` },
|
|
330
|
+
{ ...current, applicability: "unrelated" },
|
|
331
|
+
{ ...current, authority: { ...current.authority!, state: "approved" } },
|
|
332
|
+
finalizeStatus(lineageId, groupInputs(lineageId, PHASE_REVISION)),
|
|
333
|
+
] as ReviewStatusV3[]) {
|
|
334
|
+
const single = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: JSON.stringify(inputs[0]), reviewerRunAcknowledged: true }, cwd, nativeHarness([fresh]).native, undefined, null, new Map(), true);
|
|
335
|
+
assert.equal(single.outcome, "capture-binding-rejected");
|
|
336
|
+
if (fresh.applicability !== "current_target" || fresh.authority?.state === "approved") {
|
|
337
|
+
const routes = new Map();
|
|
338
|
+
const listed = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, nativeHarness([fresh]).native, undefined, null, undefined, routes);
|
|
339
|
+
assert.deepEqual(listed.collectBindings, [], "publication follows route-registration eligibility");
|
|
340
|
+
assert.equal(routes.size, 0);
|
|
341
|
+
}
|
|
342
|
+
const group = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, nativeHarness([fresh]).native, undefined, null, new Map(), true);
|
|
343
|
+
assert.equal(group.outcome, "capture-group-rejected");
|
|
344
|
+
}
|
|
345
|
+
for (const submitted of [inputs.slice(1), [inputs[0], inputs[0], ...inputs.slice(2)], [...inputs].reverse(), [...inputs.slice(0, 3), relayCollectInput("foreign", "review-reliability", 3)]]) {
|
|
346
|
+
const group = await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: submitted.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true }, cwd, nativeHarness([current]).native, undefined, null, new Map(), true);
|
|
347
|
+
assert.equal(group.outcome, "capture-group-rejected");
|
|
348
|
+
}
|
|
349
|
+
assert.equal(relays, 0);
|
|
350
|
+
});
|
|
351
|
+
|
|
229
352
|
test("one materialize binding routes exactly one provider slot through the host relay", async (t) => {
|
|
230
353
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
231
354
|
const cwd = repository(t);
|
|
@@ -315,7 +438,7 @@ test("Pi-authored review documents are rejected at the capture input boundary",
|
|
|
315
438
|
|
|
316
439
|
function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
|
|
317
440
|
|
|
318
|
-
async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
|
|
441
|
+
async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true, modelRegistry?: InProcessReviewerRegistry): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native, undefined, undefined, undefined, false, modelRegistry) as Record<string, unknown>; }
|
|
319
442
|
|
|
320
443
|
function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
|
|
321
444
|
|
|
@@ -1005,3 +1128,216 @@ test("collect inputs without the provider-issued materialize token never reach t
|
|
|
1005
1128
|
}
|
|
1006
1129
|
assert.equal(relayCalls, 0);
|
|
1007
1130
|
});
|
|
1131
|
+
|
|
1132
|
+
// gentle-shell#1544: the relay's reviewer selection must honor the repository
|
|
1133
|
+
// profile pin. These tests isolate the global config home via
|
|
1134
|
+
// GENTLE_PI_CONFIG_HOME so the pin's precedence over (and independence from)
|
|
1135
|
+
// models.json is observable without touching the operator's real
|
|
1136
|
+
// configuration; repository(t) supplies the Git identity the pin resolver reads.
|
|
1137
|
+
const GLOBAL_MODELS: AgentModelConfig = {
|
|
1138
|
+
"review-risk": { model: "global/risk-model" },
|
|
1139
|
+
"review-resilience": { model: "global/resilience-model" },
|
|
1140
|
+
"review-readability": { model: "global/readability-model" },
|
|
1141
|
+
"review-reliability": { model: "global/reliability-model" },
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
const PINNED_COMPLETE: AgentModelConfig = {
|
|
1145
|
+
"review-risk": { model: "pinned/risk-model" },
|
|
1146
|
+
"review-resilience": { model: "pinned/resilience-model" },
|
|
1147
|
+
"review-readability": { model: "pinned/readability-model" },
|
|
1148
|
+
"review-reliability": { model: "pinned/reliability-model" },
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
// A pin is a whole profile, not a per-role patch: this one deliberately omits
|
|
1152
|
+
// three reviewer roles that models.json still configures.
|
|
1153
|
+
const PINNED_PARTIAL: AgentModelConfig = { "review-risk": { model: "pinned/risk-model" } };
|
|
1154
|
+
|
|
1155
|
+
function pinFixture(t: test.TestContext): string {
|
|
1156
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-pin-config-"));
|
|
1157
|
+
const previous = process.env.GENTLE_PI_CONFIG_HOME;
|
|
1158
|
+
const previousDevBinary = process.env[GENTLE_AI_DEV_BINARY_ENV];
|
|
1159
|
+
// Redirecting the config home hides the ambient dev-binary registration, and
|
|
1160
|
+
// the pinned runtime refuses a symlinked .gentle-ai directory; resolve the
|
|
1161
|
+
// override while the ambient config home is still visible, then carry it
|
|
1162
|
+
// through the env activation path so the real relay path resolves its
|
|
1163
|
+
// executable the same way it does outside this fixture.
|
|
1164
|
+
const devBinary = resolveGentleAiDevBinaryOverride()?.path;
|
|
1165
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
1166
|
+
if (devBinary !== undefined) process.env[GENTLE_AI_DEV_BINARY_ENV] = devBinary;
|
|
1167
|
+
t.after(() => {
|
|
1168
|
+
if (previous === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
1169
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previous;
|
|
1170
|
+
if (previousDevBinary === undefined) delete process.env[GENTLE_AI_DEV_BINARY_ENV];
|
|
1171
|
+
else process.env[GENTLE_AI_DEV_BINARY_ENV] = previousDevBinary;
|
|
1172
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
1173
|
+
});
|
|
1174
|
+
let store = emptyProfilesFile();
|
|
1175
|
+
store = createProfile(store, "pinned-complete", PINNED_COMPLETE);
|
|
1176
|
+
store = createProfile(store, "pinned-partial", PINNED_PARTIAL);
|
|
1177
|
+
writeProfilesFileSync(profilesFilePath(configHome), store);
|
|
1178
|
+
writeFileSync(join(configHome, "models.json"), `${JSON.stringify(GLOBAL_MODELS, null, 2)}\n`);
|
|
1179
|
+
return repository(t);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function pinLocal(cwd: string, profile: string): void {
|
|
1183
|
+
writeProfilePinSync(localProfilePinPath(join(cwd, ".git")), profile);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function pinRepoDeclaration(cwd: string, profile: string): void {
|
|
1187
|
+
writeProfilePinSync(repoProfileDeclarationPath(cwd), profile);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
test("a valid local profile pin routes a single capture's reviewer selection through the pinned profile", async (t) => {
|
|
1191
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
1192
|
+
const cwd = pinFixture(t);
|
|
1193
|
+
pinLocal(cwd, "pinned-complete");
|
|
1194
|
+
const lineageId = "relay-pin-single";
|
|
1195
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
|
|
1196
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1197
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
1198
|
+
relayed.push(request);
|
|
1199
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
1203
|
+
|
|
1204
|
+
assert.equal(result.status, "captured");
|
|
1205
|
+
assert.equal(relayed.length, 1);
|
|
1206
|
+
assert.equal(relayed[0]!.routingKey, "review-risk");
|
|
1207
|
+
assert.equal(relayed[0]!.selection, "pinned/risk-model", "the pin re-anchors the reviewer routing away from models.json");
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("a valid profile pin routes every grouped reviewer slot through the pinned profile", async (t) => {
|
|
1211
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
1212
|
+
const cwd = pinFixture(t);
|
|
1213
|
+
pinLocal(cwd, "pinned-complete");
|
|
1214
|
+
const lineageId = "relay-pin-group";
|
|
1215
|
+
const inputs = groupInputs(lineageId);
|
|
1216
|
+
// After each submission the authoritative STATUS offers exactly the
|
|
1217
|
+
// unsubmitted suffix, so the loop's per-slot re-queries stay consistent.
|
|
1218
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(1)), finalizeStatus(lineageId, inputs.slice(2)), finalizeStatus(lineageId, inputs.slice(3))]);
|
|
1219
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1220
|
+
let submissions = 0;
|
|
1221
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(
|
|
1222
|
+
async (requests) => { relayed.push(...requests); return requests.map(prepared); },
|
|
1223
|
+
async (result) => {
|
|
1224
|
+
submissions += 1;
|
|
1225
|
+
return {
|
|
1226
|
+
promptByteLength: result.promptByteLength,
|
|
1227
|
+
resultByteLength: result.resultByteLength,
|
|
1228
|
+
submission: submissions === inputs.length
|
|
1229
|
+
? JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" })
|
|
1230
|
+
: "{}",
|
|
1231
|
+
};
|
|
1232
|
+
},
|
|
1233
|
+
);
|
|
1234
|
+
|
|
1235
|
+
const result = await runCaptureGroup(cwd, harness, lineageId, inputs);
|
|
1236
|
+
|
|
1237
|
+
assert.equal(result.status, "closed");
|
|
1238
|
+
assert.deepEqual(relayed.map((request) => [request.routingKey, request.selection]), [
|
|
1239
|
+
["review-risk", "pinned/risk-model"],
|
|
1240
|
+
["review-resilience", "pinned/resilience-model"],
|
|
1241
|
+
["review-readability", "pinned/readability-model"],
|
|
1242
|
+
["review-reliability", "pinned/reliability-model"],
|
|
1243
|
+
]);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("without a pin the relay keeps the global models.json routing", async (t) => {
|
|
1247
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
1248
|
+
const cwd = pinFixture(t);
|
|
1249
|
+
const lineageId = "relay-pin-absent";
|
|
1250
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
|
|
1251
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1252
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
1253
|
+
relayed.push(request);
|
|
1254
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
1258
|
+
|
|
1259
|
+
assert.equal(result.status, "captured");
|
|
1260
|
+
assert.equal(relayed[0]!.selection, "global/risk-model", "no pin is exactly the pre-pin global behavior");
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
test("a repository profile declaration routes the relay when no local pin exists", async (t) => {
|
|
1264
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
1265
|
+
const cwd = pinFixture(t);
|
|
1266
|
+
pinRepoDeclaration(cwd, "pinned-complete");
|
|
1267
|
+
const lineageId = "relay-pin-repo-declaration";
|
|
1268
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
|
|
1269
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1270
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
1271
|
+
relayed.push(request);
|
|
1272
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
1276
|
+
|
|
1277
|
+
assert.equal(result.status, "captured");
|
|
1278
|
+
assert.equal(relayed[0]!.selection, "pinned/risk-model", "the committed declaration reaches the reviewer routing through the same resolver");
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("a stale pin naming a dropped profile falls back to the global routing", async (t) => {
|
|
1282
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
1283
|
+
const cwd = pinFixture(t);
|
|
1284
|
+
pinLocal(cwd, "ghost-profile");
|
|
1285
|
+
const lineageId = "relay-pin-stale";
|
|
1286
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
|
|
1287
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1288
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
1289
|
+
relayed.push(request);
|
|
1290
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
1294
|
+
|
|
1295
|
+
assert.equal(result.status, "captured");
|
|
1296
|
+
assert.equal(relayed[0]!.selection, "global/risk-model", "a stale chain degrades to the global routing instead of blocking");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("a pinned profile missing a single-capture role sends no selection instead of borrowing models.json's role", async (t) => {
|
|
1300
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
1301
|
+
const cwd = pinFixture(t);
|
|
1302
|
+
pinLocal(cwd, "pinned-partial");
|
|
1303
|
+
const lineageId = "relay-pin-missing-single";
|
|
1304
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]);
|
|
1305
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
1306
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
1307
|
+
relayed.push(request);
|
|
1308
|
+
return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
await runCapture(cwd, harness, lineageId);
|
|
1312
|
+
|
|
1313
|
+
assert.equal(relayed.length, 1);
|
|
1314
|
+
assert.equal(relayed[0]!.selection, undefined, "whole-profile precedence: models.json must not fill the pinned profile's gaps");
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("a pinned profile missing a required group role is refused typed before any reviewer launch", async (t) => {
|
|
1318
|
+
const cwd = pinFixture(t);
|
|
1319
|
+
pinLocal(cwd, "pinned-partial");
|
|
1320
|
+
const lineageId = "relay-pin-missing-group";
|
|
1321
|
+
// One group slot whose role the pin omits: models.json still configures it,
|
|
1322
|
+
// so a per-role fallback would launch instead of refusing typed.
|
|
1323
|
+
const inputs = [relayCollectInput(lineageId, "review-resilience", 0)];
|
|
1324
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs)]);
|
|
1325
|
+
// Instrument the real model-start/auth boundary without bypassing the live
|
|
1326
|
+
// group runner: a reviewer model can only start after resolving through
|
|
1327
|
+
// registry.find and authenticating through registry.getApiKeyAndHeaders, so
|
|
1328
|
+
// counting those seams proves no model resolution, auth, or completion ran.
|
|
1329
|
+
let modelLookups = 0, authAttempts = 0;
|
|
1330
|
+
const registry: InProcessReviewerRegistry = {
|
|
1331
|
+
find: () => { modelLookups += 1; return undefined; },
|
|
1332
|
+
getApiKeyAndHeaders: async () => { authAttempts += 1; return { ok: true }; },
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
const result = await runCaptureGroup(cwd, harness, lineageId, inputs, true, registry);
|
|
1336
|
+
|
|
1337
|
+
assert.equal(result.outcome, "pi-host-relay-transport-failure");
|
|
1338
|
+
const failure = result.failure as { kind?: string } | undefined;
|
|
1339
|
+
assert.equal(failure?.kind, "reviewer-config-invalid");
|
|
1340
|
+
assert.match(String(result.reason), /no model is configured for review-resilience/, "whole-profile precedence: models.json must not fill the pinned profile's gaps");
|
|
1341
|
+
assert.equal(modelLookups, 0, "the typed refusal fires before any model resolution");
|
|
1342
|
+
assert.equal(authAttempts, 0, "the typed refusal fires before any provider auth");
|
|
1343
|
+
});
|