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.
- package/README.md +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- 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
|
+
}
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 && (
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|