auto-mode 0.21.0 → 1.0.1

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/README.md CHANGED
@@ -18,7 +18,7 @@ auto-mode needs node 24 or later, Claude Code, and a TypeSafe API key for Jev.
18
18
 
19
19
  ```sh
20
20
  npm i -g auto-mode
21
- claude --plugin-dir "$(npm root -g)/auto-mode/mods/auto-mode"
21
+ claude --plugin-dir "$(auto-mode mod-path)"
22
22
  ```
23
23
 
24
24
  Export your key as `TYPESAFE_API_KEY`, or point the configuration at a command that prints it.
@@ -54,8 +54,7 @@ You can call auto-mode's core APIs from your own code. `parseActionRequest` read
54
54
  mod sends the CLI ([the contract](./docs/architecture/mod.md#the-contract)), and `classifyAction`
55
55
  runs it through [the stages](./docs/architecture/overview.md#the-path-of-a-prompted-call) from the
56
56
  local rules to the judge. The two options in the example apply the denial budget and write the
57
- action log, as the CLI does, and `recordScope` adds what a finished Bash call created to the
58
- session's scope.
57
+ action log, as the CLI does.
59
58
 
60
59
  ```ts
61
60
  import { classifyAction, loadConfig, parseActionRequest } from 'auto-mode';
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
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-CE3ojHJk.js";
3
- import { chmod, mkdir, open, realpath, stat } from "node:fs/promises";
4
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { C as resolveXDGDir, D as string, E as strictObject, S as CLI_LIMITS, T as number, _ as findGitEntry, a as EMPTY_SESSION_SCOPE, b as readHostEnvironment, c as loadPolicy, d as writeActionDiagnostic, f as toRepositorySlug, g as findCheckout, h as isUnresolvedWord, i as loadSessionScope, l as resolveShippedPolicyDir, m as resolveShellPath, n as classifyAction, o as splitGitCommand, p as collectShellCommands, r as resolveSessionScopePath, s as M, t as parseActionRequest, u as loadCheckoutFacts, v as DEFAULT_SCOPE_SOURCES, w as updateJSONFile, x as runCapped, y as loadConfig } from "./parse-action-request-BfkmQxEC.js";
5
3
  import { randomUUID } from "node:crypto";
6
- import { constants } from "node:fs";
4
+ import { chmod, mkdir, open, readFile, realpath, stat } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { constants, existsSync } from "node:fs";
7
7
  import { text } from "node:stream/consumers";
8
8
  import { parseArgs } from "node:util";
9
9
  //#region src/capture/resolve-capture-dir.ts
@@ -74,15 +74,313 @@ async function writeOwnerFile(path, text, flag) {
74
74
  //#region src/config/build-jev-only-config.ts
75
75
  function buildJevOnlyConfig(config) {
76
76
  if (config.provider.protocol !== "system-one") return null;
77
+ const judge = config.judge === void 0 || config.judge === null ? config.judge : {
78
+ ...config.judge,
79
+ timeoutMs: Math.min(config.judge.timeoutMs, CLI_LIMITS.judgeMs)
80
+ };
77
81
  return {
78
82
  ...config,
79
83
  provider: {
80
84
  ...config.provider,
81
- timeoutMs: Math.min(config.provider.timeoutMs, 5e3)
82
- }
85
+ timeoutMs: Math.min(config.provider.timeoutMs, CLI_LIMITS.jevMs)
86
+ },
87
+ ...judge === void 0 ? {} : { judge }
88
+ };
89
+ }
90
+ //#endregion
91
+ //#region src/config/resolve-shipped-mod-dir.ts
92
+ function resolveShippedModDir(startDir = import.meta.dirname) {
93
+ const modDir = join(dirname(resolveShippedPolicyDir(startDir)), "mods", "auto-mode");
94
+ if (!existsSync(join(modDir, ".claude-plugin", "plugin.json"))) throw new Error("auto-mode cannot find its shipped mod");
95
+ return modDir;
96
+ }
97
+ //#endregion
98
+ //#region src/scope/read-pull-request.ts
99
+ const LOOKUP_TIMEOUT_MS = 5e3;
100
+ const MAX_LOOKUP_OUTPUT_BYTES = 65536;
101
+ async function readPullRequest(repository, number, options = {}) {
102
+ const [file, ...prefix] = options.command ?? ["gh"];
103
+ const args = [
104
+ "pr",
105
+ "view",
106
+ String(number),
107
+ "--repo",
108
+ repository,
109
+ "--json",
110
+ "headRefName,createdAt"
111
+ ];
112
+ const result = await runCapped(file, [
113
+ ...prefix,
114
+ ...args,
115
+ "--jq",
116
+ "[.headRefName, .createdAt] | @tsv"
117
+ ], {
118
+ signal: AbortSignal.timeout(options.timeoutMs ?? LOOKUP_TIMEOUT_MS),
119
+ maxOutputBytes: MAX_LOOKUP_OUTPUT_BYTES
120
+ });
121
+ const [head = "", created = ""] = result.stdout.trim().split(" ");
122
+ const createdAt = Date.parse(created);
123
+ return result.exitCode === 0 && head !== "" && !Number.isNaN(createdAt) ? {
124
+ head,
125
+ createdAt
126
+ } : null;
127
+ }
128
+ //#endregion
129
+ //#region src/git/read-branch-created-at.ts
130
+ async function readBranchCreatedAt(commonDir, branch) {
131
+ if (!/^[\w./-]+$/u.test(branch) || branch.split("/").includes("..")) return null;
132
+ const [first = ""] = (await readFile(join(commonDir, "logs", "refs", "heads", branch), "utf8").catch(() => "")).split("\n");
133
+ const seconds = /^0+ [\da-f]+ .* (?<time>\d+) [+-]\d{4}\t/u.exec(first)?.groups?.["time"];
134
+ return seconds === void 0 ? null : Number(seconds) * 1e3;
135
+ }
136
+ //#endregion
137
+ //#region src/scope/collect-pull-request-addresses.ts
138
+ function collectPullRequestAddresses(text) {
139
+ const addresses = /* @__PURE__ */ new Map();
140
+ for (const match of text.matchAll(PULL_REQUEST_URL)) {
141
+ const repository = toRepositorySlug(`https://${match.groups?.["repository"] ?? ""}`);
142
+ const number = Number(match.groups?.["number"]);
143
+ if (repository !== null) addresses.set(`${repository}#${String(number)}`, {
144
+ repository,
145
+ number
146
+ });
147
+ }
148
+ return [...addresses.values()];
149
+ }
150
+ const PULL_REQUEST_URL = /https:\/\/(?<repository>[^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/(?<number>[1-9]\d*)\b/gu;
151
+ //#endregion
152
+ //#region mods/auto-mode/contract/scope-commands.ts
153
+ const SCOPE_COMMANDS = {
154
+ git: [
155
+ "worktree add",
156
+ "checkout",
157
+ "switch",
158
+ "branch"
159
+ ],
160
+ gh: ["pr create"]
161
+ };
162
+ //#endregion
163
+ //#region src/scope/collect-scope-events.ts
164
+ function collectScopeEvents(command, cwd, home) {
165
+ return collectShellCommands(command, cwd, home).flatMap((entry) => {
166
+ const [name, ...args] = entry.words;
167
+ const directory = entry.directory;
168
+ if (directory === null) return [];
169
+ if (name === "git") return collectGitEvents(args, directory, home);
170
+ return name === "gh" && findScopeCommand(SCOPE_COMMANDS.gh, args) !== null ? [{
171
+ kind: "pull-request",
172
+ directory,
173
+ head: findOptionValue(args, ["--head", "-H"])
174
+ }] : [];
175
+ });
176
+ }
177
+ function collectGitEvents(args, cwd, home) {
178
+ const git = splitGitCommand(args, cwd, home);
179
+ const found = findScopeCommand(SCOPE_COMMANDS.git, git.args);
180
+ if (git.directory === null || found === null) return [];
181
+ return GIT_EVENT_COLLECTORS[found.command](found.rest, git.directory, home);
182
+ }
183
+ function findScopeCommand(commands, args) {
184
+ for (const command of commands) {
185
+ const words = command.split(" ");
186
+ if (words.every((word, index) => args[index] === word)) return {
187
+ command,
188
+ rest: args.slice(words.length)
189
+ };
190
+ }
191
+ return null;
192
+ }
193
+ const GIT_EVENT_COLLECTORS = {
194
+ "worktree add": collectWorktreeEvents,
195
+ checkout: collectSwitchEvents,
196
+ switch: collectSwitchEvents,
197
+ branch: collectBranchEvents
198
+ };
199
+ function collectWorktreeEvents(rest, directory, home) {
200
+ const path = findWorktreePath(rest);
201
+ const resolved = path === null ? null : resolveShellPath(directory, path, home);
202
+ return resolved === null ? [] : [{
203
+ kind: "worktree",
204
+ path: resolved
205
+ }];
206
+ }
207
+ const WORKTREE_VALUE_OPTIONS = /* @__PURE__ */ new Set([
208
+ "-b",
209
+ "-B",
210
+ "--reason"
211
+ ]);
212
+ function findWorktreePath(args) {
213
+ for (let index = 0; index < args.length; index += 1) {
214
+ const word = args[index] ?? "";
215
+ if (WORKTREE_VALUE_OPTIONS.has(word)) index += 1;
216
+ else if (!word.startsWith("-")) return word;
217
+ }
218
+ return null;
219
+ }
220
+ const BRANCH_CREATE_OPTIONS = /* @__PURE__ */ new Set([
221
+ "-b",
222
+ "-B",
223
+ "-c",
224
+ "-C",
225
+ "--create",
226
+ "--force-create"
227
+ ]);
228
+ 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;
229
+ function collectSwitchEvents(rest, directory) {
230
+ const name = findOptionValue(rest, [...BRANCH_CREATE_OPTIONS]);
231
+ return name === null ? [] : [{
232
+ kind: "branch",
233
+ name,
234
+ directory
235
+ }];
236
+ }
237
+ function collectBranchEvents(rest, directory) {
238
+ if (rest.some((word) => BRANCH_OTHER_OPTIONS.test(word))) return [];
239
+ const name = rest.find((word) => !word.startsWith("-"));
240
+ return name === void 0 || isUnresolvedWord(name) ? [] : [{
241
+ kind: "branch",
242
+ name,
243
+ directory
244
+ }];
245
+ }
246
+ function findOptionValue(args, options) {
247
+ const index = args.findIndex((word) => options.includes(word));
248
+ const value = index === -1 ? void 0 : args[index + 1];
249
+ return value === void 0 || isUnresolvedWord(value) ? null : value;
250
+ }
251
+ //#endregion
252
+ //#region src/scope/merge-session-scope.ts
253
+ function mergeSessionScope(all) {
254
+ const branches = /* @__PURE__ */ new Map();
255
+ const pullRequests = /* @__PURE__ */ new Map();
256
+ for (const branch of all.flatMap((scope) => scope.branches)) branches.set(`${branch.commonDir}\0${branch.name}`, branch);
257
+ for (const pull of all.flatMap((scope) => scope.pullRequests)) pullRequests.set(`${pull.repository}#${String(pull.number)}`, pull);
258
+ return {
259
+ worktrees: [...new Set(all.flatMap((scope) => scope.worktrees))],
260
+ branches: [...branches.values()],
261
+ pullRequests: [...pullRequests.values()]
83
262
  };
84
263
  }
85
264
  //#endregion
265
+ //#region src/scope/write-session-scope.ts
266
+ async function writeSessionScope(path, added, clock) {
267
+ await updateJSONFile(path, loadSessionScope, (current) => mergeSessionScope([current, added]), clock);
268
+ }
269
+ //#endregion
270
+ //#region src/scope/update-session-scope.ts
271
+ const MAX_CALL_AGE_MS = 9e5;
272
+ async function updateSessionScope(request, options) {
273
+ const age = options.now - request.startedAt;
274
+ if (age < -6e4 || age > MAX_CALL_AGE_MS) return EMPTY_SESSION_SCOPE;
275
+ const events = collectScopeEvents(request.command, request.cwd, options.home);
276
+ const scope = mergeSessionScope(await Promise.all(events.map((event) => tryVerifyScopeEvent(event, request, options))));
277
+ if (scope.worktrees.length + scope.branches.length + scope.pullRequests.length > 0) await writeSessionScope(resolveSessionScopePath(options.stateDir, request.sessionID), scope, options.lockClock);
278
+ return scope;
279
+ }
280
+ async function tryVerifyScopeEvent(event, request, options) {
281
+ try {
282
+ return await (options.verifyEvent === void 0 ? verifyScopeEvent(event, request, options) : options.verifyEvent(event, request));
283
+ } catch {
284
+ return EMPTY_SESSION_SCOPE;
285
+ }
286
+ }
287
+ const FORGE_CLOCK_SKEW_MS = 6e4;
288
+ const FILE_CLOCK_SLACK_MS = 50;
289
+ function verifyScopeEvent(event, request, options) {
290
+ return M(event).returnType().with({ kind: "worktree" }, (worktree) => verifyWorktreeEvent(worktree.path, request, options)).with({ kind: "branch" }, (branch) => verifyBranchEvent(branch, request, options)).with({ kind: "pull-request" }, (pull) => verifyPullRequestEvent(pull.directory, request, options)).exhaustive();
291
+ }
292
+ async function verifyWorktreeEvent(path, request, options) {
293
+ const link = await stat(join(path, ".git"));
294
+ const facts = await loadCheckoutFacts(path, options.env);
295
+ const checkout = facts.checkout;
296
+ if (!link.isFile() || link.mtimeMs < request.startedAt - FILE_CLOCK_SLACK_MS || checkout?.worktree !== path) return EMPTY_SESSION_SCOPE;
297
+ const branch = facts.head?.branch ?? null;
298
+ if (branch === null) return {
299
+ ...EMPTY_SESSION_SCOPE,
300
+ worktrees: [path]
301
+ };
302
+ const isNew = await isBranchCreatedSince(checkout.commonDir, branch, request.startedAt);
303
+ return {
304
+ ...EMPTY_SESSION_SCOPE,
305
+ worktrees: [path],
306
+ branches: isNew ? [{
307
+ name: branch,
308
+ commonDir: checkout.commonDir
309
+ }] : []
310
+ };
311
+ }
312
+ async function verifyBranchEvent(event, request, options) {
313
+ const checkout = await findCheckout(event.directory, options.env);
314
+ if (checkout === null) return EMPTY_SESSION_SCOPE;
315
+ return await isBranchCreatedSince(checkout.commonDir, event.name, request.startedAt) ? {
316
+ ...EMPTY_SESSION_SCOPE,
317
+ branches: [{
318
+ name: event.name,
319
+ commonDir: checkout.commonDir
320
+ }]
321
+ } : EMPTY_SESSION_SCOPE;
322
+ }
323
+ async function verifyPullRequestEvent(directory, request, options) {
324
+ const facts = await loadCheckoutFacts(directory, options.env);
325
+ if (facts.checkout === null) return EMPTY_SESSION_SCOPE;
326
+ const repositories = new Set(facts.remotes.map((remote) => toRepositorySlug(remote.url)));
327
+ const addresses = collectPullRequestAddresses(request.resultText).filter((address) => repositories.has(address.repository));
328
+ const pulls = await Promise.all(addresses.map(async (address) => {
329
+ const pull = await options.readPullRequest(address.repository, address.number);
330
+ return pull !== null && pull.createdAt >= request.startedAt - FORGE_CLOCK_SKEW_MS ? [{
331
+ ...address,
332
+ head: pull.head
333
+ }] : [];
334
+ }));
335
+ return {
336
+ ...EMPTY_SESSION_SCOPE,
337
+ pullRequests: pulls.flat()
338
+ };
339
+ }
340
+ async function isBranchCreatedSince(commonDir, branch, since) {
341
+ const createdAt = await readBranchCreatedAt(commonDir, branch);
342
+ if (createdAt === null || createdAt < Math.floor(since / 1e3) * 1e3) return false;
343
+ const ref = await stat(join(commonDir, "refs", "heads", branch)).catch(() => null);
344
+ return ref !== null && ref.mtimeMs >= since - FILE_CLOCK_SLACK_MS;
345
+ }
346
+ //#endregion
347
+ //#region src/record-scope.ts
348
+ async function recordScope(request, config, options = {}) {
349
+ const host = options.host ?? readHostEnvironment();
350
+ if (!Object.values(config.scopeSources ?? DEFAULT_SCOPE_SOURCES).some((source) => source.kind === "session")) return EMPTY_SESSION_SCOPE;
351
+ return await updateSessionScope(request, {
352
+ now: options.now ?? Date.now(),
353
+ stateDir: resolveXDGDir(host, "state"),
354
+ home: host.home,
355
+ env: host.env,
356
+ readPullRequest
357
+ });
358
+ }
359
+ //#endregion
360
+ //#region src/request/scope-record-request-schema.ts
361
+ const scopeRecordRequestSchema = strictObject({
362
+ sessionID: string().min(1),
363
+ cwd: string().min(1),
364
+ startedAt: number().int().positive(),
365
+ command: string().min(1),
366
+ resultText: string()
367
+ });
368
+ //#endregion
369
+ //#region src/request/parse-scope-record-request.ts
370
+ function parseScopeRecordRequest(body) {
371
+ const parsed = scopeRecordRequestSchema.safeParse(body);
372
+ return parsed.success ? parsed.data : null;
373
+ }
374
+ //#endregion
375
+ //#region src/request/render-verdict.ts
376
+ function renderVerdict(verdict) {
377
+ const rendered = M(verdict).returnType().with({ kind: "allow" }, () => ({ decision: "allow" })).with({ kind: "deny" }, (denied) => ({
378
+ decision: "deny",
379
+ reason: `[${denied.rule}] ${denied.reason}`
380
+ })).exhaustive();
381
+ return JSON.stringify(rendered);
382
+ }
383
+ //#endregion
86
384
  //#region src/request/to-mod-request.ts
87
385
  function toModRequest(request) {
88
386
  const context = request.decisionContext;
@@ -120,6 +418,7 @@ Usage:
120
418
  auto-mode run Read an action request on stdin, write a verdict on stdout
121
419
  auto-mode print-prompt Print the system prompt the classifier receives
122
420
  auto-mode record Read a finished Bash call on stdin, add what it created to the session's scope
421
+ auto-mode mod-path Print the directory of the Claude Code mod this package ships
123
422
 
124
423
  Options:
125
424
  --classifier <path> Use this framework file instead of the shipped one
@@ -158,6 +457,13 @@ async function runCLI(argv, io) {
158
457
  io.stdout.write(`${prompt}\n`);
159
458
  return 0;
160
459
  }
460
+ if (command === "mod-path") try {
461
+ io.stdout.write(`${resolveShippedModDir()}\n`);
462
+ return 0;
463
+ } catch (error) {
464
+ io.stderr.write(`auto-mode: ${error instanceof Error ? error.message : String(error)}\n`);
465
+ return 1;
466
+ }
161
467
  io.stderr.write(`auto-mode: unknown command '${command}'\n\n${USAGE}`);
162
468
  return 2;
163
469
  }
@@ -268,8 +574,7 @@ async function runRecord(io) {
268
574
  const request = parseScopeRecordRequest(body);
269
575
  if (request === null) return 0;
270
576
  try {
271
- const config = await loadConfig(void 0, io.host);
272
- await recordScope(request, config, { host: io.host });
577
+ await recordScope(request, await loadConfig(void 0, io.host), { host: io.host });
273
578
  } catch {
274
579
  io.stderr.write("auto-mode: session scope unavailable\n");
275
580
  }