auto-mode 0.9.0 → 0.11.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/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { g as string, h as strictObject, l as DEFAULT_DENIAL_BUDGET, m as number, n as parseActionRequest, r as classifyAction, s as loadPolicy, t as renderVerdict, u as loadConfig } from "./render-verdict-CK9sRJpa.js";
3
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { D as string, E as strictObject, T as number, _ as loadRepositoryContext, b as DEFAULT_SCOPE_SOURCES, c as splitShellCommand, d as resolveSessionScopePath, f as loadSessionScope, g as toRepositorySlug, h as findCheckout, l as splitCommandWords, m as loadCheckoutRemotes, n as parseActionRequest, p as EMPTY_SESSION_SCOPE, r as classifyAction, s as loadPolicy, t as renderVerdict, u as resolveStateDir, x as loadConfig, y as DEFAULT_DENIAL_BUDGET } from "./render-verdict-BiGjYb1x.js";
3
+ import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, isAbsolute, join, normalize } from "node:path";
6
+ import { spawn } from "node:child_process";
6
7
  import { createHash, randomUUID } from "node:crypto";
7
8
  import { text } from "node:stream/consumers";
8
9
  import { parseArgs } from "node:util";
10
+ import { setTimeout } from "node:timers/promises";
9
11
  //#region src/budget/build-retry-key.ts
