@erichll/pi-auto-review 0.8.0 → 0.8.2
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/CHANGELOG.md +17 -0
- package/README.md +15 -4
- package/package.json +3 -3
- package/src/index.ts +82 -17
- package/src/ui-auto-confirm.ts +37 -11
- package/src/user-feedback.ts +370 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 - 2026-08-21
|
|
4
|
+
|
|
5
|
+
- Structure interactive review results as headline, target, rationale, and a
|
|
6
|
+
meta line with the reviewer model name, input/output tokens, duration, and extra
|
|
7
|
+
calls. The TUI renders them as markdown-style quote rows (`│` + `mdQuote`),
|
|
8
|
+
with allow/deny verbs and token counts tinted from the theme. The model id is
|
|
9
|
+
shown without a provider prefix; audit still records the full `provider/id`.
|
|
10
|
+
Local hard denies omit model and token fields because no model ran.
|
|
11
|
+
|
|
12
|
+
## 0.8.1 - 2026-08-21
|
|
13
|
+
|
|
14
|
+
- Restore TUI auto-confirm for capped `path` / `external_directory` allows on
|
|
15
|
+
permission-system 26.x. `permissions:ui_prompt` no longer carries `message`;
|
|
16
|
+
the bridge now binds `requestId` plus `request.value` (falling back to the
|
|
17
|
+
display `value`, then the legacy `message`) and fingerprints the live dialog
|
|
18
|
+
with those fields. Top-level display `surface` is not treated as the gate.
|
|
19
|
+
|
|
3
20
|
## 0.8.0 - 2026-08-21
|
|
4
21
|
|
|
5
22
|
- Add `/auto-review-approve` for exact non-critical denial retries and remove
|
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ A fail-closed, model-backed boundary reviewer for the Pi coding agent.
|
|
|
4
4
|
|
|
5
5
|
The extension participates in `@gotgenes/pi-permission-system` as the
|
|
6
6
|
`pi-auto-review` authorizer and exposes a process-local broker for OS sandbox
|
|
7
|
-
adapters.
|
|
7
|
+
adapters. It is an authorizer *inside* the permission system, so installing
|
|
8
|
+
that dependency is a hard prerequisite (see [Install and enable](#install-and-enable)). The npm package and reviewer model have separate names:
|
|
8
9
|
|
|
9
10
|
- package: `@erichll/pi-auto-review`
|
|
10
11
|
- authorizer: `pi-auto-review`
|
|
@@ -12,12 +13,18 @@ adapters. The npm package and reviewer model have separate names:
|
|
|
12
13
|
|
|
13
14
|
## Install and enable
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
> **Prerequisite:** pi-auto-review is an authorizer inside
|
|
17
|
+
> `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
|
|
18
|
+
> install the permission system separately (once per machine) before this
|
|
19
|
+
> extension:
|
|
16
20
|
|
|
17
21
|
```bash
|
|
22
|
+
pi install npm:@gotgenes/pi-permission-system
|
|
18
23
|
pi install npm:@erichll/pi-auto-review
|
|
19
24
|
```
|
|
20
25
|
|
|
26
|
+
Install the package outside the agent-writable workspace:
|
|
27
|
+
|
|
21
28
|
Add it to the permission-system authorizer chain:
|
|
22
29
|
|
|
23
30
|
```json
|
|
@@ -126,8 +133,12 @@ only a fixed, budget-checked schema correction.
|
|
|
126
133
|
## Operator feedback and exact retry
|
|
127
134
|
|
|
128
135
|
Interactive sessions show a best-effort footer while review is running and a
|
|
129
|
-
|
|
130
|
-
breaker outcomes.
|
|
136
|
+
structured result row for allow, local confirmation, defer, deny, or circuit
|
|
137
|
+
breaker outcomes. In the TUI this is a quote-style entry (`│`, muted quote
|
|
138
|
+
color) that separates the outcome and surface from the target and rationale,
|
|
139
|
+
then shows the reviewer model name (without provider prefix), input/output
|
|
140
|
+
tokens, and duration when a model call ran. UI delivery never changes the
|
|
141
|
+
authorization result.
|
|
131
142
|
|
|
132
143
|
In an interactive TUI, `/auto-review-approve` lists up to ten recent
|
|
133
144
|
non-critical model denials from the current session. Selecting one asks the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Model-backed boundary approval broker for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
51
51
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
52
|
-
"@gotgenes/pi-permission-system": "^
|
|
52
|
+
"@gotgenes/pi-permission-system": "^26.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
56
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
|
-
"@gotgenes/pi-permission-system": "^
|
|
57
|
+
"@gotgenes/pi-permission-system": "^26.0.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,12 +37,17 @@ import {
|
|
|
37
37
|
} from "./broker/index.ts";
|
|
38
38
|
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
39
39
|
import {
|
|
40
|
+
buildUserReviewEntryData,
|
|
40
41
|
buildUserReviewNotice,
|
|
41
42
|
buildUserReviewStatus,
|
|
42
43
|
notifyUserReview,
|
|
44
|
+
presentUserReview,
|
|
45
|
+
renderUserReviewEntry,
|
|
43
46
|
reviewTargetFromRequest,
|
|
44
47
|
setUserReviewStatus,
|
|
48
|
+
USER_REVIEW_ENTRY_TYPE,
|
|
45
49
|
type UserReviewOutcome,
|
|
50
|
+
type UserReviewUsage,
|
|
46
51
|
} from "./user-feedback.ts";
|
|
47
52
|
|
|
48
53
|
export {
|
|
@@ -1330,6 +1335,33 @@ function aggregateUsage(attempts: readonly ReviewAttemptObservation[]): {
|
|
|
1330
1335
|
return { availability, usage };
|
|
1331
1336
|
}
|
|
1332
1337
|
|
|
1338
|
+
function userReviewMetaFromResult(
|
|
1339
|
+
result: ReviewResult | undefined,
|
|
1340
|
+
fallbackModel: string,
|
|
1341
|
+
): {
|
|
1342
|
+
model?: string;
|
|
1343
|
+
usage?: UserReviewUsage;
|
|
1344
|
+
durationMs?: number;
|
|
1345
|
+
attempts?: number;
|
|
1346
|
+
} {
|
|
1347
|
+
if (!result) return {};
|
|
1348
|
+
const attempts = result.summary.attempts;
|
|
1349
|
+
const aggregate = aggregateUsage(attempts);
|
|
1350
|
+
return {
|
|
1351
|
+
...(attempts.length > 0
|
|
1352
|
+
? {
|
|
1353
|
+
model: attempts.at(-1)?.model ?? fallbackModel,
|
|
1354
|
+
usage: {
|
|
1355
|
+
availability: aggregate.availability,
|
|
1356
|
+
...aggregate.usage,
|
|
1357
|
+
},
|
|
1358
|
+
}
|
|
1359
|
+
: {}),
|
|
1360
|
+
durationMs: result.durationMs,
|
|
1361
|
+
attempts: result.attempts,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1333
1365
|
function completeTelemetry(
|
|
1334
1366
|
request: BoundaryRequest,
|
|
1335
1367
|
config: Readonly<Config>,
|
|
@@ -1885,6 +1917,11 @@ export function createPiAutoReviewExtension(
|
|
|
1885
1917
|
process.env.PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV === "1";
|
|
1886
1918
|
|
|
1887
1919
|
return (pi: ExtensionAPI): void => {
|
|
1920
|
+
try {
|
|
1921
|
+
pi.registerEntryRenderer(USER_REVIEW_ENTRY_TYPE, renderUserReviewEntry);
|
|
1922
|
+
} catch {
|
|
1923
|
+
// Renderer registration is observational.
|
|
1924
|
+
}
|
|
1888
1925
|
let context: ExtensionContext | undefined;
|
|
1889
1926
|
let config: Readonly<Config> = trustedConfig;
|
|
1890
1927
|
let disposeAuthorizer: (() => void) | undefined;
|
|
@@ -1938,6 +1975,23 @@ export function createPiAutoReviewExtension(
|
|
|
1938
1975
|
const execution = error instanceof ReviewExecutionError
|
|
1939
1976
|
? error
|
|
1940
1977
|
: new ReviewExecutionError("unknown", noModelSummary());
|
|
1978
|
+
if (request.source === "permission-system") {
|
|
1979
|
+
reviewResults.set(request.id, {
|
|
1980
|
+
decision: {
|
|
1981
|
+
outcome: config.failureMode,
|
|
1982
|
+
risk_level: "high",
|
|
1983
|
+
user_authorization: "unknown",
|
|
1984
|
+
rationale: "Automatic review is unavailable.",
|
|
1985
|
+
},
|
|
1986
|
+
attempts: execution.summary.attempts.length,
|
|
1987
|
+
retryErrors: execution.summary.attempts
|
|
1988
|
+
.map((attempt) => attempt.errorClass)
|
|
1989
|
+
.filter((errorClass) => errorClass !== "none"),
|
|
1990
|
+
durationMs: execution.summary.durationMs,
|
|
1991
|
+
transcript: execution.summary.transcript,
|
|
1992
|
+
summary: execution.summary,
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1941
1995
|
emitTelemetry(
|
|
1942
1996
|
completeTelemetry(
|
|
1943
1997
|
request,
|
|
@@ -2263,13 +2317,16 @@ export function createPiAutoReviewExtension(
|
|
|
2263
2317
|
surface,
|
|
2264
2318
|
reason,
|
|
2265
2319
|
});
|
|
2266
|
-
|
|
2320
|
+
const unavailable = {
|
|
2321
|
+
outcome: "unavailable" as const,
|
|
2322
|
+
surface,
|
|
2323
|
+
rationale: reason,
|
|
2324
|
+
};
|
|
2325
|
+
presentUserReview(
|
|
2326
|
+
pi,
|
|
2267
2327
|
context,
|
|
2268
|
-
buildUserReviewNotice(
|
|
2269
|
-
|
|
2270
|
-
surface,
|
|
2271
|
-
rationale: reason,
|
|
2272
|
-
}),
|
|
2328
|
+
buildUserReviewNotice(unavailable),
|
|
2329
|
+
buildUserReviewEntryData(unavailable),
|
|
2273
2330
|
);
|
|
2274
2331
|
return { kind: "deny", reason };
|
|
2275
2332
|
}
|
|
@@ -2310,24 +2367,30 @@ export function createPiAutoReviewExtension(
|
|
|
2310
2367
|
} else {
|
|
2311
2368
|
userOutcome = "deny";
|
|
2312
2369
|
}
|
|
2313
|
-
|
|
2370
|
+
const reviewMeta = userReviewMetaFromResult(result, config.model);
|
|
2371
|
+
const noticeInput = {
|
|
2372
|
+
outcome: userOutcome,
|
|
2373
|
+
surface,
|
|
2374
|
+
target,
|
|
2375
|
+
rationale: decision.review.rationale,
|
|
2376
|
+
recoveryCommand:
|
|
2377
|
+
decision.kind === "deny"
|
|
2378
|
+
? decision.recoveryCommand
|
|
2379
|
+
: undefined,
|
|
2380
|
+
...reviewMeta,
|
|
2381
|
+
};
|
|
2382
|
+
presentUserReview(
|
|
2383
|
+
pi,
|
|
2314
2384
|
context,
|
|
2315
|
-
buildUserReviewNotice(
|
|
2316
|
-
|
|
2317
|
-
surface,
|
|
2318
|
-
target,
|
|
2319
|
-
rationale: decision.review.rationale,
|
|
2320
|
-
recoveryCommand:
|
|
2321
|
-
decision.kind === "deny"
|
|
2322
|
-
? decision.recoveryCommand
|
|
2323
|
-
: undefined,
|
|
2324
|
-
}),
|
|
2385
|
+
buildUserReviewNotice(noticeInput),
|
|
2386
|
+
buildUserReviewEntryData(noticeInput),
|
|
2325
2387
|
);
|
|
2326
2388
|
|
|
2327
2389
|
log.review("pi_auto_review_decision", {
|
|
2328
2390
|
requestId: request.id,
|
|
2329
2391
|
surface,
|
|
2330
2392
|
model: config.model,
|
|
2393
|
+
reviewerModel: reviewMeta.model,
|
|
2331
2394
|
outcome: allowCapped ? "defer" : decision.kind,
|
|
2332
2395
|
reviewerOutcome: decision.review.outcome,
|
|
2333
2396
|
riskLevel: decision.review.riskLevel,
|
|
@@ -2343,6 +2406,8 @@ export function createPiAutoReviewExtension(
|
|
|
2343
2406
|
attempts: result?.attempts ?? 0,
|
|
2344
2407
|
retryErrors: result?.retryErrors ?? [],
|
|
2345
2408
|
durationMs: result?.durationMs,
|
|
2409
|
+
usageAvailability: reviewMeta.usage?.availability,
|
|
2410
|
+
usage: reviewMeta.usage,
|
|
2346
2411
|
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2347
2412
|
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2348
2413
|
transcriptRelevantResultCharacters:
|
package/src/ui-auto-confirm.ts
CHANGED
|
@@ -7,9 +7,15 @@ type UiCustom = ExtensionUIContext["custom"];
|
|
|
7
7
|
|
|
8
8
|
type PermissionUiPromptEvent = {
|
|
9
9
|
requestId: string;
|
|
10
|
-
message
|
|
10
|
+
/** Value the live dialog is expected to show (command, path, or legacy message). */
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
/** Gate surface from `request.surface`, when the 26.x payload carries it. */
|
|
13
|
+
gateSurface?: string;
|
|
11
14
|
};
|
|
12
15
|
|
|
16
|
+
/** Prefix length that survives the default 400-char prompt field cap. */
|
|
17
|
+
const FINGERPRINT_PREFIX = 80;
|
|
18
|
+
|
|
13
19
|
type PendingApproval = {
|
|
14
20
|
surface: string;
|
|
15
21
|
expiresAt: number;
|
|
@@ -79,6 +85,9 @@ export class PermissionUiAutoConfirmer {
|
|
|
79
85
|
this.pending.delete(event.requestId);
|
|
80
86
|
|
|
81
87
|
if (!this.enabledSurfaces().includes(pending.surface)) return;
|
|
88
|
+
// Top-level event.surface is the display tool name (e.g. bash), not the
|
|
89
|
+
// gate. Only request.surface is comparable to the staged allow.
|
|
90
|
+
if (event.gateSurface && event.gateSurface !== pending.surface) return;
|
|
82
91
|
this.settleActiveForConflict();
|
|
83
92
|
|
|
84
93
|
const attempt: PromptAttempt = {
|
|
@@ -258,13 +267,24 @@ function parsePermissionUiPromptEvent(
|
|
|
258
267
|
raw: unknown,
|
|
259
268
|
): PermissionUiPromptEvent | undefined {
|
|
260
269
|
if (!isRecord(raw)) return;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
270
|
+
const requestId = nonemptyString(raw.requestId);
|
|
271
|
+
if (!requestId) return;
|
|
272
|
+
|
|
273
|
+
const request = isRecord(raw.request) ? raw.request : undefined;
|
|
274
|
+
// permission-system 26+ removed `message`. Prefer the structured value the
|
|
275
|
+
// dialog actually renders, then the display projection, then the legacy
|
|
276
|
+
// assembled sentence.
|
|
277
|
+
const fingerprint =
|
|
278
|
+
nonemptyString(request?.value) ??
|
|
279
|
+
nonemptyString(raw.value) ??
|
|
280
|
+
nonemptyString(raw.message);
|
|
281
|
+
if (!fingerprint) return;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
requestId,
|
|
285
|
+
fingerprint,
|
|
286
|
+
gateSurface: nonemptyString(request?.surface),
|
|
287
|
+
};
|
|
268
288
|
}
|
|
269
289
|
|
|
270
290
|
function isPermissionPromptComponent(
|
|
@@ -280,17 +300,23 @@ function isPermissionPromptComponent(
|
|
|
280
300
|
const rendered = value.render(2_000);
|
|
281
301
|
if (!Array.isArray(rendered)) return false;
|
|
282
302
|
const plain = normalizeWhitespace(stripAnsi(rendered.join("\n")));
|
|
283
|
-
const
|
|
303
|
+
const needle = event.fingerprint.slice(0, FINGERPRINT_PREFIX);
|
|
284
304
|
return (
|
|
285
305
|
plain.includes("Permission Required") &&
|
|
286
|
-
|
|
287
|
-
plain.includes(
|
|
306
|
+
needle.length > 0 &&
|
|
307
|
+
plain.includes(needle)
|
|
288
308
|
);
|
|
289
309
|
} catch {
|
|
290
310
|
return false;
|
|
291
311
|
}
|
|
292
312
|
}
|
|
293
313
|
|
|
314
|
+
function nonemptyString(value: unknown): string | undefined {
|
|
315
|
+
if (typeof value !== "string") return;
|
|
316
|
+
const compact = normalizeWhitespace(value);
|
|
317
|
+
return compact || undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
294
320
|
function invokeUiCustom(
|
|
295
321
|
method: UiCustom,
|
|
296
322
|
ui: ExtensionUIContext,
|
package/src/user-feedback.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
5
|
|
|
3
6
|
export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
|
|
7
|
+
export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
|
|
4
8
|
|
|
5
9
|
export type UserReviewOutcome =
|
|
6
10
|
| "allow"
|
|
@@ -11,6 +15,23 @@ export type UserReviewOutcome =
|
|
|
11
15
|
| "circuit_breaker"
|
|
12
16
|
| "unavailable";
|
|
13
17
|
|
|
18
|
+
export type UserReviewUsageAvailability =
|
|
19
|
+
| "reported"
|
|
20
|
+
| "estimated"
|
|
21
|
+
| "unavailable"
|
|
22
|
+
| "unknown_provenance";
|
|
23
|
+
|
|
24
|
+
export type UserReviewUsage = {
|
|
25
|
+
availability: UserReviewUsageAvailability;
|
|
26
|
+
input?: number;
|
|
27
|
+
output?: number;
|
|
28
|
+
cacheRead?: number;
|
|
29
|
+
cacheWrite?: number;
|
|
30
|
+
reasoning?: number;
|
|
31
|
+
totalTokens?: number;
|
|
32
|
+
observedInputTokens?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
14
35
|
export type UserReviewNotice = {
|
|
15
36
|
message: string;
|
|
16
37
|
type: "info" | "warning" | "error";
|
|
@@ -41,7 +62,7 @@ export function reviewTargetFromRequest(request: {
|
|
|
41
62
|
request.toolName ??
|
|
42
63
|
request.skillName ??
|
|
43
64
|
request.operation;
|
|
44
|
-
const compact = compactReviewText(target,
|
|
65
|
+
const compact = compactReviewText(target, 100);
|
|
45
66
|
return compact || undefined;
|
|
46
67
|
}
|
|
47
68
|
|
|
@@ -52,70 +73,356 @@ export function buildUserReviewStatus(
|
|
|
52
73
|
): string {
|
|
53
74
|
const compact = compactReviewText(target, 48);
|
|
54
75
|
return compact
|
|
55
|
-
? `auto-review
|
|
56
|
-
: `auto-review
|
|
76
|
+
? `auto-review · reviewing · ${surface} · ${compact}`
|
|
77
|
+
: `auto-review · reviewing · ${surface}`;
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
export function formatReviewTokenCount(count: number): string {
|
|
81
|
+
if (!Number.isFinite(count) || count < 0) return "0";
|
|
82
|
+
if (count < 1000) return String(Math.round(count));
|
|
83
|
+
if (count < 10_000) {
|
|
84
|
+
return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
85
|
+
}
|
|
86
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
87
|
+
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatReviewDuration(durationMs: number): string | undefined {
|
|
91
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) return undefined;
|
|
92
|
+
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
|
93
|
+
const seconds = durationMs / 1000;
|
|
94
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
|
|
98
|
+
if (usage.availability === "unavailable") return undefined;
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
const input = usage.input ?? usage.observedInputTokens;
|
|
101
|
+
if (input !== undefined) {
|
|
102
|
+
const cache =
|
|
103
|
+
usage.cacheRead && usage.cacheRead > 0
|
|
104
|
+
? ` (${formatReviewTokenCount(usage.cacheRead)} cache)`
|
|
105
|
+
: "";
|
|
106
|
+
parts.push(`${formatReviewTokenCount(input)} in${cache}`);
|
|
107
|
+
}
|
|
108
|
+
if (usage.output !== undefined) {
|
|
109
|
+
parts.push(`${formatReviewTokenCount(usage.output)} out`);
|
|
110
|
+
}
|
|
111
|
+
if (parts.length === 0 && usage.totalTokens !== undefined) {
|
|
112
|
+
parts.push(`${formatReviewTokenCount(usage.totalTokens)} tok`);
|
|
113
|
+
}
|
|
114
|
+
if (parts.length === 0) return undefined;
|
|
115
|
+
const approx = usage.availability === "estimated" ? "~" : "";
|
|
116
|
+
return `${approx}${parts.join(" · ")}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Display-only model id; drop a leading provider segment when present. */
|
|
120
|
+
export function formatReviewModelName(model: unknown): string | undefined {
|
|
121
|
+
const raw = String(model ?? "")
|
|
122
|
+
.replace(/\s+/g, " ")
|
|
123
|
+
.trim();
|
|
124
|
+
if (!raw) return undefined;
|
|
125
|
+
const slash = raw.indexOf("/");
|
|
126
|
+
const name =
|
|
127
|
+
slash > 0 && slash < raw.length - 1 ? raw.slice(slash + 1) : raw;
|
|
128
|
+
return compactReviewText(name, 42) || undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function formatReviewMeta(input: {
|
|
132
|
+
model?: string;
|
|
133
|
+
usage?: UserReviewUsage;
|
|
134
|
+
durationMs?: number;
|
|
135
|
+
attempts?: number;
|
|
136
|
+
}): string | undefined {
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
const model = formatReviewModelName(input.model);
|
|
139
|
+
if (model) parts.push(model);
|
|
140
|
+
if (input.usage) {
|
|
141
|
+
const usage = formatReviewUsage(input.usage);
|
|
142
|
+
if (usage) parts.push(usage);
|
|
143
|
+
}
|
|
144
|
+
const duration = input.durationMs !== undefined
|
|
145
|
+
? formatReviewDuration(input.durationMs)
|
|
146
|
+
: undefined;
|
|
147
|
+
if (duration) parts.push(duration);
|
|
148
|
+
if (input.attempts && input.attempts > 1) {
|
|
149
|
+
parts.push(`${input.attempts} calls`);
|
|
150
|
+
}
|
|
151
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function reviewHeadline(outcome: UserReviewOutcome, surface: string): string {
|
|
155
|
+
switch (outcome) {
|
|
156
|
+
case "allow":
|
|
157
|
+
return `Auto-review · allowed · ${surface}`;
|
|
158
|
+
case "auto_confirm":
|
|
159
|
+
return `Auto-review · allowed · auto-confirm · ${surface}`;
|
|
160
|
+
case "needs_confirmation":
|
|
161
|
+
return `Auto-review · allowed · confirm locally · ${surface}`;
|
|
162
|
+
case "defer":
|
|
163
|
+
return `Auto-review · deferred · ${surface}`;
|
|
164
|
+
case "deny":
|
|
165
|
+
return `Auto-review · denied · ${surface}`;
|
|
166
|
+
case "circuit_breaker":
|
|
167
|
+
return `Auto-review · stopped · ${surface}`;
|
|
168
|
+
case "unavailable":
|
|
169
|
+
return `Auto-review · unavailable · ${surface}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recoveryLine(
|
|
174
|
+
outcome: UserReviewOutcome,
|
|
175
|
+
recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false,
|
|
176
|
+
): string | undefined {
|
|
177
|
+
if (outcome === "circuit_breaker") {
|
|
178
|
+
return "use /auto-review-approve for one exact retry";
|
|
179
|
+
}
|
|
180
|
+
if (outcome !== "deny") return undefined;
|
|
181
|
+
if (recoveryCommand === false) {
|
|
182
|
+
return "local safety denials cannot be overridden";
|
|
183
|
+
}
|
|
184
|
+
if (recoveryCommand) {
|
|
185
|
+
return `use ${recoveryCommand} for one exact retry`;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function joinNoticeLines(lines: Array<string | undefined>): string[] {
|
|
191
|
+
return lines
|
|
192
|
+
.map((line) => (typeof line === "string" ? line.trimEnd() : ""))
|
|
193
|
+
.filter((line) => line.length > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type UserReviewNoticeInput = {
|
|
64
197
|
outcome: UserReviewOutcome;
|
|
65
198
|
surface: string;
|
|
66
199
|
target?: string;
|
|
67
200
|
rationale?: string;
|
|
68
201
|
recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
202
|
+
model?: string;
|
|
203
|
+
usage?: UserReviewUsage;
|
|
204
|
+
durationMs?: number;
|
|
205
|
+
attempts?: number;
|
|
206
|
+
};
|
|
74
207
|
|
|
75
|
-
|
|
208
|
+
export type UserReviewEntryData = {
|
|
209
|
+
outcome: UserReviewOutcome;
|
|
210
|
+
type: UserReviewNotice["type"];
|
|
211
|
+
lines: string[];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
type UserReviewTheme = {
|
|
215
|
+
fg(color: string, text: string): string;
|
|
216
|
+
italic(text: string): string;
|
|
217
|
+
getFgAnsi(color: string): string;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function noticeType(outcome: UserReviewOutcome): UserReviewNotice["type"] {
|
|
221
|
+
if (outcome === "unavailable") return "error";
|
|
222
|
+
if (outcome === "deny" || outcome === "circuit_breaker") return "warning";
|
|
223
|
+
return "info";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function outcomeAccent(
|
|
227
|
+
outcome: UserReviewOutcome,
|
|
228
|
+
): "success" | "warning" | "error" | "muted" {
|
|
229
|
+
switch (outcome) {
|
|
230
|
+
case "allow":
|
|
231
|
+
case "auto_confirm":
|
|
232
|
+
case "needs_confirmation":
|
|
233
|
+
return "success";
|
|
234
|
+
case "defer":
|
|
235
|
+
return "muted";
|
|
236
|
+
case "deny":
|
|
237
|
+
case "circuit_breaker":
|
|
238
|
+
return "warning";
|
|
239
|
+
case "unavailable":
|
|
240
|
+
return "error";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function outcomeVerb(outcome: UserReviewOutcome): string {
|
|
245
|
+
switch (outcome) {
|
|
76
246
|
case "allow":
|
|
77
|
-
return {
|
|
78
|
-
type: "info",
|
|
79
|
-
message: `Auto-review allowed ${subject}${why}`,
|
|
80
|
-
};
|
|
81
247
|
case "auto_confirm":
|
|
82
|
-
return {
|
|
83
|
-
type: "info",
|
|
84
|
-
message: `Auto-review allowed ${subject}; auto-confirming the local dialog${why}`,
|
|
85
|
-
};
|
|
86
248
|
case "needs_confirmation":
|
|
87
|
-
return
|
|
88
|
-
type: "info",
|
|
89
|
-
message: `Auto-review allowed ${subject}; local confirmation is still required${why}`,
|
|
90
|
-
};
|
|
249
|
+
return "allowed";
|
|
91
250
|
case "defer":
|
|
92
|
-
return
|
|
93
|
-
type: "info",
|
|
94
|
-
message: `Auto-review deferred ${subject} to you${why}`,
|
|
95
|
-
};
|
|
251
|
+
return "deferred";
|
|
96
252
|
case "deny":
|
|
97
|
-
return
|
|
98
|
-
type: "warning",
|
|
99
|
-
message: `Auto-review denied ${subject}${why}${
|
|
100
|
-
input.recoveryCommand === false
|
|
101
|
-
? " — local safety denials cannot be overridden"
|
|
102
|
-
: input.recoveryCommand
|
|
103
|
-
? ` — use ${input.recoveryCommand} for one exact retry`
|
|
104
|
-
: ""
|
|
105
|
-
}`,
|
|
106
|
-
};
|
|
253
|
+
return "denied";
|
|
107
254
|
case "circuit_breaker":
|
|
108
|
-
return
|
|
109
|
-
type: "warning",
|
|
110
|
-
message: `Auto-review stopped after repeated denials this turn${
|
|
111
|
-
why || " — use /auto-review-approve for one exact retry"
|
|
112
|
-
}`,
|
|
113
|
-
};
|
|
255
|
+
return "stopped";
|
|
114
256
|
case "unavailable":
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
257
|
+
return "unavailable";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
|
|
262
|
+
return joinNoticeLines([
|
|
263
|
+
reviewHeadline(input.outcome, input.surface),
|
|
264
|
+
compactReviewText(input.target, 100) || undefined,
|
|
265
|
+
compactReviewText(input.rationale, 180) || undefined,
|
|
266
|
+
recoveryLine(input.outcome, input.recoveryCommand),
|
|
267
|
+
formatReviewMeta({
|
|
268
|
+
model: input.model,
|
|
269
|
+
usage: input.usage,
|
|
270
|
+
durationMs: input.durationMs,
|
|
271
|
+
attempts: input.attempts,
|
|
272
|
+
}),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* User-facing (not agent-facing) notice after a review decision.
|
|
278
|
+
* Structured for TUI wrapping: headline, target, rationale, then model/tokens.
|
|
279
|
+
*/
|
|
280
|
+
export function buildUserReviewNotice(
|
|
281
|
+
input: UserReviewNoticeInput,
|
|
282
|
+
): UserReviewNotice {
|
|
283
|
+
return {
|
|
284
|
+
type: noticeType(input.outcome),
|
|
285
|
+
message: buildUserReviewLines(input).join("\n"),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function buildUserReviewEntryData(
|
|
290
|
+
input: UserReviewNoticeInput,
|
|
291
|
+
): UserReviewEntryData {
|
|
292
|
+
return {
|
|
293
|
+
outcome: input.outcome,
|
|
294
|
+
type: noticeType(input.outcome),
|
|
295
|
+
lines: buildUserReviewLines(input),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function formatUserReviewQuoteMessage(message: string): string {
|
|
300
|
+
return message
|
|
301
|
+
.split("\n")
|
|
302
|
+
.map((line) => `│ ${line}`)
|
|
303
|
+
.join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function displayWidth(text: string): number {
|
|
307
|
+
let width = 0;
|
|
308
|
+
for (const char of text) {
|
|
309
|
+
const code = char.codePointAt(0) ?? 0;
|
|
310
|
+
width += code > 0xff ? 2 : 1;
|
|
311
|
+
}
|
|
312
|
+
return width;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function wrapReviewDisplayText(text: string, width: number): string[] {
|
|
316
|
+
if (width <= 0) return [text];
|
|
317
|
+
const lines: string[] = [];
|
|
318
|
+
let current = "";
|
|
319
|
+
let currentWidth = 0;
|
|
320
|
+
const flush = () => {
|
|
321
|
+
if (current.length > 0) lines.push(current);
|
|
322
|
+
current = "";
|
|
323
|
+
currentWidth = 0;
|
|
324
|
+
};
|
|
325
|
+
for (const token of text.split(/(\s+)/)) {
|
|
326
|
+
const tokenWidth = displayWidth(token);
|
|
327
|
+
if (currentWidth + tokenWidth <= width) {
|
|
328
|
+
current += token;
|
|
329
|
+
currentWidth += tokenWidth;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (/^\s+$/.test(token)) {
|
|
333
|
+
flush();
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (currentWidth > 0) flush();
|
|
337
|
+
if (tokenWidth <= width) {
|
|
338
|
+
current = token;
|
|
339
|
+
currentWidth = tokenWidth;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const char of token) {
|
|
343
|
+
const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
|
|
344
|
+
if (currentWidth + charWidth > width) flush();
|
|
345
|
+
current += char;
|
|
346
|
+
currentWidth += charWidth;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
flush();
|
|
350
|
+
return lines.length > 0 ? lines : [""];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function withQuoteStyle(theme: UserReviewTheme, text: string): string {
|
|
354
|
+
const marker = "\u0000";
|
|
355
|
+
const prefix = theme.italic(theme.fg("mdQuote", marker)).split(marker)[0] ?? "";
|
|
356
|
+
const body = theme.italic(theme.fg("mdQuote", text));
|
|
357
|
+
return prefix ? body.replace(/\x1b\[0m/g, `\x1b[0m${prefix}`) : body;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function renderUserReviewQuoteLines(
|
|
361
|
+
data: UserReviewEntryData,
|
|
362
|
+
theme: UserReviewTheme,
|
|
363
|
+
width: number,
|
|
364
|
+
): string[] {
|
|
365
|
+
const contentWidth = Math.max(1, width - 2);
|
|
366
|
+
const border = theme.fg("mdQuoteBorder", "│ ");
|
|
367
|
+
const verb = outcomeVerb(data.outcome);
|
|
368
|
+
const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
|
|
369
|
+
const numberAnsi = theme.getFgAnsi("success");
|
|
370
|
+
const rendered: string[] = [];
|
|
371
|
+
for (const [index, line] of data.lines.entries()) {
|
|
372
|
+
for (const visual of wrapReviewDisplayText(line, contentWidth)) {
|
|
373
|
+
let painted = visual;
|
|
374
|
+
if (index === 0) {
|
|
375
|
+
painted = painted.replace(
|
|
376
|
+
verb,
|
|
377
|
+
`${verbAnsi}${verb}\x1b[0m`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (index === data.lines.length - 1) {
|
|
381
|
+
painted = painted.replace(
|
|
382
|
+
/(\d+(?:\.\d+)?[kM]?)(?=\s(?:in|out|tok|cache|calls)|ms\b|s\b)/g,
|
|
383
|
+
`${numberAnsi}$1\x1b[0m`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
rendered.push(border + withQuoteStyle(theme, painted));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return rendered;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function renderUserReviewEntry(
|
|
393
|
+
entry: { data?: unknown },
|
|
394
|
+
_options: unknown,
|
|
395
|
+
theme: UserReviewTheme,
|
|
396
|
+
): { render(width: number): string[]; invalidate(): void } | undefined {
|
|
397
|
+
const data = entry.data;
|
|
398
|
+
if (
|
|
399
|
+
!data ||
|
|
400
|
+
typeof data !== "object" ||
|
|
401
|
+
!Array.isArray((data as UserReviewEntryData).lines) ||
|
|
402
|
+
(data as UserReviewEntryData).lines.length === 0
|
|
403
|
+
) {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
const parsed = data as UserReviewEntryData;
|
|
407
|
+
return {
|
|
408
|
+
render(width: number) {
|
|
409
|
+
return renderUserReviewQuoteLines(parsed, theme, width);
|
|
410
|
+
},
|
|
411
|
+
invalidate() {},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function presentUserReviewEntry(
|
|
416
|
+
pi: ExtensionAPI | undefined,
|
|
417
|
+
ctx: ExtensionContext | undefined,
|
|
418
|
+
data: UserReviewEntryData,
|
|
419
|
+
): boolean {
|
|
420
|
+
if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
421
|
+
try {
|
|
422
|
+
pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
119
426
|
}
|
|
120
427
|
}
|
|
121
428
|
|
|
@@ -126,12 +433,23 @@ export function notifyUserReview(
|
|
|
126
433
|
): void {
|
|
127
434
|
if (!ctx?.hasUI) return;
|
|
128
435
|
try {
|
|
129
|
-
ctx.ui.notify(notice.message, notice.type);
|
|
436
|
+
ctx.ui.notify(formatUserReviewQuoteMessage(notice.message), notice.type);
|
|
130
437
|
} catch {
|
|
131
438
|
// UI delivery is observational and must not change a decision.
|
|
132
439
|
}
|
|
133
440
|
}
|
|
134
441
|
|
|
442
|
+
/** TUI quote-style entry when possible; otherwise a quoted notify toast. */
|
|
443
|
+
export function presentUserReview(
|
|
444
|
+
pi: ExtensionAPI | undefined,
|
|
445
|
+
ctx: ExtensionContext | undefined,
|
|
446
|
+
notice: UserReviewNotice,
|
|
447
|
+
data: UserReviewEntryData,
|
|
448
|
+
): void {
|
|
449
|
+
if (presentUserReviewEntry(pi, ctx, data)) return;
|
|
450
|
+
notifyUserReview(ctx, notice);
|
|
451
|
+
}
|
|
452
|
+
|
|
135
453
|
/** Best-effort footer status; pass undefined to clear. */
|
|
136
454
|
export function setUserReviewStatus(
|
|
137
455
|
ctx: ExtensionContext | undefined,
|