gentle-pi 2.4.0 → 2.5.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.
Files changed (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -0,0 +1,608 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { PassThrough } from "node:stream";
7
+ import test from "node:test";
8
+ import { Text, visibleWidth } from "@earendil-works/pi-tui";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
11
+ import { ChildStandingReviewPermissionClient, ParentStandingReviewPermissionBroker } from "../lib/review-session-standing-permission-ipc.ts";
12
+ import { captureReviewSessionIdentity, grantReviewSessionPermission, hasReviewSessionPermission, revokeReviewSessionPermissionsForSession } from "../lib/review-session-standing-permission.ts";
13
+ import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
14
+ import { NativeReviewConsentRequiredError, type NativeReviewCli } from "../lib/native-review-cli.ts";
15
+ import { decodeReviewConsentV2, decodeReviewConsentV3, type ReviewConsentEnvelope, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
16
+ import {
17
+ HOST_REVIEW_SESSION_PERMISSION_LABEL,
18
+ formatReviewConsentUi,
19
+ presentReviewConsentUi,
20
+ } from "../lib/review-consent-ui.ts";
21
+
22
+ function consentFixture(): Record<string, unknown> {
23
+ const path = join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json");
24
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
25
+ }
26
+
27
+ function consent() {
28
+ const raw = consentFixture();
29
+ raw.agent = "pi";
30
+ return decodeReviewConsentV3(raw, "pi");
31
+ }
32
+
33
+ function providerV2Consent() {
34
+ const raw = consentFixture();
35
+ delete raw.agent;
36
+ raw.schema = "gentle-ai.review-integration.consent/v2";
37
+ return decodeReviewConsentV2(raw);
38
+ }
39
+
40
+ function nonPiV3Consent() {
41
+ return decodeReviewConsentV3(consentFixture(), "claude-code");
42
+ }
43
+
44
+ test("host UI preserves the complete provider envelope and adds a separately owned third action", () => {
45
+ const envelope = consent();
46
+ const before = structuredClone(envelope.raw);
47
+ const model = formatReviewConsentUi(envelope);
48
+ assert.match(model.title, new RegExp(envelope.headline.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
49
+ for (const text of [envelope.reason, envelope.value, ...envelope.riskEvidence, envelope.offPath.note, envelope.offPath.command]) {
50
+ assert.ok(model.title.includes(text), `missing provider text: ${text}`);
51
+ }
52
+ assert.equal(model.options.length, 3);
53
+ for (const [index, choice] of envelope.choices.entries()) {
54
+ assert.ok(model.options[index]!.includes(choice.label));
55
+ assert.ok(model.options[index]!.includes(choice.effect));
56
+ }
57
+ assert.ok(model.options[2]!.includes(HOST_REVIEW_SESSION_PERMISSION_LABEL));
58
+ assert.match(model.title, /The first two actions are provider-owned and apply only to this candidate/);
59
+ assert.match(model.title, /The third action is owned by the Pi host/);
60
+ assert.equal(envelope.choices.length, 2, "the decoded provider envelope must remain a two-choice contract");
61
+ assert.deepEqual(envelope.raw, before, "formatting must not mutate or append to the provider envelope");
62
+ });
63
+
64
+ test("the real Pi Text primitive bounds every consent line at narrow terminal widths", () => {
65
+ const model = formatReviewConsentUi(consent());
66
+ for (const width of [1, 2, 8, 20, 40]) {
67
+ for (const text of [model.title, ...model.options]) {
68
+ const lines = new Text(text, 0, 0).render(width);
69
+ assert.ok(lines.length > 0);
70
+ for (const line of lines) assert.ok(visibleWidth(line) <= width, `width ${width}: ${line}`);
71
+ }
72
+ }
73
+ });
74
+
75
+ test("selection maps only exact displayed actions and cancellation or UI failure stays unresolved", async () => {
76
+ const envelope = consent();
77
+ const model = formatReviewConsentUi(envelope);
78
+ const selections = [model.options[0], model.options[1], model.options[2], undefined] as const;
79
+ const expected = [
80
+ { kind: "provider", answer: "granted" },
81
+ { kind: "provider", answer: "declined" },
82
+ { kind: "host-session" },
83
+ undefined,
84
+ ];
85
+ for (let index = 0; index < selections.length; index += 1) {
86
+ const ctx = { ui: { select: async () => selections[index] } } as unknown as ExtensionContext;
87
+ assert.deepEqual(await presentReviewConsentUi(ctx, envelope), expected[index]);
88
+ }
89
+ const failed = { ui: { select: async () => { throw new Error("UI unavailable"); } } } as unknown as ExtensionContext;
90
+ assert.equal(await presentReviewConsentUi(failed, envelope), undefined);
91
+ });
92
+
93
+ interface RegisteredTool {
94
+ execute(id: string, parameters: unknown, signal: undefined, onUpdate: undefined, context: ExtensionContext): Promise<{ details: Record<string, unknown> }>;
95
+ }
96
+
97
+ interface RegisteredCommand {
98
+ handler(args: string, context: ExtensionContext): Promise<void>;
99
+ }
100
+
101
+ interface RegisteredEvent {
102
+ (event: unknown, context: ExtensionContext): Promise<unknown> | unknown;
103
+ }
104
+
105
+ function reviewRepository(t: test.TestContext): string {
106
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-session-consent-")));
107
+ t.after(() => {
108
+ try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
109
+ rmSync(cwd, { recursive: true, force: true });
110
+ });
111
+ execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
112
+ writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
113
+ execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
114
+ execFileSync("git", ["-c", "user.name=Consent Test", "-c", "user.email=consent@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
115
+ writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
116
+ return cwd;
117
+ }
118
+
119
+ function siblingWorktree(t: test.TestContext, parentRoot: string): string {
120
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-session-consent-worktree-")));
121
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
122
+ execFileSync("git", ["worktree", "add", "-b", `permission-child-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, cwd, "HEAD"], { cwd: parentRoot, stdio: "ignore" });
123
+ return cwd;
124
+ }
125
+
126
+ function startStatus(cwd: string, intendedUntracked: readonly string[] = []): ReviewStatusV3 {
127
+ const views = new CandidateViewRegistry();
128
+ const candidate = views.create({
129
+ contributorRoot: cwd,
130
+ ...(intendedUntracked.length === 0 ? {} : { intendedUntracked }),
131
+ });
132
+ try {
133
+ return {
134
+ contract: "gentle-ai.review-integration/v2",
135
+ applicability: "unrelated",
136
+ action: "start",
137
+ replayability: "not_replayable",
138
+ targetIdentity: `sha256:${"a".repeat(64)}`,
139
+ projection: {
140
+ schema: "gentle-ai.review-candidate-projection/v1",
141
+ kind: "current-changes",
142
+ projection: "workspace",
143
+ baseTree: candidate.baseTree,
144
+ initialReviewTree: candidate.candidateTree,
145
+ currentCandidateTree: candidate.candidateTree,
146
+ pathsDigest: `sha256:${"a".repeat(64)}`,
147
+ paths: [...candidate.paths],
148
+ intendedUntracked: [...intendedUntracked],
149
+ intendedUntrackedProof: `sha256:${"a".repeat(64)}`,
150
+ initialSnapshotIdentity: `sha256:${"a".repeat(64)}`,
151
+ currentSnapshotIdentity: `sha256:${"a".repeat(64)}`,
152
+ },
153
+ candidates: [],
154
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
155
+ } as unknown as ReviewStatusV3;
156
+ } finally {
157
+ views.cleanup(candidate.token);
158
+ }
159
+ }
160
+
161
+ function intendedUntrackedSelectionStatuses(cwd: string, eligible: string): { initial: ReviewStatusV3; selected: ReviewStatusV3 } {
162
+ const initialTarget = startStatus(cwd);
163
+ const selectedTarget = startStatus(cwd, [eligible]);
164
+ const selection = {
165
+ name: "intended_untracked_selection",
166
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
167
+ captureOperation: "external.select_intended_untracked",
168
+ arguments: [
169
+ { name: "target_identity", value: initialTarget.targetIdentity },
170
+ { name: "projection", value: "workspace" },
171
+ { name: "base_tree", value: initialTarget.projection.baseTree },
172
+ { name: "candidate_tree", value: initialTarget.projection.currentCandidateTree },
173
+ { name: "eligible_paths_json", value: JSON.stringify([eligible]) },
174
+ { name: "expected_untracked_inventory", value: "sha256:" + "a".repeat(64) },
175
+ ],
176
+ submission: {
177
+ operationToken: "status",
178
+ argumentTokens: ["--contract=gentle-ai.review-integration/v2", "--next-transition=true", "--agent=pi", "--projection=workspace", "--intended-untracked-selection={{value}}"],
179
+ values: [{ slot: "intended_untracked_selection", domain: "schema_bound_json", schema: "gentle-ai.review-intended-untracked-selection/v1", substitutionLocation: 4 }],
180
+ },
181
+ };
182
+ return {
183
+ initial: {
184
+ ...initialTarget,
185
+ nextTransition: { kind: "collect", reasonCode: "intended_untracked_selection_required", collect: { inputs: [selection] } },
186
+ raw: { schema: "gentle-ai.review-integration.status/v7" },
187
+ } as unknown as ReviewStatusV3,
188
+ selected: selectedTarget,
189
+ };
190
+ }
191
+
192
+ function piConsent() {
193
+ const decoded = consent();
194
+ return {
195
+ ...decoded,
196
+ choices: decoded.choices.map((choice) => ({
197
+ ...choice,
198
+ invocation: choice.invocation.replace(" --consent ", " --agent pi --consent "),
199
+ })) as typeof decoded.choices,
200
+ };
201
+ }
202
+
203
+ function controllerHarness(cwd: string, processEnv: NodeJS.ProcessEnv = {}, options: {
204
+ now?: () => number;
205
+ answerConsentError?: Error & { mutationOutcome?: "none" | "unknown" };
206
+ startAction?: "created" | "resumed" | "replayed" | "closed" | "blocked-scope-action";
207
+ childStandingReviewPermissionClient?: ChildStandingReviewPermissionClient;
208
+ consent?: ReviewConsentEnvelope;
209
+ targetStatus?: (request: Record<string, unknown>) => Promise<ReviewStatusV3>;
210
+ } = {}) {
211
+ const tools = new Map<string, RegisteredTool>();
212
+ const commands = new Map<string, RegisteredCommand>();
213
+ const events = new Map<string, RegisteredEvent>();
214
+ const answers: string[] = [];
215
+ const answerRequests: Array<{ cwd: string; consent: { raw: unknown }; answer: "granted" | "declined" }> = [];
216
+ const permissionConsent = options.consent ?? piConsent();
217
+ const native = {
218
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "", cloneLocal: "", effective: "on", source: "default" } }),
219
+ targetStatus: async (request: Record<string, unknown>) => options.targetStatus?.(request) ?? startStatus(cwd),
220
+ start: async () => { throw new NativeReviewConsentRequiredError(permissionConsent); },
221
+ answerConsent: async (request: { cwd: string; consent: { raw: unknown }; answer: "granted" | "declined" }) => {
222
+ answers.push(request.answer);
223
+ answerRequests.push(request);
224
+ if (options.answerConsentError !== undefined && answers.length === 1) throw options.answerConsentError;
225
+ if (request.answer === "declined") return {
226
+ kind: "declined",
227
+ targetIdentity: permissionConsent.targetIdentity,
228
+ projection: permissionConsent.projection,
229
+ riskLevel: permissionConsent.riskLevel,
230
+ changedFiles: permissionConsent.changedFiles,
231
+ changedLines: permissionConsent.changedLines,
232
+ consent: "declined_this_candidate",
233
+ raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" },
234
+ };
235
+ const action = options.startAction ?? "closed";
236
+ return { kind: "started", start: { lineageId: `lineage-${answers.length}`, state: action === "blocked-scope-action" ? "unreviewed" : "approved", riskLevel: "high", selectedLenses: [], changedFiles: 1, changedLines: 2, correctionBudget: 0, action, lensesRequired: false, riskReasons: [] } };
237
+ },
238
+ } as unknown as NativeReviewCli;
239
+ createGentleAiExtension({ nativeReviewCli: native, candidateViews: new CandidateViewRegistry(), processEnv, now: options.now, childStandingReviewPermissionClient: options.childStandingReviewPermissionClient })({
240
+ on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
241
+ registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
242
+ registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
243
+ events: { emit() {} },
244
+ } as unknown as ExtensionAPI);
245
+ const controller = tools.get("gentle_review");
246
+ assert.ok(controller);
247
+ return { controller: controller!, answers, answerRequests, consent: permissionConsent, commands, events };
248
+ }
249
+
250
+ function interactiveContext(cwd: string, manager: object, select: (title: string, options: string[]) => Promise<string | undefined>, sessionId = "session-a"): ExtensionContext {
251
+ return {
252
+ cwd,
253
+ mode: "tui",
254
+ hasUI: true,
255
+ sessionManager: Object.assign(manager, { getSessionId: () => sessionId }),
256
+ ui: { select, notify() {}, setStatus() {}, theme: { fg: (_color: string, text: string) => text } },
257
+ } as unknown as ExtensionContext;
258
+ }
259
+
260
+ test("a validated intended-untracked selection may use the host third action only for its selected repository", async (t) => {
261
+ const cwd = reviewRepository(t);
262
+ const sibling = siblingWorktree(t, cwd);
263
+ const unrelated = reviewRepository(t);
264
+ const selectedPath = "selected.md";
265
+ for (const root of [cwd, sibling, unrelated]) writeFileSync(join(root, selectedPath), "selected\n");
266
+ const statuses = new Map<string, ReturnType<typeof intendedUntrackedSelectionStatuses>>();
267
+ const statusFor = (root: string) => statuses.get(root) ?? statuses.set(root, intendedUntrackedSelectionStatuses(root, selectedPath)).get(root)!;
268
+ const runtime = controllerHarness(cwd, {}, {
269
+ targetStatus: async (request) => ("intendedUntrackedSelection" in request ? statusFor(request.cwd as string).selected : statusFor(request.cwd as string).initial),
270
+ });
271
+ const manager = {};
272
+ let prompts = 0;
273
+ const select = async (_title: string, options: string[]) => {
274
+ prompts += 1;
275
+ assert.equal(options.length, 3, "the host UI exposes exactly its two provider choices plus one host action");
276
+ return options[2];
277
+ };
278
+ const selectIntended = async (root: string, context: ExtensionContext) => {
279
+ const listed = (await runtime.controller.execute(`list-${root}`, { operation: "status", workspaceRoot: root }, undefined, undefined, context)).details;
280
+ assert.equal(typeof listed.selectionBinding, "string");
281
+ return await runtime.controller.execute(`select-${root}`, { operation: "select-intended-untracked", selectionBinding: listed.selectionBinding, intendedUntracked: [selectedPath], workspaceRoot: root }, undefined, undefined, context);
282
+ };
283
+
284
+ const first = (await selectIntended(cwd, interactiveContext(cwd, manager, select))).details;
285
+ assert.equal(first.operation, "answer-consent");
286
+ assert.deepEqual(runtime.answers, ["granted"]);
287
+ assert.equal(runtime.answerRequests[0]?.answer, "granted", "the exact provider grant is relayed once");
288
+ assert.equal(JSON.stringify(runtime.answerRequests[0]?.consent.raw), JSON.stringify(runtime.consent.raw), "the exact provider envelope is granted unchanged");
289
+ const grantedIdentity = await captureReviewSessionIdentity(interactiveContext(cwd, manager, select), {});
290
+ assert.ok(grantedIdentity);
291
+ assert.equal(hasReviewSessionPermission(grantedIdentity), true, "the host third action establishes the session repository grant");
292
+
293
+ const second = (await selectIntended(sibling, interactiveContext(sibling, manager, select))).details;
294
+ assert.equal(second.operation, "answer-consent");
295
+ assert.deepEqual(runtime.answers, ["granted", "granted"]);
296
+ assert.equal(prompts, 1, "a sibling worktree consumes the selected repository grant without another prompt");
297
+
298
+ const third = (await selectIntended(unrelated, interactiveContext(unrelated, manager, select))).details;
299
+ assert.equal(third.operation, "answer-consent");
300
+ assert.deepEqual(runtime.answers, ["granted", "granted", "granted"]);
301
+ assert.equal(prompts, 2, "an unrelated repository receives a new explicit host prompt");
302
+
303
+ const headless = (await selectIntended(unrelated, { ...interactiveContext(unrelated, {}, async () => { throw new Error("headless must not prompt"); }), mode: "print", hasUI: false } as ExtensionContext)).details;
304
+ assert.equal(headless.outcome, "native-review-consent-required");
305
+ assert.equal(JSON.stringify(headless.consent), JSON.stringify(runtime.consent.raw), "an unavailable host returns the byte-equivalent raw provider envelope");
306
+ assert.deepEqual(runtime.answers, ["granted", "granted", "granted"], "an unavailable host never submits a provider answer");
307
+ });
308
+
309
+ test("standing permission replays an explicit sibling-worktree consent against its binding-owned workspace root", async (t) => {
310
+ const parentRoot = reviewRepository(t);
311
+ const siblingRoot = siblingWorktree(t, parentRoot);
312
+ writeFileSync(join(siblingRoot, "app.ts"), "export const value = 2;\n");
313
+ const manager = {};
314
+ const context = interactiveContext(parentRoot, manager, async () => {
315
+ throw new Error("an active standing permission must not reopen consent UI");
316
+ });
317
+ const identity = await captureReviewSessionIdentity(context, {});
318
+ assert.ok(identity);
319
+ assert.equal(grantReviewSessionPermission(identity), true);
320
+
321
+ const runtime = controllerHarness(parentRoot);
322
+ const details = (await runtime.controller.execute(
323
+ "sibling-standing-permission",
324
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }), workspaceRoot: siblingRoot },
325
+ undefined,
326
+ undefined,
327
+ context,
328
+ )).details;
329
+
330
+ assert.equal(details.operation, "answer-consent");
331
+ assert.deepEqual(runtime.answers, ["granted"]);
332
+ assert.equal(runtime.answerRequests[0]?.cwd, siblingRoot, "automatic continuation must preserve the pending binding's canonical sibling worktree root");
333
+ });
334
+
335
+ test("provider grant and decline remain candidate-only and cancellation stores nothing", async (t) => {
336
+ const cwd = reviewRepository(t);
337
+ const runtime = controllerHarness(cwd);
338
+ const manager = {};
339
+ const selections = [0, 1, undefined] as const;
340
+ let prompts = 0;
341
+ const ctx = interactiveContext(cwd, manager, async (_title, options) => {
342
+ const selection = selections[prompts++];
343
+ return selection === undefined ? undefined : options[selection];
344
+ });
345
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
346
+ assert.equal(((await runtime.controller.execute("grant", start, undefined, undefined, ctx)).details.result as { lineage_id?: string }).lineage_id, "lineage-1");
347
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
348
+ assert.equal((await runtime.controller.execute("decline", start, undefined, undefined, ctx)).details.outcome, "consent-declined-this-candidate");
349
+ writeFileSync(join(cwd, "app.ts"), "export const value = 4;\n");
350
+ const cancelled = (await runtime.controller.execute("cancel", start, undefined, undefined, ctx)).details;
351
+ assert.equal(cancelled.outcome, "native-review-consent-required");
352
+ assert.deepEqual(runtime.answers, ["granted", "declined"]);
353
+ assert.equal(prompts, 3, "neither provider choice creates standing host permission");
354
+ });
355
+
356
+ test("explicit revocation ends the host grant without changing provider mode or authority", async (t) => {
357
+ const cwd = reviewRepository(t);
358
+ const runtime = controllerHarness(cwd);
359
+ const manager = {};
360
+ let prompts = 0;
361
+ const ctx = interactiveContext(cwd, manager, async (_title, options) => options[prompts++ === 0 ? 2 : 1]);
362
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
363
+ await runtime.controller.execute("grant-session", start, undefined, undefined, ctx);
364
+ const command = runtime.commands.get("gentle:review-session-permission");
365
+ assert.ok(command);
366
+ await command!.handler("revoke", ctx);
367
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
368
+ await runtime.controller.execute("after-revoke", start, undefined, undefined, ctx);
369
+ assert.equal(prompts, 2);
370
+ assert.deepEqual(runtime.answers, ["granted", "declined"]);
371
+ });
372
+
373
+ test("reload shutdown preserves the grant, while nonreload shutdown revokes it", async (t) => {
374
+ const cwd = reviewRepository(t);
375
+ const runtime = controllerHarness(cwd);
376
+ const manager = {};
377
+ let prompts = 0;
378
+ const ctx = interactiveContext(cwd, manager, async (_title, options) => options[prompts++ === 0 ? 2 : 1]);
379
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
380
+ await runtime.controller.execute("grant-session", start, undefined, undefined, ctx);
381
+ const shutdown = runtime.events.get("session_shutdown");
382
+ assert.ok(shutdown);
383
+ await shutdown!({ reason: "reload" }, ctx);
384
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
385
+ await runtime.controller.execute("after-reload", start, undefined, undefined, ctx);
386
+ assert.equal(prompts, 1, "reload keeps the standing permission");
387
+ await shutdown!({ reason: "new" }, ctx);
388
+ writeFileSync(join(cwd, "app.ts"), "export const value = 4;\n");
389
+ await runtime.controller.execute("after-new", start, undefined, undefined, ctx);
390
+ assert.equal(prompts, 2, "new-session shutdown revokes the standing permission");
391
+ });
392
+
393
+ test("successful decoded native START action variants remain eligible for a host grant", async (t) => {
394
+ const cwd = reviewRepository(t);
395
+ let candidate = 3;
396
+ for (const action of ["created", "resumed", "replayed", "closed"] as const) {
397
+ const runtime = controllerHarness(cwd, {}, { startAction: action });
398
+ let prompts = 0;
399
+ const ctx = interactiveContext(cwd, {}, async (_title, options) => {
400
+ prompts += 1;
401
+ return options[2];
402
+ }, `session-${action}`);
403
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
404
+ await runtime.controller.execute(`${action}-grant`, start, undefined, undefined, ctx);
405
+ writeFileSync(join(cwd, "app.ts"), `export const value = ${candidate++};\n`);
406
+ await runtime.controller.execute(`${action}-next`, start, undefined, undefined, ctx);
407
+ assert.equal(prompts, 1, `${action} must retain the successful decoded START grant variant`);
408
+ }
409
+ });
410
+
411
+ test("blocked-scope-action never persists host permission", async (t) => {
412
+ const cwd = reviewRepository(t);
413
+ const runtime = controllerHarness(cwd, {}, { startAction: "blocked-scope-action" });
414
+ let prompts = 0;
415
+ const ctx = interactiveContext(cwd, {}, async (_title, options) => options[prompts++ === 0 ? 2 : 1]);
416
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
417
+ const blocked = (await runtime.controller.execute("blocked-host-choice", start, undefined, undefined, ctx)).details;
418
+ assert.equal((blocked.result as { action?: string }).action, "blocked-scope-action");
419
+ assert.equal((blocked.result as { state?: string }).state, "unreviewed");
420
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
421
+ await runtime.controller.execute("after-blocked-scope", start, undefined, undefined, ctx);
422
+ assert.equal(prompts, 2, "pending explicit scope action must leave the next candidate behind a fresh prompt");
423
+ assert.deepEqual(runtime.answers, ["granted", "declined"]);
424
+ });
425
+
426
+ test("a stale consent result with contextual native status never arms host permission", async (t) => {
427
+ const cwd = reviewRepository(t);
428
+ let now = 0;
429
+ const runtime = controllerHarness(cwd, {}, { now: () => now });
430
+ const manager = {};
431
+ let prompts = 0;
432
+ const ctx = interactiveContext(cwd, manager, async (_title, options) => {
433
+ prompts += 1;
434
+ if (prompts === 1) {
435
+ now = 10 * 60 * 1000 + 1;
436
+ return options[2];
437
+ }
438
+ return options[1];
439
+ });
440
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
441
+ const stale = (await runtime.controller.execute("expired-host-choice", start, undefined, undefined, ctx)).details;
442
+ assert.equal(stale.outcome, "consent-binding-stale");
443
+ assert.equal(stale.native_invocation_attempted, false);
444
+ assert.equal(typeof stale.result, "object", "native STATUS is contextual evidence, not a successful START result");
445
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
446
+ await runtime.controller.execute("after-stale", start, undefined, undefined, ctx);
447
+ assert.equal(prompts, 2, "the next fresh consent envelope must still prompt after a stale contextual result");
448
+ assert.deepEqual(runtime.answers, ["declined"]);
449
+ });
450
+
451
+ test("a failed consent invocation never arms host permission", async (t) => {
452
+ const cwd = reviewRepository(t);
453
+ const failure = Object.assign(new Error("provider start failed before mutation"), { mutationOutcome: "none" as const });
454
+ const runtime = controllerHarness(cwd, {}, { answerConsentError: failure });
455
+ let prompts = 0;
456
+ const ctx = interactiveContext(cwd, {}, async (_title, options) => options[prompts++ === 0 ? 2 : 1]);
457
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
458
+ const failed = (await runtime.controller.execute("failed-host-choice", start, undefined, undefined, ctx)).details;
459
+ assert.equal(failed.outcome, "native-operation-failed");
460
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
461
+ await runtime.controller.execute("after-error", start, undefined, undefined, ctx);
462
+ assert.equal(prompts, 2, "a native error must leave the next candidate behind a fresh prompt");
463
+ assert.deepEqual(runtime.answers, ["granted", "declined"]);
464
+ });
465
+
466
+ test("headless, child, and changed post-UI identity never use host permission", async (t) => {
467
+ const cwd = reviewRepository(t);
468
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
469
+ for (const [label, processEnv, makeContext, parameters] of [
470
+ ["headless", {}, () => ({ ...interactiveContext(cwd, {}, async () => { throw new Error("must not prompt"); }), mode: "print", hasUI: false }) as ExtensionContext, start],
471
+ ["child", { GENTLE_PI_AGENTS_CHILD: "1" }, () => interactiveContext(cwd, {}, async () => { throw new Error("must not prompt"); }), start],
472
+ ] as const) {
473
+ const runtime = controllerHarness(cwd, processEnv);
474
+ const result = (await runtime.controller.execute(label, parameters, undefined, undefined, makeContext())).details;
475
+ assert.equal(result.outcome, "native-review-consent-required", label);
476
+ assert.deepEqual(runtime.answers, [], label);
477
+ }
478
+
479
+ const runtime = controllerHarness(cwd);
480
+ const manager = { sessionId: "before", getSessionId() { return this.sessionId; } };
481
+ const ctx = {
482
+ cwd,
483
+ mode: "tui",
484
+ hasUI: true,
485
+ sessionManager: manager,
486
+ ui: {
487
+ select: async (_title: string, options: string[]) => { manager.sessionId = "after"; return options[2]; },
488
+ notify() {},
489
+ setStatus() {},
490
+ theme: { fg: (_color: string, text: string) => text },
491
+ },
492
+ } as unknown as ExtensionContext;
493
+ const switched = (await runtime.controller.execute("switched", start, undefined, undefined, ctx)).details;
494
+ assert.equal(switched.outcome, "native-review-consent-required");
495
+ assert.deepEqual(runtime.answers, []);
496
+ });
497
+
498
+ test("standing permission never replays provider v2 or non-Pi v3 consents", async (t) => {
499
+ const cwd = reviewRepository(t);
500
+ const manager = {};
501
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
502
+ const authorized = controllerHarness(cwd);
503
+ const grantingContext = interactiveContext(cwd, manager, async (_title, options) => options[2]);
504
+ await authorized.controller.execute("grant-standing-permission", start, undefined, undefined, grantingContext);
505
+ assert.deepEqual(authorized.answers, ["granted"]);
506
+
507
+ for (const [label, providerConsent] of [
508
+ ["provider v2", providerV2Consent()],
509
+ ["non-Pi v3", nonPiV3Consent()],
510
+ ] as const) {
511
+ const runtime = controllerHarness(cwd, {}, { consent: providerConsent });
512
+ const result = (await runtime.controller.execute(label, start, undefined, undefined, interactiveContext(cwd, manager, async () => { throw new Error(`${label} must not prompt`); }))).details;
513
+ assert.equal(result.outcome, "native-review-consent-required", label);
514
+ assert.deepEqual(result.consent, providerConsent.raw, `${label} returns the exact unresolved provider envelope`);
515
+ assert.deepEqual(runtime.answers, [], `${label} never submits a provider answer`);
516
+ }
517
+ });
518
+
519
+ test("a child never asks its broker to replay provider v2 or non-Pi v3 consents", async (t) => {
520
+ const cwd = reviewRepository(t);
521
+ const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }), workspaceRoot: cwd };
522
+ for (const [label, providerConsent] of [
523
+ ["provider v2", providerV2Consent()],
524
+ ["non-Pi v3", nonPiV3Consent()],
525
+ ] as const) {
526
+ let brokerRequests = 0;
527
+ const runtime = controllerHarness(cwd, { GENTLE_PI_AGENTS_CHILD: "1" }, {
528
+ consent: providerConsent,
529
+ childStandingReviewPermissionClient: {
530
+ requestAuthorization: async () => {
531
+ brokerRequests += 1;
532
+ return true;
533
+ },
534
+ close() {},
535
+ } as unknown as ChildStandingReviewPermissionClient,
536
+ });
537
+ const result = (await runtime.controller.execute(label, start, undefined, undefined, interactiveContext(cwd, {}, async () => { throw new Error(`${label} child must not prompt`); }))).details;
538
+ assert.equal(result.outcome, "native-review-consent-required", label);
539
+ assert.deepEqual(result.consent, providerConsent.raw, `${label} returns the exact unresolved provider envelope`);
540
+ assert.equal(brokerRequests, 0, `${label} never requests broker authorization`);
541
+ assert.deepEqual(runtime.answers, [], `${label} never submits a provider answer`);
542
+ }
543
+ });
544
+
545
+ test("child permission transport survives reload shutdown but closes on terminal session shutdown", (t) => {
546
+ const cwd = reviewRepository(t);
547
+ let closes = 0;
548
+ const runtime = controllerHarness(cwd, { GENTLE_PI_AGENTS_CHILD: "1" }, {
549
+ childStandingReviewPermissionClient: { close: () => { closes += 1; } } as unknown as ChildStandingReviewPermissionClient,
550
+ });
551
+ const shutdown = runtime.events.get("session_shutdown");
552
+ assert.ok(shutdown);
553
+ const context = interactiveContext(cwd, {}, async () => undefined, "child-session");
554
+ shutdown({ reason: "reload" }, context);
555
+ assert.equal(closes, 0, "reload leaves the process-owned transport open for the replacement extension");
556
+ shutdown({ reason: "new" }, context);
557
+ assert.equal(closes, 1, "a terminal lifecycle closes the transport");
558
+ });
559
+
560
+ test("a package-owned child replays its exact pending ordinary grant from a sibling Git worktree only while its parent session remains authorized", async (t) => {
561
+ const parentRoot = reviewRepository(t);
562
+ const childRoot = siblingWorktree(t, parentRoot);
563
+ const unrelatedRoot = reviewRepository(t);
564
+ writeFileSync(join(childRoot, "app.ts"), "export const value = 2;\n");
565
+ const parentManager = {};
566
+ const parentIdentity = await captureReviewSessionIdentity({
567
+ cwd: parentRoot,
568
+ mode: "tui",
569
+ hasUI: true,
570
+ sessionManager: Object.assign(parentManager, { getSessionId: () => "parent-session" }),
571
+ }, {});
572
+ assert.ok(parentIdentity);
573
+ assert.equal(grantReviewSessionPermission(parentIdentity), true);
574
+
575
+ const childToParent = new PassThrough();
576
+ const parentToChild = new PassThrough();
577
+ const broker = new ParentStandingReviewPermissionBroker(
578
+ { readable: childToParent, writable: parentToChild },
579
+ (repositoryIdentity) => repositoryIdentity === parentIdentity.repositoryIdentity && parentManager.getSessionId() === "parent-session" && hasReviewSessionPermission(parentIdentity),
580
+ );
581
+ const childPermission = new ChildStandingReviewPermissionClient(
582
+ { readable: parentToChild, writable: childToParent },
583
+ { timeoutMs: 25 },
584
+ );
585
+ const runtime = controllerHarness(childRoot, { GENTLE_PI_AGENTS_CHILD: "1" }, { childStandingReviewPermissionClient: childPermission });
586
+ const childContext = interactiveContext(childRoot, {}, async () => { throw new Error("a child must not open its own consent UI"); }, "child-session");
587
+ const explicitStart = { operation: "start", input: JSON.stringify({ mode: "ordinary" }), workspaceRoot: childRoot };
588
+
589
+ const granted = (await runtime.controller.execute("child-grant", explicitStart, undefined, undefined, childContext)).details;
590
+ assert.equal(granted.operation, "answer-consent");
591
+ assert.deepEqual(runtime.answers, ["granted"], "the child locally replays the exact provider grant once");
592
+
593
+ const unrelatedContext = interactiveContext(unrelatedRoot, {}, async () => { throw new Error("an unrelated child target must not open its own consent UI"); }, "child-session");
594
+ const unrelatedStart = { operation: "start", input: JSON.stringify({ mode: "ordinary" }), workspaceRoot: unrelatedRoot };
595
+ const unrelated = (await runtime.controller.execute("child-unrelated", unrelatedStart, undefined, undefined, unrelatedContext)).details;
596
+ assert.equal(unrelated.outcome, "native-review-consent-required", "a dynamically targeted unrelated repository is denied by the task-bound broker");
597
+ assert.deepEqual(runtime.answers, ["granted"]);
598
+
599
+ revokeReviewSessionPermissionsForSession(parentIdentity.sessionManager, parentIdentity.sessionId);
600
+ writeFileSync(join(childRoot, "app.ts"), "export const value = 3;\n");
601
+ const denied = (await runtime.controller.execute("child-revoked", explicitStart, undefined, undefined, childContext)).details;
602
+ assert.equal(denied.outcome, "native-review-consent-required", "a revoked parent session cannot authorize the next child candidate");
603
+ assert.deepEqual(runtime.answers, ["granted"]);
604
+ childPermission.close();
605
+ broker.close();
606
+ childToParent.destroy();
607
+ parentToChild.destroy();
608
+ });