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/README.md +10 -2
- package/dist/cli.js +60 -590
- package/dist/index.d.ts +4536 -219
- package/dist/index.js +2 -2
- package/dist/{render-verdict-CcVLBFKT.js → render-verdict-DBcgB5CC.js} +1284 -578
- package/mods/auto-mode/contract/types.ts +1 -1
- package/package.json +7 -6
- package/policy/decision.md +1 -1
- package/policy/judge.md +1 -1
- package/policy/rules.md +6 -1
package/dist/cli.js
CHANGED
|
@@ -1,156 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as
|
|
3
|
-
import {
|
|
4
|
-
import { basename, dirname, isAbsolute, join,
|
|
5
|
-
import {
|
|
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(
|
|
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
|
-
|
|
151
|
-
|
|
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/
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"
|
|
304
|
-
|
|
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 =
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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:
|
|
752
|
-
decidingStage:
|
|
753
|
-
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
|
-
|
|
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
|
-
|
|
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
|
}
|