gentle-pi 2.4.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -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 +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -184,6 +184,44 @@ test("failure enforces exact keys, enums, and identifiers", () => {
184
184
  assert.throws(() => decodeReviewFailureV2(badCode), /code/);
185
185
  });
186
186
 
187
+ test("failure with code managed_assets_outdated decodes its continuation, degrades without one, and forbids it elsewhere", () => {
188
+ const source: JsonObject = {
189
+ schema: "gentle-ai.review-integration.failure/v2",
190
+ contract: REVIEW_INTEGRATION_CONTRACT,
191
+ operation: "review.start",
192
+ phase: "preflight",
193
+ code: "managed_assets_outdated",
194
+ message: "the managed asset set is stale",
195
+ mutation_outcome: "not_started",
196
+ authority_applicability: "current_target",
197
+ retry_safe: true,
198
+ replayability: "manual_action_required",
199
+ required_inputs: [],
200
+ next_action: "explicit-maintainer-action",
201
+ continuation: { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", stale_assets: ["orchestration/claude-code.md"] },
202
+ };
203
+ assert.deepEqual(decodeReviewFailureV2(source).continuation, { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", staleAssets: ["orchestration/claude-code.md"] });
204
+
205
+ const missingCommand = clone(source);
206
+ delete (missingCommand.continuation as JsonObject).command;
207
+ assert.throws(() => decodeReviewFailureV2(missingCommand), /command.*required|required.*command/);
208
+
209
+ // an older gentle-ai may emit managed_assets_outdated with no continuation
210
+ // at all; decode must not refuse the whole envelope for that
211
+ const missingContinuation = clone(source);
212
+ delete missingContinuation.continuation;
213
+ assert.equal(decodeReviewFailureV2(missingContinuation).continuation, undefined);
214
+
215
+ const unexpectedContinuation: JsonObject = { ...clone(source), code: "gate_scope_changed" };
216
+ assert.throws(() => decodeReviewFailureV2(unexpectedContinuation), /continuation is only valid/);
217
+
218
+ // an envelope without continuation, on a code that does not require one, is unchanged
219
+ const withoutContinuation = clone(source);
220
+ withoutContinuation.code = "gate_scope_changed";
221
+ delete withoutContinuation.continuation;
222
+ assert.equal(decodeReviewFailureV2(withoutContinuation).continuation, undefined);
223
+ });
224
+
187
225
  test("net-new decoders: consent, next-transition, and artifact-subject reject malformed payloads", () => {
188
226
  const consentSource = fixture<JsonObject>("consent.fixture.json");
189
227
  assertRequired(decodeReviewConsentV2, consentSource, ["schema", "contract", "operation", "action", "blocking", "target_identity", "projection", "risk_level", "changed_files", "changed_lines", "headline", "reason", "value", "risk_evidence", "choices", "off_path"]);
@@ -405,6 +443,205 @@ test("next_transition decodes an execute variant and rejects a stop that carries
405
443
  assert.throws(() => decodeReviewNextTransitionV3(stopWithExecute), /stop cannot carry/);
406
444
  });
407
445
 
446
+ // gentle-pi#627: gentle-ai reports a stale managed-asset set as a typed stop
447
+ // carrying the exact `gentle-ai sync` invocation that resolves it.
448
+ function managedAssetsContinuation(overrides: Partial<JsonObject> = {}): JsonObject {
449
+ return { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", stale_assets: ["orchestration/claude-code.md"], ...overrides };
450
+ }
451
+
452
+ test("next_transition stop decodes a managed_assets_outdated continuation and rejects a malformed one", () => {
453
+ const stop: JsonObject = { kind: "stop", reason_code: "managed_assets_outdated", continuation: managedAssetsContinuation() };
454
+ const decoded = decodeReviewNextTransitionV3(stop);
455
+ assert.deepEqual(decoded.continuation, { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", staleAssets: ["orchestration/claude-code.md"] });
456
+
457
+ const bare: JsonObject = { kind: "stop", reason_code: "managed_assets_outdated", continuation: { operation: "sync", command: "gentle-ai sync" } };
458
+ assert.deepEqual(decodeReviewNextTransitionV3(bare).continuation, { operation: "sync", command: "gentle-ai sync" });
459
+
460
+ const missingCommand = clone(stop);
461
+ delete (missingCommand.continuation as JsonObject).command;
462
+ assert.throws(() => decodeReviewNextTransitionV3(missingCommand), /command.*required|required.*command/);
463
+
464
+ const emptyCommand = clone(stop);
465
+ (emptyCommand.continuation as JsonObject).command = "";
466
+ assert.throws(() => decodeReviewNextTransitionV3(emptyCommand), /command/);
467
+
468
+ // an older gentle-ai may emit this stop with no continuation at all; decode
469
+ // must not refuse the whole envelope for that
470
+ const missingForReasonCode = { kind: "stop", reason_code: "managed_assets_outdated" };
471
+ assert.equal(decodeReviewNextTransitionV3(missingForReasonCode).continuation, undefined);
472
+
473
+ const unexpectedOnOtherReasonCode = { kind: "stop", reason_code: "rdd_disabled", continuation: managedAssetsContinuation() };
474
+ assert.throws(() => decodeReviewNextTransitionV3(unexpectedOnOtherReasonCode), /continuation is only valid/);
475
+
476
+ // envelopes without continuation stay unchanged
477
+ const plainStop = decodeReviewNextTransitionV3({ kind: "stop", reason_code: "rdd_disabled" });
478
+ assert.equal(plainStop.continuation, undefined);
479
+ });
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
+
408
645
  function approvedAcknowledgementTransition(): JsonObject {
409
646
  return {
410
647
  kind: "execute",
@@ -700,6 +937,40 @@ test("v5 targeted-validator collect inputs carry the exact provider-owned valida
700
937
  assert.throws(() => decodeReviewNextTransitionV3(weakenedRequest, { v5: true }), /policy_content/);
701
938
  });
702
939
 
940
+ test("status.frozen carries the optional frozen manifest digest", () => {
941
+ const current = devFixture<JsonObject>("status-v5-repository-context.captured.json");
942
+ delete current.receipt;
943
+ if (current.action === "finalize") {
944
+ current.action = "stop";
945
+ delete current.next_transition;
946
+ delete current.forecast;
947
+ }
948
+ assert.equal(typeof (current.frozen as JsonObject | undefined)?.tier, "string", "the captured status carries frozen");
949
+ const digest = `sha256:${"ab".repeat(32)}`;
950
+ (current.frozen as JsonObject).changed_path_manifest_sha256 = digest;
951
+ assert.equal(decodeReviewStatusV3(current).frozen?.changedPathManifestSha256, digest);
952
+ (current.frozen as JsonObject).changed_path_manifest_sha256 = "not-a-digest";
953
+ assert.throws(() => decodeReviewStatusV3(current), /changed_path_manifest_sha256/);
954
+ delete (current.frozen as JsonObject).changed_path_manifest_sha256;
955
+ assert.equal(decodeReviewStatusV3(current).frozen?.changedPathManifestSha256, undefined);
956
+ });
957
+
958
+ test("a capture-result collect input decodes without changed_path_manifest, which the artifact subject digest already binds", () => {
959
+ const status = fixture<JsonObject>("status.fixture.json");
960
+ const transition = clone((status.next_transition ?? {}) as JsonObject);
961
+ const inputs = ((transition.collect as JsonObject | undefined)?.inputs ?? []) as JsonObject[];
962
+ const captureInput = inputs.find((input) => input.capture_operation === "review.capture-result");
963
+ assert.ok(captureInput, "the status fixture carries a capture-result collect input");
964
+ assert.doesNotThrow(() => decodeReviewNextTransitionV3(transition, { v5: true, v6: true }), "with the manifest");
965
+ delete captureInput.changed_path_manifest;
966
+ const decoded = decodeReviewNextTransitionV3(transition, { v5: true, v6: true });
967
+ const decodedInput = decoded.collect?.inputs.find((input) => input.captureOperation === "review.capture-result");
968
+ assert.ok(decodedInput?.artifactSubject?.changedPathManifestSha256, "the digest on the artifact subject is the binding");
969
+ assert.equal(decodedInput?.changedPathManifest, undefined);
970
+ delete captureInput.artifact_subject;
971
+ assert.throws(() => decodeReviewNextTransitionV3(transition, { v5: true, v6: true }), /requires artifact_subject, base_tree, and candidate_tree/);
972
+ });
973
+
703
974
  test("status enforces authority/frozen/receipt conditionals and decodes the required repair field", () => {
704
975
  const current = devFixture<JsonObject>("status-v5-repository-context.captured.json");
705
976
  delete current.receipt;
@@ -711,6 +982,11 @@ test("status enforces authority/frozen/receipt conditionals and decodes the requ
711
982
  const decoded = decodeReviewStatusV3(current);
712
983
  assert.equal(decoded.repair.status, "unsupported");
713
984
  assert.equal(decoded.authority?.state, "reviewing");
985
+ for (const action of ["collect", "execute"]) {
986
+ const projected = clone(current);
987
+ projected.action = action;
988
+ assert.equal(decodeReviewStatusV3(projected).action, action, "a live transaction's root action names its mandated transition kind");
989
+ }
714
990
 
715
991
  const missingAuthority = clone(current);
716
992
  delete missingAuthority.authority;
@@ -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);
@@ -331,18 +370,23 @@ test("terminal capture closes directly, nonterminal capture does not auto-follow
331
370
  assert.equal(statusCalls, 2, "only initial STATUS plus one ambiguity reconciliation");
332
371
  });
333
372
 
334
- test("unknown-capture reconciliation validates the target-scoped lineage without replay", async () => {
373
+ test("unknown-capture reconciliation preserves agentless legacy callers and forwards an explicit Pi agent", async () => {
335
374
  const calls: Array<Record<string, unknown>> = [];
336
375
  const native = {
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 });
344
383
  await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA }, { baseRef: "refs/heads/main", committedOnly: true });
345
- assert.deepEqual(calls, [{ cwd: "/repo", lineageId: "reconcile-lineage" }, { cwd: "/repo", lineageId: "reconcile-lineage", baseRef: "refs/heads/main", committedOnly: true }]);
384
+ await reconcileUnknownReviewLastEventCapture(native, "/repo", { lineageId: "reconcile-lineage", targetIdentity: SHA }, { agent: "pi" });
385
+ assert.deepEqual(calls, [
386
+ { cwd: "/repo", lineageId: "reconcile-lineage" },
387
+ { cwd: "/repo", lineageId: "reconcile-lineage", baseRef: "refs/heads/main", committedOnly: true },
388
+ { cwd: "/repo", lineageId: "reconcile-lineage", agent: "pi" },
389
+ ]);
346
390
  assert.equal(result.targetIdentity, SHA);
347
391
  let launches = 0;
348
392
  const strictNative = new nativeReviewCliModule.NativeReviewCliV216(async () => { launches += 1; throw new Error("must not launch"); }, "/package/.gentle-ai/gentle-ai");
@@ -350,6 +394,71 @@ test("unknown-capture reconciliation validates the target-scoped lineage without
350
394
  assert.equal(launches, 0);
351
395
  });
352
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
+
353
462
  // One approved terminal closure carrying the exact continuation the provider
354
463
  // renders: closed positional arguments, the single approved precondition, and a
355
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,26 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
5
+ import { declareReviewRelayHandshake, GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
6
+
7
+ // gentle-pi#550: the session shell must inherit the relay handshake, so a
8
+ // `gentle-ai review ... --agent pi` typed in a shell tool is eligible without
9
+ // the operator reading package source for the value.
10
+
11
+ test("declareReviewRelayHandshake exports the exact contract and reports whether it changed anything", () => {
12
+ const env: NodeJS.ProcessEnv = {};
13
+ assert.equal(declareReviewRelayHandshake(env), true);
14
+ assert.equal(env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
15
+ assert.equal(declareReviewRelayHandshake(env), false, "already declared");
16
+ const stale: NodeJS.ProcessEnv = { [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: "gentle-pi.review-relay/v0" };
17
+ assert.equal(declareReviewRelayHandshake(stale), true, "a wrong value is replaced: this extension is the host");
18
+ assert.equal(stale[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
19
+ });
20
+
21
+ test("loading the extension declares the handshake in the session environment", () => {
22
+ const processEnv: NodeJS.ProcessEnv = { PATH: "/bin" };
23
+ createGentleAiExtension({ nativeReviewCli: null, processEnv })({ on() {}, registerTool() {}, registerCommand() {} } as unknown as ExtensionAPI);
24
+ assert.equal(processEnv[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV], GENTLE_PI_REVIEW_RELAY_CONTRACT);
25
+ assert.equal(processEnv.PATH, "/bin");
26
+ });
@@ -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
+ });