one-code-extension 0.1.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/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The classifier call itself — the one file here that talks to a provider.
|
|
3
|
+
*
|
|
4
|
+
* A one-shot `completeSimple` rather than an agent session: the classifier gets
|
|
5
|
+
* no tools, no session, and no history beyond the user messages it is handed, so
|
|
6
|
+
* there is nothing for it to be talked into doing. Its only output is a verdict.
|
|
7
|
+
*
|
|
8
|
+
* Model selection prefers a small fast model (Claude Code uses a Sonnet-class
|
|
9
|
+
* model for this), overridable with `autoMode.classifierModel`, falling back to
|
|
10
|
+
* the session's own model so auto mode still works on a single-model setup.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
14
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
15
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { AutoModeConfig } from "./config.ts";
|
|
17
|
+
import { buildClassifierPrompt, type ClassifyRequest, type ClassifyVerdict, parseVerdict } from "./prompt.ts";
|
|
18
|
+
import {
|
|
19
|
+
type Candidate,
|
|
20
|
+
classifierCandidates,
|
|
21
|
+
describeCandidate,
|
|
22
|
+
isModelUnavailableError,
|
|
23
|
+
} from "./model-select.ts";
|
|
24
|
+
import { indexRules } from "./rules.ts";
|
|
25
|
+
|
|
26
|
+
/** A slow classifier stalls every tool call, so the wait is capped. */
|
|
27
|
+
export const CLASSIFIER_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A pinned model that times out this many calls in a row is unpinned, so a model
|
|
31
|
+
* that was fast when first chosen but has since fallen behind demand does not
|
|
32
|
+
* stay the session's classifier forever. The next call re-runs selection.
|
|
33
|
+
*/
|
|
34
|
+
export const ROTATE_AFTER_TIMEOUTS = 2;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a failure is our own classifier timeout (or an equivalent abort/stall)
|
|
38
|
+
* rather than a substantive provider error. A timeout is transient — worth a
|
|
39
|
+
* retry and worth stepping to another candidate — where a 500 or a bad request
|
|
40
|
+
* is not. A *user* cancel also aborts, so callers must rule that out first
|
|
41
|
+
* (`deps.signal?.aborted`) before treating an abort as a timeout.
|
|
42
|
+
*/
|
|
43
|
+
export function isClassifierTimeout(message: string): boolean {
|
|
44
|
+
// `\babort` (no trailing boundary) so "abort", "aborted", and "aborting" all
|
|
45
|
+
// match — the observed message is "Request aborted", which a trailing \b
|
|
46
|
+
// would miss. The others are whole words.
|
|
47
|
+
return /\babort|\btimed?\s*out\b|\btimeout\b|\betimedout\b|\bdeadline\b/i.test(message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which model the classifier settled on, and which candidates turned out to be
|
|
52
|
+
* unusable. Owned by the caller so this module stays stateless, and so the
|
|
53
|
+
* choice is *pinned*: re-resolving per call would let a registry refresh swap
|
|
54
|
+
* classifiers mid-session, and would re-try a model already known to be dead.
|
|
55
|
+
*/
|
|
56
|
+
export interface ClassifierState {
|
|
57
|
+
pinned?: Model<Api>;
|
|
58
|
+
/** `provider/id` of candidates that failed as unusable. */
|
|
59
|
+
rejected: Set<string>;
|
|
60
|
+
/** Notices already delivered, so a per-call warning is not repeated per call. */
|
|
61
|
+
notified: Set<string>;
|
|
62
|
+
/**
|
|
63
|
+
* Consecutive calls the pinned model has timed out on. Reset by any success;
|
|
64
|
+
* at ROTATE_AFTER_TIMEOUTS the pin is released so the session re-picks rather
|
|
65
|
+
* than staying bound to a model that has fallen behind demand.
|
|
66
|
+
*/
|
|
67
|
+
timeoutStreak: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createClassifierState(): ClassifierState {
|
|
71
|
+
return { rejected: new Set(), notified: new Set(), timeoutStreak: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ClassifierDeps {
|
|
75
|
+
registry: ModelRegistry;
|
|
76
|
+
sessionModel: Model<Api> | undefined;
|
|
77
|
+
config: AutoModeConfig;
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
state: ClassifierState;
|
|
80
|
+
/** Tell the user something once — which model is in use, or that theirs is dead. */
|
|
81
|
+
onNotice?: (message: string, level: "info" | "warning") => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The candidate chain, minus anything already found unusable this session. */
|
|
85
|
+
function remainingCandidates(deps: ClassifierDeps): Candidate[] {
|
|
86
|
+
const all = classifierCandidates({
|
|
87
|
+
available: deps.registry.getAvailable(),
|
|
88
|
+
sessionModel: deps.sessionModel,
|
|
89
|
+
configured: deps.config.classifierModel,
|
|
90
|
+
});
|
|
91
|
+
const usable = all.filter((entry) => !deps.state.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
92
|
+
if (usable.length > 0) return usable;
|
|
93
|
+
// If everything has been rejected, the session model is still worth one more
|
|
94
|
+
// attempt: a gate that stops asking has stopped gating. It has to be the
|
|
95
|
+
// session model specifically — the *last* chain entry is the cost-ranked
|
|
96
|
+
// pick, which nothing price-chosen should lead, let alone be the sole retry.
|
|
97
|
+
const session = all.filter((entry) => entry.source === "session");
|
|
98
|
+
return session.length > 0 ? session : all.slice(-1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function notifyOnce(deps: ClassifierDeps, key: string, message: string, level: "info" | "warning") {
|
|
102
|
+
if (deps.state.notified.has(key)) return;
|
|
103
|
+
deps.state.notified.add(key);
|
|
104
|
+
deps.onNotice?.(message, level);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ask the classifier about one tool call. Every failure path — no model, no
|
|
109
|
+
* credentials, timeout, provider error, unparseable reply — returns a block.
|
|
110
|
+
* An approval gate that cannot reach its classifier has not approved anything.
|
|
111
|
+
*/
|
|
112
|
+
export async function classify(request: ClassifyRequest, deps: ClassifierDeps): Promise<ClassifyVerdict> {
|
|
113
|
+
const candidates = remainingCandidates(deps);
|
|
114
|
+
if (candidates.length === 0) {
|
|
115
|
+
return { decision: "block", reason: "No model is available to run the auto-mode classifier.", tier: "unmatched" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A configured model that matches nothing available would otherwise fall
|
|
119
|
+
// through in silence, leaving the user believing their setting is in force
|
|
120
|
+
// while something else screens their calls.
|
|
121
|
+
const configured = deps.config.classifierModel;
|
|
122
|
+
if (configured && !candidates.some((entry) => entry.source === "configured")) {
|
|
123
|
+
notifyOnce(
|
|
124
|
+
deps,
|
|
125
|
+
`unresolved:${configured}`,
|
|
126
|
+
`autoMode.classifierModel is set to "${configured}", which is not an available model — check the name and that its provider is authenticated. Auto mode is using ${describeCandidate(candidates[0])} instead.`,
|
|
127
|
+
"warning",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const index = indexRules(deps.config);
|
|
132
|
+
const { system, user } = buildClassifierPrompt(request, deps.config, index);
|
|
133
|
+
|
|
134
|
+
// A model already pinned this session is tried first, so the classifier does
|
|
135
|
+
// not change under the session while the pin is healthy. The rest of the
|
|
136
|
+
// chain stays behind it: a pinned model can be withdrawn mid-session, and
|
|
137
|
+
// the same call must then step onward rather than fail every call forever.
|
|
138
|
+
const pinned = deps.state.pinned;
|
|
139
|
+
const attempts = pinned
|
|
140
|
+
? [
|
|
141
|
+
{ model: pinned, source: "session" as const },
|
|
142
|
+
...candidates.filter((entry) => entry.model.provider !== pinned.provider || entry.model.id !== pinned.id),
|
|
143
|
+
]
|
|
144
|
+
: candidates;
|
|
145
|
+
|
|
146
|
+
// Rejecting a model releases its pin: without that, a model withdrawn
|
|
147
|
+
// mid-session would stay pinned, be the only candidate tried on every later
|
|
148
|
+
// call, and turn the gate into a permanent block until restart.
|
|
149
|
+
const reject = (key: string) => {
|
|
150
|
+
deps.state.rejected.add(key);
|
|
151
|
+
if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
|
|
152
|
+
deps.state.pinned = undefined;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let lastError = "";
|
|
157
|
+
let sawTimeout = false;
|
|
158
|
+
for (const candidate of attempts) {
|
|
159
|
+
const model = candidate.model;
|
|
160
|
+
const key = `${model.provider}/${model.id}`;
|
|
161
|
+
|
|
162
|
+
const auth = await deps.registry.getApiKeyAndHeaders(model);
|
|
163
|
+
if (!auth.ok) {
|
|
164
|
+
reject(key);
|
|
165
|
+
lastError = auth.error;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Some pi builds resolve a per-provider baseUrl alongside the key; it is not
|
|
170
|
+
// in every published version of the auth type, so it is read defensively.
|
|
171
|
+
const baseUrl = (auth as { baseUrl?: string }).baseUrl;
|
|
172
|
+
|
|
173
|
+
const attempt = (maxTokens: number) => {
|
|
174
|
+
const timeout = AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS);
|
|
175
|
+
return completeSimple(
|
|
176
|
+
baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model,
|
|
177
|
+
{ systemPrompt: system, messages: [{ role: "user", content: user, timestamp: Date.now() }] },
|
|
178
|
+
{
|
|
179
|
+
apiKey: auth.apiKey,
|
|
180
|
+
headers: auth.headers,
|
|
181
|
+
env: auth.env,
|
|
182
|
+
signal: deps.signal ? AbortSignal.any([deps.signal, timeout]) : timeout,
|
|
183
|
+
...(process.env.CC_AUTO_MODE_DEBUG === "2"
|
|
184
|
+
? {
|
|
185
|
+
onPayload: (payload: unknown) => {
|
|
186
|
+
const body = payload as { system?: Array<Record<string, unknown>> };
|
|
187
|
+
const blocks = (body.system ?? []).map(
|
|
188
|
+
(b) =>
|
|
189
|
+
`{keys=${Object.keys(b).join(",")} textLen=${String((b as { text?: string }).text ?? "").length} cache=${JSON.stringify((b as { cache_control?: unknown }).cache_control)}}`,
|
|
190
|
+
);
|
|
191
|
+
process.stderr.write(`[auto-mode payload] systemBlocks=${blocks.join(" ")}\n`);
|
|
192
|
+
return undefined;
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
maxTokens,
|
|
197
|
+
// `reasoning` is deliberately omitted: pi turns thinking off entirely
|
|
198
|
+
// when it is absent, which is what a classifier wants (fast, cheap,
|
|
199
|
+
// deterministic). Passing even "minimal" enables thinking, and the
|
|
200
|
+
// resulting budget is derived from maxTokens — at 512 that lands under
|
|
201
|
+
// Anthropic's 1024-token floor and every request 400s, which the gate
|
|
202
|
+
// then correctly but uselessly reports as a block.
|
|
203
|
+
//
|
|
204
|
+
// `temperature` is likewise NOT passed. It looks free — a classifier
|
|
205
|
+
// wants determinism — but it is deprecated on Claude Sonnet 5 and
|
|
206
|
+
// unsupported on Opus 4.7+, and pi's compat data still advertises it,
|
|
207
|
+
// so the request 400s and the gate blocks every single call. Several
|
|
208
|
+
// OpenAI reasoning models reject it too. A gate that must work across
|
|
209
|
+
// 38 providers cannot afford an option that fails closed on some of
|
|
210
|
+
// them for a property it does not need.
|
|
211
|
+
cacheRetention: "long",
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// A cut-off reply (stopReason "length") voids the verdict, so retry once with
|
|
217
|
+
// real headroom. Used for both the initial call and the post-timeout retry.
|
|
218
|
+
const attemptWithLengthRetry = async () => {
|
|
219
|
+
let reply = await attempt(1024);
|
|
220
|
+
if (reply.stopReason === "length") reply = await attempt(4096);
|
|
221
|
+
return reply;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let failure: string | undefined;
|
|
225
|
+
try {
|
|
226
|
+
// A verdict is one JSON object, but the cap needs headroom over "one
|
|
227
|
+
// JSON object": models that reasoning cannot be disabled on (deepseek
|
|
228
|
+
// via opencode, observed live) burn output budget on deliberation —
|
|
229
|
+
// most on exactly the risky calls that deserve it — and a truncated
|
|
230
|
+
// reply voids the verdict. So a cut-off reply gets one retry with real
|
|
231
|
+
// headroom and a fresh timeout; the cap bounds runaway output, not
|
|
232
|
+
// typical cost, since replies still stop at the closing brace.
|
|
233
|
+
let reply = await attemptWithLengthRetry();
|
|
234
|
+
|
|
235
|
+
// One retry on a timeout, before giving up on this model. A stall under
|
|
236
|
+
// load usually clears on a second try; a model that is simply too slow
|
|
237
|
+
// times out again, and the loop then steps to the next candidate (or, if
|
|
238
|
+
// none remains, the call falls through to a user prompt). A *user* cancel
|
|
239
|
+
// also aborts — that is not a timeout and must not be retried.
|
|
240
|
+
if (
|
|
241
|
+
(reply.stopReason === "aborted" || reply.stopReason === "error") &&
|
|
242
|
+
!deps.signal?.aborted &&
|
|
243
|
+
isClassifierTimeout(reply.errorMessage ?? reply.stopReason ?? "")
|
|
244
|
+
) {
|
|
245
|
+
reply = await attemptWithLengthRetry();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (reply.stopReason === "error" || reply.stopReason === "aborted") {
|
|
249
|
+
failure = reply.errorMessage ?? reply.stopReason;
|
|
250
|
+
} else if (reply.stopReason === "length") {
|
|
251
|
+
// Still cut off at 4096 — same treatment as an unparseable reply (a
|
|
252
|
+
// verdict that cannot be read approved nothing), but named, because
|
|
253
|
+
// "unreadable response" sent a live debugging session hunting for
|
|
254
|
+
// JSON bugs when the real cause was the output budget.
|
|
255
|
+
if (process.env.CC_AUTO_MODE_DEBUG) {
|
|
256
|
+
const u = reply.usage as { input?: number; output?: number; reasoning?: number } | undefined;
|
|
257
|
+
process.stderr.write(
|
|
258
|
+
`[auto-mode] ${key} ${request.toolName} → verdict truncated at maxTokens ` +
|
|
259
|
+
`(in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0})\n`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
decision: "block",
|
|
264
|
+
reason:
|
|
265
|
+
"The approval classifier's reply was cut off by its output limit before the verdict completed — its analysis ran too long. If this keeps happening, pin a stronger classifier model with /auto-mode model.",
|
|
266
|
+
tier: "unmatched",
|
|
267
|
+
};
|
|
268
|
+
} else {
|
|
269
|
+
const text = reply.content
|
|
270
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
271
|
+
.map((block) => block.text)
|
|
272
|
+
.join("");
|
|
273
|
+
const verdict = parseVerdict(text, index, request.userMessages);
|
|
274
|
+
|
|
275
|
+
// A verdict came back, so the timeout streak that might have been
|
|
276
|
+
// building is over.
|
|
277
|
+
deps.state.timeoutStreak = 0;
|
|
278
|
+
|
|
279
|
+
// Pin on the first success, so the classifier cannot change under the
|
|
280
|
+
// session and a working model is not re-litigated per call.
|
|
281
|
+
if (!deps.state.pinned) {
|
|
282
|
+
deps.state.pinned = model;
|
|
283
|
+
notifyOnce(deps, `using:${key}`, `Auto mode is screening calls with ${describeCandidate(candidate)}.`, "info");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Allows carry no reason, so without this the permissive direction is
|
|
287
|
+
// invisible — which is the harder one to notice and the more costly one
|
|
288
|
+
// to get wrong. `CC_AUTO_MODE_DEBUG=1` puts the raw reply on stderr.
|
|
289
|
+
if (process.env.CC_AUTO_MODE_DEBUG) {
|
|
290
|
+
const u = reply.usage as
|
|
291
|
+
| {
|
|
292
|
+
input?: number;
|
|
293
|
+
output?: number;
|
|
294
|
+
reasoning?: number;
|
|
295
|
+
cacheRead?: number;
|
|
296
|
+
cacheWrite?: number;
|
|
297
|
+
cost?: unknown;
|
|
298
|
+
}
|
|
299
|
+
| undefined;
|
|
300
|
+
const cost = u?.cost;
|
|
301
|
+
const spend =
|
|
302
|
+
cost && typeof cost === "object"
|
|
303
|
+
? Object.entries(cost as Record<string, unknown>)
|
|
304
|
+
.filter(([, value]) => typeof value === "number" && value > 0)
|
|
305
|
+
.map(([name, value]) => `${name}=$${(value as number).toFixed(6)}`)
|
|
306
|
+
.join(" ")
|
|
307
|
+
: "";
|
|
308
|
+
process.stderr.write(
|
|
309
|
+
`[auto-mode] ${key} ${request.toolName} → ${verdict.decision}${verdict.ruleId ? ` (${verdict.ruleId})` : ""}\n` +
|
|
310
|
+
` usage: in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0} cacheRead=${u?.cacheRead ?? 0} cacheWrite=${u?.cacheWrite ?? 0} ${spend}\n` +
|
|
311
|
+
` raw: ${text.replace(/\s+/g, " ").slice(0, 400)}\n`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return verdict;
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
lastError = failure ?? "unknown error";
|
|
321
|
+
|
|
322
|
+
// A user cancel aborts the same way a timeout does. It is not a failure to
|
|
323
|
+
// route around — the user asked to stop, so stop.
|
|
324
|
+
if (deps.signal?.aborted) {
|
|
325
|
+
return { decision: "block", reason: "Auto-mode classification was cancelled.", tier: "unmatched" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// A timeout (after the one retry above) is transient, so the model is NOT
|
|
329
|
+
// rejected — it may be fine on the next call. Instead we step to the next
|
|
330
|
+
// candidate, which is a different model of the *same or cheaper* price in
|
|
331
|
+
// the same provider (the chain is privacy- and budget-contained upstream).
|
|
332
|
+
// A pinned model that keeps timing out is unpinned so the session re-picks.
|
|
333
|
+
if (isClassifierTimeout(lastError)) {
|
|
334
|
+
sawTimeout = true;
|
|
335
|
+
if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
|
|
336
|
+
deps.state.timeoutStreak += 1;
|
|
337
|
+
if (deps.state.timeoutStreak >= ROTATE_AFTER_TIMEOUTS) deps.state.pinned = undefined;
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// A model that is simply not usable here is worth stepping over. Any other
|
|
343
|
+
// transient failure is not: switching models would paper over something
|
|
344
|
+
// about to clear, so that surfaces as a block and the same model is tried
|
|
345
|
+
// again next call.
|
|
346
|
+
if (!isModelUnavailableError(lastError)) {
|
|
347
|
+
return {
|
|
348
|
+
decision: "block",
|
|
349
|
+
reason: `Auto-mode classifier could not be reached (${lastError}).`,
|
|
350
|
+
tier: "unmatched",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
reject(key);
|
|
355
|
+
const isConfigured = candidate.source === "configured";
|
|
356
|
+
notifyOnce(
|
|
357
|
+
deps,
|
|
358
|
+
`rejected:${key}`,
|
|
359
|
+
isConfigured
|
|
360
|
+
? `Auto mode cannot use ${key} from autoMode.classifierModel (${lastError}). Set a different model in ~/.claude/settings.json.`
|
|
361
|
+
: `Auto mode cannot use ${key} as its classifier (${lastError}); trying another model. Set autoMode.classifierModel in ~/.claude/settings.json to choose one.`,
|
|
362
|
+
"warning",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Every candidate timed out (with a retry each) and none was reachable in
|
|
367
|
+
// time. This is NOT a judgement that the call is unsafe — it was never
|
|
368
|
+
// screened — so it is surfaced as its own tier. The gate turns a `timeout`
|
|
369
|
+
// into a user prompt where it can (there is someone to ask), and only blocks
|
|
370
|
+
// outright when running non-interactively.
|
|
371
|
+
if (sawTimeout) {
|
|
372
|
+
return {
|
|
373
|
+
decision: "block",
|
|
374
|
+
tier: "timeout",
|
|
375
|
+
reason:
|
|
376
|
+
"Auto mode could not screen this call in time — the approval classifier timed out, so the call was not judged either way. " +
|
|
377
|
+
"Approve it if you recognise it as safe, or pin a faster classifier with /auto-mode model.",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Every candidate was unusable — say which knob fixes it rather than leaving
|
|
382
|
+
// the user with a gate that blocks everything for no stated reason.
|
|
383
|
+
return {
|
|
384
|
+
decision: "block",
|
|
385
|
+
reason: `No usable auto-mode classifier model (last error: ${lastError}). Set autoMode.classifierModel in ~/.claude/settings.json.`,
|
|
386
|
+
tier: "unmatched",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `autoMode` settings loading (pure fs), Claude Code compatible.
|
|
3
|
+
*
|
|
4
|
+
* ## Which files are read, and why that list is short
|
|
5
|
+
*
|
|
6
|
+
* User settings (`~/.claude/settings.json`) and managed settings only — **never
|
|
7
|
+
* the project's `.claude/settings.json` or `.claude/settings.local.json`**. Both
|
|
8
|
+
* live in the repository, so a checked-in file or a build step that writes one
|
|
9
|
+
* could otherwise hand itself classifier allow rules and switch off the gate
|
|
10
|
+
* that is meant to contain it. Claude Code made the same exclusion (it dropped
|
|
11
|
+
* `settings.local.json` in v2.1.207); this is that property, not an omission.
|
|
12
|
+
*
|
|
13
|
+
* `"$defaults"` in any list splices the built-in rules in at that position, so
|
|
14
|
+
* users keep inheriting updates. Omitting it replaces the list wholesale, which
|
|
15
|
+
* is a real and occasionally correct choice — `/auto-mode config` shows the
|
|
16
|
+
* result either way.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { DEFAULT_ALLOW, DEFAULT_ENVIRONMENT, DEFAULT_HARD_DENY, DEFAULT_SOFT_DENY } from "./defaults.ts";
|
|
22
|
+
|
|
23
|
+
export interface AutoModeConfig {
|
|
24
|
+
environment: string[];
|
|
25
|
+
allow: string[];
|
|
26
|
+
soft_deny: string[];
|
|
27
|
+
hard_deny: string[];
|
|
28
|
+
/** Suspend narrow Bash allow rules so every shell command reaches the classifier. */
|
|
29
|
+
classifyAllShell: boolean;
|
|
30
|
+
/** `provider/model-id` override for the classifier model. */
|
|
31
|
+
classifierModel?: string;
|
|
32
|
+
/** Append every gate decision to auto-mode-decisions.jsonl next to the session files. */
|
|
33
|
+
logDecisions: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface AutoModeSettingsFile {
|
|
37
|
+
autoMode?: {
|
|
38
|
+
environment?: unknown;
|
|
39
|
+
allow?: unknown;
|
|
40
|
+
soft_deny?: unknown;
|
|
41
|
+
hard_deny?: unknown;
|
|
42
|
+
classifyAllShell?: unknown;
|
|
43
|
+
classifierModel?: unknown;
|
|
44
|
+
logDecisions?: unknown;
|
|
45
|
+
};
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULTS_TOKEN = "$defaults";
|
|
50
|
+
|
|
51
|
+
/** Managed-settings locations, highest authority, matching Claude Code's paths. */
|
|
52
|
+
function managedSettingsPaths(): string[] {
|
|
53
|
+
if (process.platform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-settings.json"];
|
|
54
|
+
if (process.platform === "win32") return ["C:\\ProgramData\\ClaudeCode\\managed-settings.json"];
|
|
55
|
+
return ["/etc/claude-code/managed-settings.json"];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The files `autoMode` is read from, lowest precedence first. */
|
|
59
|
+
export function autoModeSettingsPaths(home: string): string[] {
|
|
60
|
+
return [join(home, ".claude", "settings.json"), ...managedSettingsPaths()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readFile(path: string, diagnostics: string[]): AutoModeSettingsFile | undefined {
|
|
64
|
+
if (!existsSync(path)) return undefined;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(path, "utf-8")) as AutoModeSettingsFile;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Swallowing this silently is how a user ends up believing rules are in
|
|
69
|
+
// force that the gate never loaded — the file is still skipped (the gate
|
|
70
|
+
// must not die on a typo), but the skip is reported.
|
|
71
|
+
diagnostics.push(`${path}: invalid JSON, autoMode settings in it are ignored (${(error as Error).message})`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const KNOWN_AUTO_MODE_KEYS = new Set([
|
|
77
|
+
"environment",
|
|
78
|
+
"allow",
|
|
79
|
+
"soft_deny",
|
|
80
|
+
"hard_deny",
|
|
81
|
+
"classifyAllShell",
|
|
82
|
+
"classifierModel",
|
|
83
|
+
"logDecisions",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const RULE_LIST_KEYS = ["environment", "allow", "soft_deny", "hard_deny"] as const;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Shape problems in one file's `autoMode` block, as human-readable lines.
|
|
90
|
+
* Purely advisory: loading stays lenient (a wrong-typed field is skipped, not
|
|
91
|
+
* fatal), but skipped-because-mistyped must be visible in `/auto-mode config`
|
|
92
|
+
* or it reads as the gate ignoring the user's settings.
|
|
93
|
+
*/
|
|
94
|
+
function validateAutoModeBlock(block: Record<string, unknown>, path: string, diagnostics: string[]): void {
|
|
95
|
+
for (const key of Object.keys(block)) {
|
|
96
|
+
if (!KNOWN_AUTO_MODE_KEYS.has(key)) diagnostics.push(`${path}: unknown autoMode key "${key}" is ignored`);
|
|
97
|
+
}
|
|
98
|
+
for (const key of RULE_LIST_KEYS) {
|
|
99
|
+
const value = block[key];
|
|
100
|
+
if (value === undefined) continue;
|
|
101
|
+
if (!Array.isArray(value)) {
|
|
102
|
+
diagnostics.push(`${path}: autoMode.${key} must be an array of strings — ignored`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const [position, entry] of value.entries()) {
|
|
106
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
107
|
+
diagnostics.push(`${path}: autoMode.${key}[${position}] is not a non-empty string — dropped`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const strings = value.filter((entry): entry is string => typeof entry === "string");
|
|
111
|
+
if (strings.length > 0 && !strings.some((entry) => entry.trim() === DEFAULTS_TOKEN)) {
|
|
112
|
+
diagnostics.push(
|
|
113
|
+
`${path}: autoMode.${key} omits "${DEFAULTS_TOKEN}", so it REPLACES the built-in ${key} list rather than extending it`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const key of ["classifyAllShell", "logDecisions"] as const) {
|
|
118
|
+
if (block[key] !== undefined && typeof block[key] !== "boolean") {
|
|
119
|
+
diagnostics.push(`${path}: autoMode.${key} must be a boolean — ignored`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
block.classifierModel !== undefined &&
|
|
124
|
+
(typeof block.classifierModel !== "string" || block.classifierModel.trim().length === 0)
|
|
125
|
+
) {
|
|
126
|
+
diagnostics.push(`${path}: autoMode.classifierModel must be a "provider/model-id" string — ignored`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
131
|
+
if (!Array.isArray(value)) return undefined;
|
|
132
|
+
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Splice `$defaults` into a user list. Entries from separate scopes are
|
|
137
|
+
* additive: a developer can extend a managed list but not remove from it.
|
|
138
|
+
*/
|
|
139
|
+
export function spliceDefaults(entries: string[] | undefined, defaults: string[]): string[] {
|
|
140
|
+
if (!entries) return [...defaults];
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
if (entry.trim() === DEFAULTS_TOKEN) out.push(...defaults);
|
|
144
|
+
else out.push(entry);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AutoModeConfigLoad {
|
|
150
|
+
config: AutoModeConfig;
|
|
151
|
+
/** Problems found while loading, for `/auto-mode config`. Empty when clean. */
|
|
152
|
+
diagnostics: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function loadAutoModeConfigWithDiagnostics(home: string): AutoModeConfigLoad {
|
|
156
|
+
const diagnostics: string[] = [];
|
|
157
|
+
const collected: {
|
|
158
|
+
environment?: string[];
|
|
159
|
+
allow?: string[];
|
|
160
|
+
soft_deny?: string[];
|
|
161
|
+
hard_deny?: string[];
|
|
162
|
+
classifyAllShell?: boolean;
|
|
163
|
+
classifierModel?: string;
|
|
164
|
+
logDecisions?: boolean;
|
|
165
|
+
} = {};
|
|
166
|
+
|
|
167
|
+
for (const path of autoModeSettingsPaths(home)) {
|
|
168
|
+
const file = readFile(path, diagnostics);
|
|
169
|
+
const block = file?.autoMode;
|
|
170
|
+
if (file !== undefined && "autoMode" in file && (typeof block !== "object" || block === null || Array.isArray(block))) {
|
|
171
|
+
diagnostics.push(`${path}: autoMode must be an object — ignored`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!block) continue;
|
|
175
|
+
validateAutoModeBlock(block as Record<string, unknown>, path, diagnostics);
|
|
176
|
+
for (const key of RULE_LIST_KEYS) {
|
|
177
|
+
const parsed = stringArray(block[key]);
|
|
178
|
+
// Additive across scopes: a later scope extends rather than replaces,
|
|
179
|
+
// so managed entries cannot be dropped by a user file.
|
|
180
|
+
if (parsed) collected[key] = [...(collected[key] ?? []), ...parsed];
|
|
181
|
+
}
|
|
182
|
+
if (typeof block.classifyAllShell === "boolean") collected.classifyAllShell = block.classifyAllShell;
|
|
183
|
+
if (typeof block.logDecisions === "boolean") collected.logDecisions = block.logDecisions;
|
|
184
|
+
if (typeof block.classifierModel === "string" && block.classifierModel.trim()) {
|
|
185
|
+
collected.classifierModel = block.classifierModel.trim();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
config: {
|
|
191
|
+
environment: spliceDefaults(collected.environment, DEFAULT_ENVIRONMENT),
|
|
192
|
+
allow: spliceDefaults(collected.allow, DEFAULT_ALLOW),
|
|
193
|
+
soft_deny: spliceDefaults(collected.soft_deny, DEFAULT_SOFT_DENY),
|
|
194
|
+
hard_deny: spliceDefaults(collected.hard_deny, DEFAULT_HARD_DENY),
|
|
195
|
+
classifyAllShell: collected.classifyAllShell ?? false,
|
|
196
|
+
classifierModel: collected.classifierModel,
|
|
197
|
+
logDecisions: collected.logDecisions ?? false,
|
|
198
|
+
},
|
|
199
|
+
diagnostics,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function loadAutoModeConfig(home: string): AutoModeConfig {
|
|
204
|
+
return loadAutoModeConfigWithDiagnostics(home).config;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Persist `autoMode.classifierModel` in the *user* settings file, preserving
|
|
209
|
+
* every other key. `undefined` removes the setting. Always user scope: this is
|
|
210
|
+
* the knob that may move the classifier to another provider, so it lives where
|
|
211
|
+
* only the user writes — never in a project file (see the module comment).
|
|
212
|
+
*
|
|
213
|
+
* Unlike loading, this throws on a malformed file: a lenient read merely skips
|
|
214
|
+
* rules, but a lenient write would replace the user's whole settings file with
|
|
215
|
+
* only ours.
|
|
216
|
+
*/
|
|
217
|
+
export function persistClassifierModel(spec: string | undefined, home: string): void {
|
|
218
|
+
const path = join(home, ".claude", "settings.json");
|
|
219
|
+
let file: Record<string, unknown> = {};
|
|
220
|
+
if (existsSync(path)) {
|
|
221
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
222
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
223
|
+
throw new Error(`${path}: root must be a JSON object`);
|
|
224
|
+
}
|
|
225
|
+
file = parsed as Record<string, unknown>;
|
|
226
|
+
}
|
|
227
|
+
const block =
|
|
228
|
+
typeof file.autoMode === "object" && file.autoMode !== null && !Array.isArray(file.autoMode)
|
|
229
|
+
? (file.autoMode as Record<string, unknown>)
|
|
230
|
+
: {};
|
|
231
|
+
if (spec === undefined) delete block.classifierModel;
|
|
232
|
+
else block.classifierModel = spec;
|
|
233
|
+
if (Object.keys(block).length === 0) delete file.autoMode;
|
|
234
|
+
else file.autoMode = block;
|
|
235
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
236
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
237
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto mode's decision log (pure apart from the append).
|
|
3
|
+
*
|
|
4
|
+
* Opt-in via `autoMode.logDecisions: true`. One JSONL line per gate decision —
|
|
5
|
+
* who decided (pre-gate, floor, classifier, the user at a prompt), what about,
|
|
6
|
+
* and on what grounds — in `auto-mode-decisions.jsonl` next to the session
|
|
7
|
+
* files, with a sessionId on each line.
|
|
8
|
+
*
|
|
9
|
+
* This exists because the calibration workflow is real: both regressions this
|
|
10
|
+
* feature has had were caught by reading raw verdicts, and stderr under
|
|
11
|
+
* CC_AUTO_MODE_DEBUG only helps when someone thought to set it *before* the
|
|
12
|
+
* session. A standing log makes "what has the gate been deciding, and which
|
|
13
|
+
* direction is drifting" answerable after the fact. The permissive direction
|
|
14
|
+
* especially: allows are invisible in the UI by design, so this file is the
|
|
15
|
+
* only complete record of them.
|
|
16
|
+
*
|
|
17
|
+
* Logging must never break the gate, so failures are swallowed: a gate that
|
|
18
|
+
* blocks tool calls because its diary is unwritable has its priorities
|
|
19
|
+
* backwards.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
|
|
25
|
+
export interface DecisionEntry {
|
|
26
|
+
ts: string;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
/** Command or path, clipped — never tool output. */
|
|
30
|
+
subject: string;
|
|
31
|
+
outcome: "allow" | "block" | "prompt";
|
|
32
|
+
/** Which layer decided. */
|
|
33
|
+
source: "pre-gate" | "classifier" | "floor" | "user" | "review";
|
|
34
|
+
tier?: string;
|
|
35
|
+
ruleId?: string;
|
|
36
|
+
reason?: string;
|
|
37
|
+
/** The classifier's own commentary, attributed (see prompt.ts). */
|
|
38
|
+
raw?: string;
|
|
39
|
+
/** `provider/id` of the model that produced a classifier verdict. */
|
|
40
|
+
model?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SUBJECT_LIMIT = 300;
|
|
44
|
+
|
|
45
|
+
export function decisionEntry(entry: Omit<DecisionEntry, "ts" | "subject"> & { subject: string }): DecisionEntry {
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
ts: new Date().toISOString(),
|
|
49
|
+
subject: entry.subject.length > SUBJECT_LIMIT ? `${entry.subject.slice(0, SUBJECT_LIMIT)}…` : entry.subject,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function appendDecision(file: string, entry: DecisionEntry): void {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
56
|
+
appendFileSync(file, `${JSON.stringify(entry)}\n`);
|
|
57
|
+
} catch {
|
|
58
|
+
// Logging must never break the gate.
|
|
59
|
+
}
|
|
60
|
+
}
|