gentle-pi 1.0.2 → 1.0.3

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 (72) hide show
  1. package/README.md +42 -7
  2. package/assets/agents/jd-judge-a.md +1 -1
  3. package/assets/agents/jd-judge-b.md +1 -1
  4. package/assets/agents/review-readability.md +1 -1
  5. package/assets/agents/review-refuter.md +17 -9
  6. package/assets/agents/review-reliability.md +1 -1
  7. package/assets/agents/review-resilience.md +1 -1
  8. package/assets/agents/review-risk.md +1 -1
  9. package/assets/agents/review-validator.md +3 -3
  10. package/assets/agents/sdd-apply.md +7 -1
  11. package/assets/agents/sdd-status.md +7 -4
  12. package/assets/agents/sdd-tasks.md +11 -0
  13. package/assets/chains/sdd-full.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +4 -4
  15. package/assets/orchestrator.md +1 -1
  16. package/assets/support/sdd-status-contract.md +14 -4
  17. package/extensions/gentle-ai.ts +1772 -148
  18. package/lib/gentle-ai-binary.ts +89 -0
  19. package/lib/native-review-cli.ts +548 -0
  20. package/lib/review-authority-supersession.ts +1055 -0
  21. package/lib/review-candidate-view.ts +546 -0
  22. package/lib/review-compact-contract.ts +94 -7
  23. package/lib/review-compact-gate.ts +88 -4
  24. package/lib/review-compact-store.ts +24 -3
  25. package/lib/review-facade.ts +175 -24
  26. package/lib/review-refuter-adapter.ts +129 -0
  27. package/lib/review-snapshot.ts +42 -0
  28. package/lib/sdd-status.ts +197 -39
  29. package/package.json +2 -1
  30. package/scripts/gentle-ai-installer.mjs +225 -0
  31. package/scripts/install-gentle-ai.mjs +14 -0
  32. package/scripts/verify-package-files.mjs +5 -0
  33. package/skills/_shared/review-ledger-contract.md +9 -7
  34. package/skills/gentle-ai/SKILL.md +8 -6
  35. package/skills/judgment-day/SKILL.md +1 -1
  36. package/skills/judgment-day/references/prompts-and-formats.md +1 -1
  37. package/tests/artifact-language.test.ts +20 -0
  38. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +25 -0
  39. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +8 -0
  40. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +139 -0
  41. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +200 -0
  42. package/tests/fixtures/native-review-cli/v2.1.2/start.json +12 -0
  43. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +24 -0
  44. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +20 -0
  45. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +28 -0
  46. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +25 -0
  47. package/tests/fixtures/native-review-cli/v2.1.3/finalize.json +8 -0
  48. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +139 -0
  49. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +200 -0
  50. package/tests/fixtures/native-review-cli/v2.1.3/start.json +14 -0
  51. package/tests/fixtures/native-review-cli/v2.1.3/validate-allow.json +24 -0
  52. package/tests/fixtures/native-review-cli/v2.1.3/validate-deny-empty-context.json +20 -0
  53. package/tests/fixtures/native-review-cli/v2.1.3/validate-deny.json +28 -0
  54. package/tests/gentle-ai-binary.test.ts +148 -0
  55. package/tests/gentle-ai-installer.test.ts +219 -0
  56. package/tests/native-review-cli.test.ts +653 -0
  57. package/tests/native-review-parity-runtime.test.ts +170 -0
  58. package/tests/package-manifest.test.ts +27 -2
  59. package/tests/review-authority-recovery-docs.test.ts +25 -0
  60. package/tests/review-authority-supersession.test.ts +581 -0
  61. package/tests/review-candidate-view.test.ts +288 -0
  62. package/tests/review-compact-contract.test.ts +36 -4
  63. package/tests/review-compact-gate.test.ts +236 -7
  64. package/tests/review-compact-store.test.ts +11 -0
  65. package/tests/review-controller-native-routing.test.ts +2280 -0
  66. package/tests/review-controller.test.ts +342 -28
  67. package/tests/review-facade.test.ts +129 -19
  68. package/tests/review-ledger-contract.test.ts +58 -5
  69. package/tests/review-refuter-adapter.test.ts +89 -0
  70. package/tests/review-snapshot.test.ts +25 -0
  71. package/tests/runtime-harness.mjs +23 -0
  72. package/tests/sdd-status.test.ts +127 -6
