gentle-pi 2.1.2 → 2.2.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 (171) hide show
  1. package/README.md +70 -12
  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 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  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 +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -4,10 +4,51 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
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 {
@@ -71,6 +126,33 @@ export interface CandidateView {
71
126
  cleanup(): void;
72
127
  }
73
128
 
129
+ export interface CandidateContextManifest {
130
+ version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
131
+ scopeByMode: Readonly<Record<string, readonly string[]>>;
132
+ gitlinks: Readonly<Record<string, string>>;
133
+ }
134
+
135
+ export interface DecodedCandidateContextManifest {
136
+ manifest: CandidateContextManifest;
137
+ bytes: Buffer;
138
+ sha256: string;
139
+ }
140
+
141
+ export interface CandidateContextPageEntry {
142
+ path: string;
143
+ mode: CandidateContextMode;
144
+ gitlinkObjectId?: string;
145
+ }
146
+
147
+ export interface CandidateContextPage {
148
+ version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
149
+ sha256: string;
150
+ cursor: number;
151
+ totalPaths: number;
152
+ entries: readonly CandidateContextPageEntry[];
153
+ nextCursor?: number;
154
+ }
155
+
74
156
  export interface FrozenCandidateProjection {
75
157
  contributorRoot: string;
76
158
  baseCommit: string;
@@ -130,22 +212,96 @@ export interface NativeCandidateProjectionDescriptor {
130
212
  providerManifestHashVerified?: true;
131
213
  }
132
214
 
215
+ export interface CandidateViewDiagnostic {
216
+ phase: typeof CANDIDATE_VIEW_DIAGNOSTIC_PHASE;
217
+ category: CandidateViewGitFailureCategory;
218
+ git_subcommand: CandidateGitSubcommand;
219
+ timeout_ms: number;
220
+ max_buffer_bytes: number;
221
+ message: string;
222
+ }
223
+
133
224
  export class CandidateViewError extends Error {
134
225
  readonly reason: string;
135
- constructor(message: string, reason = "candidate-view-invalid") {
226
+ readonly diagnostics?: CandidateViewDiagnostic;
227
+ constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) {
136
228
  super(message);
137
229
  this.name = "CandidateViewError";
138
230
  this.reason = reason;
231
+ this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics);
232
+ }
233
+ }
234
+
235
+ function candidateGitSubcommand(arguments_: readonly string[]): CandidateGitSubcommand {
236
+ switch (arguments_[0]) {
237
+ case CANDIDATE_GIT_SUBCOMMAND.ADD: return CANDIDATE_GIT_SUBCOMMAND.ADD;
238
+ case CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX: return CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX;
239
+ case CANDIDATE_GIT_SUBCOMMAND.DIFF: return CANDIDATE_GIT_SUBCOMMAND.DIFF;
240
+ case CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF: return CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF;
241
+ case CANDIDATE_GIT_SUBCOMMAND.LOG: return CANDIDATE_GIT_SUBCOMMAND.LOG;
242
+ case CANDIDATE_GIT_SUBCOMMAND.LS_FILES: return CANDIDATE_GIT_SUBCOMMAND.LS_FILES;
243
+ case CANDIDATE_GIT_SUBCOMMAND.LS_TREE: return CANDIDATE_GIT_SUBCOMMAND.LS_TREE;
244
+ case CANDIDATE_GIT_SUBCOMMAND.READ_TREE: return CANDIDATE_GIT_SUBCOMMAND.READ_TREE;
245
+ case CANDIDATE_GIT_SUBCOMMAND.REV_PARSE: return CANDIDATE_GIT_SUBCOMMAND.REV_PARSE;
246
+ case CANDIDATE_GIT_SUBCOMMAND.WORKTREE: return CANDIDATE_GIT_SUBCOMMAND.WORKTREE;
247
+ case CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE: return CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE;
248
+ default: return CANDIDATE_GIT_SUBCOMMAND.OTHER;
139
249
  }
140
250
  }
141
251
 
252
+ function candidateGitDiagnosticMessage(category: CandidateViewGitFailureCategory, subcommand: CandidateGitSubcommand, timeoutMs: number): string {
253
+ 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`;
254
+ 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`;
255
+ return `candidate-view Git command ${subcommand} failed; inspect the candidate state before any new START`;
256
+ }
257
+
258
+ function candidateGitDiagnostic(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewDiagnostic {
259
+ const git_subcommand = candidateGitSubcommand(arguments_);
260
+ return Object.freeze({
261
+ phase: CANDIDATE_VIEW_DIAGNOSTIC_PHASE,
262
+ category,
263
+ git_subcommand,
264
+ timeout_ms: timeoutMs,
265
+ max_buffer_bytes: CANDIDATE_GIT_MAX_BUFFER_BYTES,
266
+ message: candidateGitDiagnosticMessage(category, git_subcommand, timeoutMs),
267
+ });
268
+ }
269
+
270
+ function sanitizeCandidateViewDiagnostic(diagnostics: CandidateViewDiagnostic): CandidateViewDiagnostic | undefined {
271
+ const { phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message } = diagnostics;
272
+ if (
273
+ phase !== CANDIDATE_VIEW_DIAGNOSTIC_PHASE ||
274
+ !Object.values(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY).includes(category) ||
275
+ !Object.values(CANDIDATE_GIT_SUBCOMMAND).includes(git_subcommand) ||
276
+ !isCandidateGitTimeoutMs(timeout_ms) ||
277
+ max_buffer_bytes !== CANDIDATE_GIT_MAX_BUFFER_BYTES ||
278
+ message !== candidateGitDiagnosticMessage(category, git_subcommand, timeout_ms)
279
+ ) return undefined;
280
+ return Object.freeze({ phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message });
281
+ }
282
+
283
+ function candidateGitFailure(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewError {
284
+ const diagnostics = candidateGitDiagnostic(category, arguments_, timeoutMs);
285
+ return new CandidateViewError(diagnostics.message, `candidate-view-${category}`, diagnostics);
286
+ }
287
+
142
288
  function candidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, encoding: "utf8" | "buffer", executor: CandidateGitExecutor): string | Buffer {
289
+ const timeoutMs = resolveCandidateGitTimeoutMs(env);
143
290
  try {
144
- return executor("git", arguments_, { cwd, encoding, env, stdio: ["ignore", "pipe", "pipe"], timeout: CANDIDATE_GIT_TIMEOUT_MS, windowsHide: true });
291
+ return executor("git", arguments_, {
292
+ cwd,
293
+ encoding,
294
+ env,
295
+ stdio: ["ignore", "pipe", "pipe"],
296
+ timeout: timeoutMs,
297
+ maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES,
298
+ windowsHide: true,
299
+ });
145
300
  } 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"}`);
301
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean };
302
+ if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
303
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
304
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
149
305
  }
150
306
  }
