sequant 2.2.0 → 2.3.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 (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +94 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/locks.d.ts +67 -0
  8. package/dist/src/commands/locks.js +290 -0
  9. package/dist/src/commands/merge.js +11 -0
  10. package/dist/src/commands/prompt.d.ts +39 -0
  11. package/dist/src/commands/prompt.js +179 -0
  12. package/dist/src/commands/run-display.d.ts +11 -2
  13. package/dist/src/commands/run-display.js +62 -28
  14. package/dist/src/commands/run-progress.d.ts +32 -0
  15. package/dist/src/commands/run-progress.js +76 -0
  16. package/dist/src/commands/run.js +80 -18
  17. package/dist/src/commands/stats.d.ts +2 -0
  18. package/dist/src/commands/stats.js +94 -8
  19. package/dist/src/commands/status.js +12 -0
  20. package/dist/src/commands/watch.d.ts +16 -0
  21. package/dist/src/commands/watch.js +147 -0
  22. package/dist/src/lib/ac-linter.d.ts +1 -1
  23. package/dist/src/lib/ac-linter.js +81 -0
  24. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  25. package/dist/src/lib/assess-collision-detect.js +217 -0
  26. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  27. package/dist/src/lib/assess-comment-parser.js +124 -2
  28. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  29. package/dist/src/lib/cli-ui/format.js +34 -0
  30. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  31. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  32. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  33. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  34. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  35. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  36. package/dist/src/lib/locks/index.d.ts +7 -0
  37. package/dist/src/lib/locks/index.js +5 -0
  38. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  39. package/dist/src/lib/locks/lock-manager.js +433 -0
  40. package/dist/src/lib/locks/types.d.ts +59 -0
  41. package/dist/src/lib/locks/types.js +31 -0
  42. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  43. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  44. package/dist/src/lib/relay/activation.d.ts +60 -0
  45. package/dist/src/lib/relay/activation.js +122 -0
  46. package/dist/src/lib/relay/archive.d.ts +34 -0
  47. package/dist/src/lib/relay/archive.js +106 -0
  48. package/dist/src/lib/relay/frame.d.ts +20 -0
  49. package/dist/src/lib/relay/frame.js +76 -0
  50. package/dist/src/lib/relay/index.d.ts +13 -0
  51. package/dist/src/lib/relay/index.js +13 -0
  52. package/dist/src/lib/relay/paths.d.ts +43 -0
  53. package/dist/src/lib/relay/paths.js +59 -0
  54. package/dist/src/lib/relay/pid.d.ts +34 -0
  55. package/dist/src/lib/relay/pid.js +72 -0
  56. package/dist/src/lib/relay/reader.d.ts +35 -0
  57. package/dist/src/lib/relay/reader.js +115 -0
  58. package/dist/src/lib/relay/types.d.ts +68 -0
  59. package/dist/src/lib/relay/types.js +76 -0
  60. package/dist/src/lib/relay/writer.d.ts +48 -0
  61. package/dist/src/lib/relay/writer.js +113 -0
  62. package/dist/src/lib/settings.d.ts +31 -1
  63. package/dist/src/lib/settings.js +18 -3
  64. package/dist/src/lib/version-check.d.ts +60 -5
  65. package/dist/src/lib/version-check.js +97 -9
  66. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  67. package/dist/src/lib/workflow/batch-executor.js +248 -175
  68. package/dist/src/lib/workflow/config-resolver.js +4 -0
  69. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  70. package/dist/src/lib/workflow/heartbeat.js +194 -0
  71. package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
  72. package/dist/src/lib/workflow/phase-executor.js +157 -16
  73. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  74. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  75. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  76. package/dist/src/lib/workflow/platforms/github.js +20 -3
  77. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  78. package/dist/src/lib/workflow/pr-status.js +41 -9
  79. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  80. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  81. package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
  82. package/dist/src/lib/workflow/run-orchestrator.js +340 -15
  83. package/dist/src/lib/workflow/run-reflect.js +1 -1
  84. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  85. package/dist/src/lib/workflow/run-state.js +14 -0
  86. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  87. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  88. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  89. package/dist/src/lib/workflow/state-manager.js +37 -0
  90. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  91. package/dist/src/lib/workflow/state-schema.js +35 -1
  92. package/dist/src/lib/workflow/types.d.ts +74 -1
  93. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  94. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  95. package/dist/src/mcp/tools/run.d.ts +44 -0
  96. package/dist/src/mcp/tools/run.js +104 -13
  97. package/dist/src/ui/tui/App.d.ts +14 -0
  98. package/dist/src/ui/tui/App.js +41 -0
  99. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  100. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  101. package/dist/src/ui/tui/Header.d.ts +6 -0
  102. package/dist/src/ui/tui/Header.js +15 -0
  103. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  104. package/dist/src/ui/tui/IssueBox.js +68 -0
  105. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  106. package/dist/src/ui/tui/Spinner.js +18 -0
  107. package/dist/src/ui/tui/index.d.ts +15 -0
  108. package/dist/src/ui/tui/index.js +29 -0
  109. package/dist/src/ui/tui/theme.d.ts +29 -0
  110. package/dist/src/ui/tui/theme.js +52 -0
  111. package/dist/src/ui/tui/truncate.d.ts +11 -0
  112. package/dist/src/ui/tui/truncate.js +31 -0
  113. package/package.json +10 -3
  114. package/templates/agents/sequant-explorer.md +1 -0
  115. package/templates/agents/sequant-qa-checker.md +2 -1
  116. package/templates/agents/sequant-testgen.md +1 -0
  117. package/templates/hooks/post-tool.sh +11 -0
  118. package/templates/hooks/pre-tool.sh +18 -9
  119. package/templates/hooks/relay-check.sh +107 -0
  120. package/templates/relay/frame.txt +11 -0
  121. package/templates/scripts/cleanup-worktree.sh +25 -3
  122. package/templates/scripts/new-feature.sh +6 -0
  123. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  124. package/templates/skills/_shared/references/subagent-types.md +21 -8
  125. package/templates/skills/assess/SKILL.md +103 -49
  126. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  127. package/templates/skills/docs/SKILL.md +141 -22
  128. package/templates/skills/exec/SKILL.md +10 -8
  129. package/templates/skills/fullsolve/SKILL.md +79 -5
  130. package/templates/skills/loop/SKILL.md +28 -0
  131. package/templates/skills/merger/SKILL.md +621 -0
  132. package/templates/skills/qa/SKILL.md +727 -8
  133. package/templates/skills/setup/SKILL.md +6 -0
  134. package/templates/skills/spec/SKILL.md +52 -0
  135. package/templates/skills/spec/references/parallel-groups.md +7 -0
  136. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  137. package/templates/skills/testgen/SKILL.md +24 -17
@@ -186,6 +186,36 @@ export declare const AcceptanceCriteriaSchema: z.ZodObject<{
186
186
  }, z.core.$strip>;
187
187
  }, z.core.$strip>;
