@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/widget-status.ts
CHANGED
|
@@ -18,8 +18,6 @@ interface ReviewStatusFrame {
|
|
|
18
18
|
glyph: string;
|
|
19
19
|
label: string;
|
|
20
20
|
tone: ReviewStatusTone;
|
|
21
|
-
/** True once no further frames are needed (state is static). */
|
|
22
|
-
done: boolean;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
export const WAITING_FRAMES = ["✶", "✸", "✻", "✽"] as const;
|
|
@@ -32,32 +30,30 @@ export function reviewStatusFrame(
|
|
|
32
30
|
): ReviewStatusFrame {
|
|
33
31
|
const index = Math.max(0, Math.floor(frameIndex));
|
|
34
32
|
switch (state) {
|
|
35
|
-
// Static rather than animated: the row is replaced
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// renders *something* instead of a blank gap
|
|
33
|
+
// Static rather than animated: the row is replaced the moment the command
|
|
34
|
+
// gets its slot, so a spinner here would promise progress the command is
|
|
35
|
+
// not making. It exists so a guarded command waiting for a review slot or
|
|
36
|
+
// for another command's decision renders *something* instead of a blank gap.
|
|
39
37
|
case "queued":
|
|
40
38
|
return {
|
|
41
39
|
glyph: "⋯",
|
|
42
40
|
label: "queued behind another review",
|
|
43
41
|
tone: "muted",
|
|
44
|
-
done: true,
|
|
45
42
|
};
|
|
46
43
|
case "waiting":
|
|
47
44
|
return {
|
|
48
45
|
glyph: WAITING_FRAMES[index % WAITING_FRAMES.length],
|
|
49
46
|
label: `waiting for ${reviewer}`,
|
|
50
47
|
tone: "warning",
|
|
51
|
-
done: false,
|
|
52
48
|
};
|
|
53
49
|
case "approved":
|
|
54
|
-
return { glyph: "✓", label: "approved", tone: "success"
|
|
50
|
+
return { glyph: "✓", label: "approved", tone: "success" };
|
|
55
51
|
case "revise":
|
|
56
|
-
return { glyph: "↻", label: "revision requested", tone: "warning"
|
|
52
|
+
return { glyph: "↻", label: "revision requested", tone: "warning" };
|
|
57
53
|
case "ask_user":
|
|
58
|
-
return { glyph: "?", label: "waiting for your approval", tone: "accent"
|
|
54
|
+
return { glyph: "?", label: "waiting for your approval", tone: "accent" };
|
|
59
55
|
case "blocked":
|
|
60
|
-
return { glyph: "✗", label: "blocked", tone: "error"
|
|
56
|
+
return { glyph: "✗", label: "blocked", tone: "error" };
|
|
61
57
|
}
|
|
62
58
|
}
|
|
63
59
|
|
|
@@ -65,6 +61,19 @@ export function reviewFrameIntervalMs(state: ReviewDisplayState): number | undef
|
|
|
65
61
|
return state === "waiting" ? WAITING_FRAME_INTERVAL_MS : undefined;
|
|
66
62
|
}
|
|
67
63
|
|
|
64
|
+
/** The states a review is still in while it has not settled. */
|
|
65
|
+
export type ActiveReviewState = Extract<ReviewDisplayState, "queued" | "waiting" | "ask_user">;
|
|
66
|
+
|
|
67
|
+
export function isActiveReviewState(state: ReviewDisplayState): state is ActiveReviewState {
|
|
68
|
+
return state === "queued" || state === "waiting" || state === "ask_user";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ReviewSummaryCounts {
|
|
72
|
+
waiting: number;
|
|
73
|
+
queued: number;
|
|
74
|
+
askUser: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
export interface ReviewLinePalette {
|
|
69
78
|
header: (text: string) => string;
|
|
70
79
|
muted: (text: string) => string;
|
|
@@ -74,15 +83,6 @@ export interface ReviewLinePalette {
|
|
|
74
83
|
error: (text: string) => string;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
|
-
export const PLAIN_PALETTE: ReviewLinePalette = {
|
|
78
|
-
header: (text) => text,
|
|
79
|
-
muted: (text) => text,
|
|
80
|
-
warning: (text) => text,
|
|
81
|
-
success: (text) => text,
|
|
82
|
-
accent: (text) => text,
|
|
83
|
-
error: (text) => text,
|
|
84
|
-
};
|
|
85
|
-
|
|
86
86
|
/**
|
|
87
87
|
* Render the widget content: one status line, plus a dim detail line only
|
|
88
88
|
* when a guardian reason is present. The command is intentionally omitted —
|
|
@@ -104,3 +104,27 @@ export function reviewStatusLines(
|
|
|
104
104
|
if (detail) lines.push(palette.muted(detail));
|
|
105
105
|
return lines;
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One line for several unsettled reviews at once. Per-command detail is left
|
|
110
|
+
* out: each command is visible in its own tool box, and an approval prompt
|
|
111
|
+
* carries its own reason.
|
|
112
|
+
*/
|
|
113
|
+
export function reviewSummaryLines(
|
|
114
|
+
counts: ReviewSummaryCounts,
|
|
115
|
+
reviewer: string,
|
|
116
|
+
frameIndex: number,
|
|
117
|
+
palette: ReviewLinePalette,
|
|
118
|
+
): string[] {
|
|
119
|
+
const total = counts.waiting + counts.queued + counts.askUser;
|
|
120
|
+
const parts: string[] = [];
|
|
121
|
+
if (counts.waiting > 0) {
|
|
122
|
+
const frame = reviewStatusFrame("waiting", reviewer, frameIndex);
|
|
123
|
+
parts.push(palette.warning(`${frame.glyph} ${counts.waiting} ${frame.label}`));
|
|
124
|
+
}
|
|
125
|
+
if (counts.queued > 0) parts.push(palette.muted(`⋯ ${counts.queued} queued`));
|
|
126
|
+
if (counts.askUser > 0) parts.push(palette.accent(`? ${counts.askUser} waiting for your approval`));
|
|
127
|
+
return [
|
|
128
|
+
`${palette.header("auto permissions")} ${palette.muted(`· ${total} commands ·`)} ${parts.join(palette.muted(" · "))}`,
|
|
129
|
+
];
|
|
130
|
+
}
|
package/approval-pulse.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
2
|
-
|
|
3
|
-
export const APPROVAL_PULSE_INTERVAL_MS = 600;
|
|
4
|
-
|
|
5
|
-
interface ApprovalPulseTheme {
|
|
6
|
-
fg(role: "warning" | "dim", text: string): string;
|
|
7
|
-
bold(text: string): string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface ApprovalPulseScheduler {
|
|
11
|
-
setInterval(callback: () => void, intervalMs: number): unknown;
|
|
12
|
-
clearInterval(handle: unknown): void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
type InteractiveComponent = Component & Required<Pick<Component, "handleInput">>;
|
|
16
|
-
|
|
17
|
-
const DEFAULT_SCHEDULER: ApprovalPulseScheduler = {
|
|
18
|
-
setInterval(callback, intervalMs) {
|
|
19
|
-
const timer = setInterval(callback, intervalMs);
|
|
20
|
-
timer.unref?.();
|
|
21
|
-
return timer;
|
|
22
|
-
},
|
|
23
|
-
clearInterval(handle) {
|
|
24
|
-
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Decorates an interactive prompt with a bright/dim approval light.
|
|
30
|
-
*
|
|
31
|
-
* The child still owns all content and input. Only its first rendered line is
|
|
32
|
-
* replaced, preserving the selector's existing spacing and wrapping exactly.
|
|
33
|
-
*/
|
|
34
|
-
export class PulsingApprovalIndicator implements Component {
|
|
35
|
-
private bright = true;
|
|
36
|
-
private timer: unknown;
|
|
37
|
-
|
|
38
|
-
constructor(
|
|
39
|
-
private readonly headline: string,
|
|
40
|
-
private readonly child: InteractiveComponent,
|
|
41
|
-
private readonly theme: ApprovalPulseTheme,
|
|
42
|
-
private readonly requestRender: () => void,
|
|
43
|
-
private readonly scheduler: ApprovalPulseScheduler = DEFAULT_SCHEDULER,
|
|
44
|
-
) {}
|
|
45
|
-
|
|
46
|
-
start(): void {
|
|
47
|
-
if (this.timer !== undefined) return;
|
|
48
|
-
this.timer = this.scheduler.setInterval(() => {
|
|
49
|
-
this.bright = !this.bright;
|
|
50
|
-
this.requestRender();
|
|
51
|
-
}, APPROVAL_PULSE_INTERVAL_MS);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
stop(): void {
|
|
55
|
-
if (this.timer === undefined) return;
|
|
56
|
-
this.scheduler.clearInterval(this.timer);
|
|
57
|
-
this.timer = undefined;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
isRunning(): boolean {
|
|
61
|
-
return this.timer !== undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
render(width: number): string[] {
|
|
65
|
-
const lines = this.child.render(width);
|
|
66
|
-
const glyph = this.bright
|
|
67
|
-
? this.theme.fg("warning", this.theme.bold("●"))
|
|
68
|
-
: this.theme.fg("dim", "●");
|
|
69
|
-
const headline = truncateToWidth(`${glyph} ${this.headline}`, Math.max(1, width), "");
|
|
70
|
-
return lines.length > 0 ? [headline, ...lines.slice(1)] : [headline];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
handleInput(data: string): void {
|
|
74
|
-
this.child.handleInput(data);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
invalidate(): void {
|
|
78
|
-
this.child.invalidate();
|
|
79
|
-
}
|
|
80
|
-
}
|
package/loop-context.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Detecting an unattended pi-loop session.
|
|
3
|
-
*
|
|
4
|
-
* A guardian that decides "ask_user" opens a modal and waits. In an ordinary
|
|
5
|
-
* session that is the whole point — the boundary this extension exists to
|
|
6
|
-
* hold. In a session running a pi-loop it is a deadlock: a session waiting on
|
|
7
|
-
* a modal is `busy`, `busy` makes every loop continuation and every fallback
|
|
8
|
-
* tick skip, so no turn ever completes, no cap ever trips, and nothing ends
|
|
9
|
-
* the loop until it expires — up to seven days later.
|
|
10
|
-
*
|
|
11
|
-
* So the verdict stays exactly the same and only its *delivery* changes: the
|
|
12
|
-
* guardian's concern comes back as a non-blocking block the agent can read and
|
|
13
|
-
* act on, instead of a prompt nobody is there to answer. Nothing is approved
|
|
14
|
-
* that would not have been approved before.
|
|
15
|
-
*
|
|
16
|
-
* Detection mirrors `detectSubagentContext` deliberately: an environment
|
|
17
|
-
* variable, read the same way, with no import of and no dependency on the
|
|
18
|
-
* extension that sets it. pi-loop publishes `PI_LOOP_ACTIVE=1` and
|
|
19
|
-
* `PI_LOOP_ID` while a loop is active and removes them when it is not (see
|
|
20
|
-
* `packages/pi-loop/src/loop-env.ts`). Absent that env — pi-loop not
|
|
21
|
-
* installed, or installed with no loop running — behaviour is unchanged.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** Runtime facts about an active loop, forwarded to the guardian as evidence. */
|
|
25
|
-
export interface LoopExecutionContext {
|
|
26
|
-
loop: true;
|
|
27
|
-
/** The loop's id (PI_LOOP_ID), when present. */
|
|
28
|
-
loopId?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function detectLoopContext(
|
|
32
|
-
env: Record<string, string | undefined> = process.env,
|
|
33
|
-
): LoopExecutionContext | undefined {
|
|
34
|
-
if (env.PI_LOOP_ACTIVE !== "1") return undefined;
|
|
35
|
-
const context: LoopExecutionContext = { loop: true };
|
|
36
|
-
// The id reaches the guardian prompt, so cap length and charset the way the
|
|
37
|
-
// subagent run id is capped: an identifier, never a payload.
|
|
38
|
-
const loopId = env.PI_LOOP_ID?.trim().replace(/[^\w.:-]/g, "").slice(0, 128);
|
|
39
|
-
if (loopId) context.loopId = loopId;
|
|
40
|
-
return context;
|
|
41
|
-
}
|
package/loop-revise-budget.ts
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounding the revise path for an unattended loop.
|
|
3
|
-
*
|
|
4
|
-
* Turning "ask_user" into a readable block removes the deadlock, but on its
|
|
5
|
-
* own it replaces one failure with a worse one: an agent that can always try
|
|
6
|
-
* again will always try again, and "keep adjusting the command until the
|
|
7
|
-
* guardian stops objecting" is a search for the phrasing that slips past the
|
|
8
|
-
* gate. That is precisely what pi-loop's own autonomy posture forbids, and it
|
|
9
|
-
* is not made safe by the guardian re-reviewing each attempt — the guardian
|
|
10
|
-
* reviews commands, and the thing being optimised against it is the command.
|
|
11
|
-
*
|
|
12
|
-
* So the rounds are counted, per concern, and they run out. Within the bound
|
|
13
|
-
* the agent may revise to *address* what the guardian said. Past it the block
|
|
14
|
-
* stops offering that option and names `loop_wait` instead, which is the only
|
|
15
|
-
* way a loop can ask a human anything without deadlocking itself.
|
|
16
|
-
*
|
|
17
|
-
* The count is keyed on the concern rather than the command precisely because
|
|
18
|
-
* the command is what changes: counting per command would reset the budget on
|
|
19
|
-
* every reshape, which is the behaviour being bounded.
|
|
20
|
-
*
|
|
21
|
-
* Two bounds, not one, and this is the load-bearing detail. A per-concern
|
|
22
|
-
* count is what makes the block specific enough to act on ("one round remains
|
|
23
|
-
* against *this*"), but a concern is prose and prose gets reworded, so a
|
|
24
|
-
* guardian phrasing the same objection differently would hand back a fresh
|
|
25
|
-
* budget — the failure mode this exists to prevent, arriving through the back
|
|
26
|
-
* door. The per-gate count is immune to wording and bounds the whole argument
|
|
27
|
-
* regardless. Whichever runs out first ends it.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const LOOP_REVISIONS_ENTRY_TYPE = "auto-permissions-loop-revisions";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* How many revisions an agent gets against one concern before it must wait.
|
|
34
|
-
*
|
|
35
|
-
* Three blocked attempts, so there are two real revisions: the first is often
|
|
36
|
-
* an honest misunderstanding of a terse concern and the second is the
|
|
37
|
-
* considered fix. A third attempt at the same objection is not a better
|
|
38
|
-
* command, it is a search.
|
|
39
|
-
*/
|
|
40
|
-
export const LOOP_REVISE_ROUNDS = 3;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* How many blocked attempts an agent gets at one gate before it must wait,
|
|
44
|
-
* whatever the concerns were.
|
|
45
|
-
*
|
|
46
|
-
* Deliberately larger than the per-concern bound: a second, genuinely
|
|
47
|
-
* different objection deserves its own rounds. Deliberately finite: an agent
|
|
48
|
-
* collecting objection after objection at the same gate is not converging on
|
|
49
|
-
* an authorized command, and the wording of the objection is not what makes
|
|
50
|
-
* that true.
|
|
51
|
-
*/
|
|
52
|
-
export const LOOP_GATE_ROUNDS = 5;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* How many times in a row an agent may be blocked, at any gate, for any
|
|
56
|
-
* concern, before it must wait.
|
|
57
|
-
*
|
|
58
|
-
* The backstop, and the only bound nothing can reshape its way out of. Both
|
|
59
|
-
* bounds above are keyed on something the *command* determines: the concern is
|
|
60
|
-
* the guardian's prose, and the gate is whichever rule the command text
|
|
61
|
-
* matched. A canary walked straight through both — blocked on
|
|
62
|
-
* `git branch -D`, the agent tried `git branch --delete`, which is the same
|
|
63
|
-
* operation under a different rule, and the budget started over.
|
|
64
|
-
*
|
|
65
|
-
* Counting blocks is immune to both, because it measures the argument rather
|
|
66
|
-
* than the command. Only resolving that argument — an approval at a gate that
|
|
67
|
-
* has been blocking — resets it.
|
|
68
|
-
*/
|
|
69
|
-
export const LOOP_STREAK_ROUNDS = 5;
|
|
70
|
-
|
|
71
|
-
/** Words too common to distinguish one guardian concern from another. */
|
|
72
|
-
const STOPWORDS = new Set([
|
|
73
|
-
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "for", "from", "has", "have",
|
|
74
|
-
"in", "is", "it", "its", "not", "of", "on", "or", "that", "the", "this", "to", "was", "were",
|
|
75
|
-
"which", "with", "would", "user", "command", "action", "request", "because", "without",
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
/** Cap on the tokens that form a concern's signature. */
|
|
79
|
-
const SIGNATURE_TOKENS = 10;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* A stable key for "the same objection, restated".
|
|
83
|
-
*
|
|
84
|
-
* A guardian writes one concise sentence and writes it slightly differently
|
|
85
|
-
* each time, so exact text would reset the budget on every round. The sorted
|
|
86
|
-
* set of significant words survives rewording, and when two genuinely
|
|
87
|
-
* different concerns do collide the budget only ever binds *tighter* — the
|
|
88
|
-
* safe direction for a bound whose failure mode is unbounded retrying.
|
|
89
|
-
*/
|
|
90
|
-
export function concernKey(gateLabel: string, reason: string): string {
|
|
91
|
-
const tokens = [
|
|
92
|
-
...new Set(
|
|
93
|
-
reason
|
|
94
|
-
.toLowerCase()
|
|
95
|
-
.replace(/[^a-z0-9\s]/g, " ")
|
|
96
|
-
.split(/\s+/)
|
|
97
|
-
.map(stem)
|
|
98
|
-
.filter((token) => token.length > 2 && !STOPWORDS.has(token)),
|
|
99
|
-
),
|
|
100
|
-
]
|
|
101
|
-
.sort()
|
|
102
|
-
.slice(0, SIGNATURE_TOKENS);
|
|
103
|
-
return `${gateLabel}::${tokens.join(" ")}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Crude suffix stripping, so "authorize", "authorized" and "authorizing" are
|
|
108
|
-
* one token. Not linguistics: just enough that the commonest rewording of the
|
|
109
|
-
* same objection lands on the same key. Anything it misses is caught by the
|
|
110
|
-
* per-gate bound, which is why it can afford to be crude.
|
|
111
|
-
*/
|
|
112
|
-
function stem(token: string): string {
|
|
113
|
-
for (const suffix of ["ing", "ed", "es", "s"]) {
|
|
114
|
-
if (token.length > suffix.length + 3 && token.endsWith(suffix)) {
|
|
115
|
-
return token.slice(0, -suffix.length);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return token;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface PersistedBudget {
|
|
122
|
-
concerns?: unknown;
|
|
123
|
-
gates?: unknown;
|
|
124
|
-
streak?: unknown;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Per-concern revision counts for this session.
|
|
129
|
-
*
|
|
130
|
-
* Held in memory *and* persisted as a custom session entry. The memory copy is
|
|
131
|
-
* what survives a context compaction (the process does not restart, so the map
|
|
132
|
-
* is untouched while the conversation around it is rewritten); the persisted
|
|
133
|
-
* copy is what survives a session restore, and what makes the bound
|
|
134
|
-
* reconstructible rather than merely lucky. Both matter: a bound that a
|
|
135
|
-
* compaction silently reset would hand the agent unlimited rounds at exactly
|
|
136
|
-
* the moment its own memory of the concern was summarised away.
|
|
137
|
-
*/
|
|
138
|
-
/** What one charged round leaves the agent with. */
|
|
139
|
-
export interface LoopReviseCharge {
|
|
140
|
-
/** Rounds spent against this concern, including the one being reported. */
|
|
141
|
-
concernSpent: number;
|
|
142
|
-
/** Blocked attempts at this gate, including the one being reported. */
|
|
143
|
-
gateSpent: number;
|
|
144
|
-
/** Consecutive blocked attempts anywhere, including the one being reported. */
|
|
145
|
-
streakSpent: number;
|
|
146
|
-
/**
|
|
147
|
-
* Whether `concernSpent` was inherited from the streak rather than earned
|
|
148
|
-
* against this concern — i.e. the objection is being stated for the first
|
|
149
|
-
* time, mid-argument.
|
|
150
|
-
*
|
|
151
|
-
* The inheritance is what makes the bound un-reshapable (see `charge`), and
|
|
152
|
-
* it is exactly what makes the *sentence* around the number wrong if nothing
|
|
153
|
-
* carries this fact: a first-ever objection would be described as one the
|
|
154
|
-
* agent has been arguing with. A canary drove three single, un-revised
|
|
155
|
-
* commands at three different gates and was told it had "used every revision
|
|
156
|
-
* round this loop gets against it" and should "stop reshaping the command".
|
|
157
|
-
* It had reshaped nothing. The bound was right; the wording was not.
|
|
158
|
-
*/
|
|
159
|
-
concernInherited: boolean;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export class LoopReviseBudget {
|
|
163
|
-
private concerns = new Map<string, number>();
|
|
164
|
-
private gates = new Map<string, number>();
|
|
165
|
-
private streak = 0;
|
|
166
|
-
|
|
167
|
-
/** Rebuild from a session branch, fail-open: unreadable state means no rounds spent. */
|
|
168
|
-
restore(entries: readonly unknown[]): void {
|
|
169
|
-
this.concerns = new Map();
|
|
170
|
-
this.gates = new Map();
|
|
171
|
-
this.streak = 0;
|
|
172
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
173
|
-
const entry = entries[index] as
|
|
174
|
-
| { type?: string; customType?: string; data?: unknown }
|
|
175
|
-
| undefined;
|
|
176
|
-
if (entry?.type !== "custom" || entry.customType !== LOOP_REVISIONS_ENTRY_TYPE) continue;
|
|
177
|
-
const data = entry.data as PersistedBudget | undefined;
|
|
178
|
-
readCounts(data?.concerns, this.concerns);
|
|
179
|
-
readCounts(data?.gates, this.gates);
|
|
180
|
-
const streak = data?.streak;
|
|
181
|
-
if (typeof streak === "number" && Number.isSafeInteger(streak) && streak > 0) {
|
|
182
|
-
this.streak = streak;
|
|
183
|
-
}
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** The whole budget, as the data half of a session entry. */
|
|
189
|
-
snapshot(): {
|
|
190
|
-
concerns: Record<string, number>;
|
|
191
|
-
gates: Record<string, number>;
|
|
192
|
-
streak: number;
|
|
193
|
-
} {
|
|
194
|
-
return {
|
|
195
|
-
concerns: Object.fromEntries(this.concerns),
|
|
196
|
-
gates: Object.fromEntries(this.gates),
|
|
197
|
-
streak: this.streak,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** The custom-entry type this budget persists under. */
|
|
202
|
-
static get entryType(): string {
|
|
203
|
-
return LOOP_REVISIONS_ENTRY_TYPE;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** What `charge` would report without charging. */
|
|
207
|
-
spent(gateLabel: string, reason: string): LoopReviseCharge {
|
|
208
|
-
return {
|
|
209
|
-
concernSpent: this.concerns.get(concernKey(gateLabel, reason)) ?? 0,
|
|
210
|
-
gateSpent: this.gates.get(gateLabel) ?? 0,
|
|
211
|
-
streakSpent: this.streak,
|
|
212
|
-
concernInherited: false,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Charge one blocked attempt and return the new totals.
|
|
218
|
-
*
|
|
219
|
-
* A concern seen for the first time *during an argument already under way at
|
|
220
|
-
* this gate* inherits that argument's length rather than starting at one.
|
|
221
|
-
* Without it the count silently stops advancing whenever the guardian
|
|
222
|
-
* rewords its objection, and the block repeats "1 revision round remains"
|
|
223
|
-
* forever. Observed live in a canary, where the agent read two identical
|
|
224
|
-
* blocks and said so: "it says again '1 revision round remains' —
|
|
225
|
-
* ambiguous". A budget whose displayed remainder does not fall is not a
|
|
226
|
-
* budget, and the agent is right not to trust it.
|
|
227
|
-
*/
|
|
228
|
-
charge(gateLabel: string, reason: string): LoopReviseCharge {
|
|
229
|
-
const key = concernKey(gateLabel, reason);
|
|
230
|
-
this.streak += 1;
|
|
231
|
-
const gateSpent = (this.gates.get(gateLabel) ?? 0) + 1;
|
|
232
|
-
const seen = this.concerns.get(key);
|
|
233
|
-
// A concern or a gate first seen mid-argument inherits the argument's
|
|
234
|
-
// length from the streak, which is the only count a reshape cannot move.
|
|
235
|
-
const concernSpent = seen === undefined ? this.streak : seen + 1;
|
|
236
|
-
this.concerns.set(key, concernSpent);
|
|
237
|
-
this.gates.set(gateLabel, gateSpent);
|
|
238
|
-
// Inherited only when the streak actually supplied something: the first
|
|
239
|
-
// block of a loop is a concern's own first round, not a borrowed count.
|
|
240
|
-
return {
|
|
241
|
-
concernSpent,
|
|
242
|
-
gateSpent,
|
|
243
|
-
streakSpent: this.streak,
|
|
244
|
-
concernInherited: seen === undefined && this.streak > 1,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* An approved command ends the argument at that gate.
|
|
250
|
-
*
|
|
251
|
-
* Without this a long loop would accumulate unrelated objections all session
|
|
252
|
-
* and eventually refuse to revise anything. The per-concern counts stay:
|
|
253
|
-
* approving one command is not an answer to a different objection.
|
|
254
|
-
*
|
|
255
|
-
* The streak resets only when the approval lands on a gate that was actually
|
|
256
|
-
* blocking. Resetting it on *any* approval looked reasonable and was not: a
|
|
257
|
-
* canary agent ran three read-only `git log` and `git merge-base` calls
|
|
258
|
-
* between two blocked deletions, each approved, and every one of them handed
|
|
259
|
-
* the budget back. The backstop has to survive an agent looking things up in
|
|
260
|
-
* the middle of an argument, which is exactly what a thoughtful agent does.
|
|
261
|
-
*/
|
|
262
|
-
settle(gateLabel: string): void {
|
|
263
|
-
if (this.gates.delete(gateLabel)) this.streak = 0;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Forget everything: a new loop is not answerable for the last one's rounds. */
|
|
267
|
-
clear(): void {
|
|
268
|
-
this.concerns = new Map();
|
|
269
|
-
this.gates = new Map();
|
|
270
|
-
this.streak = 0;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function readCounts(value: unknown, into: Map<string, number>): void {
|
|
275
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
276
|
-
for (const [key, count] of Object.entries(value as Record<string, unknown>)) {
|
|
277
|
-
if (typeof count !== "number" || !Number.isSafeInteger(count) || count <= 0) continue;
|
|
278
|
-
if (!key || key.length > 512) continue;
|
|
279
|
-
into.set(key, count);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
interface LoopBlockOptions extends LoopReviseCharge {
|
|
284
|
-
gateLabel: string;
|
|
285
|
-
reason: string;
|
|
286
|
-
/**
|
|
287
|
-
* The verdict being delivered. Both non-approving verdicts arrive here, and
|
|
288
|
-
* they do not mean the same thing: `ask_user` is "a human would have to
|
|
289
|
-
* authorize this", `revise` is "the operation is authorized, this command
|
|
290
|
-
* is not". Announcing the second as the first tells an unattended agent to
|
|
291
|
-
* go and find a human when the reviewer asked it for an edit.
|
|
292
|
-
*/
|
|
293
|
-
decision?: "ask_user" | "revise";
|
|
294
|
-
maxRounds?: number;
|
|
295
|
-
maxGateRounds?: number;
|
|
296
|
-
maxStreakRounds?: number;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* The block an unattended loop receives instead of a modal.
|
|
301
|
-
*
|
|
302
|
-
* Within the bound it is an invitation to address the concern; past it, it is
|
|
303
|
-
* not. The final block deliberately offers exactly one next action, because a
|
|
304
|
-
* block that merely says "no" leaves the agent to invent its own way forward,
|
|
305
|
-
* and inventing a way forward past a permission gate is the failure mode.
|
|
306
|
-
*/
|
|
307
|
-
export function loopBlockReason(options: LoopBlockOptions): string {
|
|
308
|
-
const maxRounds = options.maxRounds ?? LOOP_REVISE_ROUNDS;
|
|
309
|
-
const maxGateRounds = options.maxGateRounds ?? LOOP_GATE_ROUNDS;
|
|
310
|
-
const head = options.decision === "revise"
|
|
311
|
-
? `${options.gateLabel} was not approved as written, and this session is running an unattended /loop, so the reviewer's revision request comes back here instead of a prompt: ${options.reason}`
|
|
312
|
-
: `${options.gateLabel} needs user approval, and this session is running an unattended /loop, so there is no one to ask: ${options.reason}`;
|
|
313
|
-
// One number, and it is the binding one. Reporting the per-concern budget
|
|
314
|
-
// alone let a reworded objection hold it still; reporting the per-gate one
|
|
315
|
-
// alone would promise rounds the concern budget will not honour.
|
|
316
|
-
const remaining = Math.min(
|
|
317
|
-
maxRounds - options.concernSpent,
|
|
318
|
-
maxGateRounds - options.gateSpent,
|
|
319
|
-
(options.maxStreakRounds ?? LOOP_STREAK_ROUNDS) - options.streakSpent,
|
|
320
|
-
);
|
|
321
|
-
// Whose budget the number is. A count inherited from the streak is not this
|
|
322
|
-
// objection's history, and saying that it is invites the agent to distrust a
|
|
323
|
-
// number that is in fact correct — or, at the end, to be told to stop doing
|
|
324
|
-
// something it never did.
|
|
325
|
-
const loopBound = options.concernInherited;
|
|
326
|
-
const wait = "Call loop_wait naming this concern, and record the options in the ledger so a human can answer in one word. If the objective can be advanced another way that does not involve this operation, do that instead.";
|
|
327
|
-
if (remaining <= 0) {
|
|
328
|
-
return [
|
|
329
|
-
head,
|
|
330
|
-
loopBound
|
|
331
|
-
? `This objection is new, so no revision rounds were spent against it — what ran out is the loop's budget for consecutive blocked commands: ${options.streakSpent} gated ${plural(options.streakSpent, "command")} in a row, with no approval in between. Further gated commands will not be approved either, and searching for one that is would be an end run around the gate.`
|
|
332
|
-
: `You have used every revision round this loop gets against it. Stop reshaping the command — further variations of it will not be approved, and searching for one that is would be an end run around the gate.`,
|
|
333
|
-
wait,
|
|
334
|
-
].join("\n");
|
|
335
|
-
}
|
|
336
|
-
const budget = `${remaining} revision ${plural(remaining, "round")} ${remaining === 1 ? "remains" : "remain"}`;
|
|
337
|
-
return [
|
|
338
|
-
head,
|
|
339
|
-
loopBound
|
|
340
|
-
? `You may revise the command to address that specific concern. This objection is new, so the number that follows is the loop's rather than this concern's: ${options.streakSpent} gated ${plural(options.streakSpent, "command")} in a row have been blocked with no approval in between, and ${budget} before you must call loop_wait rather than trying further gated commands.`
|
|
341
|
-
: `You may revise the command to address that specific concern. ${budget}; after that you must call loop_wait rather than reshaping further.`,
|
|
342
|
-
"Revise only to satisfy the concern as stated. Do not split, obfuscate, or re-route the command to avoid the gate — that is an end run, not a revision.",
|
|
343
|
-
].join("\n");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function plural(count: number, word: string): string {
|
|
347
|
-
return count === 1 ? word : `${word}s`;
|
|
348
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Codex models are reached over a websocket session rather than plain HTTP, and
|
|
3
|
-
* the guardian has to pick that transport itself. The check is on the model's
|
|
4
|
-
* api rather than its provider id so it keeps working for a reviewer pointed at
|
|
5
|
-
* an aliased Codex login (see @hank-warren/pi-multi-login).
|
|
6
|
-
*/
|
|
7
|
-
export function isOpenAICodexModel(model: { api?: string }): boolean {
|
|
8
|
-
return model.api === "openai-codex-responses";
|
|
9
|
-
}
|