gentle-pi 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  isSmallWriterProfile,
19
19
  resolveWriterProfile,
20
20
  verificationPlan,
21
+ verificationRisk,
21
22
  REVIEW_ASSESSMENT_SCHEMA,
22
23
  VERIFICATION_TIER,
23
24
  RDD_LINE,
@@ -389,9 +390,11 @@ test("verificationPlan: an omitted nativeReviewOutcome under rdd on defaults to
389
390
  }
390
391
  });
391
392
 
392
- test("verificationPlan: on+closed+medium+small -> verifier (the small-model bias still applies)", () => {
393
+ // Changed with verification by named risk: the small-model bias is gone, so a
394
+ // small writer on a medium plan no longer gets an independent verifier.
395
+ test("verificationPlan: on+closed+medium+small -> no verifier (the writer's model never adds one)", () => {
393
396
  const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.SMALL, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED });
394
- assert.equal(plan.independentVerifier, true);
397
+ assert.equal(plan.independentVerifier, false);
395
398
  assert.equal(plan.writerSelfVerification, true);
396
399
  });
397
400
 
@@ -401,9 +404,12 @@ test("verificationPlan: on+declined+medium+large -> no verifier (medium, large)"
401
404
  assert.equal(plan.writerSelfVerification, true);
402
405
  });
403
406
 
404
- test("verificationPlan: on+declined+medium+small -> verifier", () => {
407
+ // Changed with verification by named risk: no small-model bias, so a declined
408
+ // review over a medium plan keeps the writer's self-verification only.
409
+ test("verificationPlan: on+declined+medium+small -> no verifier", () => {
405
410
  const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.SMALL, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.DECLINED });
406
- assert.equal(plan.independentVerifier, true);
411
+ assert.equal(plan.independentVerifier, false);
412
+ assert.equal(plan.writerSelfVerification, true);
407
413
  });
408
414
 
409
415
  test("verificationPlan: on+declined+high -> verifier", () => {
@@ -468,30 +474,62 @@ for (const rddLine of [RDD_LINE.OFF, RDD_LINE.UNKNOWN]) {
468
474
  }
469
475
  });
470
476
 
