gentle-pi 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -699,3 +699,47 @@ test("review-acknowledged/v1 is disjoint from every prior captured identity in b
699
699
  assert.throws(() => decoder(clone(acknowledged)), `${name} must reject the acknowledged envelope`);
700
700
  }
701
701
  });
702
+
703
+ // Producer-shaped projection: gentle-ai d521b5e, target_status.go and
704
+ // last-event-closure.schema.json#/properties/escalation (not a live capture).
705
+ function escalatedStatusV7(): JsonObject {
706
+ const body = currentStatusFixture("status-v5-capture-result-submission.captured.json");
707
+ body.schema = "gentle-ai.review-integration.status/v7";
708
+ (body.authority as JsonObject).state = "escalated";
709
+ body.action = "stop";
710
+ body.replayability = "manual_action_required";
711
+ body.next_transition = { kind: "stop", reason_code: "escalated" };
712
+ delete body.forecast;
713
+ body.escalation = { cause: "unknown_causality", finding_ids: ["R1"] };
714
+ return body;
715
+ }
716
+
717
+ test("v7 escalation is typed metadata and preserves native stop authority", () => {
718
+ const body = escalatedStatusV7();
719
+ const decoded = decodeReviewStatusV3(body);
720
+ assert.deepEqual(decoded.escalation, { cause: "unknown_causality", findingIds: ["R1"] });
721
+ delete body.escalation;
722
+ const older = decodeReviewStatusV3(body);
723
+ assert.deepEqual({ ...decoded, escalation: undefined, raw: undefined }, { ...older, escalation: undefined, raw: undefined });
724
+ assert.equal(decoded.action, "stop");
725
+ assert.equal(decoded.authority?.state, "escalated");
726
+ assert.deepEqual(decoded.nextTransition, { kind: "stop", reasonCode: "escalated" });
727
+ });
728
+
729
+ test("v7 escalation strictly decodes all causes and optional refuter evidence", () => {
730
+ for (const cause of ["unknown_causality", "insufficient_evidence", "missing_refuter_outcome", "targeted_validator_rejected", "correction_budget_exceeded", "unresolved_severe_findings"]) {
731
+ const body = escalatedStatusV7();
732
+ const rows = ["corroborated", "refuted", "inconclusive"].map((outcome) => ({ finding_id: "R2", outcome, proof: "observed evidence" }));
733
+ body.escalation = { cause, finding_ids: [], refuter_outcomes: rows };
734
+ assert.deepEqual(decodeReviewStatusV3(body).escalation, { cause, findingIds: [], refuterOutcomes: rows.map(({ finding_id, ...row }) => ({ findingId: finding_id, ...row })) });
735
+ }
736
+ for (const escalation of [null, undefined, [], {}, { cause: "future", finding_ids: [] }, { cause: "unknown_causality" }, { cause: "unknown_causality", finding_ids: [""] }, { cause: "unknown_causality", finding_ids: "R1" }, { cause: "unknown_causality", finding_ids: [], extra: true }]) {
737
+ assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), escalation }), /status\.escalation/);
738
+ }
739
+ for (const refuter_outcomes of [null, undefined, {}, [null], [{}], [{ finding_id: "R1", outcome: "future", proof: "proof" }], [{ finding_id: "", outcome: "refuted", proof: "proof" }], [{ finding_id: "R1", outcome: "refuted", proof: "" }], [{ finding_id: "R1", outcome: "refuted", proof: "proof", extra: true }]]) {
740
+ assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), escalation: { cause: "missing_refuter_outcome", finding_ids: ["R1"], refuter_outcomes } }), /refuter_outcomes/);
741
+ }
742
+ for (const version of [3, 5, 6]) {
743
+ assert.throws(() => decodeReviewStatusV3({ ...escalatedStatusV7(), schema: `gentle-ai.review-integration.status/v${version}` }), /not allowed/);
744
+ }
745
+ });
@@ -478,6 +478,170 @@ test("next_transition stop decodes a managed_assets_outdated continuation and re
478
478
  assert.equal(plainStop.continuation, undefined);
479
479
  });
480
480
 
