sequant 1.20.2 → 2.0.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 +2 -4
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +29 -9
  4. package/dist/bin/cli.js +25 -2
  5. package/dist/src/commands/doctor.js +42 -9
  6. package/dist/src/commands/init.d.ts +1 -0
  7. package/dist/src/commands/init.js +52 -0
  8. package/dist/src/commands/logs.d.ts +1 -0
  9. package/dist/src/commands/logs.js +18 -2
  10. package/dist/src/commands/run.d.ts +7 -0
  11. package/dist/src/commands/run.js +235 -68
  12. package/dist/src/commands/serve.d.ts +13 -0
  13. package/dist/src/commands/serve.js +131 -0
  14. package/dist/src/commands/stats.d.ts +1 -0
  15. package/dist/src/commands/stats.js +185 -26
  16. package/dist/src/commands/status.d.ts +2 -0
  17. package/dist/src/commands/status.js +99 -50
  18. package/dist/src/index.d.ts +2 -2
  19. package/dist/src/index.js +4 -1
  20. package/dist/src/lib/ac-parser.d.ts +2 -0
  21. package/dist/src/lib/ac-parser.js +12 -2
  22. package/dist/src/lib/assess-comment-parser.d.ts +137 -0
  23. package/dist/src/lib/assess-comment-parser.js +344 -0
  24. package/dist/src/lib/ci/config.d.ts +22 -0
  25. package/dist/src/lib/ci/config.js +134 -0
  26. package/dist/src/lib/ci/index.d.ts +12 -0
  27. package/dist/src/lib/ci/index.js +10 -0
  28. package/dist/src/lib/ci/inputs.d.ts +29 -0
  29. package/dist/src/lib/ci/inputs.js +103 -0
  30. package/dist/src/lib/ci/labels.d.ts +34 -0
  31. package/dist/src/lib/ci/labels.js +101 -0
  32. package/dist/src/lib/ci/outputs.d.ts +25 -0
  33. package/dist/src/lib/ci/outputs.js +84 -0
  34. package/dist/src/lib/ci/triggers.d.ts +9 -0
  35. package/dist/src/lib/ci/triggers.js +86 -0
  36. package/dist/src/lib/ci/types.d.ts +131 -0
  37. package/dist/src/lib/ci/types.js +47 -0
  38. package/dist/src/lib/mcp-config.d.ts +54 -0
  39. package/dist/src/lib/mcp-config.js +172 -0
  40. package/dist/src/lib/merge-check/index.js +6 -12
  41. package/dist/src/lib/merge-check/types.d.ts +20 -7
  42. package/dist/src/lib/merge-check/types.js +11 -0
  43. package/dist/src/lib/phase-signal.d.ts +3 -3
  44. package/dist/src/lib/phase-signal.js +5 -3
  45. package/dist/src/lib/settings.d.ts +52 -0
  46. package/dist/src/lib/settings.js +41 -0
  47. package/dist/src/lib/shutdown.d.ts +16 -5
  48. package/dist/src/lib/shutdown.js +32 -12
  49. package/dist/src/lib/solve-comment-parser.d.ts +9 -102
  50. package/dist/src/lib/solve-comment-parser.js +13 -248
  51. package/dist/src/lib/stacks.d.ts +8 -0
  52. package/dist/src/lib/stacks.js +34 -0
  53. package/dist/src/lib/system.js +3 -7
  54. package/dist/src/lib/test-tautology-detector.d.ts +10 -0
  55. package/dist/src/lib/test-tautology-detector.js +43 -4
  56. package/dist/src/lib/upstream/assessment.js +9 -59
  57. package/dist/src/lib/upstream/issues.js +12 -75
  58. package/dist/src/lib/version-check.d.ts +2 -2
  59. package/dist/src/lib/version-check.js +6 -3
  60. package/dist/src/lib/version.d.ts +4 -0
  61. package/dist/src/lib/version.js +25 -0
  62. package/dist/src/lib/workflow/batch-executor.d.ts +18 -86
  63. package/dist/src/lib/workflow/batch-executor.js +232 -55
  64. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +56 -0
  65. package/dist/src/lib/workflow/drivers/agent-driver.js +8 -0
  66. package/dist/src/lib/workflow/drivers/aider.d.ts +18 -0
  67. package/dist/src/lib/workflow/drivers/aider.js +160 -0
  68. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -0
  69. package/dist/src/lib/workflow/drivers/claude-code.js +165 -0
  70. package/dist/src/lib/workflow/drivers/index.d.ts +20 -0
  71. package/dist/src/lib/workflow/drivers/index.js +27 -0
  72. package/dist/src/lib/workflow/error-classifier.d.ts +16 -0
  73. package/dist/src/lib/workflow/error-classifier.js +90 -0
  74. package/dist/src/lib/workflow/log-writer.d.ts +6 -3
  75. package/dist/src/lib/workflow/log-writer.js +57 -27
  76. package/dist/src/lib/workflow/metrics-schema.d.ts +9 -9
  77. package/dist/src/lib/workflow/phase-detection.d.ts +23 -0
  78. package/dist/src/lib/workflow/phase-detection.js +45 -29
  79. package/dist/src/lib/workflow/phase-executor.d.ts +42 -3
  80. package/dist/src/lib/workflow/phase-executor.js +345 -220
  81. package/dist/src/lib/workflow/phase-mapper.d.ts +1 -1
  82. package/dist/src/lib/workflow/phase-mapper.js +7 -7
  83. package/dist/src/lib/workflow/platforms/github.d.ts +157 -0
  84. package/dist/src/lib/workflow/platforms/github.js +466 -0
  85. package/dist/src/lib/workflow/platforms/index.d.ts +17 -0
  86. package/dist/src/lib/workflow/platforms/index.js +25 -0
  87. package/dist/src/lib/workflow/platforms/platform-provider.d.ts +67 -0
  88. package/dist/src/lib/workflow/platforms/platform-provider.js +8 -0
  89. package/dist/src/lib/workflow/pr-status.d.ts +2 -4
  90. package/dist/src/lib/workflow/pr-status.js +3 -16
  91. package/dist/src/lib/workflow/qa-cache.d.ts +58 -0
  92. package/dist/src/lib/workflow/qa-cache.js +88 -0
  93. package/dist/src/lib/workflow/reconcile.d.ts +69 -0
  94. package/dist/src/lib/workflow/reconcile.js +290 -0
  95. package/dist/src/lib/workflow/ring-buffer.d.ts +17 -0
  96. package/dist/src/lib/workflow/ring-buffer.js +37 -0
  97. package/dist/src/lib/workflow/run-log-schema.d.ts +115 -24
  98. package/dist/src/lib/workflow/run-log-schema.js +47 -12
  99. package/dist/src/lib/workflow/run-reflect.js +1 -1
  100. package/dist/src/lib/workflow/state-cleanup.js +21 -0
  101. package/dist/src/lib/workflow/state-manager.d.ts +34 -3
  102. package/dist/src/lib/workflow/state-manager.js +278 -126
  103. package/dist/src/lib/workflow/state-schema.d.ts +34 -30
  104. package/dist/src/lib/workflow/state-schema.js +35 -25
  105. package/dist/src/lib/workflow/state-utils.d.ts +3 -1
  106. package/dist/src/lib/workflow/state-utils.js +1 -0
  107. package/dist/src/lib/workflow/types.d.ts +208 -6
  108. package/dist/src/lib/workflow/types.js +20 -1
  109. package/dist/src/lib/workflow/worktree-discovery.d.ts +1 -1
  110. package/dist/src/lib/workflow/worktree-discovery.js +6 -14
  111. package/dist/src/lib/workflow/worktree-manager.js +33 -51
  112. package/dist/src/mcp/index.d.ts +4 -0
  113. package/dist/src/mcp/index.js +4 -0
  114. package/dist/src/mcp/resources.d.ts +7 -0
  115. package/dist/src/mcp/resources.js +111 -0
  116. package/dist/src/mcp/run-registry.d.ts +34 -0
  117. package/dist/src/mcp/run-registry.js +42 -0
  118. package/dist/src/mcp/server.d.ts +12 -0
  119. package/dist/src/mcp/server.js +50 -0
  120. package/dist/src/mcp/tools/logs.d.ts +7 -0
  121. package/dist/src/mcp/tools/logs.js +149 -0
  122. package/dist/src/mcp/tools/run.d.ts +121 -0
  123. package/dist/src/mcp/tools/run.js +591 -0
  124. package/dist/src/mcp/tools/status.d.ts +7 -0
  125. package/dist/src/mcp/tools/status.js +127 -0
  126. package/package.json +10 -1
  127. package/templates/hooks/post-tool.sh +19 -8
  128. package/templates/hooks/pre-tool.sh +36 -49
  129. package/templates/mcp.json +6 -0
  130. package/templates/skills/assess/SKILL.md +354 -352
  131. package/templates/skills/exec/SKILL.md +64 -1
  132. package/templates/skills/fullsolve/SKILL.md +35 -4
  133. package/templates/skills/qa/SKILL.md +486 -9
  134. package/templates/skills/qa/scripts/quality-checks.sh +1 -1
  135. package/templates/skills/setup/SKILL.md +386 -0
  136. package/templates/skills/solve/SKILL.md +38 -664
  137. package/templates/skills/spec/SKILL.md +90 -31
