mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
feat(ai-judge): show dialog advice widget with verdict and focus
- add AdvicePresenter rendering a pi-native setWidget panel while a permission dialog is up: judgment verdict with reason, high-risk skip with category, or an unavailable cause - highlight the decision-relevant command fragment via a focus cascade: high-risk match, triggering unit, then executed unit - clear the widget when permissions:decision resolves the same request - add dialogAdvice config key (default true, invalid falls back with a diagnostic) and cover formatting, lifecycle, and config in tests
This commit is contained in:
@@ -9,6 +9,17 @@ pi 的 Bash 权限 AI 判官:每条待确认的 Bash 命令先交给模型,
|
|||||||
|
|
||||||
enforce 是自担风险的便利模式:模型误判,危险命令可能在无人确认时执行。退出方式:mode 改回 shadow。
|
enforce 是自担风险的便利模式:模型误判,危险命令可能在无人确认时执行。退出方式:mode 改回 shadow。
|
||||||
|
|
||||||
|
## 弹窗意见挂件
|
||||||
|
|
||||||
|
每次弹窗出现前,判官会在编辑器上方挂一个意见面板,说明自己的判断和理由,长命令会标注重点片段(focus 行):
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-judge defer — command rewrites published history; intent not established
|
||||||
|
focus: git push --force origin main (high-risk: history_rewrite)
|
||||||
|
```
|
||||||
|
|
||||||
|
三态:`defer/deny/allow` 判决带理由(shadow 模式标 `(shadow)`);高风险跳过标类别;模型不可用/超时/异常标 `unavailable`。弹窗被处理后挂件自动消失。配置 `dialogAdvice: false` 关闭。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -47,6 +58,7 @@ pi install github.com/SikongJueluo/pi-extensions
|
|||||||
| `mode` | `shadow`(默认)或 `enforce`,非法值回退 shadow |
|
| `mode` | `shadow`(默认)或 `enforce`,非法值回退 shadow |
|
||||||
| `model` | 可选,固定判官模型;不写则跟随会话模型。解析失败按故障处理并弹窗,绝不静默改用会话模型 |
|
| `model` | 可选,固定判官模型;不写则跟随会话模型。解析失败按故障处理并弹窗,绝不静默改用会话模型 |
|
||||||
| `timeoutMs` | 单次判决等待上限,5000–30000,默认 15000 |
|
| `timeoutMs` | 单次判决等待上限,5000–30000,默认 15000 |
|
||||||
|
| `dialogAdvice` | `true`(默认)或 `false`;弹窗期间的判官意见挂件开关 |
|
||||||
|
|
||||||
## enforce 的防线
|
## enforce 的防线
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { HighRiskCategory } from "../authority/highrisk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog advice widget (PIEXTENSIO-13): a pi-native `setWidget` panel shown
|
||||||
|
* while a permission dialog is up, so the human sees what the judge said —
|
||||||
|
* or why it could not judge — instead of a silent dialog.
|
||||||
|
*
|
||||||
|
* The widget is set at defer time (the chain link runs before the terminal
|
||||||
|
* renders the dialog) and cleared when the permission system broadcasts a
|
||||||
|
* `permissions:decision` for the same request. Render-only: it never gates,
|
||||||
|
* allows, denies, or suppresses anything (ADR 0011 §8's annotation contract,
|
||||||
|
* applied on the judge's side of the seam).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ADVICE_WIDGET_KEY = "ai-bash-judge-advice";
|
||||||
|
|
||||||
|
/** Theme colors the advice lines use; all exist in pi's theme vocabulary. */
|
||||||
|
export type AdviceColor = "accent" | "warning" | "dim";
|
||||||
|
|
||||||
|
/** The pi theme surface the widget factory needs (narrow seam for tests). */
|
||||||
|
export interface AdviceTheme {
|
||||||
|
fg(color: AdviceColor, text: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal renderable component (structural subset of pi-tui's Component). */
|
||||||
|
export interface AdviceComponent {
|
||||||
|
render(width: number): string[];
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pi UI surface the presenter needs (narrow seam for tests). */
|
||||||
|
export interface AdviceWidgetUi {
|
||||||
|
setWidget(
|
||||||
|
key: string,
|
||||||
|
content:
|
||||||
|
| string[]
|
||||||
|
| ((tui: unknown, theme: AdviceTheme) => AdviceComponent)
|
||||||
|
| undefined,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the judge concluded, in dialog-facing vocabulary. */
|
||||||
|
export type AdviceView =
|
||||||
|
| {
|
||||||
|
readonly state: "judgment";
|
||||||
|
readonly verdict: "allow" | "deny" | "defer";
|
||||||
|
readonly reason: string;
|
||||||
|
/** True in Shadow mode: the dialog shows regardless of the verdict. */
|
||||||
|
readonly shadow: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly state: "skipped";
|
||||||
|
readonly category: HighRiskCategory;
|
||||||
|
readonly rule: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly state: "unavailable";
|
||||||
|
readonly cause: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The decision-relevant command fragment highlighted for long commands. */
|
||||||
|
export interface AdviceFocus {
|
||||||
|
readonly segment: string;
|
||||||
|
readonly origin: "high-risk" | "triggering-unit" | "executed-unit";
|
||||||
|
readonly category?: HighRiskCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON_MAX_CHARS = 180;
|
||||||
|
const SEGMENT_MAX_CHARS = 120;
|
||||||
|
|
||||||
|
/** Collapse model prose to one line and clamp its length (code-point aware). */
|
||||||
|
function sanitizeLine(text: string, maxChars: number): string {
|
||||||
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
|
const chars = [...collapsed];
|
||||||
|
if (chars.length <= maxChars) {
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
return `${chars.slice(0, maxChars - 1).join("")}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a rendered line to the live terminal width. */
|
||||||
|
function clampToWidth(line: string, width: number): string {
|
||||||
|
if (width <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const chars = [...line];
|
||||||
|
if (chars.length <= width) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return width >= 2
|
||||||
|
? `${chars.slice(0, width - 1).join("")}…`
|
||||||
|
: chars.slice(0, width).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdviceLines {
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
readonly colors: readonly AdviceColor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure renderer: one verdict line plus an optional focus line.
|
||||||
|
* `skipped`/`unavailable` carry their own cause in line one.
|
||||||
|
*/
|
||||||
|
export function formatAdvice(
|
||||||
|
view: AdviceView,
|
||||||
|
focus?: AdviceFocus,
|
||||||
|
): AdviceLines {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const colors: AdviceColor[] = [];
|
||||||
|
switch (view.state) {
|
||||||
|
case "judgment": {
|
||||||
|
const suffix = view.shadow ? " (shadow)" : "";
|
||||||
|
lines.push(
|
||||||
|
`ai-judge ${view.verdict}${suffix} — ${sanitizeLine(view.reason, REASON_MAX_CHARS)}`,
|
||||||
|
);
|
||||||
|
colors.push(
|
||||||
|
view.verdict === "deny" ? "warning" : "accent",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "skipped": {
|
||||||
|
lines.push(
|
||||||
|
`ai-judge skipped — high-risk ${view.category} (${view.rule}); forced dialog`,
|
||||||
|
);
|
||||||
|
colors.push("warning");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "unavailable": {
|
||||||
|
lines.push(
|
||||||
|
`ai-judge unavailable — ${sanitizeLine(view.cause, REASON_MAX_CHARS)}; not judged`,
|
||||||
|
);
|
||||||
|
colors.push("dim");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (focus !== undefined) {
|
||||||
|
const label =
|
||||||
|
focus.category !== undefined
|
||||||
|
? `high-risk: ${focus.category}`
|
||||||
|
: focus.origin;
|
||||||
|
lines.push(
|
||||||
|
`focus: ${sanitizeLine(focus.segment, SEGMENT_MAX_CHARS)} (${label})`,
|
||||||
|
);
|
||||||
|
colors.push("dim");
|
||||||
|
}
|
||||||
|
return { lines, colors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the widget lifecycle: present on defer, clear when the decision
|
||||||
|
* resolves the same request, clear on shutdown. Disabled at construction
|
||||||
|
* (`dialogAdvice: false`) makes every method a no-op.
|
||||||
|
*/
|
||||||
|
export class AdvicePresenter {
|
||||||
|
private readonly ui: AdviceWidgetUi | undefined;
|
||||||
|
private currentRequestId: string | undefined;
|
||||||
|
|
||||||
|
constructor(ui: AdviceWidgetUi | undefined, enabled: boolean) {
|
||||||
|
this.ui = enabled ? ui : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(requestId: string, view: AdviceView, focus?: AdviceFocus): void {
|
||||||
|
if (this.ui === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.currentRequestId = requestId;
|
||||||
|
const { lines, colors } = formatAdvice(view, focus);
|
||||||
|
this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({
|
||||||
|
render: (width: number) =>
|
||||||
|
lines.map((line, index) =>
|
||||||
|
theme.fg(colors[index] ?? "dim", clampToWidth(line, width)),
|
||||||
|
),
|
||||||
|
invalidate: () => {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the widget iff the decision resolves the request it describes. */
|
||||||
|
handleDecision(requestId: string): void {
|
||||||
|
if (this.ui === undefined || requestId !== this.currentRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.ui === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(): void {
|
||||||
|
this.currentRequestId = undefined;
|
||||||
|
this.ui?.setWidget(ADVICE_WIDGET_KEY, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export interface EffectiveJudgeConfig {
|
|||||||
readonly timeoutCohort: "default" | number;
|
readonly timeoutCohort: "default" | number;
|
||||||
/** Fixed judge model (v2 only); undefined follows the session model. */
|
/** Fixed judge model (v2 only); undefined follows the session model. */
|
||||||
readonly judgeModel: JudgeModelSelection | undefined;
|
readonly judgeModel: JudgeModelSelection | undefined;
|
||||||
|
/** Show the dialog-advice widget while a permission dialog is up. */
|
||||||
|
readonly dialogAdvice: boolean;
|
||||||
/** Validation diagnostics for the loaded raw file, newest wins per key. */
|
/** Validation diagnostics for the loaded raw file, newest wins per key. */
|
||||||
readonly diagnostics: readonly ConfigDiagnostic[];
|
readonly diagnostics: readonly ConfigDiagnostic[];
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ const DEFAULT_CONFIG: EffectiveJudgeConfig = {
|
|||||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
timeoutCohort: "default",
|
timeoutCohort: "default",
|
||||||
judgeModel: undefined,
|
judgeModel: undefined,
|
||||||
|
dialogAdvice: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +227,28 @@ function parseTimeout(record: Record<string, unknown>): {
|
|||||||
* diagnostic. Version 1 / unversioned files keep v1 semantics except
|
* diagnostic. Version 1 / unversioned files keep v1 semantics except
|
||||||
* that `enforce` fails closed to shadow pending explicit migration.
|
* that `enforce` fails closed to shadow pending explicit migration.
|
||||||
*/
|
*/
|
||||||
|
/** Parse `dialogAdvice`: absent keeps the default (true); invalid falls back. */
|
||||||
|
function parseDialogAdvice(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): { dialogAdvice: boolean; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
if (record.dialogAdvice === undefined) {
|
||||||
|
return { dialogAdvice: true, diagnostics: [] };
|
||||||
|
}
|
||||||
|
if (typeof record.dialogAdvice === "boolean") {
|
||||||
|
return { dialogAdvice: record.dialogAdvice, diagnostics: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dialogAdvice: true,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "dialogAdvice",
|
||||||
|
problem: `not a boolean: ${JSON.stringify(record.dialogAdvice)}`,
|
||||||
|
fallback: "true",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function loadJudgeConfig(
|
export function loadJudgeConfig(
|
||||||
deps: ConfigLoadDeps,
|
deps: ConfigLoadDeps,
|
||||||
): EffectiveJudgeConfig {
|
): EffectiveJudgeConfig {
|
||||||
@@ -289,12 +314,16 @@ export function loadJudgeConfig(
|
|||||||
const timeoutResult = parseTimeout(record);
|
const timeoutResult = parseTimeout(record);
|
||||||
diagnostics.push(...timeoutResult.diagnostics);
|
diagnostics.push(...timeoutResult.diagnostics);
|
||||||
|
|
||||||
|
const adviceResult = parseDialogAdvice(record);
|
||||||
|
diagnostics.push(...adviceResult.diagnostics);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
configVersion,
|
configVersion,
|
||||||
mode,
|
mode,
|
||||||
timeoutMs: timeoutResult.timeoutMs,
|
timeoutMs: timeoutResult.timeoutMs,
|
||||||
timeoutCohort: timeoutResult.timeoutCohort,
|
timeoutCohort: timeoutResult.timeoutCohort,
|
||||||
judgeModel: modelResult.judgeModel,
|
judgeModel: modelResult.judgeModel,
|
||||||
|
dialogAdvice: adviceResult.dialogAdvice,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { Model } from "@earendil-works/pi-ai";
|
|||||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
getPermissionsService,
|
getPermissionsService,
|
||||||
|
PERMISSIONS_DECISION_CHANNEL,
|
||||||
PERMISSIONS_READY_CHANNEL,
|
PERMISSIONS_READY_CHANNEL,
|
||||||
|
type PermissionDecisionEvent,
|
||||||
type PromptPermissionDetails,
|
type PromptPermissionDetails,
|
||||||
type AuthorizerLog,
|
type AuthorizerLog,
|
||||||
type AuthorizerVerdict,
|
type AuthorizerVerdict,
|
||||||
@@ -28,6 +30,11 @@ import {
|
|||||||
type ConversationEvidence,
|
type ConversationEvidence,
|
||||||
} from "./evidence/conversation";
|
} from "./evidence/conversation";
|
||||||
import { classifyHighRisk, type HighRiskMatch } from "./authority/highrisk";
|
import { classifyHighRisk, type HighRiskMatch } from "./authority/highrisk";
|
||||||
|
import {
|
||||||
|
AdvicePresenter,
|
||||||
|
type AdviceFocus,
|
||||||
|
type AdviceView,
|
||||||
|
} from "./advice/widget";
|
||||||
import { evaluateEnforceAuthority, type EnforceGateState } from "./authority/enforce";
|
import { evaluateEnforceAuthority, type EnforceGateState } from "./authority/enforce";
|
||||||
import {
|
import {
|
||||||
classifyModel,
|
classifyModel,
|
||||||
@@ -60,6 +67,8 @@ interface RootSession {
|
|||||||
readonly getCwd: () => string;
|
readonly getCwd: () => string;
|
||||||
/** Judge-owned audit log (ADR 0006); unhealthy refuses Enforce authority. */
|
/** Judge-owned audit log (ADR 0006); unhealthy refuses Enforce authority. */
|
||||||
readonly auditLog: AuditLog;
|
readonly auditLog: AuditLog;
|
||||||
|
/** Dialog-advice widget presenter (no-op when disabled by config). */
|
||||||
|
readonly advice: AdvicePresenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_CONVERSATION: ConversationEvidence = {
|
const EMPTY_CONVERSATION: ConversationEvidence = {
|
||||||
@@ -363,6 +372,10 @@ function runPreflightGates(
|
|||||||
details.forwarding !== undefined ||
|
details.forwarding !== undefined ||
|
||||||
details.payload.kind === "forwarded"
|
details.payload.kind === "forwarded"
|
||||||
) {
|
) {
|
||||||
|
ctx.captured.advice.present(details.requestId, {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "forwarded ask carries no structured bash input",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
kind: "stop",
|
kind: "stop",
|
||||||
verdict: preflightDefer(
|
verdict: preflightDefer(
|
||||||
@@ -378,6 +391,10 @@ function runPreflightGates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (captured.getSessionId() !== captured.expectedSessionId) {
|
if (captured.getSessionId() !== captured.expectedSessionId) {
|
||||||
|
ctx.captured.advice.present(details.requestId, {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "session ownership unproven",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
kind: "stop",
|
kind: "stop",
|
||||||
verdict: preflightDefer(
|
verdict: preflightDefer(
|
||||||
@@ -390,6 +407,10 @@ function runPreflightGates(
|
|||||||
|
|
||||||
const evidence = buildBashJudgmentEvidence(details);
|
const evidence = buildBashJudgmentEvidence(details);
|
||||||
if (evidence === undefined) {
|
if (evidence === undefined) {
|
||||||
|
ctx.captured.advice.present(details.requestId, {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "bash evidence missing or invalid",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
kind: "stop",
|
kind: "stop",
|
||||||
verdict: preflightDefer(
|
verdict: preflightDefer(
|
||||||
@@ -402,6 +423,11 @@ function runPreflightGates(
|
|||||||
|
|
||||||
const risk = classifyHighRisk(evidence.fullCommand);
|
const risk = classifyHighRisk(evidence.fullCommand);
|
||||||
if (risk !== undefined && captured.config.mode === "enforce") {
|
if (risk !== undefined && captured.config.mode === "enforce") {
|
||||||
|
ctx.captured.advice.present(
|
||||||
|
details.requestId,
|
||||||
|
{ state: "skipped", category: risk.category, rule: risk.rule },
|
||||||
|
buildAdviceFocus(evidence, risk, details),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
kind: "stop",
|
kind: "stop",
|
||||||
verdict: preflightDefer(
|
verdict: preflightDefer(
|
||||||
@@ -439,6 +465,10 @@ function prepareModelCall(
|
|||||||
captured.modelRegistry,
|
captured.modelRegistry,
|
||||||
);
|
);
|
||||||
if (resolved.kind === "unavailable") {
|
if (resolved.kind === "unavailable") {
|
||||||
|
ctx.captured.advice.present(ctx.details.requestId, {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "judge model unresolved",
|
||||||
|
});
|
||||||
infrastructureDefer(
|
infrastructureDefer(
|
||||||
ctx,
|
ctx,
|
||||||
"judge_model_unavailable",
|
"judge_model_unavailable",
|
||||||
@@ -479,6 +509,7 @@ function enforceAndEmit(
|
|||||||
conversation: ConversationEvidence,
|
conversation: ConversationEvidence,
|
||||||
risk: HighRiskMatch | undefined,
|
risk: HighRiskMatch | undefined,
|
||||||
modelSource: "configured" | "session",
|
modelSource: "configured" | "session",
|
||||||
|
adviceFocus: AdviceFocus | undefined,
|
||||||
): AuthorizerVerdict {
|
): AuthorizerVerdict {
|
||||||
const gateState: EnforceGateState = {
|
const gateState: EnforceGateState = {
|
||||||
auditHealthy: captured.auditLog.healthy(),
|
auditHealthy: captured.auditLog.healthy(),
|
||||||
@@ -511,6 +542,28 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return authority.kind === "allow"
|
return authority.kind === "allow"
|
||||||
? { kind: "allow" }
|
? { kind: "allow" }
|
||||||
: { kind: "defer" };
|
: { kind: "defer" };
|
||||||
@@ -570,16 +623,52 @@ async function judgeAuthorize(
|
|||||||
return enforceAndEmit(
|
return enforceAndEmit(
|
||||||
ctx, captured, sink, result, conversation, gate.risk,
|
ctx, captured, sink, result, conversation, gate.risk,
|
||||||
prepared.modelSource,
|
prepared.modelSource,
|
||||||
|
buildAdviceFocus(gate.evidence, gate.risk, details),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// A link exception would abort the whole authority chain.
|
// A link exception would abort the whole authority chain.
|
||||||
// Keep provider/payload/session failures fail-closed and do
|
// Keep provider/payload/session failures fail-closed and do
|
||||||
// not include raw errors or authorization evidence in logs.
|
// not include raw errors or authorization evidence in logs.
|
||||||
sink.debug("ai_bash_judge.exception");
|
sink.debug("ai_bash_judge.exception");
|
||||||
|
captured.advice.present(details.requestId, {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "judge internal error",
|
||||||
|
});
|
||||||
return { kind: "defer" };
|
return { kind: "defer" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus cascade for the dialog-advice widget (PIEXTENSIO-13): the
|
||||||
|
* high-risk match segment when one exists, else the unit that triggered
|
||||||
|
* the ask, else the executed unit the payload carries. Omitted when the
|
||||||
|
* payload offers nothing narrower than the full command.
|
||||||
|
*/
|
||||||
|
function buildAdviceFocus(
|
||||||
|
evidence: BashJudgmentEvidence,
|
||||||
|
risk: HighRiskMatch | undefined,
|
||||||
|
details: PromptPermissionDetails,
|
||||||
|
): AdviceFocus | undefined {
|
||||||
|
if (risk !== undefined) {
|
||||||
|
return {
|
||||||
|
segment: evidence.triggeringUnit ?? evidence.fullCommand,
|
||||||
|
origin: "high-risk",
|
||||||
|
category: risk.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (evidence.triggeringUnit !== undefined) {
|
||||||
|
return {
|
||||||
|
segment: evidence.triggeringUnit,
|
||||||
|
origin: "triggering-unit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const executedUnit = details.payload.request.executedUnit;
|
||||||
|
if (executedUnit !== null && executedUnit !== undefined) {
|
||||||
|
return { segment: executedUnit, origin: "executed-unit" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One non-blocking session notice in Enforce mode: the effective judge
|
* One non-blocking session notice in Enforce mode: the effective judge
|
||||||
* model and the risk contract in one line (ADR 0008). Not repeated per
|
* model and the risk contract in one line (ADR 0008). Not repeated per
|
||||||
@@ -681,6 +770,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
}),
|
}),
|
||||||
conversation: conversationProbeFromSession(ctx.sessionManager),
|
conversation: conversationProbeFromSession(ctx.sessionManager),
|
||||||
getCwd: () => ctx.sessionManager.getCwd(),
|
getCwd: () => ctx.sessionManager.getCwd(),
|
||||||
|
advice: new AdvicePresenter(ctx.ui, config.dialogAdvice),
|
||||||
};
|
};
|
||||||
for (const diagnostic of root.config.diagnostics) {
|
for (const diagnostic of root.config.diagnostics) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
@@ -714,8 +804,20 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
|
|
||||||
pi.events.on(PERMISSIONS_READY_CHANNEL, tryRegister);
|
pi.events.on(PERMISSIONS_READY_CHANNEL, tryRegister);
|
||||||
|
|
||||||
|
// Dialog-advice lifecycle (PIEXTENSIO-13): the permission system
|
||||||
|
// broadcasts one decision per request; the one resolving the request
|
||||||
|
// the widget describes takes the widget down with it.
|
||||||
|
pi.events.on(
|
||||||
|
PERMISSIONS_DECISION_CHANNEL,
|
||||||
|
(data: unknown) => {
|
||||||
|
const event = data as PermissionDecisionEvent;
|
||||||
|
root?.advice.handleDecision(event.requestId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
pi.on("session_shutdown", () => {
|
pi.on("session_shutdown", () => {
|
||||||
root?.shutdown.abort();
|
root?.shutdown.abort();
|
||||||
|
root?.advice.shutdown();
|
||||||
disposeAuthorizer?.();
|
disposeAuthorizer?.();
|
||||||
disposeAuthorizer = undefined;
|
disposeAuthorizer = undefined;
|
||||||
root = undefined;
|
root = undefined;
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ADVICE_WIDGET_KEY,
|
||||||
|
AdvicePresenter,
|
||||||
|
formatAdvice,
|
||||||
|
type AdviceComponent,
|
||||||
|
type AdviceTheme,
|
||||||
|
type AdviceWidgetUi,
|
||||||
|
} from "../src/advice/widget";
|
||||||
|
|
||||||
|
const theme: AdviceTheme = {
|
||||||
|
fg: (color, text) => `<${color}>${text}</>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(component: AdviceComponent, width = 200): string[] {
|
||||||
|
return component.render(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatAdvice", () => {
|
||||||
|
it("renders a judgment line with verdict and reason", () => {
|
||||||
|
const advice = formatAdvice({
|
||||||
|
state: "judgment",
|
||||||
|
verdict: "defer",
|
||||||
|
reason: "command rewrites published history; intent not established",
|
||||||
|
shadow: false,
|
||||||
|
});
|
||||||
|
expect(advice.lines).toEqual([
|
||||||
|
"ai-judge defer — command rewrites published history; intent not established",
|
||||||
|
]);
|
||||||
|
expect(advice.colors).toEqual(["accent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks shadow verdicts and colors deny as warning", () => {
|
||||||
|
const advice = formatAdvice({
|
||||||
|
state: "judgment",
|
||||||
|
verdict: "deny",
|
||||||
|
reason: "credential read without user intent",
|
||||||
|
shadow: true,
|
||||||
|
});
|
||||||
|
expect(advice.lines[0]).toBe(
|
||||||
|
"ai-judge deny (shadow) — credential read without user intent",
|
||||||
|
);
|
||||||
|
expect(advice.colors[0]).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the skipped state with category and rule", () => {
|
||||||
|
const advice = formatAdvice({
|
||||||
|
state: "skipped",
|
||||||
|
category: "data_loss",
|
||||||
|
rule: "git clean -xfd",
|
||||||
|
});
|
||||||
|
expect(advice.lines).toEqual([
|
||||||
|
"ai-judge skipped — high-risk data_loss (git clean -xfd); forced dialog",
|
||||||
|
]);
|
||||||
|
expect(advice.colors).toEqual(["warning"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the unavailable state with cause", () => {
|
||||||
|
const advice = formatAdvice({
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "model call failed (timeout)",
|
||||||
|
});
|
||||||
|
expect(advice.lines).toEqual([
|
||||||
|
"ai-judge unavailable — model call failed (timeout); not judged",
|
||||||
|
]);
|
||||||
|
expect(advice.colors).toEqual(["dim"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a focus line for a high-risk match", () => {
|
||||||
|
const advice = formatAdvice(
|
||||||
|
{ state: "skipped", category: "history_rewrite", rule: "git push --force" },
|
||||||
|
{ segment: "git push --force origin main", origin: "high-risk", category: "history_rewrite" },
|
||||||
|
);
|
||||||
|
expect(advice.lines[1]).toBe(
|
||||||
|
"focus: git push --force origin main (high-risk: history_rewrite)",
|
||||||
|
);
|
||||||
|
expect(advice.colors[1]).toBe("dim");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a plain triggering-unit focus by origin", () => {
|
||||||
|
const advice = formatAdvice(
|
||||||
|
{ state: "judgment", verdict: "defer", reason: "r", shadow: false },
|
||||||
|
{ segment: "rm -rf build", origin: "triggering-unit" },
|
||||||
|
);
|
||||||
|
expect(advice.lines[1]).toBe("focus: rm -rf build (triggering-unit)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses whitespace in reason and focus", () => {
|
||||||
|
const advice = formatAdvice(
|
||||||
|
{ state: "judgment", verdict: "defer", reason: "a\n b\t\tc", shadow: false },
|
||||||
|
{ segment: "x\n y", origin: "executed-unit" },
|
||||||
|
);
|
||||||
|
expect(advice.lines[0]).toContain("— a b c");
|
||||||
|
expect(advice.lines[1]).toBe("focus: x y (executed-unit)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps long reasons and segments with an ellipsis", () => {
|
||||||
|
const long = "a".repeat(400);
|
||||||
|
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]!.endsWith("…")).toBe(true);
|
||||||
|
const focusLine = advice.lines[1]!;
|
||||||
|
const segment = focusLine.slice("focus: ".length, focusLine.lastIndexOf(" ("));
|
||||||
|
expect([...segment].length).toBe(120);
|
||||||
|
expect(segment.endsWith("…")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function recordingUi(): AdviceWidgetUi & {
|
||||||
|
calls: Array<{ key: string; content: unknown }>;
|
||||||
|
} {
|
||||||
|
const calls: Array<{ key: string; content: unknown }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
setWidget(key, content) {
|
||||||
|
calls.push({ key, content });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AdvicePresenter", () => {
|
||||||
|
it("sets a themed widget on present and clamps to render width", () => {
|
||||||
|
const ui = recordingUi();
|
||||||
|
const presenter = new AdvicePresenter(ui, true);
|
||||||
|
presenter.present("req-1", {
|
||||||
|
state: "judgment",
|
||||||
|
verdict: "defer",
|
||||||
|
reason: "ambiguous intent",
|
||||||
|
shadow: false,
|
||||||
|
});
|
||||||
|
expect(ui.calls).toHaveLength(1);
|
||||||
|
expect(ui.calls[0]!.key).toBe(ADVICE_WIDGET_KEY);
|
||||||
|
const factory = ui.calls[0]!.content as (
|
||||||
|
tui: unknown,
|
||||||
|
theme: AdviceTheme,
|
||||||
|
) => AdviceComponent;
|
||||||
|
const component = factory({}, theme);
|
||||||
|
expect(render(component, 200)).toEqual([
|
||||||
|
"<accent>ai-judge defer — ambiguous intent</>",
|
||||||
|
]);
|
||||||
|
expect(render(component, 10)[0]).toBe(
|
||||||
|
"<accent>ai-judge …</>",
|
||||||
|
);
|
||||||
|
component.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op end to end when disabled", () => {
|
||||||
|
const ui = recordingUi();
|
||||||
|
const presenter = new AdvicePresenter(ui, false);
|
||||||
|
presenter.present("req-1", {
|
||||||
|
state: "unavailable",
|
||||||
|
cause: "off",
|
||||||
|
});
|
||||||
|
presenter.handleDecision("req-1");
|
||||||
|
presenter.shutdown();
|
||||||
|
expect(ui.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears only on the decision that resolves the current request", () => {
|
||||||
|
const ui = recordingUi();
|
||||||
|
const presenter = new AdvicePresenter(ui, true);
|
||||||
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
|
presenter.handleDecision("req-other");
|
||||||
|
expect(ui.calls).toHaveLength(1);
|
||||||
|
presenter.handleDecision("req-1");
|
||||||
|
expect(ui.calls).toHaveLength(2);
|
||||||
|
expect(ui.calls[1]).toEqual({ key: ADVICE_WIDGET_KEY, content: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a later present re-keys the clear guard onto the new request", () => {
|
||||||
|
const ui = recordingUi();
|
||||||
|
const presenter = new AdvicePresenter(ui, true);
|
||||||
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
|
presenter.present("req-2", { state: "unavailable", cause: "y" });
|
||||||
|
presenter.handleDecision("req-1");
|
||||||
|
expect(ui.calls).toHaveLength(2); // nothing cleared
|
||||||
|
presenter.handleDecision("req-2");
|
||||||
|
expect(ui.calls).toHaveLength(3);
|
||||||
|
expect(ui.calls[2]).toEqual({ key: ADVICE_WIDGET_KEY, content: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shutdown clears the widget", () => {
|
||||||
|
const ui = recordingUi();
|
||||||
|
const presenter = new AdvicePresenter(ui, true);
|
||||||
|
presenter.present("req-1", { state: "unavailable", cause: "x" });
|
||||||
|
presenter.shutdown();
|
||||||
|
expect(ui.calls.at(-1)).toEqual({
|
||||||
|
key: ADVICE_WIDGET_KEY,
|
||||||
|
content: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
presenter.present("r", { state: "unavailable", cause: "shape" });
|
||||||
|
expect(setWidget).toHaveBeenCalledWith(
|
||||||
|
ADVICE_WIDGET_KEY,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ describe("loadJudgeConfig — missing and malformed", () => {
|
|||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
timeoutCohort: "default",
|
timeoutCohort: "default",
|
||||||
judgeModel: undefined,
|
judgeModel: undefined,
|
||||||
|
dialogAdvice: true,
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
expect.objectContaining({ key: "file", fallback: "all defaults" }),
|
expect.objectContaining({ key: "file", fallback: "all defaults" }),
|
||||||
],
|
],
|
||||||
@@ -47,6 +48,42 @@ describe("loadJudgeConfig — missing and malformed", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loadJudgeConfig — dialogAdvice", () => {
|
||||||
|
it("defaults to true when absent", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: JSON.stringify({ version: 2 }) }),
|
||||||
|
);
|
||||||
|
expect(config.dialogAdvice).toBe(true);
|
||||||
|
expect(config.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit false", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({ version: 2, dialogAdvice: false }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(config.dialogAdvice).toBe(false);
|
||||||
|
expect(config.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to true with a diagnostic on a non-boolean", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({ version: 2, dialogAdvice: "yes" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(config.dialogAdvice).toBe(true);
|
||||||
|
expect(config.diagnostics).toEqual([
|
||||||
|
{
|
||||||
|
key: "dialogAdvice",
|
||||||
|
problem: 'not a boolean: "yes"',
|
||||||
|
fallback: "true",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("loadJudgeConfig — version selection", () => {
|
describe("loadJudgeConfig — version selection", () => {
|
||||||
it("treats a missing version as v1", () => {
|
it("treats a missing version as v1", () => {
|
||||||
const config = loadJudgeConfig(deps({ [CONFIG_PATH]: '{"mode":"shadow"}' }));
|
const config = loadJudgeConfig(deps({ [CONFIG_PATH]: '{"mode":"shadow"}' }));
|
||||||
@@ -246,6 +283,7 @@ describe("loadJudgeConfig — snapshot immutability", () => {
|
|||||||
timeoutMs: 20_000,
|
timeoutMs: 20_000,
|
||||||
timeoutCohort: 20_000,
|
timeoutCohort: 20_000,
|
||||||
judgeModel: { provider: "p", id: "m" },
|
judgeModel: { provider: "p", id: "m" },
|
||||||
|
dialogAdvice: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
return currentModel;
|
return currentModel;
|
||||||
},
|
},
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
ui: { notify: vi.fn() },
|
ui: { notify: vi.fn(), setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -342,7 +342,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
ui: { notify: vi.fn() },
|
ui: { notify: vi.fn(), setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -406,7 +406,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
sessionManager,
|
sessionManager,
|
||||||
model,
|
model,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
ui: { notify: vi.fn() },
|
ui: { notify: vi.fn(), setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -492,7 +492,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
ui: { notify: vi.fn() },
|
ui: { notify: vi.fn(), setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -571,7 +571,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
ui: { notify: vi.fn() },
|
ui: { notify: vi.fn(), setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -701,7 +701,7 @@ describe("AI judge Enforce authority seam (PIEXTENSIO-23, ADR 0008)", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete, find, hasConfiguredAuth },
|
modelRegistry: { complete, find, hasConfiguredAuth },
|
||||||
ui: { notify },
|
ui: { notify, setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -936,7 +936,7 @@ describe("AI judge Enforce authority seam (PIEXTENSIO-23, ADR 0008)", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete, find: vi.fn(), hasConfiguredAuth: vi.fn(() => true) },
|
modelRegistry: { complete, find: vi.fn(), hasConfiguredAuth: vi.fn(() => true) },
|
||||||
ui: { notify },
|
ui: { notify, setWidget: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
|
|||||||
Reference in New Issue
Block a user