gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { gentlePiConfigHome } from "./agent-home.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Double-esc-cancel policy — global file > env > default off (issue #1163)
7
+ //
8
+ // Deliberately global-only (no project-file layer, unlike
9
+ // background-subagents): this preference changes what a keypress does while
10
+ // the agent is working, and a per-project override for a personal habit like
11
+ // this would make the same key do two different things depending on which
12
+ // repo happens to be open. Otherwise mirrors the resolution shape of
13
+ // lib/background-subagents-policy.ts, minus that project-file layer.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type DoubleEscCancelPolicy = "on" | "off";
17
+
18
+ /** Which of the three sources decided the effective policy. */
19
+ export type DoubleEscCancelSource = "global_file" | "environment" | "default";
20
+
21
+ export interface DoubleEscCancelResolution {
22
+ policy: DoubleEscCancelPolicy;
23
+ source: DoubleEscCancelSource;
24
+ /** The deciding file was present but failed the strict decode. */
25
+ malformed: boolean;
26
+ globalFile: string;
27
+ globalFileExists: boolean;
28
+ /** The raw env value, reported even when it is unrecognized and inert. */
29
+ envValue: string | undefined;
30
+ }
31
+
32
+ export interface LoadDoubleEscCancelOptions {
33
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
34
+ gentlePiConfigHome?: string;
35
+ /** Override the environment lookup (used in tests). */
36
+ env?: Record<string, string | undefined>;
37
+ }
38
+
39
+ export const DOUBLE_ESC_CANCEL_SCHEMA = "gentle-pi.double-esc-cancel/v1";
40
+ export const DOUBLE_ESC_CANCEL_FILE = "double-esc-cancel.json";
41
+
42
+ // Pi's own idle double-Esc (empty editor -> /tree or /fork, interactive-mode.js
43
+ // `lastEscapeTime`) uses a 500ms window. Canceling a running turn throws away
44
+ // in-flight work and is harder to undo than switching prompts, so this
45
+ // confirmation deliberately gets double that time to land the second press.
46
+ export const DOUBLE_ESC_CANCEL_WINDOW_MS = 1000;
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * Strict decode of {"schema":"gentle-pi.double-esc-cancel/v1","policy":"on"|"off"}.
54
+ * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
55
+ * returns undefined so the caller fails closed to "off".
56
+ */
57
+ export function parseDoubleEscCancelPolicyFile(raw: string): DoubleEscCancelPolicy | undefined {
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ if (!isRecord(parsed)) return undefined;
65
+ if (parsed.schema !== DOUBLE_ESC_CANCEL_SCHEMA) return undefined;
66
+ if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
67
+ if (Object.keys(parsed).length !== 2) return undefined;
68
+ return parsed.policy;
69
+ }
70
+
71
+ /**
72
+ * Resolve the double-esc-cancel policy AND the source that decided it.
73
+ *
74
+ * Resolution order (first hit wins):
75
+ * 1. Global file `${configHome}/double-esc-cancel.json`
76
+ * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
77
+ * 2. Env var GENTLE_PI_DOUBLE_ESC_CANCEL ("on" | "off")
78
+ * 3. Default "off"
79
+ *
80
+ * A present-but-malformed file fails closed to "off" instead of falling
81
+ * through to the env var, and stays attributed to that file: "off decided
82
+ * by a broken global file" and "off by default" are different situations,
83
+ * and only the first one is a mistake to fix.
84
+ */
85
+ export function resolveDoubleEscCancelPolicy(
86
+ options: LoadDoubleEscCancelOptions = {},
87
+ ): DoubleEscCancelResolution {
88
+ const env = options.env ?? process.env;
89
+ const envValue = env.GENTLE_PI_DOUBLE_ESC_CANCEL;
90
+ let globalFile = "";
91
+ try {
92
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env);
93
+ globalFile = join(configHome, DOUBLE_ESC_CANCEL_FILE);
94
+ const globalFileExists = existsSync(globalFile);
95
+ if (globalFileExists) {
96
+ let decoded: DoubleEscCancelPolicy | undefined;
97
+ try {
98
+ decoded = parseDoubleEscCancelPolicyFile(readFileSync(globalFile, "utf8"));
99
+ } catch {
100
+ // Unreadable is indistinguishable from unusable at this layer, and
101
+ // both must fail closed on the file that claimed the decision.
102
+ decoded = undefined;
103
+ }
104
+ return decoded === undefined
105
+ ? { policy: "off", source: "global_file", malformed: true, globalFile, globalFileExists, envValue }
106
+ : { policy: decoded, source: "global_file", malformed: false, globalFile, globalFileExists, envValue };
107
+ }
108
+ if (envValue === "on" || envValue === "off") {
109
+ return { policy: envValue, source: "environment", malformed: false, globalFile, globalFileExists, envValue };
110
+ }
111
+ return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists, envValue };
112
+ } catch {
113
+ return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists: false, envValue };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The effective policy alone, for callers that do not report a source.
119
+ * It delegates so the loader and the resolver can never disagree.
120
+ */
121
+ export function loadDoubleEscCancelPolicy(options: LoadDoubleEscCancelOptions = {}): DoubleEscCancelPolicy {
122
+ return resolveDoubleEscCancelPolicy(options).policy;
123
+ }
124
+
125
+ /**
126
+ * Write the global policy file, creating the config home when needed.
127
+ * Used by both `/gentle:double-esc-cancel enable` and `... disable`.
128
+ */
129
+ export function writeDoubleEscCancelPolicy(
130
+ policy: DoubleEscCancelPolicy,
131
+ options: { gentlePiConfigHome?: string } = {},
132
+ ): string {
133
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome();
134
+ const path = join(configHome, DOUBLE_ESC_CANCEL_FILE);
135
+ mkdirSync(configHome, { recursive: true });
136
+ writeFileSync(path, `${JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }, null, 2)}\n`);
137
+ return path;
138
+ }
@@ -0,0 +1,297 @@
1
+ // In-process reviewer completion (gentle-ai#4611; gentle-pi#311 P1).
2
+ //
3
+ // The relay child this module replaced ran a locked-down `pi --print` process
4
+ // with extension discovery disabled, which dropped extension-registered
5
+ // providers ("Model not found") and stripped env-provided API keys; its env
6
+ // allowlist required per-provider manual configuration and Go roles had no
7
+ // parity with it. This module is that transport's replacement for a single
8
+ // reviewer completion: it resolves
9
+ // the caller's "provider/id" selection through pi's live model registry,
10
+ // authenticates through the registry's own resolver, and completes exactly
11
+ // one frozen prompt as a single user message — no systemPrompt, no tools, no
12
+ // session, no extension hooks.
13
+ //
14
+ // Every I/O seam is injected (`registry`, `complete`, `now`), so this module
15
+ // runs under tests with no network and no pi process. gentle-pi#311 P2 wires
16
+ // this into the lens relay (lib/review-host-relay.ts); P3 wires the provider
17
+ // role vectors. This file stays a pure completion, never invoked from here.
18
+
19
+ import type { Api, AssistantMessage, Context, Model, ProviderHeaders, SimpleStreamOptions, TextContent, ThinkingLevel } from "@earendil-works/pi-ai";
20
+ import type { completeSimple } from "@earendil-works/pi-ai/compat";
21
+ import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Registry seam — a structural subset of pi's live ModelRegistry
25
+ // (@earendil-works/pi-coding-agent core/model-registry.ts). Only `find` and
26
+ // `getApiKeyAndHeaders` are needed here; the real registry's resolved auth
27
+ // carries extra optional fields (`baseUrl`, `env`) that this narrower shape
28
+ // simply ignores.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface InProcessReviewerRegistry {
32
+ find(provider: string, modelId: string): Model<Api> | undefined;
33
+ getApiKeyAndHeaders(model: Model<Api>): Promise<
34
+ | { readonly ok: true; readonly apiKey?: string; readonly headers?: ProviderHeaders }
35
+ | { readonly ok: false; readonly error: string }
36
+ >;
37
+ }
38
+
39
+ export const INPROCESS_REVIEWER_FAILURE = {
40
+ SELECTION_INVALID: "selection-invalid",
41
+ MODEL_NOT_FOUND: "model-not-found",
42
+ AUTH_UNAVAILABLE: "auth-unavailable",
43
+ THINKING_INVALID: "thinking-invalid",
44
+ TOOL_CALL_ATTEMPTED: "tool-call-attempted",
45
+ EMPTY_OUTPUT: "empty-output",
46
+ OUTPUT_TOO_LARGE: "output-too-large",
47
+ TIMED_OUT: "timed-out",
48
+ ABORTED: "aborted",
49
+ PROVIDER_FAILED: "provider-failed",
50
+ } as const;
51
+ export type InProcessReviewerFailureCode = (typeof INPROCESS_REVIEWER_FAILURE)[keyof typeof INPROCESS_REVIEWER_FAILURE];
52
+
53
+ export interface InProcessReviewerRequest {
54
+ /** "provider/id" from models.json routing; already validated by SAFE_MODEL_ID_PATTERN upstream, re-validated here. */
55
+ readonly selection: string;
56
+ /** Routing thinking label: off | minimal | low | medium | high | xhigh | max. Omitted is treated as "off". */
57
+ readonly thinking?: string;
58
+ /** Frozen Go-materialized prompt bytes, submitted verbatim as the one user message. */
59
+ readonly prompt: Buffer;
60
+ readonly timeoutMs: number;
61
+ readonly signal?: AbortSignal;
62
+ /**
63
+ * The live pi session id, threaded from the extension context. Pi's main
64
+ * agent loop adds OpenCode attribution headers itself; this side-call must
65
+ * carry them itself instead. Absent (or empty) means no attribution header,
66
+ * never an invented one and never an error.
67
+ */
68
+ readonly sessionId?: string;
69
+ /** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
70
+ readonly routingKey: string;
71
+ }
72
+
73
+ export type InProcessReviewerOutcome =
74
+ | { readonly kind: "text"; readonly text: string; readonly reviewerModel: string }
75
+ | { readonly kind: "refused"; readonly code: InProcessReviewerFailureCode; readonly message: string; readonly evidence?: Record<string, unknown> };
76
+
77
+ export interface InProcessReviewerDeps {
78
+ readonly registry: InProcessReviewerRegistry;
79
+ readonly complete: typeof completeSimple;
80
+ /** Test seam for the single user message's timestamp; defaults to Date.now. */
81
+ readonly now?: () => number;
82
+ }
83
+
84
+ // No existing bound covers the reviewer's completion text: the child this
85
+ // module replaced returned its extracted text unbounded. 4 MiB matches this
86
+ // repo's established convention for this class of bound (lib/session-changes.ts
87
+ // MAX_SESSION_BYTES, lib/provider-contract-bundle.ts MAX_FILE_BYTES).
88
+ export const INPROCESS_REVIEWER_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
89
+
90
+ // pi-ai's `SimpleStreamOptions.reasoning` accepts every routing label except
91
+ // "off" (`ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" |
92
+ // "max"`), and pi-ai itself clamps a level to what the selected model
93
+ // supports through its `thinkingLevelMap`. The label is therefore forwarded
94
+ // verbatim: the routing config owns the choice and the library owns the
95
+ // per-provider mapping, so this module never second-guesses either.
96
+ const THINKING_LABELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
97
+ const ERROR_EXCERPT_MAX_CHARS = 512;
98
+
99
+ function parseSelection(selection: string): { provider: string; modelId: string } | undefined {
100
+ if (typeof selection !== "string" || selection.length === 0 || !SAFE_MODEL_ID_PATTERN.test(selection)) return undefined;
101
+ const separatorIndex = selection.indexOf("/");
102
+ if (separatorIndex <= 0 || separatorIndex === selection.length - 1) return undefined;
103
+ return { provider: selection.slice(0, separatorIndex), modelId: selection.slice(separatorIndex + 1) };
104
+ }
105
+
106
+ type ReasoningResolution = { readonly ok: true; readonly reasoning?: ThinkingLevel } | { readonly ok: false };
107
+
108
+ /**
109
+ * "off" (or an omitted label) omits `reasoning` entirely; every other pi-ai
110
+ * level is forwarded verbatim; an unrecognized label is a typed refusal. A model with
111
+ * `reasoning === false` never receives the field, regardless of the label —
112
+ * checked last so an unknown label is still refused even for a non-reasoning
113
+ * model, instead of silently passing validation because it would be dropped
114
+ * anyway.
115
+ */
116
+ function resolveReasoning(thinking: string | undefined, model: Model<Api>): ReasoningResolution {
117
+ if (thinking === undefined || thinking === "off") return { ok: true };
118
+ if (!THINKING_LABELS.has(thinking as ThinkingLevel)) return { ok: false };
119
+ return model.reasoning === false ? { ok: true } : { ok: true, reasoning: thinking as ThinkingLevel };
120
+ }
121
+
122
+ function sanitizeErrorExcerpt(error: unknown): string {
123
+ const raw = error instanceof Error ? error.message : String(error);
124
+ const collapsed = raw.replace(/\s+/g, " ").trim();
125
+ return collapsed.length <= ERROR_EXCERPT_MAX_CHARS ? collapsed : `${collapsed.slice(0, ERROR_EXCERPT_MAX_CHARS - 1)}…`;
126
+ }
127
+
128
+ function refuse(code: InProcessReviewerFailureCode, message: string, evidence?: Record<string, unknown>): InProcessReviewerOutcome {
129
+ return { kind: "refused", code, message, ...(evidence === undefined ? {} : { evidence }) };
130
+ }
131
+
132
+ function isTextContent(part: { type?: unknown }): part is TextContent {
133
+ return part.type === "text";
134
+ }
135
+
136
+ /**
137
+ * Mirrors pi's main-loop OpenCode attribution condition exactly
138
+ * (core/provider-attribution.js#getSessionHeaders): the model's provider is
139
+ * `opencode` or `opencode-go`, or its baseUrl host is `opencode.ai`. Returns
140
+ * the `{ x-opencode-session, x-opencode-client }` attribution pair, or
141
+ * undefined when the model is not OpenCode-routed or there is no live session
142
+ * id — a missing session id is never an error and never invents a header.
143
+ * The URL parse is guarded: an unparseable baseUrl follows the provider
144
+ * condition alone.
145
+ */
146
+ export function openCodeSessionAttributionHeaders(model: Model<Api>, sessionId: string | undefined): ProviderHeaders | undefined {
147
+ const isOpenCode = model.provider === "opencode"
148
+ || model.provider === "opencode-go"
149
+ || (() => {
150
+ try {
151
+ return new URL(String(model.baseUrl ?? "")).hostname === "opencode.ai";
152
+ } catch {
153
+ return false;
154
+ }
155
+ })();
156
+ if (!isOpenCode || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
157
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
158
+ }
159
+
160
+ /**
161
+ * Runs one reviewer completion in-process: resolve the model, authenticate,
162
+ * map the routing thinking label, and complete exactly one frozen prompt as
163
+ * a single user message. Never retries, never falls back to another model,
164
+ * never reads process.env — every seam is injected through `deps`.
165
+ */
166
+ export async function runInProcessReviewer(request: InProcessReviewerRequest, deps: InProcessReviewerDeps): Promise<InProcessReviewerOutcome> {
167
+ const parsed = parseSelection(request.selection);
168
+ if (parsed === undefined) {
169
+ return refuse(
170
+ INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID,
171
+ `Invalid model selection ${JSON.stringify(request.selection)} for ${request.routingKey}; expected the "provider/id" shape.`,
172
+ );
173
+ }
174
+
175
+ const model = deps.registry.find(parsed.provider, parsed.modelId);
176
+ if (model === undefined) {
177
+ return refuse(
178
+ INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND,
179
+ `No model matches selection ${JSON.stringify(request.selection)} configured for ${request.routingKey}; assign ${request.routingKey} a model that the interactive pi's model list actually shows.`,
180
+ );
181
+ }
182
+
183
+ const auth = await deps.registry.getApiKeyAndHeaders(model);
184
+ // Negation narrowing (`!auth.ok`) does not eliminate the `ok: true` arm of
185
+ // this discriminated union under this project's `strict: false` tsconfig;
186
+ // an explicit `=== false` comparison narrows correctly in both directions.
187
+ if (auth.ok === false) {
188
+ return refuse(
189
+ INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE,
190
+ `No credentials available for provider ${JSON.stringify(parsed.provider)} (used by ${request.routingKey}): ${auth.error}`,
191
+ );
192
+ }
193
+
194
+ const reasoning = resolveReasoning(request.thinking, model);
195
+ if (reasoning.ok === false) {
196
+ return refuse(
197
+ INPROCESS_REVIEWER_FAILURE.THINKING_INVALID,
198
+ `Unknown thinking level ${JSON.stringify(request.thinking)} for ${request.routingKey}; use one of off, minimal, low, medium, high, xhigh, max.`,
199
+ );
200
+ }
201
+
202
+ // Extension side-calls bypass pi's main agent loop, which is where OpenCode
203
+ // attribution headers are otherwise added, so this completion carries them
204
+ // itself — as a default beneath the registry's own auth headers, the same
205
+ // merge order pi's core uses for explicit header sources.
206
+ const attributionHeaders = openCodeSessionAttributionHeaders(model, request.sessionId);
207
+
208
+ // The caller's own signal (if any) and a floor timeout race together:
209
+ // whichever fires first aborts the completion. The catch branch below
210
+ // tells them apart by which underlying signal actually fired, never by
211
+ // inspecting the thrown error's shape, which providers are free to vary.
212
+ const timeoutSignal = AbortSignal.timeout(request.timeoutMs);
213
+ const combinedSignal = request.signal === undefined ? timeoutSignal : AbortSignal.any([request.signal, timeoutSignal]);
214
+
215
+ const context: Context = {
216
+ messages: [
217
+ {
218
+ role: "user",
219
+ content: [{ type: "text", text: request.prompt.toString("utf8") }],
220
+ timestamp: (deps.now ?? Date.now)(),
221
+ },
222
+ ],
223
+ };
224
+ const options: SimpleStreamOptions = {
225
+ signal: combinedSignal,
226
+ timeoutMs: request.timeoutMs,
227
+ ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
228
+ ...(auth.headers === undefined && attributionHeaders === undefined ? {} : { headers: attributionHeaders === undefined ? auth.headers : { ...attributionHeaders, ...auth.headers } }),
229
+ ...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
230
+ };
231
+
232
+ // An abort is classified by which signal actually fired, never by the
233
+ // error's shape or the message's text, and the same classification serves
234
+ // both settlement paths: a provider may reject on abort, or — the pi-ai
235
+ // provider convention — resolve an AssistantMessage with `stopReason:
236
+ // "aborted"` carrying whatever text streamed before the cut. Either way a
237
+ // fired signal is a timeout or a caller abort, never empty or usable output.
238
+ const abortRefusal = (): InProcessReviewerOutcome | undefined => {
239
+ if (timeoutSignal.aborted) {
240
+ return refuse(INPROCESS_REVIEWER_FAILURE.TIMED_OUT, `Reviewer completion for ${request.routingKey} exceeded its ${request.timeoutMs}ms bound.`);
241
+ }
242
+ if (request.signal?.aborted === true) {
243
+ return refuse(INPROCESS_REVIEWER_FAILURE.ABORTED, `Reviewer completion for ${request.routingKey} was aborted by the caller.`);
244
+ }
245
+ return undefined;
246
+ };
247
+
248
+ let assistant: AssistantMessage;
249
+ try {
250
+ assistant = await deps.complete(model, context, options);
251
+ } catch (error) {
252
+ return abortRefusal() ?? refuse(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, `Reviewer completion failed for ${request.routingKey}: ${sanitizeErrorExcerpt(error)}`);
253
+ }
254
+
255
+ const resolvedAbort = abortRefusal();
256
+ if (resolvedAbort !== undefined) return resolvedAbort;
257
+ if (assistant.stopReason === "aborted") {
258
+ // No signal of ours fired, so the provider cut the completion on its
259
+ // own: that is a provider failure, and its partial text is not a review.
260
+ return refuse(
261
+ INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
262
+ `Reviewer completion failed for ${request.routingKey}: the provider reported an aborted completion (${assistant.errorMessage ?? "no provider message"}).`,
263
+ );
264
+ }
265
+
266
+ if (assistant.stopReason === "error") {
267
+ return refuse(
268
+ INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
269
+ `Reviewer completion failed for ${request.routingKey}: ${assistant.errorMessage ?? "unknown provider error"}`,
270
+ );
271
+ }
272
+ if (assistant.content.some((part) => part.type === "toolCall")) {
273
+ return refuse(
274
+ INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED,
275
+ `Reviewer attempted a tool call for ${request.routingKey}; the in-process reviewer completion must answer in text only.`,
276
+ );
277
+ }
278
+
279
+ const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
280
+ if (text.length === 0) {
281
+ return refuse(
282
+ INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
283
+ `Reviewer produced no text for ${request.routingKey} (stopReason: ${assistant.stopReason}).`,
284
+ { stopReason: assistant.stopReason, ...(assistant.errorMessage === undefined ? {} : { errorMessage: assistant.errorMessage }) },
285
+ );
286
+ }
287
+
288
+ const textBytes = Buffer.byteLength(text, "utf8");
289
+ if (textBytes > INPROCESS_REVIEWER_OUTPUT_MAX_BYTES) {
290
+ return refuse(
291
+ INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE,
292
+ `Reviewer output for ${request.routingKey} exceeds the ${INPROCESS_REVIEWER_OUTPUT_MAX_BYTES}-byte bound (received ${textBytes} bytes).`,
293
+ );
294
+ }
295
+
296
+ return { kind: "text", text, reviewerModel: `${model.provider}/${model.id}` };
297
+ }
@@ -201,7 +201,7 @@ export interface NativeReviewModeRequest {
201
201
  // explicit `committedOnly` acknowledgement, exactly like Native START's
202
202
  // baseRef/committedOnly pairing, because both select a committed range
203
203
  // instead of the ambient working tree.
204
- export interface NativeReviewAssessRequest {
204
+ export interface NativeReviewAssessRequest extends NativeUntrackedSelection {
205
205
  cwd: string;
206
206
  baseRef?: string;
207
207
  committedOnly?: boolean;
@@ -705,7 +705,11 @@ function isNativeUntrackedPath(value: unknown): value is string {
705
705
  && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
706
706
  }
707
707
 
708
- function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): NativeUntrackedSelection {
708
+ export function nativeUntrackedSelection(request: {
709
+ untrackedScope?: unknown;
710
+ expectedUntrackedInventory?: unknown;
711
+ intendedUntracked?: unknown;
712
+ }): NativeUntrackedSelection {
709
713
  const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
710
714
  const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
711
715
  if (!declared) return {};
@@ -716,16 +720,19 @@ function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): Nat
716
720
  ) {
717
721
  throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
718
722
  }
719
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
723
+ // The guard above establishes the array and element types for both typed
724
+ // native requests and untyped facade input.
725
+ const paths = intendedUntracked as readonly string[] | undefined;
726
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
720
727
  throw new TypeError("Native exclude untracked selection cannot include paths");
721
728
  }
722
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
729
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
723
730
  throw new TypeError("Native select untracked selection requires at least one path");
724
731
  }
725
732
  return {
726
733
  untrackedScope,
727
734
  expectedUntrackedInventory,
728
- intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
735
+ intendedUntracked: paths === undefined ? undefined : [...paths],
729
736
  };
730
737
  }
