automata-cli 0.8.0-develop.368 → 0.9.0-develop.382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  readConfig,
12
12
  readRawConfig,
13
13
  writeConfig
14
- } from "./chunk-MIPBYVK5.js";
14
+ } from "./chunk-42OV4UI4.js";
15
15
 
16
16
  // src/index.ts
17
17
  import { Command as Command7 } from "commander";
@@ -184,6 +184,20 @@ var configSetDoWorkLockStaleMinutes = new Command("do-work-lock-stale-minutes").
184
184
  process.stdout.write(`do-work lock staleness set to: ${String(minutes)} minutes
185
185
  `);
186
186
  });
187
+ var configSetDoWorkDumpOnBlock = new Command("do-work-dump-on-block").description(
188
+ "Set whether `do-work` prints the full health report when a tick exits having done nothing"
189
+ ).argument("<value>", `true or false (default: ${String(DEFAULT_DO_WORK.dumpOnBlock)})`).action((value) => {
190
+ const normalized = value.trim().toLowerCase();
191
+ if (normalized !== "true" && normalized !== "false") {
192
+ process.stderr.write(`Error: do-work-dump-on-block must be true or false (got "${value}").
193
+ `);
194
+ process.exit(1);
195
+ }
196
+ const dumpOnBlock = normalized === "true";
197
+ writeDoWork({ dumpOnBlock });
198
+ process.stdout.write(`do-work dump on block set to: ${String(dumpOnBlock)}
199
+ `);
200
+ });
187
201
  var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the turn instructions for a `do-work` turn kind (prompt text or a .md filename)").argument("<turn-kind>", `Turn kind: ${VALID_TURN_KINDS.join(", ")}`).argument("<value>", "Prompt text, or a plain .md filename inside .automata/").action((turnKind, value) => {
188
202
  if (!VALID_TURN_KINDS.includes(turnKind)) {
189
203
  process.stderr.write(
@@ -225,12 +239,12 @@ var configSetGitTrunkBranch = new Command("git-trunk-branch").description("Pin t
225
239
  process.stdout.write(`git trunk branch set to: ${branch}
226
240
  `);
227
241
  });
228
- var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt).addCommand(configSetGitTrunkBranch);
242
+ var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkDumpOnBlock).addCommand(configSetDoWorkPrompt).addCommand(configSetGitTrunkBranch);
229
243
  var configCommand = new Command("config").description("Configure automata settings").addCommand(configSet).action(async () => {
230
244
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
231
245
  import("ink"),
232
246
  import("react"),
233
- import("./ConfigWizard-GULICHML.js")
247
+ import("./ConfigWizard-T7N4CZFT.js")
234
248
  ]);
235
249
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
236
250
  await waitUntilExit();
@@ -291,6 +305,38 @@ function getPrInfo() {
291
305
  };
292
306
  }
293
307
 
308
+ // src/run/commandTrace.ts
309
+ var sink = null;
310
+ function startCommandTrace() {
311
+ sink = [];
312
+ }
313
+ function stopCommandTrace() {
314
+ sink = null;
315
+ }
316
+ function takeCommandTrace() {
317
+ const taken = sink;
318
+ sink = null;
319
+ return taken;
320
+ }
321
+ function recordCommand(command, args, startedAt, exitCode) {
322
+ if (sink === null) return;
323
+ sink.push({
324
+ command,
325
+ args: [...args],
326
+ // A clock that went backwards must not produce a negative duration in a
327
+ // report an operator is reading for anomalies.
328
+ durationMs: Math.max(0, Date.now() - startedAt),
329
+ exitCode
330
+ });
331
+ }
332
+ function describeTracedCommand(traced) {
333
+ const rendered = [traced.command, ...traced.args].join(" ");
334
+ const flat = rendered.replace(/\s+/g, " ").trim();
335
+ const shown = flat.length <= MAX_COMMAND_LENGTH ? flat : `${flat.slice(0, MAX_COMMAND_LENGTH)}\u2026`;
336
+ return `${shown} \u2014 ${String(traced.durationMs)}ms exit ${String(traced.exitCode)}`;
337
+ }
338
+ var MAX_COMMAND_LENGTH = 300;
339
+
294
340
  // src/git/trunkDetection.ts
295
341
  var TRUNK_CANDIDATES = ["main", "master"];
296
342
  var TRUNK_BRANCH_CONFIG_KEY = "git.trunkBranch";
