gentle-pi 2.4.0 → 2.5.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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -1,10 +1,11 @@
1
1
  import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, utimesSync } from "node:fs";
4
4
  import { mkdtempSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { gunzipSync, gzipSync } from "node:zlib";
8
+ import { assertCandidateOwnerParent, createCandidateOwner, removeCandidateOwner, sweepCandidateOwners, type CandidateViewOwner } from "./review-candidate-view-owner.ts";
8
9
 
9
10
  const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
10
11
  export type ReviewLens = (typeof REVIEW_LENS)[number];
@@ -12,6 +13,11 @@ const CANDIDATE_GIT_TIMEOUT_MS = 10_000;
12
13
  const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000;
13
14
  const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS";
14
15
  const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
16
+ // Keep the copied index at least one timestamp tick behind its source. The
17
+ // two-second target also clears filesystems whose mtime granularity is a second
18
+ // or coarser without needlessly assigning an arbitrary historical timestamp.
19
+ const PRIVATE_INDEX_RACY_SAFETY_NS = 1_000_000_000n;
20
+ const PRIVATE_INDEX_RACY_BACKDATE_NS = 2_000_000_000n;
15
21
 
16
22
  // Candidate views may materialize full repository trees. Large repositories can
17
23
  // raise this bounded deadline without creating an unbounded child process.
@@ -94,6 +100,7 @@ interface CandidateViewScope {
94
100
  }
95
101
 
96
102
  interface CandidateViewRecord {
103
+ owner: CandidateViewOwner;
97
104
  token: string;
98
105
  root: string;
99
106
  parent: string;
@@ -333,12 +340,37 @@ function isMaterializedCandidateMode(mode: string): boolean {
333
340
  return mode === "100644" || mode === "100755" || mode === "120000";
334
341
  }
335
342
 
343
+ export function hasExpectedExecutableBits(filesystemMode: number, gitMode: string, platform: NodeJS.Platform = process.platform): boolean {
344
+ return gitMode === "100755" && platform === "win32"
345
+ || (filesystemMode & 0o111) === (gitMode === "100755" ? 0o111 : 0);
346
+ }
347
+
336
348
  function isCanonicalObjectId(objectId: string): boolean { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId); }
337
349
  function gitlinkMapsEqual(left: Readonly<Record<string, string>>, right: Readonly<Record<string, string>>): boolean {
338
350
  const entries = Object.entries(left);
339
351
  return entries.length === Object.keys(right).length && entries.every(([path, objectId]) => right[path] === objectId);
340
352
  }
341
353
 
354
+ // Two materialized candidate views describe the exact same reviewable content
355
+ // when their base, candidate tree, commit-state, and changed scope all match.
356
+ // This is what makes a retried native START's freshly-materialized duplicate
357
+ // view safely discardable in favor of an already-bound one (gentle-pi
358
+ // candidate-view rebind defect, ga#4085 / ga#4050): the comparison never
359
+ // trusts a caller-supplied claim, only Git-derived identity already computed
360
+ // by materializeCandidateView for both records.
361
+ function candidateRecordsShareIdentity(left: CandidateViewRecord, right: CandidateViewRecord): boolean {
362
+ return left.contributorRoot === right.contributorRoot &&
363
+ left.baseCommit === right.baseCommit &&
364
+ left.baseTree === right.baseTree &&
365
+ left.candidateTree === right.candidateTree &&
366
+ left.committedOnly === right.committedOnly &&
367
+ JSON.stringify(left.intendedUntracked ?? null) === JSON.stringify(right.intendedUntracked ?? null) &&
368
+ JSON.stringify(left.scope.paths) === JSON.stringify(right.scope.paths) &&
369
+ JSON.stringify(left.scope.modes) === JSON.stringify(right.scope.modes) &&
370
+ gitlinkMapsEqual(left.scope.gitlinks, right.scope.gitlinks) &&
371
+ JSON.stringify(left.scope.deletedPaths) === JSON.stringify(right.scope.deletedPaths);
372
+ }
373
+
342
374
  function decodeCanonicalPath(value: Buffer): string {
343
375
  const path = value.toString("utf8");
344
376
  if (!Buffer.from(path, "utf8").equals(value) || !isSafeCandidatePath(path)) {
@@ -603,11 +635,15 @@ function makeWritableForCleanup(path: string): void {
603
635
  }
604
636
 
605
637
  function candidateViewParent(commonDir: string): string {
606
- const parent = join(commonDir, "gentle-ai", "candidate-views");
638
+ const control = join(commonDir, "gentle-ai");
639
+ mkdirSync(control, { recursive: true, mode: 0o700 });
640
+ const controlStat = lstatSync(control);
641
+ if (!controlStat.isDirectory() || controlStat.isSymbolicLink() || realpathSync(control) !== control) throw new CandidateViewError("candidate view ancestor is unsafe");
642
+ const parent = join(control, "candidate-views");
607
643
  mkdirSync(parent, { recursive: true, mode: 0o700 });
608
644
  const stat = lstatSync(parent);
609
645
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CandidateViewError("candidate view parent is unsafe");
610
- return realpathSync(parent);
646
+ return assertCandidateOwnerParent(commonDir);
611
647
  }
612
648
 
613
649
  export interface ResolvedCandidateBase {
@@ -780,11 +816,29 @@ function isErrnoCode(error: unknown, code: string): boolean {
780
816
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
781
817
  }
782
818
 
819
+ function timestampSeconds(timestampNs: bigint): number {
820
+ // Node's utimes API takes Unix seconds as a number. Splitting the value keeps
821
+ // the seconds conversion exact; the explicit racy-clean backdate below is far
822
+ // larger than the fractional precision a Number can represent at this epoch.
823
+ return Number(timestampNs / 1_000_000_000n) + Number(timestampNs % 1_000_000_000n) / 1_000_000_000;
824
+ }
825
+
783
826
  function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
784
827
  const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
785
- const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
828
+ const entry = lstatSync(liveIndex, { bigint: true, throwIfNoEntry: false });
786
829
  if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
830
+ if (entry.mtimeNs < PRIVATE_INDEX_RACY_BACKDATE_NS) throw new CandidateViewError("candidate live Git index timestamp is too early for racy-clean protection");
787
831
  copyFileSync(liveIndex, indexPath);
832
+ // Git's racy-clean check compares index and tracked-file mtimes. copyFileSync
833
+ // gives the private index a new timestamp; restoring a Date can also lose
834
+ // nanoseconds. Backdate the bigint live timestamp instead, then verify the
835
+ // filesystem applied enough of that bounded interval for Git to refresh
836
+ // content rather than trusting a racy-clean stat match.
837
+ utimesSync(indexPath, timestampSeconds(entry.atimeNs), timestampSeconds(entry.mtimeNs - PRIVATE_INDEX_RACY_BACKDATE_NS));
838
+ const privateMtimeNs = lstatSync(indexPath, { bigint: true }).mtimeNs;
839
+ if (privateMtimeNs >= entry.mtimeNs || entry.mtimeNs - privateMtimeNs < PRIVATE_INDEX_RACY_SAFETY_NS) {
840
+ throw new CandidateViewError("candidate private Git index timestamp cannot preserve racy-clean protection");
841
+ }
788
842
  for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
789
843
  try {
790
844
  const sharedIndex = join(dirname(liveIndex), name);
@@ -809,6 +863,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
809
863
  ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
810
864
  : base;
811
865
  const parent = candidateViewParent(canonicalCommonDir);
866
+ sweepCandidateOwners(canonicalCommonDir, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup);
812
867
  const index = mkdtempSync(join(tmpdir(), "gentle-ai-candidate-index-"));
813
868
  const indexPath = join(index, "index");
814
869
  const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
@@ -832,6 +887,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
832
887
  }
833
888
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
834
889
  const root = join(parent, randomUUID());
890
+ const owner = createCandidateOwner(canonicalCommonDir, root);
835
891
  // The worktree is created under the same try/catch cleanup boundary as
836
892
  // the read-tree materialization that follows. addUnbornWorktree's
837
893
  // fallback path can register a worktree with `worktree add` and then
@@ -852,9 +908,9 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
852
908
  const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
853
909
  for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
854
910
  makeReadonly(root, entries);
855
- return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
911
+ return { owner, token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
856
912
  } catch (error) {
857
- try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
913
+ try { removeCandidateOwner(owner, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup); } catch { /* Preserve the marker and any partial worktree for conservative recovery. */ }
858
914
  throw error;
859
915
  }
860
916
  } finally {
@@ -862,7 +918,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
862
918
  }
863
919
  }
864
920
 
865
- function assertRecordSafe(record: CandidateViewRecord): void {
921
+ function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform = process.platform): void {
866
922
  try {
867
923
  const contributor = lstatSync(record.contributorRoot);
868
924
  if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
@@ -902,7 +958,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
902
958
  if (!item) throw new CandidateViewError("candidate view entry is missing or moved");
903
959
  if (entry.mode === "120000") {
904
960
  if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink is unsafe or changed");
905
- } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || ((item.mode & 0o111) !== (entry.mode === "100755" ? 0o111 : 0))) {
961
+ } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || !hasExpectedExecutableBits(item.mode, entry.mode, platform)) {
906
962
  throw new CandidateViewError("candidate view entry is unsafe, writable, or has a changed mode");
907
963
  }
908
964
  const actualHash = entryContentHash(root, entry);
@@ -913,8 +969,10 @@ function assertRecordSafe(record: CandidateViewRecord): void {
913
969
  export class CandidateViewRegistry {
914
970
  private readonly records = new Map<string, CandidateViewRecord>();
915
971
  private readonly gitExecutor: CandidateGitExecutor;
916
- constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
972
+ private readonly platform: NodeJS.Platform;
973
+ constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor, platform: NodeJS.Platform = process.platform) {
917
974
  this.gitExecutor = gitExecutor;
975
+ this.platform = platform;
918
976
  }
919
977
  // Lifecycle state is scoped to the canonical target worktree as well as the
920
978
  // provider lineage. Lineage text is repository-local and may legitimately be
@@ -1000,15 +1058,25 @@ export class CandidateViewRegistry {
1000
1058
  const key = this.lineageKey(contributorRoot, lineageId);
1001
1059
  const token = this.lineages.get(key);
1002
1060
  const record = token === undefined ? undefined : this.records.get(token);
1003
- if (record !== undefined) assertRecordSafe(record);
1061
+ if (record !== undefined) assertRecordSafe(record, this.platform);
1004
1062
  }
1005
1063
 
1006
1064
  create(request: CreateCandidateViewRequest): CandidateView {
1007
1065
  return this.createOrReuse(request);
1008
1066
  }
1009
1067
 
1068
+ sweepOrphans(contributorRoot: string): void {
1069
+ try {
1070
+ const cwd = realpathSync(contributorRoot);
1071
+ const common = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"], process.env, this.gitExecutor)));
1072
+ sweepCandidateOwners(common, (args) => git(common, args, process.env, this.gitExecutor), makeWritableForCleanup);
1073
+ } catch { /* Non-repositories and unavailable ownership are preserved. */ }
1074
+ }
1075
+
1010
1076
  cleanupAll(): void {
1011
- for (const token of [...this.records.keys()]) this.cleanup(token);
1077
+ for (const token of [...this.records.keys()]) {
1078
+ try { this.cleanup(token); } catch { /* Continue other owned views; failed records remain retryable. */ }
1079
+ }
1012
1080
  }
1013
1081
 
1014
1082
  createOrReuse(request: CreateCandidateViewRequest): CandidateView {
@@ -1017,7 +1085,7 @@ export class CandidateViewRegistry {
1017
1085
  const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
1018
1086
  const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
1019
1087
  const existing = token === undefined ? undefined : this.records.get(token);
1020
- if (existing) { assertRecordSafe(existing); return this.expose(existing); }
1088
+ if (existing) { assertRecordSafe(existing, this.platform); return this.expose(existing); }
1021
1089
  const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
1022
1090
  this.records.set(record.token, record);
1023
1091
  if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
@@ -1030,6 +1098,32 @@ export class CandidateViewRegistry {
1030
1098
 
1031
1099
  bindCurrent(request: BindCandidateViewRequest): void {
1032
1100
  const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
1101
+ const candidate = this.records.get(request.token);
1102
+ const key = candidate === undefined ? undefined : this.lineageKey(candidate.contributorRoot, request.lineageId);
1103
+ const existingToken = key === undefined ? undefined : this.lineages.get(key);
1104
+ // A retried native START for a lineage this controller already bound
1105
+ // (native reports it "resumed") re-materializes a fresh, content-
1106
+ // identical candidate view under a new token before it reaches here.
1107
+ // Rebinding that duplicate to the same lineage key used to fail closed
1108
+ // with "candidate view lineage binding is missing or ambiguous" even
1109
+ // though nothing is actually ambiguous: it is the exact same reviewable
1110
+ // content, re-verified from Git. Discard the redundant duplicate and
1111
+ // keep the already-bound view current instead of failing the retry
1112
+ // (gentle-pi candidate-view rebind defect, ga#4085 / ga#4050).
1113
+ if (candidate !== undefined && existingToken !== undefined && existingToken !== request.token) {
1114
+ const existing = this.records.get(existingToken);
1115
+ let existingSafe = false;
1116
+ if (existing !== undefined && existing.lineageId === request.lineageId) {
1117
+ try { assertRecordSafe(existing, this.platform); existingSafe = true; } catch { existingSafe = false; }
1118
+ }
1119
+ if (existing !== undefined && existingSafe && candidateRecordsShareIdentity(existing, candidate)) {
1120
+ existing.selectedLenses = selectedLenses;
1121
+ this.remove(candidate);
1122
+ this.forget(candidate);
1123
+ this.current.set(existing.contributorRoot, { lineageId: request.lineageId, token: existingToken });
1124
+ return;
1125
+ }
1126
+ }
1033
1127
  const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
1034
1128
  this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
1035
1129
  }
@@ -1043,7 +1137,7 @@ export class CandidateViewRegistry {
1043
1137
  const record = this.records.get(request.token);
1044
1138
  if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
1045
1139
  if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1046
- assertRecordSafe(record);
1140
+ assertRecordSafe(record, this.platform);
1047
1141
  this.assertCurrentBindingMatchesLiveCandidate(record);
1048
1142
  this.bindCurrent(request);
1049
1143
  }
@@ -1085,7 +1179,7 @@ export class CandidateViewRegistry {
1085
1179
  const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
1086
1180
  if (existing) {
1087
1181
  if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
1088
- assertRecordSafe(existing);
1182
+ assertRecordSafe(existing, this.platform);
1089
1183
  return this.expose(existing);
1090
1184
  }
1091
1185
  const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
@@ -1115,8 +1209,8 @@ export class CandidateViewRegistry {
1115
1209
  if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
1116
1210
  throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
1117
1211
  }
1118
- assertRecordSafe(replacement);
1119
- if (current) assertRecordSafe(current);
1212
+ assertRecordSafe(replacement, this.platform);
1213
+ if (current) assertRecordSafe(current, this.platform);
1120
1214
  if (
1121
1215
  replacement.contributorRoot !== projection.contributorRoot ||
1122
1216
  replacement.baseCommit !== projection.baseCommit ||
@@ -1177,7 +1271,7 @@ export class CandidateViewRegistry {
1177
1271
  const record = this.records.get(token);
1178
1272
  const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
1179
1273
  if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1180
- assertRecordSafe(record);
1274
+ assertRecordSafe(record, this.platform);
1181
1275
  record.lineageId = lineageId;
1182
1276
  record.selectedLenses = selectedLenses;
1183
1277
  this.lineages.set(key, record.token);
@@ -1276,21 +1370,18 @@ export class CandidateViewRegistry {
1276
1370
  * correction.
1277
1371
  */
1278
1372
  rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1279
- const root = this.canonicalRoot(contributorRoot);
1280
- const key = this.lineageKey(root, lineageId);
1281
- const staleToken = this.lineages.get(key);
1282
- const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1283
- this.projections.delete(key);
1284
- if (stale !== undefined) {
1285
- this.remove(stale);
1286
- this.forget(stale);
1287
- }
1288
- return this.restoreForFinalizeFromNative(lineageId, root, descriptor);
1373
+ return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1289
1374
  }
1290
1375
 
1376
+ /**
1377
+ * Restores a lineage's FINALIZE binding (gentle-pi #185): the stale entry
1378
+ * is only detached, not destroyed, so a failed restore can undo it.
1379
+ */
1291
1380
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1292
1381
  const root = this.canonicalRoot(contributorRoot);
1293
1382
  const key = this.lineageKey(root, lineageId);
1383
+ const staleToken = this.lineages.get(key), staleProjection = this.projections.get(key);
1384
+ this.lineages.delete(key); this.projections.delete(key);
1294
1385
  let projectionRestored = false;
1295
1386
  let record: CandidateViewRecord | undefined;
1296
1387
  try {
@@ -1308,6 +1399,8 @@ export class CandidateViewRegistry {
1308
1399
  if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
1309
1400
  this.records.set(record.token, record);
1310
1401
  this.bindRecord(record.token, lineageId, []);
1402
+ const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1403
+ if (stale !== undefined) { this.remove(stale); this.forget(stale); }
1311
1404
  return this.expose(record);
1312
1405
  } catch (error) {
1313
1406
  if (projectionRestored) this.projections.delete(key);
@@ -1315,6 +1408,8 @@ export class CandidateViewRegistry {
1315
1408
  this.forget(record);
1316
1409
  this.remove(record);
1317
1410
  }
1411
+ if (staleToken !== undefined) this.lineages.set(key, staleToken);
1412
+ if (staleProjection !== undefined) this.projections.set(key, staleProjection);
1318
1413
  throw error;
1319
1414
  }
1320
1415
  }
@@ -1375,7 +1470,7 @@ export class CandidateViewRegistry {
1375
1470
  const token = this.lineages.get(key);
1376
1471
  const record = token === undefined ? undefined : this.records.get(token);
1377
1472
  if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
1378
- assertRecordSafe(record);
1473
+ assertRecordSafe(record, this.platform);
1379
1474
  return this.expose(record);
1380
1475
  }
1381
1476
 
@@ -1426,7 +1521,7 @@ export class CandidateViewRegistry {
1426
1521
  const current = this.currentBinding(contributorRoot);
1427
1522
  const record = this.records.get(current.token);
1428
1523
  if (!record || record.lineageId !== current.lineageId || this.lineages.get(this.lineageKey(current.root, current.lineageId)) !== current.token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
1429
- assertRecordSafe(record);
1524
+ assertRecordSafe(record, this.platform);
1430
1525
  this.assertCurrentBindingMatchesLiveCandidate(record);
1431
1526
  if (!lenses.every((lens) => record.selectedLenses?.includes(lens as ReviewLens))) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected", "current-binding-lens-unselected");
1432
1527
  return lenses.map(() => this.expose(record));
@@ -1455,7 +1550,7 @@ export class CandidateViewRegistry {
1455
1550
  const token = this.lineages.get(key);
1456
1551
  const record = token === undefined ? undefined : this.records.get(token);
1457
1552
  if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
1458
- assertRecordSafe(record);
1553
+ assertRecordSafe(record, this.platform);
1459
1554
  return this.expose(record);
1460
1555
  }
1461
1556
 
@@ -1476,10 +1571,7 @@ export class CandidateViewRegistry {
1476
1571
 
1477
1572
  private remove(record: CandidateViewRecord): void {
1478
1573
  if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
1479
- try { makeWritableForCleanup(record.root); } catch {}
1480
- try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {}
1481
- // Git removes worktree metadata; physical removal remains this owner's duty.
1482
- rmSync(record.root, { recursive: true, force: true });
1574
+ removeCandidateOwner(record.owner, (args) => git(record.commonDir, args, process.env, record.gitExecutor), makeWritableForCleanup);
1483
1575
  }
1484
1576
 
1485
1577
  private forget(record: CandidateViewRecord): void {
@@ -1511,7 +1603,7 @@ export class CandidateViewRegistry {
1511
1603
  modes: record.scope.modes,
1512
1604
  gitlinks: record.scope.gitlinks,
1513
1605
  deletedPaths: record.scope.deletedPaths,
1514
- verify: () => assertRecordSafe(record),
1606
+ verify: () => assertRecordSafe(record, this.platform),
1515
1607
  cleanup: () => this.cleanup(record.token),
1516
1608
  };
1517
1609
  }
@@ -0,0 +1,65 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ReviewConsentEnvelope, ReviewConsentV3 } from "./review-integration-v2.ts";
3
+
4
+ export const HOST_REVIEW_SESSION_PERMISSION_LABEL = "Run this review and allow reviews for this Pi session";
5
+
6
+ const HOST_REVIEW_SESSION_PERMISSION_EFFECT =
7
+ "Runs the provider's exact grant for this frozen candidate, then lets the Pi host use each later candidate's fresh validated provider grant once while this exact Pi session and canonical Git repository identity, including sibling worktrees, remain active. Reload keeps it; new, resume, fork, quit, process restart, or explicit revocation ends it. It grants no verdict, acknowledgement, maintenance, delivery, or cross-repository authority.";
8
+
9
+ export type ReviewConsentUiSelection =
10
+ | { kind: "provider"; answer: "granted" | "declined" }
11
+ | { kind: "host-session" };
12
+
13
+ export interface ReviewConsentUiModel {
14
+ readonly title: string;
15
+ readonly options: readonly [string, string, string];
16
+ }
17
+
18
+ export function isPiConsentV3(consent: ReviewConsentEnvelope): consent is ReviewConsentV3 {
19
+ return consent.schema === "gentle-ai.review-integration.consent/v3" && consent.agent === "pi";
20
+ }
21
+
22
+ export function formatReviewConsentUi(consent: ReviewConsentV3): ReviewConsentUiModel {
23
+ const evidence = consent.riskEvidence.map((item) => `- ${item}`).join("\n");
24
+ const title = [
25
+ consent.headline,
26
+ "",
27
+ `Reason: ${consent.reason}`,
28
+ `Value: ${consent.value}`,
29
+ `Risk: ${consent.riskLevel}; ${consent.changedFiles} changed file(s), ${consent.changedLines} changed line(s).`,
30
+ `Target: ${consent.targetIdentity}`,
31
+ `Projection: ${consent.projection}`,
32
+ "Risk evidence:",
33
+ evidence,
34
+ "",
35
+ "Ownership: The first two actions are provider-owned and apply only to this candidate. The third action is owned by the Pi host and controls only an in-memory permission for this exact Pi session and canonical Git repository identity, including sibling worktrees.",
36
+ "",
37
+ `Off-path note: ${consent.offPath.note}`,
38
+ `Off-path command: ${consent.offPath.command}`,
39
+ ].join("\n");
40
+ return {
41
+ title,
42
+ options: [
43
+ `1. ${consent.choices[0].label}\nEffect: ${consent.choices[0].effect}`,
44
+ `2. ${consent.choices[1].label}\nEffect: ${consent.choices[1].effect}`,
45
+ `3. ${HOST_REVIEW_SESSION_PERMISSION_LABEL}\nEffect: ${HOST_REVIEW_SESSION_PERMISSION_EFFECT}`,
46
+ ],
47
+ };
48
+ }
49
+
50
+ export async function presentReviewConsentUi(
51
+ context: ExtensionContext,
52
+ consent: ReviewConsentEnvelope,
53
+ ): Promise<ReviewConsentUiSelection | undefined> {
54
+ if (!isPiConsentV3(consent)) return undefined;
55
+ const model = formatReviewConsentUi(consent);
56
+ try {
57
+ const selected = await context.ui.select(model.title, [...model.options]);
58
+ if (selected === model.options[0]) return { kind: "provider", answer: "granted" };
59
+ if (selected === model.options[1]) return { kind: "provider", answer: "declined" };
60
+ if (selected === model.options[2]) return { kind: "host-session" };
61
+ return undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
@@ -79,7 +79,10 @@ const RISK_LEVELS = ["low", "medium", "high"] as const;
79
79
  const REVIEW_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
80
80
  const RISK_REASON_CODES = ["configuration_change", "empty_content", "executable_change", "executable_mode", "hot_path", "large_change", "non_executable_only", "process_boundary", "process_scan_limit", "service_token", "shell_source"] as const;
81
81
  const RISK_SIGNALS = ["auth", "update", "security", "payments", "permissions", "shell_process"] as const;
82
- const STATUS_ACTIONS = ["start", "recover", "maintainer_action", "select_lineage", "repair_authority", "stop"] as const;
82
+ // "collect" and "execute" are the v2 envelope's projection of a live
83
+ // transaction whose next_transition is mandatory: the root action names the
84
+ // transition kind instead of reading as a terminal stop.
85
+ const STATUS_ACTIONS = ["start", "recover", "maintainer_action", "select_lineage", "repair_authority", "stop", "collect", "execute"] as const;
83
86
  const RECEIPT_STATUSES = ["expected_missing", "present", "publication_pending", "not_applicable"] as const;
84
87
  type ReviewReceiptStatus = (typeof RECEIPT_STATUSES)[number];
85
88
  export const REVIEW_STATUS_ACTION_DISPOSITION = {
@@ -385,6 +388,8 @@ export interface ReviewStatusFrozenV1 {
385
388
  tier: RiskLevel;
386
389
  originalChangedLines: number;
387
390
  correctionBudget: number;
391
+ /** Digest of the frozen changed-path manifest; binds manifest-less capture inputs (gentle-ai#3922). */
392
+ changedPathManifestSha256?: string;
388
393
  }
389
394
 
390
395
  export interface ReviewStatusReconciliationV1 {
@@ -545,6 +550,16 @@ export interface ReviewCollectInputV3 {
545
550
  validationRequest?: ReviewTargetedValidationRequestV1;
546
551
  }
547
552
 
553
+ // gentle-pi#627: the exact `gentle-ai sync` invocation that resolves a stale
554
+ // managed-asset set (STATUS next_transition stop, and START's preflight
555
+ // failure envelope both carry this same shape).
556
+ export interface ReviewManagedAssetsContinuationV1 {
557
+ operation: "sync";
558
+ command: string;
559
+ agent?: string;
560
+ staleAssets?: readonly string[];
561
+ }
562
+
548
563
  export interface ReviewNextTransitionV3 {
549
564
  kind: "execute" | "collect" | "stop";
550
565
  reasonCode: string;
@@ -552,6 +567,8 @@ export interface ReviewNextTransitionV3 {
552
567
  collect?: { inputs: readonly ReviewCollectInputV3[] };
553
568
  /** status/v5 only: the bounded correction plan request. */
554
569
  correctionRequest?: ReviewCorrectionPlanRequestV1;
570
+ /** stop only, reason_code managed_assets_outdated: the sync continuation. */
571
+ continuation?: ReviewManagedAssetsContinuationV1;
555
572
  }
556
573
 
557
574
  export interface ReviewStatusV3 {
@@ -705,6 +722,8 @@ export interface ReviewFailureV2 {
705
722
  causeCategory?: ReviewFailureCauseCategoryV2;
706
723
  cause?: string;
707
724
  context?: ReviewFailureContextV2;
725
+ /** code=managed_assets_outdated only: the sync continuation (gentle-pi#627). */
726
+ continuation?: ReviewManagedAssetsContinuationV1;
708
727
  raw: Readonly<Record<string, unknown>>;
709
728
  }
710
729
 
@@ -1639,8 +1658,11 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
1639
1658
  }
1640
1659
 
1641
1660
  if (captureOperation === "review.capture-result") {
1642
- if (input.artifact_subject === undefined || input.base_tree === undefined || input.candidate_tree === undefined || input.changed_path_manifest === undefined) {
1643
- throw new TypeError(`${label} requires artifact_subject, base_tree, candidate_tree, and changed_path_manifest`);
1661
+ // changed_path_manifest is optional here: the artifact subject's
1662
+ // changed_path_manifest_sha256 already binds the frozen manifest, and
1663
+ // gentle-ai stops inlining one copy per lens (gentle-ai#3922).
1664
+ if (input.artifact_subject === undefined || input.base_tree === undefined || input.candidate_tree === undefined) {
1665
+ throw new TypeError(`${label} requires artifact_subject, base_tree, and candidate_tree`);
1644
1666
  }
1645
1667
  if (schema !== "https://gentle-ai.dev/schema/review/reviewer/v1") throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/reviewer/v1`);
1646
1668
  } else if (input.artifact_subject !== undefined || input.base_tree !== undefined || input.candidate_tree !== undefined || input.changed_path_manifest !== undefined) {
@@ -1673,12 +1695,32 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
1673
1695
  };
1674
1696
  }
1675
1697
 
1698
+ // gentle-pi#627: gentle-ai's managed-assets continuation (STATUS next_transition
1699
+ // stop reason_code=managed_assets_outdated, and START's preflight failure
1700
+ // envelope) names the exact `gentle-ai sync` invocation that resolves a stale
1701
+ // managed-asset set without abandoning the frozen candidate. Decoded verbatim
1702
+ // from internal/cli/review_operation_contract.go's ReviewManagedAssetsContinuation.
1703
+ export function decodeReviewManagedAssetsContinuationV1(value: unknown, label: string): ReviewManagedAssetsContinuationV1 {
1704
+ const body = exactRecord(value, label, ["operation", "command"], ["agent", "stale_assets"]);
1705
+ const operation = enumeration(body.operation, ["sync"] as const, `${label}.operation`);
1706
+ const command = nonempty(body.command, `${label}.command`);
1707
+ const agent = body.agent === undefined ? undefined : nonempty(body.agent, `${label}.agent`);
1708
+ const staleAssets = body.stale_assets === undefined ? undefined : stringArray(body.stale_assets, `${label}.stale_assets`, { minimum: 1 });
1709
+ return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
1710
+ }
1711
+
1676
1712
  export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean } = {}): ReviewNextTransitionV3 {
1677
1713
  const v6 = options.v6 === true;
1678
1714
  const v5 = options.v5 === true || v6;
1679
- const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : [])]);
1715
+ const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation"]);
1680
1716
  const kind = enumeration(transition.kind, ["execute", "collect", "stop"] as const, "next_transition.kind");
1681
1717
  const reasonCode = text(transition.reason_code, "next_transition.reason_code", { minimum: 1, pattern: /^[a-z0-9_]+$/ });
1718
+ const continuation = transition.continuation === undefined ? undefined : decodeReviewManagedAssetsContinuationV1(transition.continuation, "next_transition.continuation");
1719
+ // gentle-pi#627: continuation is optional on every status version an older
1720
+ // gentle-ai may emit a managed_assets_outdated stop with no continuation at
1721
+ // all, and decode must not refuse the whole envelope for that. When present
1722
+ // it is only valid on a stop with this exact reason_code.
1723
+ if (continuation !== undefined && !(kind === "stop" && reasonCode === "managed_assets_outdated")) throw new TypeError("next_transition.continuation is only valid for a stop transition with reason_code managed_assets_outdated");
1682
1724
 
1683
1725
  // status/v5: the bounded correction plan request rides exactly its two
1684
1726
  // reason codes and never any other (vendored status-v5.schema.json).
@@ -1728,7 +1770,7 @@ export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boo
1728
1770
  return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
1729
1771
  }
1730
1772
  if (transition.execute !== undefined || transition.collect !== undefined) throw new TypeError("next_transition stop cannot carry a transition");
1731
- return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
1773
+ return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }), ...(continuation === undefined ? {} : { continuation }) };
1732
1774
  }
1733
1775
 
1734
1776
  // ---------------------------------------------------------------------------
@@ -1817,11 +1859,12 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
1817
1859
 
1818
1860
  let frozen: ReviewStatusFrozenV1 | undefined;
1819
1861
  if (body.frozen !== undefined) {
1820
- const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"]);
1862
+ const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"], ["changed_path_manifest_sha256"]);
1821
1863
  frozen = {
1822
1864
  tier: enumeration(source.tier, RISK_LEVELS, "status.frozen.tier"),
1823
1865
  originalChangedLines: integer(source.original_changed_lines, "status.frozen.original_changed_lines"),
1824
1866
  correctionBudget: integer(source.correction_budget, "status.frozen.correction_budget", 0, 200),
1867
+ ...(source.changed_path_manifest_sha256 === undefined ? {} : { changedPathManifestSha256: sha256(source.changed_path_manifest_sha256, "status.frozen.changed_path_manifest_sha256") }),
1825
1868
  };
1826
1869
  }
1827
1870
  if (authority?.version === REVIEW_AUTHORITY_VERSION.COMPACT_V2 && frozen === undefined) throw new TypeError("compact-v2 status requires frozen metadata");
@@ -2048,9 +2091,10 @@ function decodeFailureContext(value: unknown, label: string): ReviewFailureConte
2048
2091
  export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
2049
2092
  const body = exactRecord(value, "failure", [
2050
2093
  "schema", "contract", "operation", "phase", "code", "message", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "required_inputs", "next_action",
2051
- ], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context"]);
2094
+ ], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context", "continuation"]);
2052
2095
  requireIdentity(body, "gentle-ai.review-integration.failure/v2");
2053
2096
  const operation = enumeration(body.operation, FAILURE_OPERATIONS, "failure.operation");
2097
+ const code = text(body.code, "failure.code", { pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ });
2054
2098
 
2055
2099
  if (body.progress_identity !== undefined) {
2056
2100
  if (body.lineage_id === undefined || body.request_digest === undefined) throw new TypeError("failure.progress_identity requires lineage_id and request_digest");
@@ -2059,13 +2103,18 @@ export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
2059
2103
  if (body.request_digest !== undefined && operation === REVIEW_INTEGRATION_OPERATION.REPAIR && body.progress_identity === undefined) {
2060
2104
  throw new TypeError("failure.request_digest with review.repair requires progress_identity");
2061
2105
  }
2106
+ // gentle-pi#627: continuation is optional on every version — an older
2107
+ // gentle-ai may emit managed_assets_outdated with no continuation, and
2108
+ // decode must not refuse the whole envelope for that. When present it is
2109
+ // only valid on this exact code.
2110
+ if (body.continuation !== undefined && code !== "managed_assets_outdated") throw new TypeError("failure.continuation is only valid for code managed_assets_outdated");
2062
2111
 
2063
2112
  return {
2064
2113
  schema: "gentle-ai.review-integration.failure/v2",
2065
2114
  contract: REVIEW_INTEGRATION_CONTRACT,
2066
2115
  operation,
2067
2116
  phase: enumeration(body.phase, ["preflight", "pre_native", "native_running", "native_committed", "reconciliation"] as const, "failure.phase"),
2068
- code: text(body.code, "failure.code", { pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ }),
2117
+ code,
2069
2118
  message: text(body.message, "failure.message", { minimum: 1, maximum: 240, pattern: /^[^\r\n]+$/ }),
2070
2119
  mutationOutcome: enumeration(body.mutation_outcome, Object.values(REVIEW_MUTATION_OUTCOME), "failure.mutation_outcome"),
2071
2120
  authorityApplicability: enumeration(body.authority_applicability, Object.values(REVIEW_AUTHORITY_APPLICABILITY), "failure.authority_applicability"),
@@ -2079,6 +2128,7 @@ export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
2079
2128
  ...(body.cause_category === undefined ? {} : { causeCategory: text(body.cause_category, "failure.cause_category", { minimum: 1, pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ }) }),
2080
2129
  ...(body.cause === undefined ? {} : { cause: text(body.cause, "failure.cause", { minimum: 1, maximum: 4000, pattern: /^[^\r\n]+$/ }) }),
2081
2130
  ...(body.context === undefined ? {} : { context: decodeFailureContext(body.context, "failure.context") }),
2131
+ ...(body.continuation === undefined ? {} : { continuation: decodeReviewManagedAssetsContinuationV1(body.continuation, "failure.continuation") }),
2082
2132
  raw: body,
2083
2133
  };
2084
2134
  }
@@ -4,6 +4,7 @@ import type { ReviewLastEventClosureBinding, ReviewStatusV3 } from "./review-int
4
4
  export interface ReviewLastEventCaptureSelector {
5
5
  readonly baseRef?: string;
6
6
  readonly committedOnly?: true;
7
+ readonly agent?: "pi";
7
8
  }
8
9
 
9
10
  /**
@@ -14,3 +14,14 @@
14
14
 
15
15
  export const GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV = "GENTLE_PI_REVIEW_RELAY_CONTRACT";
16
16
  export const GENTLE_PI_REVIEW_RELAY_CONTRACT = "gentle-pi.review-relay/v1";
17
+
18
+ // The host is this extension: every process the session starts (pi's shell
19
+ // tools, Gentle Agents children) runs under it, so the handshake belongs in
20
+ // the session environment too, not only in the extension's own invocations.
21
+ // Without it the first `gentle-ai review status --agent pi` typed in the
22
+ // session shell fails closed (gentle-pi#550). Returns whether anything changed.
23
+ export function declareReviewRelayHandshake(env: NodeJS.ProcessEnv): boolean {
24
+ if (env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] === GENTLE_PI_REVIEW_RELAY_CONTRACT) return false;
25
+ env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] = GENTLE_PI_REVIEW_RELAY_CONTRACT;
26
+ return true;
27
+ }
@@ -55,8 +55,8 @@ export function reviewGitEnvironment(): NodeJS.ProcessEnv {
55
55
  const environment: NodeJS.ProcessEnv = {};
56
56
  for (const [key, value] of Object.entries(process.env)) if (!key.startsWith("GIT_")) environment[key] = value;
57
57
  environment.GIT_CONFIG_NOSYSTEM = "1";
58
- environment.GIT_CONFIG_GLOBAL = process.platform === "win32" ? "NUL" : "/dev/null";
59
- environment.GIT_CONFIG_SYSTEM = process.platform === "win32" ? "NUL" : "/dev/null";
58
+ environment.GIT_CONFIG_GLOBAL = "/dev/null";
59
+ environment.GIT_CONFIG_SYSTEM = "/dev/null";
60
60
  environment.GIT_OPTIONAL_LOCKS = "0";
61
61
  environment.LC_ALL = "C";
62
62
  environment.LANG = "C";