sequant 2.8.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 (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/dist/bin/cli.js +99 -18
  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 +58 -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 +38 -10
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
  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 +250 -344
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
  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 +4 -2
  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/reflect/SKILL.md +27 -13
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  29. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  30. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
  31. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  34. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  35. package/dist/src/commands/assess-render.d.ts +23 -0
  36. package/dist/src/commands/assess-render.js +60 -0
  37. package/dist/src/commands/doctor.js +8 -13
  38. package/dist/src/commands/init.js +13 -1
  39. package/dist/src/commands/locks.js +2 -0
  40. package/dist/src/commands/logs.js +20 -6
  41. package/dist/src/commands/merge.js +154 -2
  42. package/dist/src/commands/ready-tui-adapter.js +6 -1
  43. package/dist/src/commands/ready.d.ts +25 -3
  44. package/dist/src/commands/ready.js +39 -13
  45. package/dist/src/commands/resume.d.ts +113 -0
  46. package/dist/src/commands/resume.js +193 -0
  47. package/dist/src/commands/run-display.d.ts +20 -0
  48. package/dist/src/commands/run-display.js +117 -5
  49. package/dist/src/commands/run-flags.d.ts +42 -1
  50. package/dist/src/commands/run-flags.js +53 -1
  51. package/dist/src/commands/run-progress.js +26 -1
  52. package/dist/src/commands/run.js +11 -10
  53. package/dist/src/commands/state.js +7 -0
  54. package/dist/src/commands/stats.js +47 -0
  55. package/dist/src/commands/status.d.ts +9 -0
  56. package/dist/src/commands/status.js +24 -1
  57. package/dist/src/commands/sync.js +90 -19
  58. package/dist/src/commands/update.js +28 -5
  59. package/dist/src/lib/ac-parser.d.ts +14 -0
  60. package/dist/src/lib/ac-parser.js +99 -5
  61. package/dist/src/lib/assess/index.d.ts +10 -0
  62. package/dist/src/lib/assess/index.js +9 -0
  63. package/dist/src/lib/assess/renderer.d.ts +64 -0
  64. package/dist/src/lib/assess/renderer.js +481 -0
  65. package/dist/src/lib/assess/types.d.ts +224 -0
  66. package/dist/src/lib/assess/types.js +241 -0
  67. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  68. package/dist/src/lib/assess-collision-detect.js +68 -4
  69. package/dist/src/lib/ci/outputs.d.ts +15 -2
  70. package/dist/src/lib/ci/outputs.js +17 -5
  71. package/dist/src/lib/cli-flags.d.ts +33 -0
  72. package/dist/src/lib/cli-flags.js +46 -0
  73. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  74. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  75. package/dist/src/lib/cli-ui/run-renderer.js +79 -9
  76. package/dist/src/lib/errors.d.ts +84 -6
  77. package/dist/src/lib/errors.js +142 -17
  78. package/dist/src/lib/locks/index.d.ts +3 -3
  79. package/dist/src/lib/locks/index.js +2 -2
  80. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  81. package/dist/src/lib/locks/lock-manager.js +47 -3
  82. package/dist/src/lib/locks/types.d.ts +34 -3
  83. package/dist/src/lib/locks/types.js +16 -0
  84. package/dist/src/lib/manifest.js +1 -17
  85. package/dist/src/lib/mcp-config.d.ts +45 -0
  86. package/dist/src/lib/mcp-config.js +77 -1
  87. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  88. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  89. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  90. package/dist/src/lib/merge-check/command-result.js +56 -0
  91. package/dist/src/lib/merge-check/index.d.ts +4 -0
  92. package/dist/src/lib/merge-check/index.js +1 -1
  93. package/dist/src/lib/merge-check/report.js +5 -1
  94. package/dist/src/lib/merge-check/types.d.ts +9 -0
  95. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  96. package/dist/src/lib/merge-check/watch.js +215 -0
  97. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  98. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  99. package/dist/src/lib/settings.d.ts +14 -0
  100. package/dist/src/lib/settings.js +61 -8
  101. package/dist/src/lib/shutdown.d.ts +42 -2
  102. package/dist/src/lib/shutdown.js +75 -5
  103. package/dist/src/lib/skills-check.d.ts +26 -0
  104. package/dist/src/lib/skills-check.js +31 -0
  105. package/dist/src/lib/stacks.d.ts +110 -1
  106. package/dist/src/lib/stacks.js +244 -5
  107. package/dist/src/lib/templates.d.ts +74 -0
  108. package/dist/src/lib/templates.js +171 -16
  109. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  110. package/dist/src/lib/test-tautology-detector.js +153 -12
  111. package/dist/src/lib/version-check.d.ts +31 -0
  112. package/dist/src/lib/version-check.js +46 -7
  113. package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
  114. package/dist/src/lib/workflow/batch-executor.js +550 -54
  115. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  116. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  117. package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
  118. package/dist/src/lib/workflow/chain-resume.js +170 -0
  119. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  120. package/dist/src/lib/workflow/completed-status.js +57 -0
  121. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  122. package/dist/src/lib/workflow/config-resolver.js +54 -2
  123. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  124. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  125. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  126. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  127. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  128. package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
  129. package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
  130. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  131. package/dist/src/lib/workflow/error-classifier.js +14 -1
  132. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  133. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  134. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  135. package/dist/src/lib/workflow/heartbeat.js +90 -0
  136. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  137. package/dist/src/lib/workflow/log-writer.js +81 -14
  138. package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
  139. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  140. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  141. package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
  142. package/dist/src/lib/workflow/phase-executor.js +533 -56
  143. package/dist/src/lib/workflow/phase-registry.js +2 -1
  144. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  145. package/dist/src/lib/workflow/platforms/github.js +152 -0
  146. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  147. package/dist/src/lib/workflow/qa-cache.js +22 -9
  148. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  149. package/dist/src/lib/workflow/ready-gate.js +25 -9
  150. package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
  151. package/dist/src/lib/workflow/run-log-schema.js +92 -2
  152. package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
  153. package/dist/src/lib/workflow/run-orchestrator.js +429 -47
  154. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  155. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  156. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  157. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  158. package/dist/src/lib/workflow/state-manager.js +83 -0
  159. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  160. package/dist/src/lib/workflow/state-schema.js +47 -0
  161. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  162. package/dist/src/lib/workflow/status-derivation.js +27 -0
  163. package/dist/src/lib/workflow/types.d.ts +149 -4
  164. package/dist/src/lib/workflow/types.js +1 -0
  165. package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
  166. package/dist/src/lib/workflow/worktree-manager.js +201 -52
  167. package/dist/src/mcp/tools/run.d.ts +11 -1
  168. package/dist/src/mcp/tools/run.js +22 -1
  169. package/dist/src/mcp/tools/status.js +6 -0
  170. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  171. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  172. package/dist/src/ui/tui/IssueBox.js +10 -2
  173. package/dist/src/ui/tui/row-cap.js +2 -1
  174. package/dist/src/ui/tui/theme.d.ts +7 -0
  175. package/dist/src/ui/tui/theme.js +9 -0
  176. package/package.json +9 -10
  177. package/templates/hooks/parallel-marker.sh +50 -0
  178. package/templates/hooks/post-tool.sh +38 -10
  179. package/templates/hooks/pre-tool.sh +405 -67
  180. package/templates/scripts/cleanup-worktree.sh +282 -30
  181. package/templates/scripts/new-feature.sh +264 -16
  182. package/templates/skills/_shared/references/trust-model.md +18 -0
  183. package/templates/skills/assess/SKILL.md +250 -344
  184. package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
  185. package/templates/skills/docs/SKILL.md +4 -4
  186. package/templates/skills/exec/SKILL.md +26 -6
  187. package/templates/skills/fullsolve/SKILL.md +4 -2
  188. package/templates/skills/loop/SKILL.md +38 -2
  189. package/templates/skills/merger/SKILL.md +2 -2
  190. package/templates/skills/qa/SKILL.md +337 -57
  191. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  192. package/templates/skills/qa/references/call-site-review.md +2 -2
  193. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  194. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  195. package/templates/skills/qa/references/quality-gates.md +21 -7
  196. package/templates/skills/qa/references/testing-requirements.md +1 -1
  197. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  198. package/templates/skills/reflect/SKILL.md +27 -13
  199. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  200. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  201. package/templates/skills/release/SKILL.md +11 -3
  202. package/templates/skills/setup/SKILL.md +5 -5
  203. package/templates/skills/spec/SKILL.md +2 -0
  204. package/templates/skills/spec/references/verification-criteria.md +1 -1
  205. package/templates/skills/test/SKILL.md +1 -1
  206. package/dist/src/lib/phase-spinner.d.ts +0 -146
  207. package/dist/src/lib/phase-spinner.js +0 -255
  208. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  209. package/dist/src/lib/workflow/pr-operations.js +0 -326
  210. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  211. 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)
