gentle-pi 2.4.0 → 2.5.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.
Files changed (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -0,0 +1,151 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+
3
+ // gentle-ai#4309 owns anonymous usage telemetry end to end: install and
4
+ // heartbeat events, the notice, every kill switch, and the wire contract.
5
+ // gentle-pi#677 only nudges the local binary once per process, best-effort,
6
+ // and never surfaces the outcome to the user. This module is the pure,
7
+ // testable core of that nudge (`shouldTriggerTelemetry`,
8
+ // `spawnTelemetryTrigger`) plus the decode helper the foreground
9
+ // `/gentle:telemetry` slash command uses to relay `<op> --json` output
10
+ // (`decodeTelemetryTriggerDecision`).
11
+
12
+ export const TELEMETRY_TRIGGER_KILL_TIMEOUT_MS = 3_000;
13
+
14
+ export const TELEMETRY_TRIGGER_CONTRACT = "gentle-ai.telemetry-trigger/v1";
15
+
16
+ export const TELEMETRY_TRIGGER_DECISIONS = [
17
+ "enrolled",
18
+ "sent_install",
19
+ "sent_heartbeat",
20
+ "rate_limited",
21
+ "backoff",
22
+ "disabled",
23
+ ] as const;
24
+ export type TelemetryTriggerDecision = (typeof TELEMETRY_TRIGGER_DECISIONS)[number];
25
+
26
+ export interface TelemetryTriggerDecisionV1 {
27
+ schema: typeof TELEMETRY_TRIGGER_CONTRACT;
28
+ decision: TelemetryTriggerDecision;
29
+ source: string;
30
+ }
31
+
32
+ /**
33
+ * Decodes `gentle-ai telemetry trigger --json` stdout. An older binary
34
+ * without the verb prints `unknown telemetry command` and exits non-zero, so
35
+ * its stdout never parses as JSON here; that -- along with malformed JSON, a
36
+ * mismatched schema, an unrecognized decision, or a missing/non-string
37
+ * source -- means "nothing to do" and returns undefined rather than
38
+ * throwing.
39
+ */
40
+ export function decodeTelemetryTriggerDecision(stdout: string): TelemetryTriggerDecisionV1 | undefined {
41
+ let parsed: unknown;
42
+ try {
43
+ parsed = JSON.parse(stdout);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
48
+ const record = parsed as Record<string, unknown>;
49
+ if (record.schema !== TELEMETRY_TRIGGER_CONTRACT) return undefined;
50
+ if (typeof record.decision !== "string") return undefined;
51
+ if (!(TELEMETRY_TRIGGER_DECISIONS as readonly string[]).includes(record.decision)) return undefined;
52
+ if (typeof record.source !== "string" || record.source.length === 0) return undefined;
53
+ return {
54
+ schema: TELEMETRY_TRIGGER_CONTRACT,
55
+ decision: record.decision as TelemetryTriggerDecision,
56
+ source: record.source,
57
+ };
58
+ }
59
+
60
+ export type TelemetryKillSwitchReason = "do-not-track" | "telemetry-disabled" | "ci";
61
+
62
+ function telemetryKillSwitchReason(env: Readonly<Record<string, string | undefined>>): TelemetryKillSwitchReason | undefined {
63
+ if (env.DO_NOT_TRACK === "1") return "do-not-track";
64
+ if (env.GENTLE_AI_TELEMETRY === "0") return "telemetry-disabled";
65
+ if (env.CI === "true") return "ci";
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * True unless one of Gentle Pi's own kill switches is set
71
+ * (`DO_NOT_TRACK=1`, `GENTLE_AI_TELEMETRY=0`, or `CI=true`). The binary
72
+ * enforces its own opt-out and rate limiting independently; this predicate
73
+ * only decides whether Gentle Pi spawns the trigger at all.
74
+ */
75
+ export function shouldTriggerTelemetry(env: Readonly<Record<string, string | undefined>>): boolean {
76
+ return telemetryKillSwitchReason(env) === undefined;
77
+ }
78
+
79
+ // A minimal structural subset of node:child_process's ChildProcess, mirroring
80
+ // the ChildLike/Spawn seam already used by lib/agents-runner.ts: narrow
81
+ // enough that tests can stub it without touching a real process.
82
+ export interface TelemetryTriggerChildLike {
83
+ unref(): unknown;
84
+ kill(signal?: NodeJS.Signals | number): boolean;
85
+ on(event: "error", listener: (error: Error) => void): unknown;
86
+ }
87
+
88
+ export interface TelemetryTriggerSpawnOptions {
89
+ cwd: string;
90
+ env: NodeJS.ProcessEnv;
91
+ detached: boolean;
92
+ stdio: "ignore";
93
+ }
94
+
95
+ export type TelemetryTriggerSpawn = (
96
+ command: string,
97
+ args: readonly string[],
98
+ options: TelemetryTriggerSpawnOptions,
99
+ ) => TelemetryTriggerChildLike;
100
+
101
+ export interface SpawnTelemetryTriggerOptions {
102
+ executable: string;
103
+ cwd: string;
104
+ env: NodeJS.ProcessEnv;
105
+ spawn?: TelemetryTriggerSpawn;
106
+ }
107
+
108
+ export type TelemetryTriggerReason = "spawned" | "spawn-error" | TelemetryKillSwitchReason;
109
+
110
+ export interface TelemetryTriggerResult {
111
+ spawned: boolean;
112
+ reason: TelemetryTriggerReason;
113
+ }
114
+
115
+ /**
116
+ * Nudges `<executable> telemetry trigger --json`, detached and
117
+ * fire-and-forget. Never throws, never awaits the child's exit, and never
118
+ * blocks session start: a missing binary, an old binary without the verb, or
119
+ * a spawn failure all just report `{ spawned: false, ... }`. A 3 s timer
120
+ * kills a runaway child; the timer is unref'd so it can never keep the host
121
+ * process alive on its own, matching the detached child's own `unref()`.
122
+ */
123
+ export function spawnTelemetryTrigger(options: SpawnTelemetryTriggerOptions): TelemetryTriggerResult {
124
+ const killSwitch = telemetryKillSwitchReason(options.env);
125
+ if (killSwitch !== undefined) return { spawned: false, reason: killSwitch };
126
+ const spawnImpl = options.spawn ?? (nodeSpawn as unknown as TelemetryTriggerSpawn);
127
+ try {
128
+ const child = spawnImpl(options.executable, ["telemetry", "trigger", "--json"], {
129
+ cwd: options.cwd,
130
+ env: options.env,
131
+ detached: true,
132
+ stdio: "ignore",
133
+ });
134
+ child.on("error", () => {
135
+ // Fire-and-forget: a spawn-time or runtime child error never surfaces
136
+ // to the caller and never affects session start.
137
+ });
138
+ const killTimer = setTimeout(() => {
139
+ try {
140
+ child.kill();
141
+ } catch {
142
+ // Best effort only; the child may already have exited.
143
+ }
144
+ }, TELEMETRY_TRIGGER_KILL_TIMEOUT_MS);
145
+ killTimer.unref?.();
146
+ child.unref();
147
+ return { spawned: true, reason: "spawned" };
148
+ } catch {
149
+ return { spawned: false, reason: "spawn-error" };
150
+ }
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.4.0",
3
+ "version": "2.5.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",
@@ -66,11 +66,11 @@
66
66
  ]
67
67
  },
68
68
  "dependencies": {
69
- "@earendil-works/pi-tui": "*",
69
+ "@earendil-works/pi-tui": "0.85.1",
70
70
  "@heyhuynhgiabuu/pi-pretty": "0.6.14"
71
71
  },
72
72
  "peerDependencies": {
73
- "@earendil-works/pi-coding-agent": "*",
73
+ "@earendil-works/pi-coding-agent": ">=0.85.1",
74
74
  "typebox": "*"
75
75
  },
76
76
  "peerDependenciesMeta": {
@@ -79,7 +79,7 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@earendil-works/pi-coding-agent": "*"
82
+ "@earendil-works/pi-coding-agent": "0.85.1"
83
83
  },
84
84
  "packageManager": "pnpm@11.1.1"
85
85
  }
@@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, posix, win32 } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.mjs";
8
8
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.mjs";
9
+ import { decodeReviewAssessmentV1, } from "./review-risk-assessment.mjs";
9
10
  import {
10
11
  REVIEW_INTEGRATION_CONTRACT,
11
12
  decodeReviewAcknowledgedV1,
@@ -60,6 +61,7 @@ export const NATIVE_REVIEW_OPERATION = {
60
61
  RECONCILE_AUTHORITY: "review/reconcile-authority",
61
62
  REPAIR_LEGACY_ALIAS: "review/repair-legacy-alias",
62
63
  MODE: "review/mode",
64
+ ASSESS: "review/assess",
63
65
  REPAIR: "review/repair",
64
66
  CAPTURE_RESULT: "review/capture-result",
65
67
  CAPTURE_CORRECTION_PLAN: "review/capture-correction-plan",
@@ -112,6 +114,11 @@ export const NATIVE_REVIEW_ERROR_CODE = {
112
114
 
113
115
 
114
116
 
117
+
118
+
119
+
120
+
121
+
115
122
  export const NATIVE_REVIEW_MODE_OPERATION = {
116
123
  STATUS: "status",
117
124
  ENABLE: "enable",
@@ -152,6 +159,17 @@ export const NATIVE_REVIEW_MODE_SCOPE = {
152
159
 
153
160
 
154
161
 
162
+ // Read-only risk assessment request (gentle-pi#662). `baseRef` requires
163
+ // explicit `committedOnly` acknowledgement, exactly like Native START's
164
+ // baseRef/committedOnly pairing, because both select a committed range
165
+ // instead of the ambient working tree.
166
+
167
+
168
+
169
+
170
+
171
+
172
+
155
173
 
156
174
 
157
175
 
@@ -305,7 +323,6 @@ export const NATIVE_REVIEW_LEGACY_ALIAS_REPAIR = {
305
323
 
306
324
 
307
325
 
308
-
309
326
 
310
327
 
311
328
  /** Raw audited native record; Pi relays it verbatim and never reinterprets it. */
@@ -499,7 +516,12 @@ export const NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION = {
499
516
  } ;
500
517
 
501
518
 
502
- export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS = NATIVE_REVIEW_AUTHORITY_STATUS;
519
+ export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS = {
520
+ ...NATIVE_REVIEW_AUTHORITY_STATUS,
521
+ INCOMPLETE_STORE_ENTRY: "incomplete-store-entry",
522
+ HISTORICAL_PRE_RECEIPT: "historical-pre-receipt",
523
+ INVALIDATED: "invalidated",
524
+ } ;
503
525
 
504
526
 
505
527
  export const NATIVE_REVIEW_LOCK_STATUS = {
@@ -570,6 +592,11 @@ export const NATIVE_REVIEW_RECOVERY_DISPOSITION = {
570
592
 
571
593
 
572
594
 
595
+
596
+
597
+
598
+
599
+
573
600
 
574
601
 
575
602
 
@@ -805,6 +832,18 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
805
832
  // consumes did not change between 2.5.0 and 2.6.0, so the columns match
806
833
  // the 2.5.0 row.
807
834
  "2.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
835
+ // Ground-truthed against the published v2.7.0 binary installed from its
836
+ // signed archive: `review capabilities` on the v2 lane still advertises
837
+ // capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3,
838
+ // and the `start/v4` continuation, all of which the decoders already read.
839
+ // `review mode status --json` still answers `gentle-ai.rdd-mode-status/v1`
840
+ // and `review validate --gate pre-commit` still answers
841
+ // `gentle-ai.review-gate-result/v1` carrying `delivery`, both verified
842
+ // against the binary. riskEvidence and hint stay dark for the same reason
843
+ // they have on every row since 2.2.0: still not proven to reach the
844
+ // negotiated START path Pi reads. The closed fields Pi consumes did not
845
+ // change between 2.6.0 and 2.7.0, so the columns match the 2.6.0 row.
846
+ "2.7.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
808
847
  });
809
848
 
810
849
 
@@ -1047,8 +1086,15 @@ function decodeNativeReviewRecovery(value ) {
1047
1086
  ...(recovery.maintainer_authorization === undefined ? {} : { maintainerAuthorization: requiredString(recovery.maintainer_authorization) }),
1048
1087
  };
1049
1088
  }
1089
+ function decodeNativeReviewDiscardedWorkSummary(value ) {
1090
+ const discardedWork = exactObject(value, ["captured_lens_results", "findings_present"]);
1091
+ return {
1092
+ capturedLensResults: stringArray(discardedWork.captured_lens_results),
1093
+ findingsPresent: booleanValue(discardedWork.findings_present),
1094
+ };
1095
+ }
1050
1096
  function decodeNativeReviewStatusEntry(value ) {
1051
- const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery"]);
1097
+ const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery", "discarded_work"]);
1052
1098
  return {
1053
1099
  version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) ,
1054
1100
  ...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }),
@@ -1059,6 +1105,7 @@ function decodeNativeReviewStatusEntry(value )
1059
1105
  ...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }),
1060
1106
  ...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }),