@@ -103,7 +103,7 @@ function suggestImprovements(input, observations, suggestions) {
103
103
  const allSame = phaseSets.every((s) => s === phaseSets[0]);
104
104
  if (allSame) {
105
105
  observations.push("All issues ran identical phases despite different requirements");
106
- suggestions.push("Use `/solve` first to get per-issue phase recommendations");
106
+ suggestions.push("Use `/assess` first to get per-issue phase recommendations");
107
107
  }
108
108
  }
109
109
  // Check if quality loop was triggered
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { spawnSync } from "child_process";
19
19
  import { StateManager } from "./state-manager.js";
20
+ import { isTerminalStatus } from "./state-schema.js";
20
21
  import { checkPRMergeStatus, isIssueMergedIntoMain } from "./pr-status.js";
21
22
  /**
22
23
  * Get list of active worktree paths
@@ -109,6 +110,9 @@ export async function cleanupStaleEntries(options = {}) {
109
110
  // Mark as abandoned (kept for review)
110
111
  orphaned.push(issueNum);
111
112
  issueState.status = "abandoned";
113
+ if (!issueState.resolvedAt) {
114
+ issueState.resolvedAt = new Date().toISOString();
115
+ }
112
116
  if (options.verbose) {
113
117
  console.log(` → Marked as abandoned (kept for review)`);
114
118
  }
@@ -144,6 +148,20 @@ export async function cleanupStaleEntries(options = {}) {
144
148
  delete state.issues[issueNumStr];
145
149
  }
146
150
  }
151
+ continue;
152
+ }
153
+ // Force-clean: remove ALL resolved entries regardless of worktree/TTL
154
+ if (isTerminalStatus(issueState.status)) {
155
+ if (options.verbose) {
156
+ console.log(`🧹 Resolved: #${issueNum} (${issueState.status})`);
157
+ }
158
+ if (issueState.status === "merged") {
159
+ merged.push(issueNum);
160
+ }
161
+ removed.push(issueNum);
162
+ if (!options.dryRun) {
163
+ delete state.issues[issueNumStr];
164
+ }
147
165
  }
148
166
  }
149
167
  // Save updated state
@@ -223,6 +241,9 @@ export async function reconcileStateAtStartup(options = {}) {
223
241
  // Advance state to merged
224
242
  issueState.status = "merged";
225
243
  issueState.lastActivity = new Date().toISOString();
244
+ if (!issueState.resolvedAt) {
245
+ issueState.resolvedAt = new Date().toISOString();
246
+ }
226
247
  advanced.push(issueNum);
227
248
  }
228
249
  else {
@@ -27,6 +27,8 @@ export interface StateManagerOptions {
27
27
  statePath?: string;
28
28
  /** Enable verbose logging */
