auto-mode 0.19.0 → 0.19.2

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,156 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { C as resolveStateDir, D as DEFAULT_SCOPE_SOURCES, E as DEFAULT_DENIAL_BUDGET, F as string, N as number, O as loadConfig, P as strictObject, a as loadSessionScope, b as splitCommandWords, f as loadRepositoryContext, g as loadPolicy, i as resolveSessionScopePath, j as readHostEnvironment, n as parseActionRequest, o as EMPTY_SESSION_SCOPE, p as loadCheckoutRemotes, r as classifyAction, t as renderVerdict, v as toRepositorySlug, w as findCheckout, y as splitShellCommand } from "./render-verdict-CcVLBFKT.js";
3
- import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
- import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
5
- import { spawn } from "node:child_process";
2
+ import { C as readHostEnvironment, T as resolveXDGDir, a as classifyAction, b as loadConfig, i as recordScope, m as writeActionDiagnostic, n as parseScopeRecordRequest, p as loadPolicy, r as parseActionRequest, t as renderVerdict, v as findGitEntry } from "./render-verdict-DBcgB5CC.js";
3
+ import { chmod, mkdir, open, realpath, stat } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
6
  import { constants } from "node:fs";
7
- import { createHash, randomUUID } from "node:crypto";
8
7
  import { text } from "node:stream/consumers";
9
8
  import { parseArgs } from "node:util";
10
- import { setTimeout } from "node:timers/promises";
11
- //#region src/budget/build-retry-key.ts
12
- const DESCRIPTIVE_FIELDS = { Bash: [
13
- "description",
14
- "timeout",
15
- "run_in_background"
16
- ] };
17
- function buildRetryKey(request) {
18
- const ignored = Object.hasOwn(DESCRIPTIVE_FIELDS, request.toolName) ? DESCRIPTIVE_FIELDS[request.toolName] ?? [] : [];
19
- const input = Object.fromEntries(Object.entries(request.toolInput).filter(([key]) => !ignored.includes(key)));
20
- const message = request.decisionContext?.lastDirectUserMessage ?? null;
21
- const canonical = JSON.stringify(sortKeys({
22
- cwd: request.cwd,
23
- toolName: request.toolName,
24
- toolInput: input,
25
- lastDirectUserMessage: message?.text ?? null,
26
- ...message?.freshness === "stale" ? { lastDirectUserMessageFreshness: "stale" } : {}
27
- }));
28
- return createHash("sha256").update(canonical).digest("hex");
29
- }
30
- function sortKeys(value) {
31
- if (Array.isArray(value)) return value.map((item) => sortKeys(item));
32
- if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => [key, sortKeys(item)]));
33
- return value;
34
- }
35
- //#endregion
36
- //#region src/budget/find-retry-deny.ts
37
- function findRetryDeny(state, retryKey) {
38
- if (state.lastDenied === null || state.lastDenied.retryKey !== retryKey) return null;
39
- return {
40
- kind: "deny",
41
- rule: state.lastDenied.rule,
42
- reason: state.lastDenied.reason
43
- };
44
- }
45
- //#endregion
46
- //#region src/budget/types.ts
47
- const EMPTY_DENIAL_STATE = {
48
- consecutive: 0,
49
- session: 0,
50
- lastDenied: null
51
- };
52
- //#endregion
53
- //#region src/budget/load-denial-state.ts
54
- const count = number().int().nonnegative();
55
- const stateSchema = strictObject({
56
- consecutive: count,
57
- session: count,
58
- lastDenied: strictObject({
59
- retryKey: string().min(1),
60
- rule: string(),
61
- reason: string()
62
- }).nullable()
63
- });
64
- async function loadDenialState(path) {
65
- let raw;
66
- try {
67
- raw = await readFile(path, "utf8");
68
- } catch {
69
- return EMPTY_DENIAL_STATE;
70
- }
71
- try {
72
- const parsed = stateSchema.safeParse(JSON.parse(raw));
73
- return parsed.success ? parsed.data : EMPTY_DENIAL_STATE;
74
- } catch {
75
- return EMPTY_DENIAL_STATE;
76
- }
77
- }
78
- //#endregion
79
- //#region src/budget/plan-denial-budget.ts
80
- function planDenialBudget(state, verdict, retryKey, budget) {
81
- if (verdict === null) return {
82
- verdict: null,
83
- state,
84
- escalation: false
85
- };
86
- if (verdict.kind === "allow") return {
87
- verdict,
88
- state: {
89
- ...state,
90
- consecutive: 0,
91
- lastDenied: null
92
- },
93
- escalation: false
94
- };
95
- if (state.consecutive >= budget.consecutive || state.session >= budget.perSession) return {
96
- verdict: null,
97
- state: EMPTY_DENIAL_STATE,
98
- escalation: true
99
- };
100
- const next = {
101
- consecutive: state.consecutive + 1,
102
- session: state.session + 1,
103
- lastDenied: {
104
- retryKey,
105
- rule: verdict.rule,
106
- reason: verdict.reason
107
- }
108
- };
109
- const left = Math.min(budget.consecutive - next.consecutive, budget.perSession - next.session);
110
- return {
111
- verdict: {
112
- ...verdict,
113
- reason: `${verdict.reason} ${formatDenialsLeft(left)}`
114
- },
115
- state: next,
116
- escalation: false
117
- };
118
- }
119
- function formatDenialsLeft(left) {
120
- if (left > 0) return `Denials left before auto-mode asks the user: ${left}.`;
121
- 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.";
122
- }
123
- //#endregion
124
- //#region src/budget/resolve-denial-state-path.ts
125
- function resolveDenialStatePath(request, stateDir) {
126
- const agentID = request.decisionContext?.agentID ?? "";
127
- const key = createHash("sha256").update(`${request.sessionID}\0${agentID}`).digest("hex").slice(0, 32);
128
- return join(stateDir, "denials", `${key}.json`);
129
- }
130
- //#endregion
131
- //#region src/budget/write-denial-state.ts
132
- async function writeDenialState(path, state) {
133
- const staged = `${path}.${randomUUID()}.tmp`;
134
- await mkdir(dirname(path), {
135
- recursive: true,
136
- mode: 448
137
- });
138
- await writeFile(staged, `${JSON.stringify(state)}\n`, { mode: 384 });
139
- await rename(staged, path);
140
- }
141
- //#endregion
142
9
  //#region src/capture/resolve-capture-dir.ts
