sequant 2.1.1 → 2.2.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bin/cli.js +1 -0
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +122 -3
- package/dist/src/commands/run-compat.d.ts +14 -0
- package/dist/src/commands/run-compat.js +12 -0
- package/dist/src/commands/run-display.d.ts +17 -0
- package/dist/src/commands/run-display.js +116 -0
- package/dist/src/commands/run.d.ts +4 -26
- package/dist/src/commands/run.js +47 -772
- package/dist/src/commands/status.js +24 -1
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +9 -0
- package/dist/src/lib/errors.d.ts +93 -0
- package/dist/src/lib/errors.js +97 -0
- package/dist/src/lib/settings.d.ts +236 -0
- package/dist/src/lib/settings.js +482 -37
- package/dist/src/lib/skill-version.d.ts +19 -0
- package/dist/src/lib/skill-version.js +68 -0
- package/dist/src/lib/templates.d.ts +1 -0
- package/dist/src/lib/templates.js +1 -1
- package/dist/src/lib/workflow/batch-executor.js +13 -5
- package/dist/src/lib/workflow/config-resolver.d.ts +50 -0
- package/dist/src/lib/workflow/config-resolver.js +167 -0
- package/dist/src/lib/workflow/error-classifier.d.ts +17 -7
- package/dist/src/lib/workflow/error-classifier.js +113 -15
- package/dist/src/lib/workflow/phase-executor.d.ts +31 -0
- package/dist/src/lib/workflow/phase-executor.js +143 -48
- package/dist/src/lib/workflow/run-log-schema.d.ts +12 -0
- package/dist/src/lib/workflow/run-log-schema.js +7 -1
- package/dist/src/lib/workflow/run-orchestrator.d.ts +161 -0
- package/dist/src/lib/workflow/run-orchestrator.js +510 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +4 -3
- package/dist/src/lib/workflow/worktree-manager.js +61 -11
- package/package.json +1 -1
- package/templates/skills/assess/SKILL.md +239 -77
- package/templates/skills/exec/SKILL.md +7 -68
- package/templates/skills/fullsolve/SKILL.md +303 -137
- package/templates/skills/qa/SKILL.md +42 -46
- package/templates/skills/qa/scripts/quality-checks.sh +47 -1
- package/templates/skills/spec/SKILL.md +183 -982
- package/templates/skills/spec/references/quality-checklist.md +75 -0
- package/templates/skills/test/SKILL.md +0 -27
- package/templates/skills/testgen/SKILL.md +0 -27
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunOrchestrator — CLI-free execution engine for sequant workflows.
|
|
3
|
+
*
|
|
4
|
+
* Owns the full lifecycle: config → issue discovery → dispatch → results.
|
|
5
|
+
* Importable and usable without Commander.js or CLI context.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { spawnSync } from "child_process";
|
|
11
|
+
import pLimit from "p-limit";
|
|
12
|
+
import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
|
|
13
|
+
import { LogWriter } from "./log-writer.js";
|
|
14
|
+
import { StateManager } from "./state-manager.js";
|
|
15
|
+
import { ShutdownManager } from "../shutdown.js";
|
|
16
|
+
import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, } from "./batch-executor.js";
|
|
17
|
+
import { reconcileStateAtStartup } from "./state-utils.js";
|
|
18
|
+
import { getCommitHash } from "./git-diff-utils.js";
|
|
19
|
+
import { MetricsWriter } from "./metrics-writer.js";
|
|
20
|
+
import { determineOutcome } from "./metrics-schema.js";
|
|
21
|
+
import { getTokenUsageForRun } from "./token-utils.js";
|
|
22
|
+
import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
|
|
23
|
+
// ── Orchestrator ────────────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* CLI-free workflow execution engine.
|
|
26
|
+
*
|
|
27
|
+
* Two usage modes:
|
|
28
|
+
* 1. Full lifecycle: `RunOrchestrator.run(init, issueNumbers)` — handles
|
|
29
|
+
* services, worktrees, state guard, execution, and metrics.
|
|
30
|
+
* 2. Low-level: `new RunOrchestrator(config).execute(issueNumbers)` — caller
|
|
31
|
+
* manages setup/teardown.
|
|
32
|
+
*/
|
|
33
|
+
export class RunOrchestrator {
|
|
34
|
+
cfg;
|
|
35
|
+
constructor(config) {
|
|
36
|
+
this.validate(config);
|
|
37
|
+
this.cfg = config;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pure config resolution — no side effects.
|
|
41
|
+
*
|
|
42
|
+
* Produces a `ResolvedRun` containing merged options, execution config,
|
|
43
|
+
* parsed/sorted issue numbers, base branch, and display-only flags. Safe
|
|
44
|
+
* to call for preview purposes (e.g. CLI config display before run).
|
|
45
|
+
*
|
|
46
|
+
* `run()` uses this internally to avoid duplicating resolution logic.
|
|
47
|
+
*/
|
|
48
|
+
static resolveConfig(init, issueArgs, batches) {
|
|
49
|
+
const { options, settings, manifest } = init;
|
|
50
|
+
const mergedOptions = resolveRunOptions(options, settings);
|
|
51
|
+
const baseBranch = init.baseBranch ??
|
|
52
|
+
options.base ??
|
|
53
|
+
settings.run.defaultBase ??
|
|
54
|
+
detectDefaultBranch(mergedOptions.verbose ?? false);
|
|
55
|
+
let issueNumbers;
|
|
56
|
+
let resolvedBatches = batches ?? null;
|
|
57
|
+
if (mergedOptions.batch &&
|
|
58
|
+
mergedOptions.batch.length > 0 &&
|
|
59
|
+
!resolvedBatches) {
|
|
60
|
+
resolvedBatches = parseBatches(mergedOptions.batch);
|
|
61
|
+
issueNumbers = resolvedBatches.flat();
|
|
62
|
+
}
|
|
63
|
+
else if (resolvedBatches) {
|
|
64
|
+
issueNumbers = resolvedBatches.flat();
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
issueNumbers = issueArgs
|
|
68
|
+
.map((i) => parseInt(i, 10))
|
|
69
|
+
.filter((n) => !isNaN(n));
|
|
70
|
+
}
|
|
71
|
+
if (issueNumbers.length > 1 && !resolvedBatches) {
|
|
72
|
+
issueNumbers = sortByDependencies(issueNumbers);
|
|
73
|
+
}
|
|
74
|
+
const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
|
|
75
|
+
const logEnabled = !mergedOptions.noLog &&
|
|
76
|
+
!config.dryRun &&
|
|
77
|
+
(mergedOptions.logJson ?? settings.run.logJson ?? false);
|
|
78
|
+
return {
|
|
79
|
+
mergedOptions,
|
|
80
|
+
config,
|
|
81
|
+
issueNumbers,
|
|
82
|
+
batches: resolvedBatches,
|
|
83
|
+
baseBranch,
|
|
84
|
+
stack: manifest.stack,
|
|
85
|
+
autoDetectPhases: mergedOptions.autoDetectPhases ?? false,
|
|
86
|
+
worktreeIsolationEnabled: mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0,
|
|
87
|
+
logEnabled,
|
|
88
|
+
stateEnabled: !config.dryRun,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Full lifecycle execution — the primary entry point for programmatic use.
|
|
93
|
+
*
|
|
94
|
+
* Handles: config resolution → services setup → state guard →
|
|
95
|
+
* issue discovery → worktree creation → execution → metrics → cleanup.
|
|
96
|
+
*/
|
|
97
|
+
static async run(init, issueArgs, batches) {
|
|
98
|
+
const { manifest, onProgress, settings } = init;
|
|
99
|
+
// ── Config resolution ──────────────────────────────────────────────
|
|
100
|
+
const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
|
|
101
|
+
const { mergedOptions, config, baseBranch } = resolved;
|
|
102
|
+
let { issueNumbers } = resolved;
|
|
103
|
+
const resolvedBatches = resolved.batches;
|
|
104
|
+
if (issueNumbers.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
results: [],
|
|
107
|
+
logPath: null,
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
worktreeMap: new Map(),
|
|
110
|
+
issueInfoMap: new Map(),
|
|
111
|
+
config,
|
|
112
|
+
mergedOptions,
|
|
113
|
+
logWriter: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// ── Services setup ─────────────────────────────────────────────────
|
|
117
|
+
let logWriter = null;
|
|
118
|
+
const shouldLog = !mergedOptions.noLog &&
|
|
119
|
+
!config.dryRun &&
|
|
120
|
+
(mergedOptions.logJson ?? settings.run.logJson);
|
|
121
|
+
if (shouldLog) {
|
|
122
|
+
const runConfig = {
|
|
123
|
+
phases: config.phases,
|
|
124
|
+
sequential: config.sequential,
|
|
125
|
+
qualityLoop: config.qualityLoop,
|
|
126
|
+
maxIterations: config.maxIterations,
|
|
127
|
+
chain: mergedOptions.chain,
|
|
128
|
+
qaGate: mergedOptions.qaGate,
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
logWriter = new LogWriter({
|
|
132
|
+
logPath: mergedOptions.logPath ?? settings.run.logPath,
|
|
133
|
+
verbose: config.verbose,
|
|
134
|
+
startCommit: getCommitHash(process.cwd()),
|
|
135
|
+
});
|
|
136
|
+
await logWriter.initialize(runConfig);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
console.log(chalk.yellow(` ! Log initialization failed, continuing without logging: ${msg}`));
|
|
141
|
+
logWriter = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let stateManager = null;
|
|
145
|
+
if (!config.dryRun) {
|
|
146
|
+
stateManager = new StateManager({ verbose: config.verbose });
|
|
147
|
+
}
|
|
148
|
+
const shutdown = new ShutdownManager();
|
|
149
|
+
if (logWriter) {
|
|
150
|
+
const writer = logWriter;
|
|
151
|
+
shutdown.registerCleanup("Finalize run logs", async () => {
|
|
152
|
+
await writer.finalize();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// ── Pre-flight state guard ─────────────────────────────────────────
|
|
156
|
+
if (stateManager && !config.dryRun) {
|
|
157
|
+
try {
|
|
158
|
+
const reconcileResult = await reconcileStateAtStartup({
|
|
159
|
+
verbose: config.verbose,
|
|
160
|
+
});
|
|
161
|
+
if (reconcileResult.success && reconcileResult.advanced.length > 0) {
|
|
162
|
+
console.log(chalk.gray(` State reconciled: ${reconcileResult.advanced.map((n) => `#${n}`).join(", ")} → merged`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
logNonFatalWarning(" ! State reconciliation failed, continuing...", error, config.verbose);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (stateManager && !config.dryRun && !mergedOptions.force) {
|
|
170
|
+
const activeIssues = [];
|
|
171
|
+
for (const issueNumber of issueNumbers) {
|
|
172
|
+
try {
|
|
173
|
+
const issueState = await stateManager.getIssueState(issueNumber);
|
|
174
|
+
if (issueState &&
|
|
175
|
+
(issueState.status === "ready_for_merge" ||
|
|
176
|
+
issueState.status === "merged")) {
|
|
177
|
+
console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
activeIssues.push(issueNumber);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
|
|
185
|
+
activeIssues.push(issueNumber);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (activeIssues.length < issueNumbers.length) {
|
|
189
|
+
issueNumbers = activeIssues;
|
|
190
|
+
if (issueNumbers.length === 0) {
|
|
191
|
+
console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
|
|
192
|
+
shutdown.dispose();
|
|
193
|
+
return {
|
|
194
|
+
results: [],
|
|
195
|
+
logPath: null,
|
|
196
|
+
exitCode: 0,
|
|
197
|
+
worktreeMap: new Map(),
|
|
198
|
+
issueInfoMap: new Map(),
|
|
199
|
+
config,
|
|
200
|
+
mergedOptions,
|
|
201
|
+
logWriter: null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// ── Issue info + worktree setup ────────────────────────────────────
|
|
207
|
+
const issueInfoMap = new Map();
|
|
208
|
+
for (const issueNumber of issueNumbers) {
|
|
209
|
+
issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
|
|
210
|
+
}
|
|
211
|
+
const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
|
|
212
|
+
let worktreeMap = new Map();
|
|
213
|
+
if (useWorktreeIsolation && !config.dryRun) {
|
|
214
|
+
const issueData = issueNumbers.map((num) => ({
|
|
215
|
+
number: num,
|
|
216
|
+
title: issueInfoMap.get(num)?.title || `Issue #${num}`,
|
|
217
|
+
}));
|
|
218
|
+
if (mergedOptions.chain) {
|
|
219
|
+
worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, baseBranch);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, baseBranch);
|
|
223
|
+
}
|
|
224
|
+
for (const [issueNum, worktree] of worktreeMap.entries()) {
|
|
225
|
+
if (!worktree.existed) {
|
|
226
|
+
shutdown.registerCleanup(`Cleanup worktree for #${issueNum}`, async () => {
|
|
227
|
+
spawnSync("git", ["worktree", "remove", "--force", worktree.path], {
|
|
228
|
+
stdio: "pipe",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// ── Execute ────────────────────────────────────────────────────────
|
|
235
|
+
let results = [];
|
|
236
|
+
try {
|
|
237
|
+
const orchestrator = new RunOrchestrator({
|
|
238
|
+
config,
|
|
239
|
+
options: mergedOptions,
|
|
240
|
+
issueInfoMap,
|
|
241
|
+
worktreeMap,
|
|
242
|
+
services: { logWriter, stateManager, shutdownManager: shutdown },
|
|
243
|
+
packageManager: manifest.packageManager,
|
|
244
|
+
baseBranch,
|
|
245
|
+
onProgress,
|
|
246
|
+
});
|
|
247
|
+
if (resolvedBatches) {
|
|
248
|
+
for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
|
|
249
|
+
const batch = resolvedBatches[batchIdx];
|
|
250
|
+
console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
|
|
251
|
+
const batchResults = await orchestrator.execute(batch);
|
|
252
|
+
results.push(...batchResults);
|
|
253
|
+
const batchFailed = batchResults.some((r) => !r.success);
|
|
254
|
+
if (batchFailed && config.sequential) {
|
|
255
|
+
console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
results = await orchestrator.execute(issueNumbers);
|
|
262
|
+
}
|
|
263
|
+
// ── Finalize logs ──────────────────────────────────────────────
|
|
264
|
+
let logPath = null;
|
|
265
|
+
if (logWriter) {
|
|
266
|
+
logPath = await logWriter.finalize({
|
|
267
|
+
endCommit: getCommitHash(process.cwd()),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// ── Record metrics ─────────────────────────────────────────────
|
|
271
|
+
if (!config.dryRun && results.length > 0) {
|
|
272
|
+
try {
|
|
273
|
+
await RunOrchestrator.recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers);
|
|
274
|
+
}
|
|
275
|
+
catch (metricsError) {
|
|
276
|
+
logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
results,
|
|
281
|
+
logPath,
|
|
282
|
+
exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
|
|
283
|
+
worktreeMap,
|
|
284
|
+
issueInfoMap,
|
|
285
|
+
config,
|
|
286
|
+
mergedOptions,
|
|
287
|
+
logWriter,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
shutdown.dispose();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Execute workflow for the given issue numbers.
|
|
296
|
+
* Returns one IssueResult per issue.
|
|
297
|
+
*/
|
|
298
|
+
async execute(issueNumbers) {
|
|
299
|
+
if (issueNumbers.length === 0) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
const batchCtx = this.buildBatchContext();
|
|
303
|
+
const { config } = this.cfg;
|
|
304
|
+
const options = this.cfg.options;
|
|
305
|
+
// Chain mode implies sequential
|
|
306
|
+
if (options.chain) {
|
|
307
|
+
config.sequential = true;
|
|
308
|
+
}
|
|
309
|
+
if (config.sequential) {
|
|
310
|
+
return this.executeSequential(issueNumbers, batchCtx, options);
|
|
311
|
+
}
|
|
312
|
+
return this.executeParallel(issueNumbers, batchCtx);
|
|
313
|
+
}
|
|
314
|
+
// ── Private helpers ─────────────────────────────────────────────────────
|
|
315
|
+
validate(config) {
|
|
316
|
+
if (!config.config) {
|
|
317
|
+
throw new Error("OrchestratorConfig.config is required");
|
|
318
|
+
}
|
|
319
|
+
if (!config.config.phases ||
|
|
320
|
+
!Array.isArray(config.config.phases) ||
|
|
321
|
+
config.config.phases.length === 0) {
|
|
322
|
+
throw new Error("OrchestratorConfig.config.phases must be a non-empty array");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
buildBatchContext() {
|
|
326
|
+
const { config, options, issueInfoMap, worktreeMap, services } = this.cfg;
|
|
327
|
+
return {
|
|
328
|
+
config,
|
|
329
|
+
options,
|
|
330
|
+
issueInfoMap,
|
|
331
|
+
worktreeMap,
|
|
332
|
+
logWriter: services.logWriter ?? null,
|
|
333
|
+
stateManager: services.stateManager ?? null,
|
|
334
|
+
shutdownManager: services.shutdownManager,
|
|
335
|
+
packageManager: this.cfg.packageManager,
|
|
336
|
+
baseBranch: this.cfg.baseBranch,
|
|
337
|
+
onProgress: this.cfg.onProgress,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async executeSequential(issueNumbers, batchCtx, options) {
|
|
341
|
+
const results = [];
|
|
342
|
+
const shutdown = this.cfg.services.shutdownManager;
|
|
343
|
+
for (let i = 0; i < issueNumbers.length; i++) {
|
|
344
|
+
const issueNumber = issueNumbers[i];
|
|
345
|
+
if (shutdown?.shuttingDown) {
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
const result = await this.executeOneIssue({
|
|
349
|
+
issueNumber,
|
|
350
|
+
batchCtx,
|
|
351
|
+
chain: options.chain
|
|
352
|
+
? { enabled: true, isLast: i === issueNumbers.length - 1 }
|
|
353
|
+
: undefined,
|
|
354
|
+
});
|
|
355
|
+
results.push(result);
|
|
356
|
+
if (!result.success) {
|
|
357
|
+
if (options.qaGate && options.chain) {
|
|
358
|
+
const qaFailed = result.phaseResults.some((p) => p.phase === "qa" && !p.success);
|
|
359
|
+
if (qaFailed)
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return results;
|
|
366
|
+
}
|
|
367
|
+
async executeParallel(issueNumbers, batchCtx) {
|
|
368
|
+
const limit = pLimit(this.cfg.config.concurrency);
|
|
369
|
+
const shutdown = this.cfg.services.shutdownManager;
|
|
370
|
+
const settledResults = await Promise.allSettled(issueNumbers.map((issueNumber) => limit(async () => {
|
|
371
|
+
if (shutdown?.shuttingDown) {
|
|
372
|
+
return {
|
|
373
|
+
issueNumber,
|
|
374
|
+
success: false,
|
|
375
|
+
phaseResults: [],
|
|
376
|
+
durationSeconds: 0,
|
|
377
|
+
loopTriggered: false,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return this.executeOneIssue({
|
|
381
|
+
issueNumber,
|
|
382
|
+
batchCtx: { ...batchCtx, onProgress: this.cfg.onProgress },
|
|
383
|
+
parallelIssueNumber: issueNumber,
|
|
384
|
+
});
|
|
385
|
+
})));
|
|
386
|
+
return settledResults.map((settled, i) => {
|
|
387
|
+
if (settled.status === "fulfilled") {
|
|
388
|
+
return settled.value;
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
issueNumber: issueNumbers[i],
|
|
392
|
+
success: false,
|
|
393
|
+
phaseResults: [],
|
|
394
|
+
durationSeconds: 0,
|
|
395
|
+
loopTriggered: false,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
async executeOneIssue(args) {
|
|
400
|
+
const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
|
|
401
|
+
const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
|
|
402
|
+
const issueInfo = issueInfoMap.get(issueNumber) ?? {
|
|
403
|
+
title: `Issue #${issueNumber}`,
|
|
404
|
+
labels: [],
|
|
405
|
+
};
|
|
406
|
+
const worktreeInfo = worktreeMap.get(issueNumber);
|
|
407
|
+
if (logWriter) {
|
|
408
|
+
logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
|
|
409
|
+
}
|
|
410
|
+
const ctx = {
|
|
411
|
+
issueNumber,
|
|
412
|
+
title: issueInfo.title,
|
|
413
|
+
labels: issueInfo.labels,
|
|
414
|
+
config,
|
|
415
|
+
options,
|
|
416
|
+
services: { logWriter, stateManager, shutdownManager },
|
|
417
|
+
worktree: worktreeInfo
|
|
418
|
+
? { path: worktreeInfo.path, branch: worktreeInfo.branch }
|
|
419
|
+
: undefined,
|
|
420
|
+
chain,
|
|
421
|
+
packageManager,
|
|
422
|
+
baseBranch,
|
|
423
|
+
onProgress,
|
|
424
|
+
};
|
|
425
|
+
const result = await runIssueWithLogging(ctx);
|
|
426
|
+
if (logWriter && result.prNumber && result.prUrl) {
|
|
427
|
+
logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
|
|
428
|
+
}
|
|
429
|
+
if (logWriter) {
|
|
430
|
+
logWriter.completeIssue(parallelIssueNumber);
|
|
431
|
+
}
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
|
|
435
|
+
const metricsWriter = new MetricsWriter({ verbose: config.verbose });
|
|
436
|
+
const totalDuration = results.reduce((sum, r) => sum + (r.durationSeconds ?? 0), 0);
|
|
437
|
+
const allPhases = new Set();
|
|
438
|
+
for (const result of results) {
|
|
439
|
+
for (const pr of result.phaseResults) {
|
|
440
|
+
const phase = pr.phase;
|
|
441
|
+
if ([
|
|
442
|
+
"spec",
|
|
443
|
+
"security-review",
|
|
444
|
+
"testgen",
|
|
445
|
+
"exec",
|
|
446
|
+
"test",
|
|
447
|
+
"qa",
|
|
448
|
+
"loop",
|
|
449
|
+
].includes(phase)) {
|
|
450
|
+
allPhases.add(phase);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
let totalFilesChanged = 0;
|
|
455
|
+
let totalLinesAdded = 0;
|
|
456
|
+
let totalQaIterations = 0;
|
|
457
|
+
for (const result of results) {
|
|
458
|
+
const wt = worktreeMap.get(result.issueNumber);
|
|
459
|
+
if (wt?.path) {
|
|
460
|
+
const s = getWorktreeDiffStats(wt.path);
|
|
461
|
+
totalFilesChanged += s.filesChanged;
|
|
462
|
+
totalLinesAdded += s.linesAdded;
|
|
463
|
+
}
|
|
464
|
+
if (result.loopTriggered) {
|
|
465
|
+
totalQaIterations += result.phaseResults.filter((p) => p.phase === "loop").length;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const cliFlags = [];
|
|
469
|
+
if (mergedOptions.sequential)
|
|
470
|
+
cliFlags.push("--sequential");
|
|
471
|
+
if (mergedOptions.chain)
|
|
472
|
+
cliFlags.push("--chain");
|
|
473
|
+
if (mergedOptions.qaGate)
|
|
474
|
+
cliFlags.push("--qa-gate");
|
|
475
|
+
if (mergedOptions.qualityLoop)
|
|
476
|
+
cliFlags.push("--quality-loop");
|
|
477
|
+
if (mergedOptions.testgen)
|
|
478
|
+
cliFlags.push("--testgen");
|
|
479
|
+
const tokenUsage = getTokenUsageForRun(undefined, true);
|
|
480
|
+
const passed = results.filter((r) => r.success).length;
|
|
481
|
+
await metricsWriter.recordRun({
|
|
482
|
+
issues: issueNumbers,
|
|
483
|
+
phases: Array.from(allPhases),
|
|
484
|
+
outcome: determineOutcome(passed, results.length),
|
|
485
|
+
duration: totalDuration,
|
|
486
|
+
model: process.env.ANTHROPIC_MODEL ?? "opus",
|
|
487
|
+
flags: cliFlags,
|
|
488
|
+
metrics: {
|
|
489
|
+
tokensUsed: tokenUsage.tokensUsed,
|
|
490
|
+
filesChanged: totalFilesChanged,
|
|
491
|
+
linesAdded: totalLinesAdded,
|
|
492
|
+
acceptanceCriteria: 0,
|
|
493
|
+
qaIterations: totalQaIterations,
|
|
494
|
+
inputTokens: tokenUsage.inputTokens || undefined,
|
|
495
|
+
outputTokens: tokenUsage.outputTokens || undefined,
|
|
496
|
+
cacheTokens: tokenUsage.cacheTokens || undefined,
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
if (config.verbose) {
|
|
500
|
+
console.log(chalk.gray(" Metrics recorded to .sequant/metrics.json"));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Log a non-fatal warning: one-line summary always, detail in verbose. */
|
|
505
|
+
export function logNonFatalWarning(message, error, verbose) {
|
|
506
|
+
console.log(chalk.yellow(message));
|
|
507
|
+
if (verbose) {
|
|
508
|
+
console.log(chalk.gray(` ${error}`));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -156,11 +156,12 @@ export declare function ensureWorktreesChain(issues: Array<{
|
|
|
156
156
|
title: string;
|
|
157
157
|
}>, verbose: boolean, packageManager?: string, baseBranch?: string): Promise<Map<number, WorktreeInfo>>;
|
|
158
158
|
/**
|
|
159
|
-
* Create a checkpoint commit in the worktree after QA passes
|
|
160
|
-
*
|
|
159
|
+
* Create a checkpoint commit in the worktree after QA passes.
|
|
160
|
+
* Only stages files that were touched by the issue's commits (diff vs baseBranch).
|
|
161
|
+
* If unrelated dirty files exist, emits a warning and skips the checkpoint.
|
|
161
162
|
* @internal Exported for testing
|
|
162
163
|
*/
|
|
163
|
-
export declare function createCheckpointCommit(worktreePath: string, issueNumber: number, verbose: boolean): boolean;
|
|
164
|
+
export declare function createCheckpointCommit(worktreePath: string, issueNumber: number, verbose: boolean, baseBranch?: string): boolean;
|
|
164
165
|
/**
|
|
165
166
|
* Check if any lockfile changed during a rebase and re-run install if needed.
|
|
166
167
|
* This prevents dependency drift when the lockfile was updated on main.
|
|
@@ -612,30 +612,80 @@ export async function ensureWorktreesChain(issues, verbose, packageManager, base
|
|
|
612
612
|
return worktrees;
|
|
613
613
|
}
|
|
614
614
|
/**
|
|
615
|
-
* Create a checkpoint commit in the worktree after QA passes
|
|
616
|
-
*
|
|
615
|
+
* Create a checkpoint commit in the worktree after QA passes.
|
|
616
|
+
* Only stages files that were touched by the issue's commits (diff vs baseBranch).
|
|
617
|
+
* If unrelated dirty files exist, emits a warning and skips the checkpoint.
|
|
617
618
|
* @internal Exported for testing
|
|
618
619
|
*/
|
|
619
|
-
export function createCheckpointCommit(worktreePath, issueNumber, verbose) {
|
|
620
|
-
// Check if there are uncommitted changes
|
|
621
|
-
|
|
620
|
+
export function createCheckpointCommit(worktreePath, issueNumber, verbose, baseBranch) {
|
|
621
|
+
// Check if there are uncommitted changes.
|
|
622
|
+
// Use -z (NUL-terminated) so paths with unicode or special chars aren't quoted/escaped.
|
|
623
|
+
const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain", "-z"], { stdio: "pipe" });
|
|
622
624
|
if (statusResult.status !== 0) {
|
|
623
625
|
if (verbose) {
|
|
624
626
|
console.log(chalk.yellow(` ! Could not check git status for checkpoint`));
|
|
625
627
|
}
|
|
626
628
|
return false;
|
|
627
629
|
}
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
630
|
+
const statusRaw = statusResult.stdout.toString();
|
|
631
|
+
if (statusRaw.length === 0) {
|
|
630
632
|
if (verbose) {
|
|
631
633
|
console.log(chalk.gray(` 📌 No changes to checkpoint (already committed)`));
|
|
632
634
|
}
|
|
633
635
|
return true;
|
|
634
636
|
}
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
// Parse NUL-separated porcelain entries. Each entry is "XY path".
|
|
638
|
+
// For renames/copies, the next entry is the old path and must be consumed.
|
|
639
|
+
const entries = statusRaw.split("\0").filter((e) => e.length > 0);
|
|
640
|
+
const dirtyFiles = [];
|
|
641
|
+
for (let i = 0; i < entries.length; i++) {
|
|
642
|
+
const entry = entries[i];
|
|
643
|
+
const xy = entry.slice(0, 2);
|
|
644
|
+
const path = entry.slice(3);
|
|
645
|
+
if (path)
|
|
646
|
+
dirtyFiles.push(path);
|
|
647
|
+
// Rename (R) and copy (C) entries are followed by the original path — skip it
|
|
648
|
+
if (xy[0] === "R" || xy[0] === "C") {
|
|
649
|
+
i++;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Determine which files to stage.
|
|
653
|
+
// When baseBranch is provided (chain mode), scope to feature paths only.
|
|
654
|
+
// When baseBranch is absent (non-chain), treat all dirty files as in-scope.
|
|
655
|
+
let inScope;
|
|
656
|
+
if (baseBranch) {
|
|
657
|
+
const diffResult = spawnSync("git", ["-C", worktreePath, "diff", "--name-only", "-z", `${baseBranch}...HEAD`], { stdio: "pipe" });
|
|
658
|
+
const featurePaths = new Set();
|
|
659
|
+
if (diffResult.status === 0) {
|
|
660
|
+
diffResult.stdout
|
|
661
|
+
.toString()
|
|
662
|
+
.split("\0")
|
|
663
|
+
.filter((p) => p.length > 0)
|
|
664
|
+
.forEach((p) => featurePaths.add(p));
|
|
665
|
+
}
|
|
666
|
+
inScope = dirtyFiles.filter((f) => featurePaths.has(f));
|
|
667
|
+
const outOfScope = dirtyFiles.filter((f) => !featurePaths.has(f));
|
|
668
|
+
// AC-2: If unrelated dirty files exist, warn and skip checkpoint
|
|
669
|
+
if (outOfScope.length > 0) {
|
|
670
|
+
console.log(chalk.yellow(` ⚠ Skipping checkpoint for #${issueNumber}: ${outOfScope.length} unrelated dirty file(s) in worktree:`));
|
|
671
|
+
for (const f of outOfScope) {
|
|
672
|
+
console.log(chalk.yellow(` - ${f}`));
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Non-chain mode: all dirty files are in-scope
|
|
679
|
+
inScope = dirtyFiles;
|
|
680
|
+
}
|
|
681
|
+
if (inScope.length === 0) {
|
|
682
|
+
if (verbose) {
|
|
683
|
+
console.log(chalk.gray(` 📌 No in-scope changes to checkpoint`));
|
|
684
|
+
}
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
// AC-1: Stage only in-scope feature paths
|
|
688
|
+
const addResult = spawnSync("git", ["-C", worktreePath, "add", "--", ...inScope], { stdio: "pipe" });
|
|
639
689
|
if (addResult.status !== 0) {
|
|
640
690
|
if (verbose) {
|
|
641
691
|
console.log(chalk.yellow(` ! Could not stage changes for checkpoint`));
|