29
29
  verbose?: boolean;
30
+ /** Lock acquisition timeout in ms (default: 5000) */
31
+ lockTimeout?: number;
30
32
  }
31
33
  /**
32
34
  * Manages persistent workflow state
@@ -35,20 +37,42 @@ export declare class StateManager {
35
37
  private statePath;
36
38
  private verbose;
37
39
  private cachedState;
40
+ private lockTimeout;
38
41
  constructor(options?: StateManagerOptions);
42
+ /**
43
+ * Execute a callback while holding an exclusive file lock.
44
+ *
45
+ * Ensures that concurrent processes serialize their read-modify-write
46
+ * cycles on state.json. The cache is cleared before executing the
47
+ * callback so the latest on-disk state is read.
48
+ *
49
+ * External callers (e.g., reconcileState) should use this to wrap
50
+ * any read-modify-write cycle that includes getState() + saveState().
51
+ */
52
+ withLock<T>(fn: () => Promise<T>): Promise<T>;
53
+ private acquireLock;
54
+ private releaseLock;
39
55
  /**
40
56
  * Get the full path to the state file
41
57
  */
42
58
  getStatePath(): string;
43
59
  /**
44
- * Read the current workflow state
60
+ * Read the current workflow state.
61
+ *
62
+ * **Warning:** This method does NOT acquire a lock. For concurrent access
63
+ * (e.g., reconcileState), wrap your read-modify-write cycle in withLock()
64
+ * to prevent interleaving with other state mutations.
45
65
  *
46
66
  * Returns empty state if file doesn't exist.
47
67
  * Throws on parse errors.
48
68
  */
49
69
  getState(): Promise<WorkflowState>;
50
70
  /**
51
- * Write state to disk using atomic write
71
+ * Write state to disk using atomic write.
72
+ *
73
+ * **Warning:** This method does NOT acquire a lock. For concurrent access
74
+ * (e.g., reconcileState), wrap your read-modify-write cycle in withLock()
75
+ * to prevent interleaving with other state mutations.
52
76
  *
53
77
  * Writes to a temp file first, then renames to prevent corruption
54
78
  * if the process is interrupted during write.
@@ -63,9 +87,16 @@ export declare class StateManager {
63
87
  */