@@ -331,11 +377,14 @@ Set it explicitly with: automata config set git-trunk-branch <branch>`;
331
377
 
332
378
  // src/git/gitService.ts
333
379
  function run2(cmd, args) {
380
+ const startedAt = Date.now();
334
381
  const result = spawnSync2(cmd, args, { encoding: "utf8" });
382
+ const status = result.status ?? 1;
383
+ recordCommand(cmd, args, startedAt, result.error ? -1 : status);
335
384
  return {
336
385
  stdout: result.stdout ?? "",
337
386
  stderr: result.stderr ?? "",
338
- status: result.status ?? 1
387
+ status
339
388
  };
340
389
  }
341
390
  function getCurrentBranch() {
@@ -1672,7 +1721,9 @@ import { Command as Command3 } from "commander";
1672
1721
  import { spawnSync as spawnSync3 } from "child_process";
1673
1722
  var GITHUB_ISSUE_COMMENT_URL_RE = /github\.com\/([^/]+\/[^/]+)\/issues\/\d+#issuecomment-(\d+)/;
1674
1723
  function run3(cmd, args) {
1724
+ const startedAt = Date.now();
1675
1725
  const result = spawnSync3(cmd, args, { encoding: "utf8" });
1726
+ recordCommand(cmd, args, startedAt, result.error ? -1 : result.status ?? 1);
1676
1727
  if (result.error) {
1677
1728
  const err = result.error;
1678
1729
  if (err.code === "ENOENT") {
@@ -1828,7 +1879,9 @@ function addCopilotReviewer(prNumber) {
1828
1879
  // src/github/ghWorkService.ts
1829
1880
  import { spawnSync as spawnSync4 } from "child_process";
1830
1881
  function run4(cmd, args) {
1882
+ const startedAt = Date.now();
1831
1883
  const result = spawnSync4(cmd, args, { encoding: "utf8" });
1884
+ recordCommand(cmd, args, startedAt, result.error ? -1 : result.status ?? 1);
1832
1885
  if (result.error) {
1833
1886
  const err = result.error;
1834
1887
  if (err.code === "ENOENT") {
@@ -3666,19 +3719,108 @@ function analyseAnswer(messages, p, marker, watermark) {
3666
3719
  }
3667
3720
 
3668
3721
  // src/run/runLock.ts
3669
- import { writeFileSync, readFileSync as readFileSync4, unlinkSync, mkdirSync, renameSync, linkSync, statSync as statSync2 } from "fs";
3722
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, mkdirSync, renameSync, linkSync, statSync as statSync2 } from "fs";
3670
3723
  import { randomUUID } from "crypto";
3671
3724
  import { hostname } from "os";
3725
+ import { join as join4 } from "path";
3726
+
3727
+ // src/run/heartbeat.ts
3728
+ import { unlinkSync, readFileSync as readFileSync4, writeFileSync } from "fs";
3672
3729
  import { join as join3 } from "path";
3730
+ var HEARTBEAT_DIR = ".automata";
3731
+ var HEARTBEAT_FILE = "automata-heartbeat.json";
3732
+ var HEARTBEAT_RELATIVE_PATH = `${HEARTBEAT_DIR}/${HEARTBEAT_FILE}`;
3733
+ var PHASES = ["pre-flight", "discovery", "item", "summary"];
3734
+ function isPhase(value) {
3735
+ return typeof value === "string" && PHASES.includes(value);
3736
+ }
3737
+ function heartbeatPath(cwd = process.cwd()) {
3738
+ return join3(cwd, HEARTBEAT_DIR, HEARTBEAT_FILE);
3739
+ }
3740
+ function buildHeartbeat(token, update, now = /* @__PURE__ */ new Date()) {
3741
+ return {
3742
+ token,
3743
+ updatedAt: now.toISOString(),
3744
+ phase: update.phase,
3745
+ item: update.item ?? null,
3746
+ executor: update.executor ?? null
3747
+ };
3748
+ }
3749
+ function formatHeartbeat(heartbeat) {
3750
+ return JSON.stringify(heartbeat, null, 2) + "\n";
3751
+ }
3752
+ function parseHeartbeat(content) {
3753
+ let parsed;
3754
+ try {
3755
+ parsed = JSON.parse(content);
3756
+ } catch {
3757
+ return null;
3758
+ }
3759
+ if (typeof parsed !== "object" || parsed === null) return null;
3760
+ const raw = parsed;
3761
+ if (typeof raw.token !== "string" || raw.token.length === 0) return null;
3762
+ if (typeof raw.updatedAt !== "string") return null;
3763
+ if (!isPhase(raw.phase)) return null;
3764
+ return {
3765
+ token: raw.token,
3766
+ updatedAt: raw.updatedAt,
3767
+ phase: raw.phase,
3768
+ item: parseItem(raw.item),
3769
+ executor: parseExecutor(raw.executor)
3770
+ };
3771
+ }
3772
+ function parseItem(value) {
3773
+ if (typeof value !== "object" || value === null) return null;
3774
+ const raw = value;
3775
+ if (typeof raw.index !== "number" || typeof raw.total !== "number") return null;
3776
+ if (typeof raw.subject !== "string") return null;
3777
+ return { index: raw.index, total: raw.total, subject: raw.subject };
3778
+ }
3779
+ function parseExecutor(value) {
3780
+ if (typeof value !== "object" || value === null) return null;
3781
+ const raw = value;
3782
+ if (typeof raw.command !== "string" || typeof raw.startedAt !== "string") return null;
3783
+ return { command: raw.command, startedAt: raw.startedAt };
3784
+ }
3785
+ function writeHeartbeat(token, update, cwd = process.cwd(), now = /* @__PURE__ */ new Date()) {
3786
+ try {
3787
+ writeFileSync(heartbeatPath(cwd), formatHeartbeat(buildHeartbeat(token, update, now)), "utf8");
3788
+ } catch {
3789
+ }
3790
+ }
3791
+ function clearHeartbeat(token, cwd = process.cwd()) {
3792
+ if (readHeartbeat(token, cwd) === null) return;
3793
+ try {
3794
+ unlinkSync(heartbeatPath(cwd));
3795
+ } catch {
3796
+ }
3797
+ }
3798
+ function readHeartbeat(token, cwd = process.cwd()) {
3799
+ let content;
3800
+ try {
3801
+ content = readFileSync4(heartbeatPath(cwd), "utf8");
3802
+ } catch {
3803
+ return null;
3804
+ }
3805
+ const heartbeat = parseHeartbeat(content);
3806
+ if (heartbeat?.token !== token) return null;
3807
+ return heartbeat;
3808
+ }
3809
+
3810
+ // src/run/runLock.ts
3673
3811
  var LOCK_DIR = ".automata";
3674
3812
  var LOCK_FILE = "automata.lock";
3675
3813
  var RUN_LOCK_RELATIVE_PATH = `${LOCK_DIR}/${LOCK_FILE}`;
3814
+ var AUTOMATA_OWN_PATHS = [
3815
+ RUN_LOCK_RELATIVE_PATH,
3816
+ HEARTBEAT_RELATIVE_PATH
3817
+ ];
3676
3818
  function lockPath() {
3677
- return join3(process.cwd(), LOCK_DIR, LOCK_FILE);
3819
+ return join4(process.cwd(), LOCK_DIR, LOCK_FILE);
3678
3820
  }
3679
3821
  function processStartedAt(pid) {
3680
3822
  try {
3681
- const stat = readFileSync4(`/proc/${String(pid)}/stat`, "utf8");
3823
+ const stat = readFileSync5(`/proc/${String(pid)}/stat`, "utf8");
3682
3824
  const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
3683
3825
  const field = afterComm.split(" ")[19];
3684
3826
  return field === void 0 || field.length === 0 ? null : field;
@@ -3696,7 +3838,7 @@ function isAlive(pid) {
3696
3838
  }
3697
3839
  function readOwnerError(path) {
3698
3840
  try {
3699
- readFileSync4(path, "utf8");
3841
+ readFileSync5(path, "utf8");
3700
3842
  return null;
3701
3843
  } catch (err) {
3702
3844
  return err.message;
@@ -3704,7 +3846,7 @@ function readOwnerError(path) {
3704
3846
  }
3705
3847
  function readOwner(path) {
3706
3848
  try {
3707
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
3849
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3708
3850
  if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "string") return null;
3709
3851
  return {
3710
3852
  pid: parsed.pid,
@@ -3712,7 +3854,8 @@ function readOwner(path) {
3712
3854
  host: parsed.host ?? "unknown",
3713
3855
  command: parsed.command ?? "unknown",
3714
3856
  token: parsed.token ?? "",
3715
- pidStartedAt: parsed.pidStartedAt
3857
+ pidStartedAt: parsed.pidStartedAt,
3858
+ cwd: parsed.cwd
3716
3859
  };
3717
3860
  } catch {
3718
3861
  return null;
@@ -3741,10 +3884,16 @@ function heldTooLong(owner, staleMinutes) {
3741
3884
  }
3742
3885
  function makeHandle(path, token) {
3743
3886
  let released = false;
3887
+ const cwd = process.cwd();
3744
3888
  return {
3889
+ heartbeat(update) {
3890
+ if (released) return;
3891
+ writeHeartbeat(token, update, cwd);
3892
+ },
3745
3893
  release() {
3746
3894
  if (released) return;
3747
3895
  released = true;
3896
+ clearHeartbeat(token, cwd);
3748
3897
  const current = readOwner(path);
3749
3898
  if (current !== null && current.token !== token) {
3750
3899
  return;
@@ -3758,7 +3907,7 @@ function makeHandle(path, token) {
3758
3907
  const owner = readOwner(takenAway);
3759
3908
  if (owner === null || owner.token === token) {
3760
3909
  try {
3761
- unlinkSync(takenAway);
3910
+ unlinkSync2(takenAway);
3762
3911
  } catch {
3763
3912
  }
3764
3913
  return;
@@ -3768,7 +3917,7 @@ function makeHandle(path, token) {
3768
3917
  } catch {
3769
3918
  }
3770
3919
  try {
3771
- unlinkSync(takenAway);
3920
+ unlinkSync2(takenAway);
3772
3921
  } catch {
3773
3922
  }
3774
3923
  }
@@ -3781,11 +3930,12 @@ function publishLock(path, command, token) {
3781
3930
  host: hostname(),
3782
3931
  command,
3783
3932
  token,
3784
- pidStartedAt: processStartedAt(process.pid) ?? void 0
3933
+ pidStartedAt: processStartedAt(process.pid) ?? void 0,
3934
+ cwd: process.cwd()
3785
3935
  };
3786
3936
  const staging = `${path}.staging.${token}`;
3787
3937
  try {
3788
- writeFileSync(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
3938
+ writeFileSync2(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
3789
3939
  } catch {
3790
3940
  return false;
3791
3941
  }
@@ -3797,7 +3947,7 @@ function publishLock(path, command, token) {
3797
3947
  return false;
3798
3948
  } finally {
3799
3949
  try {
3800
- unlinkSync(staging);
3950
+ unlinkSync2(staging);
3801
3951
  } catch {
3802
3952
  }
3803
3953
  }
@@ -3805,7 +3955,7 @@ function publishLock(path, command, token) {
3805
3955
  function acquireRunLock(command, staleMinutes) {
3806
3956
  const path = lockPath();
3807
3957
  const token = randomUUID();
3808
- mkdirSync(join3(process.cwd(), LOCK_DIR), { recursive: true });
3958
+ mkdirSync(join4(process.cwd(), LOCK_DIR), { recursive: true });
3809
3959
  if (publishLock(path, command, token)) {
3810
3960
  return { ok: true, handle: makeHandle(path, token) };
3811
3961
  }
@@ -3840,7 +3990,7 @@ function claimStaleLock(path, token, expected) {
3840
3990
  } catch {
3841
3991
  }
3842
3992
  try {
3843
- unlinkSync(claimed);
3993
+ unlinkSync2(claimed);
3844
3994
  } catch {
3845
3995
  }
3846
3996
  return false;
@@ -3852,7 +4002,7 @@ var CLAIM_STALE_MS = 6e4;
3852
4002
  function acquireClaim(path) {
3853
4003
  const claimPath = `${path}.claim`;
3854
4004
  try {
3855
- writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
4005
+ writeFileSync2(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3856
4006
  encoding: "utf8",
3857
4007
  flag: "wx"
3858
4008
  });
@@ -3867,11 +4017,11 @@ function acquireClaim(path) {
3867
4017
  return null;
3868
4018
  }
3869
4019
  try {
3870
- unlinkSync(`${claimPath}.abandoned.${String(process.pid)}`);
4020
+ unlinkSync2(`${claimPath}.abandoned.${String(process.pid)}`);
3871
4021
  } catch {
3872
4022
  }
3873
4023
  try {
3874
- writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
4024
+ writeFileSync2(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3875
4025
  encoding: "utf8",
3876
4026
  flag: "wx"
3877
4027
  });
@@ -3883,7 +4033,7 @@ function acquireClaim(path) {
3883
4033
  function claimIsAbandoned(claimPath) {
3884
4034
  let at = Number.NaN;
3885
4035
  try {
3886
- const raw = JSON.parse(readFileSync4(claimPath, "utf8"));
4036
+ const raw = JSON.parse(readFileSync5(claimPath, "utf8"));
3887
4037
  if (raw.at !== void 0) at = Date.parse(raw.at);
3888
4038
  } catch {
3889
4039
  }
@@ -3915,13 +4065,13 @@ function reclaim(path, command, token, expected) {
3915
4065
  return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3916
4066
  } finally {
3917
4067
  try {
3918
- unlinkSync(claimed);
4068
+ unlinkSync2(claimed);
3919
4069
  } catch {
3920
4070
  }
3921
4071
  }
3922
4072
  } finally {
3923
4073
  try {
3924
- unlinkSync(claimPath);
4074
+ unlinkSync2(claimPath);
3925
4075
  } catch {
3926
4076
  }
3927
4077
  }
@@ -3946,7 +4096,12 @@ function inspectRunLock(staleMinutes, now = Date.now()) {
3946
4096
  }
3947
4097
  const held = owner;
3948
4098
  const kind = heldTooLong(held, staleMinutes) ? "suspect" : "held";
3949
- return { kind, owner: held, heldForMs: heldForMs(held, now) };
4099
+ return {
4100
+ kind,
4101
+ owner: held,
4102
+ heldForMs: heldForMs(held, now),
4103
+ heartbeat: readHeartbeat(held.token, held.cwd ?? process.cwd())
4104
+ };
3950
4105
  }
3951
4106
 
3952
4107
  // src/git/workspaceService.ts
@@ -3958,7 +4113,7 @@ function dirtyTree() {
3958
4113
  };
3959
4114
  }
3960
4115
  function prepareBaseBranch(baseBranch) {
3961
- if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
4116
+ if (hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) return dirtyTree();
3962
4117
  const checkout = checkoutBranch(baseBranch);
3963
4118
  if (!checkout.ok) {
3964
4119
  return { ok: false, reason: "checkout-failed", detail: checkout.stderr };
@@ -4054,7 +4209,7 @@ function divergenceRefusal(headRefName, pullError) {
4054
4209
  };
4055
4210
  }
4056
4211
  function preparePrBranch(headRefName) {
4057
- if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
4212
+ if (hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) return dirtyTree();
4058
4213
  const previousRemoteSha = revParse(`refs/remotes/origin/${headRefName}`);
4059
4214
  const fetched = fetchBranch(headRefName);
4060
4215
  if (!fetched.ok) {
@@ -4130,7 +4285,7 @@ function findOpenPr(branch) {
4130
4285
  return prs.find((pr) => pr.state === "OPEN") ?? null;
4131
4286
  }
4132
4287
  function rescueUncommittedChanges(options, now) {
4133
- if (!hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) {
4288
+ if (!hasUncommittedChanges([...AUTOMATA_OWN_PATHS])) {
4134
4289
  options.log(" rescue nothing to do; the working tree is clean\n");
4135
4290
  return { kind: "clean" };
4136
4291
  }
@@ -4143,7 +4298,7 @@ function rescueUncommittedChanges(options, now) {
4143
4298
  );
4144
4299
  return { kind: "would-rescue", branch: target.branch, createdBranch: target.createdBranch };
4145
4300
  }
4146
- const staged = stageAllExcept([RUN_LOCK_RELATIVE_PATH]);
4301
+ const staged = stageAllExcept([...AUTOMATA_OWN_PATHS]);
4147
4302
  if (!staged.ok) {
4148
4303
  options.log(` rescue FAILED to stage the changes: ${staged.stderr}
