sequant 2.8.0 → 2.9.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +9 -1
  4. package/dist/bin/cli.js +2 -1
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/README.md +2 -0
  7. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +18 -3
  8. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +330 -57
  9. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +96 -15
  10. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +9 -6
  11. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +1 -1
  12. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  13. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  14. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  15. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +10 -2
  16. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  17. package/dist/src/commands/logs.js +6 -1
  18. package/dist/src/commands/run-display.d.ts +20 -0
  19. package/dist/src/commands/run-display.js +80 -1
  20. package/dist/src/commands/stats.js +47 -0
  21. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  22. package/dist/src/lib/assess-collision-detect.js +68 -4
  23. package/dist/src/lib/cli-ui/run-renderer.js +17 -9
  24. package/dist/src/lib/errors.d.ts +6 -0
  25. package/dist/src/lib/errors.js +9 -2
  26. package/dist/src/lib/manifest.js +1 -17
  27. package/dist/src/lib/version-check.js +1 -5
  28. package/dist/src/lib/workflow/batch-executor.d.ts +13 -0
  29. package/dist/src/lib/workflow/batch-executor.js +81 -18
  30. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  31. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  32. package/dist/src/lib/workflow/chain-resume.d.ts +116 -0
  33. package/dist/src/lib/workflow/chain-resume.js +166 -0
  34. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  35. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  36. package/dist/src/lib/workflow/drivers/claude-code.d.ts +7 -0
  37. package/dist/src/lib/workflow/drivers/claude-code.js +30 -6
  38. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  39. package/dist/src/lib/workflow/error-classifier.js +14 -1
  40. package/dist/src/lib/workflow/log-writer.js +6 -8
  41. package/dist/src/lib/workflow/metrics-schema.d.ts +39 -0
  42. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  43. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  44. package/dist/src/lib/workflow/phase-executor.d.ts +32 -0
  45. package/dist/src/lib/workflow/phase-executor.js +77 -5
  46. package/dist/src/lib/workflow/run-log-schema.d.ts +23 -0
  47. package/dist/src/lib/workflow/run-log-schema.js +45 -1
  48. package/dist/src/lib/workflow/run-orchestrator.d.ts +14 -0
  49. package/dist/src/lib/workflow/run-orchestrator.js +291 -30
  50. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  51. package/dist/src/lib/workflow/status-derivation.js +27 -0
  52. package/dist/src/lib/workflow/types.d.ts +23 -0
  53. package/dist/src/lib/workflow/worktree-manager.d.ts +43 -1
  54. package/dist/src/lib/workflow/worktree-manager.js +103 -33
  55. package/dist/src/mcp/tools/run.d.ts +2 -0
  56. package/dist/src/mcp/tools/run.js +2 -0
  57. package/package.json +2 -4
  58. package/templates/hooks/post-tool.sh +18 -3
  59. package/templates/hooks/pre-tool.sh +330 -57
  60. package/templates/scripts/cleanup-worktree.sh +103 -14
  61. package/templates/skills/assess/SKILL.md +96 -15
  62. package/templates/skills/assess/references/predicted-collision-detection.md +9 -6
  63. package/templates/skills/fullsolve/SKILL.md +1 -1
  64. package/templates/skills/reflect/SKILL.md +27 -13
  65. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  66. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  67. package/templates/skills/release/SKILL.md +10 -2
  68. package/templates/skills/spec/references/verification-criteria.md +1 -1
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Chain resume planning (#760).
3
+ *
4
+ * When a `--chain` run fails mid-way, earlier links may already be complete
5
+ * (`ready_for_merge`) with a checkpoint commit on their feature branch
6
+ * (`createCheckpointCommit`, worktree-manager.ts). Re-running the same chain
7
+ * should skip that completed prefix and resume at the first incomplete link,
8
+ * rebased onto the last completed link's committed tip — NOT `main` (which is
9
+ * the #748 wrong-base failure this reuses the #748 rebase path to avoid).
10
+ *
11
+ * The existing pre-flight guard (`run-orchestrator.ts`) already drops
12
+ * `ready_for_merge`/`merged` issues from the run, but it is chain-unaware:
13
+ * dropping the completed prefix leaves the first incomplete link at index 0,
14
+ * where `executeSequential`'s successor-rebase never fires, so it silently
15
+ * builds on `main`. This module computes a *chain-correct* resume plan that
16
+ * preserves the completed prefix as the resume base.
17
+ *
18
+ * The planner is pure over an injected {@link CompletedLinkResolver} so the
19
+ * skip/fail-fast state machine (AC-3) is unit-testable without real git; the
20
+ * real-git rebase is covered by the integration test.
21
+ */
22
+ const COMPLETED_STATUSES = new Set(["ready_for_merge", "merged"]);
23
+ /**
24
+ * Compute a chain-correct resume plan.
25
+ *
26
+ * Peels the *contiguous* completed prefix off the front of the chain (chain
27
+ * mode breaks on the first failure, so completed links always form a prefix).
28
+ * A completed link that appears *after* an incomplete one is left in `active`
29
+ * and re-executed — the conservative choice, since automatic skipping must not
30
+ * silently drop an issue the user intended to redo.
31
+ *
32
+ * @param orderedLinks Chain links in execution order, with persisted state.
33
+ * @param baseBranch The run's base branch (resume base when the prefix merged).
34
+ * @param resolver Git-ref resolver (injected for tests).
35
+ */
36
+ export function computeChainResumePlan(orderedLinks, baseBranch, resolver) {
37
+ const skipped = [];
38
+ let firstIncomplete = 0;
39
+ for (const link of orderedLinks) {
40
+ if (link.status && COMPLETED_STATUSES.has(link.status)) {
41
+ skipped.push({
42
+ issueNumber: link.issueNumber,
43
+ status: link.status,
44
+ branch: link.branch,
45
+ worktree: link.worktree,
46
+ });
47
+ firstIncomplete++;
48
+ }
49
+ else {
50
+ break;
51
+ }
52
+ }
53
+ const allNumbers = orderedLinks.map((l) => l.issueNumber);
54
+ // No completed prefix → fresh run, nothing to resume.
55
+ if (skipped.length === 0) {
56
+ return { skipped: [], active: allNumbers, allComplete: false };
57
+ }
58
+ // Every link already complete → nothing to execute.
59
+ if (firstIncomplete >= orderedLinks.length) {
60
+ return { skipped, active: [], allComplete: true };
61
+ }
62
+ const active = allNumbers.slice(firstIncomplete);
63
+ const last = skipped[skipped.length - 1];
64
+ let resumeBase;
65
+ let resumeBaseCommit;
66
+ if (last.status === "merged") {
67
+ // The completed prefix's work is in the merged base (origin/main). Provision
68
+ // the first incomplete link from the base branch — the same base a fresh
69
+ // first link would use — rather than a local tip that squash-merge may have
70
+ // orphaned (never rebase a successor onto a stale post-merge local tip).
71
+ resumeBase = baseBranch;
72
+ resumeBaseCommit = resolver.resolveBaseTip();
73
+ }
74
+ else {
75
+ // ready_for_merge: the checkpoint tip lives only on the local feature
76
+ // branch. If that branch is gone (worktree/branch destroyed mid-way), the
77
+ // tip is unreconstructable — fail fast instead of wrong-basing the
78
+ // successor onto main (which would miss the completed link's work).
79
+ if (!last.branch) {
80
+ return {
81
+ skipped,
82
+ active,
83
+ resumeIssue: active[0],
84
+ allComplete: false,
85
+ failFast: `#${last.issueNumber} is ready_for_merge but no branch is recorded in state — ` +
86
+ `cannot reconstruct the resume base. Re-run with --force to redo the chain from scratch.`,
87
+ };
88
+ }
89
+ const tip = resolver.resolveBranchTip(last.branch);
90
+ if (!tip) {
91
+ return {
92
+ skipped,
93
+ active,
94
+ resumeIssue: active[0],
95
+ allComplete: false,
96
+ failFast: `#${last.issueNumber} is ready_for_merge but its branch "${last.branch}" no longer exists — ` +
97
+ `the resume base is unreconstructable. Re-run with --force to redo the chain from scratch.`,
98
+ };
99
+ }
100
+ // The branch exists, but a tip is only a valid resume base if it actually
101
+ // contains the link's work. `createCheckpointCommit` sweeps trailing
102
+ // uncommitted changes into a checkpoint commit; when it fails (commit hook,
103
+ // staging error, or unrelated dirty files) the status was *already* written
104
+ // as ready_for_merge, so this link still reads as a completed prefix while
105
+ // its tip is missing work. Rebasing the successor onto it would be exactly
106
+ // the silent wrong-base execution AC-3 forbids — fail fast instead.
107
+ if (last.worktree && resolver.isWorktreeDirty(last.worktree)) {
108
+ return {
109
+ skipped,
110
+ active,
111
+ resumeIssue: active[0],
112
+ allComplete: false,
113
+ failFast: `#${last.issueNumber} is ready_for_merge but its worktree has uncommitted changes — ` +
114
+ `its checkpoint commit never landed, so branch "${last.branch}" is missing that work and ` +
115
+ `resuming #${active[0]} here would build on an incomplete base. Commit them in ${last.worktree}, ` +
116
+ `or re-run with --force to redo the chain from scratch.`,
117
+ };
118
+ }
119
+ resumeBase = last.branch;
120
+ resumeBaseCommit = tip;
121
+ }
122
+ return {
123
+ skipped,
124
+ active,
125
+ resumeBase,
126
+ resumeBaseCommit,
127
+ resumeIssue: active[0],
128
+ allComplete: false,
129
+ };
130
+ }
131
+ /**
132
+ * Read each link's persisted state and compute the resume plan.
133
+ *
134
+ * Split out of `run-orchestrator.ts` so the state-reading half of resume — in
135
+ * particular "a state lookup failure must treat the link as *incomplete*", the
136
+ * conservative choice that re-executes rather than skipping on bad data — is
137
+ * testable without standing up an orchestrator.
138
+ *
139
+ * @param issueNumbers Chain issues in execution order.
140
+ * @param baseBranch The run's base branch.
141
+ * @param getIssueState Reads persisted state for one issue (may reject).
142
+ * @param resolver Git-ref resolver.
143
+ * @param onStateError Called when a lookup throws; the link is then treated as
144
+ * incomplete (no status), so it stays in `active`.
145
+ */
146
+ export async function planChainResumeFromState(issueNumbers, baseBranch, getIssueState, resolver, onStateError) {
147
+ const orderedLinks = [];
148
+ for (const issueNumber of issueNumbers) {
149
+ let status;
150
+ let branch;
151
+ let worktree;
152
+ try {
153
+ const issueState = await getIssueState(issueNumber);
154
+ status = issueState?.status;
155
+ branch = issueState?.branch;
156
+ worktree = issueState?.worktree;
157
+ }
158
+ catch (error) {
159
+ // Unknown state → treat as incomplete and re-execute. Skipping on a
160
+ // failed lookup could silently drop an issue the user meant to run.
161
+ onStateError?.(issueNumber, error);
162
+ }
163
+ orderedLinks.push({ issueNumber, status, branch, worktree });
164
+ }
165
+ return computeChainResumePlan(orderedLinks, baseBranch, resolver);
166
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared parser for line-leading dependency markers in an issue body (#767).
3
+ *
4
+ * Two callers extract issue numbers from `depends on #N` / `blocked by #N`
5
+ * declarations:
6
+ *
7
+ * - `chain-preflight.ts:parseDeclaredBlockers` — warn-only pre-flight, honors
8
+ * BOTH `depends on` and `blocked by`.
9
+ * - `batch-executor.ts:parseDependencies` — feeds `sortByDependencies`, which
10
+ * *silently reorders the run*, so it honors ONLY `depends on`.
11
+ *
12
+ * The hardened mechanics (line anchoring, required `#`, code/comment stripping)
13
+ * were introduced for the pre-flight in #762/PR #764 and lived only in
14
+ * `chain-preflight.ts`. #767 promotes them here so the sorter can adopt the same
15
+ * hardening without the two regexes drifting — while keeping the **marker set
16
+ * per-caller** so sharing the parser does NOT make the sorter start reordering
17
+ * on `blocked by` (a new, unrequested silent-reorder class; #762 Open Q #3).
18
+ */
19
+ /** A dependency-declaration marker a caller opts into honoring. */
20
+ export type DepMarker = "depends on" | "blocked by";
21
+ /**
22
+ * Parse the issue numbers a body declares itself dependent on / blocked by,
23
+ * honoring only the requested `markers`. Deduped, order-preserving.
24
+ *
25
+ * Only line-leading markers count as declarations — see `buildMarkerRegex` for
26
+ * why mid-sentence prose mentions are deliberately ignored. Code blocks, inline
27
+ * code spans, and HTML comments are stripped first.
28
+ */
29
+ export declare function parseBodyDependencyMarkers(body: string, markers: DepMarker[]): number[];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shared parser for line-leading dependency markers in an issue body (#767).
3
+ *
4
+ * Two callers extract issue numbers from `depends on #N` / `blocked by #N`
5
+ * declarations:
6
+ *
7
+ * - `chain-preflight.ts:parseDeclaredBlockers` — warn-only pre-flight, honors
8
+ * BOTH `depends on` and `blocked by`.
9
+ * - `batch-executor.ts:parseDependencies` — feeds `sortByDependencies`, which
10
+ * *silently reorders the run*, so it honors ONLY `depends on`.
11
+ *
12
+ * The hardened mechanics (line anchoring, required `#`, code/comment stripping)
13
+ * were introduced for the pre-flight in #762/PR #764 and lived only in
14
+ * `chain-preflight.ts`. #767 promotes them here so the sorter can adopt the same
15
+ * hardening without the two regexes drifting — while keeping the **marker set
16
+ * per-caller** so sharing the parser does NOT make the sorter start reordering
17
+ * on `blocked by` (a new, unrequested silent-reorder class; #762 Open Q #3).
18
+ */
19
+ /**
20
+ * Build the anchored marker regex for the requested marker set.
21
+ *
22
+ * Matches a declared marker, optionally bold-wrapped, colon-separated, and/or
23
+ * written as a list item, e.g. `- **Depends on**: #123`.
24
+ *
25
+ * Anchored to line start because a *declaration* is a line about the issue's
26
+ * own dependencies, whereas prose that merely mentions the marker mid-sentence
27
+ * is not. #762's own body is the motivating case: it contains both `...when #39
28
+ * says blocked by #38` and `...real markers like "Blocked by #36"` as examples,
29
+ * and an unanchored match reported #762 as blocked by #38 and #36 — exactly the
30
+ * false inference #604 says is worse than none. That matters most under
31
+ * `--strict-preflight`, where a bogus warning hard-aborts a legitimate chain.
32
+ *
33
+ * The `#` is required (unlike the historic looser `#?` in `parseDependencies`)
34
+ * so a line such as `Blocked by 5 days of review` — or the prose
35
+ * `Issue 14 depends on 12+13` — cannot parse as an issue number.
36
+ */
37
+ function buildMarkerRegex(markers) {
38
+ const alternation = markers.map((m) => m.split(/\s+/).join("\\s+")).join("|");
39
+ return new RegExp(`^\\s*(?:[-*]\\s*)?\\*?\\*?(?:${alternation})\\*?\\*?:?\\s*#(\\d+)`, "gim");
40
+ }
41
+ /**
42
+ * Strip fenced code blocks, inline code spans, and HTML comments so markers
43
+ * inside quoted shell snippets, documentation examples, or commented-out drafts
44
+ * don't count as real declarations. Inline spans are matched within a single
45
+ * line so an unbalanced backtick cannot swallow the rest of the body.
46
+ *
47
+ * Deliberately diverges from `assess-collision-detect.ts:stripCodeBlocksAndComments`,
48
+ * which keeps inline spans: its PATH_REGEX only matches backtick-wrapped paths,
49
+ * so stripping them there would find nothing. Same syntax, opposite meaning —
50
+ * a backticked marker here is an example, a backticked path there is the target.
51
+ * Keep the two separate (see PR #770).
52
+ */
53
+ function stripCodeAndComments(body) {
54
+ return body
55
+ .replace(/```[\s\S]*?```/g, "")
56
+ .replace(/<!--[\s\S]*?-->/g, "")
57
+ .replace(/`[^`\n]*`/g, "");
58
+ }
59
+ /**
60
+ * Parse the issue numbers a body declares itself dependent on / blocked by,
61
+ * honoring only the requested `markers`. Deduped, order-preserving.
62
+ *
63
+ * Only line-leading markers count as declarations — see `buildMarkerRegex` for
64
+ * why mid-sentence prose mentions are deliberately ignored. Code blocks, inline
65
+ * code spans, and HTML comments are stripped first.
66
+ */
67
+ export function parseBodyDependencyMarkers(body, markers) {
68
+ if (markers.length === 0)
69
+ return [];
70
+ const cleaned = stripCodeAndComments(body);
71
+ const regex = buildMarkerRegex(markers);
72
+ const found = [];
73
+ for (const m of cleaned.matchAll(regex)) {
74
+ const n = parseInt(m[1], 10);
75
+ if (!isNaN(n) && !found.includes(n))
76
+ found.push(n);
77
+ }
78
+ return found;
79
+ }
@@ -48,6 +48,13 @@ export declare class ClaudeCodeDriver implements AgentDriver {
48
48
  * Map the SDK's assistant/api-retry error enum to a typed error. Only
49
49
  * rate-limit / billing variants are mapped; other variants (auth, etc.)
50
50
  * return undefined and defer to the existing classification path.
51
+ *
52
+ * The assistant-error channel carries no `resetsAt`/`rateLimitType` (the
53
+ * enum is the whole signal — `SDKAssistantMessageError` is a bare string),
54
+ * so the originating variant is recorded in metadata to keep `rate_limit`
55
+ * and `overloaded` distinguishable downstream (#761 AC-9). With no
56
+ * `resetsAt`, these errors are treated as transient by the executor's
57
+ * window-exhaustion check — the mandatory metadata-absent fallback.
51
58
  */
52
59
  private errorFromAssistantError;
53
60
  private buildResumeHandle;
@@ -228,10 +228,22 @@ export class ClaudeCodeDriver {
228
228
  clearTimeout(timeoutId);
229
229
  const error = err instanceof Error ? err.message : String(err);
230
230
  if (error.includes("abort") || error.includes("AbortError")) {
231
+ // A rate limit can manifest as a hang rather than a stream event
232
+ // reaching the result: the SDK stalls, the phase timeout fires, and
233
+ // the abort lands here. Only failure-grade signals are ever captured
234
+ // (see the stream loop), so attaching them cannot mask a genuine
235
+ // timeout — with no signal, `abortStructuredError` is undefined and
236
+ // the bare timeout message survives unchanged (#761 AC-1). Without
237
+ // this, the captured `rateLimitInfo` was discarded and downstream
238
+ // classification saw only `Timeout after Ns`, sending a closed
239
+ // rate-limit window into the full retry + MCP-fallback ladder.
240
+ const abortStructuredError = this.buildStructuredError(rateLimitInfo, assistantError, apiRetryError);
231
241
  return {
232
242
  success: false,
233
243
  output: capturedOutput,
234
- error: `Timeout after ${config.phaseTimeout}s`,
244
+ error: abortStructuredError?.message ??
245
+ `Timeout after ${config.phaseTimeout}s`,
246
+ structuredError: abortStructuredError,
235
247
  stderrTail: stderrBuffer.getLines(),
236
248
  stdoutTail: stdoutBuffer.getLines(),
237
249
  };
@@ -239,8 +251,7 @@ export class ClaudeCodeDriver {
239
251
  // If the stream surfaced a failure-grade rate-limit/billing signal before
240
252
  // throwing, prefer that typed cause (#732) over the raw thrown message — a
241
253
  // mid-stream throw after a *rejected* rate_limit_event is very likely the
242
- // proximate cause. Abort/timeout is handled above first, so a genuine
243
- // timeout is never masked by a stale rate-limit signal.
254
+ // proximate cause.
244
255
  const structuredError = this.buildStructuredError(rateLimitInfo, assistantError, apiRetryError);
245
256
  const stderrSuffix = capturedStderr
246
257
  ? `\nStderr: ${capturedStderr.slice(0, 500)}`
@@ -287,15 +298,28 @@ export class ClaudeCodeDriver {
287
298
  * Map the SDK's assistant/api-retry error enum to a typed error. Only
288
299
  * rate-limit / billing variants are mapped; other variants (auth, etc.)
289
300
  * return undefined and defer to the existing classification path.
301
+ *
302
+ * The assistant-error channel carries no `resetsAt`/`rateLimitType` (the
303
+ * enum is the whole signal — `SDKAssistantMessageError` is a bare string),
304
+ * so the originating variant is recorded in metadata to keep `rate_limit`
305
+ * and `overloaded` distinguishable downstream (#761 AC-9). With no
306
+ * `resetsAt`, these errors are treated as transient by the executor's
307
+ * window-exhaustion check — the mandatory metadata-absent fallback.
290
308
  */
291
309
  errorFromAssistantError(error) {
292
310
  switch (error) {
293
311
  case "billing_error":
294
- return new BillingError("Billing error");
312
+ return new BillingError("Billing error", {
313
+ assistantError: "billing_error",
314
+ });
295
315
  case "rate_limit":
296
- return new RateLimitError("Rate limited");
316
+ return new RateLimitError("Rate limited", {
317
+ assistantError: "rate_limit",
318
+ });
297
319
  case "overloaded":
298
- return new RateLimitError("API overloaded");
320
+ return new RateLimitError("API overloaded", {
321
+ assistantError: "overloaded",
322
+ });
299
323
  default:
300
324
  return undefined;
301
325
  }
@@ -5,8 +5,15 @@
5
5
  * categories. Exit codes are the primary signal; stderr patterns are secondary.
6
6
  */
7
7
  import { SequantError } from "../errors.js";
8
- /** All recognized error categories (kept for backwards compatibility). */
9
- export declare const ERROR_CATEGORIES: readonly ["context_overflow", "api_error", "hook_failure", "build_error", "timeout", "unknown"];
8
+ /**
9
+ * All recognized error categories (kept for backwards compatibility).
10
+ *
11
+ * `rate_limit` / `billing` (#761 AC-6) only arise from the driver's structured
12
+ * errors — `classifyError` never produces them, since stderr text cannot
13
+ * distinguish a window-exhausted limit from a transient 429 (`api_error`).
14
+ * Keep in sync with the inline category enum in `run-log-schema.ts`.
15
+ */
16
+ export declare const ERROR_CATEGORIES: readonly ["context_overflow", "api_error", "hook_failure", "build_error", "timeout", "rate_limit", "billing", "unknown"];
10
17
  export type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
11
18
  /**
12
19
  * Map from error type name to legacy category string.
@@ -5,13 +5,22 @@
5
5
  * categories. Exit codes are the primary signal; stderr patterns are secondary.
6
6
  */
7
7
  import { ContextOverflowError, ApiError, HookFailureError, BuildError, TimeoutError, SubprocessError, } from "../errors.js";
8
- /** All recognized error categories (kept for backwards compatibility). */
8
+ /**
9
+ * All recognized error categories (kept for backwards compatibility).
10
+ *
11
+ * `rate_limit` / `billing` (#761 AC-6) only arise from the driver's structured
12
+ * errors — `classifyError` never produces them, since stderr text cannot
13
+ * distinguish a window-exhausted limit from a transient 429 (`api_error`).
14
+ * Keep in sync with the inline category enum in `run-log-schema.ts`.
15
+ */
9
16
  export const ERROR_CATEGORIES = [
10
17
  "context_overflow",
11
18
  "api_error",
12
19
  "hook_failure",
13
20
  "build_error",
14
21
  "timeout",
22
+ "rate_limit",
23
+ "billing",
15
24
  "unknown",
16
25
  ];
17
26
  /**
@@ -30,6 +39,10 @@ export function errorTypeToCategory(error) {
30
39
  return "build_error";
31
40
  case "TimeoutError":
32
41
  return "timeout";
42
+ case "RateLimitError":
43
+ return "rate_limit";
44
+ case "BillingError":
45
+ return "billing";
33
46
  default:
34
47
  return "unknown";
35
48
  }
@@ -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
@@ -94,13 +94,11 @@ export class LogWriter {
94
94
  throw new Error(`No active issue #${phaseLog.issueNumber}. Call startIssue() first.`);
95
95
  }
96
96
  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
- }
97
+ // #766: derive from the latest attempt of each phase (loop excluded) rather
98
+ // than pinning failure/partial forever. A timeout or failure that a later
99
+ // quality-loop iteration recovers from no longer sticks, so the JSON log
100
+ // agrees with the live card and summary table (AC-3/AC-5).
101
+ issue.status = deriveIssueLogStatus(issue.phases);
104
102
  if (this.verbose) {
105
103
  console.log(`Log phase: ${phaseLog.phase} (${phaseLog.status}) - ${phaseLog.durationSeconds.toFixed(1)}s`);
106
104
  }
@@ -25,6 +25,24 @@ 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
  */
@@ -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;
@@ -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
  /**
@@ -17,10 +17,19 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { z } from "zod";
20
+ import { ERROR_CATEGORIES } from "./error-classifier.js";
20
21
  /**
21
22
  * Outcome of a workflow run
22
23
  */
23
24
  export const RunOutcomeSchema = z.enum(["success", "partial", "failed"]);
25
+ /**
26
+ * Bounded-enum classification of the failure that ended a run (#761 AC-7).
27
+ *
28
+ * Sourced from `ERROR_CATEGORIES` so the metric can never carry free text —
29
+ * error *messages* stay excluded per the privacy contract above MetricRunSchema
30
+ * (they could contain sensitive info); a closed enum cannot.
31
+ */
32
+ export const FailureCategorySchema = z.enum(ERROR_CATEGORIES);
24
33
  /**
25
34
  * Available phases (aligned with run-log-schema.ts)
26
35
  */
@@ -81,6 +90,12 @@ export const MetricRunSchema = z.object({
81
90
  model: z.string(),
82
91
  /** CLI flags used (e.g., ["--chain", "--sequential"]) */
83
92
  flags: z.array(z.string()),
93
+ /**
94
+ * Category of the failure that ended the run (#761 AC-7). Optional and
95
+ * enum-only; absent on success and on records written before this field
96
+ * existed (additive — no `version` bump required).
97
+ */
98
+ failureCategory: FailureCategorySchema.optional(),
84
99
  /** Aggregate metrics */
85
100
  metrics: RunMetricsSchema,
86
101
  });
@@ -121,6 +136,7 @@ export function createMetricRun(options) {
121
136
  duration: options.duration,
122
137
  model: options.model ?? "unknown",
123
138
  flags: options.flags ?? [],
139
+ failureCategory: options.failureCategory,
124
140
  metrics: {
125
141
  tokensUsed: options.metrics?.tokensUsed ?? 0,
126
142
  filesChanged: options.metrics?.filesChanged ?? 0,
@@ -20,7 +20,7 @@
20
20
  * });
21
21
  * ```
22
22
  */
23
- import { type Metrics, type MetricRun, type MetricPhase, type RunOutcome, type RunMetrics } from "./metrics-schema.js";
23
+ import { type Metrics, type MetricRun, type MetricPhase, type RunOutcome, type RunMetrics, type FailureCategory } from "./metrics-schema.js";
24
24
  export interface MetricsWriterOptions {
25
25
  /** Path to metrics file (default: .sequant/metrics.json) */
26
26
  metricsPath?: string;
@@ -69,6 +69,7 @@ export declare class MetricsWriter {
69
69
  duration: number;
70
70
  model?: string;
71
71
  flags?: string[];
72
+ failureCategory?: FailureCategory;
72
73
  metrics?: Partial<RunMetrics>;
73
74
  }): Promise<MetricRun>;
74
75
  /**
@@ -11,6 +11,7 @@ import { ShutdownManager } from "../shutdown.js";
11
11
  import { Phase, ExecutionConfig, PhaseResult, QaVerdict, PhasePauseHandle } from "./types.js";
12
12
  import type { QaSummary } from "./run-log-schema.js";
13
13
  import type { AgentPhaseResult, ResumeHandle } from "./drivers/index.js";
14
+ import { SequantError } from "../errors.js";
14
15
  /**
15
16
  * Leading + trailing throttle. Fires the wrapped callback immediately on the
16
17
  * first call, drops subsequent calls that arrive inside `intervalMs` but
@@ -35,6 +36,37 @@ export declare function createThrottledReporter(fn: (text: string) => void, inte
35
36
  export declare const SPEC_RETRY_BACKOFF_MS: number;
36
37
  /** @internal Exported for testing only */
37
38
  export declare const SPEC_EXTRA_RETRIES: number;
39
+ /**
40
+ * A rate limit whose window resets further out than this is treated as
41
+ * exhausted rather than transient (#761 AC-2): no retry can succeed inside a
42
+ * closed window, so consuming cold-start retries (each burning up to a full
43
+ * `phaseTimeout`) only delays the labeled halt. Five minutes comfortably
44
+ * exceeds any backoff this executor performs while staying far below the
45
+ * five-hour/seven-day windows the check exists to catch.
46
+ *
47
+ * @internal Exported for testing only
48
+ */
49
+ export declare const RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS: number;
50
+ /**
51
+ * Base backoff for transient rate-limit retries (#761 AC-4), doubled per
52
+ * attempt (5s, 10s). Same scale as `SPEC_RETRY_BACKOFF_MS` — long enough to
53
+ * outlive a momentary throttle, short enough to be negligible next to a
54
+ * phase's runtime.
55
+ *
56
+ * @internal Exported for testing only
57
+ */
58
+ export declare const RATE_LIMIT_RETRY_BACKOFF_MS = 5000;
59
+ /**
60
+ * True when a failure is a rate limit whose reset lies beyond
61
+ * {@link RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS} — i.e. window exhaustion, not a
62
+ * transient throttle. Metadata-absent rate limits (the assistant-error channel
63
+ * carries no `resetsAt`, see #761 AC-9) return false and fall through to the
64
+ * transient path: with no timing signal, retry-with-backoff is the safe
65
+ * default, skipping all retries is not.
66
+ *
67
+ * @internal Exported for testing only
68
+ */
69
+ export declare function isWindowExhaustedRateLimit(error: SequantError | undefined, now?: number): boolean;
38
70
  export declare function parseQaVerdict(output: string): QaVerdict | null;
39
71
  /**
40
72
  * Parse condensed QA summary from QA phase output (#434).