codeep 2.21.0 → 2.22.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/dist/api/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as http from 'node:http';
2
2
  import * as https from 'node:https';
3
- import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
3
+ import { config, getApiKey, resolveBaseUrl, describeUnsendableKey } from '../config/index.js';
4
4
  import { withRetry, isNetworkError } from '../utils/retry.js';
5
5
  import { checkApiRateLimit } from '../utils/ratelimit.js';
6
6
  import { getProvider, getProviderBaseUrl, getProviderAuthHeader, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, reasoningParamsFor } from '../config/providers.js';
@@ -147,6 +147,28 @@ function parseApiError(status, body) {
147
147
  const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body;
148
148
  return `${status} - ${truncated}`;
149
149
  }
150
+ /**
151
+ * Put the key in the right header, refusing early if it cannot be sent.
152
+ *
153
+ * `fetch` throws "Cannot convert argument to a ByteString because the character
154
+ * at index N…" for a header value outside Latin-1, counting from the start of
155
+ * `Bearer <key>` — so the index points four characters left of where anyone
156
+ * would look, and the message never mentions the key at all. Worse, the caller
157
+ * treats it as a transient API error and retries twice more, which cannot
158
+ * possibly help.
159
+ */
160
+ function applyAuthHeader(headers, apiKey, authHeader) {
161
+ const problem = describeUnsendableKey(apiKey);
162
+ if (problem) {
163
+ throw new ApiError(`This API key cannot be used: ${problem}. Re-copy it and run /login to set it again.`, 400);
164
+ }
165
+ if (authHeader === 'Bearer') {
166
+ headers['Authorization'] = `Bearer ${apiKey}`;
167
+ }
168
+ else {
169
+ headers['x-api-key'] = apiKey;
170
+ }
171
+ }
150
172
  export async function chat(message, history = [], onChunk, onRetry, projectContext, abortSignal) {
151
173
  // Update project context if provided
152
174
  if (projectContext !== undefined) {
@@ -377,12 +399,7 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
377
399
  const headers = {
378
400
  'Content-Type': 'application/json',
379
401
  };
380
- if (authHeader === 'Bearer') {
381
- headers['Authorization'] = `Bearer ${apiKey}`;
382
- }
383
- else {
384
- headers['x-api-key'] = apiKey;
385
- }
402
+ applyAuthHeader(headers, apiKey, authHeader);
386
403
  // OpenRouter: branding headers + opt in to `usage.cost` so the
387
404
  // chat path reports authoritative per-call cost just like agentChat
388
405
  // does. Kept identical to the agentChat block so the two paths stay
@@ -650,12 +667,7 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
650
667
  'Content-Type': 'application/json',
651
668
  'anthropic-version': '2023-06-01',
652
669
  };
653
- if (authHeader === 'Bearer') {
654
- headers['Authorization'] = `Bearer ${apiKey}`;
655
- }
656
- else {
657
- headers['x-api-key'] = apiKey;
658
- }
670
+ applyAuthHeader(headers, apiKey, authHeader);
659
671
  try {
660
672
  // Anthropic prompt caching: wrap system as an array with a
661
673
  // `cache_control` marker so the static system prompt (typically large
@@ -797,12 +809,7 @@ export async function validateApiKey(apiKey, providerId) {
797
809
  const headers = {
798
810
  'Content-Type': 'application/json',
799
811
  };
800
- if (authHeader === 'Bearer') {
801
- headers['Authorization'] = `Bearer ${apiKey}`;
802
- }
803
- else {
804
- headers['x-api-key'] = apiKey;
805
- }
812
+ applyAuthHeader(headers, apiKey, authHeader);
806
813
  if (protocol === 'anthropic') {
807
814
  headers['anthropic-version'] = '2023-06-01';
808
815
  }
@@ -37,6 +37,12 @@ export interface ConfigSchema {
37
37
  * user (reply language, style, stack, preferences). Default true; set false
38
38
  * to keep the profile files but stop injecting them. Managed via `/me`. */
39
39
  userProfile: boolean;
40
+ /** Append a record of what each agent run touched to `.codeep/audit/`.
41
+ * Reads and refusals included — `history.ts` records neither, because it
42
+ * exists to undo writes rather than to say what happened. On unless set
43
+ * false: a record you must remember to enable is not one you can rely on
44
+ * having when you need it. */
45
+ auditLog: boolean;
40
46
  /** Auto-learn: at session save, run one LLM pass to extract durable facts /
41
47
  * preferences about the user and merge them into `~/.codeep/profile.learned.md`
42
48
  * (injected alongside the hand-written profile). OFF by default — opt in via
@@ -318,3 +324,17 @@ export declare function loadProfile(name: string): Profile | null;
318
324
  export declare function applyProfile(profile: Profile): void;
319
325
  export declare function listProfiles(): string[];
320
326
  export declare function deleteProfile(name: string): boolean;
327
+ /**
328
+ * Whether a key can survive being put in an HTTP header.
329
+ *
330
+ * `fetch` encodes header values as Latin-1 and throws "Cannot convert argument
331
+ * to a ByteString" on anything outside it. A key pasted from a web page or a
332
+ * chat message can pick up a non-breaking space, a zero-width character or a
333
+ * curly quote, and the resulting failure names neither the key nor the
334
+ * character — only "the character at index N", counted across the whole header
335
+ * value with `Bearer ` included, which is not where anyone would look.
336
+ *
337
+ * Returns null when the key is fine, or a description that locates the problem
338
+ * without ever reproducing the key itself.
339
+ */
340
+ export declare function describeUnsendableKey(apiKey: string): string | null;
@@ -175,6 +175,7 @@ function createConfig() {
175
175
  autoSessionTitle: true,
176
176
  autoSummarizeHistory: true,
177
177
  userProfile: true,
178
+ auditLog: true,
178
179
  autoLearnProfile: false,
179
180
  trustedHookProjects: [],
180
181
  currentSessionId: '',
@@ -1264,3 +1265,34 @@ export function deleteProfile(name) {
1264
1265
  return false;
1265
1266
  }
1266
1267
  }
1268
+ /**
1269
+ * Whether a key can survive being put in an HTTP header.
1270
+ *
1271
+ * `fetch` encodes header values as Latin-1 and throws "Cannot convert argument
1272
+ * to a ByteString" on anything outside it. A key pasted from a web page or a
1273
+ * chat message can pick up a non-breaking space, a zero-width character or a
1274
+ * curly quote, and the resulting failure names neither the key nor the
1275
+ * character — only "the character at index N", counted across the whole header
1276
+ * value with `Bearer ` included, which is not where anyone would look.
1277
+ *
1278
+ * Returns null when the key is fine, or a description that locates the problem
1279
+ * without ever reproducing the key itself.
1280
+ */
1281
+ export function describeUnsendableKey(apiKey) {
1282
+ for (let i = 0; i < apiKey.length; i++) {
1283
+ const code = apiKey.charCodeAt(i);
1284
+ if (code > 0xFF) {
1285
+ const name = code === 0x200B ? 'a zero-width space'
1286
+ : code === 0x2018 || code === 0x2019 ? 'a curly quote'
1287
+ : code === 0x201C || code === 0x201D ? 'a curly double quote'
1288
+ : `U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
1289
+ return `character ${i + 1} of the key is ${name}, which cannot be sent in an HTTP header`;
1290
+ }
1291
+ // 0xA0 is inside Latin-1 and technically sendable, but a non-breaking space
1292
+ // in a key is never intentional and produces a 401 that reads as a bad key.
1293
+ if (code === 0xA0) {
1294
+ return `character ${i + 1} of the key is a non-breaking space — probably picked up when copying`;
1295
+ }
1296
+ }
1297
+ return null;
1298
+ }
@@ -205,6 +205,7 @@ export const COMMANDS = [
205
205
  usage: ['init [project]', 'learn [on|off]', 'sync', 'off', 'forget'],
206
206
  },
207
207
  { name: 'agents', description: 'List sub-agents the agent can delegate to (researcher / reviewer / tester / your own)', category: 'settings' },
208
+ { name: 'audit', description: 'What agents did in this project — runs, tools used, and anything the boundary refused', category: 'settings', usage: ['on', 'off'] },
208
209
  { name: 'insights', description: 'Activity summary — runs, files, tools, projects over the last N days (default 7)', category: 'settings', usage: ['--days N'] },
209
210
  { name: 'openrouter', description: 'OpenRouter routing prefs (prefer/ignore providers, fallbacks, privacy)', category: 'settings' },
210
211
  // ── extensions & mcp ───────────────────────────────────────────────────────
@@ -445,6 +445,23 @@ export async function handleCommand(command, args, ctx) {
445
445
  });
446
446
  break;
447
447
  }
448
+ case 'audit': {
449
+ const { formatAuditLog } = await import('../utils/auditLog.js');
450
+ const sub = args[0]?.toLowerCase();
451
+ if (sub === 'on' || sub === 'off') {
452
+ config.set('auditLog', sub === 'on');
453
+ ctx.app.notify(sub === 'on'
454
+ ? 'Audit recording on — runs are recorded to .codeep/audit/.'
455
+ : 'Audit recording off. Records already written are kept.');
456
+ break;
457
+ }
458
+ if (sub) {
459
+ ctx.app.notify(`Unknown audit subcommand: ${sub}. Use /audit, /audit on, or /audit off`);
460
+ break;
461
+ }
462
+ ctx.app.addMessage({ role: 'system', content: formatAuditLog(ctx.projectPath) });
463
+ break;
464
+ }
448
465
  case 'agents': {
449
466
  // List sub-agents the agent can `delegate` to (built-in + .codeep/agents/).
450
467
  const { formatAgentList } = await import('../utils/agents.js');
@@ -6,6 +6,7 @@
6
6
  import { ProjectContext } from './project';
7
7
  import { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent } from './agentChat';
8
8
  import type { AgentChatResponse } from './agentChat';
9
+ import { type Personality } from './personalities';
9
10
  export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
10
11
  export type { AgentChatResponse };
11
12
  import { ToolCall, ToolResult, ActionLog } from './tools';
@@ -83,6 +84,12 @@ export interface AgentOptions {
83
84
  * sub-agent's tool actions still record into the parent's session, so undo
84
85
  * spans delegation. */
85
86
  nested?: boolean;
87
+ /** Run under this capability boundary instead of whatever the user has
88
+ * selected. Used by non-interactive callers that must pin the boundary
89
+ * themselves — a CI fix, for example, runs files+tests regardless of the
90
+ * machine's active bot. Enforced by the same gate as any other bot; this
91
+ * chooses which one applies, never whether one does. */
92
+ personalityOverride?: Personality;
86
93
  /** Delegation depth. 0 = top-level orchestrator (gets the `delegate` tool);
87
94
  * sub-agents run at depth 1 and cannot delegate further (v1). */
88
95
  depth?: number;
@@ -14,6 +14,7 @@ const debug = (...args) => {
14
14
  import { agentChat, getAgentSystemPrompt, getFallbackSystemPrompt, loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent, summarizeEarlierHistory, } from './agentChat.js';
15
15
  import { ApiError } from '../api/index.js';
16
16
  import { loadUserProfilePrompt } from './userProfile.js';
17
+ import { beginAuditRun, endAuditRun, recordAuditEvent, describeAuditTarget } from './auditLog.js';
17
18
  import { getActivePersonality, getPersonalityToolAllowlist, isPersonalityToolCallAllowed, resolvePersonalityRuntimeModel, } from './personalities.js';
18
19
  export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
19
20
  /**
@@ -172,6 +173,30 @@ const DEFAULT_OPTIONS = {
172
173
  maxDuration: 20 * 60 * 1000, // 20 minutes
173
174
  usePlanning: false, // Disable task planning - causes more problems than it solves
174
175
  };
176
+ /**
177
+ * Sleep that gives up when the run is stopped.
178
+ *
179
+ * A plain `setTimeout` promise ignores the abort signal, so pressing Stop
180
+ * during "retrying in 10s" did nothing until the wait expired — and then the
181
+ * loop went on to retry anyway. Ctrl-C behaved the same way, because both
182
+ * routes set the same signal that nothing was reading.
183
+ *
184
+ * Resolves early on abort. Callers must still check `aborted` afterwards; this
185
+ * only stops the waiting, it does not decide what to do next.
186
+ */
187
+ function abortableSleep(ms, signal) {
188
+ if (signal?.aborted)
189
+ return Promise.resolve();
190
+ return new Promise(resolve => {
191
+ const timer = setTimeout(finish, ms);
192
+ function finish() {
193
+ clearTimeout(timer);
194
+ signal?.removeEventListener('abort', finish);
195
+ resolve();
196
+ }
197
+ signal?.addEventListener('abort', finish, { once: true });
198
+ });
199
+ }
175
200
  /**
176
201
  * Run the agent loop
177
202
  */
@@ -190,7 +215,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
190
215
  const messages = [];
191
216
  // A structured custom bot is resolved once per run. This keeps a cloud sync
192
217
  // or file edit from changing policy halfway through an in-flight request.
193
- const activePersonality = getActivePersonality(projectContext.root);
218
+ const activePersonality = opts.personalityOverride ?? getActivePersonality(projectContext.root);
194
219
  const currentRuntime = {
195
220
  providerId: String(config.get('provider')),
196
221
  model: String(config.get('model')),
@@ -208,8 +233,18 @@ export async function runAgent(prompt, projectContext, options = {}) {
208
233
  // The return value is unused — startSession's point here is the side
209
234
  // effect of opening the history session. Binding it hid that from
210
235
  // noUnusedLocals, so the dead binding is gone and the call stays.
236
+ const auditRoot = projectContext.root || process.cwd();
211
237
  if (!opts.nested)
212
- startSession(prompt, projectContext.root || process.cwd());
238
+ startSession(prompt, auditRoot);
239
+ // A delegated sub-agent records into the parent's run for the same reason it
240
+ // shares the parent's history session: its actions are part of what the run
241
+ // did, not a separate story. Only the top-level call opens a run.
242
+ let auditFailure;
243
+ const auditRun = opts.nested ? '' : beginAuditRun(auditRoot, {
244
+ prompt,
245
+ agent: activePersonality?.displayName,
246
+ capabilities: activePersonality?.declaredTools,
247
+ });
213
248
  // Task planning phase (if enabled)
214
249
  // Use planning for complex keywords or multi-word prompts
215
250
  let taskPlan = null;
@@ -431,6 +466,9 @@ export async function runAgent(prompt, projectContext, options = {}) {
431
466
  messages.push({ role: 'user', content: initialPrompt });
432
467
  let iteration = 0;
433
468
  let finalResponse = '';
469
+ // Initialised rather than merely declared: the `finally` reads it to decide
470
+ // the audit outcome, and TypeScript is right that a throw before assignment
471
+ // would leave it unset.
434
472
  let result;
435
473
  let consecutiveTimeouts = 0;
436
474
  let incompleteWorkRetries = 0;
@@ -575,7 +613,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
575
613
  const totalTokensEstimate = messages.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
576
614
  const throttleMs = Math.min(Math.floor(totalTokensEstimate / 10000) * 1000, 5000);
577
615
  if (throttleMs > 0)
578
- await new Promise(resolve => setTimeout(resolve, throttleMs));
616
+ await abortableSleep(throttleMs, opts.abortSignal);
579
617
  }
580
618
  // Compress messages if context window is getting full (silent)
581
619
  const compressed = compressMessages(messages, actions);
@@ -668,7 +706,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
668
706
  break;
669
707
  }
670
708
  // Wait before retry (exponential backoff)
671
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
709
+ await abortableSleep(1000 * retryCount, opts.abortSignal);
710
+ // Stopping during a backoff must actually stop. Without this the
711
+ // wait ended and the loop retried the request the user cancelled.
712
+ if (opts.abortSignal?.aborted)
713
+ break;
672
714
  continue;
673
715
  }
674
716
  // Don't retry on 4xx client errors except 429 (rate limit)
@@ -732,7 +774,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
732
774
  });
733
775
  break; // Break retry loop, continue main loop
734
776
  }
735
- await new Promise(resolve => setTimeout(resolve, waitSec * 1000));
777
+ await abortableSleep(waitSec * 1000, opts.abortSignal);
778
+ // Stopping during a rate-limit wait must actually stop. Without
779
+ // this the wait ran to completion and the loop retried the request
780
+ // the user had already cancelled.
781
+ if (opts.abortSignal?.aborted)
782
+ break;
736
783
  continue;
737
784
  }
738
785
  }
@@ -837,6 +884,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
837
884
  };
838
885
  opts.onToolResult?.(denied, toolCall);
839
886
  actions.push(createActionLog(toolCall, denied));
887
+ // The one event nothing recorded before. A boundary you cannot audit
888
+ // is a boundary you have to take on faith.
889
+ recordAuditEvent(auditRoot, {
890
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
891
+ target: describeAuditTarget(toolCall), outcome: 'refused',
892
+ detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
893
+ });
840
894
  toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
841
895
  continue;
842
896
  }
@@ -944,6 +998,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
944
998
  // Log action
945
999
  const actionLog = createActionLog(toolCall, toolResult);
946
1000
  actions.push(actionLog);
1001
+ // createActionLog already classified this; reuse its verdict rather than
1002
+ // re-deriving the action type in a second place that could drift.
1003
+ recordAuditEvent(auditRoot, {
1004
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
1005
+ target: describeAuditTarget(toolCall),
1006
+ outcome: toolResult.success ? 'ok' : 'error',
1007
+ detail: toolResult.success ? undefined : toolResult.error,
1008
+ });
947
1009
  // ── Infinite loop detection for write/edit ──────────────────────────
948
1010
  if (toolCall.tool === 'write_file' || toolCall.tool === 'edit_file') {
949
1011
  const filePath = toolCall.parameters.path || '';
@@ -1125,6 +1187,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
1125
1187
  };
1126
1188
  opts.onToolResult?.(denied, toolCall);
1127
1189
  actions.push(createActionLog(toolCall, denied));
1190
+ // The one event nothing recorded before. A boundary you cannot audit
1191
+ // is a boundary you have to take on faith.
1192
+ recordAuditEvent(auditRoot, {
1193
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
1194
+ target: describeAuditTarget(toolCall), outcome: 'refused',
1195
+ detail: `blocked by custom bot "${activePersonality.displayName}"`,
1196
+ });
1128
1197
  fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
1129
1198
  continue;
1130
1199
  }
@@ -1145,6 +1214,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
1145
1214
  opts.onToolResult?.(toolResult, toolCall);
1146
1215
  const actionLog = createActionLog(toolCall, toolResult);
1147
1216
  actions.push(actionLog);
1217
+ // createActionLog already classified this; reuse its verdict rather than
1218
+ // re-deriving the action type in a second place that could drift.
1219
+ recordAuditEvent(auditRoot, {
1220
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
1221
+ target: describeAuditTarget(toolCall),
1222
+ outcome: toolResult.success ? 'ok' : 'error',
1223
+ detail: toolResult.success ? undefined : toolResult.error,
1224
+ });
1148
1225
  if (toolResult.success) {
1149
1226
  const truncated = truncateToolResult(toolResult.output, toolCall.tool);
1150
1227
  fixResults.push(`Tool ${toolCall.tool} succeeded:\n${truncated}`);
@@ -1203,6 +1280,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
1203
1280
  }
1204
1281
  catch (error) {
1205
1282
  const err = error;
1283
+ auditFailure = err.message;
1206
1284
  result = {
1207
1285
  success: false,
1208
1286
  iterations: iteration,
@@ -1215,8 +1293,22 @@ export async function runAgent(prompt, projectContext, options = {}) {
1215
1293
  finally {
1216
1294
  // End session and save history. Skipped for nested runs so we don't write
1217
1295
  // a separate session file or null out the parent's open session.
1218
- if (!opts.nested)
1296
+ if (!opts.nested) {
1219
1297
  endSession();
1298
+ // In `finally`, so a run that throws still closes its record. An audit
1299
+ // trail that only survives success is worth very little — the runs you
1300
+ // most want to read are the ones that went wrong.
1301
+ //
1302
+ // The outcome comes from the result, not from whether an exception
1303
+ // escaped. Several failure paths — a user abort, a 4xx from the provider
1304
+ // — return `success: false` with a plain `return`, and reading only the
1305
+ // catch recorded those as successful runs. An audit record that says a
1306
+ // failed run passed is worse than having no record at all.
1307
+ const failed = auditFailure ?? (result === undefined || result.success
1308
+ ? undefined
1309
+ : (result.aborted ? 'stopped by the user' : (result.error ?? 'run did not succeed')));
1310
+ endAuditRun(auditRoot, auditRun, failed ? 'error' : 'ok', failed);
1311
+ }
1220
1312
  }
1221
1313
  }
1222
1314
  /**
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Audit record — what an agent actually touched.
3
+ *
4
+ * Distinct from `history.ts`, which exists to undo things: that journal keeps
5
+ * file contents so a write can be reversed, records only mutations, and lives
6
+ * in `~/.codeep/history/`. This one answers a different question — *what did
7
+ * this agent do to this project* — so it records reads and refusals too, keeps
8
+ * no file contents at all, and lives with the project it describes.
9
+ *
10
+ * The most valuable entry is the one nothing recorded before: a tool call the
11
+ * capability boundary refused. A boundary you cannot audit is a boundary you
12
+ * have to take on faith.
13
+ *
14
+ * Format is JSON Lines. Appending one line per event survives a crash mid-run,
15
+ * needs no read-modify-write, and stays greppable without a parser.
16
+ *
17
+ * PRIVACY: entries carry command lines and file paths, not file contents. A
18
+ * command can still contain a secret someone typed into it, exactly as shell
19
+ * history can — treat the directory like shell history, not like source.
20
+ *
21
+ * It sits under `.codeep/`, which most projects already ignore, but Codeep does
22
+ * not edit anyone's `.gitignore` and this module must not claim otherwise. If a
23
+ * project tracks `.codeep/`, the audit log will be committed with it.
24
+ */
25
+ /** One thing an agent did, or was stopped from doing. */
26
+ export interface AuditEvent {
27
+ /** Epoch millis. */
28
+ ts: number;
29
+ /** Groups every event from one agent run. */
30
+ run: string;
31
+ /** Provider-facing tool name, e.g. `read_file`. Absent on run markers. */
32
+ tool?: string;
33
+ /** What kind of thing happened. `refused` is a boundary denial. */
34
+ action: 'run-start' | 'run-end' | 'read' | 'write' | 'edit' | 'delete' | 'command' | 'search' | 'list' | 'mkdir' | 'fetch' | 'refused';
35
+ /** File path, command line, or URL — truncated, never file contents. */
36
+ target?: string;
37
+ outcome?: 'ok' | 'error' | 'refused';
38
+ /** Short reason or note. Never a file body. */
39
+ detail?: string;
40
+ /** Run markers only: the active custom bot and what it was granted. */
41
+ agent?: string;
42
+ capabilities?: string[];
43
+ prompt?: string;
44
+ }
45
+ /** A one-line, content-free description of what a tool call was aimed at.
46
+ * Paths, commands and URLs are the point of the record; file bodies are not,
47
+ * and `content`/`old_string` style arguments are never read here. */
48
+ export declare function describeAuditTarget(call: {
49
+ tool: string;
50
+ parameters: Record<string, unknown>;
51
+ }): string;
52
+ /** Audit is on unless explicitly disabled. A record you have to remember to
53
+ * switch on is not a record you can rely on having. */
54
+ export declare function isAuditEnabled(): boolean;
55
+ /**
56
+ * Append one event. Never throws: an unwritable project (read-only checkout,
57
+ * full disk, a directory we lack permission for) must not take the agent down
58
+ * with it. A missing audit line is a gap in the record; a crashed run is worse.
59
+ */
60
+ export declare function recordAuditEvent(projectRoot: string, event: AuditEvent): void;
61
+ /** Open a run and return its id. Records the agent and the capabilities it was
62
+ * granted, so a later reader can tell what the boundary *was* at the time —
63
+ * a bot edited afterwards must not rewrite the history of what it could do. */
64
+ export declare function beginAuditRun(projectRoot: string, opts: {
65
+ prompt: string;
66
+ agent?: string;
67
+ capabilities?: string[];
68
+ }): string;
69
+ export declare function endAuditRun(projectRoot: string, run: string, outcome: 'ok' | 'error', detail?: string): void;
70
+ /** One run, reassembled from its lines. */
71
+ export interface AuditRun {
72
+ run: string;
73
+ startedAt: number;
74
+ endedAt?: number;
75
+ prompt?: string;
76
+ agent?: string;
77
+ capabilities?: string[];
78
+ outcome?: 'ok' | 'error';
79
+ events: AuditEvent[];
80
+ refusals: number;
81
+ }
82
+ /**
83
+ * Read back the most recent runs, newest first.
84
+ *
85
+ * Malformed lines are skipped rather than throwing: an append-only log written
86
+ * by a process that may be killed mid-write will occasionally end in a partial
87
+ * line, and one torn line must not make the whole record unreadable.
88
+ */
89
+ export declare function readAuditRuns(projectRoot: string, limit?: number): AuditRun[];
90
+ /** Render recent runs for `/audit`. Deliberately compact: the question this
91
+ * answers is "what has been happening here", and a wall of every event
92
+ * answers it worse than a summary with the refusals called out. */
93
+ export declare function formatAuditLog(projectRoot: string, limit?: number): string;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Audit record — what an agent actually touched.
3
+ *
4
+ * Distinct from `history.ts`, which exists to undo things: that journal keeps
5
+ * file contents so a write can be reversed, records only mutations, and lives
6
+ * in `~/.codeep/history/`. This one answers a different question — *what did
7
+ * this agent do to this project* — so it records reads and refusals too, keeps
8
+ * no file contents at all, and lives with the project it describes.
9
+ *
10
+ * The most valuable entry is the one nothing recorded before: a tool call the
11
+ * capability boundary refused. A boundary you cannot audit is a boundary you
12
+ * have to take on faith.
13
+ *
14
+ * Format is JSON Lines. Appending one line per event survives a crash mid-run,
15
+ * needs no read-modify-write, and stays greppable without a parser.
16
+ *
17
+ * PRIVACY: entries carry command lines and file paths, not file contents. A
18
+ * command can still contain a secret someone typed into it, exactly as shell
19
+ * history can — treat the directory like shell history, not like source.
20
+ *
21
+ * It sits under `.codeep/`, which most projects already ignore, but Codeep does
22
+ * not edit anyone's `.gitignore` and this module must not claim otherwise. If a
23
+ * project tracks `.codeep/`, the audit log will be committed with it.
24
+ */
25
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { randomBytes } from 'crypto';
28
+ import { config } from '../config/index.js';
29
+ /** A one-line, content-free description of what a tool call was aimed at.
30
+ * Paths, commands and URLs are the point of the record; file bodies are not,
31
+ * and `content`/`old_string` style arguments are never read here. */
32
+ export function describeAuditTarget(call) {
33
+ const p = call.parameters ?? {};
34
+ const str = (k) => (typeof p[k] === 'string' ? p[k] : undefined);
35
+ const command = str('command');
36
+ if (command) {
37
+ const args = Array.isArray(p.args) ? p.args.map(String) : [];
38
+ return [command, ...args].join(' ');
39
+ }
40
+ return str('path') ?? str('url') ?? str('pattern') ?? str('query') ?? str('directory') ?? call.tool;
41
+ }
42
+ const MAX_TARGET = 300;
43
+ const MAX_DETAIL = 200;
44
+ const MAX_PROMPT = 400;
45
+ function auditDir(projectRoot) {
46
+ return join(projectRoot, '.codeep', 'audit');
47
+ }
48
+ /** One file per day keeps a long-lived project from growing a single huge log
49
+ * while staying trivial to find — no index, no rotation logic. */
50
+ function auditFile(projectRoot) {
51
+ const day = new Date().toISOString().slice(0, 10);
52
+ return join(auditDir(projectRoot), `${day}.jsonl`);
53
+ }
54
+ function clip(value, max) {
55
+ if (value === undefined)
56
+ return undefined;
57
+ const flat = value.replace(/\s+/g, ' ').trim();
58
+ return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
59
+ }
60
+ /** Audit is on unless explicitly disabled. A record you have to remember to
61
+ * switch on is not a record you can rely on having. */
62
+ export function isAuditEnabled() {
63
+ return config.get('auditLog') !== false;
64
+ }
65
+ /**
66
+ * Append one event. Never throws: an unwritable project (read-only checkout,
67
+ * full disk, a directory we lack permission for) must not take the agent down
68
+ * with it. A missing audit line is a gap in the record; a crashed run is worse.
69
+ */
70
+ export function recordAuditEvent(projectRoot, event) {
71
+ if (!isAuditEnabled())
72
+ return;
73
+ try {
74
+ const dir = auditDir(projectRoot);
75
+ if (!existsSync(dir))
76
+ mkdirSync(dir, { recursive: true });
77
+ const line = {
78
+ ...event,
79
+ target: clip(event.target, MAX_TARGET),
80
+ detail: clip(event.detail, MAX_DETAIL),
81
+ prompt: clip(event.prompt, MAX_PROMPT),
82
+ };
83
+ // Drop undefined keys so a line stays small and diffs stay readable.
84
+ const compact = Object.fromEntries(Object.entries(line).filter(([, v]) => v !== undefined));
85
+ appendFileSync(auditFile(projectRoot), JSON.stringify(compact) + '\n');
86
+ }
87
+ catch {
88
+ /* an unwritable audit log must never fail the run */
89
+ }
90
+ }
91
+ /** Open a run and return its id. Records the agent and the capabilities it was
92
+ * granted, so a later reader can tell what the boundary *was* at the time —
93
+ * a bot edited afterwards must not rewrite the history of what it could do. */
94
+ export function beginAuditRun(projectRoot, opts) {
95
+ const run = randomBytes(6).toString('hex');
96
+ recordAuditEvent(projectRoot, {
97
+ ts: Date.now(),
98
+ run,
99
+ action: 'run-start',
100
+ prompt: opts.prompt,
101
+ agent: opts.agent,
102
+ capabilities: opts.capabilities,
103
+ });
104
+ return run;
105
+ }
106
+ export function endAuditRun(projectRoot, run, outcome, detail) {
107
+ recordAuditEvent(projectRoot, { ts: Date.now(), run, action: 'run-end', outcome, detail });
108
+ }
109
+ /**
110
+ * Read back the most recent runs, newest first.
111
+ *
112
+ * Malformed lines are skipped rather than throwing: an append-only log written
113
+ * by a process that may be killed mid-write will occasionally end in a partial
114
+ * line, and one torn line must not make the whole record unreadable.
115
+ */
116
+ export function readAuditRuns(projectRoot, limit = 20) {
117
+ const dir = auditDir(projectRoot);
118
+ if (!existsSync(dir))
119
+ return [];
120
+ let files;
121
+ try {
122
+ files = readdirSync(dir)
123
+ .filter(f => f.endsWith('.jsonl'))
124
+ .sort()
125
+ .reverse();
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ const runs = new Map();
131
+ for (const file of files) {
132
+ let raw;
133
+ try {
134
+ const path = join(dir, file);
135
+ if (statSync(path).size > 8 * 1024 * 1024)
136
+ continue; // skip an absurd file
137
+ raw = readFileSync(path, 'utf8');
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ for (const line of raw.split('\n')) {
143
+ if (!line.trim())
144
+ continue;
145
+ let event;
146
+ try {
147
+ event = JSON.parse(line);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (!event || typeof event.run !== 'string' || typeof event.ts !== 'number')
153
+ continue;
154
+ let entry = runs.get(event.run);
155
+ if (!entry) {
156
+ entry = { run: event.run, startedAt: event.ts, events: [], refusals: 0 };
157
+ runs.set(event.run, entry);
158
+ }
159
+ if (event.action === 'run-start') {
160
+ entry.startedAt = event.ts;
161
+ entry.prompt = event.prompt;
162
+ entry.agent = event.agent;
163
+ entry.capabilities = event.capabilities;
164
+ }
165
+ else if (event.action === 'run-end') {
166
+ entry.endedAt = event.ts;
167
+ entry.outcome = event.outcome === 'error' ? 'error' : 'ok';
168
+ }
169
+ else {
170
+ entry.events.push(event);
171
+ if (event.action === 'refused')
172
+ entry.refusals++;
173
+ }
174
+ }
175
+ if (runs.size >= limit * 2)
176
+ break; // enough files read to satisfy `limit`
177
+ }
178
+ return [...runs.values()]
179
+ .sort((a, b) => b.startedAt - a.startedAt)
180
+ .slice(0, limit);
181
+ }
182
+ /** Render recent runs for `/audit`. Deliberately compact: the question this
183
+ * answers is "what has been happening here", and a wall of every event
184
+ * answers it worse than a summary with the refusals called out. */
185
+ export function formatAuditLog(projectRoot, limit = 10) {
186
+ const runs = readAuditRuns(projectRoot, limit);
187
+ if (runs.length === 0) {
188
+ return isAuditEnabled()
189
+ ? 'No agent runs recorded in this project yet.\n\nThe record starts at the next run and lives in `.codeep/audit/`.'
190
+ : 'Audit recording is off for this project. Turn it on with `/audit on`.';
191
+ }
192
+ const lines = ['**Recent agent runs** — `.codeep/audit/`', ''];
193
+ for (const run of runs) {
194
+ const when = new Date(run.startedAt).toLocaleString();
195
+ const took = run.endedAt ? `${Math.max(1, Math.round((run.endedAt - run.startedAt) / 1000))}s` : 'unfinished';
196
+ const who = run.agent ? `**${run.agent}**` : 'default agent';
197
+ const grant = run.capabilities?.length ? ` (${run.capabilities.join(', ')})` : '';
198
+ const mark = run.outcome === 'error' ? '✗' : '✓';
199
+ lines.push(`${mark} ${when} · ${who}${grant} · ${took}`);
200
+ if (run.prompt)
201
+ lines.push(` ${run.prompt}`);
202
+ // Summarise by action so a hundred reads collapse to "read ×100".
203
+ const tally = new Map();
204
+ for (const e of run.events)
205
+ tally.set(e.action, (tally.get(e.action) ?? 0) + 1);
206
+ const summary = [...tally.entries()].map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
207
+ if (summary)
208
+ lines.push(` ${summary}`);
209
+ // Refusals are the reason this record exists, so they are never collapsed.
210
+ for (const e of run.events.filter(e => e.action === 'refused')) {
211
+ lines.push(` ⨯ refused: ${e.tool}${e.target ? ` → ${e.target}` : ''}`);
212
+ }
213
+ lines.push('');
214
+ }
215
+ lines.push('_Paths and commands only — file contents are never recorded._');
216
+ return lines.join('\n');
217
+ }
@@ -1,3 +1,4 @@
1
+ import { type FixPlan } from './reviewFix.js';
1
2
  import { ReviewResult } from './codeReview.js';
2
3
  export type FailOn = 'error' | 'warning' | 'info' | 'none';
3
4
  export interface ReviewArgs {
@@ -7,8 +8,12 @@ export interface ReviewArgs {
7
8
  rules: boolean;
8
9
  ai: boolean;
9
10
  help: boolean;
11
+ /** Hand the findings to an agent and let it edit the working tree. */
12
+ fix: boolean;
13
+ /** Lowest severity the fix run may act on. Suggestions are never eligible. */
14
+ fixMinSeverity: 'error' | 'warning';
10
15
  }
11
- export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
16
+ export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --fix After the review, let an agent fix what it found. Edits the\n working tree and stops there \u2014 it never commits, branches\n or pushes. Runs under a files+tests boundary: no shell, no\n network, no git. Needs an API key.\n --fix-min-severity Lowest severity --fix may act on: error | warning\n (default: warning). Suggestions are never eligible.\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
12
17
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
13
18
  export declare function parseReviewArgs(argv: string[]): ReviewArgs;
14
19
  /** Exit code for a result under a fail-on threshold. Pure. */
@@ -29,6 +34,9 @@ export interface ReviewDeps {
29
34
  provider?: string;
30
35
  model?: string;
31
36
  };
37
+ /** Run an agent over a fix plan. Returns a human summary, or null when it
38
+ * could not run at all (no API key, provider unreachable). */
39
+ applyFixes: (plan: FixPlan) => Promise<string | null>;
32
40
  }
33
41
  /**
34
42
  * Orchestrate a headless review and return the process exit code. Side effects
@@ -1,3 +1,4 @@
1
+ import { buildFixPlan, summariseFixPlan } from './reviewFix.js';
1
2
  // Headless `codeep review` — a non-interactive entry point around the
2
3
  // deterministic reviewer in codeReview.ts. No API key, no TUI: it scans, prints
3
4
  // a report (markdown or JSON), and exits non-zero when issues at/above a chosen
@@ -24,6 +25,12 @@ Options:
24
25
  --json Print the result as JSON instead of the markdown report
25
26
  --fail-on <level> Exit non-zero when an issue at or above <level> is found:
26
27
  error | warning | info | none (default: error)
28
+ --fix After the review, let an agent fix what it found. Edits the
29
+ working tree and stops there — it never commits, branches
30
+ or pushes. Runs under a files+tests boundary: no shell, no
31
+ network, no git. Needs an API key.
32
+ --fix-min-severity Lowest severity --fix may act on: error | warning
33
+ (default: warning). Suggestions are never eligible.
27
34
  --rules List the built-in rule ids (for "disable" in .codeep/review.*) and exit
28
35
  --ai After the offline pass, ask your configured provider for a
29
36
  contextual second opinion on the working-tree diff
@@ -33,7 +40,10 @@ Options:
33
40
  Exit code: 0 when nothing at/above --fail-on is found, 1 otherwise.`;
34
41
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
35
42
  export function parseReviewArgs(argv) {
36
- const out = { files: [], json: false, failOn: 'error', rules: false, ai: false, help: false };
43
+ const out = {
44
+ files: [], json: false, failOn: 'error', rules: false, ai: false, help: false,
45
+ fix: false, fixMinSeverity: 'warning',
46
+ };
37
47
  for (let i = 0; i < argv.length; i++) {
38
48
  const arg = argv[i];
39
49
  if (arg === '--json') {
@@ -45,6 +55,19 @@ export function parseReviewArgs(argv) {
45
55
  else if (arg === '--ai') {
46
56
  out.ai = true;
47
57
  }
58
+ else if (arg === '--fix') {
59
+ out.fix = true;
60
+ }
61
+ else if (arg === '--fix-min-severity') {
62
+ const v = argv[++i];
63
+ if (v === 'error' || v === 'warning')
64
+ out.fixMinSeverity = v;
65
+ }
66
+ else if (arg.startsWith('--fix-min-severity=')) {
67
+ const v = arg.slice('--fix-min-severity='.length);
68
+ if (v === 'error' || v === 'warning')
69
+ out.fixMinSeverity = v;
70
+ }
48
71
  else if (arg === '-h' || arg === '--help') {
49
72
  out.help = true;
50
73
  }
@@ -104,12 +127,26 @@ export async function runHeadlessReview(argv, deps = defaultDeps()) {
104
127
  }
105
128
  const result = deps.review(args.files.length ? args.files : undefined);
106
129
  const aiText = args.ai ? await deps.aiReview(result) : null;
130
+ // Fixing happens after reporting, and never changes the exit code. CI decides
131
+ // pass or fail from what the reviewer found; whether an agent then managed to
132
+ // repair some of it is a separate question, and letting a successful fix turn
133
+ // a red check green would hide the finding rather than resolve it.
134
+ let fixSummary = null;
135
+ if (args.fix) {
136
+ const plan = buildFixPlan(result.issues, { minSeverity: args.fixMinSeverity });
137
+ fixSummary = plan.skipped ? summariseFixPlan(plan) : await deps.applyFixes(plan);
138
+ }
107
139
  if (args.json) {
108
- deps.write(JSON.stringify(args.ai ? { ...result, aiReview: aiText } : result, null, 2));
140
+ deps.write(JSON.stringify({
141
+ ...result,
142
+ ...(args.ai ? { aiReview: aiText } : {}),
143
+ ...(args.fix ? { fix: fixSummary } : {}),
144
+ }, null, 2));
109
145
  }
110
146
  else {
111
147
  const md = formatReviewResult(result);
112
- deps.write(args.ai ? appendAiSection(md, aiText, deps.aiMeta()) : md);
148
+ const withAi = args.ai ? appendAiSection(md, aiText, deps.aiMeta()) : md;
149
+ deps.write(fixSummary ? `${withAi}\n\n## Fix run\n\n${fixSummary}\n` : withAi);
113
150
  }
114
151
  return exitCodeForResult(result, args.failOn);
115
152
  }
@@ -132,6 +169,7 @@ function defaultDeps() {
132
169
  review: (files) => performCodeReview(minimalContext(cwd), files),
133
170
  write: (text) => process.stdout.write(text + '\n'),
134
171
  listRules: () => formatBuiltinRules(),
172
+ applyFixes: (plan) => runFixPlan(plan, minimalContext(cwd)),
135
173
  aiMeta: () => {
136
174
  try {
137
175
  return { provider: getCurrentProvider().name, model: String(config.get('model') || '') };
@@ -165,3 +203,39 @@ function defaultDeps() {
165
203
  },
166
204
  };
167
205
  }
206
+ /**
207
+ * Run a fix plan through the agent.
208
+ *
209
+ * The plan's personality is passed as the active one, so the same enforcement
210
+ * any custom bot gets applies here: the model is offered `files` and `tests`
211
+ * and nothing else. It edits the working tree and stops — branching, committing
212
+ * and opening a pull request belong to whatever called this, which in CI is the
213
+ * action that holds the token.
214
+ */
215
+ async function runFixPlan(plan, context) {
216
+ try {
217
+ const { runAgent } = await import('./agent.js');
218
+ // No cast here. `as never` on this call once hid the fact that
219
+ // personalityOverride did not exist, which would have run the CI fix with
220
+ // no boundary at all while the tests happily asserted otherwise.
221
+ const result = await runAgent(plan.prompt, context, {
222
+ personalityOverride: plan.personality,
223
+ maxIterations: 12,
224
+ });
225
+ const edited = new Set(result.actions
226
+ .filter(a => a.type === 'write' || a.type === 'edit')
227
+ .map(a => a.target));
228
+ if (!result.success) {
229
+ return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}.`;
230
+ }
231
+ if (edited.size === 0) {
232
+ return `${summariseFixPlan(plan)} Nothing was changed — the agent judged the findings not mechanically fixable.`;
233
+ }
234
+ return `${summariseFixPlan(plan)} Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
235
+ }
236
+ catch (error) {
237
+ // A missing key or an unreachable provider must not fail the review. The
238
+ // findings are already reported and the exit code already decided.
239
+ return `Could not run the fix: ${error.message}`;
240
+ }
241
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Deciding what a CI agent may attempt to fix, and under what boundary.
3
+ *
4
+ * The reviewer already finds problems. This decides which of them are worth
5
+ * handing to an agent, caps how much it may take on, and pins the capabilities
6
+ * it runs with. It deliberately stops there: it produces a plan, never a
7
+ * commit. Staging, branching and opening a pull request belong to the action,
8
+ * which has the token — and keeping git out of the agent's reach is half the
9
+ * reason this is safe to run in CI at all.
10
+ *
11
+ * The boundary is the point. A fix run gets `files` (it must edit) and `tests`
12
+ * (it must check its own work) and nothing else. No shell, no git, no network.
13
+ * Enforced by the same machinery as any other custom bot, so an agent that
14
+ * decides it would like to curl something simply has no tool to do it with.
15
+ */
16
+ import type { ReviewIssue } from './codeReview.js';
17
+ import type { Personality } from './personalities.js';
18
+ export interface FixPlanOptions {
19
+ /** Lowest severity to attempt. Defaults to `warning`. */
20
+ minSeverity?: 'error' | 'warning';
21
+ /** Most issues to hand over in one run. */
22
+ maxIssues?: number;
23
+ /** Most files to touch. A fix that rewrites half the repo is not a fix. */
24
+ maxFiles?: number;
25
+ }
26
+ export interface FixPlan {
27
+ /** The issues the agent is being asked to address, in file order. */
28
+ issues: ReviewIssue[];
29
+ /** Files it is allowed to be working in. */
30
+ files: string[];
31
+ /** Why nothing is being attempted, when that is the case. */
32
+ skipped?: 'no-issues' | 'nothing-fixable';
33
+ /** The instruction handed to the agent. */
34
+ prompt: string;
35
+ /** The capability boundary the run executes under. */
36
+ personality: Personality;
37
+ }
38
+ /**
39
+ * `suggestion` and `info` are opinion — style preferences, "consider extracting
40
+ * this". Acting on them unasked produces churn in someone else's pull request
41
+ * and buries the findings that matter. Only what the reviewer states as a
42
+ * defect is eligible.
43
+ */
44
+ export declare function isFixable(issue: ReviewIssue, minSeverity: 'error' | 'warning'): boolean;
45
+ /**
46
+ * The capability set a CI fix runs under.
47
+ *
48
+ * Not a suggestion in the prompt — a real `custom-bot/v1` personality, enforced
49
+ * by `isPersonalityToolCallAllowed` and by the tool registry filter, exactly as
50
+ * a bot built in Agent Studio would be. An agent running unattended against
51
+ * someone else's repository is precisely where a boundary has to be real.
52
+ */
53
+ export declare function ciFixPersonality(): Personality;
54
+ /**
55
+ * Turn a review into a bounded instruction, or decline.
56
+ *
57
+ * Caps matter more than they look. An agent handed sixty findings across forty
58
+ * files will produce a pull request nobody reviews, which is the same as no
59
+ * pull request — except it also burned tokens and someone's afternoon.
60
+ */
61
+ export declare function buildFixPlan(issues: ReviewIssue[], options?: FixPlanOptions): FixPlan;
62
+ /** The instruction the agent receives: the findings, grouped by file. */
63
+ export declare function formatFixPrompt(issues: ReviewIssue[]): string;
64
+ /** A one-line summary for the pull request body the action opens. */
65
+ export declare function summariseFixPlan(plan: FixPlan): string;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Deciding what a CI agent may attempt to fix, and under what boundary.
3
+ *
4
+ * The reviewer already finds problems. This decides which of them are worth
5
+ * handing to an agent, caps how much it may take on, and pins the capabilities
6
+ * it runs with. It deliberately stops there: it produces a plan, never a
7
+ * commit. Staging, branching and opening a pull request belong to the action,
8
+ * which has the token — and keeping git out of the agent's reach is half the
9
+ * reason this is safe to run in CI at all.
10
+ *
11
+ * The boundary is the point. A fix run gets `files` (it must edit) and `tests`
12
+ * (it must check its own work) and nothing else. No shell, no git, no network.
13
+ * Enforced by the same machinery as any other custom bot, so an agent that
14
+ * decides it would like to curl something simply has no tool to do it with.
15
+ */
16
+ /** Severities an agent may act on, in descending confidence. */
17
+ const FIXABLE_SEVERITIES = ['error', 'warning'];
18
+ const DEFAULTS = { minSeverity: 'warning', maxIssues: 20, maxFiles: 10 };
19
+ /**
20
+ * `suggestion` and `info` are opinion — style preferences, "consider extracting
21
+ * this". Acting on them unasked produces churn in someone else's pull request
22
+ * and buries the findings that matter. Only what the reviewer states as a
23
+ * defect is eligible.
24
+ */
25
+ export function isFixable(issue, minSeverity) {
26
+ if (!FIXABLE_SEVERITIES.includes(issue.severity))
27
+ return false;
28
+ return minSeverity === 'warning' ? true : issue.severity === 'error';
29
+ }
30
+ /**
31
+ * The capability set a CI fix runs under.
32
+ *
33
+ * Not a suggestion in the prompt — a real `custom-bot/v1` personality, enforced
34
+ * by `isPersonalityToolCallAllowed` and by the tool registry filter, exactly as
35
+ * a bot built in Agent Studio would be. An agent running unattended against
36
+ * someone else's repository is precisely where a boundary has to be real.
37
+ */
38
+ export function ciFixPersonality() {
39
+ return {
40
+ name: 'ci-fix',
41
+ displayName: 'CI Fix',
42
+ description: 'Applies review findings in CI. Files and tests only.',
43
+ prompt: [
44
+ 'You are fixing problems a reviewer already found in a pull request.',
45
+ '',
46
+ 'Rules:',
47
+ '- Fix only the issues listed. Do not refactor around them.',
48
+ '- Do not reformat untouched lines; the diff should read as a fix, not a rewrite.',
49
+ '- Run the project tests when you are done and fix what you broke.',
50
+ '- If an issue needs a judgement call you cannot make from the code, leave it and say so.',
51
+ ].join('\n'),
52
+ scope: 'project',
53
+ structured: true,
54
+ schemaValid: true,
55
+ modelPreference: 'automatic',
56
+ restrictTools: true,
57
+ tools: ['files', 'tests'],
58
+ declaredTools: ['files', 'tests'],
59
+ projectScope: 'all',
60
+ };
61
+ }
62
+ /**
63
+ * Turn a review into a bounded instruction, or decline.
64
+ *
65
+ * Caps matter more than they look. An agent handed sixty findings across forty
66
+ * files will produce a pull request nobody reviews, which is the same as no
67
+ * pull request — except it also burned tokens and someone's afternoon.
68
+ */
69
+ export function buildFixPlan(issues, options = {}) {
70
+ const { minSeverity, maxIssues, maxFiles } = { ...DEFAULTS, ...options };
71
+ const personality = ciFixPersonality();
72
+ if (issues.length === 0) {
73
+ return { issues: [], files: [], skipped: 'no-issues', prompt: '', personality };
74
+ }
75
+ const eligible = issues
76
+ .filter(issue => isFixable(issue, minSeverity))
77
+ // Errors before warnings, then by file so one file's issues arrive together.
78
+ .sort((a, b) => {
79
+ if (a.severity !== b.severity)
80
+ return a.severity === 'error' ? -1 : 1;
81
+ return a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
82
+ });
83
+ if (eligible.length === 0) {
84
+ return { issues: [], files: [], skipped: 'nothing-fixable', prompt: '', personality };
85
+ }
86
+ // Take whole files rather than cutting a file's issues in half — a partial
87
+ // fix to one file is the worst outcome available, since it looks addressed.
88
+ const files = [];
89
+ const taken = [];
90
+ for (const issue of eligible) {
91
+ const knownFile = files.includes(issue.file);
92
+ if (!knownFile && files.length >= maxFiles)
93
+ continue;
94
+ if (taken.length >= maxIssues && !knownFile)
95
+ continue;
96
+ if (!knownFile)
97
+ files.push(issue.file);
98
+ taken.push(issue);
99
+ }
100
+ return { issues: taken, files, prompt: formatFixPrompt(taken), personality };
101
+ }
102
+ /** The instruction the agent receives: the findings, grouped by file. */
103
+ export function formatFixPrompt(issues) {
104
+ const byFile = new Map();
105
+ for (const issue of issues) {
106
+ const list = byFile.get(issue.file) ?? [];
107
+ list.push(issue);
108
+ byFile.set(issue.file, list);
109
+ }
110
+ const lines = [
111
+ `Fix the following ${issues.length} review finding${issues.length === 1 ? '' : 's'}.`,
112
+ '',
113
+ ];
114
+ for (const [file, found] of byFile) {
115
+ lines.push(`## ${file}`);
116
+ for (const issue of found) {
117
+ const where = issue.line ? `line ${issue.line}` : 'file';
118
+ lines.push(`- [${issue.severity}] ${where}: ${issue.message}`);
119
+ if (issue.suggestion)
120
+ lines.push(` suggested: ${issue.suggestion}`);
121
+ }
122
+ lines.push('');
123
+ }
124
+ lines.push('Change nothing outside these files.');
125
+ return lines.join('\n');
126
+ }
127
+ /** A one-line summary for the pull request body the action opens. */
128
+ export function summariseFixPlan(plan) {
129
+ if (plan.skipped === 'no-issues')
130
+ return 'The review found nothing.';
131
+ if (plan.skipped === 'nothing-fixable') {
132
+ return 'The review found only suggestions, which are left for a human to weigh.';
133
+ }
134
+ const errors = plan.issues.filter(i => i.severity === 'error').length;
135
+ const warnings = plan.issues.length - errors;
136
+ const parts = [
137
+ errors ? `${errors} error${errors === 1 ? '' : 's'}` : '',
138
+ warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : '',
139
+ ].filter(Boolean);
140
+ return `Attempting ${parts.join(' and ')} across ${plan.files.length} file${plan.files.length === 1 ? '' : 's'}.`;
141
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.21.0";
1
+ export declare const VERSION = "2.22.0";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '2.21.0';
4
+ export const VERSION = '2.22.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",