143
10
  function resolveCaptureDir(capture, host) {
144
- return capture.dir ?? join(resolveStateDir(host), "captures");
11
+ return capture.dir ?? join(resolveXDGDir(host, "state"), "captures");
145
12
  }
146
13
  //#endregion
147
14
  //#region src/capture/find-enclosing-work-tree.ts
148
15
  async function findEnclosingWorkTree(path) {
149
16
  const real = await resolveExistingPrefix(resolve(path));
150
- for (let directory = real;; directory = dirname(directory)) {
151
- if (await hasGitEntry(directory)) return directory;
152
- if (dirname(directory) === directory) return null;
153
- }
17
+ const entry = await findGitEntry(real);
18
+ return entry === null ? null : entry.directory;
154
19
  }
155
20
  async function resolveExistingPrefix(path) {
156
21
  const missing = [];
@@ -161,15 +26,6 @@ async function resolveExistingPrefix(path) {
161
26
  missing.push(basename(current));
162
27
  }
163
28
  }
164
- const ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
165
- async function hasGitEntry(directory) {
166
- try {
167
- await lstat(join(directory, ".git"));
168
- return true;
169
- } catch (error) {
170
- return !(error instanceof Error && "code" in error && ABSENT_CODES.has(String(error.code)));
171
- }
172
- }
173
29
  //#endregion
174
30
  //#region src/capture/try-write-request-capture.ts
175
31
  async function tryWriteRequestCapture(dir, record) {
@@ -227,53 +83,6 @@ function buildJevOnlyConfig(config) {
227
83
  };
228
84
  }
229
85
  //#endregion