731
738
 
@@ -1004,6 +1011,27 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1004
1011
  // remain dark because neither is proven to reach the negotiated START
1005
1012
  // path Pi consumes.
1006
1013
  "3.2.1": 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 }),
1014
+ // v3.4.0 (gentle-pi never pinned the intervening v3.3.0 tag, so it gets no
1015
+ // row here) added capabilities/v2.6 and status/v8-v9, and extended
1016
+ // `review assess` with review_due/review_due_reason/next_transition
1017
+ // (gentle-ai #4714 follow-up). Ground-truthed by diffing
1018
+ // contracts/review-integration/v2 and contracts/review-provider-contract
1019
+ // between the v3.2.1 and v3.4.0 tags in the gentle-ai source tree: the
1020
+ // provider contract stays byte-identical at 1.2.0, and every
1021
+ // review-integration/v2 change is an additive superset (new optional
1022
+ // schema/fields) that decodeReviewStatusV3 and the capabilities
1023
+ // negotiator already accept without touching the closed START/STATUS
1024
+ // fields this row negotiates, so it repeats 3.2.1 exactly. riskEvidence
1025
+ // and hint remain dark because neither is proven to reach the negotiated
1026
+ // START path Pi consumes.
1027
+ "3.4.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 }),
1028
+ // v3.5.0 repeats 3.4.0: the published provider contract bundle is
1029
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1030
+ // with only build-identity differences, and no review-integration schema
1031
+ // changed. The v2 preflight failure identity fix does not change the
1032
+ // closed START/STATUS fields this row negotiates. riskEvidence and hint
1033
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1034
+ "3.5.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 }),
1007
1035
  });
