@lumenflow/cli 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +11 -8
  2. package/dist/__tests__/gates-config.test.js +304 -0
  3. package/dist/__tests__/init-scripts.test.js +111 -0
  4. package/dist/__tests__/templates-sync.test.js +219 -0
  5. package/dist/gates.js +64 -15
  6. package/dist/init.js +90 -0
  7. package/dist/orchestrate-init-status.js +37 -9
  8. package/dist/orchestrate-initiative.js +10 -4
  9. package/dist/sync-templates.js +137 -5
  10. package/dist/wu-prep.js +131 -8
  11. package/dist/wu-spawn.js +7 -2
  12. package/package.json +7 -7
  13. package/templates/core/.lumenflow/constraints.md.template +61 -3
  14. package/templates/core/LUMENFLOW.md.template +85 -23
  15. package/templates/core/ai/onboarding/agent-invocation-guide.md.template +157 -0
  16. package/templates/core/ai/onboarding/agent-safety-card.md.template +227 -0
  17. package/templates/core/ai/onboarding/docs-generation.md.template +277 -0
  18. package/templates/core/ai/onboarding/first-wu-mistakes.md.template +49 -7
  19. package/templates/core/ai/onboarding/quick-ref-commands.md.template +343 -110
  20. package/templates/core/ai/onboarding/release-process.md.template +8 -2
  21. package/templates/core/ai/onboarding/starting-prompt.md.template +407 -0
  22. package/templates/core/ai/onboarding/test-ratchet.md.template +131 -0
  23. package/templates/core/ai/onboarding/troubleshooting-wu-done.md.template +91 -38
  24. package/templates/core/ai/onboarding/vendor-support.md.template +219 -0
  25. package/templates/vendors/claude/.claude/skills/context-management/SKILL.md.template +13 -1
  26. package/templates/vendors/claude/.claude/skills/execution-memory/SKILL.md.template +14 -16
  27. package/templates/vendors/claude/.claude/skills/orchestration/SKILL.md.template +48 -4
  28. package/templates/vendors/claude/.claude/skills/worktree-discipline/SKILL.md.template +5 -1
  29. package/templates/vendors/claude/.claude/skills/wu-lifecycle/SKILL.md.template +19 -8
package/dist/gates.js CHANGED
@@ -65,7 +65,8 @@ import { runSystemMapValidation } from '@lumenflow/core/dist/system-map-validato
65
65
  // WU-1191: Lane health gate configuration
66
66
  // WU-1262: Coverage config from methodology policy
67
67
  // WU-1280: Test policy for tests_required (warn vs block on test failures)
68
- import { loadLaneHealthConfig, resolveTestPolicy, } from '@lumenflow/core/dist/gates-config.js';
68
+ // WU-1356: Configurable package manager and test commands
69
+ import { loadLaneHealthConfig, resolveTestPolicy, resolveGatesCommands, resolveTestRunner, } from '@lumenflow/core/dist/gates-config.js';
69
70
  // WU-1191: Lane health check
70
71
  import { runLaneHealthCheck } from './lane-health.js';
71
72
  // WU-1315: Onboarding smoke test
@@ -184,8 +185,24 @@ const PRETTIER_CONFIG_FILES = new Set([
184
185
  'prettier.config.mjs',
185
186
  '.prettierignore',
186
187
  ]);
187
- const TEST_CONFIG_BASENAMES = new Set(['turbo.json', 'pnpm-lock.yaml', 'package.json']);
188
- const TEST_CONFIG_PATTERNS = [/^vitest\.config\.(ts|mts|js|mjs|cjs)$/i, /^tsconfig(\..+)?\.json$/i];
188
+ // WU-1356: Extended to support multiple build tools and test runners
189
+ const TEST_CONFIG_BASENAMES = new Set([
190
+ 'turbo.json', // Turborepo
191
+ 'nx.json', // Nx
192
+ 'lerna.json', // Lerna
193
+ 'pnpm-lock.yaml',
194
+ 'package-lock.json',
195
+ 'yarn.lock',
196
+ 'bun.lockb',
197
+ 'package.json',
198
+ ]);
199
+ // WU-1356: Extended to support vitest, jest, and mocha config patterns
200
+ const TEST_CONFIG_PATTERNS = [
201
+ /^vitest\.config\.(ts|mts|js|mjs|cjs)$/i,
202
+ /^jest\.config\.(ts|js|mjs|cjs|json)$/i,
203
+ /^\.mocharc\.(js|json|yaml|yml)$/i,
204
+ /^tsconfig(\..+)?\.json$/i,
205
+ ];
189
206
  function normalizePath(filePath) {
190
207
  return filePath.replace(/\\/g, '/');
191
208
  }
