sequant 2.2.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 (156) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +81 -5
  4. package/dist/bin/cli.js +140 -13
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/doctor.d.ts +25 -0
  8. package/dist/src/commands/doctor.js +36 -1
  9. package/dist/src/commands/locks.d.ts +67 -0
  10. package/dist/src/commands/locks.js +290 -0
  11. package/dist/src/commands/merge.js +11 -0
  12. package/dist/src/commands/prompt.d.ts +46 -0
  13. package/dist/src/commands/prompt.js +273 -0
  14. package/dist/src/commands/run-display.d.ts +11 -2
  15. package/dist/src/commands/run-display.js +62 -28
  16. package/dist/src/commands/run-progress.d.ts +42 -0
  17. package/dist/src/commands/run-progress.js +93 -0
  18. package/dist/src/commands/run.js +90 -18
  19. package/dist/src/commands/stats.d.ts +2 -0
  20. package/dist/src/commands/stats.js +94 -8
  21. package/dist/src/commands/status.js +12 -0
  22. package/dist/src/commands/watch.d.ts +18 -0
  23. package/dist/src/commands/watch.js +211 -0
  24. package/dist/src/lib/ac-linter.d.ts +1 -1
  25. package/dist/src/lib/ac-linter.js +81 -0
  26. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  27. package/dist/src/lib/assess-collision-detect.js +217 -0
  28. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  29. package/dist/src/lib/assess-comment-parser.js +124 -2
  30. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  31. package/dist/src/lib/cli-ui/format.js +34 -0
  32. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +220 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
  36. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  37. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  38. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  39. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  40. package/dist/src/lib/locks/index.d.ts +7 -0
  41. package/dist/src/lib/locks/index.js +5 -0
  42. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  43. package/dist/src/lib/locks/lock-manager.js +433 -0
  44. package/dist/src/lib/locks/types.d.ts +59 -0
  45. package/dist/src/lib/locks/types.js +31 -0
  46. package/dist/src/lib/merge-check/types.js +1 -1
  47. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  48. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  49. package/dist/src/lib/relay/activation.d.ts +60 -0
  50. package/dist/src/lib/relay/activation.js +122 -0
  51. package/dist/src/lib/relay/archive.d.ts +34 -0
  52. package/dist/src/lib/relay/archive.js +112 -0
  53. package/dist/src/lib/relay/frame.d.ts +20 -0
  54. package/dist/src/lib/relay/frame.js +76 -0
  55. package/dist/src/lib/relay/index.d.ts +13 -0
  56. package/dist/src/lib/relay/index.js +13 -0
  57. package/dist/src/lib/relay/paths.d.ts +43 -0
  58. package/dist/src/lib/relay/paths.js +59 -0
  59. package/dist/src/lib/relay/pid.d.ts +34 -0
  60. package/dist/src/lib/relay/pid.js +72 -0
  61. package/dist/src/lib/relay/reader.d.ts +35 -0
  62. package/dist/src/lib/relay/reader.js +115 -0
  63. package/dist/src/lib/relay/types.d.ts +70 -0
  64. package/dist/src/lib/relay/types.js +85 -0
  65. package/dist/src/lib/relay/writer.d.ts +48 -0
  66. package/dist/src/lib/relay/writer.js +113 -0
  67. package/dist/src/lib/settings.d.ts +31 -1
  68. package/dist/src/lib/settings.js +18 -3
  69. package/dist/src/lib/version-check.d.ts +60 -5
  70. package/dist/src/lib/version-check.js +97 -9
  71. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  72. package/dist/src/lib/workflow/batch-executor.js +274 -185
  73. package/dist/src/lib/workflow/config-resolver.js +4 -0
  74. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  75. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  76. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  77. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  78. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  79. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  80. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  81. package/dist/src/lib/workflow/event-emitter.js +102 -0
  82. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  83. package/dist/src/lib/workflow/heartbeat.js +194 -0
  84. package/dist/src/lib/workflow/notice.d.ts +32 -0
  85. package/dist/src/lib/workflow/notice.js +38 -0
  86. package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
  87. package/dist/src/lib/workflow/phase-executor.js +244 -130
  88. package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
  89. package/dist/src/lib/workflow/phase-mapper.js +70 -51
  90. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  91. package/dist/src/lib/workflow/phase-registry.js +233 -0
  92. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  93. package/dist/src/lib/workflow/platforms/github.js +20 -3
  94. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  95. package/dist/src/lib/workflow/pr-status.js +41 -9
  96. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  97. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  98. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  99. package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
  100. package/dist/src/lib/workflow/run-orchestrator.js +464 -25
  101. package/dist/src/lib/workflow/run-reflect.js +1 -1
  102. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  103. package/dist/src/lib/workflow/run-state.js +14 -0
  104. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  105. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  106. package/dist/src/lib/workflow/state-manager.d.ts +31 -2
  107. package/dist/src/lib/workflow/state-manager.js +64 -1
  108. package/dist/src/lib/workflow/state-schema.d.ts +82 -35
  109. package/dist/src/lib/workflow/state-schema.js +63 -4
  110. package/dist/src/lib/workflow/types.d.ts +139 -16
  111. package/dist/src/lib/workflow/types.js +18 -13
  112. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  113. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  114. package/dist/src/mcp/tools/run.d.ts +44 -0
  115. package/dist/src/mcp/tools/run.js +104 -13
  116. package/dist/src/ui/tui/App.d.ts +14 -0
  117. package/dist/src/ui/tui/App.js +41 -0
  118. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  119. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  120. package/dist/src/ui/tui/Header.d.ts +6 -0
  121. package/dist/src/ui/tui/Header.js +15 -0
  122. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  123. package/dist/src/ui/tui/IssueBox.js +68 -0
  124. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  125. package/dist/src/ui/tui/Spinner.js +18 -0
  126. package/dist/src/ui/tui/index.d.ts +15 -0
  127. package/dist/src/ui/tui/index.js +29 -0
  128. package/dist/src/ui/tui/theme.d.ts +29 -0
  129. package/dist/src/ui/tui/theme.js +52 -0
  130. package/dist/src/ui/tui/truncate.d.ts +11 -0
  131. package/dist/src/ui/tui/truncate.js +31 -0
  132. package/package.json +14 -6
  133. package/templates/agents/sequant-explorer.md +1 -0
  134. package/templates/agents/sequant-qa-checker.md +2 -1
  135. package/templates/agents/sequant-testgen.md +1 -0
  136. package/templates/hooks/post-tool.sh +92 -0
  137. package/templates/hooks/pre-tool.sh +18 -9
  138. package/templates/hooks/relay-check.sh +107 -0
  139. package/templates/relay/frame.txt +11 -0
  140. package/templates/scripts/cleanup-worktree.sh +25 -3
  141. package/templates/scripts/new-feature.sh +6 -0
  142. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  143. package/templates/skills/_shared/references/subagent-types.md +21 -8
  144. package/templates/skills/assess/SKILL.md +122 -68
  145. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  146. package/templates/skills/docs/SKILL.md +141 -22
  147. package/templates/skills/exec/SKILL.md +10 -8
  148. package/templates/skills/fullsolve/SKILL.md +79 -5
  149. package/templates/skills/loop/SKILL.md +28 -0
  150. package/templates/skills/merger/SKILL.md +621 -0
  151. package/templates/skills/qa/SKILL.md +727 -8
  152. package/templates/skills/setup/SKILL.md +12 -6
  153. package/templates/skills/spec/SKILL.md +52 -0
  154. package/templates/skills/spec/references/parallel-groups.md +7 -0
  155. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  156. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `sequant locks` — inspect and clear per-issue concurrency locks (#625).
3
+ */
4
+ export interface LocksListOptions {
5
+ json?: boolean;
6
+ }
7
+ export interface LocksClearOptions {
8
+ force?: boolean;
9
+ json?: boolean;
10
+ }
11
+ export interface LocksAcquireOptions {
12
+ command?: string;
13
+ skipPidCheck?: boolean;
14
+ force?: boolean;
15
+ signalOther?: boolean;
16
+ json?: boolean;
17
+ }
18
+ export interface LocksReleaseOptions {
19
+ json?: boolean;
20
+ }
21
+ export interface LocksCheckOptions {
22
+ json?: boolean;
23
+ }
24
+ export interface LocksCheckBatchOptions {
25
+ json?: boolean;
26
+ }
27
+ /** `sequant locks list` — print every active lock with staleness metadata. */
28
+ export declare function locksListCommand(options?: LocksListOptions): Promise<void>;
29
+ /**
30
+ * `sequant locks clear <issue>` — remove a lock manually.
31
+ * By default refuses to clear a fresh same-host lock whose PID is alive;
32
+ * pass `--force` to override.
33
+ */
34
+ export declare function locksClearCommand(issueArg: string, options?: LocksClearOptions): Promise<void>;
35
+ /**
36
+ * `sequant locks acquire <issue>` — claim the lock from a shell context
37
+ * (e.g. a skill SKILL.md). Use `--skip-pid-check` for skill shells whose
38
+ * Node PID dies between acquire and release; stale recovery then falls back
39
+ * to age-only (2h).
40
+ *
41
+ * Exit codes:
42
+ * 0 — acquired
43
+ * 1 — locked by another holder (printed to stderr unless --json)
44
+ * 2 — invalid arguments
45
+ */
46
+ export declare function locksAcquireCommand(issueArg: string, options?: LocksAcquireOptions): Promise<void>;
47
+ /**
48
+ * `sequant locks release <issue>` — release a lock previously acquired by
49
+ * a skill shell on this host. Refuses to release locks held by a foreign
50
+ * host or by a different process (use `locks clear --force` for that).
51
+ */
52
+ export declare function locksReleaseCommand(issueArg: string, options?: LocksReleaseOptions): Promise<void>;
53
+ /**
54
+ * `sequant locks check <issue>` — read-only lock probe for `/assess`-style
55
+ * skills. Prints holder info if any, exit code 0 when free, 1 when held.
56
+ */
57
+ export declare function locksCheckCommand(issueArg: string, options?: LocksCheckOptions): Promise<void>;
58
+ /**
59
+ * `sequant locks check-batch <issue1> <issue2> ...` — read-only batch probe
60
+ * used by `/assess`. Text mode emits one canonical warning line per held
61
+ * issue (nothing for unheld), so the skill can paste the output directly
62
+ * above its dashboard. JSON mode emits a structured `{ warnings: [...] }`.
63
+ *
64
+ * Exit code is always 0 — `/assess` is read-only and should never abort
65
+ * on locked issues; the warning is informational.
66
+ */
67
+ export declare function locksCheckBatchCommand(issueArgs: string[], options?: LocksCheckBatchOptions): Promise<void>;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * `sequant locks` — inspect and clear per-issue concurrency locks (#625).
3
+ */
4
+ import chalk from "chalk";
5
+ import { LockManager, formatLockedMessage, } from "../lib/locks/index.js";
6
+ /** Human-readable line for the `--signal-other` log output (#637). */
7
+ function formatSignalLine(issue, pid, result) {
8
+ switch (result.reason) {
9
+ case "sent":
10
+ return `Signaled PID ${pid} (SIGTERM) for #${issue}`;
11
+ case "cross-host":
12
+ return `Could not signal PID ${pid} for #${issue} (cross-host holder)`;
13
+ case "self-or-parent":
14
+ return `Refused to signal PID ${pid} for #${issue} (matches this process or its parent)`;
15
+ case "pid-dead":
16
+ return `Could not signal PID ${pid} for #${issue} (already exited)`;
17
+ case "kill-failed":
18
+ return `Could not signal PID ${pid} for #${issue} (kill syscall failed)`;
19
+ case "orchestrator":
20
+ return `Skipped signal for #${issue} (orchestrator mode)`;
21
+ }
22
+ }
23
+ function parseIssue(arg) {
24
+ const issue = Number.parseInt(arg, 10);
25
+ if (!Number.isInteger(issue) || issue <= 0) {
26
+ console.error(chalk.red(`Invalid issue number: ${arg}`));
27
+ process.exitCode = 1;
28
+ return null;
29
+ }
30
+ return issue;
31
+ }
32
+ /** `sequant locks list` — print every active lock with staleness metadata. */
33
+ export async function locksListCommand(options = {}) {
34
+ const manager = new LockManager();
35
+ if (manager.isNoop) {
36
+ if (options.json) {
37
+ console.log(JSON.stringify({ locks: [], orchestratorMode: true }));
38
+ }
39
+ else {
40
+ console.log(chalk.gray("Lock operations are disabled (SEQUANT_ORCHESTRATOR set)."));
41
+ }
42
+ return;
43
+ }
44
+ const listings = manager.list();
45
+ if (options.json) {
46
+ console.log(JSON.stringify({ locks: listings }, null, 2));
47
+ return;
48
+ }
49
+ if (listings.length === 0) {
50
+ console.log(chalk.gray("No active locks."));
51
+ return;
52
+ }
53
+ console.log(chalk.bold(`Active locks (${listings.length}):`));
54
+ console.log("");
55
+ for (const l of listings) {
56
+ const ageMinutes = Math.floor(l.ageMs / 60_000);
57
+ const staleTag = l.stale ? chalk.yellow(` (stale: ${l.staleReason})`) : "";
58
+ console.log(` #${l.issue} pid=${l.holder.pid} host=${l.holder.hostname} ` +
59
+ `age=${ageMinutes}m started=${l.holder.startedAt}${staleTag}`);
60
+ console.log(` command: ${l.holder.command}`);
61
+ }
62
+ }
63
+ /**
64
+ * `sequant locks clear <issue>` — remove a lock manually.
65
+ * By default refuses to clear a fresh same-host lock whose PID is alive;
66
+ * pass `--force` to override.
67
+ */
68
+ export async function locksClearCommand(issueArg, options = {}) {
69
+ const issue = Number.parseInt(issueArg, 10);
70
+ if (!Number.isInteger(issue) || issue <= 0) {
71
+ console.error(chalk.red(`Invalid issue number: ${issueArg}`));
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+ const manager = new LockManager();
76
+ if (manager.isNoop) {
77
+ console.log(chalk.gray("Lock operations are disabled (SEQUANT_ORCHESTRATOR set)."));
78
+ return;
79
+ }
80
+ const result = manager.clearLock(issue, { safetyCheck: !options.force });
81
+ if (options.json) {
82
+ console.log(JSON.stringify({ issue, ...result }));
83
+ return;
84
+ }
85
+ if (result.cleared) {
86
+ console.log(chalk.green(`✓ Cleared lock for #${issue}`));
87
+ return;
88
+ }
89
+ switch (result.reason) {
90
+ case "no-lock":
91
+ console.log(chalk.gray(`No lock found for #${issue}`));
92
+ return;
93
+ case "fresh-same-host-alive": {
94
+ const holder = manager.check(issue);
95
+ console.log(chalk.yellow(`Refusing to clear fresh lock on #${issue}` +
96
+ (holder ? ` (PID ${holder.pid} appears alive on this host)` : "") +
97
+ `. Re-run with --force if you really want to clear it.`));
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ default:
102
+ console.log(chalk.gray(`No-op (${result.reason})`));
103
+ }
104
+ }
105
+ /**
106
+ * `sequant locks acquire <issue>` — claim the lock from a shell context
107
+ * (e.g. a skill SKILL.md). Use `--skip-pid-check` for skill shells whose
108
+ * Node PID dies between acquire and release; stale recovery then falls back
109
+ * to age-only (2h).
110
+ *
111
+ * Exit codes:
112
+ * 0 — acquired
113
+ * 1 — locked by another holder (printed to stderr unless --json)
114
+ * 2 — invalid arguments
115
+ */
116
+ export async function locksAcquireCommand(issueArg, options = {}) {
117
+ const issue = parseIssue(issueArg);
118
+ if (issue === null)
119
+ return;
120
+ const command = options.command ?? "unknown";
121
+ const manager = new LockManager();
122
+ if (manager.isNoop) {
123
+ if (options.json) {
124
+ console.log(JSON.stringify({ acquired: true, orchestratorMode: true }));
125
+ }
126
+ else {
127
+ console.log(chalk.gray("Lock operations are disabled (SEQUANT_ORCHESTRATOR set)."));
128
+ }
129
+ return;
130
+ }
131
+ if (options.force) {
132
+ const { previous } = manager.forceAcquire(issue, command, {
133
+ skipPidCheck: options.skipPidCheck,
134
+ });
135
+ if (previous && options.signalOther) {
136
+ const result = manager.signalOther(previous);
137
+ if (!options.json) {
138
+ console.log(chalk.gray(formatSignalLine(issue, previous.pid, result)));
139
+ }
140
+ }
141
+ if (options.json) {
142
+ console.log(JSON.stringify({
143
+ acquired: true,
144
+ forced: true,
145
+ previousHolder: previous,
146
+ }));
147
+ }
148
+ else {
149
+ console.log(chalk.green(`✓ Acquired lock for #${issue} (forced)`));
150
+ }
151
+ return;
152
+ }
153
+ const result = manager.acquire(issue, command, {
154
+ skipPidCheck: options.skipPidCheck,
155
+ });
156
+ if (result.acquired) {
157
+ if (options.json) {
158
+ console.log(JSON.stringify({ acquired: true, lockPath: result.lockPath }));
159
+ }
160
+ else {
161
+ console.log(chalk.green(`✓ Acquired lock for #${issue}`));
162
+ }
163
+ return;
164
+ }
165
+ // Blocked.
166
+ process.exitCode = 1;
167
+ if (options.json) {
168
+ console.log(JSON.stringify({
169
+ acquired: false,
170
+ holder: result.holder,
171
+ lockPath: result.lockPath,
172
+ }));
173
+ }
174
+ else {
175
+ console.error(chalk.yellow(formatLockedMessage(issue, result.holder)));
176
+ }
177
+ }
178
+ /**
179
+ * `sequant locks release <issue>` — release a lock previously acquired by
180
+ * a skill shell on this host. Refuses to release locks held by a foreign
181
+ * host or by a different process (use `locks clear --force` for that).
182
+ */
183
+ export async function locksReleaseCommand(issueArg, options = {}) {
184
+ const issue = parseIssue(issueArg);
185
+ if (issue === null)
186
+ return;
187
+ const manager = new LockManager();
188
+ if (manager.isNoop) {
189
+ if (options.json) {
190
+ console.log(JSON.stringify({ issue, released: false, orchestratorMode: true }));
191
+ }
192
+ else {
193
+ console.log(chalk.gray("Lock operations are disabled (SEQUANT_ORCHESTRATOR set)."));
194
+ }
195
+ return;
196
+ }
197
+ const released = manager.releaseExternal(issue);
198
+ if (options.json) {
199
+ console.log(JSON.stringify({ issue, released }));
200
+ return;
201
+ }
202
+ if (released) {
203
+ console.log(chalk.green(`✓ Released lock for #${issue}`));
204
+ }
205
+ else {
206
+ console.log(chalk.gray(`No releasable lock for #${issue}`));
207
+ }
208
+ }
209
+ /**
210
+ * `sequant locks check <issue>` — read-only lock probe for `/assess`-style
211
+ * skills. Prints holder info if any, exit code 0 when free, 1 when held.
212
+ */
213
+ export async function locksCheckCommand(issueArg, options = {}) {
214
+ const issue = parseIssue(issueArg);
215
+ if (issue === null)
216
+ return;
217
+ const manager = new LockManager();
218
+ if (manager.isNoop) {
219
+ if (options.json) {
220
+ console.log(JSON.stringify({ locked: false, orchestratorMode: true }));
221
+ }
222
+ else {
223
+ console.log(chalk.gray("Lock operations are disabled (SEQUANT_ORCHESTRATOR set)."));
224
+ }
225
+ return;
226
+ }
227
+ const holder = manager.check(issue);
228
+ if (!holder) {
229
+ if (options.json) {
230
+ console.log(JSON.stringify({ issue, locked: false }));
231
+ }
232
+ else {
233
+ console.log(chalk.gray(`#${issue} is not locked`));
234
+ }
235
+ return;
236
+ }
237
+ process.exitCode = 1;
238
+ if (options.json) {
239
+ console.log(JSON.stringify({ issue, locked: true, holder }));
240
+ }
241
+ else {
242
+ console.log(chalk.yellow(formatLockedMessage(issue, holder)));
243
+ }
244
+ }
245
+ /**
246
+ * `sequant locks check-batch <issue1> <issue2> ...` — read-only batch probe
247
+ * used by `/assess`. Text mode emits one canonical warning line per held
248
+ * issue (nothing for unheld), so the skill can paste the output directly
249
+ * above its dashboard. JSON mode emits a structured `{ warnings: [...] }`.
250
+ *
251
+ * Exit code is always 0 — `/assess` is read-only and should never abort
252
+ * on locked issues; the warning is informational.
253
+ */
254
+ export async function locksCheckBatchCommand(issueArgs, options = {}) {
255
+ const issues = [];
256
+ for (const arg of issueArgs) {
257
+ const issue = Number.parseInt(arg, 10);
258
+ if (!Number.isInteger(issue) || issue <= 0) {
259
+ console.error(chalk.red(`Invalid issue number: ${arg}`));
260
+ process.exitCode = 2;
261
+ return;
262
+ }
263
+ issues.push(issue);
264
+ }
265
+ const manager = new LockManager();
266
+ if (manager.isNoop) {
267
+ if (options.json) {
268
+ console.log(JSON.stringify({ warnings: [], orchestratorMode: true, checked: 0 }));
269
+ }
270
+ // Non-JSON: silent in orchestrator mode (matches `acquire`/`release`
271
+ // semantics — no spurious output for /assess in MCP-driven runs).
272
+ return;
273
+ }
274
+ const warnings = [];
275
+ for (const issue of issues) {
276
+ const holder = manager.check(issue);
277
+ if (holder)
278
+ warnings.push({ issue, holder });
279
+ }
280
+ if (options.json) {
281
+ console.log(JSON.stringify({ warnings, checked: issues.length }, null, 2));
282
+ return;
283
+ }
284
+ // Text mode: one line per held issue (canonical `⚠` format that /assess
285
+ // pastes verbatim into its dashboard). Empty output when nothing is held.
286
+ for (const { issue, holder } of warnings) {
287
+ console.log(`⚠ #${issue} held by PID ${holder.pid} on ${holder.hostname} ` +
288
+ `since ${holder.startedAt} (${holder.command})`);
289
+ }
290
+ }
@@ -14,6 +14,7 @@
14
14
  import { spawnSync } from "child_process";
15
15
  import { ui, colors } from "../lib/cli-ui.js";
16
16
  import { runMergeChecks, formatReportMarkdown, } from "../lib/merge-check/index.js";
17
+ import { LockManager, formatLockedMessage } from "../lib/locks/index.js";
17
18
  /**
18
19
  * Determine exit code from batch verdict
19
20
  */
@@ -68,6 +69,16 @@ export async function mergeCommand(issues, options) {
68
69
  console.log(colors.muted(issueNumbers.length > 0
69
70
  ? `Checking issues: ${issueNumbers.map((i) => `#${i}`).join(", ")} (mode: ${mode})`
70
71
  : `Auto-detecting issues from most recent run (mode: ${mode})`));
72
+ // #625: read-only lock awareness — warn but proceed.
73
+ const lockManager = new LockManager();
74
+ if (!lockManager.isNoop) {
75
+ for (const issue of issueNumbers) {
76
+ const holder = lockManager.check(issue);
77
+ if (holder) {
78
+ console.log(colors.muted(` ! ${formatLockedMessage(issue, holder)}`));
79
+ }
80
+ }
81
+ }
71
82
  console.log("");
72
83
  }
73
84
  try {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `sequant prompt <issue> "<message>"` — send a message into a running
3
+ * headless `sequant run` session via the interactive relay (#383).
4
+ */
5
+ import { type RelayMessageType } from "../lib/relay/types.js";
6
+ import type { IssueState } from "../lib/workflow/state-schema.js";
7
+ export interface PromptCommandOptions {
8
+ type?: string;
9
+ json?: boolean;
10
+ /**
11
+ * If set, poll the outbox for a reply that matches the new message ID and
12
+ * print it inline. Exits 0 on reply, 1 on timeout (#645, Gap 4).
13
+ */
14
+ waitSeconds?: number;
15
+ /** Test seam: override the poll interval (ms). Default 250. */
16
+ waitPollIntervalMs?: number;
17
+ }
18
+ export interface ParsedPromptArgs {
19
+ issue: number | null;
20
+ message: string;
21
+ type: RelayMessageType;
22
+ }
23
+ /** Validate raw CLI args. Throws on invalid type or empty message. */
24
+ export declare function parseRelayPromptArgs(args: string[], options?: {
25
+ type?: string;
26
+ }): ParsedPromptArgs;
27
+ /** Identify candidate active runs for auto-resolution (AC-17b). */
28
+ export declare function findActiveIssues(issues: IssueState[], isAlive: (pid: number) => boolean, cwd?: string): number[];
29
+ /**
30
+ * Resolve which issue to target.
31
+ * - Explicit `issue` arg: use as-is.
32
+ * - Single active run: auto-resolve.
33
+ * - Zero active runs: error.
34
+ * - Multiple active runs: error with usage hint.
35
+ */
36
+ export declare function resolveTargetIssue(args: {
37
+ explicit: number | null;
38
+ activeIssues: number[];
39
+ }): {
40
+ issue: number;
41
+ reason: "explicit" | "single-active";
42
+ };
43
+ export declare function promptCommand(argsAndOptions: {
44
+ args: string[];
45
+ options: PromptCommandOptions;
46
+ }): Promise<void>;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * `sequant prompt <issue> "<message>"` — send a message into a running
3
+ * headless `sequant run` session via the interactive relay (#383).
4
+ */
5
+ import { existsSync, statSync, readFileSync } from "fs";
6
+ import chalk from "chalk";
7
+ import { RelayMessageTypeSchema, RelayResponseSchema, } from "../lib/relay/types.js";
8
+ import { appendInboxMessage } from "../lib/relay/writer.js";
9
+ import { cleanupStalePid, readPidFile } from "../lib/relay/pid.js";
10
+ import { outboxPathFor } from "../lib/relay/paths.js";
11
+ import { StateManager } from "../lib/workflow/state-manager.js";
12
+ /** Validate raw CLI args. Throws on invalid type or empty message. */
13
+ export function parseRelayPromptArgs(args, options = {}) {
14
+ // Allowed shapes:
15
+ // ["<message>"] — single arg, auto-resolve issue
16
+ // ["<issue>", "<message>"] — both positional
17
+ let issueArg;
18
+ let messageArg;
19
+ if (args.length === 1) {
20
+ messageArg = args[0];
21
+ }
22
+ else if (args.length >= 2) {
23
+ issueArg = args[0];
24
+ messageArg = args.slice(1).join(" ");
25
+ }
26
+ else {
27
+ throw new Error('Usage: sequant prompt [issue] "<message>" [--type TYPE]');
28
+ }
29
+ if (!messageArg || messageArg.trim() === "") {
30
+ throw new Error("Message cannot be empty");
31
+ }
32
+ let issue = null;
33
+ if (issueArg !== undefined) {
34
+ const n = Number.parseInt(issueArg, 10);
35
+ if (!Number.isInteger(n) || n <= 0) {
36
+ throw new Error(`Invalid issue number: '${issueArg}'`);
37
+ }
38
+ issue = n;
39
+ }
40
+ const rawType = options.type ?? "query";
41
+ const parsedType = RelayMessageTypeSchema.safeParse(rawType);
42
+ if (!parsedType.success) {
43
+ throw new Error(`Invalid --type '${rawType}'. Valid values: query, directive, abort.`);
44
+ }
45
+ return { issue, message: messageArg, type: parsedType.data };
46
+ }
47
+ /** Identify candidate active runs for auto-resolution (AC-17b). */
48
+ export function findActiveIssues(issues, isAlive, cwd = process.cwd()) {
49
+ const active = [];
50
+ for (const issue of issues) {
51
+ if (issue.status !== "in_progress")
52
+ continue;
53
+ const pid = readPidFile(issue.number, cwd);
54
+ if (pid !== null && isAlive(pid)) {
55
+ active.push(issue.number);
56
+ }
57
+ }
58
+ return active;
59
+ }
60
+ /**
61
+ * Resolve which issue to target.
62
+ * - Explicit `issue` arg: use as-is.
63
+ * - Single active run: auto-resolve.
64
+ * - Zero active runs: error.
65
+ * - Multiple active runs: error with usage hint.
66
+ */
67
+ export function resolveTargetIssue(args) {
68
+ if (args.explicit !== null) {
69
+ return { issue: args.explicit, reason: "explicit" };
70
+ }
71
+ if (args.activeIssues.length === 0) {
72
+ throw new Error("No active sequant runs found. Start one with `sequant run <issue>` first.");
73
+ }
74
+ if (args.activeIssues.length > 1) {
75
+ const list = args.activeIssues.map((n) => `#${n}`).join(", ");
76
+ throw new Error(`Multiple active runs: ${list}. Specify an issue number.\n` +
77
+ `Usage: sequant prompt ${args.activeIssues[0]} "your message"`);
78
+ }
79
+ return { issue: args.activeIssues[0], reason: "single-active" };
80
+ }
81
+ export async function promptCommand(argsAndOptions) {
82
+ const { args, options } = argsAndOptions;
83
+ const parsed = parseRelayPromptArgs(args, { type: options.type });
84
+ // Resolve target issue.
85
+ const stateManager = new StateManager();
86
+ let issueState;
87
+ let issueNumber;
88
+ if (parsed.issue !== null) {
89
+ issueNumber = parsed.issue;
90
+ issueState = await stateManager.getIssueState(issueNumber);
91
+ }
92
+ else {
93
+ const all = stateManager.stateExists()
94
+ ? Object.values(await stateManager.getAllIssueStates())
95
+ : [];
96
+ const { defaultIsPidAlive } = await import("../lib/locks/lock-manager.js");
97
+ const active = findActiveIssues(all, defaultIsPidAlive);
98
+ const target = resolveTargetIssue({
99
+ explicit: null,
100
+ activeIssues: active,
101
+ });
102
+ issueNumber = target.issue;
103
+ issueState = await stateManager.getIssueState(issueNumber);
104
+ }
105
+ // Liveness check — refuse to write to a dead session.
106
+ const cleanup = cleanupStalePid(issueNumber);
107
+ if (cleanup.warning) {
108
+ // Also reflect deactivation in state.
109
+ if (issueState?.relay) {
110
+ try {
111
+ await stateManager.setRelayState(issueNumber, null);
112
+ }
113
+ catch {
114
+ /* swallow */
115
+ }
116
+ }
117
+ if (options.json) {
118
+ console.log(JSON.stringify({
119
+ ok: false,
120
+ issue: issueNumber,
121
+ error: cleanup.warning,
122
+ }));
123
+ }
124
+ else {
125
+ console.error(chalk.yellow(cleanup.warning));
126
+ }
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ if (!cleanup.alive) {
131
+ const msg = `No relay PID found for #${issueNumber}. Is the run active?`;
132
+ if (options.json) {
133
+ console.log(JSON.stringify({ ok: false, issue: issueNumber, error: msg }));
134
+ }
135
+ else {
136
+ console.error(chalk.yellow(msg));
137
+ }
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ // Write to inbox (the worktree path is recorded in IssueState).
142
+ const message = appendInboxMessage(issueNumber, {
143
+ type: parsed.type,
144
+ ...(parsed.type === "abort" && parsed.message.trim() === ""
145
+ ? {}
146
+ : { message: parsed.message }),
147
+ }, { worktreePath: issueState?.worktree });
148
+ // Increment relay messageCount in state.
149
+ try {
150
+ await stateManager.incrementRelayMessageCount(issueNumber, 1);
151
+ }
152
+ catch {
153
+ /* swallow */
154
+ }
155
+ // Re-fetch state with a fresh manager to bypass any cached snapshot from
156
+ // the start-of-command read. Without this, `currentPhase` shown in the
157
+ // confirmation can be a phase-old (#645, Gap 6: user reported "exec phase"
158
+ // while state.json had advanced to qa).
159
+ let freshPhase;
160
+ let freshStartedAt;
161
+ try {
162
+ const freshState = await new StateManager().getIssueState(issueNumber);
163
+ freshPhase = freshState?.currentPhase;
164
+ freshStartedAt = freshState?.relay?.startedAt;
165
+ }
166
+ catch {
167
+ // Fall back to the issueState we already have.
168
+ freshPhase = issueState?.currentPhase;
169
+ freshStartedAt = issueState?.relay?.startedAt;
170
+ }
171
+ let elapsedSegment = "";
172
+ if (freshStartedAt) {
173
+ const ms = Date.now() - new Date(freshStartedAt).getTime();
174
+ elapsedSegment = `, ${formatElapsed(ms)} elapsed`;
175
+ }
176
+ // Omit the phase label when we don't have a fresh reading (#645, Gap 6) —
177
+ // a wrong phase is worse than no phase. Callers asking for JSON still get
178
+ // an explicit `phase: null` for that case.
179
+ const phaseSegment = freshPhase
180
+ ? ` (${freshPhase} phase${elapsedSegment})`
181
+ : "";
182
+ const confirmation = `Message sent to #${issueNumber}${phaseSegment}`;
183
+ if (options.json) {
184
+ console.log(JSON.stringify({
185
+ ok: true,
186
+ issue: issueNumber,
187
+ messageId: message.id,
188
+ type: parsed.type,
189
+ phase: freshPhase ?? null,
190
+ }));
191
+ }
192
+ else {
193
+ console.log(chalk.green(confirmation));
194
+ }
195
+ // Optional --wait: poll outbox for a reply that references our message id.
196
+ // Times out with exit 1 if no matching reply lands within the window.
197
+ if (typeof options.waitSeconds === "number" &&
198
+ options.waitSeconds > 0 &&
199
+ parsed.type !== "abort") {
200
+ const reply = await waitForReply({
201
+ issueNumber,
202
+ worktreePath: issueState?.worktree,
203
+ inReplyTo: message.id,
204
+ timeoutMs: options.waitSeconds * 1000,
205
+ pollIntervalMs: options.waitPollIntervalMs ?? 250,
206
+ });
207
+ if (reply) {
208
+ if (options.json) {
209
+ console.log(JSON.stringify({ ok: true, reply }));
210
+ }
211
+ else {
212
+ console.log(chalk.cyan(`Reply: ${reply.message}`));
213
+ }
214
+ }
215
+ else {
216
+ const msg = `No reply received within ${options.waitSeconds}s. Message may be archived without a response.`;
217
+ if (options.json) {
218
+ console.log(JSON.stringify({ ok: false, timeout: true, error: msg }));
219
+ }
220
+ else {
221
+ console.error(chalk.yellow(msg));
222
+ }
223
+ process.exitCode = 1;
224
+ }
225
+ }
226
+ }
227
+ async function waitForReply(options) {
228
+ const outboxPath = outboxPathFor(options.issueNumber, {
229
+ worktreePath: options.worktreePath,
230
+ });
231
+ // Seed offset at current EOF: only NEW replies count for this prompt's wait.
232
+ let offset = existsSync(outboxPath) ? statSync(outboxPath).size : 0;
233
+ let partial = "";
234
+ const deadline = Date.now() + options.timeoutMs;
235
+ while (Date.now() < deadline) {
236
+ if (existsSync(outboxPath)) {
237
+ try {
238
+ const size = statSync(outboxPath).size;
239
+ if (size > offset) {
240
+ const chunk = readFileSync(outboxPath, "utf-8").slice(offset);
241
+ offset = size;
242
+ const lines = (partial + chunk).split("\n");
243
+ partial = lines.pop() ?? "";
244
+ for (const line of lines) {
245
+ if (line.trim() === "")
246
+ continue;
247
+ try {
248
+ const parsed = RelayResponseSchema.safeParse(JSON.parse(line));
249
+ if (parsed.success &&
250
+ parsed.data.inReplyTo === options.inReplyTo) {
251
+ return parsed.data;
252
+ }
253
+ }
254
+ catch {
255
+ /* skip malformed */
256
+ }
257
+ }
258
+ }
259
+ }
260
+ catch {
261
+ /* transient — try again */
262
+ }
263
+ }
264
+ await new Promise((r) => setTimeout(r, options.pollIntervalMs));
265
+ }
266
+ return null;
267
+ }
268
+ function formatElapsed(ms) {
269
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
270
+ const minutes = Math.floor(totalSec / 60);
271
+ const seconds = totalSec % 60;
272
+ return `${minutes}m ${seconds}s`;
273
+ }