gentle-pi 3.2.0 → 3.3.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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -440,6 +440,67 @@ test("capabilities/v2.5 negotiates the v2.6.0 advertisement and status/v7 decode
440
440
  assert.throws(() => decodeReviewStatusV3(v6WithDigest), /eligible_untracked_inventory/);
441
441
  });
442
442
 
443
+ test("capabilities/v2.6 negotiates the v3.4.0 advertisement on the same v2.5 requirement floor", () => {
444
+ // gentle-ai v3.4.0 advertises capabilities/v2.6: the v2.5 surface plus
445
+ // status/v8 (status/v6 and status/v7 stay advertised for compatibility).
446
+ // The `review assess` review_due/review_due_reason/next_transition
447
+ // additions are a separate schema (gentle-ai.review-assessment/v1),
448
+ // unrelated to this negotiated capabilities surface.
449
+ const v26 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
450
+ v26.schema = "gentle-ai.review-integration.capabilities/v2.6";
451
+ (v26.protocol as JsonObject).minor = 6;
452
+ const features = v26.features as JsonObject;
453
+ features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
454
+ !["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
455
+ v26.schemas = [
456
+ ...(v26.schemas as string[]).map((schema) => schema
457
+ .replace("capabilities/v2.2", "capabilities/v2.6")
458
+ .replace("start/v3", "start/v4")
459
+ .replace("status/v5", "status/v6")),
460
+ "gentle-ai.review-intended-untracked-selection/v1",
461
+ "gentle-ai.review-integration.status/v7",
462
+ "gentle-ai.review-integration.status/v8",
463
+ ];
464
+ const decoded = decodeReviewCapabilitiesV2(v26, CAPTURED_DIGEST);
465
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
466
+ // The negotiated set is the v2.5 requirement floor unchanged; status/v7
467
+ // and status/v8 are additive and never required, so an advertisement
468
+ // without either still negotiates.
469
+ const withoutV7V8 = clone(v26);
470
+ withoutV7V8.schemas = (withoutV7V8.schemas as string[]).filter((schema) => !["gentle-ai.review-integration.status/v7", "gentle-ai.review-integration.status/v8"].includes(schema as string));
471
+ assert.equal(decodeReviewCapabilitiesV2(withoutV7V8, CAPTURED_DIGEST).schemas.has("gentle-ai.review-integration.status/v6"), true);
472
+
473
+ // status/v6 stays required: v7/v8 are additive extensions, not a replacement.
474
+ const missingStatusV6 = clone(v26);
475
+ missingStatusV6.schemas = (missingStatusV6.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v6");
476
+ assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
477
+ });
478
+
479
+ // status/v8 (gentle-ai main, PR #4765; the current released contract) only
480
+ // extended the reviewer-result transition for OpenCode provider tasks -- no
481
+ // new top-level key -- so it decodes on the exact v7 surface: same optional
482
+ // eligible_untracked_inventory digest, same rejection of a v6 envelope
483
+ // carrying it. status/v9 (the sibling gentle-ai branch's contract, ahead of
484
+ // the released v8) adds nothing new at the top level either; its one
485
+ // addition -- the host-mediated role submission -- lives in
486
+ // next_transition.collect.inputs and is covered by
487
+ // tests/review-integration-v2.test.ts's "v9 host-mediated" tests.
488
+ test("status/v8 and status/v9 decode with their exact identity on the v7 surface", () => {
489
+ const v8 = initialIntendedUntrackedStatusV6();
490
+ v8.schema = "gentle-ai.review-integration.status/v8";
491
+ v8.eligible_untracked_inventory = sha("e");
492
+ const decodedV8 = decodeReviewStatusV3(v8);
493
+ assert.equal(decodedV8.raw.schema, "gentle-ai.review-integration.status/v8");
494
+ assert.equal(decodedV8.eligibleUntrackedInventory, sha("e"));
495
+
496
+ const v9 = initialIntendedUntrackedStatusV6();
497
+ v9.schema = "gentle-ai.review-integration.status/v9";
498
+ v9.eligible_untracked_inventory = sha("e");
499
+ const decodedV9 = decodeReviewStatusV3(v9);
500
+ assert.equal(decodedV9.raw.schema, "gentle-ai.review-integration.status/v9");
501
+ assert.equal(decodedV9.eligibleUntrackedInventory, sha("e"));
502
+ });
503
+
443
504
  test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
444
505
  const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
445
506
  const input = decoded.nextTransition?.collect?.inputs[0];
@@ -818,11 +818,14 @@ test("next_transition decodes the self-contained provider role capture vectors s
818
818
  () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/reviewer/v1")),
819
819
  /schema must be https:\/\/gentle-ai\.dev\/schema\/review\/refuter\/v1/,
820
820
  );
821
+ // This fixture decodes on the v5 surface only (no v9): a submission
822
+ // descriptor on a role input is rejected for lacking the v9 provider
823
+ // contract, before the materialize/execute discriminator is even read.
821
824
  assert.throws(
822
825
  () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
823
826
  submission: { operation_token: "capture-refuter", argument_tokens: ["--input={{value}}"], values: [{ slot: "{{value}}", domain: "artifact-path", substitution_location: 0 }] },
824
827
  })),
