@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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 +39 -0
- package/README.md +52 -74
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +12 -51
- package/config.ts +100 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +190 -209
- package/index.ts +484 -306
- package/jsonl-sidecar.ts +22 -0
- package/override-evidence.ts +3 -17
- package/package.json +3 -8
- package/prompt-select.ts +4 -15
- package/review-display.ts +134 -220
- package/review-queue.ts +88 -21
- package/review-scope.ts +15 -4
- package/review.ts +136 -68
- package/session-overrides.ts +27 -145
- package/settings-command.ts +11 -48
- package/settings-menu.ts +34 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +45 -21
- package/approval-pulse.ts +0 -80
- package/loop-context.ts +0 -41
- package/loop-revise-budget.ts +0 -348
- package/openai-codex-transport.ts +0 -9
- package/standing-overrides.ts +0 -266
package/session-overrides.ts
CHANGED
|
@@ -1,87 +1,53 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
import type { AutoPermissionsConfig } from "./config.js";
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import type { PromptChoiceClassification } from "./evaluation-log.js";
|
|
4
|
-
import type {
|
|
5
|
-
import type
|
|
3
|
+
import type { Gate } from "./gates.js";
|
|
4
|
+
import { PERMISSION_OVERRIDE_CHOICES, type PermissionOverride } from "./override-evidence.js";
|
|
6
5
|
import type { DenialSummary } from "./settings-menu.js";
|
|
7
|
-
import {
|
|
8
|
-
grantStandingApproval,
|
|
9
|
-
readStandingApprovals,
|
|
10
|
-
STANDING_APPROVAL_LIMIT,
|
|
11
|
-
standingApprovalsToPermissionOverrides,
|
|
12
|
-
} from "./standing-overrides.js";
|
|
13
6
|
|
|
14
7
|
/**
|
|
15
|
-
* Overrides persist as custom session entries
|
|
8
|
+
* Overrides persist as custom session entries,
|
|
16
9
|
* so a resumed session keeps the user's prompt decisions instead of
|
|
17
10
|
* forgetting every allow and standing block constraint.
|
|
18
11
|
*/
|
|
19
12
|
const OVERRIDES_ENTRY_TYPE = "auto-permissions-overrides";
|
|
20
13
|
|
|
21
14
|
export interface SessionOverrides {
|
|
22
|
-
/** Commands a `request_override` grant cleared for the rest of this session. */
|
|
23
|
-
readonly allowedConventionCommands: ReadonlySet<string>;
|
|
24
15
|
/** The override records, live, for merging into reviewer evidence. */
|
|
25
16
|
list(): readonly PermissionOverride[];
|
|
26
|
-
allowConvention(command: string): void;
|
|
27
|
-
activateOverrideTool(): void;
|
|
28
|
-
reconcileOverrideTool(): void;
|
|
29
|
-
persist(): void;
|
|
30
17
|
restore(branch: readonly unknown[]): void;
|
|
31
|
-
removeStanding(matches: (override: PermissionOverride) => boolean): void;
|
|
32
|
-
loadStanding(config: AutoPermissionsConfig, ctx: ExtensionContext): void;
|
|
33
18
|
recordPromptDecision(
|
|
34
|
-
|
|
19
|
+
gate: Pick<Gate, "label">,
|
|
20
|
+
command: string,
|
|
35
21
|
classification: PromptChoiceClassification,
|
|
36
22
|
detail: string,
|
|
37
23
|
anchorKey: string | undefined,
|
|
38
24
|
): void;
|
|
39
25
|
allowRetry(denial: DenialSummary, anchorKey: string | undefined): void;
|
|
40
|
-
resetForSession(): void;
|
|
41
26
|
}
|
|
42
27
|
|
|
43
28
|
/**
|
|
44
|
-
* The user's own permission decisions for this session: the
|
|
45
|
-
*
|
|
46
|
-
* guardian is shown as user-source evidence.
|
|
29
|
+
* The user's own permission decisions for this session: the override records
|
|
30
|
+
* the guardian is shown as user-source evidence.
|
|
47
31
|
*
|
|
48
|
-
* One owner, because the pieces are one fact seen
|
|
49
|
-
* record
|
|
50
|
-
* approvals) the cross-project ledger.
|
|
32
|
+
* One owner, because the pieces are one fact seen two ways — the in-memory
|
|
33
|
+
* record and the session entry that survives a resume.
|
|
51
34
|
*/
|
|
52
35
|
export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
53
|
-
const allowedConventionCommands = new Set<string>();
|
|
54
36
|
const permissionOverrides: PermissionOverride[] = [];
|
|
55
37
|
let overrideSeq = 0;
|
|
56
|
-
let standingApprovalCapNotified = false;
|
|
57
|
-
let overrideToolActivated = false;
|
|
58
|
-
|
|
59
|
-
function reconcileOverrideTool(): void {
|
|
60
|
-
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
61
|
-
const active = pi.getActiveTools();
|
|
62
|
-
const present = active.includes("request_override");
|
|
63
|
-
if (overrideToolActivated === present) return;
|
|
64
|
-
pi.setActiveTools(
|
|
65
|
-
overrideToolActivated
|
|
66
|
-
? [...active, "request_override"]
|
|
67
|
-
: active.filter((name) => name !== "request_override"),
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
38
|
|
|
71
39
|
function persist(): void {
|
|
72
40
|
try {
|
|
73
41
|
pi.appendEntry(OVERRIDES_ENTRY_TYPE, {
|
|
74
42
|
seq: overrideSeq,
|
|
75
|
-
overrides: permissionOverrides
|
|
76
|
-
.filter((override) => !override.standing)
|
|
77
|
-
.map((override) => ({ ...override })),
|
|
43
|
+
overrides: permissionOverrides.map((override) => ({ ...override })),
|
|
78
44
|
});
|
|
79
45
|
} catch {
|
|
80
46
|
// Persistence is best-effort; the in-memory records still apply now.
|
|
81
47
|
}
|
|
82
48
|
}
|
|
83
49
|
|
|
84
|
-
/** Fail-open
|
|
50
|
+
/** Fail-open: unreadable state means no overrides. */
|
|
85
51
|
function restoreOverrides(entries: readonly unknown[]): void {
|
|
86
52
|
permissionOverrides.length = 0;
|
|
87
53
|
overrideSeq = 0;
|
|
@@ -98,7 +64,7 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
|
98
64
|
|| typeof candidate.gateLabel !== "string"
|
|
99
65
|
|| typeof candidate.command !== "string"
|
|
100
66
|
|| typeof candidate.reviewerReason !== "string"
|
|
101
|
-
|| !
|
|
67
|
+
|| !PERMISSION_OVERRIDE_CHOICES.includes(candidate.choice)
|
|
102
68
|
) {
|
|
103
69
|
continue;
|
|
104
70
|
}
|
|
@@ -119,52 +85,24 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
|
119
85
|
}
|
|
120
86
|
}
|
|
121
87
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
88
|
+
function addOverride(
|
|
89
|
+
gateLabel: string,
|
|
90
|
+
command: string,
|
|
91
|
+
reviewerReason: string,
|
|
92
|
+
choice: PermissionOverride["choice"],
|
|
93
|
+
anchorKey: string | undefined,
|
|
94
|
+
): void {
|
|
95
|
+
permissionOverrides.push({ seq: overrideSeq++, anchorKey, gateLabel, command, reviewerReason, choice });
|
|
96
|
+
persist();
|
|
126
97
|
}
|
|
127
98
|
|
|
128
99
|
return {
|
|
129
|
-
allowedConventionCommands,
|
|
130
100
|
list: () => permissionOverrides,
|
|
131
|
-
allowConvention(command: string): void {
|
|
132
|
-
allowedConventionCommands.add(command);
|
|
133
|
-
},
|
|
134
|
-
reconcileOverrideTool,
|
|
135
|
-
activateOverrideTool(): void {
|
|
136
|
-
overrideToolActivated = true;
|
|
137
|
-
reconcileOverrideTool();
|
|
138
|
-
},
|
|
139
|
-
persist,
|
|
140
|
-
removeStanding,
|
|
141
101
|
|
|
142
102
|
restore(branch: readonly unknown[]): void {
|
|
143
103
|
// Restore prompt decisions from the branch: a resumed session keeps its
|
|
144
|
-
// allows and standing block constraints
|
|
104
|
+
// allows and standing block constraints.
|
|
145
105
|
restoreOverrides(branch);
|
|
146
|
-
for (const entry of branch as Array<{
|
|
147
|
-
type?: string;
|
|
148
|
-
message?: { role?: string; toolName?: string; details?: unknown };
|
|
149
|
-
}>) {
|
|
150
|
-
if (entry.type !== "message" || entry.message?.role !== "toolResult") continue;
|
|
151
|
-
if (entry.message.toolName !== "request_override") continue;
|
|
152
|
-
const details = entry.message.details as { success?: boolean; command?: string } | undefined;
|
|
153
|
-
if (details?.success && details.command) allowedConventionCommands.add(details.command);
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
/** Replace only ledger-backed evidence; session prompt decisions stay put. */
|
|
158
|
-
loadStanding(config: AutoPermissionsConfig, ctx: ExtensionContext): void {
|
|
159
|
-
removeStanding(() => true);
|
|
160
|
-
if (!config.standingApprovals.enabled) return;
|
|
161
|
-
try {
|
|
162
|
-
const records = readStandingApprovals(config.standingApprovals.path);
|
|
163
|
-
permissionOverrides.push(...standingApprovalsToPermissionOverrides(records));
|
|
164
|
-
} catch (error) {
|
|
165
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
-
ctx.ui.notify(`Could not load standing Auto Permissions approvals: ${message}`, "warning");
|
|
167
|
-
}
|
|
168
106
|
},
|
|
169
107
|
|
|
170
108
|
/**
|
|
@@ -173,56 +111,15 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
|
173
111
|
* a guardian judgment, which is the one case this may be called for.
|
|
174
112
|
*/
|
|
175
113
|
recordPromptDecision(
|
|
176
|
-
|
|
114
|
+
gate: Pick<Gate, "label">,
|
|
115
|
+
command: string,
|
|
177
116
|
classification: PromptChoiceClassification,
|
|
178
117
|
detail: string,
|
|
179
118
|
anchorKey: string | undefined,
|
|
180
119
|
): void {
|
|
181
|
-
const { ctx, config, gate, command } = scope;
|
|
182
120
|
const overrideChoice = classification.userChoice ?? (classification.allowsExecution ? "allow" as const : undefined);
|
|
183
121
|
if (!overrideChoice) return;
|
|
184
|
-
|
|
185
|
-
if (classification.standingApproval && config.standingApprovals.enabled) {
|
|
186
|
-
try {
|
|
187
|
-
const { evicted, override } = grantStandingApproval(
|
|
188
|
-
config.standingApprovals.path,
|
|
189
|
-
{
|
|
190
|
-
gate: { label: gate.label, group: gate.group },
|
|
191
|
-
command,
|
|
192
|
-
project: ctx.cwd,
|
|
193
|
-
reason: detail,
|
|
194
|
-
},
|
|
195
|
-
overrideSeq++,
|
|
196
|
-
anchorKey,
|
|
197
|
-
);
|
|
198
|
-
removeStanding(
|
|
199
|
-
(existing) => existing.standing?.gateGroup === gate.group && existing.command === command,
|
|
200
|
-
);
|
|
201
|
-
permissionOverrides.push(override);
|
|
202
|
-
recordedStanding = true;
|
|
203
|
-
if (evicted > 0 && !standingApprovalCapNotified) {
|
|
204
|
-
standingApprovalCapNotified = true;
|
|
205
|
-
ctx.ui.notify(
|
|
206
|
-
`Standing approvals reached ${STANDING_APPROVAL_LIMIT} entries; the oldest approval was removed.`,
|
|
207
|
-
"warning",
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
-
ctx.ui.notify(`Could not save standing approval: ${message}`, "warning");
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (!recordedStanding) {
|
|
216
|
-
permissionOverrides.push({
|
|
217
|
-
seq: overrideSeq++,
|
|
218
|
-
anchorKey,
|
|
219
|
-
gateLabel: gate.label,
|
|
220
|
-
command,
|
|
221
|
-
reviewerReason: detail,
|
|
222
|
-
choice: overrideChoice,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
persist();
|
|
122
|
+
addOverride(gate.label, command, detail, overrideChoice, anchorKey);
|
|
226
123
|
},
|
|
227
124
|
|
|
228
125
|
/**
|
|
@@ -233,15 +130,7 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
|
233
130
|
* may retry. No new authorization pathway.
|
|
234
131
|
*/
|
|
235
132
|
allowRetry(denial: DenialSummary, anchorKey: string | undefined): void {
|
|
236
|
-
|
|
237
|
-
seq: overrideSeq++,
|
|
238
|
-
anchorKey,
|
|
239
|
-
gateLabel: denial.gateLabel,
|
|
240
|
-
command: denial.command,
|
|
241
|
-
reviewerReason: denial.reason,
|
|
242
|
-
choice: "allow",
|
|
243
|
-
});
|
|
244
|
-
persist();
|
|
133
|
+
addOverride(denial.gateLabel, denial.command, denial.reason, "allow", anchorKey);
|
|
245
134
|
try {
|
|
246
135
|
// The same channel a prompt note uses. This is a real user decision
|
|
247
136
|
// made in the menu, so a user message is honest provenance — and it
|
|
@@ -253,12 +142,5 @@ export function createSessionOverrides(pi: ExtensionAPI): SessionOverrides {
|
|
|
253
142
|
// The override itself is already in force; the nudge is best-effort.
|
|
254
143
|
}
|
|
255
144
|
},
|
|
256
|
-
|
|
257
|
-
resetForSession(): void {
|
|
258
|
-
overrideToolActivated = false;
|
|
259
|
-
reconcileOverrideTool();
|
|
260
|
-
standingApprovalCapNotified = false;
|
|
261
|
-
allowedConventionCommands.clear();
|
|
262
|
-
},
|
|
263
145
|
};
|
|
264
146
|
}
|
package/settings-command.ts
CHANGED
|
@@ -18,18 +18,14 @@ import {
|
|
|
18
18
|
buildSettingItems,
|
|
19
19
|
createDenialsSubmenu,
|
|
20
20
|
createModelSubmenu,
|
|
21
|
-
createStandingApprovalsSubmenu,
|
|
22
21
|
createTimeoutSubmenu,
|
|
23
22
|
type DenialsSubmenuHost,
|
|
24
23
|
type DenialSummary,
|
|
25
|
-
type StandingApprovalsSubmenuHost,
|
|
26
|
-
type StandingApprovalSummary,
|
|
27
24
|
type MenuModel,
|
|
28
25
|
type ReviewerSettings,
|
|
29
26
|
type SettingChange,
|
|
30
27
|
type SubmenuHost,
|
|
31
28
|
} from "./settings-menu.js";
|
|
32
|
-
import { readStandingApprovals, revokeStandingApproval } from "./standing-overrides.js";
|
|
33
29
|
|
|
34
30
|
/**
|
|
35
31
|
* What `/auto-permissions setup` says on the user's behalf. Named after the
|
|
@@ -44,8 +40,8 @@ export function registerSettingsCommand(
|
|
|
44
40
|
{ overrides, reviewer }: { overrides: SessionOverrides; reviewer: GuardianReviewer },
|
|
45
41
|
): void {
|
|
46
42
|
/**
|
|
47
|
-
* `/auto-permissions`: edit the reviewer model, thinking level, timeout
|
|
48
|
-
* the enabled flag without hand-editing config.json.
|
|
43
|
+
* `/auto-permissions`: edit the reviewer model, thinking level, timeout,
|
|
44
|
+
* parallel reviews and the enabled flag without hand-editing config.json.
|
|
49
45
|
*
|
|
50
46
|
* There is no live-apply step: every tool call re-reads the config through
|
|
51
47
|
* currentConfig(), so a saved edit is in force immediately -- in this session
|
|
@@ -75,16 +71,15 @@ export function registerSettingsCommand(
|
|
|
75
71
|
const path = autoPermissionsConfigPath();
|
|
76
72
|
let settings: ReviewerSettings;
|
|
77
73
|
let denialLog: AutoPermissionsConfig["denialLog"];
|
|
78
|
-
let standingApprovalsConfig: AutoPermissionsConfig["standingApprovals"];
|
|
79
74
|
try {
|
|
80
75
|
const config = loadAutoPermissionsConfig(path);
|
|
81
76
|
settings = {
|
|
82
77
|
enabled: config.enabled,
|
|
83
78
|
reviewer: config.reviewer,
|
|
79
|
+
reviewConcurrency: config.reviewConcurrency,
|
|
84
80
|
systemPromptSource: config.systemPromptSource,
|
|
85
81
|
};
|
|
86
82
|
denialLog = config.denialLog;
|
|
87
|
-
standingApprovalsConfig = config.standingApprovals;
|
|
88
83
|
} catch (error) {
|
|
89
84
|
// Editing a file we cannot validate is the one way this writer could
|
|
90
85
|
// quietly make things worse, so refuse rather than open.
|
|
@@ -103,7 +98,14 @@ export function registerSettingsCommand(
|
|
|
103
98
|
const previous = settings;
|
|
104
99
|
settings = change.settings;
|
|
105
100
|
try {
|
|
106
|
-
patchAutoPermissionsConfig(path, {
|
|
101
|
+
patchAutoPermissionsConfig(path, {
|
|
102
|
+
enabled: settings.enabled,
|
|
103
|
+
reviewer: settings.reviewer,
|
|
104
|
+
// Only when this edit changed it, so a hand-written value is never rewritten by an unrelated save.
|
|
105
|
+
...(settings.reviewConcurrency !== previous.reviewConcurrency
|
|
106
|
+
? { reviewConcurrency: settings.reviewConcurrency }
|
|
107
|
+
: {}),
|
|
108
|
+
});
|
|
107
109
|
} catch (error) {
|
|
108
110
|
settings = previous;
|
|
109
111
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -142,34 +144,6 @@ export function registerSettingsCommand(
|
|
|
142
144
|
}
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
const standingApprovalSummaries = (): StandingApprovalSummary[] => {
|
|
146
|
-
if (!standingApprovalsConfig.enabled) return [];
|
|
147
|
-
try {
|
|
148
|
-
return readStandingApprovals(standingApprovalsConfig.path).map((record, index) => ({
|
|
149
|
-
id: `${index}:${record.ts}:${record.gate.label}`,
|
|
150
|
-
record,
|
|
151
|
-
}));
|
|
152
|
-
} catch {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const revokeApproval = (approval: StandingApprovalSummary): void => {
|
|
158
|
-
try {
|
|
159
|
-
if (!revokeStandingApproval(standingApprovalsConfig.path, approval.record)) {
|
|
160
|
-
ctx.ui.notify("That standing approval is no longer in the ledger.", "warning");
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
const config = loadAutoPermissionsConfig(path);
|
|
164
|
-
overrides.loadStanding(config, ctx);
|
|
165
|
-
reviewer.discardLineage();
|
|
166
|
-
ctx.ui.notify("Standing approval revoked.", "info");
|
|
167
|
-
} catch (error) {
|
|
168
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
-
ctx.ui.notify(`Could not revoke standing approval: ${message}`, "warning");
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
147
|
/**
|
|
174
148
|
* Closes the settings dialog. Assigned when `ctx.ui.custom` builds the
|
|
175
149
|
* component, so it is only ever called from inside the dialog it closes.
|
|
@@ -213,13 +187,6 @@ export function registerSettingsCommand(
|
|
|
213
187
|
selectTheme: getSelectListTheme(),
|
|
214
188
|
requestRender: () => tui.requestRender(),
|
|
215
189
|
};
|
|
216
|
-
const standingHost: StandingApprovalsSubmenuHost = {
|
|
217
|
-
standingApprovals: standingApprovalSummaries,
|
|
218
|
-
revokeStandingApproval: revokeApproval,
|
|
219
|
-
settingsTheme: getSettingsListTheme(),
|
|
220
|
-
selectTheme: getSelectListTheme(),
|
|
221
|
-
requestRender: () => tui.requestRender(),
|
|
222
|
-
};
|
|
223
190
|
const list = new SettingsList(
|
|
224
191
|
buildSettingItems(
|
|
225
192
|
settings,
|
|
@@ -227,13 +194,9 @@ export function registerSettingsCommand(
|
|
|
227
194
|
reviewerModel: createModelSubmenu(host),
|
|
228
195
|
timeout: createTimeoutSubmenu(host),
|
|
229
196
|
...(denialLog.enabled ? { recentDenials: createDenialsSubmenu(denialsHost) } : {}),
|
|
230
|
-
...(standingApprovalsConfig.enabled
|
|
231
|
-
? { standingApprovals: createStandingApprovalsSubmenu(standingHost) }
|
|
232
|
-
: {}),
|
|
233
197
|
},
|
|
234
198
|
undefined,
|
|
235
199
|
denialLog.enabled ? recentDenials().length : undefined,
|
|
236
|
-
standingApprovalsConfig.enabled ? standingApprovalSummaries().length : undefined,
|
|
237
200
|
),
|
|
238
201
|
10,
|
|
239
202
|
host.settingsTheme,
|
package/settings-menu.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Row model and pure edit logic behind `/auto-permissions`.
|
|
3
3
|
*
|
|
4
4
|
* Everything that decides *what* a row shows or what an edit means lives here,
|
|
5
|
-
* free of pi's runtime, so it is testable without a TUI.
|
|
6
|
-
* wiring: read config, render a SettingsList, persist through config-writer.
|
|
5
|
+
* free of pi's runtime, so it is testable without a TUI. settings-command.ts owns
|
|
6
|
+
* only the wiring: read config, render a SettingsList, persist through config-writer.
|
|
7
7
|
*/
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import {
|
|
@@ -20,22 +20,22 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
DEFAULT_REVIEWER_REASONING_EFFORT,
|
|
22
22
|
DEFAULT_REVIEWER_TIMEOUT_MS,
|
|
23
|
+
MAX_REVIEW_CONCURRENCY,
|
|
23
24
|
MAX_REVIEWER_TIMEOUT_MS,
|
|
24
25
|
MIN_REVIEWER_TIMEOUT_MS,
|
|
25
26
|
REASONING_EFFORTS,
|
|
26
27
|
type ReasoningEffort,
|
|
28
|
+
type ReviewerConfig,
|
|
27
29
|
type SystemPromptSource,
|
|
28
30
|
} from "./config.js";
|
|
29
|
-
import type { ReviewerBlock } from "./config-writer.js";
|
|
30
|
-
import type { StandingApprovalRecord } from "./standing-overrides.js";
|
|
31
31
|
|
|
32
32
|
const ENABLED_ID = "enabled";
|
|
33
33
|
const REVIEWER_MODEL_ID = "reviewerModel";
|
|
34
34
|
const THINKING_LEVEL_ID = "reasoningEffort";
|
|
35
35
|
const TIMEOUT_ID = "timeoutMs";
|
|
36
|
+
const REVIEW_CONCURRENCY_ID = "reviewConcurrency";
|
|
36
37
|
const SYSTEM_PROMPT_ID = "systemPrompt";
|
|
37
38
|
export const RECENT_DENIALS_ID = "recentDenials";
|
|
38
|
-
export const STANDING_APPROVALS_ID = "standingApprovals";
|
|
39
39
|
|
|
40
40
|
export const ON = "on";
|
|
41
41
|
export const OFF = "off";
|
|
@@ -48,10 +48,21 @@ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
|
|
|
48
48
|
/** The slice of the config the menu edits, plus the read-only prompt source. */
|
|
49
49
|
export interface ReviewerSettings {
|
|
50
50
|
enabled: boolean;
|
|
51
|
-
reviewer?:
|
|
51
|
+
reviewer?: ReviewerConfig;
|
|
52
|
+
reviewConcurrency: number;
|
|
52
53
|
systemPromptSource: SystemPromptSource;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
const CONCURRENCY_PRESETS = [1, 2, 4, 8, 16];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The values the parallel-reviews row cycles through. A hand-written value
|
|
60
|
+
* outside the presets is kept in the cycle, so opening the menu never moves it.
|
|
61
|
+
*/
|
|
62
|
+
export function concurrencyValues(current: number): string[] {
|
|
63
|
+
return [...new Set([...CONCURRENCY_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
/** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
|
|
56
67
|
export interface MenuModel {
|
|
57
68
|
provider: string;
|
|
@@ -141,7 +152,6 @@ export interface SettingSubmenus {
|
|
|
141
152
|
reviewerModel?: SettingItem["submenu"];
|
|
142
153
|
timeout?: SettingItem["submenu"];
|
|
143
154
|
recentDenials?: SettingItem["submenu"];
|
|
144
|
-
standingApprovals?: SettingItem["submenu"];
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
/** Build the `/auto-permissions` rows for a settings snapshot. */
|
|
@@ -150,13 +160,12 @@ export function buildSettingItems(
|
|
|
150
160
|
submenus: SettingSubmenus = {},
|
|
151
161
|
home: string = homedir(),
|
|
152
162
|
denialCount?: number,
|
|
153
|
-
standingApprovalCount?: number,
|
|
154
163
|
): SettingItem[] {
|
|
155
164
|
return [
|
|
156
165
|
{
|
|
157
166
|
id: ENABLED_ID,
|
|
158
167
|
label: "Enabled",
|
|
159
|
-
description: "Review guarded commands. Off
|
|
168
|
+
description: "Review guarded commands. Off disables all gating, including deny rules.",
|
|
160
169
|
currentValue: toggleValue(settings.enabled),
|
|
161
170
|
values: TOGGLE_VALUES,
|
|
162
171
|
},
|
|
@@ -181,6 +190,13 @@ export function buildSettingItems(
|
|
|
181
190
|
currentValue: formatTimeout(timeoutValue(settings)),
|
|
182
191
|
...(submenus.timeout ? { submenu: submenus.timeout } : {}),
|
|
183
192
|
},
|
|
193
|
+
{
|
|
194
|
+
id: REVIEW_CONCURRENCY_ID,
|
|
195
|
+
label: "Parallel reviews",
|
|
196
|
+
description: "How many guardian reviews may run at once. 1 reviews one command at a time, none ahead of its turn.",
|
|
197
|
+
currentValue: String(settings.reviewConcurrency),
|
|
198
|
+
values: concurrencyValues(settings.reviewConcurrency),
|
|
199
|
+
},
|
|
184
200
|
{
|
|
185
201
|
id: SYSTEM_PROMPT_ID,
|
|
186
202
|
label: "System prompt",
|
|
@@ -196,15 +212,6 @@ export function buildSettingItems(
|
|
|
196
212
|
submenu: submenus.recentDenials,
|
|
197
213
|
}]
|
|
198
214
|
: []),
|
|
199
|
-
...(submenus.standingApprovals
|
|
200
|
-
? [{
|
|
201
|
-
id: STANDING_APPROVALS_ID,
|
|
202
|
-
label: "Standing approvals",
|
|
203
|
-
description: "Comparable-command approvals shared across projects. Select one to revoke it.",
|
|
204
|
-
currentValue: standingApprovalCount === undefined ? "" : String(standingApprovalCount),
|
|
205
|
-
submenu: submenus.standingApprovals,
|
|
206
|
-
}]
|
|
207
|
-
: []),
|
|
208
215
|
];
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -254,6 +261,15 @@ export function applySettingChange(
|
|
|
254
261
|
return { kind: "settings", settings: next };
|
|
255
262
|
}
|
|
256
263
|
|
|
264
|
+
if (id === REVIEW_CONCURRENCY_ID) {
|
|
265
|
+
const reviewConcurrency = Number(value);
|
|
266
|
+
if (!Number.isInteger(reviewConcurrency) || reviewConcurrency < 1 || reviewConcurrency > MAX_REVIEW_CONCURRENCY) {
|
|
267
|
+
return { kind: "error", message: `Parallel reviews must be a whole number from 1 to ${MAX_REVIEW_CONCURRENCY}` };
|
|
268
|
+
}
|
|
269
|
+
if (reviewConcurrency === settings.reviewConcurrency) return { kind: "ignored" };
|
|
270
|
+
return { kind: "settings", settings: { ...settings, reviewConcurrency } };
|
|
271
|
+
}
|
|
272
|
+
|
|
257
273
|
// The model and timeout submenus commit their own edit and hand back display
|
|
258
274
|
// text, so there is nothing left to map here.
|
|
259
275
|
return { kind: "ignored" };
|
|
@@ -568,120 +584,6 @@ export function createDenialsSubmenu(host: DenialsSubmenuHost): NonNullable<Sett
|
|
|
568
584
|
return (_currentValue, done) => new DenialsSubmenu(host, done);
|
|
569
585
|
}
|
|
570
586
|
|
|
571
|
-
export interface StandingApprovalSummary {
|
|
572
|
-
id: string;
|
|
573
|
-
record: StandingApprovalRecord;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export interface StandingApprovalsSubmenuHost {
|
|
577
|
-
standingApprovals(): StandingApprovalSummary[];
|
|
578
|
-
revokeStandingApproval(approval: StandingApprovalSummary): void;
|
|
579
|
-
settingsTheme: SettingsListTheme;
|
|
580
|
-
selectTheme: SelectListTheme;
|
|
581
|
-
requestRender(): void;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export function standingApprovalItem(approval: StandingApprovalSummary): SelectItem {
|
|
585
|
-
const command = approval.record.command.length > COMMAND_LABEL_MAX
|
|
586
|
-
? `${approval.record.command.slice(0, COMMAND_LABEL_MAX - 1)}…`
|
|
587
|
-
: approval.record.command;
|
|
588
|
-
return {
|
|
589
|
-
value: approval.id,
|
|
590
|
-
label: command,
|
|
591
|
-
description: `${approval.record.gate.label} · granted ${approval.record.ts.slice(0, 10)} · ${approval.record.project}`,
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
class StandingApprovalsSubmenu implements Component {
|
|
596
|
-
private readonly approvals: StandingApprovalSummary[];
|
|
597
|
-
private list: SelectList;
|
|
598
|
-
private selected?: StandingApprovalSummary;
|
|
599
|
-
|
|
600
|
-
constructor(
|
|
601
|
-
private readonly host: StandingApprovalsSubmenuHost,
|
|
602
|
-
private readonly done: (value?: string) => void,
|
|
603
|
-
) {
|
|
604
|
-
this.approvals = host.standingApprovals();
|
|
605
|
-
this.list = this.buildApprovalList();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
private buildApprovalList(): SelectList {
|
|
609
|
-
const items = this.approvals.length
|
|
610
|
-
? this.approvals.map(standingApprovalItem)
|
|
611
|
-
: [{ value: "", label: "(no standing approvals)", description: "Comparable-command approvals will appear here." }];
|
|
612
|
-
const list = new SelectList(items, 10, this.host.selectTheme);
|
|
613
|
-
list.onCancel = () => this.done(undefined);
|
|
614
|
-
list.onSelect = (item) => {
|
|
615
|
-
const approval = this.approvals.find((candidate) => candidate.id === item.value);
|
|
616
|
-
if (!approval) {
|
|
617
|
-
this.done(undefined);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
this.selected = approval;
|
|
621
|
-
this.list = this.buildConfirmList();
|
|
622
|
-
this.host.requestRender();
|
|
623
|
-
};
|
|
624
|
-
return list;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
private buildConfirmList(): SelectList {
|
|
628
|
-
const list = new SelectList(
|
|
629
|
-
[
|
|
630
|
-
{
|
|
631
|
-
value: "revoke",
|
|
632
|
-
label: "Revoke",
|
|
633
|
-
description: "Removes this approval from the user-scoped standing ledger.",
|
|
634
|
-
},
|
|
635
|
-
{ value: "back", label: "Back" },
|
|
636
|
-
],
|
|
637
|
-
10,
|
|
638
|
-
this.host.selectTheme,
|
|
639
|
-
);
|
|
640
|
-
const back = () => {
|
|
641
|
-
this.selected = undefined;
|
|
642
|
-
this.list = this.buildApprovalList();
|
|
643
|
-
this.host.requestRender();
|
|
644
|
-
};
|
|
645
|
-
list.onCancel = back;
|
|
646
|
-
list.onSelect = (item) => {
|
|
647
|
-
if (item.value !== "revoke" || !this.selected) {
|
|
648
|
-
back();
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
this.host.revokeStandingApproval(this.selected);
|
|
652
|
-
this.done(undefined);
|
|
653
|
-
};
|
|
654
|
-
return list;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
invalidate(): void {
|
|
658
|
-
this.list.invalidate();
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
render(width: number): string[] {
|
|
662
|
-
const title = this.selected
|
|
663
|
-
? ` Revoke standing approval? ${truncateToWidth(this.selected.record.command, Math.max(1, width - 30))}`
|
|
664
|
-
: " Standing approvals";
|
|
665
|
-
return [
|
|
666
|
-
truncateToWidth(this.host.settingsTheme.hint(title), width),
|
|
667
|
-
"",
|
|
668
|
-
...this.list.render(width),
|
|
669
|
-
"",
|
|
670
|
-
truncateToWidth(this.host.settingsTheme.hint(" Enter to select · Esc to go back"), width),
|
|
671
|
-
];
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
handleInput(data: string): void {
|
|
675
|
-
this.list.handleInput(data);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
export function createStandingApprovalsSubmenu(
|
|
680
|
-
host: StandingApprovalsSubmenuHost,
|
|
681
|
-
): NonNullable<SettingItem["submenu"]> {
|
|
682
|
-
return (_currentValue, done) => new StandingApprovalsSubmenu(host, done);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
587
|
export function createTimeoutSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
686
588
|
return (currentValue, done) =>
|
|
687
589
|
new PromptComponent(
|
package/usage-log.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
1
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
/** Rotate once the sidecar grows past this size; one previous generation is kept. */
|
|
6
|
-
export const USAGE_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
|
|
2
|
+
import { appendJsonlRecord, SIDECAR_ROTATE_BYTES } from "./jsonl-sidecar.js";
|
|
7
3
|
|
|
8
4
|
interface UsageLogTotals {
|
|
9
5
|
input: number;
|
|
@@ -55,14 +51,13 @@ export function buildUsageLogRecord(
|
|
|
55
51
|
model: string,
|
|
56
52
|
usage: unknown,
|
|
57
53
|
label = "guardian",
|
|
58
|
-
source = "auto-permissions",
|
|
59
54
|
subagent = false,
|
|
60
55
|
): UsageLogRecord {
|
|
61
56
|
return {
|
|
62
57
|
v: 1,
|
|
63
58
|
id: randomUUID(),
|
|
64
59
|
ts: new Date().toISOString(),
|
|
65
|
-
source,
|
|
60
|
+
source: "auto-permissions",
|
|
66
61
|
label,
|
|
67
62
|
provider,
|
|
68
63
|
model,
|
|
@@ -71,18 +66,6 @@ export function buildUsageLogRecord(
|
|
|
71
66
|
};
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
function rotateIfLarge(path: string): void {
|
|
75
|
-
try {
|
|
76
|
-
if (statSync(path).size < USAGE_LOG_ROTATE_BYTES) return;
|
|
77
|
-
renameSync(path, `${path}.1`);
|
|
78
|
-
} catch {
|
|
79
|
-
// A missing or unrotatable sidecar simply keeps appending.
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
69
|
export function appendUsageRecord(path: string, record: UsageLogRecord): void {
|
|
84
|
-
|
|
85
|
-
rotateIfLarge(path);
|
|
86
|
-
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
87
|
-
chmodSync(path, 0o600);
|
|
70
|
+
appendJsonlRecord(path, record, SIDECAR_ROTATE_BYTES);
|
|
88
71
|
}
|