sequant 1.10.1 → 1.11.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 (41) hide show
  1. package/README.md +6 -1
  2. package/dist/bin/cli.js +55 -2
  3. package/dist/dashboard/server.d.ts +37 -0
  4. package/dist/dashboard/server.js +968 -0
  5. package/dist/src/commands/dashboard.d.ts +25 -0
  6. package/dist/src/commands/dashboard.js +44 -0
  7. package/dist/src/commands/doctor.d.ts +18 -1
  8. package/dist/src/commands/doctor.js +105 -2
  9. package/dist/src/commands/init.d.ts +1 -0
  10. package/dist/src/commands/init.js +26 -2
  11. package/dist/src/commands/run.d.ts +20 -0
  12. package/dist/src/commands/run.js +151 -3
  13. package/dist/src/commands/state.d.ts +60 -0
  14. package/dist/src/commands/state.js +267 -0
  15. package/dist/src/commands/stats.d.ts +3 -2
  16. package/dist/src/commands/stats.js +246 -38
  17. package/dist/src/commands/status.d.ts +2 -0
  18. package/dist/src/commands/status.js +28 -3
  19. package/dist/src/lib/ac-parser.d.ts +61 -0
  20. package/dist/src/lib/ac-parser.js +156 -0
  21. package/dist/src/lib/fs.d.ts +19 -0
  22. package/dist/src/lib/fs.js +58 -1
  23. package/dist/src/lib/settings.d.ts +7 -0
  24. package/dist/src/lib/settings.js +1 -0
  25. package/dist/src/lib/system.d.ts +19 -0
  26. package/dist/src/lib/system.js +26 -0
  27. package/dist/src/lib/templates.d.ts +34 -1
  28. package/dist/src/lib/templates.js +109 -5
  29. package/dist/src/lib/workflow/metrics-schema.d.ts +153 -0
  30. package/dist/src/lib/workflow/metrics-schema.js +138 -0
  31. package/dist/src/lib/workflow/metrics-writer.d.ts +102 -0
  32. package/dist/src/lib/workflow/metrics-writer.js +189 -0
  33. package/dist/src/lib/workflow/state-manager.d.ts +18 -1
  34. package/dist/src/lib/workflow/state-manager.js +61 -1
  35. package/dist/src/lib/workflow/state-schema.d.ts +152 -1
  36. package/dist/src/lib/workflow/state-schema.js +99 -0
  37. package/dist/src/lib/workflow/state-utils.d.ts +67 -3
  38. package/dist/src/lib/workflow/state-utils.js +289 -8
  39. package/dist/src/lib/workflow/types.d.ts +2 -0
  40. package/dist/src/lib/workflow/types.js +1 -0
  41. package/package.json +5 -1
@@ -18,6 +18,30 @@ import { spawnSync } from "child_process";
18
18
  import { StateManager } from "./state-manager.js";
19
19
  import { createEmptyState, createIssueState, createPhaseState, } from "./state-schema.js";
20
20
  import { RunLogSchema, LOG_PATHS } from "./run-log-schema.js";
21
+ /**
22
+ * Check the merge status of a PR using the gh CLI
23
+ *
24
+ * @param prNumber - The PR number to check
25
+ * @returns "MERGED" | "CLOSED" | "OPEN" | null (null if PR not found or gh unavailable)
26
+ */
27
+ export function checkPRMergeStatus(prNumber) {
28
+ try {
29
+ const result = spawnSync("gh", ["pr", "view", String(prNumber), "--json", "state", "-q", ".state"], { stdio: "pipe", timeout: 10000 });
30
+ if (result.status === 0 && result.stdout) {
31
+ const state = result.stdout.toString().trim().toUpperCase();
32
+ if (state === "MERGED")
33
+ return "MERGED";
34
+ if (state === "CLOSED")
35
+ return "CLOSED";
36
+ if (state === "OPEN")
37
+ return "OPEN";
38
+ }
39
+ }
40
+ catch {
41
+ // gh not available or error - return null
42
+ }
43
+ return null;
44
+ }
21
45
  /**
22
46
  * Rebuild workflow state from run logs
23
47
  *
@@ -143,8 +167,11 @@ export async function rebuildStateFromLogs(options = {}) {
143
167
  /**
144
168
  * Clean up stale and orphaned entries from workflow state
145
169
  *
146
- * - Removes issues with non-existent worktrees (orphaned)
147
- * - Optionally removes old merged/abandoned issues
170
+ * - Checks GitHub to detect if associated PR was merged
171
+ * - Orphaned entries with merged PRs get status "merged" and are removed automatically
172
+ * - Orphaned entries without merged PRs get status "abandoned" (kept for review)
173
+ * - Use removeAll to remove both merged and abandoned orphaned entries in one step
174
+ * - Use maxAgeDays to remove old merged/abandoned issues
148
175
  */
