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
@@ -10,7 +10,9 @@
10
10
  import chalk from "chalk";
11
11
  import { ui, colors } from "../lib/cli-ui.js";
12
12
  import { renderRunSummary } from "../lib/cli-ui/run-renderer.js";
13
+ import { BillingError, RateLimitError, formatRateLimitMessage, isBillingFailure, } from "../lib/errors.js";
13
14
  import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
15
+ import { LOOP_PHASE } from "../lib/workflow/status-derivation.js";
14
16
  /**
15
17
  * Print pre-run config block.
16
18
  *
@@ -67,7 +69,19 @@ export function displayConfig(r) {
67
69
  * Convert workflow `IssueResult` to renderer `IssueSummary`.
68
70
  */
69
71
  function toIssueSummary(r) {
70
- const failedPhase = r.phaseResults.find((p) => !p.success);
72
+ // #766: the reason to show is the LAST failing attempt, not the first.
73
+ // `phaseResults` accumulates every attempt across every quality-loop
74
+ // iteration, so `.find()` (first-wins) rendered a stale first-iteration
75
+ // reason: #762's cell read `Timeout after 1800s` when its real last failure
76
+ // was an API drop. `verdict`/`unmetCount` below hang off the same entry, so
77
+ // they were stale for the same reason. `loop` is excluded on the same grounds
78
+ // the card and log exclude it (see `status-derivation.ts`) — it is auxiliary
79
+ // recovery, and a trailing loop failure would mask the phase that actually
80
+ // failed. Reverse scan rather than `findLast`: tsconfig pins `lib: ES2022`
81
+ // and `findLast` is ES2023.
82
+ const failedPhase = [...r.phaseResults]
83
+ .reverse()
84
+ .find((p) => !p.success && p.phase !== LOOP_PHASE);
71
85
  const summary = {
72
86
  issueNumber: r.issueNumber,
73
87
  success: r.success,
@@ -91,6 +105,48 @@ function toIssueSummary(r) {
91
105
  }
92
106
  return summary;
93
107
  }
108
+ /**
109
+ * Detect a chain halted by a rate-limit/billing failure and build the summary
110
+ * notice for it (#761 AC-5). Returns null when the run wasn't a chain, no
111
+ * issue failed, or the halting failure wasn't rate-limit-classified.
112
+ *
113
+ * Extracted from `displaySummary` so the halt-and-print decision is testable
114
+ * standalone — the same treatment #760 gave `planChainResumeFromState` when it
115
+ * hit the executeSequential testability wall.
116
+ *
117
+ * The failing phase is found with the same reverse non-loop scan as
118
+ * `toIssueSummary` (#766): the classification must describe the LAST attempt,
119
+ * not a stale first-iteration failure.
120
+ *
121
+ * @internal Exported for testing
122
+ */
123
+ export function buildRateLimitHaltNotice(results, chainEnabled) {
124
+ if (!chainEnabled)
125
+ return null;
126
+ // Chain mode halts at the first failed link, so at most one failed issue
127
+ // exists; scan defensively anyway.
128
+ for (const r of results) {
129
+ if (r.success)
130
+ continue;
131
+ const failedPhase = [...r.phaseResults]
132
+ .reverse()
133
+ .find((p) => !p.success && p.phase !== LOOP_PHASE);
134
+ const err = failedPhase?.structuredError;
135
+ if (err instanceof RateLimitError || err instanceof BillingError) {
136
+ // Event-derived errors get their message from formatRateLimitMessage, so
137
+ // re-deriving from metadata is exact and includes resetsAt. But errors
138
+ // from the assistant-error channel carry no billing/reset metadata —
139
+ // re-deriving there would mislabel a BillingError as "Rate limited"
140
+ // (isBillingFailure({}) is false) — so fall back to the typed message
141
+ // when the metadata carries no signal.
142
+ const label = err.metadata.resetsAt !== undefined || isBillingFailure(err.metadata)
143
+ ? formatRateLimitMessage(err.metadata)
144
+ : err.message;
145
+ return { issueNumber: r.issueNumber, label };
146
+ }
147
+ }
148
+ return null;
149
+ }
94
150
  /**
95
151
  * Print post-run summary: per-issue grid, log path, reflection, tips.
96
152
  *
@@ -120,6 +176,29 @@ export function displaySummary(result, renderer) {
120
176
  dryRun: config.dryRun,
121
177
  });
122
178
  }
179
+ // #760: a chain link whose checkpoint commit failed keeps its own work but
180
+ // loses the recovery point resume depends on, and the per-issue warning has
181
+ // long scrolled past by now on a multi-hour chain. Restate it at the summary,
182
+ // where the user is actually looking, so the next run's fail-fast is expected.
183
+ const checkpointFailures = results.filter((r) => r.checkpointFailed);
184
+ if (checkpointFailures.length > 0) {
185
+ console.log(colors.warning(` ⚠️ Checkpoint commit failed for ${checkpointFailures
186
+ .map((r) => `#${r.issueNumber}`)
187
+ .join(", ")} — uncommitted work is missing from the feature branch.`));
188
+ console.log(colors.muted(" Resuming this chain will stop at that link until the work is committed (or use --force)."));
189
+ console.log("");
190
+ }
191
+ // #761: a chain halted by a rate limit already stopped at the right link,
192
+ // but the labeled cause has long scrolled past by now (same rationale as the
193
+ // #760 restatement above), and #760 added no resume flag — resume IS
194
+ // re-running the identical command. Say both explicitly, or the halt reads
195
+ // as a bug and the resume path stays undiscovered.
196
+ const rateLimitHalt = buildRateLimitHaltNotice(results, mergedOptions.chain === true);
197
+ if (rateLimitHalt) {
198
+ console.log(colors.warning(` ⚠️ ${rateLimitHalt.label} — chain halted at #${rateLimitHalt.issueNumber}.`));
199
+ console.log(colors.muted(` Re-run the same command to resume from #${rateLimitHalt.issueNumber} (no flag needed; completed links are skipped).`));
200
+ console.log("");
201
+ }
123
202
  if (mergedOptions.reflect && results.length > 0) {
124
203
  const reflection = analyzeRun({
125
204
  results,
@@ -263,6 +263,35 @@ function loadMetrics() {
263
263
  return null;
264
264
  }
265
265
  }
266
+ /**
267
+ * Compute the failure-category breakdown over runs that recorded a failure —
268
+ * outcome "failed" (every issue failed) or "partial" (at least one issue
269
+ * failed) (#783).
270
+ *
271
+ * NOTE — deviation from AC-1's literal "over failed runs" wording: partial runs
272
+ * also carry a failureCategory (recorded whenever >=1 issue fails) and are
273
+ * genuine signal for "what's killing my runs", so they are counted here by
274
+ * explicit user decision. Success runs never carry the field and are excluded.
275
+ *
276
+ * Runs without the field (pre-#761 records, or a partial whose failure was
277
+ * never categorized) are bucketed as "unclassified" — never dropped and never
278
+ * conflated with the "unknown" enum value.
279
+ *
280
+ * Pure function of the passed runs array, so any upstream cohort filtering is
281
+ * automatically respected (computed after filtering, not before).
282
+ */
283
+ function calculateFailureCategoryBreakdown(runs) {
284
+ const counts = new Map();
285
+ for (const run of runs) {
286
+ if (run.outcome === "success")
287
+ continue;
288
+ const category = run.failureCategory ?? "unclassified";
289
+ counts.set(category, (counts.get(category) ?? 0) + 1);
290
+ }
291
+ return [...counts.entries()]
292
+ .map(([category, count]) => ({ category, count }))
293
+ .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
294
+ }
266
295
  /**
267
296
  * Calculate analytics from metrics
268
297
  */
@@ -274,6 +303,7 @@ function calculateMetricsAnalytics(metrics) {
274
303
  successCount: 0,
275
304
  partialCount: 0,
276
305
  failedCount: 0,
306
+ failureCategories: [],
277
307
  successRate: 0,
278
308
  avgTokensPerRun: 0,
279
309
  avgFilesChanged: 0,
@@ -294,6 +324,7 @@ function calculateMetricsAnalytics(metrics) {
294
324
  const successCount = runs.filter((r) => r.outcome === "success").length;
295
325
  const partialCount = runs.filter((r) => r.outcome === "partial").length;
296
326
  const failedCount = runs.filter((r) => r.outcome === "failed").length;
327
+ const failureCategories = calculateFailureCategoryBreakdown(runs);
297
328
  const successRate = (successCount / runs.length) * 100;
298
329
  const avgTokensPerRun = runs.reduce((sum, r) => sum + r.metrics.tokensUsed, 0) / runs.length;
299
330
  const avgFilesChanged = runs.reduce((sum, r) => sum + r.metrics.filesChanged, 0) / runs.length;
@@ -323,6 +354,7 @@ function calculateMetricsAnalytics(metrics) {
323
354
  successCount,
324
355
  partialCount,
325
356
  failedCount,
357
+ failureCategories,
326
358
  successRate,
327
359
  avgTokensPerRun,
328
360
  avgFilesChanged,
@@ -415,6 +447,19 @@ function displayMetricsAnalytics(analytics) {
415
447
  const failedRate = (analytics.failedCount / total) * 100;
416
448
  console.log(` ${colors.error("\u2717 Failed")} ${analytics.failedCount} (${failedRate.toFixed(0)}%) ${failedBar}`);
417
449
  }
450
+ // Failure-category breakdown (#783) \u2014 adjacent to the outcome bars above.
451
+ // Counts runs that recorded a failure (outcome "failed" or "partial"); hidden
452
+ // entirely when there are none (AC-3).
453
+ if (analytics.failureCategories.length > 0) {
454
+ const failureRunCount = analytics.failureCategories.reduce((sum, b) => sum + b.count, 0);
455
+ const runNoun = failureRunCount === 1 ? "run with a failure" : "runs with a failure";
456
+ console.log(ui.sectionHeader(`Failure Categories (${failureRunCount} ${runNoun})`));
457
+ const pad = Math.max(...analytics.failureCategories.map((b) => b.category.length));
458
+ for (const bucket of analytics.failureCategories) {
459
+ const pct = ((bucket.count / failureRunCount) * 100).toFixed(0);
460
+ console.log(` ${bucket.category.padEnd(pad)} ${bucket.count} (${pct}%)`);
461
+ }
462
+ }
418
463
  // Averages table
419
464
  console.log(ui.sectionHeader("Averages"));
420
465
  const avgData = {};
@@ -653,6 +698,8 @@ export async function statsCommand(options) {
653
698
  partialCount: analytics.partialCount,
654
699
  failedCount: analytics.failedCount,
655
700
  successRate: analytics.successRate,
701
+ // Failure-category breakdown over failed runs (#783 AC-5)
702
+ failureCategories: analytics.failureCategories,
656
703
  avgTokensPerRun: analytics.avgTokensPerRun,
657
704
  avgFilesChanged: analytics.avgFilesChanged,
658
705
  avgLinesAdded: analytics.avgLinesAdded,
@@ -22,12 +22,28 @@
22
22
  * users to ignore the warning.
23
23
  */
24
24
  export declare const EXCLUDED_PATHS: ReadonlySet<string>;
25
+ /**
26
+ * Markdown H1/H2 section headings whose bodies are background/citation prose,
27
+ * not statements of intent-to-modify. A path named *only* under one of these
28
+ * is a reference to existing code, not a file the issue will touch — so the
29
+ * whole section is stripped before path extraction.
30
+ *
31
+ * Match is case-insensitive and prefix-based, so `## Motivation — concrete
32
+ * recent miss` and `## Additional context (see #533)` both match. This is the
33
+ * cheap version of the #556 proximity-weighting mitigation (#769): exclude
34
+ * background sections wholesale rather than scoring per-path distance to AC
35
+ * bullets. "A path appears only in background" ⟺ "the path is absent from the
36
+ * foreground", so stripping these sections up front subsumes per-path tagging.
37
+ */
38
+ export declare const BACKGROUND_SECTIONS: ReadonlySet<string>;
25
39
  /**
26
40
  * Extract the set of file paths an issue body identifies as
27
41
  * targets-of-modification.
28
42
  *
29
43
  * Strategy:
30
- * 1. Strip fenced code blocks and HTML comments (AC-5 guard).
44
+ * 1. Strip fenced code blocks and HTML comments (AC-5 guard), then strip
45
+ * background/citation sections (#769) so a path named only under
46
+ * `## References`, `## Context`, etc. isn't counted as a target.
31
47
  * 2. Pull every backtick-quoted path matching the source-tree regex,
32
48
  * normalizing skill-mirror paths to their canonical bare form.
33
49
  * 3. If the body mentions "3-dir sync", also pull bare
@@ -71,7 +87,8 @@ export declare function detectFileCollisions(issuePaths: Map<number, Set<string>
71
87
  * - `chainSuggestion` — emitted only when ≥3 issues collide on the same
72
88
  * file (AC-4); suggest-only, never auto-applied. Annotated with the
73
89
  * historical chain-mode success rate at length≥3 (1/6 = 17%, per #604
74
- * forensics) so users can weigh chain mode against the parallel default.
90
+ * forensics the entire sample predates the #748/#749 fixes) so users
91
+ * can weigh chain mode against the parallel default.
75
92
  */
76
93
  export interface CollisionAnnotations {
77
94
  orderLines: string[];
@@ -27,6 +27,26 @@ export const EXCLUDED_PATHS = new Set([
27
27
  "yarn.lock",
28
28
  "pnpm-lock.yaml",
29
29
  ]);
30
+ /**
31
+ * Markdown H1/H2 section headings whose bodies are background/citation prose,
32
+ * not statements of intent-to-modify. A path named *only* under one of these
33
+ * is a reference to existing code, not a file the issue will touch — so the
34
+ * whole section is stripped before path extraction.
35
+ *
36
+ * Match is case-insensitive and prefix-based, so `## Motivation — concrete
37
+ * recent miss` and `## Additional context (see #533)` both match. This is the
38
+ * cheap version of the #556 proximity-weighting mitigation (#769): exclude
39
+ * background sections wholesale rather than scoring per-path distance to AC
40
+ * bullets. "A path appears only in background" ⟺ "the path is absent from the
41
+ * foreground", so stripping these sections up front subsumes per-path tagging.
42
+ */
43
+ export const BACKGROUND_SECTIONS = new Set([
44
+ "references",
45
+ "context",
46
+ "motivation",
47
+ "additional context",
48
+ "see also",
49
+ ]);
30
50
  /**
31
51
  * Slash-command names recognized as references to a skill's SKILL.md.
32
52
  * Used by the slash-command-skill derivation rule when an issue body
@@ -83,10 +103,52 @@ const THREE_DIR_SYNC_PATTERN = /3[- ]dir(?:ectory)?\s+sync|across\s+all\s+three\
83
103
  * single-backtick wrapper, so this gives us the "paths quoted as code in
84
104
  * prose count, paths inside a code block don't" behavior the AC-5 guard
85
105
  * specifies.
106
+ *
107
+ * Do NOT unify this with `dependency-markers.ts:stripCodeAndComments`, which
108
+ * looks nearly identical but additionally strips inline spans. The two have
109
+ * opposite requirements for the same syntax: there a backticked marker is a
110
+ * documentation example to discard, here a backticked path is the entire
111
+ * signal. Adding its inline-span strip to this function would delete every
112
+ * path PATH_REGEX is looking for and silently return an empty set.
86
113
  */
87
114
  function stripCodeBlocksAndComments(body) {
88
115
  return body.replace(/```[\s\S]*?```/g, "").replace(/<!--[\s\S]*?-->/g, "");
89
116
  }
117
+ /**
118
+ * Remove background/citation sections (see `BACKGROUND_SECTIONS`) so a path
119
+ * named only as a reference — under `## References`, `## Context`, etc. — is
120
+ * not counted as a modification target (#769).
121
+ *
122
+ * A section runs from its H1/H2 heading to the next H1/H2 heading (or EOF);
123
+ * `###`+ subsections belong to their parent section and are dropped with it.
124
+ * Only H1/H2 headings toggle sections — a `### Detail` inside a stripped
125
+ * `## Additional context` must not resurrect the rest of the section.
126
+ *
127
+ * Run this *after* `stripCodeBlocksAndComments` so a fenced block containing a
128
+ * `## `-prefixed line can't be misread as a real heading.
129
+ */
130
+ function stripBackgroundSections(body) {
131
+ const kept = [];
132
+ let stripping = false;
133
+ for (const line of body.split("\n")) {
134
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
135
+ if (heading && heading[1].length <= 2) {
136
+ const text = heading[2].trim().toLowerCase();
137
+ stripping = false;
138
+ for (const section of BACKGROUND_SECTIONS) {
139
+ if (text.startsWith(section)) {
140
+ stripping = true;
141
+ break;
142
+ }
143
+ }
144
+ if (stripping)
145
+ continue; // drop the heading line itself
146
+ }
147
+ if (!stripping)
148
+ kept.push(line);
149
+ }
150
+ return kept.join("\n");
151
+ }
90
152
  /**
91
153
  * Collapse a fully-qualified skill-mirror path to its canonical bare form.
92
154
  *
@@ -109,7 +171,9 @@ function normalizeSkillMirrorPath(path) {
109
171
  * targets-of-modification.
110
172
  *
111
173
  * Strategy:
112
- * 1. Strip fenced code blocks and HTML comments (AC-5 guard).
174
+ * 1. Strip fenced code blocks and HTML comments (AC-5 guard), then strip
175
+ * background/citation sections (#769) so a path named only under
176
+ * `## References`, `## Context`, etc. isn't counted as a target.
113
177
  * 2. Pull every backtick-quoted path matching the source-tree regex,
114
178
  * normalizing skill-mirror paths to their canonical bare form.
115
179
  * 3. If the body mentions "3-dir sync", also pull bare
@@ -125,7 +189,7 @@ function normalizeSkillMirrorPath(path) {
125
189
  */
126
190
  export function extractPathsFromIssueBody(body) {
127
191
  const paths = new Set();
128
- const cleaned = stripCodeBlocksAndComments(body);
192
+ const cleaned = stripBackgroundSections(stripCodeBlocksAndComments(body));
129
193
  for (const m of cleaned.matchAll(PATH_REGEX)) {
130
194
  paths.add(normalizeSkillMirrorPath(m[1]));
131
195
  }
@@ -208,9 +272,9 @@ export function formatCollisionAnnotations(results) {
208
272
  if (r.issues.length >= 3 && !chainSuggestion) {
209
273
  const ids = r.issues.join(" ");
210
274
  chainSuggestion =
211
- `Chain: npx sequant run ${ids} --chain --qa-gate -Q ` +
275
+ `Chain: npx sequant run ${ids} --chain -Q ` +
212
276
  `# alternative — ${r.issues.length} issues modify ${r.file} ` +
213
- `(chain length≥3 historically 1/6 = 17%; see docs/reference/chain-mode-analysis-2026-05.md)`;
277
+ `(chain length≥3 historically 1/6 = 17%, predates the #748/#749 fixes; see docs/reference/chain-mode-analysis-2026-05.md)`;
214
278
  }
215
279
  }
216
280
  return { orderLines, warnings, chainSuggestion };
@@ -19,6 +19,7 @@ import chalk from "chalk";
19
19
  import logUpdate from "log-update";
20
20
  import stringWidth from "string-width";
21
21
  import { formatElapsedTime, formatTimestamp } from "./format.js";
22
+ import { pipelineHasFailed } from "../workflow/status-derivation.js";
22
23
  const DEFAULT_LIVE_TICK_MS = 1000;
23
24
  const DEFAULT_NON_TTY_HEARTBEAT_MS = 60_000;
24
25
  const NARROW_TERMINAL_THRESHOLD = 80;
@@ -248,8 +249,13 @@ class BaseRenderer {
248
249
  else if (phase.startedAt !== undefined) {
249
250
  phase.durationMs = this.now() - phase.startedAt;
250
251
  }
251
- state.status = "failed";
252
- state.completedAt = this.now();
252
+ // #766: derive from the phase slots (loop excluded) instead of pinning
253
+ // `failed`, so a loop failure on an early quality-loop iteration doesn't
254
+ // stick after a later iteration recovers. Mirrors the orchestrator's card.
255
+ const nowFailed = pipelineHasFailed(state.phases);
256
+ state.status = nowFailed ? "failed" : "running";
257
+ if (nowFailed)
258
+ state.completedAt = this.now();
253
259
  state.currentPhase = undefined;
254
260
  if (event.error !== undefined)
255
261
  state.failureReason = event.error;
@@ -267,20 +273,22 @@ class BaseRenderer {
267
273
  }
268
274
  /** Mark an issue done after PR is recorded — derived from phase completion. */
269
275
  maybeMarkIssueDone(state) {
270
- if (state.status === "failed")
271
- return;
272
276
  const allTerminal = state.phases.every((p) => p.status === "done" || p.status === "failed");
273
277
  if (allTerminal && state.phases.length > 0) {
274
- state.status = state.phases.some((p) => p.status === "failed")
275
- ? "failed"
276
- : "done";
278
+ // #766: derive the verdict (loop excluded) so a run that failed the loop
279
+ // on an early iteration and then recovered every planned phase resolves
280
+ // to `done`. No early `failed` guard: a stale loop failure must be able
281
+ // to de-escalate once the pipeline recovers.
282
+ state.status = pipelineHasFailed(state.phases) ? "failed" : "done";
277
283
  state.completedAt = this.now();
278
284
  }
279
285
  }
280
286
  // ------------ Hooks for subclasses ------------
281
287
  afterEvent(_event, state) {
282
- if (state.status !== "failed")
283
- this.maybeMarkIssueDone(state);
288
+ // #766: always re-derive — `maybeMarkIssueDone` guards internally and must
289
+ // run even when `state.status` is currently `failed` so a recovered loop
290
+ // failure can de-escalate to `done`.
291
+ this.maybeMarkIssueDone(state);
284
292
  this.afterStateChange();
285
293
  }
286
294
  afterStateChange() {
@@ -156,6 +156,12 @@ export declare function isBillingFailure(info: RateLimitInfoLike): boolean;
156
156
  * to an unrelated phase failure.
157
157
  */
158
158
  export declare function isRateLimitFailureInfo(info: RateLimitInfoLike): boolean;
159
+ /**
160
+ * Normalize a `resetsAt` timestamp to milliseconds. The SDK does not pin the
161
+ * unit, so use the same heuristic everywhere a `resetsAt` is compared or
162
+ * displayed: values below ~1e12 are seconds, otherwise milliseconds.
163
+ */
164
+ export declare function resetsAtToMs(resetsAt: number): number;
159
165
  /**
160
166
  * Build a user-facing message from rate-limit info, naming the real cause:
161
167
  * - billing/credits → "Out of credits" (enriched with purchasable vs hard
@@ -123,6 +123,14 @@ export function isBillingFailure(info) {
123
123
  export function isRateLimitFailureInfo(info) {
124
124
  return info.status === "rejected" || isBillingFailure(info);
125
125
  }
126
+ /**
127
+ * Normalize a `resetsAt` timestamp to milliseconds. The SDK does not pin the
128
+ * unit, so use the same heuristic everywhere a `resetsAt` is compared or
129
+ * displayed: values below ~1e12 are seconds, otherwise milliseconds.
130
+ */
131
+ export function resetsAtToMs(resetsAt) {
132
+ return resetsAt < 1e12 ? resetsAt * 1000 : resetsAt;
133
+ }
126
134
  /**
127
135
  * Format a Unix timestamp (seconds or ms) as a local time string.
128
136
  *
@@ -133,8 +141,7 @@ export function isRateLimitFailureInfo(info) {
133
141
  * included whenever the reset is not today.
134
142
  */
135
143
  function formatResetTime(resetsAt) {
136
- // Heuristic: values below ~1e12 are seconds, otherwise milliseconds.
137
- const ms = resetsAt < 1e12 ? resetsAt * 1000 : resetsAt;
144
+ const ms = resetsAtToMs(resetsAt);
138
145
  const d = new Date(ms);
139
146
  const hh = String(d.getHours()).padStart(2, "0");
140
147
  const mm = String(d.getMinutes()).padStart(2, "0");
@@ -2,6 +2,7 @@
2
2
  * Manifest management for tracking installed version
3
3
  */
4
4
  import { readFile, writeFile, fileExists } from "./fs.js";
5
+ import { compareVersions } from "./version-check.js";
5
6
  import { fileURLToPath } from "url";
6
7
  import { dirname, resolve } from "path";
7
8
  import { readFileSync } from "fs";
@@ -32,23 +33,6 @@ const PACKAGE_VERSION = pkg.version;
32
33
  export function getPackageVersion() {
33
34
  return PACKAGE_VERSION;
34
35
  }
35
- /**
36
- * Compare two semver versions.
37
- * Returns: 1 if a > b, -1 if a < b, 0 if equal
38
- */
39
- function compareVersions(a, b) {
40
- const partsA = a.split(".").map(Number);
41
- const partsB = b.split(".").map(Number);
42
- for (let i = 0; i < 3; i++) {
43
- const numA = partsA[i] || 0;
44
- const numB = partsB[i] || 0;
45
- if (numA > numB)
46
- return 1;
47
- if (numA < numB)
48
- return -1;
49
- }
50
- return 0;
51
- }
52
36
  export async function getManifest() {
53
37
  if (!(await fileExists(MANIFEST_PATH))) {
54
38
  return null;
@@ -214,16 +214,13 @@ export function isCacheFresh(cache) {
214
214
  * Fetch the latest version from npm registry with timeout
215
215
  */
216
216
  export async function fetchLatestVersion() {
217
- const controller = new AbortController();
218
- const timeoutId = setTimeout(() => controller.abort(), VERSION_CHECK_TIMEOUT);
219
217
  try {
220
218
  const response = await fetch(NPM_REGISTRY_URL, {
221
- signal: controller.signal,
219
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT),
222
220
  headers: {
223
221
  Accept: "application/json",
224
222
  },
225
223
  });
226
- clearTimeout(timeoutId);
227
224
  if (!response.ok) {
228
225
  return null;
229
226
  }
@@ -231,7 +228,6 @@ export async function fetchLatestVersion() {
231
228
  return data.version || null;
232
229
  }
233
230
  catch {
234
- clearTimeout(timeoutId);
235
231
  return null;
236
232
  }
237
233
  }
@@ -8,6 +8,7 @@
8
8
  * creation).
9
9
  */
10
10
  import { ExecutionConfig, PhaseResult, IssueResult, type RunOptions, type IssueExecutionContext, type BatchExecutionContext, type ProgressCallback } from "./types.js";
11
+ import { type ErrorCategory } from "./error-classifier.js";
11
12
  export type { RunOptions, ProgressCallback, IssueExecutionContext, BatchExecutionContext, } from "./types.js";
12
13
  /**
13
14
  * Emit a structured progress line to stderr for MCP progress notifications.
@@ -73,4 +74,16 @@ export declare function parseBatches(batchArgs: string[]): number[][];
73
74
  */
74
75
  export declare function getEnvConfig(): Partial<RunOptions>;
75
76
  export declare function executeBatch(issueNumbers: number[], batchCtx: BatchExecutionContext): Promise<IssueResult[]>;
77
+ /**
78
+ * Derive the bounded-enum failure category for a failed issue (#761 AC-7).
79
+ *
80
+ * Scans for the LAST non-loop failing phase — the same reverse scan
81
+ * `toIssueSummary` uses (#766), so the recorded category and the displayed
82
+ * failure reason describe the same attempt. Prefers the driver's structured
83
+ * cause over stderr-regex classification (#732). Returns only the enum value;
84
+ * message strings never leave this function (metrics privacy contract).
85
+ *
86
+ * @internal Exported for testing
87
+ */
88
+ export declare function deriveFailureCategory(phaseResults: PhaseResult[]): ErrorCategory | undefined;
76
89
  export declare function runIssueWithLogging(ctx: IssueExecutionContext): Promise<IssueResult>;