sequant 2.9.0 → 2.10.0

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 (188) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +14 -3
  4. package/dist/bin/cli.js +97 -17
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +56 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +20 -7
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +215 -390
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -1
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +1 -1
  28. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  29. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  31. package/dist/src/commands/assess-render.d.ts +23 -0
  32. package/dist/src/commands/assess-render.js +60 -0
  33. package/dist/src/commands/doctor.js +8 -13
  34. package/dist/src/commands/init.js +13 -1
  35. package/dist/src/commands/locks.js +2 -0
  36. package/dist/src/commands/logs.js +14 -5
  37. package/dist/src/commands/merge.js +154 -2
  38. package/dist/src/commands/ready-tui-adapter.js +6 -1
  39. package/dist/src/commands/ready.d.ts +25 -3
  40. package/dist/src/commands/ready.js +39 -13
  41. package/dist/src/commands/resume.d.ts +113 -0
  42. package/dist/src/commands/resume.js +193 -0
  43. package/dist/src/commands/run-display.js +37 -4
  44. package/dist/src/commands/run-flags.d.ts +42 -1
  45. package/dist/src/commands/run-flags.js +53 -1
  46. package/dist/src/commands/run-progress.js +26 -1
  47. package/dist/src/commands/run.js +11 -10
  48. package/dist/src/commands/state.js +7 -0
  49. package/dist/src/commands/status.d.ts +9 -0
  50. package/dist/src/commands/status.js +24 -1
  51. package/dist/src/commands/sync.js +90 -19
  52. package/dist/src/commands/update.js +28 -5
  53. package/dist/src/lib/ac-parser.d.ts +14 -0
  54. package/dist/src/lib/ac-parser.js +99 -5
  55. package/dist/src/lib/assess/index.d.ts +10 -0
  56. package/dist/src/lib/assess/index.js +9 -0
  57. package/dist/src/lib/assess/renderer.d.ts +64 -0
  58. package/dist/src/lib/assess/renderer.js +481 -0
  59. package/dist/src/lib/assess/types.d.ts +224 -0
  60. package/dist/src/lib/assess/types.js +241 -0
  61. package/dist/src/lib/ci/outputs.d.ts +15 -2
  62. package/dist/src/lib/ci/outputs.js +17 -5
  63. package/dist/src/lib/cli-flags.d.ts +33 -0
  64. package/dist/src/lib/cli-flags.js +46 -0
  65. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  66. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  67. package/dist/src/lib/cli-ui/run-renderer.js +62 -0
  68. package/dist/src/lib/errors.d.ts +78 -6
  69. package/dist/src/lib/errors.js +133 -15
  70. package/dist/src/lib/locks/index.d.ts +3 -3
  71. package/dist/src/lib/locks/index.js +2 -2
  72. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  73. package/dist/src/lib/locks/lock-manager.js +47 -3
  74. package/dist/src/lib/locks/types.d.ts +34 -3
  75. package/dist/src/lib/locks/types.js +16 -0
  76. package/dist/src/lib/mcp-config.d.ts +45 -0
  77. package/dist/src/lib/mcp-config.js +77 -1
  78. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  79. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  80. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  81. package/dist/src/lib/merge-check/command-result.js +56 -0
  82. package/dist/src/lib/merge-check/index.d.ts +4 -0
  83. package/dist/src/lib/merge-check/index.js +1 -1
  84. package/dist/src/lib/merge-check/report.js +5 -1
  85. package/dist/src/lib/merge-check/types.d.ts +9 -0
  86. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  87. package/dist/src/lib/merge-check/watch.js +215 -0
  88. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  89. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  90. package/dist/src/lib/settings.d.ts +14 -0
  91. package/dist/src/lib/settings.js +61 -8
  92. package/dist/src/lib/shutdown.d.ts +42 -2
  93. package/dist/src/lib/shutdown.js +75 -5
  94. package/dist/src/lib/skills-check.d.ts +26 -0
  95. package/dist/src/lib/skills-check.js +31 -0
  96. package/dist/src/lib/stacks.d.ts +110 -1
  97. package/dist/src/lib/stacks.js +244 -5
  98. package/dist/src/lib/templates.d.ts +74 -0
  99. package/dist/src/lib/templates.js +171 -16
  100. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  101. package/dist/src/lib/test-tautology-detector.js +153 -12
  102. package/dist/src/lib/version-check.d.ts +31 -0
  103. package/dist/src/lib/version-check.js +45 -2
  104. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  105. package/dist/src/lib/workflow/batch-executor.js +477 -44
  106. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  107. package/dist/src/lib/workflow/chain-resume.js +24 -20
  108. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  109. package/dist/src/lib/workflow/completed-status.js +57 -0
  110. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  111. package/dist/src/lib/workflow/config-resolver.js +54 -2
  112. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  113. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  114. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  115. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  116. package/dist/src/lib/workflow/drivers/claude-code.js +20 -2
  117. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  118. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  119. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  120. package/dist/src/lib/workflow/heartbeat.js +90 -0
  121. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  122. package/dist/src/lib/workflow/log-writer.js +75 -6
  123. package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
  124. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  125. package/dist/src/lib/workflow/phase-executor.js +457 -52
  126. package/dist/src/lib/workflow/phase-registry.js +2 -1
  127. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  128. package/dist/src/lib/workflow/platforms/github.js +152 -0
  129. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  130. package/dist/src/lib/workflow/qa-cache.js +22 -9
  131. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  132. package/dist/src/lib/workflow/ready-gate.js +25 -9
  133. package/dist/src/lib/workflow/run-log-schema.d.ts +14 -0
  134. package/dist/src/lib/workflow/run-log-schema.js +47 -1
  135. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  136. package/dist/src/lib/workflow/run-orchestrator.js +141 -20
  137. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  138. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  139. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  140. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  141. package/dist/src/lib/workflow/state-manager.js +83 -0
  142. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  143. package/dist/src/lib/workflow/state-schema.js +47 -0
  144. package/dist/src/lib/workflow/types.d.ts +126 -4
  145. package/dist/src/lib/workflow/types.js +1 -0
  146. package/dist/src/lib/workflow/worktree-manager.d.ts +28 -2
  147. package/dist/src/lib/workflow/worktree-manager.js +102 -23
  148. package/dist/src/mcp/tools/run.d.ts +9 -1
  149. package/dist/src/mcp/tools/run.js +20 -1
  150. package/dist/src/mcp/tools/status.js +6 -0
  151. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  152. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  153. package/dist/src/ui/tui/IssueBox.js +10 -2
  154. package/dist/src/ui/tui/row-cap.js +2 -1
  155. package/dist/src/ui/tui/theme.d.ts +7 -0
  156. package/dist/src/ui/tui/theme.js +9 -0
  157. package/package.json +8 -7
  158. package/templates/hooks/parallel-marker.sh +50 -0
  159. package/templates/hooks/post-tool.sh +20 -7
  160. package/templates/hooks/pre-tool.sh +75 -10
  161. package/templates/scripts/cleanup-worktree.sh +182 -19
  162. package/templates/scripts/new-feature.sh +264 -16
  163. package/templates/skills/_shared/references/trust-model.md +18 -0
  164. package/templates/skills/assess/SKILL.md +215 -390
  165. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  166. package/templates/skills/docs/SKILL.md +4 -4
  167. package/templates/skills/exec/SKILL.md +26 -6
  168. package/templates/skills/fullsolve/SKILL.md +3 -1
  169. package/templates/skills/loop/SKILL.md +38 -2
  170. package/templates/skills/merger/SKILL.md +2 -2
  171. package/templates/skills/qa/SKILL.md +337 -57
  172. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  173. package/templates/skills/qa/references/call-site-review.md +2 -2
  174. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  175. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  176. package/templates/skills/qa/references/quality-gates.md +21 -7
  177. package/templates/skills/qa/references/testing-requirements.md +1 -1
  178. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  179. package/templates/skills/release/SKILL.md +1 -1
  180. package/templates/skills/setup/SKILL.md +5 -5
  181. package/templates/skills/spec/SKILL.md +2 -0
  182. package/templates/skills/test/SKILL.md +1 -1
  183. package/dist/src/lib/phase-spinner.d.ts +0 -146
  184. package/dist/src/lib/phase-spinner.js +0 -255
  185. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  186. package/dist/src/lib/workflow/pr-operations.js +0 -326
  187. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  188. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -73,13 +73,73 @@ function parseNameStatus(output) {
73
73
  }
