gentle-pi 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -1,13 +1,54 @@
1
1
  import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } 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
+ import { gunzipSync, gzipSync } from "node:zlib";
7
8
 
8
9
  const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
9
10
  export type ReviewLens = (typeof REVIEW_LENS)[number];
10
11
  const CANDIDATE_GIT_TIMEOUT_MS = 10_000;
12
+ const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000;
13
+ const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS";
14
+ const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
15
+
16
+ // Candidate views may materialize full repository trees. Large repositories can
17
+ // raise this bounded deadline without creating an unbounded child process.
18
+ function resolveCandidateGitTimeoutMs(environment: NodeJS.ProcessEnv = process.env): number {
19
+ const value = environment[CANDIDATE_GIT_TIMEOUT_ENV];
20
+ if (value === undefined || !/^[1-9]\d*$/.test(value)) return CANDIDATE_GIT_TIMEOUT_MS;
21
+ const parsed = Number(value);
22
+ return Number.isSafeInteger(parsed) && parsed <= CANDIDATE_GIT_TIMEOUT_MAX_MS
23
+ ? parsed
24
+ : CANDIDATE_GIT_TIMEOUT_MS;
25
+ }
26
+
27
+ function isCandidateGitTimeoutMs(value: number): boolean {
28
+ return Number.isSafeInteger(value) && value > 0 && value <= CANDIDATE_GIT_TIMEOUT_MAX_MS;
29
+ }
30
+ const CANDIDATE_VIEW_DIAGNOSTIC_PHASE = "candidate-view";
31
+ const CANDIDATE_VIEW_GIT_FAILURE_CATEGORY = {
32
+ TIMEOUT: "timeout",
33
+ OUTPUT_LIMIT: "output-limit",
34
+ GIT_FAILURE: "git-failure",
35
+ } as const;
36
+ export type CandidateViewGitFailureCategory = (typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY)[keyof typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY];
37
+ const CANDIDATE_GIT_SUBCOMMAND = {
38
+ ADD: "add",
39
+ CHECKOUT_INDEX: "checkout-index",
40
+ DIFF: "diff",
41
+ FOR_EACH_REF: "for-each-ref",
42
+ LOG: "log",
43
+ LS_FILES: "ls-files",
44
+ LS_TREE: "ls-tree",
45
+ READ_TREE: "read-tree",
46
+ REV_PARSE: "rev-parse",
47
+ WORKTREE: "worktree",
48
+ WRITE_TREE: "write-tree",
49
+ OTHER: "other",
50
+ } as const;
51
+ type CandidateGitSubcommand = (typeof CANDIDATE_GIT_SUBCOMMAND)[keyof typeof CANDIDATE_GIT_SUBCOMMAND];
11
52
 
12
53
  export type CandidateGitExecutor = (file: string, arguments_: readonly string[], options: ExecFileSyncOptions) => string | Buffer;
13
54
  const defaultCandidateGitExecutor: CandidateGitExecutor = (file, arguments_, options) => execFileSync(file, arguments_, options);
@@ -16,6 +57,20 @@ const CONTROLLER_CANDIDATE_VIEW_HEADING = "## Controller-owned candidate view";
16
57
  const MAX_SUBAGENT_TASK_LENGTH = 16_384;
17
58
  const MAX_SUBAGENT_CONTEXT_LENGTH = 4_096;
18
59
  const MAX_CANDIDATE_CONTEXT_LENGTH = 4_096;
60
+ const MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES = 1024 * 1024;
61
+ const MAX_CANDIDATE_SCOPE_PAGE_BYTES = 16 * 1024;
62
+ const MAX_CANDIDATE_SCOPE_PAGE_ENTRIES = 128;
63
+ const CANDIDATE_CONTEXT_MANIFEST = {
64
+ VERSION: 1,
65
+ } as const;
66
+ const CANDIDATE_CONTEXT_MODE = {
67
+ REGULAR: "100644",
68
+ EXECUTABLE: "100755",
69
+ SYMLINK: "120000",
70
+ GITLINK: "160000",
71
+ DELETED: "deleted",
72
+ } as const;
73
+ export type CandidateContextMode = (typeof CANDIDATE_CONTEXT_MODE)[keyof typeof CANDIDATE_CONTEXT_MODE];
19
74
  const SUBAGENT_RUN_KEYS = new Set(["agent", "agents", "task", "context", "mode"]);
20
75
 