188
188
  export type AcceptanceCriteria = z.infer<typeof AcceptanceCriteriaSchema>;
189
+ /**
190
+ * Single QA stagnation observation.
191
+ *
192
+ * Recorded when fullsolve detects a same-SHA same-verdict cycle so future
193
+ * runs can spot "we've been here before" without re-deriving it from comments.
194
+ */
195
+ export declare const QAStagnationEntrySchema: z.ZodObject<{
196
+ sha: z.ZodString;
197
+ verdict: z.ZodString;
198
+ detectedAt: z.ZodString;
199
+ iteration: z.ZodNumber;
200
+ reason: z.ZodEnum<{
201
+ SAME_SHA_NO_PROGRESS: "SAME_SHA_NO_PROGRESS";
202
+ LOOP_NO_DIFF: "LOOP_NO_DIFF";
203
+ }>;
204
+ }, z.core.$strip>;
205
+ export type QAStagnationEntry = z.infer<typeof QAStagnationEntrySchema>;
206
+ /**
207
+ * Optional relay state for the interactive bidirectional channel (#383).
208
+ *
209
+ * Present when `sequant run` has activated the relay for an issue, absent
210
+ * otherwise. Legacy state files (no relay field) still parse successfully.
211
+ */
212
+ export declare const RelayStateSchema: z.ZodObject<{
213
+ enabled: z.ZodBoolean;
214
+ pid: z.ZodNumber;
215
+ startedAt: z.ZodString;
216
+ messageCount: z.ZodNumber;
217
+ }, z.core.$strip>;
218
+ export type RelayState = z.infer<typeof RelayStateSchema>;
189
219
  /**
190
220
  * Complete state for a single issue
191
221
  */
@@ -301,6 +331,22 @@ export declare const IssueStateSchema: z.ZodObject<{
301
331
  }, z.core.$strip>;
302
332
  recommendation: z.ZodString;
303
333
  }, z.core.$strip>>;
334
+ qaStagnation: z.ZodOptional<z.ZodArray<z.ZodObject<{
335
+ sha: z.ZodString;
336
+ verdict: z.ZodString;
337
+ detectedAt: z.ZodString;
338
+ iteration: z.ZodNumber;
339
+ reason: z.ZodEnum<{
340
+ SAME_SHA_NO_PROGRESS: "SAME_SHA_NO_PROGRESS";
341
+ LOOP_NO_DIFF: "LOOP_NO_DIFF";
342
+ }>;
343
+ }, z.core.$strip>>>;
344
+ relay: z.ZodOptional<z.ZodObject<{
345
+ enabled: z.ZodBoolean;
346
+ pid: z.ZodNumber;
347
+ startedAt: z.ZodString;
348
+ messageCount: z.ZodNumber;
349
+ }, z.core.$strip>>;
304
350
  sessionId: z.ZodOptional<z.ZodString>;
305
351
  resolvedAt: z.ZodOptional<z.ZodString>;
306
352
  lastActivity: z.ZodString;
@@ -428,6 +474,22 @@ export declare const WorkflowStateSchema: z.ZodObject<{
428
474
  }, z.core.$strip>;
429
475
  recommendation: z.ZodString;
430
476
  }, z.core.$strip>>;
