gentle-pi 1.0.2 → 1.0.4
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.
- package/README.md +65 -12
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/review-readability.md +1 -1
- package/assets/agents/review-refuter.md +17 -9
- package/assets/agents/review-reliability.md +1 -1
- package/assets/agents/review-resilience.md +1 -1
- package/assets/agents/review-risk.md +1 -1
- package/assets/agents/review-validator.md +3 -3
- package/assets/agents/sdd-apply.md +7 -1
- package/assets/agents/sdd-status.md +7 -4
- package/assets/agents/sdd-tasks.md +11 -0
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/migrations/managed-assets-v0.10.7.json +7 -0
- package/assets/orchestrator-delegation.md +4 -4
- package/assets/orchestrator.md +1 -1
- package/assets/support/sdd-status-contract.md +14 -4
- package/extensions/gentle-ai.ts +1772 -148
- package/lib/gentle-ai-binary.ts +89 -0
- package/lib/native-review-cli.ts +552 -0
- package/lib/review-authority-supersession.ts +1055 -0
- package/lib/review-candidate-view.ts +546 -0
- package/lib/review-compact-contract.ts +94 -7
- package/lib/review-compact-gate.ts +88 -4
- package/lib/review-compact-store.ts +24 -3
- package/lib/review-facade.ts +175 -24
- package/lib/review-refuter-adapter.ts +129 -0
- package/lib/review-snapshot.ts +42 -0
- package/lib/sdd-preflight.ts +9 -5
- package/lib/sdd-status.ts +197 -39
- package/package.json +2 -1
- package/scripts/gentle-ai-installer.mjs +225 -0
- package/scripts/install-gentle-ai.mjs +14 -0
- package/scripts/verify-package-files.mjs +6 -0
- package/skills/_shared/review-ledger-contract.md +9 -7
- package/skills/gentle-ai/SKILL.md +8 -6
- package/skills/judgment-day/SKILL.md +1 -1
- package/skills/judgment-day/references/prompts-and-formats.md +1 -1
- package/tests/artifact-language.test.ts +20 -0
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +25 -0
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +8 -0
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +139 -0
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +200 -0
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +12 -0
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +24 -0
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +20 -0
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +28 -0
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +25 -0
- package/tests/fixtures/native-review-cli/v2.1.3/finalize.json +8 -0
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +139 -0
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +200 -0
- package/tests/fixtures/native-review-cli/v2.1.3/start.json +14 -0
- package/tests/fixtures/native-review-cli/v2.1.3/validate-allow.json +24 -0
- package/tests/fixtures/native-review-cli/v2.1.3/validate-deny-empty-context.json +20 -0
- package/tests/fixtures/native-review-cli/v2.1.3/validate-deny.json +28 -0
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +132 -0
- package/tests/gentle-ai-binary.test.ts +148 -0
- package/tests/gentle-ai-installer.test.ts +219 -0
- package/tests/native-review-cli.test.ts +675 -0
- package/tests/native-review-parity-runtime.test.ts +170 -0
- package/tests/package-manifest.test.ts +27 -2
- package/tests/review-authority-recovery-docs.test.ts +25 -0
- package/tests/review-authority-supersession.test.ts +581 -0
- package/tests/review-candidate-view.test.ts +288 -0
- package/tests/review-compact-contract.test.ts +36 -4
- package/tests/review-compact-gate.test.ts +236 -7
- package/tests/review-compact-store.test.ts +11 -0
- package/tests/review-controller-native-routing.test.ts +2280 -0
- package/tests/review-controller.test.ts +342 -28
- package/tests/review-facade.test.ts +129 -19
- package/tests/review-ledger-contract.test.ts +58 -5
- package/tests/review-refuter-adapter.test.ts +89 -0
- package/tests/review-snapshot.test.ts +25 -0
- package/tests/runtime-harness.mjs +23 -0
- package/tests/sdd-preflight.test.ts +78 -0
- package/tests/sdd-status.test.ts +127 -6
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"))
|
|
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
|
-
|
|
2820
|
-
|
|
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
|
|
2929
|
-
|
|
2930
|
-
|
|
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[],
|
|
2971
|
-
const
|
|
2972
|
-
|
|
2973
|
-
|
|
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
|
-
|
|
2990
|
-
|
|
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",
|
|
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 = (
|
|
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
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
)
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
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:
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3454
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
-
|
|
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
|
|
3627
|
-
promptSnippet: "Inspect authority, then use
|
|
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.
|
|
3630
|
-
"
|
|
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(
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
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) =>
|
|
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 =
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
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 =
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
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
|
}
|