151
307
 
@@ -348,11 +504,11 @@ function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescripto
348
504
  }
349
505
  }
350
506
 
351
- function deriveChangedScope(cwd: string, baseCommit: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
507
+ function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
352
508
  const present = new Map(entries.map((entry) => [entry.path, entry]));
353
509
  const paths = new Set<string>();
354
510
  const deleted = new Set<string>();
355
- const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseCommit, candidateTree], executor);
511
+ const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
356
512
  for (let index = 0; index < tokens.length;) {
357
513
  const status = tokens[index++]?.toString("ascii");
358
514
  if (status === undefined || !/^(?:[AMDT]|R[0-9]{3})$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
@@ -468,8 +624,60 @@ function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.Pr
468
624
  return [...new Set(candidates)].filter((candidate) => refs.has(candidate));
469
625
  }
470
626
 
627
+ // Runs a probe command that may exit nonzero as an expected signal (absent
628
+ // ref, detached HEAD). Returns the exit status and trimmed stdout. Timeout,
629
+ // output-limit, and unexpected Git failures propagate as sanitized
630
+ // CandidateViewError diagnostics, same as candidateGit.
631
+ function probeCandidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): { status: number; stdout: string } {
632
+ const timeoutMs = resolveCandidateGitTimeoutMs(env);
633
+ try {
634
+ const stdout = executor("git", arguments_, {
635
+ cwd, encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"],
636
+ timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true,
637
+ }) as string;
638
+ return { status: 0, stdout: stdout.trim() };
639
+ } catch (error) {
640
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
641
+ if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
642
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
643
+ if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
644
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
645
+ }
646
+ }
647
+
648
+ // An unborn repository's HEAD is a symbolic ref to a branch with no commits.
649
+ // `symbolic-ref --quiet HEAD` exits nonzero for a detached HEAD (not unborn).
650
+ // `rev-parse --verify --quiet <ref>` distinguishes a valid unborn (status 1,
651
+ // ref absent) from a broken symbolic ref (exit 0, ref OID text exists even
652
+ // when the object is missing). Any other status (128, etc.) signals
653
+ // corruption or an I/O failure, so it fails closed instead of masquerading as
654
+ // an unborn repository.
655
+ function isUnbornSymbolicHead(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): boolean {
656
+ const symbolic = probeCandidateGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], env, executor);
657
+ if (symbolic.status !== 0) return false;
658
+ const refProbeArguments = ["rev-parse", "--verify", "--quiet", symbolic.stdout];
659
+ const refProbe = probeCandidateGit(cwd, refProbeArguments, env, executor);
660
+ if (refProbe.status === 1) return true;
661
+ if (refProbe.status === 0) return false;
662
+ throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, refProbeArguments, resolveCandidateGitTimeoutMs(env));
663
+ }
664
+
665
+ // Derives Git's repository-native empty tree without hardcoding the SHA-1 id,
666
+ // so a sha256 repository derives its own empty-tree object id. `mktree` with
667
+ // ignored stdin reads empty input and writes the empty tree object.
668
+ function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string {
669
+ return git(cwd, ["mktree"], env, executor);
670
+ }
671
+
471
672
  function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase {
472
673
  const selector = baseRef ?? "HEAD";
674
+ // An unborn repository has a symbolic HEAD pointing at a branch with no
675
+ // commits yet. Its review base is Git's repository-native empty tree, not a
676
+ // missing or malformed commit. Only the default/HEAD selector is entitled to
677
+ // the empty-tree base; a detached HEAD over a missing commit stays fail-closed.
678
+ if (selector === "HEAD" && isUnbornSymbolicHead(cwd, env, executor)) {
679
+ return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) };
680
+ }
473
681
  try {
474
682
  if (baseRef !== undefined) {
475
683
  const candidates = explicitBaseRefCandidates(cwd, selector, env, executor);
@@ -484,7 +692,7 @@ function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: Nod
484
692
  if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved");
485
693
  return { commit: confirmedCommit, tree: confirmedTree };
486
694
  } catch (error) {
487
- if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
695
+ 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
696
  throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
489
697
  }
490
698
  }
