sequant 2.3.0 → 2.4.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +8 -5
  4. package/dist/bin/cli.js +46 -4
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/prompt.d.ts +7 -0
  8. package/dist/src/commands/prompt.js +101 -7
  9. package/dist/src/commands/run-progress.d.ts +11 -1
  10. package/dist/src/commands/run-progress.js +20 -3
  11. package/dist/src/commands/run.js +12 -2
  12. package/dist/src/commands/watch.d.ts +2 -0
  13. package/dist/src/commands/watch.js +67 -3
  14. package/dist/src/lib/assess-collision-detect.js +1 -1
  15. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  16. package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
  17. package/dist/src/lib/cli-ui/run-renderer.js +231 -14
  18. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  19. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  20. package/dist/src/lib/merge-check/types.js +1 -1
  21. package/dist/src/lib/relay/archive.js +6 -0
  22. package/dist/src/lib/relay/types.d.ts +2 -0
  23. package/dist/src/lib/relay/types.js +9 -0
  24. package/dist/src/lib/workflow/batch-executor.js +34 -18
  25. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  26. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  27. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  28. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  29. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  30. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  31. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  32. package/dist/src/lib/workflow/event-emitter.js +102 -0
  33. package/dist/src/lib/workflow/notice.d.ts +32 -0
  34. package/dist/src/lib/workflow/notice.js +38 -0
  35. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  36. package/dist/src/lib/workflow/phase-executor.js +88 -115
  37. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  38. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  39. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  40. package/dist/src/lib/workflow/phase-registry.js +233 -0
  41. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  42. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  43. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  44. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  45. package/dist/src/lib/workflow/state-manager.js +27 -1
  46. package/dist/src/lib/workflow/state-schema.d.ts +20 -35
  47. package/dist/src/lib/workflow/state-schema.js +28 -3
  48. package/dist/src/lib/workflow/types.d.ts +65 -15
  49. package/dist/src/lib/workflow/types.js +18 -13
  50. package/package.json +5 -4
  51. package/templates/hooks/post-tool.sh +81 -0
  52. package/templates/skills/assess/SKILL.md +28 -28
  53. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  54. package/templates/skills/setup/SKILL.md +6 -6
@@ -15,6 +15,7 @@ import { LogWriter } from "./log-writer.js";
15
15
  import { StateManager } from "./state-manager.js";
16
16
  import { ShutdownManager } from "../shutdown.js";
17
17
  import { LockManager, formatLockedMessage } from "../locks/index.js";
18
+ import { bracketedConsoleLog } from "./notice.js";
18
19
  /** Human-readable line for the run-orchestrator's `--signal-other` log (#637). */