74
74
  return result;
75
75
  }
76
+ /**
77
+ * Resolve the ref diff stats should compare against (#878).
78
+ *
79
+ * Worktrees are created from `origin/<base>` (worktree-manager), but callers
80
+ * historically passed the bare branch name and the diff ran against the
81
+ * *local* ref. When local `<base>` lags the remote — routine, since nothing
82
+ * in the run path updates it — `<base>...HEAD` attributes commits the run
83
+ * never made (phantom filesModified).
84
+ *
85
+ * Candidates are the origin-qualified and bare forms of `baseBranch`
86
+ * (already-remote-qualified input keeps a single candidate). Among the
87
+ * candidates that resolve to a commit, pick the one nearest to HEAD
88
+ * (smallest `rev-list --count <cand>..HEAD`), preferring the
89
+ * origin-qualified form on a tie. Nearest-wins matches the worktree's
90
+ * actual creation point in both staleness directions: a stale local
91
+ * default branch (this issue) and a chain-mode worktree branched from a
92
+ * local base that is ahead of its pushed counterpart. Falls back to
93
+ * `baseBranch` verbatim when no candidate resolves (e.g. remote-less repo
94
+ * with a missing branch) — the diff then fails gracefully to empty, the
95
+ * pre-#878 behavior.
96
+ */
97
+ export function resolveDiffBase(worktreePath, baseBranch) {
98
+ const candidates = baseBranch.startsWith("origin/")
99
+ ? [baseBranch]
100
+ : [`origin/${baseBranch}`, baseBranch];
101
+ let best;
102
+ let bestCount = Number.POSITIVE_INFINITY;
103
+ for (const candidate of candidates) {
104
+ const verify = spawnSync("git", [
105
+ "-C",
106
+ worktreePath,
107
+ "rev-parse",
108
+ "--verify",
109
+ "--quiet",
110
+ `${candidate}^{commit}`,
111
+ ], { stdio: "pipe", encoding: "utf-8" });
112
+ if (verify.status !== 0)
113
+ continue;
114
+ const count = spawnSync("git", ["-C", worktreePath, "rev-list", "--count", `${candidate}..HEAD`], { stdio: "pipe", encoding: "utf-8" });
115
+ if (count.status !== 0)
116
+ continue;
117
+ const n = Number.parseInt(count.stdout.trim(), 10);
118
+ if (Number.isNaN(n))
119
+ continue;
120
+ // Strict < keeps the earlier (origin-qualified) candidate on a tie.
121
+ if (n < bestCount) {
122
+ best = candidate;
123
+ bestCount = n;
124
+ }
125
+ }
126
+ return best ?? baseBranch;
127
+ }
76
128
  /**
77
129
  * Get git commit SHA for a worktree (AC-2)
78
130
  *
131
+ * When `baseRef` is provided, returns undefined if HEAD has no commits
132
+ * unique to it (#878) — a branch that never moved off its base would
133
+ * otherwise log the base tip as if it were the phase's commit. Callers
134
+ * recording plain "where is HEAD" markers (run start/end) omit `baseRef`.
135
+ *
79
136
  * @param worktreePath - Path to the git worktree
80
- * @returns The current HEAD commit SHA, or undefined on error
137
+ * @param baseRef - Optional resolved base ref (see resolveDiffBase); when
138
+ * given, a HEAD with zero commits past it yields undefined
139
+ * @returns The current HEAD commit SHA, or undefined on error / no unique
140
+ * commits
81
141
  */
