gentle-pi 2.1.2 → 2.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 (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -7,9 +7,9 @@ import {
7
7
  NativeReviewCliV216,
8
8
  NativeReviewConsentBindingError,
9
9
  NativeReviewConsentRequiredError,
10
- clearNativeReviewCapabilitiesCacheForTesting,
11
10
  type ExecFileAdapter,
12
11
  } from "../lib/native-review-cli.ts";
12
+ import { NativeReviewCliV216 as RuntimeNativeReviewCliV216 } from "../runtime/native-review-cli.mjs";
13
13
  import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
14
14
 
15
15
  const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
@@ -25,7 +25,7 @@ function capabilities(): Record<string, unknown> {
25
25
  function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
26
26
  const status = fixture<Record<string, unknown>>("status.fixture.json");
27
27
  status.applicability = "unrelated";
28
- status.receipt = { status: "not_applicable" };
28
+ delete status.receipt;
29
29
  status.action = "start";
30
30
  status.replayability = "not_replayable";
31
31
  status.target_identity = targetIdentity;
@@ -39,8 +39,41 @@ function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
39
39
  return status;
40
40
  }
41
41
 
42
+ function startTransitionTokens(targetIdentity: string): readonly string[] {
43
+ return [
44
+ "--contract=gentle-ai.review-integration/v2",
45
+ "--cwd=/repo",
46
+ `--target=${targetIdentity}`,
47
+ "--projection=workspace",
48
+ "--agent=pi",
49
+ "--consent=relay",
50
+ ] as const;
51
+ }
52
+
53
+ function executableStartStatus(targetIdentity: string): Record<string, unknown> {
54
+ const status = unrelatedStatus(targetIdentity);
55
+ const tokens = startTransitionTokens(targetIdentity);
56
+ status.next_transition = {
57
+ kind: "execute",
58
+ reason_code: "review_start_required",
59
+ execute: {
60
+ operation: "review.start",
61
+ arguments: tokens.map((token) => {
62
+ const separator = token.indexOf("=");
63
+ return { name: token.slice(2, separator), value: token.slice(separator + 1), token };
64
+ }),
65
+ preconditions: [],
66
+ binding: { target_identity: targetIdentity },
67
+ },
68
+ };
69
+ return status;
70
+ }
71
+
42
72
  function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
43
- const queue = [...outputs];
73
+ // V216 no longer probes capabilities before negotiated STATUS. Keep the
74
+ // historical queue construction below, but discard only its obsolete probe
75
+ // response so every remaining entry binds the current invocation.
76
+ const queue = outputs.filter((body) => body.schema !== "gentle-ai.review-integration.capabilities/v2");
44
77
  const calls: Array<readonly string[]> = [];
45
78
  return {
46
79
  calls,
@@ -54,61 +87,85 @@ function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter:
54
87
  }
55
88
 
56
89
  function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
57
- clearNativeReviewCapabilitiesCacheForTesting();
58
90
  return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
59
91
  }
60
92
 
61
- test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
93
+ function runtimeClient(adapter: ExecFileAdapter): RuntimeNativeReviewCliV216 {
94
+ return new RuntimeNativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
95
+ }
96
+
97
+ test("negotiated ordinary START executes the complete STATUS-rendered relay vector and preserves the consent envelope", async () => {
62
98
  const consent = fixture<Record<string, unknown>>("consent.fixture.json");
63
99
  const target = String(consent.target_identity);
64
- const queue = queuedAdapter([
65
- capabilities(),
66
- unrelatedStatus(target),
67
- consent,
68
- ]);
69
- await assert.rejects(
70
- () => client(queue.adapter).start({ cwd: "/repo" }),
71
- (error: unknown) => {
72
- assert.ok(error instanceof NativeReviewConsentRequiredError);
73
- assert.deepEqual(error.consent.raw, consent);
74
- assert.equal(error.consent.targetIdentity, target);
75
- return true;
76
- },
77
- );
78
- assert.deepEqual(queue.calls.at(-1), [
79
- "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
80
- "--target", target, "--projection", "workspace", "--consent", "relay",
81
- ]);
82
- assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), true);
100
+ const tokens = startTransitionTokens(target);
101
+ for (const createClient of [client, runtimeClient]) {
102
+ const queue = queuedAdapter([capabilities(), executableStartStatus(target), structuredClone(consent)]);
103
+ await assert.rejects(
104
+ () => createClient(queue.adapter).start({ cwd: "/repo" }),
105
+ (error: unknown) => {
106
+ assert.equal((error as { name?: string }).name, "NativeReviewConsentRequiredError");
107
+ const required = error as { consent: { raw: Record<string, unknown>; targetIdentity: string } };
108
+ assert.deepEqual(required.consent.raw, consent);
109
+ assert.equal(required.consent.targetIdentity, target);
110
+ return true;
111
+ },
112
+ );
113
+ assert.deepEqual(queue.calls, [
114
+ ["review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo", "--projection", "workspace", "--agent", "pi", "--next-transition"],
115
+ ["review", "start", ...tokens],
116
+ ]);
117
+ }
83
118
  });
