gentle-pi 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -12
- package/extensions/gentle-ai.ts +313 -119
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +158 -12
- 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 +158 -12
- 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 +128 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +155 -46
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-native-routing.test.ts +247 -6
- 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>;
|
|
@@ -371,6 +372,21 @@ export interface NativeReviewVerificationEvidenceV2 {
|
|
|
371
372
|
}
|
|
372
373
|
|
|
373
374
|
export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: 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;
|
|
@@ -1121,6 +1143,7 @@ function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperati
|
|
|
1121
1143
|
interface NativeJsonExecution {
|
|
1122
1144
|
body: Record<string, unknown>;
|
|
1123
1145
|
exitCode: number;
|
|
1146
|
+
process: ExecFileResult;
|
|
1124
1147
|
}
|
|
1125
1148
|
|
|
1126
1149
|
export class NativeReviewCliV214 {
|
|
@@ -1617,12 +1640,13 @@ export class NativeReviewIntegrationError extends Error {
|
|
|
1617
1640
|
}
|
|
1618
1641
|
|
|
1619
1642
|
// Raised when negotiated START answers `consent/v2` (action:
|
|
1620
|
-
// "consent_required") instead of `start/v3`.
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1643
|
+
// "consent_required") instead of `start/v3`. The provider has frozen no
|
|
1644
|
+
// authority yet: Pi must relay this complete candidate-scoped question and may
|
|
1645
|
+
// answer only through one of the exact invocations carried by the envelope.
|
|
1623
1646
|
export class NativeReviewConsentRequiredError extends Error {
|
|
1624
1647
|
readonly consent: ReviewConsentV2;
|
|
1625
1648
|
readonly launchAttempted = true;
|
|
1649
|
+
readonly mutationOutcome = "none";
|
|
1626
1650
|
constructor(consent: ReviewConsentV2) {
|
|
1627
1651
|
super(consent.headline);
|
|
1628
1652
|
this.name = "NativeReviewConsentRequiredError";
|
|
@@ -1630,6 +1654,100 @@ export class NativeReviewConsentRequiredError extends Error {
|
|
|
1630
1654
|
}
|
|
1631
1655
|
}
|
|
1632
1656
|
|
|
1657
|
+
function splitNativeConsentInvocation(invocation: string): readonly string[] {
|
|
1658
|
+
const words: string[] = [];
|
|
1659
|
+
let current = "";
|
|
1660
|
+
let quote: "'" | '"' | undefined;
|
|
1661
|
+
let escaping = false;
|
|
1662
|
+
let started = false;
|
|
1663
|
+
for (const character of invocation.trim()) {
|
|
1664
|
+
if (escaping) {
|
|
1665
|
+
current += character;
|
|
1666
|
+
escaping = false;
|
|
1667
|
+
started = true;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
if (character === "\\" && quote !== "'") {
|
|
1671
|
+
escaping = true;
|
|
1672
|
+
started = true;
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (quote !== undefined) {
|
|
1676
|
+
if (character === quote) quote = undefined;
|
|
1677
|
+
else current += character;
|
|
1678
|
+
started = true;
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
if (character === "'" || character === '"') {
|
|
1682
|
+
quote = character;
|
|
1683
|
+
started = true;
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (/\s/.test(character)) {
|
|
1687
|
+
if (started) {
|
|
1688
|
+
words.push(current);
|
|
1689
|
+
current = "";
|
|
1690
|
+
started = false;
|
|
1691
|
+
}
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
current += character;
|
|
1695
|
+
started = true;
|
|
1696
|
+
}
|
|
1697
|
+
if (quote !== undefined || escaping) throw new TypeError("Native consent invocation has invalid quoting");
|
|
1698
|
+
if (started) words.push(current);
|
|
1699
|
+
return words;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function exactConsentOption(arguments_: readonly string[], name: string): string {
|
|
1703
|
+
const values: string[] = [];
|
|
1704
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
1705
|
+
const token = arguments_[index]!;
|
|
1706
|
+
if (token === name) {
|
|
1707
|
+
const value = arguments_[index + 1];
|
|
1708
|
+
if (value === undefined) throw new TypeError(`Native consent invocation ${name} is missing its value`);
|
|
1709
|
+
values.push(value);
|
|
1710
|
+
index += 1;
|
|
1711
|
+
} else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
|
|
1712
|
+
}
|
|
1713
|
+
if (values.length !== 1) throw new TypeError(`Native consent invocation requires exactly one ${name}`);
|
|
1714
|
+
return values[0]!;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
|
|
1718
|
+
const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
|
|
1719
|
+
if (choice === undefined) throw new TypeError("Native consent answer must be granted or declined");
|
|
1720
|
+
const words = splitNativeConsentInvocation(choice.invocation);
|
|
1721
|
+
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new TypeError("Native consent invocation is not a provider review START");
|
|
1722
|
+
const arguments_ = words.slice(1);
|
|
1723
|
+
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new TypeError("Native consent invocation contract changed");
|
|
1724
|
+
if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new TypeError("Native consent invocation repository binding changed");
|
|
1725
|
+
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new TypeError("Native consent invocation target binding changed");
|
|
1726
|
+
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new TypeError("Native consent invocation projection binding changed");
|
|
1727
|
+
if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new TypeError("Native consent invocation answer binding changed");
|
|
1728
|
+
return arguments_;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult {
|
|
1732
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Native declined consent result must be an object");
|
|
1733
|
+
const body = value as Record<string, unknown>;
|
|
1734
|
+
if (body.operation !== "review/start" || body.action !== "declined" || body.consent !== "declined_this_candidate") throw new TypeError("Native declined consent result has an invalid identity");
|
|
1735
|
+
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");
|
|
1736
|
+
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");
|
|
1737
|
+
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");
|
|
1738
|
+
if (body.lineage_id !== "" || body.state !== "" || body.correction_budget !== 0) throw new TypeError("Native declined consent result cannot carry review authority");
|
|
1739
|
+
return {
|
|
1740
|
+
kind: "declined",
|
|
1741
|
+
targetIdentity: expected.consent.targetIdentity,
|
|
1742
|
+
projection: expected.consent.projection,
|
|
1743
|
+
riskLevel: expected.consent.riskLevel,
|
|
1744
|
+
changedFiles: body.changed_files,
|
|
1745
|
+
changedLines: body.changed_lines,
|
|
1746
|
+
consent: "declined_this_candidate",
|
|
1747
|
+
raw: body,
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1633
1751
|
type NativeExecutableDigestResolver = (path: string) => string;
|
|
1634
1752
|
const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV2>>();
|
|
1635
1753
|
|
|
@@ -1841,14 +1959,12 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1841
1959
|
...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
|
|
1842
1960
|
...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
|
|
1843
1961
|
...(request.focus === undefined ? [] : ["--focus", request.focus]),
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
], true, request.signal, REVIEW_CONSENT_NOTICES);
|
|
1962
|
+
"--consent", "relay",
|
|
1963
|
+
], true, request.signal);
|
|
1847
1964
|
// A negotiated v2 START may answer `consent/v2` (action:
|
|
1848
1965
|
// "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.
|
|
1966
|
+
// explicit answer it cannot infer. Discriminate before decode and surface
|
|
1967
|
+
// the complete envelope; only the caller can map a human answer.
|
|
1852
1968
|
if (execution.body.action === "consent_required") {
|
|
1853
1969
|
throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
|
|
1854
1970
|
}
|
|
@@ -1882,6 +1998,36 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
1882
1998
|
};
|
|
1883
1999
|
}
|
|
1884
2000
|
|
|
2001
|
+
async answerConsent(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult> {
|
|
2002
|
+
const arguments_ = consentInvocationArguments(request);
|
|
2003
|
+
const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
|
|
2004
|
+
if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
|
|
2005
|
+
return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
|
|
2006
|
+
}
|
|
2007
|
+
const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
|
|
2008
|
+
const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
|
|
2009
|
+
if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
|
|
2010
|
+
const lineageId = exactConsentOption(arguments_, "--lineage");
|
|
2011
|
+
if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
|
|
2012
|
+
return { kind: "started", start: {
|
|
2013
|
+
lineageId: result.lineageId,
|
|
2014
|
+
state: result.state as NativeStartResult["state"],
|
|
2015
|
+
riskLevel: result.riskLevel,
|
|
2016
|
+
selectedLenses: result.selectedLenses,
|
|
2017
|
+
changedFiles: result.changedFiles,
|
|
2018
|
+
changedLines: result.changedLines,
|
|
2019
|
+
correctionBudget: result.correctionBudget,
|
|
2020
|
+
action: result.action as NativeStartAction,
|
|
2021
|
+
lensesRequired: result.lensesRequired,
|
|
2022
|
+
riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
|
|
2023
|
+
...(() => {
|
|
2024
|
+
const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
|
|
2025
|
+
return evidence.length === 0 ? {} : { riskEvidence: evidence };
|
|
2026
|
+
})(),
|
|
2027
|
+
raw: result.raw,
|
|
2028
|
+
} };
|
|
2029
|
+
}
|
|
2030
|
+
|
|
1885
2031
|
private async stageDocument(directory: string, name: string, document: unknown): Promise<string> {
|
|
1886
2032
|
const path = join(directory, `${name}.json`);
|
|
1887
2033
|
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.0",
|
|
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 {
|