825
- /submission is not allowed on the self-contained/,
828
+ /submission requires the v9 provider contract/,
826
829
  );
827
830
 
828
831
  const validator = roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1");
@@ -837,6 +840,87 @@ test("next_transition decodes the self-contained provider role capture vectors s
837
840
  );
838
841
  });
839
842
 
843
+ // gentle-ai's v9 contract makes the same two role operations host-mediated
844
+ // exactly like a lens materialize slot: the input carries a submission
845
+ // descriptor alongside --materialize=true (never --execute), and the host
846
+ // completes the frozen prompt in-process and submits through that
847
+ // descriptor instead of executing a Go-owned pi subprocess.
848
+ test("next_transition decodes the v9 host-mediated provider role submission form", () => {
849
+ const materializeArguments = [
850
+ { name: "lineage", value: "review-fixture", token: "--lineage=review-fixture" },
851
+ { name: "expected-revision", value: digest, token: `--expected-revision=${digest}` },
852
+ { name: "target", value: digest, token: `--target=${digest}` },
853
+ { name: "repository-context", value: `rctx1_${"c".repeat(64)}`, token: `--repository-context=rctx1_${"c".repeat(64)}` },
854
+ { name: "agent", value: "pi", token: "--agent=pi" },
855
+ { name: "materialize", value: "true", token: "--materialize=true" },
856
+ ];
857
+ const roleInput = (name: string, captureOperation: string, schema: string, extra: Record<string, unknown> = {}) => ({
858
+ kind: "collect",
859
+ reason_code: "provider_refuter_required",
860
+ collect: { inputs: [{ name, schema, capture_operation: captureOperation, arguments: materializeArguments, ...extra }] },
861
+ });
862
+ // This decoder path is gated on v9 specifically -- the sibling gentle-ai
863
+ // branch's contract, ahead of the currently-released v8 -- not on v5,
864
+ // which v8 (and v6/v7) already satisfy.
865
+ const decodeRoleTransition = (value: unknown) => decodeReviewNextTransitionV3(value, { v9: true });
866
+ const refuterSubmission = {
867
+ operation_token: "capture-refuter",
868
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
869
+ value: { slot: "provider_refuter", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/refuter/v1", substitution_location: materializeArguments.length },
870
+ };
871
+
872
+ const refuter = roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", { submission: refuterSubmission });
873
+ const decodedSubmission = decodeRoleTransition(refuter).collect?.inputs[0]?.submission;
874
+ assert.equal(decodedSubmission?.operationToken, "capture-refuter");
875
+ assert.equal(decodedSubmission?.argumentTokens.length, materializeArguments.length + 1);
876
+ assert.deepEqual(decodedSubmission?.values, [{
877
+ slot: "provider_refuter",
878
+ domain: "artifact_path_or_stdin",
879
+ schema: "https://gentle-ai.dev/schema/review/refuter/v1",
880
+ substitutionLocation: materializeArguments.length,
881
+ }]);
882
+
883
+ // The exact same payload under v8 (or any earlier non-v9 rung) is
884
+ // rejected: v8 only extended the reviewer-result transition for OpenCode
885
+ // provider tasks and never renders a role submission descriptor.
886
+ assert.throws(
887
+ () => decodeReviewNextTransitionV3(refuter, { v6: true }),
888
+ /submission requires the v9 provider contract/,
889
+ );
890
+
891
+ // A submission descriptor with no --materialize=true (and no --execute
892
+ // either) matches neither wire form.
893
+ assert.throws(
894
+ () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
895
+ submission: refuterSubmission,
896
+ arguments: materializeArguments.filter((argument) => argument.name !== "materialize"),
897
+ })),
898
+ /submission requires --materialize=true/,
899
+ );
900
+
901
+ // --execute alongside a submission descriptor mixes both wire forms on
902
+ // one input, which is never valid.
903
+ assert.throws(
904
+ () => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
905
+ submission: refuterSubmission,
906
+ arguments: [...materializeArguments, { name: "execute", value: "true", token: "--execute=true" }],
907
+ })),
908
+ /must not carry --execute alongside a submission descriptor/,
909
+ );
910
+
911
+ // The targeted-validator's host-mediated form still requires its
912
+ // validation_request, exactly like the self-contained vector.
913
+ const validatorSubmission = {
914
+ operation_token: "capture-validation",
915
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
916
+ value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
917
+ };
918
+ assert.throws(
919
+ () => decodeRoleTransition(roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1", { submission: validatorSubmission })),
920
+ /validation_request is required/,
921
+ );
922
+ });
923
+
840
924
  test("v5 targeted-validator collect inputs carry the exact provider-owned validation request", () => {
841
925
  const requestHash = `sha256:${"a".repeat(64)}`;
842
926
  const expectedRevision = `sha256:${"b".repeat(64)}`;
@@ -913,6 +997,37 @@ test("v5 targeted-validator collect inputs carry the exact provider-owned valida
913
997
  }]);
