sequant 2.1.0 → 2.1.2

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.
@@ -12,10 +12,246 @@
12
12
  */
13
13
  import { readFile, writeFile, fileExists, ensureDir } from "./fs.js";
14
14
  import { dirname } from "path";
15
+ import { z } from "zod";
15
16
  /** Path to project-level settings file */
16
17
  export const SETTINGS_PATH = ".sequant/settings.json";
17
18
  /** Current settings schema version */
18
19
  export const SETTINGS_VERSION = "1.0";
20
+ // ─── Zod Schemas (AC-1, AC-5) ────────────────────────────────────────────────
21
+ /** Zod schema for RotationSettings */
22
+ export const RotationSettingsSchema = z.object({
23
+ enabled: z.boolean().default(true),
24
+ maxSizeMB: z.number().default(10),
25
+ maxFiles: z.number().default(100),
26
+ });
27
+ /** Zod schema for AiderSettings */
28
+ export const AiderSettingsSchema = z.object({
29
+ model: z.string().optional(),
30
+ editFormat: z.string().optional(),
31
+ extraArgs: z.array(z.string()).optional(),
32
+ });
33
+ /** Zod schema for AgentSettings */
34
+ export const AgentSettingsSchema = z.object({
35
+ parallel: z.boolean().default(false),
36
+ model: z.enum(["haiku", "sonnet", "opus"]).default("haiku"),
37
+ isolateParallel: z.boolean().default(false),
38
+ });
39
+ /** Zod schema for RunSettings */
40
+ export const RunSettingsSchema = z.object({
41
+ logJson: z.boolean().default(true),
42
+ logPath: z.string().default(".sequant/logs"),
43
+ autoDetectPhases: z.boolean().default(true),
44
+ timeout: z.number().default(1800),
45
+ sequential: z.boolean().default(false),
46
+ concurrency: z.number().default(3),
47
+ qualityLoop: z.boolean().default(false),
48
+ maxIterations: z.number().default(3),
49
+ smartTests: z.boolean().default(true),
50
+ rotation: RotationSettingsSchema.default(() => RotationSettingsSchema.parse({})),
51
+ defaultBase: z.string().optional(),
52
+ mcp: z.boolean().default(true),
53
+ retry: z.boolean().default(true),
54
+ staleBranchThreshold: z.number().default(5),
55
+ resolvedIssueTTL: z.number().default(7),
56
+ pmRun: z.string().optional(),
57
+ devUrl: z.string().optional(),
58
+ agent: z.string().optional(),
59
+ aider: AiderSettingsSchema.optional(),
60
+ });
61
+ /** Zod schema for ScopeThreshold (base — fields required, no defaults) */
62
+ export const ScopeThresholdSchema = z.object({
63
+ yellow: z.number(),
64
+ red: z.number(),
65
+ });
66
+ /**
67
+ * Create a threshold schema with specific defaults for partial input.
68
+ * Each threshold (featureCount, acItems, etc.) needs its own defaults
69
+ * so that `{ yellow: 10 }` fills `red` from that threshold's default.
70
+ */
71
+ function thresholdWithDefaults(defaultYellow, defaultRed) {
72
+ return z.object({
73
+ yellow: z.number().default(defaultYellow),
74
+ red: z.number().default(defaultRed),
75
+ });
76
+ }
77
+ /** Zod schema for TrivialThresholds */
78
+ export const TrivialThresholdsSchema = z.object({
79
+ maxACItems: z.number().default(3),
80
+ maxDirectories: z.number().default(1),
81
+ });
82
+ /** Zod schema for ScopeAssessmentSettings */
83
+ export const ScopeAssessmentSettingsSchema = z.object({
84
+ enabled: z.boolean().default(true),
85
+ skipIfSimple: z.boolean().default(true),
86
+ trivialThresholds: TrivialThresholdsSchema.default(() => TrivialThresholdsSchema.parse({})),
87
+ thresholds: z
88
+ .object({
89
+ featureCount: thresholdWithDefaults(2, 3).default({ yellow: 2, red: 3 }),
90
+ acItems: thresholdWithDefaults(6, 9).default({ yellow: 6, red: 9 }),
91
+ fileEstimate: thresholdWithDefaults(8, 13).default({
92
+ yellow: 8,
93
+ red: 13,
94
+ }),
95
+ directorySpread: thresholdWithDefaults(3, 5).default({
96
+ yellow: 3,
97
+ red: 5,
98
+ }),
99
+ })
100
+ .default(() => z
101
+ .object({
102
+ featureCount: thresholdWithDefaults(2, 3).default({
103
+ yellow: 2,
104
+ red: 3,
105
+ }),
106
+ acItems: thresholdWithDefaults(6, 9).default({
107
+ yellow: 6,
108
+ red: 9,
109
+ }),
110
+ fileEstimate: thresholdWithDefaults(8, 13).default({
111
+ yellow: 8,
112
+ red: 13,
113
+ }),
114
+ directorySpread: thresholdWithDefaults(3, 5).default({
115
+ yellow: 3,
116
+ red: 5,
117
+ }),
118
+ })
119
+ .parse({})),
120
+ });
121
+ /** Zod schema for QASettings */
122
+ export const QASettingsSchema = z.object({
123
+ smallDiffThreshold: z.number().default(100),
124
+ });
125
+ /**
126
+ * Zod schema for the full SequantSettings (AC-1, AC-5).
127
+ *
128
+ * Top-level uses `.passthrough()` to allow forward-compatible fields from
129
+ * newer Sequant versions. Unknown keys are preserved in parse output and
130
+ * reported as warnings via `validateSettings()`.
131
+ *
132
+ * Nested schemas don't use `.passthrough()` because unknown key detection
133
+ * is handled by `detectUnknownKeys()` at validation time.
134
+ */
135
+ export const SettingsSchema = z
136
+ .object({
137
+ version: z.string().default("1.0"),
138
+ run: RunSettingsSchema.default(() => RunSettingsSchema.parse({})),
139
+ agents: AgentSettingsSchema.default(() => AgentSettingsSchema.parse({})),
140
+ scopeAssessment: ScopeAssessmentSettingsSchema.default(() => ScopeAssessmentSettingsSchema.parse({})),
141
+ qa: QASettingsSchema.default(() => QASettingsSchema.parse({})),
142
+ })
143
+ .passthrough();
144
+ /**
145
+ * Known keys at each level of the settings schema.
146
+ * Used to detect unknown/misspelled keys and produce warnings.
147
+ */
148
+ const KNOWN_KEYS = {
149
+ "": new Set(["version", "run", "agents", "scopeAssessment", "qa"]),
150
+ run: new Set([
151
+ "logJson",
152
+ "logPath",
153
+ "autoDetectPhases",
154
+ "timeout",
155
+ "sequential",
156
+ "concurrency",
157
+ "qualityLoop",
158
+ "maxIterations",
159
+ "smartTests",
160
+ "rotation",
161
+ "defaultBase",
162
+ "mcp",
163
+ "retry",
164
+ "staleBranchThreshold",
165
+ "resolvedIssueTTL",
166
+ "pmRun",
167
+ "devUrl",
168
+ "agent",
169
+ "aider",
170
+ ]),
171
+ agents: new Set(["parallel", "model", "isolateParallel"]),
172
+ scopeAssessment: new Set([
173
+ "enabled",
174
+ "skipIfSimple",
175
+ "trivialThresholds",
176
+ "thresholds",
177
+ ]),
178
+ qa: new Set(["smallDiffThreshold"]),
179
+ "run.rotation": new Set(["enabled", "maxSizeMB", "maxFiles"]),
180
+ "run.aider": new Set(["model", "editFormat", "extraArgs"]),
181
+ "scopeAssessment.trivialThresholds": new Set([
182
+ "maxACItems",
183
+ "maxDirectories",
184
+ ]),
185
+ "scopeAssessment.thresholds": new Set([
186
+ "featureCount",
187
+ "acItems",
188
+ "fileEstimate",
189
+ "directorySpread",
190
+ ]),
191
+ };
192
+ /**
193
+ * Recursively detect unknown keys in a raw settings object.
194
+ */
195
+ function detectUnknownKeys(obj, prefix) {
196
+ const warnings = [];
197
+ const knownSet = KNOWN_KEYS[prefix];
198
+ if (!knownSet)
199
+ return warnings; // no known-keys list → skip checking
200
+ for (const key of Object.keys(obj)) {
201
+ const fullPath = prefix ? `${prefix}.${key}` : key;
202
+ if (!knownSet.has(key)) {
203
+ warnings.push({
204
+ path: fullPath,
205
+ message: `Unknown key '${fullPath}' in settings.json (ignored)`,
206
+ });
207
+ }
208
+ // Recurse into nested objects
209
+ const val = obj[key];
210
+ if (val && typeof val === "object" && !Array.isArray(val)) {
211
+ warnings.push(...detectUnknownKeys(val, fullPath));
212
+ }
213
+ }
214
+ return warnings;
215
+ }
216
+ /**
217
+ * Format a Zod error into user-friendly messages (AC-2).
218
+ *
219
+ * Produces messages like:
220
+ * settings.json: 'run.timeout' must be a number, got string 'fast'
221
+ */
222
+ function formatZodErrors(error) {
223
+ return error.issues.map((issue) => {
224
+ const path = issue.path.join(".");
225
+ // Zod v4 uses issue.message which already includes type info
226
+ const message = `settings.json: '${path}' ${issue.message}`;
227
+ return { path, message };
228
+ });
229
+ }
230
+ /**
231
+ * Validate a raw settings object against the Zod schema (AC-2).
232
+ *
233
+ * Returns validated settings (with defaults filled in) and any warnings.
234
+ * On type errors, falls back to defaults for the invalid fields and
235
+ * reports warnings — never throws.
236
+ */
237
+ export function validateSettings(raw) {
238
+ const warnings = [];
239
+ // Detect unknown keys before Zod parsing (passthrough preserves them but doesn't warn)
240
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
241
+ warnings.push(...detectUnknownKeys(raw, ""));
242
+ }
243
+ const result = SettingsSchema.safeParse(raw ?? {});
244
+ if (result.success) {
245
+ return { settings: result.data, warnings };
246
+ }
247
+ // Zod validation failed — report errors as warnings and fall back to defaults
248
+ warnings.push(...formatZodErrors(result.error));
249
+ // Try to salvage what we can: parse with defaults for the invalid parts
250
+ // by stripping invalid fields and re-parsing
251
+ const fallback = SettingsSchema.safeParse({});
252
+ const settings = (fallback.success ? fallback.data : DEFAULT_SETTINGS);
253
+ return { settings, warnings };
254
+ }
19
255
  /**
20
256
  * Default rotation settings
21
257
  */