21
76
  interface CandidateTreeEntry {
@@ -48,6 +103,7 @@ interface CandidateViewRecord {
48
103
  baseTree: string;
49
104
  candidateTree: string;
50
105
  committedOnly: boolean;
106
+ intendedUntracked?: readonly string[];
51
107
  entries: readonly CandidateViewEntry[];
52
108
  gitlinks: readonly CandidateGitlink[];
53
109
  scope: CandidateViewScope;
@@ -59,10 +115,12 @@ interface CandidateViewRecord {
59
115
  export interface CandidateView {
60
116
  token: string;
61
117
  root: string;
118
+ contributorRoot: string;
62
119
  baseCommit: string;
63
120
  baseTree: string;
64
121
  candidateTree: string;
65
122
  committedOnly: boolean;
123
+ intendedUntracked?: readonly string[];
66
124
  paths: readonly string[];
67
125
  modes: Readonly<Record<string, string>>;
68
126
  gitlinks: Readonly<Record<string, string>>;
@@ -71,12 +129,40 @@ export interface CandidateView {
71
129
  cleanup(): void;
72
130
  }
73
131
 
132
+ export interface CandidateContextManifest {
133
+ version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
134
+ scopeByMode: Readonly<Record<string, readonly string[]>>;
135
+ gitlinks: Readonly<Record<string, string>>;
136
+ }
137
+
138
+ export interface DecodedCandidateContextManifest {
139
+ manifest: CandidateContextManifest;
140
+ bytes: Buffer;
141
+ sha256: string;
142
+ }
143
+
144
+ export interface CandidateContextPageEntry {
145
+ path: string;
146
+ mode: CandidateContextMode;
147
+ gitlinkObjectId?: string;
148
+ }
149
+
150
+ export interface CandidateContextPage {
151
+ version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
152
+ sha256: string;
153
+ cursor: number;
154
+ totalPaths: number;
155
+ entries: readonly CandidateContextPageEntry[];
156
+ nextCursor?: number;
157
+ }
158
+
74
159
  export interface FrozenCandidateProjection {
75
160
  contributorRoot: string;
76
161
  baseCommit: string;
77
162
  baseTree: string;
78
163
  candidateTree: string;
79
164
  committedOnly: boolean;
165
+ intendedUntracked?: readonly string[];
80
166
  paths: readonly string[];
81
167
  modes: Readonly<Record<string, string>>;
82
168
  gitlinks: Readonly<Record<string, string>>;
@@ -87,6 +173,8 @@ export interface CreateCandidateViewRequest {
87
173
  contributorRoot: string;
88
174
  baseRef?: string;
89
175
  committedOnly?: boolean;
176
+ /** Undefined keeps legacy all-untracked capture; [] excludes untracked files. */
177
+ intendedUntracked?: readonly string[];
90
178
  replayKey?: string;
91
179
  }
92
180
 
@@ -103,6 +191,7 @@ export interface AuthoritativeReviewingCandidateState {
103
191
  baseTree: string;
104
192
  candidateTree: string;
105
193
  committedOnly?: boolean;
194
+ intendedUntracked?: readonly string[];
106
195
  paths: readonly string[];
107
196
  modes: Readonly<Record<string, string>>;
108
197
  gitlinks?: Readonly<Record<string, string>>;
@@ -130,22 +219,96 @@ export interface NativeCandidateProjectionDescriptor {
130
219
  providerManifestHashVerified?: true;
131
220
  }
132
221
 
222
+ export interface CandidateViewDiagnostic {
223
+ phase: typeof CANDIDATE_VIEW_DIAGNOSTIC_PHASE;
224
+ category: CandidateViewGitFailureCategory;
225
+ git_subcommand: CandidateGitSubcommand;
226
+ timeout_ms: number;
227
+ max_buffer_bytes: number;
228
+ message: string;
229
+ }
230
+
133
231
  export class CandidateViewError extends Error {
134
232
  readonly reason: string;
135
- constructor(message: string, reason = "candidate-view-invalid") {
233
+ readonly diagnostics?: CandidateViewDiagnostic;
234
+ constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) {
136
235
  super(message);
137
236
  this.name = "CandidateViewError";
138
237
  this.reason = reason;
238
+ this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics);
139
239
  }
140
240
  }
141
241
 
242
+ function candidateGitSubcommand(arguments_: readonly string[]): CandidateGitSubcommand {
243
+ switch (arguments_[0]) {
244
+ case CANDIDATE_GIT_SUBCOMMAND.ADD: return CANDIDATE_GIT_SUBCOMMAND.ADD;
245
+ case CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX: return CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX;
246
+ case CANDIDATE_GIT_SUBCOMMAND.DIFF: return CANDIDATE_GIT_SUBCOMMAND.DIFF;
247
+ case CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF: return CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF;
248
+ case CANDIDATE_GIT_SUBCOMMAND.LOG: return CANDIDATE_GIT_SUBCOMMAND.LOG;
249
+ case CANDIDATE_GIT_SUBCOMMAND.LS_FILES: return CANDIDATE_GIT_SUBCOMMAND.LS_FILES;
250
+ case CANDIDATE_GIT_SUBCOMMAND.LS_TREE: return CANDIDATE_GIT_SUBCOMMAND.LS_TREE;
251
+ case CANDIDATE_GIT_SUBCOMMAND.READ_TREE: return CANDIDATE_GIT_SUBCOMMAND.READ_TREE;
252
+ case CANDIDATE_GIT_SUBCOMMAND.REV_PARSE: return CANDIDATE_GIT_SUBCOMMAND.REV_PARSE;
253
+ case CANDIDATE_GIT_SUBCOMMAND.WORKTREE: return CANDIDATE_GIT_SUBCOMMAND.WORKTREE;
254
+ case CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE: return CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE;
255
+ default: return CANDIDATE_GIT_SUBCOMMAND.OTHER;
256
+ }
257
+ }
258
+
259
+ function candidateGitDiagnosticMessage(category: CandidateViewGitFailureCategory, subcommand: CandidateGitSubcommand, timeoutMs: number): string {
260
+ if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT) return `candidate-view Git command ${subcommand} timed out after ${timeoutMs}ms; inspect the candidate state before any new START`;
261
+ if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT) return `candidate-view Git command ${subcommand} exceeded the ${CANDIDATE_GIT_MAX_BUFFER_BYTES}-byte output limit; inspect the candidate state before any new START`;
262
+ return `candidate-view Git command ${subcommand} failed; inspect the candidate state before any new START`;
263
+ }
264
+
265
+ function candidateGitDiagnostic(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewDiagnostic {
266
+ const git_subcommand = candidateGitSubcommand(arguments_);
267
+ return Object.freeze({
268
+ phase: CANDIDATE_VIEW_DIAGNOSTIC_PHASE,
269
+ category,
270
+ git_subcommand,
271
+ timeout_ms: timeoutMs,
272
+ max_buffer_bytes: CANDIDATE_GIT_MAX_BUFFER_BYTES,
273
+ message: candidateGitDiagnosticMessage(category, git_subcommand, timeoutMs),
274
+ });
275
+ }
276
+
277
+ function sanitizeCandidateViewDiagnostic(diagnostics: CandidateViewDiagnostic): CandidateViewDiagnostic | undefined {
278
+ const { phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message } = diagnostics;
279
+ if (
280
+ phase !== CANDIDATE_VIEW_DIAGNOSTIC_PHASE ||
281
+ !Object.values(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY).includes(category) ||
282
+ !Object.values(CANDIDATE_GIT_SUBCOMMAND).includes(git_subcommand) ||
283
+ !isCandidateGitTimeoutMs(timeout_ms) ||
284
+ max_buffer_bytes !== CANDIDATE_GIT_MAX_BUFFER_BYTES ||
285
+ message !== candidateGitDiagnosticMessage(category, git_subcommand, timeout_ms)
286
+ ) return undefined;
287
+ return Object.freeze({ phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message });
288
+ }
289
+
290
+ function candidateGitFailure(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewError {
291
+ const diagnostics = candidateGitDiagnostic(category, arguments_, timeoutMs);
292
+ return new CandidateViewError(diagnostics.message, `candidate-view-${category}`, diagnostics);
293
+ }
294
+
142
295
  function candidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, encoding: "utf8" | "buffer", executor: CandidateGitExecutor): string | Buffer {
296
+ const timeoutMs = resolveCandidateGitTimeoutMs(env);
143
297
  try {
144
- return executor("git", arguments_, { cwd, encoding, env, stdio: ["ignore", "pipe", "pipe"], timeout: CANDIDATE_GIT_TIMEOUT_MS, windowsHide: true });
298
+ return executor("git", arguments_, {
299
+ cwd,
300
+ encoding,
301
+ env,
302
+ stdio: ["ignore", "pipe", "pipe"],
303
+ timeout: timeoutMs,
304
+ maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES,
305
+ windowsHide: true,
306
+ });
145
307
  } catch (error) {
146
- const detail = error as NodeJS.ErrnoException & { stderr?: Buffer; killed?: boolean };
147
- if (detail.code === "ETIMEDOUT" || detail.killed === true) throw new CandidateViewError(`candidate view Git operation timed out after ${CANDIDATE_GIT_TIMEOUT_MS}ms`);
148
- throw new CandidateViewError(`candidate view Git operation failed: ${detail.stderr?.toString("utf8").trim() || detail.message || "unknown Git error"}`);
308
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean };
309
+ if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
310
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
311
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
149
312
  }
150
313
  }
151
314
 
@@ -260,23 +423,30 @@ export interface ChangedPathEntry {
260
423
  readonly modeOnly: boolean;
261
424
  }
262
425
 
426
+ //
427
+ // gentle-pi#518: both derivations diff with `--no-renames`, exactly as the
428
+ // native provider does. A rename is then its source deletion plus its
429
+ // destination addition, one path each, so the projection identity Pi freezes
430
+ // is the identity native STATUS projects. Rename detection here previously
431
+ // kept only the destination, and an exact staged rename was rejected before
432
+ // native admission with candidate-target-projection-drift.
263
433
  export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] {
264
- const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
434
+ const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
265
435
  const entries: ChangedPathEntry[] = [];
266
436
  for (let index = 0; index < tokens.length;) {
267
437
  const header = tokens[index++]?.toString("ascii");
268
438
  if (header === undefined) break;
269
- // `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`
270
- const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT]|R[0-9]{3})$/.exec(header);
439
+ // `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`; with rename
440
+ // detection off, Git never emits a two-path R or C record here.
441
+ const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT])$/.exec(header);
271
442
  if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid");
272
443
  const [, oldMode, newMode, oldSha, newSha, status] = match;