149
176
  export async function cleanupStaleEntries(options = {}) {
150
177
  const manager = new StateManager({
@@ -156,12 +183,14 @@ export async function cleanupStaleEntries(options = {}) {
156
183
  success: true,
157
184
  removed: [],
158
185
  orphaned: [],
186
+ merged: [],
159
187
  };
160
188
  }
161
189
  try {
162
190
  const state = await manager.getState();
163
191
  const removed = [];
164
192
  const orphaned = [];
193
+ const merged = [];
165
194
  // Get list of active worktrees
166
195
  const activeWorktrees = getActiveWorktrees();
167
196
  for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
@@ -169,20 +198,61 @@ export async function cleanupStaleEntries(options = {}) {
169
198
  // Check if worktree exists (if issue has one)
170
199
  if (issueState.worktree &&
171
200
  !activeWorktrees.includes(issueState.worktree)) {
172
- orphaned.push(issueNum);
173
201
  if (options.verbose) {
174
- console.log(`🗑️ Orphaned: #${issueNum} (worktree not found: ${issueState.worktree})`);
202
+ console.log(`🔍 Orphaned: #${issueNum} (worktree not found: ${issueState.worktree})`);
203
+ }
204
+ // Check if this issue has a PR and if it's merged
205
+ let prMerged = false;
206
+ if (issueState.pr?.number) {
207
+ if (options.verbose) {
208
+ console.log(` Checking PR #${issueState.pr.number} status...`);
209
+ }
210
+ const prStatus = checkPRMergeStatus(issueState.pr.number);
211
+ prMerged = prStatus === "MERGED";
212
+ if (options.verbose) {
213
+ console.log(` PR status: ${prStatus ?? "unknown"}`);
214
+ }
175
215
  }
176
216
  if (!options.dryRun) {
177
- // Mark as abandoned or remove based on status
178
- if (issueState.status === "merged" ||
179
- issueState.status === "abandoned") {
217
+ if (prMerged || issueState.status === "merged") {
218
+ // Merged PRs are auto-removed
219
+ merged.push(issueNum);
180
220
  removed.push(issueNum);
221
+ if (options.verbose) {
222
+ console.log(` ✓ Merged PR detected, removing entry`);
223
+ }
224
+ delete state.issues[issueNumStr];
225
+ }
226
+ else if (issueState.status === "abandoned" || options.removeAll) {
227
+ // Already abandoned or removeAll flag - remove it
228
+ orphaned.push(issueNum);
229
+ removed.push(issueNum);
230
+ if (options.verbose) {
231
+ console.log(` ✓ Removing abandoned entry`);
232
+ }
181
233
  delete state.issues[issueNumStr];
182
234
  }
183
235
  else {
184
- // Update status to indicate orphaned state
236
+ // Mark as abandoned (kept for review)
237
+ orphaned.push(issueNum);
185
238
  issueState.status = "abandoned";
239
+ if (options.verbose) {
240
+ console.log(` → Marked as abandoned (kept for review)`);
241
+ }
242
+ }
243
+ }
244
+ else {
245
+ // Dry run - report what would happen
246
+ if (prMerged || issueState.status === "merged") {
247
+ merged.push(issueNum);
248
+ removed.push(issueNum);
249
+ }
250
+ else if (issueState.status === "abandoned" || options.removeAll) {
251
+ orphaned.push(issueNum);
252
+ removed.push(issueNum);
253
+ }
254
+ else {
255
+ orphaned.push(issueNum);
186
256
  }
187
257
  }
188
258
  continue;
@@ -211,6 +281,7 @@ export async function cleanupStaleEntries(options = {}) {
211
281
  success: true,
212
282
  removed,
213
283
  orphaned,
284
+ merged,
214
285
  };
215
286
  }
216
287
  catch (error) {
@@ -218,6 +289,7 @@ export async function cleanupStaleEntries(options = {}) {
218
289
  success: false,
219
290
  removed: [],
220
291
  orphaned: [],
292
+ merged: [],
221
293
  error: String(error),
222
294
  };
223
295
  }
@@ -241,3 +313,212 @@ function getActiveWorktrees() {
241
313
  }
242
314
  return paths;
243
315
  }
316
+ /**
317
+ * Parse issue number from a branch name
318
+ *
319
+ * Supports patterns:
320
+ * - feature/<number>-<slug>
321
+ * - issue-<number>
322
+ * - <number>-<slug>
323
+ */
324
+ function parseIssueNumberFromBranch(branch) {
325
+ // Pattern: feature/123-description or feature/123
326
+ const featureMatch = branch.match(/^feature\/(\d+)(?:-|$)/);
327
+ if (featureMatch) {
328
+ return parseInt(featureMatch[1], 10);
329
+ }
330
+ // Pattern: issue-123
331
+ const issueMatch = branch.match(/^issue-(\d+)$/);
332
+ if (issueMatch) {
333
+ return parseInt(issueMatch[1], 10);
334
+ }
335
+ // Pattern: 123-description (bare number prefix)
336
+ const bareMatch = branch.match(/^(\d+)-/);
337
+ if (bareMatch) {
338
+ return parseInt(bareMatch[1], 10);
339
+ }
340
+ return null;
341
+ }
342
+ /**
343
+ * Fetch issue title from GitHub using gh CLI
344
+ *
345
+ * Returns placeholder if gh is not available or fetch fails.
346
+ */
347
+ function fetchIssueTitle(issueNumber) {
348
+ try {
349
+ const result = spawnSync("gh", ["issue", "view", String(issueNumber), "--json", "title", "-q", ".title"], { stdio: "pipe", timeout: 10000 });
350
+ if (result.status === 0 && result.stdout) {
351
+ const title = result.stdout.toString().trim();
352
+ if (title) {
353
+ return title;
354
+ }
355
+ }
356
+ }
357
+ catch {
358
+ // gh not available or error - use placeholder
359
+ }
360
+ return `(title unavailable for #${issueNumber})`;
361
+ }
362
+ function getWorktreeDetails() {
363
+ const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
364
+ stdio: "pipe",
365
+ });
366
+ if (result.status !== 0) {
367
+ return [];
368
+ }
369
+ const output = result.stdout.toString();
370
+ const worktrees = [];
371
+ let current = {};
372
+ for (const line of output.split("\n")) {
373
+ if (line.startsWith("worktree ")) {
374
+ // Start of new worktree entry
375
+ if (current.path) {
376
+ worktrees.push(current);
377
+ }
378
+ current = { path: line.substring(9) };
379
+ }
380
+ else if (line.startsWith("HEAD ")) {
381
+ current.head = line.substring(5);
382
+ }
383
+ else if (line.startsWith("branch refs/heads/")) {
384
+ current.branch = line.substring(18);
385
+ }
386
+ else if (line === "" && current.path) {
387
+ // End of entry
388
+ worktrees.push(current);
389
+ current = {};
390
+ }
391
+ }
392
+ // Don't forget the last entry
393
+ if (current.path && current.branch) {
394
+ worktrees.push(current);
395
+ }
396
+ return worktrees;
397
+ }
398
+ /**
399
+ * Infer the current phase for an issue by checking logs
400
+ */
401
+ function inferPhaseFromLogs(issueNumber) {
402
+ const logPath = LOG_PATHS.project;
403
+ if (!fs.existsSync(logPath)) {
404
+ return undefined;
405
+ }
406
+ try {
407
+ const files = fs.readdirSync(logPath).filter((f) => f.endsWith(".json"));
408
+ // Sort by timestamp (newest first)
409
+ files.sort().reverse();
410
+ for (const file of files) {
411
+ try {
412
+ const content = fs.readFileSync(path.join(logPath, file), "utf-8");
413
+ const logData = JSON.parse(content);
414
+ const log = RunLogSchema.safeParse(logData);
415
+ if (!log.success)
416
+ continue;
417
+ // Find this issue in the log
418
+ const issueLog = log.data.issues.find((i) => i.issueNumber === issueNumber);
419
+ if (issueLog && issueLog.phases.length > 0) {
420
+ // Return the last executed phase
421
+ const lastPhase = issueLog.phases[issueLog.phases.length - 1];
422
+ return lastPhase.phase;
423
+ }
424
+ }
425
+ catch {
426
+ continue;
427
+ }
428
+ }
429
+ }
430
+ catch {
431
+ return undefined;
432
+ }
433
+ return undefined;
434
+ }
435
+ /**
436
+ * Discover worktrees that are not yet tracked in state
437
+ *
438
+ * Scans all git worktrees, identifies those with issue-related branch names,
439
+ * and returns information about worktrees not yet in the state file.
440
+ */
441
+ export async function discoverUntrackedWorktrees(options = {}) {
442
+ try {
443
+ const worktrees = getWorktreeDetails();
444
+ const discovered = [];
445
+ const skipped = [];
446
+ let alreadyTracked = 0;
447
+ // Get existing state
448
+ const manager = new StateManager({
449
+ statePath: options.statePath,
450
+ verbose: options.verbose,
451
+ });
452
+ const state = await manager.getState();
453
+ const trackedIssues = new Set(Object.keys(state.issues).map((n) => parseInt(n, 10)));
454
+ for (const worktree of worktrees) {
455
+ // Skip if no branch (detached HEAD)
456
+ if (!worktree.branch) {
457
+ skipped.push({
458
+ path: worktree.path,
459
+ reason: "detached HEAD (no branch)",
460
+ });
461
+ continue;
462
+ }
463
+ // Skip main/master branches
464
+ if (worktree.branch === "main" || worktree.branch === "master") {
465
+ skipped.push({
466
+ path: worktree.path,
467
+ reason: "main/master branch (not a feature worktree)",
468
+ });
469
+ continue;
470
+ }
471
+ // Try to parse issue number from branch
472
+ const issueNumber = parseIssueNumberFromBranch(worktree.branch);
473
+ if (issueNumber === null) {
474
+ skipped.push({
475
+ path: worktree.path,
476
+ reason: `branch name doesn't match issue pattern: ${worktree.branch}`,
477
+ });
478
+ continue;
479
+ }
480
+ // Check if already tracked
481
+ if (trackedIssues.has(issueNumber)) {
482
+ alreadyTracked++;
483
+ if (options.verbose) {
484
+ console.log(` Already tracked: #${issueNumber} (${worktree.branch})`);
485
+ }
486
+ continue;
487
+ }
488
+ // Fetch title from GitHub
489
+ if (options.verbose) {
490
+ console.log(` Fetching title for #${issueNumber}...`);
491
+ }
492
+ const title = fetchIssueTitle(issueNumber);
493
+ // Try to infer phase from logs
494
+ const inferredPhase = inferPhaseFromLogs(issueNumber);
495
+ discovered.push({
496
+ issueNumber,
497
+ title,
498
+ worktreePath: worktree.path,
499
+ branch: worktree.branch,
500
+ inferredPhase,
501
+ });
502
+ if (options.verbose) {
503
+ console.log(` Discovered: #${issueNumber} - ${title}${inferredPhase ? ` (phase: ${inferredPhase})` : ""}`);
504
+ }
505
+ }
506
+ return {
507
+ success: true,
508
+ worktreesScanned: worktrees.length,
509
+ alreadyTracked,
510
+ discovered,
511
+ skipped,
512
+ };
513
+ }
514
+ catch (error) {
515
+ return {
516
+ success: false,
517
+ worktreesScanned: 0,
518
+ alreadyTracked: 0,
519
+ discovered: [],
520
+ skipped: [],
521
+ error: String(error),
522
+ };
523
+ }
524
+ }
@@ -33,6 +33,8 @@ export interface ExecutionConfig {
33
33
  noSmartTests: boolean;
34
34
  /** Dry run mode - don't actually execute */
35
35
  dryRun: boolean;
36
+ /** Enable MCP servers in headless mode (true by default, false if --no-mcp flag used) */
37
+ mcp: boolean;
36
38
  }
37
39
  /**
38
40
  * Default execution configuration
@@ -19,4 +19,5 @@ export const DEFAULT_CONFIG = {
19
19
  verbose: false,
20
20
  noSmartTests: false,
21
21
  dryRun: false,
22
+ mcp: true,
22
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,10 +55,14 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@anthropic-ai/claude-agent-sdk": "^0.2.11",
58
+ "@hono/node-server": "^1.19.9",
58
59
  "chalk": "^5.3.0",
60
+ "chokidar": "^5.0.0",
59
61
  "commander": "^12.1.0",
60
62
  "diff": "^7.0.0",
63
+ "hono": "^4.11.4",
61
64
  "inquirer": "^12.3.2",
65
+ "open": "^11.0.0",
62
66
  "yaml": "^2.7.0",
63
67
  "zod": "^4.3.5"
64
68
  },