gentle-pi 1.2.0 → 2.0.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 (107) hide show
  1. package/README.md +40 -9
  2. package/assets/agents/review-readability.md +0 -1
  3. package/assets/agents/review-reliability.md +0 -1
  4. package/assets/agents/review-resilience.md +0 -1
  5. package/assets/agents/review-risk.md +0 -1
  6. package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
  7. package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
  8. package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
  9. package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
  10. package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
  11. package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
  12. package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
  13. package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
  14. package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
  15. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
  16. package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
  17. package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
  18. package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
  19. package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
  20. package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
  21. package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
  22. package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
  23. package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
  24. package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
  25. package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
  26. package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
  27. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
  28. package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
  29. package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
  30. package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
  31. package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
  32. package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
  33. package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
  34. package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
  35. package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
  36. package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
  37. package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
  38. package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
  39. package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
  40. package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
  41. package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
  42. package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
  43. package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
  44. package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
  45. package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
  46. package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
  47. package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
  48. package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
  49. package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
  50. package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
  51. package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
  52. package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
  53. package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
  54. package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
  55. package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
  56. package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
  57. package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
  58. package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
  59. package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
  60. package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
  61. package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
  62. package/docs/native-authority-architecture.md +18 -3
  63. package/docs/review-integration.md +175 -27
  64. package/extensions/gentle-ai.ts +414 -19
  65. package/lib/gentle-ai-binary.ts +1 -1
  66. package/lib/native-review-cli.ts +679 -57
  67. package/lib/review-candidate-view.ts +131 -1
  68. package/lib/review-compact-contract.ts +12 -3
  69. package/lib/review-consent-latch.ts +51 -0
  70. package/lib/review-correction-lifecycle.ts +170 -0
  71. package/lib/review-integration-v2.ts +1563 -0
  72. package/package.json +15 -11
  73. package/runtime/gentle-ai-binary.mjs +1 -1
  74. package/runtime/native-review-cli.mjs +663 -41
  75. package/runtime/review-integration-v2.mjs +1564 -0
  76. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  77. package/scripts/gentle-ai-installer.mjs +16 -11
  78. package/scripts/install-gentle-ai.mjs +7 -3
  79. package/scripts/test-packed-runner.mjs +2 -2
  80. package/scripts/verify-package-files.mjs +216 -64
  81. package/skills/release/SKILL.md +38 -11
  82. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  83. package/tests/gentle-ai-binary.test.ts +8 -1
  84. package/tests/gentle-ai-installer.test.ts +24 -21
  85. package/tests/native-binary-gate.test.ts +145 -0
  86. package/tests/native-review-capability-contract.test.ts +87 -0
  87. package/tests/native-review-cli.test.ts +44 -1
  88. package/tests/native-review-parity-runtime.test.ts +70 -23
  89. package/tests/native-review-parity.test.ts +673 -0
  90. package/tests/native-risk-evidence-parity.test.ts +91 -0
  91. package/tests/package-manifest.test.ts +72 -7
  92. package/tests/review-actor-tool-deny.test.ts +20 -5
  93. package/tests/review-candidate-view.test.ts +331 -1
  94. package/tests/review-compact-contract.test.ts +5 -0
  95. package/tests/review-consent-latch.test.ts +67 -0
  96. package/tests/review-controller-native-recovery.test.ts +180 -5
  97. package/tests/review-controller-native-routing.test.ts +385 -12
  98. package/tests/review-controller-workspace-root.test.ts +26 -6
  99. package/tests/review-correction-lifecycle.test.ts +174 -0
  100. package/tests/review-integration-v2.test.ts +539 -0
  101. package/tests/runtime-harness.mjs +187 -0
  102. package/tests/support/native-binary-gate.ts +86 -0
  103. package/tests/verify-package-files.test.ts +119 -0
  104. package/lib/review-integration-v1.ts +0 -795
  105. package/runtime/review-integration-v1.mjs +0 -796
  106. package/tests/native-review-integration-v1.test.ts +0 -102
  107. package/tests/review-integration-v1.test.ts +0 -530
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { NATIVE_CLI_CONTRACTS } from "../lib/native-review-cli.ts";
4
+
5
+ // Organic RDD parity: the four organic-parity capability columns exist on every
6
+ // shipped NATIVE_CLI_CONTRACTS row. Every row through the pinned "2.1.11"
7
+ // reports them false. "2.2.0" is the first capability-true row and it lights
8
+ // only what was demonstrated to reach a negotiated consumer, per Design
9
+ // Decision #1 ("Future row policy").
10
+ //
11
+ // A row describes what the NATIVE CLI puts on the wire, never what Pi manages
12
+ // to show. `riskEvidence` and `hint` stay false on 2.2.0 even though a Pi
13
+ // operator now sees both, because Pi derives them from `risk_reasons` and
14
+ // `changed_files` rather than receiving them: the closed `start/v2` schema
15
+ // omits the fields. Lighting the row would tell a future reader the envelope
16
+ // carries something it does not.
17
+
18
+ const ORGANIC_PARITY_CAPABILITIES = ["mode", "riskEvidence", "hint", "delivery"] as const;
19
+ const DARK_VERSIONS = ["2.1.4", "2.1.5", "2.1.6", "2.1.7", "2.1.8", "2.1.9", "2.1.10", "2.1.11"] as const;
20
+
21
+ test("every row through the pinned 2.1.11 reports the organic-parity capability columns false", () => {
22
+ for (const version of DARK_VERSIONS) {
23
+ const contract = NATIVE_CLI_CONTRACTS[version] as Record<string, boolean>;
24
+ for (const capability of ORGANIC_PARITY_CAPABILITIES) {
25
+ assert.equal(
26
+ Object.hasOwn(contract, capability) ? contract[capability] : false,
27
+ false,
28
+ `${version}.${capability} must be false or absent`,
29
+ );
30
+ }
31
+ }
32
+ });
33
+
34
+ test("the 2.1.11 row explicitly reports all four organic-parity capabilities false", () => {
35
+ const contract = NATIVE_CLI_CONTRACTS["2.1.11"] as Record<string, boolean>;
36
+ assert.equal(contract.mode, false);
37
+ assert.equal(contract.riskEvidence, false);
38
+ assert.equal(contract.hint, false);
39
+ assert.equal(contract.delivery, false);
40
+ });
41
+
42
+ test("2.2.0 lights only the two capabilities its negotiated envelope demonstrably carries", () => {
43
+ // Ground-truthed against the released v2.2.0 binary: the kill switch and
44
+ // delivery disposition reach the negotiated path, while `risk_evidence` and
45
+ // `hint` appear only in the plain envelope. See
46
+ // tests/native-risk-evidence-parity.test.ts for the captured evidence.
47
+ const contract = NATIVE_CLI_CONTRACTS["2.2.0"] as Record<string, boolean>;
48
+ assert.equal(contract.mode, true);
49
+ assert.equal(contract.delivery, true);
50
+ assert.equal(contract.riskEvidence, false);
51
+ assert.equal(contract.hint, false);
52
+ });
53
+
54
+ test("2.2.1 repeats 2.2.0 because the lane Pi speaks did not move", () => {
55
+ // Ground-truthed against the released v2.2.1 binary. On
56
+ // review-integration/v1 it advertises capabilities/v1.5 (protocol minor 5),
57
+ // but the negotiated start envelope is still the closed `start/v2`, so
58
+ // `risk_evidence` and `hint` still cannot arrive. v2.2.1 does publish a
59
+ // second contract, review-integration/v2, whose `start/v3` carries frozen
60
+ // base/candidate trees -- but Pi does not negotiate it yet, and a row
61
+ // describes the lane in use, not the lane available.
62
+ const contract = NATIVE_CLI_CONTRACTS["2.2.1"] as Record<string, boolean>;
63
+ assert.equal(contract.mode, true);
64
+ assert.equal(contract.delivery, true);
65
+ assert.equal(contract.riskEvidence, false);
66
+ assert.equal(contract.hint, false);
67
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.0"] as Record<string, boolean>);
68
+ });
69
+
70
+ test("2.2.2 repeats 2.2.1 because the lane Pi speaks still did not move", () => {
71
+ // Ground-truthed against the released v2.2.2 binary: on
72
+ // review-integration/v1 it advertises capabilities/v1.5 and the negotiated
73
+ // start envelope is still the closed `start/v2`, so risk_evidence and hint
74
+ // still cannot arrive on the lane Pi negotiates.
75
+ const contract = NATIVE_CLI_CONTRACTS["2.2.2"] as Record<string, boolean>;
76
+ assert.equal(contract.mode, true);
77
+ assert.equal(contract.delivery, true);
78
+ assert.equal(contract.riskEvidence, false);
79
+ assert.equal(contract.hint, false);
80
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.1"] as Record<string, boolean>);
81
+ });
82
+
83
+ test("no shipped version key was added beyond the pin bump", () => {
84
+ // Rows are promises to consumers, so a new key only ever appears in a
85
+ // dedicated commit alongside a pin bump, never as a side effect.
86
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2"]);
87
+ });
@@ -676,7 +676,7 @@ test("native review status decodes 2.1.8 released lock residue and keeps unknown
676
676
  // schema-incompatible on every repository that ever completed a review.
677
677
  //
678
678
  // Lock status stays a CLOSED enum extended by exactly `released` — unlike
679
- // the cause_category widening in review-integration-v1 (diagnostic
679
+ // the cause_category widening in review-integration-v2 (diagnostic
680
680
  // metadata nothing routes on), lock status routes blocking behavior in the
681
681
  // controller, so an unknown future status must keep failing closed rather
682
682
  // than being silently classified as blocking or non-blocking.
@@ -829,6 +829,49 @@ test("native SDD readiness requires an unblocked post-review action with publish
829
829
  }
830
830
  });