273
- const firstPath = tokens[index++];
274
- if (firstPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
275
- // A rename emits both the old and the new path; the new one is the scope.
276
- const path = status.startsWith("R") ? decodeCanonicalPath(tokens[index++] ?? firstPath) : decodeCanonicalPath(firstPath);
444
+ const rawPath = tokens[index++];
445
+ if (rawPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
446
+ const path = decodeCanonicalPath(rawPath);
277
447
  entries.push(Object.freeze({
278
448
  path,
279
- status: status.startsWith("R") ? "A" : status,
449
+ status,
280
450
  oldMode,
281
451
  newMode,
282
452
  deleted: status === "D",
@@ -348,24 +518,20 @@ function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescripto
348
518
  }
349
519
  }
350
520
 
351
- function deriveChangedScope(cwd: string, baseCommit: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
521
+ function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
352
522
  const present = new Map(entries.map((entry) => [entry.path, entry]));
353
523
  const paths = new Set<string>();
354
524
  const deleted = new Set<string>();
355
- const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseCommit, candidateTree], executor);
525
+ // `--no-renames` mirrors the native projection: a rename is one deleted
526
+ // path plus one added path (gentle-pi#518), and every record carries
527
+ // exactly one path.
528
+ const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
356
529
  for (let index = 0; index < tokens.length;) {
357
530
  const status = tokens[index++]?.toString("ascii");
358
- if (status === undefined || !/^(?:[AMDT]|R[0-9]{3})$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
359
- const oldPath = tokens[index++];
360
- if (oldPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete");
361
- const firstPath = decodeCanonicalPath(oldPath);
362
- const path = status.startsWith("R")
363
- ? (() => {
364
- const newPath = tokens[index++];
365
- if (newPath === undefined) throw new CandidateViewError("candidate scope rename output is incomplete");
366
- return decodeCanonicalPath(newPath);
367
- })()
368
- : firstPath;
531
+ if (status === undefined || !/^[AMDT]$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
532
+ const rawPath = tokens[index++];
533
+ if (rawPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete");
534
+ const path = decodeCanonicalPath(rawPath);
369
535
  if (paths.has(path) || deleted.has(path)) throw new CandidateViewError("candidate scope Git output contains duplicate paths");
370
536
  if (status === "D") {
371
537
  if (present.has(path)) throw new CandidateViewError("candidate scope deletion is present in the candidate tree");
@@ -468,8 +634,60 @@ function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.Pr
468
634
  return [...new Set(candidates)].filter((candidate) => refs.has(candidate));
469
635
  }
470
636
 
637
+ // Runs a probe command that may exit nonzero as an expected signal (absent
638
+ // ref, detached HEAD). Returns the exit status and trimmed stdout. Timeout,
639
+ // output-limit, and unexpected Git failures propagate as sanitized
640
+ // CandidateViewError diagnostics, same as candidateGit.
641
+ function probeCandidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): { status: number; stdout: string } {
642
+ const timeoutMs = resolveCandidateGitTimeoutMs(env);
643
+ try {
644
+ const stdout = executor("git", arguments_, {
645
+ cwd, encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"],
646
+ timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true,
647
+ }) as string;
648
+ return { status: 0, stdout: stdout.trim() };
649
+ } catch (error) {
650
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
651
+ if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
652
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
653
+ if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
654
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
655
+ }
656
+ }
657
+
658
+ // An unborn repository's HEAD is a symbolic ref to a branch with no commits.
659
+ // `symbolic-ref --quiet HEAD` exits nonzero for a detached HEAD (not unborn).
660
+ // `rev-parse --verify --quiet <ref>` distinguishes a valid unborn (status 1,
661
+ // ref absent) from a broken symbolic ref (exit 0, ref OID text exists even
662
+ // when the object is missing). Any other status (128, etc.) signals
663
+ // corruption or an I/O failure, so it fails closed instead of masquerading as
664
+ // an unborn repository.
665
+ function isUnbornSymbolicHead(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): boolean {
666
+ const symbolic = probeCandidateGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], env, executor);
667
+ if (symbolic.status !== 0) return false;
668
+ const refProbeArguments = ["rev-parse", "--verify", "--quiet", symbolic.stdout];
669
+ const refProbe = probeCandidateGit(cwd, refProbeArguments, env, executor);
670
+ if (refProbe.status === 1) return true;
671
+ if (refProbe.status === 0) return false;
672
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, refProbeArguments, resolveCandidateGitTimeoutMs(env));
673
+ }
674
+
675
+ // Derives Git's repository-native empty tree without hardcoding the SHA-1 id,
676
+ // so a sha256 repository derives its own empty-tree object id. `mktree` with
677
+ // ignored stdin reads empty input and writes the empty tree object.
678
+ function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string {
679
+ return git(cwd, ["mktree"], env, executor);
680
+ }
681
+
471
682
  function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase {
472
683
  const selector = baseRef ?? "HEAD";
684
+ // An unborn repository has a symbolic HEAD pointing at a branch with no
685
+ // commits yet. Its review base is Git's repository-native empty tree, not a
686
+ // missing or malformed commit. Only the default/HEAD selector is entitled to
687
+ // the empty-tree base; a detached HEAD over a missing commit stays fail-closed.
688
+ if (selector === "HEAD" && isUnbornSymbolicHead(cwd, env, executor)) {
689
+ return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) };
690
+ }
473
691
  try {
474
692
  if (baseRef !== undefined) {
475
693
  const candidates = explicitBaseRefCandidates(cwd, selector, env, executor);
@@ -484,7 +702,7 @@ function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: Nod
484
702
  if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved");
485
703
  return { commit: confirmedCommit, tree: confirmedTree };
486
704
  } catch (error) {
487
- if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
705
+ if (error instanceof CandidateViewError && (error.diagnostics !== undefined || error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
488
706
  throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
489
707
  }
490
708
  }
@@ -519,6 +737,65 @@ function checkoutMaterializedEntries(root: string, entries: readonly CandidateTr
519
737
  flush();
520
738
  }
521
739
 
740
+ // Creates an unborn worktree (symbolic HEAD pointing at a branch with no
741
+ // commits, no ref written, no phantom commit). Git 2.42+ supports --orphan
742
+ // directly; older Git lacks the flag and reports an unsupported-option usage
743
+ // error (exit status 129). Only that exact status triggers the fallback: a
744
+ // temporary empty-tree commit seeds a detached --no-checkout worktree, then
745
+ // a symbolic-ref rewrite makes HEAD unborn. The temporary commit is never
746
+ // referenced by any ref and is GC-able, so it is not a phantom commit. The
747
+ // fallback uses a deterministic author/committer identity and timestamp in a
748
+ // copied environment so it does not depend on user.name/user.email or the
749
+ // ambient date. Contributor HEAD, branch, refs, and index are never touched.
750
+ function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): void {
751
+ const primary = probeCandidateGit(cwd, ["worktree", "add", "--orphan", "-b", branch, root], env, executor);
752
+ if (primary.status === 0) return;
753
+ // Only the unsupported-option usage status (129, pre-2.42 Git lacking --orphan)
754
+ // triggers the fallback. Every other status propagates as a git-failure.
755
+ if (primary.status !== 129 || existsSync(root)) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, ["worktree", "add", "--orphan", "-b", branch, root], resolveCandidateGitTimeoutMs(env));
756
+ const fallbackEnv = {
757
+ ...env,
758
+ GIT_AUTHOR_NAME: "gentle-ai-candidate",
759
+ GIT_AUTHOR_EMAIL: "gentle-ai-candidate@example.invalid",
760
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
761
+ GIT_COMMITTER_NAME: "gentle-ai-candidate",
762
+ GIT_COMMITTER_EMAIL: "gentle-ai-candidate@example.invalid",
763
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
764
+ };
765
+ const emptyTree = git(cwd, ["mktree"], fallbackEnv, executor);
766
+ const tempCommit = git(cwd, ["commit-tree", "-m", "gentle-ai-candidate", emptyTree], fallbackEnv, executor);
767
+ git(cwd, ["worktree", "add", "--no-checkout", "--detach", root, tempCommit], env, executor);
768
+ git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
769
+ }
770
+
771
+ function normalizeIntendedUntracked(paths: readonly string[] | undefined): readonly string[] | undefined {
772
+ if (paths === undefined) return undefined;
773
+ if (!Array.isArray(paths) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) {
774
+ throw new CandidateViewError("candidate intended-untracked selection is invalid");
775
+ }
776
+ return Object.freeze([...paths]);
777
+ }
778
+
779
+ function isErrnoCode(error: unknown, code: string): boolean {
780
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
781
+ }
782
+
783
+ function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
784
+ const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
785
+ const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
786
+ if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
787
+ copyFileSync(liveIndex, indexPath);
788
+ for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
789
+ try {
790
+ const sharedIndex = join(dirname(liveIndex), name);
791
+ if (lstatSync(sharedIndex).isFile()) copyFileSync(sharedIndex, join(dirname(indexPath), name));
792
+ } catch (error) {
793
+ if (!isErrnoCode(error, "ENOENT")) throw error;
794
+ }
795
+ }
796
+ return true;
797
+ }
798
+
522
799
  function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
523
800
  const contributorRoot = realpathSync(request.contributorRoot);
524
801
  if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
@@ -527,6 +804,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
527
804
  const canonicalCommonDir = realpathSync(commonDir);
528
805
  const base = resolveCandidateBase(contributorRoot, request.baseRef, process.env, executor);
529
806
  const committedOnly = request.committedOnly === true;
807
+ const intendedUntracked = normalizeIntendedUntracked(request.intendedUntracked);
530
808
  const candidateCommit = committedOnly
531
809
  ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
532
810
  : base;
@@ -536,20 +814,45 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
536
814
  const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
