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.
@@ -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 { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
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 before = lstatSync(binaryPath);
70
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
71
- const asset = resolveGentleAiReleaseAsset(platform, process.arch);
72
- if (
73
- Object.keys(manifest).length !== 4 ||
74
- ["version", "asset", "assetSha256", "binarySha256"].some((key) => !(key in manifest)) ||
75
- manifest.version !== GENTLE_AI_VERSION ||
76
- manifest.asset !== asset.name ||
77
- manifest.assetSha256 !== asset.sha256 ||
78
- typeof manifest.binarySha256 !== "string" ||
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 {
@@ -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
- function nativeRiskEvidenceSubject(reason: Record<string, unknown>): string {
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: Record<string, unknown>): string {
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 Record<string, unknown>[]): readonly string[] {
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 this.verifyVersion(request.cwd, request.signal, ["mode"]);
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
- request.cwd,
1345
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
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`. Pi stays headless and never
1621
- // sends `--consent relay`, so this is a blocking question surfaced to the
1622
- // caller rather than answered on its behalf.
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
- // A negotiated START requires exactly one --contract, --target, and
1823
- // --projection. The target is resolved here, from the same root START is
1824
- // about to run in, rather than threaded in by the caller: the caller's
1825
- // root and START's root differ whenever a candidate view is in play, and
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 target = await this.targetStatus({
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", target.targetIdentity, "--projection", projection,
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
- // A headless START that reviews without asking still succeeds; its
1845
- // consent notices are console output, never a failure signal.
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. Discriminated BEFORE decode: the two
1850
- // envelopes share no required shape, and Pi never sends `--consent
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
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
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
- // The descriptor's own `projection` label ("staged" = a committed range,
802
- // "workspace" = dirty-inclusive) must agree with what Pi independently
803
- // derives from the base/candidate/HEAD relationship. Neither trusting
804
- // nor silently overriding a mislabeled claim is safe: fail closed.
805
- if ((descriptor.projection === "staged") !== committedOnly) {
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: sha256(body.target_identity, "consent.target_identity"),
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.0.0",
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 { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
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 before = lstatSync(binaryPath);
71
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) ;
72
- const asset = resolveGentleAiReleaseAsset(platform, process.arch);
73
- if (
74
- Object.keys(manifest).length !== 4 ||
75
- ["version", "asset", "assetSha256", "binarySha256"].some((key) => !(key in manifest)) ||
76
- manifest.version !== GENTLE_AI_VERSION ||
77
- manifest.asset !== asset.name ||
78
- manifest.assetSha256 !== asset.sha256 ||
79
- typeof manifest.binarySha256 !== "string" ||
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 {