diff --git a/packages/pi-permission-ai-judge/README.md b/packages/pi-permission-ai-judge/README.md index ddda987..27f859e 100644 --- a/packages/pi-permission-ai-judge/README.md +++ b/packages/pi-permission-ai-judge/README.md @@ -20,6 +20,8 @@ focus: git push --force origin main (high-risk: history_rewrite) 三态:`defer/deny/allow` 判决带理由(shadow 模式标 `(shadow)`);高风险跳过标类别;模型不可用/超时/异常标 `unavailable`。弹窗被处理后挂件自动消失。配置 `dialogAdvice: false` 关闭。 +Enforce 模式下判官代批(auto-allow,无弹窗)时会发一条非阻塞通知 `ai-bash-judge auto-allowed — <理由>` 留痕,代批决策可审计。 + ## 安装 ```bash diff --git a/packages/pi-permission-ai-judge/src/advice/widget.ts b/packages/pi-permission-ai-judge/src/advice/widget.ts index 5d67e09..f7fdd6b 100644 --- a/packages/pi-permission-ai-judge/src/advice/widget.ts +++ b/packages/pi-permission-ai-judge/src/advice/widget.ts @@ -40,6 +40,9 @@ export interface AdviceWidgetUi { ): void; } +/** Transient-notification seam (ctx.ui.notify's narrow shape). */ +export type AdviceNotify = (message: string, kind: "info") => void; + /** What the judge concluded, in dialog-facing vocabulary. */ export type AdviceView = | { @@ -66,10 +69,15 @@ export interface AdviceFocus { readonly category?: HighRiskCategory; } -const REASON_MAX_CHARS = 180; -const SEGMENT_MAX_CHARS = 120; +const REASON_MAX_CHARS = 600; +const SEGMENT_MAX_CHARS = 240; -/** Collapse model prose to one line and clamp its length (code-point aware). */ +/** + * Collapse model prose to one logical line and clamp pathological length + * (code-point aware). The cap is a sanity guard against runaway model + * output, not a display budget: rendering wraps to the terminal width, so + * everything under the cap is shown in full. + */ function sanitizeLine(text: string, maxChars: number): string { const collapsed = text.replace(/\s+/g, " ").trim(); const chars = [...collapsed]; @@ -155,10 +163,16 @@ export function formatAdvice( */ export class AdvicePresenter { private readonly ui: AdviceWidgetUi | undefined; + private readonly notify: AdviceNotify | undefined; private currentRequestId: string | undefined; - constructor(ui: AdviceWidgetUi | undefined, enabled: boolean) { + constructor( + ui: AdviceWidgetUi | undefined, + notify: AdviceNotify | undefined, + enabled: boolean, + ) { this.ui = enabled ? ui : undefined; + this.notify = enabled ? notify : undefined; } present(requestId: string, view: AdviceView, focus?: AdviceFocus): void { @@ -179,6 +193,21 @@ export class AdvicePresenter { })); } + /** + * Transient trace for an Enforce auto-allow: no dialog ever shows, so + * there is no widget — a notify line keeps the delegation auditable + * (PIEXTENSIO-13 option 3: visibility without interruption). + */ + notifyAllowed(reason: string): void { + if (this.notify === undefined) { + return; + } + this.notify( + `ai-bash-judge auto-allowed — ${sanitizeLine(reason, REASON_MAX_CHARS)}`, + "info", + ); + } + /** Clear the widget iff the decision resolves the request it describes. */ handleDecision(requestId: string): void { if (this.ui === undefined || requestId !== this.currentRequestId) { diff --git a/packages/pi-permission-ai-judge/src/index.ts b/packages/pi-permission-ai-judge/src/index.ts index 8597704..8ebd3d8 100644 --- a/packages/pi-permission-ai-judge/src/index.ts +++ b/packages/pi-permission-ai-judge/src/index.ts @@ -543,30 +543,33 @@ function enforceAndEmit( } // Dialog advice rides only the defer arm: an Enforce allow never shows - // a dialog, so there is nothing for the widget to annotate. - if (authority.kind !== "allow") { - const view: AdviceView = - result.kind === "judgment" - ? { - state: "judgment", - verdict: result.verdict, - reason: result.reason, - shadow: captured.config.mode === "shadow", - } - : { - state: "unavailable", - cause: `model call failed (${result.kind})`, - }; - ctx.captured.advice.present( - ctx.details.requestId, - view, - adviceFocus, - ); + // a dialog, so there is nothing for the widget to annotate — instead a + // transient notify keeps the auto-approval auditable. + if (authority.kind === "allow") { + if (result.kind === "judgment") { + ctx.captured.advice.notifyAllowed(result.reason); + } + return { kind: "allow" }; } - return authority.kind === "allow" - ? { kind: "allow" } - : { kind: "defer" }; + const view: AdviceView = + result.kind === "judgment" + ? { + state: "judgment", + verdict: result.verdict, + reason: result.reason, + shadow: captured.config.mode === "shadow", + } + : { + state: "unavailable", + cause: `model call failed (${result.kind})`, + }; + ctx.captured.advice.present( + ctx.details.requestId, + view, + adviceFocus, + ); + return { kind: "defer" }; } /** @@ -770,7 +773,11 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { }), conversation: conversationProbeFromSession(ctx.sessionManager), getCwd: () => ctx.sessionManager.getCwd(), - advice: new AdvicePresenter(ctx.ui, config.dialogAdvice), + advice: new AdvicePresenter( + ctx.ui, + (message, kind) => ctx.ui.notify(message, kind), + config.dialogAdvice, + ), }; for (const diagnostic of root.config.diagnostics) { ctx.ui.notify( diff --git a/packages/pi-permission-ai-judge/test/advice.test.ts b/packages/pi-permission-ai-judge/test/advice.test.ts index f8fb1e6..e5b273e 100644 --- a/packages/pi-permission-ai-judge/test/advice.test.ts +++ b/packages/pi-permission-ai-judge/test/advice.test.ts @@ -103,19 +103,32 @@ describe("formatAdvice", () => { expect(advice.lines[1]).toBe("focus: x y (executed-unit)"); }); - it("clamps long reasons and segments with an ellipsis", () => { - const long = "a".repeat(400); + it("clamps pathological reasons and segments with an ellipsis", () => { + const long = "a".repeat(4000); const advice = formatAdvice( { state: "judgment", verdict: "defer", reason: long, shadow: false }, { segment: long, origin: "triggering-unit" }, ); - expect([...advice.lines[0]!].length).toBe("ai-judge defer — ".length + 180); + expect([...advice.lines[0]!].length).toBe("ai-judge defer — ".length + 600); expect(advice.lines[0]!.endsWith("…")).toBe(true); const focusLine = advice.lines[1]!; const segment = focusLine.slice("focus: ".length, focusLine.lastIndexOf(" (")); - expect([...segment].length).toBe(120); + expect([...segment].length).toBe(240); expect(segment.endsWith("…")).toBe(true); }); + + it("keeps ordinary multi-sentence reasons complete", () => { + const reason = + "命令会重写已发布的历史记录且用户意图未确立。".repeat(6); + const advice = formatAdvice({ + state: "judgment", + verdict: "defer", + reason, + shadow: false, + }); + expect(advice.lines[0]!.endsWith("。")); + expect(advice.lines[0]!.endsWith("…")).toBe(false); + }); }); function recordingUi(): AdviceWidgetUi & { @@ -133,7 +146,7 @@ function recordingUi(): AdviceWidgetUi & { describe("AdvicePresenter", () => { it("sets a themed widget on present and clamps to render width", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); presenter.present("req-1", { state: "judgment", verdict: "defer", @@ -162,7 +175,7 @@ describe("AdvicePresenter", () => { it("never renders wider than the terminal and loses no CJK text", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); const reason = "命令会重写已发布的历史记录且用户意图未确立".repeat(10); presenter.present("req-1", { state: "judgment", @@ -182,15 +195,14 @@ describe("AdvicePresenter", () => { } expect(visibleWidth(lines[0]!)).toBeGreaterThan(0); } - // Wrapping, not truncation: the full (sanitized) reason survives. - const sanitized = [...reason].slice(0, 180).join(""); + // Wrapping, not truncation: the full reason survives the render. const joined = render(component, 20).map(stripAnsi).join(""); - expect(joined).toContain(sanitized.slice(-REASON_TAIL)); + expect(joined).toContain(reason.slice(-REASON_TAIL)); }); it("is a no-op end to end when disabled", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, false); + const presenter = new AdvicePresenter(ui, undefined, false); presenter.present("req-1", { state: "unavailable", cause: "off", @@ -202,7 +214,7 @@ describe("AdvicePresenter", () => { it("clears only on the decision that resolves the current request", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); presenter.present("req-1", { state: "unavailable", cause: "x" }); presenter.handleDecision("req-other"); expect(ui.calls).toHaveLength(1); @@ -213,7 +225,7 @@ describe("AdvicePresenter", () => { it("a later present re-keys the clear guard onto the new request", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); presenter.present("req-1", { state: "unavailable", cause: "x" }); presenter.present("req-2", { state: "unavailable", cause: "y" }); presenter.handleDecision("req-1"); @@ -225,7 +237,7 @@ describe("AdvicePresenter", () => { it("shutdown clears the widget", () => { const ui = recordingUi(); - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); presenter.present("req-1", { state: "unavailable", cause: "x" }); presenter.shutdown(); expect(ui.calls.at(-1)).toEqual({ @@ -233,13 +245,31 @@ describe("AdvicePresenter", () => { content: undefined, }); }); + + it("emits a sanitized auto-allow notify", () => { + const notify = vi.fn(); + const presenter = new AdvicePresenter(recordingUi(), notify, true); + presenter.notifyAllowed(" command matches\nexplicit user intent "); + expect(notify).toHaveBeenCalledWith( + "ai-bash-judge auto-allowed — command matches explicit user intent", + "info", + ); + }); + + it("skips the auto-allow notify when disabled", () => { + const notify = vi.fn(); + const presenter = new AdvicePresenter(recordingUi(), notify, false); + presenter.notifyAllowed("r"); + presenter.present("r", { state: "unavailable", cause: "x" }); + expect(notify).not.toHaveBeenCalled(); + }); }); describe("AdvicePresenter with a real ExtensionUIContext-shaped ui", () => { it("accepts the overload-style setWidget surface", () => { const setWidget = vi.fn(); const ui = { setWidget } as unknown as AdviceWidgetUi; - const presenter = new AdvicePresenter(ui, true); + const presenter = new AdvicePresenter(ui, undefined, true); presenter.present("r", { state: "unavailable", cause: "shape" }); expect(setWidget).toHaveBeenCalledWith( ADVICE_WIDGET_KEY,