10
12
  const DESCRIPTIVE_FIELDS = { Bash: [
11
13
  "description",
@@ -117,13 +119,6 @@ function formatDenialsLeft(left) {
117
119
  return "This is the last denial before auto-mode asks the user. Stop now, without finishing the rest of the task, and tell the user what consent you need to continue.";
118
120
  }
119
121
  //#endregion
120
- //#region src/state/resolve-state-dir.ts
121
- function resolveStateDir() {
122
- const stateHome = process.env["XDG_STATE_HOME"];
123
- const base = stateHome === void 0 || stateHome === "" ? join(homedir(), ".local", "state") : stateHome;
124
- return join(base, "auto-mode");
125
- }
126
- //#endregion
127
122
  //#region src/budget/resolve-denial-state-path.ts
128
123
  function resolveDenialStatePath(request) {
129
124
  const agentID = request.decisionContext?.agentID ?? "";
@@ -185,12 +180,303 @@ function toHash(value) {
185
180
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
186
181
  }
187
182
  //#endregion
183
+ //#region src/request/parse-scope-record-request.ts
184
+ const recordSchema = strictObject({
185
+ sessionID: string().min(1),
186
+ cwd: string().min(1),
187
+ startedAt: number().int().positive(),
188
+ command: string().min(1),
189
+ resultText: string()
190
+ });
191
+ function parseScopeRecordRequest(body) {
192
+ const parsed = recordSchema.safeParse(body);
193
+ return parsed.success ? parsed.data : null;
194
+ }
195
+ //#endregion
196
+ //#region src/scope/read-pull-request.ts
197
+ const LOOKUP_TIMEOUT_MS = 5e3;
198
+ function readPullRequest(repository, number) {
199
+ const args = [
200
+ "pr",
201
+ "view",
202
+ String(number),
203
+ "--repo",
204
+ repository,
205
+ "--json",
206
+ "headRefName,createdAt"
207
+ ];
208
+ return new Promise((resolve) => {
209
+ const child = spawn("gh", [
210
+ ...args,
211
+ "--jq",
212
+ "[.headRefName, .createdAt] | @tsv"
213
+ ], {
214
+ stdio: [
215
+ "ignore",
216
+ "pipe",
217
+ "ignore"
218
+ ],
219
+ timeout: LOOKUP_TIMEOUT_MS
220
+ });
221
+ let output = "";
222
+ child.stdout.setEncoding("utf8");
223
+ child.stdout.on("data", (chunk) => {
224
+ output += chunk;
225
+ });
226
+ child.on("error", () => {
227
+ resolve(null);
228
+ });
229
+ child.on("close", (code) => {
230
+ const [head = "", created = ""] = output.trim().split(" ");
231
+ const createdAt = Date.parse(created);
232
+ resolve(code === 0 && head !== "" && !Number.isNaN(createdAt) ? {
233
+ head,
234
+ createdAt
235
+ } : null);
236
+ });
237
+ });
238
+ }
239
+ //#endregion
240
+ //#region src/scope/collect-pull-request-addresses.ts
241
+ function collectPullRequestAddresses(text) {
242
+ const addresses = /* @__PURE__ */ new Map();
243
+ for (const match of text.matchAll(PULL_REQUEST_URL)) {
244
+ const repository = toRepositorySlug(`https://${match.groups?.["repository"] ?? ""}`);
245
+ const number = Number(match.groups?.["number"]);
246
+ if (repository !== null) addresses.set(`${repository}#${String(number)}`, {
247
+ repository,
248
+ number
249
+ });
250
+ }
251
+ return [...addresses.values()];
252
+ }
253
+ const PULL_REQUEST_URL = /https:\/\/(?<repository>[^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/(?<number>[1-9]\d*)\b/gu;
254
+ //#endregion
255
+ //#region src/scope/collect-scope-events.ts
256
+ function collectScopeEvents(command, cwd, home) {
257
+ const events = [];
258
+ const hasPipe = /(?<!\|)\|(?!\|)/u.test(command);
259
+ let directory = cwd;
260
+ for (const segment of splitShellCommand(command).segments) {
261
+ const [name, ...args] = splitCommandWords(segment.text);
262
+ if (name === "cd") directory = hasPipe ? null : resolvePath(directory, args[0] ?? home, home);
263
+ else if (name === "git" && directory !== null) events.push(...collectGitEvents(args, directory, home));
264
+ else if (name === "gh" && directory !== null && args[0] === "pr" && args[1] === "create") events.push({
265
+ kind: "pull-request",
266
+ directory,
267
+ head: findOptionValue(args, ["--head", "-H"])
268
+ });
269
+ }
270
+ return events;
271
+ }
272
+ const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
273
+ "-b",
274
+ "-B",
275
+ "-c",
276
+ "-C",
277
+ "--create",
278
+ "--force-create"
279
+ ]);
280
+ const BRANCH_OTHER_OPTIONS = /^-(?:d|D|m|M|c|C|l|a|r|v|vv|u)$|^--(?:delete|move|copy|list|all|remotes|verbose|show-current|contains|no-contains|merged|no-merged|points-at|format|sort|set-upstream-to|unset-upstream|edit-description)/u;
281
+ function collectGitEvents(args, cwd, home) {
282
+ let directory = cwd;
283
+ let index = 0;
284
+ while (index < args.length && args[index]?.startsWith("-") === true) {
285
+ if (args[index] === "-C") directory = resolvePath(directory, args[index + 1] ?? "", home);
286
+ index += args[index] === "-C" || args[index] === "-c" ? 2 : 1;
287
+ }
288
+ const subcommand = args[index];
289
+ const rest = args.slice(index + 1);
290
+ if (directory === null) return [];
291
+ if (subcommand === "worktree" && rest[0] === "add") {
292
+ const path = findWorktreePath(rest.slice(1));
293
+ const resolved = path === null ? null : resolvePath(directory, path, home);
294
+ return resolved === null ? [] : [{
295
+ kind: "worktree",
296
+ path: resolved
297
+ }];
298
+ }
299
+ if (subcommand === "checkout" || subcommand === "switch") {
300
+ const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
301
+ return name === null ? [] : [{
302
+ kind: "branch",
303
+ name,
304
+ directory
305
+ }];
306
+ }
307
+ if (subcommand === "branch" && !rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) {
308
+ const name = rest.find((word) => !word.startsWith("-"));
309
+ return name === void 0 || isUnresolved(name) ? [] : [{
310
+ kind: "branch",
311
+ name,
312
+ directory
313
+ }];
314
+ }
315
+ return [];
316
+ }
317
+ const WORKTREE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
318
+ "-b",
319
+ "-B",
320
+ "--reason"
321
+ ]);
322
+ function findWorktreePath(args) {
323
+ for (let index = 0; index < args.length; index += 1) {
324
+ const word = args[index] ?? "";
325
+ if (WORKTREE_VALUE_OPTIONS.has(word)) index += 1;
326
+ else if (!word.startsWith("-")) return word;
327
+ }
328
+ return null;
329
+ }
330
+ function findOptionValue(args, options) {
331
+ const index = args.findIndex((word) => options.includes(word));
332
+ const value = index === -1 ? void 0 : args[index + 1];
333
+ return value === void 0 || isUnresolved(value) ? null : value;
334
+ }
335
+ function isUnresolved(word) {
336
+ return /[$`]/u.test(word);
337
+ }
338
+ function resolvePath(directory, path, home) {
339
+ const expanded = path.replace(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u, home);
340
+ if (isUnresolved(expanded) || expanded.startsWith("~") || expanded === "") return null;
341
+ if (!isAbsolute(expanded) && directory === null) return null;
342
+ const absolute = isAbsolute(expanded) || directory === null ? expanded : join(directory, expanded);
343
+ return normalize(absolute).replace(/(?<=.)\/$/u, "");
344
+ }
345
+ //#endregion
346
+ //#region src/scope/merge-session-scope.ts
347
+ function mergeSessionScope(all) {
348
+ const branches = /* @__PURE__ */ new Map();
349
+ const pullRequests = /* @__PURE__ */ new Map();
350
+ for (const branch of all.flatMap((scope) => scope.branches)) branches.set(`${branch.commonDir}\0${branch.name}`, branch);
351
+ for (const pull of all.flatMap((scope) => scope.pullRequests)) pullRequests.set(`${pull.repository}#${String(pull.number)}`, pull);
352
+ return {
353
+ worktrees: [...new Set(all.flatMap((scope) => scope.worktrees))],
354
+ branches: [...branches.values()],
355
+ pullRequests: [...pullRequests.values()]
356
+ };
357
+ }
358
+ //#endregion
359
+ //#region src/scope/read-branch-created-at.ts
360
+ async function readBranchCreatedAt(commonDir, branch) {
361
+ if (!/^[\w./-]+$/u.test(branch) || branch.split("/").includes("..")) return null;
362
+ const [first = ""] = (await readFile(join(commonDir, "logs", "refs", "heads", branch), "utf8").catch(() => "")).split("\n");
363
+ const seconds = /^0+ [\da-f]+ .* (?<time>\d+) [+-]\d{4}\t/u.exec(first)?.groups?.["time"];
364
+ return seconds === void 0 ? null : Number(seconds) * 1e3;
365
+ }
366
+ //#endregion
367
+ //#region src/scope/write-session-scope.ts
368
+ async function writeSessionScope(path, added) {
369
+ await mkdir(dirname(path), {
370
+ recursive: true,
371
+ mode: 448
372
+ });
373
+ const lock = `${path}.lock`;
374
+ await claimLock(lock);
375
+ try {
376
+ const merged = mergeSessionScope([await loadSessionScope(path), added]);
377
+ const staged = `${path}.${randomUUID()}.tmp`;
378
+ await writeFile(staged, `${JSON.stringify(merged)}\n`, { mode: 384 });
379
+ await rename(staged, path);
380
+ } finally {
381
+ await rm(lock, { force: true });
382
+ }
383
+ }
384
+ const STALE_LOCK_MS = 1e4;
385
+ const LOCK_WAIT_MS = 3e3;
386
+ const LOCK_POLL_MS = 10;
387
+ async function claimLock(lock) {
388
+ const deadline = Date.now() + LOCK_WAIT_MS;
389
+ for (;;) {
390
+ try {
391
+ await (await open(lock, "wx", 384)).close();
392
+ return;
393
+ } catch (error) {
394
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
395
+ }
396
+ const held = await stat(lock).catch(() => null);
397
+ if (held !== null && Date.now() - held.mtimeMs > STALE_LOCK_MS) await rm(lock, { force: true });
398
+ else if (Date.now() > deadline) throw new Error("session scope lock unavailable");
399
+ else await setTimeout(LOCK_POLL_MS);
400
+ }
401
+ }
402
+ //#endregion
403
+ //#region src/scope/update-session-scope.ts
404
+ const MAX_CALL_AGE_MS = 9e5;
405
+ async function updateSessionScope(request, options) {
406
+ const age = options.now - request.startedAt;
407
+ if (age < -6e4 || age > MAX_CALL_AGE_MS) return EMPTY_SESSION_SCOPE;
408
+ const events = collectScopeEvents(request.command, request.cwd, options.home);
409
+ const scope = mergeSessionScope(await Promise.all(events.map((event) => tryVerifyScopeEvent(event, request, options))));
410
+ if (scope.worktrees.length + scope.branches.length + scope.pullRequests.length > 0) await writeSessionScope(resolveSessionScopePath(options.stateDir, request.sessionID), scope);
411
+ return scope;
412
+ }
413
+ async function tryVerifyScopeEvent(event, request, options) {
414
+ try {
415
+ return await verifyScopeEvent(event, request, options);
416
+ } catch {
417
+ return EMPTY_SESSION_SCOPE;
418
+ }
419
+ }
420
+ const FORGE_CLOCK_SKEW_MS = 6e4;
421
+ const FILE_CLOCK_SLACK_MS = 50;
422
+ async function verifyScopeEvent(event, request, options) {
423
+ if (event.kind === "worktree") {
424
+ const link = await stat(join(event.path, ".git"));
425
+ const checkout = await findCheckout(event.path);
426
+ if (!link.isFile() || link.mtimeMs < request.startedAt - FILE_CLOCK_SLACK_MS || checkout?.worktree !== event.path) return EMPTY_SESSION_SCOPE;
427
+ const branch = (await loadRepositoryContext(event.path))?.branch ?? null;
428
+ if (branch === null) return {
429
+ ...EMPTY_SESSION_SCOPE,
430
+ worktrees: [event.path]
431
+ };
432
+ const isNew = await isBranchCreatedSince(checkout.commonDir, branch, request.startedAt);
433
+ return {
434
+ ...EMPTY_SESSION_SCOPE,
435
+ worktrees: [event.path],
436
+ branches: isNew ? [{
437
+ name: branch,
438
+ commonDir: checkout.commonDir
439
+ }] : []
440
+ };
441
+ }
442
+ const checkout = await findCheckout(event.directory);
443
+ if (checkout === null) return EMPTY_SESSION_SCOPE;
444
+ if (event.kind === "branch") return await isBranchCreatedSince(checkout.commonDir, event.name, request.startedAt) ? {
445
+ ...EMPTY_SESSION_SCOPE,
446
+ branches: [{
447
+ name: event.name,
448
+ commonDir: checkout.commonDir
449
+ }]
450
+ } : EMPTY_SESSION_SCOPE;
451
+ const remotes = await loadCheckoutRemotes(checkout.commonDir);
452
+ const repositories = new Set(remotes.map((remote) => toRepositorySlug(remote.url)));
453
+ const addresses = collectPullRequestAddresses(request.resultText).filter((address) => repositories.has(address.repository));
454
+ const pulls = await Promise.all(addresses.map(async (address) => {
455
+ const pull = await options.readPullRequest(address.repository, address.number);
456
+ return pull !== null && pull.createdAt >= request.startedAt - FORGE_CLOCK_SKEW_MS ? [{
457
+ ...address,
458
+ head: pull.head
459
+ }] : [];
460
+ }));
461
+ return {
462
+ ...EMPTY_SESSION_SCOPE,
463
+ pullRequests: pulls.flat()
464
+ };
465
+ }
466
+ async function isBranchCreatedSince(commonDir, branch, since) {
467
+ const createdAt = await readBranchCreatedAt(commonDir, branch);
468
+ if (createdAt === null || createdAt < Math.floor(since / 1e3) * 1e3) return false;
469
+ const ref = await stat(join(commonDir, "refs", "heads", branch)).catch(() => null);
470
+ return ref !== null && ref.mtimeMs >= since - FILE_CLOCK_SLACK_MS;
471
+ }
472
+ //#endregion
188
473
  //#region src/cli.ts
189
474
  const USAGE = `auto-mode — a permission classifier for the auto-mode Claude Code mod
190
475
 
191
476
  Usage:
192
477
  auto-mode run Read an action request on stdin, write a verdict on stdout
193
478
  auto-mode print-prompt Print the system prompt the classifier receives
479
+ auto-mode record Read a finished Bash call on stdin, add what it created to the session's scope
194
480
 
195
481
  Options:
196
482
  --classifier <path> Use this framework file instead of the shipped one
@@ -294,6 +580,29 @@ async function run(explain, localOnly, jevOnly, deadline) {
294
580
  process.off("SIGINT", stopEvaluation);
295
581
  }
296
582
  }
583
+ async function runRecord() {
584
+ const raw = await readStdin();
585
+ let body;
586
+ try {
587
+ body = JSON.parse(raw);
588
+ } catch {
589
+ return 0;
590
+ }
591
+ const request = parseScopeRecordRequest(body);
592
+ if (request === null) return 0;
593
+ try {
594
+ const config = await loadConfig();
595
+ if (Object.values(config.scopeSources ?? DEFAULT_SCOPE_SOURCES).some((source) => source.kind === "session")) await updateSessionScope(request, {
596
+ now: Date.now(),
597
+ stateDir: resolveStateDir(),
598
+ home: homedir(),
599
+ readPullRequest
600
+ });
601
+ } catch {
602
+ process.stderr.write("auto-mode: session scope unavailable\n");
603
+ }
604
+ return 0;
605
+ }
297
606
  async function tryWriteDenialState(path, state) {
298
607
  try {
299
608
  await writeDenialState(path, state);
@@ -339,6 +648,7 @@ async function main(argv) {
339
648
  process.stderr.write("auto-mode: configuration unreadable; no verdict\n");
340
649
  return 0;
341
650
  }
651
+ if (command === "record") return runRecord();
342
652
  if (command === "print-prompt") {
343
653
  const config = await loadConfig();
344
654
  printWarnings(config.warnings);
package/dist/index.d.ts CHANGED
@@ -115,7 +115,7 @@ interface DecisionDiagnostics {
115
115
  }
116
116
  //#endregion
117
117
  //#region src/classify-action.d.ts
118
- type DecidingStage = 'local' | 'jev' | 'messages' | 'retry';
118
+ type DecidingStage = 'local' | 'containment' | 'jev' | 'messages' | 'retry';
119
119
  interface ActionOutcome {
120
120
  readonly verdict: Verdict | null;
121
121
  readonly decidingStage: DecidingStage;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as classifyWithModel, c as DEFAULT_CONFIG, d as resolveApiKey, f as resolveConfigPath, i as classifyLocally, n as parseActionRequest, o as parseModelVerdict, p as PRESETS, r as classifyAction, s as loadPolicy, t as renderVerdict, u as loadConfig } from "./render-verdict-CK9sRJpa.js";
1
+ import { C as resolveConfigPath, S as resolveApiKey, a as classifyWithModel, i as classifyLocally, n as parseActionRequest, o as parseModelVerdict, r as classifyAction, s as loadPolicy, t as renderVerdict, v as DEFAULT_CONFIG, w as PRESETS, x as loadConfig } from "./render-verdict-BiGjYb1x.js";
2
2
  export { DEFAULT_CONFIG, PRESETS, classifyAction, classifyLocally, classifyWithModel, loadConfig, loadPolicy, parseActionRequest, parseModelVerdict, renderVerdict, resolveApiKey, resolveConfigPath };