477
+ qaStagnation: z.ZodOptional<z.ZodArray<z.ZodObject<{
478
+ sha: z.ZodString;
479
+ verdict: z.ZodString;
480
+ detectedAt: z.ZodString;
481
+ iteration: z.ZodNumber;
482
+ reason: z.ZodEnum<{
483
+ SAME_SHA_NO_PROGRESS: "SAME_SHA_NO_PROGRESS";
484
+ LOOP_NO_DIFF: "LOOP_NO_DIFF";
485
+ }>;
486
+ }, z.core.$strip>>>;
487
+ relay: z.ZodOptional<z.ZodObject<{
488
+ enabled: z.ZodBoolean;
489
+ pid: z.ZodNumber;
490
+ startedAt: z.ZodString;
491
+ messageCount: z.ZodNumber;
492
+ }, z.core.$strip>>;
431
493
  sessionId: z.ZodOptional<z.ZodString>;
432
494
  resolvedAt: z.ZodOptional<z.ZodString>;
433
495
  lastActivity: z.ZodString;
@@ -31,7 +31,7 @@ export const PhaseStatusSchema = z.enum([
31
31
  "in_progress", // Phase currently executing
32
32
  "completed", // Phase finished successfully
33
33
  "failed", // Phase finished with errors
34
- "skipped", // Phase intentionally skipped (e.g., bug labels skip spec)
34
+ "skipped", // Phase intentionally skipped (e.g., prior phase marker already exists)
35
35
  ]);
36
36
  /**
37
37
  * Issue status - tracks overall issue progress
@@ -153,6 +153,36 @@ export const AcceptanceCriteriaSchema = z.object({
153
153
  blocked: z.number().int().nonnegative(),
154
154
  }),
155
155
  });
156
+ /**
157
+ * Single QA stagnation observation.
158
+ *
159
+ * Recorded when fullsolve detects a same-SHA same-verdict cycle so future
160
+ * runs can spot "we've been here before" without re-deriving it from comments.
161
+ */
162
+ export const QAStagnationEntrySchema = z.object({
163
+ /** HEAD SHA at the time of detection */
164
+ sha: z.string(),
165
+ /** QA verdict that repeated (e.g., AC_NOT_MET) */
166
+ verdict: z.string(),
167
+ /** ISO 8601 timestamp of detection */
168
+ detectedAt: z.string().datetime(),
169
+ /** QA loop iteration number where stagnation was detected */
170
+ iteration: z.number().int().nonnegative(),
171
+ /** Reason code: SAME_SHA_NO_PROGRESS or LOOP_NO_DIFF */
172
+ reason: z.enum(["SAME_SHA_NO_PROGRESS", "LOOP_NO_DIFF"]),
173
+ });
174
+ /**
175
+ * Optional relay state for the interactive bidirectional channel (#383).
176
+ *
177
+ * Present when `sequant run` has activated the relay for an issue, absent
178
+ * otherwise. Legacy state files (no relay field) still parse successfully.
179
+ */
180
+ export const RelayStateSchema = z.object({
181
+ enabled: z.boolean(),
182
+ pid: z.number().int().positive(),
183
+ startedAt: z.string().datetime(),
184
+ messageCount: z.number().int().nonnegative(),
185
+ });
156
186
  /**
157
187
  * Complete state for a single issue
158
188
  */