481
+ // gentle-pi#638: after the host relay declares a selected lens slot
482
+ // unachievable through the native capture-unachievable verb, gentle-ai's
483
+ // STATUS stops with reason_code unachievable_lens_slot and carries one entry
484
+ // per declared slot, each naming the complete withdraw command
485
+ // (contracts/review-integration/v2/schemas/status-v7.schema.json, the stop
486
+ // variant). A restart that never saw the collect offer must still be able to
487
+ // retract the declaration from the stop alone, so the withdraw binding is
488
+ // decoded, never dropped.
489
+ function unachievableSlot(overrides: Partial<JsonObject> = {}): JsonObject {
490
+ return {
491
+ lens: "review-risk",
492
+ selected_order: 0,
493
+ subject_hash: digest,
494
+ reason: "relay_transport_bound_exceeded",
495
+ withdraw: {
496
+ operation: "review.capture-unachievable",
497
+ command: `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`,
498
+ arguments: [
499
+ { name: "lineage", value: "review-fixture", token: "--lineage=review-fixture" },
500
+ { name: "expected-revision", value: digest, token: `--expected-revision=${digest}` },
501
+ { name: "target", value: digest, token: `--target=${digest}` },
502
+ { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
503
+ { name: "request-hash", value: digest, token: `--request-hash=${digest}` },
504
+ { name: "withdraw", value: "true", token: "--withdraw=true" },
505
+ ],
506
+ binding: { lineage_id: "review-fixture", revision: digest, target_identity: digest, repository_context: `rctx1_${"e".repeat(64)}` },
507
+ },
508
+ ...overrides,
509
+ };
510
+ }
511
+
512
+ test("next_transition stop decodes unachievable_lens_slots and their withdraw bindings only on that reason code", () => {
513
+ const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
514
+ const decoded = decodeReviewNextTransitionV3(stop);
515
+ assert.equal(decoded.kind, "stop");
516
+ assert.equal(decoded.reasonCode, "unachievable_lens_slot");
517
+ const slot = decoded.unachievableLensSlots?.[0];
518
+ assert.equal(slot?.lens, "review-risk");
519
+ assert.equal(slot?.selectedOrder, 0);
520
+ assert.equal(slot?.subjectHash, digest);
521
+ assert.equal(slot?.reason, "relay_transport_bound_exceeded");
522
+ assert.equal(slot?.detail, undefined);
523
+ assert.equal(slot?.withdraw.operation, "review.capture-unachievable");
524
+ assert.deepEqual(slot?.withdraw.arguments.map((argument) => argument.token), ["--lineage=review-fixture", `--expected-revision=${digest}`, `--target=${digest}`, `--repository-context=rctx1_${"e".repeat(64)}`, `--request-hash=${digest}`, "--withdraw=true"]);
525
+ assert.equal(slot?.withdraw.binding.targetIdentity, digest);
526
+ assert.equal(slot?.withdraw.command, `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`);
527
+
528
+ // detail is the one optional field the schema declares
529
+ const detailed = decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "killed after 2256004ms against a 2256000ms relay bound" })] });
530
+ assert.equal(detailed.unachievableLensSlots?.[0]?.detail, "killed after 2256004ms against a 2256000ms relay bound");
531
+
532
+ // malformed entries are refused: a negative selected_order, a slot missing
533
+ // its withdraw form, a foreign withdraw operation, or an empty array
534
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ selected_order: -1 })] }), /selected_order/);
535
+ const withoutWithdraw = unachievableSlot();
536
+ delete withoutWithdraw.withdraw;
537
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [withoutWithdraw] }), /withdraw.*required|required.*withdraw/);
538
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ withdraw: { ...(unachievableSlot().withdraw as JsonObject), operation: "review.capture-result" } })] }), /operation/);
539
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [] }), /length/);
540
+
541
+ // the slots are only valid on their own exact stop reason code
542
+ assert.throws(() => decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled", unachievable_lens_slots: [unachievableSlot()] }), /unachievable_lens_slots is only valid/);
543
+ // an execute or collect transition never carries them
544
+ assert.throws(() => decodeReviewNextTransitionV3({ kind: "execute", reason_code: "fresh_target_ready", execute: { operation: "review.start", arguments: [{ name: "lineage", value: "review-fixture" }], preconditions: [], binding: { target_identity: digest } }, unachievable_lens_slots: [unachievableSlot()] }), /unachievable_lens_slots is only valid/);
545
+ // plain stops stay unchanged
546
+ assert.equal(decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled" }).unachievableLensSlots, undefined);
547
+ });
548
+
549
+ // gentle-pi#822: the withdraw form names the slot identity twice — as named arguments and as the binding object — and a slot whose two renderings disagree never decodes, so restart cannot withdraw a different slot.
550
+ test("next_transition stop refuses an unachievable slot whose withdraw arguments disagree with its identity", () => {
551
+ const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
552
+ const drift = (name: string, value: string): JsonObject => {
553
+ const drifted = unachievableSlot();
554
+ const withdraw = drifted.withdraw as JsonObject;
555
+ withdraw.arguments = (withdraw.arguments as unknown[]).map((argument) => (argument as JsonObject).name === name ? { ...(argument as JsonObject), value } : argument);
556
+ return drifted;
557
+ };
558
+ for (const [name, value] of [["request-hash", `sha256:${"b".repeat(64)}`], ["target", `sha256:${"c".repeat(64)}`], ["lineage", "review-fixture-drifted"], ["expected-revision", `sha256:${"d".repeat(64)}`]] as const) {
559
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [drift(name, value)] }), new RegExp(`${name} does not match`), name);
560
+ }
561
+ // the pristine fixture still decodes with both renderings agreeing
562
+ assert.doesNotThrow(() => decodeReviewNextTransitionV3(stop));
563
+ });
564
+
565
+ // gentle-pi#822: identity arguments appear exactly once, and the rendered
566
+ // command is an exact, ordered rendering of every validated provider token.
567
+ // Substring checks would accept a missing non-identity token, repeated command,
568
+ // suffix, or shell payload despite the command no longer naming this slot alone.
569
+ test("next_transition stop refuses duplicate identities and non-exact withdraw commands", () => {
570
+ const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
571
+ const duplicated = unachievableSlot();
572
+ const duplicatedWithdraw = duplicated.withdraw as JsonObject;
573
+ duplicatedWithdraw.arguments = [...(duplicatedWithdraw.arguments as unknown[]), { name: "lineage", value: "review-fixture-smuggled", token: "--lineage=review-fixture-smuggled" }];
574
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [duplicated] }), /withdraw\.arguments lineage must appear exactly once/);
575
+
576
+ const canonical = `gentle-ai review capture-unachievable --lineage=review-fixture --expected-revision=${digest} --target=${digest} --repository-context=rctx1_${"e".repeat(64)} --request-hash=${digest} --withdraw=true`;
577
+ for (const [name, command] of [
578
+ ["omitted token", canonical.replace(" --withdraw=true", "")],
579
+ ["duplicate command", `gentle-ai review capture-unachievable ${canonical}`],
580
+ ["extra suffix", `${canonical} --surplus=true`],
581
+ ["shell payload", `${canonical} && printf injected`],
582
+ ] as const) {
583
+ const malformed = unachievableSlot();
584
+ (malformed.withdraw as JsonObject).command = command;
585
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [malformed] }), /withdraw\.command must exactly render/, name);
586
+ }
587
+ assert.doesNotThrow(() => decodeReviewNextTransitionV3(stop));
588
+ });
589
+
590
+ // gentle-pi#822 (CodeRabbit finding): withdrawal is a narrowly scoped, runnable
591
+ // retraction. Its one affirmative flag is part of the exact native vector, not
592
+ // just another provider-defined argument that a malformed STATUS can omit or
593
+ // negate while still rendering a plausible command.
594
+ test("next_transition stop requires exactly one canonical --withdraw=true argument", () => {
595
+ const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
596
+ const canonicalCommand = (slot: JsonObject): string => {
597
+ const withdraw = slot.withdraw as JsonObject;
598
+ return `gentle-ai review capture-unachievable ${(withdraw.arguments as JsonObject[]).map((argument) => String(argument.token)).join(" ")}`;
599
+ };
600
+
601
+ const omitted = unachievableSlot();
602
+ const omittedWithdraw = omitted.withdraw as JsonObject;
603
+ omittedWithdraw.arguments = (omittedWithdraw.arguments as JsonObject[]).filter((argument) => argument.name !== "withdraw");
604
+ omittedWithdraw.command = canonicalCommand(omitted);
605
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [omitted] }), /withdraw must appear exactly once/);
606
+
607
+ const falseValue = unachievableSlot();
608
+ const falseWithdraw = falseValue.withdraw as JsonObject;
609
+ falseWithdraw.arguments = (falseWithdraw.arguments as JsonObject[]).map((argument) => argument.name === "withdraw"
610
+ ? { ...argument, value: "false", token: "--withdraw=false" }
611
+ : argument);
612
+ falseWithdraw.command = canonicalCommand(falseValue);
613
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [falseValue] }), /withdraw must be true/);
614
+
615
+ const noncanonicalToken = unachievableSlot();
616
+ const tokenWithdraw = noncanonicalToken.withdraw as JsonObject;
617
+ tokenWithdraw.arguments = (tokenWithdraw.arguments as JsonObject[]).map((argument) => argument.name === "withdraw"
618
+ ? { ...argument, token: "--withdraw true" }
619
+ : argument);
620
+ tokenWithdraw.command = canonicalCommand(noncanonicalToken);
621
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [noncanonicalToken] }), /token must exactly render/);
622
+
623
+ const duplicate = unachievableSlot();
624
+ const duplicateWithdraw = duplicate.withdraw as JsonObject;
625
+ duplicateWithdraw.arguments = [...duplicateWithdraw.arguments as JsonObject[], { name: "withdraw", value: "true", token: "--withdraw=true" }];
626
+ duplicateWithdraw.command = canonicalCommand(duplicate);
627
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [duplicate] }), /withdraw must appear exactly once/);
628
+ });
629
+
630
+ // gentle-pi#822 (CodeRabbit finding): Go bounds the CAPTURE_UNACHIEVABLE detail at 512 UTF-8 bytes (native-review-cli.ts enforces the same limit when declaring), so the transition decoder mirrors that bound — measured in bytes, not UTF-16 code units — and a STATUS stop can never carry a detail this client would have refused to declare.
631
+ test("next_transition stop bounds an unachievable slot detail at 512 UTF-8 bytes", () => {
632
+ const stop: JsonObject = { kind: "stop", reason_code: "unachievable_lens_slot", unachievable_lens_slots: [unachievableSlot()] };
633
+ // exactly 512 one-byte characters still decodes
634
+ assert.equal(decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "a".repeat(512) })] }).unachievableLensSlots?.[0]?.detail, "a".repeat(512));
635
+ // exactly 512 bytes of two-byte characters still decodes: 256 × 2
636
+ assert.equal(decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "é".repeat(256) })] }).unachievableLensSlots?.[0]?.detail, "é".repeat(256));
637
+ // 257 two-byte characters are 514 bytes
638
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "é".repeat(257) })] }), /detail exceeds 512 bytes/);
639
+ // 171 three-byte characters are 513 bytes
640
+ assert.throws(() => decodeReviewNextTransitionV3({ ...stop, unachievable_lens_slots: [unachievableSlot({ detail: "世".repeat(171) })] }), /detail exceeds 512 bytes/);
641
+ // an absent detail still decodes as undefined
642
+ assert.equal(decodeReviewNextTransitionV3(stop).unachievableLensSlots?.[0]?.detail, undefined);
643
+ });
644
+
481
645
  function approvedAcknowledgementTransition(): JsonObject {
482
646
  return {
483
647
  kind: "execute",
@@ -156,6 +156,45 @@ test("strictly decodes the real zero-lens closed START and every last-event clos
156
156
  }
157
157
  });