@@ -519,6 +727,37 @@ function checkoutMaterializedEntries(root: string, entries: readonly CandidateTr
519
727
  flush();
520
728
  }
521
729
 
730
+ // Creates an unborn worktree (symbolic HEAD pointing at a branch with no
731
+ // commits, no ref written, no phantom commit). Git 2.42+ supports --orphan
732
+ // directly; older Git lacks the flag and reports an unsupported-option usage
733
+ // error (exit status 129). Only that exact status triggers the fallback: a
734
+ // temporary empty-tree commit seeds a detached --no-checkout worktree, then
735
+ // a symbolic-ref rewrite makes HEAD unborn. The temporary commit is never
736
+ // referenced by any ref and is GC-able, so it is not a phantom commit. The
737
+ // fallback uses a deterministic author/committer identity and timestamp in a
738
+ // copied environment so it does not depend on user.name/user.email or the
739
+ // ambient date. Contributor HEAD, branch, refs, and index are never touched.
740
+ function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): void {
741
+ const primary = probeCandidateGit(cwd, ["worktree", "add", "--orphan", "-b", branch, root], env, executor);
742
+ if (primary.status === 0) return;
743
+ // Only the unsupported-option usage status (129, pre-2.42 Git lacking --orphan)
744
+ // triggers the fallback. Every other status propagates as a git-failure.
745
+ if (primary.status !== 129 || existsSync(root)) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, ["worktree", "add", "--orphan", "-b", branch, root], resolveCandidateGitTimeoutMs(env));
746
+ const fallbackEnv = {
747
+ ...env,
748
+ GIT_AUTHOR_NAME: "gentle-ai-candidate",
749
+ GIT_AUTHOR_EMAIL: "gentle-ai-candidate@example.invalid",
750
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
751
+ GIT_COMMITTER_NAME: "gentle-ai-candidate",
752
+ GIT_COMMITTER_EMAIL: "gentle-ai-candidate@example.invalid",
753
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
754
+ };
755
+ const emptyTree = git(cwd, ["mktree"], fallbackEnv, executor);
756
+ const tempCommit = git(cwd, ["commit-tree", "-m", "gentle-ai-candidate", emptyTree], fallbackEnv, executor);
757
+ git(cwd, ["worktree", "add", "--no-checkout", "--detach", root, tempCommit], env, executor);
758
+ git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
759
+ }
760
+
522
761
  function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