@@ -1,4 +1,5 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import {
3
4
  existsSync,
4
5
  lstatSync,
@@ -20,6 +21,7 @@ import {
20
21
  import { homedir, tmpdir } from "node:os";
21
22
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
24
+ import { promisify } from "node:util";
23
25
  import type {
24
26
  ExtensionAPI,
25
27
  ExtensionContext,
@@ -49,7 +51,7 @@ import {
49
51
  } from "../lib/sdd-status.ts";
50
52
  import type { TriggerEvent } from "../lib/review-triggers.ts";
51
53
  import { ReviewBundleExporter, ReviewBundleImporter } from "../lib/review-bundle.ts";
52
- import { domainHashV1 } from "../lib/review-canonical.ts";
54
+ import { canonicalJsonV1, domainHashV1 } from "../lib/review-canonical.ts";
53
55
  import { inspectLegacyReviewAuthorityV1, type LegacyInspectionV1 } from "../lib/review-legacy-detector.ts";
54
56
  import { compactResetRequestV1, destructiveResetReviewAuthorityV1 } from "../lib/review-reset.ts";
55
57
  import { ReviewMutationLockV1 } from "../lib/review-lock.ts";
@@ -57,14 +59,31 @@ import {
57
59
  COMPACT_START_BLOCK_ACTION,
58
60
  GRAPH_V1_ORDINARY_READ_ONLY,
59
61
  CompactReviewStartBlockedError,
62
+ CompactReviewTerminalAmbiguityError,
63
+ inspectCompactTerminalApplicability,
60
64
  discoverCompactReview,
61
65
  finalizeCompactReview,
62
66
  startCompactReview,
63
67
  } from "../lib/review-facade.ts";
68
+ import { parseCompactFinalizeInput, parseNativeCompactFinalizeInput, toNativeReviewerDocument, toNativeValidatorDocument } from "../lib/review-compact-contract.ts";
69
+ import { toNativeRefuterDocument } from "../lib/review-refuter-adapter.ts";
64
70
  import { validateCompactReviewGate } from "../lib/review-compact-gate.ts";
71
+ import {
72
+ assertLiveRecoveredSourceBindingV1,
73
+ assertLiveRecoveredSuccessorBindingV1,
74
+ createSupersessionEnvelopeV1,
75
+ hasEligibleGraphV1RecoveryAuthorityV1,
76
+ inspectApprovedCompactSuccessorV1,
77
+ inspectRecoverableGraphSourceV1,
78
+ prepareSupersessionV1,
79
+ resolveReviewAuthorityForChange,
80
+ SupersessionStoreV1,
81
+ type PrepareSupersessionInputV1,
82
+ } from "../lib/review-authority-supersession.ts";
65
83
  import {
66
84
  COMPACT_AUTHORITY_OUTCOME,
67
85
  compactV2LineageExists,
86
+ discoverCompactReviewStores,
68
87
  graphV1LineageExists,
69
88
  hasGraphV1Authority,
70
89
  inspectCompactReviewAuthorityV2,
@@ -72,8 +91,11 @@ import {
72
91
  } from "../lib/review-compact-store.ts";
73
92
  import {
74
93
  inheritedUnsafeGitEnvironmentKeys,
94
+ publicationProbeGitEnvironment,
95
+ ReviewRepositoryError,
75
96
  resolveRepositoryAuthorityV1,
76
97
  } from "../lib/review-repository.ts";
98
+ import { captureLiveReviewCandidateBinding } from "../lib/review-snapshot.ts";
77
99
  import {
78
100
  EXTERNAL_RELEASE_EVIDENCE,
79
101
  GATE_RESULT,
@@ -87,10 +109,12 @@ import {
87
109
  createReviewState,
88
110
  evaluateReleaseFastPathV1,
89
111
  recheckReleaseFastPathRemoteHeadV1,
112
+ resolveConfiguredPushDestinationV1,
90
113
  resolvePushDestinationRefV1,
91
114
  resolvePushRemoteRefV1,
92
115
  validateAuthoritativeReviewGate,
93
116
  type GateTargetV1,
117
+ type PushGateTargetV1,
94
118
  type ReleaseFastPathEvidenceV1,
95
119
  type ReviewBudgetV1,
96
120
  type ReviewReducerInput,
@@ -106,6 +130,17 @@ import {
106
130
  type ReviewProjectionV1,
107
131
  } from "../lib/review-snapshot.ts";
108
132
  import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
133
+ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView } from "../lib/review-candidate-view.ts";
134
+ import {
135
+ createNativeReviewCli,
136
+ isCanonicalProcessString,
137
+ type NativeReviewCli,
138
+ type NativeFinalizeResult,
139
+ type NativeStartResult,
140
+ type NativeValidateResult,
141
+ } from "../lib/native-review-cli.ts";
142
+
143
+ const execFileAsync = promisify(execFile);
109
144
 
110
145
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
111
146
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
@@ -2086,12 +2121,17 @@ const REVIEW_CONTROLLER_OPERATION = {
2086
2121
  RESET: "reset",
2087
2122
  RECOVER: "recover",
2088
2123
  RECOVER_LOCK: "recover-lock",
2124
+ PREPARE_SUPERSESSION: "prepare-supersession",
2125
+ SUPERSEDE: "supersede",
2089
2126
  REPAIR: "repair",
2127
+ BIND_SDD: "bind-sdd",
2090
2128
  } as const;
2091
2129
 
2092
2130
  type ReviewControllerOperation =
2093
2131
  (typeof REVIEW_CONTROLLER_OPERATION)[keyof typeof REVIEW_CONTROLLER_OPERATION];
2094
2132
 
2133
+ const NATIVE_BIND_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
2134
+
2095
2135
  const REVIEW_CONTROLLER_PARAMETERS = {
2096
2136
  type: "object",
2097
2137
  additionalProperties: false,
@@ -2106,6 +2146,10 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2106
2146
  type: "string",
2107
2147
  description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.",
2108
2148
  },
2149
+ changeName: {
2150
+ type: "string",
2151
+ description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.",
2152
+ },
2109
2153
  idempotencyKey: {
2110
2154
  type: "string",
2111
2155
  description: "Required for graph-v1 start/advance and lifecycle validate operations.",
@@ -2120,7 +2164,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2120
2164
  },
2121
2165
  input: {
2122
2166
  type: "string",
2123
- description: "A JSON-serialized object string, not a nested object. Ordinary START uses {\"mode\":\"ordinary\",\"policyHash\":\"<hash>\"}; FINALIZE supplies reviewer results, correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean. Judgment Day retains graph-v1 input.",
2167
+ description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"} with optional baseRef and repository-local policyPath; legacy compact START retains policyHash. FINALIZE supplies reviewer results, correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean. Judgment Day retains graph-v1 input.",
2124
2168
  },
2125
2169
  outputPath: { type: "string", description: "Destination path for deterministic bundle export." },
2126
2170
  inputPath: { type: "string", description: "Source path for staged bundle import." },
@@ -2132,6 +2176,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2132
2176
  interface ReviewControllerParameters {
2133
2177
  operation: ReviewControllerOperation;
2134
2178
  lineageId?: string;
2179
+ changeName?: string;
2135
2180
  idempotencyKey?: string;
2136
2181
  transition?: string;
2137
2182
  command?: string;
@@ -2143,6 +2188,16 @@ interface ReviewControllerParameters {
2143
2188
  acknowledgeUntrustedBundleSource?: string;
2144
2189
  }
2145
2190
 
2191
+ interface SupersessionControllerInput {
2192
+ changeName: string;
2193
+ sourceLineageId: string;
2194
+ successorLineageId: string;
2195
+ operationId: string;
2196
+ prepared: PrepareSupersessionInputV1;
2197
+ challenge?: string;
2198
+ request_hash?: string;
2199
+ }
2200
+
2146
2201
  interface ReviewControllerStartInput {
2147
2202
  mode: ReviewMode;
2148
2203
  projection: ReviewProjectionV1;
@@ -2181,18 +2236,199 @@ interface DerivedReviewGateTarget {
2181
2236
  command: ReviewLifecycleCommand;
2182
2237
  target: GateTargetV1;
2183
2238
  actualIntendedCommitTree?: string;
2239
+ nativePublication?: NativePublicationBinding;
2240
+ }
2241
+
2242
+ interface NativePrePrBoundaryBinding {
2243
+ source: "explicit";
2244
+ selector: string;
2245
+ commit: string;
2246
+ remote: string;
2247
+ remoteRef: string;
2248
+ remoteIdentity: string;
2249
+ }
2250
+
2251
+ const GH_REPOSITORY_SOURCE = {
2252
+ EXPLICIT: "explicit",
2253
+ ENVIRONMENT: "environment",
2254
+ LOCAL: "local",
2255
+ } as const;
2256
+
2257
+ type GhRepositorySource = (typeof GH_REPOSITORY_SOURCE)[keyof typeof GH_REPOSITORY_SOURCE];
2258
+
2259
+ interface GhRepositoryBinding {
2260
+ source: GhRepositorySource;
2261
+ value: string;
2262
+ remote: string;
2263
+ remoteIdentity: string;
2264
+ }
2265
+
2266
+ interface NativePrePrHeadBinding {
2267
+ selector: string;
2268
+ commit: string;
2269
+ remote: string;
2270
+ remoteRef: string;
2271
+ remoteIdentity: string;
2272
+ }
2273
+
2274
+ interface NativePrePushRangeBinding {
2275
+ remote: string;
2276
+ destinationRef: string;
2277
+ oldObject: string;
2278
+ newObject: string;
2279
+ baseSelector: string;
2280
+ advertisedBaseCommit: string;
2281
+ }
2282
+
2283
+ interface NativePublicationBinding {
2284
+ flags: readonly string[];
2285
+ pushRemote: string;
2286
+ pushIdentity: string;
2287
+ prePushRange?: NativePrePushRangeBinding;
2288
+ prePrBoundary?: NativePrePrBoundaryBinding;
2289
+ prePrHead?: NativePrePrHeadBinding;
2290
+ repository?: GhRepositoryBinding;
2291
+ }
2292
+
2293
+ interface AdvertisedBranch {
2294
+ selector: string;
2295
+ remote: string;
2296
+ remoteRef: string;
2297
+ commit: string;
2298
+ remoteIdentity: string;
2299
+ localRef: string;
2300
+ }
2301
+
2302
+ interface AdvertisedRemoteBranch {
2303
+ remote: string;
2304
+ remoteRef: string;
2305
+ commit: string;
2306
+ remoteIdentity: string;
2307
+ }
2308
+
2309
+ const ADVERTISED_BRANCH_KIND = {
2310
+ BASE: "base",
2311
+ HEAD: "head",
2312
+ } as const;
2313
+
2314
+ type AdvertisedBranchKind = (typeof ADVERTISED_BRANCH_KIND)[keyof typeof ADVERTISED_BRANCH_KIND];
2315
+
2316
+ const PUBLICATION_PROBE_ERROR_CODE = {
2317
+ CANCELLED: "cancelled",
2318
+ TIMEOUT: "timeout",
2319
+ UNAVAILABLE: "unavailable",
2320
+ NON_ZERO: "non-zero",
2321
+ SIGNAL: "signal",
2322
+ OUTPUT_LIMIT: "output-limit",
2323
+ } as const;
2324
+
2325
+ type PublicationProbeErrorCode = (typeof PUBLICATION_PROBE_ERROR_CODE)[keyof typeof PUBLICATION_PROBE_ERROR_CODE];
2326
+
2327
+ interface PublicationProbeRequest {
2328
+ file: "git";
2329
+ arguments: readonly string[];
2330
+ cwd: string;
2331
+ timeoutMs: number;
2332
+ maxBufferBytes: number;
2333
+ shell: false;
2334
+ signal?: AbortSignal;
2335
+ environment: NodeJS.ProcessEnv;
2336
+ }
2337
+
2338
+ interface PublicationProbeResult {
2339
+ stdout: string;
2340
+ stderr: string;
2341
+ exitCode: number;
2342
+ signal: NodeJS.Signals | null;
2343
+ timedOut: boolean;
2344
+ outputLimitExceeded: boolean;
2345
+ }
2346
+
2347
+ type PublicationProbe = (request: PublicationProbeRequest) => Promise<PublicationProbeResult>;
2348
+
2349
+ class PublicationProbeError extends Error {
2350
+ readonly code: PublicationProbeErrorCode;
2351
+
2352
+ constructor(code: PublicationProbeErrorCode, message: string) {
2353
+ super(message);
2354
+ this.name = "PublicationProbeError";
2355
+ this.code = code;
2356
+ }
2184
2357
  }
2185
2358
 
2359
+ const NATIVE_PUBLICATION_BASE_NEXT_ACTION = {
2360
+ UNSUPPORTED_UNTIL_PERSISTED_BASE: "native-first-push-unsupported-until-persisted-advertised-base-exists",
2361
+ } as const;
2362
+
2363
+ const NATIVE_SPLIT_FETCH_PUSH_NEXT_ACTION = "native-split-fetch-push-unsupported-until-upstream-supports-explicit-push-base";
2364
+
2365
+ class NativePublicationBaseRequiredError extends Error {
2366
+ readonly nextAction = NATIVE_PUBLICATION_BASE_NEXT_ACTION.UNSUPPORTED_UNTIL_PERSISTED_BASE;
2367
+
2368
+ constructor() {
2369
+ super("Native first-push authorization is unsupported until Pi has a persisted explicit advertised-base source");
2370
+ this.name = "NativePublicationBaseRequiredError";
2371
+ }
2372
+ }
2373
+
2374
+ class NativeSplitFetchPushUnsupportedError extends Error {
2375
+ readonly nextAction = NATIVE_SPLIT_FETCH_PUSH_NEXT_ACTION;
2376
+
2377
+ constructor() {
2378
+ super("Native split fetch/push pre-push is unsupported by the upstream base-ref contract because <remote>/<branch> resolves through fetch-side remote-tracking state");
2379
+ this.name = "NativeSplitFetchPushUnsupportedError";
2380
+ }
2381
+ }
2382
+
2383
+ const PUBLICATION_PROBE_TIMEOUT_MS = 2_000;
2384
+ const PUBLICATION_PROBE_MAX_BUFFER_BYTES = 64 * 1024;
2385
+ const BASH_TIME_REVALIDATION_TIMEOUT_MS = 30_000;
2386
+
2387
+ const nodePublicationProbe: PublicationProbe = async (request) => {
2388
+ try {
2389
+ const output = await execFileAsync(request.file, [...request.arguments], {
2390
+ cwd: request.cwd,
2391
+ encoding: "utf8",
2392
+ env: request.environment,
2393
+ maxBuffer: request.maxBufferBytes,
2394
+ shell: request.shell,
2395
+ signal: request.signal,
2396
+ timeout: request.timeoutMs,
2397
+ windowsHide: true,
2398
+ });
2399
+ return { stdout: output.stdout, stderr: output.stderr, exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
2400
+ } catch (error) {
2401
+ const detail = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number; signal?: NodeJS.Signals; killed?: boolean };
2402
+ if (detail.code === "ENOENT" || detail.code === "EACCES" || detail.name === "AbortError") throw error;
2403
+ return {
2404
+ stdout: detail.stdout ?? "",
2405
+ stderr: detail.stderr ?? "",
2406
+ exitCode: typeof detail.code === "number" ? detail.code : 1,
2407
+ signal: detail.signal ?? null,
2408
+ timedOut: detail.killed === true,
2409
+ outputLimitExceeded: detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
2410
+ };
2411
+ }
2412
+ };
2413
+
2186
2414
  interface ReleaseFastPathAuthorizationV1 {
2187
2415
  remote: string;
2188
2416
  protected_ref: string;
2189
2417
  expected_remote_head: string;
2190
2418
  }
2191
2419
 
2420
+ interface NativeReviewAuthorizationContext {
2421
+ lineage_id: string;
2422
+ store_revision: string;
2423
+ fingerprint: string;
2424
+ intended_tree?: string;
2425
+ }
2426
+
2192
2427
  interface PendingReviewAuthorization {
2193
2428
  command_hash: string;
2194
2429
  target_hash: string;
2195
2430
  receipt_hash: string | null;
2431
+ native_gate?: NativeReviewAuthorizationContext;
2196
2432
  release_fast_path?: ReleaseFastPathAuthorizationV1;
2197
2433
  }
2198
2434
 
@@ -2208,7 +2444,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2208
2444
  // VALIDATE defers its lineage requirement to execution time: the proven
2209
2445
  // release-from-protected-main fast path needs no receipt lineage, while
2210
2446
  // every other validation still requires one before receipt validation.
2211
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.FINALIZE, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.VALIDATE].includes(value.operation as ReviewControllerOperation);
2447
+ const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.FINALIZE, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.PREPARE_SUPERSESSION, REVIEW_CONTROLLER_OPERATION.SUPERSEDE, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.VALIDATE, REVIEW_CONTROLLER_OPERATION.BIND_SDD].includes(value.operation as ReviewControllerOperation);
2212
2448
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2213
2449
  throw new Error("Review controller requires a lineageId");
2214
2450
  }
@@ -2216,7 +2452,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2216
2452
  operation: value.operation,
2217
2453
  ...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}),
2218
2454
  };
2219
- for (const key of ["idempotencyKey", "transition", "command", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"] as const) {
2455
+ for (const key of ["changeName", "idempotencyKey", "transition", "command", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"] as const) {
2220
2456
  const optional = value[key];
2221
2457
  if (optional !== undefined && typeof optional !== "string") {
2222
2458
  if (value.operation === REVIEW_CONTROLLER_OPERATION.START && key === "input") {
@@ -2272,9 +2508,67 @@ function parseControllerJson(input: string, operation: ReviewControllerOperation
2272
2508
  return value;
2273
2509
  }
2274
2510
 
2511
+ function parseSupersessionControllerInput(value: Record<string, unknown>): SupersessionControllerInput {
2512
+ for (const key of ["changeName", "sourceLineageId", "successorLineageId", "operationId"] as const) {
2513
+ if (typeof value[key] !== "string" || value[key].trim().length === 0) throw new Error(`Review controller supersession requires ${key}`);
2514
+ }
2515
+ if (!isRecord(value.prepared)) throw new Error("Review controller supersession requires the exact prepared request input");
2516
+ return {
2517
+ changeName: value.changeName as string,
2518
+ sourceLineageId: value.sourceLineageId as string,
2519
+ successorLineageId: value.successorLineageId as string,
2520
+ operationId: value.operationId as string,
2521
+ prepared: value.prepared as unknown as PrepareSupersessionInputV1,
2522
+ ...(typeof value.challenge === "string" ? { challenge: value.challenge } : {}),
2523
+ ...(typeof value.request_hash === "string" ? { request_hash: value.request_hash } : {}),
2524
+ };
2525
+ }
2526
+
2527
+ function assertSupersessionControllerIdentity(
2528
+ input: SupersessionControllerInput,
2529
+ prepared: ReturnType<typeof prepareSupersessionV1>,
2530
+ ): void {
2531
+ if (
2532
+ input.operationId !== prepared.body.operation_id ||
2533
+ input.changeName !== prepared.body.change.change_name ||
2534
+ input.sourceLineageId !== prepared.body.source.lineage_id ||
2535
+ input.successorLineageId !== prepared.body.successor.lineage_id ||
2536
+ canonicalJsonV1(input.prepared) !== canonicalJsonV1({
2537
+ operation_id: prepared.body.operation_id,
2538
+ eligibility: input.prepared.eligibility,
2539
+ equivalence: input.prepared.equivalence,
2540
+ ...(input.prepared.predecessor_recovery_id === undefined ? {} : { predecessor_recovery_id: input.prepared.predecessor_recovery_id }),
2541
+ ...(input.prepared.sequence === undefined ? {} : { sequence: input.prepared.sequence }),
2542
+ })
2543
+ ) throw new Error("Review controller supersession identity does not exactly match the prepared request");
2544
+ }
2545
+
2546
+ const REVIEW_CONTROLLER_OUTCOME = {
2547
+ RESET_STATE_UNAVAILABLE: "reset-state-unavailable",
2548
+ } as const;
2549
+
2550
+ class ReviewResetStateUnavailableError extends Error {
2551
+ readonly code = REVIEW_CONTROLLER_OUTCOME.RESET_STATE_UNAVAILABLE;
2552
+ readonly statePath: string;
2553
+
2554
+ constructor(statePath: string) {
2555
+ super("Review reset recovery state is unavailable");
2556
+ this.name = "ReviewResetStateUnavailableError";
2557
+ this.statePath = statePath;
2558
+ }
2559
+ }
2560
+
2275
2561
  function durableResetRecoveryRequest(cwd: string): LegacyInspectionV1["reset_request"] {
2276
2562
  const authority = resolveRepositoryAuthorityV1(cwd);
2277
- const value = JSON.parse(readFileSync(join(authority.store_root, "control", "reset-state.json"), "utf8")) as unknown;
2563
+ const statePath = join(authority.store_root, "control", "reset-state.json");
2564
+ let serialized: string;
2565
+ try {
2566
+ serialized = readFileSync(statePath, "utf8");
2567
+ } catch (error) {
2568
+ if (isRecord(error) && error.code === "ENOENT") throw new ReviewResetStateUnavailableError(statePath);
2569
+ throw error;
2570
+ }
2571
+ const value = JSON.parse(serialized) as unknown;
2278
2572
  if (!isRecord(value) || !isRecord(value.body)) throw new Error("Review reset recovery state is malformed");
2279
2573
  const envelope = value as unknown as ReviewResetStateEnvelope;
2280
2574
  const body = envelope.body;
@@ -2344,7 +2638,16 @@ function inspectReviewAuthorityForController(cwd: string): ControllerReviewAutho
2344
2638
  const legacy = inspectLegacyReviewAuthorityV1(cwd);
2345
2639
  const compact = inspectCompactReviewAuthorityV2(cwd);
2346
2640
  const inspection = legacy.outcome === "reset-in-progress"
2347
- ? { ...legacy, reset_request: durableResetRecoveryRequest(cwd) }
2641
+ ? (() => {
2642
+ try {
2643
+ return { ...legacy, reset_request: durableResetRecoveryRequest(cwd) };
2644
+ } catch (error) {
2645
+ if (error instanceof ReviewResetStateUnavailableError) {
2646
+ return { ...legacy, reset_state_outcome: REVIEW_CONTROLLER_OUTCOME.RESET_STATE_UNAVAILABLE };
2647
+ }
2648
+ throw error;
2649
+ }
2650
+ })()
2348
2651
  : compact.outcome === COMPACT_AUTHORITY_OUTCOME.INVALID
2349
2652
  ? { ...legacy, reset_request: compactResetRequestV1(cwd, legacy) }
2350
2653
  : legacy;
@@ -2375,31 +2678,29 @@ async function authorizeDestructiveReviewOperation(
2375
2678
  ctx: ExtensionContext,
2376
2679
  ): Promise<void> {
2377
2680
  const parameters = parseReviewControllerParameters(parametersValue);
2378
- if (
2379
- parameters.operation !== REVIEW_CONTROLLER_OPERATION.RESET &&
2380
- parameters.operation !== REVIEW_CONTROLLER_OPERATION.RECOVER
2381
- ) return;
2681
+ const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET || parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER;
2682
+ const isSupersession = parameters.operation === REVIEW_CONTROLLER_OPERATION.SUPERSEDE;
2683
+ if (!isReset && !isSupersession) return;
2382
2684
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
2383
- for (const key of ["repositoryId", "commonDirHash", "inventoryHash", "confirmation"] as const) {
2384
- if (typeof input[key] !== "string" || input[key].length === 0) {
2385
- throw new Error(`Review controller ${parameters.operation} requires an exact string ${key}`);
2685
+ const challengeKey = isSupersession ? "challenge" : "confirmation";
2686
+ if (isReset) {
2687
+ for (const key of ["repositoryId", "commonDirHash", "inventoryHash"] as const) {
2688
+ if (typeof input[key] !== "string" || input[key].length === 0) throw new Error(`Review controller ${parameters.operation} requires an exact string ${key}`);
2386
2689
  }
2387
2690
  }
2691
+ if (typeof input[challengeKey] !== "string" || input[challengeKey].length === 0) {
2692
+ throw new Error(`Review controller ${parameters.operation} requires an exact string ${challengeKey}`);
2693
+ }
2388
2694
  if (!ctx.hasUI) {
2389
2695
  throw new Error(`Review controller ${parameters.operation.toUpperCase()} requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed`);
2390
2696
  }
2391
2697
  const approved = await ctx.ui.confirm(
2392
- `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`,
2393
- [
2394
- `Operation: ${parameters.operation.toUpperCase()}`,
2395
- `Repository: ${input.repositoryId}`,
2396
- `Exact challenge: ${input.confirmation}`,
2397
- "This invalidates all prior review authority for this repository.",
2398
- ].join("\n"),
2698
+ isSupersession ? "Authorize review authority SUPERSESSION?" : `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`,
2699
+ isSupersession
2700
+ ? ["Operation: SUPERSEDE", `Exact challenge: ${input.challenge}`, "This preserves graph-v1 history and activates only the exact compact-v2 successor."].join("\n")
2701
+ : [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n"),
2399
2702
  );
2400
- if (!approved) {
2401
- throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`);
2402
- }
2703
+ if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`);
2403
2704
  }
2404
2705
 
2405
2706
  function parseReviewBudget(value: unknown, label: string): ReviewBudgetV1 {
@@ -2735,6 +3036,401 @@ function runReviewGit(
2735
3036
  }).trim();
2736
3037
  }
2737
3038
 
3039
+ function runPublicationGit(cwd: string, args: readonly string[]): string {
3040
+ return execFileSync("git", args, {
3041
+ cwd,
3042
+ encoding: "utf8",
3043
+ stdio: ["ignore", "pipe", "pipe"],
3044
+ env: publicationProbeGitEnvironment(),
3045
+ }).trim();
3046
+ }
3047
+
3048
+ async function runPublicationProbeGit(
3049
+ cwd: string,
3050
+ args: readonly string[],
3051
+ probe: PublicationProbe,
3052
+ timeoutMs: number,
3053
+ signal?: AbortSignal,
3054
+ ): Promise<string> {
3055
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
3056
+ const boundedSignal = signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
3057
+ let result: PublicationProbeResult;
3058
+ try {
3059
+ result = await probe({
3060
+ file: "git",
3061
+ arguments: args,
3062
+ cwd,
3063
+ timeoutMs,
3064
+ maxBufferBytes: PUBLICATION_PROBE_MAX_BUFFER_BYTES,
3065
+ shell: false,
3066
+ signal: boundedSignal,
3067
+ environment: publicationProbeGitEnvironment(),
3068
+ });
3069
+ } catch (error) {
3070
+ if (error instanceof PublicationProbeError) throw error;
3071
+ if (error instanceof Error && error.name === "AbortError") {
3072
+ throw new PublicationProbeError(
3073
+ signal?.aborted ? PUBLICATION_PROBE_ERROR_CODE.CANCELLED : PUBLICATION_PROBE_ERROR_CODE.TIMEOUT,
3074
+ signal?.aborted ? "Publication probe was cancelled" : "Publication probe timed out",
3075
+ );
3076
+ }
3077
+ throw new PublicationProbeError(PUBLICATION_PROBE_ERROR_CODE.UNAVAILABLE, "Publication probe could not start");
3078
+ }
3079
+ if (result.timedOut) throw new PublicationProbeError(PUBLICATION_PROBE_ERROR_CODE.TIMEOUT, "Publication probe timed out");
3080
+ if (result.outputLimitExceeded) throw new PublicationProbeError(PUBLICATION_PROBE_ERROR_CODE.OUTPUT_LIMIT, "Publication probe output exceeded its limit");
3081
+ if (result.signal) throw new PublicationProbeError(PUBLICATION_PROBE_ERROR_CODE.SIGNAL, "Publication probe was signalled");
3082
+ if (result.exitCode !== 0) throw new PublicationProbeError(PUBLICATION_PROBE_ERROR_CODE.NON_ZERO, "Publication probe failed");
3083
+ return result.stdout.trim();
3084
+ }
3085
+
3086
+ function configuredGitValues(cwd: string, key: string): string[] {
3087
+ try {
3088
+ return runPublicationGit(cwd, ["config", "--get-all", key]).split(/\r?\n/).filter(Boolean);
3089
+ } catch {
3090
+ return [];
3091
+ }
3092
+ }
3093
+
3094
+ function configuredRemotes(cwd: string): string[] {
3095
+ const remotes = runPublicationGit(cwd, ["remote"]).split(/\r?\n/).filter(Boolean);
3096
+ if (new Set(remotes).size !== remotes.length) throw new Error("Configured Git remotes are ambiguous");
3097
+ return remotes;
3098
+ }
3099
+
3100
+ function singleConfiguredValue(cwd: string, key: string): string | undefined {
3101
+ const values = configuredGitValues(cwd, key);
3102
+ if (values.length > 1) throw new Error(`Git configuration ${key} is ambiguous`);
3103
+ return values[0];
3104
+ }
3105
+
3106
+ function currentBranch(cwd: string): string {
3107
+ try {
3108
+ return runPublicationGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
3109
+ } catch {
3110
+ throw new Error("Publication requires an attached current branch");
3111
+ }
3112
+ }
3113
+
3114
+ function resolveNativePushRemote(cwd: string): string {
3115
+ const branch = currentBranch(cwd);
3116
+ const keys = [`branch.${branch}.pushRemote`, "remote.pushDefault", `branch.${branch}.remote`];
3117
+ for (const key of keys) {
3118
+ const remote = singleConfiguredValue(cwd, key);
3119
+ if (remote !== undefined) {
3120
+ resolveConfiguredPushDestinationV1(cwd, remote);
3121
+ return remote;
3122
+ }
3123
+ }
3124
+ if (configuredRemotes(cwd).includes("origin")) {
3125
+ resolveConfiguredPushDestinationV1(cwd, "origin");
3126
+ return "origin";
3127
+ }
3128
+ throw new Error("Native publication push remote is not configured");
3129
+ }
3130
+
3131
+ function repositoryLocationIdentity(cwd: string, location: string): string {
3132
+ let normalized = location;
3133
+ try {
3134
+ const parsed = new URL(location);
3135
+ if (parsed.protocol && !parsed.pathname.startsWith("//")) {
3136
+ normalized = `${parsed.host.toLowerCase()}/${parsed.pathname.replace(/^\/+|\/+$/g, "")}`;
3137
+ } else throw new Error("not an absolute URL");
3138
+ } catch {
3139
+ const colon = location.indexOf(":");
3140
+ const slash = location.indexOf("/");
3141
+ if (colon > 0 && (slash < 0 || colon < slash)) {
3142
+ const host = location.slice(0, colon).split("@").at(-1)!.toLowerCase();
3143
+ normalized = `${host}/${location.slice(colon + 1)}`;
3144
+ } else if (!isAbsolute(location)) {
3145
+ normalized = resolve(runPublicationGit(cwd, ["rev-parse", "--show-toplevel"]), location);
3146
+ }
3147
+ }
3148
+ normalized = normalized.replace(/\/+$/, "").replace(/\.git$/, "");
3149
+ return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
3150
+ }
3151
+
3152
+ function repositoryCoordinates(location: string): { host: string; owner: string; repository: string } | undefined {
3153
+ let host: string;
3154
+ let path: string;
3155
+ try {
3156
+ const parsed = new URL(location);
3157
+ if (!parsed.host) throw new Error("not an absolute URL");
3158
+ host = parsed.host.toLowerCase();
3159
+ path = parsed.pathname;
3160
+ } catch {
3161
+ const colon = location.indexOf(":");
3162
+ const slash = location.indexOf("/");
3163
+ if (colon <= 0 || (slash >= 0 && colon > slash)) return undefined;
3164
+ host = location.slice(0, colon).split("@").at(-1)!.toLowerCase();
3165
+ path = location.slice(colon + 1);
3166
+ }
3167
+ const segments = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/");
3168
+ if (segments.length !== 2 || !segments[0] || !segments[1]) return undefined;
3169
+ return { host, owner: segments[0], repository: segments[1] };
3170
+ }
3171
+
3172
+ function remoteFetchUrl(cwd: string, remote: string): string {
3173
+ const value = singleConfiguredValue(cwd, `remote.${remote}.url`);
3174
+ if (value === undefined) throw new Error(`Publication remote ${remote} has no unambiguous fetch URL`);
3175
+ return value;
3176
+ }
3177
+
3178
+ function pushRemoteIdentity(cwd: string, remote: string): string {
3179
+ return repositoryLocationIdentity(cwd, resolveConfiguredPushDestinationV1(cwd, remote).url);
3180
+ }
3181
+
3182
+ function localRefAtCommit(cwd: string, remote: string, branch: string, commit: string): string {
3183
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/${remote}/${branch}`]) {
3184
+ try {
3185
+ if (runPublicationGit(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]) === commit) return ref;
3186
+ } catch {
3187
+ // Continue to the other exact local evidence source.
3188
+ }
3189
+ }
3190
+ throw new Error(`Advertised base ${remote}/${branch} is not available at the same local commit`);
3191
+ }
3192
+
3193
+ async function advertisedRemoteBranch(
3194
+ cwd: string,
3195
+ remote: string,
3196
+ branch: string,
3197
+ label: AdvertisedBranchKind,
3198
+ probe: PublicationProbe,
3199
+ timeoutMs: number,
3200
+ signal?: AbortSignal,
3201
+ location = remoteFetchUrl(cwd, remote),
3202
+ ): Promise<AdvertisedRemoteBranch> {
3203
+ runPublicationGit(cwd, ["check-ref-format", "--branch", branch]);
3204
+ const remoteRef = `refs/heads/${branch}`;
3205
+ const output = await runPublicationProbeGit(cwd, ["ls-remote", "--heads", location, remoteRef], probe, timeoutMs, signal);
3206
+ const rows = output.split(/\r?\n/).filter(Boolean);
3207
+ if (rows.length !== 1) throw new Error(`Advertised ${label} ${remote}/${branch} is missing or ambiguous`);
3208
+ const [commit, ref, extra] = rows[0]!.split(/\s+/);
3209
+ if (extra !== undefined || ref !== remoteRef || !/^[0-9a-f]{40,64}$/.test(commit ?? "")) throw new Error(`Advertised ${label} ${remote}/${branch} is malformed`);
3210
+ return {
3211
+ remote,
3212
+ remoteRef,
3213
+ commit: commit!,
3214
+ remoteIdentity: repositoryLocationIdentity(cwd, location),
3215
+ };
3216
+ }
3217
+
3218
+ async function advertisedBranch(
3219
+ cwd: string,
3220
+ remote: string,
3221
+ branch: string,
3222
+ probe: PublicationProbe,
3223
+ timeoutMs: number,
3224
+ signal?: AbortSignal,
3225
+ location = remoteFetchUrl(cwd, remote),
3226
+ ): Promise<AdvertisedBranch> {
3227
+ const advertised = await advertisedRemoteBranch(cwd, remote, branch, ADVERTISED_BRANCH_KIND.BASE, probe, timeoutMs, signal, location);
3228
+ return {
3229
+ ...advertised,
3230
+ selector: `${remote}/${branch}`,
3231
+ localRef: localRefAtCommit(cwd, remote, branch, advertised.commit),
3232
+ };
3233
+ }
3234
+
3235
+ function optionalCommandOptionValue(arguments_: readonly string[], names: readonly string[]): string | undefined {
3236
+ const matches: string[] = [];
3237
+ for (let index = 0; index < arguments_.length; index += 1) {
3238
+ const argument = arguments_[index]!;
3239
+ const exact = names.find((name) => argument === name);
3240
+ if (exact) {
3241
+ const value = arguments_[index + 1];
3242
+ if (!value) throw new Error(`${exact} is missing its value`);
3243
+ matches.push(value);
3244
+ index += 1;
3245
+ continue;
3246
+ }
3247
+ const equals = names.find((name) => argument.startsWith(`${name}=`));
3248
+ if (equals) matches.push(argument.slice(equals.length + 1));
3249
+ }
3250
+ if (matches.length > 1) throw new Error(`Command option ${names.join("/")} is ambiguous`);
3251
+ return matches[0];
3252
+ }
3253
+
3254
+ interface ParsedGhRepository {
3255
+ host?: string;
3256
+ owner: string;
3257
+ repository: string;
3258
+ value: string;
3259
+ }
3260
+
3261
+ function parseGhRepository(value: string, label: string): ParsedGhRepository {
3262
+ if (value.length === 0 || value.trim() !== value || /[\u0000-\u001f\u007f\\?#@]/.test(value) || value.includes("://")) {
3263
+ throw new Error(`${label} is malformed`);
3264
+ }
3265
+ const segments = value.split("/");
3266
+ if (segments.length !== 2 && segments.length !== 3) throw new Error(`${label} must use [HOST/]OWNER/REPO`);
3267
+ const [host, owner, repository] = segments.length === 3
3268
+ ? [segments[0], segments[1], segments[2]]
3269
+ : [undefined, segments[0], segments[1]];
3270
+ if (!owner || !repository || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repository)) {
3271
+ throw new Error(`${label} is malformed`);
3272
+ }
3273
+ if (host !== undefined) {
3274
+ const match = /^([A-Za-z0-9.-]+)(?::([0-9]+))?$/.exec(host);
3275
+ const port = match?.[2] === undefined ? undefined : Number(match[2]);
3276
+ if (!match || (port !== undefined && (!Number.isSafeInteger(port) || port < 1 || port > 65_535))) {
3277
+ throw new Error(`${label} host is malformed`);
3278
+ }
3279
+ }
3280
+ const normalizedHost = host?.toLowerCase();
3281
+ return {
3282
+ ...(normalizedHost === undefined ? {} : { host: normalizedHost }),
3283
+ owner,
3284
+ repository,
3285
+ value: normalizedHost === undefined ? `${owner}/${repository}` : `${normalizedHost}/${owner}/${repository}`,
3286
+ };
3287
+ }
3288
+
3289
+ function repositoryRemoteMatches(cwd: string, remote: string, repository: ParsedGhRepository): boolean {
3290
+ const coordinates = repositoryCoordinates(remoteFetchUrl(cwd, remote));
3291
+ return coordinates !== undefined &&
3292
+ (repository.host === undefined || coordinates.host === repository.host) &&
3293
+ coordinates.owner.toLowerCase() === repository.owner.toLowerCase() &&
3294
+ coordinates.repository.toLowerCase() === repository.repository.toLowerCase();
3295
+ }
3296
+
3297
+ function effectiveGhRepository(command: ReviewLifecycleCommand): GhRepositoryBinding {
3298
+ if (command.arguments.some((argument) => /^-R.+/.test(argument))) {
3299
+ throw new Error("Pull request -R must pass its repository as a separate value");
3300
+ }
3301
+ const explicit = optionalCommandOptionValue(command.arguments, ["--repo", "-R"]);
3302
+ const inherited = explicit === undefined && process.env.GH_REPO !== undefined && process.env.GH_REPO.length > 0
3303
+ ? process.env.GH_REPO
3304
+ : undefined;
3305
+ const selected = explicit ?? inherited;
3306
+ const remotes = configuredRemotes(command.cwd);
3307
+ if (remotes.length === 0) throw new Error("Pull request repository has no configured remote");
3308
+ let source: GhRepositorySource;
3309
+ let value: string;
3310
+ let remote: string;
3311
+ if (selected !== undefined) {
3312
+ const repository = parseGhRepository(selected, explicit === undefined ? "GH_REPO" : "Pull request --repo");
3313
+ const matches = remotes.filter((candidate) => repositoryRemoteMatches(command.cwd, candidate, repository));
3314
+ if (matches.length !== 1) throw new Error("Pull request repository does not map to one configured remote");
3315
+ source = explicit === undefined ? GH_REPOSITORY_SOURCE.ENVIRONMENT : GH_REPOSITORY_SOURCE.EXPLICIT;
3316
+ value = repository.value;
3317
+ remote = matches[0]!;
3318
+ } else {
3319
+ const resolved = remotes.filter((candidate) => singleConfiguredValue(command.cwd, `remote.${candidate}.gh-resolved`) !== undefined);
3320
+ if (resolved.length > 1) throw new Error("GitHub CLI default repository context is ambiguous");
3321
+ if (resolved.length === 0 && remotes.length !== 1) throw new Error("GitHub CLI local repository inference is ambiguous");
3322
+ remote = resolved[0] ?? remotes[0]!;
3323
+ const location = remoteFetchUrl(command.cwd, remote);
3324
+ const coordinates = repositoryCoordinates(location);
3325
+ source = GH_REPOSITORY_SOURCE.LOCAL;
3326
+ value = coordinates === undefined
3327
+ ? location
3328
+ : `${coordinates.host}/${coordinates.owner}/${coordinates.repository}`;
3329
+ }
3330
+ return {
3331
+ source,
3332
+ value,
3333
+ remote,
3334
+ remoteIdentity: repositoryLocationIdentity(command.cwd, remoteFetchUrl(command.cwd, remote)),
3335
+ };
3336
+ }
3337
+
3338
+ async function deriveAdvertisedPrePrBase(
3339
+ command: ReviewLifecycleCommand,
3340
+ base: string,
3341
+ repository: GhRepositoryBinding,
3342
+ probe: PublicationProbe,
3343
+ timeoutMs: number,
3344
+ signal?: AbortSignal,
3345
+ ): Promise<AdvertisedBranch> {
3346
+ if (base.startsWith("refs/") || /^[0-9a-f]{40,64}$/.test(base)) throw new Error("Pull request base must be an advertised branch name");
3347
+ runPublicationGit(command.cwd, ["check-ref-format", "--branch", base]);
3348
+ return advertisedBranch(command.cwd, repository.remote, base, probe, timeoutMs, signal);
3349
+ }
3350
+
3351
+ function parsePullRequestHead(head: string): { owner?: string; branch: string; remoteRef: string } {
3352
+ const separator = head.indexOf(":");
3353
+ if (separator !== head.lastIndexOf(":")) throw new Error("Pull request head is malformed");
3354
+ const owner = separator < 0 ? undefined : head.slice(0, separator);
3355
+ const branch = separator < 0 ? head : head.slice(separator + 1);
3356
+ if (
3357
+ !branch ||
3358
+ branch.startsWith("refs/") ||
3359
+ /^[0-9a-f]{40,64}$/.test(branch) ||
3360
+ (owner !== undefined && !owner) ||
3361
+ !/^[A-Za-z0-9_.-]+$/.test(owner ?? "owner")
3362
+ ) throw new Error("Pull request head must use branch or owner:branch syntax");
3363
+ return { ...(owner === undefined ? {} : { owner }), branch, remoteRef: `refs/heads/${branch}` };
3364
+ }
3365
+
3366
+ async function deriveAdvertisedPrePrHead(
3367
+ command: ReviewLifecycleCommand,
3368
+ head: string,
3369
+ repository: GhRepositoryBinding,
3370
+ probe: PublicationProbe,
3371
+ timeoutMs: number,
3372
+ signal?: AbortSignal,
3373
+ ): Promise<NativePrePrHeadBinding> {
3374
+ const parsed = parsePullRequestHead(head);
3375
+ runPublicationGit(command.cwd, ["check-ref-format", "--branch", parsed.branch]);
3376
+ let remote = repository.remote;
3377
+ if (parsed.owner !== undefined) {
3378
+ const baseCoordinates = repositoryCoordinates(remoteFetchUrl(command.cwd, repository.remote));
3379
+ if (baseCoordinates === undefined) throw new Error("Pull request base repository coordinates are unavailable for an owner-qualified head");
3380
+ const matches = configuredRemotes(command.cwd).filter((candidate) => {
3381
+ const coordinates = repositoryCoordinates(remoteFetchUrl(command.cwd, candidate));
3382
+ return coordinates !== undefined &&
3383
+ coordinates.owner.toLowerCase() === parsed.owner!.toLowerCase() &&
3384
+ coordinates.host === baseCoordinates.host &&
3385
+ coordinates.repository.toLowerCase() === baseCoordinates.repository.toLowerCase();
3386
+ });
3387
+ if (matches.length !== 1) throw new Error("Pull request head repository does not map to one configured remote");
3388
+ remote = matches[0]!;
3389
+ }
3390
+ const advertised = await advertisedRemoteBranch(command.cwd, remote, parsed.branch, ADVERTISED_BRANCH_KIND.HEAD, probe, timeoutMs, signal);
3391
+ const localHead = runPublicationGit(command.cwd, ["rev-parse", "--verify", "HEAD^{commit}"]);
3392
+ if (advertised.commit !== localHead) throw new Error("Advertised pull request head does not match reviewed local HEAD");
3393
+ return { selector: head, ...advertised };
3394
+ }
3395
+
3396
+ async function deriveNativePrePushBinding(
3397
+ command: ReviewLifecycleCommand,
3398
+ target: PushGateTargetV1,
3399
+ probe: PublicationProbe,
3400
+ timeoutMs: number,
3401
+ signal?: AbortSignal,
3402
+ ): Promise<NativePublicationBinding> {
3403
+ const pushRemote = resolveNativePushRemote(command.cwd);
3404
+ if (pushRemote !== target.remote) throw new Error(`Push command remote ${target.remote} does not match native publication remote ${pushRemote}`);
3405
+ if (target.updates.length !== 1) throw new Error("Native pre-push requires one exact destination update");
3406
+ const update = target.updates[0]!;
3407
+ if (update.kind === PUSH_UPDATE_KIND.CREATE) throw new NativePublicationBaseRequiredError();
3408
+ if (!update.destination_ref.startsWith("refs/heads/")) throw new Error("Native pre-push destination must be an advertised branch");
3409
+ const branch = update.destination_ref.slice("refs/heads/".length);
3410
+ const destination = resolveConfiguredPushDestinationV1(command.cwd, target.remote);
3411
+ if (destination.destination_id !== target.destination_id) throw new Error("Push publication destination changed after exact command target derivation");
3412
+ const fetchUrl = remoteFetchUrl(command.cwd, target.remote);
3413
+ const pushIdentity = repositoryLocationIdentity(command.cwd, destination.url);
3414
+ if (destination.url !== fetchUrl || pushIdentity !== repositoryLocationIdentity(command.cwd, fetchUrl)) {
3415
+ throw new NativeSplitFetchPushUnsupportedError();
3416
+ }
3417
+ const base = await advertisedBranch(command.cwd, target.remote, branch, probe, timeoutMs, signal, destination.url);
3418
+ if (base.commit !== update.old_object) throw new Error("Advertised push destination changed after exact command target derivation");
3419
+ return {
3420
+ flags: ["--base-ref", base.selector],
3421
+ pushRemote,
3422
+ pushIdentity,
3423
+ prePushRange: {
3424
+ remote: target.remote,
3425
+ destinationRef: update.destination_ref,
3426
+ oldObject: update.old_object,
3427
+ newObject: update.new_object,
3428
+ baseSelector: base.selector,
3429
+ advertisedBaseCommit: base.commit,
3430
+ },
3431
+ };
3432
+ }
3433
+
2738
3434
  function commitIncludesAllTracked(arguments_: readonly string[]): boolean {
2739
3435
  let includesAllTracked = false;
2740
3436
  const booleanOptions = new Set([
@@ -2785,15 +3481,14 @@ function commitIncludesAllTracked(arguments_: readonly string[]): boolean {
2785
3481
  throw new Error(`Unsupported commit tree semantics: ${argument}`);
2786
3482
  }
2787
3483
  if (argument === "-a" || argument === "--all") {
2788
- includesAllTracked = true;
2789
- continue;
3484
+ throw new Error("Commit --all cannot be exactly proven against the frozen reviewed projection");
2790
3485
  }
2791
3486
  if (/^-[^-]+$/.test(argument) && argument.length > 2) {
2792
3487
  const flags = argument.slice(1);
2793
3488
  if (/[^aemnsqv]/.test(flags)) {
2794
3489
  throw new Error(`Unsupported combined commit option: ${argument}`);
2795
3490
  }
2796
- if (flags.includes("a")) includesAllTracked = true;
3491
+ if (flags.includes("a")) throw new Error("Commit --all cannot be exactly proven against the frozen reviewed projection");
2797
3492
  if (flags.includes("m")) {
2798
3493
  index += 1;
2799
3494
  if (arguments_[index] === undefined) throw new Error("Commit message option is missing its value");
@@ -2816,19 +3511,8 @@ function commitIncludesAllTracked(arguments_: readonly string[]): boolean {
2816
3511
  }
2817
3512
 
2818
3513
  function deriveCommitTree(command: ReviewLifecycleCommand): string {
2819
- const includesAllTracked = commitIncludesAllTracked(command.arguments);
2820
- if (!includesAllTracked) return runReviewGit(command.cwd, ["write-tree"]);
2821
- const temporaryDirectory = mkdtempSync(join(tmpdir(), "gentle-pi-commit-tree-"));
2822
- const temporaryIndex = join(temporaryDirectory, "index");
2823
- try {
2824
- const environment = { ...process.env, GIT_INDEX_FILE: temporaryIndex };
2825
- const stagedTree = runReviewGit(command.cwd, ["write-tree"]);
2826
- runReviewGit(command.cwd, ["read-tree", stagedTree], environment);
2827
- runReviewGit(command.cwd, ["add", "-u", "--", "."], environment);
2828
- return runReviewGit(command.cwd, ["write-tree"], environment);
2829
- } finally {
2830
- rmSync(temporaryDirectory, { recursive: true, force: true });
2831
- }
3514
+ commitIncludesAllTracked(command.arguments);
3515
+ return runReviewGit(command.cwd, ["write-tree"]);
2832
3516
  }
2833
3517
 
2834
3518
  function resolveLocalFullRef(cwd: string, value: string, label: string): string {
@@ -2912,7 +3596,7 @@ function pushRemoteAndRefspec(arguments_: readonly string[]): { remote: string;
2912
3596
  return { remote, refspec: refspecs[0]! };
2913
3597
  }
2914
3598
 
2915
- function derivePushTarget(command: ReviewLifecycleCommand): GateTargetV1 {
3599
+ function derivePushTarget(command: ReviewLifecycleCommand, pinnedTarget?: PushGateTargetV1): GateTargetV1 {
2916
3600
  const { remote, refspec } = pushRemoteAndRefspec(command.arguments);
2917
3601
  if (refspec.startsWith(":")) throw new Error("Push deletion is unsupported");
2918
3602
  if (refspec.startsWith("+")) throw new Error("Force push refspecs are unsupported");
@@ -2925,15 +3609,28 @@ function derivePushTarget(command: ReviewLifecycleCommand): GateTargetV1 {
2925
3609
  const newObject = runReviewGit(command.cwd, ["rev-parse", "--verify", sourceRef]);
2926
3610
  const newPeeledCommit = runReviewGit(command.cwd, ["rev-parse", "--verify", `${sourceRef}^{commit}`]);
2927
3611
  const newTree = runReviewGit(command.cwd, ["rev-parse", "--verify", `${newPeeledCommit}^{tree}`]);
2928
- const remoteResolution = separator < 0
2929
- ? { ...resolvePushRemoteRefV1(command.cwd, remote, sourceRef, "push remote destination ref"), ref: sourceRef }
2930
- : resolvePushDestinationRefV1(
3612
+ const pinnedUpdate = pinnedTarget?.updates.length === 1 ? pinnedTarget.updates[0] : undefined;
3613
+ const pinnedDestinationMatches = pinnedUpdate === undefined || (separator < 0
3614
+ ? pinnedUpdate.destination_ref === sourceRef
3615
+ : destinationValue.startsWith("refs/")
3616
+ ? pinnedUpdate.destination_ref === destinationValue
3617
+ : pinnedUpdate.destination_ref.endsWith(`/${destinationValue}`));
3618
+ if (!pinnedDestinationMatches) throw new Error("Push destination changed after authorization");
3619
+ const remoteResolution = pinnedUpdate === undefined
3620
+ ? separator < 0
3621
+ ? { ...resolvePushRemoteRefV1(command.cwd, remote, sourceRef, "push remote destination ref"), ref: sourceRef }
3622
+ : resolvePushDestinationRefV1(
2931
3623
  command.cwd,
2932
3624
  remote,
2933
3625
  destinationValue,
2934
3626
  sourceRef,
2935
3627
  "push remote destination ref",
2936
- );
3628
+ )
3629
+ : {
3630
+ destination: resolveConfiguredPushDestinationV1(command.cwd, remote),
3631
+ ref: pinnedUpdate.destination_ref,
3632
+ object_id: pinnedUpdate.old_object,
3633
+ };
2937
3634
  const destinationRef = remoteResolution.ref;
2938
3635
  const oldObject = remoteResolution.object_id;
2939
3636
  const update = oldObject === null
@@ -2967,36 +3664,19 @@ function derivePushTarget(command: ReviewLifecycleCommand): GateTargetV1 {
2967
3664
  };
2968
3665
  }
2969
3666
 
2970
- function commandOptionValue(arguments_: readonly string[], name: string): string {
2971
- const matches: string[] = [];
2972
- for (let index = 0; index < arguments_.length; index += 1) {
2973
- const argument = arguments_[index]!;
2974
- if (argument === name) {
2975
- const value = arguments_[index + 1];
2976
- if (!value) throw new Error(`${name} is missing its value`);
2977
- matches.push(value);
2978
- index += 1;
2979
- } else if (argument.startsWith(`${name}=`)) {
2980
- matches.push(argument.slice(name.length + 1));
2981
- }
2982
- }
2983
- if (matches.length !== 1) throw new Error(`Command requires exactly one ${name} value`);
2984
- return matches[0]!;
3667
+ function commandOptionValue(arguments_: readonly string[], names: readonly string[]): string {
3668
+ const value = optionalCommandOptionValue(arguments_, names);
3669
+ if (value === undefined) throw new Error(`Command requires exactly one ${names.join("/")} value`);
3670
+ return value;
2985
3671
  }
2986
3672
 
3673
+
2987
3674
  function derivePullRequestTarget(command: ReviewLifecycleCommand): GateTargetV1 {
2988
- const baseRef = resolveLocalFullRef(
2989
- command.cwd,
2990
- commandOptionValue(command.arguments, "--base"),
2991
- "Pull request base",
2992
- );
2993
- const headRef = resolveLocalFullRef(
2994
- command.cwd,
2995
- commandOptionValue(command.arguments, "--head"),
2996
- "Pull request head",
2997
- );
3675
+ const baseRef = resolveLocalFullRef(command.cwd, commandOptionValue(command.arguments, ["--base", "-B"]), "Pull request base");
3676
+ const headOption = commandOptionValue(command.arguments, ["--head", "-H"]);
3677
+ const headRef = parsePullRequestHead(headOption).remoteRef;
2998
3678
  const baseCommit = runReviewGit(command.cwd, ["rev-parse", "--verify", `${baseRef}^{commit}`]);
2999
- const headCommit = runReviewGit(command.cwd, ["rev-parse", "--verify", `${headRef}^{commit}`]);
3679
+ const headCommit = runReviewGit(command.cwd, ["rev-parse", "--verify", "HEAD^{commit}"]);
3000
3680
  return {
3001
3681
  kind: GATE_TARGET_KIND.PULL_REQUEST,
3002
3682
  base_ref: baseRef,
@@ -3008,6 +3688,61 @@ function derivePullRequestTarget(command: ReviewLifecycleCommand): GateTargetV1
3008
3688
  };
3009
3689
  }
3010
3690
 
3691
+ async function deriveNativePublicationTarget(
3692
+ derived: DerivedReviewGateTarget,
3693
+ probe: PublicationProbe,
3694
+ timeoutMs: number,
3695
+ signal?: AbortSignal,
3696
+ ): Promise<DerivedReviewGateTarget> {
3697
+ if (derived.command.event === "pre-push") {
3698
+ if (derived.target.kind !== GATE_TARGET_KIND.PUSH) throw new Error("Push target derivation returned the wrong kind");
3699
+ return { ...derived, nativePublication: await deriveNativePrePushBinding(derived.command, derived.target, probe, timeoutMs, signal) };
3700
+ }
3701
+ if (derived.command.event !== "pre-pr") return derived;
3702
+ if (derived.target.kind !== GATE_TARGET_KIND.PULL_REQUEST) throw new Error("Pull request target derivation returned the wrong kind");
3703
+ const repository = effectiveGhRepository(derived.command);
3704
+ const advertised = await deriveAdvertisedPrePrBase(
3705
+ derived.command,
3706
+ commandOptionValue(derived.command.arguments, ["--base", "-B"]),
3707
+ repository,
3708
+ probe,
3709
+ timeoutMs,
3710
+ signal,
3711
+ );
3712
+ const head = await deriveAdvertisedPrePrHead(
3713
+ derived.command,
3714
+ commandOptionValue(derived.command.arguments, ["--head", "-H"]),
3715
+ repository,
3716
+ probe,
3717
+ timeoutMs,
3718
+ signal,
3719
+ );
3720
+ const pushRemote = resolveNativePushRemote(derived.command.cwd);
3721
+ if (
3722
+ advertised.commit !== derived.target.base_commit ||
3723
+ head.remoteRef !== derived.target.head_ref ||
3724
+ head.commit !== derived.target.head_commit
3725
+ ) throw new Error("Advertised PR topology does not match the exact local command target");
3726
+ return {
3727
+ ...derived,
3728
+ nativePublication: {
3729
+ flags: ["--base-ref", advertised.selector],
3730
+ pushRemote,
3731
+ pushIdentity: pushRemoteIdentity(derived.command.cwd, pushRemote),
3732
+ repository,
3733
+ prePrBoundary: {
3734
+ source: "explicit",
3735
+ selector: advertised.selector,
3736
+ commit: advertised.commit,
3737
+ remote: advertised.remote,
3738
+ remoteRef: advertised.remoteRef,
3739
+ remoteIdentity: advertised.remoteIdentity,
3740
+ },
3741
+ prePrHead: head,
3742
+ },
3743
+ };
3744
+ }
3745
+
3011
3746
  function deriveReleaseTarget(command: ReviewLifecycleCommand): GateTargetV1 {
3012
3747
  const tag = command.arguments[0];
3013
3748
  if (!tag || tag.startsWith("-")) {
@@ -3041,6 +3776,7 @@ function deriveReleaseTarget(command: ReviewLifecycleCommand): GateTargetV1 {
3041
3776
  function deriveReviewGateTarget(
3042
3777
  command: string,
3043
3778
  defaultCwd: string,
3779
+ pinnedPushTarget?: PushGateTargetV1,
3044
3780
  ): DerivedReviewGateTarget {
3045
3781
  const inspection = inspectReviewLifecycleCommand(command, defaultCwd);
3046
3782
  if (!inspection.event || !inspection.command) {
@@ -3068,13 +3804,10 @@ function deriveReviewGateTarget(
3068
3804
  };
3069
3805
  }
3070
3806
  if (inspection.command.event === "pre-push") {
3071
- return { command: inspection.command, target: derivePushTarget(inspection.command) };
3807
+ return { command: inspection.command, target: derivePushTarget(inspection.command, pinnedPushTarget) };
3072
3808
  }
3073
3809
  if (inspection.command.event === "pre-pr") {
3074
- return {
3075
- command: inspection.command,
3076
- target: derivePullRequestTarget(inspection.command),
3077
- };
3810
+ return { command: inspection.command, target: derivePullRequestTarget(inspection.command) };
3078
3811
  }
3079
3812
  if (inspection.command.event === "pre-release") {
3080
3813
  return { command: inspection.command, target: deriveReleaseTarget(inspection.command) };
@@ -3086,7 +3819,9 @@ function reviewAuthorizationKey(command: string, cwd: string): string {
3086
3819
  return canonicalHash({ command, cwd: resolve(cwd) });
3087
3820
  }
3088
3821
 
3089
- type ReviewGateEvaluator = (command: string) => ToolCallEventResult | undefined;
3822
+ type ReviewGateEvaluator = (
3823
+ command: string,
3824
+ ) => Promise<ToolCallEventResult | undefined>;
3090
3825
  type CommandSafetyEvaluator = (
3091
3826
  command: string,
3092
3827
  ) => Promise<ToolCallEventResult | undefined>;
@@ -3095,55 +3830,490 @@ function isReviewTransition(value: string): value is ReviewTransition {
3095
3830
  return Object.values(REVIEW_TRANSITION).some((transition) => transition === value);
3096
3831
  }
3097
3832
 
3098
- function executeReviewControllerOperation(
3099
- parametersValue: unknown,
3100
- defaultCwd: string,
3101
- pendingAuthorizations: Map<string, PendingReviewAuthorization>,
3102
- ): Record<string, unknown> {
3103
- const parameters = parseReviewControllerParameters(parametersValue);
3104
- if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT) {
3105
- const outputPath = requiredControllerString(parameters, "outputPath");
3106
- const operationId = requiredControllerString(parameters, "operationId");
3107
- const lineageIds = parameters.lineageIds === undefined ? undefined : JSON.parse(parameters.lineageIds) as unknown;
3108
- if (lineageIds !== undefined && (!Array.isArray(lineageIds) || lineageIds.some((id) => typeof id !== "string"))) throw new Error("Export lineageIds must be a JSON string array");
3109
- return { operation: parameters.operation, result: new ReviewBundleExporter(defaultCwd).export({ outputPath, operationId, lineageIds }) };
3110
- }
3111
- if (parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
3112
- // RISK2-001: adopting a brand-new lineage from a bundle is an experimental,
3113
- // operator-attested trust decision (see lib/review-bundle.ts import gate).
3114
- return { operation: parameters.operation, result: new ReviewBundleImporter(defaultCwd).import({ inputPath: requiredControllerString(parameters, "inputPath"), operationId: requiredControllerString(parameters, "operationId"), acknowledgeUntrustedBundleSource: parameters.acknowledgeUntrustedBundleSource === "true" }) };
3833
+ function isKnownPiLegacyLineage(cwd: string, lineageId: string): boolean {
3834
+ try {
3835
+ return compactV2LineageExists(cwd, lineageId) || graphV1LineageExists(cwd, lineageId);
3836
+ } catch (error) {
3837
+ if (error instanceof ReviewRepositoryError) return false;
3838
+ throw error;
3839
+ }
3840
+ }
3841
+
3842
+ const COMPACT_NATIVE_START_APPLICABILITY = {
3843
+ UNRELATED_HISTORY: "unrelated-history",
3844
+ COMPATIBLE_RECEIPT: "compatible-receipt",
3845
+ NONTERMINAL: "nonterminal",
3846
+ ESCALATED: "escalated",
3847
+ AMBIGUOUS: "ambiguous",
3848
+ INVALID: "invalid",
3849
+ } as const;
3850
+
3851
+ type CompactNativeStartApplicability =
3852
+ (typeof COMPACT_NATIVE_START_APPLICABILITY)[keyof typeof COMPACT_NATIVE_START_APPLICABILITY];
3853
+
3854
+ interface CompactNativeStartClassification {
3855
+ applicability: CompactNativeStartApplicability;
3856
+ lineageIds: string[];
3857
+ }
3858
+
3859
+ function isBrokenRepositoryIdentity(error: unknown): error is ReviewRepositoryError {
3860
+ return error instanceof ReviewRepositoryError && /pinned repository identity|root commit authority/i.test(error.message);
3861
+ }
3862
+
3863
+ function classifyCompactAuthorityApplicability(cwd: string): CompactNativeStartClassification {
3864
+ try {
3865
+ const authority = resolveRepositoryAuthorityV1(cwd);
3866
+ const live = captureLiveReviewCandidateBinding({ cwd, repositoryId: authority.repository_id });
3867
+ const matching = discoverCompactReviewStores(cwd).flatMap((store) => {
3868
+ const record = store.load();
3869
+ const state = record.state;
3870
+ const stateMatches = state.initial_snapshot.base_tree === live.base_tree &&
3871
+ state.initial_snapshot.complete_snapshot_tree === live.complete_snapshot_tree &&
3872
+ state.initial_snapshot.initial_review_tree === live.initial_review_tree &&
3873
+ canonicalJsonV1(state.genesis_paths) === canonicalJsonV1(live.genesis_paths) &&
3874
+ canonicalJsonV1(state.intended_untracked) === canonicalJsonV1(live.intended_untracked);
3875
+ if (state.state !== "approved" && state.state !== "escalated") {
3876
+ return stateMatches ? [{ lineageId: state.lineage_id, state: state.state }] : [];
3877
+ }
3878
+ const { body } = store.loadTerminalReceipt().receipt;
3879
+ const receiptMatches = body.base_tree === live.base_tree &&
3880
+ body.final_candidate_tree === live.complete_snapshot_tree &&
3881
+ body.genesis_paths_hash === domainHashV1("compact-paths", live.genesis_paths) &&
3882
+ body.intended_untracked_hash === domainHashV1("compact-untracked", live.intended_untracked);
3883
+ if (stateMatches && !receiptMatches) throw new CompactReviewStoreError("Compact terminal receipt is incomplete or mismatched for its candidate");
3884
+ return receiptMatches ? [{ lineageId: state.lineage_id, state: state.state }] : [];
3885
+ }).toSorted((left, right) => left.lineageId.localeCompare(right.lineageId));
3886
+ if (matching.length === 0) return { applicability: COMPACT_NATIVE_START_APPLICABILITY.UNRELATED_HISTORY, lineageIds: [] };
3887
+ if (matching.length > 1) return { applicability: COMPACT_NATIVE_START_APPLICABILITY.AMBIGUOUS, lineageIds: matching.map(({ lineageId }) => lineageId) };
3888
+ const match = matching[0]!;
3889
+ return {
3890
+ applicability: match.state === "approved"
3891
+ ? COMPACT_NATIVE_START_APPLICABILITY.COMPATIBLE_RECEIPT
3892
+ : match.state === "escalated"
3893
+ ? COMPACT_NATIVE_START_APPLICABILITY.ESCALATED
3894
+ : COMPACT_NATIVE_START_APPLICABILITY.NONTERMINAL,
3895
+ lineageIds: [match.lineageId],
3896
+ };
3897
+ } catch {
3898
+ return { applicability: COMPACT_NATIVE_START_APPLICABILITY.INVALID, lineageIds: [] };
3899
+ }
3900
+ }
3901
+
3902
+ function compactApplicabilityNextAction(applicability: CompactNativeStartClassification): string {
3903
+ switch (applicability.applicability) {
3904
+ case COMPACT_NATIVE_START_APPLICABILITY.COMPATIBLE_RECEIPT:
3905
+ return "use-compatible-read-or-gate-route";
3906
+ case COMPACT_NATIVE_START_APPLICABILITY.NONTERMINAL:
3907
+ return "stop-and-resolve-existing-compact-authority";
3908
+ case COMPACT_NATIVE_START_APPLICABILITY.ESCALATED:
3909
+ return "stop-and-report-escalated-compact-authority";
3910
+ case COMPACT_NATIVE_START_APPLICABILITY.AMBIGUOUS:
3911
+ return "stop-and-report-ambiguous-compact-authority";
3912
+ case COMPACT_NATIVE_START_APPLICABILITY.INVALID:
3913
+ return "stop-and-report-invalid-compact-authority";
3914
+ case COMPACT_NATIVE_START_APPLICABILITY.UNRELATED_HISTORY:
3915
+ return "start-native-authoritative";
3916
+ }
3917
+ }
3918
+
3919
+ function compactNativeStartBlockedResult(applicability: CompactNativeStartClassification): Record<string, unknown> {
3920
+ return {
3921
+ operation: REVIEW_CONTROLLER_OPERATION.START,
3922
+ status: "blocked",
3923
+ outcome: `compact-authority-${applicability.applicability}`,
3924
+ mutation_performed: false,
3925
+ next_action: compactApplicabilityNextAction(applicability),
3926
+ ...(applicability.lineageIds.length === 0 ? {} : { lineage_ids: applicability.lineageIds }),
3927
+ };
3928
+ }
3929
+
3930
+ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
3931
+ return {
3932
+ operation,
3933
+ status: "blocked",
3934
+ outcome: "native-status-unsupported",
3935
+ mutation_performed: false,
3936
+ inventory_complete: false,
3937
+ next_action: "require-upstream-read-only-native-status-inventory",
3938
+ evidence: {
3939
+ native_contract: "gentle-ai/2.1.4",
3940
+ general_status: "unsupported",
3941
+ claimant_inventory: "unsupported",
3942
+ },
3943
+ };
3944
+ }
3945
+
3946
+ function mapNativeStartResult(result: NativeStartResult): Record<string, unknown> {
3947
+ return {
3948
+ lineage_id: result.lineageId,
3949
+ state: result.state,
3950
+ risk_tier: result.riskLevel,
3951
+ selected_lenses: result.selectedLenses,
3952
+ changed_files: result.changedFiles,
3953
+ original_changed_lines: result.changedLines,
3954
+ correction_budget: result.correctionBudget,
3955
+ action: result.action,
3956
+ lenses_required: result.lensesRequired,
3957
+ };
3958
+ }
3959
+
3960
+ function mapNativeFinalizeResult(result: NativeFinalizeResult): Record<string, unknown> {
3961
+ return {
3962
+ lineage_id: result.lineageId,
3963
+ state: result.state,
3964
+ action: result.action,
3965
+ store_revision: result.storeRevision,
3966
+ ...(result.receiptPath === undefined ? {} : { receipt_path: result.receiptPath }),
3967
+ };
3968
+ }
3969
+
3970
+ function mapNativeValidateResult(result: NativeValidateResult): Record<string, unknown> {
3971
+ return {
3972
+ allowed: result.allowed,
3973
+ result: result.result,
3974
+ action: result.action,
3975
+ reason: result.reason,
3976
+ context: result.gateContext.raw,
3977
+ };
3978
+ }
3979
+
3980
+ function nativeGateFingerprint(result: NativeValidateResult, derived: DerivedReviewGateTarget): string {
3981
+ return canonicalHash({
3982
+ gate_context: result.gateContext.raw,
3983
+ publication_target: {
3984
+ target: derived.target,
3985
+ native_publication: derived.nativePublication ?? null,
3986
+ },
3987
+ });
3988
+ }
3989
+
3990
+ function requestedNativeGate(command: ReviewLifecycleCommand): string {
3991
+ return command.event === "pre-release" ? "release" : command.event;
3992
+ }
3993
+
3994
+ function nativeGateFlags(derived: DerivedReviewGateTarget): readonly string[] {
3995
+ return derived.nativePublication?.flags ?? [];
3996
+ }
3997
+
3998
+ function assertFrozenPreCommitProjection(
3999
+ derived: DerivedReviewGateTarget,
4000
+ lineageId: string,
4001
+ candidateViews: CandidateViewRegistry | null,
4002
+ ): string | undefined {
4003
+ if (derived.command.event !== "pre-commit" || candidateViews === null || !candidateViews.hasProjection(lineageId)) return undefined;
4004
+ const projection = candidateViews.resolveProjection(lineageId, derived.command.cwd);
4005
+ if (derived.actualIntendedCommitTree !== projection.candidateTree) {
4006
+ throw new CandidateViewError("staged commit tree does not exactly match the frozen reviewed candidate projection");
4007
+ }
4008
+ return projection.candidateTree;
4009
+ }
4010
+
4011
+ function authorizationTargetHash(derived: DerivedReviewGateTarget): string {
4012
+ return derived.nativePublication === undefined
4013
+ ? canonicalHash(derived.target)
4014
+ : canonicalHash({ target: derived.target, native_publication: derived.nativePublication });
4015
+ }
4016
+
4017
+ function assertNativePublicationBinding(result: NativeValidateResult, derived: DerivedReviewGateTarget): void {
4018
+ const returnedGate = result.gateContext.raw.gate;
4019
+ if (returnedGate !== requestedNativeGate(derived.command) && (result.allowed || returnedGate !== "")) {
4020
+ throw new Error("Native validation returned a gate context for a different gate");
4021
+ }
4022
+ const expected = derived.nativePublication?.prePrBoundary;
4023
+ if (expected === undefined || !result.allowed || result.result !== "allow") return;
4024
+ const value = result.gateContext.raw.pre_pr_boundary;
4025
+ if (!isRecord(value)) throw new Error("Native pre-PR result omitted its publication boundary");
4026
+ if (
4027
+ value.source !== expected.source ||
4028
+ value.selector !== expected.selector ||
4029
+ value.commit !== expected.commit ||
4030
+ value.remote !== expected.remote ||
4031
+ value.remote_ref !== expected.remoteRef ||
4032
+ value.remote_identity !== expected.remoteIdentity
4033
+ ) throw new Error("Native pre-PR publication boundary does not match the exact PR command target");
4034
+ }
4035
+
4036
+ function assertNativePublicationUnchanged(before: DerivedReviewGateTarget, after: DerivedReviewGateTarget): void {
4037
+ if (authorizationTargetHash(before) !== authorizationTargetHash(after)) {
4038
+ throw new Error("Native publication target changed during native validation");
4039
+ }
4040
+ if (before.target.kind === GATE_TARGET_KIND.PULL_REQUEST) {
4041
+ if (
4042
+ after.target.kind !== GATE_TARGET_KIND.PULL_REQUEST ||
4043
+ after.target.head_commit !== before.target.head_commit ||
4044
+ after.nativePublication?.prePrHead?.commit !== before.target.head_commit
4045
+ ) throw new Error("Advertised pull request head changed during native validation");
4046
+ }
4047
+ }
4048
+
4049
+ async function rederiveNativePublicationTarget(
4050
+ expected: DerivedReviewGateTarget,
4051
+ command: string,
4052
+ defaultCwd: string,
4053
+ probe: PublicationProbe,
4054
+ timeoutMs: number,
4055
+ signal?: AbortSignal,
4056
+ ): Promise<DerivedReviewGateTarget> {
4057
+ const rederived = deriveReviewGateTarget(
4058
+ command,
4059
+ defaultCwd,
4060
+ expected.target.kind === GATE_TARGET_KIND.PUSH ? expected.target : undefined,
4061
+ );
4062
+ if (rederived.command.cwd !== expected.command.cwd) throw new Error("Lifecycle command repository changed during native validation");
4063
+ const fresh = await deriveNativePublicationTarget(rederived, probe, timeoutMs, signal);
4064
+ assertNativePublicationUnchanged(expected, fresh);
4065
+ return fresh;
4066
+ }
4067
+
4068
+ interface NativeStartPolicyValidation {
4069
+ policyPath?: string;
4070
+ reason?: string;
4071
+ }
4072
+
4073
+ function isStrictDescendantPath(parent: string, candidate: string): boolean {
4074
+ const pathFromParent = relative(parent, candidate);
4075
+ return pathFromParent.length > 0 && pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent);
4076
+ }
4077
+
4078
+ function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStartPolicyValidation {
4079
+ if (typeof value !== "string" || value.trim().length === 0) return { reason: "policy-path-not-regular" };
4080
+ let repository: string;
4081
+ try {
4082
+ repository = realpathSync(cwd);
4083
+ } catch {
4084
+ return { reason: "policy-path-outside-scope" };
4085
+ }
4086
+ const policyRoot = join(repository, ".gentle-ai", "policies");
4087
+ const candidate = resolve(repository, value);
4088
+ if (!isStrictDescendantPath(policyRoot, candidate)) return { reason: "policy-path-outside-scope" };
4089
+ const gentleDirectory = join(repository, ".gentle-ai");
4090
+ for (const directory of [gentleDirectory, policyRoot]) {
4091
+ try {
4092
+ const metadata = lstatSync(directory);
4093
+ if (metadata.isSymbolicLink()) return { reason: "policy-path-symlink" };
4094
+ if (!metadata.isDirectory()) return { reason: "policy-path-not-regular" };
4095
+ } catch {
4096
+ return { reason: "policy-path-not-regular" };
4097
+ }
4098
+ }
4099
+ const segments = relative(policyRoot, candidate).split(sep);
4100
+ let current = policyRoot;
4101
+ for (const [index, segment] of segments.entries()) {
4102
+ current = join(current, segment);
4103
+ try {
4104
+ const metadata = lstatSync(current);
4105
+ if (metadata.isSymbolicLink()) return { reason: "policy-path-symlink" };
4106
+ if (index === segments.length - 1) {
4107
+ if (!metadata.isFile()) return { reason: "policy-path-not-regular" };
4108
+ } else if (!metadata.isDirectory()) {
4109
+ return { reason: "policy-path-not-regular" };
4110
+ }
4111
+ } catch {
4112
+ return { reason: "policy-path-not-regular" };
4113
+ }
4114
+ }
4115
+ try {
4116
+ const canonicalPath = realpathSync(candidate);
4117
+ if (canonicalPath !== candidate || !isStrictDescendantPath(policyRoot, canonicalPath)) return { reason: "policy-path-symlink" };
4118
+ return { policyPath: canonicalPath };
4119
+ } catch {
4120
+ return { reason: "policy-path-not-regular" };
4121
+ }
4122
+ }
4123
+
4124
+ function nativeStartRejection(reason: string, field?: string): Record<string, unknown> {
4125
+ return {
4126
+ operation: REVIEW_CONTROLLER_OPERATION.START,
4127
+ status: "blocked",
4128
+ outcome: reason === "legacy-policy-hash-unsupported"
4129
+ ? "native-start-legacy-policy-hash-unsupported"
4130
+ : reason === "base-ref-invalid"
4131
+ ? "native-start-base-ref-invalid"
4132
+ : reason === "unknown-field"
4133
+ ? "native-start-input-invalid"
4134
+ : "native-start-policy-path-invalid",
4135
+ reason,
4136
+ ...(field === undefined ? {} : { field }),
4137
+ mutation_performed: false,
4138
+ mutation_outcome: "none",
4139
+ };
4140
+ }
4141
+
4142
+ function nativeOperationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
4143
+ const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
4144
+ const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
4145
+ return {
4146
+ operation,
4147
+ status: "blocked",
4148
+ outcome: "native-operation-failed",
4149
+ mutation_performed: false,
4150
+ mutation_outcome: mutationOutcome,
4151
+ ...(mutationOutcome === "unknown" || value.nextAction === "replay-exact-native-operation"
4152
+ ? { next_action: "replay-exact-native-operation" }
4153
+ : { next_action: "resolve-native-operation-failure" }),
4154
+ };
4155
+ }
4156
+
4157
+ function nativePublicationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
4158
+ if (error instanceof NativeSplitFetchPushUnsupportedError) {
4159
+ return {
4160
+ operation,
4161
+ status: "blocked",
4162
+ outcome: "native-split-fetch-push-unsupported",
4163
+ reason: error.message,
4164
+ mutation_performed: false,
4165
+ mutation_outcome: "none",
4166
+ next_action: error.nextAction,
4167
+ };
4168
+ }
4169
+ if (!(error instanceof NativePublicationBaseRequiredError)) return nativeOperationFailure(operation, error);
4170
+ return {
4171
+ operation,
4172
+ status: "blocked",
4173
+ outcome: "native-publication-base-required",
4174
+ reason: error.message,
4175
+ mutation_performed: false,
4176
+ mutation_outcome: "none",
4177
+ next_action: error.nextAction,
4178
+ };
4179
+ }
4180
+
4181
+ async function executeReviewControllerOperation(
4182
+ parametersValue: unknown,
4183
+ defaultCwd: string,
4184
+ pendingAuthorizations: Map<string, PendingReviewAuthorization>,
4185
+ nativeReviewCli: NativeReviewCli | null,
4186
+ signal?: AbortSignal,
4187
+ publicationProbe: PublicationProbe = nodePublicationProbe,
4188
+ publicationProbeTimeoutMs = PUBLICATION_PROBE_TIMEOUT_MS,
4189
+ candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
4190
+ ): Promise<Record<string, unknown>> {
4191
+ const parameters = parseReviewControllerParameters(parametersValue);
4192
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT) {
4193
+ const outputPath = requiredControllerString(parameters, "outputPath");
4194
+ const operationId = requiredControllerString(parameters, "operationId");
4195
+ const lineageIds = parameters.lineageIds === undefined ? undefined : JSON.parse(parameters.lineageIds) as unknown;
4196
+ if (lineageIds !== undefined && (!Array.isArray(lineageIds) || lineageIds.some((id) => typeof id !== "string"))) throw new Error("Export lineageIds must be a JSON string array");
4197
+ return { operation: parameters.operation, result: new ReviewBundleExporter(defaultCwd).export({ outputPath, operationId, lineageIds }) };
4198
+ }
4199
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
4200
+ // RISK2-001: adopting a brand-new lineage from a bundle is an experimental,
4201
+ // operator-attested trust decision (see lib/review-bundle.ts import gate).
4202
+ return { operation: parameters.operation, result: new ReviewBundleImporter(defaultCwd).import({ inputPath: requiredControllerString(parameters, "inputPath"), operationId: requiredControllerString(parameters, "operationId"), acknowledgeUntrustedBundleSource: parameters.acknowledgeUntrustedBundleSource === "true" }) };
4203
+ }
4204
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.PREPARE_SUPERSESSION) {
4205
+ const input = parseSupersessionControllerInput(parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation));
4206
+ const prepared = prepareSupersessionV1(input.prepared);
4207
+ assertSupersessionControllerIdentity(input, prepared);
4208
+ return { operation: parameters.operation, change_name: input.changeName, request_hash: prepared.request_hash, challenge: prepared.challenge, body: prepared.body };
4209
+ }
4210
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SUPERSEDE) {
4211
+ const input = parseSupersessionControllerInput(parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation));
4212
+ if (typeof input.challenge !== "string" || typeof input.request_hash !== "string") throw new Error("Review controller SUPERSEDE requires the exact prepared request_hash and challenge");
4213
+ const prepared = prepareSupersessionV1(input.prepared);
4214
+ assertSupersessionControllerIdentity(input, prepared);
4215
+ if (input.request_hash !== prepared.request_hash || input.challenge !== prepared.challenge) throw new Error("Review controller SUPERSEDE prepared request and challenge must exactly match the fresh authorization");
4216
+ const body = { ...prepared.body, authorization_hash: domainHashV1("review-authority-supersession-authorization", { challenge: prepared.challenge, request_hash: prepared.request_hash }) };
4217
+ const envelope = createSupersessionEnvelopeV1(body);
4218
+ const installed = SupersessionStoreV1.forRepository(defaultCwd).install(input.changeName, envelope, {
4219
+ casChecks: [
4220
+ {
4221
+ label: "graph source",
4222
+ expected: canonicalJsonV1(prepared.body.source),
4223
+ observe: () => {
4224
+ assertLiveRecoveredSourceBindingV1(defaultCwd, envelope);
4225
+ return canonicalJsonV1(inspectRecoverableGraphSourceV1(defaultCwd, input.changeName, input.sourceLineageId).source);
4226
+ },
4227
+ },
4228
+ {
4229
+ label: "compact successor",
4230
+ expected: canonicalJsonV1(prepared.body.successor),
4231
+ observe: () => {
4232
+ const successor = inspectApprovedCompactSuccessorV1(defaultCwd, input.successorLineageId);
4233
+ assertLiveRecoveredSuccessorBindingV1(defaultCwd, envelope, discoverCompactReview(defaultCwd, input.successorLineageId, true).record.state);
4234
+ return canonicalJsonV1(successor);
4235
+ },
4236
+ },
4237
+ ],
4238
+ });
4239
+ const active = resolveReviewAuthorityForChange(defaultCwd, input.changeName);
4240
+ if (!active || active.recovery.recovery_id !== installed.recovery_id) throw new Error("Review controller SUPERSEDE did not produce one validated active authority");
4241
+ return { operation: parameters.operation, change_name: input.changeName, recovery_id: installed.recovery_id, active_authority_id: active.record.state.lineage_id, next_action: "validate-recovered-authority" };
4242
+ }
4243
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && nativeReviewCli !== null) {
4244
+ try {
4245
+ const inspection = inspectReviewAuthorityForController(defaultCwd);
4246
+ if (inspection.outcome !== "clean") return { operation: parameters.operation, status: "blocked", inspection, inventory_complete: false, next_action: "resolve-pi-owned-preflight" };
4247
+ const compact = classifyCompactAuthorityApplicability(defaultCwd);
4248
+ const diagnostic = {
4249
+ compact_authority_applicability: compact.applicability,
4250
+ ...(compact.lineageIds.length === 0 ? {} : { compact_authority_lineage_ids: compact.lineageIds }),
4251
+ };
4252
+ if (compact.applicability === COMPACT_NATIVE_START_APPLICABILITY.UNRELATED_HISTORY) {
4253
+ return { operation: parameters.operation, status: "ready", inspection, ...diagnostic, inventory_complete: false, next_action: compactApplicabilityNextAction(compact), evidence: { native_contract: "gentle-ai/2.1.4", general_status: "unsupported", claimant_inventory: "unsupported", start_routing: "authoritative" } };
4254
+ }
4255
+ return {
4256
+ operation: parameters.operation,
4257
+ inspection,
4258
+ ...diagnostic,
4259
+ inventory_complete: false,
4260
+ status: compact.applicability === COMPACT_NATIVE_START_APPLICABILITY.NONTERMINAL ? "in-progress" : "blocked",
4261
+ next_action: compactApplicabilityNextAction(compact),
4262
+ };
4263
+ } catch (error) {
4264
+ if (isBrokenRepositoryIdentity(error)) {
4265
+ return {
4266
+ operation: parameters.operation,
4267
+ status: "blocked",
4268
+ outcome: "compact-authority-invalid",
4269
+ compact_authority_applicability: COMPACT_NATIVE_START_APPLICABILITY.INVALID,
4270
+ inventory_complete: false,
4271
+ next_action: "stop-and-report-invalid-compact-authority",
4272
+ };
4273
+ }
4274
+ if (error instanceof ReviewRepositoryError) return nativeStatusUnsupported(parameters.operation);
4275
+ throw error;
4276
+ }
3115
4277
  }
3116
4278
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT) {
3117
4279
  const authority = resolveRepositoryAuthorityV1(defaultCwd);
3118
4280
  const lock = new ReviewMutationLockV1(join(authority.store_root, "control"), authority.repository_id, authority.authority_id);
3119
4281
  const inspection = inspectReviewAuthorityForController(defaultCwd);
4282
+ const input = parameters.input === undefined ? undefined : parseControllerJson(parameters.input, parameters.operation);
4283
+ const policyHash = input?.mode === REVIEW_MODE.ORDINARY && isRecord(input.projection) && input.projection.kind === REVIEW_PROJECTION.COMPLETE && typeof input.policyHash === "string" ? input.policyHash : undefined;
4284
+ const applicability = inspection.outcome === "clean" && (
4285
+ inspection.compact_authority?.outcome === COMPACT_AUTHORITY_OUTCOME.APPROVED ||
4286
+ inspection.compact_authority?.outcome === COMPACT_AUTHORITY_OUTCOME.ESCALATED
4287
+ ) ? inspectCompactTerminalApplicability(defaultCwd, policyHash) : undefined;
4288
+ const candidateInspection = applicability === undefined ? inspection : {
4289
+ ...inspection,
4290
+ terminal_applicability: applicability.applicability,
4291
+ terminal_lineage_ids: applicability.lineageIds,
4292
+ };
4293
+ const terminal = applicability?.applicability === "applicable";
4294
+ const terminalAmbiguous = applicability?.applicability === "ambiguous";
3120
4295
  return {
3121
4296
  operation: parameters.operation,
3122
- inspection,
4297
+ inspection: candidateInspection,
3123
4298
  lock: lock.inspect(),
3124
4299
  ...(inspection.outcome === "clean" && inspection.compact_authority !== undefined
3125
- ? {
3126
- status: inspection.compact_authority.outcome === COMPACT_AUTHORITY_OUTCOME.ESCALATED
3127
- ? "escalated"
3128
- : inspection.compact_authority.outcome === COMPACT_AUTHORITY_OUTCOME.APPROVED
3129
- ? "terminal"
3130
- : inspection.compact_authority.outcome === COMPACT_AUTHORITY_OUTCOME.ACTIVE
3131
- ? "in-progress"
3132
- : "blocked",
3133
- next_action: compactAuthorityAction(inspection),
3134
- }
4300
+ ? inspection.compact_authority.outcome === COMPACT_AUTHORITY_OUTCOME.INVALID
4301
+ ? { status: "blocked", next_action: "request-explicit-reset-authorization" }
4302
+ : inspection.compact_authority.outcome === COMPACT_AUTHORITY_OUTCOME.ACTIVE
4303
+ ? { status: "in-progress", next_action: "finalize-existing-ordinary-review" }
4304
+ : terminalAmbiguous
4305
+ ? { status: "blocked", next_action: "stop-and-report-ambiguous-compact-terminal-authority" }
4306
+ : terminal
4307
+ ? { status: "terminal", next_action: compactAuthorityAction(inspection) }
4308
+ : { status: "ready", next_action: "start-ordinary-review" }
3135
4309
  : inspection.outcome === "clean"
3136
4310
  ? { status: "ready", next_action: "start-ordinary-review" }
3137
4311
  : inspection.outcome === "blocked-ambiguous"
3138
- ? { status: "blocked", next_action: "stop-and-report-ambiguous-authority" }
3139
- : {
3140
- status: "blocked",
3141
- next_action: inspection.outcome === "reset-in-progress"
3142
- ? "request-explicit-reset-recovery-authorization"
3143
- : "request-explicit-reset-authorization",
3144
- }),
4312
+ ? { status: "blocked", next_action: "stop-and-report-ambiguous-authority" }
4313
+ : { status: "blocked", next_action: inspection.outcome === "reset-in-progress" ? "request-explicit-reset-recovery-authorization" : "request-explicit-reset-authorization" }),
3145
4314
  };
3146
4315
  }
4316
+
3147
4317
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK) {
3148
4318
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
3149
4319
  if (typeof input.ownerHash !== "string") throw new Error("Lock recovery requires an exact ownerHash");
@@ -3154,7 +4324,20 @@ function executeReviewControllerOperation(
3154
4324
  }
3155
4325
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) {
3156
4326
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
3157
- durableResetRecoveryRequest(defaultCwd);
4327
+ try {
4328
+ durableResetRecoveryRequest(defaultCwd);
4329
+ } catch (error) {
4330
+ if (error instanceof ReviewResetStateUnavailableError) {
4331
+ return {
4332
+ operation: parameters.operation,
4333
+ status: "blocked",
4334
+ outcome: REVIEW_CONTROLLER_OUTCOME.RESET_STATE_UNAVAILABLE,
4335
+ mutation_performed: false,
4336
+ next_action: "stop-and-restore-durable-reset-state",
4337
+ };
4338
+ }
4339
+ throw error;
4340
+ }
3158
4341
  pendingAuthorizations.clear();
3159
4342
  const result = destructiveResetReviewAuthorityV1({ cwd: defaultCwd, repositoryId: String(input.repositoryId), commonDirHash: String(input.commonDirHash), inventoryHash: String(input.inventoryHash), confirmation: String(input.confirmation), resume: true });
3160
4343
  const inspection = inspectReviewAuthorityForController(defaultCwd);
@@ -3186,11 +4369,114 @@ function executeReviewControllerOperation(
3186
4369
  store.repairCurrentAuthority();
3187
4370
  return { operation: parameters.operation, repaired: true };
3188
4371
  }
4372
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.BIND_SDD) {
4373
+ if (nativeReviewCli === null) return nativeStatusUnsupported(parameters.operation);
4374
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
4375
+ if (
4376
+ typeof input.change !== "string" ||
4377
+ typeof input.lineageId !== "string" ||
4378
+ typeof input.expectedBindingRevision !== "string"
4379
+ ) throw new Error("Native bind-sdd requires change, lineageId, and expected binding revision");
4380
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(input.change)) throw new Error("Native bind-sdd change name is invalid");
4381
+ if (!NATIVE_BIND_TOKEN_RE.test(input.lineageId)) throw new Error("Native bind-sdd lineageId is invalid");
4382
+ if (input.expectedBindingRevision !== "" && !NATIVE_BIND_TOKEN_RE.test(input.expectedBindingRevision)) throw new Error("Native bind-sdd expected binding revision is invalid");
4383
+ const expectedPath = join("openspec", "changes", input.change);
4384
+ const canonicalCwd = realpathSync(defaultCwd);
4385
+ const absolutePath = resolve(canonicalCwd, expectedPath);
4386
+ if (!existsSync(absolutePath) || !lstatSync(absolutePath).isDirectory()) throw new Error("Native bind-sdd change path is outside or missing from the repository");
4387
+ const canonicalPath = realpathSync(absolutePath);
4388
+ const pathFromRepository = relative(canonicalCwd, canonicalPath);
4389
+ if (pathFromRepository === ".." || pathFromRepository.startsWith(`..${sep}`) || isAbsolute(pathFromRepository)) throw new Error("Native bind-sdd change path is outside or missing from the repository");
4390
+ try {
4391
+ const bound = await nativeReviewCli.bindSdd({
4392
+ cwd: canonicalCwd,
4393
+ change: input.change,
4394
+ lineage: input.lineageId,
4395
+ expectedBindingRevision: input.expectedBindingRevision,
4396
+ ...(signal === undefined ? {} : { signal }),
4397
+ });
4398
+ if (
4399
+ bound.change !== input.change ||
4400
+ bound.lineage !== input.lineageId ||
4401
+ typeof bound.revision !== "string" || bound.revision.length === 0 ||
4402
+ typeof bound.authorityRevision !== "string" || bound.authorityRevision.length === 0 ||
4403
+ typeof bound.receiptHash !== "string" || bound.receiptHash.length === 0 ||
4404
+ bound.gateContext.lineageId !== input.lineageId ||
4405
+ bound.gateContext.storeRevision !== bound.authorityRevision
4406
+ ) throw Object.assign(
4407
+ new Error("Native bind-sdd returned malformed or inconsistent binding evidence"),
4408
+ { mutationOutcome: "unknown", nextAction: "replay-exact-native-operation" },
4409
+ );
4410
+ return { operation: parameters.operation, binding: {
4411
+ revision: bound.revision,
4412
+ change: bound.change,
4413
+ lineage: bound.lineage,
4414
+ authority_revision: bound.authorityRevision,
4415
+ receipt_hash: bound.receiptHash,
4416
+ gate_context: bound.gateContext.raw,
4417
+ } };
4418
+ } catch (error) {
4419
+ return nativeOperationFailure(parameters.operation, error);
4420
+ }
4421
+ }
3189
4422
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
3190
4423
  const rawStart = parseControllerJson(
3191
4424
  requiredControllerString(parameters, "input"),
3192
4425
  REVIEW_CONTROLLER_OPERATION.START,
3193
4426
  );
4427
+ if (rawStart.mode === REVIEW_MODE.ORDINARY && nativeReviewCli !== null) {
4428
+ const selectorFree = !("policyPath" in rawStart) && !("baseRef" in rawStart);
4429
+ if (selectorFree) {
4430
+ let compact: CompactNativeStartClassification | undefined;
4431
+ try {
4432
+ const inspection = inspectReviewAuthorityForController(defaultCwd);
4433
+ if (inspection.outcome !== "clean") {
4434
+ return { operation: parameters.operation, status: "blocked", outcome: "legacy-read-only", mutation_performed: false, next_action: "use-compatible-read-or-gate-route" };
4435
+ }
4436
+ compact = classifyCompactAuthorityApplicability(defaultCwd);
4437
+ } catch (error) {
4438
+ if (isBrokenRepositoryIdentity(error)) {
4439
+ return compactNativeStartBlockedResult({ applicability: COMPACT_NATIVE_START_APPLICABILITY.INVALID, lineageIds: [] });
4440
+ }
4441
+ if (!(error instanceof ReviewRepositoryError)) throw error;
4442
+ }
4443
+ if (compact !== undefined && compact.applicability !== COMPACT_NATIVE_START_APPLICABILITY.UNRELATED_HISTORY) {
4444
+ return compactNativeStartBlockedResult(compact);
4445
+ }
4446
+ }
4447
+ if (typeof parameters.lineageId === "string" && isKnownPiLegacyLineage(defaultCwd, parameters.lineageId)) {
4448
+ return { operation: parameters.operation, status: "blocked", outcome: "legacy-read-only", mutation_performed: false, next_action: "use-compatible-read-or-gate-route" };
4449
+ }
4450
+ if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported");
4451
+ const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "policyPath"].includes(field));
4452
+ if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField);
4453
+ const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined
4454
+ ? {}
4455
+ : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath);
4456
+ if (policy.reason !== undefined) return nativeStartRejection(policy.reason);
4457
+ const baseRef = rawStart.baseRef;
4458
+ if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid");
4459
+ const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
4460
+ let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
4461
+ try {
4462
+ candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey });
4463
+ const result = await nativeReviewCli.start({
4464
+ cwd: candidateView?.root ?? defaultCwd,
4465
+ ...(baseRef === undefined ? {} : { baseRef }),
4466
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
4467
+ ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
4468
+ ...(signal === undefined ? {} : { signal }),
4469
+ });
4470
+ if (candidateView && candidateViews && result.lensesRequired) candidateViews.bind({ token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses });
4471
+ else if (candidateView && candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed" || result.action === "reuse-receipt")) candidateViews.retain(candidateView.token, result.lineageId);
4472
+ else if (candidateView && candidateViews) candidateViews.cleanup(candidateView.token);
4473
+ return { operation: parameters.operation, result: mapNativeStartResult(result) };
4474
+ } catch (error) {
4475
+ const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
4476
+ if (candidateView && candidateViews && value.mutationOutcome !== "unknown" && value.nextAction !== "replay-exact-native-operation") candidateViews.cleanup(candidateView.token);
4477
+ return nativeOperationFailure(parameters.operation, error);
4478
+ }
4479
+ }
3194
4480
  const inspection = inspectReviewAuthorityForController(defaultCwd);
3195
4481
  if (inspection.outcome !== "clean") {
3196
4482
  return {
@@ -3225,6 +4511,16 @@ function executeReviewControllerOperation(
3225
4511
  const state = discoverCompactReview(defaultCwd, result.lineage_id).record.state;
3226
4512
  return { operation: parameters.operation, result, state };
3227
4513
  } catch (error) {
4514
+ if (error instanceof CompactReviewTerminalAmbiguityError) {
4515
+ return {
4516
+ operation: parameters.operation,
4517
+ status: "blocked",
4518
+ lineage_created: false,
4519
+ lineage_ids: error.lineageIds,
4520
+ next_action: "stop-and-report-ambiguous-compact-terminal-authority",
4521
+ inspection: inspectReviewAuthorityForController(defaultCwd),
4522
+ };
4523
+ }
3228
4524
  if (error instanceof CompactReviewStartBlockedError) {
3229
4525
  return {
3230
4526
  operation: parameters.operation,
@@ -3297,6 +4593,47 @@ function executeReviewControllerOperation(
3297
4593
  if (raw.final_evidence !== undefined && typeof raw.final_evidence !== "string") throw new Error("Review controller finalize final_evidence must be a string");
3298
4594
  if (raw.final_verification_passed !== undefined && typeof raw.final_verification_passed !== "boolean") throw new Error("Review controller finalize final_verification_passed must be boolean");
3299
4595
  if (raw.final_evidence !== undefined && raw.final_verification_passed === undefined) throw new Error("Review controller finalize with final_evidence requires an explicit final_verification_passed boolean");
4596
+ if (
4597
+ nativeReviewCli !== null &&
4598
+ typeof parameters.lineageId === "string" &&
4599
+ isKnownPiLegacyLineage(defaultCwd, parameters.lineageId)
4600
+ ) {
4601
+ return { operation: parameters.operation, status: "blocked", outcome: "legacy-read-only", mutation_performed: false, next_action: "use-compatible-read-or-gate-route" };
4602
+ }
4603
+ if (nativeReviewCli !== null) {
4604
+ const input = parseNativeCompactFinalizeInput({
4605
+ cwd: defaultCwd,
4606
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
4607
+ ...raw,
4608
+ });
4609
+ let correctionCompletion = false;
4610
+ let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
4611
+ try {
4612
+ if (candidateViews && parameters.lineageId === undefined) throw new CandidateViewError("Native FINALIZE requires an explicit candidate-view lineage");
4613
+ correctionCompletion = input.review_result === undefined && (input.validation !== undefined || input.validation_proof !== undefined) && input.final_evidence !== undefined;
4614
+ const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
4615
+ candidateView = candidateViews && parameters.lineageId ? correctionCompletion ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey) : candidateViews.resolveForFinalize(parameters.lineageId) : undefined;
4616
+ const result = await nativeReviewCli.finalize({
4617
+ cwd: candidateView?.root ?? defaultCwd,
4618
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
4619
+ ...(input.review_result === undefined ? {} : { lensResults: input.review_result.lens_results.map((document, index) => ({ lens: document.lens ?? `lens-${index}`, document: toNativeReviewerDocument(document) })) }),
4620
+ ...(input.refuter_batch === undefined ? {} : { refuterDocument: toNativeRefuterDocument(input.refuter_batch) }),
4621
+ ...(input.correction_line_forecast === undefined ? {} : { correctionLines: input.correction_line_forecast }),
4622
+ ...(input.validation === undefined && input.validation_proof === undefined ? {} : { validationDocument: toNativeValidatorDocument(input.validation ?? input.validation_proof!) }),
4623
+ ...(input.final_evidence === undefined ? {} : { evidenceDocument: input.final_evidence, failed: input.final_verification_passed === false }),
4624
+ ...(signal === undefined ? {} : { signal }),
4625
+ });
4626
+ try {
4627
+ if (correctionCompletion && candidateViews && parameters.lineageId) candidateViews.promoteCorrected(parameters.lineageId, candidateView!.token);
4628
+ candidateViews?.cleanupTerminal(result.lineageId, result.state);
4629
+ } catch {}
4630
+ return { operation: parameters.operation, result: mapNativeFinalizeResult(result) };
4631
+ } catch (error) {
4632
+ const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
4633
+ if (correctionCompletion && candidateView && candidateViews && value.mutationOutcome !== "unknown" && value.nextAction !== "replay-exact-native-operation") candidateViews.cleanup(candidateView.token);
4634
+ return nativeOperationFailure(parameters.operation, error);
4635
+ }
4636
+ }
3300
4637
  return {
3301
4638
  operation: parameters.operation,
3302
4639
  result: finalizeCompactReview({
@@ -3372,6 +4709,7 @@ function executeReviewControllerOperation(
3372
4709
  };
3373
4710
  }
3374
4711
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.STATUS) {
4712
+ if (parameters.lineageId === undefined) return nativeStatusUnsupported(parameters.operation);
3375
4713
  const compactExists = compactV2LineageExists(defaultCwd, parameters.lineageId!);
3376
4714
  const graphExists = graphV1LineageExists(defaultCwd, parameters.lineageId!);
3377
4715
  if (compactExists && graphExists) throw new Error("Review authority is ambiguous across graph-v1 and compact-v2");
@@ -3403,6 +4741,65 @@ function executeReviewControllerOperation(
3403
4741
  ),
3404
4742
  );
3405
4743
  const derived = deriveReviewGateTarget(commandValue, defaultCwd);
4744
+ if (
4745
+ nativeReviewCli !== null &&
4746
+ typeof parameters.lineageId === "string" &&
4747
+ !compactV2LineageExists(derived.command.cwd, parameters.lineageId) &&
4748
+ !graphV1LineageExists(derived.command.cwd, parameters.lineageId)
4749
+ ) {
4750
+ try {
4751
+ const nativeDerived = await deriveNativePublicationTarget(
4752
+ derived,
4753
+ publicationProbe,
4754
+ publicationProbeTimeoutMs,
4755
+ signal,
4756
+ );
4757
+ const intendedTree = assertFrozenPreCommitProjection(nativeDerived, parameters.lineageId, candidateViews);
4758
+ const result = await nativeReviewCli.validate({
4759
+ cwd: nativeDerived.command.cwd,
4760
+ gate: requestedNativeGate(nativeDerived.command),
4761
+ lineageId: parameters.lineageId,
4762
+ flags: nativeGateFlags(nativeDerived),
4763
+ ...(signal === undefined ? {} : { signal }),
4764
+ });
4765
+ assertNativePublicationBinding(result, nativeDerived);
4766
+ const authorizedDerived = result.allowed && result.result === "allow"
4767
+ ? await rederiveNativePublicationTarget(
4768
+ nativeDerived,
4769
+ commandValue,
4770
+ defaultCwd,
4771
+ publicationProbe,
4772
+ publicationProbeTimeoutMs,
4773
+ signal,
4774
+ )
4775
+ : nativeDerived;
4776
+ if (result.allowed && result.result === "allow") assertFrozenPreCommitProjection(authorizedDerived, parameters.lineageId, candidateViews);
4777
+ const response: Record<string, unknown> = {
4778
+ operation: parameters.operation,
4779
+ result: mapNativeValidateResult(result),
4780
+ derived_target: nativeDerived.target,
4781
+ };
4782
+ if (result.allowed && result.result === "allow") {
4783
+ const commandHash = reviewAuthorizationKey(commandValue, authorizedDerived.command.cwd);
4784
+ const authorization: PendingReviewAuthorization = {
4785
+ command_hash: commandHash,
4786
+ target_hash: authorizationTargetHash(authorizedDerived),
4787
+ receipt_hash: null,
4788
+ native_gate: {
4789
+ lineage_id: result.gateContext.lineageId,
4790
+ store_revision: result.gateContext.storeRevision,
4791
+ fingerprint: nativeGateFingerprint(result, authorizedDerived),
4792
+ ...(intendedTree === undefined ? {} : { intended_tree: intendedTree }),
4793
+ },
4794
+ };
4795
+ pendingAuthorizations.set(commandHash, authorization);
4796
+ response.authorization = authorization;
4797
+ }
4798
+ return response;
4799
+ } catch (error) {
4800
+ return nativePublicationFailure(parameters.operation, error);
4801
+ }
4802
+ }
3406
4803
  let releaseFastPath: Record<string, unknown> | undefined;
3407
4804
  if (input.release !== undefined) {
3408
4805
  if (derived.command.event !== "pre-release") {
@@ -3450,13 +4847,30 @@ function executeReviewControllerOperation(
3450
4847
  };
3451
4848
  }
3452
4849
  }
3453
- let compact = false;
3454
- if (typeof parameters.lineageId === "string" && parameters.lineageId.trim().length > 0) {
4850
+ if (
4851
+ parameters.changeName === undefined &&
4852
+ (parameters.lineageId === undefined || !graphV1LineageExists(derived.command.cwd, parameters.lineageId)) &&
4853
+ hasEligibleGraphV1RecoveryAuthorityV1(derived.command.cwd)
4854
+ ) {
4855
+ throw new Error("Review controller validate requires changeName and a valid supersession for eligible graph-v1 recovery authority");
4856
+ }
4857
+ const recoveredAuthority = parameters.changeName === undefined
4858
+ ? undefined
4859
+ : resolveReviewAuthorityForChange(derived.command.cwd, parameters.changeName);
4860
+ if (
4861
+ recoveredAuthority !== undefined &&
4862
+ parameters.lineageId !== undefined &&
4863
+ parameters.lineageId !== recoveredAuthority.record.state.lineage_id
4864
+ ) {
4865
+ throw new Error("Review controller validate lineageId does not match the change-scoped recovered authority");
4866
+ }
4867
+ let compact = recoveredAuthority !== undefined;
4868
+ if (!compact && typeof parameters.lineageId === "string" && parameters.lineageId.trim().length > 0) {
3455
4869
  const compactExists = compactV2LineageExists(derived.command.cwd, parameters.lineageId);
3456
4870
  const graphExists = graphV1LineageExists(derived.command.cwd, parameters.lineageId);
3457
4871
  if (compactExists && graphExists) throw new Error("Review authority is ambiguous across graph-v1 and compact-v2");
3458
4872
  compact = compactExists;
3459
- } else {
4873
+ } else if (!compact) {
3460
4874
  try {
3461
4875
  discoverCompactReview(derived.command.cwd, undefined, true);
3462
4876
  compact = true;
@@ -3468,6 +4882,7 @@ function executeReviewControllerOperation(
3468
4882
  const result = validateCompactReviewGate({
3469
4883
  cwd: derived.command.cwd,
3470
4884
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
4885
+ ...(parameters.changeName === undefined ? {} : { changeName: parameters.changeName }),
3471
4886
  deriveTarget: () => {
3472
4887
  const rederived = deriveReviewGateTarget(commandValue, defaultCwd);
3473
4888
  if (rederived.command.cwd !== derived.command.cwd) throw new Error("Lifecycle command repository changed during compact validation");
@@ -3529,11 +4944,16 @@ function executeReviewControllerOperation(
3529
4944
  return response;
3530
4945
  }
3531
4946
 
3532
- function gateLifecycleCommand(
4947
+ async function gateLifecycleCommand(
3533
4948
  command: string,
3534
4949
  defaultCwd: string,
3535
4950
  pendingAuthorizations: Map<string, PendingReviewAuthorization>,
3536
- ): ToolCallEventResult | undefined {
4951
+ nativeReviewCli: NativeReviewCli | null = null,
4952
+ publicationProbe: PublicationProbe = nodePublicationProbe,
4953
+ publicationProbeTimeoutMs = PUBLICATION_PROBE_TIMEOUT_MS,
4954
+ signal?: AbortSignal,
4955
+ candidateViews: CandidateViewRegistry | null = null,
4956
+ ): Promise<ToolCallEventResult | undefined> {
3537
4957
  const inspection = inspectReviewLifecycleCommand(command, defaultCwd);
3538
4958
  if (!inspection.event) return undefined;
3539
4959
  if (!inspection.command) {
@@ -3557,9 +4977,40 @@ function gateLifecycleCommand(
3557
4977
  };
3558
4978
  }
3559
4979
  pendingAuthorizations.delete(commandHash);
4980
+ if (authorization.native_gate) {
4981
+ try {
4982
+ derived = await deriveNativePublicationTarget(
4983
+ derived,
4984
+ publicationProbe,
4985
+ publicationProbeTimeoutMs,
4986
+ signal,
4987
+ );
4988
+ } catch (error) {
4989
+ return {
4990
+ block: true,
4991
+ reason: `Gentle AI ${inspection.event} gate native publication target changed after authorization and failed closed: ${error instanceof Error ? error.message : String(error)}`,
4992
+ };
4993
+ }
4994
+ }
4995
+ try {
4996
+ const intendedTree = authorization.native_gate === undefined
4997
+ ? undefined
4998
+ : assertFrozenPreCommitProjection(derived, authorization.native_gate.lineage_id, candidateViews);
4999
+ if (authorization.native_gate?.intended_tree !== undefined && intendedTree !== authorization.native_gate.intended_tree) {
5000
+ return {
5001
+ block: true,
5002
+ reason: `Gentle AI ${inspection.event} gate staged projection changed after authorization and failed closed.`,
5003
+ };
5004
+ }
5005
+ } catch (error) {
5006
+ return {
5007
+ block: true,
5008
+ reason: `Gentle AI ${inspection.event} gate could not re-prove the staged projection and failed closed: ${error instanceof Error ? error.message : String(error)}`,
5009
+ };
5010
+ }
3560
5011
  if (
3561
5012
  authorization.command_hash !== commandHash ||
3562
- authorization.target_hash !== canonicalHash(derived.target)
5013
+ authorization.target_hash !== authorizationTargetHash(derived)
3563
5014
  ) {
3564
5015
  const mismatch = authorization.command_hash !== commandHash ? "command identity" : "typed target";
3565
5016
  return {
@@ -3567,6 +5018,56 @@ function gateLifecycleCommand(
3567
5018
  reason: `Gentle AI ${inspection.event} gate ${mismatch} changed after authorization and failed closed.`,
3568
5019
  };
3569
5020
  }
5021
+ if (authorization.native_gate) {
5022
+ if (nativeReviewCli === null) {
5023
+ return {
5024
+ block: true,
5025
+ reason: `Gentle AI ${inspection.event} gate native validation dependency is unavailable and failed closed.`,
5026
+ };
5027
+ }
5028
+ try {
5029
+ const fresh = await nativeReviewCli.validate({
5030
+ cwd: derived.command.cwd,
5031
+ gate: requestedNativeGate(derived.command),
5032
+ lineageId: authorization.native_gate.lineage_id,
5033
+ flags: nativeGateFlags(derived),
5034
+ ...(signal === undefined ? {} : { signal }),
5035
+ });
5036
+ assertNativePublicationBinding(fresh, derived);
5037
+ if (
5038
+ !fresh.allowed ||
5039
+ fresh.result !== "allow"
5040
+ ) {
5041
+ return {
5042
+ block: true,
5043
+ reason: `Gentle AI ${inspection.event} gate native authority, receipt, revision, or target changed after authorization and failed closed.`,
5044
+ };
5045
+ }
5046
+ const postNativeDerived = await rederiveNativePublicationTarget(
5047
+ derived,
5048
+ command,
5049
+ defaultCwd,
5050
+ publicationProbe,
5051
+ publicationProbeTimeoutMs,
5052
+ signal,
5053
+ );
5054
+ const postNativeIntendedTree = assertFrozenPreCommitProjection(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
5055
+ if (authorization.native_gate.intended_tree !== undefined && postNativeIntendedTree !== authorization.native_gate.intended_tree) throw new CandidateViewError("staged projection changed during native validation");
5056
+ assertNativePublicationBinding(fresh, postNativeDerived);
5057
+ if (nativeGateFingerprint(fresh, postNativeDerived) !== authorization.native_gate.fingerprint) {
5058
+ return {
5059
+ block: true,
5060
+ reason: `Gentle AI ${inspection.event} gate native authority, receipt, revision, or target changed after authorization and failed closed.`,
5061
+ };
5062
+ }
5063
+ derived = postNativeDerived;
5064
+ } catch {
5065
+ return {
5066
+ block: true,
5067
+ reason: `Gentle AI ${inspection.event} gate native bash-time validation failed closed.`,
5068
+ };
5069
+ }
5070
+ }
3570
5071
  if (authorization.release_fast_path) {
3571
5072
  // The remote protected main head is rechecked immediately before the tag
3572
5073
  // push; an advanced or unprovable head fails closed.
@@ -3592,7 +5093,7 @@ export async function enforceReviewGateAndCommandSafety(
3592
5093
  ): Promise<ToolCallEventResult | undefined> {
3593
5094
  const safetyResult = await evaluateSafety(command);
3594
5095
  if (safetyResult) return safetyResult;
3595
- return evaluateGate(command);
5096
+ return await evaluateGate(command);
3596
5097
  }
3597
5098
 
3598
5099
  /** @internal */
@@ -3609,25 +5110,119 @@ export const __testing = {
3609
5110
  inspectReviewLifecycleCommand,
3610
5111
  deriveReviewGateTarget,
3611
5112
  gateLifecycleCommand,
5113
+ nativeStatusUnsupported,
3612
5114
  executeReviewControllerOperation,
3613
5115
  enforceReviewGateAndCommandSafety,
3614
5116
  renderSddModelPanel: renderSddModelPanelForTesting,
3615
5117
  getOrchestratorPrompt,
3616
5118
  renderOrchestratorPrompt,
5119
+ resolveControllerSddStatus,
5120
+ resolveStartupControllerSddStatus,
5121
+ repositoryLocationIdentity,
5122
+ runPublicationProbeGit,
5123
+ publicationProbeErrorCode: PUBLICATION_PROBE_ERROR_CODE,
3617
5124
  };
3618
5125
 
3619
- export default function gentleAi(pi: ExtensionAPI): void {
5126
+ const NATIVE_SDD_STATUS_STARTUP_TIMEOUT_MS = 1_000;
5127
+
5128
+ async function resolveControllerSddStatus(
5129
+ cwd: string,
5130
+ changeName: string | undefined,
5131
+ includeInstructions: boolean,
5132
+ artifactStore: SddPreflightPreferences["artifactStore"] | undefined,
5133
+ nativeReviewCli: NativeReviewCli | null = null,
5134
+ signal?: AbortSignal,
5135
+ ) {
5136
+ const base = resolveSddStatus({ cwd, changeName, includeInstructions, artifactStore });
5137
+ if (!base.changeName || base.isNonAuthoritative) return base;
5138
+ if (hasRecoveryObligationForChange(cwd, base.changeName)) {
5139
+ return resolveSddStatus({
5140
+ cwd,
5141
+ changeName: base.changeName,
5142
+ includeInstructions,
5143
+ artifactStore,
5144
+ reviewAuthority: {
5145
+ expected: true,
5146
+ resolve: () => {
5147
+ const active = resolveReviewAuthorityForChange(cwd, base.changeName!);
5148
+ if (!active) throw new Error("validated recovered review authority is missing");
5149
+ return { activeAuthorityId: active.record.state.lineage_id };
5150
+ },
5151
+ },
5152
+ });
5153
+ }
5154
+ if (base.applyState !== "all_done" || nativeReviewCli === null) return base;
5155
+ try {
5156
+ const native = await nativeReviewCli.sddStatus({ cwd, change: base.changeName, ...(signal === undefined ? {} : { signal }) });
5157
+ return resolveSddStatus({
5158
+ cwd,
5159
+ changeName: base.changeName,
5160
+ includeInstructions,
5161
+ artifactStore,
5162
+ nativeReviewReadiness: { expected: true, ready: native.ready },
5163
+ });
5164
+ } catch (error) {
5165
+ return resolveSddStatus({
5166
+ cwd,
5167
+ changeName: base.changeName,
5168
+ includeInstructions,
5169
+ artifactStore,
5170
+ nativeReviewReadiness: { expected: true, ready: false, reason: error instanceof Error ? error.message : "native bound status failed" },
5171
+ });
5172
+ }
5173
+ }
5174
+
5175
+ async function resolveStartupControllerSddStatus(
5176
+ cwd: string,
5177
+ changeName: string | undefined,
5178
+ includeInstructions: boolean,
5179
+ artifactStore: SddPreflightPreferences["artifactStore"] | undefined,
5180
+ nativeReviewCli: NativeReviewCli | null,
5181
+ timeoutMs = NATIVE_SDD_STATUS_STARTUP_TIMEOUT_MS,
5182
+ ) {
5183
+ return resolveControllerSddStatus(cwd, changeName, includeInstructions, artifactStore, nativeReviewCli, AbortSignal.timeout(timeoutMs));
5184
+ }
5185
+
5186
+ function hasRecoveryObligationForChange(cwd: string, changeName: string): boolean {
5187
+ try {
5188
+ return SupersessionStoreV1.forRepository(cwd).hasRecoveryRequiredMarker(changeName) || hasEligibleGraphV1RecoveryAuthorityV1(cwd);
5189
+ } catch (error) {
5190
+ // A non-repository cannot have repository-anchored recovery authority.
5191
+ if (error instanceof Error && error.message === "Unable to resolve Git repository authority") return false;
5192
+ // An unreadable marker, store, or Git authority is recovery-required, never absent.
5193
+ return true;
5194
+ }
5195
+ }
5196
+
5197
+ export interface GentleAiRuntimeDependencies {
5198
+ nativeReviewCli?: NativeReviewCli | null;
5199
+ candidateViews?: CandidateViewRegistry | null;
5200
+ publicationProbe?: PublicationProbe;
5201
+ publicationProbeTimeoutMs?: number;
5202
+ bashTimeRevalidationTimeoutMs?: number;
5203
+ }
5204
+
5205
+ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
5206
+ const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
5207
+ const publicationProbe = dependencies.publicationProbe ?? nodePublicationProbe;
5208
+ const publicationProbeTimeoutMs = dependencies.publicationProbeTimeoutMs ?? PUBLICATION_PROBE_TIMEOUT_MS;
5209
+ const bashTimeRevalidationTimeoutMs = dependencies.bashTimeRevalidationTimeoutMs ?? BASH_TIME_REVALIDATION_TIMEOUT_MS;
5210
+ if (!Number.isSafeInteger(publicationProbeTimeoutMs) || publicationProbeTimeoutMs <= 0) throw new TypeError("Publication probe timeout must be a positive safe integer");
5211
+ if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
5212
+ return function gentleAi(pi: ExtensionAPI): void {
3620
5213
  const pendingReviewAuthorizations = new Map<string, PendingReviewAuthorization>();
5214
+ const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
3621
5215
 
3622
5216
  pi.registerTool({
3623
5217
  name: "gentle_review",
3624
5218
  label: "Gentle Review Controller",
3625
5219
  description:
3626
- "Inspect and recover review authority, run new ordinary review through compact start/finalize/validate, preserve graph-v1 Judgment Day and compatibility reads, and authorize one exact lifecycle command. RESET and RECOVER require fresh interactive authorization and fail closed headlessly.",
3627
- promptSnippet: "Inspect authority, then use compact start/finalize/validate for ordinary review; use graph-v1 only for explicit Judgment Day",
5220
+ "Inspect and recover review authority, run new native ordinary review through start/finalize/validate, preserve legacy compact compatibility reads and graph-v1 Judgment Day, and authorize one exact lifecycle command. FINALIZE input is a JSON string: review_result.lens_results[] entries contain lens, findings, and non-empty evidence exactly once for every lens selected by START; final_evidence and final_verification_passed are paired. This is the Pi wrapper contract, distinct from native CLI --result, --refuter, --validation, and --evidence files. RESET/RECOVER remain destructive; prepare-supersession/supersede require fresh interactive authorization and fail closed headlessly.",
5221
+ promptSnippet: "Inspect authority, then use native start/finalize/validate for a new ordinary review; use graph-v1 only for explicit Judgment Day",
3628
5222
  promptGuidelines: [
3629
- 'Call {"operation":"inspect"} before START. Ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\",\\"policyHash\\":\\"<hash>\\"}"; the controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget.',
3630
- "Run selected lenses once, then call FINALIZE with causal result JSON. FINALIZE alone records correction forecast, Git-derived correction evidence, targeted validation, and final evidence. Use ADVANCE only for explicit graph-v1 Judgment Day.",
5223
+ 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}", with optional baseRef or repository-local policyPath; policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget.',
5224
+ "For non-destructive legacy recovery, call prepare-supersession with one exact change, source, successor, operation, and prepared evidence input. Call supersede only with the returned request hash and exact English challenge after fresh UI approval; it never falls back to RESET or RECOVER. Headless, stale, conflicting, malformed, or unsupported recovery remains resolve-review blocked; exact retries are idempotent, but semantic retries require a new operation.",
5225
+ "Run selected lenses once, then call FINALIZE with a JSON string containing review_result.lens_results entries for every START-selected lens. Each entry has lens, findings, and non-empty evidence; clean lenses use findings: []. Pair final_evidence with final_verification_passed. This Pi wrapper shape differs from native CLI --result, --refuter, --validation, and --evidence files. FINALIZE alone records correction forecast, Git-derived correction evidence, targeted validation, and final evidence. Use ADVANCE only for explicit graph-v1 Judgment Day.",
3631
5226
  "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization for the exact reset_request challenge, then call RESET or RECOVER only after authorization. RESET and RECOVER internally INSPECT authority; require their verified clean result and next_action start-fresh-ordinary-review-after-verified-clean before continuing directly to a fresh ordinary START.",
3632
5227
  "A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START or FINALIZE output, replay the exact operation so compact CAS returns the committed revision or rejects a stale/semantic retry.",
3633
5228
  "Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
@@ -3637,10 +5232,15 @@ export default function gentleAi(pi: ExtensionAPI): void {
3637
5232
  async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
3638
5233
  if (signal?.aborted) throw new Error("Review controller operation was cancelled");
3639
5234
  await authorizeDestructiveReviewOperation(parameters, ctx);
3640
- const details = executeReviewControllerOperation(
5235
+ const details = await executeReviewControllerOperation(
3641
5236
  parameters,
3642
5237
  ctx.cwd,
3643
5238
  pendingReviewAuthorizations,
5239
+ nativeReviewCli,
5240
+ signal,
5241
+ publicationProbe,
5242
+ publicationProbeTimeoutMs,
5243
+ candidateViews,
3644
5244
  );
3645
5245
  return {
3646
5246
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -3708,11 +5308,13 @@ export default function gentleAi(pi: ExtensionAPI): void {
3708
5308
  : "";
3709
5309
  const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
3710
5310
  const nativeStatusPrompt = phase
3711
- ? `\n\n${renderNativeSddPhasePrompt(resolveSddStatus({
3712
- cwd: ctx.cwd,
3713
- includeInstructions: true,
3714
- artifactStore: prefs?.artifactStore,
3715
- }), phase)}`
5311
+ ? `\n\n${renderNativeSddPhasePrompt(await resolveStartupControllerSddStatus(
5312
+ ctx.cwd,
5313
+ undefined,
5314
+ true,
5315
+ prefs?.artifactStore,
5316
+ nativeReviewCli,
5317
+ ), phase)}`
3716
5318
  : "";
3717
5319
  const gentlePrompt = isNamedAgent || isSddAgent
3718
5320
  ? ""
@@ -3728,12 +5330,27 @@ export default function gentleAi(pi: ExtensionAPI): void {
3728
5330
  event.input,
3729
5331
  );
3730
5332
  if (sensitivePathDenied) return sensitivePathDenied;
5333
+ if (event.toolName === "subagent_run") {
5334
+ try {
5335
+ injectReviewCandidateView(event.input, candidateViews);
5336
+ return undefined;
5337
+ } catch (error) {
5338
+ return {
5339
+ block: true,
5340
+ reason: error instanceof Error ? error.message : "review subagent dispatch is invalid",
5341
+ };
5342
+ }
5343
+ }
3731
5344
  if (event.toolName !== "bash") return undefined;
3732
5345
  if (!isRecord(event.input) || typeof event.input.command !== "string")
3733
5346
  return undefined;
3734
5347
  return enforceReviewGateAndCommandSafety(
3735
5348
  event.input.command,
3736
- (command) => gateLifecycleCommand(command, ctx.cwd, pendingReviewAuthorizations),
5349
+ (command) => {
5350
+ const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
5351
+ const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
5352
+ return gateLifecycleCommand(command, ctx.cwd, pendingReviewAuthorizations, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal, candidateViews);
5353
+ },
3737
5354
  (command) => confirmCommand(command, ctx),
3738
5355
  );
3739
5356
  });
@@ -3759,14 +5376,15 @@ export default function gentleAi(pi: ExtensionAPI): void {
3759
5376
  },
3760
5377
  });
3761
5378
 
3762
- const handleSddStatusCommand = (args: string, ctx: ExtensionContext) => {
5379
+ const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
3763
5380
  const parsed = parseSddStatusCommandArgs(args);
3764
- const status = resolveSddStatus({
3765
- cwd: ctx.cwd,
3766
- changeName: parsed.changeName,
3767
- includeInstructions: true,
3768
- artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
3769
- });
5381
+ const status = await resolveControllerSddStatus(
5382
+ ctx.cwd,
5383
+ parsed.changeName,
5384
+ true,
5385
+ getSddPreflightPreferences(ctx)?.artifactStore,
5386
+ nativeReviewCli,
5387
+ );
3770
5388
  ctx.ui.notify(
3771
5389
  parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status),
3772
5390
  sddStatusSeverity(status),
@@ -3776,18 +5394,19 @@ export default function gentleAi(pi: ExtensionAPI): void {
3776
5394
  pi.registerCommand("sdd-status", {
3777
5395
  description: "Show deterministic SDD change status and instructions.",
3778
5396
  handler: async (args, ctx) => {
3779
- handleSddStatusCommand(args, ctx);
5397
+ await handleSddStatusCommand(args, ctx);
3780
5398
  },
3781
5399
  });
3782
5400
 
3783
- const handleSddContinueCommand = (args: string, ctx: ExtensionContext) => {
5401
+ const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
3784
5402
  const parsed = parseSddStatusCommandArgs(args);
3785
- const status = resolveSddStatus({
3786
- cwd: ctx.cwd,
3787
- changeName: parsed.changeName,
3788
- includeInstructions: true,
3789
- artifactStore: getSddPreflightPreferences(ctx)?.artifactStore,
3790
- });
5403
+ const status = await resolveControllerSddStatus(
5404
+ ctx.cwd,
5405
+ parsed.changeName,
5406
+ true,
5407
+ getSddPreflightPreferences(ctx)?.artifactStore,
5408
+ nativeReviewCli,
5409
+ );
3791
5410
  ctx.ui.notify(
3792
5411
  parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status),
3793
5412
  sddStatusSeverity(status),
@@ -3797,7 +5416,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
3797
5416
  pi.registerCommand("sdd-continue", {
3798
5417
  description: "Resolve SDD status and route the next phase deterministically.",
3799
5418
  handler: async (args, ctx) => {
3800
- handleSddContinueCommand(args, ctx);
5419
+ await handleSddContinueCommand(args, ctx);
3801
5420
  },
3802
5421
  });
3803
5422
 
@@ -3901,4 +5520,9 @@ export default function gentleAi(pi: ExtensionAPI): void {
3901
5520
  );
3902
5521
  },
3903
5522
  });
5523
+ };
5524
+ }
5525
+
5526
+ export default function gentleAi(pi: ExtensionAPI): void {
5527
+ return createGentleAiExtension()(pi);
3904
5528
  }