1008
1036
 
1009
1037
  export interface NativeReviewProcessDiagnostics {
@@ -1088,8 +1116,19 @@ function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired:
1088
1116
  function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
1089
1117
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
1090
1118
 
1091
- function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string {
1092
- const normalized = value
1119
+ function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation?: NativeReviewOperation): string {
1120
+ // ASSESS diagnostics are projected into a public verification plan. Retain
1121
+ // native guidance, not local paths or environment assignment values.
1122
+ const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
1123
+ ? value
1124
+ .replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
1125
+ .replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
1126
+ // Quoted paths have a clear boundary. For an unquoted path, the
1127
+ // remaining line is ambiguous (spaces may belong to the filename).
1128
+ // Redact that suffix rather than leak trailing path components.
1129
+ .replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
1130
+ : value;
1131
+ const normalized = input
1093
1132
  .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
1094
1133
  .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
1095
1134
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
@@ -1123,7 +1162,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeRe
1123
1162
  timed_out: booleanValue(raw.timed_out),
1124
1163
  output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1125
1164
  ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
1126
- ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1165
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
1127
1166
  };
1128
1167
  } catch { return undefined; }
1129
1168
  }
@@ -1140,7 +1179,7 @@ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: Native
1140
1179
  ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1141
1180
  ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1142
