gentle-pi 2.2.0 → 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 (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -1,631 +1,654 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import test from "node:test";
5
5
  import {
6
+ REVIEW_ACKNOWLEDGED_SCHEMA,
7
+ decodeReviewAcknowledgedV1,
6
8
  decodeReviewCapabilitiesV2,
7
9
  decodeReviewConsentV2,
8
10
  decodeReviewConsentV3,
9
11
  decodeReviewFailureV2,
10
- decodeReviewOperationV2,
12
+ decodeReviewLastEventClosureV1,
13
+ decodeReviewNextTransitionV3,
14
+ decodeReviewRepairV2,
11
15
  decodeReviewResultArtifactV2,
12
16
  decodeReviewStartV3,
17
+ decodeReviewStartV4,
13
18
  decodeReviewStatusV3,
14
19
  } from "../lib/review-integration-v2.ts";
15
20
 
16
- // Forward decoders for the gentle-ai main line (dev-binary field-test lane).
17
- //
18
- // Fixture provenance:
19
- // - capabilities-v2.2.captured.json and status-v5.captured.json were captured
20
- // 2026-08-15 from the real binary at /home/gentleman/.cargo/bin/gentle-ai
21
- // reporting "gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery" (built
22
- // from the gentle-ai main line), in a scratch git repository:
23
- // gentle-ai review capabilities --contract gentle-ai.review-integration/v2
24
- // GENTLE_PI_REVIEW_RELAY_CONTRACT=gentle-pi.review-relay/v1 \
25
- // gentle-ai review status --contract gentle-ai.review-integration/v2 \
26
- // --cwd <scratch> --projection workspace --next-transition
27
- // - capabilities-v2.1.derived.json is derived from the captured v2.2 payload
28
- // plus gentle-ai origin/main contracts/review-integration/v2/schemas/
29
- // capabilities-v2.1.schema.json (schema const v2.1, protocol minor 1,
30
- // consent/v3 + status/v3 schema advertisements, 17 optional features): no
31
- // installed binary emits v2.1 to capture from.
32
- // - The v5-only next_transition samples (correction_request, provider_task,
33
- // submission descriptor forms) are constructed from gentle-ai origin/main
34
- // contracts/review-integration/v2/schemas/status-v5.schema.json and
35
- // contracts/review-integration/v1/schemas/correction-plan-request.schema.json.
36
- // - consent-v3.captured.json, start-v3-consent-granted.captured.json, and
37
- // start-v3-consent-declined.captured.json were captured 2026-08-16 from the
38
- // real binary at /home/gentleman/.cargo/bin/gentle-ai reporting
39
- // "gentle-ai 2.4.0-main.b1afef46", in a scratch git repository holding a
40
- // committed internal/runner/{runner.go,runner_test.go} baseline plus 12
41
- // uncommitted changed lines that add a process-starting helper (risk signal
42
- // shell_process, high tier):
43
- // gentle-ai review status --contract gentle-ai.review-integration/v2 \
44
- // --cwd <scratch> --projection workspace --next-transition # target source
45
- // gentle-ai review start --contract gentle-ai.review-integration/v2 \
46
- // --cwd <scratch> --target <identity> --projection workspace \
47
- // --consent relay # consent-v3
48
- // then the envelope's exact declined invocation (declined capture, which
49
- // persists nothing) followed by its exact granted invocation (granted
50
- // start/v3 capture). Note: the live granted/declined invocations carry no
51
- // --agent token even though the envelope pins agent: claude-code — the
52
- // published consent-v3.schema.json invocation pattern is narrower than the
53
- // emitter, so the capture is authoritative (parity playbook, known traps).
54
- // - status-v5-repository-context.captured.json was captured 2026-08-16 from
55
- // the real binary at /home/gentleman/.cargo/bin/gentle-ai reporting
56
- // "gentle-ai 2.4.0-main.b1afef46", in a scratch git repository with one
57
- // medium-tier lineage in state `reviewing` whose single reviewer result was
58
- // already admitted (consent granted start, then review capture-result):
59
- // gentle-ai review status --contract gentle-ai.review-integration/v2 \
60
- // --cwd <scratch> --agent claude-code --lineage <lineage> \
61
- // --projection workspace --next-transition
62
- // The top-level `repository_context` reference it carries is emitted by the
63
- // live binary but MISSING from the published status-v5.schema.json
64
- // (additionalProperties: false) at the same commit — the capture is
65
- // authoritative (parity playbook, known traps).
66
- // - result-artifact-v2.captured.json and result-artifact-v2-path.captured.json
67
- // were captured 2026-08-16 from the real binary at
68
- // /home/gentleman/.cargo/bin/gentle-ai reporting
69
- // "gentle-ai 2.4.0-main.b1afef46", in a scratch git repository holding a
70
- // committed src/greet.js baseline plus an uncommitted shout() helper
71
- // (medium tier, one review-reliability lens): consent-granted START, then
72
- // the STATUS transition's exact `review capture-result` collect arguments
73
- // with a completed reviewer input document. The reference form is the
74
- // stdout of the --repository-context invocation (opaque rart1_ locator);
75
- // the path form re-ran the exact same admitted slot with --cwd instead,
76
- // which discovers the identical canonical bytes and answers with the
77
- // provider-owned store path. Both agree on every binding field.
78
- // - status-v5-capture-result-submission.captured.json was captured 2026-08-16
79
- // from the real binary at /home/gentleman/.cargo/bin/gentle-ai reporting
80
- // "gentle-ai 2.4.0-main.b1afef46", in a scratch git repository holding a
81
- // committed src/add.js baseline plus an uncommitted double() helper
82
- // (medium tier, one review-reliability lens), with
83
- // GENTLE_PI_REVIEW_RELAY_CONTRACT=gentle-pi.review-relay/v1 exported:
84
- // negotiated STATUS (--agent pi), the rendered consent/v3 START, its exact
85
- // granted invocation, then
86
- // gentle-ai review status --contract gentle-ai.review-integration/v2 \
87
- // --cwd <scratch> --agent pi --next-transition
88
- // at reviewer_results_required. Its materialize capture-result collect
89
- // input carries the submission descriptor as a SINGULAR `value` object
90
- // with a `schema` key (emitter: gentle-ai f1a95179, singular from its
91
- // first commit) — not the `values` array this decoder was originally
92
- // written against. The capture is authoritative.
93
-
94
- const fixtureRoot = join(import.meta.dirname, "fixtures", "devbinary");
95
- const fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T;
96
- const v2FixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
97
- const v2Fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(v2FixtureRoot, name), "utf8")) as T;
98
- const capturedDigest = "ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1";
99
- const pinnedFixtureDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
21
+ const DEV_FIXTURES = join(process.cwd(), "tests", "fixtures", "devbinary");
22
+ const V2_FIXTURES = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
23
+ const CAPTURED_DIGEST = "ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1";
100
24
 
101
25
  type JsonObject = Record<string, unknown>;
102
26
  const clone = <T>(value: T): T => structuredClone(value);
103
27
  const sha = (fill: string): string => `sha256:${fill.repeat(64)}`;
104
28
 
