gentle-pi 2.3.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,245 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Box, Container, Text, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
5
+ import { createNativePointerScope } from "../lib/native-pointer-region.ts";
6
+
7
+ function mouse(
8
+ type: TuiMouseEvent["type"], button: TuiMouseEvent["button"], y: number, height: number,
9
+ ): TuiMouseEvent {
10
+ return {
11
+ type, button, x: 0, y, screenX: 0, screenY: y, width: 40, height,
12
+ shift: false, alt: false, ctrl: false,
13
+ };
14
+ }
15
+
16
+ function dispatch(
17
+ root: Container,
18
+ observer: ReturnType<ReturnType<typeof createNativePointerScope>["createMouseObserver"]>,
19
+ event: TuiMouseEvent,
20
+ ) {
21
+ observer.beforeMouse(event);
22
+ try {
23
+ return root.handleMouse(event);
24
+ } finally {
25
+ observer.afterMouse(event);
26
+ }
27
+ }
28
+
29
+ type PublicCustomFactory = Parameters<ExtensionContext["ui"]["custom"]>[0];
30
+ type PublicCustomOptions = NonNullable<Parameters<ExtensionContext["ui"]["custom"]>[1]>;
31
+
32
+ // Typed callback harness, not proof of physical terminal overlay UX.
33
+ async function mountOverlay(
34
+ factory: PublicCustomFactory,
35
+ options: PublicCustomOptions,
36
+ ): Promise<Component & { dispose?(): void }> {
37
+ assert.equal(options.overlay, true, "the public custom factory receives overlay mode");
38
+ return factory(
39
+ {} as Parameters<PublicCustomFactory>[0],
40
+ {} as Parameters<PublicCustomFactory>[1],
41
+ {} as Parameters<PublicCustomFactory>[2],
42
+ () => {},
43
+ );
44
+ }
45
+
46
+ test("pointer regions give a public Text hover, press, and click behavior without implicit focus", () => {
47
+ const scope = createNativePointerScope();
48
+ const calls: string[] = [];
49
+ const region = scope.wrap(new Text("Open editor", 0, 0), {
50
+ onHover: () => {
51
+ calls.push("hover");
52
+ return { handled: true, render: true };
53
+ },
54
+ onPress: () => {
55
+ calls.push("press");
56
+ return { handled: true };
57
+ },
58
+ onClick: () => {
59
+ calls.push("click");
60
+ return { handled: true };
61
+ },
62
+ onWheel: () => {
63
+ calls.push("wheel");
64
+ return { handled: true };
65
+ },
66
+ });
67
+ const root = new Container();
68
+ root.addChild(region);
69
+ const height = root.render(40).length;
70
+ const observer = scope.createMouseObserver();
71
+
72
+ assert.equal(dispatch(root, observer, mouse("move", "none", 0, height))?.render, true);
73
+ const press = dispatch(root, observer, mouse("press", "left", 0, height));
74
+ assert.equal(press?.handled, true);
75
+ assert.equal(press?.focus, undefined, "a Text region never claims keyboard focus by itself");
76
+ assert.equal(dispatch(root, observer, mouse("click", "left", 0, height))?.handled, true);
77
+ assert.equal(dispatch(root, observer, mouse("wheel", "none", 0, height))?.handled, true);
78
+ assert.deepEqual(calls, ["hover", "press", "click", "wheel"]);
79
+ });
80
+
81
+ test("pointer scopes clear hover only through observed lifecycle boundaries", () => {
82
+ const scope = createNativePointerScope();
83
+ const states: string[] = [];
84
+ const region = scope.wrap(new Text("Hover me", 0, 0), {
85
+ onHover: () => {
86
+ states.push("hover");
87
+ return { handled: true, render: true };
88
+ },
89
+ onLeave: () => states.push("leave"),
90
+ });
91
+ const root = new Container();
92
+ root.addChild(region);
93
+ const observer = scope.createMouseObserver();
94
+ const height = root.render(40).length;
95
+
96
+ dispatch(root, observer, mouse("move", "none", 0, height));
97
+ dispatch(root, observer, mouse("move", "none", height, height + 1));
98
+ assert.deepEqual(states, ["hover", "leave"], "an in-root empty move clears hover");
99
+
100
+ dispatch(root, observer, mouse("move", "none", 0, height));
101
+ region.render(30);
102
+ assert.deepEqual(states, ["hover", "leave", "hover", "leave"], "resize clears hover");
103
+
104
+ dispatch(root, observer, mouse("move", "none", 0, height));
105
+ region.invalidate();
106
+ region.setDisabled(true);
107
+ region.dispose();
108
+ scope.dispose();
109
+ dispatch(root, observer, mouse("move", "none", 0, height));
110
+ assert.deepEqual(
111
+ states,
112
+ ["hover", "leave", "hover", "leave", "hover", "leave"],
113
+ "invalidate, disable, disposal, and late events cannot retain or restore hover",
114
+ );
115
+ });
116
+
117
+ test("a consuming public Box child keeps its exact native target and focus without outer callbacks", () => {
118
+ const scope = createNativePointerScope();
119
+ let outerClicks = 0;
120
+ const child: Component = {
121
+ render: () => ["native child"],
122
+ invalidate() {},
123
+ handleMouse: () => ({ handled: true, focus: true, render: true }),
124
+ };
125
+ const box = new Box(0, 0);
126
+ box.addChild(child);
127
+ const region = scope.wrap(box, {
128
+ onClick: () => {
129
+ outerClicks++;
130
+ return { handled: true };
131
+ },
132
+ });
133
+ const root = new Container();
134
+ root.addChild(region);
135
+ const height = root.render(40).length;
136
+ const result = dispatch(root, scope.createMouseObserver(), mouse("click", "left", 0, height));
137
+
138
+ assert.equal(result?.target.component, child);
139
+ assert.equal(result?.focusTarget, child);
140
+ assert.equal(outerClicks, 0, "a consumed child prevents outer pointer callbacks");
141
+ });
142
+
143
+ test("the same composed region can open an input entry point without answering on press", () => {
144
+ const scope = createNativePointerScope();
145
+ let opened = 0;
146
+ const panel = new Container();
147
+ panel.addChild(scope.wrap(new Text("Open input", 0, 0), {
148
+ onPress: () => ({ handled: true }),
149
+ onClick: () => {
150
+ opened++;
151
+ return { handled: true };
152
+ },
153
+ }));
154
+ const root = new Container();
155
+ root.addChild(panel);
156
+ const height = root.render(40).length;
157
+ const observer = scope.createMouseObserver();
158
+
159
+ assert.equal(dispatch(root, observer, mouse("press", "left", 0, height))?.handled, true);
160
+ assert.equal(opened, 0, "press can prepare a control but cannot answer it");
161
+ assert.equal(dispatch(root, observer, mouse("click", "left", 0, height))?.handled, true);
162
+ assert.equal(opened, 1, "a caller-owned callback opens the input entry point");
163
+ });
164
+
165
+ test("a typed public custom overlay factory composes pointer content and disposes late events", async () => {
166
+ let opened = 0;
167
+ let leaves = 0;
168
+ const component = await mountOverlay(() => {
169
+ const scope = createNativePointerScope();
170
+ const panel = new Container();
171
+ panel.addChild(scope.wrap(new Text("Open overlay input", 0, 0), {
172
+ onHover: () => ({ handled: true, render: true }),
173
+ onLeave: () => { leaves++; },
174
+ onClick: () => {
175
+ opened++;
176
+ return { handled: true };
177
+ },
178
+ }));
179
+ const observer = scope.createMouseObserver();
180
+ return {
181
+ render: (width) => panel.render(width),
182
+ handleMouse: (event) => {
183
+ observer.beforeMouse(event);
184
+ try {
185
+ return panel.handleMouse(event);
186
+ } finally {
187
+ observer.afterMouse(event);
188
+ }
189
+ },
190
+ invalidate: () => panel.invalidate(),
191
+ dispose: () => scope.dispose(),
192
+ };
193
+ }, { overlay: true });
194
+ const height = component.render(40).length;
195
+ const hover = component.handleMouse?.(mouse("move", "none", 0, height));
196
+ const click = component.handleMouse?.(mouse("click", "left", 0, height));
197
+
198
+ assert.equal(hover?.render, true);
199
+ assert.equal(click?.focus, undefined, "overlay content does not claim focus without a caller result");
200
+ assert.equal(opened, 1, "the factory-returned component routes a native click to its callback");
201
+ component.dispose?.();
202
+ assert.equal(component.handleMouse?.(mouse("click", "left", 0, height)), undefined);
203
+ assert.deepEqual([opened, leaves], [1, 1], "disposal clears hover and rejects late overlay events");
204
+ });
205
+
206
+ test("scopes apply disabled and disposed state to later Text regions", () => {
207
+ const calls: string[] = [];
208
+ const disabled = createNativePointerScope();
209
+ disabled.setDisabled(true);
210
+ const disabledRoot = new Container();
211
+ disabledRoot.addChild(disabled.wrap(new Text("Disabled", 0, 0), {
212
+ onClick: () => {
213
+ calls.push("disabled");
214
+ return { handled: true };
215
+ },
216
+ }));
217
+ const disabledHeight = disabledRoot.render(40).length;
218
+ const disabledClick = dispatch(
219
+ disabledRoot, disabled.createMouseObserver(), mouse("click", "left", 0, disabledHeight),
220
+ );
221
+ disabled.setDisabled(false);
222
+ const reenabledClick = dispatch(
223
+ disabledRoot, disabled.createMouseObserver(), mouse("click", "left", 0, disabledHeight),
224
+ );
225
+
226
+ const disposed = createNativePointerScope();
227
+ disposed.dispose();
228
+ const disposedRoot = new Container();
229
+ disposedRoot.addChild(disposed.wrap(new Text("Disposed", 0, 0), {
230
+ onClick: () => {
231
+ calls.push("disposed");
232
+ return { handled: true };
233
+ },
234
+ }));
235
+ const disposedHeight = disposedRoot.render(40).length;
236
+ const disposedClick = dispatch(
237
+ disposedRoot, disposed.createMouseObserver(), mouse("click", "left", 0, disposedHeight),
238
+ );
239
+
240
+ assert.deepEqual(
241
+ [disabledClick?.handled, reenabledClick?.handled, disposedClick?.handled, calls],
242
+ [undefined, true, undefined, ["disabled"]],
243
+ "later regions inherit scope disable/disposal while a live scope can be re-enabled",
244
+ );
245
+ });
@@ -150,11 +150,43 @@ test("2.5.0 repeats 2.5.0-rc.3 because the stable lane Pi consumes is unchanged"
150
150
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.5.0-rc.3"] as Record<string, boolean>);
151
151
  });