523
762
  const contributorRoot = realpathSync(request.contributorRoot);
524
763
  if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
@@ -536,17 +775,32 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
536
775
  const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
537
776
  try {
538
777
  const baseCommit = base.commit;
539
- git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
778
+ const unborn = baseCommit === "HEAD";
779
+ // For an unborn repository the base tree is Git's empty tree, so seed the
780
+ // private candidate index from `--empty` instead of a non-existent commit.
781
+ if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
782
+ else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
540
783
  if (!committedOnly) git(contributorRoot, ["add", "-A"], environment, executor);
541
784
  const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
542
785
  const root = join(parent, randomUUID());
543
- git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
786
+ // The worktree is created under the same try/catch cleanup boundary as
787
+ // the read-tree materialization that follows. addUnbornWorktree's
788
+ // fallback path can register a worktree with `worktree add` and then
789
+ // fail on a later step (for example `symbolic-ref`); moving creation
790
+ // here ensures any such partial registration is removed by the catch
791
+ // below instead of leaking a registered/admin worktree and directory.
544
792
  try {
793
+ // An unborn repository has no commit to detach a worktree at. addUnbornWorktree
794
+ // creates an orphan worktree (unborn branch, no commit, no ref) to host the
795
+ // materialized candidate tree without a phantom commit, with a fallback for
796
+ // Git versions older than 2.42 that do not support --orphan.
797
+ if (unborn) addUnbornWorktree(contributorRoot, root, `gentle-ai-candidate-${randomUUID()}`, process.env, executor);
798
+ else git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
545
799
  git(root, ["read-tree", candidateTree], process.env, executor);
546
800
  const tree = parseTree(root, candidateTree, executor);
547
801
  checkoutMaterializedEntries(root, tree.entries, executor);
548
802
  const entries = tree.entries.map((entry) => ({ ...entry, contentHash: entryContentHash(root, entry) }));
549
- const scope = deriveChangedScope(contributorRoot, baseCommit, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
803
+ const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
550
804
  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
805
  makeReadonly(root, entries);
552
806
  return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
@@ -603,6 +857,11 @@ export class CandidateViewRegistry {
603
857
  private readonly projections = new Map<string, FrozenCandidateProjection>();
604
858
  private readonly replays = new Map<string, string>();
605
859
  private current: { lineageId: string; token: string } | undefined;
860
+ // The last dispatch-binding hydration that was attempted and failed. A
861
+ // swallowed hydration failure is its own defect (field report 2026-08-16):
862
+ // without it the later dispatch refusal claims no binding was ever
863
+ // available instead of naming the attempt and its typed cause.
864
+ private lastHydrationFailure: { lineageId: string; reason: string; message: string } | undefined;
606
865
 
607
866
  create(request: CreateCandidateViewRequest): CandidateView {
608
867
  return this.createOrReuse(request);
@@ -833,6 +1092,37 @@ export class CandidateViewRegistry {
833
1092
  });
834
1093
  }
835
1094
 
1095
+ /**
1096
+ * Re-derives this lineage's FINALIZE binding from the provider's own
1097
+ * projection, replacing a binding this session is still holding.
1098
+ *
1099
+ * Field defect (Engram #12547): once a bounded correction is admitted, the
1100
+ * candidate identity legitimately moves, and the provider issues its
1101
+ * finalize transition for that corrected target. A session that started the
1102
+ * review still holds the START-time immutable reviewer view, so comparing
1103
+ * it against the corrected projection reads as drift and no receipt is ever
1104
+ * minted — while a fresh process, which restores from the native descriptor,
1105
+ * finalizes the very same lineage successfully. This makes the in-session
1106
+ * path behave like that already-correct fresh-process path.
1107
+ *
1108
+ * This is a re-derivation, not a relaxation: the replacement is
1109
+ * materialized from Git and must match the provider descriptor exactly
1110
+ * (base tree, projection kind, changed-path manifest), and the caller still
1111
+ * asserts the binding afterwards. The immutable reviewer view is retired
1112
+ * here on purpose — the lenses that consumed it finished before the
1113
+ * correction.
1114
+ */
1115
+ rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
1116
+ const staleToken = this.lineages.get(lineageId);
1117
+ const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
1118
+ this.projections.delete(lineageId);
1119
+ if (stale !== undefined) {
1120
+ this.remove(stale);
1121
+ this.forget(stale);
1122
+ }
1123
+ return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1124
+ }
1125
+
836
1126
  restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
837
1127
  this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
838
1128
  const projection = this.resolveProjection(lineageId, contributorRoot);
@@ -851,6 +1141,46 @@ export class CandidateViewRegistry {
851
1141
  }
852
1142
  }
853
1143
 
1144
+ /**
1145
+ * Mirrors the START-time dispatch registration for a lineage this
1146
+ * controller never started (live defect 2026-08-16: a successor created
1147
+ * by native `review recover` exists only in native authority). The
1148
+ * authoritative STATUS descriptor supplies the frozen projection; the
1149
+ * live candidate is re-materialized and must match it exactly before the
1150
+ * dispatch-facing current binding is established with the provider-named
1151
+ * pending lenses.
1152
+ */
1153
+ restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
1154
+ if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
1155
+ try {
1156
+ const lenses = this.validateSelectedLenses(selectedLenses);
1157
+ this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
1158
+ const projection = this.resolveProjection(lineageId, contributorRoot);
1159
+ const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
1160
+ try {
1161
+ if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
1162
+ throw new CandidateViewError("live candidate does not match the native frozen projection");
1163
+ }
1164
+ this.records.set(record.token, record);
1165
+ this.bindRecord(record.token, lineageId, lenses);
1166
+ this.current = { lineageId, token: record.token };
1167
+ } catch (error) {
1168
+ this.projections.delete(lineageId);
1169
+ this.records.delete(record.token);
1170
+ this.remove(record);
1171
+ throw error;
1172
+ }
1173
+ this.lastHydrationFailure = undefined;
1174
+ } catch (error) {
1175
+ this.lastHydrationFailure = {
1176
+ lineageId,
1177
+ reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
1178
+ message: error instanceof Error ? error.message : String(error),
1179
+ };
1180
+ throw error;
1181
+ }
1182
+ }
1183
+
854
1184
  resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
