@lumenflow/cli 2.2.2 → 2.3.1

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 (118) hide show
  1. package/README.md +147 -57
  2. package/dist/__tests__/agent-log-issue.test.js +56 -0
  3. package/dist/__tests__/cli-entry-point.test.js +66 -17
  4. package/dist/__tests__/cli-subprocess.test.js +25 -0
  5. package/dist/__tests__/init.test.js +298 -0
  6. package/dist/__tests__/initiative-plan.test.js +340 -0
  7. package/dist/__tests__/mem-cleanup-execution.test.js +19 -0
  8. package/dist/__tests__/merge-block.test.js +220 -0
  9. package/dist/__tests__/safe-git.test.js +191 -0
  10. package/dist/__tests__/state-doctor.test.js +274 -0
  11. package/dist/__tests__/wu-done.test.js +36 -0
  12. package/dist/__tests__/wu-edit.test.js +119 -0
  13. package/dist/__tests__/wu-prep.test.js +108 -0
  14. package/dist/agent-issues-query.js +4 -3
  15. package/dist/agent-log-issue.js +25 -4
  16. package/dist/backlog-prune.js +5 -4
  17. package/dist/cli-entry-point.js +11 -1
  18. package/dist/doctor.js +368 -0
  19. package/dist/flow-bottlenecks.js +6 -5
  20. package/dist/flow-report.js +4 -3
  21. package/dist/gates.js +356 -101
  22. package/dist/guard-locked.js +4 -3
  23. package/dist/guard-worktree-commit.js +4 -3
  24. package/dist/init.js +508 -86
  25. package/dist/initiative-add-wu.js +4 -3
  26. package/dist/initiative-bulk-assign-wus.js +8 -5
  27. package/dist/initiative-create.js +73 -37
  28. package/dist/initiative-edit.js +37 -21
  29. package/dist/initiative-list.js +4 -3
  30. package/dist/initiative-plan.js +337 -0
  31. package/dist/initiative-status.js +4 -3
  32. package/dist/lane-health.js +377 -0
  33. package/dist/lane-suggest.js +382 -0
  34. package/dist/mem-checkpoint.js +2 -2
  35. package/dist/mem-cleanup.js +2 -2
  36. package/dist/mem-context.js +306 -0
  37. package/dist/mem-create.js +2 -2
  38. package/dist/mem-delete.js +293 -0
  39. package/dist/mem-inbox.js +2 -2
  40. package/dist/mem-index.js +211 -0
  41. package/dist/mem-init.js +1 -1
  42. package/dist/mem-profile.js +207 -0
  43. package/dist/mem-promote.js +254 -0
  44. package/dist/mem-ready.js +2 -2
  45. package/dist/mem-signal.js +2 -2
  46. package/dist/mem-start.js +2 -2
  47. package/dist/mem-summarize.js +2 -2
  48. package/dist/mem-triage.js +2 -2
  49. package/dist/merge-block.js +222 -0
  50. package/dist/metrics-cli.js +7 -4
  51. package/dist/metrics-snapshot.js +4 -3
  52. package/dist/orchestrate-initiative.js +10 -4
  53. package/dist/orchestrate-monitor.js +379 -31
  54. package/dist/signal-cleanup.js +296 -0
  55. package/dist/spawn-list.js +6 -5
  56. package/dist/state-bootstrap.js +5 -4
  57. package/dist/state-cleanup.js +360 -0
  58. package/dist/state-doctor-fix.js +196 -0
  59. package/dist/state-doctor.js +501 -0
  60. package/dist/validate-agent-skills.js +4 -3
  61. package/dist/validate-agent-sync.js +4 -3
  62. package/dist/validate-backlog-sync.js +4 -3
  63. package/dist/validate-skills-spec.js +4 -3
  64. package/dist/validate.js +4 -3
  65. package/dist/wu-block.js +3 -3
  66. package/dist/wu-claim.js +208 -98
  67. package/dist/wu-cleanup.js +5 -4
  68. package/dist/wu-create.js +71 -46
  69. package/dist/wu-delete.js +88 -60
  70. package/dist/wu-deps.js +6 -5
  71. package/dist/wu-done-check.js +34 -0
  72. package/dist/wu-done.js +39 -12
  73. package/dist/wu-edit.js +63 -28
  74. package/dist/wu-infer-lane.js +7 -6
  75. package/dist/wu-preflight.js +23 -81
  76. package/dist/wu-prep.js +125 -0
  77. package/dist/wu-prune.js +4 -3
  78. package/dist/wu-recover.js +88 -22
  79. package/dist/wu-repair.js +7 -6
  80. package/dist/wu-spawn.js +226 -270
  81. package/dist/wu-status.js +4 -3
  82. package/dist/wu-unblock.js +5 -5
  83. package/dist/wu-unlock-lane.js +4 -3
  84. package/dist/wu-validate.js +5 -4
  85. package/package.json +16 -7
  86. package/templates/core/.lumenflow/constraints.md.template +192 -0
  87. package/templates/core/.lumenflow/rules/git-safety.md.template +27 -0
  88. package/templates/core/.lumenflow/rules/wu-workflow.md.template +48 -0
  89. package/templates/core/AGENTS.md.template +60 -0
  90. package/templates/core/LUMENFLOW.md.template +255 -0
  91. package/templates/core/UPGRADING.md.template +121 -0
  92. package/templates/core/ai/onboarding/agent-safety-card.md.template +106 -0
  93. package/templates/core/ai/onboarding/first-wu-mistakes.md.template +198 -0
  94. package/templates/core/ai/onboarding/quick-ref-commands.md.template +186 -0
  95. package/templates/core/ai/onboarding/release-process.md.template +362 -0
  96. package/templates/core/ai/onboarding/troubleshooting-wu-done.md.template +159 -0
  97. package/templates/core/ai/onboarding/wu-create-checklist.md.template +117 -0
  98. package/templates/vendors/aider/.aider.conf.yml.template +27 -0
  99. package/templates/vendors/claude/.claude/CLAUDE.md.template +52 -0
  100. package/templates/vendors/claude/.claude/settings.json.template +49 -0
  101. package/templates/vendors/claude/.claude/skills/bug-classification/SKILL.md.template +192 -0
  102. package/templates/vendors/claude/.claude/skills/code-quality/SKILL.md.template +152 -0
  103. package/templates/vendors/claude/.claude/skills/context-management/SKILL.md.template +155 -0
  104. package/templates/vendors/claude/.claude/skills/execution-memory/SKILL.md.template +304 -0
  105. package/templates/vendors/claude/.claude/skills/frontend-design/SKILL.md.template +131 -0
  106. package/templates/vendors/claude/.claude/skills/initiative-management/SKILL.md.template +164 -0
  107. package/templates/vendors/claude/.claude/skills/library-first/SKILL.md.template +98 -0
  108. package/templates/vendors/claude/.claude/skills/lumenflow-gates/SKILL.md.template +87 -0
  109. package/templates/vendors/claude/.claude/skills/multi-agent-coordination/SKILL.md.template +84 -0
  110. package/templates/vendors/claude/.claude/skills/ops-maintenance/SKILL.md.template +254 -0
  111. package/templates/vendors/claude/.claude/skills/orchestration/SKILL.md.template +189 -0
  112. package/templates/vendors/claude/.claude/skills/tdd-workflow/SKILL.md.template +139 -0
  113. package/templates/vendors/claude/.claude/skills/worktree-discipline/SKILL.md.template +138 -0
  114. package/templates/vendors/claude/.claude/skills/wu-lifecycle/SKILL.md.template +106 -0
  115. package/templates/vendors/cline/.clinerules.template +53 -0
  116. package/templates/vendors/cursor/.cursor/rules/lumenflow.md.template +34 -0
  117. package/templates/vendors/cursor/.cursor/rules.md.template +28 -0
  118. package/templates/vendors/windsurf/.windsurf/rules/lumenflow.md.template +34 -0