471
- test(`verificationPlan: rdd ${rddLine}, medium risk -> independent verifier only for a small writer profile`, () => {
472
- const large = verificationPlan({ rddLine, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.LARGE });
473
- assert.equal(large.writerSelfVerification, true);
474
- assert.equal(large.structuralReadbackOnly, false);
475
- assert.equal(large.independentVerifier, false);
477
+ // Changed with verification by named risk: the small-model bias is gone, so
478
+ // medium never adds a verifier, whatever the writer profile.
479
+ test(`verificationPlan: rdd ${rddLine}, medium risk -> writer self-verification only, for every writer profile`, () => {
480
+ for (const writerProfile of PROFILES) {
481
+ const plan = verificationPlan({ rddLine, risk: VERIFICATION_TIER.MEDIUM, writerProfile });
482
+ assert.equal(plan.writerSelfVerification, true, `profile ${writerProfile}`);
483
+ assert.equal(plan.structuralReadbackOnly, false);
484
+ assert.equal(plan.independentVerifier, false, `profile ${writerProfile}`);
485
+ }
486
+ });
476
487
 
477
- const small = verificationPlan({ rddLine, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.SMALL });
478
- assert.equal(small.writerSelfVerification, true);
479
- assert.equal(small.structuralReadbackOnly, false);
480
- assert.equal(small.independentVerifier, true, "the small-model bias raises medium to high for verification purposes");
488
+ // The plan risk is high only through an agent escalation (verificationRisk),
489
+ // so high still means writer self-verification plus one independent verifier.
490
+ test(`verificationPlan: rdd ${rddLine}, high (escalated) risk -> writer self-verification plus independent verifier`, () => {
491
+ for (const writerProfile of PROFILES) {
492
+ const plan = verificationPlan({ rddLine, risk: VERIFICATION_TIER.HIGH, writerProfile });
493
+ assert.equal(plan.writerSelfVerification, true, `rdd ${rddLine}, profile ${writerProfile}`);
494
+ assert.equal(plan.structuralReadbackOnly, false);
495
+ assert.equal(plan.independentVerifier, true, `rdd ${rddLine}, profile ${writerProfile}`);
496
+ assert.match(plan.reason, /escalat/);
497
+ }
481
498
  });
482
499
 
483
- test(`verificationPlan: rdd ${rddLine}, high or unassessable risk -> writer self-verification plus independent verifier, always`, () => {
484
- for (const risk of [VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE]) {
485
- for (const writerProfile of PROFILES) {
486
- const plan = verificationPlan({ rddLine, risk, writerProfile });
487
- assert.equal(plan.writerSelfVerification, true, `rdd ${rddLine}, risk ${risk}, profile ${writerProfile}`);
488
- assert.equal(plan.structuralReadbackOnly, false);
489
- assert.equal(plan.independentVerifier, true, `rdd ${rddLine}, risk ${risk}, profile ${writerProfile}`);
490
- }
500
+ // Changed with verification by named risk: a failed or unrecognized
501
+ // assessment informs only; it is no longer treated as high.
502
+ test(`verificationPlan: rdd ${rddLine}, unassessable risk -> writer self-verification only, no verifier`, () => {
503
+ for (const writerProfile of PROFILES) {
504
+ const plan = verificationPlan({ rddLine, risk: VERIFICATION_TIER.UNASSESSABLE, writerProfile });
505
+ assert.equal(plan.writerSelfVerification, true, `rdd ${rddLine}, profile ${writerProfile}`);
506
+ assert.equal(plan.structuralReadbackOnly, false);
507
+ assert.equal(plan.independentVerifier, false, `rdd ${rddLine}, profile ${writerProfile}`);
491
508
  }
492
509
  });
493
510
  }
494
511
 
512
+ test("verificationPlan: the writer's model never changes the plan, for every rdd line, risk, and outcome", () => {
513
+ for (const rddLine of RDD_LINES) {
514
+ for (const risk of RISKS) {
515
+ for (const nativeReviewOutcome of Object.values(NATIVE_REVIEW_OUTCOME)) {
516
+ const small = verificationPlan({ rddLine, risk, writerProfile: WRITER_PROFILE.SMALL, nativeReviewOutcome });
517
+ const large = verificationPlan({ rddLine, risk, writerProfile: WRITER_PROFILE.LARGE, nativeReviewOutcome });
518
+ assert.deepEqual(small, large, `rdd ${rddLine}, risk ${risk}, outcome ${nativeReviewOutcome}`);
519
+ }
520
+ }
521
+ }
522
+ });
523
+
524
+ test("verificationRisk: only an escalation makes the plan high; a native high or unassessable tier informs as medium", () => {
525
+ const escalation = { item: 2 as const, reason: "weakens the permission check" };
526
+ for (const nativeRisk of RISKS) assert.equal(verificationRisk(nativeRisk, escalation), VERIFICATION_TIER.HIGH, `escalated ${nativeRisk}`);
527
+ assert.equal(verificationRisk(VERIFICATION_TIER.PASSIVE, undefined), VERIFICATION_TIER.PASSIVE);
528
+ assert.equal(verificationRisk(VERIFICATION_TIER.MEDIUM, undefined), VERIFICATION_TIER.MEDIUM);
529
+ assert.equal(verificationRisk(VERIFICATION_TIER.HIGH, undefined), VERIFICATION_TIER.MEDIUM, "a native high alone never adds a verifier");
530
+ assert.equal(verificationRisk(VERIFICATION_TIER.UNASSESSABLE, undefined), VERIFICATION_TIER.MEDIUM, "a failed assessment alone never adds a verifier");
531
+ });
532
+
495
533
  test("verificationPlan: an unknown rdd line never lowers a tier relative to off", () => {
496
534
  for (const risk of RISKS) {
497
535
  for (const writerProfile of PROFILES) {
@@ -567,12 +605,14 @@ test("resolveWriterProfile: an unknown or omitted profile fails closed to small,
567
605
  });
568
606
 
569
607
  // ---------------------------------------------------------------------------
570
- // Tool-level fail-closed path: the `gentle_review` tool's `assess` operation
608
+ // Tool-level unassessable path: the `gentle_review` tool's `assess` operation
571
609
  // (`extensions/gentle-ai.ts`, gentle-pi#662) must treat a native CLI without
572
610
  // the `assess` verb (an older binary) or a rejected `assess` call (a process
573
- // failure) the same way -- risk "unassessable", which `verificationPlan`
574
- // treats as `high`. `assess` is exposed as a `gentle_review` operation, not a
575
- // dedicated tool, so the fixed `gentle_*` tool registry stays unchanged.
611
+ // failure) the same way -- native risk "unassessable", which informs only:
612
+ // the plan is medium and adds no independent verifier unless the agent
613
+ // escalates a named high-risk item. `assess` is exposed as a `gentle_review`
614
+ // operation, not a dedicated tool, so the fixed `gentle_*` tool registry stays
615
+ // unchanged.
576
616
 
577
617
  function reviewControllerTool(nativeReviewCli: Partial<NativeReviewCli> | null): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ content: readonly { type: string; text: string }[]; details: unknown }> } {
578
618
  const tools = new Map<string, any>();
@@ -591,27 +631,37 @@ function reviewControllerTool(nativeReviewCli: Partial<NativeReviewCli> | null):
591
631
 
592
632
  const ctx = { cwd: process.cwd() } as ExtensionContext;
593
633
 
594
- test("gentle_review assess: an older binary without the assess verb fails closed to high", async () => {
634
+ // The only input that makes the plan high: an agent escalation naming a Task
635
+ // Size high-risk item. Tests whose subject is "closure or a decline never
636
+ // removes the verifier the plan requires" pass it so a verifier is required.
637
+ const ESCALATE = { escalate: { item: 3, reason: "changes a published export" } } as const;
638
+
639
+ // Changed with verification by named risk: an unassessable candidate no longer
640
+ // verifies like high; the plan is medium and the native tier stays visible.
641
+ test("gentle_review assess: an older binary without the assess verb informs only (no verifier without escalate)", async () => {
595
642
  const nativeReviewCli: Partial<NativeReviewCli> = {
596
643
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
597
644
  // assess intentionally absent: pre-gentle-ai#4295 binary.
598
645
  };
599
646
  const tool = reviewControllerTool(nativeReviewCli);
600
647
  const result = await tool.execute("call-1", { operation: "assess" }, undefined, undefined, ctx);
601
- const details = result.details as { risk: string; rddLine: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
602
- assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
648
+ const details = result.details as { risk: string; nativeRisk: string; rddLine: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
649
+ assert.equal(details.nativeRisk, VERIFICATION_TIER.UNASSESSABLE);
650
+ assert.equal(details.risk, VERIFICATION_TIER.MEDIUM);
603
651
  assert.equal(details.rddLine, "off");
604
652
  assert.equal(details.plan.writerSelfVerification, true);
605
- assert.equal(details.plan.independentVerifier, true);
653
+ assert.equal(details.plan.independentVerifier, false, "a failed assessment alone never adds a verifier");
606
654
  assert.equal(details.plan.structuralReadbackOnly, false);
607
- const highEquivalent = verificationPlan({ rddLine: "off", risk: VERIFICATION_TIER.HIGH, writerProfile: WRITER_PROFILE.LARGE });
608
- assert.equal(details.plan.writerSelfVerification, highEquivalent.writerSelfVerification, "unassessable must verify exactly like high");
609
- assert.equal(details.plan.independentVerifier, highEquivalent.independentVerifier, "unassessable must verify exactly like high");
610
- assert.equal(details.plan.structuralReadbackOnly, highEquivalent.structuralReadbackOnly, "unassessable must verify exactly like high");
611
- assert.equal(JSON.parse(result.content[0].text).risk, VERIFICATION_TIER.UNASSESSABLE);
655
+ assert.equal(JSON.parse(result.content[0].text).nativeRisk, VERIFICATION_TIER.UNASSESSABLE);
656
+
657
+ const escalated = await tool.execute("call-1b", { operation: "assess", input: JSON.stringify(ESCALATE) }, undefined, undefined, ctx);
658
+ assert.equal((escalated.details as { risk: string }).risk, VERIFICATION_TIER.HIGH);
659
+ assert.equal((escalated.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, true, "an escalation over an unassessable candidate adds the verifier");
612
660
  });
613
661
 
614
- test("gentle_review assess: a rejected assess call fails closed to high, and RDD status read failure fails closed to unknown", async () => {
662
+ // Changed with verification by named risk: a rejected assess call informs
663
+ // only; the RDD status fail-closed half of this test is unchanged.
664
+ test("gentle_review assess: a rejected assess call informs only, and RDD status read failure fails closed to unknown", async () => {
615
665
  const nativeReviewCli: Partial<NativeReviewCli> = {
616
666
  reviewMode: async () => {
617
667
  throw new Error("native review mode is unavailable");
@@ -622,12 +672,13 @@ test("gentle_review assess: a rejected assess call fails closed to high, and RDD
622
672
  };
623
673
  const tool = reviewControllerTool(nativeReviewCli);
624
674
  const result = await tool.execute("call-2", { operation: "assess" }, undefined, undefined, ctx);
625
- const details = result.details as { risk: string; rddLine: string; reasons: readonly { code: string }[]; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
626
- assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
675
+ const details = result.details as { risk: string; nativeRisk: string; rddLine: string; reasons: readonly { code: string }[]; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
676
+ assert.equal(details.nativeRisk, VERIFICATION_TIER.UNASSESSABLE);
677
+ assert.equal(details.risk, VERIFICATION_TIER.MEDIUM);
627
678
  assert.equal(details.rddLine, "unknown", "an unresolved RDD status must fail closed to unknown, never on or off");
628
679
  assert.equal(details.reasons[0]?.code, "native-assess-unavailable");
629
680
  assert.equal(details.plan.writerSelfVerification, true);
630
- assert.equal(details.plan.independentVerifier, true);
681
+ assert.equal(details.plan.independentVerifier, false);
631
682
  });
632
683
 
633
684
  test("gentle_review assess: a successful native assessment is reflected directly in the returned plan", async () => {
@@ -658,7 +709,10 @@ test("gentle_review assess: baseRef without committedOnly is rejected", async ()
658
709
  );
659
710
  });
660
711
 
661
- test("gentle_review assess: writerModelId/writerEffort in input select the writer profile, and an omitted profile fails closed to small", async () => {
712
+ // Changed with verification by named risk: the writer profile is still
713
+ // resolved and reported (omitted fails closed to small, gemini is large), but
714
+ // it no longer adds a verifier to a medium plan.
715
+ test("gentle_review assess: writerModelId/writerEffort in input select the reported writer profile, which never changes the plan", async () => {
662
716
  const nativeReviewCli: Partial<NativeReviewCli> = {
663
717
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
664
718
  assess: async () => ({
@@ -672,12 +726,11 @@ test("gentle_review assess: writerModelId/writerEffort in input select the write
672
726
  };
673
727
  const tool = reviewControllerTool(nativeReviewCli);
674
728
 
675
- // Omitted profile + medium + off -> fails closed to small -> independent
676
- // verifier true (this is the fix for the fail-open finding: an omitted
677
- // profile must never resolve as permissively as a known large model).
729
+ // Omitted profile + medium + off -> reported as small (fail closed), and
730
+ // still no independent verifier: the model never adds one.
678
731
  const omitted = await tool.execute("call-5", { operation: "assess" }, undefined, undefined, ctx);
679
732
  assert.equal((omitted.details as { writerProfile: string }).writerProfile, "small");
680
- assert.equal((omitted.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, true, "an omitted writer profile must fail closed to small, not default to large");
733
+ assert.equal((omitted.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false, "an omitted writer profile never adds a verifier");
681
734
 
682
735
  // Explicit large profile + medium + off -> independent verifier false.
683
736
  const explicitLarge = await tool.execute(
@@ -688,10 +741,12 @@ test("gentle_review assess: writerModelId/writerEffort in input select the write
688
741
  ctx,
689
742
  );
690
743
  assert.equal((explicitLarge.details as { writerProfile: string }).writerProfile, "large");
691
- assert.equal((explicitLarge.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false, "an explicitly recorded large profile must not be forced into the small-model bias");
744
+ assert.equal((explicitLarge.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false);
692
745
 
693
746
  const small = await tool.execute("call-7", { operation: "assess", input: JSON.stringify({ writerEffort: "low" }) }, undefined, undefined, ctx);
694
- assert.equal((small.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, true, "a low-effort writer profile must trigger the small-model bias");
747
+ assert.equal((small.details as { writerProfile: string }).writerProfile, "small");
748
+ assert.equal((small.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false, "a low-effort writer profile never adds a verifier");
749
+ assert.deepEqual((small.details as { plan: unknown }).plan, (explicitLarge.details as { plan: unknown }).plan, "the writer's model never changes the plan");
695
750
 
696
751
  // A gemini model id must resolve large, never small on the bare "mini" substring.
697
752
  const gemini = await tool.execute("call-8", { operation: "assess", input: JSON.stringify({ writerModelId: "gemini-2.5-pro", writerEffort: "high" }) }, undefined, undefined, ctx);
@@ -699,6 +754,9 @@ test("gentle_review assess: writerModelId/writerEffort in input select the write
699
754
  assert.equal((gemini.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false);
700
755
  });
701
756
 
757
+ // Changed with verification by named risk: a native high without escalate is
758
+ // a medium plan (no verifier); the declined fallback still applies, and an
759
+ // escalation still gets its verifier.
702
760
  test("gentle_review assess: an explicit nativeReviewOutcome:\"declined\" input falls back to the risk-gated plan even when RDD is on (gentle-pi#668)", async () => {
703
761
  const nativeReviewCli: Partial<NativeReviewCli> = {
704
762
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
@@ -713,13 +771,19 @@ test("gentle_review assess: an explicit nativeReviewOutcome:\"declined\" input f
713
771
  };
714
772
  const tool = reviewControllerTool(nativeReviewCli);
715
773
  const result = await tool.execute("call-9", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "declined" }) }, undefined, undefined, ctx);
716
- const details = result.details as { risk: string; rddLine: string; outcome_source: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
717
- assert.equal(details.risk, "high");
774
+ const details = result.details as { risk: string; nativeRisk: string; rddLine: string; outcome_source: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
775
+ assert.equal(details.nativeRisk, "high");
776
+ assert.equal(details.risk, "medium", "a native high without escalate informs only");
718
777
  assert.equal(details.rddLine, "on", "the rendered RDD line still reads on -- only the verification plan falls back");
719
778
  assert.equal(details.outcome_source, "explicit");
720
779
  assert.equal(details.plan.writerSelfVerification, true);
721
- assert.equal(details.plan.independentVerifier, true, "a declined review for this candidate must re-enable the risk-gated independent verifier");
780
+ assert.equal(details.plan.independentVerifier, false, "a native high without escalate adds no verifier");
722
781
  assert.equal(details.plan.structuralReadbackOnly, false);
782
+
783
+ const escalated = await tool.execute("call-9b", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "declined", ...ESCALATE }) }, undefined, undefined, ctx);
784
+ const escalatedDetails = escalated.details as { risk: string; plan: { independentVerifier: boolean } };
785
+ assert.equal(escalatedDetails.risk, "high");
786
+ assert.equal(escalatedDetails.plan.independentVerifier, true, "a declined review keeps the verifier an escalation requires");
723
787
  });
724
788
 
725
789
  test("gentle_review assess: an unrecognized nativeReviewOutcome value is rejected", async () => {
@@ -763,16 +827,19 @@ test("gentle_review assess: derivation is bound to the exact candidate recorded,
763
827
  __testing.clearNativeReviewOutcomeMemoForTesting();
764
828
  let current = "target-a";
765
829
  const tool = reviewControllerTool(assessOnNativeCli(() => current));
830
+ // Verification by named risk: the plan requires a verifier only through an
831
+ // escalation, so each call escalates to keep "never removes it" observable.
832
+ const escalated = { operation: "assess", input: JSON.stringify(ESCALATE) };
766
833
 
767
834
  // Nothing recorded for candidate A yet -> unknown, risk-gated.
768
- const before = (await tool.execute("call-11", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
835
+ const before = (await tool.execute("call-11", escalated, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
769
836
  assert.equal(before.nativeReviewOutcome, "unknown");
770
837
  assert.equal(before.outcome_source, "unknown");
771
838
  assert.equal(before.plan.independentVerifier, true);
772
839
 
773
840
  // Candidate A recorded declined -> assess for A derives it.
774
841
  __testing.recordNativeReviewOutcome(ctx.cwd, "target-a", "declined");
775
- const forA = (await tool.execute("call-12", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
842
+ const forA = (await tool.execute("call-12", escalated, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
776
843
  assert.equal(forA.nativeReviewOutcome, "declined");
777
844
  assert.equal(forA.outcome_source, "derived");
778
845
  assert.equal(forA.plan.independentVerifier, true, "a derived decline re-enables the independent verifier for the matching candidate");
@@ -781,14 +848,14 @@ test("gentle_review assess: derivation is bound to the exact candidate recorded,
781
848
  // and since closed is never written, an acknowledged A can never leak a
782
849
  // closed derivation into B either.
783
850
  current = "target-b";
784
- const forB = (await tool.execute("call-13", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
851
+ const forB = (await tool.execute("call-13", escalated, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
785
852
  assert.equal(forB.nativeReviewOutcome, "unknown", "candidate B must never inherit candidate A's recorded outcome");
786
853
  assert.equal(forB.outcome_source, "unknown");
787
854
  assert.equal(forB.plan.independentVerifier, true);
788
855
 
789
856
  // gentle-pi#1175: a caller-declared closed is only a claim. This older
790
857
  // assessment carries no candidate.consumed, so it fails closed to unknown.
791
- const closed = (await tool.execute("call-14", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "closed" }) }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
858
+ const closed = (await tool.execute("call-14", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "closed", ...ESCALATE }) }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
792
859
  assert.equal(closed.nativeReviewOutcome, "unknown");
793
860
  assert.equal(closed.outcome_source, "unknown");
794
861
  assert.equal(closed.plan.writerSelfVerification, true);
@@ -836,6 +903,7 @@ function closureCli(options: ClosureCliOptions): Partial<NativeReviewCli> {
836
903
 
837
904
  interface AssessDetails {
838
905
  risk: string;
906
+ nativeRisk: string;
839
907
  nativeReviewOutcome: string;
840
908
  outcome_source: string;
841
909
  writerProfile: string;
@@ -882,8 +950,11 @@ test("gentle_review assess: already-reviewed predecessor remains medium and neve
882
950
  }
883
951
  });
884
952
 
953
+ // Verification by named risk: the closure tests below that check "closure or a
954
+ // decline never removes the verifier" escalate, because only an escalation
955
+ // makes the plan require one.
885
956
  test("gentle_review assess: native consumed true derives closed for this candidate", async () => {
886
- const details = await assessWith(closureCli({ consumed: true }));
957
+ const details = await assessWith(closureCli({ consumed: true }), ESCALATE);
887
958
  assert.equal(details.nativeReviewOutcome, "closed");
888
959
  assert.equal(details.outcome_source, "derived");
889
960
  assert.equal(details.plan.writerSelfVerification, true);
@@ -892,7 +963,7 @@ test("gentle_review assess: native consumed true derives closed for this candida
892
963
 
893
964
  test("gentle_review assess: a caller-declared closed without native consumed evidence fails closed to unknown", async () => {
894
965
  for (const consumed of [false, undefined]) {
895
- const details = await assessWith(closureCli({ consumed }), { nativeReviewOutcome: "closed" });
966
+ const details = await assessWith(closureCli({ consumed }), { nativeReviewOutcome: "closed", ...ESCALATE });
896
967
  assert.equal(details.nativeReviewOutcome, "unknown", `consumed ${String(consumed)}`);
897
968
  assert.equal(details.outcome_source, "unknown");
898
969
  assert.equal(details.plan.independentVerifier, true);
@@ -900,7 +971,7 @@ test("gentle_review assess: a caller-declared closed without native consumed evi
900
971
  });
901
972
 
902
973
  test("gentle_review assess: a caller-declared closed corroborated by native consumed stays closed", async () => {
903
- const details = await assessWith(closureCli({ consumed: true }), { nativeReviewOutcome: "closed" });
974
+ const details = await assessWith(closureCli({ consumed: true }), { nativeReviewOutcome: "closed", ...ESCALATE });
904
975
  assert.equal(details.nativeReviewOutcome, "closed");
905
976
  assert.equal(details.outcome_source, "derived", "closure is attributed to the native evidence, not to the caller's claim");
906
977
  assert.equal(details.plan.independentVerifier, true, "closure never removes the risk-tier verifier");
@@ -908,7 +979,7 @@ test("gentle_review assess: a caller-declared closed corroborated by native cons
908
979
 
909
980
  test("gentle_review assess: explicit declined, unavailable, or unknown beats native consumed (only ever raises the bar)", async () => {
910
981
  for (const outcome of ["declined", "unavailable", "unknown"]) {
911
- const details = await assessWith(closureCli({ consumed: true }), { nativeReviewOutcome: outcome });
982
+ const details = await assessWith(closureCli({ consumed: true }), { nativeReviewOutcome: outcome, ...ESCALATE });
912
983
  assert.equal(details.nativeReviewOutcome, outcome);
913
984
  assert.equal(details.outcome_source, "explicit");
914
985
  assert.equal(details.plan.independentVerifier, true, `${outcome} must keep the risk-gated verifier`);
@@ -920,7 +991,7 @@ test("gentle_review assess: a recorded decline for the same candidate beats nati
920
991
  __testing.clearNativeReviewOutcomeMemoForTesting();
921
992
  __testing.recordNativeReviewOutcome(ctx.cwd, "target-a", "declined");
922
993
  const cli = closureCli({ consumed: true, targetIdentity: () => "target-a" });
923
- for (const input of [undefined, { nativeReviewOutcome: "closed" }]) {
994
+ for (const input of [ESCALATE, { nativeReviewOutcome: "closed", ...ESCALATE }]) {
924
995
  const details = await assessWith(cli, input);
925
996
  assert.equal(details.nativeReviewOutcome, "declined", JSON.stringify(input));
926
997
  assert.equal(details.outcome_source, "derived");
@@ -936,29 +1007,33 @@ test("gentle_review assess: a different candidate never inherits closure or a re
936
1007
  const closedB = await assessWith(closureCli({ consumed: true, targetIdentity: () => "target-b" }));
937
1008
  assert.equal(closedB.nativeReviewOutcome, "closed");
938
1009
  // Candidate C follows B, but its own assessment is not consumed: B's closure never carries over.
939
- const openC = await assessWith(closureCli({ consumed: false, targetIdentity: () => "target-c" }));
1010
+ const openC = await assessWith(closureCli({ consumed: false, targetIdentity: () => "target-c" }), ESCALATE);
940
1011
  assert.equal(openC.nativeReviewOutcome, "unknown");
941
1012
  assert.equal(openC.outcome_source, "unknown");
942
1013
  assert.equal(openC.plan.independentVerifier, true);
943
1014
  });
944
1015
 
1016
+ // Changed with verification by named risk: an unassessable candidate is a
1017
+ // medium plan with no verifier unless escalated; it is still never closed.
945
1018
  test("gentle_review assess: an unassessable candidate is never closed", async () => {
946
1019
  for (const input of [undefined, { nativeReviewOutcome: "closed" }]) {
947
1020
  const details = await assessWith(closureCli({ unassessable: true }), input);
948
- assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
1021
+ assert.equal(details.nativeRisk, VERIFICATION_TIER.UNASSESSABLE);
1022
+ assert.equal(details.risk, VERIFICATION_TIER.MEDIUM);
949
1023
  assert.equal(details.nativeReviewOutcome, "unknown");
950
- assert.equal(details.plan.independentVerifier, true);
1024
+ assert.equal(details.plan.independentVerifier, false);
951
1025
  }
952
1026
  });
953
1027
 
954
1028
  test("gentle_review assess: derived closure only changes the RDD on-path; RDD off plans are unchanged", async () => {
955
1029
  for (const risk of ["passive", "medium", "high"] as const) {
956
1030
  for (const consumed of [true, false]) {
1031
+ // The plan follows verificationRisk (native high without escalate is medium).
957
1032
  const off = await assessWith(closureCli({ rdd: "off", risk, consumed }), { writerModelId: "claude-sonnet-5", writerEffort: "high" });
958
- const expectedOff = verificationPlan({ rddLine: RDD_LINE.OFF, risk, writerProfile: WRITER_PROFILE.LARGE });
1033
+ const expectedOff = verificationPlan({ rddLine: RDD_LINE.OFF, risk: verificationRisk(risk, undefined), writerProfile: WRITER_PROFILE.LARGE });
959
1034
  assert.deepEqual(off.plan, { ...expectedOff }, `off ${risk} consumed ${consumed}`);
960
1035
  const on = await assessWith(closureCli({ rdd: "on", risk, consumed }), { writerModelId: "claude-sonnet-5", writerEffort: "high" });
961
- const expectedOn = verificationPlan({ rddLine: RDD_LINE.ON, risk, writerProfile: WRITER_PROFILE.LARGE, nativeReviewOutcome: consumed ? NATIVE_REVIEW_OUTCOME.CLOSED : NATIVE_REVIEW_OUTCOME.UNKNOWN });
1036
+ const expectedOn = verificationPlan({ rddLine: RDD_LINE.ON, risk: verificationRisk(risk, undefined), writerProfile: WRITER_PROFILE.LARGE, nativeReviewOutcome: consumed ? NATIVE_REVIEW_OUTCOME.CLOSED : NATIVE_REVIEW_OUTCOME.UNKNOWN });
962
1037
  assert.deepEqual(on.plan, { ...expectedOn }, `on ${risk} consumed ${consumed}`);
963
1038
  }
964
1039
  }
@@ -993,13 +1068,16 @@ function workspaceRoot(): string {
993
1068
 
994
1069
  const mediumOffCli = (): Partial<NativeReviewCli> => closureCli({ rdd: "off", risk: "medium" });
995
1070
 
1071
+ // Changed with verification by named risk (here and in the next two tests):
1072
+ // the runtime writer profile still wins and is reported, but a small profile
1073
+ // no longer adds a verifier to a medium plan.
996
1074
  test("gentle_review assess: the runtime writer profile wins over contradicting caller input", async () => {
997
1075
  const small = receiptContext();
998
1076
  small.record({ toolCallId: "w1", writerModelId: "openai/gpt-5-mini", writerEffort: "high" });
999
1077
  const claimedLarge = await assessWith(mediumOffCli(), { writerModelId: "claude-sonnet-5", writerEffort: "high" }, small.context);
1000
1078
  assert.equal(claimedLarge.writerProfile, "small");
1001
1079
  assert.equal(claimedLarge.writerProfileSource, "runtime");
1002
- assert.equal(claimedLarge.plan.independentVerifier, true, "a caller can never talk a runtime mini writer into a large profile");
1080
+ assert.equal(claimedLarge.plan.independentVerifier, false, "the reported profile never adds a verifier");
1003
1081
 
1004
1082
  const large = receiptContext();
1005
1083
  large.record({ toolCallId: "w1", writerModelId: "google/gemini-2.5-pro", writerEffort: "high" });
@@ -1022,7 +1100,7 @@ test("gentle_review assess: any small or unknown pending runtime writer makes th
1022
1100
  const details = await assessWith(mediumOffCli(), { writerModelId: "claude-sonnet-5", writerEffort: "high" }, fixture.context);
1023
1101
  assert.equal(details.writerProfile, "small", entry.name);
1024
1102
  assert.equal(details.writerProfileSource, "runtime", entry.name);
1025
- assert.equal(details.plan.independentVerifier, true, entry.name);
1103
+ assert.equal(details.plan.independentVerifier, false, entry.name);
1026
1104
  }
1027
1105
 
1028
1106
  const known = receiptContext();
@@ -1048,7 +1126,7 @@ test("gentle_review assess: without pending runtime receipts the caller input pa
1048
1126
  const fallback = await assessWith(mediumOffCli(), undefined, fixture.context);
1049
1127
  assert.equal(fallback.writerProfile, "small");
1050
1128
  assert.equal(fallback.writerProfileSource, "fallback");
1051
- assert.equal(fallback.plan.independentVerifier, true);
1129
+ assert.equal(fallback.plan.independentVerifier, false);
1052
1130
 
1053
1131
  // No session at all behaves exactly the same way.
1054
1132
  const noSession = await assessWith(mediumOffCli());
@@ -1093,14 +1171,17 @@ function nativeClient(adapter: ExecFileAdapter): NativeReviewCliV216 {
1093
1171
  return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
1094
1172
  }
1095
1173
 
1096
- test("gentle_review assess: stderr-only refusal preserves safe actionable diagnostics and fails closed", async () => {
1174
+ // Changed with verification by named risk: the refusal is reported as native
1175
+ // risk unassessable, which informs only (medium plan, no verifier).
1176
+ test("gentle_review assess: stderr-only refusal preserves safe actionable diagnostics and reports unassessable", async () => {
1097
1177
  const queue = queuedAdapter([{ stdout: "", stderr: "untracked scope declaration required; use --untracked-scope=exclude or select; token=hidden-secret", exitCode: 1 }]);
1098
1178
  const client = nativeClient(queue.adapter);
1099
1179
  const tool = reviewControllerTool({ assess: client.assess.bind(client) });
1100
1180
  const result = await tool.execute("refusal", { operation: "assess" }, undefined, undefined, ctx);
1101
- const details = result.details as { risk: string; reasons: { code: string; detail: string }[]; plan: { independentVerifier: boolean } };
1102
- assert.equal(details.risk, "unassessable");
1103
- assert.equal(details.plan.independentVerifier, true);
1181
+ const details = result.details as { risk: string; nativeRisk: string; reasons: { code: string; detail: string }[]; plan: { independentVerifier: boolean } };
1182
+ assert.equal(details.nativeRisk, "unassessable");
1183
+ assert.equal(details.risk, "medium");
1184
+ assert.equal(details.plan.independentVerifier, false);
1104
1185
  assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
1105
1186
  assert.match(details.reasons[0].detail, /untracked scope declaration required/);
1106
1187
  assert.match(details.reasons[0].detail, /--untracked-scope=exclude/);
@@ -1133,8 +1214,9 @@ for (const [shape, sensitive] of [
1133
1214
  const queue = queuedAdapter([{ stdout: "", stderr: `${sensitive}\nuntracked scope declaration required; use --untracked-scope=exclude or select`, exitCode: 1 }]);
1134
1215
  const client = nativeClient(queue.adapter);
1135
1216
  const result = await reviewControllerTool({ assess: client.assess.bind(client) }).execute("private-shape", { operation: "assess" }, undefined, undefined, ctx);
1136
- const details = result.details as { risk: string; reasons: { code: string; detail: string }[] };
1137
- assert.equal(details.risk, "unassessable");
1217
+ // The failed assessment shows as nativeRisk; the plan risk is medium.
1218
+ const details = result.details as { nativeRisk: string; reasons: { code: string; detail: string }[] };
1219
+ assert.equal(details.nativeRisk, "unassessable");
1138
1220
  assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
1139
1221
  assert.doesNotMatch(JSON.stringify(result), /hidden|private_project| project|file\.ts|\/private\//);
1140
1222
  assert.match(details.reasons[0].detail, /untracked scope declaration required; use --untracked-scope=exclude or select/);
@@ -1189,8 +1271,9 @@ test("facade assess leaves absent declarations to native and preserves stale-inv
1189
1271
  const client = nativeClient(queue.adapter);
1190
1272
  const tool = reviewControllerTool({ assess: client.assess.bind(client) });
1191
1273
  const result = await tool.execute("stale", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx);
1192
- const details = result.details as { risk: string; reasons: { detail: string }[] };
1193
- assert.equal(details.risk, "unassessable");
1274
+ // The failed assessment shows as nativeRisk; the plan risk is medium.
1275
+ const details = result.details as { nativeRisk: string; reasons: { detail: string }[] };
1276
+ assert.equal(details.nativeRisk, "unassessable");
1194
1277
  assert.match(details.reasons[0].detail, /inventory changed/);
1195
1278
  if (!("untrackedScope" in selection)) assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), "--json"]);
1196
1279
  }
@@ -1252,7 +1335,10 @@ test("gentle_review assess: consumed, reviewDue, reviewDueReason, and nextTransi
1252
1335
  const client = nativeClient(queue.adapter);
1253
1336
  const result = await reviewControllerTool({ assess: client.assess.bind(client) }).execute("new-envelope", { operation: "assess" }, undefined, undefined, ctx);
1254
1337
  const details = result.details as Record<string, unknown> & { candidate: Record<string, unknown> };
1255
- assert.equal(details.risk, "high");
1338
+ // Verification by named risk: the native high shows as nativeRisk; the
1339
+ // plan risk is medium without an escalation. The projection is unchanged.
1340
+ assert.equal(details.nativeRisk, "high");
1341
+ assert.equal(details.risk, "medium");
1256
1342
  assert.equal(details.candidate.consumed, false);
1257
1343
  assert.equal(details.reviewDue, true);
1258
1344
  assert.equal(details.reviewDueReason, "high_risk");
@@ -1275,8 +1361,9 @@ test("gentle_review assess: an older native envelope projects no consumed/review
1275
1361
 
1276
1362
  test("gentle_review assess: an unassessable projection omits reason.path instead of emitting an empty string", async () => {
1277
1363
  const result = await reviewControllerTool({}).execute("no-assess", { operation: "assess" }, undefined, undefined, ctx);
1278
- const details = result.details as { risk: string; reasons: Record<string, unknown>[] };
1279
- assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
1364
+ // Verification by named risk: the failed assessment shows as nativeRisk.
1365
+ const details = result.details as { nativeRisk: string; reasons: Record<string, unknown>[] };
1366
+ assert.equal(details.nativeRisk, VERIFICATION_TIER.UNASSESSABLE);
1280
1367
  assert.equal(details.reasons.length, 1);
1281
1368
  assert.equal(Object.hasOwn(details.reasons[0], "path"), false);
1282
1369
  assert.equal(typeof details.reasons[0].detail, "string");
@@ -4,7 +4,8 @@ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { PassThrough } from "node:stream";
7
- import test from "node:test";
7
+ import test, { after, before } from "node:test";
8
+ import { setWindowsAclAuthorityForTesting } from "../lib/review-candidate-view-owner.ts";
8
9
  import { Text, visibleWidth } from "@earendil-works/pi-tui";
9
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
11
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
@@ -19,6 +20,16 @@ import {
19
20
  presentReviewConsentUi,
20
21
  } from "../lib/review-consent-ui.ts";
21
22
 
23
+ // Coordination tests use fixture authority; real Windows ACL coverage lives
24
+ // in review-candidate-view.test.ts, in a separate test process.
25
+ let fixtureAclChecks = 0;
26
+ before(() => {
27
+ if (process.platform === "win32") {
28
+ setWindowsAclAuthorityForTesting(() => { fixtureAclChecks++; });
29
+ }
30
+ });
31
+ after(() => setWindowsAclAuthorityForTesting());
32
+
22
33
  function consentFixture(): Record<string, unknown> {
23
34
  const path = join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json");
24
35
  return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
@@ -173,10 +184,12 @@ function siblingWorktree(t: test.TestContext, parentRoot: string): string {
173
184
 
174
185
  function startStatus(cwd: string, intendedUntracked: readonly string[] = []): ReviewStatusV3 {
175
186
  const views = new CandidateViewRegistry();
187
+ const checksBefore = fixtureAclChecks;
176
188
  const candidate = views.create({
177
189
  contributorRoot: cwd,
178
190
  ...(intendedUntracked.length === 0 ? {} : { intendedUntracked }),
179
191
  });
192
+ if (process.platform === "win32") assert.ok(fixtureAclChecks > checksBefore, "coordination candidates must use the fixture ACL authority");
180
193
  try {
181
194
  return {
182
195
  contract: "gentle-ai.review-integration/v2",
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { execFileSync } from "node:child_process";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -100,6 +100,32 @@ test("review session Git identity lookups hide every Windows child process", asy
100
100
  }
101
101
  });
102
102
 
103
+ test("synchronous repository discovery stays silent outside Git", (t) => {
104
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-non-git-identity-")));
105
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
106
+ const moduleUrl = new URL("../lib/review-session-standing-permission.ts", import.meta.url).href;
107
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith("GIT_")));
108
+ const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", `
109
+ import { resolveCanonicalGitRepositoryIdentitySync } from ${JSON.stringify(moduleUrl)};
110
+ const identity = resolveCanonicalGitRepositoryIdentitySync(process.cwd());
111
+ console.log(JSON.stringify({ identity: identity ?? null }));
112
+ `], { cwd, env, encoding: "utf8", timeout: 10_000 });
113
+ assert.equal(child.error, undefined);
114
+ assert.equal(child.status, 0, child.stderr);
115
+ assert.deepEqual(JSON.parse(child.stdout), { identity: null });
116
+ assert.equal(child.stderr, "", "a caught Git failure must not write over the TUI editor");
117
+ });
118
+
119
+ test("synchronous repository discovery preserves clone identity across worktrees", (t) => {
120
+ const root = repository(t);
121
+ const sibling = siblingWorktree(t, root);
122
+ const unrelated = repository(t);
123
+ const identity = resolveCanonicalGitRepositoryIdentitySync(root);
124
+ assert.match(identity ?? "", /^sha256:/);
125
+ assert.equal(resolveCanonicalGitRepositoryIdentitySync(sibling), identity);
126
+ assert.notEqual(resolveCanonicalGitRepositoryIdentitySync(unrelated), identity);
127
+ });
128
+
103
129
  test("headless, child, empty-session, and non-Git contexts cannot offer or consume standing permission", async (t) => {
104
130
  const root = repository(t);
105
131
  const manager = {};