@@ -380,9 +397,11 @@ export function loadCurrentWUCodePaths(options = {}) {
380
397
  }
381
398
  /**
382
399
  * WU-1299: Run filtered tests for docs-only mode
400
+ * WU-1356: Updated to use configured test command
383
401
  *
384
402
  * When --docs-only is passed and code_paths contains packages, this runs tests
385
- * only for those specific packages using turbo's --filter flag.
403
+ * only for those specific packages. The filter syntax adapts to the configured
404
+ * build tool (turbo, nx, or plain package manager).
386
405
  *
387
406
  * @param options - Options including packages to test and agent log context
388
407
  * @returns Result object with ok status and duration
@@ -395,10 +414,20 @@ async function runDocsOnlyFilteredTests({ packages, agentLog, }) {
395
414
  return { ok: true, duration: Date.now() - start };
396
415
  }
397
416
  logLine(`\n> Tests (docs-only filtered: ${packages.join(', ')})\n`);
398
- // Build turbo filter args for each package
399
- // turbo supports --filter=@scope/package or --filter=package
417
+ // WU-1356: Use configured test command with filter
418
+ const gatesCommands = resolveGatesCommands(process.cwd());
419
+ // If there's a configured test_docs_only command, use it
420
+ if (gatesCommands.test_docs_only) {
421
+ const result = run(gatesCommands.test_docs_only, { agentLog });
422
+ return { ok: result.ok, duration: Date.now() - start };
423
+ }
424
+ // Otherwise, use the full test command with filter args
425
+ // Build filter args for each package (works with turbo, nx, and pnpm/yarn workspaces)
400
426
  const filterArgs = packages.map((pkg) => `--filter=${pkg}`);
401
- const result = run(pnpmCmd('turbo', 'run', 'test', ...filterArgs), { agentLog });
427
+ const baseCmd = gatesCommands.test_full;
428
+ // Append filter args to the base command
429
+ const filteredCmd = `${baseCmd} ${filterArgs.join(' ')}`;
430
+ const result = run(filteredCmd, { agentLog });
402
431
  return { ok: result.ok, duration: Date.now() - start };
403
432
  }
404
433
  export function parsePrettierListOutput(output) {
@@ -782,7 +811,8 @@ async function runIncrementalLint({ agentLog, } = {}) {
782
811
  }
783
812
  }
784
813
  /**
785
- * Run changed tests using Vitest's --changed flag from the repo root.
814
+ * Run changed tests using configured test runner's incremental mode.
815
+ * WU-1356: Updated to use configured commands from gates-config.
786
816
  * Falls back to full test suite if on main branch or if the run fails.
787
817
  *
788
818
  * @returns {{ ok: boolean, duration: number, isIncremental: boolean }}
@@ -797,13 +827,16 @@ async function runChangedTests({ agentLog, } = {}) {
797
827
  }
798
828
  writeSync(agentLog.logFd, `${line}\n`);
799
829
  };
830
+ // WU-1356: Get configured commands
831
+ const gatesCommands = resolveGatesCommands(process.cwd());
832
+ const testRunner = resolveTestRunner(process.cwd());
800
833
  try {
801
834
  const git = getGitForCwd();
802
835
  const currentBranch = await git.getCurrentBranch();
803
836
  const isMainBranch = currentBranch === BRANCHES.MAIN || currentBranch === BRANCHES.MASTER;
804
837
  if (isMainBranch) {
805
838
  logLine('📋 On main branch - running full test suite');
806
- const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
839
+ const result = run(gatesCommands.test_full, { agentLog });
807
840
  return { ...result, isIncremental: false };
808
841
  }
809
842
  let changedFiles = [];
@@ -841,16 +874,29 @@ async function runChangedTests({ agentLog, } = {}) {
841
874
  logLine('⚠️ Changed file list unavailable - running full test suite');
842
875
  }
843
876
  logLine('📋 Running full test suite to avoid missing coverage');
844
- const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
877
+ const result = run(gatesCommands.test_full, { agentLog });
845
878
  return { ...result, duration: Date.now() - start, isIncremental: false };
846
879
  }
847
- logLine('\n> Running tests (vitest --changed)\n');
848
- const result = run(pnpmCmd('vitest', 'run', ...buildVitestChangedArgs({ baseBranch: 'origin/main' })), { agentLog });
849
- return { ...result, duration: Date.now() - start, isIncremental: true };
880
+ // WU-1356: Use configured incremental test command
881
+ logLine(`\n> Running tests (${testRunner} --changed)\n`);
882
+ // If test_incremental is configured, use it directly
883
+ if (gatesCommands.test_incremental) {
884
+ const result = run(gatesCommands.test_incremental, { agentLog });
885
+ return { ...result, duration: Date.now() - start, isIncremental: true };
886
+ }
887
+ // Fallback: For vitest, use the built-in changed args helper
888
+ if (testRunner === 'vitest') {
889
+ const result = run(pnpmCmd('vitest', 'run', ...buildVitestChangedArgs({ baseBranch: 'origin/main' })), { agentLog });
890
+ return { ...result, duration: Date.now() - start, isIncremental: true };
891
+ }
892
+ // For other runners without configured incremental, fall back to full
893
+ logLine('⚠️ No incremental test command configured, running full suite');
894
+ const result = run(gatesCommands.test_full, { agentLog });
895
+ return { ...result, duration: Date.now() - start, isIncremental: false };
850
896
  }
851
897
  catch (error) {
852
898
  console.error('⚠️ Changed tests failed, falling back to full suite:', error.message);
853
- const result = run(pnpmCmd('turbo', 'run', 'test'), { agentLog });
899
+ const result = run(gatesCommands.test_full, { agentLog });
854
900
  return { ...result, isIncremental: false };
855
901
  }
856
902
  }
@@ -1055,6 +1101,8 @@ async function executeGates(opts) {
1055
1101
  const testsRequired = resolvedTestPolicy.tests_required;
1056
1102
  // WU-1191: Lane health gate mode (warn, error, or off)
1057
1103
  const laneHealthMode = loadLaneHealthConfig(process.cwd());
1104
+ // WU-1356: Resolve configured gates commands for test execution
1105
+ const configuredGatesCommands = resolveGatesCommands(process.cwd());
1058
1106
  if (useAgentMode) {
1059
1107
  console.log(`🧾 gates (agent mode): output -> ${agentLog.logPath} (use --verbose for streaming)\n`);
1060
1108
  }
@@ -1185,10 +1233,11 @@ async function executeGates(opts) {
1185
1233
  // WU-1920: Use changed tests by default, full suite with --full-tests
1186
1234
  // WU-2244: --full-coverage implies --full-tests for accurate coverage
1187
1235
  // WU-1280: When tests_required=false (methodology.testing: none), failures only warn
1236
+ // WU-1356: Use configured test command instead of hard-coded turbo
1188
1237
  {
1189
1238
  name: GATE_NAMES.TEST,
1190
1239
  cmd: isFullTests || isFullCoverage
1191
- ? pnpmCmd('turbo', 'run', 'test')
1240
+ ? configuredGatesCommands.test_full
1192
1241
  : GATE_COMMANDS.INCREMENTAL_TEST,
1193
1242
  warnOnly: !testsRequired,
1194
1243
  },
package/dist/init.js CHANGED
@@ -1909,6 +1909,8 @@ export async function scaffoldProject(targetDir, options) {
1909
1909
  // Create .lumenflow/agents directory with .gitkeep
1910
1910
  await createDirectory(path.join(targetDir, LUMENFLOW_AGENTS_DIR), result, targetDir);
1911
1911
  await createFile(path.join(targetDir, LUMENFLOW_AGENTS_DIR, '.gitkeep'), '', options.force ? 'force' : 'skip', result, targetDir);
1912
+ // WU-1342: Create .gitignore with required exclusions
1913
+ await scaffoldGitignore(targetDir, options, result);
1912
1914
  // Optional: full docs scaffolding
1913
1915
  if (options.full) {
1914
1916
  await scaffoldFullDocs(targetDir, options, result, tokenDefaults);
@@ -1925,18 +1927,106 @@ export async function scaffoldProject(targetDir, options) {
1925
1927
  }
1926
1928
  return result;
1927
1929
  }
1930
+ /**
1931
+ * WU-1342: .gitignore template with required exclusions
1932
+ * Includes node_modules, .lumenflow/state, and worktrees
1933
+ */
1934
+ const GITIGNORE_TEMPLATE = `# Dependencies
1935
+ node_modules/
1936
+
1937
+ # LumenFlow state (local only, not shared)
1938
+ .lumenflow/state/
1939
+
1940
+ # Worktrees (isolated parallel work directories)
1941
+ worktrees/
1942
+
1943
+ # Build output
1944
+ dist/
1945
+ *.tsbuildinfo
1946
+
1947
+ # Environment files
1948
+ .env
1949
+ .env.local
1950
+ .env.*.local
1951
+
1952
+ # IDE
1953
+ .idea/
1954
+ .vscode/
1955
+ *.swp
1956
+ *.swo
1957
+
1958
+ # OS files
1959
+ .DS_Store
1960
+ Thumbs.db
1961
+ `;
1962
+ /** Gitignore file name constant to avoid duplicate string lint error */
1963
+ const GITIGNORE_FILE_NAME = '.gitignore';
1964
+ /**
1965
+ * WU-1342: Scaffold .gitignore file with LumenFlow exclusions
1966
+ * Supports merge mode to add exclusions to existing .gitignore
1967
+ */
1968
+ async function scaffoldGitignore(targetDir, options, result) {
1969
+ const gitignorePath = path.join(targetDir, GITIGNORE_FILE_NAME);
1970
+ const fileMode = getFileMode(options);
1971
+ if (fileMode === 'merge' && fs.existsSync(gitignorePath)) {
1972
+ // Merge mode: append LumenFlow exclusions if not already present
1973
+ const existingContent = fs.readFileSync(gitignorePath, 'utf-8');
1974
+ const linesToAdd = [];
1975
+ // Check each required exclusion
1976
+ const requiredExclusions = [
1977
+ { pattern: 'node_modules', line: 'node_modules/' },
1978
+ { pattern: '.lumenflow/state', line: '.lumenflow/state/' },
1979
+ { pattern: 'worktrees', line: 'worktrees/' },
1980
+ ];
1981
+ for (const { pattern, line } of requiredExclusions) {
1982
+ if (!existingContent.includes(pattern)) {
1983
+ linesToAdd.push(line);
1984
+ }
1985
+ }
1986
+ if (linesToAdd.length > 0) {
1987
+ const separator = existingContent.endsWith('\n') ? '' : '\n';
1988
+ const lumenflowBlock = `${separator}
1989
+ # LumenFlow (auto-added)
1990
+ ${linesToAdd.join('\n')}
1991
+ `;
1992
+ fs.writeFileSync(gitignorePath, existingContent + lumenflowBlock);
1993
+ result.merged?.push(GITIGNORE_FILE_NAME);
1994
+ }
1995
+ else {
1996
+ result.skipped.push(GITIGNORE_FILE_NAME);
1997
+ }
1998
+ return;
1999
+ }
2000
+ // Skip or force mode
2001
+ await createFile(gitignorePath, GITIGNORE_TEMPLATE, fileMode, result, targetDir);
2002
+ }
1928
2003
  /**
1929
2004
  * WU-1307: LumenFlow scripts to inject into package.json
2005
+ * WU-1342: Expanded to include all 17 essential commands
1930
2006
  * Uses standalone binaries (wu-claim, wu-done, gates) that work in consumer projects
1931
2007
  * after installing @lumenflow/cli.
1932
2008
  */