82
- export function getCommitHash(worktreePath) {
142
+ export function getCommitHash(worktreePath, baseRef) {
83
143
  const result = spawnSync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
84
144
  stdio: "pipe",
85
145
  encoding: "utf-8",
@@ -87,20 +147,33 @@ export function getCommitHash(worktreePath) {
87
147
  if (result.status !== 0) {
88
148
  return undefined;
89
149
  }
150
+ if (baseRef !== undefined) {
151
+ const count = spawnSync("git", ["-C", worktreePath, "rev-list", "--count", `${baseRef}..HEAD`], { stdio: "pipe", encoding: "utf-8" });
152
+ // Fail open on git errors: a transient failure should not erase a real
153
+ // commit hash from the log — only a confirmed zero suppresses it.
154
+ if (count.status === 0) {
155
+ const n = Number.parseInt(count.stdout.trim(), 10);
156
+ if (n === 0) {
157
+ return undefined;
158
+ }
159
+ }
160
+ }
90
161
  return result.stdout.trim();
91
162
  }
92
163
  /**
93
164
  * Get git diff statistics for a worktree (AC-1, AC-3, AC-4)
94
165
  *
95
166
  * Efficiently captures both filesModified and fileDiffStats using
96
- * minimal git commands. Uses main...HEAD comparison by default.
167
+ * minimal git commands. The base is resolved via resolveDiffBase (#878) so
168
+ * the comparison targets the ref the worktree was actually created from
169
+ * (origin/<base> in the common case) rather than a possibly-stale local ref.
97
170
  *
98
171
  * @param worktreePath - Path to the git worktree
99
172
  * @param baseBranch - Branch to compare against (default: "main")
100
173
  * @returns GitDiffStatsResult with files, stats, and totals
101
174
  */
102
175
  export function getGitDiffStats(worktreePath, baseBranch = "main") {
103
- const diffRef = `${baseBranch}...HEAD`;
176
+ const diffRef = `${resolveDiffBase(worktreePath, baseBranch)}...HEAD`;
104
177
  // Get numstat for additions/deletions
105
178
  const numstatResult = spawnSync("git", ["-C", worktreePath, "diff", "--numstat", diffRef], { stdio: "pipe", encoding: "utf-8" });
106
179
  // Get name-status for file status (added/modified/deleted/renamed)
@@ -1,4 +1,13 @@
1
1
  import type { ShutdownManager } from "../shutdown.js";
2
+ /**
3
+ * Cadence of the non-TTY auto-wait notice (#860 AC-6). Non-TTY output is
4
+ * append-only (no `\r` rewrite), so the TTY heartbeat's every-tick refresh
5
+ * would log ~600 lines over a five-hour wait. Five minutes keeps an unattended
6
+ * log legibly alive (~60 lines per five-hour window) without drowning it.
7
+ *
8
+ * @internal Exported for testing only
9
+ */
10
+ export declare const NON_TTY_WAIT_NOTICE_INTERVAL_MS: number;
2
11
  export interface LivenessHeartbeatOptions {
3
12
  /** Polling cadence for heartbeat ticks. Default: 30_000ms */
4
13
  pollIntervalMs?: number;
@@ -60,10 +69,39 @@ export declare class LivenessHeartbeat {
60
69
  * Dispose all tracked phases and clear the timer. Idempotent.
61
70
  */
62
71
  dispose(): void;
72
+ /**
73
+ * Mark a phase as auto-waiting for a rate-limit window to reopen (#804 AC-7).
74
+ *
75
+ * This is not cosmetic. The stall detector's activity proxy is the mtime of
76
+ * `.sequant/state.json`, and an auto-wait produces no writes by definition —
77
+ * so a multi-hour wait would otherwise trip the "no log activity" warning
78
+ * within minutes and report an entirely expected pause as a stall. Marking
79
+ * the phase both relabels the heartbeat line with the wake time and
80
+ * suppresses that false warning.
81
+ *
82
+ * No-op for an untracked phase, so an out-of-order or late notice cannot
83
+ * resurrect a stopped entry.
84
+ */
85
+ pauseForWait(key: PhaseKey, wakeAtMs: number): void;
86
+ /** Clear the auto-wait marker set by {@link pauseForWait} (#804). */
87
+ resumeFromWait(key: PhaseKey): void;
63
88
  /** Test hook: drive a poll synchronously without waiting on real timers. */
64
89
  tickNow(): void;
65
90
  private tick;
66
91
  private writeHeartbeat;
92
+ /**
93
+ * Heartbeat line for a phase paused on an auto-wait (#804). Names the wake
94
+ * time and the remaining wait so a multi-hour pause is legibly deliberate
95
+ * rather than indistinguishable from a hang.
96
+ */
97
+ private writeWaitHeartbeat;
98
+ /**
99
+ * Append-only auto-wait notice for non-TTY runs (#860 AC-6). Same facts as
100
+ * {@link writeWaitHeartbeat} — wake time and remaining wait — but a plain
101
+ * `\n`-terminated line with no cursor control, throttled by the caller to
102
+ * {@link NON_TTY_WAIT_NOTICE_INTERVAL_MS} so an unattended log stays legible.
103
+ */
104
+ private writeNonTtyWaitNotice;
67
105
  private writeStallWarning;
68
106
  }
69
107
  /** Convenience factory mirroring `phaseSpinner()`. */
@@ -12,10 +12,20 @@
12
12
  */
13
13
  import * as fs from "fs";
14
14
  import { formatElapsedTime } from "../cli-ui/format.js";
15
+ import { formatResetTime } from "../errors.js";
15
16
  const DEFAULT_POLL_INTERVAL_MS = 30_000;
16
17
  const DEFAULT_STALL_THRESHOLD_MS = 5 * 60_000;
17
18
  const DEFAULT_LIVENESS_FILE = ".sequant/state.json";
18
19
  const CLEANUP_NAME = "liveness-heartbeat";
20
+ /**
21
+ * Cadence of the non-TTY auto-wait notice (#860 AC-6). Non-TTY output is
22
+ * append-only (no `\r` rewrite), so the TTY heartbeat's every-tick refresh
23
+ * would log ~600 lines over a five-hour wait. Five minutes keeps an unattended
24
+ * log legibly alive (~60 lines per five-hour window) without drowning it.
25
+ *
26
+ * @internal Exported for testing only
27
+ */
28
+ export const NON_TTY_WAIT_NOTICE_INTERVAL_MS = 5 * 60_000;
19
29
  function keyFor(k) {
20
30
  return `${k.issueNumber}:${k.phase}`;
21
31
  }
@@ -118,6 +128,39 @@ export class LivenessHeartbeat {
118
128
  this.cleanupRegistered = false;
119
129
  }
120
130
  }
131
+ /**
132
+ * Mark a phase as auto-waiting for a rate-limit window to reopen (#804 AC-7).
133
+ *
134
+ * This is not cosmetic. The stall detector's activity proxy is the mtime of
135
+ * `.sequant/state.json`, and an auto-wait produces no writes by definition —
136
+ * so a multi-hour wait would otherwise trip the "no log activity" warning
137
+ * within minutes and report an entirely expected pause as a stall. Marking
138
+ * the phase both relabels the heartbeat line with the wake time and
139
+ * suppresses that false warning.
140
+ *
141
+ * No-op for an untracked phase, so an out-of-order or late notice cannot
142
+ * resurrect a stopped entry.
143
+ */
144
+ pauseForWait(key, wakeAtMs) {
145
+ const entry = this.phases.get(keyFor(key));
146
+ if (!entry)
147
+ return;
148
+ entry.waitingUntil = wakeAtMs;
149
+ // Clear any warning already fired so a genuine stall after the wait can
150
+ // still warn once.
151
+ entry.warningFired = false;
152
+ // #860 AC-6: a fresh wait announces itself on the next non-TTY tick.
153
+ entry.lastWaitNoticeAt = undefined;
154
+ }
155
+ /** Clear the auto-wait marker set by {@link pauseForWait} (#804). */
156
+ resumeFromWait(key) {
157
+ const entry = this.phases.get(keyFor(key));
158
+ if (!entry)
159
+ return;
160
+ entry.waitingUntil = undefined;
161
+ entry.warningFired = false;
162
+ entry.lastWaitNoticeAt = undefined;
163
+ }
121
164
  /** Test hook: drive a poll synchronously without waiting on real timers. */
122
165
  tickNow() {
123
166
  this.tick();
@@ -127,6 +170,23 @@ export class LivenessHeartbeat {
127
170
  return;
128
171
  const now = this.now();
129
172
  for (const entry of this.phases.values()) {
173
+ // #804: an auto-waiting phase has its own liveness story. Handled before
174
+ // the mtime read because the wait produces no file activity at all — the
175
+ // stall detector's proxy is meaningless here, and the `mtimeMs === null`
176
+ // early-continue below would otherwise leave the wait completely silent.
177
+ if (entry.waitingUntil !== undefined) {
178
+ if (this.tty) {
179
+ this.writeWaitHeartbeat(entry, Math.max(0, now - entry.startedAt));
180
+ }
181
+ else if (entry.lastWaitNoticeAt === undefined ||
182
+ now - entry.lastWaitNoticeAt >= NON_TTY_WAIT_NOTICE_INTERVAL_MS) {
183
+ // #860 AC-6: without this, a non-TTY wait emits nothing for hours —
184
+ // indistinguishable from the #856 hang. Append-only line, throttled.
185
+ this.writeNonTtyWaitNotice(entry);
186
+ entry.lastWaitNoticeAt = now;
187
+ }
188
+ continue;
189
+ }
130
190
  let mtimeMs;
131
191
  try {
132
192
  const stat = fs.statSync(this.livenessFile);
@@ -168,6 +228,36 @@ export class LivenessHeartbeat {
168
228
  const line = `\r ▸ #${entry.issueNumber} ${entry.phase} (${elapsed} elapsed, last log update ${sinceActivity} ago)`;
169
229
  this.stdoutWrite(line);
170
230
  }
231
+ /**
232
+ * Heartbeat line for a phase paused on an auto-wait (#804). Names the wake
233
+ * time and the remaining wait so a multi-hour pause is legibly deliberate
234
+ * rather than indistinguishable from a hang.
235
+ */
236
+ writeWaitHeartbeat(entry, elapsedSinceStartMs) {
237
+ if (this.stopped || entry.waitingUntil === undefined)
238
+ return;
239
+ const elapsed = formatElapsedTime(Math.floor(elapsedSinceStartMs / 1000));
240
+ const remainingMs = Math.max(0, entry.waitingUntil - this.now());
241
+ const remaining = formatElapsedTime(Math.floor(remainingMs / 1000));
242
+ const wake = formatResetTime(entry.waitingUntil);
243
+ const line = `\r ⏸ #${entry.issueNumber} ${entry.phase} (${elapsed} elapsed, rate-limit window — resuming at ${wake}, ${remaining} left)\x1b[K`;
244
+ this.stdoutWrite(line);
245
+ }
246
+ /**
247
+ * Append-only auto-wait notice for non-TTY runs (#860 AC-6). Same facts as
248
+ * {@link writeWaitHeartbeat} — wake time and remaining wait — but a plain
249
+ * `\n`-terminated line with no cursor control, throttled by the caller to
250
+ * {@link NON_TTY_WAIT_NOTICE_INTERVAL_MS} so an unattended log stays legible.
251
+ */
252
+ writeNonTtyWaitNotice(entry) {
253
+ if (this.stopped || entry.waitingUntil === undefined)
254
+ return;
255
+ const remainingMs = Math.max(0, entry.waitingUntil - this.now());
256
+ const remaining = formatElapsedTime(Math.floor(remainingMs / 1000));
257
+ const wake = formatResetTime(entry.waitingUntil);
258
+ const line = ` ⏸ #${entry.issueNumber} ${entry.phase} rate-limit window — resuming at ${wake} (${remaining} left)\n`;
259
+ this.stdoutWrite(line);
260
+ }
171
261
  writeStallWarning(entry, sinceActivityMs, elapsedSinceStartMs) {
172
262
  if (this.stopped)
173
263
  return;
@@ -26,6 +26,13 @@ export interface LogWriterOptions {
26
26
  rotation?: RotationSettings;
27
27
  /** Git commit SHA at run start (AC-2) */
28
28
  startCommit?: string;
29
+ /**
30
+ * Run start timestamp (#867). When provided, the log's `startTime` uses this
31
+ * origin instead of self-stamping `new Date()` at initialize(), so the log's
32
+ * stored wall clock and the orchestrator's summary derive from one shared
33
+ * start. Defaults to now when omitted.
34
+ */
35
+ startTime?: Date;
29
36
  }
30
37
  /**
31
38
  * Manages writing structured run logs to disk
@@ -36,11 +43,20 @@ export declare class LogWriter {
36
43
  private activeIssues;
37
44
  /** @deprecated Single-issue slot for backwards compatibility — use activeIssues */
38
45
  private currentIssue;
46
+ /**
47
+ * Issue slots forced to `failure` by {@link markIssueFailed} (#879). Keyed on
48
+ * the slot object so it covers both the numbered and legacy `currentIssue`
49
+ * paths. `completeIssue` re-derives status from phases (#856), so a seeded
50
+ * `issue.status = "failure"` alone would be overwritten back to `success`;
51
+ * this marker survives that re-derivation.
52
+ */
53
+ private forcedFailures;
39
54
  private logPath;
40
55
  private writeToUserLogs;
41
56
  private verbose;
42
57
  private rotation;
43
58
  private startCommit?;
59
+ private startTime?;
44
60
  constructor(options?: LogWriterOptions);
45
61
  /**
46
62
  * Initialize a new run log
@@ -66,10 +82,28 @@ export declare class LogWriter {
66
82
  * Set PR info on the current issue (call before completeIssue)
67
83
  */
68
84
  setPRInfo(prNumber: number, prUrl: string, issueNumber?: number): void;
85
+ /**
86
+ * Force the in-flight issue's status to `failure` (#879).
87
+ *
88
+ * `deriveIssueLogStatus` runs at phase-log time, so an issue whose phases all
89
+ * passed but whose PR creation then failed would otherwise be recorded as
90
+ * `success`. Call this after the last phase is logged and before
91
+ * {@link completeIssue} to count it under `failed`. No-op if the issue is not
92
+ * active. Reuses the existing `failure` enum — no schema change.
93
+ */
94
+ markIssueFailed(issueNumber?: number): void;
69
95
  /**
70
96
  * Complete the current issue and add it to the run log
97
+ *
98
+ * @param issueNumber - Issue to complete (defaults to the legacy single slot)
99
+ * @param abort - Set when the run is being torn down by an external signal
100
+ * (#856). Marks the issue aborted with its cause instead of persisting
101
+ * whatever verdict the incomplete phase list happens to imply.
71
102
  */
72
- completeIssue(issueNumber?: number): void;
103
+ completeIssue(issueNumber?: number, abort?: {
104
+ signal: string;
105
+ reason: string;
106
+ }): void;
73
107
  /**
74
108
  * Finalize the run log and write to disk
75
109
  *
@@ -77,10 +111,19 @@ export declare class LogWriter {
77
111
  *
78
112
  * @param options - Optional finalization options
79
113
  * @param options.endCommit - Git commit SHA at run end (AC-2)
114
+ * @param options.aborted - Set when finalizing because the run was
115
+ * terminated by an external signal (#856). Every still-in-flight issue is
116
+ * recorded as an abort naming its cause, and the run log carries
117
+ * `abortedBy`. Without it, a killed run's log is indistinguishable from a
118
+ * clean one that happened to do nothing.
80
119
  * @returns Path to the written log file
81
120
  */
82
121
  finalize(options?: {
83
122
  endCommit?: string;
123
+ aborted?: {
124
+ signal: string;
125
+ reason: string;
126
+ };
84
127
  }): Promise<string>;
85
128
  /**
86
129
  * Get the current run log (for inspection)
@@ -27,17 +27,27 @@ export class LogWriter {
27
27
  activeIssues = new Map();
28
28
  /** @deprecated Single-issue slot for backwards compatibility — use activeIssues */
29
29
  currentIssue = null;
30
+ /**
31
+ * Issue slots forced to `failure` by {@link markIssueFailed} (#879). Keyed on
32
+ * the slot object so it covers both the numbered and legacy `currentIssue`
33
+ * paths. `completeIssue` re-derives status from phases (#856), so a seeded
34
+ * `issue.status = "failure"` alone would be overwritten back to `success`;
35
+ * this marker survives that re-derivation.
36
+ */
37
+ forcedFailures = new WeakSet();
30
38
  logPath;
31
39
  writeToUserLogs;
32
40
  verbose;
33
41
  rotation;
34
42
  startCommit;
43
+ startTime;
35
44
  constructor(options = {}) {
36
45
  this.logPath = options.logPath ?? LOG_PATHS.project;
37
46
  this.writeToUserLogs = options.writeToUserLogs ?? false;
38
47
  this.verbose = options.verbose ?? false;
39
48
  this.rotation = options.rotation ?? DEFAULT_ROTATION_SETTINGS;
40
49
  this.startCommit = options.startCommit;
50
+ this.startTime = options.startTime;
41
51
  }
42
52
  /**
43
53
  * Initialize a new run log
@@ -45,7 +55,10 @@ export class LogWriter {
45
55
  * @param config - Run configuration
46
56
  */
47
57
  async initialize(config) {
48
- this.runLog = createEmptyRunLog(config, { startCommit: this.startCommit });
58
+ this.runLog = createEmptyRunLog(config, {
59
+ startCommit: this.startCommit,
60
+ startTime: this.startTime,
61
+ });
49
62
  // Ensure log directory exists
50
63
  await this.ensureLogDirectory(this.logPath);
51
64
  if (this.writeToUserLogs) {
@@ -67,12 +80,17 @@ export class LogWriter {
67
80
  if (!this.runLog) {
68
81
  throw new Error("LogWriter not initialized. Call initialize() first.");
69
82
  }
83
+ // #856: seed pessimistically. This slot is only revised by `logPhase`, so
84
+ // an optimistic `"success"` seed became the persisted verdict for any
85
+ // issue whose first phase never completed — the exact shape of a run
86
+ // killed mid-flight. `completeIssue` re-derives from `phases` regardless,
87
+ // but the seed should not itself assert a pass that never happened.
70
88
  const issueData = {
71
89
  issueNumber,
72
90
  title,
73
91
  labels,
74
92
  phases: [],
75
- status: "success",
93
+ status: "failure",
76
94
  totalDurationSeconds: 0,
77
95
  };
78
96
  this.activeIssues.set(issueNumber, issueData);
@@ -116,10 +134,36 @@ export class LogWriter {
116
134
  issue.prNumber = prNumber;
117
135
  issue.prUrl = prUrl;
118
136
  }
137
+ /**
138
+ * Force the in-flight issue's status to `failure` (#879).
139
+ *
140
+ * `deriveIssueLogStatus` runs at phase-log time, so an issue whose phases all
141
+ * passed but whose PR creation then failed would otherwise be recorded as
142
+ * `success`. Call this after the last phase is logged and before
143
+ * {@link completeIssue} to count it under `failed`. No-op if the issue is not
144
+ * active. Reuses the existing `failure` enum — no schema change.
145
+ */
146
+ markIssueFailed(issueNumber) {
147
+ const issue = issueNumber
148
+ ? (this.activeIssues.get(issueNumber) ?? this.currentIssue)
149
+ : this.currentIssue;
150
+ if (!issue) {
151
+ return;
152
+ }
153
+ issue.status = "failure";
154
+ // completeIssue re-derives status from the phase list (#856), so the seed
155
+ // above is not enough on its own — mark the slot so completion honors it.
156
+ this.forcedFailures.add(issue);
157
+ }
119
158
  /**
120
159
  * Complete the current issue and add it to the run log
160
+ *
161
+ * @param issueNumber - Issue to complete (defaults to the legacy single slot)
162
+ * @param abort - Set when the run is being torn down by an external signal
163
+ * (#856). Marks the issue aborted with its cause instead of persisting
164
+ * whatever verdict the incomplete phase list happens to imply.
121
165
  */
122
- completeIssue(issueNumber) {
166
+ completeIssue(issueNumber, abort) {
123
167
  if (!this.runLog) {
124
168
  throw new Error("No run log. Call initialize() first.");
125
169
  }
@@ -134,13 +178,31 @@ export class LogWriter {
134
178
  }
135
179
  // Calculate total duration from phases
136
180
  const totalDurationSeconds = issue.phases?.reduce((sum, p) => sum + p.durationSeconds, 0) ?? 0;
181
+ // #856: derive from the phase list rather than trusting the slot's seed.
182
+ // An issue with no completed phase is a failure — the run was cut short.
183
+ //
184
+ // An abort forces `failure` even when every phase logged so far passed:
185
+ // the issue was still in flight when the signal arrived, so its pipeline
186
+ // never reached a terminal state and a partial prefix of green phases is
187
+ // not a pass. `abortReason` records why, so the log names its own cause
188
+ // instead of leaving a silently truncated record.
189
+ // #879: markIssueFailed forces `failure` when every phase passed but a
190
+ // later step (e.g. PR creation) failed — same override intent as `abort`,
191
+ // so it must win over the phase-derived status too.
192
+ const status = abort || this.forcedFailures.has(issue)
193
+ ? "failure"
194
+ : deriveIssueLogStatus(issue.phases ?? []);
137
195
  const issueLog = {
138
196
  issueNumber: issue.issueNumber,
139
197
  title: issue.title,
140
198
  labels: issue.labels,
141
- status: issue.status,
199
+ status,
142
200
  phases: issue.phases,
143
201
  totalDurationSeconds,
202
+ ...(abort && {
203
+ aborted: true,
204
+ abortReason: abort.reason,
205
+ }),
144
206
  ...(issue.prNumber != null && {
145
207
  prNumber: issue.prNumber,
146
208
  }),
@@ -168,22 +230,29 @@ export class LogWriter {
168
230
  *
169
231
  * @param options - Optional finalization options
170
232
  * @param options.endCommit - Git commit SHA at run end (AC-2)
233
+ * @param options.aborted - Set when finalizing because the run was
234
+ * terminated by an external signal (#856). Every still-in-flight issue is
235
+ * recorded as an abort naming its cause, and the run log carries
236
+ * `abortedBy`. Without it, a killed run's log is indistinguishable from a
237
+ * clean one that happened to do nothing.
171
238
  * @returns Path to the written log file
172
239
  */
173
240
  async finalize(options) {
174
241
  if (!this.runLog) {
175
242
  throw new Error("LogWriter not initialized.");
176
243
  }
244
+ const abort = options?.aborted;
177
245
  // Complete any pending issues (Map-based concurrent tracking)
178
246
  for (const issueNum of [...this.activeIssues.keys()]) {
179
- this.completeIssue(issueNum);
247
+ this.completeIssue(issueNum, abort);
180
248
  }
181
249
  // Fallback: complete legacy currentIssue if not already handled
182
250
  if (this.currentIssue) {
183
- this.completeIssue();
251
+ this.completeIssue(undefined, abort);
184
252
  }
185
253
  const finalLog = finalizeRunLog(this.runLog, {
186
254
  endCommit: options?.endCommit,
255
+ abortedBy: abort?.signal,
187
256
  });
188
257
  const filename = generateLogFilename(finalLog.runId, new Date(finalLog.startTime));
189
258
  // Write to project logs
@@ -47,11 +47,11 @@ export type FailureCategory = z.infer<typeof FailureCategorySchema>;
47
47
  * Available phases (aligned with run-log-schema.ts)
48
48
  */
49
49
  export declare const MetricPhaseSchema: z.ZodEnum<{
50
+ exec: "exec";
50
51
  qa: "qa";
51
52
  loop: "loop";
52
53
  spec: "spec";
53
54
  "security-review": "security-review";
54
- exec: "exec";
55
55
  testgen: "testgen";
56
56
  test: "test";
57
57
  }>;
@@ -85,11 +85,11 @@ export declare const MetricRunSchema: z.ZodObject<{
85
85
  date: z.ZodString;
86
86
  issues: z.ZodArray<z.ZodNumber>;
87
87
  phases: z.ZodArray<z.ZodEnum<{
88
+ exec: "exec";
88
89
  qa: "qa";
89
90
  loop: "loop";
90
91
  spec: "spec";
91
92
  "security-review": "security-review";
92
- exec: "exec";
93
93
  testgen: "testgen";
94
94
  test: "test";
95
95
  }>>;
@@ -135,11 +135,11 @@ export declare const MetricsSchema: z.ZodObject<{
135
135
  date: z.ZodString;
136
136
  issues: z.ZodArray<z.ZodNumber>;
137
137
  phases: z.ZodArray<z.ZodEnum<{
138
+ exec: "exec";
138
139
  qa: "qa";
139
140
  loop: "loop";
140
141
  spec: "spec";
141
142
  "security-review": "security-review";
142
- exec: "exec";
143
143
  testgen: "testgen";
144
144
  test: "test";
145
145
  }>>;