64
88
  getIssueState(issueNumber: number): Promise<IssueState | null>;
65
89
  /**
66
- * Get all issue states
90
+ * Get all issue states, filtering out expired resolved issues based on TTL.
91
+ *
92
+ * Uses `run.resolvedIssueTTL` from settings (default: 7 days).
93
+ * Expired entries are hidden in-memory; disk cleanup happens lazily on next write.
67
94
  */
68
95
  getAllIssueStates(): Promise<Record<number, IssueState>>;
96
+ /**
97
+ * Get all issue states without TTL filtering (for --all escape hatch).
98
+ */
99
+ getAllIssueStatesUnfiltered(): Promise<Record<number, IssueState>>;
69
100
  /**
70
101
  * Get the current phase for an issue
71
102
  */
@@ -23,7 +23,8 @@
23
23
  import * as fs from "fs";
24
24
  import * as path from "path";
25
25
  import * as os from "os";
26
- import { WorkflowStateSchema, STATE_FILE_PATH, createEmptyState, createIssueState, createPhaseState, updateAcceptanceCriteriaSummary, } from "./state-schema.js";
26
+ import { WorkflowStateSchema, STATE_FILE_PATH, createEmptyState, createIssueState, createPhaseState, updateAcceptanceCriteriaSummary, isTerminalStatus, isExpired, } from "./state-schema.js";
27
+ import { getSettings } from "../settings.js";
27
28
  /**
28
29
  * Manages persistent workflow state
29
30
  */