914
998
  assert.deepEqual(decoded.collect?.inputs[0]?.arguments, input.arguments, "provider-rendered arguments must remain unchanged");
915
999
 
1000
+ // The same validation_request rides the v9 host-mediated form too: swap
1001
+ // --execute=true for --materialize=true and add the provider-owned
1002
+ // submission descriptor.
1003
+ const materializeArguments = input.arguments.map((argument) => argument.name === "execute" ? { name: "materialize", value: "true", token: "--materialize=true" } : argument);
1004
+ const hostMediatedInput = {
1005
+ ...input,
1006
+ arguments: materializeArguments,
1007
+ submission: {
1008
+ operation_token: "capture-validation",
1009
+ argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
1010
+ value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
1011
+ },
1012
+ };
1013
+ const hostMediatedTransition = { kind: "collect" as const, reason_code: "targeted_validation_required", collect: { inputs: [hostMediatedInput] } };
1014
+ const decodedHostMediated = decodeReviewNextTransitionV3(hostMediatedTransition, { v9: true });
1015
+ assert.equal(decodedHostMediated.collect?.inputs[0]?.submission?.operationToken, "capture-validation");
1016
+ assert.deepEqual(decodedHostMediated.collect?.inputs[0]?.submission?.values, [{
1017
+ slot: "provider_targeted_validator",
1018
+ domain: "artifact_path_or_stdin",
1019
+ schema: "https://gentle-ai.dev/schema/review/validator/v1",
1020
+ substitutionLocation: materializeArguments.length,
1021
+ }]);
1022
+ assert.equal(decodedHostMediated.collect?.inputs[0]?.validationRequest?.requestHash, requestHash);
1023
+
1024
+ // The identical v8 surface (v6/v7/v8 all decode collect inputs alike)
1025
+ // rejects the same host-mediated submission: only v9 renders it.
1026
+ assert.throws(
1027
+ () => decodeReviewNextTransitionV3(hostMediatedTransition, { v6: true }),
1028
+ /submission requires the v9 provider contract/,
1029
+ );
1030
+
916
1031
  const missingRequest = clone(transition);
