gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,339 @@
1
+ // gentle-pi#662: pure decoding, tier mapping, and verification-plan logic for
2
+ // the native read-only `gentle-ai review assess` command (gentle-ai#4295).
3
+ // Kept independent of the native review CLI wrapper (`lib/native-review-cli.ts`)
4
+ // so the decoder and the verificationPlan table can be unit-tested without a
5
+ // child process, and so the parent tool and the delegation asset agree on one
6
+ // deterministic mapping.
7
+ //
8
+ // Native reference (gentle-ai#4295, landing in parallel with this change):
9
+ // `gentle-ai review assess --cwd <repo> [--base-ref <ref> --committed-only] --json`
10
+ // prints `{"schema":"gentle-ai.review-assessment/v1","risk":"passive|medium|high",
11
+ // "reasons":[{"code":"…","path":"…","detail":"…"}],"changed_paths":1,
12
+ // "changed_lines":1,"candidate":{"kind":"current-changes|base-diff","base_ref":"…"}}`.
13
+ // A non-zero exit or a failure envelope means the candidate could not be
14
+ // assessed; hosts treat that as `high`. Older binaries without the verb (or
15
+ // any other decode/process failure) fail closed the same way -- see
16
+ // `verificationTierForUnassessable` below.
17
+
18
+ export const REVIEW_ASSESSMENT_SCHEMA = "gentle-ai.review-assessment/v1" as const;
19
+
20
+ export const REVIEW_ASSESSMENT_RISK = {
21
+ PASSIVE: "passive",
22
+ MEDIUM: "medium",
23
+ HIGH: "high",
24
+ } as const;
25
+ export type ReviewAssessmentRisk = (typeof REVIEW_ASSESSMENT_RISK)[keyof typeof REVIEW_ASSESSMENT_RISK];
26
+
27
+ export const REVIEW_ASSESSMENT_CANDIDATE_KIND = {
28
+ CURRENT_CHANGES: "current-changes",
29
+ BASE_DIFF: "base-diff",
30
+ } as const;
31
+ export type ReviewAssessmentCandidateKind = (typeof REVIEW_ASSESSMENT_CANDIDATE_KIND)[keyof typeof REVIEW_ASSESSMENT_CANDIDATE_KIND];
32
+
33
+ export interface ReviewAssessmentReason {
34
+ readonly code: string;
35
+ readonly path: string;
36
+ readonly detail: string;
37
+ }
38
+
39
+ export interface ReviewAssessmentCandidate {
40
+ readonly kind: ReviewAssessmentCandidateKind;
41
+ readonly baseRef: string | undefined;
42
+ }
43
+
44
+ export interface ReviewAssessmentV1 {
45
+ readonly schema: typeof REVIEW_ASSESSMENT_SCHEMA;
46
+ readonly risk: ReviewAssessmentRisk;
47
+ readonly reasons: readonly ReviewAssessmentReason[];
48
+ readonly changedPaths: number;
49
+ readonly changedLines: number;
50
+ readonly candidate: ReviewAssessmentCandidate;
51
+ }
52
+
53
+ function isRecord(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === "object" && value !== null && !Array.isArray(value);
55
+ }
56
+
57
+ function decodeReason(value: unknown): ReviewAssessmentReason {
58
+ if (!isRecord(value)) throw new TypeError("review assessment reason must be an object");
59
+ const { code, path, detail } = value;
60
+ if (typeof code !== "string" || code.length === 0) throw new TypeError("review assessment reason.code must be a non-empty string");
61
+ if (typeof path !== "string") throw new TypeError("review assessment reason.path must be a string");
62
+ if (typeof detail !== "string") throw new TypeError("review assessment reason.detail must be a string");
63
+ return Object.freeze({ code, path, detail });
64
+ }
65
+
66
+ function decodeCandidate(value: unknown): ReviewAssessmentCandidate {
67
+ if (!isRecord(value)) throw new TypeError("review assessment candidate must be an object");
68
+ const { kind, base_ref: baseRef } = value;
69
+ if (kind !== REVIEW_ASSESSMENT_CANDIDATE_KIND.CURRENT_CHANGES && kind !== REVIEW_ASSESSMENT_CANDIDATE_KIND.BASE_DIFF) {
70
+ throw new TypeError("review assessment candidate.kind must be current-changes or base-diff");
71
+ }
72
+ if (baseRef !== undefined && (typeof baseRef !== "string" || baseRef.length === 0)) {
73
+ throw new TypeError("review assessment candidate.base_ref must be a non-empty string when present");
74
+ }
75
+ return Object.freeze({ kind, baseRef: baseRef as string | undefined });
76
+ }
77
+
78
+ /**
79
+ * Decodes and validates one `gentle-ai.review-assessment/v1` envelope. Rejects
80
+ * any other schema, an unrecognized risk value, or a malformed shape -- the
81
+ * native review CLI wrapper turns a thrown error here into the same
82
+ * schema-incompatible failure it already produces for every other decoded
83
+ * native response.
84
+ */
85
+ export function decodeReviewAssessmentV1(value: unknown): ReviewAssessmentV1 {
86
+ if (!isRecord(value)) throw new TypeError("review assessment must be an object");
87
+ if (value.schema !== REVIEW_ASSESSMENT_SCHEMA) throw new TypeError(`review assessment schema must be ${REVIEW_ASSESSMENT_SCHEMA}`);
88
+ const { risk, reasons, changed_paths: changedPaths, changed_lines: changedLines, candidate } = value;
89
+ if (risk !== REVIEW_ASSESSMENT_RISK.PASSIVE && risk !== REVIEW_ASSESSMENT_RISK.MEDIUM && risk !== REVIEW_ASSESSMENT_RISK.HIGH) {
90
+ throw new TypeError("review assessment risk must be passive, medium, or high");
91
+ }
92
+ if (!Array.isArray(reasons)) throw new TypeError("review assessment reasons must be an array");
93
+ if (!Number.isSafeInteger(changedPaths) || (changedPaths as number) < 0) throw new TypeError("review assessment changed_paths must be a non-negative integer");
94
+ if (!Number.isSafeInteger(changedLines) || (changedLines as number) < 0) throw new TypeError("review assessment changed_lines must be a non-negative integer");
95
+ return Object.freeze({
96
+ schema: REVIEW_ASSESSMENT_SCHEMA,
97
+ risk,
98
+ reasons: Object.freeze(reasons.map(decodeReason)),
99
+ changedPaths: changedPaths as number,
100
+ changedLines: changedLines as number,
101
+ candidate: decodeCandidate(candidate),
102
+ });
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Verification-plan tiering (gentle-pi#662).
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export const VERIFICATION_TIER = {
110
+ PASSIVE: "passive",
111
+ MEDIUM: "medium",
112
+ HIGH: "high",
113
+ UNASSESSABLE: "unassessable",
114
+ } as const;
115
+ export type VerificationTier = (typeof VERIFICATION_TIER)[keyof typeof VERIFICATION_TIER];
116
+
117
+ export const RDD_LINE = {
118
+ ON: "on",
119
+ OFF: "off",
120
+ UNKNOWN: "unknown",
121
+ } as const;
122
+ export type RddLine = (typeof RDD_LINE)[keyof typeof RDD_LINE];
123
+
124
+ export const WRITER_PROFILE = {
125
+ SMALL: "small",
126
+ LARGE: "large",
127
+ } as const;
128
+ export type WriterProfile = (typeof WRITER_PROFILE)[keyof typeof WRITER_PROFILE];
129
+
130
+ // gentle-pi#668: whether the native review actually reached a terminal
131
+ // outcome for the current candidate. The `on` branch of `verificationPlan`
132
+ // (the writer self-verifies, the native review is the independent check, no
133
+ // separate verifier) holds only for `CLOSED` -- a human decline for this
134
+ // candidate, a clone-local RDD disable, a refused START/STATUS, or any other
135
+ // non-terminal outcome falls back to the exact same risk-gated path as `off`.
136
+ // `UNKNOWN` is the fail-closed default for an omitted outcome: never treated
137
+ // as `CLOSED`.
138
+ export const NATIVE_REVIEW_OUTCOME = {
139
+ CLOSED: "closed",
140
+ DECLINED: "declined",
141
+ UNAVAILABLE: "unavailable",
142
+ UNKNOWN: "unknown",
143
+ } as const;
144
+ export type NativeReviewOutcome = (typeof NATIVE_REVIEW_OUTCOME)[keyof typeof NATIVE_REVIEW_OUTCOME];
145
+
146
+ export interface VerificationPlanInput {
147
+ readonly rddLine: RddLine;
148
+ readonly risk: VerificationTier;
149
+ readonly writerProfile: WriterProfile;
150
+ /**
151
+ * Native review outcome for the current candidate. Only consulted when
152
+ * `rddLine` is `"on"`; `"off"`/`"unknown"` lines ignore it entirely.
153
+ * Omitted resolves to `NATIVE_REVIEW_OUTCOME.UNKNOWN` (gentle-pi#668).
154
+ */
155
+ readonly nativeReviewOutcome?: NativeReviewOutcome;
156
+ }
157
+
158
+ export interface VerificationPlan {
159
+ readonly writerSelfVerification: boolean;
160
+ readonly structuralReadbackOnly: boolean;
161
+ readonly independentVerifier: boolean;
162
+ readonly reason: string;
163
+ }
164
+
165
+ /**
166
+ * Builds the risk-gated verification plan shared by the `off`/`unknown` RDD
167
+ * lines and by an `on` line whose native review did not close for this
168
+ * candidate (gentle-pi#668). `gatePrefix` supplies the sentence fragment
169
+ * naming which branch fired, up to and including the word "and"; the
170
+ * tier-specific clause is appended after it.
171
+ */
172
+ function riskGatedPlan(gatePrefix: string, risk: VerificationTier, writerProfile: WriterProfile): VerificationPlan {
173
+ if (risk === VERIFICATION_TIER.PASSIVE) {
174
+ return Object.freeze({
175
+ writerSelfVerification: false,
176
+ structuralReadbackOnly: true,
177
+ independentVerifier: false,
178
+ 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.`,
179
+ });
180
+ }
181
+
182
+ if (risk === VERIFICATION_TIER.MEDIUM) {
183
+ const smallModelBias = writerProfile === WRITER_PROFILE.SMALL;
184
+ return Object.freeze({
185
+ writerSelfVerification: true,
186
+ structuralReadbackOnly: false,
187
+ independentVerifier: smallModelBias,
188
+ reason: smallModelBias
189
+ ? `${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.`
190
+ : `${gatePrefix} the native assessment reports medium risk with a large writer profile: the writer's self-verification stands and no separate verifier is required.`,
191
+ });
192
+ }
193
+
194
+ // high or unassessable.
195
+ return Object.freeze({
196
+ writerSelfVerification: true,
197
+ structuralReadbackOnly: false,
198
+ independentVerifier: true,
199
+ reason: risk === VERIFICATION_TIER.UNASSESSABLE
200
+ ? `${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.`
201
+ : `${gatePrefix} the native assessment reports high risk: the writer self-verifies and a separate independent verifier always runs.`,
202
+ });
203
+ }
204
+
205
+ // Names why an `on` line falls back to the risk-gated path, for every
206
+ // non-closed native review outcome (gentle-pi#668). `UNKNOWN` covers both an
207
+ // omitted outcome and a caller-supplied one this decoder does not
208
+ // recognize -- both fail closed the same way.
209
+ function nonClosedOutcomeClause(outcome: NativeReviewOutcome): string {
210
+ switch (outcome) {
211
+ case NATIVE_REVIEW_OUTCOME.DECLINED:
212
+ return "the native review was declined for this candidate";
213
+ case NATIVE_REVIEW_OUTCOME.UNAVAILABLE:
214
+ return "the native review is unavailable for this candidate";
215
+ default:
216
+ return "the native review outcome for this candidate is unknown";
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Computes who verifies a delegated writer's change, exactly as
222
+ * gentle-pi#662/#668 specify:
223
+ *
224
+ * - `rdd: "on"` AND the native review reached `NATIVE_REVIEW_OUTCOME.CLOSED`
225
+ * for this candidate: the writer self-verifies and no independent verifier
226
+ * runs, because the closed native review is the check -- except `passive`,
227
+ * which gets a structural readback by the parent instead.
228
+ * - `rdd: "on"` with any other outcome (`declined`, `unavailable`, or the
229
+ * fail-closed `unknown` default for an omitted/unrecognized outcome): the
230
+ * `on` branch never held for this candidate, so the risk-gated path below
231
+ * applies exactly as `off` -- declining a review is candidate-scoped and
232
+ * never lowers the bar below the RDD-off path.
233
+ * - `rdd: "off"` or `"unknown"` (both gate identically, so an unknown RDD line
234
+ * never lowers a tier relative to `off`; `nativeReviewOutcome` is ignored
235
+ * entirely on these lines):
236
+ * - `passive`: structural readback by the parent only; no verifier, no
237
+ * tests.
238
+ * - `medium`: the writer self-verifies; a separate independent verifier
239
+ * runs only when `writerProfile` is `"small"` -- the small-model bias
240
+ * raises the tier by one for verification purposes (medium -> high). An
241
+ * unknown or omitted writer profile resolves to `"small"` before it
242
+ * reaches this function (see `resolveWriterProfile`), so an unrecognized
243
+ * profile never lowers a tier below what a known small model would get.
244
+ * - `high` or `unassessable` (a failed or unrecognized native assessment is
245
+ * always treated as high): the writer self-verifies and an independent
246
+ * verifier always runs.
247
+ */
248
+ export function verificationPlan(input: VerificationPlanInput): VerificationPlan {
249
+ const { rddLine, risk, writerProfile } = input;
250
+
251
+ if (rddLine === RDD_LINE.ON) {
252
+ const outcome = input.nativeReviewOutcome ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
253
+ if (outcome === NATIVE_REVIEW_OUTCOME.CLOSED) {
254
+ if (risk === VERIFICATION_TIER.PASSIVE) {
255
+ return Object.freeze({
256
+ writerSelfVerification: false,
257
+ structuralReadbackOnly: true,
258
+ independentVerifier: false,
259
+ 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.",
260
+ });
261
+ }
262
+ return Object.freeze({
263
+ writerSelfVerification: true,
264
+ structuralReadbackOnly: false,
265
+ independentVerifier: false,
266
+ 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.",
267
+ });
268
+ }
269
+ // The on branch holds only while the native review reaches a terminal
270
+ // outcome for this candidate. Anything else -- declined, unavailable, or
271
+ // unknown -- falls back to the exact same risk-gated path as off.
272
+ return riskGatedPlan(
273
+ `receipt-driven development is on, but ${nonClosedOutcomeClause(outcome)}, so the risk-gated path applies exactly as receipt-driven development off, and`,
274
+ risk,
275
+ writerProfile,
276
+ );
277
+ }
278
+
279
+ // rddLine is "off" or "unknown" here -- both gate on the native risk
280
+ // assessment identically, so an unknown RDD line never lowers a tier, and
281
+ // neither line ever consults nativeReviewOutcome.
282
+ return riskGatedPlan(`receipt-driven development is ${rddLine} and`, risk, writerProfile);
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Small-writer-profile predicate (gentle-pi#662).
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * The minimal shape of `lib/agents-config.ts`'s `ResolvedProfile` this
291
+ * predicate needs: the resolved model reference and its resolved effort
292
+ * (`ResolvedProfile.thinking`, one of `ThinkingLevel`).
293
+ */
294
+ export interface WriterModelProfileLike {
295
+ readonly model?: { readonly id: string } | undefined;
296
+ readonly thinking?: string | undefined;
297
+ }
298
+
299
+ // Matches `mini` only as a whole token of the model id -- delimited by the
300
+ // string start/end or one of `-_./:` or whitespace -- never a bare substring.
301
+ // `gpt-5.4-mini`, `o4-mini`, `openai/gpt-5.4-mini`, `claude-mini`, and
302
+ // `mini-high` all match; `gemini`, `gemini-2.5-pro`, and `gemini-2.5-flash`
303
+ // do not, because their `mini` is preceded by `ge`, not a delimiter.
304
+ const SMALL_MODEL_ID_TOKEN = /(?:^|[-_./:\s])mini(?:$|[-_./:\s])/i;
305
+
306
+ /**
307
+ * A writer profile counts as a "small model" for the medium-risk bias when:
308
+ * - its resolved effort is `low`, or
309
+ * - its resolved model id carries `mini` as a whole token (see
310
+ * `SMALL_MODEL_ID_TOKEN`), or
311
+ * - it carries NO signal at all -- no profile object, or a profile with
312
+ * neither a model id nor a resolved effort. An unknown or omitted writer
313
+ * profile fails closed to small rather than defaulting to large, so a
314
+ * caller that cannot resolve the writer's profile never gets a weaker
315
+ * verification plan than a known small model would (gentle-pi#662).
316
+ *
317
+ * A profile that carries a model id or an effort, just not a matching one
318
+ * (for example `{model: {id: "claude-sonnet-5"}, thinking: "high"}`), is
319
+ * "known large" and returns `false` -- fail-closed applies only to a truly
320
+ * unknown/omitted profile, not to an explicitly resolved large one.
321
+ */
322
+ export function isSmallWriterProfile(profile: WriterModelProfileLike | undefined): boolean {
323
+ if (profile === undefined) return true;
324
+ if (profile.thinking === "low") return true;
325
+ const modelId = profile.model?.id;
326
+ if (typeof modelId === "string") return SMALL_MODEL_ID_TOKEN.test(modelId);
327
+ // No model id was resolved. Fail closed to small only when effort is also
328
+ // unknown -- there is no signal at all to call this a known large model.
329
+ return profile.thinking === undefined;
330
+ }
331
+
332
+ /**
333
+ * Maps a resolved writer profile to the `writerProfile` input `verificationPlan`
334
+ * expects. An unknown or omitted profile never lowers a tier: it resolves to
335
+ * `"small"`, exactly like `isSmallWriterProfile` (gentle-pi#662).
336
+ */
337
+ export function resolveWriterProfile(profile: WriterModelProfileLike | undefined): WriterProfile {
338
+ return isSmallWriterProfile(profile) ? WRITER_PROFILE.SMALL : WRITER_PROFILE.LARGE;
339
+ }
@@ -0,0 +1,309 @@
1
+ import { Socket } from "node:net";
2
+ import type { Readable, Writable } from "node:stream";
3
+
4
+ // This is a deliberately tiny parent-owned channel for package-launched Pi
5
+ // children. It carries only a one-shot authorization question and answer; no
6
+ // review candidate, consent binding, provider vector, or session authority is
7
+ // serialized across the process boundary.
8
+
9
+ const REQUEST_TYPE = "standing-review-permission-request";
10
+ const RESPONSE_TYPE = "standing-review-permission-response";
11
+ const MAX_LINE_BYTES = 4096;
12
+ const DEFAULT_MAX_REQUESTS = 16;
13
+ export const CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SCHEMA = "gentle-pi.child-standing-review-permission/v1";
14
+ export const CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL = Symbol.for(CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SCHEMA);
15
+ export const CHILD_STANDING_REVIEW_PERMISSION_CLIENT_SCHEMA = "gentle-pi.child-standing-review-permission-client/v1";
16
+
17
+ export interface ChildStandingReviewPermissionClientHandle {
18
+ readonly schema: typeof CHILD_STANDING_REVIEW_PERMISSION_CLIENT_SCHEMA;
19
+ requestAuthorization(repositoryIdentity: string): Promise<boolean>;
20
+ close(): void;
21
+ }
22
+
23
+ interface ChildPermissionRegistry {
24
+ readonly schema: typeof CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SCHEMA;
25
+ client: ChildStandingReviewPermissionClientHandle | undefined;
26
+ owner: symbol | undefined;
27
+ terminal: boolean;
28
+ }
29
+
30
+ type GlobalRegistry = Record<symbol, unknown>;
31
+
32
+ function isChildPermissionClient(value: unknown): value is ChildStandingReviewPermissionClientHandle {
33
+ return typeof value === "object" && value !== null &&
34
+ (value as { schema?: unknown }).schema === CHILD_STANDING_REVIEW_PERMISSION_CLIENT_SCHEMA &&
35
+ typeof (value as { requestAuthorization?: unknown }).requestAuthorization === "function" &&
36
+ typeof (value as { close?: unknown }).close === "function";
37
+ }
38
+
39
+ function childPermissionRegistry(): ChildPermissionRegistry | undefined {
40
+ const globalRegistry = globalThis as GlobalRegistry;
41
+ const current = globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL];
42
+ if (current === undefined) {
43
+ const created: ChildPermissionRegistry = { schema: CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SCHEMA, client: undefined, owner: undefined, terminal: false };
44
+ globalRegistry[CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SYMBOL] = created;
45
+ return created;
46
+ }
47
+ if (
48
+ typeof current !== "object" || current === null ||
49
+ (current as { schema?: unknown }).schema !== CHILD_STANDING_REVIEW_PERMISSION_REGISTRY_SCHEMA ||
50
+ typeof (current as { terminal?: unknown }).terminal !== "boolean" ||
51
+ !((current as { client?: unknown }).client === undefined || isChildPermissionClient((current as { client?: unknown }).client)) ||
52
+ !((current as { owner?: unknown }).owner === undefined || typeof (current as { owner?: unknown }).owner === "symbol")
53
+ ) return undefined;
54
+ return current as ChildPermissionRegistry;
55
+ }
56
+
57
+ export interface StandingReviewPermissionChannel {
58
+ readonly readable: Readable;
59
+ readonly writable: Writable;
60
+ }
61
+
62
+ interface Request {
63
+ type: typeof REQUEST_TYPE;
64
+ id: string;
65
+ repositoryIdentity: string;
66
+ }
67
+
68
+ interface Response {
69
+ type: typeof RESPONSE_TYPE;
70
+ id: string;
71
+ granted: boolean;
72
+ }
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === "object" && value !== null;
76
+ }
77
+
78
+ function request(value: unknown): Request | undefined {
79
+ if (
80
+ !isRecord(value) ||
81
+ Object.keys(value).length !== 3 ||
82
+ value.type !== REQUEST_TYPE ||
83
+ typeof value.id !== "string" || value.id.length === 0 || value.id.length > 128 ||
84
+ typeof value.repositoryIdentity !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.repositoryIdentity)
85
+ ) return undefined;
86
+ return { type: REQUEST_TYPE, id: value.id, repositoryIdentity: value.repositoryIdentity };
87
+ }
88
+
89
+ function response(value: unknown): Response | undefined {
90
+ if (!isRecord(value) || value.type !== RESPONSE_TYPE || typeof value.id !== "string" || typeof value.granted !== "boolean") return undefined;
91
+ return { type: RESPONSE_TYPE, id: value.id, granted: value.granted };
92
+ }
93
+
94
+ function attachJsonLines(stream: Readable, onValue: (value: unknown) => void): () => void {
95
+ let buffer = "";
96
+ const onData = (chunk: Buffer | string) => {
97
+ buffer += chunk.toString();
98
+ if (buffer.length > MAX_LINE_BYTES) {
99
+ buffer = "";
100
+ return;
101
+ }
102
+ const lines = buffer.split("\n");
103
+ buffer = lines.pop() ?? "";
104
+ for (const raw of lines) {
105
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
106
+ if (line.length === 0 || Buffer.byteLength(line) > MAX_LINE_BYTES) continue;
107
+ try { onValue(JSON.parse(line)); } catch { /* Malformed child traffic is denied by silence. */ }
108
+ }
109
+ };
110
+ stream.on("data", onData);
111
+ return () => stream.off("data", onData);
112
+ }
113
+
114
+ function write(channel: Writable, value: Response | Request): boolean {
115
+ try {
116
+ return channel.write(`${JSON.stringify(value)}\n`);
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The parent binds this broker to one live AgentRunner task. Every request
124
+ * calls `authorize` again, so replacement, revocation, cancellation, and a
125
+ * dead parent session fail closed without trusting child environment state.
126
+ */
127
+ export class ParentStandingReviewPermissionBroker {
128
+ private closed = false;
129
+ private acceptedRequests = 0;
130
+ private readonly channel: StandingReviewPermissionChannel;
131
+ private readonly detach: () => void;
132
+ private readonly authorize: (repositoryIdentity: string) => Promise<boolean> | boolean;
133
+ private readonly maxRequests: number;
134
+ private readonly onChannelTerminal = () => this.close();
135
+
136
+ constructor(channel: StandingReviewPermissionChannel, authorize: (repositoryIdentity: string) => Promise<boolean> | boolean, options: { maxRequests?: number } = {}) {
137
+ this.channel = channel;
138
+ this.authorize = authorize;
139
+ this.maxRequests = Number.isSafeInteger(options.maxRequests) && options.maxRequests! > 0 ? options.maxRequests! : DEFAULT_MAX_REQUESTS;
140
+ // These listeners deliberately outlive detach(): late pipe errors can arrive
141
+ // after close and must stay confined to this task's broker.
142
+ channel.readable.on("error", this.onChannelTerminal);
143
+ channel.readable.on("close", this.onChannelTerminal);
144
+ channel.readable.on("end", this.onChannelTerminal);
145
+ channel.writable.on("error", this.onChannelTerminal);
146
+ channel.writable.on("close", this.onChannelTerminal);
147
+ this.detach = attachJsonLines(channel.readable, (value) => {
148
+ const incoming = request(value);
149
+ if (incoming === undefined || this.closed) return;
150
+ if (this.acceptedRequests >= this.maxRequests) {
151
+ this.respond({ type: RESPONSE_TYPE, id: incoming.id, granted: false });
152
+ return;
153
+ }
154
+ this.acceptedRequests += 1;
155
+ void Promise.resolve(this.authorize(incoming.repositoryIdentity))
156
+ .then((granted) => {
157
+ this.respond({ type: RESPONSE_TYPE, id: incoming.id, granted: granted === true });
158
+ })
159
+ .catch(() => {
160
+ this.respond({ type: RESPONSE_TYPE, id: incoming.id, granted: false });
161
+ });
162
+ });
163
+ }
164
+
165
+ close(): void {
166
+ if (this.closed) return;
167
+ this.closed = true;
168
+ this.detach();
169
+ }
170
+
171
+ private respond(value: Response): void {
172
+ if (this.closed || this.channel.writable.destroyed || this.channel.writable.writableEnded) return;
173
+ try {
174
+ this.channel.writable.write(`${JSON.stringify(value)}\n`, (error) => {
175
+ if (error !== undefined && error !== null) this.close();
176
+ });
177
+ } catch {
178
+ this.close();
179
+ }
180
+ }
181
+ }
182
+
183
+ function configuredChildPermissionRegistry(processEnv: NodeJS.ProcessEnv): ChildPermissionRegistry | undefined {
184
+ if (processEnv.GENTLE_PI_AGENTS_CHILD !== "1" || processEnv.GENTLE_PI_AGENTS_PARENT_PERMISSION_FD !== "3") return undefined;
185
+ return childPermissionRegistry();
186
+ }
187
+
188
+ function registryClient(state: ChildPermissionRegistry): ChildStandingReviewPermissionClientHandle | undefined {
189
+ if (state.terminal) return undefined;
190
+ if (state.client !== undefined) return state.client;
191
+ try {
192
+ // Node exposes stdin/out/err but not a process.stdio array. The package
193
+ // runner creates fd 3 as an inherited duplex pipe; wrap it once for this
194
+ // process and let reload closures share the same reader and request state.
195
+ const stream = new Socket({ fd: 3, readable: true, writable: true });
196
+ let record: ChildStandingReviewPermissionClientHandle | undefined;
197
+ const client = new ChildStandingReviewPermissionClient({ readable: stream, writable: stream }, {}, () => {
198
+ state.terminal = true;
199
+ if (state.client === record) state.client = undefined;
200
+ });
201
+ record = {
202
+ schema: CHILD_STANDING_REVIEW_PERMISSION_CLIENT_SCHEMA,
203
+ requestAuthorization: (repositoryIdentity) => client.requestAuthorization(repositoryIdentity),
204
+ close: () => client.close(),
205
+ };
206
+ state.client = record;
207
+ return record;
208
+ } catch {
209
+ // An absent or incompatible inherited descriptor is never authorization.
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ /** The child can ask only whether its parent currently permits a replay. */
215
+ export function createChildStandingReviewPermissionClient(
216
+ processEnv: NodeJS.ProcessEnv = process.env,
217
+ ): ChildStandingReviewPermissionClientHandle | undefined {
218
+ const state = configuredChildPermissionRegistry(processEnv);
219
+ return state === undefined ? undefined : registryClient(state);
220
+ }
221
+
222
+ export interface ChildStandingReviewPermissionLease {
223
+ readonly client: ChildStandingReviewPermissionClientHandle | undefined;
224
+ closeIfCurrent(): void;
225
+ }
226
+
227
+ /** A replacement extension becomes the sole terminal owner of the shared fd3 client. */
228
+ export function acquireChildStandingReviewPermissionClient(
229
+ processEnv: NodeJS.ProcessEnv = process.env,
230
+ ): ChildStandingReviewPermissionLease | undefined {
231
+ const state = configuredChildPermissionRegistry(processEnv);
232
+ if (state === undefined) return undefined;
233
+ const client = registryClient(state);
234
+ if (client === undefined) return { client: undefined, closeIfCurrent() {} };
235
+ const owner = Symbol("child-standing-review-permission-owner");
236
+ state.owner = owner;
237
+ return {
238
+ client,
239
+ closeIfCurrent() {
240
+ if (state.owner !== owner) return;
241
+ state.owner = undefined;
242
+ client.close();
243
+ },
244
+ };
245
+ }
246
+
247
+ export class ChildStandingReviewPermissionClient {
248
+ private closed = false;
249
+ private nextId = 0;
250
+ private pending: { id: string; resolve: (granted: boolean) => void; cancel: () => void } | undefined;
251
+ private readonly detach: () => void;
252
+ private readonly onClosed = () => {
253
+ this.closed = true;
254
+ this.failPending();
255
+ this.onTerminal();
256
+ };
257
+ private readonly channel: StandingReviewPermissionChannel;
258
+ private readonly options: { timeoutMs?: number };
259
+ private readonly onTerminal: () => void;
260
+
261
+ constructor(channel: StandingReviewPermissionChannel, options: { timeoutMs?: number } = {}, onTerminal: () => void = () => {}) {
262
+ this.channel = channel;
263
+ this.options = options;
264
+ this.onTerminal = onTerminal;
265
+ this.detach = attachJsonLines(channel.readable, (value) => {
266
+ const incoming = response(value);
267
+ const pending = this.pending;
268
+ if (incoming === undefined || pending === undefined || incoming.id !== pending.id) return;
269
+ this.pending = undefined;
270
+ pending.cancel();
271
+ pending.resolve(incoming.granted);
272
+ });
273
+ channel.readable.once("close", this.onClosed);
274
+ channel.readable.once("end", this.onClosed);
275
+ channel.readable.once("error", this.onClosed);
276
+ }
277
+
278
+ requestAuthorization(repositoryIdentity: string): Promise<boolean> {
279
+ if (this.closed || this.pending !== undefined || !/^sha256:[0-9a-f]{64}$/.test(repositoryIdentity)) return Promise.resolve(false);
280
+ this.nextId += 1;
281
+ const id = `p${this.nextId}`;
282
+ return new Promise((resolve) => {
283
+ const timeout = setTimeout(() => this.failPending(), this.options.timeoutMs ?? 5_000);
284
+ timeout.unref?.();
285
+ this.pending = { id, resolve, cancel: () => clearTimeout(timeout) };
286
+ if (!write(this.channel.writable, { type: REQUEST_TYPE, id, repositoryIdentity })) this.failPending();
287
+ });
288
+ }
289
+
290
+ close(): void {
291
+ if (this.closed) return;
292
+ this.closed = true;
293
+ this.detach();
294
+ this.channel.readable.off("close", this.onClosed);
295
+ this.channel.readable.off("end", this.onClosed);
296
+ this.channel.readable.off("error", this.onClosed);
297
+ this.failPending();
298
+ this.onTerminal();
299
+ try { this.channel.readable.destroy(); } catch { /* Closing an unavailable child fd is best effort. */ }
300
+ }
301
+
302
+ private failPending(): void {
303
+ const pending = this.pending;
304
+ if (pending === undefined) return;
305
+ this.pending = undefined;
306
+ pending.cancel();
307
+ pending.resolve(false);
308
+ }
309
+ }