855
1185
  const projection = this.projections.get(lineageId);
856
1186
  if (!projection || realpathSync(contributorRoot) !== projection.contributorRoot) {
@@ -868,10 +1198,24 @@ export class CandidateViewRegistry {
868
1198
  }
869
1199
 
870
1200
  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");
1201
+ if (this.current === undefined) {
1202
+ const failure = this.lastHydrationFailure;
1203
+ if (failure !== undefined) {
1204
+ throw new CandidateViewError(
1205
+ `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}`,
1206
+ "current-binding-hydration-failed",
1207
+ );
1208
+ }
1209
+ throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
1210
+ }
872
1211
  return this.current.lineageId;
873
1212
  }
874
1213
 
1214
+ /** The last failed dispatch-binding hydration, for controller envelopes. */
1215
+ lastDispatchHydrationFailure(): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
1216
+ return this.lastHydrationFailure;
1217
+ }
1218
+
875
1219
  resolveCurrentForLens(lens: string): CandidateView {
876
1220
  return this.resolveCurrentForLenses([lens])[0]!;
877
1221
  }
@@ -986,22 +1330,187 @@ function hasCandidateContextConflict(text: string, views: readonly CandidateView
986
1330
  || views.some((view) => text.includes(view.root) || text.includes(view.candidateTree));
987
1331
  }