package/dist/gates.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-console -- Gates runner uses console for status output; refactoring to logger is tracked for future work */
2
3
  /**
3
4
  * Quality Gates Runner
4
5
  *
@@ -32,11 +33,11 @@
32
33
  * - Standard WUs: changed tests + safety-critical tests
33
34
  *
34
35
  * Usage:
35
- * node tools/gates.mjs # Tiered gates (default)
36
- * node tools/gates.mjs --docs-only # Docs-only gates
37
- * node tools/gates.mjs --full-lint # Full lint (bypass incremental)
38
- * node tools/gates.mjs --full-tests # Full tests (bypass incremental)
39
- * node tools/gates.mjs --coverage-mode=block # Coverage gate in block mode
36
+ * node tools/gates.ts # Tiered gates (default)
37
+ * node tools/gates.ts --docs-only # Docs-only gates
38
+ * node tools/gates.ts --full-lint # Full lint (bypass incremental)
39
+ * node tools/gates.ts --full-tests # Full tests (bypass incremental)
40
+ * node tools/gates.ts --coverage-mode=block # Coverage gate in block mode
40
41
  */
41
42
  import { execSync, spawnSync } from 'node:child_process';
42
43
  import { closeSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
@@ -44,21 +45,27 @@ import { access } from 'node:fs/promises';
44
45
  import path from 'node:path';
45
46
  import { emitGateEvent, getCurrentWU, getCurrentLane } from '@lumenflow/core/dist/telemetry.js';
46
47
  import { die } from '@lumenflow/core/dist/error-handler.js';
47
- import { getChangedLintableFiles, convertToPackageRelativePaths, } from '@lumenflow/core/dist/incremental-lint.js';
48
- import { isCodeFilePath } from '@lumenflow/core/dist/incremental-test.js';
48
+ import { getChangedLintableFiles, isLintableFile } from '@lumenflow/core/dist/incremental-lint.js';
49
+ import { buildVitestChangedArgs, isCodeFilePath } from '@lumenflow/core/dist/incremental-test.js';
49
50
  import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
50
51
  import { runCoverageGate, COVERAGE_GATE_MODES } from '@lumenflow/core/dist/coverage-gate.js';
51
52
  import { buildGatesLogPath, shouldUseGatesAgentMode, updateGatesLatestSymlink, } from '@lumenflow/core/dist/gates-agent-mode.js';
52
53
  // WU-2062: Import risk detector for tiered test execution
53
- // eslint-disable-next-line no-unused-vars -- Pre-existing: SAFETY_CRITICAL_TEST_PATTERNS imported for future use
54
- import { detectRiskTier, RISK_TIERS, } from '@lumenflow/core/dist/risk-detector.js';
54
+ import { detectRiskTier, RISK_TIERS } from '@lumenflow/core/dist/risk-detector.js';
55
55
  // WU-2252: Import invariants runner for first-check validation
56
56
  import { runInvariants } from '@lumenflow/core/dist/invariants-runner.js';
57
57
  import { createWUParser } from '@lumenflow/core/dist/arg-parser.js';
58
58
  import { validateBacklogSync } from '@lumenflow/core/dist/validators/backlog-sync.js';
59
59
  import { runSupabaseDocsLinter } from '@lumenflow/core/dist/validators/supabase-docs-linter.js';
60
60
  import { runSystemMapValidation } from '@lumenflow/core/dist/system-map-validator.js';
61
- import { BRANCHES, PACKAGES, PKG_MANAGER, PKG_FLAGS, ESLINT_FLAGS, ESLINT_COMMANDS, ESLINT_DEFAULTS, SCRIPTS, CACHE_STRATEGIES, DIRECTORIES, GATE_NAMES, GATE_COMMANDS, CLI_MODES, EXIT_CODES, FILE_SYSTEM, PRETTIER_ARGS, PRETTIER_FLAGS, } from '@lumenflow/core/dist/wu-constants.js';
61
+ // WU-1067: Config-driven gates support (partial implementation - unused imports removed)
62
+ // WU-1191: Lane health gate configuration
63
+ // WU-1262: Coverage config from methodology policy
64
+ // WU-1280: Test policy for tests_required (warn vs block on test failures)
65
+ import { loadLaneHealthConfig, resolveTestPolicy, } from '@lumenflow/core/dist/gates-config.js';
66
+ // WU-1191: Lane health check
67
+ import { runLaneHealthCheck } from './lane-health.js';
68
+ import { BRANCHES, PACKAGES, PKG_MANAGER, ESLINT_FLAGS, ESLINT_COMMANDS, ESLINT_DEFAULTS, SCRIPTS, CACHE_STRATEGIES, DIRECTORIES, GATE_NAMES, GATE_COMMANDS, CLI_MODES, EXIT_CODES, FILE_SYSTEM, PRETTIER_ARGS, PRETTIER_FLAGS, } from '@lumenflow/core/dist/wu-constants.js';
62
69
  /**
63
70
  * WU-1087: Gates-specific option definitions for createWUParser.
64
71
  * Exported for testing and consistency with other CLI commands.
@@ -141,6 +148,7 @@ export function parseGatesOptions() {
141
148
  * @deprecated Use parseGatesOptions() instead (WU-1087)
142
149
  * Kept for backward compatibility during migration.
143
150
  */
151
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Pre-existing: argv kept for backwards compatibility
144
152
  function parseGatesArgs(argv = process.argv) {
145
153
  return parseGatesOptions();
146
154
  }
@@ -157,6 +165,91 @@ function pnpmRun(script, ...args) {
157
165
  const argsStr = args.length > 0 ? ` ${args.join(' ')}` : '';
158
166
  return `${PKG_MANAGER} ${SCRIPTS.RUN} ${script}${argsStr}`;
159
167
  }
168
+ const PRETTIER_CONFIG_FILES = new Set([
169
+ '.prettierrc',
170
+ '.prettierrc.json',
171
+ '.prettierrc.yaml',
172
+ '.prettierrc.yml',
173
+ '.prettierrc.js',
174
+ '.prettierrc.cjs',
175
+ '.prettierrc.ts',
176
+ 'prettier.config.js',
177
+ 'prettier.config.cjs',
178
+ 'prettier.config.ts',
179
+ 'prettier.config.mjs',
180
+ '.prettierignore',
181
+ ]);
182
+ const TEST_CONFIG_BASENAMES = new Set(['turbo.json', 'pnpm-lock.yaml', 'package.json']);
183
+ const TEST_CONFIG_PATTERNS = [/^vitest\.config\.(ts|mts|js|mjs|cjs)$/i, /^tsconfig(\..+)?\.json$/i];
184
+ function normalizePath(filePath) {
185
+ return filePath.replace(/\\/g, '/');
186
+ }
187
+ function getBasename(filePath) {
188
+ const normalized = normalizePath(filePath);
189
+ const parts = normalized.split('/');
190
+ return parts[parts.length - 1] || normalized;
191
+ }
192
+ function quoteShellArgs(files) {
193
+ return files.map((file) => `"${file}"`).join(' ');
194
+ }
195
+ export function isPrettierConfigFile(filePath) {
196
+ if (!filePath)
197
+ return false;
198
+ const basename = getBasename(filePath);
199
+ return PRETTIER_CONFIG_FILES.has(basename);
200
+ }
201
+ export function isTestConfigFile(filePath) {
202
+ if (!filePath)
203
+ return false;
204
+ const basename = getBasename(filePath);
205
+ if (TEST_CONFIG_BASENAMES.has(basename)) {
206
+ return true;
207
+ }
208
+ return TEST_CONFIG_PATTERNS.some((pattern) => pattern.test(basename));
209
+ }
210
+ /* eslint-disable sonarjs/no-duplicate-string -- Pre-existing: format check reasons are intentionally distinct string literals */
211
+ export function resolveFormatCheckPlan({ changedFiles, fileListError = false, }) {
212
+ if (fileListError) {
213
+ return { mode: 'full', files: [], reason: 'file-list-error' };
214
+ }
215
+ if (changedFiles.some(isPrettierConfigFile)) {
216
+ return { mode: 'full', files: [], reason: 'prettier-config' };
217
+ }
218
+ if (changedFiles.length === 0) {
219
+ return { mode: 'skip', files: [] };
220
+ }
221
+ return { mode: 'incremental', files: changedFiles };
222
+ }
223
+ export function resolveLintPlan({ isMainBranch, changedFiles, }) {
224
+ if (isMainBranch) {
225
+ return { mode: 'full', files: [] };
226
+ }
227
+ const lintTargets = changedFiles.filter((filePath) => {
228
+ const normalized = normalizePath(filePath);
229
+ return ((normalized.startsWith('apps/') || normalized.startsWith('packages/')) &&
230
+ isLintableFile(normalized));
231
+ });
232
+ if (lintTargets.length === 0) {
233
+ return { mode: 'skip', files: [] };
234
+ }
235
+ return { mode: 'incremental', files: lintTargets };
236
+ }
237
+ /* eslint-enable sonarjs/no-duplicate-string */
238
+ export function resolveTestPlan({ isMainBranch, hasUntrackedCode, hasConfigChange, fileListError, }) {
239
+ if (fileListError) {
240
+ return { mode: 'full', reason: 'file-list-error' };
241
+ }
242
+ if (hasUntrackedCode) {
243
+ return { mode: 'full', reason: 'untracked-code' };
244
+ }
245
+ if (hasConfigChange) {
246
+ return { mode: 'full', reason: 'test-config' };
247
+ }
248
+ if (isMainBranch) {
249
+ return { mode: 'full' };
250
+ }
251
+ return { mode: 'incremental' };
252
+ }
160
253
  export function parsePrettierListOutput(output) {
161
254
  if (!output)
162
255
  return [];
@@ -174,6 +267,10 @@ export function buildPrettierWriteCommand(files) {
174
267
  const base = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_FLAGS.WRITE);
175
268
  return quotedFiles ? `${base} ${quotedFiles}` : base;
176
269
  }
270
+ function buildPrettierCheckCommand(files) {
271
+ const filesArg = files.length > 0 ? quoteShellArgs(files) : '.';
272
+ return pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.CHECK, filesArg);
273
+ }
177
274
  export function formatFormatCheckGuidance(files) {
178
275
  if (!files.length)
179
276
  return [];
@@ -189,8 +286,10 @@ export function formatFormatCheckGuidance(files) {
189
286
  '',
190
287
  ];
191
288
  }