84
119
 
85
- test("controller-prebound START target is used without projecting a second workspace candidate", async () => {
120
+ test("controller-prebound START target checks STATUS once and executes its exact matching transition", async () => {
86
121
  const consent = fixture<Record<string, unknown>>("consent.fixture.json");
87
122
  const target = String(consent.target_identity);
88
- const queue = queuedAdapter([capabilities(), consent]);
89
- await assert.rejects(
90
- () => client(queue.adapter).start({ cwd: "/repo", targetIdentity: target, projection: "workspace" }),
91
- (error: unknown) => error instanceof NativeReviewConsentRequiredError,
92
- );
93
- assert.deepEqual(queue.calls.at(-1), [
94
- "review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
95
- "--target", target, "--projection", "workspace", "--consent", "relay",
96
- ]);
97
- assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), false, "a prebound START target must not be projected again");
123
+ const tokens = startTransitionTokens(target);
124
+ for (const createClient of [client, runtimeClient]) {
125
+ const queue = queuedAdapter([capabilities(), executableStartStatus(target), structuredClone(consent)]);
126
+ await assert.rejects(
127
+ () => createClient(queue.adapter).start({ cwd: "/repo", targetIdentity: target, projection: "workspace" }),
128
+ (error: unknown) => (error as { name?: string }).name === "NativeReviewConsentRequiredError",
129
+ );
130
+ assert.equal(queue.calls.filter((arguments_) => arguments_[1] === "status").length, 1, "a prebound target is a drift check, never a STATUS bypass");
131
+ assert.deepEqual(queue.calls.at(-1), ["review", "start", ...tokens]);
132
+ }
98
133
  });
99
134
 