988
1332
 
989
- function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
1333
+ function compareCanonicalStrings(left: string, right: string): number {
1334
+ return left < right ? -1 : left > right ? 1 : 0;
1335
+ }
1336
+
1337
+ function canonicalStringMap(value: Readonly<Record<string, string>>): Record<string, string> {
1338
+ // Plain objects enumerate integer-like keys numerically; canonical round trips intentionally preserve that ordering.
1339
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
1340
+ }
1341
+
1342
+ function candidateScopeByMode(view: CandidateView): Record<string, string[]> {
990
1343
  const grouped = new Map<string, string[]>();
1344
+ const deletedPaths = new Set(view.deletedPaths);
991
1345
  for (const path of view.paths) {
992
- const group = view.deletedPaths.includes(path) ? "deleted" : view.modes[path];
1346
+ const group = deletedPaths.has(path) ? "deleted" : view.modes[path];
993
1347
  if (group === undefined) throw new CandidateViewError("candidate view scope omits a changed path mode");
994
1348
  const paths = grouped.get(group) ?? [];
995
1349
  paths.push(path);
996
1350
  grouped.set(group, paths);
997
1351
  }
998
- const scope = Object.fromEntries([...grouped.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0));
1352
+ return Object.fromEntries(
1353
+ [...grouped.entries()]
1354
+ .sort(([left], [right]) => compareCanonicalStrings(left, right))
1355
+ .map(([mode, paths]) => [mode, paths.sort(compareCanonicalStrings)]),
1356
+ );
1357
+ }
1358
+
1359
+ function isCandidateContextMode(value: string): boolean {
1360
+ return (Object.values(CANDIDATE_CONTEXT_MODE) as readonly string[]).includes(value);
1361
+ }
1362
+
1363
+ function isCanonicalStringArray(value: unknown): value is readonly string[] {
1364
+ return Array.isArray(value)
1365
+ && value.length > 0
1366
+ && value.every((item) => typeof item === "string" && isSafeCandidatePath(item))
1367
+ && value.every((item, index, items) => index === 0 || compareCanonicalStrings(items[index - 1]!, item) < 0);
1368
+ }
1369
+
1370
+ function hasCanonicalRecordOrder(value: Readonly<Record<string, unknown>>): boolean {
1371
+ const normalized = Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
1372
+ return JSON.stringify(value) === JSON.stringify(normalized);
1373
+ }
1374
+
1375
+ function invalidCandidateContextManifest(message: string): never {
1376
+ throw new CandidateViewError(message, "candidate-context-manifest-invalid");
1377
+ }
1378
+
1379
+ function validateCandidateContextManifest(value: unknown, bytes: Buffer): CandidateContextManifest {
1380
+ if (!isRecord(value)) return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1381
+ const keys = Object.keys(value);
1382
+ const expectedKeys = ["version", "scopeByMode", "gitlinks"];
1383
+ if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) {
1384
+ return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1385
+ }
1386
+ if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1387
+ if (value.version !== CANDIDATE_CONTEXT_MANIFEST.VERSION || !isRecord(value.scopeByMode) || !isRecord(value.gitlinks)) {
1388
+ return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
1389
+ }
1390
+ const scopeByMode = value.scopeByMode;
1391
+ if (!hasCanonicalRecordOrder(scopeByMode)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1392
+ const scopePaths = new Set<string>();
1393
+ for (const [mode, paths] of Object.entries(scopeByMode)) {
1394
+ if (!isCandidateContextMode(mode) || !isCanonicalStringArray(paths)) return invalidCandidateContextManifest("candidate context manifest has an invalid scope");
1395
+ for (const path of paths) {
1396
+ if (scopePaths.has(path)) return invalidCandidateContextManifest("candidate context manifest has duplicate scope paths");
1397
+ scopePaths.add(path);
1398
+ }
1399
+ }
1400
+ const gitlinks = value.gitlinks;
1401
+ if (!hasCanonicalRecordOrder(gitlinks)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
1402
+ for (const [path, objectId] of Object.entries(gitlinks)) {
1403
+ if (!isSafeCandidatePath(path) || typeof objectId !== "string" || !isCanonicalObjectId(objectId)) {
1404
+ return invalidCandidateContextManifest("candidate context manifest has an invalid gitlink map");
1405
+ }
1406
+ }
1407
+ const gitlinkPaths = scopeByMode["160000"];
1408
+ const canonicalGitlinkPaths = Object.keys(gitlinks).sort(compareCanonicalStrings);
1409
+ if (
1410
+ (gitlinkPaths === undefined && canonicalGitlinkPaths.length !== 0)
1411
+ || (gitlinkPaths !== undefined && JSON.stringify(canonicalGitlinkPaths) !== JSON.stringify(gitlinkPaths))
1412
+ ) return invalidCandidateContextManifest("candidate context manifest gitlinks do not match its scope");
1413
+ const manifest: CandidateContextManifest = {
1414
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1415
+ scopeByMode: scopeByMode as Readonly<Record<string, readonly string[]>>,
1416
+ gitlinks: gitlinks as Readonly<Record<string, string>>,
1417
+ };
1418
+ if (!Buffer.from(JSON.stringify(manifest), "utf8").equals(bytes)) {
1419
+ return invalidCandidateContextManifest("candidate context manifest is not canonical");
1420
+ }
1421
+ return manifest;
1422
+ }
1423
+
1424
+ export function decodeCandidateContextManifest(encoded: string, sha256: string): DecodedCandidateContextManifest {
1425
+ if (encoded.length > MAX_CANDIDATE_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[0-9a-f]{64}$/.test(sha256)) {
1426
+ throw new CandidateViewError("candidate context manifest encoding is invalid", "candidate-context-manifest-invalid");
1427
+ }
1428
+ let bytes: Buffer;
1429
+ try {
1430
+ bytes = gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES });
1431
+ } catch {
1432
+ throw new CandidateViewError("candidate context manifest cannot be decompressed", "candidate-context-manifest-invalid");
1433
+ }
1434
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
1435
+ if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity");
1436
+ const text = bytes.toString("utf8");
1437
+ if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8");
1438
+ if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) {
1439
+ return invalidCandidateContextManifest("candidate context manifest transport is not canonical");
1440
+ }
1441
+ let value: unknown;
1442
+ try {
1443
+ value = JSON.parse(text);
1444
+ } catch (error) {
1445
+ if (error instanceof CandidateViewError) throw error;
1446
+ return invalidCandidateContextManifest("candidate context manifest is not valid JSON");
1447
+ }
1448
+ return { manifest: validateCandidateContextManifest(value, bytes), bytes, sha256: actualSha256 };
1449
+ }
1450
+
1451
+ export function readCandidateContextManifestPage(encoded: string, sha256: string, cursor = 0): CandidateContextPage {
1452
+ if (!Number.isSafeInteger(cursor) || cursor < 0) throw new CandidateViewError("candidate context manifest cursor is invalid", "candidate-context-cursor-invalid");
1453
+ const decoded = decodeCandidateContextManifest(encoded, sha256);
1454
+ const entries = Object.entries(decoded.manifest.scopeByMode).flatMap(([mode, paths]) => paths.map((path): CandidateContextPageEntry => ({
1455
+ path,
1456
+ mode: mode as CandidateContextMode,
1457
+ ...(mode === CANDIDATE_CONTEXT_MODE.GITLINK ? { gitlinkObjectId: decoded.manifest.gitlinks[path]! } : {}),
1458
+ })));
1459
+ if (cursor > entries.length) throw new CandidateViewError("candidate context manifest cursor exceeds the changed scope", "candidate-context-cursor-invalid");
1460
+ const pageEntries: CandidateContextPageEntry[] = [];
1461
+ for (let index = cursor; index < entries.length && pageEntries.length < MAX_CANDIDATE_SCOPE_PAGE_ENTRIES; index += 1) {
1462
+ const candidateEntries = [...pageEntries, entries[index]!];
1463
+ const candidatePage: CandidateContextPage = {
1464
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1465
+ sha256: decoded.sha256,
1466
+ cursor,
1467
+ totalPaths: entries.length,
1468
+ entries: candidateEntries,
1469
+ ...(cursor + candidateEntries.length < entries.length ? { nextCursor: cursor + candidateEntries.length } : {}),
1470
+ };
1471
+ if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CANDIDATE_SCOPE_PAGE_BYTES) {
1472
+ if (pageEntries.length === 0) throw new CandidateViewError("candidate context manifest path exceeds the bounded actor response", "candidate-context-page-too-large");
1473
+ break;
1474
+ }
1475
+ pageEntries.push(entries[index]!);
1476
+ }
1477
+ return {
1478
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1479
+ sha256: decoded.sha256,
1480
+ cursor,
1481
+ totalPaths: entries.length,
1482
+ entries: pageEntries,
1483
+ ...(cursor + pageEntries.length < entries.length ? { nextCursor: cursor + pageEntries.length } : {}),
1484
+ };
1485
+ }
1486
+
1487
+ function candidateContextPreamble(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string): string {
1488
+ 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}`;
1489
+ }
1490
+
1491
+ function compactCandidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string, scopeByMode: Record<string, string[]>): string {
1492
+ const manifest: CandidateContextManifest = {
1493
+ version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
1494
+ scopeByMode,
1495
+ gitlinks: canonicalStringMap(view.gitlinks),
1496
+ };
1497
+ const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
1498
+ if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
1499
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1500
+ const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
1501
+ 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.`;
1502
+ if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
1503
+ return block;
1504
+ }
1505
+
1506
+ function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
1507
+ const scopeByMode = candidateScopeByMode(view);
999
1508
  const scopeSemantics = view.committedOnly
1000
1509
  ? "Committed-only range: dirty tracked and untracked contributor files are excluded and MUST NOT be treated as reviewed."
1001
1510
  : "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;
1511
+ 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.`;
1512
+ if (Buffer.byteLength(readableBlock, "utf8") <= MAX_CANDIDATE_CONTEXT_LENGTH) return readableBlock;
1513
+ return compactCandidateContextBlock(lineageId, agents, view, scopeSemantics, scopeByMode);
1005
1514
  }
1006
1515
 
1007
1516
  /**