1061
1107
  ...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }),
1108
+ ...(entry.discarded_work === undefined ? {} : { discardedWork: decodeNativeReviewDiscardedWorkSummary(entry.discarded_work) }),
1062
1109
  problems: stringArray(entry.problems),
1063
1110
  };
1064
1111
  }
@@ -1373,9 +1420,7 @@ class NativeReviewPlainCli {
1373
1420
  if (!isCanonicalProcessString(value)) throw new TypeError(`Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`);
1374
1421
  }
1375
1422
  if (!Array.isArray(request.capturedLensResults) || request.capturedLensResults.some((entry) => !isCanonicalProcessString(entry))) throw new TypeError("Native ABANDON capturedLensResults must be an array of non-empty, trimmed, NUL-free strings");
1376
- for (const [name, value] of [["findingsPresent", request.findingsPresent], ["evidenceRecordsPresent", request.evidenceRecordsPresent]] ) {
1377
- if (typeof value !== "boolean") throw new TypeError(`Native ABANDON ${name} must be a boolean`);
1378
- }
1423
+ if (typeof request.findingsPresent !== "boolean") throw new TypeError("Native ABANDON findingsPresent must be a boolean");
1379
1424
  if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding");
1380
1425
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.ABANDON, request.cwd, [
1381
1426
  "review", "abandon", "--cwd", request.cwd,
@@ -1464,7 +1509,11 @@ class NativeReviewPlainCli {
1464
1509
  }
1465
1510
  }