537
815
  try {
538
816
  const baseCommit = base.commit;
539
- git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
540
- if (!committedOnly) git(contributorRoot, ["add", "-A"], environment, executor);
817
+ const unborn = baseCommit === "HEAD";
818
+ // Workspace candidates seed their isolated index from the resolved live index; missing indexes use the frozen base.
819
+ const seededFromLiveIndex = !committedOnly && intendedUntracked !== undefined && seedPrivateIndexFromLiveIndex(contributorRoot, indexPath, executor);
820
+ if (!seededFromLiveIndex) {
821
+ // For an unborn repository the base tree is Git's empty tree, so seed the
822
+ // private candidate index from `--empty` instead of a non-existent commit.
823
+ if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
824
+ else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
825
+ }
826
+ if (!committedOnly) {
827
+ if (intendedUntracked === undefined) git(contributorRoot, ["add", "-A"], environment, executor);
828
+ else {
829
+ git(contributorRoot, ["add", "-u"], environment, executor);
830
+ if (intendedUntracked.length > 0) git(contributorRoot, ["add", "--", ...intendedUntracked], { ...environment, GIT_LITERAL_PATHSPECS: "1" }, executor);
831
+ }
832
+ }
541
833
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
542
834
  const root = join(parent, randomUUID());
543
- git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
835
+ // The worktree is created under the same try/catch cleanup boundary as
836
+ // the read-tree materialization that follows. addUnbornWorktree's
837
+ // fallback path can register a worktree with `worktree add` and then
838
+ // fail on a later step (for example `symbolic-ref`); moving creation
839
+ // here ensures any such partial registration is removed by the catch
840
+ // below instead of leaking a registered/admin worktree and directory.
544
841
  try {
842
+ // An unborn repository has no commit to detach a worktree at. addUnbornWorktree
843
+ // creates an orphan worktree (unborn branch, no commit, no ref) to host the
844
+ // materialized candidate tree without a phantom commit, with a fallback for
845
+ // Git versions older than 2.42 that do not support --orphan.
846
+ if (unborn) addUnbornWorktree(contributorRoot, root, `gentle-ai-candidate-${randomUUID()}`, process.env, executor);
847
+ else git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
545
848
  git(root, ["read-tree", candidateTree], process.env, executor);
546
849
  const tree = parseTree(root, candidateTree, executor);
547
850
  checkoutMaterializedEntries(root, tree.entries, executor);
548
851
  const entries = tree.entries.map((entry) => ({ ...entry, contentHash: entryContentHash(root, entry) }));
549
- const scope = deriveChangedScope(contributorRoot, baseCommit, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
852
+ const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
550
853
  for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
551
854
  makeReadonly(root, entries);
552
- return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
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 };
553
856
  } catch (error) {
554
857
  try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
555
858
  throw error;
@@ -560,6 +863,20 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
560
863
  }
561
864
 
562
865
  function assertRecordSafe(record: CandidateViewRecord): void {
866
+ try {
867
+ const contributor = lstatSync(record.contributorRoot);
868
+ if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
869
+ throw new CandidateViewError("candidate contributor root identity changed", "contributor-root-drift");
870
+ }
871
+ const toplevel = realpathSync(git(record.contributorRoot, ["rev-parse", "--show-toplevel"], process.env, record.gitExecutor));
872
+ const commonDir = realpathSync(resolve(record.contributorRoot, git(record.contributorRoot, ["rev-parse", "--git-common-dir"], process.env, record.gitExecutor)));
873
+ if (toplevel !== record.contributorRoot || commonDir !== record.commonDir) {
874
+ throw new CandidateViewError("candidate contributor root Git identity changed", "contributor-root-drift");
875
+ }
876
+ } catch (error) {
877
+ if (error instanceof CandidateViewError) throw error;
878
+ throw new CandidateViewError("candidate contributor root identity cannot be verified", "contributor-root-drift");
879
+ }
563
880
  const root = record.root;
564
881
  if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
565
882
  const rootStat = lstatSync(root);
@@ -599,22 +916,111 @@ export class CandidateViewRegistry {
599
916
  constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
600
917
  this.gitExecutor = gitExecutor;
601
918
  }
919
+ // Lifecycle state is scoped to the canonical target worktree as well as the
920
+ // provider lineage. Lineage text is repository-local and may legitimately be
921
+ // identical in two repositories owned by one Pi session.
602
922
  private readonly lineages = new Map<string, string>();
603
923
  private readonly projections = new Map<string, FrozenCandidateProjection>();
604
924
  private readonly replays = new Map<string, string>();
605
- private current: { lineageId: string; token: string } | undefined;
925
+ private readonly current = new Map<string, { lineageId: string; token: string }>();
926
+ // The last dispatch-binding hydration that was attempted and failed. A
927
+ // swallowed hydration failure is its own defect (field report 2026-08-16):
928
+ // without it the later dispatch refusal claims no binding was ever
929
+ // available instead of naming the attempt and its typed cause.
930
+ private readonly lastHydrationFailures = new Map<string, { lineageId: string; reason: string; message: string }>();
931
+
932
+ private canonicalRoot(contributorRoot: string): string {
933
+ try {
934
+ return realpathSync(contributorRoot);
935
+ } catch {
936
+ throw new CandidateViewError("candidate contributor root could not be resolved", "contributor-root-unresolvable");
937
+ }
938
+ }
939
+
940
+ private lineageKey(contributorRoot: string, lineageId: string): string {
941
+ return `${this.canonicalRoot(contributorRoot)}\u0000${lineageId}`;
942
+ }
943
+
944
+ private replayKey(contributorRoot: string, replayKey: string): string {
945
+ return `${this.canonicalRoot(contributorRoot)}\u0000${replayKey}`;
946
+ }
947
+
948
+ private uniqueKey(
949
+ entries: ReadonlyMap<string, unknown>,
950
+ lineageId: string,
951
+ contributorRoot: string | undefined,
952
+ ): string | undefined {
953
+ if (contributorRoot !== undefined) {
954
+ const key = this.lineageKey(contributorRoot, lineageId);
955
+ return entries.has(key) ? key : undefined;
956
+ }
957
+ const suffix = `\u0000${lineageId}`;
958
+ const matches = [...entries.keys()].filter((key) => key.endsWith(suffix));
959
+ if (matches.length === 0) return undefined;
960
+ if (matches.length !== 1) {
961
+ throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is ambiguous across target roots; pass an explicit workspaceRoot`, "lineage-root-ambiguous");
962
+ }
963
+ return matches[0]!;
964
+ }
965
+
966
+ private requireKey(
967
+ entries: ReadonlyMap<string, unknown>,
968
+ lineageId: string,
969
+ contributorRoot: string | undefined,
970
+ missing: string,
971
+ ): string {
972
+ return this.uniqueKey(entries, lineageId, contributorRoot)
973
+ ?? (() => { throw new CandidateViewError(missing); })();
974
+ }
975
+
976
+ /**
977
+ * Returns the one active target root bound to a lineage, or undefined.
978
+ * Throws when that lineage is bound across multiple roots; callers must pass
979
+ * an explicit workspaceRoot to resolve the ambiguity.
980
+ */
981
+ resolveWorkspaceRoot(lineageId: string): string | undefined {
982
+ const key = this.uniqueKey(this.lineages, lineageId, undefined);
983
+ return key === undefined ? undefined : key.slice(0, key.lastIndexOf("\u0000"));
984
+ }
985
+
986
+ assertWorkspaceRoot(lineageId: string, contributorRoot: string): void {
987
+ const root = this.canonicalRoot(contributorRoot);
988
+ const exactKey = this.lineageKey(root, lineageId);
989
+ if (this.lineages.has(exactKey)) {
990
+ this.assertLineageRootIdentity(lineageId, root);
991
+ return;
992
+ }
993
+ const bound = this.resolveWorkspaceRoot(lineageId);
994
+ if (bound !== undefined) {
995
+ throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is bound to ${bound}, not the requested workspaceRoot ${root}`, "lineage-root-drift");
996
+ }
997
+ }
998
+
999
+ private assertLineageRootIdentity(lineageId: string, contributorRoot: string): void {
1000
+ const key = this.lineageKey(contributorRoot, lineageId);
1001
+ const token = this.lineages.get(key);
1002
+ const record = token === undefined ? undefined : this.records.get(token);
1003
+ if (record !== undefined) assertRecordSafe(record);
1004
+ }
606
1005
 
607
1006
  create(request: CreateCandidateViewRequest): CandidateView {
608
1007
  return this.createOrReuse(request);
609
1008
  }
610
1009
 
1010
+ cleanupAll(): void {
1011
+ for (const token of [...this.records.keys()]) this.cleanup(token);
1012
+ }
1013
+
611
1014
  createOrReuse(request: CreateCandidateViewRequest): CandidateView {
612
- const token = request.replayKey === undefined ? undefined : this.replays.get(request.replayKey);
1015
+ const contributorRoot = this.canonicalRoot(request.contributorRoot);
1016
+ const normalizedRequest = { ...request, contributorRoot };
1017
+ const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
1018
+ const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
613
1019
  const existing = token === undefined ? undefined : this.records.get(token);
614
1020
  if (existing) { assertRecordSafe(existing); return this.expose(existing); }
615
- const record = materializeCandidateView(request, this.gitExecutor);
1021
+ const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
616
1022
  this.records.set(record.token, record);
617
- if (request.replayKey !== undefined) this.replays.set(request.replayKey, record.token);
1023
+ if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
618
1024
  return this.expose(record);
619
1025
  }
620
1026
 