1933
2009
  const LUMENFLOW_SCRIPTS = {
2010
+ // Core WU lifecycle
1934
2011
  'wu:claim': 'wu-claim',
1935
2012
  'wu:done': 'wu-done',
1936
2013
  'wu:create': 'wu-create',
1937
2014
  'wu:status': 'wu-status',
1938
2015
  'wu:block': 'wu-block',
1939
2016
  'wu:unblock': 'wu-unblock',
2017
+ // WU-1342: Additional critical commands
2018
+ 'wu:prep': 'wu-prep',
2019
+ 'wu:recover': 'wu-recover',
2020
+ 'wu:spawn': 'wu-spawn',
2021
+ 'wu:validate': 'wu-validate',
2022
+ 'wu:infer-lane': 'wu-infer-lane',
2023
+ // Memory commands
2024
+ 'mem:init': 'mem-init',
2025
+ 'mem:checkpoint': 'mem-checkpoint',
2026
+ 'mem:inbox': 'mem-inbox',
2027
+ // Lane commands
2028
+ 'lane:suggest': 'lane-suggest',
2029
+ // Gates
1940
2030
  gates: 'gates',
1941
2031
  'gates:docs': 'gates --docs-only',
1942
2032
  };
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-console -- CLI tool requires console output */
2
3
  /**
3
4
  * Orchestrate Initiative Status CLI
4
5
  *
@@ -10,10 +11,14 @@
10
11
  */