158
158
 
159
+ test("approved closure preserves complete admitted reviewer results before acknowledgement", () => {
160
+ const approved = closure("review/capture-result", "review-results");
161
+ approved.reviewer_results = [{
162
+ lens: "review-reliability",
163
+ findings: [{
164
+ id: "R3-W01",
165
+ lens: "reliability",
166
+ location: "lib/session.ts:4",
167
+ severity: "SUGGESTION",
168
+ claim: "the complete explanatory narrative remains available",
169
+ proof_refs: ["the changed line was inspected"],
170
+ evidence_class: "insufficient",
171
+ causal_disposition: "pre-existing",
172
+ }],
173
+ evidence: ["inspected the complete frozen candidate"],
174
+ result_hash: SHA,
175
+ }];
176
+ const decoded = decodeReviewLastEventClosureV1(approved);
177
+ assert.deepEqual(decoded.reviewerResults, [{
178
+ lens: "review-reliability",
179
+ findings: [{
180
+ id: "R3-W01",
181
+ lens: "reliability",
182
+ location: "lib/session.ts:4",
183
+ severity: "SUGGESTION",
184
+ claim: "the complete explanatory narrative remains available",
185
+ proofRefs: ["the changed line was inspected"],
186
+ evidenceClass: "insufficient",
187
+ causalDisposition: "pre-existing",
188
+ }],
189
+ evidence: ["inspected the complete frozen candidate"],
190
+ resultHash: SHA,
191
+ }]);
192
+
193
+ const correctionPlan = closure("review.capture-correction-plan", "review-results");
194
+ correctionPlan.reviewer_results = approved.reviewer_results;
195
+ assert.throws(() => decodeReviewLastEventClosureV1(correctionPlan), /reviewer_results requires approved state/);
196
+ });
197
+
159
198
  test("retired legacy client no longer exposes a FINALIZE route", () => {
160
199
  assert.equal("NativeReviewCliV214" in nativeReviewCliModule, false);
161
200
  assert.equal("NativeReviewCliV213" in nativeReviewCliModule, false);
@@ -337,7 +376,7 @@ test("unknown-capture reconciliation preserves agentless legacy callers and forw
337
376
  targetStatus: async (request: Record<string, unknown>) => {
338
377
  calls.push(request);
339
378
  assert.equal(request.lineageId, "reconcile-lineage");
340
- return { targetIdentity: SHA, authority: { lineageId: "reconcile-lineage" } } as ReviewStatusV3;
379
+ return { applicability: "current_target", targetIdentity: SHA, authority: { lineageId: "reconcile-lineage" } } as ReviewStatusV3;
341
380
  },
342
381
  } as unknown as NativeReviewCli;
343
382
  const result = await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA });
