@erichll/pi-auto-review 0.13.0 → 0.15.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/CHANGELOG.md +67 -0
- package/README.md +48 -10
- package/package.json +5 -5
- package/src/broker/broker.ts +10 -1
- package/src/broker/grants.ts +3 -1
- package/src/broker/overrides.ts +17 -9
- package/src/index.ts +130 -13
- package/src/policy-audit/classifier.ts +117 -141
- package/src/policy-audit/report.ts +221 -36
- package/src/policy-audit/store.ts +74 -14
- package/src/user-feedback.ts +62 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0 - 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Fix a deterministic reviewer-unavailable denial for large CJK tool inputs.
|
|
6
|
+
The reviewer input estimator counted one token per UTF-8 byte, tripling the
|
|
7
|
+
estimate for CJK text, so multi-kilobyte Chinese payloads (e.g. a long plan
|
|
8
|
+
document submitted through a tool call) synchronously failed the
|
|
9
|
+
`reviewer_input_budget_exceeded` preflight before the reviewer model was
|
|
10
|
+
ever called, and the request was denied as "Automatic review is
|
|
11
|
+
unavailable." CJK code points now estimate at one token each and remaining
|
|
12
|
+
code points at their UTF-8 byte length over three (estimator
|
|
13
|
+
`conservative:cjk-aware`).
|
|
14
|
+
- Record the denial when a reviewer-unavailable review fails closed, so
|
|
15
|
+
`/auto-review-approve` — the recovery command that verdict recommends —
|
|
16
|
+
actually has a matching denial to approve. Previously the recommendation
|
|
17
|
+
was dead text for this failure class.
|
|
18
|
+
- Let an explicit human override (`/auto-review-approve` trusted exact retry)
|
|
19
|
+
bypass the reviewer input-budget preflight failure. A sizing estimate is
|
|
20
|
+
not a safety verdict and must not veto an already-authorized retry; the
|
|
21
|
+
review proceeds with truncated evidence.
|
|
22
|
+
- Show "waiting for you" in the review widget while a `ctx.ui` prompt blocks
|
|
23
|
+
the session during an active review (pi >= 0.84.4 `ui_prompt_start` /
|
|
24
|
+
`ui_prompt_end`). The widget previously kept claiming "Waiting for
|
|
25
|
+
<model>…" while pi was actually waiting on the user (permission dialog,
|
|
26
|
+
`/review-denials`, break-glass challenge). Completed outcome summaries and
|
|
27
|
+
sessions without a live widget are unaffected.
|
|
28
|
+
- Fix `/auto-review-approve` and `/auto-review-break-glass` never applying in
|
|
29
|
+
real TUI sessions. The exact-match request hash included `toolCallId`, but a
|
|
30
|
+
retry is always a brand-new model tool call with a fresh tool call ID, so
|
|
31
|
+
the authorization could never match the retried request. The hash now
|
|
32
|
+
excludes both retry-minted identifiers (`id` was already excluded).
|
|
33
|
+
- Fix break-glass authorization being discarded on every retry. The stored
|
|
34
|
+
authorization was bound to the denial-time turn scope, but the command flow
|
|
35
|
+
appends a user message (`pi.sendUserMessage`) before the agent retries, so
|
|
36
|
+
the retry always lands in a later turn whose scope differs. Consumption now
|
|
37
|
+
binds on session and request hash plus the confirmation TTL instead of the
|
|
38
|
+
turn scope. The non-critical approve path already behaved this way.
|
|
39
|
+
- Reset the turn circuit breaker when a break-glass allow is consumed, so the
|
|
40
|
+
human-authorized scope can continue instead of staying blocked for the rest
|
|
41
|
+
of the turn. Fresh repeated denials still trip it again after three
|
|
42
|
+
consecutive failures.
|
|
43
|
+
- Clean up break-glass authorization bookkeeping: an expired authorization
|
|
44
|
+
that was never consumed no longer blocks re-approving the same action, and
|
|
45
|
+
a fresh denial of an action whose approval was already consumed re-arms
|
|
46
|
+
one-shot approval (one approval per denial).
|
|
47
|
+
|
|
48
|
+
## 0.14.0 - 2026-08-28
|
|
49
|
+
|
|
50
|
+
- Upgrade `/auto-review-policy-audit` from redacted statistics to an
|
|
51
|
+
evidence-driven permission-configuration optimizer. Reports now separate
|
|
52
|
+
suggested allow rules, keep-ask rules, and insufficient evidence, and emit a
|
|
53
|
+
copyable JSON fragment targeting the selected project or global
|
|
54
|
+
permission-system configuration. Suggestions remain advisory and never read
|
|
55
|
+
or modify permission configuration.
|
|
56
|
+
- Discover candidates from actual audit data without built-in tool, executable,
|
|
57
|
+
Git, RTK, package-manager, or read-only command catalogs. Valid observed
|
|
58
|
+
surfaces and sanitized Bash templates—including unknown tools and write
|
|
59
|
+
operations—can qualify after the configured minimum number of successful
|
|
60
|
+
ask-path approvals. Denials, gate failures, unavailable confirmations,
|
|
61
|
+
forwarded requests, and structurally unsafe Bash variants block a suggestion.
|
|
62
|
+
- Migrate the policy-audit database transactionally to schema v2. Existing
|
|
63
|
+
aggregate counts and `collecting_since` are preserved, while the new
|
|
64
|
+
`recommendations_since` boundary prevents pre-migration data from producing
|
|
65
|
+
allow suggestions. Valid custom tool names and sanitized Bash templates are
|
|
66
|
+
retained for reporting. Templates replace paths, URLs, assignments, quoted
|
|
67
|
+
values, and option values with wildcards; raw commands and non-Bash tool
|
|
68
|
+
arguments or inputs remain excluded.
|
|
69
|
+
|
|
3
70
|
## 0.13.0 - 2026-08-28
|
|
4
71
|
|
|
5
72
|
- Add compatibility with `@gotgenes/pi-permission-system` 27.1 directional
|
package/README.md
CHANGED
|
@@ -192,7 +192,9 @@ The host-generated override:
|
|
|
192
192
|
- expires after 60 seconds and is consumed once;
|
|
193
193
|
- remains separate from untrusted user/tool evidence;
|
|
194
194
|
- still goes through deterministic hard denies and model review; and
|
|
195
|
-
- cannot be reissued
|
|
195
|
+
- cannot be reissued while a previous authorization for it is pending, or
|
|
196
|
+
again after consumption until the same action is denied afresh (one approval
|
|
197
|
+
per denial).
|
|
196
198
|
|
|
197
199
|
It is authorization evidence, not a direct allow.
|
|
198
200
|
|
|
@@ -202,11 +204,15 @@ same session made in the last five minutes. After showing the rationale,
|
|
|
202
204
|
surface, cwd, command or target summary, and request-hash fingerprint, it
|
|
203
205
|
requires an explicit confirmation and a random `BREAK-GLASS <CODE>` phrase
|
|
204
206
|
within 60 seconds. Successful confirmation creates a 60-second, one-use
|
|
205
|
-
authorization bound to the complete request hash, session,
|
|
206
|
-
request ID. The
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
authorization bound to the complete request hash, session, and original
|
|
208
|
+
request ID. The request hash deliberately excludes the per-attempt `id` and
|
|
209
|
+
`toolCallId` fields: the exact retry is a brand-new model tool call issued in
|
|
210
|
+
a later turn, so retry-minted identifiers cannot participate in the match.
|
|
211
|
+
The exact retry reruns local hard-deny checks, then allows directly without
|
|
212
|
+
calling the reviewer and, for sandbox adapters, still issues the normal
|
|
213
|
+
one-shot grant. A break-glass allow resets the turn circuit breaker (a human
|
|
214
|
+
re-authorized the scope), and break glass can be disabled in trusted or
|
|
215
|
+
project configuration with `breakGlassEnabled: false`.
|
|
210
216
|
|
|
211
217
|
## Boundary broker API
|
|
212
218
|
|
|
@@ -334,11 +340,18 @@ only daily, redacted aggregates. Collection is enabled by default and retains
|
|
|
334
340
|
180 days. It starts when this version first initializes successfully; no
|
|
335
341
|
permission-system JSONL or RTK history is read or imported.
|
|
336
342
|
|
|
337
|
-
Before storage, Bash values become
|
|
338
|
-
`
|
|
343
|
+
Before storage, Bash values become observed command templates such as
|
|
344
|
+
`git status --short`, `cat *`, or `npm test * --token *`. URLs, paths,
|
|
345
|
+
assignments, quoted values, and option values are replaced with `*`; safe bare
|
|
346
|
+
command words remain in plaintext so the report can emit a matching rule.
|
|
347
|
+
Paths used for path-surface statistics become only
|
|
339
348
|
`workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
|
|
340
|
-
project locations, and matched-rule patterns are HMACed.
|
|
341
|
-
|
|
349
|
+
project locations, and matched-rule patterns are HMACed. Syntactically valid
|
|
350
|
+
custom tool names are also retained in plaintext; invalid names stay anonymous
|
|
351
|
+
as `<custom-tool>`. The database never stores raw commands, raw paths,
|
|
352
|
+
URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
|
|
353
|
+
words are retained, command templates can contain non-secret filenames or
|
|
354
|
+
project-specific labels; inspect suggestions before copying them.
|
|
342
355
|
|
|
343
356
|
Data lives at
|
|
344
357
|
`~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
|
|
@@ -357,6 +370,31 @@ Run `/auto-review-policy-audit` for a durable TUI report that is not sent to the
|
|
|
357
370
|
LLM. Options are `--days 1..retention`, `--top 1..50`, `--min-count >=1`, and
|
|
358
371
|
`--scope current|all`; defaults are `30`, `20`, `5`, and `current`.
|
|
359
372
|
|
|
373
|
+
In addition to the redacted statistics, the report proposes copyable
|
|
374
|
+
permission-system fragments. `--scope current` targets
|
|
375
|
+
`.pi/extensions/pi-permission-system/config.json`; `--scope all` targets
|
|
376
|
+
`~/.pi/agent/extensions/pi-permission-system/config.json`. The extension never
|
|
377
|
+
reads, edits, or rewrites either file. Merge suggested Bash patterns into the
|
|
378
|
+
existing `permission.bash` map and place the narrow allow entries after broader
|
|
379
|
+
matching ask entries, because permission-system uses last-match-wins.
|
|
380
|
+
|
|
381
|
+
Suggestions are discovered entirely from observed audit data; there is no
|
|
382
|
+
built-in tool, executable, Git action, package-manager action, or read-only
|
|
383
|
+
command catalog. Every valid observed permission surface and reliably templated
|
|
384
|
+
Bash command can qualify, including previously unknown tools and write
|
|
385
|
+
operations. Environment prefixes, compound syntax, pipelines, redirection,
|
|
386
|
+
path executables, and unreliable parsing block that Bash template. Forwarded
|
|
387
|
+
requests are statistics-only because the requester's cwd is not available. A
|
|
388
|
+
candidate needs at least `--min-count` successful ask-path approvals and no
|
|
389
|
+
denial, gate error, unavailable confirmation, blocked structural variant, or
|
|
390
|
+
forwarded-only evidence in the selected window. Existing policy/infrastructure
|
|
391
|
+
allows do not count as evidence of user friction. Repeated approval is evidence
|
|
392
|
+
of user preference, not independent proof that a capability is safe.
|
|
393
|
+
|
|
394
|
+
Schema v2 migrates v1 aggregates transactionally. Old totals remain visible,
|
|
395
|
+
`collecting_since` is preserved, and `recommendations_since` records when safe
|
|
396
|
+
recommendation evidence began; pre-migration data cannot produce a suggestion.
|
|
397
|
+
|
|
360
398
|
The report is an extension-owned custom entry. It is deliberately not exposed
|
|
361
399
|
as an Agent tool or packaged skill, so neither a tool schema nor skill metadata
|
|
362
400
|
is added to the model context. Permission changes remain a separate, explicit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
50
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
52
52
|
"@gotgenes/pi-permission-system": "^27.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
55
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
57
57
|
"@gotgenes/pi-permission-system": "27.1.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
package/src/broker/broker.ts
CHANGED
|
@@ -99,6 +99,9 @@ export class BoundaryApprovalBroker {
|
|
|
99
99
|
? this.#denials.consumeCritical(request, context)
|
|
100
100
|
: undefined;
|
|
101
101
|
if (breakGlass) {
|
|
102
|
+
// A human explicitly authorized this action; treat the scope as healthy
|
|
103
|
+
// again, exactly like a reviewer allow would.
|
|
104
|
+
this.#breaker.record(context.scopeKey, false);
|
|
102
105
|
const review: BoundaryReview = {
|
|
103
106
|
outcome: "allow",
|
|
104
107
|
riskLevel: "critical",
|
|
@@ -166,9 +169,15 @@ export class BoundaryApprovalBroker {
|
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
const breaker = this.#breaker.record(context.scopeKey, true);
|
|
172
|
+
// Record the failure denial so /auto-review-approve — the exact recovery
|
|
173
|
+
// command this verdict recommends — has a matching denial to approve.
|
|
174
|
+
// Without this the override path was unreachable for reviewer
|
|
175
|
+
// unavailability, and the recovery advice was dead text.
|
|
176
|
+
const review = { ...FAILURE_REVIEW, rationale: reason };
|
|
177
|
+
this.#denials.record(request, context, review);
|
|
169
178
|
return {
|
|
170
179
|
kind: "deny",
|
|
171
|
-
review
|
|
180
|
+
review,
|
|
172
181
|
circuitBreakerTripped: breaker.tripped,
|
|
173
182
|
denialSource: "reviewer",
|
|
174
183
|
recoveryCommand: "/auto-review-approve",
|
package/src/broker/grants.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import type { BoundaryGrant, BoundaryRequest } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
// Retry stability: a retried action mints a fresh requestId and toolCallId (the
|
|
5
|
+
// model issues a new tool call), so neither may take part in the exact-match
|
|
6
|
+
// hash. Everything else must be identical for an authorization to apply.
|
|
4
7
|
function stableValue(value: unknown): unknown {
|
|
5
8
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
6
9
|
if (!value || typeof value !== "object") return value;
|
|
@@ -25,7 +28,6 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
|
|
|
25
28
|
destinationHost: request.destinationHost,
|
|
26
29
|
destinationPort: request.destinationPort,
|
|
27
30
|
destinationProtocol: request.destinationProtocol,
|
|
28
|
-
toolCallId: request.toolCallId,
|
|
29
31
|
toolName: request.toolName,
|
|
30
32
|
skillName: request.skillName,
|
|
31
33
|
toolInputPreview: request.toolInputPreview,
|
package/src/broker/overrides.ts
CHANGED
|
@@ -13,9 +13,7 @@ type PendingOverride = {
|
|
|
13
13
|
approvedAt: number;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
type PendingBreakGlass = BoundaryBreakGlassAuthorization
|
|
17
|
-
scopeKey: string;
|
|
18
|
-
};
|
|
16
|
+
type PendingBreakGlass = BoundaryBreakGlassAuthorization;
|
|
19
17
|
|
|
20
18
|
export class RecentDenialStore {
|
|
21
19
|
readonly #denials: RecentBoundaryDenial[] = [];
|
|
@@ -63,6 +61,10 @@ export class RecentDenialStore {
|
|
|
63
61
|
scopeKey: context.scopeKey,
|
|
64
62
|
deniedAt: this.#now(),
|
|
65
63
|
});
|
|
64
|
+
// A fresh denial is a fresh human decision point: re-enable one-shot
|
|
65
|
+
// approval for this exact action even if a previous approval was already
|
|
66
|
+
// consumed earlier in the session.
|
|
67
|
+
this.#used.delete(this.key(context.sessionId, requestHash));
|
|
66
68
|
if (this.#denials.length > this.#limit) {
|
|
67
69
|
this.#denials.length = this.#limit;
|
|
68
70
|
}
|
|
@@ -144,15 +146,18 @@ export class RecentDenialStore {
|
|
|
144
146
|
if (index < 0) return;
|
|
145
147
|
const denial = this.#denials[index];
|
|
146
148
|
const key = this.key(sessionId, denial.requestHash);
|
|
147
|
-
|
|
149
|
+
const pending = this.#breakGlassPending.get(key);
|
|
150
|
+
if (pending) {
|
|
151
|
+
// A pending authorization that was never consumed (the retry never
|
|
152
|
+
// arrived) must not lock break-glass forever once it has expired.
|
|
153
|
+
if (this.#now() - pending.confirmedAt <= this.#overrideTtlMs) return;
|
|
154
|
+
this.#breakGlassPending.delete(key);
|
|
155
|
+
}
|
|
148
156
|
const authorization = {
|
|
149
157
|
originalRequestId: denial.requestId,
|
|
150
158
|
confirmedAt: this.#now(),
|
|
151
159
|
};
|
|
152
|
-
this.#breakGlassPending.set(key, {
|
|
153
|
-
...authorization,
|
|
154
|
-
scopeKey,
|
|
155
|
-
});
|
|
160
|
+
this.#breakGlassPending.set(key, { ...authorization });
|
|
156
161
|
this.#denials.splice(index, 1);
|
|
157
162
|
return {
|
|
158
163
|
denial: structuredClone(denial),
|
|
@@ -164,10 +169,13 @@ export class RecentDenialStore {
|
|
|
164
169
|
request: BoundaryRequest,
|
|
165
170
|
context: BoundaryReviewContext,
|
|
166
171
|
): BoundaryBreakGlassAuthorization | undefined {
|
|
172
|
+
// Note: the retry always lands in a later turn than the denial (the
|
|
173
|
+
// command flow appends a user message before retrying), so the scope key
|
|
174
|
+
// is intentionally not compared here; the session + request hash and the
|
|
175
|
+
// confirmation TTL bound the match.
|
|
167
176
|
const key = this.key(context.sessionId, boundaryRequestHash(request));
|
|
168
177
|
const pending = this.#breakGlassPending.get(key);
|
|
169
178
|
if (!pending) return;
|
|
170
|
-
if (pending.scopeKey !== context.scopeKey) return;
|
|
171
179
|
this.#breakGlassPending.delete(key);
|
|
172
180
|
if (this.#now() - pending.confirmedAt > this.#overrideTtlMs) {
|
|
173
181
|
return;
|
package/src/index.ts
CHANGED
|
@@ -178,7 +178,7 @@ type ReviewAttemptObservation = {
|
|
|
178
178
|
type PreflightPart = { characters: number; estimatedTokens: number };
|
|
179
179
|
|
|
180
180
|
type ReviewPreflight = {
|
|
181
|
-
estimator: "conservative:
|
|
181
|
+
estimator: "conservative:cjk-aware";
|
|
182
182
|
maxReviewerInputTokens: number;
|
|
183
183
|
framingReserveTokens: number;
|
|
184
184
|
fixedPrompt: PreflightPart;
|
|
@@ -898,10 +898,51 @@ function sharedReviewContext(
|
|
|
898
898
|
});
|
|
899
899
|
}
|
|
900
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Conservative token estimate for reviewer prompt sizing.
|
|
903
|
+
*
|
|
904
|
+
* CJK code points are estimated at one token each: UTF-8 encodes them as
|
|
905
|
+
* three bytes, so the previous byte-for-token estimator overestimated
|
|
906
|
+
* CJK-heavy review payloads ~3x and synchronously failed the input-budget
|
|
907
|
+
* preflight ("reviewer_input_budget_exceeded") for large CJK tool inputs
|
|
908
|
+
* such as long Chinese plan documents — before the reviewer model was ever
|
|
909
|
+
* called. Remaining code points are estimated from their UTF-8 byte length
|
|
910
|
+
* at 3 bytes per token, slightly conservative for ASCII (typical tokenizers
|
|
911
|
+
* average ~4 bytes per token) and safely conservative for 2-4 byte scripts.
|
|
912
|
+
*/
|
|
913
|
+
const OTHER_BYTES_PER_TOKEN = 3;
|
|
914
|
+
|
|
915
|
+
function codePointUtf8Bytes(code: number): number {
|
|
916
|
+
return code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function isCjkCodePoint(code: number): boolean {
|
|
920
|
+
return (
|
|
921
|
+
(code >= 0x3000 && code <= 0x9fff) || // CJK symbols, punctuation, ideographs
|
|
922
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK extension A
|
|
923
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
|
|
924
|
+
(code >= 0xac00 && code <= 0xd7af) || // Hangul syllables
|
|
925
|
+
(code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
|
|
926
|
+
(code >= 0xff00 && code <= 0xffef) // fullwidth/halfwidth forms
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Exported for tests; see the estimator doc comment above. */
|
|
931
|
+
export function estimateReviewerTokens(text: string): number {
|
|
932
|
+
let tokens = 0;
|
|
933
|
+
for (const char of text) {
|
|
934
|
+
const code = char.codePointAt(0) ?? 0;
|
|
935
|
+
tokens += isCjkCodePoint(code)
|
|
936
|
+
? 1
|
|
937
|
+
: codePointUtf8Bytes(code) / OTHER_BYTES_PER_TOKEN;
|
|
938
|
+
}
|
|
939
|
+
return Math.ceil(tokens);
|
|
940
|
+
}
|
|
941
|
+
|
|
901
942
|
function preflightPart(text: string): PreflightPart {
|
|
902
943
|
return {
|
|
903
944
|
characters: text.length,
|
|
904
|
-
estimatedTokens:
|
|
945
|
+
estimatedTokens: estimateReviewerTokens(text),
|
|
905
946
|
};
|
|
906
947
|
}
|
|
907
948
|
|
|
@@ -910,7 +951,7 @@ function combinedPreflightPart(values: readonly string[]): PreflightPart {
|
|
|
910
951
|
(total, value) => ({
|
|
911
952
|
characters: total.characters + value.length,
|
|
912
953
|
estimatedTokens:
|
|
913
|
-
total.estimatedTokens +
|
|
954
|
+
total.estimatedTokens + estimateReviewerTokens(value),
|
|
914
955
|
}),
|
|
915
956
|
{ characters: 0, estimatedTokens: 0 },
|
|
916
957
|
);
|
|
@@ -966,7 +1007,7 @@ function reviewPreflight(
|
|
|
966
1007
|
REVIEWER_FRAMING_RESERVE_TOKENS,
|
|
967
1008
|
};
|
|
968
1009
|
return {
|
|
969
|
-
estimator: "conservative:
|
|
1010
|
+
estimator: "conservative:cjk-aware",
|
|
970
1011
|
maxReviewerInputTokens,
|
|
971
1012
|
framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
|
|
972
1013
|
fixedPrompt,
|
|
@@ -1489,7 +1530,7 @@ function noModelSummary(): ReviewExecutionSummary {
|
|
|
1489
1530
|
compactionState: "none",
|
|
1490
1531
|
},
|
|
1491
1532
|
preflight: {
|
|
1492
|
-
estimator: "conservative:
|
|
1533
|
+
estimator: "conservative:cjk-aware",
|
|
1493
1534
|
maxReviewerInputTokens: DEFAULT_CONFIG.maxReviewerInputTokens,
|
|
1494
1535
|
framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
|
|
1495
1536
|
fixedPrompt: zero,
|
|
@@ -1726,7 +1767,12 @@ async function complete(
|
|
|
1726
1767
|
});
|
|
1727
1768
|
|
|
1728
1769
|
try {
|
|
1729
|
-
|
|
1770
|
+
// A budget preflight failure is a sizing estimate, not a safety verdict.
|
|
1771
|
+
// When a human explicitly authorized this exact retry, their decision
|
|
1772
|
+
// must not be vetoed by an estimator: proceed with the truncated
|
|
1773
|
+
// evidence and let the reviewer see the override. The failureCode stays
|
|
1774
|
+
// on the transcript for observability.
|
|
1775
|
+
if (transcript.failureCode && !reviewerContext?.userOverride) {
|
|
1730
1776
|
incrementError(errorCounts, transcript.failureCode);
|
|
1731
1777
|
throw new ReviewExecutionError(
|
|
1732
1778
|
transcript.failureCode,
|
|
@@ -1964,6 +2010,69 @@ export type PiAutoReviewExtensionOptions = {
|
|
|
1964
2010
|
|
|
1965
2011
|
const POLICY_AUDIT_ENTRY_TYPE = "pi-auto-review-policy-audit";
|
|
1966
2012
|
|
|
2013
|
+
// Headings flagged for "this is actionable" emphasis. The visual weight
|
|
2014
|
+
// otherwise matches the rest of the report and a user skimming the TUI can
|
|
2015
|
+
// miss the two sections that actually drive decisions.
|
|
2016
|
+
const EMPHASIS_HEADINGS = new Set([
|
|
2017
|
+
"Suggested allow rules",
|
|
2018
|
+
"Keep ask",
|
|
2019
|
+
]);
|
|
2020
|
+
|
|
2021
|
+
function wrapWidth(text: string, width: number): string[] {
|
|
2022
|
+
if (width <= 0) return [text];
|
|
2023
|
+
const lines: string[] = [];
|
|
2024
|
+
for (const paragraph of text.split("\n")) {
|
|
2025
|
+
if (paragraph.length === 0) {
|
|
2026
|
+
lines.push("");
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
const tokens = paragraph.split(/(\s+)/);
|
|
2030
|
+
let current = "";
|
|
2031
|
+
let currentWidth = 0;
|
|
2032
|
+
const flush = () => {
|
|
2033
|
+
lines.push(current);
|
|
2034
|
+
current = "";
|
|
2035
|
+
currentWidth = 0;
|
|
2036
|
+
};
|
|
2037
|
+
for (const token of tokens) {
|
|
2038
|
+
if (token.length === 0) continue;
|
|
2039
|
+
const tokenWidth = [...token].reduce(
|
|
2040
|
+
(sum, ch) => sum + ((ch.codePointAt(0) ?? 0) > 0xff ? 2 : 1),
|
|
2041
|
+
0,
|
|
2042
|
+
);
|
|
2043
|
+
if (tokenWidth > width) {
|
|
2044
|
+
if (current.length > 0) flush();
|
|
2045
|
+
for (const char of token) {
|
|
2046
|
+
const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
|
|
2047
|
+
if (currentWidth + charWidth > width) flush();
|
|
2048
|
+
current += char;
|
|
2049
|
+
currentWidth += charWidth;
|
|
2050
|
+
}
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
if (currentWidth + tokenWidth > width && current.length > 0) flush();
|
|
2054
|
+
current += token;
|
|
2055
|
+
currentWidth += tokenWidth;
|
|
2056
|
+
}
|
|
2057
|
+
if (current.length > 0) flush();
|
|
2058
|
+
}
|
|
2059
|
+
return lines.length > 0 ? lines : [""];
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function styleAuditLine(theme: { fg(color: string, text: string): string }, line: string): string {
|
|
2063
|
+
if (line.length === 0) return line;
|
|
2064
|
+
if (line.startsWith("```")) return theme.fg("mdCodeBlockBorder", line);
|
|
2065
|
+
if (line.startsWith("# ")) return theme.fg("mdHeading", line);
|
|
2066
|
+
if (line.startsWith("## ")) {
|
|
2067
|
+
const title = line.slice(3).trim();
|
|
2068
|
+
return theme.fg(EMPHASIS_HEADINGS.has(title) ? "success" : "mdHeading", line);
|
|
2069
|
+
}
|
|
2070
|
+
// JSON payload inside the config code block — color it so it reads as a
|
|
2071
|
+
// config block, not a paragraph the user is expected to read.
|
|
2072
|
+
if (/^[ {]/.test(line) || /^["}{,]/.test(line)) return theme.fg("mdCodeBlock", line);
|
|
2073
|
+
return theme.fg("muted", line);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1967
2076
|
function renderPolicyAuditEntry(
|
|
1968
2077
|
entry: { data?: unknown },
|
|
1969
2078
|
_options: unknown,
|
|
@@ -1977,13 +2086,7 @@ function renderPolicyAuditEntry(
|
|
|
1977
2086
|
return {
|
|
1978
2087
|
render(width: number) {
|
|
1979
2088
|
const max = Math.max(20, width - 2);
|
|
1980
|
-
return markdown.split("\n").flatMap((line) =>
|
|
1981
|
-
const output: string[] = [];
|
|
1982
|
-
for (let offset = 0; offset < Math.max(1, line.length); offset += max) {
|
|
1983
|
-
output.push(theme.fg("muted", line.slice(offset, offset + max)));
|
|
1984
|
-
}
|
|
1985
|
-
return output;
|
|
1986
|
-
});
|
|
2089
|
+
return markdown.split("\n").flatMap((line) => wrapWidth(line, max).map((visual) => styleAuditLine(theme, visual)));
|
|
1987
2090
|
},
|
|
1988
2091
|
invalidate() {},
|
|
1989
2092
|
};
|
|
@@ -2025,6 +2128,20 @@ export function createPiAutoReviewExtension(
|
|
|
2025
2128
|
() => config.autoConfirmBoundedAllows,
|
|
2026
2129
|
);
|
|
2027
2130
|
const reviewWidget = new UserReviewWidgetController();
|
|
2131
|
+
// pi >= 0.84.4 notification-only events: while a ctx.ui prompt blocks the
|
|
2132
|
+
// session during an active review, show "waiting for you" instead of the
|
|
2133
|
+
// misleading "Waiting for <model>…". Best-effort registration: on older
|
|
2134
|
+
// pi these event names do not exist and the overlay stays off.
|
|
2135
|
+
try {
|
|
2136
|
+
pi.on("ui_prompt_start", (event) => {
|
|
2137
|
+
reviewWidget.promptStart(event);
|
|
2138
|
+
});
|
|
2139
|
+
pi.on("ui_prompt_end", () => {
|
|
2140
|
+
reviewWidget.promptEnd();
|
|
2141
|
+
});
|
|
2142
|
+
} catch {
|
|
2143
|
+
// Older pi: widget behavior is unchanged.
|
|
2144
|
+
}
|
|
2028
2145
|
const policyAudit = new PolicyAuditController({
|
|
2029
2146
|
config: () => config.policyAudit,
|
|
2030
2147
|
cwd: () => context?.cwd,
|