105
- test("the captured capabilities/v2.2 payload decodes and reports its own package version", () => {
106
- const capabilities = decodeReviewCapabilitiesV2(fixture("capabilities-v2.2.captured.json"), capturedDigest);
29
+ function fixture(root: string, name: string): unknown {
30
+ return JSON.parse(readFileSync(join(root, name), "utf8"));
31
+ }
32
+
33
+ function currentStatusFixture(name: string): Record<string, unknown> {
34
+ const body = structuredClone(fixture(DEV_FIXTURES, name)) as Record<string, unknown>;
35
+ if (body.action === "finalize") {
36
+ body.action = "stop";
37
+ const transition = body.next_transition as Record<string, unknown> | undefined;
38
+ if (transition?.kind === "execute") {
39
+ delete body.next_transition;
40
+ delete body.forecast;
41
+ }
42
+ }
43
+ return body;
44
+ }
45
+
46
+ function initialIntendedUntrackedStatusV6(): JsonObject {
47
+ const body = currentStatusFixture("status-v5.captured.json");
48
+ const projection = body.projection as JsonObject;
49
+ const schema = "gentle-ai.review-intended-untracked-selection/v1";
50
+ body.schema = "gentle-ai.review-integration.status/v6";
51
+ body.forecast = {
52
+ horizon: "partial", steps: [{ step: 1, kind: "collect", reason_code: "intended_untracked_selection_required", description: "initial intended-untracked selection required" }],
53
+ };
54
+ body.next_transition = {
55
+ kind: "collect",
56
+ reason_code: "intended_untracked_selection_required",
57
+ collect: { inputs: [{
58
+ name: "intended_untracked_selection", schema, capture_operation: "external.select_intended_untracked",
59
+ arguments: [
60
+ ["target_identity", body.target_identity], ["projection", "workspace"], ["base_tree", projection.base_tree],
61
+ ["candidate_tree", projection.current_candidate_tree], ["eligible_paths_json", '["docs/selected.md"]'], ["expected_untracked_inventory", sha("e")],
62
+ ].map(([name, value]) => ({ name, value })),
63
+ submission: {
64
+ operation_token: "status",
65
+ argument_tokens: ["--contract=gentle-ai.review-integration/v2", "--next-transition=true", "--agent=pi", "--projection=workspace", "--intended-untracked-selection={{value}}"],
66
+ value: { slot: "intended_untracked_selection", domain: "schema_bound_json", schema, substitution_location: 4 },
67
+ },
68
+ }] },
69
+ };
70
+ return body;
71
+ }
72
+
73
+ test("captured capabilities retain their exact schema identity", () => {
74
+ const capabilities = decodeReviewCapabilitiesV2(
75
+ fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json"),
76
+ CAPTURED_DIGEST,
77
+ );
107
78
  assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery");
108
79
  assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v5"), true);
109
- assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v3"), true);
110
- assert.equal(capabilities.optionalFeatures.has("provider_bound_native_git_context"), true);
80
+ });
81
+
82
+ test("historical v3 FINALIZE status remains rejected without rewriting fixture bytes", () => {
83
+ assert.throws(
84
+ () => decodeReviewStatusV3(fixture(V2_FIXTURES, "status.fixture.json")),
85
+ /receipt|finalize|status/i,
86
+ );
87
+ });
88
+
89
+ test("v5 STATUS rejects malformed receipt status, identity, and extra fields", () => {
90
+ const unsupported = currentStatusFixture("status-v5.captured.json");
91
+ (unsupported.receipt as JsonObject).status = "retired";
92
+ assert.throws(() => decodeReviewStatusV3(unsupported), /status\.receipt\.status/);
93
+
94
+ const invalidIdentity = currentStatusFixture("status-v5.captured.json");
95
+ (invalidIdentity.receipt as JsonObject).identity = "not-a-sha256";
96
+ assert.throws(() => decodeReviewStatusV3(invalidIdentity), /status\.receipt\.identity/);
97
+
98
+ const extra = currentStatusFixture("status-v5.captured.json");
99
+ (extra.receipt as JsonObject).unexpected = true;
100
+ assert.throws(() => decodeReviewStatusV3(extra), /status\.receipt\.unexpected is not allowed/);
101
+ });
102
+
103
+ test("legacy-v1 v5 STATUS accepts only its two compatible receipt states", () => {
104
+ const legacyStatus = (status: string, identity?: string): JsonObject => {
105
+ const body = currentStatusFixture("status-v5-capture-result-submission.captured.json");
106
+ (body.authority as JsonObject).version = "legacy-v1";
107
+ body.receipt = { status, ...(identity === undefined ? {} : { identity }) };
108
+ delete body.frozen;
109
+ delete body.authority_target_identity;
110
+ return body;
111
+ };
112
+
113
+ assert.deepEqual(decodeReviewStatusV3(legacyStatus("expected_missing")).receipt, { status: "expected_missing" });
114
+ assert.deepEqual(decodeReviewStatusV3(legacyStatus("present", sha("a"))).receipt, { status: "present", identity: sha("a") });
115
+ for (const status of ["publication_pending", "not_applicable"]) {
116
+ assert.throws(() => decodeReviewStatusV3(legacyStatus(status)), /legacy status receipt is incompatible/);
117
+ }
118
+ });
119
+
120
+ test("captured terminal closure decodes without a compatibility status projection", () => {
121
+ const closure = decodeReviewLastEventClosureV1(
122
+ fixture(DEV_FIXTURES, "last-event-capture-result-approved.captured.json"),
123
+ );
124
+ assert.equal(closure.operation, "review/capture-result");
125
+ assert.equal(closure.state, "approved");
126
+ });
127
+
128
+ test("derived and pinned capabilities remain separately schema-pinned", () => {
129
+ const derived = decodeReviewCapabilitiesV2(fixture(DEV_FIXTURES, "capabilities-v2.1.derived.json"), CAPTURED_DIGEST);
130
+ assert.equal(derived.schemas.has("gentle-ai.review-integration.consent/v3"), true);
131
+ const pinned = decodeReviewCapabilitiesV2(fixture(V2_FIXTURES, "capabilities.fixture.json"), "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
132
+ assert.equal(pinned.schemas.has("gentle-ai.review-integration.consent/v2"), true);
133
+ });
134
+
135
+ test("capabilities reject a protocol minor that disagrees with their exact identity", () => {
136
+ const body = fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as Record<string, unknown>;
137
+ const mutated = structuredClone(body) as Record<string, unknown>;
138
+ (mutated.protocol as Record<string, unknown>).minor = 1;
139
+ assert.throws(() => decodeReviewCapabilitiesV2(mutated, CAPTURED_DIGEST), /protocol/);
140
+ });
141
+
142
+ test("captured v5 STATUS preserves its provider forecast and collect binding", () => {
143
+ const decoded = decodeReviewStatusV3(currentStatusFixture("status-v5.captured.json"));
144
+ assert.equal(decoded.forecast?.horizon, "partial");
145
+ assert.equal(decoded.nextTransition?.kind, "collect");
146
+ assert.equal(decoded.nextTransition?.collect?.inputs[0]?.captureOperation, "external.select_base_ref");
147
+ });
148
+
149
+ test("v3 status identity rejects v5-only forecast fields", () => {
150
+ const body = currentStatusFixture("status-v5.captured.json");
151
+ body.schema = "gentle-ai.review-integration.status/v3";
152
+ delete body.receipt;
153
+ assert.throws(() => decodeReviewStatusV3(body), /forecast/);
154
+ });
155
+
156
+ test("captured v5 STATUS retains its opaque repository context reference", () => {
157
+ const decoded = decodeReviewStatusV3(currentStatusFixture("status-v5-repository-context.captured.json"));
158
+ assert.match(decoded.repositoryContext?.handle ?? "", /^rctx1_[0-9a-f]{64}$/);
159
+ assert.equal(decoded.repositoryContext?.targetIdentity, decoded.targetIdentity);
160
+ });
161
+
162
+ test("the singular v5 capture-result submission normalizes to one typed value", () => {
163
+ const decoded = decodeReviewStatusV3(currentStatusFixture("status-v5-capture-result-submission.captured.json"));
164
+ const input = decoded.nextTransition?.collect?.inputs[0];
165
+ assert.equal(input?.captureOperation, "review.capture-result");
166
+ assert.deepEqual(input?.submission?.values, [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/reviewer/v1", substitutionLocation: 7 }]);
167
+ });
168
+
169
+ test("the singular capture-result form rejects competing legacy values", () => {
170
+ const body = currentStatusFixture("status-v5-capture-result-submission.captured.json");
171
+ const input = (((body.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs as Record<string, unknown>[])[0]!;
172
+ const submission = input.submission as Record<string, unknown>;
173
+ submission.values = [submission.value];
174
+ assert.throws(() => decodeReviewStatusV3(body), /value/);
175
+ });
176
+
177
+ test("consent/v3 accepts the Pi runtime only when its exact agent binding matches", () => {
178
+ const captured = fixture(DEV_FIXTURES, "consent-v3.captured.json") as Record<string, unknown>;
179
+ const piConsent = decodeReviewConsentV3({ ...captured, agent: "pi" }, "pi");
180
+ assert.equal(piConsent.agent, "pi");
181
+ assert.throws(() => decodeReviewConsentV3(captured, "pi"), /consent\.agent/);
182
+ });
183
+
184
+ test("consent identities remain cross-decoder incompatible", () => {
185
+ const v3 = fixture(DEV_FIXTURES, "consent-v3.captured.json");
186
+ const v2 = fixture(V2_FIXTURES, "consent.fixture.json");
187
+ assert.throws(() => decodeReviewConsentV2(v3), /agent|schema/);
188
+ assert.throws(() => decodeReviewConsentV3(v2), /agent|schema/);
189
+ });
190
+
191
+ test("captured granted START keeps its repository-context event binding", () => {
192
+ const start = decodeReviewStartV3(fixture(DEV_FIXTURES, "start-v3-consent-granted.captured.json"));
193
+ assert.equal(start.state, "reviewing");
194
+ assert.match(start.repositoryContext?.eventId ?? "", /^sha256:[0-9a-f]{64}$/);
195
+ });
196
+
197
+ test("result artifacts retain mutually exclusive reference and path locators", () => {
198
+ const reference = decodeReviewResultArtifactV2(fixture(DEV_FIXTURES, "result-artifact-v2.captured.json"));
199
+ const path = decodeReviewResultArtifactV2(fixture(DEV_FIXTURES, "result-artifact-v2-path.captured.json"));
200
+ assert.match(reference.reference ?? "", /^rart1_[0-9a-f]{64}$/);
201
+ assert.equal(reference.path, undefined);
202
+ assert.equal(path.reference, undefined);
203
+ assert.equal(path.sha256, reference.sha256);
204
+ });
205
+
206
+ test("result artifacts reject a second locator", () => {
207
+ const body = structuredClone(fixture(DEV_FIXTURES, "result-artifact-v2.captured.json")) as Record<string, unknown>;
208
+ body.path = "/store/reviewer-results/00-review-reliability.json";
209
+ assert.throws(() => decodeReviewResultArtifactV2(body), /exactly one/);
210
+ });
211
+
212
+ test("nonuniform role capture closures retain their exact upstream operation identities", () => {
213
+ assert.equal(decodeReviewLastEventClosureV1(fixture(DEV_FIXTURES, "last-event-capture-refuter-approved.captured.json")).operation, "review.capture-refuter");
214
+ assert.equal(decodeReviewLastEventClosureV1(fixture(DEV_FIXTURES, "last-event-capture-validation-approved.captured.json")).operation, "review/capture-validation");
111
215
  });
112
216
 
113
217
  test("the derived capabilities/v2.1 payload decodes with protocol minor 1", () => {
114
- const capabilities = decodeReviewCapabilitiesV2(fixture("capabilities-v2.1.derived.json"), capturedDigest);
218
+ const capabilities = decodeReviewCapabilitiesV2(fixture(DEV_FIXTURES, "capabilities-v2.1.derived.json"), CAPTURED_DIGEST);
115
219
  assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery");
116
220
  assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v3"), true);
117
221
  assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v3"), true);
118
222
  });
119
223
 
120
- test("the pinned capabilities/v2 fixture still decodes unchanged", () => {
121
- const capabilities = decodeReviewCapabilitiesV2(v2Fixture("capabilities.fixture.json"), pinnedFixtureDigest);
122
- assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v3"), true);
123
- assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v2"), true);
124
- });
125
-
126
- test("capabilities schema identity and protocol minor must agree", () => {
127
- const base = fixture<JsonObject>("capabilities-v2.2.captured.json");
128
- const wrongMinor = clone(base);
129
- (wrongMinor.protocol as JsonObject).minor = 1;
130
- assert.throws(() => decodeReviewCapabilitiesV2(wrongMinor, capturedDigest), /protocol/);
131
- const wrongSchema = clone(base);
132
- wrongSchema.schema = "gentle-ai.review-integration.capabilities/v2.3";
133
- assert.throws(() => decodeReviewCapabilitiesV2(wrongSchema, capturedDigest), /schema/);
134
- // The old identity never inherits the new advertisements: a v2 envelope
135
- // advertising the v2.2 schema surface is missing its own required floor.
136
- const crossed = clone(base);
137
- crossed.schema = "gentle-ai.review-integration.capabilities/v2";
138
- (crossed.protocol as JsonObject).minor = 0;
139
- assert.throws(() => decodeReviewCapabilitiesV2(crossed, capturedDigest), /schemas/);
140
- });
141
-
142
- test("the captured status/v5 payload decodes with its forecast and base-ref collect input", () => {
143
- const status = decodeReviewStatusV3(fixture("status-v5.captured.json"));
144
- assert.equal(status.applicability, "unrelated");
145
- assert.equal(status.action, "start");
146
- assert.equal(status.forecast?.horizon, "partial");
147
- assert.deepEqual(status.forecast?.steps, [{ step: 1, kind: "collect", reasonCode: "empty_candidate_base_ref_required", description: "empty candidate base ref required" }]);
148
- assert.equal(status.nextTransition?.kind, "collect");
149
- assert.equal(status.nextTransition?.collect?.inputs[0]?.captureOperation, "external.select_base_ref");
150
- });
151
-
152
- test("the pinned status/v3 fixture still decodes unchanged", () => {
153
- const status = decodeReviewStatusV3(v2Fixture("status.fixture.json"));
154
- assert.equal(status.contract, "gentle-ai.review-integration/v2");
155
- });
156
-
157
- test("the captured status/v5 payload decodes its top-level repository context reference", () => {
158
- const status = decodeReviewStatusV3(fixture("status-v5-repository-context.captured.json"));
159
- assert.equal(status.authority?.state, "reviewing");
160
- assert.equal(status.repositoryContext?.capability, "review.opaque_repository_context");
161
- assert.match(status.repositoryContext?.handle ?? "", /^rctx1_[0-9a-f]{64}$/);
162
- assert.equal(status.repositoryContext?.revision, status.authority?.revision);
163
- assert.equal(status.repositoryContext?.targetIdentity, status.targetIdentity);
164
- assert.equal(status.repositoryContext?.outcome, "applied");
165
- });
166
-
167
224
  test("a status/v3 payload never carries a top-level repository context", () => {
168
- const v5 = fixture<JsonObject>("status-v5-repository-context.captured.json");
169
- const downgraded = clone(v5);
170
- downgraded.schema = "gentle-ai.review-integration.status/v3";
171
- delete downgraded.forecast; // forecast is rejected first; isolate the field under test
172
- assert.throws(() => decodeReviewStatusV3(downgraded), /repository_context/);
173
- });
174
-
175
- test("a status/v3 payload never carries v5 fields", () => {
176
- const v5 = fixture<JsonObject>("status-v5.captured.json");
177
- const downgraded = clone(v5);
178
- downgraded.schema = "gentle-ai.review-integration.status/v3";
179
- // forecast is a v5 field; the v3 identity must keep rejecting it.
180
- assert.throws(() => decodeReviewStatusV3(downgraded), /forecast/);
181
- const withoutForecast = clone(downgraded);
182
- delete withoutForecast.forecast;
183
- const status = decodeReviewStatusV3(withoutForecast);
184
- assert.equal(status.forecast, undefined);
225
+ const body = currentStatusFixture("status-v5-repository-context.captured.json");
226
+ body.schema = "gentle-ai.review-integration.status/v3";
227
+ delete body.receipt;
228
+ delete body.forecast;
229
+ assert.throws(() => decodeReviewStatusV3(body), /repository_context/);
185
230
  });
186
231
 
187
232
  test("a v5 forecast requires the next transition and a coherent horizon", () => {
188
- const base = fixture<JsonObject>("status-v5.captured.json");
189
- const orphanForecast = clone(base);
233
+ const orphanForecast = currentStatusFixture("status-v5.captured.json");
190
234
  delete orphanForecast.next_transition;
191
235
  assert.throws(() => decodeReviewStatusV3(orphanForecast), /forecast/);
192
- const wrongHorizon = clone(base);
236
+ const wrongHorizon = currentStatusFixture("status-v5.captured.json");
193
237
  (wrongHorizon.forecast as JsonObject).horizon = "terminal";
194
238
  assert.throws(() => decodeReviewStatusV3(wrongHorizon), /horizon/);
195
- const badStep = clone(base);
239
+ const badStep = currentStatusFixture("status-v5.captured.json");
196
240
  ((badStep.forecast as JsonObject).steps as JsonObject[])[0]!.step = 2;
197
241
  assert.throws(() => decodeReviewStatusV3(badStep), /step/);
198
- const extraKey = clone(base);
199
- (extraKey.forecast as JsonObject).unadvertised = true;
200
- assert.throws(() => decodeReviewStatusV3(extraKey), /not allowed/);
201
- });
202
-
203
- // --- v5-only next_transition surfaces, constructed per the vendored schemas ---
204
-
205
- function v5StatusWith(nextTransition: JsonObject, forecast?: JsonObject): JsonObject {
206
- const base = fixture<JsonObject>("status-v5.captured.json");
207
- base.next_transition = nextTransition;
208
- if (forecast === undefined) delete base.forecast;
209
- else base.forecast = forecast;
210
- return base;
211
- }
212
-
213
- const correctionPlanRequest: JsonObject = {
214
- schema: "gentle-ai.review-correction-plan-request/v1",
215
- request_hash: sha("a"),
216
- lineage_id: "review-1d5aadacc600e167",
217
- expected_revision: sha("b"),
218
- target_identity: sha("c"),
219
- correction_budget: 25,
220
- fix_finding_ids: ["risk-1"],
221
- findings: [{
222
- id: "risk-1",
223
- lens: "risk",
224
- location: "app.ts:10",
225
- severity: "BLOCKER",
226
- claim: "candidate-introduced credential exposure",
227
- proof_refs: ["app.ts:10-14"],
228
- evidence: "the candidate logs the raw token",
229
- evidence_class: "deterministic",
230
- causal_disposition: "introduced",
231
- }],
232
- };
233
-
234
- test("a v5 correction_plan_required transition carries its correction request and plan submission", () => {
235
- const transition: JsonObject = {
236
- kind: "collect",
237
- reason_code: "correction_plan_required",
238
- correction_request: correctionPlanRequest,
239
- collect: {
240
- inputs: [{
241
- name: "correction_plan",
242
- schema: "gentle-ai.review-correction-plan/v1",
243
- capture_operation: "external.plan_correction",
244
- arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }],
245
- submission: {
246
- operation_token: "finalize",
247
- argument_tokens: [
248
- "--contract=gentle-ai.review-integration/v2",
249
- "--lineage=review-1d5aadacc600e167",
250
- `--expected-revision=${sha("b")}`,
251
- `--target=${sha("c")}`,
252
- `--request-hash=${sha("a")}`,
253
- `--repository-context=rctx1_${"0".repeat(64)}`,
254
- "--correction-lines={{value}}",
255
- ],
256
- value: { slot: "correction_lines", domain: "positive_correction_lines", minimum: 1, maximum: 25, substitution_location: 6 },
257
- },
258
- }],
259
- },
260
- };
261
- const status = decodeReviewStatusV3(v5StatusWith(transition));
262
- assert.equal(status.nextTransition?.reasonCode, "correction_plan_required");
263
- assert.equal(status.nextTransition?.correctionRequest?.correctionBudget, 25);
264
- assert.equal(status.nextTransition?.collect?.inputs[0]?.submissionDescriptor?.operationToken, "finalize");
265
- // The same reason code without its correction request is incomplete.
266
- const missing = clone(transition);
267
- delete missing.correction_request;
268
- assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /correction_request/);
269
- // And correction_request never rides an unrelated reason code.
270
- const unrelated = clone(transition);
271
- unrelated.reason_code = "verification_evidence_required";
272
- assert.throws(() => decodeReviewStatusV3(v5StatusWith(unrelated)), /correction_request/);
273
- });
274
-
275
- test("a v5 capture-evidence transition decodes its evidence submission descriptor", () => {
276
- const transition: JsonObject = {
277
- kind: "collect",
278
- reason_code: "verification_evidence_required",
279
- collect: {
280
- inputs: [{
281
- name: "verification_evidence",
282
- schema: "https://gentle-ai.dev/schema/review/verification-evidence/v1",
283
- capture_operation: "review.capture-evidence",
284
- arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }],
285
- submission: {
286
- operation_token: "capture-evidence",
287
- argument_tokens: [
288
- "--lineage=review-1d5aadacc600e167",
289
- `--expected-revision=${sha("b")}`,
290
- `--target=${sha("c")}`,
291
- `--repository-context=rctx1_${"0".repeat(64)}`,
292
- "--outcome={{outcome}}",
293
- "--input={{input}}",
294
- ],
295
- values: [
296
- { slot: "outcome", domain: "verification_outcome", allowed_values: ["passed", "verification_failed", "procedural_tooling_failed"], substitution_location: 4 },
297
- { slot: "input", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/verification-evidence/v1", substitution_location: 5 },
298
- ],
299
- },
300
- }],
301
- },
302
- };
303
- const status = decodeReviewStatusV3(v5StatusWith(transition));
304
- const input = status.nextTransition?.collect?.inputs[0];
305
- assert.equal(input?.submissionDescriptor?.operationToken, "capture-evidence");
306
- assert.equal(input?.submission, undefined);
307
- // Missing its required submission descriptor is incomplete.
308
- const missing = clone(transition);
309
- delete ((missing.collect as JsonObject).inputs as JsonObject[])[0]!.submission;
310
- assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /submission/);
311
242
  });
312
243
 
313
244
  test("a v5 provider role task input decodes and is confined to external.run_provider_role", () => {
314
- const providerTask = { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" };
315
- const transition: JsonObject = {
316
- kind: "collect",
317
- reason_code: "refuter_batch_required",
318
- collect: {
319
- inputs: [{
320
- name: "refuter_batch",
321
- schema: "https://gentle-ai.dev/schema/review/refuter/v1",
322
- capture_operation: "external.run_provider_role",
323
- arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }],
324
- provider_task: providerTask,
325
- }],
326
- },
327
- };
328
- const status = decodeReviewStatusV3(v5StatusWith(transition));
329
- assert.deepEqual(status.nextTransition?.collect?.inputs[0]?.providerTask, { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" });
330
- // The task is required on its own vector and forbidden anywhere else.
331
- const missing = clone(transition);
332
- delete ((missing.collect as JsonObject).inputs as JsonObject[])[0]!.provider_task;
333
- assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /provider_task/);
334
- const misplaced = fixture<JsonObject>("status-v5.captured.json");
335
- (((misplaced.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!.provider_task = providerTask;
336
- assert.throws(() => decodeReviewStatusV3(misplaced), /provider_task/);
337
- });
338
-
339
- // --- v5 pi materialize capture-result submission: the singular `value` form ---
340
-
341
- test("the captured v5 materialize capture-result submission decodes its singular value form", () => {
342
- const status = decodeReviewStatusV3(fixture("status-v5-capture-result-submission.captured.json"));
343
- assert.equal(status.authority?.state, "reviewing");
344
- assert.equal(status.nextTransition?.reasonCode, "reviewer_results_required");
345
- const input = status.nextTransition?.collect?.inputs[0];
346
- assert.equal(input?.captureOperation, "review.capture-result");
347
- assert.equal(input?.submissionDescriptor, undefined);
348
- assert.equal(input?.submission?.operationToken, "capture-result");
349
- // The singular wire `value` normalizes into the typed one-entry values
350
- // array the host relay already consumes.
351
- assert.deepEqual(input?.submission?.values, [{
352
- slot: "reviewer_result",
353
- domain: "artifact_path_or_stdin",
354
- schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
355
- substitutionLocation: 7,
356
- }]);
357
- });
358
-
359
- test("the v5 singular capture-result value form is closed to its captured shape", () => {
360
- const base = fixture<JsonObject>("status-v5-capture-result-submission.captured.json");
361
- const withSubmission = (mutate: (submission: JsonObject) => void): JsonObject => {
362
- const body = clone(base);
363
- mutate((((body.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!.submission as JsonObject);
364
- return body;
365
- };
366
- // Carrying both wire forms at once matches no captured shape.
367
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
368
- submission.values = [submission.value];
369
- })), /value/);
370
- // The singular form owns no numeric or enumerated domain: minimum,
371
- // maximum, and allowed_values belong to the finalize/capture-evidence
372
- // descriptor forms only.
373
- for (const [key, smuggled] of [["minimum", 1], ["maximum", 2], ["allowed_values", ["x"]]] as const) {
374
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
375
- (submission.value as JsonObject)[key] = smuggled;
376
- })), /not allowed/);
377
- }
378
- // The captured form always names its reviewer schema and slot.
379
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
380
- delete (submission.value as JsonObject).schema;
381
- })), /schema/);
382
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
383
- (submission.value as JsonObject).slot = "validation";
384
- })), /slot/);
385
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
386
- submission.operation_token = "finalize-relay";
387
- })), /operation_token/);
388
- // The legacy values-array rows stay exactly as they were: they never
389
- // learned the schema key the singular form carries.
390
- assert.throws(() => decodeReviewStatusV3(withSubmission((submission) => {
391
- const row = submission.value as JsonObject;
392
- delete submission.value;
393
- submission.values = [row];
394
- })), /not allowed/);
245
+ const body = currentStatusFixture("status-v5.captured.json");
246
+ const transition = body.next_transition as JsonObject;
247
+ const input = ((transition.collect as JsonObject).inputs as JsonObject[])[0]!;
248
+ input.name = "refuter_batch";
249
+ input.schema = "https://gentle-ai.dev/schema/review/refuter/v1";
250
+ input.capture_operation = "external.run_provider_role";
251
+ input.provider_task = { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" };
252
+ const decoded = decodeReviewStatusV3(body);
253
+ assert.deepEqual(decoded.nextTransition?.collect?.inputs[0]?.providerTask, { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" });
254
+ const missing = clone(body);
255
+ delete ((((missing.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!).provider_task;
256
+ assert.throws(() => decodeReviewStatusV3(missing), /provider_task/);
395
257
  });
396
258
 
397
259
  test("the v3 identity keeps rejecting the singular capture-result value form", () => {
398
- const body = clone(fixture<JsonObject>("status-v5-capture-result-submission.captured.json"));
260
+ const body = currentStatusFixture("status-v5-capture-result-submission.captured.json");
399
261
  body.schema = "gentle-ai.review-integration.status/v3";
262
+ delete body.receipt;
400
263
  delete body.forecast;
401
264
  delete body.repository_context;
402
265
  assert.throws(() => decodeReviewStatusV3(body), /values is required/);
403
266
  });
404
267
 
405
268
  test("the v3 next transition keeps rejecting every v5-only surface", () => {
406
- const v5 = fixture<JsonObject>("status-v5.captured.json");
407
- // Re-identify the captured envelope as v3 (drop the v5-only forecast) and
408
- // then try to smuggle each v5 surface through the old identity.
409
- const asV3 = (mutate: (transition: JsonObject) => void): JsonObject => {
410
- const body = clone(v5);
411
- body.schema = "gentle-ai.review-integration.status/v3";
412
- delete body.forecast;
413
- mutate(body.next_transition as JsonObject);
414
- return body;
415
- };
416
- assert.throws(() => decodeReviewStatusV3(asV3((transition) => { transition.correction_request = correctionPlanRequest; })), /not allowed|correction_request/);
417
- assert.throws(() => decodeReviewStatusV3(asV3((transition) => {
418
- (((transition.collect as JsonObject).inputs as JsonObject[])[0]!).provider_task = { agent: "review-refuter", role: "refuter", prompt: "x" };
419
- })), /not allowed|provider_task/);
420
- assert.throws(() => decodeReviewStatusV3(asV3((transition) => {
421
- (((transition.collect as JsonObject).inputs as JsonObject[])[0]!).submission = { operation_token: "finalize", argument_tokens: ["--correction-lines={{value}}"], value: { slot: "correction_lines", domain: "positive_correction_lines", minimum: 1, maximum: 25, substitution_location: 6 } };
422
- })), /submission|values/);
423
- });
424
-
425
- // --- consent/v3 — the negotiated v2.1+ consent question (adds `agent`) ---
426
-
427
- test("the captured consent/v3 envelope decodes with its fixed agent binding", () => {
428
- const consent = decodeReviewConsentV3(fixture("consent-v3.captured.json"));
429
- assert.equal(consent.schema, "gentle-ai.review-integration.consent/v3");
430
- assert.equal(consent.agent, "claude-code");
431
- assert.equal(consent.action, "consent_required");
432
- assert.equal(consent.blocking, true);
433
- assert.equal(consent.riskLevel, "high");
434
- assert.equal(consent.changedFiles, 2);
435
- assert.equal(consent.changedLines, 12);
436
- assert.equal(consent.choices[0].answer, "granted");
437
- assert.equal(consent.choices[1].answer, "declined");
438
- for (const choice of consent.choices) {
439
- assert.ok(choice.invocation.includes(` --target ${consent.targetIdentity} `));
269
+ const v5 = currentStatusFixture("status-v5.captured.json");
270
+ v5.schema = "gentle-ai.review-integration.status/v3";
271
+ delete v5.receipt;
272
+ delete v5.forecast;
273
+ const transition = v5.next_transition as JsonObject;
274
+ transition.correction_request = { schema: "gentle-ai.review-correction-plan-request/v1", request_hash: sha("a") };
275
+ assert.throws(() => decodeReviewStatusV3(v5), /not allowed|correction_request/);
276
+ });
277
+
278
+ test("consent/v3 keeps every v2 semantic guard and rejects agents outside the fixed runtime contract", () => {
279
+ const base = fixture(DEV_FIXTURES, "consent-v3.captured.json") as JsonObject;
280
+ for (const agent of ["kilocode", "future-runtime", ""] as const) {
281
+ assert.throws(() => decodeReviewConsentV3({ ...clone(base), agent }), /agent/);
440
282
  }
441
- assert.equal(consent.offPath.command, "gentle-ai review mode disable");
442
- });
443
-
444
- test("consent identities never cross-decode", () => {
445
- const v3 = fixture<JsonObject>("consent-v3.captured.json");
446
- const v2 = v2Fixture<JsonObject>("consent.fixture.json");
447
- // The old identity never accepts the new surface, and vice versa.
448
- assert.throws(() => decodeReviewConsentV2(clone(v3)), /agent|schema/);
449
- assert.throws(() => decodeReviewConsentV3(clone(v2)), /agent|schema/);
450
- // A schema-swapped v3 body keeps its agent key, which the v2 identity
451
- // still rejects as an unadvertised surface.
452
- const downgraded = clone(v3);
453
- downgraded.schema = "gentle-ai.review-integration.consent/v2";
454
- assert.throws(() => decodeReviewConsentV2(downgraded), /agent/);
455
- // A schema-swapped v2 body is missing the agent key v3 requires.
456
- const upgraded = clone(v2);
457
- upgraded.schema = "gentle-ai.review-integration.consent/v3";
458
- assert.throws(() => decodeReviewConsentV3(upgraded), /agent/);
459
- });
460
-
461
- test("consent/v3 keeps every v2 semantic guard and pins its agent constant", () => {
462
- const base = fixture<JsonObject>("consent-v3.captured.json");
463
- const wrongAgent = clone(base);
464
- wrongAgent.agent = "opencode";
465
- assert.throws(() => decodeReviewConsentV3(wrongAgent), /agent/);
466
283
  const swapped = clone(base);
467
284
  swapped.choices = [...(swapped.choices as JsonObject[])].reverse();
468
285
  assert.throws(() => decodeReviewConsentV3(swapped), /answer/);
469
- const badInvocation = clone(base);
470
- ((badInvocation.choices as JsonObject[])[0]!).invocation = "gentle-ai review finalize --consent granted";
471
- assert.throws(() => decodeReviewConsentV3(badInvocation), /invocation/);
472
- const differentTarget = clone(base);
473
- differentTarget.target_identity = sha("d");
474
- assert.throws(() => decodeReviewConsentV3(differentTarget), /target|invocation/);
475
- const notBlocking = clone(base);
476
- notBlocking.blocking = false;
477
- assert.throws(() => decodeReviewConsentV3(notBlocking), /blocking/);
478
- const extraKey = clone(base);
479
- extraKey.unadvertised = true;
480
- assert.throws(() => decodeReviewConsentV3(extraKey), /not allowed/);
481
- });
482
-
483
- test("the pinned consent/v2 fixture still decodes unchanged", () => {
484
- const consent = decodeReviewConsentV2(v2Fixture("consent.fixture.json"));
485
- assert.equal(consent.schema, "gentle-ai.review-integration.consent/v2");
486
- assert.equal(consent.action, "consent_required");
487
- });
488
-
489
- // --- start/v3 — main extends repository_context with event_id/outcome ---
490
-
491
- test("the captured granted start/v3 decodes with its repository context event binding", () => {
492
- const start = decodeReviewStartV3(fixture("start-v3-consent-granted.captured.json"));
493
- assert.equal(start.lineageId, "review-377c60e10b852cfc");
494
- assert.equal(start.state, "reviewing");
495
- assert.equal(start.riskLevel, "high");
496
- assert.equal(start.selectedLenses.length, 4);
497
- assert.equal(start.correctionBudget, 6);
498
- assert.equal(start.repositoryContext?.outcome, "applied");
499
- assert.match(start.repositoryContext?.eventId ?? "", /^sha256:[0-9a-f]{64}$/);
286
+ const wrongTarget = clone(base);
287
+ wrongTarget.target_identity = sha("d");
288
+ assert.throws(() => decodeReviewConsentV3(wrongTarget), /target|invocation/);
500
289
  });
501
290
 
502
291
  test("start/v3 repository context event fields stay optional and exact", () => {
503
- // The pinned fixture carries neither field and still decodes unchanged.
504
- const pinned = decodeReviewStartV3(v2Fixture("start.fixture.json"));
292
+ const pinned = decodeReviewStartV3(fixture(V2_FIXTURES, "start.fixture.json"));
505
293
  assert.equal(pinned.repositoryContext?.eventId, undefined);
506
294
  assert.equal(pinned.repositoryContext?.outcome, undefined);
507
- const base = fixture<JsonObject>("start-v3-consent-granted.captured.json");
508
- const badEvent = clone(base);
295
+ const badEvent = clone(fixture(DEV_FIXTURES, "start-v3-consent-granted.captured.json") as JsonObject);
509
296
  (badEvent.repository_context as JsonObject).event_id = "not-a-digest";
510
297
  assert.throws(() => decodeReviewStartV3(badEvent), /event_id/);
511
- const badOutcome = clone(base);
298
+ const badOutcome = clone(fixture(DEV_FIXTURES, "start-v3-consent-granted.captured.json") as JsonObject);
512
299
  (badOutcome.repository_context as JsonObject).outcome = "unheard-of";
513
300
  assert.throws(() => decodeReviewStartV3(badOutcome), /outcome/);
514
- const extraKey = clone(base);
515
- (extraKey.repository_context as JsonObject).unadvertised = true;
516
- assert.throws(() => decodeReviewStartV3(extraKey), /not allowed/);
517
- });
518
-
519
- // --- result-artifact/v2: the `review capture-result` admission envelope ---
520
-
521
- test("the captured reference-form result artifact decodes with its rart1 locator", () => {
522
- const artifact = decodeReviewResultArtifactV2(fixture("result-artifact-v2.captured.json"));
523
- assert.equal(artifact.schema, "gentle-ai.review-result-artifact/v2");
524
- assert.equal(artifact.capability, "review.native_result_artifact");
525
- assert.match(artifact.reference ?? "", /^rart1_[0-9a-f]{64}$/);
526
- assert.equal(artifact.path, undefined);
527
- assert.equal(artifact.lineageId, "review-ceeb2b862bd39709");
528
- assert.equal(artifact.lens, "review-reliability");
529
- assert.equal(artifact.selectedOrder, 0);
530
- assert.equal(artifact.admissionDecision, "completed");
531
- assert.match(artifact.sha256, /^sha256:[0-9a-f]{64}$/);
532
- assert.match(artifact.subjectHash, /^sha256:[0-9a-f]{64}$/);
533
- assert.match(artifact.targetIdentity, /^sha256:[0-9a-f]{64}$/);
534
- });
535
-
536
- test("the captured path-form result artifact decodes with its provider-owned path", () => {
537
- const artifact = decodeReviewResultArtifactV2(fixture("result-artifact-v2-path.captured.json"));
538
- assert.equal(artifact.reference, undefined);
539
- assert.match(artifact.path ?? "", /reviewer-results\/00-review-reliability\.json$/);
540
- // Same admitted slot through both locator forms: every binding agrees.
541
- const reference = decodeReviewResultArtifactV2(fixture("result-artifact-v2.captured.json"));
542
- assert.equal(artifact.sha256, reference.sha256);
543
- assert.equal(artifact.subjectHash, reference.subjectHash);
544
- assert.equal(artifact.lineageId, reference.lineageId);
545
- assert.equal(artifact.targetIdentity, reference.targetIdentity);
546
- });
547
-
548
- test("a result artifact carries exactly one locator", () => {
549
- const base = fixture<JsonObject>("result-artifact-v2.captured.json");
550
- const both = clone(base);
551
- both.path = "/store/reviewer-results/00-review-reliability.json";
552
- assert.throws(() => decodeReviewResultArtifactV2(both), /exactly one/);
553
- const neither = clone(base);
554
- delete neither.reference;
555
- assert.throws(() => decodeReviewResultArtifactV2(neither), /exactly one/);
556
301
  });
557
302
 
558
303
  test("a result artifact rejects unknown keys and weakened bindings", () => {
559
- const base = fixture<JsonObject>("result-artifact-v2.captured.json");
304
+ const base = fixture(DEV_FIXTURES, "result-artifact-v2.captured.json") as JsonObject;
560
305
  const extra = clone(base);
561
306
  extra.unadvertised = true;
562
307
  assert.throws(() => decodeReviewResultArtifactV2(extra), /not allowed/);
563
308
  const wrongCapability = clone(base);
564
309
  wrongCapability.capability = "review.result_artifact";
565
310
  assert.throws(() => decodeReviewResultArtifactV2(wrongCapability), /capability/);
566
- const unadmitted = clone(base);
567
- unadmitted.admission_decision = "quarantined";
568
- assert.throws(() => decodeReviewResultArtifactV2(unadmitted), /admission_decision/);
569
311
  const foreignLocator = clone(base);
570
312
  foreignLocator.reference = `rref1_${"b".repeat(64)}`;
571
313
  assert.throws(() => decodeReviewResultArtifactV2(foreignLocator), /reference/);
572
- const outOfRange = clone(base);
573
- outOfRange.selected_order = 4;
574
- assert.throws(() => decodeReviewResultArtifactV2(outOfRange), /selected_order/);
575
- });
576
-
577
- test("result artifact identities never cross-decode", () => {
578
- // A prior identity rejects the new surface (it carries no negotiated
579
- // contract/operation identity pair at all)...
580
- assert.throws(() => decodeReviewOperationV2(fixture("result-artifact-v2.captured.json")), /contract|schema/);
581
- // ...the new decoder pins its own exact identity...
582
- const downgraded = clone(fixture<JsonObject>("result-artifact-v2.captured.json"));
583
- downgraded.schema = "gentle-ai.review-result-artifact/v1";
584
- assert.throws(() => decodeReviewResultArtifactV2(downgraded), /schema/);
585
- // ...and rejects prior surfaces wholesale.
586
- assert.throws(() => decodeReviewResultArtifactV2({
587
- schema: "gentle-ai.review-provider-role-capture/v1",
588
- lineage_id: "review-1d5aadacc600e167",
589
- target_identity: `sha256:${"9".repeat(64)}`,
590
- role: "targeted-validator",
591
- captured: true,
592
- }));
593
- assert.throws(() => decodeReviewResultArtifactV2(v2Fixture("status.fixture.json")));
594
- });
595
-
596
- // Fixture provenance:
597
- // - failure-v2-capture-evidence.captured.json was captured 2026-08-16 from a
598
- // binary built at gentle-ai commit a2d57117 (branch
599
- // fix/capture-evidence-typed-refusal, `go build ./cmd/gentle-ai`, version
600
- // banner "gentle-ai dev"), in a scratch git repository driven to the
601
- // correction evidence-pending state (medium start with consent granted,
602
- // one deterministic BLOCKER capture-result, rendered finalize transition,
603
- // rendered plan-correction submission, bounded fix edit), then a
604
- // deliberately misbound capture:
605
- // gentle-ai review capture-evidence --cwd <scratch> --lineage <lineage> \
606
- // --target <live workspace identity> --expected-revision <revision> \
607
- // --outcome passed --input <evidence file>
608
- // The live workspace identity instead of the slot's fix-diff --target is
609
- // the exact fambig misbinding shape; the branch answers it with the typed
610
- // failure/v2 envelope on stdout (operation review.capture-evidence, code
611
- // verification_evidence_binding_mismatch, mutation_outcome not_started)
612
- // instead of a bare stderr line.
613
- // - The other three capture-verb operations are constructed from the same
614
- // captured envelope, grounded in that branch's
615
- // contracts/review-integration/v2/schemas/failure.schema.json 12-value
616
- // operation enum; no cheap live flow provokes them individually.
617
- test("failure/v2 decodes the four collect-capture operations the typed-refusal branch emits", () => {
618
- const captured = JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "failure-v2-capture-evidence.captured.json"), "utf8")) as Record<string, unknown>;
619
- const decoded = decodeReviewFailureV2(captured);
620
- assert.equal(decoded.operation, "review.capture-evidence");
621
- assert.equal(decoded.code, "verification_evidence_binding_mismatch");
622
- assert.equal(decoded.mutationOutcome, "not_started");
623
- assert.equal(decoded.nextAction, "review.status");
624
- for (const operation of ["review.capture-result", "review.capture-refuter", "review.capture-validation"]) {
625
- assert.equal(decodeReviewFailureV2({ ...captured, operation }).operation, operation, operation);
314
+ });
315
+
316
+ function reviewingStartV4(action: "created" | "replayed" = "created"): JsonObject {
317
+ const body = clone(fixture(DEV_FIXTURES, "start-v3-consent-granted.captured.json") as JsonObject);
318
+ body.schema = "gentle-ai.review-integration.start/v4";
319
+ body.action = action;
320
+ const targetIdentity = (body.repository_context as JsonObject).target_identity as string;
321
+ const baseTree = body.base_tree as string;
322
+ body.next_transition = {
323
+ kind: "execute",
324
+ reason_code: "review_status_required",
325
+ execute: {
326
+ operation: "review.status",
327
+ arguments: [
328
+ { name: "contract", value: "gentle-ai.review-integration/v2", token: "--contract=gentle-ai.review-integration/v2" },
329
+ { name: "next-transition", value: "true", token: "--next-transition=true" },
330
+ { name: "lineage", value: body.lineage_id, token: `--lineage=${body.lineage_id}` },
331
+ { name: "agent", value: "pi", token: "--agent=pi" },
332
+ { name: "base-ref", value: baseTree, token: `--base-ref=${baseTree}` },
333
+ { name: "committed-only", value: "true", token: "--committed-only=true" },
334
+ ],
335
+ preconditions: [],
336
+ binding: { target_identity: targetIdentity },
337
+ },
338
+ };
339
+ const execute = (body.next_transition as JsonObject).execute as JsonObject;
340
+ execute.selector_arguments = (execute.arguments as JsonObject[]).slice(-2).map(clone);
341
+ return body;
342
+ }
343
+
344
+ test("capabilities/v2.3 retains status/v5 while v2.4 requires status/v6", () => {
345
+ const v23 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
346
+ v23.schema = "gentle-ai.review-integration.capabilities/v2.3";
347
+ (v23.protocol as JsonObject).minor = 3;
348
+ v23.schemas = (v23.schemas as string[]).map((schema) => schema
349
+ .replace("capabilities/v2.2", "capabilities/v2.3")
350
+ .replace("start/v3", "start/v4"));
351
+ // The v2.3 provider contract retired these three mandatory features; the
352
+ // pinned v2.5.0-rc.3 runtime no longer advertises them.
353
+ const features = v23.features as JsonObject;
354
+ features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
355
+ !["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
356
+ const decoded = decodeReviewCapabilitiesV2(v23, CAPTURED_DIGEST);
357
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.start/v4"), true);
358
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.start/v3"), false);
359
+ assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v5"), true);
360
+
361
+ const v24 = clone(v23);
362
+ v24.schema = "gentle-ai.review-integration.capabilities/v2.4";
363
+ (v24.protocol as JsonObject).minor = 4;
364
+ v24.schemas = [
365
+ ...(v24.schemas as string[]).map((schema) => schema
366
+ .replace("capabilities/v2.3", "capabilities/v2.4")
367
+ .replace("status/v5", "status/v6")),
368
+ "gentle-ai.review-intended-untracked-selection/v1",
369
+ ];
370
+ const v24Decoded = decodeReviewCapabilitiesV2(v24, CAPTURED_DIGEST);
371
+ assert.equal(v24Decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
372
+ assert.equal(v24Decoded.schemas.has("gentle-ai.review-integration.status/v5"), false);
373
+
374
+ const missingStatusV6 = clone(v24);
375
+ missingStatusV6.schemas = (missingStatusV6.schemas as string[]).map((schema) => schema.replace("status/v6", "status/v5"));
376
+ assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
377
+
378
+ // Distinguishing case for the v2.3 retired-schema filter: the real rc.3
379
+ // provider no longer advertises these three identities, so a v2.3
380
+ // advertisement WITHOUT them must still negotiate. A no-op filter would
381
+ // keep requiring them and fail the superset assertion here.
382
+ const retiredSchemas = ["gentle-ai.review-final-verification-incident/v1", "gentle-ai.review-receipt/v2", "gentle-ai.review-verification-evidence/v2"];
383
+ const retiredRemoved = clone(v23);
384
+ retiredRemoved.schemas = (retiredRemoved.schemas as string[]).filter((schema) => !retiredSchemas.includes(schema));
385
+ assert.equal((v23.schemas as string[]).length - (retiredRemoved.schemas as string[]).length, 3, "fixture must actually carry and then remove the three retired identities");
386
+ const relaxed = decodeReviewCapabilitiesV2(retiredRemoved, CAPTURED_DIGEST);
387
+ for (const schema of retiredSchemas) assert.equal(relaxed.schemas.has(schema), false);
388
+
389
+ // Earlier minors keep the full common requirement: the same removal under
390
+ // the v2.2 identity must fail closed.
391
+ const v22Retired = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
392
+ v22Retired.schemas = (v22Retired.schemas as string[]).filter((schema) => !retiredSchemas.includes(schema));
393
+ assert.throws(() => decodeReviewCapabilitiesV2(v22Retired, CAPTURED_DIGEST), /schema/);
394
+ });
395
+
396
+ test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
397
+ const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
398
+ const input = decoded.nextTransition?.collect?.inputs[0];
399
+ assert.deepEqual([input?.schema, input?.captureOperation, input?.arguments, input?.submission], [
400
+ "gentle-ai.review-intended-untracked-selection/v1", "external.select_intended_untracked", [
401
+ { name: "target_identity", value: decoded.targetIdentity }, { name: "projection", value: "workspace" },
402
+ { name: "base_tree", value: decoded.projection.baseTree }, { name: "candidate_tree", value: decoded.projection.currentCandidateTree },
403
+ { name: "eligible_paths_json", value: '["docs/selected.md"]' }, { name: "expected_untracked_inventory", value: sha("e") },
404
+ ], {
405
+ operationToken: "status",
406
+ argumentTokens: ["--contract=gentle-ai.review-integration/v2", "--next-transition=true", "--agent=pi", "--projection=workspace", "--intended-untracked-selection={{value}}"],
407
+ values: [{ slot: "intended_untracked_selection", domain: "schema_bound_json", schema: "gentle-ai.review-intended-untracked-selection/v1", substitutionLocation: 4 }],
408
+ },
409
+ ]);
410
+ const submission = (body: JsonObject) => (((body.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!.submission as JsonObject;
411
+ const cases: Array<[string, (value: JsonObject) => void, RegExp]> = [
412
+ ["operation", (value) => { value.operation_token = "start"; }, /operation/],
413
+ ["competing values", (value) => { value.values = [clone(value.value)]; }, /value/],
414
+ ["slot", (value) => { delete (value.value as JsonObject).slot; }, /slot/],
415
+ ["schema", (value) => { (value.value as JsonObject).schema = "gentle-ai.review-intended-untracked-selection/v2"; }, /schema/],
416
+ ["placeholder", (value) => { (value.argument_tokens as string[])[4] = "--intended-untracked-selection={{selection}}"; }, /value|substitution/],
417
+ ["location", (value) => { (value.value as JsonObject).substitution_location = 3; }, /substitution/],
418
+ ];
419
+ for (const [name, mutate, pattern] of cases) {
420
+ const body = initialIntendedUntrackedStatusV6();
421
+ mutate(submission(body));
422
+ assert.throws(() => decodeReviewStatusV3(body), pattern, name);
423
+ }
424
+ const v5 = initialIntendedUntrackedStatusV6();
425
+ v5.schema = "gentle-ai.review-integration.status/v5";
426
+ assert.throws(() => decodeReviewStatusV3(v5), /submission/);
427
+
428
+ const collectInput = (body: JsonObject) => (((body.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!;
429
+ const artifactSubject = { schema: "gentle-ai.review-artifact-subject/v2", subject_hash: sha("a"), lineage_id: "review-test", authority_revision: sha("b"), target_identity: sha("c"), base_tree: "a".repeat(40), candidate_tree: "b".repeat(40), changed_path_manifest_sha256: sha("d"), lens: "review-risk", selected_order: 0 };
430
+ const incompatibleCaptures: ReadonlyArray<readonly [string, JsonObject]> = [
431
+ ["review.capture-result", { schema: "https://gentle-ai.dev/schema/review/reviewer/v1", artifact_subject: artifactSubject, base_tree: "a".repeat(40), candidate_tree: "b".repeat(40), changed_path_manifest: [{ path: "x.ts", status: "M", old_mode: "100644", new_mode: "100644", deleted: false, type_changed: false, mode_only: false, intended_untracked: false }] }],
432
+ ["review.capture-correction-plan", { schema: "gentle-ai.review-correction-plan/v1" }],
433
+ ];
434
+ for (const [captureOperation, fields] of incompatibleCaptures) {
435
+ const body = initialIntendedUntrackedStatusV6();
436
+ Object.assign(collectInput(body), { capture_operation: captureOperation, ...fields });
437
+ assert.throws(() => decodeReviewStatusV3(body), /operation_token/, captureOperation);
438
+ }
439
+
440
+ const mismatchedBindings: ReadonlyArray<readonly [string, string]> = [
441
+ ["name", "renamed_selection"],
442
+ ["schema", "gentle-ai.review-intended-untracked-selection/v0"],
443
+ ];
444
+ for (const [field, value] of mismatchedBindings) {
445
+ const body = initialIntendedUntrackedStatusV6();
446
+ collectInput(body)[field] = value;
447
+ assert.throws(() => decodeReviewStatusV3(body), /intended_untracked_selection binding/, field);
448
+ }
449
+ });
450
+
451
+ test("START/v4 accepts only its reviewing status continuation and preserves v3 strictness", () => {
452
+ for (const action of ["created", "replayed"] as const) {
453
+ const start = reviewingStartV4(action);
454
+ const decoded = decodeReviewStartV4(start);
455
+ assert.equal(decoded.nextTransition?.execute?.operation, "review.status");
456
+ assert.deepEqual(decoded.nextTransition?.execute?.arguments, [
457
+ { name: "contract", value: "gentle-ai.review-integration/v2", token: "--contract=gentle-ai.review-integration/v2" },
458
+ { name: "next-transition", value: "true", token: "--next-transition=true" },
459
+ { name: "lineage", value: decoded.lineageId, token: `--lineage=${decoded.lineageId}` },
460
+ { name: "agent", value: "pi", token: "--agent=pi" },
461
+ { name: "base-ref", value: decoded.baseTree, token: `--base-ref=${decoded.baseTree}` },
462
+ { name: "committed-only", value: "true", token: "--committed-only=true" },
463
+ ]);
464
+ assert.deepEqual(decoded.nextTransition?.execute?.selectorArguments, [
465
+ { name: "base-ref", value: decoded.baseTree, token: `--base-ref=${decoded.baseTree}` },
466
+ { name: "committed-only", value: "true", token: "--committed-only=true" },
467
+ ]);
468
+ }
469
+
470
+ const v3 = reviewingStartV4();
471
+ v3.schema = "gentle-ai.review-integration.start/v3";
472
+ assert.throws(() => decodeReviewStartV3(v3), /next_transition is not allowed/);
473
+
474
+ const wrongOperation = reviewingStartV4();
475
+ ((wrongOperation.next_transition as JsonObject).execute as JsonObject).operation = "review.start";
476
+ assert.throws(() => decodeReviewStartV4(wrongOperation), /review.status/);
477
+ });
478
+
479
+ test("START/v4 falls back to the frozen overlay target when repository_context is absent", () => {
480
+ // A replayed reviewing START must not carry repository_context, so the
481
+ // binding assert has to derive the frozen target from the overlay identity.
482
+ const start = reviewingStartV4("replayed");
483
+ const execute = (start.next_transition as JsonObject).execute as JsonObject;
484
+ const targetIdentity = (start.repository_context as JsonObject).target_identity as string;
485
+ delete start.repository_context;
486
+ start.target_mode = "base-workspace-overlay";
487
+ start.target_identity = targetIdentity;
488
+ const arguments_ = execute.arguments as JsonObject[];
489
+ const committedOnly = arguments_.find((argument) => argument.name === "committed-only")!;
490
+ committedOnly.name = "workspace-overlay";
491
+ committedOnly.token = "--workspace-overlay=true";
492
+ execute.selector_arguments = arguments_.slice(-2).map(clone);
493
+ const decoded = decodeReviewStartV4(start);
494
+ assert.equal(decoded.targetIdentity, targetIdentity);
495
+ assert.equal(decoded.repositoryContext, undefined);
496
+ assert.equal(decoded.nextTransition?.execute?.binding.targetIdentity, targetIdentity);
497
+
498
+ const unbound = reviewingStartV4("replayed");
499
+ delete unbound.repository_context;
500
+ assert.throws(() => decodeReviewStartV4(unbound), /repository_context is required/);
501
+ });
502
+
503
+ test("START/v4 rejects status vectors that drift from frozen bindings", () => {
504
+ const bindingTarget = reviewingStartV4();
505
+ (((bindingTarget.next_transition as JsonObject).execute as JsonObject).binding as JsonObject).target_identity = sha("d");
506
+ assert.throws(() => decodeReviewStartV4(bindingTarget), /binding/);
507
+
508
+ for (const [name, value] of [["lineage", "review-other"], ["base-ref", "a".repeat(40)]] as const) {
509
+ const start = reviewingStartV4();
510
+ const arguments_ = ((start.next_transition as JsonObject).execute as JsonObject).arguments as JsonObject[];
511
+ arguments_.find((argument) => argument.name === name)!.value = value;
512
+ assert.throws(() => decodeReviewStartV4(start), /binding/);
513
+ }
514
+
515
+ for (const [name, value, token] of [["projection", "staged", "--projection=staged"], ["target", sha("d"), `--target=${sha("d")}`]] as const) {
516
+ const start = reviewingStartV4();
517
+ const arguments_ = ((start.next_transition as JsonObject).execute as JsonObject).arguments as JsonObject[];
518
+ arguments_.push({ name, value, token });
519
+ assert.throws(() => decodeReviewStartV4(start), /binding/);
520
+ }
521
+
522
+ const bindingLineage = reviewingStartV4();
523
+ (((bindingLineage.next_transition as JsonObject).execute as JsonObject).binding as JsonObject).lineage_id = "review-other";
524
+ assert.throws(() => decodeReviewStartV4(bindingLineage), /binding/);
525
+
526
+ const outerTarget = reviewingStartV4();
527
+ outerTarget.target_mode = "base-workspace-overlay";
528
+ outerTarget.target_identity = sha("d");
529
+ assert.throws(() => decodeReviewStartV4(outerTarget), /binding/);
530
+
531
+ const artifactTarget = reviewingStartV4();
532
+ (artifactTarget.artifact_subjects as JsonObject[])[0]!.target_identity = sha("d");
533
+ assert.throws(() => decodeReviewStartV4(artifactTarget), /binding/);
534
+
535
+ const duplicate = reviewingStartV4();
536
+ const arguments_ = ((duplicate.next_transition as JsonObject).execute as JsonObject).arguments as JsonObject[];
537
+ arguments_.push(clone(arguments_.find((argument) => argument.name === "lineage")!));
538
+ assert.throws(() => decodeReviewStartV4(duplicate), /binding/);
539
+ });
540
+
541
+ test("START/v4 rejects incomplete or incoherent provider-owned status selectors", () => {
542
+ const execute = (start: JsonObject) => (start.next_transition as JsonObject).execute as JsonObject;
543
+ const rows = (start: JsonObject, field: "arguments" | "selector_arguments") => execute(start)[field] as JsonObject[];
544
+ for (const change of [
545
+ (start: JsonObject) => delete execute(start).selector_arguments,
546
+ (start: JsonObject) => rows(start, "selector_arguments").splice(1, 1),
547
+ (start: JsonObject) => {
548
+ for (const field of ["arguments", "selector_arguments"] as const) rows(start, field).find((row) => row.name === "committed-only")!.value = "false";
549
+ },
550
+ (start: JsonObject) => rows(start, "selector_arguments")[1]!.token = "--committed-only",
551
+ ]) {
552
+ const start = reviewingStartV4();
553
+ change(start);
554
+ assert.throws(() => decodeReviewStartV4(start), /selector|binding/);
555
+ }
556
+ for (const [name, value] of [["contract", "other"], ["next-transition", "false"], ["lineage", "review-other"], ["agent", "other"]] as const) {
557
+ const missing = reviewingStartV4();
558
+ rows(missing, "arguments").splice(rows(missing, "arguments").findIndex((row) => row.name === name), 1);
559
+ assert.throws(() => decodeReviewStartV4(missing), /binding/);
560
+ const wrong = reviewingStartV4();
561
+ rows(wrong, "arguments").find((row) => row.name === name)!.value = value;
562
+ assert.throws(() => decodeReviewStartV4(wrong), /binding/);
563
+ }
564
+ const controlToken = reviewingStartV4(); rows(controlToken, "arguments")[0]!.token = "--contract=other";
565
+ assert.throws(() => decodeReviewStartV4(controlToken), /binding/);
566
+ });
567
+
568
+ test("START/v4 closed approval keeps acknowledgement but forbids a continuation", () => {
569
+ const closed = clone(fixture(DEV_FIXTURES, "start-v3-zero-lens-closed.captured.json") as JsonObject);
570
+ closed.schema = "gentle-ai.review-integration.start/v4";
571
+ closed.acknowledgement = { provider_owned: true };
572
+ assert.deepEqual(decodeReviewStartV4(closed).raw.acknowledgement, { provider_owned: true });
573
+
574
+ closed.next_transition = (reviewingStartV4().next_transition as JsonObject);
575
+ assert.throws(() => decodeReviewStartV4(closed), /closed approved zero-lens START cannot carry next_transition/);
576
+ });
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // review-acknowledged/v1 — gentle-ai #3947: the exact acknowledgement burn
580
+ // prints one typed envelope instead of nothing. Provenance:
581
+ // tests/fixtures/devbinary/review-acknowledged.provenance.md.
582
+ // ---------------------------------------------------------------------------
583
+
584
+ const ACKNOWLEDGED_FIXTURE = "review-acknowledged-v1.captured.json";
585
+
586
+ function acknowledgedFixture(): JsonObject {
587
+ return clone(fixture(DEV_FIXTURES, ACKNOWLEDGED_FIXTURE) as JsonObject);
588
+ }
589
+
590
+ test("review-acknowledged/v1 decodes the captured burn envelope exactly", () => {
591
+ const raw = acknowledgedFixture();
592
+ const decoded = decodeReviewAcknowledgedV1(raw);
593
+ assert.deepEqual(decoded, {
594
+ schema: "gentle-ai.review-acknowledged/v1",
595
+ operation: "review/acknowledge-approved",
596
+ action: "acknowledged",
597
+ lineageId: "review-3ec95251db75f626",
598
+ targetIdentity: "sha256:b505dcd8d82395c053c9786935e11e0e235cbdecca4f1f46f98c768ea6248d3d",
599
+ consumedRevision: "sha256:9732b1c3526bfecd3851093239241145c970cc126acea59bfaf14133214b60ee",
600
+ authority: "burned",
601
+ raw,
602
+ });
603
+ assert.equal(decodeReviewAcknowledgedV1(raw, { lineageId: decoded.lineageId, targetIdentity: decoded.targetIdentity, revision: decoded.consumedRevision }).authority, "burned");
604
+ assert.equal(REVIEW_ACKNOWLEDGED_SCHEMA, "gentle-ai.review-acknowledged/v1");
605
+ });
606
+
607
+ test("review-acknowledged/v1 rejects identity drift, foreign fields, and a binding that names another burn", () => {
608
+ const cases: Array<[string, (body: JsonObject) => void, RegExp]> = [
609
+ ["schema", (body) => { body.schema = "gentle-ai.review-acknowledged/v2"; }, /schema/],
610
+ ["operation", (body) => { body.operation = "review.acknowledge-approved"; }, /operation/],
611
+ ["action", (body) => { body.action = "replayed"; }, /action/],
612
+ ["authority", (body) => { body.authority = "retained"; }, /authority/],
613
+ ["unknown key", (body) => { body.receipt = { status: "created" }; }, /receipt/],
614
+ ["missing consumed revision", (body) => { delete body.consumed_revision; }, /consumed_revision/],
615
+ ["malformed target", (body) => { body.target_identity = "b505dcd8"; }, /target_identity/],
616
+ ["malformed lineage", (body) => { body.lineage_id = "Review_3ec95251"; }, /lineage_id/],
617
+ ];
618
+ for (const [name, mutate, pattern] of cases) {
619
+ const body = acknowledgedFixture();
620
+ mutate(body);
621
+ assert.throws(() => decodeReviewAcknowledgedV1(body), pattern, name);
622
+ }
623
+ const raw = acknowledgedFixture();
624
+ assert.throws(() => decodeReviewAcknowledgedV1(raw, { lineageId: "review-other" }), /lineage/);
625
+ assert.throws(() => decodeReviewAcknowledgedV1(raw, { targetIdentity: sha("b") }), /target/);
626
+ assert.throws(() => decodeReviewAcknowledgedV1(raw, { revision: sha("c") }), /revision/);
627
+ assert.throws(() => decodeReviewAcknowledgedV1(null), /object/);
628
+ assert.throws(() => decodeReviewAcknowledgedV1("{}"), /object/);
629
+ });
630
+
631
+ test("review-acknowledged/v1 is disjoint from every prior captured identity in both directions", () => {
632
+ const priorFixtures = readdirSync(DEV_FIXTURES).filter((name) => name.endsWith(".json") && name !== ACKNOWLEDGED_FIXTURE);
633
+ assert.ok(priorFixtures.length >= 15, "the prior captured corpus must be present");
634
+ for (const name of priorFixtures) {
635
+ assert.throws(() => decodeReviewAcknowledgedV1(fixture(DEV_FIXTURES, name)), /schema|object/, `${name} must not decode as an acknowledgement`);
636
+ }
637
+ const acknowledged = acknowledgedFixture();
638
+ const priorDecoders: Array<[string, (value: unknown) => unknown]> = [
639
+ ["status/v3", decodeReviewStatusV3],
640
+ ["start/v3", decodeReviewStartV3],
641
+ ["start/v4", decodeReviewStartV4],
642
+ ["capabilities/v2", decodeReviewCapabilitiesV2],
643
+ ["consent/v2", decodeReviewConsentV2],
644
+ ["consent/v3", decodeReviewConsentV3],
645
+ ["last-event-closure/v1", decodeReviewLastEventClosureV1],
646
+ ["result-artifact/v2", decodeReviewResultArtifactV2],
647
+ ["failure/v2", decodeReviewFailureV2],
648
+ ["next-transition/v3", decodeReviewNextTransitionV3],
649
+ ["repair/v2", decodeReviewRepairV2],
650
+ ];
651
+ for (const [name, decoder] of priorDecoders) {
652
+ assert.throws(() => decoder(clone(acknowledged)), `${name} must reject the acknowledged envelope`);
626
653
  }
627
- // Cross-pinned rejection preserved: an operation outside the published
628
- // 12-value enum never decodes.
629
- assert.throws(() => decodeReviewFailureV2({ ...captured, operation: "review.capture-bogus" }), /failure\.operation/);
630
- assert.throws(() => decodeReviewFailureV2({ ...captured, operation: "review.recover" }), /failure\.operation/);
631
654
  });