@@ -355,6 +394,71 @@ test("unknown-capture reconciliation preserves agentless legacy callers and forw
355
394
  assert.equal(launches, 0);
356
395
  });
357
396
 
397
+ test("unknown targeted-validator capture reconciliation requires current exact authority and target", async () => {
398
+ const lineageId = "targeted-validator-reconciliation";
399
+ const input = roleInput(lineageId, "review.capture-validation");
400
+ const expected = status(lineageId, [input]);
401
+ const cases = [
402
+ ["current matching STATUS", expected, true],
403
+ ["unrelated STATUS", { ...expected, applicability: "unrelated" }, false],
404
+ ["STATUS without authority", { ...expected, authority: undefined }, false],
405
+ ["STATUS for another lineage", { ...expected, authority: { ...expected.authority!, lineageId: "other-lineage" } }, false],
406
+ ["STATUS for another target", { ...expected, targetIdentity: `sha256:${"d".repeat(64)}` }, false],
407
+ ] as const;
408
+
409
+ for (const [_name, reconciliationStatus, trusted] of cases) {
410
+ let statusCalls = 0;
411
+ let captureCalls = 0;
412
+ let startCalls = 0;
413
+ const native = {
414
+ targetStatus: async () => {
415
+ statusCalls += 1;
416
+ return statusCalls === 1 ? expected : reconciliationStatus;
417
+ },
418
+ captureProviderRole: async () => {
419
+ captureCalls += 1;
420
+ throw Object.assign(new Error("targeted-validator response lost"), {
421
+ mutationOutcome: "unknown",
422
+ nextAction: "review.status",
423
+ failureEnvelope: { raw: { code: "targeted-validator-response-lost" }, mutationOutcome: "unknown" },
424
+ });
425
+ },
426
+ start: async () => { startCalls += 1; },
427
+ } as unknown as NativeReviewCli;
428
+
429
+ const result = await capture(lineageId, input, native);
430
+ assert.equal(result.outcome, trusted ? "native-capture-outcome-unknown" : "native-capture-status-reconciliation-failed");
431
+ assert.equal(captureCalls, 1, "an ambiguous capture is never replayed");
432
+ assert.equal(statusCalls, 2, "an ambiguous capture gets one reconciliation STATUS");
433
+ assert.equal(startCalls, 0, "reconciliation never invokes START");
434
+ if (trusted) {
435
+ assert.equal(result.status, "reconciled");
436
+ assert.deepEqual((result.native_failure as { native_failure: unknown }).native_failure, { code: "targeted-validator-response-lost" });
437
+ } else {
438
+ assert.deepEqual(result.native_failure, { code: "targeted-validator-response-lost" }, "the original capture failure is preserved");
439
+ assert.ok(result.reconciliation_failure, "the rejected STATUS diagnostic remains nested");
440
+ }
441
+ }
442
+ });
443
+
444
+ test("unknown-capture reconciliation rejects a binding without a target identity", async () => {
445
+ const lineageId = "missing-reconciliation-target";
446
+ const input = roleInput(lineageId, "review.capture-validation");
447
+ let statusCalls = 0;
448
+ const native = {
449
+ targetStatus: async () => {
450
+ statusCalls += 1;
451
+ return status(lineageId, [input]);
452
+ },
453
+ } as unknown as NativeReviewCli;
454
+
455
+ await assert.rejects(
456
+ () => reconcileUnknownReviewLastEventCapture(native, process.cwd(), { lineageId }),
457
+ /capture reconciliation returned missing or different target/,
458
+ );
459
+ assert.equal(statusCalls, 1, "the missing binding is rejected after one reconciliation STATUS");
460
+ });
461
+
358
462
  // One approved terminal closure carrying the exact continuation the provider