19
20
  function formatSignalLine(issue, pid, result) {
20
21
  switch (result.reason) {
@@ -36,6 +37,7 @@ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, em
36
37
  import { reconcileStateAtStartup } from "./state-utils.js";
37
38
  import { getCommitHash } from "./git-diff-utils.js";
38
39
  import { MetricsWriter } from "./metrics-writer.js";
40
+ import { WorkflowEventEmitter } from "./event-emitter.js";
39
41
  import { determineOutcome } from "./metrics-schema.js";
40
42
  import { getTokenUsageForRun } from "./token-utils.js";
41
43
  import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
@@ -65,12 +67,31 @@ export class RunOrchestrator {
65
67
  cfg;
66
68
  issueStates = new Map();
67
69
  phaseStartTimes = new Map();
70
+ emitter;
68
71
  done = false;
69
72
  constructor(config) {
70
73
  this.validate(config);
74
+ // Build the event emitter before wrapProgress so the wrapper can route
75
+ // status transitions through `issue_status_changed` events (AC-3).
76
+ this.emitter = new WorkflowEventEmitter({
77
+ onListenerError: (event, error) => {
78
+ // Mirror the orchestrator's verbose-gated non-fatal warning style.
79
+ // Listener failures must never propagate to the run.
80
+ logNonFatalWarning(` ! Event listener for "${event}" threw, ignoring`, error, config.config?.verbose ?? false);
81
+ },
82
+ });
71
83
  this.cfg = { ...config, onProgress: this.wrapProgress(config.onProgress) };
72
84
  this.initIssueStates();
73
85
  }
86
+ /**
87
+ * Returns the workflow event emitter. External consumers (TUI, MCP server,
88
+ * future webhooks) call `getEmitter().on(...)` to subscribe to lifecycle
89
+ * events. Subscribing is opt-in — the orchestrator runs unaware of who is
90
+ * listening (#504, AC-3).
91
+ */
92
+ getEmitter() {
93
+ return this.emitter;
94
+ }
74
95
  /**
75
96
  * Point-in-time view of the entire run.
76
97
  *
@@ -97,9 +118,14 @@ export class RunOrchestrator {
97
118
  capturedAt: new Date(),
98
119
  };
99
120
  }
100
- /** Mark the run as completed so the dashboard can unmount. */
121
+ /**
122
+ * Mark the run as completed so the dashboard can unmount and drop event
123
+ * subscribers. Drains the emitter to prevent leaks across multiple
124
+ * `run()` invocations in the same process (e.g. the MCP server).
125
+ */
101
126
  markDone() {
102
127
  this.done = true;
128
+ this.emitter.removeAllListeners();
103
129
  }
104
130
  initIssueStates() {
105
131
  const { issueInfoMap, worktreeMap, config } = this.cfg;
@@ -129,6 +155,7 @@ export class RunOrchestrator {
129
155
  if (!state)
130
156
  return;
131
157
  if (event === "start") {
158
+ const wasStatus = state.status;
132
159
  if (!state.startedAt)
133
160
  state.startedAt = new Date();
134
161
  state.status = "running";
@@ -143,6 +170,19 @@ export class RunOrchestrator {
143
170
  const p = findOrAppendPhase(state, phase);
144
171
  p.status = "running";
145
172
  p.startedAt = now;
173
+ // Fire-and-forget — listener safety guaranteed by the emitter (AC-5).
174
+ void this.emitter.emit("phase_started", {
175
+ issueNumber: issue,
176
+ phase,
177
+ iteration: extra?.iteration,
178
+ });
179
+ if (wasStatus !== "running") {
180
+ void this.emitter.emit("issue_status_changed", {
181
+ issueNumber: issue,
182
+ from: wasStatus,
183
+ to: "running",
184
+ });
185
+ }
146
186
  return;
147
187
  }
148
188
  if (event === "activity") {
@@ -155,6 +195,11 @@ export class RunOrchestrator {
155
195
  return;
156
196
  state.currentPhase.nowLine = line;
157
197
  state.currentPhase.lastActivityAt = new Date();
198
+ void this.emitter.emit("progress", {
199
+ issueNumber: issue,
200
+ phase,
201
+ text: line,
202
+ });
158
203
  return;
159
204
  }
160
205
  // complete / failed
@@ -170,18 +215,48 @@ export class RunOrchestrator {
170
215
  p.status = event === "complete" ? "done" : "failed";
171
216
  p.elapsedMs = elapsedMs;
172
217
  state.currentPhase = undefined;
218
+ const durationSec = elapsedMs !== undefined ? Math.round(elapsedMs / 1000) : undefined;
173
219
  if (event === "failed") {
220
+ const prev = state.status;
174
221
  state.status = "failed";
175
222
  state.completedAt = new Date();
223
+ void this.emitter.emit("phase_failed", {
224
+ issueNumber: issue,
225
+ phase,
226
+ duration: durationSec,
227
+ error: extra?.error ?? "unknown",
228
+ iteration: extra?.iteration,
229
+ });
230
+ if (prev !== "failed") {
231
+ void this.emitter.emit("issue_status_changed", {
232
+ issueNumber: issue,
233
+ from: prev,
234
+ to: "failed",
235
+ });
236
+ }
176
237
  return;
177
238
  }
239
+ void this.emitter.emit("phase_completed", {
240
+ issueNumber: issue,
241
+ phase,
242
+ duration: durationSec ?? 0,
243
+ iteration: extra?.iteration,
244
+ });
178
245
  // Completed phase: if it's the last phase in the plan, mark issue passed.
179
246
  const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
180
247
  if (allDone) {
248
+ const prev = state.status;
181
249
  state.status = state.phases.some((ph) => ph.status === "failed")
182
250
  ? "failed"
183
251
  : "passed";
184
252
  state.completedAt = new Date();
253
+ if (prev !== state.status) {
254
+ void this.emitter.emit("issue_status_changed", {
255
+ issueNumber: issue,
256
+ from: prev,
257
+ to: state.status,
258
+ });
259
+ }
185
260
  }
186
261
  }
187
262
  /**
@@ -243,7 +318,7 @@ export class RunOrchestrator {
243
318
  * issue discovery → worktree creation → execution → metrics → cleanup.
244
319
  */
245
320
  static async run(init, issueArgs, batches) {
246
- const { manifest, onProgress, settings } = init;
321
+ const { manifest, onProgress, phasePauseHandle, settings } = init;
247
322
  // ── Config resolution ──────────────────────────────────────────────
248
323
  const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
249
324
  const { mergedOptions, config, baseBranch } = resolved;
@@ -450,18 +525,24 @@ export class RunOrchestrator {
450
525
  packageManager: manifest.packageManager,
451
526
  baseBranch,
452
527
  onProgress,
528
+ onPhasePlan: init.onPhasePlan,
529
+ phasePauseHandle,
453
530
  });
454
531
  init.onOrchestratorReady?.(orchestrator);
455
532
  try {
456
533
  if (resolvedBatches) {
457
534
  for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
458
535
  const batch = resolvedBatches[batchIdx];
459
- console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
536
+ // #647 AC-3: between-batches in a multi-batch run, the renderer is
537
+ // still alive and may have a populated live zone from the previous
538
+ // batch. Route through `bracketedConsoleLog` so log-update's cursor
539
+ // model stays consistent.
540
+ bracketedConsoleLog(phasePauseHandle, chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
460
541
  const batchResults = await orchestrator.execute(batch);
461
542
  results.push(...batchResults);
462
543
  const batchFailed = batchResults.some((r) => !r.success);
463
544
  if (batchFailed && config.sequential) {
464
- console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
545
+ bracketedConsoleLog(phasePauseHandle, chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
465
546
  break;
466
547
  }
467
548
  }
@@ -546,6 +627,8 @@ export class RunOrchestrator {
546
627
  packageManager: this.cfg.packageManager,
547
628
  baseBranch: this.cfg.baseBranch,
548
629
  onProgress: this.cfg.onProgress,
630
+ onPhasePlan: this.cfg.onPhasePlan,
631
+ phasePauseHandle: this.cfg.phasePauseHandle,
549
632
  };
550
633
  }
551
634
  async executeSequential(issueNumbers, batchCtx, options) {
@@ -632,7 +715,7 @@ export class RunOrchestrator {
632
715
  }
633
716
  async executeOneIssue(args) {
634
717
  const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
635
- const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
718
+ const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
636
719
  const issueInfo = issueInfoMap.get(issueNumber) ?? {
637
720
  title: `Issue #${issueNumber}`,
638
721
  labels: [],
@@ -655,15 +738,46 @@ export class RunOrchestrator {
655
738
  packageManager,
656
739
  baseBranch,
657
740
  onProgress,
741
+ onPhasePlan,
742
+ phasePauseHandle,
658
743
  };
659
- const result = await runIssueWithLogging(ctx);
660
- if (logWriter && result.prNumber && result.prUrl) {
661
- logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
744
+ // Fire-and-forget orchestrator does not await listener completion on
745
+ // the lifecycle bracket events. Listener safety is the emitter's job (AC-5).
746
+ void this.emitter.emit("run_started", { issueNumber });
747
+ const issueStartedAt = Date.now();
748
+ // `run_completed` is emitted in the finally so the bracket stays
749
+ // symmetric with `run_started` even if `runIssueWithLogging` throws —
750
+ // subscribers (MCP, dashboard) can rely on every started run ending.
751
+ let result;
752
+ try {
753
+ result = await runIssueWithLogging(ctx);
754
+ // Surface QA verdicts as a dedicated event so consumers don't have to
755
+ // re-parse phase output. Emits at most once per QA phase result.
756
+ for (const pr of result.phaseResults) {
757
+ if (pr.phase === "qa" && pr.verdict) {
758
+ void this.emitter.emit("qa_verdict", {
759
+ issueNumber,
760
+ phase: "qa",
761
+ verdict: pr.verdict,
762
+ });
763
+ }
764
+ }
765
+ if (logWriter && result.prNumber && result.prUrl) {
766
+ logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
767
+ }
768
+ if (logWriter) {
769
+ logWriter.completeIssue(parallelIssueNumber);
770
+ }
771
+ return result;
662
772
  }
663
- if (logWriter) {
664
- logWriter.completeIssue(parallelIssueNumber);
773
+ finally {
774
+ const durationSec = Math.round((Date.now() - issueStartedAt) / 1000);
775
+ void this.emitter.emit("run_completed", {
776
+ issueNumber,
777
+ duration: durationSec,
778
+ success: result?.success ?? false,
779
+ });
665
780
  }
666
- return result;
667
781
  }
668
782
  static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
669
783
  const metricsWriter = new MetricsWriter({ verbose: config.verbose });
@@ -130,9 +130,27 @@ export declare class StateManager {
130
130
  */
131
131
  updateWorktreeInfo(issueNumber: number, worktree: string, branch: string): Promise<void>;
132
132
  /**
133
- * Update session ID for an issue (for resume)
133
+ * Update session ID for an issue (for resume).
134
+ *
135
+ * @deprecated Use {@link updateResumeHandle} (#674). This entry point only
136
+ * writes the legacy `sessionId` field — without an `originCwd`, the next
137
+ * phase's driver-owned `canResume()` fail-safe will decline resume, so the
138
+ * data is effectively inert. Retained for one release to keep callers from
139
+ * older sequant builds compiling.
134
140
  */
135
141
  updateSessionId(issueNumber: number, sessionId: string): Promise<void>;
142
+ /**
143
+ * Update the driver-tagged resume handle for an issue (#674).
144
+ *
145
+ * Writes both the new `resumeHandle` field and mirrors the token into
146
+ * the deprecated `sessionId` field so state files round-trip through
147
+ * older sequant readers for one release.
148
+ */
149
+ updateResumeHandle(issueNumber: number, handle: {
150
+ driver: string;
151
+ token: string;
152
+ originCwd: string;
153
+ }): Promise<void>;
136
154
  /**
137
155
  * Set or clear the relay state for an issue (#383).
138
156
  *
@@ -378,7 +378,13 @@ export class StateManager {
378
378
  }
379
379
  }
380
380
  /**
381
- * Update session ID for an issue (for resume)
381
+ * Update session ID for an issue (for resume).
382
+ *
383
+ * @deprecated Use {@link updateResumeHandle} (#674). This entry point only
384
+ * writes the legacy `sessionId` field — without an `originCwd`, the next
385
+ * phase's driver-owned `canResume()` fail-safe will decline resume, so the
386
+ * data is effectively inert. Retained for one release to keep callers from
387
+ * older sequant builds compiling.
382
388
  */
383
389
  async updateSessionId(issueNumber, sessionId) {
384
390
  await this.withLock(async () => {
@@ -392,6 +398,26 @@ export class StateManager {
392
398
  await this.saveState(state);
393
399
  });
394
400
  }
401
+ /**
402
+ * Update the driver-tagged resume handle for an issue (#674).
403
+ *
404
+ * Writes both the new `resumeHandle` field and mirrors the token into
405
+ * the deprecated `sessionId` field so state files round-trip through
406
+ * older sequant readers for one release.
407
+ */
408
+ async updateResumeHandle(issueNumber, handle) {
409
+ await this.withLock(async () => {
410
+ const state = await this.getState();
411
+ const issueState = state.issues[String(issueNumber)];
412
+ if (!issueState) {
413
+ throw new Error(`Issue #${issueNumber} not found in state`);
414
+ }
415
+ issueState.resumeHandle = handle;
416
+ issueState.sessionId = handle.token;
417
+ issueState.lastActivity = new Date().toISOString();
418
+ await this.saveState(state);
419
+ });
420
+ }
395
421
  /**
396
422
  * Set or clear the relay state for an issue (#383).
397
423
  *
@@ -19,9 +19,14 @@
19
19
  import { z } from "zod";
20
20
  import { PhaseSchema } from "./types.js";
21
21
  /**
22
- * Workflow phases in order of execution
22
+ * Workflow phases in order of execution.
23
+ *
24
+ * Sourced from the phase registry — replaces the previous `PhaseSchema.options`
25
+ * literal that existed when `PhaseSchema` was a `z.enum`. Computed at module
26
+ * load (after `built-in-phases.ts` has run); insertion order is the canonical
27
+ * pipeline order.
23
28
  */
24
- export declare const WORKFLOW_PHASES: ("qa" | "loop" | "verify" | "spec" | "security-review" | "exec" | "testgen" | "test" | "merger")[];
29
+ export declare const WORKFLOW_PHASES: readonly string[];
25
30
  /**
26
31
  * Phase status - tracks individual phase progress
27
32
  */
@@ -54,17 +59,7 @@ export type { Phase } from "./types.js";
54
59
  * Embedded as HTML comments: `<!-- SEQUANT_PHASE: {...} -->`
55
60
  */
56
61
  export declare const PhaseMarkerSchema: z.ZodObject<{
57
- phase: z.ZodEnum<{
58
- qa: "qa";
59
- loop: "loop";
60
- verify: "verify";
61
- spec: "spec";
62
- "security-review": "security-review";
63
- exec: "exec";
64
- testgen: "testgen";
65
- test: "test";
66
- merger: "merger";
67
- }>;
62
+ phase: z.ZodString;
68
63
  status: z.ZodEnum<{
69
64
  pending: "pending";
70
65
  skipped: "skipped";
@@ -233,17 +228,7 @@ export declare const IssueStateSchema: z.ZodObject<{
233
228
  }>;
234
229
  worktree: z.ZodOptional<z.ZodString>;
235
230
  branch: z.ZodOptional<z.ZodString>;
236
- currentPhase: z.ZodOptional<z.ZodEnum<{
237
- qa: "qa";
238
- loop: "loop";
239
- verify: "verify";
240
- spec: "spec";
241
- "security-review": "security-review";
242
- exec: "exec";
243
- testgen: "testgen";
244
- test: "test";
245
- merger: "merger";
246
- }>>;
231
+ currentPhase: z.ZodOptional<z.ZodString>;
247
232
  phases: z.ZodRecord<z.ZodString, z.ZodObject<{
248
233
  status: z.ZodEnum<{
249
234
  pending: "pending";
@@ -348,6 +333,11 @@ export declare const IssueStateSchema: z.ZodObject<{
348
333
  messageCount: z.ZodNumber;
349
334
  }, z.core.$strip>>;
350
335
  sessionId: z.ZodOptional<z.ZodString>;
336
+ resumeHandle: z.ZodOptional<z.ZodObject<{
337
+ driver: z.ZodString;
338
+ token: z.ZodString;
339
+ originCwd: z.ZodString;
340
+ }, z.core.$strip>>;
351
341
  resolvedAt: z.ZodOptional<z.ZodString>;
352
342
  lastActivity: z.ZodString;
353
343
  createdAt: z.ZodString;
@@ -376,17 +366,7 @@ export declare const WorkflowStateSchema: z.ZodObject<{
376
366
  }>;
377
367
  worktree: z.ZodOptional<z.ZodString>;
378
368
  branch: z.ZodOptional<z.ZodString>;
379
- currentPhase: z.ZodOptional<z.ZodEnum<{
380
- qa: "qa";
381
- loop: "loop";
382
- verify: "verify";
383
- spec: "spec";
384
- "security-review": "security-review";
385
- exec: "exec";
386
- testgen: "testgen";
387
- test: "test";
388
- merger: "merger";
389
- }>>;
369
+ currentPhase: z.ZodOptional<z.ZodString>;
390
370
  phases: z.ZodRecord<z.ZodString, z.ZodObject<{
391
371
  status: z.ZodEnum<{
392
372
  pending: "pending";
@@ -491,6 +471,11 @@ export declare const WorkflowStateSchema: z.ZodObject<{
491
471
  messageCount: z.ZodNumber;
492
472
  }, z.core.$strip>>;
493
473
  sessionId: z.ZodOptional<z.ZodString>;
474
+ resumeHandle: z.ZodOptional<z.ZodObject<{
475
+ driver: z.ZodString;
476
+ token: z.ZodString;
477
+ originCwd: z.ZodString;
478
+ }, z.core.$strip>>;
494
479
  resolvedAt: z.ZodOptional<z.ZodString>;
495
480
  lastActivity: z.ZodString;
496
481
  createdAt: z.ZodString;
@@ -19,10 +19,16 @@
19
19
  import { z } from "zod";
20
20
  import { ScopeAssessmentSchema } from "../scope/types.js";
21
21
  import { PhaseSchema } from "./types.js";
22
+ import { getPhaseNames } from "./phase-registry.js";
22
23
  /**
23
- * Workflow phases in order of execution
24
+ * Workflow phases in order of execution.
25
+ *
26
+ * Sourced from the phase registry — replaces the previous `PhaseSchema.options`
27
+ * literal that existed when `PhaseSchema` was a `z.enum`. Computed at module
28
+ * load (after `built-in-phases.ts` has run); insertion order is the canonical
29
+ * pipeline order.
24
30
  */
25
- export const WORKFLOW_PHASES = PhaseSchema.options;
31
+ export const WORKFLOW_PHASES = getPhaseNames();
26
32
  /**
27
33
  * Phase status - tracks individual phase progress
28
34
  */
@@ -213,8 +219,27 @@ export const IssueStateSchema = z.object({
213
219
  qaStagnation: z.array(QAStagnationEntrySchema).optional(),
214
220
  /** Relay state (#383); present when bidirectional relay is active */
215
221
  relay: RelayStateSchema.optional(),
216
- /** Claude session ID (for resume) */
222
+ /**
223
+ * Claude session ID (for resume).
224
+ * @deprecated Use {@link resumeHandle} (#674). Retained as a read-path
225
+ * mirror of `resumeHandle.token` for one release so state files written by
226
+ * prior sequant builds load cleanly. Legacy entries (token without
227
+ * `originCwd`) intentionally do NOT resume — the driver-owned `canResume`
228
+ * fail-safe disables them.
229
+ */
217
230
  sessionId: z.string().optional(),
231
+ /**
232
+ * Driver-tagged resume handle (#674). Stores the driver name, the
233
+ * resume token, and the cwd the session was created in so the next phase
234
+ * can prove cwd-equality before reattaching.
235
+ */
236
+ resumeHandle: z
237
+ .object({
238
+ driver: z.string(),
239
+ token: z.string(),
240
+ originCwd: z.string(),
241
+ })
242
+ .optional(),
218
243
  /** When the issue transitioned to a terminal status (merged/abandoned/closed) */
219
244
  resolvedAt: z.string().datetime().optional(),
220
245
  /** Most recent activity timestamp */
@@ -7,27 +7,47 @@ import type { LogWriter } from "./log-writer.js";
7
7
  import type { StateManager } from "./state-manager.js";
8
8
  import type { ShutdownManager } from "../shutdown.js";
9
9
  import type { WorktreeInfo } from "./worktree-manager.js";
10
+ export type { WorkflowEventEmitter, WorkflowEvents, WorkflowEventListener, IssueEventStatus, BaseEventPayload, RunEventPayload, PhaseStartedPayload, PhaseCompletedPayload, PhaseFailedPayload, IssueStatusChangedPayload, QaVerdictPayload, ProgressPayload, } from "./event-emitter.js";
10
11
  /**
11
12
  * Canonical Zod schema for all workflow phases.
12
13
  *
13
- * This is the single source of truth — state-schema.ts and run-log-schema.ts
14
- * both reference this definition. Add new phases here only.
14
+ * Backed by the phase registry. `PhaseSchema.parse(name)` succeeds iff
15
+ * `phaseRegistry.has(name)`. The set of valid phases is the registry's
16
+ * keys at the time of parsing — registration happens at module load,
17
+ * so for normal runtime use the set is fixed by the time any code parses.
18
+ *
19
+ * This replaces the prior `z.enum([...])` literal. The set of valid names
20
+ * is identical for the 9 built-in phases; the only observable behavior
21
+ * change is that `PhaseSchema.options` is no longer available — use
22
+ * `getPhaseNames()` from `phase-registry.ts` instead.
23
+ */
24
+ export declare const PhaseSchema: z.ZodString;
25
+ /**
26
+ * Available workflow phases. Widened from a string-literal union to `string`
27
+ * after the registry migration — exhaustiveness checking on `switch (phase)`
28
+ * is now a runtime concern (see the comment in phase-executor.ts where the
29
+ * only relevant switch lives).
15
30
  */
16
- export declare const PhaseSchema: z.ZodEnum<{
17
- qa: "qa";
18
- loop: "loop";
19
- verify: "verify";
20
- spec: "spec";
21
- "security-review": "security-review";
22
- exec: "exec";
23
- testgen: "testgen";
24
- test: "test";
25
- merger: "merger";
26
- }>;
31
+ export type Phase = string;
27
32
  /**
28
- * Available workflow phases (inferred from PhaseSchema)
33
+ * Lifecycle hook for pausing the run renderer's live zone while verbose
34
+ * Claude streaming writes through stdout, then resuming after the agent
35
+ * call completes. Replaces the legacy `PhaseSpinner` argument (#618).
36
+ *
37
+ * Lives in the workflow types barrel so the cli-ui layer can implement it
38
+ * without the workflow layer reaching back into cli-ui (#656).
29
39
  */
30
- export type Phase = z.infer<typeof PhaseSchema>;
40
+ export interface PhasePauseHandle {
41
+ pause(): void;
42
+ resume(): void;
43
+ /**
44
+ * #647 AC-3: print a notice line (e.g., retry/fallback message) without
45
+ * breaking log-update's cursor model. Implementations clear the live zone,
46
+ * write the line through the renderer's own stdout channel, then redraw.
47
+ * In quiet / non-TTY paths this degrades to a plain write.
48
+ */
49
+ appendNotice(message: string): void;
50
+ }
31
51
  /**
32
52
  * Default phases for workflow execution
33
53
  */
@@ -338,6 +358,17 @@ export type ProgressCallback = (issue: number, phase: string, event: "start" | "
338
358
  iteration?: number;
339
359
  text?: string;
340
360
  }) => void;
361
+ /**
362
+ * #672 AC-2: fired once per issue after the executor has resolved the final
363
+ * phase pipeline (post auto-detect, post resume filter, post testgen /
364
+ * security-review insertion). Lets the run renderer seed pending cells for
365
+ * the full roadmap before any phase fires, so users see what is about to run
366
+ * instead of phases appearing one at a time as they stream.
367
+ *
368
+ * Empty `phases` means "no plan known" — the renderer should fall back to
369
+ * streaming-only display.
370
+ */
371
+ export type PhasePlanCallback = (issue: number, phases: string[]) => void;
341
372
  /**
342
373
  * Shared context for executing a batch of issues.
343
374
  * Replaces 11 positional parameters in executeBatch (#402).
@@ -356,6 +387,17 @@ export interface BatchExecutionContext {
356
387
  packageManager?: string;
357
388
  baseBranch?: string;
358
389
  onProgress?: ProgressCallback;
390
+ /** #672 AC-2: forwarded to per-issue context so batch-executor can fire it
391
+ * once the final phase pipeline is known. */
392
+ onPhasePlan?: PhasePlanCallback;
393
+ /**
394
+ * Optional live-zone pause handle (#656). When set, the phase executor calls
395
+ * `pause()` before forwarding verbose Claude SDK output to stdout and
396
+ * `resume()` after the agent call completes — so the 1Hz live grid does not
397
+ * collide with streaming text. Wired from the active `RunRenderer` at the
398
+ * composition root in `run.ts`; left undefined for quiet/TUI modes.
399
+ */
400
+ phasePauseHandle?: PhasePauseHandle;
359
401
  }
360
402
  /**
361
403
  * Context object for executing a single issue through the workflow.
@@ -405,4 +447,12 @@ export interface IssueExecutionContext {
405
447
  baseBranch?: string;
406
448
  /** Per-phase progress callback (used in parallel mode) */
407
449
  onProgress?: ProgressCallback;
450
+ /** #672 AC-2: invoked once after the per-issue phase plan resolves. */
451
+ onPhasePlan?: PhasePlanCallback;
452
+ /**
453
+ * Optional live-zone pause handle (#656). Forwarded to
454
+ * `executePhaseWithRetry` so the renderer's `pause`/`resume` hooks fire
455
+ * around verbose Claude streaming.
456
+ */
457
+ phasePauseHandle?: PhasePauseHandle;
408
458
  }
@@ -2,23 +2,28 @@
2
2
  * Core types for workflow execution
3
3
  */
4
4
  import { z } from "zod";
5
+ // Importing the registry triggers its side-effect registrations (built-ins
6
+ // live at the bottom of phase-registry.ts), guaranteeing the registry is
7
+ // populated before any PhaseSchema parse runs.
8
+ import { phaseRegistry, getPhaseNames } from "./phase-registry.js";
5
9
  /**
6
10
  * Canonical Zod schema for all workflow phases.
7
11
  *
8
- * This is the single source of truth — state-schema.ts and run-log-schema.ts
9
- * both reference this definition. Add new phases here only.
12
+ * Backed by the phase registry. `PhaseSchema.parse(name)` succeeds iff
13
+ * `phaseRegistry.has(name)`. The set of valid phases is the registry's
14
+ * keys at the time of parsing — registration happens at module load,
15
+ * so for normal runtime use the set is fixed by the time any code parses.
16
+ *
17
+ * This replaces the prior `z.enum([...])` literal. The set of valid names
18
+ * is identical for the 9 built-in phases; the only observable behavior
19
+ * change is that `PhaseSchema.options` is no longer available — use
20
+ * `getPhaseNames()` from `phase-registry.ts` instead.
10
21
  */
11
- export const PhaseSchema = z.enum([
12
- "spec",
13
- "security-review",
14
- "exec",
15
- "testgen",
16
- "test",
17
- "verify",
18
- "qa",
19
- "loop",
20
- "merger",
21
- ]);
22
+ export const PhaseSchema = z
23
+ .string()
24
+ .refine((name) => phaseRegistry.has(name), {
25
+ error: (issue) => `Unknown phase "${String(issue.input)}". Available: ${getPhaseNames().join(", ")}`,
26
+ });
22
27
  /**
23
28
  * Default phases for workflow execution
24
29
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "test": "vitest run",
27
27
  "lint": "eslint src/ bin/ --max-warnings 0",
28
28
  "sync:skills": "cp -r templates/skills/* .claude/skills/",
29
+ "sync:hooks": "bash scripts/sync-hooks.sh",
29
30
  "validate:skills": "for skill in templates/skills/*/; do npx skills-ref validate \"$skill\"; done",
30
31
  "lint:skill-calls": "npx tsx scripts/lint-skill-calls.ts",
31
32
  "prepare:marketplace": "npx tsx scripts/prepare-marketplace.ts",
@@ -65,7 +66,7 @@
65
66
  },
66
67
  "homepage": "https://sequant.io",
67
68
  "engines": {
68
- "node": ">=20.19.0"
69
+ "node": ">=22.12.0"
69
70
  },
70
71
  "peerDependencies": {
71
72
  "@modelcontextprotocol/sdk": "^1.27.1"
@@ -76,7 +77,7 @@
76
77
  }
77
78
  },
78
79
  "dependencies": {
79
- "@anthropic-ai/claude-agent-sdk": "^0.2.11",
80
+ "@anthropic-ai/claude-agent-sdk": "^0.3.142",
80
81
  "@hono/node-server": "^2.0.0",
81
82
  "boxen": "^8.0.1",
82
83
  "chalk": "^5.3.0",
@@ -87,7 +88,7 @@
87
88
  "gradient-string": "^3.0.0",
88
89
  "hono": "^4.12.1",
89
90
  "ink": "^7.0.1",
90
- "inquirer": "^13.3.0",
91
+ "inquirer": "^14.0.1",
91
92
  "log-update": "^7.0.1",
92
93
  "open": "^11.0.0",
93
94
  "ora": "^9.3.0",