@@ -179,6 +209,10 @@ export const IssueStateSchema = z.object({
179
209
  acceptanceCriteria: AcceptanceCriteriaSchema.optional(),
180
210
  /** Scope assessment result (if performed by /spec) */
181
211
  scopeAssessment: ScopeAssessmentSchema.optional(),
212
+ /** QA stagnation log: same-SHA same-verdict cycles detected during fullsolve */
213
+ qaStagnation: z.array(QAStagnationEntrySchema).optional(),
214
+ /** Relay state (#383); present when bidirectional relay is active */
215
+ relay: RelayStateSchema.optional(),
182
216
  /** Claude session ID (for resume) */
183
217
  sessionId: z.string().optional(),
184
218
  /** When the issue transitioned to a terminal status (merged/abandoned/closed) */
@@ -101,6 +101,21 @@ export interface ExecutionConfig {
101
101
  * Propagated as SEQUANT_FAILED_ACS env var to skills.
102
102
  */
103
103
  failedAcs?: string;
104
+ /**
105
+ * Runtime callback invoked when the agent driver emits a chunk of output
106
+ * during phase execution (#543). Used by the multi-issue TUI to enrich
107
+ * `nowLine` with sub-phase activity. Not serialized — set per-call by the
108
+ * orchestrator. The phase executor throttles calls to ~10 Hz so the TUI's
109
+ * poll budget is preserved.
110
+ */
111
+ onActivity?: (text: string) => void;
112
+ /**
113
+ * Enable interactive relay (#383). When true, phase-executor sets
114
+ * `SEQUANT_RELAY=true` in the agent environment so the PostToolUse hook
115
+ * starts polling `<worktree>/.sequant/relay/inbox.jsonl` for user messages.
116
+ * Default: false (opt-in for the initial rollout).
117
+ */
118
+ relayEnabled?: boolean;
104
119
  }
105
120
  /**
106
121
  * Default execution configuration
@@ -143,6 +158,18 @@ export interface IssueResult {
143
158
  prNumber?: number;
144
159
  /** PR URL if created after successful QA */
145
160
  prUrl?: string;
161
+ /**
162
+ * Set when the issue was skipped because another sequant session holds
163
+ * the per-issue lock (#625). Surfaced in the summary as
164
+ * `locked by PID <n>`. When present, `success` is false and the issue
165
+ * was not executed.
166
+ */
167
+ locked?: {
168
+ pid: number;
169
+ hostname: string;
170
+ startedAt: string;
171
+ command: string;
172
+ };
146
173
  }
147
174
  /**
148
175
  * CLI options for the run command, merged with settings and env config.
@@ -163,6 +190,7 @@ export interface RunOptions {
163
190
  smartTests?: boolean;
164
191
  noSmartTests?: boolean;
165
192
  testgen?: boolean;
193
+ securityReview?: boolean;
166
194
  autoDetectPhases?: boolean;
167
195
  /** Enable automatic worktree creation for issue isolation */
168
196
  worktreeIsolation?: boolean;
@@ -172,6 +200,12 @@ export interface RunOptions {
172
200
  quiet?: boolean;
173
201
  /** Chain issues: each branches from previous (requires --sequential) */
174
202
  chain?: boolean;
203
+ /**
204
+ * Stacked PRs: each non-first PR targets its predecessor branch instead of
205
+ * `main`. Implies --chain. The final PR still targets `main` so partial
206
+ * progress can land without the whole stack. (#605)
207
+ */
208
+ stacked?: boolean;
175
209
  /**
176
210
  * Wait for QA pass before starting next issue in chain mode.
177
211
  * When enabled, the chain pauses if QA fails, preventing downstream issues
@@ -239,6 +273,24 @@ export interface RunOptions {
239
273
  * Resolution priority: CLI flag → settings.agents.isolateParallel → false
240
274
  */
241
275
  isolateParallel?: boolean;
276
+ /**
277
+ * Render a live multi-issue dashboard during the run.
278
+ * Requires a TTY; auto-falls back to linear output when stdout is piped.
279
+ * Experimental — surface and behavior may change.
280
+ */
281
+ experimentalTui?: boolean;
282
+ /**
283
+ * With `--force`, SIGTERM the prior PID holding the per-issue lock
284
+ * before claiming it. Only acts on same-host alive PIDs. (#625)
285
+ */
286
+ signalOther?: boolean;
287
+ /**
288
+ * Interactive relay (#383). Set via `--no-relay`, which Commander surfaces as
289
+ * `options.relay = false`. When `false`, the PostToolUse hook is not
290
+ * activated and `sequant prompt` cannot reach this run.
291
+ * Resolution priority: this CLI flag → settings.run.relay → default (true).
292
+ */
293
+ relay?: boolean;
242
294
  }
243
295
  /**
244
296
  * CLI arguments for run command
@@ -271,10 +323,20 @@ export interface BatchResult {
271
323
  /**
272
324
  * Callback type for per-phase progress updates.
273
325
  * Used by parallel mode in run.ts to render phase status to the terminal.
326
+ *
327
+ * `extra.iteration` (#624 Item 3): outer quality-loop iteration. Threaded
328
+ * through to the renderer as `(attempt N/M)` on retried phase events and
329
+ * `loop N/M` on loop-phase live-zone status cells.
330
+ *
331
+ * `"activity"` (#543): sub-phase activity ping. `extra.text` carries a short
332
+ * one-line snippet (e.g. last line of agent output) for the dashboard's
333
+ * `nowLine`. Fires at most ~10 Hz from the phase executor.
274
334
  */
275
- export type ProgressCallback = (issue: number, phase: string, event: "start" | "complete" | "failed", extra?: {
335
+ export type ProgressCallback = (issue: number, phase: string, event: "start" | "complete" | "failed" | "activity", extra?: {
276
336
  durationSeconds?: number;
277
337
  error?: string;
338
+ iteration?: number;
339
+ text?: string;
278
340
  }) => void;
279
341
  /**
280
342
  * Shared context for executing a batch of issues.
@@ -325,6 +387,17 @@ export interface IssueExecutionContext {
325
387
  chain?: {
326
388
  enabled: boolean;
327
389
  isLast: boolean;
390
+ /**
391
+ * Stacked-PR base branch for this issue. Set only when --stacked is active
392
+ * and this issue has a predecessor in the chain. When set, createPR targets
393
+ * this branch instead of `main`. (#605)
394
+ */
395
+ predecessorBranch?: string;
396
+ /**
397
+ * Pre-rendered stack manifest line for the PR body, e.g.
398
+ * `Part of stack: #100 → #101 (this) → #102`. Set only under --stacked.
399
+ */
400
+ stackManifest?: string;
328
401
  };
329
402
  /** Package manager name (e.g., "npm", "pnpm") */
330
403
  packageManager?: string;
@@ -200,7 +200,14 @@ export declare function rebaseBeforePR(worktreePath: string, issueNumber: number
200
200
  * @param issueTitle Issue title (for PR title)
201
201
  * @param branch Branch name
202
202
  * @param verbose Whether to show verbose output
203
+ * @param labels Issue labels (used to pick `fix(...)` vs `feat(...)` prefix)
204
+ * @param stackOptions When set under --stacked, `prBase` overrides the default
205
+ * PR target (otherwise gh defaults to the repo's default branch) and
206
+ * `stackManifest` is appended to the PR body. (#605)
203
207
  * @returns PRCreationResult with PR info or error
204
208
  * @internal Exported for testing
205
209
  */
206
- export declare function createPR(worktreePath: string, issueNumber: number, issueTitle: string, branch: string, verbose: boolean, labels?: string[]): PRCreationResult;
210
+ export declare function createPR(worktreePath: string, issueNumber: number, issueTitle: string, branch: string, verbose: boolean, labels?: string[], stackOptions?: {
211
+ prBase?: string;
212
+ stackManifest?: string;
213
+ }): PRCreationResult;
@@ -843,10 +843,14 @@ export function rebaseBeforePR(worktreePath, issueNumber, packageManager, verbos
843
843
  * @param issueTitle Issue title (for PR title)
844
844
  * @param branch Branch name
845
845
  * @param verbose Whether to show verbose output
846
+ * @param labels Issue labels (used to pick `fix(...)` vs `feat(...)` prefix)
847
+ * @param stackOptions When set under --stacked, `prBase` overrides the default
848
+ * PR target (otherwise gh defaults to the repo's default branch) and
849
+ * `stackManifest` is appended to the PR body. (#605)
846
850
  * @returns PRCreationResult with PR info or error
847
851
  * @internal Exported for testing
848
852
  */
849
- export function createPR(worktreePath, issueNumber, issueTitle, branch, verbose, labels) {
853
+ export function createPR(worktreePath, issueNumber, issueTitle, branch, verbose, labels, stackOptions) {
850
854
  const github = new GitHubProvider();
851
855
  // Step 1: Check for existing PR on this branch
852
856
  const existingPRInfo = github.viewPRByBranchSync(branch, worktreePath);
@@ -882,17 +886,22 @@ export function createPR(worktreePath, issueNumber, issueTitle, branch, verbose,
882
886
  const isBug = labels?.some((l) => /^bug/i.test(l));
883
887
  const prefix = isBug ? "fix" : "feat";
884
888
  const prTitle = `${prefix}(#${issueNumber}): ${issueTitle}`;
885
- const prBody = [
889
+ const bodyLines = [
886
890
  `## Summary`,
887
891
  ``,
888
892
  `Automated PR for issue #${issueNumber}.`,
889
893
  ``,
890
894
  `Fixes #${issueNumber}`,
891
895
  ``,
892
- `---`,
893
- `🤖 Generated by \`sequant run\``,
894
- ].join("\n");
895
- const prResult = github.createPRCliSync(prTitle, prBody, branch, worktreePath);
896
+ ];
897
+ // #605 AC-4: emit stack manifest before the trailer so reviewers see the
898
+ // chain at the top of the body. Manifest is only set under --stacked.
899
+ if (stackOptions?.stackManifest) {
900
+ bodyLines.push(stackOptions.stackManifest, ``);
901
+ }
902
+ bodyLines.push(`---`, `🤖 Generated by \`sequant run\``);
903
+ const prBody = bodyLines.join("\n");
904
+ const prResult = github.createPRCliSync(prTitle, prBody, branch, worktreePath, stackOptions?.prBase);
896
905
  if (prResult.exitCode !== 0) {
897
906
  const prError = prResult.stderr.trim() || "Unknown error";
898
907
  // Check if PR already exists (race condition or push-before-PR scenarios)
@@ -50,6 +50,16 @@ interface RunToolResponse {
50
50
  * spawnAsync(command, [...prefixArgs, "run", ...userArgs])
51
51
  */
52
52
  export declare function resolveCliBinary(): [string, string[]];
53
+ /**
54
+ * Find and parse a run log file by its exact runId suffix (#631).
55
+ *
56
+ * Avoids the recency-window heuristic in `readLatestRunLog`, which can
57
+ * return another concurrent run's log or a stale same-issue log when
58
+ * filesystem ordering doesn't favor the current run.
59
+ *
60
+ * Returns null on miss, parse failure, or I/O error (no new failure mode).
61
+ */
62
+ export declare function readRunLogById(runId: string): Promise<RunLog | null>;
53
63
  /**
54
64
  * Find and parse the most recent run log file.
55
65
  *
@@ -58,10 +68,26 @@ export declare function resolveCliBinary(): [string, string[]];
58
68
  * stale logs from a previous run being returned.
59
69
  */
60
70
  export declare function readLatestRunLog(runStartTime?: Date): Promise<RunLog | null>;
71
+ /**
72
+ * Resolve the run log for an MCP `sequant_run` invocation (#631).
73
+ *
74
+ * Prefers exact-filename lookup by captured runId; falls back to the
75
+ * time-window heuristic when no runId was captured (older CLI, startup
76
+ * race) or when the runId lookup returned null (corrupted file, slow
77
+ * fsync). On lookup-miss with a captured runId, emit a debug line on
78
+ * the MCP server's own stderr so the silent fallback is observable.
79
+ */
80
+ export declare function resolveRunLog(capturedRunId: string | null, runStartTime: Date): Promise<RunLog | null>;
61
81
  /**
62
82
  * Build a structured response from a parsed RunLog
63
83
  */
64
84
  export declare function buildStructuredResponse(runLog: RunLog, rawOutput: string, overallStatus: "success" | "failure", exitCode?: number | null, errorOutput?: string): RunToolResponse;
85
+ /**
86
+ * Parse a SEQUANT_RUN_ID line emitted by the batch executor (#631).
87
+ * Returns the runId UUID or null if the line isn't a runId line or the
88
+ * payload isn't a well-formed UUID.
89
+ */
90
+ export declare function parseRunIdLine(line: string): string | null;
65
91
  /** Parsed progress event from a SEQUANT_PROGRESS line. */
66
92
  export interface ProgressEvent {
67
93
  issue: number;
@@ -75,6 +101,24 @@ export interface ProgressEvent {
75
101
  * Returns the parsed event or null if the line isn't a progress line.
76
102
  */
77
103
  export declare function parseProgressLine(line: string): ProgressEvent | null;
104
+ /**
105
+ * Stateful capture of the per-run UUID emitted on stderr by the spawned
106
+ * CLI (#631). Each MCP request creates its own capture instance.
107
+ *
108
+ * `routeLine` consumes a complete stderr line:
109
+ * - Until a `SEQUANT_RUN_ID:` line is seen, attempts to capture it.
110
+ * - After capture (or for non-runId lines), delegates to `parseProgressLine`
111
+ * and returns the parsed `ProgressEvent`, if any.
112
+ *
113
+ * Returning `ProgressEvent | null` (instead of side-effecting) keeps the
114
+ * factory pure and lets callers wire emission (`emitProgress`) at the
115
+ * outer layer. This separation also makes the capture logic directly
116
+ * testable without driving the full MCP request handler.
117
+ */
118
+ export declare function createRunIdCapture(): {
119
+ routeLine: (line: string) => ProgressEvent | null;
120
+ getCapturedRunId: () => string | null;
121
+ };
78
122
  /**
79
123
  * Build a human-readable message for a progress notification (AC-3).
80
124
  * @internal Exported for testing only.
@@ -71,6 +71,30 @@ function resolveLogDir() {
71
71
  }
72
72
  return projectPath;
73
73
  }
74
+ /**
75
+ * Find and parse a run log file by its exact runId suffix (#631).
76
+ *
77
+ * Avoids the recency-window heuristic in `readLatestRunLog`, which can
78
+ * return another concurrent run's log or a stale same-issue log when
79
+ * filesystem ordering doesn't favor the current run.
80
+ *
81
+ * Returns null on miss, parse failure, or I/O error (no new failure mode).
82
+ */
83
+ export async function readRunLogById(runId) {
84
+ try {
85
+ const logDir = resolveLogDir();
86
+ const entries = await readdir(logDir);
87
+ // Filename format: run-<timestamp>-<runId>.json — match by exact suffix.
88
+ const match = entries.find((f) => f.endsWith(`-${runId}.json`));
89
+ if (!match)
90
+ return null;
91
+ const content = await readFile(join(logDir, match), "utf-8");
92
+ return RunLogSchema.parse(JSON.parse(content));
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
74
98
  /**
75
99
  * Find and parse the most recent run log file.
76
100
  *
@@ -109,6 +133,24 @@ export async function readLatestRunLog(runStartTime) {
109
133
  return null;
110
134
  }
111
135
  }
136
+ /**
137
+ * Resolve the run log for an MCP `sequant_run` invocation (#631).
138
+ *
139
+ * Prefers exact-filename lookup by captured runId; falls back to the
140
+ * time-window heuristic when no runId was captured (older CLI, startup
141
+ * race) or when the runId lookup returned null (corrupted file, slow
142
+ * fsync). On lookup-miss with a captured runId, emit a debug line on
143
+ * the MCP server's own stderr so the silent fallback is observable.
144
+ */
145
+ export async function resolveRunLog(capturedRunId, runStartTime) {
146
+ if (capturedRunId !== null) {
147
+ const byId = await readRunLogById(capturedRunId);
148
+ if (byId)
149
+ return byId;
150
+ console.error(`[mcp:run] runId ${capturedRunId} lookup miss — falling back to readLatestRunLog`);
151
+ }
152
+ return readLatestRunLog(runStartTime);
153
+ }
112
154
  /**
113
155
  * Build a structured response from a parsed RunLog
114
156
  */
@@ -199,6 +241,21 @@ function buildFallbackResponse(stdout, issueNumbers, overallStatus, phases, exit
199
241
  }
200
242
  /** Prefix used by the batch executor to emit structured progress lines. */
201
243
  const PROGRESS_LINE_PREFIX = "SEQUANT_PROGRESS:";
244
+ /** Prefix used by the batch executor to emit the current run's UUID (#631). */
245
+ const RUN_ID_LINE_PREFIX = "SEQUANT_RUN_ID:";
246
+ /** UUID v4 pattern produced by `crypto.randomUUID()`. */
247
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
248
+ /**
249
+ * Parse a SEQUANT_RUN_ID line emitted by the batch executor (#631).
250
+ * Returns the runId UUID or null if the line isn't a runId line or the
251
+ * payload isn't a well-formed UUID.
252
+ */
253
+ export function parseRunIdLine(line) {
254
+ if (!line.startsWith(RUN_ID_LINE_PREFIX))
255
+ return null;
256
+ const id = line.slice(RUN_ID_LINE_PREFIX.length).trim();
257
+ return UUID_RE.test(id) ? id : null;
258
+ }
202
259
  /**
203
260
  * Parse a SEQUANT_PROGRESS line emitted by the batch executor.
204
261
  * Returns the parsed event or null if the line isn't a progress line.
@@ -233,6 +290,38 @@ export function parseProgressLine(line) {
233
290
  return null;
234
291
  }
235
292
  }
293
+ /**
294
+ * Stateful capture of the per-run UUID emitted on stderr by the spawned
295
+ * CLI (#631). Each MCP request creates its own capture instance.
296
+ *
297
+ * `routeLine` consumes a complete stderr line:
298
+ * - Until a `SEQUANT_RUN_ID:` line is seen, attempts to capture it.
299
+ * - After capture (or for non-runId lines), delegates to `parseProgressLine`
300
+ * and returns the parsed `ProgressEvent`, if any.
301
+ *
302
+ * Returning `ProgressEvent | null` (instead of side-effecting) keeps the
303
+ * factory pure and lets callers wire emission (`emitProgress`) at the
304
+ * outer layer. This separation also makes the capture logic directly
305
+ * testable without driving the full MCP request handler.
306
+ */
307
+ export function createRunIdCapture() {
308
+ let capturedRunId = null;
309
+ return {
310
+ routeLine(line) {
311
+ if (capturedRunId === null) {
312
+ const id = parseRunIdLine(line);
313
+ if (id) {
314
+ capturedRunId = id;
315
+ return null;
316
+ }
317
+ }
318
+ return parseProgressLine(line);
319
+ },
320
+ getCapturedRunId() {
321
+ return capturedRunId;
322
+ },
323
+ };
324
+ }
236
325
  /**
237
326
  * Build a human-readable message for a progress notification (AC-3).
238
327
  * @internal Exported for testing only.
@@ -368,22 +457,21 @@ export function registerRunTool(server) {
368
457
  // Swallow synchronous errors (AC-6)
369
458
  }
370
459
  };
371
- /**
372
- * Handle a complete line of subprocess stderr, checking for progress events.
373
- * The batch executor emits SEQUANT_PROGRESS:{json} lines at phase boundaries.
374
- */
460
+ // Per-request capture of the runId line emitted by the spawned CLI
461
+ // (#631). `routeLine` also parses progress events; we wire emission
462
+ // at this layer so the capture factory stays pure / testable.
463
+ const { routeLine, getCapturedRunId } = createRunIdCapture();
375
464
  const handleLine = (line) => {
376
- const event = parseProgressLine(line);
465
+ const event = routeLine(line);
377
466
  if (event)
378
467
  emitProgress(event);
379
468
  };
380
- // Line-buffer stderr to handle chunk boundaries correctly.
381
- // When a progressToken is present, we also enable spawnAsync's
382
- // internal progress detection for timeout reset (AC-4).
469
+ // `hasProgressToken` no longer gates the line buffer (always-on so
470
+ // runId capture works without a subscriber); it still gates the
471
+ // `onProgress` callback below, which controls spawnAsync's
472
+ // timeout-reset behavior on progress events.
383
473
  const hasProgressToken = progressToken !== undefined;
384
- const stderrLineBuffer = hasProgressToken
385
- ? createLineBuffer(handleLine)
386
- : undefined;
474
+ const stderrLineBuffer = createLineBuffer(handleLine);
387
475
  // Register all issues as active runs for real-time status polling
388
476
  for (const issue of issues) {
389
477
  registerRun(issue);
@@ -405,8 +493,11 @@ export function registerRunTool(server) {
405
493
  const stdout = result.stdout || "";
406
494
  const stderr = result.stderr || "";
407
495
  const overallStatus = result.exitCode === 0 ? "success" : "failure";
408
- // Try to read structured log file for rich per-issue data
409
- const runLog = await readLatestRunLog(runStartTime);
496
+ // Try to read structured log file for rich per-issue data.
497
+ // Prefer exact-filename lookup by captured runId (#631); fall back to
498
+ // the time-window heuristic when the CLI didn't emit a runId (older
499
+ // CLI, startup race) or the file is not yet visible on disk.
500
+ const runLog = await resolveRunLog(getCapturedRunId(), runStartTime);
410
501
  let response;
411
502
  if (runLog) {
412
503
  response = buildStructuredResponse(runLog, stdout, overallStatus, result.exitCode, stderr || undefined);
@@ -0,0 +1,14 @@
1
+ import { type JSX } from "react";
2
+ import type { RunSnapshot } from "../../lib/workflow/run-state.js";
3
+ /**
4
+ * Root TUI component.
5
+ *
6
+ * Polls `getSnapshot` at 10 Hz. A 1 Hz "now" tick keeps the
7
+ * last-activity stamp moving even when the snapshot itself is unchanged.
8
+ * When the snapshot reports `done`, the component stops polling and
9
+ * invokes `onDone` so the caller can `unmount` the ink instance.
10
+ */
11
+ export declare function App({ getSnapshot, onDone, }: {
12
+ getSnapshot: () => RunSnapshot;
13
+ onDone?: () => void;
14
+ }): JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, useStdout } from "ink";
4
+ import { Header } from "./Header.js";
5
+ import { IssueBox } from "./IssueBox.js";
6
+ const POLL_MS = 100; // 10 Hz
7
+ /**
8
+ * Root TUI component.
9
+ *
10
+ * Polls `getSnapshot` at 10 Hz. A 1 Hz "now" tick keeps the
11
+ * last-activity stamp moving even when the snapshot itself is unchanged.
12
+ * When the snapshot reports `done`, the component stops polling and
13
+ * invokes `onDone` so the caller can `unmount` the ink instance.
14
+ */
15
+ export function App({ getSnapshot, onDone, }) {
16
+ const [snapshot, setSnapshot] = useState(() => getSnapshot());
17
+ const [now, setNow] = useState(() => Date.now());
18
+ const doneFired = useRef(false);
19
+ const { stdout } = useStdout();
20
+ // Snapshot poller (drives all state transitions).
21
+ useEffect(() => {
22
+ const id = setInterval(() => {
23
+ const next = getSnapshot();
24
+ setSnapshot(next);
25
+ if (next.done && !doneFired.current) {
26
+ doneFired.current = true;
27
+ clearInterval(id);
28
+ onDone?.();
29
+ }
30
+ }, POLL_MS);
31
+ return () => clearInterval(id);
32
+ }, [getSnapshot, onDone]);
33
+ // Coarse 1 Hz tick for the last-activity stamp.
34
+ useEffect(() => {
35
+ const id = setInterval(() => setNow(Date.now()), 1000);
36
+ return () => clearInterval(id);
37
+ }, []);
38
+ const columns = stdout?.columns ?? 80;
39
+ const boxWidth = Math.min(columns - 2, 100);
40
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { snapshot: snapshot }), snapshot.issues.map((issue, i) => (_jsx(IssueBox, { state: issue, slot: i, width: boxWidth, now: now }, issue.number)))] }));
41
+ }
@@ -0,0 +1,10 @@
1
+ import { type JSX } from "react";
2
+ /**
3
+ * Per-issue elapsed timer. Owns its own interval so tick-driven re-renders
4
+ * are scoped to this leaf component and do not propagate to `IssueBox`.
5
+ */
6
+ export declare function ElapsedTimer({ startedAt }: {
7
+ startedAt?: Date;
8
+ }): JSX.Element;
9
+ /** Format an absolute timestamp as the "last activity Xs ago" stamp. */
10
+ export declare function formatSinceActivity(now: number, activityAt: Date): string;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ /**
5
+ * Per-issue elapsed timer. Owns its own interval so tick-driven re-renders
6
+ * are scoped to this leaf component and do not propagate to `IssueBox`.
7
+ */
8
+ export function ElapsedTimer({ startedAt }) {
9
+ const [now, setNow] = useState(() => Date.now());
10
+ useEffect(() => {
11
+ const id = setInterval(() => setNow(Date.now()), 1000);
12
+ return () => clearInterval(id);
13
+ }, []);
14
+ if (!startedAt)
15
+ return _jsx(Text, { children: "--:--" });
16
+ const secs = Math.max(0, Math.floor((now - startedAt.getTime()) / 1000));
17
+ const mm = Math.floor(secs / 60)
18
+ .toString()
19
+ .padStart(2, "0");
20
+ const ss = (secs % 60).toString().padStart(2, "0");
21
+ return _jsx(Text, { children: `${mm}:${ss}` });
22
+ }
23
+ /** Format an absolute timestamp as the "last activity Xs ago" stamp. */
24
+ export function formatSinceActivity(now, activityAt) {
25
+ const secs = Math.max(0, Math.floor((now - activityAt.getTime()) / 1000));
26
+ if (secs < 60)
27
+ return `${secs}s ago`;
28
+ const mm = Math.floor(secs / 60);
29
+ const ss = secs % 60;
30
+ return `${mm}m ${ss}s ago`;
31
+ }
@@ -0,0 +1,6 @@
1
+ import type { JSX } from "react";
2
+ import type { RunSnapshot } from "../../lib/workflow/run-state.js";
3
+ /** Top-of-dashboard summary: count, concurrency, base, quality-loop. */
4
+ export declare function Header({ snapshot }: {
5
+ snapshot: RunSnapshot;
6
+ }): JSX.Element;