4149
4304
  `);
@@ -4422,13 +4577,13 @@ import {
4422
4577
  accessSync as accessSync2,
4423
4578
  appendFileSync,
4424
4579
  constants as constants2,
4425
- readFileSync as readFileSync5,
4580
+ readFileSync as readFileSync6,
4426
4581
  renameSync as renameSync2,
4427
- unlinkSync as unlinkSync2,
4428
- writeFileSync as writeFileSync2
4582
+ unlinkSync as unlinkSync3,
4583
+ writeFileSync as writeFileSync3
4429
4584
  } from "fs";
4430
4585
  import { randomUUID as randomUUID2 } from "crypto";
4431
- import { dirname as dirname2, join as join4 } from "path";
4586
+ import { dirname as dirname2, join as join5 } from "path";
4432
4587
  var EXECUTION_LOG_FILE = "automata-execution.log";
4433
4588
  var WORK_LOG_FILE = "automata-work.log";
4434
4589
  var MAX_EXECUTION_LINES = 1e3;
@@ -4444,6 +4599,14 @@ var OUTCOMES = [
4444
4599
  function operationLogDirectory() {
4445
4600
  return dirname2(process.cwd());
4446
4601
  }
4602
+ function inspectLogDirectory(dir = operationLogDirectory(), cwd = process.cwd()) {
4603
+ try {
4604
+ accessSync2(dir, constants2.W_OK);
4605
+ return { dir, cwd, writable: true, detail: null };
4606
+ } catch (err) {
4607
+ return { dir, cwd, writable: false, detail: err.message };
4608
+ }
4609
+ }
4447
4610
  function repoField(repo) {
4448
4611
  if (repo === null) return "-";
4449
4612
  const flat = oneLine(repo);
@@ -4550,18 +4713,18 @@ function pruneOldRecords(content, now, maxAgeMs) {
4550
4713
  }).map((group) => group.lines.join("\n") + "\n").join("");
4551
4714
  }
4552
4715
  function appendWithRetention(dir, file, content, retain) {
4553
- const target = join4(dir, file);
4716
+ const target = join5(dir, file);
4554
4717
  appendFileSync(target, content, "utf8");
4555
- const existing = readFileSync5(target, "utf8");
4718
+ const existing = readFileSync6(target, "utf8");
4556
4719
  const retained = retain(existing);
4557
4720
  if (retained === existing) return;
4558
4721
  const temp = `${target}.${randomUUID2()}.tmp`;
4559
4722
  try {
4560
- writeFileSync2(temp, retained, "utf8");
4723
+ writeFileSync3(temp, retained, "utf8");
4561
4724
  renameSync2(temp, target);
4562
4725
  } finally {
4563
4726
  try {
4564
- unlinkSync2(temp);
4727
+ unlinkSync3(temp);
4565
4728
  } catch {
4566
4729
  }
4567
4730
  }
@@ -4599,7 +4762,7 @@ function isOutcome(value) {
4599
4762
  }
4600
4763
  function readLogFile(path) {
4601
4764
  try {
4602
- return { content: readFileSync5(path, "utf8"), error: null };
4765
+ return { content: readFileSync6(path, "utf8"), error: null };
4603
4766
  } catch (err) {
4604
4767
  if (err.code === "ENOENT") return { content: null, error: null };
4605
4768
  return { content: null, error: err.message };
@@ -4665,7 +4828,7 @@ function parseExecutionLine(line) {
4665
4828
  };
4666
4829
  }
4667
4830
  function readExecutionTicks(options = {}) {
4668
- const path = join4(options.dir ?? operationLogDirectory(), EXECUTION_LOG_FILE);
4831
+ const path = join5(options.dir ?? operationLogDirectory(), EXECUTION_LOG_FILE);
4669
4832
  const { content, error } = readLogFile(path);
4670
4833
  if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4671
4834
  const entries = [];
@@ -4727,7 +4890,7 @@ function parseWorkItem(line) {
4727
4890
  };
4728
4891
  }
4729
4892
  function readWorkRecords(options = {}) {
4730
- const path = join4(options.dir ?? operationLogDirectory(), WORK_LOG_FILE);
4893
+ const path = join5(options.dir ?? operationLogDirectory(), WORK_LOG_FILE);
4731
4894
  const { content, error } = readLogFile(path);
4732
4895
  if (content === null) return emptyResult(path, error, isFiltered(options.repo));
4733
4896
  const entries = [];
@@ -4779,11 +4942,14 @@ function readWorkRecords(options = {}) {
4779
4942
  import { spawnSync as spawnSync7 } from "child_process";
4780
4943
  var GIT_BIN = resolveCommand("git");
4781
4944
  function git(args) {
4945
+ const startedAt = Date.now();
4782
4946
  const result = spawnSync7(GIT_BIN, args, { encoding: "utf8" });
4947
+ const status = result.status ?? 1;
4948
+ recordCommand(GIT_BIN, args, startedAt, result.error ? -1 : status);
4783
4949
  return {
4784
4950
  stdout: result.stdout ?? "",
4785
4951
  stderr: result.stderr ?? "",
4786
- status: result.status ?? 1
4952
+ status
4787
4953
  };
4788
4954
  }
4789
4955
  function notARepo(baseBranch, detail) {
@@ -4804,7 +4970,8 @@ function notARepo(baseBranch, detail) {
4804
4970
  };
4805
4971
  }
4806
4972
  function isOwnLockFile(porcelainEntry) {
4807
- return porcelainEntry.slice(3).trim().endsWith(RUN_LOCK_RELATIVE_PATH);
4973
+ const path = porcelainEntry.slice(3).trim();
4974
+ return AUTOMATA_OWN_PATHS.some((own) => path.endsWith(own));
4808
4975
  }
4809
4976
  function divergence(upstream, branch) {
4810
4977
  const { stdout, status } = git([
@@ -4890,6 +5057,9 @@ function inspectRepoStatus(options) {
4890
5057
  }
4891
5058
 
4892
5059
  // src/run/checkReport.ts
5060
+ function finding(summary, command) {
5061
+ return { summary, command };
5062
+ }
4893
5063
  var SECTION_ORDER = [
4894
5064
  "lock",
4895
5065
  "ticks",
@@ -4951,36 +5121,84 @@ function build(id, lines, problems, data) {
4951
5121
  id,
4952
5122
  title: SECTION_TITLES[id],
4953
5123
  lines,
4954
- problems: problems.map((summary) => ({ section: id, summary })),
5124
+ problems: problems.map((item) => ({ section: id, ...item })),
4955
5125
  data
4956
5126
  };
4957
5127
  }
4958
5128
  function describeOwner(owner) {
4959
5129
  return `pid ${String(owner.pid)} on ${owner.host}, \`${owner.command}\`, since ${owner.startedAt}`;
4960
5130
  }
