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.
@@ -118,6 +118,7 @@ export const NATIVE_SDD_ARTIFACT_STATE = {
118
118
 
119
119
 
120
120
 
121
+
121
122
 
122
123
 
123
124
   
@@ -372,6 +373,21 @@ export const NATIVE_REVIEW_CAPTURE_OUTCOME = ["passed", "verification_failed", "
372
373
 
373
374
 
374
375
 
376
+ export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } ;
377
+
378
+
379
+
380
+
381
+
382
+
383
+
384
+
385
+
386
+
387
+
388
+
389
+
390
+
375
391
 
376
392
 
377
393
 
@@ -560,7 +576,13 @@ const REVIEW_RISK_SUBJECT_BY_SIGNAL = Object.f
560
576
  // an unmapped signal degrades to this rather than dropping the path entirely.
561
577
  const REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT = "a sensitive area";
562
578
 
563
- function nativeRiskEvidenceSubject(reason ) {
579
+
580
+
581
+
582
+
583
+
584
+
585
+ function nativeRiskEvidenceSubject(reason ) {
564
586
  const code = typeof reason.code === "string" ? reason.code : "";
565
587
  if (code === "hot_path") {
566
588
  const signal = typeof reason.signal === "string" ? reason.signal : "";
@@ -569,7 +591,7 @@ function nativeRiskEvidenceSubject(reason ) {
569
591
  return REVIEW_RISK_SUBJECT_BY_CODE[code] ?? "";
570
592
  }
571
593
 
572
- function nativeRiskEvidencePhrase(reason ) {
594
+ function nativeRiskEvidencePhrase(reason ) {
573
595
  const path = typeof reason.path === "string" ? reason.path.trim() : "";
574
596
  // An empty file is named first and described second. Every other subject
575
597
  // reads "<what changed> in <path>", which for a file with no bytes would
@@ -582,7 +604,7 @@ function nativeRiskEvidencePhrase(reason ) {
582
604
  return `${subject} in ${path}`;
583
605
  }
584
606
 
585
- export function nativeRiskEvidencePhrases(riskLevel , reasons ) {
607
+ export function nativeRiskEvidencePhrases(riskLevel , reasons ) {
586
608
  if (riskLevel !== "high" && riskLevel !== "medium") return [];
587
609
  const phrases = reasons.map((reason) => nativeRiskEvidencePhrase(reason)).filter((phrase) => phrase !== "");
588
610
  return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases;
@@ -1016,6 +1038,21 @@ function decodeNativeReviewStatus(value ) {
1016
1038
  };
1017
1039
  }
1018
1040
  function isWindowsRepositoryPath(value ) { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); }
1041
+ export function normalizeNativeReviewCwd(value , platform = process.platform) {
1042
+ if (platform !== "win32") return value;
1043
+ const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value);
1044
+ const windowsPath = gitBashDrive === null
1045
+ ? value
1046
+ : `${gitBashDrive[1] .toUpperCase()}:/${gitBashDrive[2] ?? ""}`;
1047
+ if (!isWindowsRepositoryPath(windowsPath)) return windowsPath;
1048
+ const normalized = win32.normalize(windowsPath);
1049
+ return normalized.replace(/^([a-z]):/, (_match, drive ) => `${drive.toUpperCase()}:`);
1050
+ }
1051
+ async function canonicalNativeReviewCwd(value ) {
1052
+ const normalized = normalizeNativeReviewCwd(value);
1053
+ try { return await realpath(normalized); }
1054
+ catch { return normalized; }
1055
+ }
1019
1056
  async function repositoryPathIdentity(value ) {
1020
1057
  const windowsPath = isWindowsRepositoryPath(value);
1021
1058
  try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; }
@@ -1124,6 +1161,7 @@ function nativeError(code , operation
1124
1161
 
1125
1162
 
1126
1163
 
1164
+
1127
1165
  export class NativeReviewCliV214 {
1128
1166
  adapter ;
1129
1167
  executable ;
@@ -1338,12 +1376,13 @@ export class NativeReviewCliV214 {
1338
1376
  // and always pass `--scope clone` so Pi's own kill-switch command surface
1339
1377
  // never mutates the operator's global gentle-ai state across other clones.
1340
1378
  async reviewMode(request ) {
1341
- await this.verifyVersion(request.cwd, request.signal, ["mode"]);
1379
+ const cwd = await canonicalNativeReviewCwd(request.cwd);
1380
+ await this.verifyVersion(cwd, request.signal, ["mode"]);
1342
1381
  const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
1343
1382
  const { body } = await this.execute(
1344
1383
  NATIVE_REVIEW_OPERATION.MODE,
1345
- request.cwd,
1346
- ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1384
+ cwd,
1385
+ ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1347
1386
  mutating,
1348
1387
  request.signal,
1349
1388
  );
@@ -1618,12 +1657,13 @@ export class NativeReviewIntegrationError extends Error {
1618
1657
  }
1619
1658
 
1620
1659
  // Raised when negotiated START answers `consent/v2` (action:
1621
- // "consent_required") instead of `start/v3`. Pi stays headless and never
1622
- // sends `--consent relay`, so this is a blocking question surfaced to the
1623
- // caller rather than answered on its behalf.
1660
+ // "consent_required") instead of `start/v3`. The provider has frozen no
1661
+ // authority yet: Pi must relay this complete candidate-scoped question and may
1662
+ // answer only through one of the exact invocations carried by the envelope.
1624
1663
  export class NativeReviewConsentRequiredError extends Error {
1625
1664
  consent ;
1626
1665
  launchAttempted = true;
1666
+ mutationOutcome = "none";
1627
1667
  constructor(consent ) {
1628
1668
  super(consent.headline);
1629
1669
  this.name = "NativeReviewConsentRequiredError";
@@ -1631,6 +1671,117 @@ export class NativeReviewConsentRequiredError extends Error {
1631
1671
  }
1632
1672
  }
1633
1673
 
1674
+ // Raised when the provider-issued consent invocation no longer matches the
1675
+ // binding Pi is answering for. Every one of these guards runs before the
1676
+ // provider is launched, so the failure is local and nothing was mutated. It
1677
+ // carries its own identity precisely so callers never report it as a provider
1678
+ // outage: an opaque `native-operation-failed` here sent issue #247 chasing a
1679
+ // missing `--cwd` that Pi does forward.
1680
+ export class NativeReviewConsentBindingError extends Error {
1681
+ reason ;
1682
+ launchAttempted = false;
1683
+ mutationOutcome = "none";
1684
+ constructor(reason , message ) {
1685
+ super(message);
1686
+ this.name = "NativeReviewConsentBindingError";
1687
+ this.reason = reason;
1688
+ }
1689
+ }
1690
+
1691
+ function splitNativeConsentInvocation(invocation ) {
1692
+ const words = [];
1693
+ let current = "";
1694
+ let quote ;
1695
+ let escaping = false;
1696
+ let started = false;
1697
+ for (const character of invocation.trim()) {
1698
+ if (escaping) {
1699
+ current += character;
1700
+ escaping = false;
1701
+ started = true;
1702
+ continue;
1703
+ }
1704
+ if (character === "\\" && quote !== "'") {
1705
+ escaping = true;
1706
+ started = true;
1707
+ continue;
1708
+ }
1709
+ if (quote !== undefined) {
1710
+ if (character === quote) quote = undefined;
1711
+ else current += character;
1712
+ started = true;
1713
+ continue;
1714
+ }
1715
+ if (character === "'" || character === '"') {
1716
+ quote = character;
1717
+ started = true;
1718
+ continue;
1719
+ }
1720
+ if (/\s/.test(character)) {
1721
+ if (started) {
1722
+ words.push(current);
1723
+ current = "";
1724
+ started = false;
1725
+ }
1726
+ continue;
1727
+ }
1728
+ current += character;
1729
+ started = true;
1730
+ }
1731
+ if (quote !== undefined || escaping) throw new TypeError("Native consent invocation has invalid quoting");
1732
+ if (started) words.push(current);
1733
+ return words;
1734
+ }
1735
+
1736
+ function exactConsentOption(arguments_ , name ) {
1737
+ const values = [];
1738
+ for (let index = 0; index < arguments_.length; index += 1) {
1739
+ const token = arguments_[index] ;
1740
+ if (token === name) {
1741
+ const value = arguments_[index + 1];
1742
+ if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`);
1743
+ values.push(value);
1744
+ index += 1;
1745
+ } else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
1746
+ }
1747
+ if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`);
1748
+ return values[0] ;
1749
+ }
1750
+
1751
+ function consentInvocationArguments(request ) {
1752
+ const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
1753
+ if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
1754
+ const words = splitNativeConsentInvocation(choice.invocation);
1755
+ 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");
1756
+ const arguments_ = words.slice(1);
1757
+ if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
1758
+ if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1759
+ if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1760
+ if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
1761
+ if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
1762
+ return arguments_;
1763
+ }
1764
+
1765
+ function decodeDeclinedConsentStart(value , expected ) {
1766
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Native declined consent result must be an object");
1767
+ const body = value ;
1768
+ if (body.operation !== "review/start" || body.action !== "declined" || body.consent !== "declined_this_candidate") throw new TypeError("Native declined consent result has an invalid identity");
1769
+ 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");
1770
+ 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");
1771
+ 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");
1772
+ if (body.lineage_id !== "" || body.state !== "" || body.correction_budget !== 0) throw new TypeError("Native declined consent result cannot carry review authority");
1773
+ return {
1774
+ kind: "declined",
1775
+ targetIdentity: expected.consent.targetIdentity,
1776
+ projection: expected.consent.projection,
1777
+ riskLevel: expected.consent.riskLevel,
1778
+ changedFiles: body.changed_files,
1779
+ changedLines: body.changed_lines,
1780
+ consent: "declined_this_candidate",
1781
+ raw: body,
1782
+ };
1783
+ }
1784
+
1634
1785
 
1635
1786
  const nativeCapabilitiesByDigest = new Map ();
1636
1787
 
@@ -1820,41 +1971,40 @@ export class NativeReviewCliV216 {
1820
1971
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
1821
1972
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1822
1973
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
1823
- // A negotiated START requires exactly one --contract, --target, and
1824
- // --projection. The target is resolved here, from the same root START is
1825
- // about to run in, rather than threaded in by the caller: the caller's
1826
- // root and START's root differ whenever a candidate view is in play, and
1827
- // a target frozen from the wrong root would name a snapshot this START
1828
- // never inspects. targetStatus is read-only, so this costs one extra
1829
- // read and cannot create authority.
1974
+ if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
1975
+ // The controller supplies the target it already projected from the
1976
+ // authority workspace after proving its immutable actor view is identical.
1977
+ // Direct adapter callers may omit it and retain the same-root projection.
1830
1978
  const projection = request.projection ?? "workspace";
1831
- const target = await this.targetStatus({
1979
+ const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
1832
1980
  cwd: request.cwd,
1833
1981
  projection,
1834
1982
  ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
1835
1983
  ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
1836
1984
  ...(request.signal === undefined ? {} : { signal: request.signal }),
1837
- });
1985
+ })).targetIdentity;
1838
1986
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
1839
1987
  "review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1840
- "--target", target.targetIdentity, "--projection", projection,
1988
+ "--target", targetIdentity, "--projection", projection,
1841
1989
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
1842
1990
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1843
1991
  ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
1844
1992
  ...(request.focus === undefined ? [] : ["--focus", request.focus]),
1845
- // A headless START that reviews without asking still succeeds; its
1846
- // consent notices are console output, never a failure signal.
1847
- ], true, request.signal, REVIEW_CONSENT_NOTICES);
1993
+ "--consent", "relay",
1994
+ ], true, request.signal);
1848
1995
  // A negotiated v2 START may answer `consent/v2` (action:
1849
1996
  // "consent_required") instead of `start/v3` when the provider needs an
1850
- // explicit answer it cannot infer. Discriminated BEFORE decode: the two
1851
- // envelopes share no required shape, and Pi never sends `--consent
1852
- // relay`, so this is always surfaced to the caller, never answered here.
1997
+ // explicit answer it cannot infer. Discriminate before decode and surface
1998
+ // the complete envelope; only the caller can map a human answer.
1853
1999
  if (execution.body.action === "consent_required") {
1854
- throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
2000
+ const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
2001
+ 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");
2002
+ throw new NativeReviewConsentRequiredError(consent);
1855
2003
  }
1856
2004
  const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1857
2005
  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");
2006
+ const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2007
+ if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
1858
2008
  return {
1859
2009
  lineageId: result.lineageId,
1860
2010
  state: result.state ,
@@ -1883,6 +2033,36 @@ export class NativeReviewCliV216 {
1883
2033
  };
1884
2034
  }
1885
2035
 
2036
+ async answerConsent(request ) {
2037
+ const arguments_ = consentInvocationArguments(request);
2038
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
2039
+ if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
2040
+ return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
2041
+ }
2042
+ const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
2043
+ const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2044
+ if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
2045
+ const lineageId = exactConsentOption(arguments_, "--lineage");
2046
+ if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
2047
+ return { kind: "started", start: {
2048
+ lineageId: result.lineageId,
2049
+ state: result.state ,
2050
+ riskLevel: result.riskLevel,
2051
+ selectedLenses: result.selectedLenses,
2052
+ changedFiles: result.changedFiles,
2053
+ changedLines: result.changedLines,
2054
+ correctionBudget: result.correctionBudget,
2055
+ action: result.action ,
2056
+ lensesRequired: result.lensesRequired,
2057
+ riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
2058
+ ...(() => {
2059
+ const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
2060
+ return evidence.length === 0 ? {} : { riskEvidence: evidence };
2061
+ })(),
2062
+ raw: result.raw,
2063
+ } };
2064
+ }
2065
+
1886
2066
  async stageDocument(directory , name , document ) {
1887
2067
  const path = join(directory, `${name}.json`);
1888
2068
  await writeFile(path, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
@@ -1304,10 +1304,14 @@ export function decodeReviewConsentV2(value ) {
1304
1304
  if (body.action !== "consent_required") throw new TypeError("consent.action must be consent_required");
1305
1305
  if (body.blocking !== true) throw new TypeError("consent.blocking must be true");
1306
1306
 
1307
+ const targetIdentity = sha256(body.target_identity, "consent.target_identity");
1307
1308
  const choicesArray = body.choices;
1308
1309
  if (!Array.isArray(choicesArray) || choicesArray.length !== 2) throw new TypeError("consent.choices must have exactly 2 items");
1309
1310
  const granted = decodeConsentChoice(choicesArray[0], "consent.choices[0]", "granted");
1310
1311
  const declined = decodeConsentChoice(choicesArray[1], "consent.choices[1]", "declined");
1312
+ for (const choice of [granted, declined]) {
1313
+ if (!choice.invocation.includes(` --target ${targetIdentity} `)) throw new TypeError(`consent choice ${choice.answer} invocation must bind consent.target_identity`);
1314
+ }
1311
1315
 
1312
1316
  const offPathSource = exactRecord(body.off_path, "consent.off_path", ["note", "command"]);
1313
1317
  if (offPathSource.command !== "gentle-ai review mode disable") throw new TypeError("consent.off_path.command is unsupported");
@@ -1318,7 +1322,7 @@ export function decodeReviewConsentV2(value ) {
1318
1322
  operation: "review.start",
1319
1323
  action: "consent_required",
1320
1324
  blocking: true,
1321
- targetIdentity: sha256(body.target_identity, "consent.target_identity"),
1325
+ targetIdentity,
1322
1326
  projection: enumeration(body.projection, REQUIRED_PROJECTIONS, "consent.projection"),
1323
1327
  riskLevel: enumeration(body.risk_level, ["medium", "high"] , "consent.risk_level"),
1324
1328
  changedFiles: integer(body.changed_files, "consent.changed_files"),