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
@@ -13,7 +13,7 @@ function scratchDir(prefix: string): string {
13
13
 
14
14
  async function runRetiredOperation(parameters: Record<string, unknown>): Promise<Record<string, unknown>> {
15
15
  const cwd = scratchDir("gentle-pi-retired-ops-");
16
- return await __testing.executeReviewControllerOperation(parameters, cwd, new Map(), null);
16
+ return await __testing.executeReviewControllerOperation(parameters, cwd, null);
17
17
  }
18
18
 
19
19
  function assertRetiredEnvelope(details: Record<string, unknown>, operation: string): void {
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
8
+ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
9
9
  import type { NativeReviewCli } from "../lib/native-review-cli.ts";
10
10
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
11
11
  import type { AuthorityRepairAssessmentV1, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
@@ -55,8 +55,13 @@ function context(cwd: string): ExtensionContext {
55
55
  }
56
56
 
57
57
  function repository(t: test.TestContext, prefix = "gentle-pi-workspace-root-"): string {
58
- const cwd = mkdtempSync(join(tmpdir(), prefix));
59
- t.after(() => rmSync(cwd, { recursive: true, force: true }));
58
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
59
+ t.after(() => {
60
+ if (process.platform !== "win32") {
61
+ try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
62
+ }
63
+ rmSync(cwd, { recursive: true, force: true });
64
+ });
60
65
  execFileSync("git", ["init", "-b", "main"], { cwd });
61
66
  writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
62
67
  execFileSync("git", ["add", "."], { cwd });
@@ -65,7 +70,7 @@ function repository(t: test.TestContext, prefix = "gentle-pi-workspace-root-"):
65
70
  }
66
71
 
67
72
  function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
68
- const parent = mkdtempSync(join(tmpdir(), "gentle-pi-workspace-worktrees-"));
73
+ const parent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-workspace-worktrees-")));
69
74
  t.after(() => {
70
75
  try { execFileSync("git", ["worktree", "remove", "--force", join(parent, branch)], { cwd }); } catch {}
71
76
  rmSync(parent, { recursive: true, force: true });
@@ -78,14 +83,11 @@ function addWorktree(t: test.TestContext, cwd: string, branch: string): string {
78
83
  function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
79
84
  return {
80
85
  start: async () => ({ lineageId: "native-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 2, changedLines: 7, correctionBudget: 4, action: "created", lensesRequired: true }),
81
- finalize: async () => ({ lineageId: "native-lineage", state: "approved", action: "approved", storeRevision: "r1", receiptPath: "/opaque/receipt" }),
82
- validate: async () => { throw new Error("validate is not expected in workspace-root tests"); },
83
- bindSdd: async () => { throw new Error("bind-sdd is not expected in workspace-root tests"); },
84
86
  sddStatus: async () => ({ ready: false }),
85
87
  reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: { schema: "gentle-ai.review-authority-status/v1", operation: "review/status", repository: "/repo", complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [] } }),
86
88
  targetStatus: async (request) => request.lineageId === undefined
87
89
  ? candidateStartTargetStatus(request)
88
- : candidateFinalizeTargetStatus(request, request.lineageId),
90
+ : candidateTargetStatus(request, request.lineageId),
89
91
  ...overrides,
90
92
  };
91
93
  }
@@ -102,17 +104,20 @@ function targetStatusFixture(options: {
102
104
  applicability?: "current_target" | "unrelated";
103
105
  action?: ReviewStatusV3["action"];
104
106
  lineageId?: string;
107
+ authorityState?: NonNullable<ReviewStatusV3["authority"]>["state"];
105
108
  baseTree?: string;
106
109
  currentCandidateTree?: string;
107
110
  paths?: readonly string[];
111
+ intendedUntracked?: readonly string[];
108
112
  } = {}): ReviewStatusV3 {
109
113
  const applicability = options.applicability ?? "current_target";
110
- const action = options.action ?? (applicability === "current_target" ? "finalize" : "start");
111
- const lineageId = options.lineageId ?? "native-lineage";
114
+ const action = options.action ?? (applicability === "current_target" ? "stop" : "start"); const lineageId = options.lineageId ?? "native-lineage";
115
+ const authorityState = options.authorityState ?? "reviewing";
112
116
  const sha = `sha256:${"a".repeat(64)}`;
113
117
  const tree = options.currentCandidateTree ?? "b".repeat(40);
114
118
  const baseTree = options.baseTree ?? tree;
115
119
  const paths = options.paths ?? ["app.ts"];
120
+ const intendedUntracked = options.intendedUntracked ?? [];
116
121
  const projection = {
117
122
  schema: "gentle-ai.review-integration.projection/v1" as const,
118
123
  kind: "current-changes" as const,
@@ -122,7 +127,7 @@ function targetStatusFixture(options: {
122
127
  currentCandidateTree: tree,
123
128
  pathsDigest: sha,
124
129
  paths,
125
- intendedUntracked: [],
130
+ intendedUntracked,
126
131
  intendedUntrackedProof: sha,
127
132
  initialSnapshotIdentity: sha,
128
133
  currentSnapshotIdentity: sha,
@@ -156,7 +161,7 @@ function targetStatusFixture(options: {
156
161
  current_candidate_tree: tree,
157
162
  paths_digest: sha,
158
163
  paths,
159
- intended_untracked: [],
164
+ intended_untracked: intendedUntracked,
160
165
  intended_untracked_proof: sha,
161
166
  initial_snapshot_identity: sha,
162
167
  current_snapshot_identity: sha,
@@ -164,13 +169,13 @@ function targetStatusFixture(options: {
164
169
  candidates: [],
165
170
  };
166
171
  if (applicability === "current_target") {
167
- raw.authority = { version: "compact-v2", lineage_id: lineageId, state: "reviewing", generation: 1, revision: sha };
172
+ raw.authority = { version: "compact-v2", lineage_id: lineageId, state: authorityState, generation: 1, revision: sha };
168
173
  raw.frozen = { tier: "medium", original_changed_lines: 2, correction_budget: 1 };
169
174
  }
170
175
  return {
171
176
  contract: "gentle-ai.review-integration/v2",
172
177
  applicability,
173
- ...(applicability === "current_target" ? { authority: { version: "compact-v2" as const, lineageId, state: "reviewing", generation: 1, revision: sha } } : {}),
178
+ ...(applicability === "current_target" ? { authority: { version: "compact-v2" as const, lineageId, state: authorityState, generation: 1, revision: sha } } : {}),
174
179
  receipt: { status: applicability === "current_target" ? "expected_missing" : "not_applicable" },
175
180
  action,
176
181
  replayability: "not_replayable",
@@ -189,6 +194,7 @@ function candidateStartTargetStatus(request: Parameters<NonNullable<NativeReview
189
194
  candidate = new CandidateViewRegistry().create({
190
195
  contributorRoot: request.cwd,
191
196
  ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }),
197
+ ...(request.intendedUntracked === undefined ? {} : { intendedUntracked: request.intendedUntracked }),
192
198
  });
193
199
  return targetStatusFixture({
194
200
  applicability: "unrelated",
@@ -196,13 +202,14 @@ function candidateStartTargetStatus(request: Parameters<NonNullable<NativeReview
196
202
  baseTree: candidate.baseTree,
197
203
  currentCandidateTree: candidate.candidateTree,
198
204
  paths: candidate.paths,
205
+ intendedUntracked: request.intendedUntracked,
199
206
  });
200
207
  } finally {
201
208
  candidate?.cleanup();
202
209
  }
203
210
  }
204
211
 
205
- function candidateFinalizeTargetStatus(request: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0], lineageId: string): ReviewStatusV3 {
212
+ function candidateTargetStatus(request: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0], lineageId: string): ReviewStatusV3 {
206
213
  let candidate: ReturnType<CandidateViewRegistry["create"]> | undefined;
207
214
  try {
208
215
  candidate = new CandidateViewRegistry().create({ contributorRoot: request.cwd });
@@ -270,39 +277,164 @@ test("START freezes the candidate from the explicit workspace root and returns t
270
277
  candidateViews.cleanup(view.token);
271
278
  });
272
279
 
273
- test("absent workspaceRoot keeps the session-cwd flow and still reports the actor binding envelope", async (t) => {
274
- const sessionCwd = repository(t);
275
- writeFileSync(join(sessionCwd, "app.ts"), "export const value = 3;\n");
280
+ test("an omitted workspaceRoot canonicalizes a nested same-worktree session before supported operations", async (t) => {
281
+ const root = repository(t);
282
+ const sessionCwd = join(root, "nested");
283
+ mkdirSync(sessionCwd);
284
+ writeFileSync(join(root, "app.ts"), "export const value = 3;\n");
276
285
  const candidateViews = new CandidateViewRegistry();
286
+ const targetStatusCwds: string[] = [];
277
287
  const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
278
288
  const { controller } = runtime(fakeNative({
289
+ targetStatus: async (request) => { targetStatusCwds.push(request.cwd); return request.lineageId === undefined ? candidateStartTargetStatus(request) : candidateTargetStatus(request, request.lineageId); },
279
290
  start: async (request) => {
280
291
  startRequests.push(request);
281
292
  return { lineageId: "session-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
282
293
  },
283
294
  }), candidateViews);
284
- const started = await controller.execute("start-session", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(sessionCwd));
285
- const details = started.details as {
286
- workspace_root: string;
287
- actor_binding: { workspace_root: string; candidate_root: string; candidate_paths: readonly string[] };
288
- };
289
- assert.equal(details.workspace_root, sessionCwd);
290
- assert.equal(details.actor_binding.workspace_root, sessionCwd);
295
+ const ctx = context(sessionCwd);
296
+ await controller.execute("inspect-session", { operation: "inspect" }, undefined, undefined, ctx);
297
+ const started = await controller.execute("start-session", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, ctx);
298
+ const status = await controller.execute("status-session", { operation: "status", lineageId: "session-lineage" }, undefined, undefined, ctx);
299
+ const details = started.details as { workspace_root: string; actor_binding: { workspace_root: string; candidate_root: string; candidate_paths: readonly string[] } };
300
+ assert.equal(details.workspace_root, root);
301
+ assert.equal(details.actor_binding.workspace_root, root);
302
+ assert.equal((status.details as { workspace_root: string }).workspace_root, root);
291
303
  assert.deepEqual(details.actor_binding.candidate_paths, ["app.ts"]);
292
- assert.deepEqual(startRequests, [{ cwd: sessionCwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
304
+ assert.deepEqual(startRequests, [{ cwd: root, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
293
305
  const view = candidateViews.resolveForLens("session-lineage", "review-reliability");
294
306
  assert.equal(details.actor_binding.candidate_root, view.root);
307
+ assert.ok(targetStatusCwds.every((cwd) => cwd === root), JSON.stringify(targetStatusCwds));
295
308
  candidateViews.cleanup(view.token);
296
309
  });
297
310
 
298
- test("workspaceRoot fails closed before any native call for non-worktree and foreign targets", async (t) => {
311
+ test("an explicit nested foreign workspace root canonically owns inspect, status, and START", async (t) => {
312
+ const sessionCwd = repository(t, "gentle-pi-session-a-");
313
+ const target = repository(t, "gentle-pi-target-b-");
314
+ const nested = join(target, "nested");
315
+ mkdirSync(nested);
316
+ writeFileSync(join(target, "app.ts"), "export const value = 2; // target B\n");
317
+ const root = realpathSync(target);
318
+ const targetStatusCwds: string[] = [];
319
+ const targetStatusRequests: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0][] = [];
320
+ const startCwds: string[] = [];
321
+ const candidateViews = new CandidateViewRegistry();
322
+ let initialCrossRootStatus = true;
323
+ const { controller } = runtime(fakeNative({
324
+ targetStatus: async (request) => {
325
+ targetStatusCwds.push(request.cwd);
326
+ targetStatusRequests.push(request);
327
+ if (request.lineageId === "cross-root-lineage" && initialCrossRootStatus) {
328
+ initialCrossRootStatus = false;
329
+ return request.untrackedScope === "select"
330
+ ? targetStatusFixture({ lineageId: request.lineageId, authorityState: "correction_required" })
331
+ : targetStatusFixture({ applicability: "unrelated", action: "start" });
332
+ }
333
+ return request.lineageId === undefined
334
+ ? candidateStartTargetStatus(request)
335
+ : candidateTargetStatus(request, request.lineageId);
336
+ },
337
+ start: async (request) => {
338
+ startCwds.push(request.cwd);
339
+ return { lineageId: "cross-root-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
340
+ },
341
+ }), candidateViews);
342
+
343
+ const digest = `sha256:${"b".repeat(64)}`;
344
+ const intendedUntracked = ["selected-b.ts", "selected-a.ts"];
345
+ const inspect = await controller.execute("inspect-target-b", { operation: "inspect", workspaceRoot: nested }, undefined, undefined, context(sessionCwd));
346
+ const status = await controller.execute("status-target-b", { operation: "status", lineageId: "cross-root-lineage", workspaceRoot: nested, input: JSON.stringify({ untrackedScope: "select", expectedUntrackedInventory: digest, intendedUntracked }) }, undefined, undefined, context(sessionCwd));
347
+ const started = await controller.execute("start-target-b", { operation: "start", workspaceRoot: nested, input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(sessionCwd));
348
+
349
+ for (const result of [inspect, status, started]) {
350
+ assert.equal((result.details as { workspace_root?: string }).workspace_root, root);
351
+ }
352
+ const statusDetails = status.details as { status?: string; result?: { authority?: { state?: string } } };
353
+ assert.equal(statusDetails.status, "blocked");
354
+ assert.equal(statusDetails.result?.authority?.state, "correction_required");
355
+ assert.deepEqual(targetStatusRequests[1], { cwd: root, lineageId: "cross-root-lineage", untrackedScope: "select", expectedUntrackedInventory: digest, intendedUntracked, agent: "pi" });
356
+ assert.ok(targetStatusCwds.every((cwd) => cwd === root), JSON.stringify(targetStatusCwds));
357
+ assert.deepEqual(startCwds, [root]);
358
+ candidateViews.cleanup(candidateViews.resolveForLens("cross-root-lineage", "review-reliability").token);
359
+ });
360
+
361
+
362
+ test("STATUS relays exclude selection and rejects malformed input before native status", async (t) => {
363
+ const cwd = repository(t);
364
+ const requests: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0][] = [];
365
+ const { controller } = runtime(fakeNative({
366
+ targetStatus: async (request) => {
367
+ requests.push(request);
368
+ return targetStatusFixture({ lineageId: request.lineageId });
369
+ },
370
+ }));
371
+ const digest = `sha256:${"c".repeat(64)}`;
372
+ const excluded = await controller.execute("status-exclude", { operation: "status", lineageId: "excluded-lineage", input: JSON.stringify({ untrackedScope: "exclude", expectedUntrackedInventory: digest, intendedUntracked: [] }) }, undefined, undefined, context(cwd));
373
+ assert.equal((excluded.details as { status?: string }).status, "blocked");
374
+ assert.deepEqual(requests, [{ cwd, lineageId: "excluded-lineage", untrackedScope: "exclude", expectedUntrackedInventory: digest, intendedUntracked: [], agent: "pi" }]);
375
+
376
+ for (const [input, reason] of [
377
+ [{}, "untracked-selection-invalid"],
378
+ [{ untrackedScope: "select", expectedUntrackedInventory: digest, intendedUntracked: [] }, "untracked-selection-invalid"],
379
+ [{ untrackedScope: "select" }, "untracked-selection-invalid"],
380
+ [{ untrackedScope: "exclude", expectedUntrackedInventory: digest, intendedUntracked: [], unexpected: true }, "unknown-field"],
381
+ ] as const) {
382
+ const rejected = await controller.execute("status-invalid", { operation: "status", input: JSON.stringify(input) }, undefined, undefined, context(cwd));
383
+ assert.equal((rejected.details as { reason?: string }).reason, reason);
384
+ }
385
+ assert.equal(requests.length, 1);
386
+ });
387
+
388
+ test("an explicit foreign workspace root works when the Pi session cwd is not a Git repository", async (t) => {
389
+ const target = repository(t, "gentle-pi-target-b-non-git-session-");
390
+ const nested = join(target, "nested");
391
+ mkdirSync(nested);
392
+ const nonGit = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-non-git-session-")));
393
+ t.after(() => rmSync(nonGit, { recursive: true, force: true }));
394
+ const observed: string[] = [];
395
+ const { controller } = runtime(fakeNative({
396
+ targetStatus: async (request) => {
397
+ observed.push(request.cwd);
398
+ return targetStatusFixture();
399
+ },
400
+ }));
401
+ const result = await controller.execute("inspect-target-from-non-git", { operation: "inspect", workspaceRoot: nested }, undefined, undefined, context(nonGit));
402
+ assert.equal((result.details as { workspace_root?: string }).workspace_root, realpathSync(target));
403
+ assert.deepEqual(observed, [realpathSync(target)]);
404
+ });
405
+
406
+ test("omitted workspaceRoot fails closed for the same lineage bound to two target repositories", async (t) => {
407
+ const rootA = repository(t, "gentle-pi-ambiguous-a-");
408
+ const rootB = repository(t, "gentle-pi-ambiguous-b-");
409
+ const candidateViews = new CandidateViewRegistry();
410
+ const viewA = candidateViews.create({ contributorRoot: rootA });
411
+ const viewB = candidateViews.create({ contributorRoot: rootB });
412
+ candidateViews.bindCurrent({ token: viewA.token, lineageId: "shared-lineage", selectedLenses: ["review-reliability"] });
413
+ candidateViews.bindCurrent({ token: viewB.token, lineageId: "shared-lineage", selectedLenses: ["review-reliability"] });
414
+ let nativeCalls = 0;
415
+ const { controller } = runtime(fakeNative({
416
+ targetStatus: async (request) => {
417
+ nativeCalls += 1;
418
+ return targetStatusFixture({ lineageId: request.lineageId });
419
+ },
420
+ }), candidateViews);
421
+ await assert.rejects(
422
+ controller.execute("ambiguous-omission", { operation: "status", lineageId: "shared-lineage" }, undefined, undefined, context(rootA)),
423
+ /workspaceRoot/,
424
+ );
425
+ assert.equal(nativeCalls, 0);
426
+ const explicit = await controller.execute("explicit-b", { operation: "status", lineageId: "shared-lineage", workspaceRoot: rootB }, undefined, undefined, context(rootA));
427
+ assert.equal((explicit.details as { workspace_root?: string }).workspace_root, realpathSync(rootB));
428
+ assert.equal(nativeCalls, 1);
429
+ candidateViews.cleanupTerminal("shared-lineage", "approved", rootA);
430
+ candidateViews.cleanupTerminal("shared-lineage", "approved", rootB);
431
+ });
432
+
433
+ test("workspaceRoot fails closed before any native call for invalid target paths", async (t) => {
299
434
  const sessionCwd = repository(t);
300
435
  const worktree = addWorktree(t, sessionCwd, "feat-guard");
301
- const foreign = repository(t, "gentle-pi-foreign-root-");
302
- const nonGit = mkdtempSync(join(tmpdir(), "gentle-pi-non-git-"));
436
+ const nonGit = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-non-git-")));
303
437
  t.after(() => rmSync(nonGit, { recursive: true, force: true }));
304
- const nested = join(worktree, "nested");
305
- mkdirSync(nested);
306
438
  const filePath = join(worktree, "app.ts");
307
439
  let nativeCalls = 0;
308
440
  const counting = fakeNative({
@@ -311,16 +443,13 @@ test("workspaceRoot fails closed before any native call for non-worktree and for
311
443
  reviewStatus: async () => { nativeCalls += 1; throw new Error("native review status must not run"); },
312
444
  });
313
445
  const { controller } = runtime(counting);
314
- const rejected: Array<{ label: string; workspaceRoot: string; ctx: string }> = [
315
- { label: "non-git directory", workspaceRoot: nonGit, ctx: sessionCwd },
316
- { label: "missing directory", workspaceRoot: join(nonGit, "missing"), ctx: sessionCwd },
317
- { label: "file path", workspaceRoot: filePath, ctx: sessionCwd },
318
- { label: "relative path", workspaceRoot: "relative/worktree", ctx: sessionCwd },
319
- { label: "worktree subdirectory", workspaceRoot: nested, ctx: sessionCwd },
320
- { label: "foreign repository", workspaceRoot: foreign, ctx: sessionCwd },
321
- { label: "session outside a repository", workspaceRoot: worktree, ctx: nonGit },
446
+ const rejected: Array<{ label: string; workspaceRoot: string }> = [
447
+ { label: "non-git directory", workspaceRoot: nonGit },
448
+ { label: "missing directory", workspaceRoot: join(nonGit, "missing") },
449
+ { label: "file path", workspaceRoot: filePath },
450
+ { label: "relative path", workspaceRoot: "relative/worktree" },
322
451
  ];
323
- for (const { label, workspaceRoot, ctx } of rejected) {
452
+ for (const { label, workspaceRoot } of rejected) {
324
453
  for (const operation of ["inspect", "start", "status"] as const) {
325
454
  await assert.rejects(
326
455
  controller.execute(`${operation}-${label}`, {
@@ -328,7 +457,7 @@ test("workspaceRoot fails closed before any native call for non-worktree and for
328
457
  ...(operation === "start" ? { input: JSON.stringify({ mode: "ordinary" }) } : {}),
329
458
  ...(operation === "status" ? { lineageId: "native-lineage" } : {}),
330
459
  workspaceRoot,
331
- }, undefined, undefined, context(ctx)),
460
+ }, undefined, undefined, context(sessionCwd)),
332
461
  /workspaceRoot/,
333
462
  `${operation} must fail closed for ${label}`,
334
463
  );
@@ -337,40 +466,62 @@ test("workspaceRoot fails closed before any native call for non-worktree and for
337
466
  assert.equal(nativeCalls, 0);
338
467
  });
339
468
 
340
- test("workspaceRoot rejection reports both roots for a shared-common-dir mismatch", async (t) => {
469
+ function providerRoleBinding(lineageId: string) {
470
+ return {
471
+ name: "provider_targeted_validator",
472
+ schema: "https://gentle-ai.dev/schema/review/targeted-validator/v1",
473
+ captureOperation: "review.capture-validation",
474
+ arguments: [
475
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
476
+ { name: "target", value: `sha256:${"a".repeat(64)}`, token: `--target=sha256:${"a".repeat(64)}` },
477
+ { name: "agent", value: "pi", token: "--agent=pi" },
478
+ { name: "execute", value: "true", token: "--execute=true" },
479
+ ],
480
+ };
481
+ }
482
+
483
+ test("same-session START binding migrates to one validation capture without a FINALIZE operation", async (t) => {
341
484
  const sessionCwd = repository(t);
342
- const foreign = repository(t, "gentle-pi-foreign-report-");
343
- const { controller } = runtime(fakeNative());
344
- await assert.rejects(
345
- controller.execute("inspect-foreign", { operation: "inspect", workspaceRoot: foreign }, undefined, undefined, context(sessionCwd)),
346
- (error: Error) => {
347
- assert.match(error.message, /workspaceRoot/);
348
- assert.ok(error.message.includes(realpathSync(foreign)));
349
- assert.ok(error.message.includes(sessionCwd));
350
- return true;
485
+ const worktree = addWorktree(t, sessionCwd, "feat-last-event");
486
+ writeFileSync(join(worktree, "app.ts"), "export const value = 2;\n");
487
+ const candidateViews = new CandidateViewRegistry();
488
+ const lineageId = "workspace-last-event";
489
+ const binding = providerRoleBinding(lineageId);
490
+ let captureCalls = 0;
491
+ const native = fakeNative({
492
+ targetStatus: async (request) => request.lineageId === undefined
493
+ ? candidateStartTargetStatus(request)
494
+ : ({ ...candidateTargetStatus(request, lineageId), nextTransition: { kind: "collect", reasonCode: "provider_role_required", collect: { inputs: [binding] } } } as unknown as ReviewStatusV3),
495
+ start: async () => ({ lineageId, state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true }),
496
+ captureProviderRole: async (request) => {
497
+ captureCalls += 1;
498
+ assert.equal(request.captureOperation, "review.capture-validation");
499
+ return { schema: "gentle-ai.review-last-event-closure/v1", operation: "review.capture-validation", lineageId, state: "approved", storeRevision: `sha256:${"a".repeat(64)}` };
351
500
  },
352
- );
501
+ });
502
+ const { controller } = runtime(native, candidateViews);
503
+ await controller.execute("start", { operation: "start", workspaceRoot: worktree, input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(sessionCwd));
504
+ const captured = await __testing.executeReviewCaptureOperation({ lineageId, workspaceRoot: worktree, collectBinding: JSON.stringify(binding) }, sessionCwd, native, undefined, candidateViews);
505
+ assert.equal(captured.outcome, "native-last-event-closure");
506
+ assert.equal(captured.status, "closed");
507
+ assert.equal(captureCalls, 1);
508
+ candidateViews.cleanupTerminal(lineageId, "approved", realpathSync(worktree));
353
509
  });
354
510
 
355
- test("FINALIZE fails closed when the frozen projection belongs to a different workspace than requested", async (t) => {
511
+ test("an explicit linked-worktree root owns STATUS collect capture over the session candidate root", async (t) => {
356
512
  const sessionCwd = repository(t);
357
- const worktree = addWorktree(t, sessionCwd, "feat-finalize");
358
- writeFileSync(join(worktree, "app.ts"), "export const value = 4;\n");
359
- const candidateViews = new CandidateViewRegistry();
360
- let finalizes = 0;
361
- const { controller } = runtime(fakeNative({
362
- start: async () => ({ lineageId: "finalize-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true }),
363
- finalize: async () => {
364
- finalizes += 1;
365
- return { lineageId: "finalize-lineage", state: "approved", action: "approved", storeRevision: "r1" };
513
+ const worktree = addWorktree(t, sessionCwd, "feat-explicit-capture");
514
+ const lineageId = "explicit-worktree-capture";
515
+ const binding = providerRoleBinding(lineageId);
516
+ const observedRoots: string[] = [];
517
+ const native = {
518
+ targetStatus: async (request: { cwd: string }) => {
519
+ observedRoots.push(request.cwd);
520
+ return { ...targetStatusFixture({ lineageId }), nextTransition: { kind: "collect", reasonCode: "provider_role_required", collect: { inputs: [binding] } } } as unknown as ReviewStatusV3;
366
521
  },
367
- }), candidateViews);
368
- await controller.execute("start-finalize-b", { operation: "start", workspaceRoot: worktree, input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(sessionCwd));
369
- const input = JSON.stringify({});
370
- const mismatch = await controller.execute("finalize-wrong-root", { operation: "finalize", lineageId: "finalize-lineage", input }, undefined, undefined, context(sessionCwd));
371
- assert.equal((mismatch.details as { outcome?: string }).outcome, "native-operation-failed");
372
- assert.equal(finalizes, 0);
373
- const matched = await controller.execute("finalize-right-root", { operation: "finalize", lineageId: "finalize-lineage", workspaceRoot: worktree, input }, undefined, undefined, context(sessionCwd));
374
- assert.equal((matched.details as { result?: { state?: string } }).result?.state, "approved");
375
- assert.equal(finalizes, 1);
522
+ captureProviderRole: async () => ({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review.capture-validation", lineageId, state: "approved", storeRevision: `sha256:${"a".repeat(64)}` }),
523
+ } as unknown as NativeReviewCli;
524
+ const result = await __testing.executeReviewCaptureOperation({ lineageId, workspaceRoot: worktree, collectBinding: JSON.stringify(binding) }, sessionCwd, native);
525
+ assert.equal(result.outcome, "native-last-event-closure");
526
+ assert.deepEqual(observedRoots, [realpathSync(worktree)]);
376
527
  });