@lumenflow/core 2.4.0 → 2.5.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.
@@ -32,6 +32,13 @@ export declare const WU_OPTIONS: Record<string, WUOption>;
32
32
  * These options control how wu:create handles external plan storage.
33
33
  */
34
34
  export declare const WU_CREATE_OPTIONS: Record<string, WUOption>;
35
+ /**
36
+ * Negated options that commander handles specially.
37
+ * --no-foo creates opts.foo = false. We convert to noFoo = true.
38
+ *
39
+ * WU-1329: Export for testing purposes.
40
+ */
41
+ export declare const NEGATED_OPTIONS: string[];
35
42
  /**
36
43
  * Create a commander-based CLI parser for a WU script.
37
44
  *
@@ -438,6 +438,14 @@ export const WU_OPTIONS = {
438
438
  flags: '--skip-setup',
439
439
  description: 'Skip automatic pnpm install in worktree after creation (faster claims when deps already built)',
440
440
  },
441
+ // WU-1329: Strict validation options
442
+ // NOTE: --no-strict is the opt-out flag; strict is the default behavior
443
+ noStrict: {
444
+ name: 'noStrict',
445
+ flags: '--no-strict',
446
+ description: 'Bypass strict validation (skip code_paths/test_paths existence checks, treat warnings as advisory). Logged when used.',
447
+ isNegated: true,
448
+ },
441
449
  };
442
450
  /**
443
451
  * WU-1062: Additional options for wu:create command
@@ -458,8 +466,10 @@ export const WU_CREATE_OPTIONS = {
458
466
  /**
459
467
  * Negated options that commander handles specially.
460
468
  * --no-foo creates opts.foo = false. We convert to noFoo = true.
469
+ *
470
+ * WU-1329: Export for testing purposes.
461
471
  */
462
- const NEGATED_OPTIONS = ['auto', 'remove', 'merge', 'autoRebase', 'push'];
472
+ export const NEGATED_OPTIONS = ['auto', 'remove', 'merge', 'autoRebase', 'push', 'strict'];
463
473
  /**
464
474
  * Post-process commander opts to handle negated boolean options.
465
475
  * Commander's --no-* flags create opts.foo = false.
@@ -6,6 +6,7 @@
6
6
  * Used by wu-claim.ts and wu-unblock.ts to prevent WIP violations.
7
7
  */
8
8
  import { getSubLanesForParent } from './lane-inference.js';
9
+ import type { LockPolicy } from './lumenflow-config-schema.js';
9
10
  interface ValidateLaneOptions {
10
11
  strict?: boolean;
11
12
  }