11
12
  import { Command } from 'commander';
12
13
  import { existsSync, readdirSync } from 'node:fs';
13
- import { loadInitiativeWUs, calculateProgress, formatProgress } from '@lumenflow/initiatives';
14
+ import { loadInitiativeWUs, calculateProgress, formatProgress, getLaneAvailability, resolveLaneConfigsFromConfig, } from '@lumenflow/initiatives';
14
15
  import { EXIT_CODES, LUMENFLOW_PATHS } from '@lumenflow/core/dist/wu-constants.js';
16
+ import { getConfig } from '@lumenflow/core/dist/lumenflow-config.js';
15
17
  import chalk from 'chalk';
16
18
  const LOG_PREFIX = '[orchestrate:init-status]';
19
+ function getErrorMessage(error) {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
17
22
  function getCompletedWUs(wuIds) {
18
23
  const completed = new Set();
19
24
  if (!existsSync(LUMENFLOW_PATHS.STAMPS_DIR)) {
@@ -27,6 +32,21 @@ function getCompletedWUs(wuIds) {
27
32
  }
28
33
  return completed;
29
34
  }
35
+ function formatLaneAvailability(availability, laneConfigs) {
36
+ const lanes = Object.keys(availability).sort((a, b) => a.localeCompare(b));
37
+ if (lanes.length === 0) {
38
+ return ' (no lanes found)';
39
+ }
40
+ return lanes
41
+ .map((lane) => {
42
+ const entry = availability[lane];
43
+ const wipLimit = laneConfigs[lane]?.wip_limit ?? 1;
44
+ const statusLabel = entry.available ? chalk.green('available') : chalk.red('occupied');
45
+ const occupiedBy = entry.occupiedBy ?? 'none';
46
+ return ` ${lane}: ${statusLabel} (wip_limit=${wipLimit}, lock_policy=${entry.policy}, in_progress=${entry.inProgressCount}, blocked=${entry.blockedCount}, occupied_by=${occupiedBy})`;
47
+ })
48
+ .join('\n');
49
+ }
30
50
  const program = new Command()
31
51
  .name('orchestrate:init-status')
32
52
  .description('Show initiative progress status')
@@ -45,18 +65,26 @@ const program = new Command()
45
65
  const completed = getCompletedWUs(wus.map((w) => w.id));
46
66
  console.log(chalk.bold('WUs:'));
47
67
  for (const wu of wus) {
48
- const status = completed.has(wu.id)
49
- ? chalk.green('✓ done')
50
- : wu.doc.status === 'in_progress'
51
- ? chalk.yellow('⟳ in_progress')
52
- : wu.doc.status === 'blocked'
53
- ? chalk.red(' blocked')
54
- : chalk.gray('○ ready');
68
+ let status = chalk.gray('○ ready');
69
+ if (completed.has(wu.id)) {
70
+ status = chalk.green('✓ done');
71
+ }
72
+ else if (wu.doc.status === 'in_progress') {
73
+ status = chalk.yellow(' in_progress');
74
+ }
75
+ else if (wu.doc.status === 'blocked') {
76
+ status = chalk.red('⛔ blocked');
77
+ }
55
78
  console.log(` ${wu.id}: ${wu.doc.title} [${status}]`);
56
79
  }
80
+ const laneConfigs = resolveLaneConfigsFromConfig(getConfig());
81
+ const availability = getLaneAvailability(wus, { laneConfigs });
82
+ console.log('');
83
+ console.log(chalk.bold('Lane Availability:'));
84
+ console.log(formatLaneAvailability(availability, laneConfigs));
57
85
  }
58
86
  catch (err) {
59
- console.error(chalk.red(`${LOG_PREFIX} Error: ${err.message}`));
87
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${getErrorMessage(err)}`));
60
88
  process.exit(EXIT_CODES.ERROR);
61
89
  }
62
90
  });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-console -- CLI tool requires console output */
2
3
  /**
3
4
  * Orchestrate Initiative CLI
4
5
  *
@@ -11,8 +12,12 @@
11
12
  */
12
13
  import { Command } from 'commander';
13
14
  import chalk from 'chalk';
14
- import { loadInitiativeWUs, loadMultipleInitiatives, buildExecutionPlanAsync, formatExecutionPlan, formatExecutionPlanWithEmbeddedSpawns, calculateProgress, formatProgress, buildCheckpointWave, formatCheckpointOutput, validateCheckpointFlags, resolveCheckpointModeAsync, LOG_PREFIX, } from '@lumenflow/initiatives';
15
+ import { loadInitiativeWUs, loadMultipleInitiatives, buildExecutionPlanWithLockPolicy, resolveLaneConfigsFromConfig, formatExecutionPlan, formatExecutionPlanWithEmbeddedSpawns, calculateProgress, formatProgress, buildCheckpointWave, formatCheckpointOutput, validateCheckpointFlags, resolveCheckpointModeAsync, LOG_PREFIX, } from '@lumenflow/initiatives';
15
16
  import { EXIT_CODES } from '@lumenflow/core/dist/wu-constants.js';
17
+ import { getConfig } from '@lumenflow/core/dist/lumenflow-config.js';
18
+ function getErrorMessage(error) {
19
+ return error instanceof Error ? error.message : String(error);
20
+ }
16
21
  const program = new Command()
17
22
  .name('orchestrate-initiative')
18
23
  .description('Orchestrate initiative execution with parallel agent spawning')
@@ -28,7 +33,7 @@ const program = new Command()
28
33
  validateCheckpointFlags({ checkpointPerWave, dryRun, noCheckpoint });
29
34
  }
30
35
  catch (error) {
31
- console.error(chalk.red(`${LOG_PREFIX} Error: ${error.message}`));
36
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${getErrorMessage(error)}`));
32
37
  process.exit(EXIT_CODES.ERROR);