917
1032
  delete (missingRequest.collect.inputs[0] as JsonObject).validation_request;
918
1033
  assert.throws(() => decodeReviewNextTransitionV3(missingRequest, { v5: true }), /validation_request is required/);
@@ -5,6 +5,7 @@ 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 { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
8
9
  import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
9
10
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
10
11
  import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
@@ -186,6 +187,88 @@ test("the negotiated status asks for the pi agent so the provider offers its mat
186
187
  assert.equal(hostRelay.transport, "pi_host_relay");
187
188
  });
188
189
 
190
+ // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
191
+ // user-owned completion selection (agent model routing config) rides the
192
+ // relay request alongside the live model registry; there is no extension
193
+ // allowlist and no ambient default model to fall back to.
194
+ test("capture forwards the lens's user-owned selection and thinking level to the relay request", async (t) => {
195
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
196
+ const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-"));
197
+ const cwd = repository(t);
198
+ t.after(() => rmSync(configHome, { recursive: true, force: true }));
199
+ writeFileSync(join(configHome, "models.json"), JSON.stringify({ "review-reliability": { model: "minimax/MiniMax-M3", thinking: "high" } }), "utf8");
200
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
201
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
202
+ t.after(() => {
203
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
204
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
205
+ });
206
+ const { native } = transportAwareNative();
207
+ const relayed: ReviewHostRelayRequest[] = [];
208
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
209
+ relayed.push(request);
210
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
211
+ });
212
+
213
+ await runCapture(cwd, native, "selection-lineage");
214
+ assert.equal(relayed.length, 1);
215
+ assert.equal(relayed[0]!.selection, "minimax/MiniMax-M3", "the lens's routing entry must name the completion's selection");
216
+ assert.equal(relayed[0]!.thinking, "high");
217
+ assert.equal(relayed[0]!.routingKey, "review-reliability");
218
+ });
219
+
220
+ // gentle-pi#311 P2 decision (flagged for confirmation): the in-process path
221
+ // has no ambient default model. A routing entry with no configured model used
222
+ // to launch the child selection-free (inheriting pi's own default); the real
223
+ // relay now refuses it typed instead, since there is no child to inherit a
224
+ // default from (lib/review-host-relay.ts's own tests exercise that refusal
225
+ // through the real, unfaked runner). This extension layer only forwards
226
+ // whatever the routing config yields — it never validates the selection
227
+ // itself — so with the runner faked here the request still reaches it, and
228
+ // its `selection` field is simply absent.
229
+ test("capture forwards no selection when the lens has no configured model, leaving the missing-model refusal to the relay", async (t) => {
230
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
231
+ const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-empty-"));
232
+ const cwd = repository(t);
233
+ t.after(() => rmSync(configHome, { recursive: true, force: true }));
234
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
235
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
236
+ t.after(() => {
237
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
238
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
239
+ });
240
+ const { native } = transportAwareNative();
241
+ const relayed: ReviewHostRelayRequest[] = [];
242
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
243
+ relayed.push(request);
244
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
245
+ });
246
+
247
+ await runCapture(cwd, native, "default-lineage");
248
+ assert.equal(relayed.length, 1);
249
+ assert.equal(relayed[0]!.selection, undefined);
250
+ assert.equal(relayed[0]!.routingKey, "review-reliability");
251
+ });
252
+
253
+ test("a relayed empty-output failure carries the child's own evidence in the failure report", async (t) => {
254
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
255
+ const cwd = repository(t);
256
+ const { native } = transportAwareNative();
257
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
258
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no assistant text (stdout kind: no-assistant-text; a tool call was attempted)", {
259
+ reviewerEvidence: { stdoutKind: "no-assistant-text", reviewerModel: "nan/deepseek-v4-flash", toolCallAttempted: true },
260
+ });
261
+ });
262
+
263
+ const result = await runCapture(cwd, native, "evidence-lineage");
264
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
265
+ const failure = result.failure as { reviewer?: { stdoutKind?: string; reviewerModel?: string; toolCallAttempted?: boolean } } | undefined;
266
+ assert.ok(failure !== undefined, "the envelope carries the failure report");
267
+ assert.equal(failure.reviewer?.stdoutKind, "no-assistant-text");
268
+ assert.equal(failure.reviewer?.reviewerModel, "nan/deepseek-v4-flash");
269
+ assert.equal(failure.reviewer?.toolCallAttempted, true);
270
+ });
271
+
189
272
  test("capture forecasts the reviewer model run once and spends nothing until it is acknowledged", async (t) => {
190
273
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
191
274
  const cwd = repository(t);
@@ -524,7 +524,18 @@ async function run() {
524
524
 
525
525
  const toolCwd = await tempWorkspace();
526
526
  try {
527
+ execFileSync("git", ["init"], { cwd: toolCwd, stdio: "ignore" });
527
528
  const toolHook = hooks.get("tool_call")[0];
529
+ const toolResultHook = hooks.get("tool_result")[0];
530
+ const promptHook = hooks.get("before_agent_start")[0];
531
+ const oddCtx = createCtx(toolCwd, false, "odd-runtime-gate");
532
+ await promptHook({ systemPrompt: "primary" }, oddCtx);
533
+ const firstOddPath = join(toolCwd, "first.ts");
534
+ assert.equal(await toolHook({ toolName: "write", input: { path: firstOddPath } }, oddCtx), undefined);
535
+ await toolResultHook({ toolName: "write", toolCallId: "odd-first", input: { path: firstOddPath }, isError: false }, oddCtx);
536
+ const secondOdd = await toolHook({ toolName: "edit", input: { path: join(toolCwd, "second.ts") } }, oddCtx);
537
+ assert.equal(secondOdd?.block, true, "the real primary hook must stop a second distinct direct file");
538
+ assert.match(secondOdd?.reason ?? "", /subagent_run/);
528
539
  const ghPrCwd = await tempWorkspace();
529
540
  try {
530
541
  execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });
@@ -36,3 +36,30 @@ test("reload and new evidence refresh Changes without Git or live file reads",as
36
36
  assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
37
37
  await f.fire("session_shutdown");
38
38
  });
39
+ test("the changes overlay labels each session tree with its root's branch instead of detached",async()=>{
40
+ const evidence=(root:string,id:string)=>({type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id,root,path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}});
41
+ const entries=[evidence("/repo","child:1"),evidence("/other","child:2")];
42
+ const handlers=new Map<string,Function[]>();
43
+ const commands=new Map<string,any>();
44
+ const gitRoots:string[]=[];
45
+ const pi:any={on:(key,fn)=>handlers.set(key,[...(handlers.get(key)??[]),fn]),events:{on:()=>()=>{},emit(){}},appendEntry(){},registerTool(){},registerShortcut(){},registerMessageRenderer(){},registerCommand:(key,registration)=>commands.set(key,registration)};
46
+ let view:any; let renders=0;
47
+ const ctx:any={hasUI:true,cwd:"/repo",sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
48
+ ui:{setFooter(){},getEditorComponent:()=>({}),setWorkingVisible(){},setWidget(){},notify(){},
49
+ custom:async(factory:any)=>{view=factory({terminal:{rows:40,columns:120},requestRender:()=>renders++},{fg:(_c:string,t:string)=>t,bold:(t:string)=>t},{},()=>{});return new Promise(()=>{});}}};
50
+ shell(pi,{},{resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
51
+ gitRunner:(root:string)=>async(args:string[])=>{gitRoots.push(root);await new Promise(r=>setImmediate(r));
52
+ if(args[0]==="symbolic-ref")return root==="/repo"?{stdout:"feature\n",code:0}:{stdout:"",code:1};
53
+ return {stdout:"deadbeef\n",code:0};}});
54
+ for(const fn of handlers.get("session_start")??[])await fn({},ctx);
55
+ void commands.get("gentle:changes").handler("",ctx);
56
+ await new Promise(r=>setImmediate(r));
57
+ assert.ok(view,"the overlay opened");
58
+ for(let i=0;i<6;i++)await new Promise(r=>setImmediate(r));
59
+ const rendered=view.render(120).join("\n");
60
+ assert.match(rendered,/feature · repo/,"the repo tree carries its branch");
61
+ assert.match(rendered,/detached · other/,"a genuinely detached root still reads detached");
62
+ assert.deepEqual([...new Set(gitRoots)].sort(),["/other","/repo"],"git is asked once per root, only while the overlay is open");
63
+ assert.ok(renders>0,"a resolved label asks the overlay to repaint");
64
+ for(const fn of handlers.get("session_shutdown")??[])await fn({},ctx);
65
+ });
@@ -133,3 +133,44 @@ test("only standard path-bearing calls have registration candidates; shell and p
133
133
  for (const name of ["bash", "powershell", "custom", "subagent_run"]) assert.equal(toolWorktreePath(name, { path: "/linked", command: "cd /linked", task: "/linked" }), undefined);
134
134
  assert.equal(toolWorktreePath("read", { path: 42 }), undefined);
135
135
  });