230
- //#region src/diagnostics/write-action-diagnostic.ts
231
- async function writeActionDiagnostic(payload, entry, host, warnings) {
232
- const path = host.env["AUTO_MODE_DIAGNOSTICS_PATH"] ?? join(resolveStateDir(host), "actions.jsonl");
233
- if (path === "") return;
234
- const record = {
235
- schemaVersion: 4,
236
- time: (/* @__PURE__ */ new Date()).toISOString(),
237
- invocationID: entry.invocationID,
238
- sessionHash: toHash(payload.sessionID),
239
- actionHash: payload.toolUseID === void 0 ? null : toHash(payload.toolUseID),
240
- status: entry.status,
241
- verdict: entry.verdict ?? null,
242
- decidingStage: entry.decidingStage ?? null,
243
- denials: entry.denials ?? null,
244
- escalation: entry.escalation ?? false,
245
- diagnostics: entry.diagnostics ?? null,
246
- judge: entry.judge ?? null
247
- };
248
- try {
249
- await mkdir(dirname(path), {
250
- recursive: true,
251
- mode: 448
252
- });
253
- await appendFile(path, `${JSON.stringify(record)}\n`, { mode: 384 });
254
- } catch {
255
- warnings.write("auto-mode: diagnostics unavailable\n");
256
- }
257
- }
258
- function toHash(value) {
259
- return createHash("sha256").update(value).digest("hex").slice(0, 16);
260
- }
261
- //#endregion
262
- //#region src/request/scope-record-request-schema.ts
263
- const scopeRecordRequestSchema = strictObject({
264
- sessionID: string().min(1),
265
- cwd: string().min(1),
266
- startedAt: number().int().positive(),
267
- command: string().min(1),
268
- resultText: string()
269
- });
270
- //#endregion
271
- //#region src/request/parse-scope-record-request.ts
272
- function parseScopeRecordRequest(body) {
273
- const parsed = scopeRecordRequestSchema.safeParse(body);
274
- return parsed.success ? parsed.data : null;
275
- }
276
- //#endregion
277
86
  //#region src/request/to-mod-request.ts
278
87
  function toModRequest(request) {
279
88
  const context = request.decisionContext;
@@ -290,314 +99,18 @@ function toModRequest(request) {
290
99
  };
291
100
  }
292
101
  //#endregion