1181
  : {}),
1143
- ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
1182
+ ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
1144
1183
  };
1145
1184
  }
1146
1185
 
@@ -2521,6 +2560,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2521
2560
  // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2522
2561
  // closed to `high`.
2523
2562
  async assess(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1> {
2563
+ const selection = nativeUntrackedSelection(request);
2524
2564
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2525
2565
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2526
2566
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
@@ -2528,7 +2568,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2528
2568
  const execution = await this.invoke(
2529
2569
  NATIVE_REVIEW_OPERATION.ASSESS,
2530
2570
  cwd,
2531
- ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2571
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
2532
2572
  false,
2533
2573
  request.signal,
2534
2574
  this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),
@@ -0,0 +1,88 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { resolveSessionWorktree } from "./session-worktree-registry.ts";
4
+
5
+ export interface OddDelegationRefusal {
6
+ block: true;
7
+ reason: string;
8
+ }
9
+
10
+ interface SessionState {
11
+ primary: boolean;
12
+ childDepth: number;
13
+ firstSuccessfulPath?: string;
14
+ }
15
+
16
+ function canonicalTarget(path: string): string {
17
+ let candidate = path;
18
+ const missing: string[] = [];
19
+ while (true) {
20
+ try { return resolve(realpathSync(candidate), ...missing); }
21
+ catch {
22
+ const parent = dirname(candidate);
23
+ if (parent === candidate) return path;
24
+ missing.unshift(basename(candidate));
25
+ candidate = parent;
26
+ }
27
+ }
28
+ }
29
+
30
+ function sessionPath(toolName: string, input: unknown, cwd: string): string | undefined {
31
+ if ((toolName !== "edit" && toolName !== "write") || !input || typeof input !== "object") return undefined;
32
+ const path = (input as { path?: unknown }).path;
33
+ if (typeof path !== "string" || path.trim().length === 0) return undefined;
34
+ const root = resolveSessionWorktree(cwd, cwd)?.root;
35
+ if (!root) return undefined;
36
+ const spelling = path.replace(/^@/, "");
37
+ const canonicalCwd = realpathSync(cwd);
38
+ const lexicalTarget = isAbsolute(spelling)
39
+ ? resolve(spelling)
40
+ : resolve(canonicalCwd, spelling);
41
+ const target = canonicalTarget(lexicalTarget);
42
+ const repositoryPath = relative(root, target);
43
+ if (repositoryPath === "" || repositoryPath === ".." || repositoryPath.startsWith(`..${sep}`) || isAbsolute(repositoryPath)) return undefined;
44
+ const canonical = repositoryPath.split(sep).join("/");
45
+ return canonical === "odd/tasks" || canonical.startsWith("odd/tasks/") ? undefined : canonical;
46
+ }
47
+
48
+ export class OddRuntimeDelegationGate {
49
+ private readonly sessions = new Map<string, SessionState>();
50
+
51
+ start(sessionId: string, primary: boolean): void {
52
+ const state = this.sessions.get(sessionId);
53
+ if (primary) this.sessions.set(sessionId, { primary: true, childDepth: 0 });
54
+ else if (state?.primary) state.childDepth += 1;
55
+ else this.sessions.set(sessionId, { primary: false, childDepth: 1 });
56
+ }
57
+
58
+ endChild(sessionId: string): void {
59
+ const state = this.sessions.get(sessionId);
60
+ if (state && state.childDepth > 0) state.childDepth -= 1;
61
+ }
62
+
63
+ beforeTool(
64
+ sessionId: string,
65
+ toolName: string,
66
+ input: unknown,
67
+ cwd: string,
68
+ availableTools: readonly string[],
69
+ ): OddDelegationRefusal | undefined {
70
+ const state = this.sessions.get(sessionId);
71
+ if (!state?.primary || state.childDepth > 0) return undefined;
72
+ const path = sessionPath(toolName, input, cwd);
73
+ if (!path || !state.firstSuccessfulPath || path === state.firstSuccessfulPath) return undefined;
74
+ return {
75
+ block: true,
76
+ reason: availableTools.includes("subagent_run")
77
+ ? `ODD multi-file write refused before mutation: direct edits already changed ${state.firstSuccessfulPath}. Delegate this additional file through subagent_run, preferring gentle-ai-worker and then worker.`
78
+ : "ODD multi-file write refused before mutation: direct edits already changed another file, but subagent_run is not callable. Stop and report that no delegation mechanism is callable.",
79
+ };
80
+ }
81
+
82
+ recordSuccess(sessionId: string, toolName: string, input: unknown, cwd: string): void {
83
+ const state = this.sessions.get(sessionId);
84
+ if (!state?.primary || state.childDepth > 0 || state.firstSuccessfulPath) return;
85
+ const path = sessionPath(toolName, input, cwd);
86
+ if (path) state.firstSuccessfulPath = path;
87
+ }
88
+ }