192
- function collectPrettierListDifferent(cwd) {
193
- const cmd = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.LIST_DIFFERENT, '.');
289
+ function collectPrettierListDifferent(cwd, files = []) {
290
+ const filesArg = files.length > 0 ? quoteShellArgs(files) : '.';
291
+ const cmd = pnpmCmd(SCRIPTS.PRETTIER, PRETTIER_ARGS.LIST_DIFFERENT, filesArg);
292
+ // eslint-disable-next-line sonarjs/os-command -- Pre-existing: executes trusted pnpm prettier command
194
293
  const result = spawnSync(cmd, [], {
195
294
  shell: true,
196
295
  cwd,
@@ -199,11 +298,11 @@ function collectPrettierListDifferent(cwd) {
199
298
  const output = `${result.stdout || ''}\n${result.stderr || ''}`;
200
299
  return parsePrettierListOutput(output);
201
300
  }
202
- function emitFormatCheckGuidance({ agentLog, useAgentMode, }) {
203
- const files = collectPrettierListDifferent(process.cwd());
204
- if (!files.length)
301
+ function emitFormatCheckGuidance({ agentLog, useAgentMode, files, }) {
302
+ const formattedFiles = collectPrettierListDifferent(process.cwd(), files ?? []);
303
+ if (!formattedFiles.length)
205
304
  return;
206
- const lines = formatFormatCheckGuidance(files);
305
+ const lines = formatFormatCheckGuidance(formattedFiles);
207
306
  const logLine = useAgentMode && agentLog
208
307
  ? (line) => writeSync(agentLog.logFd, `${line}\n`)
209
308
  : (line) => console.log(line);
@@ -211,12 +310,6 @@ function emitFormatCheckGuidance({ agentLog, useAgentMode, }) {
211
310
  logLine(line);
212
311
  }
213
312
  }
214
- /**
215
- * Build a pnpm --filter command string
216
- */
217
- function pnpmFilter(pkg, script) {
218
- return `${PKG_MANAGER} ${PKG_FLAGS.FILTER} ${pkg} ${script}`;
219
- }
220
313
  function readLogTail(logPath, { maxLines = 40, maxBytes = 64 * 1024 } = {}) {
221
314
  try {
222
315
  const stats = statSync(logPath);
@@ -261,6 +354,7 @@ function run(cmd, { agentLog } = {}) {
261
354
  if (!agentLog) {
262
355
  console.log(`\n> ${cmd}\n`);
263
356
  try {
357
+ // eslint-disable-next-line sonarjs/os-command -- Pre-existing: cmd is built from trusted constants
264
358
  execSync(cmd, { stdio: 'inherit', encoding: FILE_SYSTEM.ENCODING });
265
359
  return { ok: true, duration: Date.now() - start };
266
360
  }
@@ -269,6 +363,7 @@ function run(cmd, { agentLog } = {}) {
269
363
  }
270
364
  }
271
365
  writeSync(agentLog.logFd, `\n> ${cmd}\n\n`);
366
+ // eslint-disable-next-line sonarjs/os-command -- Pre-existing: cmd is built from trusted constants
272
367
  const result = spawnSync(cmd, [], {
273
368
  shell: true,
274
369
  stdio: ['ignore', agentLog.logFd, agentLog.logFd],
@@ -342,6 +437,119 @@ async function runSystemMapGate({ agentLog, useAgentMode }) {
342
437
  }
343
438
  return { ok: result.valid, duration: Date.now() - start };
344
439
  }
440
+ /**
441
+ * WU-1191: Run lane health check gate
442
+ *
443
+ * Checks lane configuration for overlaps and coverage gaps.
444
+ * Mode is configurable via gates.lane_health in .lumenflow.config.yaml:
445
+ * - 'warn': Log warnings but don't fail (default)
446
+ * - 'error': Fail the gate if issues detected
447
+ * - 'off': Skip the check entirely
448
+ */
449
+ async function runLaneHealthGate({ agentLog, useAgentMode, mode, }) {
450
+ const start = Date.now();
451
+ const logLine = makeGateLogger({ agentLog, useAgentMode });
452
+ // Skip if mode is 'off'
453
+ if (mode === 'off') {
454
+ logLine('\n> Lane health check (skipped - mode: off)\n');
455
+ return { ok: true, duration: Date.now() - start };
456
+ }
457
+ logLine(`\n> Lane health check (mode: ${mode})\n`);
458
+ const report = runLaneHealthCheck({ projectRoot: process.cwd() });
459
+ if (!report.healthy) {
460
+ logLine('⚠️ Lane health issues detected:');
461
+ if (report.overlaps.hasOverlaps) {
462
+ logLine(` - ${report.overlaps.overlaps.length} overlapping code_paths`);
463
+ }
464
+ if (report.gaps.hasGaps) {
465
+ logLine(` - ${report.gaps.uncoveredFiles.length} uncovered files`);
466
+ }
467
+ logLine(` Run 'pnpm lane:health' for full report.`);
468
+ if (mode === 'error') {
469
+ return { ok: false, duration: Date.now() - start };
470
+ }
471
+ // mode === 'warn': report but don't fail
472
+ logLine(' (mode: warn - not blocking)');
473
+ }
474
+ else {
475
+ logLine('Lane health check passed.');
476
+ }
477
+ return { ok: true, duration: Date.now() - start };
478
+ }
479
+ async function filterExistingFiles(files) {
480
+ const existingFiles = await Promise.all(files.map(async (file) => {
481
+ try {
482
+ await access(file);
483
+ return file;
484
+ }
485
+ catch {
486
+ return null;
487
+ }
488
+ }));
489
+ return existingFiles.filter((file) => Boolean(file));
490
+ }
491
+ async function runFormatCheckGate({ agentLog, useAgentMode }) {
492
+ const start = Date.now();
493
+ const logLine = makeGateLogger({ agentLog, useAgentMode });
494
+ let git;
495
+ let isMainBranch = false;
496
+ try {
497
+ git = getGitForCwd();
498
+ const currentBranch = await git.getCurrentBranch();
499
+ isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
500
+ }
501
+ catch (error) {
502
+ logLine(`⚠️ Failed to determine branch for format check: ${error.message}`);
503
+ const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog });
504
+ return { ...result, duration: Date.now() - start, fileCount: -1 };
505
+ }
506
+ if (isMainBranch) {
507
+ logLine('📋 On main branch - running full format check');
508
+ const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog });
509
+ return { ...result, duration: Date.now() - start, fileCount: -1 };
510
+ }
511
+ let changedFiles = [];
512
+ let fileListError = false;
513
+ try {
514
+ changedFiles = await getChangedFilesForIncremental({ git });
515
+ }
516
+ catch (error) {
517
+ fileListError = true;
518
+ logLine(`⚠️ Failed to determine changed files for format check: ${error.message}`);
519
+ }
520
+ const plan = resolveFormatCheckPlan({ changedFiles, fileListError });
521
+ if (plan.mode === 'skip') {
522
+ logLine('\n> format:check (incremental)\n');
523
+ logLine('✅ No files changed - skipping format check');
524
+ return { ok: true, duration: Date.now() - start, fileCount: 0, filesChecked: [] };
525
+ }
526
+ if (plan.mode === 'full') {
527
+ /* eslint-disable sonarjs/no-nested-conditional -- Pre-existing: simple reason mapping, readable as-is */
528
+ const reason = plan.reason === 'prettier-config'
529
+ ? ' (prettier config changed)'
530
+ : plan.reason === 'file-list-error'
531
+ ? ' (file list unavailable)'
532
+ : '';
533
+ /* eslint-enable sonarjs/no-nested-conditional */
534
+ logLine(`📋 Running full format check${reason}`);
535
+ const result = run(pnpmCmd(SCRIPTS.FORMAT_CHECK), { agentLog });
536
+ return { ...result, duration: Date.now() - start, fileCount: -1 };
537
+ }
538
+ const existingFiles = await filterExistingFiles(plan.files);
539
+ if (existingFiles.length === 0) {
540
+ logLine('\n> format:check (incremental)\n');
541
+ logLine('✅ All changed files were deleted - skipping format check');
542
+ return { ok: true, duration: Date.now() - start, fileCount: 0, filesChecked: [] };
543
+ }
544
+ logLine(`\n> format:check (incremental: ${existingFiles.length} files)\n`);
545
+ const result = run(buildPrettierCheckCommand(existingFiles), { agentLog });
546
+ return {
547
+ ...result,
548
+ duration: Date.now() - start,
549
+ fileCount: existingFiles.length,
550
+ filesChecked: existingFiles,
551
+ };
552
+ }
345
553
  /**
346
554
  * Run incremental ESLint on changed files only
347
555
  * Falls back to full lint if on main branch or if incremental fails
@@ -356,56 +564,36 @@ async function runIncrementalLint({ agentLog, } = {}) {
356
564
  }
357
565
  writeSync(agentLog.logFd, `${line}\n`);
358
566
  };
359
- // WU-1006: Skip incremental lint if apps/web doesn't exist (repo-agnostic)
360
- const webDir = path.join(process.cwd(), DIRECTORIES.APPS_WEB);
361
- try {
362
- await access(webDir);
363
- }
364
- catch {
365
- logLine('\n> ESLint (incremental) skipped (apps/web not present)\n');
366
- return { ok: true, duration: Date.now() - start, fileCount: 0 };
367
- }
368
567
  try {
369
568
  // Check if we're on main branch
370
569
  const git = getGitForCwd();
371
570
  const currentBranch = await git.getCurrentBranch();
372
- if (currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER) {
571
+ const isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
572
+ if (isMainBranch) {
373
573
  logLine('📋 On main branch - running full lint');
374
- const result = run(pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT), { agentLog });
574
+ const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog });
375
575
  return { ...result, fileCount: -1 };
376
576
  }
377
- // Get changed files in apps/web
378
- const changedFiles = await getChangedLintableFiles({
379
- git,
380
- filterPath: DIRECTORIES.APPS_WEB,
381
- });
382
- if (changedFiles.length === 0) {
577
+ const changedFiles = await getChangedLintableFiles({ git });
578
+ const plan = resolveLintPlan({ isMainBranch, changedFiles });
579
+ if (plan.mode === 'skip') {
383
580
  logLine('\n> ESLint (incremental)\n');
384
581
  logLine('✅ No lintable files changed - skipping lint');
385
582
  return { ok: true, duration: Date.now() - start, fileCount: 0 };
386
583
  }
387
- // Filter to files that still exist (in case of deletions)
388
- const existingFiles = (await Promise.all(changedFiles.map(async (f) => {
389
- try {
390
- await access(f);
391
- return f;
392
- }
393
- catch {
394
- return null;
395
- }
396
- }))).filter(Boolean);
584
+ if (plan.mode === 'full') {
585
+ logLine('📋 Running full lint (incremental plan forced full)');
586
+ const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog });
587
+ return { ...result, fileCount: -1 };
588
+ }
589
+ const existingFiles = await filterExistingFiles(plan.files);
397
590
  if (existingFiles.length === 0) {
398
591
  logLine('\n> ESLint (incremental)\n');
399
592
  logLine('✅ All changed files were deleted - skipping lint');
400
593
  return { ok: true, duration: Date.now() - start, fileCount: 0 };
401
594
  }
402
- // WU-2571: Convert repo-relative paths to package-relative paths
403
- // ESLint runs from apps/web/ where repo-relative paths don't exist
404
- const packageRelativeFiles = convertToPackageRelativePaths(existingFiles, DIRECTORIES.APPS_WEB);
405
- logLine(`\n> ESLint (incremental: ${packageRelativeFiles.length} files)\n`);
406
- logLine(`Files to lint:\n ${packageRelativeFiles.join('\n ')}\n`);
407
- // WU-2571: Run ESLint from apps/web directory with package-relative paths
408
- const webDir = path.join(process.cwd(), DIRECTORIES.APPS_WEB);
595
+ logLine(`\n> ESLint (incremental: ${existingFiles.length} files)\n`);
596
+ logLine(`Files to lint:\n ${existingFiles.join('\n ')}\n`);
409
597
  const result = spawnSync(PKG_MANAGER, [
410
598
  ESLINT_COMMANDS.ESLINT,
411
599
  ESLINT_FLAGS.MAX_WARNINGS,
@@ -417,28 +605,28 @@ async function runIncrementalLint({ agentLog, } = {}) {
417
605
  ESLINT_FLAGS.CACHE_LOCATION,
418
606
  '.eslintcache',
419
607
  ESLINT_FLAGS.PASS_ON_UNPRUNED,
420
- ...packageRelativeFiles,
608
+ ...existingFiles,
421
609
  ], agentLog
422
610
  ? {
423
611
  stdio: ['ignore', agentLog.logFd, agentLog.logFd],
424
612
  encoding: FILE_SYSTEM.ENCODING,
425
- cwd: webDir,
613
+ cwd: process.cwd(),
426
614
  }
427
615
  : {
428
616
  stdio: 'inherit',
429
617
  encoding: FILE_SYSTEM.ENCODING,
430
- cwd: webDir,
618
+ cwd: process.cwd(),
431
619
  });
432
620
  const duration = Date.now() - start;
433
621
  return {
434
622
  ok: result.status === EXIT_CODES.SUCCESS,
435
623
  duration,
436
- fileCount: packageRelativeFiles.length,
624
+ fileCount: existingFiles.length,
437
625
  };
438
626
  }
439
627
  catch (error) {
440
628
  console.error('⚠️ Incremental lint failed, falling back to full lint:', error.message);
441
- const result = run(pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT), { agentLog });
629
+ const result = run(pnpmCmd(SCRIPTS.LINT), { agentLog });
442
630
  return { ...result, fileCount: -1 };
443
631
  }
444
632
  }
@@ -461,29 +649,53 @@ async function runChangedTests({ agentLog, } = {}) {
461
649
  try {
462
650
  const git = getGitForCwd();
463
651
  const currentBranch = await git.getCurrentBranch();
464
- if (currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER) {
652
+ const isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
653
+ if (isMainBranch) {
465
654
  logLine('📋 On main branch - running full test suite');
466
655
  const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
467
656
  return { ...result, isIncremental: false };
468
657
  }
658
+ let changedFiles = [];
659
+ let fileListError = false;
660
+ try {
661
+ changedFiles = await getChangedFilesForIncremental({ git });
662
+ }
663
+ catch (error) {
664
+ fileListError = true;
665
+ logLine(`⚠️ Failed to determine changed files for tests: ${error.message}`);
666
+ }
667
+ const hasConfigChange = !fileListError && changedFiles.some(isTestConfigFile);
469
668
  const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
470
669
  const untrackedFiles = untrackedOutput
471
670
  .split(/\r?\n/)
472
671
  .map((f) => f.trim())
473
672
  .filter(Boolean);
474
673
  const untrackedCodeFiles = untrackedFiles.filter(isCodeFilePath);
475
- if (untrackedCodeFiles.length > 0) {
476
- const preview = untrackedCodeFiles.slice(0, 5).join(', ');
477
- logLine(`⚠️ Untracked code files detected (${untrackedCodeFiles.length}): ${preview}${untrackedCodeFiles.length > 5 ? '...' : ''}`);
674
+ const hasUntrackedCode = untrackedCodeFiles.length > 0;
675
+ const plan = resolveTestPlan({
676
+ isMainBranch,
677
+ hasUntrackedCode,
678
+ hasConfigChange,
679
+ fileListError,
680
+ });
681
+ if (plan.mode === 'full') {
682
+ if (plan.reason === 'untracked-code') {
683
+ const preview = untrackedCodeFiles.slice(0, 5).join(', ');
684
+ logLine(`⚠️ Untracked code files detected (${untrackedCodeFiles.length}): ${preview}${untrackedCodeFiles.length > 5 ? '...' : ''}`);
685
+ }
686
+ else if (plan.reason === 'test-config') {
687
+ logLine('⚠️ Test config changes detected - running full test suite');
688
+ }
689
+ else if (plan.reason === 'file-list-error') {
690
+ logLine('⚠️ Changed file list unavailable - running full test suite');
691
+ }
478
692
  logLine('📋 Running full test suite to avoid missing coverage');
479
693
  const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
480
694
  return { ...result, duration: Date.now() - start, isIncremental: false };
481
695
  }
482
- // WU-1006: Use turbo for tests (repo-agnostic)
483
- // Previously used --project tools and test:changed which don't exist in all repos
484
- logLine('\n> Running tests (turbo run test)\n');
485
- const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
486
- return { ...result, duration: Date.now() - start, isIncremental: false };
696
+ logLine('\n> Running tests (vitest --changed)\n');
697
+ const result = run(pnpmCmd('vitest', 'run', ...buildVitestChangedArgs({ baseBranch: 'origin/main' })), { agentLog });
698
+ return { ...result, duration: Date.now() - start, isIncremental: true };
487
699
  }
488
700
  catch (error) {
489
701
  console.error('⚠️ Changed tests failed, falling back to full suite:', error.message);
@@ -613,31 +825,29 @@ async function runIntegrationTests({ agentLog, } = {}) {
613
825
  return { ok: false, duration: Date.now() - start };
614
826
  }
615
827
  }
828
+ async function getChangedFilesForIncremental({ git, baseBranch = 'origin/main', }) {
829
+ const mergeBase = await git.mergeBase('HEAD', baseBranch);
830
+ const committedOutput = await git.raw(['diff', '--name-only', `${mergeBase}...HEAD`]);
831
+ const committedFiles = committedOutput
832
+ .split('\n')
833
+ .map((f) => f.trim())
834
+ .filter(Boolean);
835
+ const unstagedOutput = await git.raw(['diff', '--name-only']);
836
+ const unstagedFiles = unstagedOutput
837
+ .split('\n')
838
+ .map((f) => f.trim())
839
+ .filter(Boolean);
840
+ const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
841
+ const untrackedFiles = untrackedOutput
842
+ .split('\n')
843
+ .map((f) => f.trim())
844
+ .filter(Boolean);
845
+ return [...new Set([...committedFiles, ...unstagedFiles, ...untrackedFiles])];
846
+ }
616
847
  async function getAllChangedFiles(options = {}) {
617
848
  const { git = getGitForCwd() } = options;
618
849
  try {
619
- // Get merge base
620
- const mergeBase = await git.mergeBase('HEAD', 'origin/main');
621
- // Get committed changes
622
- const committedOutput = await git.raw(['diff', '--name-only', `${mergeBase}...HEAD`]);
623
- const committedFiles = committedOutput
624
- .split('\n')
625
- .map((f) => f.trim())
626
- .filter(Boolean);
627
- // Get unstaged changes
628
- const unstagedOutput = await git.raw(['diff', '--name-only']);
629
- const unstagedFiles = unstagedOutput
630
- .split('\n')
631
- .map((f) => f.trim())
632
- .filter(Boolean);
633
- // Get untracked files
634
- const untrackedOutput = await git.raw(['ls-files', '--others', '--exclude-standard']);
635
- const untrackedFiles = untrackedOutput
636
- .split('\n')
637
- .map((f) => f.trim())
638
- .filter(Boolean);
639
- // Combine and deduplicate
640
- return [...new Set([...committedFiles, ...unstagedFiles, ...untrackedFiles])];
850
+ return await getChangedFilesForIncremental({ git });
641
851
  }
642
852
  catch (error) {
643
853
  console.error('⚠️ Failed to get changed files:', error.message);
@@ -680,9 +890,20 @@ async function executeGates(opts) {
680
890
  const isFullTests = opts.fullTests || false;
681
891
  // WU-2244: Full coverage flag forces full test suite and coverage gate (deterministic)
682
892
  const isFullCoverage = opts.fullCoverage || false;
893
+ // WU-1262: Resolve coverage config from methodology policy
894
+ // This derives coverage threshold and mode from methodology.testing setting
895
+ // WU-1280: Use resolveTestPolicy to also get tests_required for test failure handling
896
+ const resolvedTestPolicy = resolveTestPolicy(process.cwd());
683
897
  // WU-1433: Coverage gate mode (warn or block)
684
898
  // WU-2334: Default changed from WARN to BLOCK for TDD enforcement
685
- const coverageMode = opts.coverageMode || COVERAGE_GATE_MODES.BLOCK;
899
+ // WU-1262: CLI flag overrides resolved policy, which overrides methodology defaults
900
+ const coverageMode = opts.coverageMode || resolvedTestPolicy.mode || COVERAGE_GATE_MODES.BLOCK;
901
+ const coverageThreshold = resolvedTestPolicy.threshold;
902
+ // WU-1280: Determine if tests are required (affects whether test failures block or warn)
903
+ // When tests_required=false (methodology.testing: none), test failures produce warnings only
904
+ const testsRequired = resolvedTestPolicy.tests_required;
905
+ // WU-1191: Lane health gate mode (warn, error, or off)
906
+ const laneHealthMode = loadLaneHealthConfig(process.cwd());
686
907
  if (useAgentMode) {
687
908
  console.log(`🧾 gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
688
909
  }
@@ -720,7 +941,7 @@ async function executeGates(opts) {
720
941
  ? [
721
942
  // WU-2252: Invariants check runs first (non-bypassable)
722
943
  { name: GATE_NAMES.INVARIANTS, cmd: GATE_COMMANDS.INVARIANTS },
723
- { name: GATE_NAMES.FORMAT_CHECK, cmd: pnpmCmd(SCRIPTS.FORMAT_CHECK) },
944
+ { name: GATE_NAMES.FORMAT_CHECK, run: runFormatCheckGate },
724
945
  { name: GATE_NAMES.SPEC_LINTER, cmd: pnpmRun(SCRIPTS.SPEC_LINTER) },
725
946
  {
726
947
  name: GATE_NAMES.PROMPTS_LINT,
@@ -733,14 +954,20 @@ async function executeGates(opts) {
733
954
  run: runSystemMapGate,
734
955
  warnOnly: true,
735
956
  },
957
+ // WU-1191: Lane health check (configurable: warn/error/off)
958
+ {
959
+ name: GATE_NAMES.LANE_HEALTH,
960
+ run: (ctx) => runLaneHealthGate({ ...ctx, mode: laneHealthMode }),
961
+ warnOnly: laneHealthMode !== 'error',
962
+ },
736
963
  ]
737
964
  : [
738
965
  // WU-2252: Invariants check runs first (non-bypassable)
739
966
  { name: GATE_NAMES.INVARIANTS, cmd: GATE_COMMANDS.INVARIANTS },
740
- { name: GATE_NAMES.FORMAT_CHECK, cmd: pnpmCmd(SCRIPTS.FORMAT_CHECK) },
967
+ { name: GATE_NAMES.FORMAT_CHECK, run: runFormatCheckGate },
741
968
  {
742
969
  name: GATE_NAMES.LINT,
743
- cmd: isFullLint ? pnpmFilter(PACKAGES.WEB, SCRIPTS.LINT) : GATE_COMMANDS.INCREMENTAL,
970
+ cmd: isFullLint ? pnpmCmd(SCRIPTS.LINT) : GATE_COMMANDS.INCREMENTAL,
744
971
  },
745
972
  { name: GATE_NAMES.TYPECHECK, cmd: pnpmCmd(SCRIPTS.TYPECHECK) },
746
973
  { name: GATE_NAMES.SPEC_LINTER, cmd: pnpmRun(SCRIPTS.SPEC_LINTER) },
@@ -756,19 +983,39 @@ async function executeGates(opts) {
756
983
  run: runSystemMapGate,
757
984
  warnOnly: true,
758
985
  },
986
+ // WU-1191: Lane health check (configurable: warn/error/off)
987
+ {
988
+ name: GATE_NAMES.LANE_HEALTH,
989
+ run: (ctx) => runLaneHealthGate({ ...ctx, mode: laneHealthMode }),
990
+ warnOnly: laneHealthMode !== 'error',
991
+ },
759
992
  // WU-2062: Safety-critical tests ALWAYS run
760
- { name: GATE_NAMES.SAFETY_CRITICAL_TEST, cmd: GATE_COMMANDS.SAFETY_CRITICAL_TEST },
993
+ // WU-1280: When tests_required=false (methodology.testing: none), failures only warn
994
+ {
995
+ name: GATE_NAMES.SAFETY_CRITICAL_TEST,
996
+ cmd: GATE_COMMANDS.SAFETY_CRITICAL_TEST,
997
+ warnOnly: !testsRequired,
998
+ },
761
999
  // WU-1920: Use changed tests by default, full suite with --full-tests
762
1000
  // WU-2244: --full-coverage implies --full-tests for accurate coverage
1001
+ // WU-1280: When tests_required=false (methodology.testing: none), failures only warn
763
1002
  {
764
1003
  name: GATE_NAMES.TEST,
765
1004
  cmd: isFullTests || isFullCoverage
766
1005
  ? pnpmCmd('turbo', 'run', 'test')
767
1006
  : GATE_COMMANDS.INCREMENTAL_TEST,
1007
+ warnOnly: !testsRequired,
768
1008
  },
769
1009
  // WU-2062: Integration tests only for high-risk changes
1010
+ // WU-1280: When tests_required=false (methodology.testing: none), failures only warn
770
1011
  ...(riskTier && riskTier.shouldRunIntegration
771
- ? [{ name: GATE_NAMES.INTEGRATION_TEST, cmd: GATE_COMMANDS.TIERED_TEST }]
1012
+ ? [
1013
+ {
1014
+ name: GATE_NAMES.INTEGRATION_TEST,
1015
+ cmd: GATE_COMMANDS.TIERED_TEST,
1016
+ warnOnly: !testsRequired,
1017
+ },
1018
+ ]
772
1019
  : []),
773
1020
  // WU-1433: Coverage gate with configurable mode (warn/block)
774
1021
  { name: GATE_NAMES.COVERAGE, cmd: GATE_COMMANDS.COVERAGE_GATE },
@@ -784,10 +1031,14 @@ async function executeGates(opts) {
784
1031
  // Run all gates sequentially
785
1032
  // WU-1920: Track last test result to skip coverage gate on changed tests
786
1033
  let lastTestResult = null;
1034
+ let lastFormatCheckFiles = null;
787
1035
  for (const gate of gates) {
788
1036
  let result;
789
1037
  if (gate.run) {
790
1038
  result = await gate.run({ agentLog, useAgentMode });
1039
+ if (gate.name === GATE_NAMES.FORMAT_CHECK) {
1040
+ lastFormatCheckFiles = result.filesChecked ?? null;
1041
+ }
791
1042
  }
792
1043
  else if (gate.cmd === GATE_COMMANDS.INVARIANTS) {
793
1044
  // WU-2252: Invariants check runs first (non-bypassable)
@@ -836,14 +1087,17 @@ async function executeGates(opts) {
836
1087
  continue;
837
1088
  }
838
1089
  // WU-1433: Special handling for coverage gate
1090
+ // WU-1262: Include threshold from resolved policy in log
839
1091
  if (!useAgentMode) {
840
- console.log(`\n> Coverage gate (mode: ${coverageMode})\n`);
1092
+ console.log(`\n> Coverage gate (mode: ${coverageMode}, threshold: ${coverageThreshold}%)\n`);
841
1093
  }
842
1094
  else {
843
- writeSync(agentLog.logFd, `\n> Coverage gate (mode: ${coverageMode})\n\n`);
1095
+ writeSync(agentLog.logFd, `\n> Coverage gate (mode: ${coverageMode}, threshold: ${coverageThreshold}%)\n\n`);
844
1096
  }
845
1097
  result = await runCoverageGate({
846
1098
  mode: coverageMode,
1099
+ // WU-1262: Pass resolved threshold from methodology policy
1100
+ threshold: coverageThreshold,
847
1101
  logger: useAgentMode
848
1102
  ? {
849
1103
  log: (msg) => {
@@ -877,7 +1131,7 @@ async function executeGates(opts) {
877
1131
  continue;
878
1132
  }
879
1133
  if (gate.name === GATE_NAMES.FORMAT_CHECK) {
880
- emitFormatCheckGuidance({ agentLog, useAgentMode });
1134
+ emitFormatCheckGuidance({ agentLog, useAgentMode, files: lastFormatCheckFiles });
881
1135
  }
882
1136
  if (useAgentMode) {
883
1137
  const tail = readLogTail(agentLog.logPath);
@@ -905,6 +1159,7 @@ async function executeGates(opts) {
905
1159
  // The old pattern fails with pnpm symlinks because process.argv[1] is the symlink
906
1160
  // path but import.meta.url resolves to the real path - they never match
907
1161
  if (import.meta.main) {
1162
+ // eslint-disable-next-line sonarjs/deprecation -- Pre-existing: parseGatesArgs kept for backwards compatibility
908
1163
  const opts = parseGatesArgs();
909
1164
  executeGates({ ...opts, argv: process.argv.slice(2) })
910
1165
  .then((ok) => {