293
- //#region src/scope/read-pull-request.ts
294
- const LOOKUP_TIMEOUT_MS = 5e3;
295
- function readPullRequest(repository, number, options = {}) {
296
- const [file, ...prefix] = options.command ?? ["gh"];
297
- const args = [
298
- "pr",
299
- "view",
300
- String(number),
301
- "--repo",
302
- repository,
303
- "--json",
304
- "headRefName,createdAt"
305
- ];
306
- return new Promise((resolve) => {
307
- const child = spawn(file, [
308
- ...prefix,
309
- ...args,
310
- "--jq",
311
- "[.headRefName, .createdAt] | @tsv"
312
- ], {
313
- stdio: [
314
- "ignore",
315
- "pipe",
316
- "ignore"
317
- ],
318
- timeout: options.timeoutMs ?? LOOKUP_TIMEOUT_MS
319
- });
320
- let output = "";
321
- child.stdout.setEncoding("utf8");
322
- child.stdout.on("data", (chunk) => {
323
- output += chunk;
324
- });
325
- child.on("error", () => {
326
- resolve(null);
327
- });
328
- child.on("close", (code) => {
329
- const [head = "", created = ""] = output.trim().split(" ");
330
- const createdAt = Date.parse(created);
331
- resolve(code === 0 && head !== "" && !Number.isNaN(createdAt) ? {
332
- head,
333
- createdAt
334
- } : null);
335
- });
336
- });
337
- }
338
- //#endregion
339
- //#region src/scope/collect-pull-request-addresses.ts
340
- function collectPullRequestAddresses(text) {
341
- const addresses = /* @__PURE__ */ new Map();
342
- for (const match of text.matchAll(PULL_REQUEST_URL)) {
343
- const repository = toRepositorySlug(`https://${match.groups?.["repository"] ?? ""}`);
344
- const number = Number(match.groups?.["number"]);
345
- if (repository !== null) addresses.set(`${repository}#${String(number)}`, {
346
- repository,
347
- number
348
- });
349
- }
350
- return [...addresses.values()];
351
- }
352
- const PULL_REQUEST_URL = /https:\/\/(?<repository>[^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/(?<number>[1-9]\d*)\b/gu;
353
- //#endregion
354
- //#region mods/auto-mode/contract/scope-commands.ts
355
- const SCOPE_COMMANDS = {
356
- git: [
357
- "worktree add",
358
- "checkout",
359
- "switch",
360
- "branch"
361
- ],
362
- gh: ["pr create"]
363
- };
364
- //#endregion
365
- //#region src/scope/collect-scope-events.ts
366
- function collectScopeEvents(command, cwd, home) {
367
- const events = [];
368
- const hasPipe = /(?<!\|)\|(?!\|)/u.test(command);
369
- let directory = cwd;
370
- for (const segment of splitShellCommand(command).segments) {
371
- const [name, ...args] = splitCommandWords(segment.text);
372
- if (name === "cd") directory = hasPipe ? null : resolvePath(directory, args[0] ?? home, home);
373
- else if (name === "git" && directory !== null) events.push(...collectGitEvents(args, directory, home));
374
- else if (name === "gh" && directory !== null && findScopeCommand(SCOPE_COMMANDS.gh, args) !== null) events.push({
375
- kind: "pull-request",
376
- directory,
377
- head: findOptionValue(args, ["--head", "-H"])
378
- });
379
- }
380
- return events;
381
- }
382
- function collectGitEvents(args, cwd, home) {
383
- let directory = cwd;
384
- let index = 0;
385
- while (index < args.length && args[index]?.startsWith("-") === true) {
386
- if (args[index] === "-C") directory = resolvePath(directory, args[index + 1] ?? "", home);
387
- index += args[index] === "-C" || args[index] === "-c" ? 2 : 1;
388
- }
389
- const found = findScopeCommand(SCOPE_COMMANDS.git, args.slice(index));
390
- if (directory === null || found === null) return [];
391
- return GIT_EVENT_COLLECTORS[found.command](found.rest, directory, home);
392
- }
393
- function findScopeCommand(commands, args) {
394
- for (const command of commands) {
395
- const words = command.split(" ");
396
- if (words.every((word, index) => args[index] === word)) return {
397
- command,
398
- rest: args.slice(words.length)
399
- };
400
- }
401
- return null;
402
- }
403
- const GIT_EVENT_COLLECTORS = {
404
- "worktree add": collectWorktreeEvents,
405
- checkout: collectSwitchEvents,
406
- switch: collectSwitchEvents,
407
- branch: collectBranchEvents
408
- };
409
- function collectWorktreeEvents(rest, directory, home) {
410
- const path = findWorktreePath(rest);
411
- const resolved = path === null ? null : resolvePath(directory, path, home);
412
- return resolved === null ? [] : [{
413
- kind: "worktree",
414
- path: resolved
415
- }];
416
- }
417
- const WORKTREE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
418
- "-b",
419
- "-B",
420
- "--reason"
421
- ]);
422
- function findWorktreePath(args) {
423
- for (let index = 0; index < args.length; index += 1) {
424
- const word = args[index] ?? "";
425
- if (WORKTREE_VALUE_OPTIONS.has(word)) index += 1;
426
- else if (!word.startsWith("-")) return word;
427
- }
428
- return null;
429
- }
430
- const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
431
- "-b",
432
- "-B",
433
- "-c",
434
- "-C",
435
- "--create",
436
- "--force-create"
437
- ]);
438
- 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;
439
- function collectSwitchEvents(rest, directory) {
440
- const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
441
- return name === null ? [] : [{
442
- kind: "branch",
443
- name,
444
- directory
445
- }];
446
- }
447
- function collectBranchEvents(rest, directory) {
448
- if (rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) return [];
449
- const name = rest.find((word) => !word.startsWith("-"));
450
- return name === void 0 || isUnresolved(name) ? [] : [{
451
- kind: "branch",
452
- name,
453
- directory
454
- }];
455
- }
456
- function findOptionValue(args, options) {
457
- const index = args.findIndex((word) => options.includes(word));
458
- const value = index === -1 ? void 0 : args[index + 1];
459
- return value === void 0 || isUnresolved(value) ? null : value;
460
- }
461
- function isUnresolved(word) {
462
- return /[$`]/u.test(word);
463
- }
464
- function resolvePath(directory, path, home) {
465
- const expanded = path.replace(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/u, home);
466
- if (isUnresolved(expanded) || expanded.startsWith("~") || expanded === "") return null;
467
- if (!isAbsolute(expanded) && directory === null) return null;
468
- const absolute = isAbsolute(expanded) || directory === null ? expanded : join(directory, expanded);
469
- return normalize(absolute).replace(/(?<=.)\/$/u, "");
470
- }
471
- //#endregion
472
- //#region src/scope/merge-session-scope.ts
473
- function mergeSessionScope(all) {
474
- const branches = /* @__PURE__ */ new Map();
475
- const pullRequests = /* @__PURE__ */ new Map();
476
- for (const branch of all.flatMap((scope) => scope.branches)) branches.set(`${branch.commonDir}\0${branch.name}`, branch);
477
- for (const pull of all.flatMap((scope) => scope.pullRequests)) pullRequests.set(`${pull.repository}#${String(pull.number)}`, pull);
478
- return {
479
- worktrees: [...new Set(all.flatMap((scope) => scope.worktrees))],
480
- branches: [...branches.values()],
481
- pullRequests: [...pullRequests.values()]
482
- };
483
- }
484
- //#endregion
485
- //#region src/scope/read-branch-created-at.ts
486
- async function readBranchCreatedAt(commonDir, branch) {
487
- if (!/^[\w./-]+$/u.test(branch) || branch.split("/").includes("..")) return null;
488
- const [first = ""] = (await readFile(join(commonDir, "logs", "refs", "heads", branch), "utf8").catch(() => "")).split("\n");
489
- const seconds = /^0+ [\da-f]+ .* (?<time>\d+) [+-]\d{4}\t/u.exec(first)?.groups?.["time"];
490
- return seconds === void 0 ? null : Number(seconds) * 1e3;
491
- }
492
- //#endregion
493
- //#region src/scope/write-session-scope.ts
494
- const SYSTEM_CLOCK = {
495
- now: Date.now,
496
- wait: (ms) => setTimeout(ms)
497
- };
498
- async function writeSessionScope(path, added, clock = SYSTEM_CLOCK) {
499
- await mkdir(dirname(path), {
500
- recursive: true,
501
- mode: 448
502
- });
503
- const lock = `${path}.lock`;
504
- await claimLock(lock, clock);
505
- try {
506
- const merged = mergeSessionScope([await loadSessionScope(path), added]);
507
- const staged = `${path}.${randomUUID()}.tmp`;
508
- await writeFile(staged, `${JSON.stringify(merged)}\n`, { mode: 384 });
509
- await rename(staged, path);
510
- } finally {
511
- await rm(lock, { force: true });
512
- }
513
- }
514
- const STALE_LOCK_MS = 1e4;
515
- const LOCK_WAIT_MS = 3e3;
516
- const LOCK_POLL_MS = 10;
517
- async function claimLock(lock, clock) {
518
- const deadline = clock.now() + LOCK_WAIT_MS;
519
- for (;;) {
520
- try {
521
- await (await open(lock, "wx", 384)).close();
522
- return;
523
- } catch (error) {
524
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
525
- }
526
- const held = await stat(lock).catch(() => null);
527
- if (held !== null && clock.now() - held.mtimeMs > STALE_LOCK_MS) await rm(lock, { force: true });
528
- else if (clock.now() > deadline) throw new Error("session scope lock unavailable");
529
- else await clock.wait(LOCK_POLL_MS);
530
- }
531
- }
532
- //#endregion
533
- //#region src/scope/update-session-scope.ts
534
- const MAX_CALL_AGE_MS = 9e5;
535
- async function updateSessionScope(request, options) {
536
- const age = options.now - request.startedAt;
537
- if (age < -6e4 || age > MAX_CALL_AGE_MS) return EMPTY_SESSION_SCOPE;
538
- const events = collectScopeEvents(request.command, request.cwd, options.home);
539
- const scope = mergeSessionScope(await Promise.all(events.map((event) => tryVerifyScopeEvent(event, request, options))));
540
- if (scope.worktrees.length + scope.branches.length + scope.pullRequests.length > 0) await writeSessionScope(resolveSessionScopePath(options.stateDir, request.sessionID), scope, options.lockClock);
541
- return scope;
542
- }
543
- async function tryVerifyScopeEvent(event, request, options) {
544
- try {
545
- return await (options.verifyEvent === void 0 ? verifyScopeEvent(event, request, options) : options.verifyEvent(event, request));
546
- } catch {
547
- return EMPTY_SESSION_SCOPE;
548
- }
549
- }
550
- const FORGE_CLOCK_SKEW_MS = 6e4;
551
- const FILE_CLOCK_SLACK_MS = 50;
552
- async function verifyScopeEvent(event, request, options) {
553
- if (event.kind === "worktree") {
554
- const link = await stat(join(event.path, ".git"));
555
- const checkout = await findCheckout(event.path, options.env);
556
- if (!link.isFile() || link.mtimeMs < request.startedAt - FILE_CLOCK_SLACK_MS || checkout?.worktree !== event.path) return EMPTY_SESSION_SCOPE;
557
- const branch = (await loadRepositoryContext(event.path, options.env))?.branch ?? null;
558
- if (branch === null) return {
559
- ...EMPTY_SESSION_SCOPE,
560
- worktrees: [event.path]
561
- };
562
- const isNew = await isBranchCreatedSince(checkout.commonDir, branch, request.startedAt);
563
- return {
564
- ...EMPTY_SESSION_SCOPE,
565
- worktrees: [event.path],
566
- branches: isNew ? [{
567
- name: branch,
568
- commonDir: checkout.commonDir
569
- }] : []
570
- };
571
- }
572
- const checkout = await findCheckout(event.directory, options.env);
573
- if (checkout === null) return EMPTY_SESSION_SCOPE;
574
- if (event.kind === "branch") return await isBranchCreatedSince(checkout.commonDir, event.name, request.startedAt) ? {
575
- ...EMPTY_SESSION_SCOPE,
576
- branches: [{
577
- name: event.name,
578
- commonDir: checkout.commonDir
579
- }]
580
- } : EMPTY_SESSION_SCOPE;
581
- const remotes = await loadCheckoutRemotes(checkout.commonDir);
582
- const repositories = new Set(remotes.map((remote) => toRepositorySlug(remote.url)));
583
- const addresses = collectPullRequestAddresses(request.resultText).filter((address) => repositories.has(address.repository));
584
- const pulls = await Promise.all(addresses.map(async (address) => {
585
- const pull = await options.readPullRequest(address.repository, address.number);
586
- return pull !== null && pull.createdAt >= request.startedAt - FORGE_CLOCK_SKEW_MS ? [{
587
- ...address,
588
- head: pull.head
589
- }] : [];
590
- }));
591
- return {
592
- ...EMPTY_SESSION_SCOPE,
593
- pullRequests: pulls.flat()
594
- };
595
- }
596
- async function isBranchCreatedSince(commonDir, branch, since) {
597
- const createdAt = await readBranchCreatedAt(commonDir, branch);
598
- if (createdAt === null || createdAt < Math.floor(since / 1e3) * 1e3) return false;
599
- const ref = await stat(join(commonDir, "refs", "heads", branch)).catch(() => null);
600
- return ref !== null && ref.mtimeMs >= since - FILE_CLOCK_SLACK_MS;
102
+ //#region src/write-action-failure.ts
103
+ async function writeActionFailure(request, host, stderr) {
104
+ const invocationID = randomUUID();
105
+ await writeActionDiagnostic(request, {
106
+ invocationID,
107
+ status: "started"
108
+ }, host, stderr);
109
+ await writeActionDiagnostic(request, {
110
+ invocationID,
111
+ status: "failure",
112
+ verdict: "defer"
113
+ }, host, stderr);
601
114
  }
602
115
  //#endregion
603
116
  //#region src/run-cli.ts
@@ -617,22 +130,11 @@ Options:
617
130
  --evaluation-deadline <unix-ms> With --jev-only: share the helper and API deadline
618
131
  `;
619
132
  async function runCLI(argv, io) {
620
- const args = parseArgs({
621
- args: [...argv],
622
- allowPositionals: true,
623
- options: {
624
- classifier: { type: "string" },
625
- rules: { type: "string" },
626
- explain: { type: "boolean" },
627
- "local-only": { type: "boolean" },
628
- "jev-only": { type: "boolean" },
629
- "evaluation-deadline": { type: "string" },
630
- help: {
631
- type: "boolean",
632
- short: "h"
633
- }
634
- }
635
- });
133
+ const args = tryParseCLIArgs(argv);
134
+ if (args instanceof Error) {
135
+ io.stderr.write(`auto-mode: ${args.message}\n\n${USAGE}`);
136
+ return 2;
137
+ }
636
138
  const [command] = args.positionals;
637
139
  if (args.values.help === true || command === void 0) {
638
140
  io.stdout.write(USAGE);
@@ -659,6 +161,28 @@ async function runCLI(argv, io) {
659
161
  io.stderr.write(`auto-mode: unknown command '${command}'\n\n${USAGE}`);
660
162
  return 2;
661
163
  }
164
+ function tryParseCLIArgs(argv) {
165
+ try {
166
+ return parseArgs({
167
+ args: [...argv],
168
+ allowPositionals: true,
169
+ options: {
170
+ classifier: { type: "string" },
171
+ rules: { type: "string" },
172
+ explain: { type: "boolean" },
173
+ "local-only": { type: "boolean" },
174
+ "jev-only": { type: "boolean" },
175
+ "evaluation-deadline": { type: "string" },
176
+ help: {
177
+ type: "boolean",
178
+ short: "h"
179
+ }
180
+ }
181
+ });
182
+ } catch (error) {
183
+ return error instanceof Error ? error : new Error(String(error));
184
+ }
185
+ }
662
186
  async function run(io, explain, localOnly, jevOnly, deadline) {
663
187
  const raw = await io.stdin();
664
188
  let body;
@@ -670,39 +194,22 @@ async function run(io, explain, localOnly, jevOnly, deadline) {
670
194
  const request = parseActionRequest(body);
671
195
  if (request === null) return printNote(io.stderr, explain, "not an action request this classifier judges, so no verdict");
672
196
  const host = io.host;
673
- const invocationID = randomUUID();
674
- await writeActionDiagnostic(request, {
675
- invocationID,
676
- status: "started"
677
- }, host, io.stderr);
678
197
  let loaded;
679
198
  try {
680
199
  loaded = await loadConfig(void 0, host);
681
200
  } catch {
682
- await writeActionDiagnostic(request, {
683
- invocationID,
684
- status: "failure",
685
- verdict: "defer"
686
- }, host, io.stderr);
201
+ await writeActionFailure(request, host, io.stderr);
687
202
  return printNote(io.stderr, true, "configuration unreadable; no verdict");
688
203
  }
689
204
  printWarnings(io.stderr, loaded.warnings);
690
205
  const config = jevOnly ? buildJevOnlyConfig(loaded) : loaded;
691
206
  if (config === null) {
692
- await writeActionDiagnostic(request, {
693
- invocationID,
694
- status: "failure",
695
- verdict: "defer"
696
- }, host, io.stderr);
207
+ await writeActionFailure(request, host, io.stderr);
697
208
  return printNote(io.stderr, true, "Jev-only evaluation requires system-one; no verdict");
698
209
  }
699
210
  const deadlineAt = deadline === void 0 ? void 0 : Number(deadline);
700
211
  if (deadlineAt !== void 0 && (!jevOnly || !Number.isSafeInteger(deadlineAt) || deadlineAt <= 0)) {
701
- await writeActionDiagnostic(request, {
702
- invocationID,
703
- status: "failure",
704
- verdict: "defer"
705
- }, host, io.stderr);
212
+ await writeActionFailure(request, host, io.stderr);
706
213
  return printNote(io.stderr, true, "invalid Jev evaluation deadline; no verdict");
707
214
  }
708
215
  const controller = new AbortController();
@@ -710,52 +217,28 @@ async function run(io, explain, localOnly, jevOnly, deadline) {
710
217
  controller.abort();
711
218
  }) : null;
712
219
  try {
713
- const statePath = resolveDenialStatePath(request, resolveStateDir(host));
714
- const retryKey = buildRetryKey(request);
715
- const before = await loadDenialState(statePath);
716
- const retry = findRetryDeny(before, retryKey);
717
- const outcome = retry === null ? await classifyAction(request, config, {
220
+ const outcome = await classifyAction(request, config, {
718
221
  localOnly,
719
222
  deadlineAt,
720
223
  signal: controller.signal,
721
224
  host,
722
- stderr: io.stderr
723
- }) : {
724
- verdict: retry,
725
- decidingStage: "retry",
726
- note: "retry of the action just denied; denied again",
727
- status: "deny"
728
- };
729
- const latest = retry === null ? await loadDenialState(statePath) : before;
730
- const plan = planDenialBudget(latest, outcome.verdict, retryKey, config.denialBudget ?? DEFAULT_DENIAL_BUDGET);
731
- if (plan.state !== latest) await tryWriteDenialState(io.stderr, statePath, plan.state);
732
- if (plan.verdict !== null) printVerdict(io.stdout, plan.verdict);
733
- await writeActionDiagnostic(request, {
734
- invocationID,
735
- status: outcome.status,
736
- verdict: plan.verdict?.kind ?? "defer",
737
- decidingStage: plan.escalation ? "budget" : outcome.decidingStage,
738
- denials: {
739
- consecutive: plan.state.consecutive,
740
- session: plan.state.session
741
- },
742
- escalation: plan.escalation,
743
- ...outcome.diagnostics === void 0 ? {} : { diagnostics: outcome.diagnostics },
744
- ...outcome.judge === void 0 ? {} : { judge: outcome.judge }
745
- }, host, io.stderr);
225
+ stderr: io.stderr,
226
+ budget: true,
227
+ actionLog: true
228
+ });
229
+ if (outcome.verdict !== null) printVerdict(io.stdout, outcome.verdict);
746
230
  if (config.capture?.enabled === true) {
747
231
  const captured = await tryWriteRequestCapture(resolveCaptureDir(config.capture, host), {
748
232
  schemaVersion: 1,
749
233
  time: (/* @__PURE__ */ new Date()).toISOString(),
750
234
  request: toModRequest(request),
751
- verdict: plan.verdict,
752
- decidingStage: plan.escalation ? "budget" : outcome.decidingStage,
753
- escalation: plan.escalation
235
+ verdict: outcome.verdict,
236
+ decidingStage: outcome.decidingStage,
237
+ escalation: outcome.escalation ?? false
754
238
  });
755
239
  printCaptureNote(io.stderr, captured);
756
240
  }
757
- const note = plan.escalation ? "denial budget exhausted; the user decides this action" : outcome.note;
758
- return printNote(io.stderr, explain || outcome.unavailable === true, note);
241
+ return printNote(io.stderr, explain || outcome.unavailable === true, outcome.note);
759
242
  } finally {
760
243
  unsubscribe?.();
761
244
  }
@@ -767,13 +250,6 @@ function printNote(stderr, explain, message) {
767
250
  function printWarnings(stderr, warnings = []) {
768
251
  for (const warning of warnings) stderr.write(`auto-mode: ${warning}\n`);
769
252
  }
770
- async function tryWriteDenialState(stderr, path, state) {
771
- try {
772
- await writeDenialState(path, state);
773
- } catch {
774
- stderr.write("auto-mode: denial counts unavailable\n");
775
- }
776
- }
777
253
  function printVerdict(stdout, verdict) {
778
254
  stdout.write(renderVerdict(verdict));
779
255
  }
@@ -793,13 +269,7 @@ async function runRecord(io) {
793
269
  if (request === null) return 0;
794
270
  try {
795
271
  const config = await loadConfig(void 0, io.host);
796
- if (Object.values(config.scopeSources ?? DEFAULT_SCOPE_SOURCES).some((source) => source.kind === "session")) await updateSessionScope(request, {
797
- now: Date.now(),
798
- stateDir: resolveStateDir(io.host),
799
- home: io.host.home,
800
- env: io.host.env,
801
- readPullRequest
802
- });
272
+ await recordScope(request, config, { host: io.host });
803
273
  } catch {
804
274
  io.stderr.write("auto-mode: session scope unavailable\n");
805
275
  }