@lumenflow/cli 1.0.0 → 1.1.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.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrate Initiative CLI
4
+ *
5
+ * Orchestrate initiative execution with parallel agent spawning.
6
+ * Builds execution plan based on WU dependencies and manages wave-based execution.
7
+ *
8
+ * Usage:
9
+ * pnpm orchestrate:initiative --initiative INIT-001
10
+ * pnpm orchestrate:initiative --initiative INIT-001 --dry-run
11
+ */
12
+ import { Command } from 'commander';
13
+ import chalk from 'chalk';
14
+ import { loadInitiativeWUs, loadMultipleInitiatives, buildExecutionPlan, formatExecutionPlan, calculateProgress, formatProgress, buildCheckpointWave, formatCheckpointOutput, validateCheckpointFlags, resolveCheckpointMode, LOG_PREFIX, } from '@lumenflow/initiatives';
15
+ import { EXIT_CODES } from '@lumenflow/core/dist/wu-constants.js';
16
+ const program = new Command()
17
+ .name('orchestrate-initiative')
18
+ .description('Orchestrate initiative execution with parallel agent spawning')
19
+ .option('-i, --initiative <ids...>', 'Initiative ID(s) to orchestrate')
20
+ .option('-d, --dry-run', 'Show execution plan without spawning agents')
21
+ .option('-p, --progress', 'Show current progress only')
22
+ .option('-c, --checkpoint-per-wave', 'Spawn next wave then exit (no polling)')
23
+ .option('--no-checkpoint', 'Force polling mode')
24
+ .action(async (options) => {
25
+ const { initiative: initIds, dryRun, progress: progressOnly, checkpointPerWave, checkpoint, } = options;
26
+ const noCheckpoint = checkpoint === false;
27
+ try {
28
+ validateCheckpointFlags({ checkpointPerWave, dryRun, noCheckpoint });
29
+ }
30
+ catch (error) {
31
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${error.message}`));
32
+ process.exit(EXIT_CODES.ERROR);
33
+ }
34
+ if (!initIds || initIds.length === 0) {
35
+ console.error(chalk.red(`${LOG_PREFIX} Error: --initiative is required`));
36
+ console.error('');
37
+ console.error('Usage:');
38
+ console.error(' pnpm orchestrate:initiative --initiative INIT-001');
39
+ console.error(' pnpm orchestrate:initiative --initiative INIT-001 --dry-run');
40
+ process.exit(EXIT_CODES.ERROR);
41
+ }
42
+ try {
43
+ console.log(chalk.cyan(`${LOG_PREFIX} Loading initiative(s): ${initIds.join(', ')}`));
44
+ let wus;
45
+ let initiative;
46
+ if (initIds.length === 1) {
47
+ const result = loadInitiativeWUs(initIds[0]);
48
+ initiative = result.initiative;
49
+ wus = result.wus;
50
+ }
51
+ else {
52
+ wus = loadMultipleInitiatives(initIds);
53
+ initiative = { id: 'MULTI', title: `Combined: ${initIds.join(', ')}` };
54
+ }
55
+ console.log(chalk.green(`${LOG_PREFIX} Loaded ${wus.length} WU(s)`));
56
+ console.log('');
57
+ const progress = calculateProgress(wus);
58
+ console.log(chalk.bold('Progress:'));
59
+ console.log(formatProgress(progress));
60
+ console.log('');
61
+ if (progressOnly) {
62
+ return;
63
+ }
64
+ const checkpointDecision = resolveCheckpointMode({ checkpointPerWave, noCheckpoint, dryRun }, wus);
65
+ if (checkpointDecision.enabled) {
66
+ if (initIds.length > 1) {
67
+ console.error(chalk.red(`${LOG_PREFIX} Error: Checkpoint mode only supports single initiative`));
68
+ process.exit(EXIT_CODES.ERROR);
69
+ }
70
+ const waveData = buildCheckpointWave(initIds[0], { dryRun });
71
+ if (!waveData) {
72
+ console.log(chalk.green(`${LOG_PREFIX} All WUs are complete! Nothing to spawn.`));
73
+ return;
74
+ }
75
+ console.log(formatCheckpointOutput({ ...waveData, dryRun }));
76
+ return;
77
+ }
78
+ console.log(chalk.cyan(`${LOG_PREFIX} Building execution plan...`));
79
+ const plan = buildExecutionPlan(wus);
80
+ if (plan.waves.length === 0) {
81
+ console.log(chalk.green(`${LOG_PREFIX} All WUs are complete! Nothing to execute.`));
82
+ return;
83
+ }
84
+ console.log('');
85
+ console.log(chalk.bold('Execution Plan:'));
86
+ console.log(formatExecutionPlan(initiative, plan));
87
+ if (dryRun) {
88
+ console.log(chalk.yellow(`${LOG_PREFIX} Dry run mode - no agents spawned`));
89
+ console.log(chalk.cyan('To execute this plan, remove the --dry-run flag.'));
90
+ return;
91
+ }
92
+ console.log(chalk.green(`${LOG_PREFIX} Execution plan output complete.`));
93
+ console.log(chalk.cyan('Copy the spawn commands above to execute.'));
94
+ }
95
+ catch (error) {
96
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${error.message}`));
97
+ process.exit(EXIT_CODES.ERROR);
98
+ }
99
+ });
100
+ program.parse();
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrate Monitor CLI
4
+ *
5
+ * Monitors spawned agent progress using mem:inbox signals.
6
+ * Designed to prevent context exhaustion by using compact signal output.
7
+ *
8
+ * Usage:
9
+ * pnpm orchestrate:monitor --since 30m
10
+ */
11
+ import { Command } from 'commander';
12
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { EXIT_CODES } from '@lumenflow/core/dist/wu-constants.js';
15
+ import chalk from 'chalk';
16
+ import ms from 'ms';
17
+ const LOG_PREFIX = '[orchestrate:monitor]';
18
+ const MEMORY_DIR = '.beacon/memory';
19
+ function parseTimeString(timeStr) {
20
+ const msValue = ms(timeStr);
21
+ if (typeof msValue === 'number') {
22
+ return new Date(Date.now() - msValue);
23
+ }
24
+ const date = new Date(timeStr);
25
+ if (isNaN(date.getTime())) {
26
+ throw new Error(`Invalid time format: ${timeStr}`);
27
+ }
28
+ return date;
29
+ }
30
+ function loadRecentSignals(since) {
31
+ const signals = [];
32
+ if (!existsSync(MEMORY_DIR)) {
33
+ return signals;
34
+ }
35
+ const files = readdirSync(MEMORY_DIR).filter((f) => f.endsWith('.ndjson'));
36
+ for (const file of files) {
37
+ const filePath = join(MEMORY_DIR, file);
38
+ const content = readFileSync(filePath, 'utf-8');
39
+ const lines = content.trim().split('\n').filter(Boolean);
40
+ for (const line of lines) {
41
+ try {
42
+ const signal = JSON.parse(line);
43
+ const signalTime = new Date(signal.timestamp);
44
+ if (signalTime >= since) {
45
+ signals.push(signal);
46
+ }
47
+ }
48
+ catch {
49
+ // Skip malformed lines
50
+ }
51
+ }
52
+ }
53
+ return signals.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
54
+ }
55
+ const program = new Command()
56
+ .name('orchestrate:monitor')
57
+ .description('Monitor spawned agent progress')
58
+ .option('--since <time>', 'Show signals since (e.g., 30m, 1h)', '30m')
59
+ .option('--wu <id>', 'Filter by WU ID')
60
+ .action((opts) => {
61
+ try {
62
+ const since = parseTimeString(opts.since);
63
+ console.log(chalk.cyan(`${LOG_PREFIX} Loading signals since ${since.toISOString()}...`));
64
+ const signals = loadRecentSignals(since);
65
+ if (signals.length === 0) {
66
+ console.log(chalk.yellow(`${LOG_PREFIX} No signals found.`));
67
+ console.log(chalk.gray('Agents may still be starting up, or memory layer not initialized.'));
68
+ return;
69
+ }
70
+ const filtered = opts.wu ? signals.filter((s) => s.wuId === opts.wu) : signals;
71
+ console.log(chalk.bold(`\nRecent Signals (${filtered.length}):\n`));
72
+ for (const signal of filtered) {
73
+ const time = new Date(signal.timestamp).toLocaleTimeString();
74
+ const wu = signal.wuId ? chalk.cyan(signal.wuId) : chalk.gray('system');
75
+ const type = signal.type === 'complete'
76
+ ? chalk.green(signal.type)
77
+ : signal.type === 'error'
78
+ ? chalk.red(signal.type)
79
+ : chalk.yellow(signal.type);
80
+ console.log(` ${chalk.gray(time)} [${wu}] ${type}: ${signal.message || ''}`);
81
+ }
82
+ console.log('');
83
+ console.log(chalk.gray(`Use: pnpm mem:inbox --since ${opts.since} for more details`));
84
+ }
85
+ catch (err) {
86
+ console.error(chalk.red(`${LOG_PREFIX} Error: ${err.message}`));
87
+ process.exit(EXIT_CODES.ERROR);
88
+ }
89
+ });
90
+ program.parse();
package/dist/wu-claim.js CHANGED
@@ -507,7 +507,10 @@ function validateLaneFormatWithError(lane) {
507
507
  }