831
831
 
832
+ // Phase 13.13 (migrate-review-integration-v2): with the kill switch off,
833
+ // gentle-ai's gate result can never become "allow" (delivery follows ordinary
834
+ // repository policy instead), so gating `ready` on result==="allow" alone
835
+ // deadlocked readiness forever. `delivery` also arrived on the reviewGate
836
+ // struct itself (json:"delivery,omitempty") without a corresponding optional
837
+ // key on the decoder, so that payload was previously rejected outright rather
838
+ // than merely read as not-ready.
839
+ test("native SDD readiness unblocks on disabled/unmanaged delivery instead of deadlocking on a permanently non-allow gate result", async () => {
840
+ const openspec = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
841
+
842
+ for (const nextRecommended of ["verify", "archive"] as const) {
843
+ const body = {
844
+ ...openspec,
845
+ nextRecommended,
846
+ blockedReasons: [],
847
+ reviewGate: { result: "invalidated", reason: "review-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy", delivery: "disabled/unmanaged" },
848
+ };
849
+ const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
850
+ const status = await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" });
851
+ assert.equal(status.ready, true, `${nextRecommended} must be ready under disabled/unmanaged delivery even though result is not "allow"`);
852
+ }
853
+
854
+ // Blocked reasons still take priority over a legitimate unmanaged delivery.
855
+ const stillBlocked = {
856
+ ...openspec, nextRecommended: "verify", blockedReasons: ["stale authority"],
857
+ reviewGate: { result: "invalidated", reason: "delivery follows ordinary repository policy", delivery: "disabled/unmanaged" },
858
+ };
859
+ const blockedQueue = queuedAdapter([VERSION, { stdout: JSON.stringify(stillBlocked) }]);
860
+ assert.equal((await new NativeReviewCliV213(blockedQueue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" })).ready, false);
861
+
862
+ // An unrecognized delivery value must still fail closed (exact enum, not a
863
+ // free string).
864
+ const unknownDelivery = {
865
+ ...openspec, nextRecommended: "verify", blockedReasons: [],
866
+ reviewGate: { result: "invalidated", reason: "delivery follows ordinary repository policy", delivery: "unmanaged" },
867
+ };
868
+ const unknownQueue = queuedAdapter([VERSION, { stdout: JSON.stringify(unknownDelivery) }]);
869
+ await assert.rejects(
870
+ () => new NativeReviewCliV213(unknownQueue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }),
871
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
872
+ );
873
+ });
874
+
832
875
  test("native client decodes the exact v2.1.3 Engram artifact map", async () => {
833
876
  const queue = queuedAdapter([VERSION, { stdout: await fixture("sdd-status-engram") }]);
834
877
  const status = await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" });
@@ -14,6 +14,7 @@ import { NativeReviewCliV216 } from "../lib/native-review-cli.ts";
14
14
  import { NativeReviewCliV216 as RuntimeNativeReviewCliV216 } from "../runtime/native-review-cli.mjs";
15
15
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
16
16
  import { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
17
+ import { requireNativeBinary } from "./support/native-binary-gate.ts";
17
18
 
18
19
  const execFileAsync = promisify(execFile);
19
20
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -27,7 +28,9 @@ const resolvedBinary = (() => {
27
28
  return undefined;
28
29
  }
29
30
  })();
30
- const test = resolvedBinary === undefined ? baseTest.skip : baseTest;
31
+ const nativeBinaryGate = requireNativeBinary({ resolvedBinary, digestsPinned: true, env: process.env });
32
+ if (!nativeBinaryGate.run) console.log(`native-review-parity-runtime: ${nativeBinaryGate.reason}`);
33
+ const test = nativeBinaryGate.run ? baseTest : baseTest.skip;
31
34
  const binary = resolvedBinary ?? "";
32
35
  const OFFICIAL_BINARY_SHA256 = resolveGentleAiReleaseAsset(process.platform, process.arch).binarySha256;
33
36
  const REVIEWED_PATHS = ["tracked.txt", "initially-untracked.txt"] as const;
@@ -153,16 +156,51 @@ function authorityInventory(status: Record<string, unknown>): ReviewAuthorityEnt
153
156
  });
154
157
  }
155
158
 
159
+ // Every reviewer result reaches authority through `review capture-result`.
160
+ // `review finalize --result` was removed because a result handed over that way
161
+ // carries no provider-owned admission, so it cannot prove the lens inspected
162
+ // the frozen candidate. The bindings are never guessed here: each one comes
163
+ // from the collect transition the provider itself publishes, which is also
164
+ // what the removal message tells a caller to do.
165
+ //
166
+ // Artifacts stay outside the repository on purpose. Writing them inside changes
167
+ // the workspace snapshot between deriving a target and using it, and authority
168
+ // correctly rejects the stale target that results.
169
+ async function nextTransition(repository: string): Promise<Record<string, unknown>> {
170
+ const status = JSON.parse((await run(binary, ["review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", repository, "--projection", "workspace", "--next-transition"], repository)).stdout) as Record<string, unknown>;
171
+ return status.next_transition as Record<string, unknown>;
172
+ }
173
+
174
+ async function captureSelectedLenses(repository: string, artifacts: string, evidenceName: string): Promise<void> {
175
+ for (let guard = 0; guard < 8; guard += 1) {
176
+ const transition = await nextTransition(repository);
177
+ const collect = transition.collect as { inputs?: readonly Record<string, unknown>[] } | undefined;
178
+ if (transition.kind !== "collect" || collect?.inputs === undefined) return;
179
+ for (const [index, input] of collect.inputs.entries()) {
180
+ const subject = input.artifact_subject as { subject_hash: string };
181
+ const manifest = input.changed_path_manifest as readonly { path: string }[];
182
+ const result = join(artifacts, `${evidenceName}-capture-${index}.json`);
183
+ await writeFile(result, JSON.stringify({
184
+ subject_hash: subject.subject_hash,
185
+ inspection: { status: "completed", paths: manifest.map((entry) => entry.path) },
186
+ findings: [],
187
+ evidence: ["reviewed the complete frozen candidate scope"],
188
+ }));
189
+ // The published tokens already carry the repository context, and
190
+ // capture-result accepts that or --cwd, never both.
191
+ const tokens = (input.arguments as readonly { token: string }[]).map((argument) => argument.token);
192
+ const captured = await run(binary, ["review", "capture-result", ...tokens, "--input", result], repository);
193
+ assert.equal(captured.exitCode, 0, `capture-result failed: ${captured.stderr}`);
194
+ }
195
+ }
196
+ assert.fail("collect transitions did not converge");
197
+ }
198
+
156
199
  async function finalizeEmptyReview(repository: string, artifacts: string, started: ReviewStart, evidenceName: string): Promise<CommandResult> {
157
200
  const evidence = join(artifacts, evidenceName);
158
201
  await writeFile(evidence, `evidence for ${started.lineage_id}\n`);
159
- const resultFiles: string[] = [];
160
- for (const [index] of started.selected_lenses.entries()) {
161
- const result = join(artifacts, `${evidenceName}-lens-${index}.json`);
162
- await writeFile(result, JSON.stringify({ findings: [], evidence: ["reviewed frozen candidate"] }));
163
- resultFiles.push(result);
164
- }
165
- return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, ...resultFiles.flatMap((result) => ["--result", result]), "--evidence", evidence], repository);
202
+ await captureSelectedLenses(repository, artifacts, evidenceName);
203
+ return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], repository);
166
204
  }