100
- test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
135
+ test("consent follow-up executes the provider-named invocation exactly once and refuses changed lineage or target bindings", async () => {
101
136
  const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
102
137
  const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
103
- const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
104
- .replaceAll("review-start-fixture", "review-consent-fixture")) as Record<string, unknown>;
138
+ const preboundLineage = "Review.Lineage_42";
139
+ for (const choice of decodedConsent.choices) {
140
+ choice.invocation = choice.invocation.replace("review-consent-fixture", preboundLineage);
141
+ }
142
+ const started = fixture<Record<string, unknown>>("start.fixture.json");
143
+ started.lineage_id = preboundLineage;
144
+ started.artifact_subjects = [];
105
145
  const queue = queuedAdapter([capabilities(), started]);
106
146
  const native = client(queue.adapter);
107
147
  const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
108
148
  assert.equal(result.kind, "started");
149
+ assert.ok(result.kind === "started");
150
+ assert.equal(result.start.lineageId, preboundLineage);
109
151
  assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
110
152
  assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
111
153
 
154
+ const runtimeQueue = queuedAdapter([capabilities(), structuredClone(started)]);
155
+ const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
156
+ assert.equal(runtimeResult.kind, "started");
157
+ assert.ok(runtimeResult.kind === "started");
158
+ assert.equal(runtimeResult.start.lineageId, preboundLineage);
159
+ assert.equal(runtimeQueue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
160
+
161
+ const changedLineage = JSON.parse(JSON.stringify(started).replaceAll(preboundLineage, "different-lineage")) as Record<string, unknown>;
162
+ const lineageQueue = queuedAdapter([capabilities(), changedLineage]);
163
+ await assert.rejects(
164
+ () => client(lineageQueue.adapter).answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" }),
165
+ /lineage mismatch/,
166
+ );
167
+ assert.equal(lineageQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
168
+
112
169
  const changed = structuredClone(decodedConsent);
113
170
  changed.targetIdentity = `sha256:${"b".repeat(64)}`;
114
171
  await assert.rejects(
@@ -177,6 +234,68 @@ test("every consent invocation binding guard reports its own reason without laun
177
234
  }
178
235
  });
179
236
 
237
+ test("fresh consent START accepts the strictly decoded provider-created lineage and rejects a mismatched target", async () => {
238
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
239
+ const freshConsent = structuredClone(decoded);
240
+ for (const choice of freshConsent.choices) {
241
+ choice.invocation = choice.invocation.replace(" --lineage review-consent-fixture", "");
242
+ }
243
+ const freshStart = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
244
+ .replaceAll("review-start-fixture", "provider-created-lineage")) as Record<string, unknown>;
245
+ const queue = queuedAdapter([capabilities(), freshStart]);
246
+ const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent: freshConsent, answer: "granted" });
247
+ assert.equal(result.kind, "started");
248
+ assert.ok(result.kind === "started");
249
+ assert.equal(result.start.lineageId, "provider-created-lineage");
250
+ assert.equal(queue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
251
+ assert.equal(queue.calls.at(-1)?.includes("--lineage"), false);
252
+
253
+ const runtimeQueue = queuedAdapter([capabilities(), structuredClone(freshStart)]);
254
+ const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd: "/repo", consent: freshConsent, answer: "granted" });
255
+ assert.equal(runtimeResult.kind, "started");
256
+ assert.ok(runtimeResult.kind === "started");
257
+ assert.equal(runtimeResult.start.lineageId, "provider-created-lineage");
258
+ assert.equal(runtimeQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
259
+ assert.equal(runtimeQueue.calls.at(-1)?.includes("--lineage"), false);
260
+
261
+ const mismatchedTarget = structuredClone(freshStart);
262
+ ((mismatchedTarget.repository_context as Record<string, unknown>).target_identity as string) = `sha256:${"b".repeat(64)}`;
263
+ const mismatchQueue = queuedAdapter([capabilities(), mismatchedTarget]);
264
+ await assert.rejects(
265
+ () => client(mismatchQueue.adapter).answerConsent!({ cwd: "/repo", consent: freshConsent, answer: "granted" }),
266
+ /target mismatch/,
267
+ );
268
+ assert.equal(mismatchQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
269
+ });
270
+
271
+ test("duplicate, empty, missing, and malformed consent lineages fail before provider launch", async () => {
272
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
273
+ const drifted = (replace: (invocation: string) => string): ReviewConsentV2 => {
274
+ const consent = structuredClone(decoded);
275
+ const choice = consent.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
276
+ choice.invocation = replace(choice.invocation);
277
+ return consent;
278
+ };
279
+ const cases = [
280
+ { label: "duplicate", consent: drifted((value) => `${value} --lineage duplicate-lineage`) },
281
+ { label: "empty", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage=")) },
282
+ { label: "missing", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage")) },
283
+ { label: "malformed", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage invalid/lineage")) },
284
+ ] as const;
285
+ for (const scenario of cases) {
286
+ const queue = queuedAdapter([]);
287
+ await assert.rejects(
288
+ () => client(queue.adapter).answerConsent!({ cwd: "/repo", consent: scenario.consent, answer: "granted" }),
289
+ (error: unknown) => {
290
+ assert.ok(error instanceof NativeReviewConsentBindingError, `${scenario.label} lineage must be a typed binding error`);
291
+ assert.equal(error.reason, "consent-invocation-option-invalid");
292
+ return true;
293
+ },
294
+ );
295
+ assert.deepEqual(queue.calls, [], `${scenario.label} lineage must not launch the provider`);
296
+ }
297
+ });
298
+
180
299
  test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
181
300
  const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
182
301
  const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
@@ -203,3 +322,129 @@ test("declined consent decodes the provider's explicit empty authority fields wi
203
322
  assert.equal("lineageId" in result, false);
204
323
  assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
205
324
  });
325
+
326
+ // --- consent/v3 (dev-binary field-test lane) ---
327
+ //
328
+ // Fixture provenance: consent-v3.captured.json, start-v3-consent-granted
329
+ // .captured.json, and start-v3-consent-declined.captured.json were captured
330
+ // 2026-08-16 from the real binary at /home/gentleman/.cargo/bin/gentle-ai
331
+ // reporting "gentle-ai 2.4.0-main.b1afef46"; full capture commands are
332
+ // documented in tests/review-integration-v2-forward.test.ts.
333
+
334
+ const devbinaryFixtureRoot = join(process.cwd(), "tests", "fixtures", "devbinary");
335
+ const devbinaryFixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(devbinaryFixtureRoot, name), "utf8")) as T;
336
+
337
+ function piConsent(rewriteInvocation: (invocation: string) => string = (invocation) => invocation): Record<string, unknown> {
338
+ const consent = devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json");
339
+ consent.agent = "pi";
340
+ for (const choice of consent.choices as Array<Record<string, unknown>>) {
341
+ choice.invocation = rewriteInvocation(String(choice.invocation));
342
+ }
343
+ return consent;
344
+ }
345
+
346
+ function piBoundConsent(): Record<string, unknown> {
347
+ return piConsent((invocation) => invocation.replace(" --consent ", " --agent pi --consent "));
348
+ }
349
+
350
+ test("Pi START rejects a Pi-bound consent/v3 envelope when any provider choice omits or misbinds the Pi agent", async () => {
351
+ const target = String(devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json").target_identity);
352
+ const tokens = startTransitionTokens(target);
353
+ const invalidUnselectedChoice = (replace: (invocation: string) => string): Record<string, unknown> => {
354
+ const consent = piBoundConsent();
355
+ const choice = (consent.choices as Array<Record<string, unknown>>).find((candidate) => candidate.answer === "declined")!;
356
+ choice.invocation = replace(String(choice.invocation));
357
+ return consent;
358
+ };
359
+ const cases = [
360
+ ["missing", piConsent()],
361
+ ["embedded", piConsent((invocation) => invocation.replace(" --consent ", " --note=--agent\\ pi --consent "))],
362
+ ["duplicate", invalidUnselectedChoice((invocation) => invocation.replace("--agent pi", "--agent pi --agent pi"))],
363
+ ["conflicting", invalidUnselectedChoice((invocation) => invocation.replace("--agent pi", "--agent pi --agent claude-code"))],
364
+ ] as const;
365
+ for (const [label, consent] of cases) {
366
+ for (const createClient of [client, runtimeClient]) {
367
+ const queue = queuedAdapter([capabilities(), executableStartStatus(target), structuredClone(consent)]);
368
+ await assert.rejects(
369
+ () => createClient(queue.adapter).start({ cwd: "/repo" }),
370
+ (error: unknown) => {
371
+ assert.equal((error as { name?: string }).name, "NativeReviewCliError", `${label} binding must fail closed before consent presentation`);
372
+ assert.equal((error as { code?: string }).code, "schema-incompatible");
373
+ return true;
374
+ },
375
+ );
376
+ assert.deepEqual(queue.calls.at(-1), ["review", "start", ...tokens]);
377
+ }
378
+ }
379
+ });
380
+
381
+ test("Pi START rejects a foreign consent/v3 envelope and only relays exact Pi-bound choice invocations", async () => {
382
+ const consent = devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json");
383
+ const target = String(consent.target_identity);
384
+ const tokens = startTransitionTokens(target);
385
+ for (const createClient of [client, runtimeClient]) {
386
+ const foreignQueue = queuedAdapter([capabilities(), executableStartStatus(target), structuredClone(consent)]);
387
+ await assert.rejects(
388
+ () => createClient(foreignQueue.adapter).start({ cwd: "/repo" }),
389
+ (error: unknown) => {
390
+ assert.equal((error as { name?: string }).name, "NativeReviewCliError");
391
+ assert.equal((error as { code?: string }).code, "schema-incompatible");
392
+ return true;
393
+ },
394
+ );
395
+ assert.deepEqual(foreignQueue.calls.at(-1), ["review", "start", ...tokens]);
396
+ for (const piBound of [
397
+ piBoundConsent(),
398
+ piConsent((invocation) => invocation.replace(" --consent ", " --agent=pi --consent ")),
399
+ ]) {
400
+ const piQueue = queuedAdapter([capabilities(), executableStartStatus(target), piBound]);
401
+ await assert.rejects(
402
+ () => createClient(piQueue.adapter).start({ cwd: "/repo" }),
403
+ (error: unknown) => {
404
+ assert.equal((error as { name?: string }).name, "NativeReviewConsentRequiredError");
405
+ const required = error as { consent: { raw: Record<string, unknown>; schema: string; targetIdentity: string; agent?: string } };
406
+ assert.deepEqual(required.consent.raw, piBound);
407
+ assert.equal(required.consent.schema, "gentle-ai.review-integration.consent/v3");
408
+ assert.equal(required.consent.targetIdentity, target);
409
+ assert.equal(required.consent.agent, "pi");
410
+ return true;
411
+ },
412
+ );
413
+ assert.deepEqual(piQueue.calls.at(-1), ["review", "start", ...tokens]);
414
+ }
415
+ }
416
+ });
417
+
418
+ test("a granted Pi consent/v3 answer replays the captured exact agent-bound invocation", async () => {
419
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV3(piBoundConsent(), "pi");
420
+ const cwd = decoded.choices[0].invocation.split(" --cwd ")[1]!.split(" ")[0]!;
421
+ const started = devbinaryFixture<Record<string, unknown>>("start-v3-consent-granted.captured.json");
422
+ const queue = queuedAdapter([capabilities(), started]);
423
+ const result = await client(queue.adapter).answerConsent!({ cwd, consent: decoded, answer: "granted" });
424
+ assert.equal(result.kind, "started");
425
+ assert.ok(result.kind === "started");
426
+ assert.equal(result.start.lineageId, "review-377c60e10b852cfc");
427
+ assert.equal(result.start.selectedLenses.length, 4);
428
+ // The follow-up runs the provider-owned invocation verbatim, exactly once.
429
+ assert.deepEqual(queue.calls.at(-1), decoded.choices[0].invocation.split(" ").slice(1));
430
+ assert.equal(queue.calls.filter((arguments_) => arguments_.includes("--agent") && arguments_.includes("pi")).length, 1);
431
+ assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
432
+
433
+ const runtimeQueue = queuedAdapter([capabilities(), structuredClone(started)]);
434
+ const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd, consent: decoded, answer: "granted" });
435
+ assert.equal(runtimeResult.kind, "started");
436
+ });
437
+
438
+ test("a declined Pi consent/v3 answer replays the captured exact agent-bound invocation and creates no authority", async () => {
439
+ const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV3(piBoundConsent(), "pi");
440
+ const cwd = decoded.choices[1].invocation.split(" --cwd ")[1]!.split(" ")[0]!;
441
+ const declined = devbinaryFixture<Record<string, unknown>>("start-v3-consent-declined.captured.json");
442
+ const queue = queuedAdapter([capabilities(), declined]);
443
+ const result = await client(queue.adapter).answerConsent!({ cwd, consent: decoded, answer: "declined" });
444
+ assert.equal(result.kind, "declined");
445
+ assert.ok(result.kind === "declined");
446
+ assert.equal(result.consent, "declined_this_candidate");
447
+ assert.equal(result.targetIdentity, decoded.targetIdentity);
448
+ assert.deepEqual(queue.calls.at(-1), decoded.choices[1].invocation.split(" ").slice(1));
449
+ assert.equal(queue.calls.filter((arguments_) => arguments_.includes("--agent") && arguments_.includes("pi")).length, 1);
450
+ });