@@ -126,52 +362,46 @@ export function validateAiderSettings(aider) {
126
362
  return obj;
127
363
  }
128
364
  /**
129
- * Get the current project settings
365
+ * Get the current project settings with validation warnings (AC-2, AC-3).
130
366
  *
131
- * Returns default settings if no settings file exists.
367
+ * Returns settings merged with defaults and any validation warnings.
368
+ * Use this when you need to display warnings to the user (e.g., status command).
132
369
  */
133
- export async function getSettings() {
370
+ export async function getSettingsWithWarnings() {
134
371
  if (!(await fileExists(SETTINGS_PATH))) {
135
- return DEFAULT_SETTINGS;
372
+ return { settings: DEFAULT_SETTINGS, warnings: [] };
136
373
  }
137
374
  try {
138
375
  const content = await readFile(SETTINGS_PATH);
139
- const parsed = JSON.parse(content);
140
- // Validate aider settings if present
141
- const aiderSettings = validateAiderSettings(parsed.run?.aider);
142
- // Merge with defaults to ensure all fields exist
376
+ if (!content.trim()) {
377
+ return { settings: DEFAULT_SETTINGS, warnings: [] };
378
+ }
379
+ const parsed = JSON.parse(stripJsoncComments(content));
380
+ return validateSettings(parsed);
381
+ }
382
+ catch (err) {
383
+ const message = err instanceof SyntaxError
384
+ ? `settings.json: Invalid JSON — ${err.message}. Check syntax or delete the file to use defaults.`
385
+ : `settings.json: Failed to read — ${err instanceof Error ? err.message : String(err)}`;
143
386
  return {
144
- version: parsed.version ?? DEFAULT_SETTINGS.version,
145
- run: {
146
- ...DEFAULT_SETTINGS.run,
147
- ...parsed.run,
148
- ...(aiderSettings !== undefined ? { aider: aiderSettings } : {}),
149
- },
150
- agents: {
151
- ...DEFAULT_AGENT_SETTINGS,
152
- ...parsed.agents,
153
- },
154
- scopeAssessment: {
155
- ...DEFAULT_SCOPE_ASSESSMENT_SETTINGS,
156
- ...parsed.scopeAssessment,
157
- trivialThresholds: {
158
- ...DEFAULT_SCOPE_ASSESSMENT_SETTINGS.trivialThresholds,
159
- ...parsed.scopeAssessment?.trivialThresholds,
160
- },
161
- thresholds: {
162
- ...DEFAULT_SCOPE_ASSESSMENT_SETTINGS.thresholds,
163
- ...parsed.scopeAssessment?.thresholds,
164
- },
165
- },
166
- qa: {
167
- ...DEFAULT_QA_SETTINGS,
168
- ...parsed.qa,
169
- },
387
+ settings: DEFAULT_SETTINGS,
388
+ warnings: [{ path: "", message }],
170
389
  };
171
390
  }
172
- catch {
173
- return DEFAULT_SETTINGS;
391
+ }
392
+ /**
393
+ * Get the current project settings
394
+ *
395
+ * Returns default settings if no settings file exists.
396
+ * Validates against Zod schema (AC-2) — warnings are logged to stderr.
397
+ */
398
+ export async function getSettings() {
399
+ const { settings, warnings } = await getSettingsWithWarnings();
400
+ // Log validation warnings to stderr so they're visible but don't pollute stdout
401
+ for (const w of warnings) {
402
+ console.error(`⚠ ${w.message}`);
174
403
  }
404
+ return settings;
175
405
  }
176
406
  /**
177
407
  * Save project settings
@@ -189,6 +419,221 @@ export async function settingsExist() {
189
419
  /**
190
420
  * Create default settings file
191
421
  */
422
+ /**
423
+ * Create default settings file with JSONC inline comments (AC-4).
424
+ *
425
+ * Generates a JSONC file (.json with // comments) documenting each field
426
+ * and its default value. The loadSettings path strips comments before parsing.
427
+ */
192
428
  export async function createDefaultSettings() {
193
- await saveSettings(DEFAULT_SETTINGS);
429
+ await ensureDir(dirname(SETTINGS_PATH));
430
+ const jsonc = generateSettingsJsonc(DEFAULT_SETTINGS);
431
+ await writeFile(SETTINGS_PATH, jsonc);
432
+ }
433
+ /**
434
+ * Generate JSONC content with inline comments for each settings field (AC-4).
435
+ */
436
+ export function generateSettingsJsonc(settings) {
437
+ const lines = ["{"];
438
+ lines.push(` // Schema version for migration support`);
439
+ lines.push(` "version": ${JSON.stringify(settings.version)},`);
440
+ lines.push("");
441
+ lines.push(` // Run command settings`);
442
+ lines.push(` "run": {`);
443
+ lines.push(` // Enable JSON logging`);
444
+ lines.push(` "logJson": ${JSON.stringify(settings.run.logJson)},`);
445
+ lines.push(` // Path to log directory`);
446
+ lines.push(` "logPath": ${JSON.stringify(settings.run.logPath)},`);
447
+ lines.push(` // Auto-detect phases from GitHub issue labels`);
448
+ lines.push(` "autoDetectPhases": ${JSON.stringify(settings.run.autoDetectPhases)},`);
449
+ lines.push(` // Default timeout per phase in seconds`);
450
+ lines.push(` "timeout": ${JSON.stringify(settings.run.timeout)},`);
451
+ lines.push(` // Run issues sequentially by default`);
452
+ lines.push(` "sequential": ${JSON.stringify(settings.run.sequential)},`);
453
+ lines.push(` // Max concurrent issues in parallel mode`);
454
+ lines.push(` "concurrency": ${JSON.stringify(settings.run.concurrency)},`);
455
+ lines.push(` // Enable quality loop by default`);
456
+ lines.push(` "qualityLoop": ${JSON.stringify(settings.run.qualityLoop)},`);
457
+ lines.push(` // Max iterations for quality loop`);
458
+ lines.push(` "maxIterations": ${JSON.stringify(settings.run.maxIterations)},`);
459
+ lines.push(` // Enable smart test detection`);
460
+ lines.push(` "smartTests": ${JSON.stringify(settings.run.smartTests)},`);
461
+ lines.push(` // Enable MCP servers in headless mode`);
462
+ lines.push(` "mcp": ${JSON.stringify(settings.run.mcp)},`);
463
+ lines.push(` // Enable automatic retry with MCP fallback`);
464
+ lines.push(` "retry": ${JSON.stringify(settings.run.retry)},`);
465
+ lines.push(` // Commits behind main before warning`);
466
+ lines.push(` "staleBranchThreshold": ${JSON.stringify(settings.run.staleBranchThreshold)},`);
467
+ lines.push(` // Days before resolved issues auto-prune (0=never, -1=immediate)`);
468
+ lines.push(` "resolvedIssueTTL": ${JSON.stringify(settings.run.resolvedIssueTTL)},`);
469
+ lines.push("");
470
+ lines.push(` // Log rotation settings`);
471
+ lines.push(` "rotation": {`);
472
+ lines.push(` // Enable automatic log rotation`);
473
+ lines.push(` "enabled": ${JSON.stringify(settings.run.rotation.enabled)},`);
474
+ lines.push(` // Maximum total size in MB before rotation`);
475
+ lines.push(` "maxSizeMB": ${JSON.stringify(settings.run.rotation.maxSizeMB)},`);
476
+ lines.push(` // Maximum number of rotated log files to keep`);
477
+ lines.push(` "maxFiles": ${JSON.stringify(settings.run.rotation.maxFiles)}`);
478
+ lines.push(` }`);
479
+ lines.push(` },`);
480
+ lines.push("");
481
+ lines.push(` // Agent settings`);
482
+ lines.push(` "agents": {`);
483
+ lines.push(` // Run agents in parallel (faster, higher token usage)`);
484
+ lines.push(` "parallel": ${JSON.stringify(settings.agents.parallel)},`);
485
+ lines.push(` // Default model for sub-agents ("haiku", "sonnet", "opus")`);
486
+ lines.push(` "model": ${JSON.stringify(settings.agents.model)},`);
487
+ lines.push(` // Isolate parallel agent groups in separate worktrees`);
488
+ lines.push(` "isolateParallel": ${JSON.stringify(settings.agents.isolateParallel)}`);
489
+ lines.push(` }`);
490
+ lines.push("}");
491
+ lines.push("");
492
+ return lines.join("\n");
493
+ }
494
+ /**
495
+ * Strip single-line // comments from JSONC content for JSON.parse compatibility.
496
+ * Handles comments on their own line and trailing comments after values.
497
+ * Preserves strings containing // (e.g., URLs).
498
+ */
499
+ export function stripJsoncComments(content) {
500
+ const lines = content.split("\n");
501
+ const result = [];
502
+ for (const line of lines) {
503
+ // Find // outside of strings
504
+ let inString = false;
505
+ let escaped = false;
506
+ let commentStart = -1;
507
+ for (let i = 0; i < line.length; i++) {
508
+ const ch = line[i];
509
+ if (escaped) {
510
+ escaped = false;
511
+ continue;
512
+ }
513
+ if (ch === "\\") {
514
+ escaped = true;
515
+ continue;
516
+ }
517
+ if (ch === '"') {
518
+ inString = !inString;
519
+ continue;
520
+ }
521
+ if (!inString &&
522
+ ch === "/" &&
523
+ i + 1 < line.length &&
524
+ line[i + 1] === "/") {
525
+ commentStart = i;
526
+ break;
527
+ }
528
+ }
529
+ if (commentStart === -1) {
530
+ result.push(line);
531
+ }
532
+ else {
533
+ const before = line.slice(0, commentStart).trimEnd();
534
+ if (before) {
535
+ result.push(before);
536
+ }
537
+ // Skip comment-only lines entirely
538
+ }
539
+ }
540
+ return result.join("\n");
541
+ }
542
+ /**
543
+ * Generate settings.reference.md companion document (AC-4).
544
+ *
545
+ * Supplements the inline JSONC comments with a structured Markdown reference.
546
+ */
547
+ export function generateSettingsReference() {
548
+ return `# Sequant Settings Reference
549
+
550
+ This file documents all settings available in \`.sequant/settings.json\`.
551
+ Generated by \`sequant init\`. See defaults below.
552
+
553
+ ## Top-Level
554
+
555
+ | Key | Type | Default | Description |
556
+ |-----|------|---------|-------------|
557
+ | \`version\` | string | \`"${SETTINGS_VERSION}"\` | Schema version for migration support |
558
+
559
+ ## \`run\` — Run Command Settings
560
+
561
+ | Key | Type | Default | Description |
562
+ |-----|------|---------|-------------|
563
+ | \`logJson\` | boolean | \`true\` | Enable JSON logging |
564
+ | \`logPath\` | string | \`".sequant/logs"\` | Path to log directory |
565
+ | \`autoDetectPhases\` | boolean | \`true\` | Auto-detect phases from GitHub issue labels |
566
+ | \`timeout\` | number | \`1800\` | Default timeout per phase in seconds |
567
+ | \`sequential\` | boolean | \`false\` | Run issues sequentially by default |
568
+ | \`concurrency\` | number | \`3\` | Max concurrent issues in parallel mode |
569
+ | \`qualityLoop\` | boolean | \`false\` | Enable quality loop by default |
570
+ | \`maxIterations\` | number | \`3\` | Max iterations for quality loop |
571
+ | \`smartTests\` | boolean | \`true\` | Enable smart test detection |
572
+ | \`defaultBase\` | string | — | Default base branch for worktree creation |
573
+ | \`mcp\` | boolean | \`true\` | Enable MCP servers in headless mode |
574
+ | \`retry\` | boolean | \`true\` | Enable automatic retry with MCP fallback |
575
+ | \`staleBranchThreshold\` | number | \`5\` | Commits behind main before warning |
576
+ | \`resolvedIssueTTL\` | number | \`7\` | Days before resolved issues auto-prune (0=never, -1=immediate) |
577
+ | \`agent\` | string | — | Agent driver: \`"claude-code"\` (default) or \`"aider"\` |
578
+
579
+ ### \`run.rotation\` — Log Rotation
580
+
581
+ | Key | Type | Default | Description |
582
+ |-----|------|---------|-------------|
583
+ | \`enabled\` | boolean | \`true\` | Enable automatic log rotation |
584
+ | \`maxSizeMB\` | number | \`10\` | Maximum total size in MB before rotation |
585
+ | \`maxFiles\` | number | \`100\` | Maximum file count before rotation |
586
+
587
+ ### \`run.aider\` — Aider Settings (when agent="aider")
588
+
589
+ | Key | Type | Default | Description |
590
+ |-----|------|---------|-------------|
591
+ | \`model\` | string | — | Model to use (e.g., "claude-3-sonnet") |
592
+ | \`editFormat\` | string | — | Edit format: "diff", "whole", "udiff" |
593
+ | \`extraArgs\` | string[] | — | Extra CLI arguments passed to aider |
594
+
595
+ ## \`agents\` — Agent Execution Settings
596
+
597
+ | Key | Type | Default | Description |
598
+ |-----|------|---------|-------------|
599
+ | \`parallel\` | boolean | \`false\` | Run agents in parallel (faster, higher token usage) |
600
+ | \`model\` | enum | \`"haiku"\` | Default model: \`"haiku"\`, \`"sonnet"\`, or \`"opus"\` |
601
+ | \`isolateParallel\` | boolean | \`false\` | Isolate parallel agents in separate worktrees |
602
+
603
+ ## \`scopeAssessment\` — Scope Assessment Settings
604
+
605
+ | Key | Type | Default | Description |
606
+ |-----|------|---------|-------------|
607
+ | \`enabled\` | boolean | \`true\` | Whether scope assessment is enabled |
608
+ | \`skipIfSimple\` | boolean | \`true\` | Skip assessment for trivial issues |
609
+
610
+ ### \`scopeAssessment.trivialThresholds\`
611
+
612
+ | Key | Type | Default | Description |
613
+ |-----|------|---------|-------------|
614
+ | \`maxACItems\` | number | \`3\` | Max AC items for trivial classification |
615
+ | \`maxDirectories\` | number | \`1\` | Max directories for trivial classification |
616
+
617
+ ### \`scopeAssessment.thresholds\`
618
+
619
+ Each threshold has \`yellow\` (warning) and \`red\` (split recommended) values:
620
+
621
+ | Metric | Yellow | Red |
622
+ |--------|--------|-----|
623
+ | \`featureCount\` | 2 | 3 |
624
+ | \`acItems\` | 6 | 9 |
625
+ | \`fileEstimate\` | 8 | 13 |
626
+ | \`directorySpread\` | 3 | 5 |
627
+
628
+ ## \`qa\` — QA Skill Settings
629
+
630
+ | Key | Type | Default | Description |
631
+ |-----|------|---------|-------------|
632
+ | \`smallDiffThreshold\` | number | \`100\` | Diff size threshold for small-diff fast path |
633
+
634
+ ---
635
+
636
+ *Unknown keys are preserved but logged as warnings. This allows forward compatibility
637
+ with newer Sequant versions.*
638
+ `;
194
639
  }
@@ -10,7 +10,7 @@
10
10
  import chalk from "chalk";
11
11
  import { spawnSync } from "child_process";
12
12
  import { createPhaseLogFromTiming } from "./log-writer.js";
13
- import { classifyError } from "./error-classifier.js";
13
+ import { classifyError, errorTypeToCategory } from "./error-classifier.js";
14
14
  import { PhaseSpinner } from "../phase-spinner.js";
15
15
  import { getGitDiffStats, getCommitHash } from "./git-diff-utils.js";
16
16
  import { createCheckpointCommit, rebaseBeforePR, createPR, readCacheMetrics, filterResumedPhases, } from "./worktree-manager.js";
@@ -399,11 +399,15 @@ export async function runIssueWithLogging(ctx) {
399
399
  // Build errorContext from captured stderr/stdout tails (#447)
400
400
  let specErrorContext;
401
401
  if (!specResult.success && specResult.stderrTail) {
402
+ const specError = classifyError(specResult.stderrTail ?? [], specResult.exitCode);
402
403
  specErrorContext = {
403
404
  stderrTail: specResult.stderrTail ?? [],
404
405
  stdoutTail: specResult.stdoutTail ?? [],
405
406
  exitCode: specResult.exitCode,
406
- category: classifyError(specResult.stderrTail ?? []),
407
+ category: errorTypeToCategory(specError),
408
+ errorType: specError.name,
409
+ errorMetadata: specError.metadata,
410
+ isRetryable: specError.isRetryable,
407
411
  };
408
412
  }
409
413
  const phaseLog = createPhaseLogFromTiming("spec", issueNumber, specStartTime, specEndTime, specResult.success
@@ -595,14 +599,18 @@ export async function runIssueWithLogging(ctx) {
595
599
  : undefined;
596
600
  // Read cache metrics for QA phase (AC-7)
597
601
  const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
598
- // Build errorContext from captured stderr/stdout tails (#447)
602
+ // Build errorContext from captured stderr/stdout tails (#447, AC-7/AC-8)
599
603
  let errorContext;
600
604
  if (!result.success && result.stderrTail) {
605
+ const typedError = classifyError(result.stderrTail ?? [], result.exitCode);
601
606
  errorContext = {
602
607
  stderrTail: result.stderrTail ?? [],
603
608
  stdoutTail: result.stdoutTail ?? [],
604
609
  exitCode: result.exitCode,
605
- category: classifyError(result.stderrTail ?? []),
610
+ category: errorTypeToCategory(typedError),
611
+ errorType: typedError.name,
612
+ errorMetadata: typedError.metadata,
613
+ isRetryable: typedError.isRetryable,
606
614
  };
607
615
  }
608
616
  const phaseLog = createPhaseLogFromTiming(phase, issueNumber, phaseStartTime, phaseEndTime, result.success
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ConfigResolver — 4-layer configuration merge for sequant run.
3
+ *
4
+ * Priority: defaults < settings < env < explicit (CLI flags).
5
+ * Handles Commander.js --no-X boolean negation at the CLI boundary.
6
+ *
7
+ * @module
8
+ */
9
+ import { type ExecutionConfig, type RunOptions } from "./types.js";
10
+ import type { SequantSettings } from "../settings.js";
11
+ /**
12
+ * Layers for config resolution.
13
+ * Each field is optional — only defined values participate in merging.
14
+ */
15
+ export interface ConfigLayers {
16
+ defaults: Record<string, unknown>;
17
+ settings: Record<string, unknown>;
18
+ env: Record<string, unknown>;
19
+ explicit: Record<string, unknown>;
20
+ }
21
+ /**
22
+ * Generic 4-layer priority merge.
23
+ * For each key across all layers: explicit > env > settings > defaults.
24
+ * Env strings are coerced to match the type of the default value.
25
+ */
26
+ export declare class ConfigResolver {
27
+ private layers;
28
+ constructor(layers: ConfigLayers);
29
+ /**
30
+ * Resolve all layers into a single merged config object.
31
+ * Priority: explicit > env > settings > defaults.
32
+ */
33
+ resolve(): Record<string, unknown>;
34
+ }
35
+ /**
36
+ * Normalize Commander.js --no-X flags into RunOptions negation fields.
37
+ * This is a thin adapter at the CLI boundary — not used by programmatic callers.
38
+ */
39
+ export declare function normalizeCommanderOptions(options: RunOptions): RunOptions;
40
+ /**
41
+ * Resolve RunOptions + settings + env into a fully merged RunOptions.
42
+ * This replaces the inline merging logic previously in run.ts.
43
+ */
44
+ export declare function resolveRunOptions(cliOptions: RunOptions, settings: SequantSettings): RunOptions;
45
+ /**
46
+ * Build an ExecutionConfig from merged RunOptions and settings.
47
+ * Extracts the phase-timeout, MCP, retry, and mode resolution logic
48
+ * that was previously inline in run.ts.
49
+ */
50
+ export declare function buildExecutionConfig(mergedOptions: RunOptions, settings: SequantSettings, issueCount: number): ExecutionConfig;