152
152
 
153
+ test("2.6.0 repeats 2.5.0 because the negotiated lane Pi consumes is unchanged", () => {
154
+ // Ground-truthed against the published v2.6.0 binary installed from its
155
+ // signed archive: the v2 lane advertises capabilities/v2.5 and answers
156
+ // status/v7, consent/v3, and the `start/v4` continuation, all already
157
+ // decoded. `review mode status --json` and `review validate --gate
158
+ // pre-commit` were re-run against the binary and still answer
159
+ // `gentle-ai.rdd-mode-status/v1` and `gentle-ai.review-gate-result/v1`
160
+ // respectively. Declining the consent/v3 prompt during `review start`
161
+ // still surfaces `risk_evidence` only on that blocking envelope, not on
162
+ // the negotiated `start/v4` continuation, so riskEvidence and hint stay
163
+ // dark: still not proven to reach the negotiated START path Pi consumes.
164
+ const contract = NATIVE_CLI_CONTRACTS["2.6.0"] as Record<string, boolean>;
165
+ assert.equal(contract.riskEvidence, false);
166
+ assert.equal(contract.hint, false);
167
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.5.0"] as Record<string, boolean>);
168
+ });
169
+
170
+ test("2.7.0 repeats 2.6.0 because the negotiated lane Pi consumes is unchanged", () => {
171
+ // Ground-truthed against the published v2.7.0 binary installed from its
172
+ // signed archive: the v2 lane still advertises capabilities/v2.5 and
173
+ // answers status/v7, consent/v3, and the `start/v4` continuation, all
174
+ // already decoded. `review mode status --json` and `review validate --gate
175
+ // pre-commit` were re-run against the binary and still answer
176
+ // `gentle-ai.rdd-mode-status/v1` and `gentle-ai.review-gate-result/v1`
177
+ // respectively. riskEvidence and hint stay dark: still not proven to reach
178
+ // the negotiated START path Pi consumes.
179
+ const contract = NATIVE_CLI_CONTRACTS["2.7.0"] as Record<string, boolean>;
180
+ assert.equal(contract.riskEvidence, false);
181
+ assert.equal(contract.hint, false);
182
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.6.0"] as Record<string, boolean>);
183
+ });
184
+
153
185
  test("no shipped version key was added beyond the pin bump", () => {
154
186
  // Rows are promises to consumers, so a new key only ever appears in a
155
187
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
156
188
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
157
189
  // so they get no row: a row asserts ground truth measured against a binary
158
190
  // Pi actually ran, and the table only has to be ascending, not gapless.
159
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0"]);
191
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0"]);
160
192
  });
