gentle-pi 2.0.0 → 2.1.1
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 +3 -12
- package/extensions/gentle-ai.ts +362 -123
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +207 -27
- package/lib/review-candidate-view.ts +9 -5
- package/lib/review-integration-v2.ts +5 -1
- package/package.json +1 -1
- package/runtime/gentle-ai-binary.mjs +64 -17
- package/runtime/native-review-cli.mjs +206 -26
- package/runtime/review-integration-v2.mjs +5 -1
- package/scripts/gentle-ai-installer.mjs +454 -71
- package/scripts/test-packed-runner.mjs +1 -1
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/gentle-ai-binary.test.ts +70 -3
- package/tests/gentle-ai-installer.test.ts +543 -7
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-review-consent.test.ts +205 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +228 -53
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-lock-status.test.ts +25 -7
- package/tests/review-controller-native-routing.test.ts +419 -20
- package/tests/review-controller-workspace-root.test.ts +40 -10
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-integration-v2.test.ts +10 -1
- package/tests/runtime-harness.mjs +24 -12
package/lib/gentle-ai-binary.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
GENTLE_AI_INSTALL_METHOD,
|
|
6
|
+
GENTLE_AI_WINDOWS_SOURCE_MODULE,
|
|
7
|
+
GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
8
|
+
GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
|
|
9
|
+
GENTLE_AI_WINDOWS_SOURCE_TAG,
|
|
10
|
+
isGentleAiWindowsGoVersionSupported,
|
|
11
|
+
isWindowsGoSumdbSourceTarget,
|
|
12
|
+
resolveGentleAiReleaseAsset,
|
|
13
|
+
} from "../scripts/gentle-ai-installer.mjs";
|
|
5
14
|
import { fileURLToPath } from "node:url";
|
|
6
15
|
|
|
7
16
|
export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
|
|
@@ -49,6 +58,50 @@ function assertPosixExecutable(path: string, platform: string): void {
|
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
function signedReleaseManifest(asset: { name: string; sha256: string; binarySha256: string }): Record<string, string> {
|
|
62
|
+
return { version: GENTLE_AI_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256: asset.binarySha256 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function windowsSourceManifest(manifest: Record<string, unknown>, binarySha256: string, platform: string): Record<string, string> {
|
|
66
|
+
if (!isWindowsGoSumdbSourceTarget(platform, process.arch)) throw new Error("unsupported Windows source architecture");
|
|
67
|
+
const architecture = process.arch === "x64" ? "x64" : "arm64";
|
|
68
|
+
const goVersion = manifest.goVersion;
|
|
69
|
+
if (manifest.moduleChecksum !== GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM || typeof goVersion !== "string" || !isGentleAiWindowsGoVersionSupported(goVersion)) throw new Error("invalid Windows source provenance");
|
|
70
|
+
return {
|
|
71
|
+
version: GENTLE_AI_VERSION,
|
|
72
|
+
method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD,
|
|
73
|
+
package: GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
|
|
74
|
+
module: GENTLE_AI_WINDOWS_SOURCE_MODULE,
|
|
75
|
+
tag: GENTLE_AI_WINDOWS_SOURCE_TAG,
|
|
76
|
+
architecture,
|
|
77
|
+
binarySha256,
|
|
78
|
+
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
79
|
+
goVersion,
|
|
80
|
+
goos: "windows",
|
|
81
|
+
goarch: process.arch === "x64" ? "amd64" : "arm64",
|
|
82
|
+
buildMode: "exe",
|
|
83
|
+
compiler: "gc",
|
|
84
|
+
cgoEnabled: "0",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function expectedRuntimeManifest(platform: string, binarySha256: string, manifest: Record<string, unknown>): Record<string, string> {
|
|
89
|
+
if (platform === "win32") return windowsSourceManifest(manifest, binarySha256, platform);
|
|
90
|
+
const asset = resolveGentleAiReleaseAsset(platform, process.arch);
|
|
91
|
+
if (binarySha256 !== asset.binarySha256) throw new Error("runtime binary does not match pinned release digest");
|
|
92
|
+
return signedReleaseManifest(asset);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isCanonicalManifest(contents: string, manifest: Record<string, unknown>, expected: Record<string, string>): boolean {
|
|
96
|
+
return contents === `${JSON.stringify(expected)}\n`
|
|
97
|
+
&& Object.keys(manifest).length === Object.keys(expected).length
|
|
98
|
+
&& Object.entries(expected).every(([key, value]) => manifest[key] === value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameFile(before: ReturnType<typeof lstatSync>, after: ReturnType<typeof lstatSync>): boolean {
|
|
102
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
52
105
|
export function resolveGentleAiBinary(
|
|
53
106
|
packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
|
|
54
107
|
platform = process.platform,
|
|
@@ -66,22 +119,16 @@ export function resolveGentleAiBinary(
|
|
|
66
119
|
assertRegularNonSymlink(binaryPath);
|
|
67
120
|
assertPosixExecutable(binaryPath, platform);
|
|
68
121
|
assertRegularNonSymlink(manifestPath);
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
manifest.binarySha256 !== asset.binarySha256 ||
|
|
80
|
-
!/^[0-9a-f]{64}$/.test(manifest.binarySha256) ||
|
|
81
|
-
sha256(readBinary(binaryPath)) !== manifest.binarySha256
|
|
82
|
-
) throw new Error("invalid runtime integrity manifest");
|
|
83
|
-
const after = lstatSync(binaryPath);
|
|
84
|
-
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("runtime replaced during verification");
|
|
122
|
+
const beforeBinary = lstatSync(binaryPath);
|
|
123
|
+
const beforeManifest = lstatSync(manifestPath);
|
|
124
|
+
const manifestContents = readFileSync(manifestPath, "utf8");
|
|
125
|
+
const manifest = JSON.parse(manifestContents) as Record<string, unknown>;
|
|
126
|
+
const binarySha256 = sha256(readBinary(binaryPath));
|
|
127
|
+
const expected = expectedRuntimeManifest(platform, binarySha256, manifest);
|
|
128
|
+
if (!isCanonicalManifest(manifestContents, manifest, expected)) throw new Error("invalid runtime integrity manifest");
|
|
129
|
+
const afterBinary = lstatSync(binaryPath);
|
|
130
|
+
const afterManifest = lstatSync(manifestPath);
|
|
131
|
+
if (!sameFile(beforeBinary, afterBinary) || !sameFile(beforeManifest, afterManifest)) throw new Error("runtime replaced during verification");
|
|
85
132
|
assertPosixExecutable(binaryPath, platform);
|
|
86
133
|
return binaryPath;
|
|
87
134
|
} catch {
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -106,6 +106,7 @@ export interface NativeReviewCli {
|
|
|
106
106
|
reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult>;
|
|
107
107
|
capabilities?(request?: NativeCapabilitiesRequest): Promise<ReviewCapabilitiesV2>;
|
|
108
108
|
targetStatus?(request: NativeTargetStatusRequest): Promise<ReviewStatusV3>;
|
|
109
|
+
answerConsent?(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult>;
|
|
109
110
|
reclaim?(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult>;
|
|
110
111
|
recover?(request: NativeReviewRecoverRequest): Promise<NativeReviewRecoveryResult>;
|
|
111
112
|
abandon?(request: NativeReviewAbandonRequest): Promise<NativeReviewRecoveryResult>;
|
|
@@ -370,7 +371,22 @@ export interface NativeReviewVerificationEvidenceV2 {
|
|
|
370
371
|
recordDigest: string;
|
|
371
372
|
}
|
|
372
373
|
|
|
373
|
-
export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
|
|
374
|
+
export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; targetIdentity?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
|
|
375
|
+
export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const;
|
|
376
|
+
export type NativeReviewConsentAnswer = (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER];
|
|
377
|
+
export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentV2; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
|
|
378
|
+
export interface NativeReviewConsentDeclinedResult {
|
|
379
|
+
kind: "declined";
|
|
380
|
+
targetIdentity: string;
|
|
381
|
+
projection: "workspace" | "staged";
|
|
382
|
+
riskLevel: "medium" | "high";
|
|
383
|
+
changedFiles: number;
|
|
384
|
+
changedLines: number;
|
|
385
|
+
consent: "declined_this_candidate";
|
|
386
|
+
raw: Readonly<Record<string, unknown>>;
|
|
387
|
+
}
|
|
388
|
+
export interface NativeReviewConsentStartedResult { kind: "started"; start: NativeStartResult; }
|
|
389
|
+
export type NativeReviewConsentAnswerResult = NativeReviewConsentStartedResult | NativeReviewConsentDeclinedResult;
|
|
374
390
|
export interface NativeFinalizeLensResult { lens: string; document: unknown; }
|
|
375
391
|
export interface NativeFinalizeRequest extends NativeReviewFinalizeCapturedResults {
|
|
376
392
|
cwd: string;
|
|
@@ -559,7 +575,13 @@ const REVIEW_RISK_SUBJECT_BY_SIGNAL: Readonly<Record<string, string>> = Object.f
|
|
|
559
575
|
// an unmapped signal degrades to this rather than dropping the path entirely.
|
|
560
576
|
const REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT = "a sensitive area";
|
|
561
577
|
|
|
562
|
-
|
|
578
|
+
interface NativeRiskEvidenceReason {
|
|
579
|
+
readonly code?: string;
|
|
580
|
+
readonly signal?: string;
|
|
581
|
+
readonly path?: string;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function nativeRiskEvidenceSubject(reason: NativeRiskEvidenceReason): string {
|
|
563
585
|
const code = typeof reason.code === "string" ? reason.code : "";
|
|
564
586
|
if (code === "hot_path") {
|
|
565
587
|
const signal = typeof reason.signal === "string" ? reason.signal : "";
|
|
@@ -568,7 +590,7 @@ function nativeRiskEvidenceSubject(reason: Record<string, unknown>): string {
|
|
|
568
590
|
return REVIEW_RISK_SUBJECT_BY_CODE[code] ?? "";
|
|
569
591
|
}
|
|
570
592
|
|
|
571
|
-
function nativeRiskEvidencePhrase(reason:
|
|
593
|
+
function nativeRiskEvidencePhrase(reason: NativeRiskEvidenceReason): string {
|
|
572
594
|
const path = typeof reason.path === "string" ? reason.path.trim() : "";
|
|
573
595
|
// An empty file is named first and described second. Every other subject
|
|
574
596
|
// reads "<what changed> in <path>", which for a file with no bytes would
|
|
@@ -581,7 +603,7 @@ function nativeRiskEvidencePhrase(reason: Record<string, unknown>): string {
|
|
|
581
603
|
return `${subject} in ${path}`;
|
|
582
604
|
}
|
|
583
605
|
|
|
584
|
-
export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly
|
|
606
|
+
export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly NativeRiskEvidenceReason[]): readonly string[] {
|
|
585
607
|
if (riskLevel !== "high" && riskLevel !== "medium") return [];
|
|
586
608
|
const phrases = reasons.map((reason) => nativeRiskEvidencePhrase(reason)).filter((phrase) => phrase !== "");
|
|
587
609
|
return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases;
|
|
@@ -1015,6 +1037,21 @@ function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult {
|
|
|
1015
1037
|
};
|
|
1016
1038
|
}
|
|
1017
1039
|
function isWindowsRepositoryPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
|
|
1040
|
+
export function normalizeNativeReviewCwd(value: string, platform: NodeJS.Platform = process.platform): string {
|
|
1041
|
+
if (platform !== "win32") return value;
|
|
1042
|
+
const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
|
|
1043
|
+
const windowsPath = gitBashDrive === null
|
|
1044
|
+
? value
|
|
1045
|
+
: `${gitBashDrive[1]!.toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
|
|
1046
|
+
if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
|
|
1047
|
+
const normalized = win32.normalize(windowsPath);
|
|
1048
|
+
return normalized.replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`);
|
|
1049
|
+
}
|
|
1050
|
+
async function canonicalNativeReviewCwd(value: string): Promise<string> {
|
|
1051
|
+
const normalized = normalizeNativeReviewCwd(value);
|
|
1052
|
+
try { return await realpath(normalized); }
|
|
1053
|
+
catch { return normalized; }
|
|
1054
|
+
}
|
|
1018
1055
|
async function repositoryPathIdentity(value: string): Promise<string> {
|
|
1019
1056
|
const windowsPath = isWindowsRepositoryPath(value);
|
|
1020
1057
|
try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
|
|
@@ -1121,6 +1158,7 @@ function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperati
|
|
|
1121
1158
|
interface NativeJsonExecution {
|
|
1122
1159
|
body: Record<string, unknown>;
|
|
1123
1160
|
exitCode: number;
|
|
1161
|
+
process: ExecFileResult;
|
|
1124
1162
|
}
|
|
1125
1163
|
|
|
1126
1164
|
export class NativeReviewCliV214 {
|
|
@@ -1337,12 +1375,13 @@ export class NativeReviewCliV214 {
|
|
|
1337
1375
|
// and always pass `--scope clone` so Pi's own kill-switch command surface
|
|
1338
1376
|
// never mutates the operator's global gentle-ai state across other clones.
|
|
1339
1377
|
async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
|
|
1340
|
-
await
|
|
1378
|
+
const cwd = await canonicalNativeReviewCwd(request.cwd);
|
|
1379
|
+
await this.verifyVersion(cwd, request.signal, ["mode"]);
|
|
1341
1380
|
const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
|
|
1342
1381
|
const { body } = await this.execute(
|
|
1343
1382
|
NATIVE_REVIEW_OPERATION.MODE,
|
|
1344
|
-
|
|
1345
|
-
["review", "mode", request.operation, "--cwd",
|
|
1383
|
+
cwd,
|
|
1384
|
+
["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
|
|
1346
1385
|
mutating,
|
|
1347
1386
|
request.signal,
|
|
1348
1387
|
);
|
|
@@ -1617,12 +1656,13 @@ export class NativeReviewIntegrationError extends Error {
|
|
|
1617
1656
|
}
|
|
1618
1657
|
|
|
1619
1658
|
// Raised when negotiated START answers `consent/v2` (action:
|
|
1620
|
-
// "consent_required") instead of `start/v3`.
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1659
|
+
// "consent_required") instead of `start/v3`. The provider has frozen no
|
|
1660
|
+
// authority yet: Pi must relay this complete candidate-scoped question and may
|
|
1661
|
+
// answer only through one of the exact invocations carried by the envelope.
|
|
1623
1662
|
export class NativeReviewConsentRequiredError extends Error {
|
|
1624
1663
|
readonly consent: ReviewConsentV2;
|
|
1625
1664
|
readonly launchAttempted = true;
|
|
1665
|
+
readonly mutationOutcome = "none";
|
|
1626
1666
|
constructor(consent: ReviewConsentV2) {
|
|
1627
1667
|
super(consent.headline);
|
|
1628
1668
|
this.name = "NativeReviewConsentRequiredError";
|
|
@@ -1630,6 +1670,117 @@ export class NativeReviewConsentRequiredError extends Error {
|
|
|
1630
1670
|
}
|
|
1631
1671
|
}
|
|
1632
1672
|
|
|
1673
|
+
// Raised when the provider-issued consent invocation no longer matches the
|
|
1674
|
+
// binding Pi is answering for. Every one of these guards runs before the
|
|
1675
|
+
// provider is launched, so the failure is local and nothing was mutated. It
|
|
1676
|
+
// carries its own identity precisely so callers never report it as a provider
|
|
1677
|
+
// outage: an opaque `native-operation-failed` here sent issue #247 chasing a
|
|
1678
|
+
// missing `--cwd` that Pi does forward.
|
|
1679
|
+
export class NativeReviewConsentBindingError extends Error {
|
|
1680
|
+
readonly reason: string;
|
|
1681
|
+
readonly launchAttempted = false;
|
|
1682
|
+
readonly mutationOutcome = "none";
|
|
1683
|
+
constructor(reason: string, message: string) {
|
|
1684
|
+
super(message);
|
|
1685
|
+
this.name = "NativeReviewConsentBindingError";
|
|
1686
|
+
this.reason = reason;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function splitNativeConsentInvocation(invocation: string): readonly string[] {
|
|
1691
|
+
const words: string[] = [];
|
|
1692
|
+
let current = "";
|
|
1693
|
+
let quote: "'" | '"' | undefined;
|
|
1694
|
+
let escaping = false;
|
|
1695
|
+
let started = false;
|
|
1696
|
+
for (const character of invocation.trim()) {
|
|
1697
|
+
if (escaping) {
|
|
1698
|
+
current += character;
|
|
1699
|
+
escaping = false;
|
|
1700
|
+
started = true;
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
if (character === "\\" && quote !== "'") {
|
|
1704
|
+
escaping = true;
|
|
1705
|
+
started = true;
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
if (quote !== undefined) {
|
|
1709
|
+
if (character === quote) quote = undefined;
|
|
1710
|
+
else current += character;
|
|
1711
|
+
started = true;
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
if (character === "'" || character === '"') {
|
|
1715
|
+
quote = character;
|
|
1716
|
+
started = true;
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (/\s/.test(character)) {
|
|
1720
|
+
if (started) {
|
|
1721
|
+
words.push(current);
|
|
1722
|
+
current = "";
|
|
1723
|
+
started = false;
|
|
1724
|
+
}
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
current += character;
|
|
1728
|
+
started = true;
|
|
1729
|
+
}
|
|
1730
|
+
if (quote !== undefined || escaping) throw new TypeError("Native consent invocation has invalid quoting");
|
|
1731
|
+
if (started) words.push(current);
|
|
1732
|
+
return words;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function exactConsentOption(arguments_: readonly string[], name: string): string {
|
|
1736
|
+
const values: string[] = [];
|
|
1737
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
1738
|
+
const token = arguments_[index]!;
|
|
1739
|
+
if (token === name) {
|
|
1740
|
+
const value = arguments_[index + 1];
|
|
1741
|
+
if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
|
|
1742
|
+
values.push(value);
|
|
1743
|
+
index += 1;
|
|
1744
|
+
} else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
|
|
1745
|
+
}
|
|
1746
|
+
if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
|
|
1747
|
+
return values[0]!;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
|
|
1751
|
+
const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
|
|
1752
|
+
if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
|
|
1753
|
+
const words = splitNativeConsentInvocation(choice.invocation);
|
|
1754
|
+
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new NativeReviewConsentBindingError("consent-invocation-not-start", "Native consent invocation is not a provider review START");
|
|
1755
|
+
const arguments_ = words.slice(1);
|
|
1756
|
+
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
|
|
1757
|
+
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
1758
|
+
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
|
|
1759
|
+
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
|
|
1760
|
+
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
|
|
1761
|
+
return arguments_;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult {
|
|
1765
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Native declined consent result must be an object");
|
|
1766
|
+
const body = value as Record<string, unknown>;
|
|
1767
|
+
if (body.operation !== "review/start" || body.action !== "declined" || body.consent !== "declined_this_candidate") throw new TypeError("Native declined consent result has an invalid identity");
|
|
1768
|
+
if (body.target_identity !== expected.consent.targetIdentity || body.projection !== expected.consent.projection || body.risk_level !== expected.consent.riskLevel) throw new TypeError("Native declined consent result target binding changed");
|
|
1769
|
+
if (body.lenses_required !== false || !Array.isArray(body.selected_lenses) || body.selected_lenses.length !== 0 || !Array.isArray(body.lens_bindings) || body.lens_bindings.length !== 0) throw new TypeError("Native declined consent result must create no review authority");
|
|
1770
|
+
if (typeof body.changed_files !== "number" || !Number.isSafeInteger(body.changed_files) || body.changed_files < 0 || typeof body.changed_lines !== "number" || !Number.isSafeInteger(body.changed_lines) || body.changed_lines < 0) throw new TypeError("Native declined consent result has invalid change counts");
|
|
1771
|
+
if (body.lineage_id !== "" || body.state !== "" || body.correction_budget !== 0) throw new TypeError("Native declined consent result cannot carry review authority");
|
|
1772
|
+
return {
|
|
1773
|
+
kind: "declined",
|
|
1774
|
+
targetIdentity: expected.consent.targetIdentity,
|
|
1775
|
+
projection: expected.consent.projection,
|
|
1776
|
+
riskLevel: expected.consent.riskLevel,
|
|
1777
|
+
changedFiles: body.changed_files,
|
|
1778
|
+
changedLines: body.changed_lines,
|
|
1779
|
+
consent: "declined_this_candidate",
|
|
1780
|
+
raw: body,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1633
1784
|
type NativeExecutableDigestResolver = (path: string) => string;
|
|
1634
1785
|
const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV2>>();
|
|
1635
1786
|
|
|
@@ -1819,41 +1970,40 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1819
1970
|
if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
|
|
1820
1971
|
if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
|
|
1821
1972
|
if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
|
|
1822
|
-
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
// a target frozen from the wrong root would name a snapshot this START
|
|
1827
|
-
// never inspects. targetStatus is read-only, so this costs one extra
|
|
1828
|
-
// read and cannot create authority.
|
|
1973
|
+
if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
|
|
1974
|
+
// The controller supplies the target it already projected from the
|
|
1975
|
+
// authority workspace after proving its immutable actor view is identical.
|
|
1976
|
+
// Direct adapter callers may omit it and retain the same-root projection.
|
|
1829
1977
|
const projection = request.projection ?? "workspace";
|
|
1830
|
-
const
|
|
1978
|
+
const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
|
|
1831
1979
|
cwd: request.cwd,
|
|
1832
1980
|
projection,
|
|
1833
1981
|
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
|
|
1834
1982
|
...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
|
|
1835
1983
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
1836
|
-
});
|
|
1984
|
+
})).targetIdentity;
|
|
1837
1985
|
const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
|
|
1838
1986
|
"review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
|
|
1839
|
-
"--target",
|
|
1987
|
+
"--target", targetIdentity, "--projection", projection,
|
|
1840
1988
|
...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
|
|
1841
1989
|
...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
|
|
1842
1990
|
...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
|
|
1843
1991
|
...(request.focus === undefined ? [] : ["--focus", request.focus]),
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
], true, request.signal, REVIEW_CONSENT_NOTICES);
|
|
1992
|
+
"--consent", "relay",
|
|
1993
|
+
], true, request.signal);
|
|
1847
1994
|
// A negotiated v2 START may answer `consent/v2` (action:
|
|
1848
1995
|
// "consent_required") instead of `start/v3` when the provider needs an
|
|
1849
|
-
// explicit answer it cannot infer.
|
|
1850
|
-
//
|
|
1851
|
-
// relay`, so this is always surfaced to the caller, never answered here.
|
|
1996
|
+
// explicit answer it cannot infer. Discriminate before decode and surface
|
|
1997
|
+
// the complete envelope; only the caller can map a human answer.
|
|
1852
1998
|
if (execution.body.action === "consent_required") {
|
|
1853
|
-
|
|
1999
|
+
const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
|
|
2000
|
+
if (consent.targetIdentity !== targetIdentity || consent.projection !== projection) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent target binding mismatch");
|
|
2001
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
1854
2002
|
}
|
|
1855
2003
|
const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
|
|
1856
2004
|
if (request.lineageId !== undefined && result.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
|
|
2005
|
+
const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
|
|
2006
|
+
if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
|
|
1857
2007
|
return {
|
|
1858
2008
|
lineageId: result.lineageId,
|
|
1859
2009
|
state: result.state as NativeStartResult["state"],
|
|
@@ -1882,6 +2032,36 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1882
2032
|
};
|
|
1883
2033
|
}
|
|
1884
2034
|
|
|
2035
|
+
async answerConsent(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult> {
|
|
2036
|
+
const arguments_ = consentInvocationArguments(request);
|
|
2037
|
+
const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
|
|
2038
|
+
if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
|
|
2039
|
+
return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
|
|
2040
|
+
}
|
|
2041
|
+
const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
|
|
2042
|
+
const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
|
|
2043
|
+
if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
|
|
2044
|
+
const lineageId = exactConsentOption(arguments_, "--lineage");
|
|
2045
|
+
if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
|
|
2046
|
+
return { kind: "started", start: {
|
|
2047
|
+
lineageId: result.lineageId,
|
|
2048
|
+
state: result.state as NativeStartResult["state"],
|
|
2049
|
+
riskLevel: result.riskLevel,
|
|
2050
|
+
selectedLenses: result.selectedLenses,
|
|
2051
|
+
changedFiles: result.changedFiles,
|
|
2052
|
+
changedLines: result.changedLines,
|
|
2053
|
+
correctionBudget: result.correctionBudget,
|
|
2054
|
+
action: result.action as NativeStartAction,
|
|
2055
|
+
lensesRequired: result.lensesRequired,
|
|
2056
|
+
riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
|
|
2057
|
+
...(() => {
|
|
2058
|
+
const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
|
|
2059
|
+
return evidence.length === 0 ? {} : { riskEvidence: evidence };
|
|
2060
|
+
})(),
|
|
2061
|
+
raw: result.raw,
|
|
2062
|
+
} };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1885
2065
|
private async stageDocument(directory: string, name: string, document: unknown): Promise<string> {
|
|
1886
2066
|
const path = join(directory, `${name}.json`);
|
|
1887
2067
|
await writeFile(path, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
|
|
@@ -798,11 +798,15 @@ export class CandidateViewRegistry {
|
|
|
798
798
|
const base = head.tree === descriptor.baseTree ? head : resolveCandidateBaseTree(root, descriptor.baseTree, this.gitExecutor);
|
|
799
799
|
const committedOnly = head.tree === descriptor.currentCandidateTree && base.tree !== head.tree;
|
|
800
800
|
if (!committedOnly && head.tree !== descriptor.baseTree) throw new CandidateViewError("native projection base no longer matches HEAD");
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
801
|
+
// Native `staged` covers both a committed HEAD range and the exact current
|
|
802
|
+
// index over HEAD. Re-derive the latter from Git instead of trusting the
|
|
803
|
+
// label; this also rejects dirty-inclusive snapshots mislabeled as staged.
|
|
804
|
+
const stagedIndex = !committedOnly && descriptor.baseTree === head.tree &&
|
|
805
|
+
git(root, ["write-tree"], process.env, this.gitExecutor) === descriptor.currentCandidateTree;
|
|
806
|
+
if (
|
|
807
|
+
(descriptor.projection === "staged" && !committedOnly && !stagedIndex) ||
|
|
808
|
+
(descriptor.projection === "workspace" && committedOnly)
|
|
809
|
+
) {
|
|
806
810
|
throw new CandidateViewError("native projection commit-state does not match its declared projection kind", "projection-kind-drift");
|
|
807
811
|
}
|
|
808
812
|
const tree = parseTree(root, descriptor.currentCandidateTree, this.gitExecutor);
|
|
@@ -1303,10 +1303,14 @@ export function decodeReviewConsentV2(value: unknown): ReviewConsentV2 {
|
|
|
1303
1303
|
if (body.action !== "consent_required") throw new TypeError("consent.action must be consent_required");
|
|
1304
1304
|
if (body.blocking !== true) throw new TypeError("consent.blocking must be true");
|
|
1305
1305
|
|
|
1306
|
+
const targetIdentity = sha256(body.target_identity, "consent.target_identity");
|
|
1306
1307
|
const choicesArray = body.choices;
|
|
1307
1308
|
if (!Array.isArray(choicesArray) || choicesArray.length !== 2) throw new TypeError("consent.choices must have exactly 2 items");
|
|
1308
1309
|
const granted = decodeConsentChoice(choicesArray[0], "consent.choices[0]", "granted");
|
|
1309
1310
|
const declined = decodeConsentChoice(choicesArray[1], "consent.choices[1]", "declined");
|
|
1311
|
+
for (const choice of [granted, declined]) {
|
|
1312
|
+
if (!choice.invocation.includes(` --target ${targetIdentity} `)) throw new TypeError(`consent choice ${choice.answer} invocation must bind consent.target_identity`);
|
|
1313
|
+
}
|
|
1310
1314
|
|
|
1311
1315
|
const offPathSource = exactRecord(body.off_path, "consent.off_path", ["note", "command"]);
|
|
1312
1316
|
if (offPathSource.command !== "gentle-ai review mode disable") throw new TypeError("consent.off_path.command is unsupported");
|
|
@@ -1317,7 +1321,7 @@ export function decodeReviewConsentV2(value: unknown): ReviewConsentV2 {
|
|
|
1317
1321
|
operation: "review.start",
|
|
1318
1322
|
action: "consent_required",
|
|
1319
1323
|
blocking: true,
|
|
1320
|
-
targetIdentity
|
|
1324
|
+
targetIdentity,
|
|
1321
1325
|
projection: enumeration(body.projection, REQUIRED_PROJECTIONS, "consent.projection"),
|
|
1322
1326
|
riskLevel: enumeration(body.risk_level, ["medium", "high"] as const, "consent.risk_level"),
|
|
1323
1327
|
changedFiles: integer(body.changed_files, "consent.changed_files"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
GENTLE_AI_INSTALL_METHOD,
|
|
7
|
+
GENTLE_AI_WINDOWS_SOURCE_MODULE,
|
|
8
|
+
GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
9
|
+
GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
|
|
10
|
+
GENTLE_AI_WINDOWS_SOURCE_TAG,
|
|
11
|
+
isGentleAiWindowsGoVersionSupported,
|
|
12
|
+
isWindowsGoSumdbSourceTarget,
|
|
13
|
+
resolveGentleAiReleaseAsset,
|
|
14
|
+
} from "../scripts/gentle-ai-installer.mjs";
|
|
6
15
|
import { fileURLToPath } from "node:url";
|
|
7
16
|
|
|
8
17
|
export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
|
|
@@ -50,6 +59,50 @@ function assertPosixExecutable(path , platform ) {
|
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
function signedReleaseManifest(asset ) {
|
|
63
|
+
return { version: GENTLE_AI_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256: asset.binarySha256 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function windowsSourceManifest(manifest , binarySha256 , platform ) {
|
|
67
|
+
if (!isWindowsGoSumdbSourceTarget(platform, process.arch)) throw new Error("unsupported Windows source architecture");
|
|
68
|
+
const architecture = process.arch === "x64" ? "x64" : "arm64";
|
|
69
|
+
const goVersion = manifest.goVersion;
|
|
70
|
+
if (manifest.moduleChecksum !== GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM || typeof goVersion !== "string" || !isGentleAiWindowsGoVersionSupported(goVersion)) throw new Error("invalid Windows source provenance");
|
|
71
|
+
return {
|
|
72
|
+
version: GENTLE_AI_VERSION,
|
|
73
|
+
method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD,
|
|
74
|
+
package: GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
|
|
75
|
+
module: GENTLE_AI_WINDOWS_SOURCE_MODULE,
|
|
76
|
+
tag: GENTLE_AI_WINDOWS_SOURCE_TAG,
|
|
77
|
+
architecture,
|
|
78
|
+
binarySha256,
|
|
79
|
+
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
80
|
+
goVersion,
|
|
81
|
+
goos: "windows",
|
|
82
|
+
goarch: process.arch === "x64" ? "amd64" : "arm64",
|
|
83
|
+
buildMode: "exe",
|
|
84
|
+
compiler: "gc",
|
|
85
|
+
cgoEnabled: "0",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function expectedRuntimeManifest(platform , binarySha256 , manifest ) {
|
|
90
|
+
if (platform === "win32") return windowsSourceManifest(manifest, binarySha256, platform);
|
|
91
|
+
const asset = resolveGentleAiReleaseAsset(platform, process.arch);
|
|
92
|
+
if (binarySha256 !== asset.binarySha256) throw new Error("runtime binary does not match pinned release digest");
|
|
93
|
+
return signedReleaseManifest(asset);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isCanonicalManifest(contents , manifest , expected ) {
|
|
97
|
+
return contents === `${JSON.stringify(expected)}\n`
|
|
98
|
+
&& Object.keys(manifest).length === Object.keys(expected).length
|
|
99
|
+
&& Object.entries(expected).every(([key, value]) => manifest[key] === value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sameFile(before , after ) {
|
|
103
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs;
|
|
104
|
+
}
|
|
105
|
+
|
|
53
106
|
export function resolveGentleAiBinary(
|
|
54
107
|
packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
|
|
55
108
|
platform = process.platform,
|
|
@@ -67,22 +120,16 @@ export function resolveGentleAiBinary(
|
|
|
67
120
|
assertRegularNonSymlink(binaryPath);
|
|
68
121
|
assertPosixExecutable(binaryPath, platform);
|
|
69
122
|
assertRegularNonSymlink(manifestPath);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
manifest.binarySha256 !== asset.binarySha256 ||
|
|
81
|
-
!/^[0-9a-f]{64}$/.test(manifest.binarySha256) ||
|
|
82
|
-
sha256(readBinary(binaryPath)) !== manifest.binarySha256
|
|
83
|
-
) throw new Error("invalid runtime integrity manifest");
|
|
84
|
-
const after = lstatSync(binaryPath);
|
|
85
|
-
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("runtime replaced during verification");
|
|
123
|
+
const beforeBinary = lstatSync(binaryPath);
|
|
124
|
+
const beforeManifest = lstatSync(manifestPath);
|
|
125
|
+
const manifestContents = readFileSync(manifestPath, "utf8");
|
|
126
|
+
const manifest = JSON.parse(manifestContents) ;
|
|
127
|
+
const binarySha256 = sha256(readBinary(binaryPath));
|
|
128
|
+
const expected = expectedRuntimeManifest(platform, binarySha256, manifest);
|
|
129
|
+
if (!isCanonicalManifest(manifestContents, manifest, expected)) throw new Error("invalid runtime integrity manifest");
|
|
130
|
+
const afterBinary = lstatSync(binaryPath);
|
|
131
|
+
const afterManifest = lstatSync(manifestPath);
|
|
132
|
+
if (!sameFile(beforeBinary, afterBinary) || !sameFile(beforeManifest, afterManifest)) throw new Error("runtime replaced during verification");
|
|
86
133
|
assertPosixExecutable(binaryPath, platform);
|
|
87
134
|
return binaryPath;
|
|
88
135
|
} catch {
|