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,340 @@
1
+ // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
+ // gentle-pi#662: pure decoding, tier mapping, and verification-plan logic for
3
+ // the native read-only `gentle-ai review assess` command (gentle-ai#4295).
4
+ // Kept independent of the native review CLI wrapper (`lib/native-review-cli.ts`)
5
+ // so the decoder and the verificationPlan table can be unit-tested without a
6
+ // child process, and so the parent tool and the delegation asset agree on one
7
+ // deterministic mapping.
8
+ //
9
+ // Native reference (gentle-ai#4295, landing in parallel with this change):
10
+ // `gentle-ai review assess --cwd <repo> [--base-ref <ref> --committed-only] --json`
11
+ // prints `{"schema":"gentle-ai.review-assessment/v1","risk":"passive|medium|high",
12
+ // "reasons":[{"code":"…","path":"…","detail":"…"}],"changed_paths":1,
13
+ // "changed_lines":1,"candidate":{"kind":"current-changes|base-diff","base_ref":"…"}}`.
14
+ // A non-zero exit or a failure envelope means the candidate could not be
15
+ // assessed; hosts treat that as `high`. Older binaries without the verb (or
16
+ // any other decode/process failure) fail closed the same way -- see
17
+ // `verificationTierForUnassessable` below.
18
+
19
+ export const REVIEW_ASSESSMENT_SCHEMA = "gentle-ai.review-assessment/v1" ;
20
+
21
+ export const REVIEW_ASSESSMENT_RISK = {
22
+ PASSIVE: "passive",
23
+ MEDIUM: "medium",
24
+ HIGH: "high",
25
+ } ;
26
+
27
+
28
+ export const REVIEW_ASSESSMENT_CANDIDATE_KIND = {
29
+ CURRENT_CHANGES: "current-changes",
30
+ BASE_DIFF: "base-diff",
31
+ } ;
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+ function isRecord(value ) {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ function decodeReason(value ) {
59
+ if (!isRecord(value)) throw new TypeError("review assessment reason must be an object");
60
+ const { code, path, detail } = value;
61
+ if (typeof code !== "string" || code.length === 0) throw new TypeError("review assessment reason.code must be a non-empty string");
62
+ if (typeof path !== "string") throw new TypeError("review assessment reason.path must be a string");
63
+ if (typeof detail !== "string") throw new TypeError("review assessment reason.detail must be a string");
64
+ return Object.freeze({ code, path, detail });
65
+ }
66
+
67
+ function decodeCandidate(value ) {
68
+ if (!isRecord(value)) throw new TypeError("review assessment candidate must be an object");
69
+ const { kind, base_ref: baseRef } = value;
70
+ if (kind !== REVIEW_ASSESSMENT_CANDIDATE_KIND.CURRENT_CHANGES && kind !== REVIEW_ASSESSMENT_CANDIDATE_KIND.BASE_DIFF) {
71
+ throw new TypeError("review assessment candidate.kind must be current-changes or base-diff");
72
+ }
73
+ if (baseRef !== undefined && (typeof baseRef !== "string" || baseRef.length === 0)) {
74
+ throw new TypeError("review assessment candidate.base_ref must be a non-empty string when present");
75
+ }
76
+ return Object.freeze({ kind, baseRef: baseRef });
77
+ }
78
+
79
+ /**
80
+ * Decodes and validates one `gentle-ai.review-assessment/v1` envelope. Rejects
81
+ * any other schema, an unrecognized risk value, or a malformed shape -- the
82
+ * native review CLI wrapper turns a thrown error here into the same
83
+ * schema-incompatible failure it already produces for every other decoded
84
+ * native response.
85
+ */
86
+ export function decodeReviewAssessmentV1(value ) {
87
+ if (!isRecord(value)) throw new TypeError("review assessment must be an object");
88
+ if (value.schema !== REVIEW_ASSESSMENT_SCHEMA) throw new TypeError(`review assessment schema must be ${REVIEW_ASSESSMENT_SCHEMA}`);
89
+ const { risk, reasons, changed_paths: changedPaths, changed_lines: changedLines, candidate } = value;
90
+ if (risk !== REVIEW_ASSESSMENT_RISK.PASSIVE && risk !== REVIEW_ASSESSMENT_RISK.MEDIUM && risk !== REVIEW_ASSESSMENT_RISK.HIGH) {
91
+ throw new TypeError("review assessment risk must be passive, medium, or high");
92
+ }
93
+ if (!Array.isArray(reasons)) throw new TypeError("review assessment reasons must be an array");
94
+ if (!Number.isSafeInteger(changedPaths) || (changedPaths ) < 0) throw new TypeError("review assessment changed_paths must be a non-negative integer");
95
+ if (!Number.isSafeInteger(changedLines) || (changedLines ) < 0) throw new TypeError("review assessment changed_lines must be a non-negative integer");
96
+ return Object.freeze({
97
+ schema: REVIEW_ASSESSMENT_SCHEMA,
98
+ risk,
99
+ reasons: Object.freeze(reasons.map(decodeReason)),
100
+ changedPaths: changedPaths ,
101
+ changedLines: changedLines ,
102
+ candidate: decodeCandidate(candidate),
103
+ });
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Verification-plan tiering (gentle-pi#662).
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export const VERIFICATION_TIER = {
111
+ PASSIVE: "passive",
112
+ MEDIUM: "medium",
113
+ HIGH: "high",
114
+ UNASSESSABLE: "unassessable",
115
+ } ;
116
+
117
+
118
+ export const RDD_LINE = {
119
+ ON: "on",
120
+ OFF: "off",
121
+ UNKNOWN: "unknown",
122
+ } ;
123
+
124
+
125
+ export const WRITER_PROFILE = {
126
+ SMALL: "small",
127
+ LARGE: "large",
128
+ } ;
129
+
130
+
131
+ // gentle-pi#668: whether the native review actually reached a terminal
132
+ // outcome for the current candidate. The `on` branch of `verificationPlan`
133
+ // (the writer self-verifies, the native review is the independent check, no
134
+ // separate verifier) holds only for `CLOSED` -- a human decline for this
135
+ // candidate, a clone-local RDD disable, a refused START/STATUS, or any other
136
+ // non-terminal outcome falls back to the exact same risk-gated path as `off`.
137
+ // `UNKNOWN` is the fail-closed default for an omitted outcome: never treated
138
+ // as `CLOSED`.
139
+ export const NATIVE_REVIEW_OUTCOME = {
140
+ CLOSED: "closed",
141
+ DECLINED: "declined",
142
+ UNAVAILABLE: "unavailable",
143
+ UNKNOWN: "unknown",
144
+ } ;
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+ /**
167
+ * Builds the risk-gated verification plan shared by the `off`/`unknown` RDD
168
+ * lines and by an `on` line whose native review did not close for this
169
+ * candidate (gentle-pi#668). `gatePrefix` supplies the sentence fragment
170
+ * naming which branch fired, up to and including the word "and"; the
171
+ * tier-specific clause is appended after it.
172
+ */
173
+ function riskGatedPlan(gatePrefix , risk , writerProfile ) {
174
+ if (risk === VERIFICATION_TIER.PASSIVE) {
175
+ return Object.freeze({
176
+ writerSelfVerification: false,
177
+ structuralReadbackOnly: true,
178
+ independentVerifier: false,
179
+ reason: `${gatePrefix} the native assessment reports passive risk: a structural readback by the parent is the complete check, with no separate verifier and no tests.`,
180
+ });
181
+ }
182
+
183
+ if (risk === VERIFICATION_TIER.MEDIUM) {
184
+ const smallModelBias = writerProfile === WRITER_PROFILE.SMALL;
185
+ return Object.freeze({
186
+ writerSelfVerification: true,
187
+ structuralReadbackOnly: false,
188
+ independentVerifier: smallModelBias,
189
+ reason: smallModelBias
190
+ ? `${gatePrefix} the native assessment reports medium risk, but the small-model bias raises it one tier to high for verification purposes: the writer self-verifies and a separate independent verifier also runs.`
191
+ : `${gatePrefix} the native assessment reports medium risk with a large writer profile: the writer's self-verification stands and no separate verifier is required.`,
192
+ });
193
+ }
194
+
195
+ // high or unassessable.
196
+ return Object.freeze({
197
+ writerSelfVerification: true,
198
+ structuralReadbackOnly: false,
199
+ independentVerifier: true,
200
+ reason: risk === VERIFICATION_TIER.UNASSESSABLE
201
+ ? `${gatePrefix} the native risk assessment could not be produced, so the candidate is treated as high risk: the writer self-verifies and a separate independent verifier always runs.`
202
+ : `${gatePrefix} the native assessment reports high risk: the writer self-verifies and a separate independent verifier always runs.`,
203
+ });
204
+ }
205
+
206
+ // Names why an `on` line falls back to the risk-gated path, for every
207
+ // non-closed native review outcome (gentle-pi#668). `UNKNOWN` covers both an
208
+ // omitted outcome and a caller-supplied one this decoder does not
209
+ // recognize -- both fail closed the same way.
210
+ function nonClosedOutcomeClause(outcome ) {
211
+ switch (outcome) {
212
+ case NATIVE_REVIEW_OUTCOME.DECLINED:
213
+ return "the native review was declined for this candidate";
214
+ case NATIVE_REVIEW_OUTCOME.UNAVAILABLE:
215
+ return "the native review is unavailable for this candidate";
216
+ default:
217
+ return "the native review outcome for this candidate is unknown";
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Computes who verifies a delegated writer's change, exactly as
223
+ * gentle-pi#662/#668 specify:
224
+ *
225
+ * - `rdd: "on"` AND the native review reached `NATIVE_REVIEW_OUTCOME.CLOSED`
226
+ * for this candidate: the writer self-verifies and no independent verifier
227
+ * runs, because the closed native review is the check -- except `passive`,
228
+ * which gets a structural readback by the parent instead.
229
+ * - `rdd: "on"` with any other outcome (`declined`, `unavailable`, or the
230
+ * fail-closed `unknown` default for an omitted/unrecognized outcome): the
231
+ * `on` branch never held for this candidate, so the risk-gated path below
232
+ * applies exactly as `off` -- declining a review is candidate-scoped and
233
+ * never lowers the bar below the RDD-off path.
234
+ * - `rdd: "off"` or `"unknown"` (both gate identically, so an unknown RDD line
235
+ * never lowers a tier relative to `off`; `nativeReviewOutcome` is ignored
236
+ * entirely on these lines):
237
+ * - `passive`: structural readback by the parent only; no verifier, no
238
+ * tests.
239
+ * - `medium`: the writer self-verifies; a separate independent verifier
240
+ * runs only when `writerProfile` is `"small"` -- the small-model bias
241
+ * raises the tier by one for verification purposes (medium -> high). An
242
+ * unknown or omitted writer profile resolves to `"small"` before it
243
+ * reaches this function (see `resolveWriterProfile`), so an unrecognized
244
+ * profile never lowers a tier below what a known small model would get.
245
+ * - `high` or `unassessable` (a failed or unrecognized native assessment is
246
+ * always treated as high): the writer self-verifies and an independent
247
+ * verifier always runs.
248
+ */
249
+ export function verificationPlan(input ) {
250
+ const { rddLine, risk, writerProfile } = input;
251
+
252
+ if (rddLine === RDD_LINE.ON) {
253
+ const outcome = input.nativeReviewOutcome ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
254
+ if (outcome === NATIVE_REVIEW_OUTCOME.CLOSED) {
255
+ if (risk === VERIFICATION_TIER.PASSIVE) {
256
+ return Object.freeze({
257
+ writerSelfVerification: false,
258
+ structuralReadbackOnly: true,
259
+ independentVerifier: false,
260
+ reason: "receipt-driven development is on and the native review closed for this candidate; the assessment also reports passive risk: a structural readback by the parent replaces both the writer's self-verification and any separate verifier.",
261
+ });
262
+ }
263
+ return Object.freeze({
264
+ writerSelfVerification: true,
265
+ structuralReadbackOnly: false,
266
+ independentVerifier: false,
267
+ reason: "receipt-driven development is on and the native review closed for this candidate: the writer's self-verification is the record and the closed native review was the independent check the writer cannot influence, so no separate verifier is required.",
268
+ });
269
+ }
270
+ // The on branch holds only while the native review reaches a terminal
271
+ // outcome for this candidate. Anything else -- declined, unavailable, or
272
+ // unknown -- falls back to the exact same risk-gated path as off.
273
+ return riskGatedPlan(
274
+ `receipt-driven development is on, but ${nonClosedOutcomeClause(outcome)}, so the risk-gated path applies exactly as receipt-driven development off, and`,
275
+ risk,
276
+ writerProfile,
277
+ );
278
+ }
279
+
280
+ // rddLine is "off" or "unknown" here -- both gate on the native risk
281
+ // assessment identically, so an unknown RDD line never lowers a tier, and
282
+ // neither line ever consults nativeReviewOutcome.
283
+ return riskGatedPlan(`receipt-driven development is ${rddLine} and`, risk, writerProfile);
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Small-writer-profile predicate (gentle-pi#662).
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * The minimal shape of `lib/agents-config.ts`'s `ResolvedProfile` this
292
+ * predicate needs: the resolved model reference and its resolved effort
293
+ * (`ResolvedProfile.thinking`, one of `ThinkingLevel`).
294
+ */
295
+
296
+
297
+
298
+
299
+
300
+ // Matches `mini` only as a whole token of the model id -- delimited by the
301
+ // string start/end or one of `-_./:` or whitespace -- never a bare substring.
302
+ // `gpt-5.4-mini`, `o4-mini`, `openai/gpt-5.4-mini`, `claude-mini`, and
303
+ // `mini-high` all match; `gemini`, `gemini-2.5-pro`, and `gemini-2.5-flash`
304
+ // do not, because their `mini` is preceded by `ge`, not a delimiter.
305
+ const SMALL_MODEL_ID_TOKEN = /(?:^|[-_./:\s])mini(?:$|[-_./:\s])/i;
306
+
307
+ /**
308
+ * A writer profile counts as a "small model" for the medium-risk bias when:
309
+ * - its resolved effort is `low`, or
310
+ * - its resolved model id carries `mini` as a whole token (see
311
+ * `SMALL_MODEL_ID_TOKEN`), or
312
+ * - it carries NO signal at all -- no profile object, or a profile with
313
+ * neither a model id nor a resolved effort. An unknown or omitted writer
314
+ * profile fails closed to small rather than defaulting to large, so a
315
+ * caller that cannot resolve the writer's profile never gets a weaker
316
+ * verification plan than a known small model would (gentle-pi#662).
317
+ *
318
+ * A profile that carries a model id or an effort, just not a matching one
319
+ * (for example `{model: {id: "claude-sonnet-5"}, thinking: "high"}`), is
320
+ * "known large" and returns `false` -- fail-closed applies only to a truly
321
+ * unknown/omitted profile, not to an explicitly resolved large one.
322
+ */
323
+ export function isSmallWriterProfile(profile ) {
324
+ if (profile === undefined) return true;
325
+ if (profile.thinking === "low") return true;
326
+ const modelId = profile.model?.id;
327
+ if (typeof modelId === "string") return SMALL_MODEL_ID_TOKEN.test(modelId);
328
+ // No model id was resolved. Fail closed to small only when effort is also
329
+ // unknown -- there is no signal at all to call this a known large model.
330
+ return profile.thinking === undefined;
331
+ }
332
+
333
+ /**
334
+ * Maps a resolved writer profile to the `writerProfile` input `verificationPlan`
335
+ * expects. An unknown or omitted profile never lowers a tier: it resolves to
336
+ * `"small"`, exactly like `isSmallWriterProfile` (gentle-pi#662).
337
+ */
338
+ export function resolveWriterProfile(profile ) {
339
+ return isSmallWriterProfile(profile) ? WRITER_PROFILE.SMALL : WRITER_PROFILE.LARGE;
340
+ }
@@ -0,0 +1,152 @@
1
+ // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+
4
+ // gentle-ai#4309 owns anonymous usage telemetry end to end: install and
5
+ // heartbeat events, the notice, every kill switch, and the wire contract.
6
+ // gentle-pi#677 only nudges the local binary once per process, best-effort,
7
+ // and never surfaces the outcome to the user. This module is the pure,
8
+ // testable core of that nudge (`shouldTriggerTelemetry`,
9
+ // `spawnTelemetryTrigger`) plus the decode helper the foreground
10
+ // `/gentle:telemetry` slash command uses to relay `<op> --json` output
11
+ // (`decodeTelemetryTriggerDecision`).
12
+
13
+ export const TELEMETRY_TRIGGER_KILL_TIMEOUT_MS = 3_000;
14
+
15
+ export const TELEMETRY_TRIGGER_CONTRACT = "gentle-ai.telemetry-trigger/v1";
16
+
17
+ export const TELEMETRY_TRIGGER_DECISIONS = [
18
+ "enrolled",
19
+ "sent_install",
20
+ "sent_heartbeat",
21
+ "rate_limited",
22
+ "backoff",
23
+ "disabled",
24
+ ] ;
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+ /**
34
+ * Decodes `gentle-ai telemetry trigger --json` stdout. An older binary
35
+ * without the verb prints `unknown telemetry command` and exits non-zero, so
36
+ * its stdout never parses as JSON here; that -- along with malformed JSON, a
37
+ * mismatched schema, an unrecognized decision, or a missing/non-string
38
+ * source -- means "nothing to do" and returns undefined rather than
39
+ * throwing.
40
+ */
41
+ export function decodeTelemetryTriggerDecision(stdout ) {
42
+ let parsed ;
43
+ try {
44
+ parsed = JSON.parse(stdout);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
49
+ const record = parsed ;
50
+ if (record.schema !== TELEMETRY_TRIGGER_CONTRACT) return undefined;
51
+ if (typeof record.decision !== "string") return undefined;
52
+ if (!(TELEMETRY_TRIGGER_DECISIONS ).includes(record.decision)) return undefined;
53
+ if (typeof record.source !== "string" || record.source.length === 0) return undefined;
54
+ return {
55
+ schema: TELEMETRY_TRIGGER_CONTRACT,
56
+ decision: record.decision ,
57
+ source: record.source,
58
+ };
59
+ }
60
+
61
+
62
+
63
+ function telemetryKillSwitchReason(env ) {
64
+ if (env.DO_NOT_TRACK === "1") return "do-not-track";
65
+ if (env.GENTLE_AI_TELEMETRY === "0") return "telemetry-disabled";
66
+ if (env.CI === "true") return "ci";
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * True unless one of Gentle Pi's own kill switches is set
72
+ * (`DO_NOT_TRACK=1`, `GENTLE_AI_TELEMETRY=0`, or `CI=true`). The binary
73
+ * enforces its own opt-out and rate limiting independently; this predicate
74
+ * only decides whether Gentle Pi spawns the trigger at all.
75
+ */
76
+ export function shouldTriggerTelemetry(env ) {
77
+ return telemetryKillSwitchReason(env) === undefined;
78
+ }
79
+
80
+ // A minimal structural subset of node:child_process's ChildProcess, mirroring
81
+ // the ChildLike/Spawn seam already used by lib/agents-runner.ts: narrow
82
+ // enough that tests can stub it without touching a real process.
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+
115
+
116
+ /**
117
+ * Nudges `<executable> telemetry trigger --json`, detached and
118
+ * fire-and-forget. Never throws, never awaits the child's exit, and never
119
+ * blocks session start: a missing binary, an old binary without the verb, or
120
+ * a spawn failure all just report `{ spawned: false, ... }`. A 3 s timer
121
+ * kills a runaway child; the timer is unref'd so it can never keep the host
122
+ * process alive on its own, matching the detached child's own `unref()`.
123
+ */
124
+ export function spawnTelemetryTrigger(options ) {
125
+ const killSwitch = telemetryKillSwitchReason(options.env);
126
+ if (killSwitch !== undefined) return { spawned: false, reason: killSwitch };
127
+ const spawnImpl = options.spawn ?? (nodeSpawn );
128
+ try {
129
+ const child = spawnImpl(options.executable, ["telemetry", "trigger", "--json"], {
130
+ cwd: options.cwd,
131
+ env: options.env,
132
+ detached: true,
133
+ stdio: "ignore",
134
+ });
135
+ child.on("error", () => {
136
+ // Fire-and-forget: a spawn-time or runtime child error never surfaces
137
+ // to the caller and never affects session start.
138
+ });
139
+ const killTimer = setTimeout(() => {
140
+ try {
141
+ child.kill();
142
+ } catch {
143
+ // Best effort only; the child may already have exited.
144
+ }
145
+ }, TELEMETRY_TRIGGER_KILL_TIMEOUT_MS);
146
+ killTimer.unref?.();
147
+ child.unref();
148
+ return { spawned: true, reason: "spawned" };
149
+ } catch {
150
+ return { spawned: false, reason: "spawn-error" };
151
+ }
152
+ }
@@ -10,7 +10,9 @@ const sources = [
10
10
  "gentle-ai-binary",
11
11
  "review-relay-contract",
12
12
  "review-integration-v2",
13
+ "review-risk-assessment",
13
14
  "native-review-cli",
15
+ "telemetry-trigger",
14
16
  ];
15
17
  const header = "// Generated by scripts/build-runtime-modules.mjs. Do not edit.\n";
16
18
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "2.6.0";
39
+ export const INSTALLER_VERSION = "2.7.0";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.6.0`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.7.0`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit c24a724a, the published v2.6.0 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:scGoZYnPHh4oCVqlcfQAOSpm22Ii5lmB0kBbiBKvNZk=";
50
+ // tag resolves to commit 4e4470b3, the published v2.7.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:SE4KCLo3y1qWaTq+7HJKZ1DkAAAl+w+82YY0+qQkFwM=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v2.6.0 digests are pinned from the published release:
70
+ // still holds it. The v2.7.0 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v2.6.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v2.7.0 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_2.6.0_darwin_amd64.tar.gz", "e09389a4fa2de6a1999cf44bb7850d1f8bc166f330010b64c20aaab32bc68cb7", "a142e166a7638b1d9920ec01be2417a0257c52074395ed829bf5b989199b3f03", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_2.6.0_darwin_arm64.tar.gz", "9999842e0ccb769101d51454b714f599535c551cde73411a1f72936ff635c13e", "ddc9ffb6aa64268f8de8ffe80056b58b97e3cf480dea849c38d97d7c2b2618f4", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_2.6.0_linux_amd64.tar.gz", "1dbf4e4ebc2b0d0e0f3f003a77ca2cf0fedc7b4d3e5f85939abeb877694630f0", "37e4ef32e869fa181b77346be160dfef35ff6fa40f26723d6dcd705ffb6d385d", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_2.6.0_linux_arm64.tar.gz", "9fc43679476486fb234c302c91f8e15fd7d878e43de1104c2ebdddd68baaa95b", "7ff61e615fe861fd1175bb9750b4c15c23dcdfeaa4a7ed0a96d5ec2bdf39e72f", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_2.7.0_darwin_amd64.tar.gz", "5edf1e38894d6b2c1d257583cf4da23e2471812ed2efd0de33cbeef608339609", "e7aa77701e3cfd8a382a756ff2f257ba1a94ab4699050b3c3ac8e6237eb53950", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_2.7.0_darwin_arm64.tar.gz", "a9efa8cf182d853b7d0115efbc73a83465d435ec429f6465bea9181ea8022fde", "497932a73664a958cf46b0b131277da4a41a48f8d7da4f777d1157f8ffa9f646", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_2.7.0_linux_amd64.tar.gz", "ff765a183247fe31d4739ff6df3d6874740979987ea310de3856c8a6e85375e9", "8748b9f18c05a831692abc0ae560fdc50a4e4c8f492705edcce7f65e067a7b12", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_2.7.0_linux_arm64.tar.gz", "d8e114cd4d226f6c974813a49fce4d3e2028f4880b87e08f270eadf0a0607561", "65dd6bd7249d5eb7a2c948eebd0aee1bf2e85e1a2123e9b3782e978b167c3875", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import assert from "node:assert/strict";
3
4
  import { execFileSync } from "node:child_process";
4
5
  import { createHash } from "node:crypto";
5
6
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
@@ -50,6 +51,27 @@ try {
50
51
  stdio: "inherit",
51
52
  });
52
53
  const packageRoot = join(installDirectory, "node_modules", "gentle-pi");
54
+ const { nativeReviewAbandonAuthorization } = await import(pathToFileURL(join(packageRoot, "runtime", "native-review-cli.mjs")).href);
55
+ const abandonAuthorization = nativeReviewAbandonAuthorization({
56
+ lineage: "review-abc",
57
+ expectedRevision: "revision-9",
58
+ snapshotIdentity: "snapshot-1",
59
+ capturedLensResults: ["00-risk.json", "01-refuter.json"],
60
+ findingsPresent: true,
61
+ actor: "maintainer",
62
+ reason: "operator_disposition",
63
+ });
64
+ assert.equal(abandonAuthorization, [
65
+ "gentle-ai.review-abandon-authorization/v2",
66
+ "lineage=review-abc",
67
+ "revision=revision-9",
68
+ "snapshot_identity=snapshot-1",
69
+ "reason=operator_disposition",
70
+ "captured_lens_results=00-risk.json,01-refuter.json",
71
+ "findings_present=true",
72
+ "actor=maintainer",
73
+ ].join("\n"));
74
+ assert.ok(!abandonAuthorization.includes("evidence_records_present"));
53
75
  // Accept prerelease pins too: a stable-only pattern here was a second,
54
76
  // silent pin that refused the first prerelease version directory.
55
77
  const versions = readdirSync(join(packageRoot, ".gentle-ai"), { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.]*)?$/.test(entry.name));
@@ -46,6 +46,7 @@ const requiredPaths = [
46
46
  "assets/support/sdd-status-contract.md",
47
47
  "assets/support/strict-tdd.md",
48
48
  "assets/support/strict-tdd-verify.md",
49
+ "docs/delegated-verification.md",
49
50
  "docs/native-authority-architecture.md",
50
51
  "docs/skill-style-guide.md",
51
52
  "docs/review-integration.md",
@@ -59,10 +60,13 @@ const requiredPaths = [
59
60
  "lib/review-integration-v2.ts",
60
61
  "lib/review-relay-contract.ts",
61
62
  "lib/sdd-preflight.ts",
63
+ "lib/telemetry-trigger.ts",
62
64
  "runtime/gentle-ai-binary.mjs",
63
65
  "runtime/native-review-cli.mjs",
64
66
  "runtime/review-integration-v2.mjs",
67
+ "runtime/review-risk-assessment.mjs",
65
68
  "runtime/review-relay-contract.mjs",
69
+ "runtime/telemetry-trigger.mjs",
66
70
  "scripts/check-provider-contract.mjs",
67
71
  "scripts/gentle-ai-installer.mjs",
68
72
  "scripts/install-gentle-ai.mjs",
@@ -334,7 +338,7 @@ async function main() {
334
338
  });
335
339
 
336
340
  if (driftedContracts.length > 0) {
337
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.6.0 runtime's vendored Gentle AI contract artifacts:");
341
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.7.0 runtime's vendored Gentle AI contract artifacts:");
338
342
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
339
343
  process.exit(1);
340
344
  }
@@ -379,7 +383,7 @@ async function main() {
379
383
  process.exit(1);
380
384
  }
381
385
 
382
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.6.0 runtime).`);
386
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.7.0 runtime).`);
383
387
  }
384
388
 
385
389
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -20,7 +20,9 @@ Risk routing is deterministic:
20
20
 
21
21
  Generated files matching `testdata/golden/**` remain in snapshot identity but do not count as authored risk lines. Ordinary tests, fixtures, and snapshots are never broadly excluded. The correction budget is frozen as `min(200, ceil(original_changed_lines / 2))`.
22
22
 
23
- Before status/START, consult effective review mode. `off` creates no authority or authorization and yields organic `disabled/unmanaged`, never approval. Ordinary START declares `--consent relay`; low risk stays silent. A medium/high `consent/v2` result always returns the complete raw provider envelope plus an opaque in-memory candidate binding to the parent, then stops without UI or provider follow-up. The parent localizes and presents it losslessly while preserving tokens, commands, target IDs, and invocations. One explicit `answer-consent` call accepts only that binding and `granted|declined`, consumes it before provider mutation, and rechecks repository/target/projection/lineage/answer binding. Ambiguity reconciles through STATUS, never replay. Grant is exact-candidate-only. Decline creates no lineage, authority, actor/candidate binding, latch, or pending authorization; the next candidate asks again. Old Pi clone latch files are inert.
23
+ Before status/START, consult effective review mode. `off` creates no authority or authorization and yields organic `disabled/unmanaged`, never approval. Ordinary START declares `--consent relay`; low risk stays silent. A medium/high `consent/v3` result carries the complete raw two-choice provider envelope plus an opaque in-memory candidate binding. In an eligible interactive parent Pi session, the host displays both provider labels and effects unchanged plus one clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. The first two actions stay candidate-only. Direct human selection of the third action executes that fresh envelope's exact existing `granted` invocation through `answer-consent`, then grants only future fresh validated envelopes for the same live SessionManager object, exact nonempty session ID, and canonical Git worktree root. Every envelope is consumed once before provider mutation and rechecks repository, target, projection, lineage, answer binding, and live session identity; ambiguity reconciles through STATUS and never replays.
24
+
25
+ The host grant lives only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry. Reload preserves the SessionManager and reconnects the grant; quit, new, resume, fork, explicit revoke, or process restart removes it. `/tree` retains it. Child-agent processes, headless/RPC/unsupported UI, explicit cross-repository `workspaceRoot`, model prose, and tool arguments cannot offer, create, or consume it. It grants no provider mode, verdict, cost forecast, acknowledgement, maintenance, delivery, or cross-repository authority. If host UI is cancelled, fails, or cannot prove current Git/session identity, the original unresolved provider envelope is returned unchanged. If that envelope reaches the parent, localize and present its original two choices losslessly; never append the host action to the decoded provider contract. Decline creates no lineage, authority, standing grant, latch, or pending authorization; the next candidate asks again. Old Pi clone latch files are inert, and Pi writes no asked latch.
24
26
 
25
27
  Reviewer, refuter, and validator verdicts are admitted natively, never Pi-authored. `finalize` follows the provider's negotiated `next_transition` and supplies only the negotiated collection answers: a lens `review.capture-result` collect input rendered with `--agent=pi --materialize=true` is satisfied by the gentle-pi host relay, which prints the exact Go-materialized opaque prompt, launches a fresh locked-down print-mode `pi` subprocess in an empty scratch directory with every discovery surface disabled, and submits the untouched raw output bytes through the provider-owned submission form. The adversarial roles do not go through that relay: `review.capture-refuter` and `review.capture-validation` collect inputs render as self-contained authority-advancing vectors (binding tokens plus `--agent=pi --execute=true`, no submission descriptor); executing the exact rendered invocation makes Go materialize the role prompt, spawn its own locked-down `pi` process, and admit the raw verdict. Native Go owns validation, canonicalization, missing lens/finding ID assignment, persistence, and hashing, and performs only the legal transition from the current compact state. The five states are `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`.
26
28