359
463
  // renders: closed positional arguments, the single approved precondition, and a
360
464
  // binding that names the candidate being burned.
@@ -66,13 +66,28 @@ function assertNativeJsonHasNoMetadata(path: string, value: unknown): void {
66
66
  }
67
67
  }
68
68
 
69
- const JUDGMENT_DAY_PATTERNS = [
70
- /Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage\./,
69
+ const JUDGMENT_DAY_DISCOVERY_PATTERNS = [
71
70
  /Judgment Day starts with exactly two blind judges and zero refuters\./,
72
71
  /Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds\./,
73
72
  /Findings surviving round two escalate; no third-round transition exists\./,
74
73
  ] as const;
75
74
 
75
+ const JUDGMENT_DAY_STANDALONE_SEMANTICS =
76
+ "Judgment Day is independent: it neither enables nor replaces ordinary review; a separately requested ordinary review remains independent.";
77
+
78
+ const OBSOLETE_JUDGMENT_DAY_REPLACEMENT =
79
+ /Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage\./;
80
+
81
+ const JUDGMENT_DAY_SEMANTIC_SURFACES = [
82
+ CANONICAL,
83
+ JD_SKILL,
84
+ JD_PROMPTS,
85
+ ...JUDGES,
86
+ FIX_AGENT,
87
+ "assets/orchestrator-delegation.md",
88
+ SDD_WORKFLOW,
89
+ ] as const;
90
+
76
91
  const JUDGMENT_DAY_REJUDGMENT_PATTERNS = [
77
92
  /Initial discovery and scoped re-judgment are separate modes\./,
78
93
  /On controller-requested scoped re-judgment, receive only requested frozen IDs, their exact hash-bound rows, and the fix diff\./,
@@ -115,7 +130,7 @@ test("canonical contract defines compact risk, causal admission, correction, CAS
115
130
  /reviewer and validator outputs remain semantically untrusted/i,
116
131
  /do not report.*trusted local orchestrator.*security finding/i,
117
132
  /untrusted repository content.*malformed inputs.*stale authority.*path drift.*external callers/i,
118
- ...JUDGMENT_DAY_PATTERNS,
133
+ ...JUDGMENT_DAY_DISCOVERY_PATTERNS,
119
134
  ]);
120
135
  assert.match(read(README), /Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy\./);
121
136
  assert.doesNotMatch(read(README), /one one-shot authorization for the exact command/i);
@@ -267,7 +282,7 @@ test("the Pi-owned adversarial role agents are retired: roles execute through Go
267
282
  for (const path of JUDGES) {
268
283
  test(`${path} preserves graph-v1 Judgment Day discovery and scoped re-judgment`, () => {
269
284
  const content = read(path);
270
- assertMatches(path, content, JUDGMENT_DAY_PATTERNS);
285
+ assertMatches(path, content, JUDGMENT_DAY_DISCOVERY_PATTERNS);
271
286
  assertMatches(path, content, JUDGMENT_DAY_REJUDGMENT_PATTERNS);
272
287
  });
273
288
  }
@@ -303,13 +318,53 @@ test("Judgment Day judge prompts contain distinct graph-v1 discovery and re-judg
303
318
  assert.doesNotMatch(judgePrompt, /End with `Skill Resolution:/);
304
319
  });
305
320
 
321
+ test("Judgment Day canonical and packaged surfaces preserve the independent lifecycle", () => {
322
+ for (const path of JUDGMENT_DAY_SEMANTIC_SURFACES) {
323
+ const content = read(path);
324
+ assert.ok(content.includes(JUDGMENT_DAY_STANDALONE_SEMANTICS), `${path} must use the current standalone Judgment Day sentence`);
325
+ assert.doesNotMatch(content, OBSOLETE_JUDGMENT_DAY_REPLACEMENT, `${path} must reject the obsolete replacement semantics`);
326
+ }
327
+ });
328
+
306
329
  test("Judgment Day skill and prompts preserve bounded fix and re-judgment authority", () => {
307
- assertMatches(JD_SKILL, read(JD_SKILL), [...JUDGMENT_DAY_PATTERNS, ...JUDGMENT_DAY_REJUDGMENT_PATTERNS, ...FIX_PATTERNS]);
308
- assertMatches(JD_PROMPTS, fencedBlock(JD_PROMPTS, "## Judge Prompt"), JUDGMENT_DAY_PATTERNS);
330
+ const skill = read(JD_SKILL);
331
+ const judgePrompt = fencedBlock(JD_PROMPTS, "## Judge Prompt");
332
+ assertMatches(JD_SKILL, skill, [...JUDGMENT_DAY_DISCOVERY_PATTERNS, ...JUDGMENT_DAY_REJUDGMENT_PATTERNS, ...FIX_PATTERNS]);
333
+ assertMatches(JD_PROMPTS, judgePrompt, JUDGMENT_DAY_DISCOVERY_PATTERNS);
309
334
  assertMatches(JD_PROMPTS, fencedBlock(JD_PROMPTS, "## Fix Agent Prompt"), FIX_PATTERNS);
310
335
  assertMatches(FIX_AGENT, read(FIX_AGENT), FIX_PATTERNS);
311
336
  });
312
337
 
338
+ test("Judgment Day fix routing has one canonical shape and never falls back to generic roles", () => {
339
+ const canonicalShape = [
340
+ "## Judgment Day activation",
341
+ "User explicitly requested Judgment Day.",
342
+ "## Exact authorized severe IDs",
343
+ "- `JD-A-001`",
344
+ "## Judgment Day correction batch",
345
+ "Round: 1 of 2.",
346
+ "Frozen ledger SHA-256: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`",
347
+ "## Exact frozen finding rows",
348
+ '{"id":"JD-A-001","lens":"judgment-day","location":"path/to/authorized-file.ts:1","severity":"CRITICAL","status_at_freeze":"open","evidence_class":"deterministic","evidence_claim":"Concrete user-impact claim supported by the frozen location."}',
349
+ "## Allowed edit surfaces",
350
+ ].join("\n");
351
+ for (const [path, content] of [
352
+ [CANONICAL, read(CANONICAL)],
353
+ [FIX_AGENT, read(FIX_AGENT)],
354
+ [JD_SKILL, read(JD_SKILL)],
355
+ [JD_PROMPTS, fencedBlock(JD_PROMPTS, "## Fix Agent Prompt")],
356
+ ["assets/orchestrator-delegation.md", read("assets/orchestrator-delegation.md")],
357
+ [SDD_WORKFLOW, read(SDD_WORKFLOW)],
358
+ ] as const) {
359
+ assert.ok(content.includes(canonicalShape), `${path} must carry the canonical Judgment Day fix shape`);
360
+ assert.match(content, /requires no graph-v1 or native review lineage/i);
361
+ }
362
+ const routing = `${read("assets/orchestrator-delegation.md")}\n${read(SDD_WORKFLOW)}`;
363
+ assert.match(routing, /Judgment Day phase roles are never generic fallbacks\./);
364
+ assert.match(routing, /If the generic writer chain is unavailable, use the documented native generic fallback or stop\./);
365
+ assert.match(read(SDD_WORKFLOW), /\| default\s+\| balanced\s+\| SDD phase fallback; never a Judgment Day role\s+\|/);
366
+ });
367
+
313
368
  test("orchestrator, injected skill, and README defer RDD lifecycle ownership to Gentle AI", () => {
314
369
  const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
315
370
  const orchestrator = union(ORCHESTRATOR);
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
4
+
5
+ function fixture() {
6
+ let sessionId = "parent";
7
+ const entries: Array<{ type: string; customType: string; data: unknown }> = [];
8
+ const session = { getSessionId: () => sessionId, getBranch: () => entries };
9
+ const host = { appendEntry: (customType: string, data: unknown) => { entries.push({ type: "custom", customType, data }); } };
10
+ const write = (toolCallId: string, root = "/repo") => recordReviewMutation(host, session, root, { source: "direct", toolName: "write", toolCallId });
11
+ return { host, session, entries, write, switchSession: (id: string) => { sessionId = id; } };
12
+ }
13
+
14
+ test("receipts survive reload but are isolated by session, root, and active branch", () => {
15
+ const f = fixture();
16
+ f.write("a");
17
+ const captured = pendingReviewMutation(f.session, "/repo");
18
+ assert.ok(captured);
19
+ assert.equal(pendingReviewMutation({ ...f.session }, "/repo"), captured);
20
+ assert.equal(pendingReviewMutation(f.session, "/sibling"), undefined);
21
+ f.switchSession("fork");
22
+ assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
23
+ f.switchSession("new");
24
+ assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
25
+ f.switchSession("parent");
26
+ assert.equal(pendingReviewMutation({ ...f.session, getBranch: () => [] }, "/repo"), undefined);
27
+ f.write("a");
28
+ assert.equal(f.entries.length, 1, "duplicate tool completion cannot re-enable a generation");
29
+ });
30
+
31
+ for (const kind of ["nudged", "acknowledged"] as const) {
32
+ test(`${kind} consumes only its captured prefix, retaining a concurrent own mutation`, () => {
33
+ const f = fixture();
34
+ f.write("a");
35
+ const captured = pendingReviewMutation(f.session, "/repo");
36
+ f.write("b");
37
+ consumeReviewMutation(f.host, f.session, "/repo", captured, kind, "whole-target");
38
+ assert.equal(pendingReviewMutation(f.session, "/repo", captured), undefined);
39
+ const next = pendingReviewMutation(f.session, "/repo");
40
+ assert.ok(next && next !== captured);
41
+ consumeReviewMutation(f.host, f.session, "/repo", next, kind, "whole-target");
42
+ assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
43
+ f.write("c");
44
+ assert.ok(pendingReviewMutation(f.session, "/repo"));
45
+ });
46
+ }
47
+
48
+ test("malformed, unknown-version, and unmatched consumption entries do not establish ownership", () => {
49
+ const f = fixture();
50
+ f.write("valid");
51
+ const good = f.entries.pop()!;
52
+ const data = good.data as Record<string, unknown>;
53
+ for (const patch of [{ kind: "spawn" }, { source: "prose" }, { toolName: "bash" }, { sessionId: "" }, { root: "relative" }, { taskId: "foreign" }, { extra: true }, { toolCallId: "" }, { id: null }]) {
54
+ f.entries.push({ ...good, data: { ...data, ...patch } });
55
+ }
56
+ f.entries.push({ ...good, customType: "gentle-pi.review-reminder-receipt/v99" });
57
+ assert.equal(pendingReviewMutation(f.session, "/repo"), undefined);
58
+ f.entries.push(good);
59
+ const captured = pendingReviewMutation(f.session, "/repo");
60
+ consumeReviewMutation(f.host, f.session, "/repo", "unknown", "acknowledged", "target");
61
+ assert.equal(pendingReviewMutation(f.session, "/repo"), captured);
62
+ });
@@ -41,6 +41,31 @@ function nonPiV3Consent() {
41
41
  return decodeReviewConsentV3(consentFixture(), "claude-code");
42
42
  }
43
43
 
44
+ function consentCustom(select: (title: string, options: string[]) => Promise<string | undefined>) {
45
+ return async (factory: (...args: never[]) => unknown) => {
46
+ let result: unknown;
47
+ const component = factory(
48
+ { terminal: { rows: 24 }, requestRender() {} },
49
+ { fg: (_color: string, text: string) => text, bold: (text: string) => text },
50
+ undefined,
51
+ (value: unknown) => { result = value; },
52
+ ) as { getActionOptions(): readonly string[]; render(width: number): string[]; handleInput?(data: string): void };
53
+ component.render(80);
54
+ const options = [...component.getActionOptions()];
55
+ const selected = await select("", options);
56
+ const index = options.indexOf(selected ?? "");
57
+ if (index === -1) component.handleInput?.("\u001b");
58
+ else {
59
+ for (let current = 0; current < index; current += 1) {
60
+ component.handleInput?.("\u001b[B");
61
+ component.render(80);
62
+ }
63
+ component.handleInput?.("\r");
64
+ }
65
+ return result;
66
+ };
67
+ }
68
+
44
69
  test("host UI preserves the complete provider envelope and adds a separately owned third action", () => {
45
70
  const envelope = consent();
46
71
  const before = structuredClone(envelope.raw);
@@ -54,9 +79,16 @@ test("host UI preserves the complete provider envelope and adds a separately own
54
79
  assert.ok(model.options[index]!.includes(choice.label));
55
80
  assert.ok(model.options[index]!.includes(choice.effect));
56
81
  }
82
+ assert.equal(model.content.actions[2]?.label, "Review and allow this session");
83
+ assert.equal(model.content.actions[2]?.effect, "Reviews this change and allows later reviews in this session and repository; revoke anytime.");
57
84
  assert.ok(model.options[2]!.includes(HOST_REVIEW_SESSION_PERMISSION_LABEL));
58
85
  assert.match(model.title, /The first two actions are provider-owned and apply only to this candidate/);
59
86
  assert.match(model.title, /The third action is owned by the Pi host/);
87
+ assert.match(model.content.ownership, /runs the current provider grant for this frozen candidate/);
88
+ assert.match(model.content.ownership, /one fresh validated provider grant for each later candidate/);
89
+ assert.match(model.content.ownership, /canonical Git repository identity, including sibling worktrees/);
90
+ assert.match(model.content.ownership, /Reload preserves it; new, resume, fork, quit, process restart, or explicit revocation ends it/);
91
+ assert.match(model.content.ownership, /no verdict, acknowledgement, maintenance, delivery, or cross-repository authority/);
60
92
  assert.equal(envelope.choices.length, 2, "the decoded provider envelope must remain a two-choice contract");
61
93
  assert.deepEqual(envelope.raw, before, "formatting must not mutate or append to the provider envelope");
62
94
  });
@@ -83,13 +115,29 @@ test("selection maps only exact displayed actions and cancellation or UI failure
83
115
  undefined,
84
116
  ];
85
117
  for (let index = 0; index < selections.length; index += 1) {
86
- const ctx = { ui: { select: async () => selections[index] } } as unknown as ExtensionContext;
118
+ const ctx = { mode: "tui", ui: { custom: consentCustom(async () => selections[index]) } } as unknown as ExtensionContext;
87
119
  assert.deepEqual(await presentReviewConsentUi(ctx, envelope), expected[index]);
88
120
  }
89
- const failed = { ui: { select: async () => { throw new Error("UI unavailable"); } } } as unknown as ExtensionContext;
121
+ const failed = { mode: "tui", ui: { custom: consentCustom(async () => { throw new Error("UI unavailable"); }) } } as unknown as ExtensionContext;
90
122
  assert.equal(await presentReviewConsentUi(failed, envelope), undefined);
91
123
  });
92
124
 
125
+ test("non-TUI selection preserves the existing action and cancellation mapping", async () => {
126
+ const envelope = consent();
127
+ const model = formatReviewConsentUi(envelope);
128
+ const selections = [model.options[0], model.options[1], model.options[2], undefined] as const;
129
+ const expected = [
130
+ { kind: "provider", answer: "granted" },
131
+ { kind: "provider", answer: "declined" },
132
+ { kind: "host-session" },
133
+ undefined,
134
+ ];
135
+ for (let index = 0; index < selections.length; index += 1) {
136
+ const ctx = { mode: "rpc", ui: { select: async () => selections[index] } } as unknown as ExtensionContext;
137
+ assert.deepEqual(await presentReviewConsentUi(ctx, envelope), expected[index]);
138
+ }
139
+ });
140
+
93
141
  interface RegisteredTool {
94
142
  execute(id: string, parameters: unknown, signal: undefined, onUpdate: undefined, context: ExtensionContext): Promise<{ details: Record<string, unknown> }>;
95
143
  }
@@ -253,7 +301,7 @@ function interactiveContext(cwd: string, manager: object, select: (title: string
253
301
  mode: "tui",
254
302
  hasUI: true,
255
303
  sessionManager: Object.assign(manager, { getSessionId: () => sessionId }),
256
- ui: { select, notify() {}, setStatus() {}, theme: { fg: (_color: string, text: string) => text } },
304
+ ui: { custom: consentCustom(select), notify() {}, setStatus() {}, theme: { fg: (_color: string, text: string) => text } },
257
305
  } as unknown as ExtensionContext;
258
306
  }
259
307
 
@@ -484,7 +532,7 @@ test("headless, child, and changed post-UI identity never use host permission",
484
532
  hasUI: true,
485
533
  sessionManager: manager,
486
534
  ui: {
487
- select: async (_title: string, options: string[]) => { manager.sessionId = "after"; return options[2]; },
535
+ custom: consentCustom(async (_title: string, options: string[]) => { manager.sessionId = "after"; return options[2]; }),
488
536
  notify() {},
489
537
  setStatus() {},
490
538
  theme: { fg: (_color: string, text: string) => text },