@@ -16,7 +16,7 @@
16
16
  import * as fs from "fs";
17
17
  import * as path from "path";
18
18
  import * as os from "os";
19
- import { createEmptyRunLog, finalizeRunLog, generateLogFilename, LOG_PATHS, } from "./run-log-schema.js";
19
+ import { createEmptyRunLog, finalizeRunLog, deriveIssueLogStatus, generateLogFilename, LOG_PATHS, } from "./run-log-schema.js";
20
20
  import { rotateIfNeeded, DEFAULT_ROTATION_SETTINGS, } from "./log-rotation.js";
21
21
  /**
22
22
  * Manages writing structured run logs to disk
@@ -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);
@@ -94,13 +112,11 @@ export class LogWriter {
94
112
  throw new Error(`No active issue #${phaseLog.issueNumber}. Call startIssue() first.`);
95
113
  }
96
114
  issue.phases = [...(issue.phases ?? []), phaseLog];
97
- // Update issue status based on phase result
98
- if (phaseLog.status === "failure") {
99
- issue.status = "failure";
100
- }
101
- else if (phaseLog.status === "timeout" && issue.status !== "failure") {
102
- issue.status = "partial";
103
- }
115
+ // #766: derive from the latest attempt of each phase (loop excluded) rather
116
+ // than pinning failure/partial forever. A timeout or failure that a later
117
+ // quality-loop iteration recovers from no longer sticks, so the JSON log
118
+ // agrees with the live card and summary table (AC-3/AC-5).
119
+ issue.status = deriveIssueLogStatus(issue.phases);
104
120
  if (this.verbose) {
105
121
  console.log(`Log phase: ${phaseLog.phase} (${phaseLog.status}) - ${phaseLog.durationSeconds.toFixed(1)}s`);
106
122
  }
@@ -118,10 +134,36 @@ export class LogWriter {
118
134
  issue.prNumber = prNumber;
119
135
  issue.prUrl = prUrl;
120
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
+ }
121
158
  /**
122
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.
123
165
  */