@@ -61,13 +62,39 @@ export declare function validateLaneFormat(lane: string, configPath?: string | n
61
62
  * @returns {number} The WIP limit for the lane (default: 1)
62
63
  */
63
64
  export declare function getWipLimitForLane(lane: string, options?: GetWipLimitOptions): number;
65
+ /** WU-1325: Options for getLockPolicyForLane */
66
+ interface GetLockPolicyOptions {
67
+ /** Path to .lumenflow.config.yaml (for testing) */
68
+ configPath?: string;
69
+ }
64
70
  /**
65
- * Check if a lane is free (in_progress WU count is below wip_limit)
71
+ * WU-1325: Get lock policy for a lane from config
72
+ *
73
+ * Reads the lock_policy field from .lumenflow.config.yaml for the specified lane.
74
+ * Returns DEFAULT_LOCK_POLICY ('all') if the lane is not found or lock_policy is not specified.
75
+ *
76
+ * Lock policies:
77
+ * - 'all' (default): Lock held through entire WU lifecycle (claim to done)
78
+ * - 'active': Lock released on block, re-acquired on unblock
79
+ * - 'none': No lock files created, WIP checking disabled for this lane
80
+ *
81
+ * @param {string} lane - Lane name (e.g., "Framework: Core", "Content: Documentation")
82
+ * @param {GetLockPolicyOptions} options - Options including configPath for testing
83
+ * @returns {LockPolicy} The lock policy for the lane (default: 'all')
84
+ */
85
+ export declare function getLockPolicyForLane(lane: string, options?: GetLockPolicyOptions): LockPolicy;
86
+ /**
87
+ * Check if a lane is free (WU count is below wip_limit)
66
88
  *
67
89
  * WU-1016: Now respects configurable wip_limit per lane from .lumenflow.config.yaml.
68
- * Lane is considered "free" if current in_progress count < wip_limit.
90
+ * Lane is considered "free" if current WU count < wip_limit.
69
91
  * Default wip_limit is 1 if not specified in config (backward compatible).
70
92
  *
93
+ * WU-1324: Now respects lock_policy for WIP counting:
94
+ * - 'all' (default): Count in_progress + blocked WUs toward WIP limit
95
+ * - 'active': Count only in_progress WUs (blocked WUs release lane lock)
96
+ * - 'none': Disable WIP checking entirely (lane always free)
97
+ *
71
98
  * @param {string} statusPath - Path to status.md
72
99
  * @param {string} lane - Lane name (e.g., "Operations", "Intelligence")
73
100
  * @param {string} wuid - WU ID being claimed (e.g., "WU-419")
@@ -35,6 +35,15 @@ export function extractParent(lane) {
35
35
  // Sub-lane format: extract parent before colon
36
36
  return trimmed.substring(0, colonIndex).trim();
37
37
  }
38
+ /**
39
+ * WU-1308: Check if lane-inference.yaml file exists
40
+ * @returns {boolean} True if the file exists
41
+ */
42
+ function laneInferenceFileExists() {
43
+ const projectRoot = findProjectRoot();
44
+ const taxonomyPath = path.join(projectRoot, CONFIG_FILES.LANE_INFERENCE);
45
+ return existsSync(taxonomyPath);
46
+ }
38
47
  /**
39
48
  * Check if a parent lane has sub-lane taxonomy defined
40
49
  * @param {string} parent - Parent lane name
@@ -141,6 +150,17 @@ function validateSubLaneFormat(lane, trimmed, colonIndex, configPath) {
141
150
  if (!isValidParentLane(parent, configPath)) {
142
151
  throw createError(ErrorCodes.INVALID_LANE, `Unknown parent lane: "${parent}". Check ${CONFIG_FILES.LUMENFLOW_CONFIG} for valid lanes.`, { parent, lane });
143
152
  }
153
+ // WU-1308: Check if lane-inference file exists before validating sub-lanes
154
+ // This provides a clear error message when the file is missing
155
+ if (!laneInferenceFileExists()) {
156
+ throw createError(ErrorCodes.FILE_NOT_FOUND, `Sub-lane validation requires ${CONFIG_FILES.LANE_INFERENCE} which is missing.\n\n` +
157
+ `The file "${CONFIG_FILES.LANE_INFERENCE}" defines the lane taxonomy for sub-lane validation.\n\n` +
158
+ `To fix this:\n` +
159
+ ` 1. Generate a lane taxonomy from your codebase:\n` +
160
+ ` pnpm lane:suggest --output ${CONFIG_FILES.LANE_INFERENCE}\n\n` +
161
+ ` 2. Or copy from an example project and customize.\n\n` +
162
+ `See: LUMENFLOW.md "Setup Notes" section for details.`, { lane, parent, subdomain, missingFile: CONFIG_FILES.LANE_INFERENCE });
163
+ }
144
164
  // Validate sub-lane exists in taxonomy
145
165
  if (hasSubLaneTaxonomy(parent)) {
146
166
  validateSubLaneInTaxonomy(parent, subdomain);
@@ -342,6 +362,78 @@ export function getWipLimitForLane(lane, options = {}) {
342
362
  return DEFAULT_WIP_LIMIT;
343
363
  }
344
364
  }
365
+ /** WU-1325: Default lock policy when not specified in config */
366
+ const DEFAULT_LOCK_POLICY = 'all';
367
+ /**
368
+ * WU-1325: Get lock policy for a lane from config
369
+ *
370
+ * Reads the lock_policy field from .lumenflow.config.yaml for the specified lane.
371
+ * Returns DEFAULT_LOCK_POLICY ('all') if the lane is not found or lock_policy is not specified.
372
+ *
373
+ * Lock policies:
374
+ * - 'all' (default): Lock held through entire WU lifecycle (claim to done)
375
+ * - 'active': Lock released on block, re-acquired on unblock
376
+ * - 'none': No lock files created, WIP checking disabled for this lane
377
+ *
378
+ * @param {string} lane - Lane name (e.g., "Framework: Core", "Content: Documentation")
379
+ * @param {GetLockPolicyOptions} options - Options including configPath for testing
380
+ * @returns {LockPolicy} The lock policy for the lane (default: 'all')
381
+ */
382
+ export function getLockPolicyForLane(lane, options = {}) {
383
+ // Determine config path
384
+ let resolvedConfigPath = options.configPath;
385
+ if (!resolvedConfigPath) {
386
+ const projectRoot = findProjectRoot();
387
+ resolvedConfigPath = path.join(projectRoot, CONFIG_FILES.LUMENFLOW_CONFIG);
388
+ }
389
+ // Check if config file exists
390
+ if (!existsSync(resolvedConfigPath)) {
391
+ return DEFAULT_LOCK_POLICY;
392
+ }
393
+ try {
394
+ const configContent = readFileSync(resolvedConfigPath, { encoding: 'utf-8' });
395
+ const config = parseYAML(configContent);
396
+ if (!config.lanes) {
397
+ return DEFAULT_LOCK_POLICY;
398
+ }
399
+ // Normalize lane name for case-insensitive comparison
400
+ const normalizedLane = lane.toLowerCase().trim();
401
+ // Extract all lanes with their lock_policy
402
+ let allLanes = [];
403
+ if (Array.isArray(config.lanes)) {
404
+ // Flat array format: lanes: [{name: "Core", lock_policy: "none"}, ...]
405
+ allLanes = config.lanes;
406
+ }
407
+ else {
408
+ // New format with definitions
409
+ if (config.lanes.definitions) {
410
+ allLanes.push(...config.lanes.definitions);
411
+ }
412
+ // Legacy nested format: lanes: {engineering: [...], business: [...]}
413
+ if (config.lanes.engineering) {
414
+ allLanes.push(...config.lanes.engineering);
415
+ }
416
+ if (config.lanes.business) {
417
+ allLanes.push(...config.lanes.business);
418
+ }
419
+ }
420
+ // Find matching lane (case-insensitive)
421
+ const matchingLane = allLanes.find((l) => l.name.toLowerCase().trim() === normalizedLane);
422
+ if (!matchingLane) {
423
+ return DEFAULT_LOCK_POLICY;
424
+ }
425
+ // Return lock_policy if specified and valid, otherwise default
426
+ const policy = matchingLane.lock_policy;
427
+ if (policy === 'all' || policy === 'active' || policy === 'none') {
428
+ return policy;
429
+ }
430
+ return DEFAULT_LOCK_POLICY;
431
+ }
432
+ catch {
433
+ // If config parsing fails, return default
434
+ return DEFAULT_LOCK_POLICY;
435
+ }
436
+ }
345
437
  /** WU-1197: Section heading marker for H2 headings */
346
438
  const SECTION_HEADING_PREFIX = '## ';
347
439
  /**
@@ -379,6 +471,38 @@ function extractInProgressSection(lines) {
379
471
  const section = lines.slice(inProgressIdx + 1, endIdx).join(STRING_LITERALS.NEWLINE);
380
472
  return { section, error: null };
381
473
  }
474
+ /** WU-1324: Blocked section header patterns */
475
+ const BLOCKED_HEADERS = ['## blocked', '## ⛔ blocked'];
476
+ /**
477
+ * WU-1324: Check if a line matches a Blocked section header.
478
+ * @param {string} line - Line to check (will be trimmed and lowercased)
479
+ * @returns {boolean} True if line is a Blocked header
480
+ */
481
+ function isBlockedHeader(line) {
482
+ const normalized = line.trim().toLowerCase();
483
+ return BLOCKED_HEADERS.some((header) => normalized === header || normalized.startsWith(header));
484
+ }
485
+ /**
486
+ * WU-1324: Extract Blocked section from status.md lines
487
+ * @returns Section content (may be empty if section doesn't exist)
488
+ */
489
+ function extractBlockedSection(lines) {
490
+ const blockedIdx = lines.findIndex((l) => isBlockedHeader(l));
491
+ if (blockedIdx === -1) {
492
+ // Blocked section doesn't exist - return empty
493
+ return { section: '' };
494
+ }
495
+ // Find end of Blocked section (next ## heading or end of file)
496
+ let endIdx = lines.slice(blockedIdx + 1).findIndex((l) => l.startsWith(SECTION_HEADING_PREFIX));
497
+ if (endIdx === -1) {
498
+ endIdx = lines.length - blockedIdx - 1;
499
+ }
500
+ else {
501
+ endIdx = blockedIdx + 1 + endIdx;
502
+ }
503
+ const section = lines.slice(blockedIdx + 1, endIdx).join(STRING_LITERALS.NEWLINE);
504
+ return { section };
505
+ }
382
506
  /**
383
507
  * WU-1197: Check if a WU belongs to the target lane
384
508
  * @returns The WU ID if it matches the target lane, null otherwise
@@ -430,12 +554,37 @@ function collectInProgressWUsForLane(matches, wuid, projectRoot, targetLane) {
430
554
  return inProgressWUs;
431
555
  }
432
556
  /**
433
- * Check if a lane is free (in_progress WU count is below wip_limit)
557
+ * WU-1324: Extract WU IDs from a section's WU links and filter by target lane
558
+ * @param section - Section content from status.md
559
+ * @param wuid - WU ID being claimed (excluded from results)
560
+ * @param projectRoot - Project root path
561
+ * @param targetLane - Target lane name (normalized lowercase)
562
+ * @returns Array of WU IDs in the target lane
563
+ */
564
+ function extractWUsFromSection(section, wuid, projectRoot, targetLane) {
565
+ if (!section || section.includes(NO_ITEMS_MARKER)) {
566
+ return [];
567
+ }
568
+ // Extract WU IDs from links like [WU-334 — Title](wu/WU-334.yaml)
569
+ WU_LINK_PATTERN.lastIndex = 0; // Reset global regex state
570
+ const matches = [...section.matchAll(WU_LINK_PATTERN)];
571
+ if (matches.length === 0) {
572
+ return [];
573
+ }
574
+ return collectInProgressWUsForLane(matches, wuid, projectRoot, targetLane);
575
+ }
576
+ /**
577
+ * Check if a lane is free (WU count is below wip_limit)
434
578
  *
435
579
  * WU-1016: Now respects configurable wip_limit per lane from .lumenflow.config.yaml.
436
- * Lane is considered "free" if current in_progress count < wip_limit.
580
+ * Lane is considered "free" if current WU count < wip_limit.
437
581
  * Default wip_limit is 1 if not specified in config (backward compatible).
438
582
  *
583
+ * WU-1324: Now respects lock_policy for WIP counting:
584
+ * - 'all' (default): Count in_progress + blocked WUs toward WIP limit
585
+ * - 'active': Count only in_progress WUs (blocked WUs release lane lock)
586
+ * - 'none': Disable WIP checking entirely (lane always free)
587
+ *
439
588
  * @param {string} statusPath - Path to status.md
440
589
  * @param {string} lane - Lane name (e.g., "Operations", "Intelligence")
441
590
  * @param {string} wuid - WU ID being claimed (e.g., "WU-419")
@@ -444,40 +593,47 @@ function collectInProgressWUsForLane(matches, wuid, projectRoot, targetLane) {
444
593
  */
445
594
  export function checkLaneFree(statusPath, lane, wuid, options = {}) {
446
595
  try {
596
+ // WU-1016: Get WIP limit for this lane from config
597
+ const wipLimit = getWipLimitForLane(lane, { configPath: options.configPath });
598
+ // WU-1324: Get lock policy for this lane from config
599
+ const lockPolicy = getLockPolicyForLane(lane, { configPath: options.configPath });
600
+ // WU-1324: If policy is 'none', WIP checking is disabled - lane is always free
601
+ if (lockPolicy === 'none') {
602
+ return createEmptyLaneResult(wipLimit);
603
+ }
447
604
  // Read status.md
448
605
  if (!existsSync(statusPath)) {
449
606
  return { free: false, occupiedBy: null, error: `status.md not found: ${statusPath}` };
450
607
  }
451
608
  const content = readFileSync(statusPath, { encoding: 'utf-8' });
452
609
  const lines = content.split(/\r?\n/);
453
- const { section, error } = extractInProgressSection(lines);
610
+ // Extract In Progress section
611
+ const { section: inProgressSection, error } = extractInProgressSection(lines);
454
612
  if (error) {
455
613
  return { free: false, occupiedBy: null, error };
456
614
  }
457
- // WU-1016: Get WIP limit for this lane from config
458
- const wipLimit = getWipLimitForLane(lane, { configPath: options.configPath });
459
- // Check for "No items" marker or no WU links
460
- if (section.includes(NO_ITEMS_MARKER)) {
461
- return createEmptyLaneResult(wipLimit);
462
- }
463
- // Extract WU IDs from links like [WU-334 — Title](wu/WU-334.yaml)
464
- WU_LINK_PATTERN.lastIndex = 0; // Reset global regex state
465
- const matches = [...section.matchAll(WU_LINK_PATTERN)];
466
- if (matches.length === 0) {
467
- return createEmptyLaneResult(wipLimit);
468
- }
469
615
  // Get project root from statusPath (docs/04-operations/tasks/status.md)
470
616
  const projectRoot = path.dirname(path.dirname(path.dirname(path.dirname(statusPath))));
471
617
  const targetLane = lane.toString().trim().toLowerCase();
472
- const inProgressWUs = collectInProgressWUsForLane(matches, wuid, projectRoot, targetLane);
473
- // WU-1016: Check if lane is free based on WIP limit
474
- const currentCount = inProgressWUs.length;
618
+ // Collect in_progress WUs
619
+ const inProgressWUs = extractWUsFromSection(inProgressSection, wuid, projectRoot, targetLane);
620
+ // WU-1324: If policy is 'all', also count blocked WUs
621
+ let blockedWUs = [];
622
+ if (lockPolicy === 'all') {
623
+ const { section: blockedSection } = extractBlockedSection(lines);
624
+ blockedWUs = extractWUsFromSection(blockedSection, wuid, projectRoot, targetLane);
625
+ }
626
+ // WU-1324: Calculate total count based on policy
627
+ // - 'all': in_progress + blocked
628
+ // - 'active': in_progress only
629
+ const allCountedWUs = [...inProgressWUs, ...blockedWUs];
630
+ const currentCount = allCountedWUs.length;
475
631
  const isFree = currentCount < wipLimit;
476
632
  return {
477
633
  free: isFree,
478
- occupiedBy: isFree ? null : inProgressWUs[0] || null,
634
+ occupiedBy: isFree ? null : allCountedWUs[0] || null,
479
635
  error: null,
480
- inProgressWUs,
636
+ inProgressWUs: allCountedWUs, // Include all counted WUs for visibility
481
637
  wipLimit,
482
638
  currentCount,
483
639
  };
@@ -12,7 +12,13 @@
12
12
  * Lock file location: .lumenflow/locks/<lane-kebab>.lock
13
13
  * Lock file format: JSON with wuId, timestamp, agentSession, pid
14
14
  *
15
+ * Lock policy support (WU-1323):
16
+ * - 'all' (default): Lock held through entire WU lifecycle
17
+ * - 'active': Lock released on block, re-acquired on unblock (CLI behavior)
18
+ * - 'none': No lock files created, WIP checking disabled for the lane
19
+ *
15
20
  * @see WU-1603 - Race condition fix for wu:claim
21
+ * @see WU-1323 - Lock policy integration tests
16
22
  */
17
23
  export interface LockMetadata {
18
24
  wuId: string;
@@ -26,6 +32,8 @@ interface LockResult {
26
32
  error: string | null;
27
33
  existingLock: LockMetadata | null;
28
34
  isStale: boolean;
35
+ /** WU-1325: True if lock acquisition was skipped due to lock_policy=none */
36
+ skipped?: boolean;
29
37
  }
30
38
  interface UnlockResult {
31
39
  released: boolean;
package/dist/lane-lock.js CHANGED
@@ -12,11 +12,19 @@
12
12
  * Lock file location: .lumenflow/locks/<lane-kebab>.lock
13
13
  * Lock file format: JSON with wuId, timestamp, agentSession, pid
14
14
  *
15
+ * Lock policy support (WU-1323):
16
+ * - 'all' (default): Lock held through entire WU lifecycle
17
+ * - 'active': Lock released on block, re-acquired on unblock (CLI behavior)
18
+ * - 'none': No lock files created, WIP checking disabled for the lane
19
+ *
15
20
  * @see WU-1603 - Race condition fix for wu:claim
21
+ * @see WU-1323 - Lock policy integration tests
16
22
  */
17
23
  import { openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync, } from 'node:fs';
18
24
  import path from 'node:path';
19
25
  import { toKebab, LUMENFLOW_PATHS, getProjectRoot } from './wu-constants.js';
26
+ // WU-1325: Import lock policy getter
27
+ import { getLockPolicyForLane } from './lane-checker.js';
20
28
  /** Log prefix for lane-lock messages */
21
29
  const LOG_PREFIX = '[lane-lock]';
22
30
  /** Directory for lock files relative to project root */
@@ -169,6 +177,19 @@ export function readLockMetadata(lockPath) {
169
177
  // eslint-disable-next-line sonarjs/cognitive-complexity -- WU-1808: Added zombie lock detection increases complexity but all paths are necessary
170
178
  export function acquireLaneLock(lane, wuId, options = {}) {
171
179
  const { agentSession = null, baseDir = null } = options;
180
+ // WU-1325: Check lock policy before acquiring
181
+ const lockPolicy = getLockPolicyForLane(lane);
182
+ if (lockPolicy === 'none') {
183
+ // eslint-disable-next-line no-console -- CLI tool status message
184
+ console.log(`${LOG_PREFIX} Skipping lock acquisition for "${lane}" (lock_policy=none)`);
185
+ return {
186
+ acquired: true,
187
+ error: null,
188
+ existingLock: null,
189
+ isStale: false,
190
+ skipped: true,
191
+ };
192
+ }
172
193
  try {
173
194
  ensureLocksDir(baseDir);
174
195
  const lockPath = getLockFilePath(lane, baseDir);