167
205
 
168
206
  test("official pinned package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => {
@@ -206,13 +244,10 @@ test("official pinned package runtime authorizes an unchanged linked-view candid
206
244
 
207
245
  const evidence = join(artifacts, "final-evidence.txt");
208
246
  await writeFile(evidence, "linked-view parity probe\n");
209
- const resultFiles: string[] = [];
210
- for (const [index] of started.selected_lenses.entries()) {
211
- const result = join(artifacts, `lens-${index}.json`);
212
- await writeFile(result, JSON.stringify({ findings: [], evidence: ["reviewed linked candidate view"] }));
213
- resultFiles.push(result);
214
- }
215
- await run(binary, ["review", "finalize", "--cwd", view, "--lineage", started.lineage_id, ...resultFiles.flatMap((result) => ["--result", result]), "--evidence", evidence], view);
247
+ // The linked view is the root authority froze, so its collect bindings are
248
+ // derived from the view, not from the contributor repository.
249
+ await captureSelectedLenses(view, artifacts, "final-evidence.txt");
250
+ await run(binary, ["review", "finalize", "--cwd", view, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], view);
216
251
 
217
252
  await run("git", ["add", "--", ...REVIEWED_PATHS], repository);
218
253
  await assertPublishedProjection(repository, candidateTree);
@@ -306,20 +341,29 @@ test("official pinned package runtime keeps frozen candidate lineages and receip
306
341
  assert.equal(competingStartResult.lineage_id, first.lineage_id);
307
342
  assert.equal(competingStartResult.state, "approved");
308
343
  assert.deepEqual(authorityAfterCompetingStart, authorityBeforeCompetingStart, "a blocked scope action must not mutate approved authority");
344
+ // A plain START over the moved candidate does NOT mint a fresh lineage. An
345
+ // approved lineage whose scope changed is recovered, keeping the operator
346
+ // pointed at the receipt that already exists instead of quietly abandoning
347
+ // it, and the recovery itself needs a maintainer to authorize it. Pi mirrors
348
+ // this because the experience must be the one gentle-ai defines, not a
349
+ // friendlier one Pi invents.
309
350
  const second = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
310
351
  const secondInventory = authorityInventory(await reviewStatus(repository));
311
- assert.equal(second.action, "created");
312
- assert.equal(second.state, "reviewing");
313
- assert.notEqual(second.lineage_id, first.lineage_id, "a distinct candidate must establish a distinct lineage");
352
+ assert.equal(second.action, "recover");
353
+ assert.equal(second.state, "approved");
354
+ assert.equal(second.lineage_id, first.lineage_id, "a moved candidate recovers the approved lineage rather than replacing it");
314
355
  const secondReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
315
356
  const secondReplayInventory = authorityInventory(await reviewStatus(repository));
357
+ assert.equal(secondReplay.action, "recover", "recovery is idempotent");
316
358
  assert.equal(secondReplay.lineage_id, second.lineage_id, "replaying the second START must reuse its lineage");
317
359
  assert.deepEqual(secondReplayInventory, secondInventory, "replaying the second START must not duplicate durable authority");
318
- await finalizeEmptyReview(repository, artifacts, second, "second-evidence.txt");
360
+ const recoveryTransition = await nextTransition(repository);
361
+ assert.equal(recoveryTransition.kind, "collect");
362
+ assert.equal(recoveryTransition.reason_code, "recovery_authorization_required", "recovery must ask a maintainer, never proceed on its own");
319
363
 
320
- const secondAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", second.lineage_id], repository)).stdout) as ReviewGateResult;
321
- assert.equal(secondAllowed.result, "allow");
322
- assert.equal(secondAllowed.context.candidate_tree, secondCandidateTree);
364
+ // No receipt covers the moved candidate, so delivery is denied rather than
365
+ // waved through, and the denial names `gentle-ai review recover` with the
366
+ // inputs it needs. A blocked gate that names its exit is the whole point.
323
367
  await assertScopeChanged(repository, first.lineage_id);
324
368
 
325
369
  await writeFile(join(repository, "tracked.txt"), "candidate one\n");
@@ -379,7 +423,10 @@ test("registered gentle_review START materializes a safe internal skill symlink
379
423
  let returned: { details?: unknown } | undefined;
380
424
  let thrown: unknown;
381
425
  try {
382
- returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { confirm: async () => true } } as unknown as ExtensionContext);
426
+ // A headless context still receives notices, so the stub carries notify.
427
+ // Without it the consent path throws on a real ExtensionContext member,
428
+ // and the failure reads as a START problem rather than a stub gap.
429
+ returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { confirm: async () => true, notify: () => {} } } as unknown as ExtensionContext);
383
430
  } catch (caught) {
384
431
  thrown = caught;
385
432
  }