508
508
  }
509
509
  /**
510
- * Handle lane occupancy check and enforce WIP=1 policy
510
+ * Handle lane occupancy check and enforce WIP limit policy
511
+ *
512
+ * WU-1016: Updated to support configurable WIP limits per lane.
513
+ * The WIP limit is read from .lumenflow.config.yaml and defaults to 1.
511
514
  */
512
515
  function handleLaneOccupancy(laneCheck, lane, id, force) {
513
516
  if (laneCheck.free)
@@ -517,18 +520,26 @@ function handleLaneOccupancy(laneCheck, lane, id, force) {
517
520
  }
518
521
  if (!laneCheck.occupiedBy)
519
522
  return;
523
+ // WU-1016: Include WIP limit info in messages
524
+ const wipLimit = laneCheck.wipLimit ?? 1;
525
+ const currentCount = laneCheck.currentCount ?? 0;
526
+ const inProgressList = laneCheck.inProgressWUs?.join(', ') || laneCheck.occupiedBy;
520
527
  if (force) {
521
- console.warn(`${PREFIX} ⚠️ WARNING: Lane "${lane}" is occupied by ${laneCheck.occupiedBy}`);
522
- console.warn(`${PREFIX} ⚠️ Forcing WIP=2 in same lane. Risk of worktree collision!`);
528
+ console.warn(`${PREFIX} ⚠️ WARNING: Lane "${lane}" has ${currentCount}/${wipLimit} WUs in progress`);
529
+ console.warn(`${PREFIX} ⚠️ In progress: ${inProgressList}`);
530
+ console.warn(`${PREFIX} ⚠️ Forcing WIP limit override. Risk of worktree collision!`);
523
531
  console.warn(`${PREFIX} ⚠️ Use only for P0 emergencies or manual recovery.`);
524
532
  return;
525
533
  }
526
- die(`Lane "${lane}" is already occupied by ${laneCheck.occupiedBy}.\n\n` +
527
- `LumenFlow enforces one-WU-per-lane to maintain focus.\n\n` +
534
+ die(`Lane "${lane}" is at WIP limit (${currentCount}/${wipLimit}).\n\n` +
535
+ `In progress: ${inProgressList}\n\n` +
536
+ `LumenFlow enforces WIP limits per lane to maintain focus.\n` +
537
+ `Current limit for "${lane}": ${wipLimit} (configure in .lumenflow.config.yaml)\n\n` +
528
538
  `Options:\n` +
529
- ` 1. Wait for ${laneCheck.occupiedBy} to complete or block\n` +
539
+ ` 1. Wait for a WU to complete or block\n` +
530
540
  ` 2. Choose a different lane\n` +
531
- ` 3. Use --force to override (P0 emergencies only)\n\n` +
541
+ ` 3. Increase wip_limit in .lumenflow.config.yaml\n` +
542
+ ` 4. Use --force to override (P0 emergencies only)\n\n` +
532
543
  `To check lane status: grep "${STATUS_SECTIONS.IN_PROGRESS}" docs/04-operations/tasks/status.md`);
533
544
  }
534
545
  /**
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WU Delete Helper
4
+ *
5
+ * Race-safe WU deletion using micro-worktree isolation.
6
+ *
7
+ * Uses micro-worktree pattern:
8
+ * 1) Validate inputs (WU exists, status is not in_progress)
9
+ * 2) Ensure main is clean and up-to-date with origin
10
+ * 3) Create temp branch WITHOUT switching (main checkout stays on main)
11
+ * 4) Create micro-worktree in /tmp pointing to temp branch
12
+ * 5) Delete WU file and update backlog.md in micro-worktree
13
+ * 6) Commit, ff-only merge, push
14
+ * 7) Cleanup temp branch and micro-worktree
15
+ *
16
+ * Usage:
17
+ * pnpm wu:delete --id WU-123 # Single WU deletion
18
+ * pnpm wu:delete --id WU-123 --dry-run # Dry run
19
+ * pnpm wu:delete --batch WU-1,WU-2,WU-3 # Batch deletion
20
+ */
21
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
24
+ import { die } from '@lumenflow/core/dist/error-handler.js';
25
+ import { parseYAML } from '@lumenflow/core/dist/wu-yaml.js';
26
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
27
+ import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
28
+ import { FILE_SYSTEM, EXIT_CODES, MICRO_WORKTREE_OPERATIONS, LOG_PREFIX, WU_STATUS, } from '@lumenflow/core/dist/wu-constants.js';
29
+ import { ensureOnMain, ensureMainUpToDate, validateWUIDFormat, } from '@lumenflow/core/dist/wu-helpers.js';
30
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
31
+ const PREFIX = LOG_PREFIX.DELETE || '[wu:delete]';
32
+ const DELETE_OPTIONS = {
33
+ dryRun: {
34
+ name: 'dryRun',
35
+ flags: '--dry-run',
36
+ description: 'Show what would be deleted without making changes',
37
+ },
38
+ batch: {
39
+ name: 'batch',
40
+ flags: '--batch <ids>',
41
+ description: 'Delete multiple WUs atomically (comma-separated: WU-1,WU-2,WU-3)',
42
+ },
43
+ };
44
+ function parseArgs() {
45
+ return createWUParser({
46
+ name: 'wu-delete',
47
+ description: 'Safely delete WU YAML files with micro-worktree isolation',
48
+ options: [WU_OPTIONS.id, DELETE_OPTIONS.dryRun, DELETE_OPTIONS.batch],
49
+ required: [],
50
+ allowPositionalId: true,
51
+ });
52
+ }
53
+ function parseBatchIds(batchArg) {
54
+ return batchArg
55
+ .split(',')
56
+ .map((id) => id.trim().toUpperCase())
57
+ .filter((id) => id.length > 0);
58
+ }
59
+ function validateWUDeletable(id) {
60
+ const wuPath = WU_PATHS.WU(id);
61
+ if (!existsSync(wuPath)) {
62
+ die(`WU ${id} not found at ${wuPath}\n\nEnsure the WU exists and you're in the repo root.`);
63
+ }
64
+ const content = readFileSync(wuPath, FILE_SYSTEM.ENCODING);
65
+ const wu = parseYAML(content);
66
+ if (wu.status === WU_STATUS.IN_PROGRESS) {
67
+ die(`Cannot delete WU ${id}: status is '${WU_STATUS.IN_PROGRESS}'.\n\n` +
68
+ `WUs that are actively being worked on cannot be deleted.\n` +
69
+ `If the WU was abandoned, first run: pnpm wu:block --id ${id} --reason "Abandoned"\n` +
70
+ `Then retry the delete operation.`);
71
+ }
72
+ return { wu, wuPath };
73
+ }
74
+ async function ensureCleanWorkingTree() {
75
+ const status = await getGitForCwd().getStatus();
76
+ if (status.trim()) {
77
+ die(`Working tree is not clean. Cannot delete WU.\n\nUncommitted changes:\n${status}\n\nCommit or stash changes before deleting:\n git add . && git commit -m "..."\n`);
78
+ }
79
+ }
80
+ function getStampPath(id) {
81
+ return join(WU_PATHS.STAMPS_DIR(), `${id}.done`);
82
+ }
83
+ function stampExists(id) {
84
+ return existsSync(getStampPath(id));
85
+ }
86
+ function removeFromBacklog(backlogPath, id) {
87
+ if (!existsSync(backlogPath)) {
88
+ return false;
89
+ }
90
+ const content = readFileSync(backlogPath, FILE_SYSTEM.ENCODING);
91
+ const wuLinkPattern = new RegExp(`^.*\\[${id}[^\\]]*\\].*$`, 'gmi');
92
+ const wuSimplePattern = new RegExp(`^.*${id}.*\\.yaml.*$`, 'gmi');
93
+ let updated = content.replace(wuLinkPattern, '');
94
+ updated = updated.replace(wuSimplePattern, '');
95
+ updated = updated.replace(/\n{3,}/g, '\n\n');
96
+ if (updated !== content) {
97
+ writeFileSync(backlogPath, updated, FILE_SYSTEM.ENCODING);
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+ async function deleteSingleWU(id, dryRun) {
103
+ console.log(`${PREFIX} Starting WU delete for ${id}`);
104
+ validateWUIDFormat(id);
105
+ const { wu, wuPath } = validateWUDeletable(id);
106
+ console.log(`${PREFIX} WU details:`);
107
+ console.log(`${PREFIX} Title: ${wu.title}`);
108
+ console.log(`${PREFIX} Lane: ${wu.lane}`);
109
+ console.log(`${PREFIX} Status: ${wu.status}`);
110
+ console.log(`${PREFIX} Path: ${wuPath}`);
111
+ if (dryRun) {
112
+ console.log(`\n${PREFIX} 🔍 DRY RUN: Would delete ${id}`);
113
+ console.log(`${PREFIX} - Delete file: ${wuPath}`);
114
+ console.log(`${PREFIX} - Update: ${WU_PATHS.BACKLOG()}`);
115
+ if (stampExists(id)) {
116
+ console.log(`${PREFIX} - Delete stamp: ${getStampPath(id)}`);
117
+ }
118
+ console.log(`${PREFIX} No changes made.`);
119
+ process.exit(EXIT_CODES.SUCCESS);
120
+ }
121
+ await ensureOnMain(getGitForCwd());
122
+ await ensureCleanWorkingTree();
123
+ await ensureMainUpToDate(getGitForCwd(), 'wu:delete');
124
+ console.log(`${PREFIX} Deleting via micro-worktree...`);
125
+ await withMicroWorktree({
126
+ operation: MICRO_WORKTREE_OPERATIONS.WU_DELETE,
127
+ id: id,
128
+ logPrefix: PREFIX,
129
+ execute: async ({ worktreePath, gitWorktree }) => {
130
+ const wuFilePath = join(worktreePath, wuPath);
131
+ const backlogFilePath = join(worktreePath, WU_PATHS.BACKLOG());
132
+ unlinkSync(wuFilePath);
133
+ console.log(`${PREFIX} ✅ Deleted ${id}.yaml`);
134
+ const stampPath = join(worktreePath, getStampPath(id));
135
+ if (existsSync(stampPath)) {
136
+ unlinkSync(stampPath);
137
+ console.log(`${PREFIX} ✅ Deleted stamp ${id}.done`);
138
+ }
139
+ const removedFromBacklog = removeFromBacklog(backlogFilePath, id);
140
+ if (removedFromBacklog) {
141
+ console.log(`${PREFIX} ✅ Removed ${id} from backlog.md`);
142
+ }
143
+ else {
144
+ console.log(`${PREFIX} ℹ️ ${id} was not found in backlog.md`);
145
+ }
146
+ await gitWorktree.add('.');
147
+ const commitMessage = `docs: delete ${id.toLowerCase()}`;
148
+ return {
149
+ commitMessage,
150
+ files: [],
151
+ };
152
+ },
153
+ });
154
+ console.log(`${PREFIX} ✅ Successfully deleted ${id}`);
155
+ console.log(`${PREFIX} Changes pushed to origin/main`);
156
+ }
157
+ async function deleteBatchWUs(ids, dryRun) {
158
+ console.log(`${PREFIX} Starting batch delete for ${ids.length} WU(s): ${ids.join(', ')}`);
159
+ const wusToDelete = [];
160
+ const stampsToDelete = [];
161
+ for (const id of ids) {
162
+ validateWUIDFormat(id);
163
+ const { wu, wuPath } = validateWUDeletable(id);
164
+ wusToDelete.push({ id, wu, wuPath });
165
+ if (stampExists(id)) {
166
+ stampsToDelete.push(id);
167
+ }
168
+ }
169
+ console.log(`${PREFIX} WUs to delete:`);
170
+ for (const { id, wu, wuPath } of wusToDelete) {
171
+ console.log(`${PREFIX} ${id}: ${wu.title} (${wu.status}) - ${wuPath}`);
172
+ }
173
+ if (dryRun) {
174
+ console.log(`\n${PREFIX} 🔍 DRY RUN: Would delete ${ids.length} WU(s)`);
175
+ console.log(`${PREFIX} No changes made.`);
176
+ process.exit(EXIT_CODES.SUCCESS);
177
+ }
178
+ await ensureOnMain(getGitForCwd());
179
+ await ensureCleanWorkingTree();
180
+ await ensureMainUpToDate(getGitForCwd(), 'wu:delete --batch');
181
+ console.log(`${PREFIX} Deleting ${ids.length} WU(s) via micro-worktree...`);
182
+ await withMicroWorktree({
183
+ operation: MICRO_WORKTREE_OPERATIONS.WU_DELETE,
184
+ id: `batch-${ids.length}`,
185
+ logPrefix: PREFIX,
186
+ execute: async ({ worktreePath, gitWorktree }) => {
187
+ const backlogFilePath = join(worktreePath, WU_PATHS.BACKLOG());
188
+ for (const { id, wuPath } of wusToDelete) {
189
+ const wuFilePath = join(worktreePath, wuPath);
190
+ unlinkSync(wuFilePath);
191
+ console.log(`${PREFIX} ✅ Deleted ${id}.yaml`);
192
+ }
193
+ for (const id of stampsToDelete) {
194
+ const stampPath = join(worktreePath, getStampPath(id));
195
+ if (existsSync(stampPath)) {
196
+ unlinkSync(stampPath);
197
+ console.log(`${PREFIX} ✅ Deleted stamp ${id}.done`);
198
+ }
199
+ }
200
+ for (const { id } of wusToDelete) {
201
+ const removed = removeFromBacklog(backlogFilePath, id);
202
+ if (removed) {
203
+ console.log(`${PREFIX} ✅ Removed ${id} from backlog.md`);
204
+ }
205
+ }
206
+ await gitWorktree.add('.');
207
+ const idList = ids.map((id) => id.toLowerCase()).join(', ');
208
+ const commitMessage = `chore(repair): delete ${ids.length} orphaned wus (${idList})`;
209
+ return {
210
+ commitMessage,
211
+ files: [],
212
+ };
213
+ },
214
+ });
215
+ console.log(`${PREFIX} ✅ Successfully deleted ${ids.length} WU(s)`);
216
+ console.log(`${PREFIX} Changes pushed to origin/main`);
217
+ }
218
+ async function main() {
219
+ const opts = parseArgs();
220
+ const { id, dryRun, batch } = opts;
221
+ if (!id && !batch) {
222
+ die('Must specify either --id WU-XXX or --batch WU-1,WU-2,WU-3');
223
+ }
224
+ if (id && batch) {
225
+ die('Cannot use both --id and --batch. Use one or the other.');
226
+ }
227
+ if (batch) {
228
+ const ids = parseBatchIds(batch);
229
+ if (ids.length === 0) {
230
+ die('--batch requires at least one WU ID');
231
+ }
232
+ await deleteBatchWUs(ids, dryRun);
233
+ }
234
+ else {
235
+ await deleteSingleWU(id, dryRun);
236
+ }
237
+ }
238
+ main().catch((err) => {
239
+ console.error(`${PREFIX} ❌ ${err.message}`);
240
+ process.exit(EXIT_CODES.ERROR);
241
+ });
package/dist/wu-done.js CHANGED
@@ -174,6 +174,77 @@ export function validateAccessibilityOrDie(wu, options = {}) {
174
174
  `This gate prevents "orphaned code" - features that exist but users cannot access.`);
175
175
  }
176
176
  }
177
+ export function validateDocsOnlyFlag(wu, args) {
178
+ // If --docs-only flag is not used, no validation needed
179
+ if (!args.docsOnly) {
180
+ return { valid: true, errors: [] };
181
+ }
182
+ const wuId = wu.id || 'unknown';
183
+ const exposure = wu.exposure;
184
+ const type = wu.type;
185
+ const codePaths = wu.code_paths;
186
+ // Check 1: exposure is 'documentation'
187
+ if (exposure === 'documentation') {
188
+ return { valid: true, errors: [] };
189
+ }
190
+ // Check 2: type is 'documentation'
191
+ if (type === 'documentation') {
192
+ return { valid: true, errors: [] };
193
+ }
194
+ // Check 3: all code_paths are documentation paths
195
+ const DOCS_ONLY_PREFIXES = ['docs/', 'ai/', '.claude/', 'memory-bank/'];
196
+ const DOCS_ONLY_ROOT_FILES = ['readme', 'claude'];
197
+ const isDocsPath = (p) => {
198
+ const path = p.trim().toLowerCase();
199
+ // Check docs prefixes
200
+ for (const prefix of DOCS_ONLY_PREFIXES) {
201
+ if (path.startsWith(prefix))
202
+ return true;
203
+ }
204
+ // Check markdown files
205
+ if (path.endsWith('.md'))
206
+ return true;
207
+ // Check root file patterns
208
+ for (const pattern of DOCS_ONLY_ROOT_FILES) {
209
+ if (path.startsWith(pattern))
210
+ return true;
211
+ }
212
+ return false;
213
+ };
214
+ if (codePaths && Array.isArray(codePaths) && codePaths.length > 0) {
215
+ const allDocsOnly = codePaths.every((p) => typeof p === 'string' && isDocsPath(p));
216
+ if (allDocsOnly) {
217
+ return { valid: true, errors: [] };
218
+ }
219
+ }
220
+ // Validation failed - provide clear error message
221
+ const currentExposure = exposure || 'not set';
222
+ const currentType = type || 'not set';
223
+ return {
224
+ valid: false,
225
+ errors: [
226
+ `--docs-only flag used on ${wuId} but WU is not documentation-focused.\n\n` +
227
+ `Current exposure: ${currentExposure}\n` +
228
+ `Current type: ${currentType}\n\n` +
229
+ `--docs-only requires one of:\n` +
230
+ ` 1. exposure: documentation\n` +
231
+ ` 2. type: documentation\n` +
232
+ ` 3. All code_paths under docs/, ai/, .claude/, or *.md files\n\n` +
233
+ `To fix, either:\n` +
234
+ ` - Remove --docs-only flag and run full gates\n` +
235
+ ` - Change WU exposure to 'documentation' if this is truly a docs-only change`,
236
+ ],
237
+ };
238
+ }
239
+ export function buildGatesCommand(options) {
240
+ const { docsOnly = false, isDocsOnly = false } = options;
241
+ // Use docs-only gates if either explicit flag or auto-detected
242
+ const shouldUseDocsOnly = docsOnly || isDocsOnly;
243
+ if (shouldUseDocsOnly) {
244
+ return `${PKG_MANAGER} ${SCRIPTS.GATES} -- ${CLI_FLAGS.DOCS_ONLY}`;
245
+ }
246
+ return `${PKG_MANAGER} ${SCRIPTS.GATES}`;
247
+ }
177
248
  async function assertWorktreeWUInProgressInStateStore(id, worktreePath) {
178
249
  const resolvedWorktreePath = path.resolve(worktreePath);
179
250
  const stateDir = path.join(resolvedWorktreePath, '.beacon', 'state');
@@ -810,15 +881,27 @@ function checkNodeModulesStaleness(worktreePath) {
810
881
  console.warn(`${LOG_PREFIX.DONE} Could not check node_modules staleness: ${e.message}`);
811
882
  }
812
883
  }
813
- function runGatesInWorktree(worktreePath, id, isDocsOnly = false) {
884
+ /**
885
+ * Run gates in worktree
886
+ * @param {string} worktreePath - Path to worktree
887
+ * @param {string} id - WU ID
888
+ * @param {object} options - Gates options
889
+ * @param {boolean} options.isDocsOnly - Auto-detected docs-only from code_paths
890
+ * @param {boolean} options.docsOnly - Explicit --docs-only flag from CLI
891
+ */
892
+ function runGatesInWorktree(worktreePath, id, options = {}) {
893
+ const { isDocsOnly = false, docsOnly = false } = options;
814
894
  console.log(`\n${LOG_PREFIX.DONE} Running gates in worktree: ${worktreePath}`);
815
895
  // Check for stale node_modules before running gates (prevents confusing failures)
816
896
  checkNodeModulesStaleness(worktreePath);
817
- const gatesCmd = isDocsOnly
818
- ? `${PKG_MANAGER} ${SCRIPTS.GATES} -- ${CLI_FLAGS.DOCS_ONLY}`
819
- : `${PKG_MANAGER} ${SCRIPTS.GATES}`;
820
- if (isDocsOnly) {
897
+ // WU-1012: Use docs-only gates if explicit --docs-only flag OR auto-detected
898
+ const useDocsOnlyGates = docsOnly || isDocsOnly;
899
+ const gatesCmd = buildGatesCommand({ docsOnly, isDocsOnly });
900
+ if (useDocsOnlyGates) {
821
901
  console.log(`${LOG_PREFIX.DONE} Using docs-only gates (skipping lint/typecheck/tests)`);
902
+ if (docsOnly) {
903
+ console.log(`${LOG_PREFIX.DONE} (explicit --docs-only flag)`);
904
+ }
822
905
  }
823
906
  const startTime = Date.now();
824
907
  try {
@@ -1544,6 +1627,11 @@ async function executePreFlightChecks({ id, args, isBranchOnly, isDocsOnly, docM
1544
1627
  console.warn(`\nThis is a NON-BLOCKING warning.`);
1545
1628
  console.warn(`Use --require-agents to make this a blocking error.\n`);
1546
1629
  }
1630
+ // WU-1012: Validate --docs-only flag usage (BLOCKING)
1631
+ const docsOnlyValidation = validateDocsOnlyFlag(docForValidation, { docsOnly: args.docsOnly });
1632
+ if (!docsOnlyValidation.valid) {
1633
+ die(docsOnlyValidation.errors[0]);
1634
+ }
1547
1635
  // WU-1999: Exposure validation (NON-BLOCKING warning)
1548
1636
  printExposureWarnings(docForValidation, { skipExposureCheck: args.skipExposureCheck });
1549
1637
  // WU-2022: Feature accessibility validation (BLOCKING)
@@ -1656,11 +1744,14 @@ async function executeGates({ id, args, isBranchOnly, isDocsOnly, worktreePath,
1656
1744
  else if (isBranchOnly) {
1657
1745
  // Branch-Only mode: run gates in-place (current directory on lane branch)
1658
1746
  console.log(`\n${LOG_PREFIX.DONE} Running gates in Branch-Only mode (in-place on lane branch)`);
1659
- const gatesCmd = isDocsOnly
1660
- ? `${PKG_MANAGER} ${SCRIPTS.GATES} -- ${CLI_FLAGS.DOCS_ONLY}`
1661
- : `${PKG_MANAGER} ${SCRIPTS.GATES}`;
1662
- if (isDocsOnly) {
1747
+ // WU-1012: Use docs-only gates if explicit --docs-only flag OR auto-detected
1748
+ const useDocsOnlyGates = args.docsOnly || isDocsOnly;
1749
+ const gatesCmd = buildGatesCommand({ docsOnly: Boolean(args.docsOnly), isDocsOnly });
1750
+ if (useDocsOnlyGates) {
1663
1751
  console.log(`${LOG_PREFIX.DONE} Using docs-only gates (skipping lint/typecheck/tests)`);
1752
+ if (args.docsOnly) {
1753
+ console.log(`${LOG_PREFIX.DONE} (explicit --docs-only flag)`);
1754
+ }
1664
1755
  }
1665
1756
  const startTime = Date.now();
1666
1757
  try {
@@ -1697,7 +1788,8 @@ async function executeGates({ id, args, isBranchOnly, isDocsOnly, worktreePath,
1697
1788
  }
1698
1789
  else if (worktreePath && existsSync(worktreePath)) {
1699
1790
  // Worktree mode: run gates in the dedicated worktree
1700
- runGatesInWorktree(worktreePath, id, isDocsOnly);
1791
+ // WU-1012: Pass both auto-detected and explicit docs-only flags
1792
+ runGatesInWorktree(worktreePath, id, { isDocsOnly, docsOnly: Boolean(args.docsOnly) });
1701
1793
  }
1702
1794
  else {
1703
1795
  die(`Worktree not found (${worktreePath || 'unknown'}). Gates must run in the lane worktree.\n` +