@@ -31,9 +32,88 @@ export class StateManager {
31
32
  statePath;
32
33
  verbose;
33
34
  cachedState = null;
35
+ lockTimeout;
34
36
  constructor(options = {}) {
35
37
  this.statePath = options.statePath ?? STATE_FILE_PATH;
36
38
  this.verbose = options.verbose ?? false;
39
+ this.lockTimeout = options.lockTimeout ?? 5000;
40
+ }
41
+ /**
42
+ * Execute a callback while holding an exclusive file lock.
43
+ *
44
+ * Ensures that concurrent processes serialize their read-modify-write
45
+ * cycles on state.json. The cache is cleared before executing the
46
+ * callback so the latest on-disk state is read.
47
+ *
48
+ * External callers (e.g., reconcileState) should use this to wrap
49
+ * any read-modify-write cycle that includes getState() + saveState().
50
+ */
51
+ async withLock(fn) {
52
+ const lockPath = this.statePath + ".lock";
53
+ await this.acquireLock(lockPath);
54
+ try {
55
+ // Clear cache so we read the latest on-disk state
56
+ this.clearCache();
57
+ return await fn();
58
+ }
59
+ finally {
60
+ this.releaseLock(lockPath);
61
+ }
62
+ }
63
+ async acquireLock(lockPath) {
64
+ const start = Date.now();
65
+ const retryDelay = 50; // ms
66
+ // Ensure directory exists for lock file
67
+ const dir = path.dirname(lockPath);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
71
+ while (true) {
72
+ try {
73
+ // O_EXCL: fail atomically if file already exists
74
+ const fd = fs.openSync(lockPath, "wx");
75
+ fs.writeSync(fd, String(process.pid));
76
+ fs.closeSync(fd);
77
+ return; // Lock acquired
78
+ }
79
+ catch (err) {
80
+ const code = err.code;
81
+ if (code !== "EEXIST")
82
+ throw err;
83
+ // Check for stale lock (older than timeout)
84
+ try {
85
+ const stat = fs.statSync(lockPath);
86
+ const age = Date.now() - stat.mtimeMs;
87
+ if (age > this.lockTimeout) {
88
+ // Stale lock — remove and retry
89
+ try {
90
+ fs.unlinkSync(lockPath);
91
+ }
92
+ catch {
93
+ // Another process may have removed it
94
+ }
95
+ continue;
96
+ }
97
+ }
98
+ catch {
99
+ // Lock file disappeared between open and stat — retry
100
+ continue;
101
+ }
102
+ if (Date.now() - start > this.lockTimeout) {
103
+ throw new Error(`Timeout acquiring state lock after ${this.lockTimeout}ms`);
104
+ }
105
+ // Wait and retry
106
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
107
+ }
108
+ }
109
+ }
110
+ releaseLock(lockPath) {
111
+ try {
112
+ fs.unlinkSync(lockPath);
113
+ }
114
+ catch {
115
+ // Ignore — lock may have been cleaned up by stale detection
116
+ }
37
117
  }
38
118
  /**
39
119
  * Get the full path to the state file
@@ -42,7 +122,11 @@ export class StateManager {
42
122
  return this.statePath;
43
123
  }
44
124
  /**
45
- * Read the current workflow state
125
+ * Read the current workflow state.
126
+ *
127
+ * **Warning:** This method does NOT acquire a lock. For concurrent access
128
+ * (e.g., reconcileState), wrap your read-modify-write cycle in withLock()
129
+ * to prevent interleaving with other state mutations.
46
130
  *
47
131
  * Returns empty state if file doesn't exist.
48
132
  * Throws on parse errors.
@@ -72,7 +156,11 @@ export class StateManager {
72
156
  }
73
157
  }
74
158
  /**
75
- * Write state to disk using atomic write
159
+ * Write state to disk using atomic write.
160
+ *
161
+ * **Warning:** This method does NOT acquire a lock. For concurrent access
162
+ * (e.g., reconcileState), wrap your read-modify-write cycle in withLock()
163
+ * to prevent interleaving with other state mutations.
76
164
  *
77
165
  * Writes to a temp file first, then renames to prevent corruption
78
166
  * if the process is interrupted during write.
@@ -82,6 +170,24 @@ export class StateManager {
82
170
  WorkflowStateSchema.parse(state);
83
171
  // Update lastUpdated timestamp
84
172
  state.lastUpdated = new Date().toISOString();
173
+ // Lazy disk cleanup: prune expired entries before writing
174
+ try {
175
+ const settings = await getSettings();
176
+ const ttlDays = settings.run.resolvedIssueTTL ?? 7;
177
+ const pruned = [];
178
+ for (const [key, entry] of Object.entries(state.issues)) {
179
+ if (isExpired(entry, ttlDays)) {
180
+ pruned.push(key);
181
+ delete state.issues[key];
182
+ }
183
+ }
184
+ if (pruned.length > 0 && this.verbose) {
185
+ console.log(`📊 Pruned ${pruned.length} expired issue(s): ${pruned.map((k) => `#${k}`).join(", ")}`);
186
+ }
187
+ }
188
+ catch {
189
+ // Settings read failure should not block state writes
190
+ }
85
191
  // Ensure directory exists
86
192
  const dir = path.dirname(this.statePath);
87
193
  if (!fs.existsSync(dir)) {
@@ -119,9 +225,27 @@ export class StateManager {
119
225
  return state.issues[String(issueNumber)] ?? null;
120
226
  }
121
227
  /**
122
- * Get all issue states
228
+ * Get all issue states, filtering out expired resolved issues based on TTL.
229
+ *
230
+ * Uses `run.resolvedIssueTTL` from settings (default: 7 days).
231
+ * Expired entries are hidden in-memory; disk cleanup happens lazily on next write.
123
232
  */
124
233
  async getAllIssueStates() {
234
+ const state = await this.getState();
235
+ const settings = await getSettings();
236
+ const ttlDays = settings.run.resolvedIssueTTL ?? 7;
237
+ const result = {};
238
+ for (const [key, value] of Object.entries(state.issues)) {
239
+ if (!isExpired(value, ttlDays)) {
240
+ result[parseInt(key, 10)] = value;
241
+ }
242
+ }
243
+ return result;
244
+ }
245
+ /**
246
+ * Get all issue states without TTL filtering (for --all escape hatch).
247
+ */
248
+ async getAllIssueStatesUnfiltered() {
125
249
  const state = await this.getState();
126
250
  const result = {};
127
251
  for (const [key, value] of Object.entries(state.issues)) {
@@ -140,11 +264,13 @@ export class StateManager {
140
264
  * Initialize tracking for a new issue
141
265
  */
142
266
  async initializeIssue(issueNumber, title, options) {
143
- const state = await this.getState();
144
- // Create new issue state
145
- const issueState = createIssueState(issueNumber, title, options);
146
- state.issues[String(issueNumber)] = issueState;
147
- await this.saveState(state);
267
+ await this.withLock(async () => {
268
+ const state = await this.getState();
269
+ // Create new issue state
270
+ const issueState = createIssueState(issueNumber, title, options);
271
+ state.issues[String(issueNumber)] = issueState;
272
+ await this.saveState(state);
273
+ });
148
274
  if (this.verbose) {
149
275
  console.log(`📊 Initialized issue #${issueNumber}: ${title}`);
150
276
  }
@@ -153,35 +279,39 @@ export class StateManager {
153
279
  * Update the status of a specific phase
154
280
  */
155
281
  async updatePhaseStatus(issueNumber, phase, status, options) {
156
- const state = await this.getState();
157
- const issueState = state.issues[String(issueNumber)];
158
- if (!issueState) {
159
- throw new Error(`Issue #${issueNumber} not found in state`);
160
- }
161
- // Update phase state
162
- const phaseState = createPhaseState(status);
163
- if (status === "completed" || status === "failed" || status === "skipped") {
164
- phaseState.completedAt = new Date().toISOString();
165
- }
166
- if (options?.error) {
167
- phaseState.error = options.error;
168
- }
169
- if (options?.iteration !== undefined) {
170
- phaseState.iteration = options.iteration;
171
- }
172
- // Preserve startedAt if already set
173
- const existingPhase = issueState.phases[phase];
174
- if (existingPhase?.startedAt && status !== "pending") {
175
- phaseState.startedAt = existingPhase.startedAt;
176
- }
177
- issueState.phases[phase] = phaseState;
178
- issueState.currentPhase = phase;
179
- issueState.lastActivity = new Date().toISOString();
180
- // Update issue status based on phase status
181
- if (status === "in_progress" && issueState.status === "not_started") {
182
- issueState.status = "in_progress";
183
- }
184
- await this.saveState(state);
282
+ await this.withLock(async () => {
283
+ const state = await this.getState();
284
+ const issueState = state.issues[String(issueNumber)];
285
+ if (!issueState) {
286
+ throw new Error(`Issue #${issueNumber} not found in state`);
287
+ }
288
+ // Update phase state
289
+ const phaseState = createPhaseState(status);
290
+ if (status === "completed" ||
291
+ status === "failed" ||
292
+ status === "skipped") {
293
+ phaseState.completedAt = new Date().toISOString();
294
+ }
295
+ if (options?.error) {
296
+ phaseState.error = options.error;
297
+ }
298
+ if (options?.iteration !== undefined) {
299
+ phaseState.iteration = options.iteration;
300
+ }
301
+ // Preserve startedAt if already set
302
+ const existingPhase = issueState.phases[phase];
303
+ if (existingPhase?.startedAt && status !== "pending") {
304
+ phaseState.startedAt = existingPhase.startedAt;
305
+ }
306
+ issueState.phases[phase] = phaseState;
307
+ issueState.currentPhase = phase;
308
+ issueState.lastActivity = new Date().toISOString();
309
+ // Update issue status based on phase status
310
+ if (status === "in_progress" && issueState.status === "not_started") {
311
+ issueState.status = "in_progress";
312
+ }
313
+ await this.saveState(state);
314
+ });
185
315
  if (this.verbose) {
186
316
  console.log(`📊 Phase ${phase} → ${status} for issue #${issueNumber}`);
187
317
  }
@@ -190,14 +320,20 @@ export class StateManager {
190
320
  * Update the overall issue status
191
321
  */
192
322
  async updateIssueStatus(issueNumber, status) {
193
- const state = await this.getState();
194
- const issueState = state.issues[String(issueNumber)];
195
- if (!issueState) {
196
- throw new Error(`Issue #${issueNumber} not found in state`);
197
- }
198
- issueState.status = status;
199
- issueState.lastActivity = new Date().toISOString();
200
- await this.saveState(state);
323
+ await this.withLock(async () => {
324
+ const state = await this.getState();
325
+ const issueState = state.issues[String(issueNumber)];
326
+ if (!issueState) {
327
+ throw new Error(`Issue #${issueNumber} not found in state`);
328
+ }
329
+ issueState.status = status;
330
+ issueState.lastActivity = new Date().toISOString();
331
+ // Record resolvedAt on first transition to terminal status
332
+ if (isTerminalStatus(status) && !issueState.resolvedAt) {
333
+ issueState.resolvedAt = new Date().toISOString();
334
+ }
335
+ await this.saveState(state);
336
+ });
201
337
  if (this.verbose) {
202
338
  console.log(`📊 Issue #${issueNumber} status → ${status}`);
203
339
  }
@@ -206,14 +342,16 @@ export class StateManager {
206
342
  * Update PR information for an issue
207
343
  */
208
344
  async updatePRInfo(issueNumber, pr) {
209
- const state = await this.getState();
210
- const issueState = state.issues[String(issueNumber)];
211
- if (!issueState) {
212
- throw new Error(`Issue #${issueNumber} not found in state`);
213
- }
214
- issueState.pr = pr;
215
- issueState.lastActivity = new Date().toISOString();
216
- await this.saveState(state);
345
+ await this.withLock(async () => {
346
+ const state = await this.getState();
347
+ const issueState = state.issues[String(issueNumber)];
348
+ if (!issueState) {
349
+ throw new Error(`Issue #${issueNumber} not found in state`);
350
+ }
351
+ issueState.pr = pr;
352
+ issueState.lastActivity = new Date().toISOString();
353
+ await this.saveState(state);
354
+ });
217
355
  if (this.verbose) {
218
356
  console.log(`📊 PR #${pr.number} linked to issue #${issueNumber}`);
219
357
  }
@@ -222,15 +360,17 @@ export class StateManager {
222
360
  * Update worktree information for an issue
223
361
  */
224
362
  async updateWorktreeInfo(issueNumber, worktree, branch) {
225
- const state = await this.getState();
226
- const issueState = state.issues[String(issueNumber)];
227
- if (!issueState) {
228
- throw new Error(`Issue #${issueNumber} not found in state`);
229
- }
230
- issueState.worktree = worktree;
231
- issueState.branch = branch;
232
- issueState.lastActivity = new Date().toISOString();
233
- await this.saveState(state);
363
+ await this.withLock(async () => {
364
+ const state = await this.getState();
365
+ const issueState = state.issues[String(issueNumber)];
366
+ if (!issueState) {
367
+ throw new Error(`Issue #${issueNumber} not found in state`);
368
+ }
369
+ issueState.worktree = worktree;
370
+ issueState.branch = branch;
371
+ issueState.lastActivity = new Date().toISOString();
372
+ await this.saveState(state);
373
+ });
234
374
  if (this.verbose) {
235
375
  console.log(`📊 Worktree updated for issue #${issueNumber}: ${worktree}`);
236
376
  }
@@ -239,29 +379,33 @@ export class StateManager {
239
379
  * Update session ID for an issue (for resume)
240
380
  */
241
381
  async updateSessionId(issueNumber, sessionId) {
242
- const state = await this.getState();
243
- const issueState = state.issues[String(issueNumber)];
244
- if (!issueState) {
245
- throw new Error(`Issue #${issueNumber} not found in state`);
246
- }
247
- issueState.sessionId = sessionId;
248
- issueState.lastActivity = new Date().toISOString();
249
- await this.saveState(state);
382
+ await this.withLock(async () => {
383
+ const state = await this.getState();
384
+ const issueState = state.issues[String(issueNumber)];
385
+ if (!issueState) {
386
+ throw new Error(`Issue #${issueNumber} not found in state`);
387
+ }
388
+ issueState.sessionId = sessionId;
389
+ issueState.lastActivity = new Date().toISOString();
390
+ await this.saveState(state);
391
+ });
250
392
  }
251
393
  /**
252
394
  * Update loop iteration for an issue
253
395
  */
254
396
  async updateLoopIteration(issueNumber, iteration) {
255
- const state = await this.getState();
256
- const issueState = state.issues[String(issueNumber)];
257
- if (!issueState) {
258
- throw new Error(`Issue #${issueNumber} not found in state`);
259
- }
260
- if (issueState.loop) {
261
- issueState.loop.iteration = iteration;
262
- }
263
- issueState.lastActivity = new Date().toISOString();
264
- await this.saveState(state);
397
+ await this.withLock(async () => {
398
+ const state = await this.getState();
399
+ const issueState = state.issues[String(issueNumber)];
400
+ if (!issueState) {
401
+ throw new Error(`Issue #${issueNumber} not found in state`);
402
+ }
403
+ if (issueState.loop) {
404
+ issueState.loop.iteration = iteration;
405
+ }
406
+ issueState.lastActivity = new Date().toISOString();
407
+ await this.saveState(state);
408
+ });
265
409
  if (this.verbose) {
266
410
  console.log(`📊 Loop iteration ${iteration} for issue #${issueNumber}`);
267
411
  }
@@ -270,12 +414,14 @@ export class StateManager {
270
414
  * Remove an issue from state
271
415
  */
272
416
  async removeIssue(issueNumber) {
273
- const state = await this.getState();
274
- if (!state.issues[String(issueNumber)]) {
275
- return; // Already removed
276
- }
277
- delete state.issues[String(issueNumber)];
278
- await this.saveState(state);
417
+ await this.withLock(async () => {
418
+ const state = await this.getState();
419
+ if (!state.issues[String(issueNumber)]) {
420
+ return; // Already removed
421
+ }
422
+ delete state.issues[String(issueNumber)];
423
+ await this.saveState(state);
424
+ });
279
425
  if (this.verbose) {
280
426
  console.log(`📊 Removed issue #${issueNumber} from state`);
281
427
  }
@@ -287,14 +433,16 @@ export class StateManager {
287
433
  * Used by /spec to store extracted ACs from the issue body.
288
434
  */
289
435
  async updateAcceptanceCriteria(issueNumber, acceptanceCriteria) {
290
- const state = await this.getState();
291
- const issueState = state.issues[String(issueNumber)];
292
- if (!issueState) {
293
- throw new Error(`Issue #${issueNumber} not found in state`);
294
- }
295
- issueState.acceptanceCriteria = acceptanceCriteria;
296
- issueState.lastActivity = new Date().toISOString();
297
- await this.saveState(state);
436
+ await this.withLock(async () => {
437
+ const state = await this.getState();
438
+ const issueState = state.issues[String(issueNumber)];
439
+ if (!issueState) {
440
+ throw new Error(`Issue #${issueNumber} not found in state`);
441
+ }
442
+ issueState.acceptanceCriteria = acceptanceCriteria;
443
+ issueState.lastActivity = new Date().toISOString();
444
+ await this.saveState(state);
445
+ });
298
446
  if (this.verbose) {
299
447
  console.log(`📊 AC updated for issue #${issueNumber}: ${acceptanceCriteria.items.length} items`);
300
448
  }
@@ -313,29 +461,31 @@ export class StateManager {
313
461
  * Automatically recalculates the summary counts.
314
462
  */
315
463
  async updateACStatus(issueNumber, acId, status, notes) {
316
- const state = await this.getState();
317
- const issueState = state.issues[String(issueNumber)];
318
- if (!issueState) {
319
- throw new Error(`Issue #${issueNumber} not found in state`);
320
- }
321
- if (!issueState.acceptanceCriteria) {
322
- throw new Error(`Issue #${issueNumber} has no acceptance criteria`);
323
- }
324
- // Find and update the AC item
325
- const acItem = issueState.acceptanceCriteria.items.find((item) => item.id === acId);
326
- if (!acItem) {
327
- throw new Error(`AC "${acId}" not found in issue #${issueNumber}. ` +
328
- `Available IDs: ${issueState.acceptanceCriteria.items.map((i) => i.id).join(", ")}`);
329
- }
330
- acItem.status = status;
331
- acItem.verifiedAt = new Date().toISOString();
332
- if (notes !== undefined) {
333
- acItem.notes = notes;
334
- }
335
- // Recalculate summary counts
336
- issueState.acceptanceCriteria = updateAcceptanceCriteriaSummary(issueState.acceptanceCriteria);
337
- issueState.lastActivity = new Date().toISOString();
338
- await this.saveState(state);
464
+ await this.withLock(async () => {
465
+ const state = await this.getState();
466
+ const issueState = state.issues[String(issueNumber)];
467
+ if (!issueState) {
468
+ throw new Error(`Issue #${issueNumber} not found in state`);
469
+ }
470
+ if (!issueState.acceptanceCriteria) {
471
+ throw new Error(`Issue #${issueNumber} has no acceptance criteria`);
472
+ }
473
+ // Find and update the AC item
474
+ const acItem = issueState.acceptanceCriteria.items.find((item) => item.id === acId);
475
+ if (!acItem) {
476
+ throw new Error(`AC "${acId}" not found in issue #${issueNumber}. ` +
477
+ `Available IDs: ${issueState.acceptanceCriteria.items.map((i) => i.id).join(", ")}`);
478
+ }
479
+ acItem.status = status;
480
+ acItem.verifiedAt = new Date().toISOString();
481
+ if (notes !== undefined) {
482
+ acItem.notes = notes;
483
+ }
484
+ // Recalculate summary counts
485
+ issueState.acceptanceCriteria = updateAcceptanceCriteriaSummary(issueState.acceptanceCriteria);
486
+ issueState.lastActivity = new Date().toISOString();
487
+ await this.saveState(state);
488
+ });
339
489
  if (this.verbose) {
340
490
  console.log(`📊 AC ${acId} → ${status} for issue #${issueNumber}`);
341
491
  }
@@ -347,14 +497,16 @@ export class StateManager {
347
497
  * Used by /spec to store the scope assessment result.
348
498
  */
349
499
  async updateScopeAssessment(issueNumber, scopeAssessment) {
350
- const state = await this.getState();
351
- const issueState = state.issues[String(issueNumber)];
352
- if (!issueState) {
353
- throw new Error(`Issue #${issueNumber} not found in state`);
354
- }
355
- issueState.scopeAssessment = scopeAssessment;
356
- issueState.lastActivity = new Date().toISOString();
357
- await this.saveState(state);
500
+ await this.withLock(async () => {
501
+ const state = await this.getState();
502
+ const issueState = state.issues[String(issueNumber)];
503
+ if (!issueState) {
504
+ throw new Error(`Issue #${issueNumber} not found in state`);
505
+ }
506
+ issueState.scopeAssessment = scopeAssessment;
507
+ issueState.lastActivity = new Date().toISOString();
508
+ await this.saveState(state);
509
+ });
358
510
  if (this.verbose) {
359
511
  console.log(`📊 Scope assessment updated for issue #${issueNumber}: ${scopeAssessment.verdict}`);
360
512
  }