33
38
  }
34
39
  if (!initIds || initIds.length === 0) {
@@ -76,7 +81,8 @@ const program = new Command()
76
81
  return;
77
82
  }
78
83
  console.log(chalk.cyan(`${LOG_PREFIX} Building execution plan...`));
79
- const plan = await buildExecutionPlanAsync(wus);
84
+ const laneConfigs = resolveLaneConfigsFromConfig(getConfig());
85
+ const plan = buildExecutionPlanWithLockPolicy(wus, { laneConfigs });
80
86
  if (plan.waves.length === 0) {
81
87
  console.log(chalk.green(`${LOG_PREFIX} All WUs are complete! Nothing to execute.`));
82
88
  return;
@@ -99,7 +105,7 @@ const program = new Command()
99
105
  console.log(chalk.cyan('Copy the spawn XML above to execute agents.'));
100
106
  }
101
107
  catch (error) {
102
- console.error(chalk.red(`${LOG_PREFIX} Error: ${error.message}`));
108
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${getErrorMessage(error)}`));
103
109
  process.exit(EXIT_CODES.ERROR);
104
110
  }
105
111
  });
@@ -8,9 +8,16 @@
8
8
  * - Claude skills -> templates/vendors/claude/.claude/skills/
9
9
  * - Core docs (LUMENFLOW.md, constraints.md) -> templates/core/
10
10
  */
11
+ /* eslint-disable no-console -- CLI tool requires console output */
12
+ /* eslint-disable security/detect-non-literal-fs-filename -- CLI tool syncs templates from known paths */
13
+ /* eslint-disable security/detect-non-literal-regexp -- Dynamic date pattern for template substitution */
11
14
  import * as fs from 'node:fs';
12
15
  import * as path from 'node:path';
13
16
  import { createWUParser } from '@lumenflow/core';
17
+ // Directory name constants to avoid duplicate strings
18
+ const LUMENFLOW_DIR = '.lumenflow';
19
+ const CLAUDE_DIR = '.claude';
20
+ const SKILLS_DIR = 'skills';
14
21
  // Template variable patterns
15
22
  const DATE_PATTERN = /\d{4}-\d{2}-\d{2}/g;
16
23
  /**
@@ -29,6 +36,12 @@ const SYNC_TEMPLATES_OPTIONS = {
29
36
  description: 'Show detailed output',
30
37
  default: false,
31
38
  },
39
+ checkDrift: {
40
+ name: 'check-drift',
41
+ flags: '--check-drift',
42
+ description: 'Check for template drift without syncing (CI mode)',
43
+ default: false,
44
+ },
32
45
  };
33
46
  /**
34
47
  * Parse sync-templates command options
@@ -42,6 +55,7 @@ export function parseSyncTemplatesOptions() {
42
55
  return {
43
56
  dryRun: opts['dry-run'] ?? false,
44
57
  verbose: opts.verbose ?? false,
58
+ checkDrift: opts['check-drift'] ?? false,
45
59
  };
46
60
  }
47
61
  /**
@@ -119,8 +133,8 @@ export async function syncOnboardingDocs(projectRoot, dryRun = false) {
119
133
  */
120
134
  export async function syncSkillsToTemplates(projectRoot, dryRun = false) {
121
135
  const result = { synced: [], errors: [] };
122
- const sourceDir = path.join(projectRoot, '.claude', 'skills');
123
- const targetDir = path.join(getTemplatesDir(projectRoot), 'vendors', 'claude', '.claude', 'skills');
136
+ const sourceDir = path.join(projectRoot, CLAUDE_DIR, SKILLS_DIR);
137
+ const targetDir = path.join(getTemplatesDir(projectRoot), 'vendors', 'claude', CLAUDE_DIR, SKILLS_DIR);
124
138
  if (!fs.existsSync(sourceDir)) {
125
139
  result.errors.push(`Skills source directory not found: ${sourceDir}`);
126
140
  return result;
@@ -153,8 +167,8 @@ export async function syncCoreDocs(projectRoot, dryRun = false) {
153
167
  const lumenflowTarget = path.join(templatesDir, 'core', 'LUMENFLOW.md.template');
154
168
  syncFile(lumenflowSource, lumenflowTarget, projectRoot, result, dryRun);
155
169
  // Sync constraints.md
156
- const constraintsSource = path.join(projectRoot, '.lumenflow', 'constraints.md');
157
- const constraintsTarget = path.join(templatesDir, 'core', '.lumenflow', 'constraints.md.template');
170
+ const constraintsSource = path.join(projectRoot, LUMENFLOW_DIR, 'constraints.md');
171
+ const constraintsTarget = path.join(templatesDir, 'core', LUMENFLOW_DIR, 'constraints.md.template');
158
172
  syncFile(constraintsSource, constraintsTarget, projectRoot, result, dryRun);
159
173
  return result;
160
174
  }
@@ -167,12 +181,130 @@ export async function syncTemplates(projectRoot, dryRun = false) {
167
181
  const core = await syncCoreDocs(projectRoot, dryRun);
168
182
  return { onboarding, skills, core };
169
183
  }
184
+ /**
185
+ * Compare source file content with template content (ignoring date placeholders)
186
+ */
187
+ function compareContent(sourceContent, templateContent, projectRoot) {
188
+ // Convert source to template format for comparison
189
+ const convertedSource = convertToTemplate(sourceContent, projectRoot);
190
+ return convertedSource === templateContent;
191
+ }
192
+ /**
193
+ * Check if a single template file is in sync with its source
194
+ */
195
+ function checkFileDrift(sourcePath, templatePath, projectRoot) {
196
+ const relativePath = path.relative(projectRoot, templatePath);
197
+ if (!fs.existsSync(sourcePath)) {
198
+ return { isDrifting: false, relativePath }; // Source doesn't exist, can't drift
199
+ }
200
+ if (!fs.existsSync(templatePath)) {
201
+ return { isDrifting: true, relativePath }; // Template missing, definitely drifting
202
+ }
203
+ const sourceContent = fs.readFileSync(sourcePath, 'utf-8');
204
+ const templateContent = fs.readFileSync(templatePath, 'utf-8');
205
+ const isDrifting = !compareContent(sourceContent, templateContent, projectRoot);
206
+ return { isDrifting, relativePath };
207
+ }
208
+ /**
209
+ * Check for template drift - compares source docs with templates (WU-1353)
210
+ *
211
+ * This function compares source documents with their template counterparts
212
+ * to detect if templates have drifted out of sync. Used by CI to warn
213
+ * when templates need to be re-synced.
214
+ */
215
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- Multi-category drift check requires nested iteration
216
+ export async function checkTemplateDrift(projectRoot) {
217
+ const driftingFiles = [];
218
+ const checkedFiles = [];
219
+ const templatesDir = getTemplatesDir(projectRoot);
220
+ // Check core docs
221
+ const coreChecks = [
222
+ {
223
+ source: path.join(projectRoot, 'LUMENFLOW.md'),
224
+ template: path.join(templatesDir, 'core', 'LUMENFLOW.md.template'),
225
+ },
226
+ {
227
+ source: path.join(projectRoot, LUMENFLOW_DIR, 'constraints.md'),
228
+ template: path.join(templatesDir, 'core', LUMENFLOW_DIR, 'constraints.md.template'),
229
+ },
230
+ ];
231
+ for (const check of coreChecks) {
232
+ const result = checkFileDrift(check.source, check.template, projectRoot);
233
+ checkedFiles.push(result.relativePath);
234
+ if (result.isDrifting) {
235
+ driftingFiles.push(result.relativePath);
236
+ }
237
+ }
238
+ // Check onboarding docs
239
+ const onboardingSourceDir = path.join(projectRoot, 'docs', '04-operations', '_frameworks', 'lumenflow', 'agent', 'onboarding');
240
+ const onboardingTargetDir = path.join(templatesDir, 'core', 'ai', 'onboarding');
241
+ if (fs.existsSync(onboardingSourceDir)) {
242
+ const files = fs.readdirSync(onboardingSourceDir).filter((f) => f.endsWith('.md'));
243
+ for (const file of files) {
244
+ const sourcePath = path.join(onboardingSourceDir, file);
245
+ const templatePath = path.join(onboardingTargetDir, `${file}.template`);
246
+ const result = checkFileDrift(sourcePath, templatePath, projectRoot);
247
+ checkedFiles.push(result.relativePath);
248
+ if (result.isDrifting) {
249
+ driftingFiles.push(result.relativePath);
250
+ }
251
+ }
252
+ }
253
+ // Check skills
254
+ const skillsSourceDir = path.join(projectRoot, CLAUDE_DIR, SKILLS_DIR);
255
+ const skillsTargetDir = path.join(templatesDir, 'vendors', 'claude', CLAUDE_DIR, SKILLS_DIR);
256
+ if (fs.existsSync(skillsSourceDir)) {
257
+ const skillDirs = fs
258
+ .readdirSync(skillsSourceDir, { withFileTypes: true })
259
+ .filter((d) => d.isDirectory())
260
+ .map((d) => d.name);
261
+ for (const skillName of skillDirs) {
262
+ const skillFile = path.join(skillsSourceDir, skillName, 'SKILL.md');
263
+ const templatePath = path.join(skillsTargetDir, skillName, 'SKILL.md.template');
264
+ if (fs.existsSync(skillFile)) {
265
+ const result = checkFileDrift(skillFile, templatePath, projectRoot);
266
+ checkedFiles.push(result.relativePath);
267
+ if (result.isDrifting) {
268
+ driftingFiles.push(result.relativePath);
269
+ }
270
+ }
271
+ }
272
+ }
273
+ return {
274
+ hasDrift: driftingFiles.length > 0,
275
+ driftingFiles,
276
+ checkedFiles,
277
+ };
278
+ }
170
279
  /**
171
280
  * CLI entry point
172
281
  */
282
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- CLI main() handles multiple modes and output formatting
173
283
  export async function main() {
174
284
  const opts = parseSyncTemplatesOptions();
175
285
  const projectRoot = process.cwd();
286
+ // Check-drift mode: verify templates match source without syncing
287
+ if (opts.checkDrift) {
288
+ console.log('[sync-templates] Checking for template drift...');
289
+ const drift = await checkTemplateDrift(projectRoot);
290
+ if (opts.verbose) {
291
+ console.log(` Checked ${drift.checkedFiles.length} files`);
292
+ }
293
+ if (drift.hasDrift) {
294
+ console.log('\n[sync-templates] WARNING: Template drift detected!');
295
+ console.log(' The following templates are out of sync with their source:');
296
+ for (const file of drift.driftingFiles) {
297
+ console.log(` - ${file}`);
298
+ }
299
+ console.log('\n Run `pnpm sync:templates` to update templates.');
300
+ process.exitCode = 1;
301
+ }
302
+ else {
303
+ console.log('[sync-templates] All templates are in sync.');
304
+ }
305
+ return;
306
+ }
307
+ // Sync mode: update templates from source
176
308
  console.log('[sync-templates] Syncing internal docs to CLI templates...');
177
309
  if (opts.dryRun) {
178
310
  console.log(' (dry-run mode - no files will be written)');
@@ -208,5 +340,5 @@ export async function main() {
208
340
  // CLI entry point
209
341
  import { runCLI } from './cli-entry-point.js';
210
342
  if (import.meta.main) {
211
- runCLI(main);
343
+ void runCLI(main);
212
344
  }