136
+
137
+ // C2 (odd/tasks/usage-click-and-changes-attribution.md): a repo nested inside
138
+ // another repo (the live session's ~/work/NaN-builders inside ~/work) must
139
+ // resolve to the INNER repo, never the outer one, because Git itself walks
140
+ // up from the file's own directory and stops at the first .git it finds.
141
+ function nestedFixture(t: test.TestContext) {
142
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "session-worktrees-nested-")));
143
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
144
+ const outer = join(dir, "work");
145
+ const inner = join(outer, "NaN-builders");
146
+ const empty = join(dir, "empty");
147
+ mkdirSync(empty);
148
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
149
+ Object.assign(env, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1" });
150
+ writeFileSync(join(empty, "config"), "");
151
+ const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
152
+ // Outer repo: no commits, matching the live evidence's `~/work` exactly.
153
+ git(dir, ["init", "--initial-branch=main", `--template=${empty}`, outer]);
154
+ // Inner repo: its own .git, a real branch and a commit.
155
+ git(dir, ["init", "--initial-branch=feature", `--template=${empty}`, inner]);
156
+ git(inner, ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "Fixture"]);
157
+ mkdirSync(join(inner, "odd", "tasks"), { recursive: true });
158
+ writeFileSync(join(inner, "odd", "tasks", "jpg-png-converter.md"), "converted\n");
159
+ return { dir, outer, inner };
160
+ }
161
+
162
+ test("nearest-repository resolution: an inner repo's files never resolve to an outer ancestor repo", (t) => {
163
+ const f = nestedFixture(t);
164
+ const forFile = resolveSessionWorktree(join("NaN-builders", "odd", "tasks", "jpg-png-converter.md"), f.outer);
165
+ assert.equal(forFile?.root, f.inner, "the file's own nearest repository must win, not the outer ~/work repo");
166
+ // The inner repo's own root and files resolve to itself too, whether
167
+ // addressed from the outer cwd or the inner cwd directly.
168
+ const forRoot = resolveSessionWorktree("NaN-builders", f.outer);
169
+ assert.equal(forRoot?.root, f.inner);
170
+ const fromInnerCwd = resolveSessionWorktree(join("odd", "tasks", "jpg-png-converter.md"), f.inner);
171
+ assert.equal(fromInnerCwd?.root, f.inner);
172
+ // The outer repo is still resolvable for its own files.
173
+ const outerFile = join(f.outer, "README.md");
174
+ writeFileSync(outerFile, "outer\n");
175
+ assert.equal(resolveSessionWorktree("README.md", f.outer)?.root, f.outer);
176
+ });