@@ -609,6 +609,46 @@ test("current native process boundary preserves caps, timeout classes, and sanit
609
609
  );
610
610
  });
611
611
 
612
+ test("authority inventory accepts compact discarded work and historical entry statuses", async () => {
613
+ const repository = process.cwd();
614
+ const discardedWork = { captured_lens_results: [], findings_present: false };
615
+ const statuses = ["incomplete-store-entry", "historical-pre-receipt", "invalidated"] as const;
616
+ const body = {
617
+ schema: "gentle-ai.review-authority-status/v1",
618
+ operation: "review/status",
619
+ repository,
620
+ complete: true,
621
+ authoritative: true,
622
+ status: "active",
623
+ entries: statuses.map((status) => ({
624
+ version: "compact-v2",
625
+ lineage_id: `review-${status}`,
626
+ path: `${repository}/.git/gentle-ai/${status}`,
627
+ status,
628
+ discarded_work: discardedWork,
629
+ problems: [],
630
+ })),
631
+ locks: [],
632
+ diagnostics: [],
633
+ };
634
+ const decoded = await client(queuedAdapter([{ stdout: JSON.stringify(body) }]).adapter).reviewStatus({ cwd: repository });
635
+ assert.deepEqual(decoded.entries.map((entry) => ({ status: entry.status, discardedWork: entry.discardedWork })), statuses.map((status) => ({
636
+ status,
637
+ discardedWork: { capturedLensResults: [], findingsPresent: false },
638
+ })));
639
+
640
+ for (const entry of [
641
+ { ...body.entries[0], discarded_work: { captured_lens_results: "not-an-array", findings_present: false } },
642
+ { ...body.entries[0], discarded_work: { ...discardedWork, unexpected: true } },
643
+ { ...body.entries[0], status: "future-status" },
644
+ ]) {
645
+ await assert.rejects(
646
+ () => client(queuedAdapter([{ stdout: JSON.stringify({ ...body, entries: [entry] }) }]).adapter).reviewStatus({ cwd: repository }),
647
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
648
+ );
649
+ }
650
+ });
651
+
612
652
  test("current review STATUS retains compact snapshot and released-lock wire fields", async (t) => {
613
653
  const repository = process.cwd();
614
654
  const snapshotIdentity = `sha256:${"d".repeat(64)}`;
@@ -176,6 +176,97 @@ test("consent follow-up executes the provider-named invocation exactly once and
176
176
 
177
177
  // A binding mismatch is decided entirely inside Pi, before the provider is
178
178
  // launched, so it must not be reported as a provider failure (issue #247).
179
+ test("consent answer preserves quoted Windows provider cwd tokens and launches the provider exactly once", async () => {
180
+ const windowsCwd = "C:\\Users\\x\\repo with spaces";
181
+ const gitBashCwd = "/c/Users/x/repo with spaces";
182
+ const cwdPairs = process.platform === "win32"
183
+ ? [{ providerCwd: windowsCwd, requestedCwd: gitBashCwd }, { providerCwd: gitBashCwd, requestedCwd: windowsCwd }, { providerCwd: gitBashCwd, requestedCwd: gitBashCwd }, { providerCwd: windowsCwd, requestedCwd: windowsCwd }]
184
+ : [{ providerCwd: windowsCwd, requestedCwd: windowsCwd }];
185
+ for (const { providerCwd, requestedCwd } of cwdPairs) {
186
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
187
+ const lineage = "windows-cwd-lineage";
188
+ for (const choice of decoded.choices) {
189
+ choice.invocation = choice.invocation
190
+ .replace("--cwd /repo", `--cwd "${providerCwd}"`)
191
+ .replace("review-consent-fixture", lineage);
192
+ }
193
+ const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json")).replaceAll("review-start-fixture", lineage)) as Record<string, unknown>;
194
+ const expected = [
195
+ "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", providerCwd,
196
+ "--target", decoded.targetIdentity, "--projection", "workspace", "--lineage", lineage, "--consent", "granted",
197
+ ];
198
+ for (const createClient of [client, runtimeClient]) {
199
+ const queue = queuedAdapter([capabilities(), structuredClone(started)]);
200
+ const result = await createClient(queue.adapter).answerConsent({ cwd: requestedCwd, consent: decoded, answer: "granted" });
201
+ assert.equal(result.kind, "started");
202
+ assert.deepEqual(queue.calls, [expected]);
203
+ }
204
+ }
205
+ });
206
+
207
+ test("consent answer preserves quoted UNC and drive-root cwd tokens", async () => {
208
+ for (const providerCwd of ["\\\\server\\share", "C:\\"]) {
209
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
210
+ const lineage = "windows-root-lineage";
211
+ for (const choice of decoded.choices) {
212
+ choice.invocation = choice.invocation
213
+ .replace("--cwd /repo", `--cwd "${providerCwd}"`)
214
+ .replace("review-consent-fixture", lineage);
215
+ }
216
+ const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json")).replaceAll("review-start-fixture", lineage)) as Record<string, unknown>;
217
+ const expected = [
218
+ "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", providerCwd,
219
+ "--target", decoded.targetIdentity, "--projection", "workspace", "--lineage", lineage, "--consent", "granted",
220
+ ];
221
+ for (const createClient of [client, runtimeClient]) {
222
+ const queue = queuedAdapter([capabilities(), structuredClone(started)]);
223
+ const result = await createClient(queue.adapter).answerConsent({ cwd: providerCwd, consent: decoded, answer: "granted" });
224
+ assert.equal(result.kind, "started");
225
+ assert.deepEqual(queue.calls, [expected]);
226
+ }
227
+ }
228
+ });
229
+
230
+ test("unmatched consent invocation quotes reject before provider launch", async () => {
231
+ for (const quote of ["'", '"']) {
232
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
233
+ const choice = decoded.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
234
+ choice.invocation = `${choice.invocation} ${quote}`;
235
+ for (const createClient of [client, runtimeClient]) {
236
+ const queue = queuedAdapter([]);
237
+ await assert.rejects(
238
+ () => createClient(queue.adapter).answerConsent({ cwd: "/repo", consent: decoded, answer: "granted" }),
239
+ (error: unknown) => error instanceof TypeError && error.message === "Native consent invocation has invalid quoting",
240
+ );
241
+ assert.deepEqual(queue.calls, []);
242
+ }
243
+ }
244
+ });
245
+
246
+ test("different, duplicate, missing, and malformed consent cwd options reject before provider launch", async () => {
247
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
248
+ const drifted = (replace: (invocation: string) => string): ReviewConsentV2 => {
249
+ const consent = structuredClone(decoded);
250
+ const choice = consent.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
251
+ choice.invocation = replace(choice.invocation);
252
+ return consent;
253
+ };
254
+ const cases = [
255
+ { label: "different", reason: "consent-invocation-cwd-changed", consent: drifted((value) => value.replace("--cwd /repo", "--cwd /another-repo")) },
256
+ { label: "duplicate", reason: "consent-invocation-option-invalid", consent: drifted((value) => `${value} --cwd /repo`) },
257
+ { label: "missing", reason: "consent-invocation-option-invalid", consent: drifted((value) => value.replace("--cwd /repo ", "")) },
258
+ { label: "malformed", reason: "consent-invocation-cwd-changed", consent: drifted((value) => value.replace("--cwd /repo", '--cwd ""')) },
259
+ ] as const;
260
+ for (const scenario of cases) {
261
+ const queue = queuedAdapter([]);
262
+ await assert.rejects(
263
+ () => client(queue.adapter).answerConsent!({ cwd: "/repo", consent: scenario.consent, answer: "granted" }),
264
+ (error: unknown) => error instanceof NativeReviewConsentBindingError && error.reason === scenario.reason,
265
+ );
266
+ assert.deepEqual(queue.calls, [], `${scenario.label} cwd must not launch the provider`);
267
+ }
268
+ });
269
+
179
270
  test("a consent invocation binding mismatch is a typed pre-native error that never launches the provider", async () => {
180
271
  const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
181
272
  const queue = queuedAdapter([]);
@@ -140,6 +140,8 @@ test("generated runtime decoder consumes the captured terminal closure directly"
140
140
 
141
141
  test("registered gentle_review surfaces the package-pinned Pi transport refusal before native START for a safe internal symlink candidate", async (t) => {
142
142
  await reviewEnabledHome(t);
143
+ const handshakeLessEnvironment = { ...process.env };
144
+ delete handshakeLessEnvironment.GENTLE_PI_REVIEW_RELAY_CONTRACT;
143
145
  const workspace = await realpath(await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-")));
144
146
  const repository = join(workspace, "repository");
145
147
  t.after(async () => {
@@ -174,11 +176,15 @@ test("registered gentle_review surfaces the package-pinned Pi transport refusal
174
176
  // refusal happens before native START, regardless of candidate materialization.
175
177
  const native = new NativeReviewCliV216(async (request) => {
176
178
  if (request.arguments[0] === "review" && request.arguments[1] === "start") nativeStartReached = true;
177
- const command = await run(binary, request.arguments, request.cwd, true);
179
+ const command = await run(binary, request.arguments, request.cwd, true, handshakeLessEnvironment);
178
180
  return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
179
181
  });
180
182
  const tools = new Map<string, RegisteredController>();
181
- createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
183
+ // The extension declares the relay handshake in the session environment on
184
+ // load (gentle-pi#550). Keep registration isolated in a throwaway environment
185
+ // and spawn the pinned binary with an explicit handshake-less environment so
186
+ // ambient valid relay configuration cannot change this fixture's behavior.
187
+ createGentleAiExtension({ nativeReviewCli: native, candidateViews, processEnv: {} } as Parameters<typeof createGentleAiExtension>[0])({
182
188
  on() {},
183
189
  registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
184
190
  registerCommand() {},
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, 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";
@@ -183,7 +183,6 @@ interface ParityRuntimeOptions {
183
183
  pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
184
184
  now?: () => number;
185
185
  scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
186
- writeConsentLatch?: (cwd: string) => void;
187
186
  }
188
187
 
189
188
  function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime {
@@ -197,7 +196,7 @@ function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityR
197
196
  now: options.now,
198
197
  scheduleTimer: options.scheduleTimer,
199
198
  } as unknown as Parameters<typeof createGentleAiExtension>[0];
200
- __testing.createGentleAiExtension(dependencies, options.writeConsentLatch ?? (() => {}))({
199
+ __testing.createGentleAiExtension(dependencies)({
201
200
  on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
202
201
  registerTool(definition: RegisteredControllerTool & { name: string }) { tools.set(definition.name, definition); },
203
202
  registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
@@ -401,7 +400,7 @@ test("public gentle:review-mode handler reports current operations, global-off w
401
400
  assert.deepEqual(unavailableNotices, [{ message: "Gentle AI review mode is not available with the currently negotiated native version.", type: "info" }]);
402
401
  });
403
402
 
404
- test("public consent relay is session-bound, one-shot, and candidate-scoped", async (t) => {
403
+ test("public consent relay allows any active session to resolve a live binding, is one-shot, and is candidate-scoped", async (t) => {
405
404
  const cwd = repository(t);
406
405
  const sharedRegistry = new PendingReviewConsentRegistry();
407
406
  const fixture = consentNative(cwd);
@@ -411,14 +410,22 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
411
410
  const sameSessionResult = await answerConsent(second, cwd, sameSession.consent_binding, "declined", "same-session");
412
411
  assert.equal(sameSessionResult.outcome, "consent-declined-this-candidate");
413
412
 
413
+ // gentle-pi#455: a binding is answerable by whichever active Pi session
414
+ // presents it, not only the session whose START created it.
414
415
  const blockedA = await beginConsent(first, cwd, "session-a");
415
416
  assert.deepEqual(blockedA.consent, decodeReviewConsentV3(captured("consent-v3.captured.json")).raw);
416
- const staleOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
417
- assert.equal(staleOtherSession.operation, "answer-consent");
418
- // gentle-pi#516: a binding this session does not hold must never read as
419
- // a healthy pre-start STATUS; it names itself and the exit.
420
- assertStaleConsentBinding(staleOtherSession, blockedA.consent_binding, "consent-binding-unknown");
421
- assert.deepEqual(fixture.answers, ["declined"]);
417
+ const declinedFromOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
418
+ assert.equal(declinedFromOtherSession.operation, "answer-consent");
419
+ assert.equal(declinedFromOtherSession.outcome, "consent-declined-this-candidate");
420
+ assert.deepEqual(fixture.answers, ["declined", "declined"]);
421
+
422
+ // Cross-session resolution still enforces single use: once answered, no
423
+ // session -- including the one whose START created it -- may answer it again.
424
+ const staleAfterCrossSessionAnswer = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
425
+ assert.equal(staleAfterCrossSessionAnswer.operation, "answer-consent");
426
+ assertStaleConsentBinding(staleAfterCrossSessionAnswer, blockedA.consent_binding, "consent-binding-already-consumed");
427
+ assert.deepEqual(fixture.answers, ["declined", "declined"]);
428
+
422
429
  const blockedB = await beginConsent(second, cwd, "session-b");
423
430
  const declined = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
424
431
  assert.equal(declined.outcome, "consent-declined-this-candidate");
@@ -426,15 +433,19 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
426
433
  const staleConsumed = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
427
434
  assert.equal(staleConsumed.operation, "answer-consent");
428
435
  assertStaleConsentBinding(staleConsumed, blockedB.consent_binding, "consent-binding-already-consumed");
429
- assert.deepEqual(fixture.answers, ["declined", "declined"]);
436
+ assert.deepEqual(fixture.answers, ["declined", "declined", "declined"]);
430
437
 
438
+ // A binding still pending when its owning session shuts down is discarded
439
+ // and becomes unreachable to every session -- cross-session resolution
440
+ // included.
441
+ const blockedShutdown = await beginConsent(first, cwd, "session-a");
431
442
  const shutdown = first.events.get("session_shutdown");
432
443
  assert.ok(shutdown);
433
444
  await shutdown!({}, context(cwd, "session-a"));
434
445
  await shutdown!({}, context(cwd, "session-a"));
435
- const staleShutdown = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
446
+ const staleShutdown = await answerConsent(second, cwd, blockedShutdown.consent_binding, "declined", "session-b");
436
447
  assert.equal(staleShutdown.operation, "answer-consent");
437
- assertStaleConsentBinding(staleShutdown, blockedA.consent_binding, "consent-binding-unknown");
448
+ assertStaleConsentBinding(staleShutdown, blockedShutdown.consent_binding, "consent-binding-unknown");
438
449
 
439
450
  writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
440
451
  const nextCandidate = await beginConsent(second, cwd, "session-b");
@@ -450,7 +461,7 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
450
461
  const staleModeCleared = await answerConsent(second, cwd, nextCandidate.consent_binding, "declined", "session-b");
451
462
  assert.equal(staleModeCleared.operation, "answer-consent");
452
463
  assertStaleConsentBinding(staleModeCleared, nextCandidate.consent_binding, "consent-binding-unknown");
453
- assert.equal(fixture.starts.count, 4);
464
+ assert.equal(fixture.starts.count, 5);
454
465
  });
455
466
 
456
467
  test("already-consumed local consent bindings reconcile exactly once with the native status transition", async (t) => {
@@ -783,18 +794,14 @@ test("native START maps only provider facts and omits absent evidence", async (t
783
794
  assert.equal(rendered.next_transition, nextTransition);
784
795
  });
785
796
 
786
- test("consent completion stays authoritative when local latch recording fails", async (t) => {
797
+ test("candidate consent completion writes no persistent asked latch", async (t) => {
787
798
  const cwd = repository(t);
788
799
  const fixture = consentNative(cwd);
789
- const notices: Array<{ message: string; type?: string }> = [];
790
- const runtime = parityRuntime(fixture.native, {
791
- writeConsentLatch: () => { throw new Error("injected latch failure"); },
792
- });
800
+ const runtime = parityRuntime(fixture.native);
793
801
  const blocked = await beginConsent(runtime, cwd);
794
802
  const completed = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
795
803
  assert.equal((completed.result as { lineage_id?: string }).lineage_id, "consent-lineage");
796
804
  assert.deepEqual(fixture.answers, ["granted"]);
797
- assert.deepEqual(notices, []);
798
- // The extension handler owns UI reporting; the controller's successful native
799
- // result remains authoritative even when the local best-effort latch fails.
805
+ const commonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8" }).trim();
806
+ assert.equal(existsSync(join(commonDir, "gentle-pi", "review-consent", "asked.json")), false);
800
807
  });