@@ -624,36 +1030,37 @@ export class CandidateViewRegistry {
624
1030
 
625
1031
  bindCurrent(request: BindCandidateViewRequest): void {
626
1032
  const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
627
- this.bindRecord(request.token, request.lineageId, selectedLenses);
628
- this.current = { lineageId: request.lineageId, token: request.token };
1033
+ const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
1034
+ this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
629
1035
  }
630
1036
 
631
1037
  retain(token: string, lineageId: string): void {
632
- this.bindRecord(token, lineageId, []);
633
- this.current = { lineageId, token };
1038
+ const record = this.bindRecord(token, lineageId, []);
1039
+ this.current.set(record.contributorRoot, { lineageId, token });
634
1040
  }
635
1041
 
636
1042
  restoreCurrentFromNativeStart(request: BindCandidateViewRequest): void {
637
- if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
638
1043
  const record = this.records.get(request.token);
639
1044
  if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
1045
+ if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
640
1046
  assertRecordSafe(record);
641
1047
  this.assertCurrentBindingMatchesLiveCandidate(record);
642
1048
  this.bindCurrent(request);
643
1049
  }
644
1050
 
645
- hasCurrentBinding(): boolean {
646
- return this.current !== undefined;
1051
+ hasCurrentBinding(contributorRoot?: string): boolean {
1052
+ return contributorRoot === undefined ? this.current.size > 0 : this.current.has(this.canonicalRoot(contributorRoot));
647
1053
  }
648
1054
 
649
1055
  restoreCurrentFromAuthoritativeReviewingStates(
650
1056
  contributorRoot: string,
651
1057
  states: readonly AuthoritativeReviewingCandidateState[],
652
1058
  ): void {
653
- if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1059
+ const root = this.canonicalRoot(contributorRoot);
1060
+ if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
654
1061
  if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
655
1062
  if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous");
656
- const live = materializeCandidateView({ contributorRoot, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true }, this.gitExecutor);
1063
+ const live = materializeCandidateView({ contributorRoot: root, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true, ...(states[0]!.intendedUntracked === undefined ? {} : { intendedUntracked: states[0]!.intendedUntracked }) }, this.gitExecutor);
657
1064
  try {
658
1065
  const matches = states.filter((state) => this.matchesAuthoritativeState(live, state));
659
1066
  if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
@@ -662,28 +1069,31 @@ export class CandidateViewRegistry {
662
1069
  const selectedLenses = this.validateSelectedLenses(state.selectedLenses);
663
1070
  this.records.set(live.token, live);
664
1071
  this.bindRecord(live.token, state.lineageId, selectedLenses);
665
- this.current = { lineageId: state.lineageId, token: live.token };
1072
+ this.current.set(root, { lineageId: state.lineageId, token: live.token });
666
1073
  } catch (error) {
667
- if (!this.records.has(live.token)) this.remove(live);
1074
+ this.records.delete(live.token);
1075
+ this.remove(live);
668
1076
  throw error;
669
1077
  }
670
1078
  }
671
1079
 
672
1080
  createCorrected(lineageId: string, contributorRoot: string, replayKey: string): CandidateView {
673
- const projection = this.resolveProjection(lineageId, contributorRoot);
674
- const existingToken = this.replays.get(replayKey);
1081
+ const root = this.canonicalRoot(contributorRoot);
1082
+ const projection = this.resolveProjection(lineageId, root);
1083
+ const scopedReplayKey = this.replayKey(root, replayKey);
1084
+ const existingToken = this.replays.get(scopedReplayKey);
675
1085
  const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
676
1086
  if (existing) {
677
1087
  if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
678
1088
  assertRecordSafe(existing);
679
1089
  return this.expose(existing);
680
1090
  }
681
- const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1091
+ const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
682
1092
  try {
683
1093
  if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base");
684
1094
  if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths");
685
1095
  this.records.set(record.token, record);
686
- this.replays.set(replayKey, record.token);
1096
+ this.replays.set(scopedReplayKey, record.token);
687
1097
  return this.expose(record);
688
1098
  } catch (error) {
689
1099
  this.remove(record);
@@ -691,15 +1101,18 @@ export class CandidateViewRegistry {
691
1101
  }
692
1102
  }
693
1103
 
694
- promoteCorrected(lineageId: string, token: string): void {
1104
+ promoteCorrected(lineageId: string, token: string, contributorRoot?: string): void {
695
1105
  const replacement = this.records.get(token);
696
- const projection = this.projections.get(lineageId);
697
- const currentToken = this.lineages.get(lineageId);
1106
+ const root = contributorRoot === undefined ? replacement?.contributorRoot : this.canonicalRoot(contributorRoot);
1107
+ const key = root === undefined ? undefined : this.uniqueKey(this.projections, lineageId, root);
1108
+ const projection = key === undefined ? undefined : this.projections.get(key);
1109
+ const currentToken = key === undefined ? undefined : this.lineages.get(key);
698
1110
  const current = currentToken === undefined ? undefined : this.records.get(currentToken);
699
- if (!replacement || replacement.lineageId !== undefined || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
1111
+ if (!replacement || replacement.lineageId !== undefined || !key || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
700
1112
  throw new CandidateViewError("corrected candidate replacement is missing or ambiguous");
701
1113
  }
702
- if (this.current !== undefined && this.current.lineageId !== lineageId) {
1114
+ const currentBinding = this.current.get(root);
1115
+ if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
703
1116
  throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
704
1117
  }
705
1118
  assertRecordSafe(replacement);
@@ -709,26 +1122,28 @@ export class CandidateViewRegistry {
709
1122
  replacement.baseCommit !== projection.baseCommit ||
710
1123
  replacement.baseTree !== projection.baseTree ||
711
1124
  replacement.committedOnly !== projection.committedOnly ||
1125
+ JSON.stringify(replacement.intendedUntracked ?? null) !== JSON.stringify(projection.intendedUntracked ?? null) ||
712
1126
  !replacement.scope.paths.every((path) => projection.paths.includes(path))
713
1127
  ) {
714
1128
  throw new CandidateViewError("corrected candidate replacement does not preserve its frozen lineage projection");
715
1129
  }
716
1130
  replacement.lineageId = lineageId;
717
1131
  replacement.selectedLenses = [];
718
- this.lineages.set(lineageId, token);
719
- for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key);
720
- this.projections.set(lineageId, {
1132
+ this.lineages.set(key, token);
1133
+ for (const [pendingKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(pendingKey);
1134
+ this.projections.set(key, {
721
1135
  contributorRoot: replacement.contributorRoot,
722
1136
  baseCommit: replacement.baseCommit,
723
1137
  baseTree: replacement.baseTree,
724
1138
  candidateTree: replacement.candidateTree,
725
1139
  committedOnly: replacement.committedOnly,
1140
+ intendedUntracked: replacement.intendedUntracked,
726
1141
  paths: replacement.scope.paths,
727
1142
  modes: replacement.scope.modes,
728
1143
  gitlinks: replacement.scope.gitlinks,
729
1144
  deletedPaths: replacement.scope.deletedPaths,
730
1145
  });
731
- this.current = { lineageId, token };
1146
+ this.current.set(root, { lineageId, token });
732
1147
  if (current) {
733
1148
  this.remove(current);
734
1149
  this.forget(current);
@@ -748,6 +1163,7 @@ export class CandidateViewRegistry {
748
1163
  state.baseTree === record.baseTree &&
749
1164
  state.candidateTree === record.candidateTree &&
750
1165
  (state.committedOnly ?? false) === record.committedOnly &&
1166
+ (state.intendedUntracked === undefined || JSON.stringify(state.intendedUntracked) === JSON.stringify(record.intendedUntracked)) &&
751
1167
  JSON.stringify(state.paths) === JSON.stringify(record.scope.paths) &&
752
1168
  JSON.stringify(state.modes) === JSON.stringify(record.scope.modes) &&
753
1169
  gitlinkMapsEqual(state.gitlinks ?? {}, record.scope.gitlinks) &&
@@ -757,41 +1173,46 @@ export class CandidateViewRegistry {
757
1173
  }
758
1174
  }
759
1175
 
760
- private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): void {
1176
+ private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): CandidateViewRecord {
761
1177
  const record = this.records.get(token);
762
- if (!record || record.lineageId !== undefined || this.lineages.has(lineageId)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
1178
+ const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
1179
+ if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
763
1180
  assertRecordSafe(record);
764
1181
  record.lineageId = lineageId;
765
1182
  record.selectedLenses = selectedLenses;
766
- this.lineages.set(lineageId, record.token);
767
- this.projections.set(lineageId, {
1183
+ this.lineages.set(key, record.token);
1184
+ this.projections.set(key, {
768
1185
  contributorRoot: record.contributorRoot,
769
1186
  baseCommit: record.baseCommit,
770
1187
  baseTree: record.baseTree,
771
1188
  candidateTree: record.candidateTree,
772
1189
  committedOnly: record.committedOnly,
1190
+ intendedUntracked: record.intendedUntracked,
773
1191
  paths: record.scope.paths,
774
1192
  modes: record.scope.modes,
775
1193
  gitlinks: record.scope.gitlinks,
776
1194
  deletedPaths: record.scope.deletedPaths,
777
1195
  });
778
- for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key);
1196
+ for (const [replayKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(replayKey);
1197
+ return record;
779
1198
  }
780
1199
 
781
- hasProjection(lineageId: string): boolean {
782
- return this.projections.has(lineageId);
1200
+ hasProjection(lineageId: string, contributorRoot?: string): boolean {
1201
+ return this.uniqueKey(this.projections, lineageId, contributorRoot) !== undefined;
783
1202
  }
784
1203
 
785
1204
  restoreProjection(lineageId: string, contributorRoot: string, baseCommit: string, baseTree: string, candidateTree: string, paths: readonly string[]): void {
786
- const root = realpathSync(contributorRoot);
1205
+ const root = this.canonicalRoot(contributorRoot);
1206
+ const key = this.lineageKey(root, lineageId);
787
1207
  const base = resolveCandidateBase(root, baseCommit, process.env, this.gitExecutor);
788
- if (!lineageId || this.projections.has(lineageId) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored");
789
- this.projections.set(lineageId, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] });
1208
+ if (!lineageId || this.projections.has(key) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored");
1209
+ this.projections.set(key, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] });
790
1210
  }
791
1211
 
792
1212
  restoreProjectionFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): void {
793
- const root = realpathSync(contributorRoot);
794
- if (!lineageId || this.projections.has(lineageId) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored");
1213
+ const root = this.canonicalRoot(contributorRoot);
1214
+ const key = this.lineageKey(root, lineageId);
1215
+ if (!lineageId || this.projections.has(key) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored");
795
1216
  if (descriptor.paths.some((path) => !isSafeCandidatePath(path)) || new Set(descriptor.paths).size !== descriptor.paths.length) throw new CandidateViewError("native frozen projection paths are invalid");
796
1217
  if (descriptor.intendedUntracked.some((path) => !descriptor.paths.includes(path)) || new Set(descriptor.intendedUntracked).size !== descriptor.intendedUntracked.length) throw new CandidateViewError("native intended-untracked projection is invalid");
797
1218
  const head = resolveCandidateBase(root, "HEAD", process.env, this.gitExecutor);
@@ -820,12 +1241,13 @@ export class CandidateViewRegistry {
820
1241
  } else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) {
821
1242
  throw new CandidateViewError("native projection paths do not match Git content");
822
1243
  }
823
- this.projections.set(lineageId, {
1244
+ this.projections.set(key, {
824
1245
  contributorRoot: root,
825
1246
  baseCommit: base.commit,
826
1247
  baseTree: descriptor.baseTree,
827
1248
  candidateTree: descriptor.currentCandidateTree,
828
1249
  committedOnly,
1250
+ intendedUntracked: Object.freeze([...descriptor.intendedUntracked]),
829
1251
  paths: scope.paths,
830
1252
  modes: scope.modes,
831
1253
  gitlinks: scope.gitlinks,
@@ -833,54 +1255,177 @@ export class CandidateViewRegistry {
833
1255
  });
834
1256
  }
835
1257
 
1258
+ /**
1259
+ * Re-derives this lineage's FINALIZE binding from the provider's own
1260
+ * projection, replacing a binding this session is still holding.
1261
+ *
1262
+ * Field defect (Engram #12547): once a bounded correction is admitted, the
1263
+ * candidate identity legitimately moves, and the provider issues its
1264
+ * finalize transition for that corrected target. A session that started the
1265
+ * review still holds the START-time immutable reviewer view, so comparing
1266
+ * it against the corrected projection reads as drift and no receipt is ever
1267
+ * minted — while a fresh process, which restores from the native descriptor,
1268
+ * finalizes the very same lineage successfully. This makes the in-session
1269
+ * path behave like that already-correct fresh-process path.
1270
+ *
1271
+ * This is a re-derivation, not a relaxation: the replacement is
1272
+ * materialized from Git and must match the provider descriptor exactly
1273
+ * (base tree, projection kind, changed-path manifest), and the caller still
1274
+ * asserts the binding afterwards. The immutable reviewer view is retired
1275
+ * here on purpose — the lenses that consumed it finished before the
1276
+ * correction.
1277
+ */
1278
+ 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);
1289
+ }
1290
+
836
1291
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
837
- this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
838
- const projection = this.resolveProjection(lineageId, contributorRoot);
839
- const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1292
+ const root = this.canonicalRoot(contributorRoot);
1293
+ const key = this.lineageKey(root, lineageId);
1294
+ let projectionRestored = false;
1295
+ let record: CandidateViewRecord | undefined;
840
1296
  try {
841
- if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
842
- throw new CandidateViewError("live candidate does not match the native frozen projection");
1297
+ this.restoreProjectionFromNative(lineageId, root, descriptor);
1298
+ projectionRestored = true;
1299
+ const projection = this.resolveProjection(lineageId, root);
1300
+ const matchesProjection = (candidate: CandidateViewRecord): boolean => candidate.baseTree === projection.baseTree && candidate.candidateTree === projection.candidateTree && JSON.stringify(candidate.scope.paths) === JSON.stringify(projection.paths);
1301
+ const emptyIntendedUntracked = projection.intendedUntracked?.length === 0;
1302
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(!emptyIntendedUntracked && projection.intendedUntracked !== undefined ? { intendedUntracked: projection.intendedUntracked } : {}) }, this.gitExecutor);
1303
+ if (!matchesProjection(record) && emptyIntendedUntracked) {
1304
+ this.remove(record);
1305
+ record = undefined;
1306
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, intendedUntracked: [] }, this.gitExecutor);
843
1307
  }
1308
+ if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
844
1309
  this.records.set(record.token, record);
845
1310
  this.bindRecord(record.token, lineageId, []);
846
1311
  return this.expose(record);
847
1312
  } catch (error) {
848
- this.projections.delete(lineageId);
849
- this.remove(record);
1313
+ if (projectionRestored) this.projections.delete(key);
1314
+ if (record !== undefined) {
1315
+ this.forget(record);
1316
+ this.remove(record);
1317
+ }
850
1318
  throw error;
851
1319
  }
852
1320
  }
853
1321
 
854
- resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
855
- const projection = this.projections.get(lineageId);
856
- if (!projection || realpathSync(contributorRoot) !== projection.contributorRoot) {
857
- throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root");
1322
+ /**
1323
+ * Mirrors the START-time dispatch registration for a lineage this
1324
+ * controller never started (live defect 2026-08-16: a successor created
1325
+ * by native `review recover` exists only in native authority). The
1326
+ * authoritative STATUS descriptor supplies the frozen projection; the
1327
+ * live candidate is re-materialized and must match it exactly before the
1328
+ * dispatch-facing current binding is established with the provider-named
1329
+ * pending lenses.
1330
+ */
1331
+ restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
1332
+ const root = this.canonicalRoot(contributorRoot);
1333
+ const key = this.lineageKey(root, lineageId);
1334
+ if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1335
+ let projectionRestored = false;
1336
+ let record: CandidateViewRecord | undefined;
1337
+ try {
1338
+ const lenses = this.validateSelectedLenses(selectedLenses);
1339
+ this.restoreProjectionFromNative(lineageId, root, descriptor);
1340
+ projectionRestored = true;
1341
+ const projection = this.resolveProjection(lineageId, root);
1342
+ record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
1343
+ if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1344
+ throw new CandidateViewError("live candidate does not match the native frozen projection");
1345
+ }
1346
+ this.records.set(record.token, record);
1347
+ this.bindRecord(record.token, lineageId, lenses);
1348
+ this.current.set(root, { lineageId, token: record.token });
1349
+ this.lastHydrationFailures.delete(root);
1350
+ } catch (error) {
1351
+ if (projectionRestored) this.projections.delete(key);
1352
+ if (record !== undefined) {
1353
+ this.forget(record);
1354
+ this.remove(record);
1355
+ }
1356
+ this.lastHydrationFailures.set(root, {
1357
+ lineageId,
1358
+ reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
1359
+ message: error instanceof Error ? error.message : String(error),
1360
+ });
1361
+ throw error;
858
1362
  }
1363
+ }
1364
+
1365
+ resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
1366
+ const key = this.lineageKey(contributorRoot, lineageId);
1367
+ const projection = this.projections.get(key);
1368
+ if (!projection) throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root");
1369
+ this.assertLineageRootIdentity(lineageId, contributorRoot);
859
1370
  return projection;
860
1371
  }
861
1372
 
862
- resolveForLens(lineageId: string, lens: string): CandidateView {
863
- const token = this.lineages.get(lineageId);
1373
+ resolveForLens(lineageId: string, lens: string, contributorRoot?: string): CandidateView {
1374
+ const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing, ambiguous, stale, or lens-unselected");
1375
+ const token = this.lineages.get(key);
864
1376
  const record = token === undefined ? undefined : this.records.get(token);
865
1377
  if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
866
1378
  assertRecordSafe(record);
867
1379
  return this.expose(record);
868
1380
  }
869
1381
 
870
- currentLineageId(): string {
871
- if (this.current === undefined) throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
872
- return this.current.lineageId;
1382
+ private currentBinding(contributorRoot?: string): { root: string; lineageId: string; token: string } {
1383
+ if (contributorRoot !== undefined) {
1384
+ const root = this.canonicalRoot(contributorRoot);
1385
+ const binding = this.current.get(root);
1386
+ if (binding !== undefined) return { root, ...binding };
1387
+ const failure = this.lastHydrationFailures.get(root);
1388
+ if (failure !== undefined) {
1389
+ throw new CandidateViewError(
1390
+ `review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
1391
+ "current-binding-hydration-failed",
1392
+ );
1393
+ }
1394
+ throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
1395
+ }
1396
+ if (this.current.size !== 1) {
1397
+ if (this.current.size > 1) throw new CandidateViewError("review subagent dispatch has multiple current lineage bindings across target roots; pass an explicit workspaceRoot", "current-binding-root-ambiguous");
1398
+ const failure = [...this.lastHydrationFailures.values()][0];
1399
+ if (failure !== undefined) {
1400
+ throw new CandidateViewError(
1401
+ `review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
1402
+ "current-binding-hydration-failed",
1403
+ );
1404
+ }
1405
+ throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
1406
+ }
1407
+ const [root, binding] = this.current.entries().next().value as [string, { lineageId: string; token: string }];
1408
+ return { root, ...binding };
873
1409
  }
874
1410
 
875
- resolveCurrentForLens(lens: string): CandidateView {
876
- return this.resolveCurrentForLenses([lens])[0]!;
1411
+ currentLineageId(contributorRoot?: string): string {
1412
+ return this.currentBinding(contributorRoot).lineageId;
877
1413
  }
878
1414
 
879
- resolveCurrentForLenses(lenses: readonly string[]): CandidateView[] {
880
- const lineageId = this.currentLineageId();
881
- const token = this.current?.token;
882
- const record = token === undefined ? undefined : this.records.get(token);
883
- if (!record || record.lineageId !== lineageId || this.lineages.get(lineageId) !== token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
1415
+ /** The last failed dispatch-binding hydration, for controller envelopes. */
1416
+ lastDispatchHydrationFailure(contributorRoot?: string): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
1417
+ if (contributorRoot !== undefined) return this.lastHydrationFailures.get(this.canonicalRoot(contributorRoot));
1418
+ return this.lastHydrationFailures.size === 1 ? [...this.lastHydrationFailures.values()][0] : undefined;
1419
+ }
1420
+
1421
+ resolveCurrentForLens(lens: string, contributorRoot?: string): CandidateView {
1422
+ return this.resolveCurrentForLenses([lens], contributorRoot)[0]!;
1423
+ }
1424
+
1425
+ resolveCurrentForLenses(lenses: readonly string[], contributorRoot?: string): CandidateView[] {
1426
+ const current = this.currentBinding(contributorRoot);
1427
+ const record = this.records.get(current.token);
1428
+ 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");
884
1429
  assertRecordSafe(record);
885
1430
  this.assertCurrentBindingMatchesLiveCandidate(record);
886
1431
  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");
@@ -888,7 +1433,7 @@ export class CandidateViewRegistry {
888
1433
  }
889
1434
 
890
1435
  private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void {
891
- const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly }, this.gitExecutor);
1436
+ const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly, ...(record.intendedUntracked === undefined ? {} : { intendedUntracked: record.intendedUntracked }) }, this.gitExecutor);
892
1437
  try {
893
1438
  if (
894
1439
  live.baseCommit !== record.baseCommit ||
@@ -905,8 +1450,9 @@ export class CandidateViewRegistry {
905
1450
  }
906
1451
  }
907
1452
 
908
- resolveForFinalize(lineageId: string): CandidateView {
909
- const token = this.lineages.get(lineageId);
1453
+ resolveForFinalize(lineageId: string, contributorRoot?: string): CandidateView {
1454
+ const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing or ambiguous for FINALIZE");
1455
+ const token = this.lineages.get(key);
910
1456
  const record = token === undefined ? undefined : this.records.get(token);
911
1457
  if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
912
1458
  assertRecordSafe(record);
@@ -920,40 +1466,47 @@ export class CandidateViewRegistry {
920
1466
  this.forget(record);
921
1467
  }
922
1468
 
923
- cleanupTerminal(lineageId: string, state: string): void {
1469
+ cleanupTerminal(lineageId: string, state: string, contributorRoot?: string): void {
924
1470
  if (state !== "approved" && state !== "escalated") return;
925
- const token = this.lineages.get(lineageId); if (token) this.cleanup(token);
926
- if (state === "escalated") this.projections.delete(lineageId);
1471
+ const key = this.uniqueKey(this.lineages, lineageId, contributorRoot);
1472
+ const token = key === undefined ? undefined : this.lineages.get(key);
1473
+ if (token) this.cleanup(token);
1474
+ if (state === "escalated" && key !== undefined) this.projections.delete(key);
927
1475
  }
928
1476
 
929
1477
  private remove(record: CandidateViewRecord): void {
930
1478
  if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
931
1479
  try { makeWritableForCleanup(record.root); } catch {}
932
- try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {
933
- try { makeWritableForCleanup(record.root); } catch {}
934
- rmSync(record.root, { recursive: true, force: true });
935
- }
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 });
936
1483
  }
937
1484
 
938
1485
  private forget(record: CandidateViewRecord): void {
939
1486
  this.records.delete(record.token);
940
- if (record.lineageId && this.lineages.get(record.lineageId) === record.token) this.lineages.delete(record.lineageId);
941
- if (this.current?.token === record.token) this.current = undefined;
942
- for (const [key, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(key);
1487
+ if (record.lineageId) {
1488
+ const key = this.lineageKey(record.contributorRoot, record.lineageId);
1489
+ if (this.lineages.get(key) === record.token) this.lineages.delete(key);
1490
+ }
1491
+ if (this.current.get(record.contributorRoot)?.token === record.token) this.current.delete(record.contributorRoot);
1492
+ for (const [replayKey, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(replayKey);
943
1493
  }
944
1494
 
945
- consumeProjection(lineageId: string): void {
946
- this.projections.delete(lineageId);
1495
+ consumeProjection(lineageId: string, contributorRoot?: string): void {
1496
+ const key = this.uniqueKey(this.projections, lineageId, contributorRoot);
1497
+ if (key !== undefined) this.projections.delete(key);
947
1498
  }
948
1499
 
949
1500
  private expose(record: CandidateViewRecord): CandidateView {
950
1501
  return {
951
1502
  token: record.token,
952
1503
  root: record.root,
1504
+ contributorRoot: record.contributorRoot,
953
1505
  baseCommit: record.baseCommit,
954
1506
  baseTree: record.baseTree,
955
1507
  candidateTree: record.candidateTree,
956
1508
  committedOnly: record.committedOnly,
1509
+ intendedUntracked: record.intendedUntracked,
957
1510
  paths: record.scope.paths,
958
1511
  modes: record.scope.modes,
959
1512
  gitlinks: record.scope.gitlinks,
@@ -986,22 +1539,187 @@ function hasCandidateContextConflict(text: string, views: readonly CandidateView
986
1539
  || views.some((view) => text.includes(view.root) || text.includes(view.candidateTree));
987
1540
  }
988
1541
 
989
- function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
1542
+ function compareCanonicalStrings(left: string, right: string): number {
1543
+ return left < right ? -1 : left > right ? 1 : 0;
1544
+ }
1545
+
1546
+ function canonicalStringMap(value: Readonly<Record<string, string>>): Record<string, string> {
1547
+ // Plain objects enumerate integer-like keys numerically; canonical round trips intentionally preserve that ordering.
1548
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
1549
+ }
1550
+
1551
+ function candidateScopeByMode(view: CandidateView): Record<string, string[]> {
990
1552
  const grouped = new Map<string, string[]>();
1553
+ const deletedPaths = new Set(view.deletedPaths);
991
1554
  for (const path of view.paths) {
992
- const group = view.deletedPaths.includes(path) ? "deleted" : view.modes[path];
1555
+ const group = deletedPaths.has(path) ? "deleted" : view.modes[path];
993
1556
  if (group === undefined) throw new CandidateViewError("candidate view scope omits a changed path mode");
994
1557
  const paths = grouped.get(group) ?? [];
995
1558
  paths.push(path);
996
1559
  grouped.set(group, paths);
997
1560
  }
998
- const scope = Object.fromEntries([...grouped.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0));
1561
+ return Object.fromEntries(
1562
+ [...grouped.entries()]
1563
+ .sort(([left], [right]) => compareCanonicalStrings(left, right))
1564
+ .map(([mode, paths]) => [mode, paths.sort(compareCanonicalStrings)]),
1565
+ );
1566
+ }
1567
+
1568
+ function isCandidateContextMode(value: string): boolean {
1569
+ return (Object.values(CANDIDATE_CONTEXT_MODE) as readonly string[]).includes(value);
1570
+ }
1571
+
1572
+ function isCanonicalStringArray(value: unknown): value is readonly string[] {
1573
+ return Array.isArray(value)
1574
+ && value.length > 0
1575
+ && value.every((item) => typeof item === "string" && isSafeCandidatePath(item))
1576
+ && value.every((item, index, items) => index === 0 || compareCanonicalStrings(items[index - 1]!, item) < 0);
1577
+ }
1578
+
1579
+ function hasCanonicalRecordOrder(value: Readonly<Record<string, unknown>>): boolean {
1580
+ const normalized = Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
1581
+ return JSON.stringify(value) === JSON.stringify(normalized);
1582
+ }
1583
+
1584
+ function invalidCandidateContextManifest(message: string): never {
1585
+ throw new CandidateViewError(message, "candidate-context-manifest-invalid");
1586
+ }
1587
+
1588
+ function validateCandidateContextManifest(value: unknown, bytes: Buffer): CandidateContextManifest {
1589
+ if (!isRecord(value)) return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1590
+ const keys = Object.keys(value);
1591
+ const expectedKeys = ["version", "scopeByMode", "gitlinks"];
1592
+ if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) {
1593
+ return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1594
+ }
1595
+ if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1596
+ if (value.version !== CANDIDATE_CONTEXT_MANIFEST.VERSION || !isRecord(value.scopeByMode) || !isRecord(value.gitlinks)) {
1597
+ return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1598
+ }
1599
+ const scopeByMode = value.scopeByMode;
1600
+ if (!hasCanonicalRecordOrder(scopeByMode)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1601
+ const scopePaths = new Set<string>();
1602
+ for (const [mode, paths] of Object.entries(scopeByMode)) {
1603
+ if (!isCandidateContextMode(mode) || !isCanonicalStringArray(paths)) return invalidCandidateContextManifest("candidate context manifest has an invalid scope");
1604
+ for (const path of paths) {
1605
+ if (scopePaths.has(path)) return invalidCandidateContextManifest("candidate context manifest has duplicate scope paths");
1606
+ scopePaths.add(path);
1607
+ }
1608
+ }
1609
+ const gitlinks = value.gitlinks;
1610
+ if (!hasCanonicalRecordOrder(gitlinks)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1611
+ for (const [path, objectId] of Object.entries(gitlinks)) {
1612
+ if (!isSafeCandidatePath(path) || typeof objectId !== "string" || !isCanonicalObjectId(objectId)) {
1613
+ return invalidCandidateContextManifest("candidate context manifest has an invalid gitlink map");
1614
+ }
1615
+ }
1616
+ const gitlinkPaths = scopeByMode["160000"];
1617
+ const canonicalGitlinkPaths = Object.keys(gitlinks).sort(compareCanonicalStrings);
1618
+ if (
1619
+ (gitlinkPaths === undefined && canonicalGitlinkPaths.length !== 0)
1620
+ || (gitlinkPaths !== undefined && JSON.stringify(canonicalGitlinkPaths) !== JSON.stringify(gitlinkPaths))
1621
+ ) return invalidCandidateContextManifest("candidate context manifest gitlinks do not match its scope");
1622
+ const manifest: CandidateContextManifest = {
1623
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1624
+ scopeByMode: scopeByMode as Readonly<Record<string, readonly string[]>>,
1625
+ gitlinks: gitlinks as Readonly<Record<string, string>>,
1626
+ };
1627
+ if (!Buffer.from(JSON.stringify(manifest), "utf8").equals(bytes)) {
1628
+ return invalidCandidateContextManifest("candidate context manifest is not canonical");
1629
+ }
1630
+ return manifest;
1631
+ }
1632
+
1633
+ export function decodeCandidateContextManifest(encoded: string, sha256: string): DecodedCandidateContextManifest {
1634
+ if (encoded.length > MAX_CANDIDATE_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[0-9a-f]{64}$/.test(sha256)) {
1635
+ throw new CandidateViewError("candidate context manifest encoding is invalid", "candidate-context-manifest-invalid");
1636
+ }
1637
+ let bytes: Buffer;
1638
+ try {
1639
+ bytes = gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES });
1640
+ } catch {
1641
+ throw new CandidateViewError("candidate context manifest cannot be decompressed", "candidate-context-manifest-invalid");
1642
+ }
1643
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
1644
+ if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity");
1645
+ const text = bytes.toString("utf8");
1646
+ if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8");
1647
+ if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) {
1648
+ return invalidCandidateContextManifest("candidate context manifest transport is not canonical");
1649
+ }
1650
+ let value: unknown;
1651
+ try {
1652
+ value = JSON.parse(text);
1653
+ } catch (error) {
1654
+ if (error instanceof CandidateViewError) throw error;
1655
+ return invalidCandidateContextManifest("candidate context manifest is not valid JSON");
1656
+ }
1657
+ return { manifest: validateCandidateContextManifest(value, bytes), bytes, sha256: actualSha256 };
1658
+ }
1659
+
1660
+ export function readCandidateContextManifestPage(encoded: string, sha256: string, cursor = 0): CandidateContextPage {
1661
+ if (!Number.isSafeInteger(cursor) || cursor < 0) throw new CandidateViewError("candidate context manifest cursor is invalid", "candidate-context-cursor-invalid");
1662
+ const decoded = decodeCandidateContextManifest(encoded, sha256);
1663
+ const entries = Object.entries(decoded.manifest.scopeByMode).flatMap(([mode, paths]) => paths.map((path): CandidateContextPageEntry => ({
1664
+ path,
1665
+ mode: mode as CandidateContextMode,
1666
+ ...(mode === CANDIDATE_CONTEXT_MODE.GITLINK ? { gitlinkObjectId: decoded.manifest.gitlinks[path]! } : {}),
1667
+ })));
1668
+ if (cursor > entries.length) throw new CandidateViewError("candidate context manifest cursor exceeds the changed scope", "candidate-context-cursor-invalid");
1669
+ const pageEntries: CandidateContextPageEntry[] = [];
1670
+ for (let index = cursor; index < entries.length && pageEntries.length < MAX_CANDIDATE_SCOPE_PAGE_ENTRIES; index += 1) {
1671
+ const candidateEntries = [...pageEntries, entries[index]!];
1672
+ const candidatePage: CandidateContextPage = {
1673
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1674
+ sha256: decoded.sha256,
1675
+ cursor,
1676
+ totalPaths: entries.length,
1677
+ entries: candidateEntries,
1678
+ ...(cursor + candidateEntries.length < entries.length ? { nextCursor: cursor + candidateEntries.length } : {}),
1679
+ };
1680
+ if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CANDIDATE_SCOPE_PAGE_BYTES) {
1681
+ if (pageEntries.length === 0) throw new CandidateViewError("candidate context manifest path exceeds the bounded actor response", "candidate-context-page-too-large");
1682
+ break;
1683
+ }
1684
+ pageEntries.push(entries[index]!);
1685
+ }
1686
+ return {
1687
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1688
+ sha256: decoded.sha256,
1689
+ cursor,
1690
+ totalPaths: entries.length,
1691
+ entries: pageEntries,
1692
+ ...(cursor + pageEntries.length < entries.length ? { nextCursor: cursor + pageEntries.length } : {}),
1693
+ };
1694
+ }
1695
+
1696
+ function candidateContextPreamble(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string): string {
1697
+ return `\n\n${CONTROLLER_CANDIDATE_VIEW_HEADING}\nController-owned review lineage: \`${lineageId}\`.\nAuthorized review actors: ${agents.join(", ")}.\nRead ONLY the absolute frozen candidate view at \`${view.root}\`.\nFrozen candidate tree: \`${view.candidateTree}\`.\nScope semantics: ${scopeSemantics}`;
1698
+ }
1699
+
1700
+ function compactCandidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string, scopeByMode: Record<string, string[]>): string {
1701
+ const manifest: CandidateContextManifest = {
1702
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1703
+ scopeByMode,
1704
+ gitlinks: canonicalStringMap(view.gitlinks),
1705
+ };
1706
+ const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
1707
+ if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
1708
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1709
+ const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
1710
+ const block = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope manifest (gzip+base64url): \`${encoded}\`.\nFrozen changed scope manifest SHA-256: \`${sha256}\`.\nCall \`gentle_review_scope\` with exactly this manifest, SHA-256, and cursor 0; continue with each returned \`nextCursor\` until absent. It is the only authorized scope enumerator: do not infer scope by traversing the candidate or ambient tree. Gitlinks are metadata-only and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`;
1711
+ if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
1712
+ return block;
1713
+ }
1714
+
1715
+ function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
1716
+ const scopeByMode = candidateScopeByMode(view);
999
1717
  const scopeSemantics = view.committedOnly
1000
1718
  ? "Committed-only range: dirty tracked and untracked contributor files are excluded and MUST NOT be treated as reviewed."
1001
1719
  : "Dirty-inclusive workspace snapshot: tracked and untracked contributor changes are included.";
1002
- const block = `\n\n${CONTROLLER_CANDIDATE_VIEW_HEADING}\nController-owned review lineage: \`${lineageId}\`.\nAuthorized review actors: ${agents.join(", ")}.\nRead ONLY the absolute frozen candidate view at \`${view.root}\`.\nFrozen candidate tree: \`${view.candidateTree}\`.\nScope semantics: ${scopeSemantics}\nFrozen changed scope by mode: ${JSON.stringify(scope)}.\nFrozen metadata-only gitlinks: ${JSON.stringify(view.gitlinks)}. Gitlink paths have no materialized contents and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`;
1003
- if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
1004
- return block;
1720
+ const readableBlock = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope by mode: ${JSON.stringify(scopeByMode)}.\nFrozen metadata-only gitlinks: ${JSON.stringify(view.gitlinks)}. Gitlink paths have no materialized contents and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`;
1721
+ if (Buffer.byteLength(readableBlock, "utf8") <= MAX_CANDIDATE_CONTEXT_LENGTH) return readableBlock;
1722
+ return compactCandidateContextBlock(lineageId, agents, view, scopeSemantics, scopeByMode);
1005
1723
  }
1006
1724
 
1007
1725
  /**