automata-cli 0.8.0-develop.353 → 0.8.0-develop.358

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.
Files changed (2) hide show
  1. package/dist/index.js +1081 -76
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1831,10 +1831,16 @@ function getRepoSlug() {
1831
1831
  return { owner: match[1], repo: match[2] };
1832
1832
  }
1833
1833
  function getAuthenticatedLogin() {
1834
- const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
1835
- if (status !== 0) return null;
1834
+ const identity = getAuthenticatedIdentity();
1835
+ return identity.kind === "login" ? identity.login : null;
1836
+ }
1837
+ function getAuthenticatedIdentity() {
1838
+ const { stdout, stderr, status } = run4("gh", ["api", "user", "--jq", ".login"]);
1839
+ if (status !== 0) {
1840
+ return { kind: "unavailable", detail: stderr.trim() || `gh api user exited ${String(status)}` };
1841
+ }
1836
1842
  const login2 = stdout.trim();
1837
- return login2.length > 0 ? login2 : null;
1843
+ return login2.length > 0 ? { kind: "login", login: login2 } : { kind: "no-user" };
1838
1844
  }
1839
1845
  function listCandidateIssues(technique, value, limit) {
1840
1846
  const args = [
@@ -2234,10 +2240,27 @@ function parseCreatedPrUrl(stdout, head) {
2234
2240
  // src/claude/claudeService.ts
2235
2241
  import { spawn, spawnSync as spawnSync5 } from "child_process";
2236
2242
  import { createInterface } from "readline";
2237
- import { existsSync as existsSync2 } from "fs";
2238
- import { delimiter, join } from "path";
2239
2243
 
2240
2244
  // src/cli/spawnUtils.ts
2245
+ import { accessSync, constants, statSync } from "fs";
2246
+ import { delimiter, join } from "path";
2247
+ function resolveCommand(name) {
2248
+ const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
2249
+ for (const dir of pathDirs) {
2250
+ const candidate = join(dir, name);
2251
+ if (isLaunchable(candidate)) return candidate;
2252
+ }
2253
+ return name;
2254
+ }
2255
+ function isLaunchable(candidate) {
2256
+ try {
2257
+ if (!statSync(candidate).isFile()) return false;
2258
+ accessSync(candidate, constants.X_OK);
2259
+ return true;
2260
+ } catch {
2261
+ return false;
2262
+ }
2263
+ }
2241
2264
  var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
2242
2265
  var ESCAPED_QUOTE = String.raw`'\''`;
2243
2266
  function shellQuote(arg) {
@@ -2261,8 +2284,10 @@ function handleSpawnError(error, toolName) {
2261
2284
  }
2262
2285
  function handleExitCode(status, toolName) {
2263
2286
  if (status === null) {
2264
- process.stderr.write(`Error: ${toolName} terminated abnormally (exit code is null, likely due to a signal).
2265
- `);
2287
+ process.stderr.write(
2288
+ `Error: ${toolName} terminated abnormally (exit code is null, likely due to a signal).
2289
+ `
2290
+ );
2266
2291
  process.exit(1);
2267
2292
  }
2268
2293
  if (status !== 0) {
@@ -2271,14 +2296,20 @@ function handleExitCode(status, toolName) {
2271
2296
  process.exit(status);
2272
2297
  }
2273
2298
  }
2274
- function resolveEffortOption(value) {
2275
- if (value === void 0) return void 0;
2299
+ function normalizeEffortOption(value) {
2300
+ if (value === void 0) return { ok: true, value: void 0 };
2276
2301
  const trimmed2 = value.trim();
2277
- if (trimmed2.length === 0) {
2278
- process.stderr.write("Error: --effort must be a non-empty level.\n");
2302
+ if (trimmed2.length === 0) return { ok: false, error: "--effort must be a non-empty level." };
2303
+ return { ok: true, value: trimmed2 };
2304
+ }
2305
+ function resolveEffortOption(value) {
2306
+ const result = normalizeEffortOption(value);
2307
+ if (!result.ok) {
2308
+ process.stderr.write(`Error: ${result.error}
2309
+ `);
2279
2310
  process.exit(1);
2280
2311
  }
2281
- return trimmed2;
2312
+ return result.value;
2282
2313
  }
2283
2314
 
2284
2315
  // src/cli/childRegistry.ts
@@ -2324,14 +2355,6 @@ async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
2324
2355
  }
2325
2356
 
2326
2357
  // src/claude/claudeService.ts
2327
- function resolveCommand(name) {
2328
- const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
2329
- for (const dir of pathDirs) {
2330
- const candidate = join(dir, name);
2331
- if (existsSync2(candidate)) return candidate;
2332
- }
2333
- return name;
2334
- }
2335
2358
  function buildClaudeArgs(prompt, options = {}) {
2336
2359
  const args = [];
2337
2360
  if (options.yolo) args.push("--dangerously-skip-permissions");
@@ -3133,6 +3156,16 @@ var executePromptCommand = new Command5("execute-prompt").description("Execute a
3133
3156
  // src/commands/doWork.ts
3134
3157
  import { Command as Command6 } from "commander";
3135
3158
 
3159
+ // src/github/identity.ts
3160
+ function identityProblemFor(login2, agentUser, allowedUsers) {
3161
+ if (login2 === null) return null;
3162
+ if (login2.toLowerCase() === agentUser.toLowerCase()) return null;
3163
+ if (allowedUsers.some((user) => user.toLowerCase() === login2.toLowerCase())) {
3164
+ return `\`gh\` is authenticated as "${login2}", which is listed in allowedUsers. Everything do-work posts would be attributed to an account that is allowed to instruct the agent, so its own marker comment would look like a new instruction and each tick would answer the previous tick forever. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`;
3165
+ }
3166
+ return `\`gh\` is authenticated as "${login2}" but agentUser is "${agentUser}". Comments posted under that identity are neither the agent's nor an authorized user's, so they are filtered out of the conversation: the answer boundary would never advance and the same message would start a run on every tick. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`;
3167
+ }
3168
+
3136
3169
  // src/github/workDetection.ts
3137
3170
  var NO_ISSUE_MESSAGES = {
3138
3171
  messages: [],
@@ -3596,7 +3629,7 @@ function analyseAnswer(messages, p, marker, watermark) {
3596
3629
  }
3597
3630
 
3598
3631
  // src/run/runLock.ts
3599
- import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync, statSync } from "fs";
3632
+ import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync, statSync as statSync2 } from "fs";
3600
3633
  import { randomUUID } from "crypto";
3601
3634
  import { hostname } from "os";
3602
3635
  import { join as join2 } from "path";
@@ -3624,6 +3657,14 @@ function isAlive(pid) {
3624
3657
  return err.code === "EPERM";
3625
3658
  }
3626
3659
  }
3660
+ function readOwnerError(path) {
3661
+ try {
3662
+ readFileSync3(path, "utf8");
3663
+ return null;
3664
+ } catch (err) {
3665
+ return err.message;
3666
+ }
3667
+ }
3627
3668
  function readOwner(path) {
3628
3669
  try {
3629
3670
  const parsed = JSON.parse(readFileSync3(path, "utf8"));
@@ -3811,7 +3852,7 @@ function claimIsAbandoned(claimPath) {
3811
3852
  }
3812
3853
  if (Number.isNaN(at)) {
3813
3854
  try {
3814
- at = statSync(claimPath).mtimeMs;
3855
+ at = statSync2(claimPath).mtimeMs;
3815
3856
  } catch {
3816
3857
  return true;
3817
3858
  }
@@ -3848,6 +3889,28 @@ function reclaim(path, command, token, expected) {
3848
3889
  }
3849
3890
  }
3850
3891
  }
3892
+ function heldForMs(owner, now) {
3893
+ const startedAt = Date.parse(owner.startedAt);
3894
+ return Number.isNaN(startedAt) ? null : now - startedAt;
3895
+ }
3896
+ function inspectRunLock(staleMinutes, now = Date.now()) {
3897
+ const path = lockPath();
3898
+ try {
3899
+ statSync2(path);
3900
+ } catch (err) {
3901
+ if (err.code === "ENOENT") return { kind: "free" };
3902
+ return { kind: "unreadable", detail: err.message };
3903
+ }
3904
+ const readError = readOwnerError(path);
3905
+ if (readError !== null) return { kind: "unreadable", detail: readError };
3906
+ const owner = readOwner(path);
3907
+ if (isStale(owner, staleMinutes)) {
3908
+ return { kind: "stale", owner, heldForMs: owner === null ? null : heldForMs(owner, now) };
3909
+ }
3910
+ const held = owner;
3911
+ const kind = heldTooLong(held, staleMinutes) ? "suspect" : "held";
3912
+ return { kind, owner: held, heldForMs: heldForMs(held, now) };
3913
+ }
3851
3914
 
3852
3915
  // src/git/workspaceService.ts
3853
3916
  function dirtyTree() {
@@ -3881,11 +3944,11 @@ function resetToForcePushedRemote(headRefName, previousRemoteSha) {
3881
3944
  }
3882
3945
  function rebaseOntoAlreadyAppliedRemote(headRefName) {
3883
3946
  const upstream = `refs/remotes/origin/${headRefName}`;
3884
- const divergence = describeDivergence(upstream, `refs/heads/${headRefName}`);
3885
- if (divergence === null) return null;
3886
- if (divergence.merges > 0) return null;
3887
- if (divergence.commits.length === 0) return null;
3888
- if (divergence.commits.some((commit) => !commit.alreadyUpstream)) return null;
3947
+ const divergence2 = describeDivergence(upstream, `refs/heads/${headRefName}`);
3948
+ if (divergence2 === null) return null;
3949
+ if (divergence2.merges > 0) return null;
3950
+ if (divergence2.commits.length === 0) return null;
3951
+ if (divergence2.commits.some((commit) => !commit.alreadyUpstream)) return null;
3889
3952
  if (isRebaseInProgress()) {
3890
3953
  return {
3891
3954
  ok: false,
@@ -3922,18 +3985,18 @@ function rebaseOntoAlreadyAppliedRemote(headRefName) {
3922
3985
  return { ok: true, branch: headRefName, strategy: "rebase" };
3923
3986
  }
3924
3987
  function divergenceRefusal(headRefName, pullError) {
3925
- const divergence = describeDivergence(
3988
+ const divergence2 = describeDivergence(
3926
3989
  `refs/remotes/origin/${headRefName}`,
3927
3990
  `refs/heads/${headRefName}`
3928
3991
  );
3929
- if (divergence === null) {
3992
+ if (divergence2 === null) {
3930
3993
  return {
3931
3994
  ok: false,
3932
3995
  reason: "pull-failed",
3933
3996
  detail: `${pullError} \u2014 how the local ${headRefName} relates to origin/${headRefName} could not be established, so nothing was changed and ${headRefName} is untouched; inspect it with \`git log --oneline origin/${headRefName}...${headRefName}\` before deciding what to do`
3934
3997
  };
3935
3998
  }
3936
- const unpushedCount = divergence.commits.filter((c) => !c.alreadyUpstream).length + divergence.merges;
3999
+ const unpushedCount = divergence2.commits.filter((c) => !c.alreadyUpstream).length + divergence2.merges;
3937
4000
  if (unpushedCount === 0) {
3938
4001
  return {
3939
4002
  ok: false,
@@ -4319,9 +4382,9 @@ function describeRescueRemains(rescue) {
4319
4382
 
4320
4383
  // src/run/operationLog.ts
4321
4384
  import {
4322
- accessSync,
4385
+ accessSync as accessSync2,
4323
4386
  appendFileSync,
4324
- constants,
4387
+ constants as constants2,
4325
4388
  readFileSync as readFileSync4,
4326
4389
  renameSync as renameSync2,
4327
4390
  unlinkSync as unlinkSync2,
@@ -4468,7 +4531,7 @@ function appendWithRetention(dir, file, content, retain) {
4468
4531
  }
4469
4532
  function recordTick(tick, dir = operationLogDirectory()) {
4470
4533
  try {
4471
- accessSync(dir, constants.W_OK);
4534
+ accessSync2(dir, constants2.W_OK);
4472
4535
  } catch {
4473
4536
  return;
4474
4537
  }
@@ -4494,6 +4557,696 @@ function recordTick(tick, dir = operationLogDirectory()) {
4494
4557
  } catch {
4495
4558
  }
4496
4559
  }
4560
+ function isOutcome(value) {
4561
+ return OUTCOMES.includes(value);
4562
+ }
4563
+ function readLogFile(path) {
4564
+ try {
4565
+ return { content: readFileSync4(path, "utf8"), error: null };
4566
+ } catch (err) {
4567
+ if (err.code === "ENOENT") return { content: null, error: null };
4568
+ return { content: null, error: err.message };
4569
+ }
4570
+ }
4571
+ function emptyResult(path, error, filtered) {
4572
+ return { entries: [], present: false, error, skipped: 0, otherRepos: 0, filtered, path };
4573
+ }
4574
+ function isFiltered(want) {
4575
+ return want !== void 0 && want !== null;
4576
+ }
4577
+ function matchesRepo(entryRepo, want) {
4578
+ if (want === void 0 || want === null) return true;
4579
+ return entryRepo === null || entryRepo === want;
4580
+ }
4581
+ function readInt(value) {
4582
+ if (value === void 0) return 0;
4583
+ if (!/^-?\d+$/.test(value)) return null;
4584
+ const parsed = Number(value);
4585
+ return Number.isSafeInteger(parsed) ? parsed : null;
4586
+ }
4587
+ function readDurationSeconds(value) {
4588
+ if (value === void 0) return 0;
4589
+ if (!/^\d+(?:\.\d+)?s?$/.test(value)) return null;
4590
+ const parsed = Number.parseFloat(value);
4591
+ return Number.isFinite(parsed) ? parsed : null;
4592
+ }
4593
+ function parseExecutionLine(line) {
4594
+ const fields = line.trim().split(" ").filter((field) => field.length > 0);
4595
+ if (fields.length < 2) return null;
4596
+ const timestamp = new Date(fields[0]);
4597
+ if (Number.isNaN(timestamp.getTime())) return null;
4598
+ const pairs = /* @__PURE__ */ new Map();
4599
+ for (const field of fields.slice(2)) {
4600
+ const eq = field.indexOf("=");
4601
+ if (eq <= 0) continue;
4602
+ pairs.set(field.slice(0, eq), field.slice(eq + 1));
4603
+ }
4604
+ const exit = readInt(pairs.get("exit"));
4605
+ if (exit === null) return null;
4606
+ const counts = {};
4607
+ for (const outcome of OUTCOMES) {
4608
+ const count = readInt(pairs.get(outcome));
4609
+ if (count === null) return null;
4610
+ counts[outcome] = count;
4611
+ }
4612
+ const items = readInt(pairs.get("items"));
4613
+ const runs = readInt(pairs.get("runs"));
4614
+ const durationSeconds = readDurationSeconds(pairs.get("dur"));
4615
+ if (items === null || runs === null || durationSeconds === null) return null;
4616
+ const repo = pairs.get("repo");
4617
+ const note = pairs.get("note");
4618
+ return {
4619
+ timestamp,
4620
+ command: fields[1],
4621
+ repo: repo === void 0 || repo === "-" ? null : repo,
4622
+ items,
4623
+ counts,
4624
+ runs,
4625
+ exitCode: exit,
4626
+ durationSeconds,
4627
+ note: note === void 0 || note.length === 0 ? null : note
4628
+ };
4629
+ }
4630
+ function readExecutionTicks(options = {}) {
4631
+ const path = join3(options.dir ?? operationLogDirectory(), EXECUTION_LOG_FILE);
4632
+ const { content, error } = readLogFile(path);
4633
+ if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4634
+ const entries = [];
4635
+ let skipped = 0;
4636
+ let otherRepos = 0;
4637
+ for (const line of content.split("\n")) {
4638
+ if (line.trim().length === 0) continue;
4639
+ const tick = parseExecutionLine(line);
4640
+ if (tick === null) {
4641
+ skipped++;
4642
+ continue;
4643
+ }
4644
+ if (!matchesRepo(tick.repo, options.repo)) {
4645
+ otherRepos++;
4646
+ continue;
4647
+ }
4648
+ entries.push(tick);
4649
+ }
4650
+ entries.reverse();
4651
+ return {
4652
+ entries: options.limit === void 0 ? entries : entries.slice(0, options.limit),
4653
+ present: true,
4654
+ error: null,
4655
+ skipped,
4656
+ otherRepos,
4657
+ filtered: isFiltered(options.repo),
4658
+ path
4659
+ };
4660
+ }
4661
+ var WORK_ITEM_LINE = /^(#\S+|PR #\S+) (\S+) (answered-no-reply|answered|skipped|failed|deferred)(?: \[([^\]]*)\])?(?: sync=(.*?))? — (.*)$/;
4662
+ function parseExecution(bracket) {
4663
+ if (bracket === void 0) return { executor: null, model: null, effort: null };
4664
+ const parts = bracket.split(" ").filter((part) => part.length > 0);
4665
+ const executor = parts[0] ?? null;
4666
+ const find = (key) => {
4667
+ const hit = parts.find((part) => part.startsWith(`${key}=`));
4668
+ return hit === void 0 ? null : hit.slice(key.length + 1);
4669
+ };
4670
+ return { executor, model: find("model"), effort: find("effort") };
4671
+ }
4672
+ function parseWorkHeader(line) {
4673
+ const header = /^=== (\S+) (\S+) ===$/.exec(line);
4674
+ if (header === null) return "not-a-header";
4675
+ const timestamp = new Date(header[1]);
4676
+ if (Number.isNaN(timestamp.getTime())) return null;
4677
+ return { timestamp, repo: header[2] === "-" ? null : header[2], items: [] };
4678
+ }
4679
+ function parseWorkItem(line) {
4680
+ const item = WORK_ITEM_LINE.exec(line);
4681
+ if (item === null) return null;
4682
+ return {
4683
+ subject: item[1],
4684
+ turn: item[2] === "-" ? null : item[2],
4685
+ // The alternation in the pattern admits nothing else.
4686
+ outcome: isOutcome(item[3]) ? item[3] : "skipped",
4687
+ ...parseExecution(item[4]),
4688
+ sync: item[5] === void 0 || item[5].length === 0 ? null : item[5],
4689
+ detail: item[6]
4690
+ };
4691
+ }
4692
+ function readWorkRecords(options = {}) {
4693
+ const path = join3(options.dir ?? operationLogDirectory(), WORK_LOG_FILE);
4694
+ const { content, error } = readLogFile(path);
4695
+ if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4696
+ const entries = [];
4697
+ let skipped = 0;
4698
+ let otherRepos = 0;
4699
+ let current = null;
4700
+ const close = () => {
4701
+ if (current === null) return;
4702
+ if (matchesRepo(current.repo, options.repo)) {
4703
+ entries.push(current);
4704
+ } else {
4705
+ otherRepos++;
4706
+ }
4707
+ current = null;
4708
+ };
4709
+ for (const line of content.split("\n")) {
4710
+ if (line.trim().length === 0) continue;
4711
+ const header = parseWorkHeader(line);
4712
+ if (header === null) {
4713
+ skipped++;
4714
+ continue;
4715
+ }
4716
+ if (header !== "not-a-header") {
4717
+ close();
4718
+ current = header;
4719
+ continue;
4720
+ }
4721
+ const item = parseWorkItem(line);
4722
+ if (item === null || current === null) {
4723
+ skipped++;
4724
+ continue;
4725
+ }
4726
+ current.items.push(item);
4727
+ }
4728
+ close();
4729
+ entries.reverse();
4730
+ return {
4731
+ entries: options.limit === void 0 ? entries : entries.slice(0, options.limit),
4732
+ present: true,
4733
+ error: null,
4734
+ skipped,
4735
+ otherRepos,
4736
+ filtered: isFiltered(options.repo),
4737
+ path
4738
+ };
4739
+ }
4740
+
4741
+ // src/git/repoStatus.ts
4742
+ import { spawnSync as spawnSync7 } from "child_process";
4743
+ var GIT_BIN = resolveCommand("git");
4744
+ function git(args) {
4745
+ const result = spawnSync7(GIT_BIN, args, { encoding: "utf8" });
4746
+ return {
4747
+ stdout: result.stdout ?? "",
4748
+ stderr: result.stderr ?? "",
4749
+ status: result.status ?? 1
4750
+ };
4751
+ }
4752
+ function notARepo(baseBranch, detail) {
4753
+ return {
4754
+ branch: null,
4755
+ head: null,
4756
+ dirtyPaths: [],
4757
+ statusError: null,
4758
+ baseBranch,
4759
+ baseLocal: false,
4760
+ upstream: null,
4761
+ upstreamTracked: false,
4762
+ ahead: null,
4763
+ behind: null,
4764
+ refreshed: false,
4765
+ fetchError: null,
4766
+ error: detail
4767
+ };
4768
+ }
4769
+ function isOwnLockFile(porcelainEntry) {
4770
+ return porcelainEntry.slice(3).trim().endsWith(RUN_LOCK_RELATIVE_PATH);
4771
+ }
4772
+ function divergence(upstream, branch) {
4773
+ const { stdout, status } = git([
4774
+ "rev-list",
4775
+ "--left-right",
4776
+ "--count",
4777
+ `${upstream}...${branch}`
4778
+ ]);
4779
+ if (status !== 0) return null;
4780
+ const parts = stdout.trim().split(/\s+/);
4781
+ if (parts.length < 2) return null;
4782
+ const behind = Number.parseInt(parts[0], 10);
4783
+ const ahead = Number.parseInt(parts[1], 10);
4784
+ if (Number.isNaN(behind) || Number.isNaN(ahead)) return null;
4785
+ return { ahead, behind };
4786
+ }
4787
+ function readHead() {
4788
+ const head = git(["rev-parse", "--short", "HEAD"]);
4789
+ if (head.status === 0) return { head: head.stdout.trim(), fatal: null };
4790
+ if (git(["rev-parse", "--is-inside-work-tree"]).status === 0) return { head: null, fatal: null };
4791
+ return { head: null, fatal: head.stderr.trim() || "not a git repository" };
4792
+ }
4793
+ function readDirtyPaths() {
4794
+ const porcelain = git(["status", "--porcelain"]);
4795
+ if (porcelain.status !== 0) {
4796
+ return { paths: [], error: porcelain.stderr.trim() || "git status --porcelain failed" };
4797
+ }
4798
+ return {
4799
+ paths: porcelain.stdout.split("\n").filter((line) => line.trim().length > 0).filter((line) => !isOwnLockFile(line)),
4800
+ error: null
4801
+ };
4802
+ }
4803
+ function refreshBase(baseBranch) {
4804
+ const fetched = git([
4805
+ "fetch",
4806
+ "origin",
4807
+ `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`
4808
+ ]);
4809
+ if (fetched.status === 0) return { refreshed: true, fetchError: null };
4810
+ return { refreshed: false, fetchError: fetched.stderr.trim() || "git fetch failed" };
4811
+ }
4812
+ function resolveUpstream(baseBranch, baseLocal) {
4813
+ if (baseLocal) {
4814
+ const configured = git([
4815
+ "rev-parse",
4816
+ "--abbrev-ref",
4817
+ "--symbolic-full-name",
4818
+ `${baseBranch}@{u}`
4819
+ ]);
4820
+ if (configured.status === 0) return { upstream: configured.stdout.trim(), tracked: true };
4821
+ }
4822
+ if (git(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${baseBranch}`]).status === 0) {
4823
+ return { upstream: `origin/${baseBranch}`, tracked: false };
4824
+ }
4825
+ return { upstream: null, tracked: false };
4826
+ }
4827
+ function inspectRepoStatus(options) {
4828
+ const { baseBranch } = options;
4829
+ const { head, fatal } = readHead();
4830
+ if (fatal !== null) return notARepo(baseBranch, fatal);
4831
+ const symbolic = git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
4832
+ const branch = symbolic.status === 0 ? symbolic.stdout.trim() : null;
4833
+ const { paths: dirtyPaths, error: statusError } = readDirtyPaths();
4834
+ const baseLocal = git(["rev-parse", "--verify", "--quiet", `refs/heads/${baseBranch}`]).status === 0;
4835
+ const { refreshed, fetchError } = options.fetch ? refreshBase(baseBranch) : { refreshed: false, fetchError: null };
4836
+ const { upstream, tracked } = resolveUpstream(baseBranch, baseLocal);
4837
+ const counts = baseLocal && upstream !== null ? divergence(upstream, baseBranch) : null;
4838
+ return {
4839
+ branch,
4840
+ head,
4841
+ dirtyPaths,
4842
+ statusError,
4843
+ baseBranch,
4844
+ baseLocal,
4845
+ upstream,
4846
+ upstreamTracked: tracked,
4847
+ ahead: counts?.ahead ?? null,
4848
+ behind: counts?.behind ?? null,
4849
+ refreshed,
4850
+ fetchError,
4851
+ error: null
4852
+ };
4853
+ }
4854
+
4855
+ // src/run/checkReport.ts
4856
+ var SECTION_ORDER = [
4857
+ "lock",
4858
+ "ticks",
4859
+ "work",
4860
+ "git",
4861
+ "selection",
4862
+ "environment"
4863
+ ];
4864
+ var SECTION_TITLES = {
4865
+ lock: "Run lock",
4866
+ ticks: "Recent ticks",
4867
+ work: "Last work",
4868
+ git: "Repository",
4869
+ selection: "Selection",
4870
+ environment: "Environment"
4871
+ };
4872
+ function sectionTitle(id) {
4873
+ return SECTION_TITLES[id];
4874
+ }
4875
+ var TICK_HISTORY = 20;
4876
+ var WORK_HISTORY = 3;
4877
+ var SILENCE_MULTIPLIER = 3;
4878
+ var MIN_INTERVALS = 3;
4879
+ function median(values) {
4880
+ const sorted = [...values].sort((a, b) => a - b);
4881
+ const middle = Math.floor(sorted.length / 2);
4882
+ return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
4883
+ }
4884
+ function tickCadence(ticks, now) {
4885
+ if (ticks.length === 0) return { medianIntervalMs: null, sinceNewestMs: null, silent: false };
4886
+ const sinceNewestMs = now.getTime() - ticks[0].timestamp.getTime();
4887
+ const intervals = [];
4888
+ for (let i = 0; i + 1 < ticks.length; i++) {
4889
+ const gap = ticks[i].timestamp.getTime() - ticks[i + 1].timestamp.getTime();
4890
+ if (gap > 0) intervals.push(gap);
4891
+ }
4892
+ if (intervals.length < MIN_INTERVALS)
4893
+ return { medianIntervalMs: null, sinceNewestMs, silent: false };
4894
+ const medianIntervalMs = median(intervals);
4895
+ return {
4896
+ medianIntervalMs,
4897
+ sinceNewestMs,
4898
+ silent: sinceNewestMs > SILENCE_MULTIPLIER * medianIntervalMs
4899
+ };
4900
+ }
4901
+ function describeDuration(ms) {
4902
+ if (ms < 0) return "in the future";
4903
+ const seconds = Math.floor(ms / 1e3);
4904
+ if (seconds < 60) return `${String(seconds)}s`;
4905
+ const minutes = Math.floor(seconds / 60);
4906
+ if (minutes < 60) return `${String(minutes)}m`;
4907
+ const hours = Math.floor(minutes / 60);
4908
+ if (hours < 24) return `${String(hours)}h ${String(minutes % 60)}m`;
4909
+ const days = Math.floor(hours / 24);
4910
+ return `${String(days)}d ${String(hours % 24)}h`;
4911
+ }
4912
+ function build(id, lines, problems, data) {
4913
+ return {
4914
+ id,
4915
+ title: SECTION_TITLES[id],
4916
+ lines,
4917
+ problems: problems.map((summary) => ({ section: id, summary })),
4918
+ data
4919
+ };
4920
+ }
4921
+ function describeOwner(owner) {
4922
+ return `pid ${String(owner.pid)} on ${owner.host}, \`${owner.command}\`, since ${owner.startedAt}`;
4923
+ }
4924
+ function lockSection(status, staleMinutes) {
4925
+ const data = { status: status.kind, staleMinutes };
4926
+ switch (status.kind) {
4927
+ case "free":
4928
+ return build("lock", ["no tick is running in this checkout"], [], data);
4929
+ case "held": {
4930
+ const held = status.heldForMs === null ? "" : ` (${describeDuration(status.heldForMs)} so far)`;
4931
+ return build("lock", [`a tick is running: ${describeOwner(status.owner)}${held}`], [], {
4932
+ ...data,
4933
+ owner: status.owner,
4934
+ heldForMs: status.heldForMs
4935
+ });
4936
+ }
4937
+ case "suspect": {
4938
+ const held = status.heldForMs === null ? "unknown" : describeDuration(status.heldForMs);
4939
+ return build(
4940
+ "lock",
4941
+ [`a tick has held the lock for ${held}: ${describeOwner(status.owner)}`],
4942
+ [
4943
+ `the run lock has been held longer than ${String(staleMinutes)} minutes by a process whose identity cannot be verified; if no executor is running, kill the holder or delete \`.automata/automata.lock\``
4944
+ ],
4945
+ { ...data, owner: status.owner, heldForMs: status.heldForMs }
4946
+ );
4947
+ }
4948
+ case "stale":
4949
+ return build(
4950
+ "lock",
4951
+ [
4952
+ status.owner === null ? "a run lock exists but could not be parsed" : `a stale run lock is present: ${describeOwner(status.owner)}`
4953
+ ],
4954
+ [
4955
+ "a stale run lock is present; the next tick reclaims it automatically, so no action is needed unless ticks keep being turned away"
4956
+ ],
4957
+ { ...data, owner: status.owner, heldForMs: status.heldForMs }
4958
+ );
4959
+ case "unreadable":
4960
+ return build(
4961
+ "lock",
4962
+ [`the run lock could not be read: ${status.detail}`],
4963
+ [`the run lock at \`.automata/automata.lock\` could not be read: ${status.detail}`],
4964
+ { ...data, detail: status.detail }
4965
+ );
4966
+ }
4967
+ }
4968
+ function describeTick(tick, now) {
4969
+ const counts = [
4970
+ `answered=${String(tick.counts.answered)}`,
4971
+ `no-reply=${String(tick.counts["answered-no-reply"])}`,
4972
+ `skipped=${String(tick.counts.skipped)}`,
4973
+ `failed=${String(tick.counts.failed)}`,
4974
+ `deferred=${String(tick.counts.deferred)}`,
4975
+ `runs=${String(tick.runs)}`
4976
+ ].join(" ");
4977
+ const note = tick.note === null ? "" : ` note=${tick.note}`;
4978
+ const age = describeDuration(now.getTime() - tick.timestamp.getTime());
4979
+ return `${tick.timestamp.toISOString()} (${age} ago) exit=${String(tick.exitCode)} ${counts}${note}`;
4980
+ }
4981
+ function describeMissingTicks(read) {
4982
+ if (read.error !== null) {
4983
+ return {
4984
+ line: `the execution log could not be read: ${read.error}`,
4985
+ problem: `the execution log \`${read.path}\` could not be read: ${read.error}`
4986
+ };
4987
+ }
4988
+ if (!read.present) {
4989
+ return {
4990
+ line: `no execution log at ${read.path}`,
4991
+ problem: `no execution log at \`${read.path}\` \u2014 no tick has ever run here, or automata cannot write to the workspace root; check that the scheduler runs \`do-work\` from inside the checkout`
4992
+ };
4993
+ }
4994
+ if (read.entries.length === 0) {
4995
+ return {
4996
+ line: "the execution log holds no tick for this repository",
4997
+ problem: "the execution log holds no tick for this repository \u2014 the scheduler has never successfully run `do-work` here"
4998
+ };
4999
+ }
5000
+ return null;
5001
+ }
5002
+ function describeTickHistory(ticks, cadence, now, lines, problems) {
5003
+ lines.push(`last tick: ${describeTick(ticks[0], now)}`);
5004
+ if (ticks[0].exitCode !== 0) {
5005
+ problems.push(
5006
+ `the last tick exited ${String(ticks[0].exitCode)} \u2014 see \`Last work\` below and the work log for the item that failed`
5007
+ );
5008
+ }
5009
+ const lockHeld = ticks.filter((tick) => tick.note === "lock-held").length;
5010
+ const withRuns = ticks.filter((tick) => tick.runs > 0).length;
5011
+ lines.push(
5012
+ `history: ${String(ticks.length)} tick(s), ${String(withRuns)} invoked the executor, ${String(lockHeld)} were turned away by a held lock`
5013
+ );
5014
+ if (lockHeld === ticks.length && ticks.length > 1) {
5015
+ problems.push(
5016
+ "every recorded tick was turned away by a held run lock \u2014 a previous tick is wedged; see `Run lock` above"
5017
+ );
5018
+ }
5019
+ if (cadence.medianIntervalMs === null) {
5020
+ lines.push("cadence: not enough history to judge whether the scheduler is still firing");
5021
+ return;
5022
+ }
5023
+ lines.push(`cadence: about one tick every ${describeDuration(cadence.medianIntervalMs)}`);
5024
+ if (cadence.silent) {
5025
+ problems.push(
5026
+ `no tick for ${describeDuration(cadence.sinceNewestMs ?? 0)}, against a usual interval of ${describeDuration(cadence.medianIntervalMs)} \u2014 the scheduler appears to have stopped firing (automata does not manage the scheduler; check it on this host)`
5027
+ );
5028
+ }
5029
+ }
5030
+ function unfilteredLine(unit) {
5031
+ return `not filtered by repository: the slug could not be resolved, so ${unit} from other checkouts may be shown`;
5032
+ }
5033
+ function tickSection(read, now) {
5034
+ const lines = [];
5035
+ const problems = [];
5036
+ const ticks = read.entries;
5037
+ const cadence = tickCadence(ticks, now);
5038
+ const missing = describeMissingTicks(read);
5039
+ if (missing !== null) {
5040
+ lines.push(missing.line);
5041
+ problems.push(missing.problem);
5042
+ } else {
5043
+ describeTickHistory(ticks, cadence, now, lines, problems);
5044
+ }
5045
+ if (!read.filtered && read.present) lines.push(unfilteredLine("line(s)"));
5046
+ if (read.skipped > 0)
5047
+ lines.push(`${String(read.skipped)} log line(s) could not be parsed and were ignored`);
5048
+ if (read.otherRepos > 0)
5049
+ lines.push(`${String(read.otherRepos)} line(s) belonged to another repository`);
5050
+ return build("ticks", lines, problems, {
5051
+ newest: ticks[0] ?? null,
5052
+ history: ticks,
5053
+ lockHeldCount: ticks.filter((tick) => tick.note === "lock-held").length,
5054
+ medianIntervalMs: cadence.medianIntervalMs,
5055
+ sinceNewestMs: cadence.sinceNewestMs,
5056
+ silent: cadence.silent,
5057
+ skipped: read.skipped,
5058
+ otherRepos: read.otherRepos,
5059
+ filtered: read.filtered,
5060
+ logPath: read.path,
5061
+ logPresent: read.present
5062
+ });
5063
+ }
5064
+ function describeWorkItem(item) {
5065
+ const how = item.executor === null ? "" : ` [${[item.executor, item.model, item.effort].filter((part) => part !== null).join(" ")}]`;
5066
+ const sync = item.sync === null ? "" : ` sync=${item.sync}`;
5067
+ return ` ${item.subject} ${item.turn ?? "-"} ${item.outcome}${how}${sync} \u2014 ${item.detail}`;
5068
+ }
5069
+ function describeWorkRecords(records, now) {
5070
+ return records.flatMap((record) => [
5071
+ `${record.timestamp.toISOString()} (${describeDuration(now.getTime() - record.timestamp.getTime())} ago)`,
5072
+ ...record.items.map(describeWorkItem)
5073
+ ]);
5074
+ }
5075
+ function workSection(read, now) {
5076
+ const lines = [];
5077
+ const problems = [];
5078
+ const records = read.entries;
5079
+ if (read.error !== null) {
5080
+ lines.push(`the work log could not be read: ${read.error}`);
5081
+ problems.push(`the work log \`${read.path}\` could not be read: ${read.error}`);
5082
+ } else if (!read.present || records.length === 0) {
5083
+ lines.push("no tick has invoked the executor in the retained window");
5084
+ } else {
5085
+ lines.push(...describeWorkRecords(records, now));
5086
+ }
5087
+ if (!read.filtered && read.present) lines.push(unfilteredLine("record(s)"));
5088
+ if (read.skipped > 0)
5089
+ lines.push(`${String(read.skipped)} log line(s) could not be parsed and were ignored`);
5090
+ if (read.otherRepos > 0)
5091
+ lines.push(`${String(read.otherRepos)} record(s) belonged to another repository`);
5092
+ return build("work", lines, problems, {
5093
+ records,
5094
+ skipped: read.skipped,
5095
+ otherRepos: read.otherRepos,
5096
+ filtered: read.filtered,
5097
+ logPath: read.path,
5098
+ logPresent: read.present
5099
+ });
5100
+ }
5101
+ function describeCheckout(status, lines, problems) {
5102
+ if (status.branch === null) {
5103
+ lines.push(`HEAD is detached at ${status.head ?? "an unknown commit"}`);
5104
+ problems.push("HEAD is detached; the pre-flight expects a branch, so check one out");
5105
+ } else {
5106
+ const at = status.head === null ? "" : ` at ${status.head}`;
5107
+ lines.push(`on ${status.branch}${at}`);
5108
+ }
5109
+ if (status.statusError !== null) {
5110
+ lines.push(`the working tree could not be inspected: ${status.statusError}`);
5111
+ problems.push(
5112
+ `\`git status\` failed (${status.statusError}), so whether the working tree is clean is unknown; the pre-flight runs the same command and stops every item when it cannot answer`
5113
+ );
5114
+ return;
5115
+ }
5116
+ if (status.dirtyPaths.length === 0) {
5117
+ lines.push("working tree is clean");
5118
+ return;
5119
+ }
5120
+ lines.push(`working tree has ${String(status.dirtyPaths.length)} uncommitted change(s):`);
5121
+ for (const path of status.dirtyPaths) lines.push(` ${path}`);
5122
+ problems.push(
5123
+ `the working tree has ${String(status.dirtyPaths.length)} uncommitted change(s); the pre-flight will try to rescue them onto a branch, and every item skips as \`dirty-tree\` if that fails`
5124
+ );
5125
+ }
5126
+ function describeBaseBranch(status, lines, problems) {
5127
+ if (!status.baseLocal) {
5128
+ lines.push(`base branch ${status.baseBranch} does not exist in this checkout`);
5129
+ problems.push(
5130
+ `the base branch \`${status.baseBranch}\` does not exist locally; either check it out or correct \`doWork.baseBranch\` with \`automata config set do-work-base-branch <branch>\``
5131
+ );
5132
+ return;
5133
+ }
5134
+ if (status.upstream === null) {
5135
+ lines.push(`base branch ${status.baseBranch} has no upstream`);
5136
+ problems.push(
5137
+ `the base branch \`${status.baseBranch}\` has no upstream, so the pre-flight cannot fast-forward it`
5138
+ );
5139
+ return;
5140
+ }
5141
+ if (!status.upstreamTracked) {
5142
+ lines.push(
5143
+ `base branch ${status.baseBranch} has no tracking configuration; counted against ${status.upstream}`
5144
+ );
5145
+ problems.push(
5146
+ `the base branch \`${status.baseBranch}\` has no upstream configured, so the pre-flight's \`git pull --ff-only\` fails even though ${status.upstream} exists; set it with \`git branch --set-upstream-to=${status.upstream} ${status.baseBranch}\``
5147
+ );
5148
+ }
5149
+ const freshness = status.refreshed ? "" : " (not refreshed)";
5150
+ const ahead = status.ahead === null ? "?" : String(status.ahead);
5151
+ const behind = status.behind === null ? "?" : String(status.behind);
5152
+ lines.push(
5153
+ `base branch ${status.baseBranch} vs ${status.upstream}: ahead ${ahead}, behind ${behind}${freshness}`
5154
+ );
5155
+ if (status.ahead === null || status.behind === null) {
5156
+ problems.push(
5157
+ `the divergence of \`${status.baseBranch}\` from ${status.upstream} could not be read, so whether the pre-flight's fast-forward pull will succeed is unknown; try \`git rev-list --left-right --count ${status.upstream}...${status.baseBranch}\` to see git's own error`
5158
+ );
5159
+ return;
5160
+ }
5161
+ if (status.ahead === 0) return;
5162
+ if (status.behind > 0) {
5163
+ problems.push(
5164
+ `the base branch \`${status.baseBranch}\` has diverged from ${status.upstream} (${String(status.ahead)} ahead, ${String(status.behind)} behind); the pre-flight's fast-forward pull will fail until that is resolved by hand`
5165
+ );
5166
+ return;
5167
+ }
5168
+ lines.push(` ${String(status.ahead)} local commit(s) not on ${status.upstream}`);
5169
+ }
5170
+ function gitSection(status) {
5171
+ const lines = [];
5172
+ const problems = [];
5173
+ if (status.error !== null) {
5174
+ return build(
5175
+ "git",
5176
+ [`not a usable git repository: ${status.error}`],
5177
+ [`not a usable git repository: ${status.error}`],
5178
+ {
5179
+ ...status
5180
+ }
5181
+ );
5182
+ }
5183
+ describeCheckout(status, lines, problems);
5184
+ describeBaseBranch(status, lines, problems);
5185
+ if (status.fetchError !== null) {
5186
+ lines.push(`fetch failed: ${status.fetchError}`);
5187
+ problems.push(
5188
+ `\`git fetch\` failed (${status.fetchError}); the ahead/behind figures above are from the last successful fetch and may be out of date`
5189
+ );
5190
+ }
5191
+ return build("git", lines, problems, { ...status });
5192
+ }
5193
+ function assembleReport(input) {
5194
+ const byId = new Map(input.sections.map((section) => [section.id, section]));
5195
+ const ordered = SECTION_ORDER.flatMap((id) => {
5196
+ const section = byId.get(id);
5197
+ return section === void 0 ? [] : [section];
5198
+ });
5199
+ const problems = ordered.flatMap((section) => section.problems);
5200
+ return {
5201
+ generatedAt: input.generatedAt,
5202
+ repo: input.repo,
5203
+ offline: input.offline,
5204
+ sections: ordered,
5205
+ problems,
5206
+ exitCode: problems.length === 0 ? 0 : 1
5207
+ };
5208
+ }
5209
+ function renderText(report) {
5210
+ const head = `automata do-work --check \u2014 ${report.repo ?? "unknown repository"} \u2014 ${report.generatedAt.toISOString()}`;
5211
+ const parts = [head, ""];
5212
+ for (const section of report.sections) {
5213
+ parts.push(section.title);
5214
+ if (section.lines.length === 0) {
5215
+ parts.push(" (nothing to report)");
5216
+ } else {
5217
+ for (const line of section.lines) parts.push(` ${line}`);
5218
+ }
5219
+ parts.push("");
5220
+ }
5221
+ if (report.problems.length > 0) {
5222
+ parts.push(`Problems (${String(report.problems.length)})`);
5223
+ for (const problem of report.problems) parts.push(` \xB7 ${problem.section}: ${problem.summary}`);
5224
+ parts.push("");
5225
+ }
5226
+ parts.push(
5227
+ report.problems.length === 0 ? "RESULT: healthy" : `RESULT: ${String(report.problems.length)} problem(s) found`
5228
+ );
5229
+ return parts.join("\n") + "\n";
5230
+ }
5231
+ function toJson(report) {
5232
+ const sections = {};
5233
+ for (const section of report.sections) {
5234
+ sections[section.id] = {
5235
+ title: section.title,
5236
+ lines: section.lines,
5237
+ problems: section.problems,
5238
+ data: section.data
5239
+ };
5240
+ }
5241
+ return {
5242
+ generatedAt: report.generatedAt.toISOString(),
5243
+ repo: report.repo,
5244
+ offline: report.offline,
5245
+ exitCode: report.exitCode,
5246
+ problems: report.problems,
5247
+ sections
5248
+ };
5249
+ }
4497
5250
 
4498
5251
  // src/commands/doWork.ts
4499
5252
  var inFlightMarker = null;
@@ -4528,55 +5281,89 @@ function fail(message) {
4528
5281
  }
4529
5282
  process.exit(1);
4530
5283
  }
5284
+ var SettingsError = class extends Error {
5285
+ };
5286
+ function failSettings(message) {
5287
+ throw new SettingsError(message);
5288
+ }
4531
5289
  function parsePositiveInt(value, label) {
4532
5290
  const trimmed2 = value.trim();
4533
5291
  if (!/^\d+$/.test(trimmed2)) {
4534
- fail(`${label} must be a positive integer (got "${value}").`);
5292
+ failSettings(`${label} must be a positive integer (got "${value}").`);
4535
5293
  }
4536
5294
  const parsed = Number(trimmed2);
4537
5295
  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
4538
- fail(`${label} must be a positive integer within the safe range (got "${value}").`);
5296
+ failSettings(`${label} must be a positive integer within the safe range (got "${value}").`);
4539
5297
  }
4540
5298
  return parsed;
4541
5299
  }
4542
- function resolveSettings(options) {
4543
- let config;
5300
+ function resolveSettingsResult(options, verifyIdentity) {
4544
5301
  try {
4545
- config = readConfig();
5302
+ return { ok: true, settings: buildSettings(options, verifyIdentity) };
4546
5303
  } catch (err) {
4547
- fail(err.message);
5304
+ if (err instanceof SettingsError) return { ok: false, error: err.message };
5305
+ throw err;
4548
5306
  }
5307
+ }
5308
+ function resolveSettings(options) {
5309
+ const result = resolveSettingsResult(options, options.dryRun !== true);
5310
+ if (!result.ok) fail(result.error);
5311
+ return result.settings;
5312
+ }
5313
+ function requireParticipants(config) {
4549
5314
  if (config.remoteType !== "gh") {
4550
- fail(
5315
+ failSettings(
4551
5316
  "do-work is only supported for GitHub remotes. Set it with `automata config set type gh`. Azure DevOps lacks the issue conversation APIs this needs \u2014 see docs/azdo-gap.md."
4552
5317
  );
4553
5318
  }
4554
5319
  if (!config.issueDiscoveryTechnique) {
4555
- fail("No issue discovery technique configured. Run `automata config set issue-discovery-technique <value>`.");
5320
+ failSettings("No issue discovery technique configured. Run `automata config set issue-discovery-technique <value>`.");
4556
5321
  }
4557
5322
  if (!config.issueDiscoveryValue) {
4558
- fail("No issue discovery value configured. Run `automata config set issue-discovery-value <value>`.");
5323
+ failSettings("No issue discovery value configured. Run `automata config set issue-discovery-value <value>`.");
4559
5324
  }
4560
5325
  const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
4561
5326
  if (allowedUsers.length === 0) {
4562
- fail("No allowed users configured. Run `automata config set allowed-users <user1,user2>`.");
5327
+ failSettings("No allowed users configured. Run `automata config set allowed-users <user1,user2>`.");
4563
5328
  }
4564
5329
  const agentUser = (config.agentUser ?? "").trim();
4565
5330
  if (agentUser.length === 0) {
4566
- fail("No agent user configured. Run `automata config set agent-user <login>`.");
5331
+ failSettings("No agent user configured. Run `automata config set agent-user <login>`.");
5332
+ }
5333
+ return {
5334
+ allowedUsers,
5335
+ agentUser,
5336
+ technique: config.issueDiscoveryTechnique,
5337
+ discoveryValue: config.issueDiscoveryValue
5338
+ };
5339
+ }
5340
+ function resolveWithOption(value) {
5341
+ if (value === void 0) return void 0;
5342
+ const requested = value.toLowerCase();
5343
+ if (requested !== "claude" && requested !== "codex") {
5344
+ failSettings(`--with must be 'claude' or 'codex', got '${value}'.`);
5345
+ }
5346
+ return requested;
5347
+ }
5348
+ function resolveEffort(value) {
5349
+ const result = normalizeEffortOption(value);
5350
+ if (!result.ok) failSettings(result.error);
5351
+ return result.value;
5352
+ }
5353
+ function buildSettings(options, verifyIdentity) {
5354
+ let config;
5355
+ try {
5356
+ config = readConfig();
5357
+ } catch (err) {
5358
+ failSettings(err.message);
4567
5359
  }
5360
+ const { allowedUsers, agentUser, technique, discoveryValue } = requireParticipants(config);
4568
5361
  validateDoWorkConfig(config.doWork);
4569
5362
  const doWork = config.doWork ?? {};
4570
- let withOption;
4571
- if (options.with !== void 0) {
4572
- const requested = options.with.toLowerCase();
4573
- if (requested !== "claude" && requested !== "codex") {
4574
- fail(`--with must be 'claude' or 'codex', got '${options.with}'.`);
4575
- }
4576
- withOption = requested;
4577
- }
4578
- if (options.dryRun !== true) {
4579
- checkAuthenticatedIdentity(agentUser, allowedUsers);
5363
+ const withOption = resolveWithOption(options.with);
5364
+ if (verifyIdentity) {
5365
+ const problem = describeIdentityProblem(agentUser, allowedUsers);
5366
+ if (problem !== null) failSettings(problem);
4580
5367
  }
4581
5368
  return {
4582
5369
  baseBranch: doWork.baseBranch ?? DEFAULT_DO_WORK.baseBranch,
@@ -4588,8 +5375,10 @@ function resolveSettings(options) {
4588
5375
  // Rejected here rather than per item: an empty `--effort` is an operator
4589
5376
  // mistake on this invocation, not a property of any one work item. The
4590
5377
  // configured per-executor defaults are trimmed inside `resolveExecution`,
4591
- // which is where the executor in use is finally known.
4592
- effortOption: resolveEffortOption(options.effort),
5378
+ // which is where the executor in use is finally known. Routed through
5379
+ // `failSettings` rather than `resolveEffortOption`, whose rejection exits the
5380
+ // process — which would take `--check` down before it printed a section.
5381
+ effortOption: resolveEffort(options.effort),
4593
5382
  configEfforts: doWork.effort,
4594
5383
  maxRuns: options.maxRuns !== void 0 ? parsePositiveInt(options.maxRuns, "--max-runs") : doWork.maxRunsPerTick ?? DEFAULT_DO_WORK.maxRunsPerTick,
4595
5384
  lockStaleMinutes: doWork.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes,
@@ -4600,30 +5389,22 @@ function resolveSettings(options) {
4600
5389
  "pr-work": doWork.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT,
4601
5390
  "pr-orphan": doWork.prompts?.prOrphan ?? DEFAULT_DO_WORK_PR_ORPHAN_PROMPT
4602
5391
  },
4603
- technique: config.issueDiscoveryTechnique,
4604
- discoveryValue: config.issueDiscoveryValue,
5392
+ technique,
5393
+ discoveryValue,
4605
5394
  onlyIssue: options.issue === void 0 ? void 0 : parsePositiveInt(options.issue, "--issue"),
4606
5395
  onlyPr: options.pr === void 0 ? void 0 : parsePositiveInt(options.pr, "--pr")
4607
5396
  };
4608
5397
  }
4609
- function checkAuthenticatedIdentity(agentUser, allowedUsers) {
5398
+ function describeIdentityProblem(agentUser, allowedUsers) {
4610
5399
  const login2 = getAuthenticatedLogin();
4611
5400
  if (login2 === null) {
4612
5401
  progress(
4613
5402
  `Warning: could not determine which account \`gh\` is authenticated as; assuming it is the agent (${agentUser}).
4614
5403
  `
4615
5404
  );
4616
- return;
4617
- }
4618
- if (login2.toLowerCase() === agentUser.toLowerCase()) return;
4619
- if (allowedUsers.some((user) => user.toLowerCase() === login2.toLowerCase())) {
4620
- fail(
4621
- `\`gh\` is authenticated as "${login2}", which is listed in allowedUsers. Everything do-work posts would be attributed to an account that is allowed to instruct the agent, so its own marker comment would look like a new instruction and each tick would answer the previous tick forever. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
4622
- );
5405
+ return null;
4623
5406
  }
4624
- fail(
4625
- `\`gh\` is authenticated as "${login2}" but agentUser is "${agentUser}". Comments posted under that identity are neither the agent's nor an authorized user's, so they are filtered out of the conversation: the answer boundary would never advance and the same message would start a run on every tick. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
4626
- );
5407
+ return identityProblemFor(login2, agentUser, allowedUsers);
4627
5408
  }
4628
5409
  function resolveItemExecution(item, settings) {
4629
5410
  const trigger = triggeringMessage(item);
@@ -4740,24 +5521,24 @@ function validateOptionalString(container, key, path) {
4740
5521
  const value = container[key];
4741
5522
  if (value === void 0 || value === null) return;
4742
5523
  if (typeof value !== "string" || value.trim().length === 0) {
4743
- fail(`${path} must be a non-empty string.`);
5524
+ failSettings(`${path} must be a non-empty string.`);
4744
5525
  }
4745
5526
  }
4746
5527
  function validateOptionalInt(container, key, path, min, hint) {
4747
5528
  const value = container[key];
4748
5529
  if (value === void 0 || value === null) return;
4749
5530
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
4750
- fail(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
5531
+ failSettings(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
4751
5532
  }
4752
5533
  }
4753
5534
  function validateDoWorkConfig(section) {
4754
5535
  if (section === void 0 || section === null) return;
4755
5536
  if (!isPlainObject(section)) {
4756
- fail(`doWork must be an object, got ${JSON.stringify(section)}.`);
5537
+ failSettings(`doWork must be an object, got ${JSON.stringify(section)}.`);
4757
5538
  }
4758
5539
  const executor = section["executor"];
4759
5540
  if (executor !== void 0 && executor !== "claude" && executor !== "codex") {
4760
- fail(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
5541
+ failSettings(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
4761
5542
  }
4762
5543
  validateOptionalString(section, "baseBranch", "doWork.baseBranch");
4763
5544
  validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
@@ -4771,20 +5552,20 @@ function validateProtectedBranches(value) {
4771
5552
  if (value === void 0 || value === null) return;
4772
5553
  const isNonEmptyString = (b) => typeof b === "string" && b.trim().length > 0;
4773
5554
  if (!Array.isArray(value) || !value.every(isNonEmptyString)) {
4774
- fail("doWork.protectedBranches must be an array of non-empty strings.");
5555
+ failSettings("doWork.protectedBranches must be an array of non-empty strings.");
4775
5556
  }
4776
5557
  }
4777
5558
  function validateSettingContainer(value, container, keys) {
4778
5559
  if (value === void 0 || value === null) return;
4779
5560
  if (!isPlainObject(value)) {
4780
- fail(`doWork.${container} must be an object, got ${JSON.stringify(value)}.`);
5561
+ failSettings(`doWork.${container} must be an object, got ${JSON.stringify(value)}.`);
4781
5562
  }
4782
5563
  for (const key of keys) {
4783
5564
  validateOptionalString(value, key, `doWork.${container}.${key}`);
4784
5565
  }
4785
5566
  for (const key of Object.keys(value)) {
4786
5567
  if (!keys.includes(key)) {
4787
- fail(`doWork.${container}.${key} is not a recognised setting; expected one of: ${keys.join(", ")}.`);
5568
+ failSettings(`doWork.${container}.${key} is not a recognised setting; expected one of: ${keys.join(", ")}.`);
4788
5569
  }
4789
5570
  }
4790
5571
  }
@@ -5319,8 +6100,25 @@ var doWorkCommand = new Command6("do-work").description(
5319
6100
  ).option("--limit <n>", "Maximum number of issues to fetch", "10").option("--max-runs <n>", "Maximum number of model runs this tick").option(
5320
6101
  "--dry-run",
5321
6102
  "Print the work plan, plus a summary and the exact command that would be launched for each item, and exit without changing anything"
6103
+ ).option(
6104
+ "--check",
6105
+ "Print a read-only health report for the loop \u2014 run lock, tick history, last work, repository state, per-candidate selection and environment \u2014 and exit 0 when it found no problem, 1 when it did"
6106
+ ).option(
6107
+ "--no-fetch",
6108
+ "With --check: make no network call at all \u2014 no `git fetch` and no `gh` query. Ahead/behind is reported from the last fetch and the selection section does not run"
5322
6109
  ).option("--json", "Emit the work plan and outcomes as JSON on stdout").option("--silent", "Suppress step-by-step Claude output; show only the final summary").action(async (options) => {
5323
6110
  const startedAt = Date.now();
6111
+ if (options.check === true) {
6112
+ if (options.dryRun === true) {
6113
+ process.stderr.write(
6114
+ "Error: --check and --dry-run are two different read-only reports; run one or the other.\n"
6115
+ );
6116
+ process.exit(1);
6117
+ }
6118
+ const exitCode2 = runCheck(options);
6119
+ if (exitCode2 !== 0) process.exit(exitCode2);
6120
+ return;
6121
+ }
5324
6122
  if (options.dryRun !== true) loggableInvocation = { startedAt };
5325
6123
  const settings = resolveSettings(options);
5326
6124
  if (options.dryRun === true) {
@@ -5374,6 +6172,213 @@ var doWorkCommand = new Command6("do-work").description(
5374
6172
  logTick(reports, exitCode, startedAt);
5375
6173
  if (exitCode !== 0) process.exit(exitCode);
5376
6174
  });
6175
+ function resolveRepoSlug() {
6176
+ try {
6177
+ const slug = getRepoSlug();
6178
+ return `${slug.owner}/${slug.repo}`;
6179
+ } catch {
6180
+ return null;
6181
+ }
6182
+ }
6183
+ function collectSelection(settings) {
6184
+ const issues = discoverIssues(settings);
6185
+ const linkMap = getOpenPrLinkMap();
6186
+ const policy = {
6187
+ baseBranch: settings.baseBranch,
6188
+ defaultBranch: linkMap.defaultBranch,
6189
+ protectedBranches: settings.protectedBranches
6190
+ };
6191
+ return skipDuplicateHeadBranches([
6192
+ ...issues.map((issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, policy)),
6193
+ ...discoverOrphanPrs(settings, linkMap).map(
6194
+ (candidate) => decideOrphanPrWork({ prSurface: getPrSurface(candidate.pr.number) }, settings.participants, policy)
6195
+ )
6196
+ ]);
6197
+ }
6198
+ function selectionSection(settings, offline) {
6199
+ const section = (lines2, problems, data) => ({
6200
+ id: "selection",
6201
+ title: sectionTitle("selection"),
6202
+ lines: lines2,
6203
+ problems: problems.map((summary) => ({ section: "selection", summary })),
6204
+ data
6205
+ });
6206
+ if (offline) {
6207
+ return section(
6208
+ ["not run: --no-fetch makes no network call, and the selection needs live GitHub data"],
6209
+ [],
6210
+ { ran: false, detail: "offline", plan: [] }
6211
+ );
6212
+ }
6213
+ if (settings === null) {
6214
+ return section(
6215
+ ["not run: the configuration could not be resolved \u2014 see Environment below"],
6216
+ [],
6217
+ { ran: false, detail: "configuration invalid", plan: [] }
6218
+ );
6219
+ }
6220
+ let decisions;
6221
+ try {
6222
+ decisions = collectSelection(settings);
6223
+ } catch (err) {
6224
+ const detail = err.message;
6225
+ return section(
6226
+ [`could not be computed: ${detail}`],
6227
+ [`the GitHub selection could not be computed: ${detail}`],
6228
+ { ran: false, detail, plan: [] }
6229
+ );
6230
+ }
6231
+ const work = decisions.filter((decision) => decision.kind === "work").length;
6232
+ const lines = [
6233
+ `${String(work)} of ${String(decisions.length)} candidate(s) would be picked up`,
6234
+ ...describePlan(decisions).trimEnd().split("\n")
6235
+ ];
6236
+ return section(lines, [], { ran: true, detail: null, plan: decisions.map(toPlanJson) });
6237
+ }
6238
+ function describeExecutor(settings, lines, problems) {
6239
+ const executor = settings.withOption ?? settings.configExecutor ?? "claude";
6240
+ const command = executor === "codex" ? "codex" : "claude";
6241
+ const resolvedPath = resolveCommand(command);
6242
+ const onPath = resolvedPath !== command;
6243
+ lines.push(`default executor: ${executor} (${onPath ? resolvedPath : "not found on PATH"})`);
6244
+ if (!onPath) {
6245
+ problems.push(
6246
+ `the \`${command}\` command is not on PATH, so every run this tick would fail. Under cron the PATH is not your login shell's \u2014 set it in the crontab or use an absolute path`
6247
+ );
6248
+ }
6249
+ return { executor, command, onPath };
6250
+ }
6251
+ function describeGitHubIdentity(options, settings, lines, problems) {
6252
+ if (options.fetch === false) {
6253
+ lines.push("`gh` authentication not checked: --no-fetch");
6254
+ return { ghAvailable: null, ghLogin: null, identityProblem: null };
6255
+ }
6256
+ let identity;
6257
+ try {
6258
+ identity = getAuthenticatedIdentity();
6259
+ } catch (err) {
6260
+ const detail = err.message;
6261
+ lines.push(`\`gh\` could not be queried: ${detail}`);
6262
+ problems.push(`\`gh\` could not be queried (${detail}); install it and run \`gh auth login\``);
6263
+ return { ghAvailable: false, ghLogin: null, identityProblem: null };
6264
+ }
6265
+ if (identity.kind === "unavailable") {
6266
+ lines.push(`\`gh\` could not name an account: ${identity.detail}`);
6267
+ problems.push(
6268
+ `\`gh api user\` failed (${identity.detail}), so \`gh\` is not authenticated here and every GitHub call a tick makes would fail; run \`gh auth login\`, or set \`GH_TOKEN\` in the scheduler's environment`
6269
+ );
6270
+ return { ghAvailable: false, ghLogin: null, identityProblem: null };
6271
+ }
6272
+ const ghLogin = identity.kind === "login" ? identity.login : null;
6273
+ lines.push(
6274
+ ghLogin === null ? "`gh` is available but its account could not be determined (an app installation token has none)" : `\`gh\` is authenticated as ${ghLogin}`
6275
+ );
6276
+ const identityProblem = identityProblemFor(
6277
+ ghLogin,
6278
+ settings.participants.agentUser,
6279
+ settings.participants.allowedUsers
6280
+ );
6281
+ if (identityProblem !== null) problems.push(identityProblem);
6282
+ return { ghAvailable: true, ghLogin, identityProblem };
6283
+ }
6284
+ function readRemoteType() {
6285
+ try {
6286
+ return readConfig().remoteType ?? null;
6287
+ } catch {
6288
+ return null;
6289
+ }
6290
+ }
6291
+ function environmentSection(options, resolved, repo) {
6292
+ const lines = [`automata ${version}`];
6293
+ const problems = [];
6294
+ const remoteType = readRemoteType();
6295
+ const data = {
6296
+ version,
6297
+ repo,
6298
+ remoteType,
6299
+ configValid: resolved.ok,
6300
+ configError: resolved.ok ? null : resolved.error
6301
+ };
6302
+ if (repo === null) {
6303
+ lines.push("repository slug could not be resolved from `origin`");
6304
+ problems.push(
6305
+ "the repository slug could not be resolved; `gh` needs an `origin` remote pointing at GitHub to read issues, and the operation logs cannot attribute their lines without it"
6306
+ );
6307
+ } else {
6308
+ lines.push(`repository ${repo}`);
6309
+ }
6310
+ if (!resolved.ok) {
6311
+ lines.push(`configuration is not usable: ${resolved.error}`);
6312
+ problems.push(`the configuration is not usable: ${resolved.error}`);
6313
+ return {
6314
+ id: "environment",
6315
+ title: sectionTitle("environment"),
6316
+ lines,
6317
+ problems: problems.map((summary) => ({ section: "environment", summary })),
6318
+ // `ghAvailable` is null rather than false: nothing was asked of `gh`,
6319
+ // which is not the same as having asked and been refused.
6320
+ data: { ...data, ghAvailable: null, ghLogin: null, identityProblem: null }
6321
+ };
6322
+ }
6323
+ const settings = resolved.settings;
6324
+ const runCap = settings.maxRuns === 0 ? "unlimited" : String(settings.maxRuns);
6325
+ lines.push(
6326
+ "configuration parses and validates",
6327
+ `discovery: ${settings.technique} = ${settings.discoveryValue}`,
6328
+ `base branch: ${settings.baseBranch}`,
6329
+ `run cap: ${runCap}; lock stale after ${String(settings.lockStaleMinutes)} minutes`
6330
+ );
6331
+ const { executor, command, onPath } = describeExecutor(settings, lines, problems);
6332
+ const { ghAvailable, ghLogin, identityProblem } = describeGitHubIdentity(
6333
+ options,
6334
+ settings,
6335
+ lines,
6336
+ problems
6337
+ );
6338
+ return {
6339
+ id: "environment",
6340
+ title: sectionTitle("environment"),
6341
+ lines,
6342
+ problems: problems.map((summary) => ({ section: "environment", summary })),
6343
+ data: {
6344
+ ...data,
6345
+ discovery: { technique: settings.technique, value: settings.discoveryValue },
6346
+ baseBranch: settings.baseBranch,
6347
+ maxRuns: settings.maxRuns,
6348
+ lockStaleMinutes: settings.lockStaleMinutes,
6349
+ executor,
6350
+ executorCommand: command,
6351
+ executorOnPath: onPath,
6352
+ ghAvailable,
6353
+ ghLogin,
6354
+ identityProblem
6355
+ }
6356
+ };
6357
+ }
6358
+ function runCheck(options) {
6359
+ const now = /* @__PURE__ */ new Date();
6360
+ const offline = options.fetch === false;
6361
+ const repo = resolveRepoSlug();
6362
+ const resolved = resolveSettingsResult(options, false);
6363
+ const settings = resolved.ok ? resolved.settings : null;
6364
+ const staleMinutes = settings?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
6365
+ const baseBranch = settings?.baseBranch ?? DEFAULT_DO_WORK.baseBranch;
6366
+ const report = assembleReport({
6367
+ generatedAt: now,
6368
+ repo,
6369
+ offline,
6370
+ sections: [
6371
+ lockSection(inspectRunLock(staleMinutes), staleMinutes),
6372
+ tickSection(readExecutionTicks({ repo, limit: TICK_HISTORY }), now),
6373
+ workSection(readWorkRecords({ repo, limit: WORK_HISTORY }), now),
6374
+ gitSection(inspectRepoStatus({ baseBranch, fetch: !offline })),
6375
+ selectionSection(settings, offline),
6376
+ environmentSection(options, resolved, repo)
6377
+ ]
6378
+ });
6379
+ out(options.json === true ? JSON.stringify(toJson(report), null, 2) + "\n" : renderText(report));
6380
+ return report.exitCode;
6381
+ }
5377
6382
  function toTickLogItem(report) {
5378
6383
  return {
5379
6384
  subject: reportLabel(report),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.8.0-develop.353",
3
+ "version": "0.8.0-develop.358",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "engines": {