1466
1511
 
1467
- export function nativeReviewAbandonAuthorization(request ) {
1512
+ export function nativeReviewAbandonAuthorization(request ) {
1513
+ // gentle-ai 0ed9225f removed evidence records from the discarded-work summary,
1514
+ // so the native v2 gate verifies an exact eight-line binding (schema, lineage,
1515
+ // revision, snapshot_identity, reason, captured_lens_results, findings_present,
1516
+ // actor) — there is no evidence_records_present line to derive or relay.
1468
1517
  return [
1469
1518
  "gentle-ai.review-abandon-authorization/v2",
1470
1519
  `lineage=${request.lineage}`,
@@ -1473,7 +1522,6 @@ export function nativeReviewAbandonAuthorization(request
1473
1522
  `reason=${request.reason}`,
1474
1523
  `captured_lens_results=${request.capturedLensResults.join(",")}`,
1475
1524
  `findings_present=${request.findingsPresent}`,
1476
- `evidence_records_present=${request.evidenceRecordsPresent}`,
1477
1525
  `actor=${request.actor.trim()}`,
1478
1526
  ].join("\n");
1479
1527
  }
@@ -1596,14 +1644,20 @@ function splitNativeConsentInvocation(invocation ) {
1596
1644
  let quote ;
1597
1645
  let escaping = false;
1598
1646
  let started = false;
1599
- for (const character of invocation.trim()) {
1647
+ const source = invocation.trim();
1648
+ for (let index = 0; index < source.length; index += 1) {
1649
+ const character = source[index] ;
1600
1650
  if (escaping) {
1601
1651
  current += character;
1602
1652
  escaping = false;
1603
1653
  started = true;
1604
1654
  continue;
1605
1655
  }
1606
- if (character === "\\" && quote !== "'") {
1656
+ // Provider invocations are not shell commands. Keep every path backslash
1657
+ // verbatim, including quoted UNC and drive-root paths. Outside quotes,
1658
+ // only a backslash before whitespace joins a space-containing token.
1659
+ const next = source[index + 1];
1660
+ if (character === "\\" && quote === undefined && next !== undefined && /\s/.test(next)) {
1607
1661
  escaping = true;
1608
1662
  started = true;
1609
1663
  continue;
@@ -1694,7 +1748,9 @@ function consentInvocationArguments(request )
1694
1748
  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");
1695
1749
  const arguments_ = words.slice(1);
1696
1750
  if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
1697
- if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1751
+ const providerCwd = exactConsentOption(arguments_, "--cwd");
1752
+ const requestedCwd = normalizeNativeReviewCwd(request.cwd);
1753
+ if (normalizeNativeReviewCwd(providerCwd) !== requestedCwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1698
1754
  if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1699
1755
  if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
1700
1756
  const lineageId = optionalConsentLineageOption(arguments_);
@@ -2194,6 +2250,26 @@ export class NativeReviewCliV216 {
2194
2250
  return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(execution.body, request.operation));
2195
2251
  }
2196
2252
 
2253
+ // Read-only risk assessment (gentle-ai#4295, gentle-pi#662). Never mutates;
2254
+ // an older binary without the verb, or any other process/decode failure,
2255
+ // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2256
+ // closed to `high`.
2257
+ async assess(request ) {
2258
+ if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2259
+ if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2260
+ if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
2261
+ const cwd = await canonicalNativeReviewCwd(request.cwd);
2262
+ const execution = await this.invoke(
2263
+ NATIVE_REVIEW_OPERATION.ASSESS,
2264
+ cwd,
2265
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2266
+ false,
2267
+ request.signal,
2268
+ this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),
2269
+ );
2270
+ return decode(NATIVE_REVIEW_OPERATION.ASSESS, false, () => decodeReviewAssessmentV1(execution.body));
2271
+ }
2272
+
2197
2273
  // Recovery commands are version-gated plain CLI operations outside the
2198
2274
  // negotiated integration-v1 contract, exactly like reviewStatus.
2199
2275
  reclaim(request ) {
@@ -80,7 +80,10 @@ const RISK_LEVELS = ["low", "medium", "high"] ;
80
80
  const REVIEW_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] ;
81
81
  const RISK_REASON_CODES = ["configuration_change", "empty_content", "executable_change", "executable_mode", "hot_path", "large_change", "non_executable_only", "process_boundary", "process_scan_limit", "service_token", "shell_source"] ;
82
82
  const RISK_SIGNALS = ["auth", "update", "security", "payments", "permissions", "shell_process"] ;
83
- const STATUS_ACTIONS = ["start", "recover", "maintainer_action", "select_lineage", "repair_authority", "stop"] ;
83
+ // "collect" and "execute" are the v2 envelope's projection of a live
84
+ // transaction whose next_transition is mandatory: the root action names the
85
+ // transition kind instead of reading as a terminal stop.
86
+ const STATUS_ACTIONS = ["start", "recover", "maintainer_action", "select_lineage", "repair_authority", "stop", "collect", "execute"] ;
84
87
  const RECEIPT_STATUSES = ["expected_missing", "present", "publication_pending", "not_applicable"] ;
85
88
 
86
89
  export const REVIEW_STATUS_ACTION_DISPOSITION = {
@@ -404,6 +407,8 @@ const REPOSITORY_CONTEXT_OUTCOMES = ["applied", "pending", "blocked_conflict", "
404
407
 
405
408
 
406
409
 
410
+
411
+
407
412
 
408
413
 
409
414
 
@@ -546,6 +551,18 @@ export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS = Object.freeze(Object.valu
546
551
 
547
552
 
548
553
 
554
+ // gentle-pi#627: the exact `gentle-ai sync` invocation that resolves a stale
555
+ // managed-asset set (STATUS next_transition stop, and START's preflight
556
+ // failure envelope both carry this same shape).
557
+
558
+
559
+
560
+
561
+
562
+
563
+
564
+
565
+
549
566
 
550
567
 
551
568
 
@@ -706,6 +723,8 @@ const FAILURE_CAUSE_CATEGORIES = ["inventory_io_or_layout", "lock_ambiguous", "r
706
723
 
707
724
 
708
725
 
726
+
727
+
709
728
 
710
729
 
711
730
 
@@ -1640,8 +1659,11 @@ function decodeCollectInput(value , label , v5 , v6
1640
1659
  }
1641
1660
 
1642
1661
  if (captureOperation === "review.capture-result") {
1643
- if (input.artifact_subject === undefined || input.base_tree === undefined || input.candidate_tree === undefined || input.changed_path_manifest === undefined) {
1644
- throw new TypeError(`${label} requires artifact_subject, base_tree, candidate_tree, and changed_path_manifest`);
1662
+ // changed_path_manifest is optional here: the artifact subject's
1663
+ // changed_path_manifest_sha256 already binds the frozen manifest, and
1664
+ // gentle-ai stops inlining one copy per lens (gentle-ai#3922).
1665
+ if (input.artifact_subject === undefined || input.base_tree === undefined || input.candidate_tree === undefined) {
1666
+ throw new TypeError(`${label} requires artifact_subject, base_tree, and candidate_tree`);
1645
1667
  }
1646
1668
  if (schema !== "https://gentle-ai.dev/schema/review/reviewer/v1") throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/reviewer/v1`);
1647
1669
  } else if (input.artifact_subject !== undefined || input.base_tree !== undefined || input.candidate_tree !== undefined || input.changed_path_manifest !== undefined) {
@@ -1674,12 +1696,32 @@ function decodeCollectInput(value , label , v5 , v6
1674
1696
  };
1675
1697
  }
1676
1698
 
1699
+ // gentle-pi#627: gentle-ai's managed-assets continuation (STATUS next_transition
1700
+ // stop reason_code=managed_assets_outdated, and START's preflight failure
1701
+ // envelope) names the exact `gentle-ai sync` invocation that resolves a stale
1702
+ // managed-asset set without abandoning the frozen candidate. Decoded verbatim
1703
+ // from internal/cli/review_operation_contract.go's ReviewManagedAssetsContinuation.
1704
+ export function decodeReviewManagedAssetsContinuationV1(value , label ) {
1705
+ const body = exactRecord(value, label, ["operation", "command"], ["agent", "stale_assets"]);
1706
+ const operation = enumeration(body.operation, ["sync"] , `${label}.operation`);
1707
+ const command = nonempty(body.command, `${label}.command`);
1708
+ const agent = body.agent === undefined ? undefined : nonempty(body.agent, `${label}.agent`);
1709
+ const staleAssets = body.stale_assets === undefined ? undefined : stringArray(body.stale_assets, `${label}.stale_assets`, { minimum: 1 });
1710
+ return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
1711
+ }
1712
+
1677
1713
  export function decodeReviewNextTransitionV3(value , options = {}) {
1678
1714
  const v6 = options.v6 === true;
1679
1715
  const v5 = options.v5 === true || v6;
1680
- const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : [])]);
1716
+ const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation"]);
1681
1717
  const kind = enumeration(transition.kind, ["execute", "collect", "stop"] , "next_transition.kind");
1682
1718
  const reasonCode = text(transition.reason_code, "next_transition.reason_code", { minimum: 1, pattern: /^[a-z0-9_]+$/ });
1719
+ const continuation = transition.continuation === undefined ? undefined : decodeReviewManagedAssetsContinuationV1(transition.continuation, "next_transition.continuation");
1720
+ // gentle-pi#627: continuation is optional on every status version an older
1721
+ // gentle-ai may emit a managed_assets_outdated stop with no continuation at
1722
+ // all, and decode must not refuse the whole envelope for that. When present
1723
+ // it is only valid on a stop with this exact reason_code.
1724
+ if (continuation !== undefined && !(kind === "stop" && reasonCode === "managed_assets_outdated")) throw new TypeError("next_transition.continuation is only valid for a stop transition with reason_code managed_assets_outdated");
1683
1725
 
1684
1726
  // status/v5: the bounded correction plan request rides exactly its two
1685
1727
  // reason codes and never any other (vendored status-v5.schema.json).
@@ -1729,7 +1771,7 @@ export function decodeReviewNextTransitionV3(value , options
1729
1771
  return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
1730
1772
  }
1731
1773
  if (transition.execute !== undefined || transition.collect !== undefined) throw new TypeError("next_transition stop cannot carry a transition");
1732
- return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
1774
+ return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }), ...(continuation === undefined ? {} : { continuation }) };
1733
1775
  }
1734
1776
 
1735
1777
  // ---------------------------------------------------------------------------
@@ -1818,11 +1860,12 @@ export function decodeReviewStatusV3(value ) {
1818
1860
 
1819
1861
  let frozen ;
1820
1862
  if (body.frozen !== undefined) {
1821
- const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"]);
1863
+ const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"], ["changed_path_manifest_sha256"]);
1822
1864
  frozen = {
1823
1865
  tier: enumeration(source.tier, RISK_LEVELS, "status.frozen.tier"),
1824
1866
  originalChangedLines: integer(source.original_changed_lines, "status.frozen.original_changed_lines"),
1825
1867
  correctionBudget: integer(source.correction_budget, "status.frozen.correction_budget", 0, 200),
1868
+ ...(source.changed_path_manifest_sha256 === undefined ? {} : { changedPathManifestSha256: sha256(source.changed_path_manifest_sha256, "status.frozen.changed_path_manifest_sha256") }),
1826
1869
  };
1827
1870
  }
1828
1871
  if (authority?.version === REVIEW_AUTHORITY_VERSION.COMPACT_V2 && frozen === undefined) throw new TypeError("compact-v2 status requires frozen metadata");
@@ -2049,9 +2092,10 @@ function decodeFailureContext(value , label )
2049
2092
  export function decodeReviewFailureV2(value ) {
2050
2093
  const body = exactRecord(value, "failure", [
2051
2094
  "schema", "contract", "operation", "phase", "code", "message", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "required_inputs", "next_action",
2052
- ], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context"]);
2095
+ ], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context", "continuation"]);
2053
2096
  requireIdentity(body, "gentle-ai.review-integration.failure/v2");
2054
2097
  const operation = enumeration(body.operation, FAILURE_OPERATIONS, "failure.operation");
2098
+ const code = text(body.code, "failure.code", { pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ });
2055
2099
 
2056
2100
  if (body.progress_identity !== undefined) {
2057
2101
  if (body.lineage_id === undefined || body.request_digest === undefined) throw new TypeError("failure.progress_identity requires lineage_id and request_digest");
@@ -2060,13 +2104,18 @@ export function decodeReviewFailureV2(value ) {
2060
2104
  if (body.request_digest !== undefined && operation === REVIEW_INTEGRATION_OPERATION.REPAIR && body.progress_identity === undefined) {
2061
2105
  throw new TypeError("failure.request_digest with review.repair requires progress_identity");
2062
2106
  }
2107
+ // gentle-pi#627: continuation is optional on every version — an older
2108
+ // gentle-ai may emit managed_assets_outdated with no continuation, and
2109
+ // decode must not refuse the whole envelope for that. When present it is
2110
+ // only valid on this exact code.
2111
+ if (body.continuation !== undefined && code !== "managed_assets_outdated") throw new TypeError("failure.continuation is only valid for code managed_assets_outdated");
2063
2112
 
2064
2113
  return {
2065
2114
  schema: "gentle-ai.review-integration.failure/v2",
2066
2115
  contract: REVIEW_INTEGRATION_CONTRACT,
2067
2116
  operation,
2068
2117
  phase: enumeration(body.phase, ["preflight", "pre_native", "native_running", "native_committed", "reconciliation"] , "failure.phase"),
2069
- code: text(body.code, "failure.code", { pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ }),
2118
+ code,
2070
2119
  message: text(body.message, "failure.message", { minimum: 1, maximum: 240, pattern: /^[^\r\n]+$/ }),
2071
2120
  mutationOutcome: enumeration(body.mutation_outcome, Object.values(REVIEW_MUTATION_OUTCOME), "failure.mutation_outcome"),
2072
2121
  authorityApplicability: enumeration(body.authority_applicability, Object.values(REVIEW_AUTHORITY_APPLICABILITY), "failure.authority_applicability"),
@@ -2080,6 +2129,7 @@ export function decodeReviewFailureV2(value ) {
2080
2129
  ...(body.cause_category === undefined ? {} : { causeCategory: text(body.cause_category, "failure.cause_category", { minimum: 1, pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ }) }),
2081
2130
  ...(body.cause === undefined ? {} : { cause: text(body.cause, "failure.cause", { minimum: 1, maximum: 4000, pattern: /^[^\r\n]+$/ }) }),
2082
2131
  ...(body.context === undefined ? {} : { context: decodeFailureContext(body.context, "failure.context") }),
2132
+ ...(body.continuation === undefined ? {} : { continuation: decodeReviewManagedAssetsContinuationV1(body.continuation, "failure.continuation") }),
2083
2133
  raw: body,
2084
2134
  };
2085
2135
  }
@@ -15,3 +15,14 @@
15
15
 
16
16
  export const GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV = "GENTLE_PI_REVIEW_RELAY_CONTRACT";
17
17
  export const GENTLE_PI_REVIEW_RELAY_CONTRACT = "gentle-pi.review-relay/v1";
18
+
19
+ // The host is this extension: every process the session starts (pi's shell
20
+ // tools, Gentle Agents children) runs under it, so the handshake belongs in
21
+ // the session environment too, not only in the extension's own invocations.
22
+ // Without it the first `gentle-ai review status --agent pi` typed in the
23
+ // session shell fails closed (gentle-pi#550). Returns whether anything changed.
24
+ export function declareReviewRelayHandshake(env ) {
25
+ if (env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] === GENTLE_PI_REVIEW_RELAY_CONTRACT) return false;
26
+ env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] = GENTLE_PI_REVIEW_RELAY_CONTRACT;
27
+ return true;
28
+ }