124
- completeIssue(issueNumber) {
166
+ completeIssue(issueNumber, abort) {
125
167
  if (!this.runLog) {
126
168
  throw new Error("No run log. Call initialize() first.");
127
169
  }
@@ -136,13 +178,31 @@ export class LogWriter {
136
178
  }
137
179
  // Calculate total duration from phases
138
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 ?? []);
139
195
  const issueLog = {
140
196
  issueNumber: issue.issueNumber,
141
197
  title: issue.title,
142
198
  labels: issue.labels,
143
- status: issue.status,
199
+ status,
144
200
  phases: issue.phases,
145
201
  totalDurationSeconds,
202
+ ...(abort && {
203
+ aborted: true,
204
+ abortReason: abort.reason,
205
+ }),
146
206
  ...(issue.prNumber != null && {
147
207
  prNumber: issue.prNumber,
148
208
  }),
@@ -170,22 +230,29 @@ export class LogWriter {
170
230
  *
171
231
  * @param options - Optional finalization options
172
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.
173
238
  * @returns Path to the written log file
174
239
  */
175
240
  async finalize(options) {
176
241
  if (!this.runLog) {
177
242
  throw new Error("LogWriter not initialized.");
178
243
  }
244
+ const abort = options?.aborted;
179
245
  // Complete any pending issues (Map-based concurrent tracking)
180
246
  for (const issueNum of [...this.activeIssues.keys()]) {
181
- this.completeIssue(issueNum);
247
+ this.completeIssue(issueNum, abort);
182
248
  }
183
249
  // Fallback: complete legacy currentIssue if not already handled
184
250
  if (this.currentIssue) {
185
- this.completeIssue();
251
+ this.completeIssue(undefined, abort);
186
252
  }
187
253
  const finalLog = finalizeRunLog(this.runLog, {
188
254
  endCommit: options?.endCommit,
255
+ abortedBy: abort?.signal,
189
256
  });
190
257
  const filename = generateLogFilename(finalLog.runId, new Date(finalLog.startTime));
191
258
  // Write to project logs
@@ -25,15 +25,33 @@ export declare const RunOutcomeSchema: z.ZodEnum<{
25
25
  failed: "failed";
26
26
  }>;
27
27
  export type RunOutcome = z.infer<typeof RunOutcomeSchema>;
28
+ /**
29
+ * Bounded-enum classification of the failure that ended a run (#761 AC-7).
30
+ *
31
+ * Sourced from `ERROR_CATEGORIES` so the metric can never carry free text —
32
+ * error *messages* stay excluded per the privacy contract above MetricRunSchema
33
+ * (they could contain sensitive info); a closed enum cannot.
34
+ */
35
+ export declare const FailureCategorySchema: z.ZodEnum<{
36
+ unknown: "unknown";
37
+ timeout: "timeout";
38
+ context_overflow: "context_overflow";
39
+ api_error: "api_error";
40
+ hook_failure: "hook_failure";
41
+ build_error: "build_error";
42
+ rate_limit: "rate_limit";
43
+ billing: "billing";
44
+ }>;
45
+ export type FailureCategory = z.infer<typeof FailureCategorySchema>;
28
46
  /**
29
47
  * Available phases (aligned with run-log-schema.ts)
30
48
  */
31
49
  export declare const MetricPhaseSchema: z.ZodEnum<{
50
+ exec: "exec";
32
51
  qa: "qa";
33
52
  loop: "loop";
34
53
  spec: "spec";
35
54
  "security-review": "security-review";
36
- exec: "exec";
37
55
  testgen: "testgen";
38
56
  test: "test";
39
57
  }>;
@@ -67,11 +85,11 @@ export declare const MetricRunSchema: z.ZodObject<{
67
85
  date: z.ZodString;
68
86
  issues: z.ZodArray<z.ZodNumber>;
69
87
  phases: z.ZodArray<z.ZodEnum<{
88
+ exec: "exec";
70
89
  qa: "qa";
71
90
  loop: "loop";
72
91
  spec: "spec";
73
92
  "security-review": "security-review";
74
- exec: "exec";
75
93
  testgen: "testgen";
76
94
  test: "test";
77
95
  }>>;
@@ -83,6 +101,16 @@ export declare const MetricRunSchema: z.ZodObject<{
83
101
  duration: z.ZodNumber;
84
102
  model: z.ZodString;
85
103
  flags: z.ZodArray<z.ZodString>;
104
+ failureCategory: z.ZodOptional<z.ZodEnum<{
105
+ unknown: "unknown";
106
+ timeout: "timeout";
107
+ context_overflow: "context_overflow";
108
+ api_error: "api_error";
109
+ hook_failure: "hook_failure";
110
+ build_error: "build_error";
111
+ rate_limit: "rate_limit";
112
+ billing: "billing";
113
+ }>>;
86
114
  metrics: z.ZodObject<{
87
115
  tokensUsed: z.ZodNumber;
88
116
  filesChanged: z.ZodNumber;
@@ -107,11 +135,11 @@ export declare const MetricsSchema: z.ZodObject<{
107
135
  date: z.ZodString;
108
136
  issues: z.ZodArray<z.ZodNumber>;
109
137
  phases: z.ZodArray<z.ZodEnum<{
138
+ exec: "exec";
110
139
  qa: "qa";
111
140
  loop: "loop";
112
141
  spec: "spec";
113
142
  "security-review": "security-review";
114
- exec: "exec";
115
143
  testgen: "testgen";
116
144
  test: "test";
117
145
  }>>;
@@ -123,6 +151,16 @@ export declare const MetricsSchema: z.ZodObject<{
123
151
  duration: z.ZodNumber;
124
152
  model: z.ZodString;
125
153
  flags: z.ZodArray<z.ZodString>;
154
+ failureCategory: z.ZodOptional<z.ZodEnum<{
155
+ unknown: "unknown";
156
+ timeout: "timeout";
157
+ context_overflow: "context_overflow";
158
+ api_error: "api_error";
159
+ hook_failure: "hook_failure";
160
+ build_error: "build_error";
161
+ rate_limit: "rate_limit";
162
+ billing: "billing";
163
+ }>>;
126
164
  metrics: z.ZodObject<{
127
165
  tokensUsed: z.ZodNumber;
128
166
  filesChanged: z.ZodNumber;
@@ -154,6 +192,7 @@ export declare function createMetricRun(options: {
154
192
  duration: number;
155
193
  model?: string;
156
194
  flags?: string[];
195
+ failureCategory?: FailureCategory;
157
196
  metrics?: Partial<RunMetrics>;
158
197
  }): MetricRun;
159
198
  /**