4961
- function lockSection(status, staleMinutes) {
4962
- const data = { status: status.kind, staleMinutes };
5131
+ function describeOwnerLocation(owner, context, lines, problems) {
5132
+ if (owner.cwd === void 0) {
5133
+ lines.push(" working directory: not recorded (lock written by an older automata)");
5134
+ return;
5135
+ }
5136
+ lines.push(` working directory: ${owner.cwd}`);
5137
+ if (owner.cwd === context.cwd) return;
5138
+ const ownerLogs = context.parentOf(owner.cwd);
5139
+ lines.push(` logs to ${ownerLogs} (this check reads ${context.logDirectory})`);
5140
+ problems.push(
5141
+ finding(
5142
+ `the tick holding the lock runs from \`${owner.cwd}\` and writes its operation logs to \`${ownerLogs}\`, while this check reads \`${context.logDirectory}\` \u2014 the two will not agree about what has run; run the check from the directory the scheduler uses`,
5143
+ `ls -l ${ownerLogs}/automata-execution.log`
5144
+ )
5145
+ );
5146
+ }
5147
+ function describeHeartbeat(heartbeat, now, lines) {
5148
+ if (heartbeat === void 0 || heartbeat === null) {
5149
+ lines.push(" phase: not reported (no heartbeat from this holder)");
5150
+ return;
5151
+ }
5152
+ const updated = Date.parse(heartbeat.updatedAt);
5153
+ const age = Number.isNaN(updated) ? "unknown" : `${describeDuration(now.getTime() - updated)} ago`;
5154
+ const item = heartbeat.item === null ? "" : ` \u2014 item ${String(heartbeat.item.index)} of ${String(heartbeat.item.total)}, ${heartbeat.item.subject}`;
5155
+ lines.push(` phase: ${heartbeat.phase}${item} (updated ${age})`);
5156
+ if (heartbeat.executor === null) return;
5157
+ const started = Date.parse(heartbeat.executor.startedAt);
5158
+ const running = Number.isNaN(started) ? `since ${heartbeat.executor.startedAt}` : `running for ${describeDuration(now.getTime() - started)}`;
5159
+ lines.push(` executor: ${heartbeat.executor.command}, ${running}`);
5160
+ }
5161
+ function lockSection(status, staleMinutes, context, now = /* @__PURE__ */ new Date()) {
5162
+ const data = {
5163
+ status: status.kind,
5164
+ staleMinutes,
5165
+ cwd: context.cwd,
5166
+ logDirectory: context.logDirectory
5167
+ };
4963
5168
  switch (status.kind) {
4964
5169
  case "free":
4965
5170
  return build("lock", ["no tick is running in this checkout"], [], data);
4966
5171
  case "held": {
4967
5172
  const held = status.heldForMs === null ? "" : ` (${describeDuration(status.heldForMs)} so far)`;
4968
- return build("lock", [`a tick is running: ${describeOwner(status.owner)}${held}`], [], {
5173
+ const lines = [`a tick is running: ${describeOwner(status.owner)}${held}`];
5174
+ const problems = [];
5175
+ describeOwnerLocation(status.owner, context, lines, problems);
5176
+ describeHeartbeat(status.heartbeat, now, lines);
5177
+ return build("lock", lines, problems, {
4969
5178
  ...data,
4970
5179
  owner: status.owner,
4971
- heldForMs: status.heldForMs
5180
+ heldForMs: status.heldForMs,
5181
+ heartbeat: status.heartbeat ?? null
4972
5182
  });
4973
5183
  }
4974
5184
  case "suspect": {
4975
5185
  const held = status.heldForMs === null ? "unknown" : describeDuration(status.heldForMs);
4976
- return build(
4977
- "lock",
4978
- [`a tick has held the lock for ${held}: ${describeOwner(status.owner)}`],
4979
- [
4980
- `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\``
4981
- ],
4982
- { ...data, owner: status.owner, heldForMs: status.heldForMs }
5186
+ const lines = [`a tick has held the lock for ${held}: ${describeOwner(status.owner)}`];
5187
+ const problems = [];
5188
+ describeOwnerLocation(status.owner, context, lines, problems);
5189
+ describeHeartbeat(status.heartbeat, now, lines);
5190
+ problems.push(
5191
+ finding(
5192
+ `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\``,
5193
+ `ps -p ${String(status.owner.pid)} -o pid,lstart,args`
5194
+ )
4983
5195
  );
5196
+ return build("lock", lines, problems, {
5197
+ ...data,
5198
+ owner: status.owner,
5199
+ heldForMs: status.heldForMs,
5200
+ heartbeat: status.heartbeat ?? null
5201
+ });
4984
5202
  }
4985
5203
  case "stale":
4986
5204
  return build(
@@ -4989,7 +5207,10 @@ function lockSection(status, staleMinutes) {
4989
5207
  status.owner === null ? "a run lock exists but could not be parsed" : `a stale run lock is present: ${describeOwner(status.owner)}`
4990
5208
  ],
4991
5209
  [
4992
- "a stale run lock is present; the next tick reclaims it automatically, so no action is needed unless ticks keep being turned away"
5210
+ finding(
5211
+ "a stale run lock is present; the next tick reclaims it automatically, so no action is needed unless ticks keep being turned away",
5212
+ "cat .automata/automata.lock"
5213
+ )
4993
5214
  ],
4994
5215
  { ...data, owner: status.owner, heldForMs: status.heldForMs }
4995
5216
  );
@@ -4997,7 +5218,12 @@ function lockSection(status, staleMinutes) {
4997
5218
  return build(
4998
5219
  "lock",
4999
5220
  [`the run lock could not be read: ${status.detail}`],
5000
- [`the run lock at \`.automata/automata.lock\` could not be read: ${status.detail}`],
5221
+ [
5222
+ finding(
5223
+ `the run lock at \`.automata/automata.lock\` could not be read: ${status.detail}`,
5224
+ "ls -l .automata/automata.lock"
5225
+ )
5226
+ ],
5001
5227
  { ...data, detail: status.detail }
5002
5228
  );
5003
5229
  }
@@ -5019,28 +5245,56 @@ function describeMissingTicks(read) {
5019
5245
  if (read.error !== null) {
5020
5246
  return {
5021
5247
  line: `the execution log could not be read: ${read.error}`,
5022
- problem: `the execution log \`${read.path}\` could not be read: ${read.error}`
5248
+ problem: finding(
5249
+ `the execution log \`${read.path}\` could not be read: ${read.error}`,
5250
+ `ls -l ${read.path}`
5251
+ ),
5252
+ explainedByLiveTick: false
5023
5253
  };
5024
5254
  }
5025
5255
  if (!read.present) {
5026
5256
  return {
5027
5257
  line: `no execution log at ${read.path}`,
5028
- 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`
5258
+ problem: finding(
5259
+ `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`,
5260
+ `ls -la ${read.path}`
5261
+ ),
5262
+ explainedByLiveTick: true
5029
5263
  };
5030
5264
  }
5031
5265
  if (read.entries.length === 0) {
5032
5266
  return {
5033
5267
  line: "the execution log holds no tick for this repository",
5034
- problem: "the execution log holds no tick for this repository \u2014 the scheduler has never successfully run `do-work` here"
5268
+ problem: finding(
5269
+ "the execution log holds no tick for this repository \u2014 the scheduler has never successfully run `do-work` here",
5270
+ `tail -5 ${read.path}`
5271
+ ),
5272
+ explainedByLiveTick: true
5035
5273
  };
5036
5274
  }
5037
5275
  return null;
5038
5276
  }
5039
- function describeTickHistory(ticks, cadence, now, lines, problems) {
5277
+ function describeLogDirectory(directory, lines, problems) {
5278
+ const writable = directory.writable ? "writable" : `not writable: ${directory.detail ?? "unknown"}`;
5279
+ lines.push(
5280
+ `log directory: ${directory.dir} (the parent of the working directory ${directory.cwd}), ${writable}`
5281
+ );
5282
+ if (directory.writable) return;
5283
+ problems.push(
5284
+ finding(
5285
+ `the operation log directory \`${directory.dir}\` is not writable (${directory.detail ?? "unknown"}), so every tick records nothing and this report can only ever be blank; make it writable by the account the scheduler runs as`,
5286
+ `ls -ld ${directory.dir}`
5287
+ )
5288
+ );
5289
+ }
5290
+ function describeTickHistory(ticks, cadence, now, logPath, lines, problems) {
5040
5291
  lines.push(`last tick: ${describeTick(ticks[0], now)}`);
5041
5292
  if (ticks[0].exitCode !== 0) {
5042
5293
  problems.push(
5043
- `the last tick exited ${String(ticks[0].exitCode)} \u2014 see \`Last work\` below and the work log for the item that failed`
5294
+ finding(
5295
+ `the last tick exited ${String(ticks[0].exitCode)} \u2014 see \`Last work\` below and the work log for the item that failed`,
5296
+ "automata do-work --check --verbose"
5297
+ )
5044
5298
  );
5045
5299
  }
5046
5300
  const lockHeld = ticks.filter((tick) => tick.note === "lock-held").length;
@@ -5050,7 +5304,10 @@ function describeTickHistory(ticks, cadence, now, lines, problems) {
5050
5304
  );
5051
5305
  if (lockHeld === ticks.length && ticks.length > 1) {
5052
5306
  problems.push(
5053
- "every recorded tick was turned away by a held run lock \u2014 a previous tick is wedged; see `Run lock` above"
5307
+ finding(
5308
+ "every recorded tick was turned away by a held run lock \u2014 a previous tick is wedged; see `Run lock` above",
5309
+ "cat .automata/automata.lock"
5310
+ )
5054
5311
  );
5055
5312
  }
5056
5313
  if (cadence.medianIntervalMs === null) {
@@ -5060,24 +5317,38 @@ function describeTickHistory(ticks, cadence, now, lines, problems) {
5060
5317
  lines.push(`cadence: about one tick every ${describeDuration(cadence.medianIntervalMs)}`);
5061
5318
  if (cadence.silent) {
5062
5319
  problems.push(
5063
- `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)`
5320
+ finding(
5321
+ `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)`,
5322
+ // The log's own tail is the only thing automata can offer: the
5323
+ // scheduler belongs to the host, and naming a `crontab -l` here would
5324
+ // be a guess about how this installation is driven.
5325
+ `tail -5 ${logPath}`
5326
+ )
5064
5327
  );
5065
5328
  }
5066
5329
  }
5067
5330
  function unfilteredLine(unit) {
5068
5331
  return `not filtered by repository: the slug could not be resolved, so ${unit} from other checkouts may be shown`;
5069
5332
  }
5070
- function tickSection(read, now) {
5333
+ function tickSection(read, now, directory, lockStatus) {
5071
5334
  const lines = [];
5072
5335
  const problems = [];
5073
5336
  const ticks = read.entries;
5074
5337
  const cadence = tickCadence(ticks, now);
5338
+ const tickInFlight = lockStatus.kind === "held" || lockStatus.kind === "suspect";
5339
+ describeLogDirectory(directory, lines, problems);
5075
5340
  const missing = describeMissingTicks(read);
5076
5341
  if (missing !== null) {
5077
5342
  lines.push(missing.line);
5078
- problems.push(missing.problem);
5343
+ if (tickInFlight && missing.explainedByLiveTick) {
5344
+ lines.push(
5345
+ "a tick is running and has not recorded itself yet \u2014 the log is written when a tick ends, so this is the expected state for a first tick still in flight"
5346
+ );
5347
+ } else {
5348
+ problems.push(missing.problem);
5349
+ }
5079
5350
  } else {
5080
- describeTickHistory(ticks, cadence, now, lines, problems);
5351
+ describeTickHistory(ticks, cadence, now, read.path, lines, problems);
5081
5352
  }
5082
5353
  if (!read.filtered && read.present) lines.push(unfilteredLine("line(s)"));
5083
5354
  if (read.skipped > 0)
@@ -5095,7 +5366,9 @@ function tickSection(read, now) {
5095
5366
  otherRepos: read.otherRepos,
5096
5367
  filtered: read.filtered,
5097
5368
  logPath: read.path,
5098
- logPresent: read.present
5369
+ logPresent: read.present,
5370
+ logDirectory: directory,
5371
+ tickInFlight
5099
5372
  });
5100
5373
  }
5101
5374
  function describeWorkItem(item) {
@@ -5115,7 +5388,9 @@ function workSection(read, now) {
5115
5388
  const records = read.entries;
5116
5389
  if (read.error !== null) {
5117
5390
  lines.push(`the work log could not be read: ${read.error}`);
5118
- problems.push(`the work log \`${read.path}\` could not be read: ${read.error}`);
5391
+ problems.push(
5392
+ finding(`the work log \`${read.path}\` could not be read: ${read.error}`, `ls -l ${read.path}`)
5393
+ );
5119
5394
  } else if (!read.present || records.length === 0) {
5120
5395
  lines.push("no tick has invoked the executor in the retained window");
5121
5396
  } else {
@@ -5138,7 +5413,12 @@ function workSection(read, now) {
5138
5413
  function describeCheckout(status, lines, problems) {
5139
5414
  if (status.branch === null) {
5140
5415
  lines.push(`HEAD is detached at ${status.head ?? "an unknown commit"}`);
5141
- problems.push("HEAD is detached; the pre-flight expects a branch, so check one out");
5416
+ problems.push(
5417
+ finding(
5418
+ "HEAD is detached; the pre-flight expects a branch, so check one out",
5419
+ "git status --short --branch"
5420
+ )
5421
+ );
5142
5422
  } else {
5143
5423
  const at = status.head === null ? "" : ` at ${status.head}`;
5144
5424
  lines.push(`on ${status.branch}${at}`);
@@ -5146,7 +5426,10 @@ function describeCheckout(status, lines, problems) {
5146
5426
  if (status.statusError !== null) {
5147
5427
  lines.push(`the working tree could not be inspected: ${status.statusError}`);
5148
5428
  problems.push(
5149
- `\`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`
5429
+ finding(
5430
+ `\`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`,
5431
+ "git status --porcelain"
5432
+ )
5150
5433
  );
5151
5434
  return;
5152
5435
  }
@@ -5157,21 +5440,30 @@ function describeCheckout(status, lines, problems) {
5157
5440
  lines.push(`working tree has ${String(status.dirtyPaths.length)} uncommitted change(s):`);
5158
5441
  for (const path of status.dirtyPaths) lines.push(` ${path}`);
5159
5442
  problems.push(
5160
- `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`
5443
+ finding(
5444
+ `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`,
5445
+ "git status --porcelain"
5446
+ )
5161
5447
  );
5162
5448
  }
5163
5449
  function describeBaseBranch(status, lines, problems) {
5164
5450
  if (!status.baseLocal) {
5165
5451
  lines.push(`base branch ${status.baseBranch} does not exist in this checkout`);
5166
5452
  problems.push(
5167
- `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>\``
5453
+ finding(
5454
+ `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>\``,
5455
+ "git branch --list --all"
5456
+ )
5168
5457
  );
5169
5458
  return;
5170
5459
  }
5171
5460
  if (status.upstream === null) {
5172
5461
  lines.push(`base branch ${status.baseBranch} has no upstream`);
5173
5462
  problems.push(
5174
- `the base branch \`${status.baseBranch}\` has no upstream, so the pre-flight cannot fast-forward it`
5463
+ finding(
5464
+ `the base branch \`${status.baseBranch}\` has no upstream, so the pre-flight cannot fast-forward it`,
5465
+ `git branch -vv --list ${status.baseBranch}`
5466
+ )
5175
5467
  );
5176
5468
  return;
5177
5469
  }
@@ -5180,7 +5472,10 @@ function describeBaseBranch(status, lines, problems) {
5180
5472
  `base branch ${status.baseBranch} has no tracking configuration; counted against ${status.upstream}`
5181
5473
  );
5182
5474
  problems.push(
5183
- `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}\``
5475
+ finding(
5476
+ `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}\``,
5477
+ `git branch -vv --list ${status.baseBranch}`
5478
+ )
5184
5479
  );
5185
5480
  }
5186
5481
  const freshness = status.refreshed ? "" : " (not refreshed)";
@@ -5191,14 +5486,20 @@ function describeBaseBranch(status, lines, problems) {
5191
5486
  );
5192
5487
  if (status.ahead === null || status.behind === null) {
5193
5488
  problems.push(
5194
- `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`
5489
+ finding(
5490
+ `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`,
5491
+ `git rev-list --left-right --count ${status.upstream}...${status.baseBranch}`
5492
+ )
5195
5493
  );
5196
5494
  return;
5197
5495
  }
5198
5496
  if (status.ahead === 0) return;
5199
5497
  if (status.behind > 0) {
5200
5498
  problems.push(
5201
- `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`
5499
+ finding(
5500
+ `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`,
5501
+ `git log --oneline --left-right ${status.upstream}...${status.baseBranch}`
5502
+ )
5202
5503
  );
5203
5504
  return;
5204
5505
  }
@@ -5211,7 +5512,7 @@ function gitSection(status) {
5211
5512
  return build(
5212
5513
  "git",
5213
5514
  [`not a usable git repository: ${status.error}`],
5214
- [`not a usable git repository: ${status.error}`],
5515
+ [finding(`not a usable git repository: ${status.error}`, "git rev-parse --is-inside-work-tree")],
5215
5516
  {
5216
5517
  ...status
5217
5518
  }
@@ -5222,7 +5523,10 @@ function gitSection(status) {
5222
5523
  if (status.fetchError !== null) {
5223
5524
  lines.push(`fetch failed: ${status.fetchError}`);
5224
5525
  problems.push(
5225
- `\`git fetch\` failed (${status.fetchError}); the ahead/behind figures above are from the last successful fetch and may be out of date`
5526
+ finding(
5527
+ `\`git fetch\` failed (${status.fetchError}); the ahead/behind figures above are from the last successful fetch and may be out of date`,
5528
+ "git fetch origin"
5529
+ )
5226
5530
  );
5227
5531
  }
5228
5532
  return build("git", lines, problems, { ...status });
@@ -5240,26 +5544,38 @@ function assembleReport(input) {
5240
5544
  offline: input.offline,
5241
5545
  sections: ordered,
5242
5546
  problems,
5243
- exitCode: problems.length === 0 ? 0 : 1
5547
+ exitCode: problems.length === 0 ? 0 : 1,
5548
+ trace: input.trace ?? null,
5549
+ blocked: input.blocked ?? null
5244
5550
  };
5245
5551
  }
5552
+ function renderBlock(title, body, empty) {
5553
+ return [title, ...(body.length === 0 ? [empty] : body).map((line) => ` ${line}`), ""];
5554
+ }
5555
+ function renderTrace(trace) {
5556
+ return renderBlock(
5557
+ `Commands (${String(trace.length)})`,
5558
+ trace.map(describeTracedCommand),
5559
+ "(no git or gh command was run)"
5560
+ );
5561
+ }
5562
+ function renderProblems(problems) {
5563
+ const body = problems.flatMap((problem) => [
5564
+ `\xB7 ${problem.section}: ${problem.summary}`,
5565
+ // Indented under its problem rather than beside it: the command is a
5566
+ // follow-up to that one finding, not another finding.
5567
+ ...problem.command === null ? [] : [` try: ${problem.command}`]
5568
+ ]);
5569
+ return [`Problems (${String(problems.length)})`, ...body.map((line) => ` ${line}`), ""];
5570
+ }
5246
5571
  function renderText(report) {
5247
5572
  const head = `automata do-work --check \u2014 ${report.repo ?? "unknown repository"} \u2014 ${report.generatedAt.toISOString()}`;
5248
- const parts = [head, ""];
5573
+ const parts = report.blocked === null ? [head, ""] : [`blocked: ${report.blocked.trigger}`, head, ""];
5249
5574
  for (const section of report.sections) {
5250
- parts.push(section.title);
5251
- if (section.lines.length === 0) {
5252
- parts.push(" (nothing to report)");
5253
- } else {
5254
- for (const line of section.lines) parts.push(` ${line}`);
5255
- }
5256
- parts.push("");
5257
- }
5258
- if (report.problems.length > 0) {
5259
- parts.push(`Problems (${String(report.problems.length)})`);
5260
- for (const problem of report.problems) parts.push(` \xB7 ${problem.section}: ${problem.summary}`);
5261
- parts.push("");
5575
+ parts.push(...renderBlock(section.title, section.lines, "(nothing to report)"));
5262
5576
  }
5577
+ if (report.trace !== null) parts.push(...renderTrace(report.trace));
5578
+ if (report.problems.length > 0) parts.push(...renderProblems(report.problems));
5263
5579
  parts.push(
5264
5580
  report.problems.length === 0 ? "RESULT: healthy" : `RESULT: ${String(report.problems.length)} problem(s) found`
5265
5581
  );
@@ -5281,13 +5597,21 @@ function toJson(report) {
5281
5597
  offline: report.offline,
5282
5598
  exitCode: report.exitCode,
5283
5599
  problems: report.problems,
5284
- sections
5600
+ sections,
5601
+ trace: report.trace,
5602
+ blocked: report.blocked
5285
5603
  };
5286
5604
  }
5287
5605
 
5288
5606
  // src/commands/doWork.ts
5607
+ import { dirname as dirname3 } from "path";
5289
5608
  var inFlightMarker = null;
5290
5609
  var inFlightSync = null;
5610
+ var lockHandle = null;
5611
+ var heartbeatItem = null;
5612
+ function beat(update) {
5613
+ lockHandle?.heartbeat(update);
5614
+ }
5291
5615
  function subjectLabel(issue, pr) {
5292
5616
  if (issue !== null) return `#${String(issue)}`;
5293
5617
  return pr === null ? "#?" : `PR #${String(pr)}`;
@@ -5342,11 +5666,6 @@ function resolveSettingsResult(options, verifyIdentity) {
5342
5666
  throw err;
5343
5667
  }
5344
5668
  }
5345
- function resolveSettings(options) {
5346
- const result = resolveSettingsResult(options, options.dryRun !== true);
5347
- if (!result.ok) fail(result.error);
5348
- return result.settings;
5349
- }
5350
5669
  function requireParticipants(config) {
5351
5670
  if (config.remoteType !== "gh") {
5352
5671
  failSettings(
@@ -5419,6 +5738,7 @@ function buildSettings(options, verifyIdentity) {
5419
5738
  configEfforts: doWork.effort,
5420
5739
  maxRuns: options.maxRuns !== void 0 ? parsePositiveInt(options.maxRuns, "--max-runs") : doWork.maxRunsPerTick ?? DEFAULT_DO_WORK.maxRunsPerTick,
5421
5740
  lockStaleMinutes: doWork.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes,
5741
+ dumpOnBlock: doWork.dumpOnBlock ?? DEFAULT_DO_WORK.dumpOnBlock,
5422
5742
  limit: parsePositiveInt(options.limit, "--limit"),
5423
5743
  participants: { allowedUsers, agentUser },
5424
5744
  prompts: {
@@ -5580,11 +5900,19 @@ function validateDoWorkConfig(section) {
5580
5900
  validateOptionalString(section, "baseBranch", "doWork.baseBranch");
5581
5901
  validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
5582
5902
  validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
5903
+ validateOptionalBoolean(section, "dumpOnBlock", "doWork.dumpOnBlock");
5583
5904
  validateProtectedBranches(section["protectedBranches"]);
5584
5905
  validateSettingContainer(section["models"], "models", ["claude", "codex"]);
5585
5906
  validateSettingContainer(section["effort"], "effort", ["claude", "codex"]);
5586
5907
  validateSettingContainer(section["prompts"], "prompts", ["issueDiscuss", "prWork", "prOrphan"]);
5587
5908
  }
5909
+ function validateOptionalBoolean(container, key, path) {
5910
+ const value = container[key];
5911
+ if (value === void 0 || value === null) return;
5912
+ if (typeof value !== "boolean") {
5913
+ failSettings(`${path} must be true or false, got ${JSON.stringify(value)}.`);
5914
+ }
5915
+ }
5588
5916
  function validateProtectedBranches(value) {
5589
5917
  if (value === void 0 || value === null) return;
5590
5918
  const isNonEmptyString = (b) => typeof b === "string" && b.trim().length > 0;
@@ -5894,6 +6222,11 @@ function reportNoAnswer(item, marker, runError) {
5894
6222
  return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer", reason: "no-answer" } : { outcome: "failed", detail: `run failed: ${runError.message}`, reason: "no-answer" };
5895
6223
  }
5896
6224
  async function invokeExecutor(prompt, execution, silent) {
6225
+ beat({
6226
+ phase: "item",
6227
+ item: heartbeatItem,
6228
+ executor: { command: execution.executor, startedAt: (/* @__PURE__ */ new Date()).toISOString() }
6229
+ });
5897
6230
  if (execution.executor === "codex") {
5898
6231
  await runCodex(prompt, { model: execution.model, effort: execution.effort });
5899
6232
  return;
@@ -6143,37 +6476,82 @@ var doWorkCommand = new Command6("do-work").description(
6143
6476
  ).option(
6144
6477
  "--no-fetch",
6145
6478
  "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"
6479
+ ).option(
6480
+ "--verbose",
6481
+ "Show the work behind the report: every `git` and `gh` invocation with its duration and exit code, the discovery query as sent, and every candidate considered. Applies to --check and to a blocked-exit dump"
6146
6482
  ).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) => {
6483
+ const exitCode = await runDoWork(options);
6484
+ if (exitCode !== 0) process.exit(exitCode);
6485
+ });
6486
+ async function runDoWork(options) {
6487
+ try {
6488
+ return await runDoWorkTraced(options);
6489
+ } finally {
6490
+ stopCommandTrace();
6491
+ }
6492
+ }
6493
+ async function runDoWorkTraced(options) {
6147
6494
  const startedAt = Date.now();
6148
6495
  if (options.check === true) {
6149
6496
  if (options.dryRun === true) {
6150
6497
  process.stderr.write(
6151
6498
  "Error: --check and --dry-run are two different read-only reports; run one or the other.\n"
6152
6499
  );
6153
- process.exit(1);
6500
+ return 1;
6154
6501
  }
6155
- const exitCode2 = runCheck(options);
6156
- if (exitCode2 !== 0) process.exit(exitCode2);
6157
- return;
6502
+ return runCheck(options);
6158
6503
  }
6504
+ if (options.verbose === true) startCommandTrace();
6159
6505
  if (options.dryRun !== true) loggableInvocation = { startedAt };
6160
- const settings = resolveSettings(options);
6506
+ const resolved = resolveSettingsResult(options, options.dryRun !== true);
6507
+ if (!resolved.ok) {
6508
+ const payload = dumpBlocked(options, {
6509
+ reason: "config-invalid",
6510
+ trigger: `configuration is not usable \u2014 ${resolved.error}`,
6511
+ selection: null,
6512
+ resolved
6513
+ });
6514
+ if (payload !== null) out(JSON.stringify({ blocked: payload, exitCode: 1 }, null, 2) + "\n");
6515
+ fail(resolved.error);
6516
+ }
6517
+ const settings = resolved.settings;
6161
6518
  if (options.dryRun === true) {
6162
6519
  const { exitCode: exitCode2 } = await runTick(settings, options);
6163
- if (exitCode2 !== 0) process.exit(exitCode2);
6164
- return;
6520
+ return exitCode2;
6165
6521
  }
6166
6522
  loggableInvocation = null;
6167
6523
  const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
6168
6524
  if (!lock.ok) {
6169
- const exitCode2 = reportLockHeld(lock, settings, options);
6525
+ const exitCode2 = reportLockHeld(lock, settings, options, resolved);
6170
6526
  logTick([], exitCode2, startedAt, "lock-held");
6171
- if (exitCode2 !== 0) process.exit(exitCode2);
6172
- return;
6527
+ return exitCode2;
6173
6528
  }
6174
- const handle = lock.handle;
6529
+ const { exitCode, reports } = await runTickUnderLock(lock.handle, settings, options);
6530
+ logTick(reports, exitCode, startedAt);
6531
+ return exitCode;
6532
+ }
6533
+ async function runTickUnderLock(handle, settings, options) {
6534
+ lockHandle = handle;
6535
+ const onSignal = interruptHandler(handle);
6536
+ process.once("SIGINT", onSignal);
6537
+ process.once("SIGTERM", onSignal);
6538
+ try {
6539
+ return await runTick(settings, options);
6540
+ } catch (err) {
6541
+ process.stderr.write(`Error: ${err.message}
6542
+ `);
6543
+ return { exitCode: 1, reports: [] };
6544
+ } finally {
6545
+ lockHandle = null;
6546
+ heartbeatItem = null;
6547
+ handle.release();
6548
+ process.removeListener("SIGINT", onSignal);
6549
+ process.removeListener("SIGTERM", onSignal);
6550
+ }
6551
+ }
6552
+ function interruptHandler(handle) {
6175
6553
  let shuttingDown = false;
6176
- const onSignal = () => {
6554
+ return () => {
6177
6555
  if (shuttingDown) return;
6178
6556
  shuttingDown = true;
6179
6557
  progress("\nInterrupted: stopping the executor before releasing the run lock\u2026\n");
@@ -6189,26 +6567,7 @@ var doWorkCommand = new Command6("do-work").description(
6189
6567
  process.exit(130);
6190
6568
  });
6191
6569
  };
6192
- process.once("SIGINT", onSignal);
6193
- process.once("SIGTERM", onSignal);
6194
- let exitCode;
6195
- let reports = [];
6196
- try {
6197
- const result = await runTick(settings, options);
6198
- exitCode = result.exitCode;
6199
- reports = result.reports;
6200
- } catch (err) {
6201
- process.stderr.write(`Error: ${err.message}
6202
- `);
6203
- exitCode = 1;
6204
- } finally {
6205
- handle.release();
6206
- process.removeListener("SIGINT", onSignal);
6207
- process.removeListener("SIGTERM", onSignal);
6208
- }
6209
- logTick(reports, exitCode, startedAt);
6210
- if (exitCode !== 0) process.exit(exitCode);
6211
- });
6570
+ }
6212
6571
  function resolveRepoSlug() {
6213
6572
  try {
6214
6573
  const slug = getRepoSlug();
@@ -6217,7 +6576,24 @@ function resolveRepoSlug() {
6217
6576
  return null;
6218
6577
  }
6219
6578
  }
6220
- function collectSelection(settings) {
6579
+ function buildSelectionEvidence(settings, issues, linkMap, decisions, verbose) {
6580
+ const issuePass = issuePassEnabled(settings);
6581
+ const orphanPass = orphanPassEnabled(settings);
6582
+ return {
6583
+ decisions,
6584
+ issuePass,
6585
+ orphanPass,
6586
+ issues: verbose && issuePass ? issues : null,
6587
+ // Read off the link map rather than off `discoverOrphanPrs`, which returns
6588
+ // only the survivors — the whole point here is to show what was dropped.
6589
+ orphans: verbose && orphanPass ? linkMap.orphans.map((candidate) => ({
6590
+ number: candidate.pr.number,
6591
+ title: candidate.pr.title,
6592
+ matched: prMatchesFilter(candidate, settings)
6593
+ })) : null
6594
+ };
6595
+ }
6596
+ function collectSelection(settings, verbose) {
6221
6597
  const issues = discoverIssues(settings);
6222
6598
  const linkMap = getOpenPrLinkMap();
6223
6599
  const policy = {
@@ -6225,52 +6601,135 @@ function collectSelection(settings) {
6225
6601
  defaultBranch: linkMap.defaultBranch,
6226
6602
  protectedBranches: settings.protectedBranches
6227
6603
  };
6228
- return skipDuplicateHeadBranches([
6604
+ const decisions = skipDuplicateHeadBranches([
6229
6605
  ...issues.map((issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, policy)),
6230
6606
  ...discoverOrphanPrs(settings, linkMap).map(
6231
6607
  (candidate) => decideOrphanPrWork({ prSurface: getPrSurface(candidate.pr.number) }, settings.participants, policy)
6232
6608
  )
6233
6609
  ]);
6610
+ return buildSelectionEvidence(settings, issues, linkMap, decisions, verbose);
6234
6611
  }
6235
- function selectionSection(settings, offline) {
6236
- const section = (lines2, problems, data) => ({
6612
+ function makeSelectionSection(lines, problems, data) {
6613
+ return {
6237
6614
  id: "selection",
6238
6615
  title: sectionTitle("selection"),
6239
- lines: lines2,
6240
- problems: problems.map((summary) => ({ section: "selection", summary })),
6616
+ lines,
6617
+ problems: problems.map((problem) => ({ section: "selection", ...problem })),
6241
6618
  data
6242
- });
6619
+ };
6620
+ }
6621
+ function describeSelectionEvidence(settings, evidence, lines) {
6622
+ lines.push(
6623
+ ...describeIssueEvidence(settings, evidence),
6624
+ ...describeOrphanEvidence(settings, evidence)
6625
+ );
6626
+ }
6627
+ function heading(text, count) {
6628
+ return `${text}${count === 0 ? "" : ":"}`;
6629
+ }
6630
+ function describeIssueEvidence(settings, evidence) {
6631
+ if (!evidence.issuePass) {
6632
+ return [
6633
+ `issue discovery: not run \u2014 --pr ${String(settings.onlyPr)} restricts this tick to that pull request`
6634
+ ];
6635
+ }
6636
+ const restriction = settings.onlyIssue === void 0 ? "" : `, restricted to #${String(settings.onlyIssue)}`;
6637
+ const lines = [
6638
+ `discovery query: ${settings.technique} = ${settings.discoveryValue}, limit ${String(settings.limit)}${restriction}`
6639
+ ];
6640
+ if (evidence.issues === null) return lines;
6641
+ lines.push(heading(`discovery returned ${String(evidence.issues.length)} issue(s)`, evidence.issues.length));
6642
+ for (const issue of evidence.issues) {
6643
+ lines.push(` #${String(issue.number)} ${issue.title}`);
6644
+ }
6645
+ return lines;
6646
+ }
6647
+ function describeOrphanEvidence(settings, evidence) {
6648
+ if (!evidence.orphanPass) {
6649
+ return [
6650
+ `orphan pull-request pass: not run \u2014 --issue ${String(settings.onlyIssue)} restricts this tick to that issue`
6651
+ ];
6652
+ }
6653
+ if (evidence.orphans === null) return [];
6654
+ const restriction = settings.onlyPr === void 0 ? "" : ` (only PR #${String(settings.onlyPr)} would be taken up)`;
6655
+ const lines = [
6656
+ heading(
6657
+ `${String(evidence.orphans.length)} open orphan pull request(s) considered${restriction}`,
6658
+ evidence.orphans.length
6659
+ )
6660
+ ];
6661
+ for (const orphan of evidence.orphans) {
6662
+ const verdict = orphan.matched ? "matches the filter" : "dropped by the discovery filter";
6663
+ lines.push(` PR #${String(orphan.number)} ${orphan.title} \u2014 ${verdict}`);
6664
+ }
6665
+ return lines;
6666
+ }
6667
+ function describeSelectionDecisions(decisions) {
6668
+ const work = decisions.filter((decision) => decision.kind === "work").length;
6669
+ return [
6670
+ `${String(work)} of ${String(decisions.length)} candidate(s) would be picked up`,
6671
+ ...describePlan(decisions).trimEnd().split("\n")
6672
+ ];
6673
+ }
6674
+ function selectionSection(settings, offline, verbose) {
6243
6675
  if (offline) {
6244
- return section(
6676
+ return makeSelectionSection(
6245
6677
  ["not run: --no-fetch makes no network call, and the selection needs live GitHub data"],
6246
6678
  [],
6247
6679
  { ran: false, detail: "offline", plan: [] }
6248
6680
  );
6249
6681
  }
6250
6682
  if (settings === null) {
6251
- return section(
6683
+ return makeSelectionSection(
6252
6684
  ["not run: the configuration could not be resolved \u2014 see Environment below"],
6253
6685
  [],
6254
6686
  { ran: false, detail: "configuration invalid", plan: [] }
6255
6687
  );
6256
6688
  }
6257
- let decisions;
6689
+ let evidence;
6258
6690
  try {
6259
- decisions = collectSelection(settings);
6691
+ evidence = collectSelection(settings, verbose);
6260
6692
  } catch (err) {
6261
6693
  const detail = err.message;
6262
- return section(
6694
+ return makeSelectionSection(
6263
6695
  [`could not be computed: ${detail}`],
6264
- [`the GitHub selection could not be computed: ${detail}`],
6696
+ [
6697
+ {
6698
+ summary: `the GitHub selection could not be computed: ${detail}`,
6699
+ command: `gh issue list --limit ${String(settings.limit)}`
6700
+ }
6701
+ ],
6265
6702
  { ran: false, detail, plan: [] }
6266
6703
  );
6267
6704
  }
6268
- const work = decisions.filter((decision) => decision.kind === "work").length;
6269
- const lines = [
6270
- `${String(work)} of ${String(decisions.length)} candidate(s) would be picked up`,
6271
- ...describePlan(decisions).trimEnd().split("\n")
6272
- ];
6273
- return section(lines, [], { ran: true, detail: null, plan: decisions.map(toPlanJson) });
6705
+ const lines = [];
6706
+ if (verbose) describeSelectionEvidence(settings, evidence, lines);
6707
+ lines.push(...describeSelectionDecisions(evidence.decisions));
6708
+ return makeSelectionSection(lines, [], selectionData(evidence));
6709
+ }
6710
+ function selectionData(evidence) {
6711
+ return {
6712
+ ran: true,
6713
+ detail: null,
6714
+ plan: evidence.decisions.map(toPlanJson),
6715
+ issues: evidence.issues,
6716
+ orphans: evidence.orphans,
6717
+ issuePass: evidence.issuePass,
6718
+ orphanPass: evidence.orphanPass
6719
+ };
6720
+ }
6721
+ function selectionSectionFromEvidence(settings, evidence, verbose) {
6722
+ if (evidence === null) {
6723
+ return makeSelectionSection(
6724
+ ["not run: the tick was blocked before discovery"],
6725
+ [],
6726
+ { ran: false, detail: "blocked before discovery", plan: [] }
6727
+ );
6728
+ }
6729
+ const lines = [];
6730
+ if (verbose && settings !== null) describeSelectionEvidence(settings, evidence, lines);
6731
+ lines.push(...describeSelectionDecisions(evidence.decisions));
6732
+ return makeSelectionSection(lines, [], selectionData(evidence));
6274
6733
  }
6275
6734
  function describeExecutor(settings, lines, problems) {
6276
6735
  const executor = settings.withOption ?? settings.configExecutor ?? "claude";
@@ -6279,15 +6738,16 @@ function describeExecutor(settings, lines, problems) {
6279
6738
  const onPath = resolvedPath !== command;
6280
6739
  lines.push(`default executor: ${executor} (${onPath ? resolvedPath : "not found on PATH"})`);
6281
6740
  if (!onPath) {
6282
- problems.push(
6283
- `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`
6284
- );
6741
+ problems.push({
6742
+ summary: `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`,
6743
+ command: `command -v ${command}`
6744
+ });
6285
6745
  }
6286
6746
  return { executor, command, onPath };
6287
6747
  }
6288
- function describeGitHubIdentity(options, settings, lines, problems) {
6289
- if (options.fetch === false) {
6290
- lines.push("`gh` authentication not checked: --no-fetch");
6748
+ function describeGitHubIdentity(checkIdentity, settings, lines, problems, reason) {
6749
+ if (!checkIdentity) {
6750
+ lines.push(`\`gh\` authentication not checked: ${reason}`);
6291
6751
  return { ghAvailable: null, ghLogin: null, identityProblem: null };
6292
6752
  }
6293
6753
  let identity;
@@ -6296,14 +6756,18 @@ function describeGitHubIdentity(options, settings, lines, problems) {
6296
6756
  } catch (err) {
6297
6757
  const detail = err.message;
6298
6758
  lines.push(`\`gh\` could not be queried: ${detail}`);
6299
- problems.push(`\`gh\` could not be queried (${detail}); install it and run \`gh auth login\``);
6759
+ problems.push({
6760
+ summary: `\`gh\` could not be queried (${detail}); install it and run \`gh auth login\``,
6761
+ command: "gh auth status"
6762
+ });
6300
6763
  return { ghAvailable: false, ghLogin: null, identityProblem: null };
6301
6764
  }
6302
6765
  if (identity.kind === "unavailable") {
6303
6766
  lines.push(`\`gh\` could not name an account: ${identity.detail}`);
6304
- problems.push(
6305
- `\`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`
6306
- );
6767
+ problems.push({
6768
+ summary: `\`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`,
6769
+ command: "gh auth status"
6770
+ });
6307
6771
  return { ghAvailable: false, ghLogin: null, identityProblem: null };
6308
6772
  }
6309
6773
  const ghLogin = identity.kind === "login" ? identity.login : null;
@@ -6315,7 +6779,9 @@ function describeGitHubIdentity(options, settings, lines, problems) {
6315
6779
  settings.participants.agentUser,
6316
6780
  settings.participants.allowedUsers
6317
6781
  );
6318
- if (identityProblem !== null) problems.push(identityProblem);
6782
+ if (identityProblem !== null) {
6783
+ problems.push({ summary: identityProblem, command: "automata config get" });
6784
+ }
6319
6785
  return { ghAvailable: true, ghLogin, identityProblem };
6320
6786
  }
6321
6787
  function readRemoteType() {
@@ -6325,7 +6791,7 @@ function readRemoteType() {
6325
6791
  return null;
6326
6792
  }
6327
6793
  }
6328
- function environmentSection(options, resolved, repo) {
6794
+ function environmentSection(checkIdentity, identitySkipReason, resolved, repo) {
6329
6795
  const lines = [`automata ${version}`];
6330
6796
  const problems = [];
6331
6797
  const remoteType = readRemoteType();
@@ -6338,20 +6804,24 @@ function environmentSection(options, resolved, repo) {
6338
6804
  };
6339
6805
  if (repo === null) {
6340
6806
  lines.push("repository slug could not be resolved from `origin`");
6341
- problems.push(
6342
- "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"
6343
- );
6807
+ problems.push({
6808
+ summary: "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",
6809
+ command: "git remote -v"
6810
+ });
6344
6811
  } else {
6345
6812
  lines.push(`repository ${repo}`);
6346
6813
  }
6347
6814
  if (!resolved.ok) {
6348
6815
  lines.push(`configuration is not usable: ${resolved.error}`);
6349
- problems.push(`the configuration is not usable: ${resolved.error}`);
6816
+ problems.push({
6817
+ summary: `the configuration is not usable: ${resolved.error}`,
6818
+ command: "automata config get"
6819
+ });
6350
6820
  return {
6351
6821
  id: "environment",
6352
6822
  title: sectionTitle("environment"),
6353
6823
  lines,
6354
- problems: problems.map((summary) => ({ section: "environment", summary })),
6824
+ problems: problems.map((problem) => ({ section: "environment", ...problem })),
6355
6825
  // `ghAvailable` is null rather than false: nothing was asked of `gh`,
6356
6826
  // which is not the same as having asked and been refused.
6357
6827
  data: { ...data, ghAvailable: null, ghLogin: null, identityProblem: null }
@@ -6367,16 +6837,17 @@ function environmentSection(options, resolved, repo) {
6367
6837
  );
6368
6838
  const { executor, command, onPath } = describeExecutor(settings, lines, problems);
6369
6839
  const { ghAvailable, ghLogin, identityProblem } = describeGitHubIdentity(
6370
- options,
6840
+ checkIdentity,
6371
6841
  settings,
6372
6842
  lines,
6373
- problems
6843
+ problems,
6844
+ identitySkipReason
6374
6845
  );
6375
6846
  return {
6376
6847
  id: "environment",
6377
6848
  title: sectionTitle("environment"),
6378
6849
  lines,
6379
- problems: problems.map((summary) => ({ section: "environment", summary })),
6850
+ problems: problems.map((problem) => ({ section: "environment", ...problem })),
6380
6851
  data: {
6381
6852
  ...data,
6382
6853
  discovery: { technique: settings.technique, value: settings.discoveryValue },
@@ -6392,30 +6863,121 @@ function environmentSection(options, resolved, repo) {
6392
6863
  }
6393
6864
  };
6394
6865
  }
6866
+ function buildCheckReport(input) {
6867
+ const settings = input.resolved.ok ? input.resolved.settings : null;
6868
+ const staleMinutes = settings?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
6869
+ const baseBranch = settings?.baseBranch ?? DEFAULT_DO_WORK.baseBranch;
6870
+ const lockStatus = inspectRunLock(staleMinutes);
6871
+ const logDirectory = inspectLogDirectory();
6872
+ const lockContext = {
6873
+ cwd: logDirectory.cwd,
6874
+ logDirectory: logDirectory.dir,
6875
+ parentOf: dirname3
6876
+ };
6877
+ return assembleReport({
6878
+ generatedAt: input.now,
6879
+ repo: input.repo,
6880
+ offline: input.offline,
6881
+ blocked: input.blocked,
6882
+ sections: [
6883
+ lockSection(lockStatus, staleMinutes, lockContext, input.now),
6884
+ tickSection(
6885
+ readExecutionTicks({ repo: input.repo, limit: TICK_HISTORY }),
6886
+ input.now,
6887
+ logDirectory,
6888
+ lockStatus
6889
+ ),
6890
+ workSection(readWorkRecords({ repo: input.repo, limit: WORK_HISTORY }), input.now),
6891
+ gitSection(inspectRepoStatus({ baseBranch, fetch: input.fetch })),
6892
+ input.selection,
6893
+ environmentSection(input.checkIdentity, input.identitySkipReason, input.resolved, input.repo)
6894
+ ],
6895
+ // Taken last, so the trace covers every command the collectors above made.
6896
+ trace: takeCommandTrace()
6897
+ });
6898
+ }
6395
6899
  function runCheck(options) {
6900
+ const verbose = options.verbose === true;
6901
+ if (verbose) startCommandTrace();
6396
6902
  const now = /* @__PURE__ */ new Date();
6397
6903
  const offline = options.fetch === false;
6398
6904
  const repo = resolveRepoSlug();
6399
6905
  const resolved = resolveSettingsResult(options, false);
6400
6906
  const settings = resolved.ok ? resolved.settings : null;
6401
- const staleMinutes = settings?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
6402
- const baseBranch = settings?.baseBranch ?? DEFAULT_DO_WORK.baseBranch;
6403
- const report = assembleReport({
6404
- generatedAt: now,
6907
+ const report = buildCheckReport({
6908
+ now,
6405
6909
  repo,
6910
+ resolved,
6911
+ selection: selectionSection(settings, offline, verbose),
6912
+ fetch: !offline,
6913
+ checkIdentity: !offline,
6914
+ identitySkipReason: "--no-fetch",
6406
6915
  offline,
6407
- sections: [
6408
- lockSection(inspectRunLock(staleMinutes), staleMinutes),
6409
- tickSection(readExecutionTicks({ repo, limit: TICK_HISTORY }), now),
6410
- workSection(readWorkRecords({ repo, limit: WORK_HISTORY }), now),
6411
- gitSection(inspectRepoStatus({ baseBranch, fetch: !offline })),
6412
- selectionSection(settings, offline),
6413
- environmentSection(options, resolved, repo)
6414
- ]
6916
+ blocked: null
6415
6917
  });
6416
6918
  out(options.json === true ? JSON.stringify(toJson(report), null, 2) + "\n" : renderText(report));
6417
6919
  return report.exitCode;
6418
6920
  }
6921
+ function dumpOnBlockEnabled(resolved) {
6922
+ if (resolved.ok) return resolved.settings.dumpOnBlock;
6923
+ try {
6924
+ return readConfig().doWork?.dumpOnBlock ?? DEFAULT_DO_WORK.dumpOnBlock;
6925
+ } catch {
6926
+ return DEFAULT_DO_WORK.dumpOnBlock;
6927
+ }
6928
+ }
6929
+ function buildBlockedReport(context, verbose) {
6930
+ return buildCheckReport({
6931
+ now: /* @__PURE__ */ new Date(),
6932
+ repo: resolveRepoSlug(),
6933
+ resolved: context.resolved,
6934
+ selection: selectionSectionFromEvidence(
6935
+ context.resolved.ok ? context.resolved.settings : null,
6936
+ context.selection,
6937
+ verbose
6938
+ ),
6939
+ fetch: false,
6940
+ checkIdentity: false,
6941
+ identitySkipReason: "the tick is exiting and the dump makes no network call",
6942
+ offline: true,
6943
+ blocked: { reason: context.reason, trigger: context.trigger }
6944
+ });
6945
+ }
6946
+ function dumpBlocked(options, context) {
6947
+ if (!dumpOnBlockEnabled(context.resolved)) return null;
6948
+ try {
6949
+ const report = buildBlockedReport(context, options.verbose === true);
6950
+ if (options.json === true) {
6951
+ return { reason: context.reason, trigger: context.trigger, report: toJson(report) };
6952
+ }
6953
+ progress(renderText(report));
6954
+ return null;
6955
+ } catch (err) {
6956
+ progress(`Warning: could not render the blocked-exit report: ${err.message}
6957
+ `);
6958
+ return null;
6959
+ }
6960
+ }
6961
+ function classifyBlocked(hygiene, decisions, items, reports) {
6962
+ const ran = reports.some((report) => report.ranExecutor === true);
6963
+ if (ran) return null;
6964
+ if (hygiene.degraded) {
6965
+ return {
6966
+ reason: "preflight-failed",
6967
+ trigger: `the pre-flight did not prepare the checkout (${describePreflightFailures(hygiene).join("; ")})`
6968
+ };
6969
+ }
6970
+ if (items.length === 0) {
6971
+ return {
6972
+ reason: "no-candidates",
6973
+ trigger: `no candidate was picked up (0 of ${String(decisions.length)})`
6974
+ };
6975
+ }
6976
+ return {
6977
+ reason: "all-skipped",
6978
+ trigger: `${String(items.length)} item(s) selected, none reached the executor`
6979
+ };
6980
+ }
6419
6981
  function toTickLogItem(report) {
6420
6982
  return {
6421
6983
  subject: reportLabel(report),
@@ -6447,22 +7009,41 @@ function logTick(reports, exitCode, startedAt, note) {
6447
7009
  items: reports.map(toTickLogItem)
6448
7010
  });
6449
7011
  }
6450
- function reportLockHeld(lock, settings, options) {
7012
+ function reportLockHeld(lock, settings, options, resolved) {
6451
7013
  const held = lock.heldBy;
6452
7014
  const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
6453
7015
  `;
6454
7016
  const suspectSentence = lock.suspect ? `Warning: that lock has been held longer than ${String(settings.lockStaleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${RUN_LOCK_RELATIVE_PATH} once you have confirmed that.
6455
7017
  ` : "";
6456
7018
  const exitCode = lock.suspect ? 2 : 0;
7019
+ const context = {
7020
+ reason: "lock-held",
7021
+ trigger: `run lock held by pid ${String(held.pid)} on ${held.host}`,
7022
+ selection: null,
7023
+ resolved
7024
+ };
6457
7025
  if (options.json === true) {
6458
7026
  progress(sentence + suspectSentence);
6459
7027
  out(
6460
- JSON.stringify({ lockHeld: true, suspect: lock.suspect, heldBy: held, plan: [], items: [], exitCode }, null, 2) + "\n"
7028
+ JSON.stringify(
7029
+ {
7030
+ lockHeld: true,
7031
+ suspect: lock.suspect,
7032
+ heldBy: held,
7033
+ blocked: dumpBlocked(options, context),
7034
+ plan: [],
7035
+ items: [],
7036
+ exitCode
7037
+ },
7038
+ null,
7039
+ 2
7040
+ ) + "\n"
6461
7041
  );
6462
- } else {
6463
- out(sentence);
6464
- if (suspectSentence) progress(suspectSentence);
7042
+ return exitCode;
6465
7043
  }
7044
+ out(sentence);
7045
+ if (suspectSentence) progress(suspectSentence);
7046
+ dumpBlocked(options, context);
6466
7047
  return exitCode;
6467
7048
  }
6468
7049
  function explainInterruptedMarker() {
@@ -6479,6 +7060,7 @@ function explainInterruptedMarker() {
6479
7060
  }
6480
7061
  }
6481
7062
  async function runTick(settings, options) {
7063
+ beat({ phase: "pre-flight" });
6482
7064
  const hygiene = runRepoHygiene({
6483
7065
  baseBranch: settings.baseBranch,
6484
7066
  protectedBranches: settings.protectedBranches,
@@ -6486,6 +7068,7 @@ async function runTick(settings, options) {
6486
7068
  agentUser: settings.participants.agentUser,
6487
7069
  log: progress
6488
7070
  });
7071
+ beat({ phase: "discovery" });
6489
7072
  const issues = discoverIssues(settings);
6490
7073
  const linkMap = getOpenPrLinkMap();
6491
7074
  const policy = {
@@ -6511,49 +7094,85 @@ ${describePlan(decisions)}`;
6511
7094
  reportDryRun(items, decisions, settings, options, hygiene);
6512
7095
  return { exitCode: 0, reports: [] };
6513
7096
  }
7097
+ const { reports, deferred } = await processItems(items, settings, options, hygiene);
7098
+ heartbeatItem = null;
7099
+ beat({ phase: "summary" });
7100
+ reports.push(...reportDeferred(deferred, settings));
7101
+ const degraded = hygiene.degraded || reports.some((report) => report.outcome !== "answered");
7102
+ const exitCode = degraded ? 2 : 0;
7103
+ const blockedBy = classifyBlocked(hygiene, decisions, items, reports);
7104
+ const context = blockedBy === null ? null : {
7105
+ ...blockedBy,
7106
+ selection: buildSelectionEvidence(
7107
+ settings,
7108
+ issues,
7109
+ linkMap,
7110
+ decisions,
7111
+ options.verbose === true
7112
+ ),
7113
+ resolved: { ok: true, settings }
7114
+ };
7115
+ reportTickOutcome({ options, hygiene, decisions, reports, context, exitCode });
7116
+ return { exitCode, reports };
7117
+ }
7118
+ async function processItems(items, settings, options, hygiene) {
6514
7119
  const reports = [];
6515
7120
  const deferred = [];
6516
7121
  const preflightCauses = describePreflightFailures(hygiene);
6517
7122
  let runsUsed = 0;
6518
- for (const item of items) {
7123
+ for (const [index, item] of items.entries()) {
6519
7124
  if (settings.maxRuns > 0 && runsUsed >= settings.maxRuns) {
6520
7125
  deferred.push(item);
6521
7126
  continue;
6522
7127
  }
6523
- let report;
6524
- try {
6525
- report = await processItem(item, settings, options.silent === true, preflightCauses);
6526
- } catch (err) {
6527
- progress(` failed: ${err.message}
6528
- `);
6529
- report = {
6530
- issue: item.issue?.number ?? null,
6531
- pr: item.pr?.number ?? null,
6532
- title: itemTitle(item),
6533
- turn: item.turn,
6534
- outcome: "failed",
6535
- detail: err.message,
6536
- sync: inFlightSync ?? void 0
6537
- };
6538
- }
7128
+ heartbeatItem = { index: index + 1, total: items.length, subject: itemLabel(item).trim() };
7129
+ beat({ phase: "item", item: heartbeatItem });
7130
+ const report = await reportForItem(item, settings, options, preflightCauses);
6539
7131
  if (report.ranExecutor === true) runsUsed++;
6540
7132
  reports.push(report);
6541
7133
  }
6542
- for (const item of deferred) {
7134
+ return { reports, deferred };
7135
+ }
7136
+ async function reportForItem(item, settings, options, preflightCauses) {
7137
+ try {
7138
+ return await processItem(item, settings, options.silent === true, preflightCauses);
7139
+ } catch (err) {
7140
+ progress(` failed: ${err.message}
7141
+ `);
7142
+ return {
7143
+ issue: item.issue?.number ?? null,
7144
+ pr: item.pr?.number ?? null,
7145
+ title: itemTitle(item),
7146
+ turn: item.turn,
7147
+ outcome: "failed",
7148
+ detail: err.message,
7149
+ sync: inFlightSync ?? void 0
7150
+ };
7151
+ }
7152
+ }
7153
+ function reportDeferred(deferred, settings) {
7154
+ return deferred.map((item) => {
6543
7155
  progress(`
6544
7156
  ${itemLabel(item)} deferred: --max-runs / maxRunsPerTick reached.
6545
7157
  `);
6546
- reports.push({
7158
+ return {
6547
7159
  issue: item.issue?.number ?? null,
6548
7160
  pr: item.pr?.number ?? null,
6549
7161
  title: itemTitle(item),
6550
7162
  turn: item.turn,
6551
7163
  outcome: "deferred",
6552
7164
  detail: `run cap of ${String(settings.maxRuns)} reached`
6553
- });
6554
- }
6555
- const degraded = hygiene.degraded || reports.some((report) => report.outcome !== "answered");
6556
- const exitCode = degraded ? 2 : 0;
7165
+ };
7166
+ });
7167
+ }
7168
+ function reportTickOutcome({
7169
+ options,
7170
+ hygiene,
7171
+ decisions,
7172
+ reports,
7173
+ context,
7174
+ exitCode
7175
+ }) {
6557
7176
  if (options.json) {
6558
7177
  out(
6559
7178
  JSON.stringify(
@@ -6562,17 +7181,18 @@ ${itemLabel(item)} deferred: --max-runs / maxRunsPerTick reached.
6562
7181
  preflight: toHygieneJson(hygiene),
6563
7182
  plan: decisions.map(toPlanJson),
6564
7183
  items: reports.map(toItemJson),
7184
+ blocked: context === null ? null : dumpBlocked(options, context),
6565
7185
  exitCode
6566
7186
  },
6567
7187
  null,
6568
7188
  2
6569
7189
  ) + "\n"
6570
7190
  );
6571
- } else {
6572
- summarizeHygiene(hygiene);
6573
- summarize(reports);
7191
+ return;
6574
7192
  }
6575
- return { exitCode, reports };
7193
+ summarizeHygiene(hygiene);
7194
+ summarize(reports);
7195
+ if (context !== null) dumpBlocked(options, context);
6576
7196
  }
6577
7197
  function toRunJson(entry) {
6578
7198
  if (entry.kind === "refused") {