@lumenflow/cli 1.6.0 → 2.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.
Files changed (42) hide show
  1. package/README.md +19 -0
  2. package/dist/__tests__/backlog-prune.test.js +478 -0
  3. package/dist/__tests__/deps-operations.test.js +206 -0
  4. package/dist/__tests__/file-operations.test.js +906 -0
  5. package/dist/__tests__/git-operations.test.js +668 -0
  6. package/dist/__tests__/guards-validation.test.js +416 -0
  7. package/dist/__tests__/init-plan.test.js +340 -0
  8. package/dist/__tests__/lumenflow-upgrade.test.js +107 -0
  9. package/dist/__tests__/metrics-cli.test.js +619 -0
  10. package/dist/__tests__/rotate-progress.test.js +127 -0
  11. package/dist/__tests__/session-coordinator.test.js +109 -0
  12. package/dist/__tests__/state-bootstrap.test.js +432 -0
  13. package/dist/__tests__/trace-gen.test.js +115 -0
  14. package/dist/backlog-prune.js +299 -0
  15. package/dist/deps-add.js +215 -0
  16. package/dist/deps-remove.js +94 -0
  17. package/dist/docs-sync.js +72 -326
  18. package/dist/file-delete.js +236 -0
  19. package/dist/file-edit.js +247 -0
  20. package/dist/file-read.js +197 -0
  21. package/dist/file-write.js +220 -0
  22. package/dist/git-branch.js +187 -0
  23. package/dist/git-diff.js +177 -0
  24. package/dist/git-log.js +230 -0
  25. package/dist/git-status.js +208 -0
  26. package/dist/guard-locked.js +169 -0
  27. package/dist/guard-main-branch.js +202 -0
  28. package/dist/guard-worktree-commit.js +160 -0
  29. package/dist/init-plan.js +337 -0
  30. package/dist/lumenflow-upgrade.js +178 -0
  31. package/dist/metrics-cli.js +433 -0
  32. package/dist/rotate-progress.js +247 -0
  33. package/dist/session-coordinator.js +300 -0
  34. package/dist/state-bootstrap.js +307 -0
  35. package/dist/sync-templates.js +212 -0
  36. package/dist/trace-gen.js +331 -0
  37. package/dist/validate-agent-skills.js +218 -0
  38. package/dist/validate-agent-sync.js +148 -0
  39. package/dist/validate-backlog-sync.js +152 -0
  40. package/dist/validate-skills-spec.js +206 -0
  41. package/dist/validate.js +230 -0
  42. package/package.json +37 -7
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guard Main Branch CLI Tool
4
+ *
5
+ * Provides branch protection checks for WU workflow:
6
+ * - Blocks operations on main/master branches
7
+ * - Blocks operations on lane branches (require worktree)
8
+ * - Optionally allows agent branches
9
+ *
10
+ * Usage:
11
+ * node guard-main-branch.js [--allow-agent-branch] [--strict]
12
+ *
13
+ * WU-1109: INIT-003 Phase 4b - Migrate git operations
14
+ */
15
+ import { createGitForPath, getGitForCwd, isAgentBranch, getConfig } from '@lumenflow/core';
16
+ /**
17
+ * Parse command line arguments for guard-main-branch
18
+ */
19
+ export function parseGuardMainBranchArgs(argv) {
20
+ const args = {};
21
+ // Skip node and script name
22
+ const cliArgs = argv.slice(2);
23
+ for (let i = 0; i < cliArgs.length; i++) {
24
+ const arg = cliArgs[i];
25
+ if (arg === '--help' || arg === '-h') {
26
+ args.help = true;
27
+ }
28
+ else if (arg === '--allow-agent-branch') {
29
+ args.allowAgentBranch = true;
30
+ }
31
+ else if (arg === '--strict') {
32
+ args.strict = true;
33
+ }
34
+ else if (arg === '--base-dir') {
35
+ args.baseDir = cliArgs[++i];
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+ /**
41
+ * Get lane branch pattern from config
42
+ */
43
+ function getLaneBranchPattern() {
44
+ const config = getConfig();
45
+ const prefix = config?.git?.laneBranchPrefix ?? 'lane/';
46
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ return new RegExp(`^${escaped}`);
48
+ }
49
+ /**
50
+ * Get protected branches from config
51
+ */
52
+ function getProtectedBranches() {
53
+ const config = getConfig();
54
+ const mainBranch = config?.git?.mainBranch ?? 'main';
55
+ // Always include master for legacy compatibility
56
+ const protectedSet = new Set([mainBranch, 'master']);
57
+ return Array.from(protectedSet);
58
+ }
59
+ /**
60
+ * Check if a branch is a lane branch (requires worktree)
61
+ */
62
+ function isLaneBranch(branch) {
63
+ return getLaneBranchPattern().test(branch);
64
+ }
65
+ /**
66
+ * Guard against operations on protected branches
67
+ */
68
+ export async function guardMainBranch(args) {
69
+ try {
70
+ const git = args.baseDir ? createGitForPath(args.baseDir) : getGitForCwd();
71
+ const currentBranch = await git.getCurrentBranch();
72
+ // Handle detached HEAD
73
+ if (currentBranch === 'HEAD' || !currentBranch) {
74
+ return {
75
+ success: true,
76
+ isProtected: true,
77
+ currentBranch: 'HEAD (detached)',
78
+ reason: 'Detached HEAD state is protected - checkout a branch',
79
+ };
80
+ }
81
+ const protectedBranches = getProtectedBranches();
82
+ // Check if on main/master
83
+ if (protectedBranches.includes(currentBranch)) {
84
+ return {
85
+ success: true,
86
+ isProtected: true,
87
+ currentBranch,
88
+ reason: `Branch '${currentBranch}' is protected - use a worktree for WU work`,
89
+ };
90
+ }
91
+ // Check if on a lane branch (requires worktree discipline)
92
+ if (isLaneBranch(currentBranch)) {
93
+ return {
94
+ success: true,
95
+ isProtected: true,
96
+ currentBranch,
97
+ reason: `Lane branch '${currentBranch}' requires worktree - use 'pnpm wu:claim' to create worktree`,
98
+ };
99
+ }
100
+ // Check agent branch if not explicitly allowed
101
+ if (!args.allowAgentBranch) {
102
+ const isAgent = await isAgentBranch(currentBranch);
103
+ if (isAgent) {
104
+ // Agent branches are allowed by default (unless --strict)
105
+ if (args.strict) {
106
+ return {
107
+ success: true,
108
+ isProtected: true,
109
+ currentBranch,
110
+ reason: `Agent branch '${currentBranch}' is protected in strict mode`,
111
+ };
112
+ }
113
+ // Allow agent branch
114
+ return {
115
+ success: true,
116
+ isProtected: false,
117
+ currentBranch,
118
+ };
119
+ }
120
+ }
121
+ // Branch is not protected
122
+ return {
123
+ success: true,
124
+ isProtected: false,
125
+ currentBranch,
126
+ };
127
+ }
128
+ catch (error) {
129
+ const errorMessage = error instanceof Error ? error.message : String(error);
130
+ return {
131
+ success: false,
132
+ isProtected: true, // Fail-closed
133
+ error: errorMessage,
134
+ };
135
+ }
136
+ }
137
+ /**
138
+ * Print help message
139
+ */
140
+ /* istanbul ignore next -- CLI entry point tested via subprocess */
141
+ function printHelp() {
142
+ console.log(`
143
+ Usage: guard-main-branch [options]
144
+
145
+ Check if current branch is protected and block operations.
146
+
147
+ Options:
148
+ --base-dir <dir> Base directory for git operations
149
+ --allow-agent-branch Allow operations on agent branches
150
+ --strict Block all protected branches (including agent)
151
+ -h, --help Show this help message
152
+
153
+ Exit codes:
154
+ 0 - Branch is not protected (safe to proceed)
155
+ 1 - Branch is protected (operation blocked)
156
+
157
+ Protected branches:
158
+ - main/master: Always protected
159
+ - lane/*: Requires worktree (use wu:claim)
160
+ - Agent branches: Allowed unless --strict
161
+
162
+ Examples:
163
+ guard-main-branch
164
+ guard-main-branch --allow-agent-branch
165
+ guard-main-branch --strict
166
+ `);
167
+ }
168
+ /**
169
+ * Main entry point
170
+ */
171
+ /* istanbul ignore next -- CLI entry point tested via subprocess */
172
+ async function main() {
173
+ const args = parseGuardMainBranchArgs(process.argv);
174
+ if (args.help) {
175
+ printHelp();
176
+ process.exit(0);
177
+ }
178
+ const result = await guardMainBranch(args);
179
+ if (result.success) {
180
+ if (result.isProtected) {
181
+ console.error(`[guard-main-branch] BLOCKED: ${result.reason}`);
182
+ console.error(`Current branch: ${result.currentBranch}`);
183
+ process.exit(1);
184
+ }
185
+ else {
186
+ // Silent success in normal mode
187
+ if (process.env.DEBUG) {
188
+ console.log(`[guard-main-branch] OK: Branch '${result.currentBranch}' is not protected`);
189
+ }
190
+ process.exit(0);
191
+ }
192
+ }
193
+ else {
194
+ console.error(`Error: ${result.error}`);
195
+ process.exit(1);
196
+ }
197
+ }
198
+ // Run main if executed directly
199
+ import { runCLI } from './cli-entry-point.js';
200
+ if (import.meta.main) {
201
+ runCLI(main);
202
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file guard-worktree-commit.ts
4
+ * @description Guard that prevents WU commits from main checkout (WU-1111)
5
+ *
6
+ * Validates that WU-related commits are only made from worktrees, not main.
7
+ * Used by git commit-msg hooks to enforce worktree discipline.
8
+ *
9
+ * Usage:
10
+ * guard-worktree-commit "commit message"
11
+ * guard-worktree-commit --message "commit message"
12
+ *
13
+ * Exit codes:
14
+ * 0 - Commit allowed
15
+ * 1 - Commit blocked (WU commit from main)
16
+ *
17
+ * @see {@link docs/04-operations/_frameworks/lumenflow/lumenflow-complete.md} - Worktree discipline
18
+ */
19
+ import { fileURLToPath } from 'node:url';
20
+ import { isInWorktree, isMainBranch } from '@lumenflow/core/dist/core/worktree-guard.js';
21
+ const LOG_PREFIX = '[guard-worktree-commit]';
22
+ /**
23
+ * Patterns that indicate a WU-related commit message
24
+ */
25
+ const WU_COMMIT_PATTERNS = [
26
+ /^wu\(/i, // wu(WU-123): message
27
+ /\(wu-\d+\)/i, // feat(WU-123): message
28
+ /\(WU-\d+\)/i, // Same with uppercase
29
+ /^WU-\d+:/i, // WU-123: message
30
+ /^wu-\d+:/i, // wu-123: message
31
+ ];
32
+ /**
33
+ * Check if a commit should be blocked
34
+ *
35
+ * @param options - Check options
36
+ * @param options.commitMessage - The commit message
37
+ * @param options.isMainCheckout - Whether in main checkout
38
+ * @param options.isInWorktree - Whether in a worktree
39
+ * @returns Whether commit should be blocked and why
40
+ *
41
+ * @example
42
+ * const result = shouldBlockCommit({
43
+ * commitMessage: 'wu(WU-123): add feature',
44
+ * isMainCheckout: true,
45
+ * isInWorktree: false,
46
+ * });
47
+ * if (result.blocked) {
48
+ * console.error(result.reason);
49
+ * process.exit(1);
50
+ * }
51
+ */
52
+ export function shouldBlockCommit(options) {
53
+ const { commitMessage, isMainCheckout, isInWorktree } = options;
54
+ // Allow all commits from worktrees
55
+ if (isInWorktree) {
56
+ return { blocked: false };
57
+ }
58
+ // Check if commit message indicates WU work
59
+ const isWUCommit = WU_COMMIT_PATTERNS.some((pattern) => pattern.test(commitMessage));
60
+ // Block WU commits from main checkout
61
+ if (isWUCommit && isMainCheckout) {
62
+ return {
63
+ blocked: true,
64
+ reason: `${LOG_PREFIX} BLOCKED: WU commits must be made from a worktree.
65
+
66
+ You are attempting to commit WU work from the main checkout.
67
+
68
+ To fix:
69
+ 1. Navigate to your worktree:
70
+ cd worktrees/<lane>-wu-xxx/
71
+
72
+ 2. Make your commit there:
73
+ git add . && git commit -m "${commitMessage}"
74
+
75
+ 3. Complete the WU from main:
76
+ cd ../.. && pnpm wu:done --id WU-XXX
77
+
78
+ For more information:
79
+ See docs/04-operations/_frameworks/lumenflow/lumenflow-complete.md
80
+ See .claude/skills/worktree-discipline/SKILL.md
81
+ `,
82
+ };
83
+ }
84
+ // Allow non-WU commits from anywhere
85
+ return { blocked: false };
86
+ }
87
+ /**
88
+ * Check if a commit message is WU-related
89
+ *
90
+ * @param message - Commit message to check
91
+ * @returns true if message indicates WU work
92
+ */
93
+ export function isWUCommitMessage(message) {
94
+ return WU_COMMIT_PATTERNS.some((pattern) => pattern.test(message));
95
+ }
96
+ /**
97
+ * Main CLI entry point
98
+ */
99
+ async function main() {
100
+ const args = process.argv.slice(2);
101
+ // Parse arguments
102
+ let commitMessage;
103
+ for (let i = 0; i < args.length; i++) {
104
+ const arg = args[i];
105
+ if (arg === '--message' || arg === '-m') {
106
+ commitMessage = args[++i];
107
+ }
108
+ else if (arg === '--help' || arg === '-h') {
109
+ console.log(`Usage: guard-worktree-commit [--message] "commit message"
110
+
111
+ Check if a WU commit should be blocked from main checkout.
112
+
113
+ Options:
114
+ --message, -m MSG Commit message to check
115
+ -h, --help Show this help message
116
+
117
+ Examples:
118
+ guard-worktree-commit "wu(WU-123): add feature"
119
+ guard-worktree-commit --message "chore: update deps"
120
+ `);
121
+ process.exit(0);
122
+ }
123
+ else if (!commitMessage) {
124
+ commitMessage = arg;
125
+ }
126
+ }
127
+ if (!commitMessage) {
128
+ console.error(`${LOG_PREFIX} Error: Commit message required`);
129
+ console.error('Usage: guard-worktree-commit [--message] "commit message"');
130
+ process.exit(1);
131
+ }
132
+ // Check context
133
+ const inWorktree = isInWorktree();
134
+ let onMain = false;
135
+ try {
136
+ onMain = await isMainBranch();
137
+ }
138
+ catch {
139
+ // If we can't determine branch, be conservative and allow
140
+ onMain = false;
141
+ }
142
+ const result = shouldBlockCommit({
143
+ commitMessage,
144
+ isMainCheckout: onMain && !inWorktree,
145
+ isInWorktree: inWorktree,
146
+ });
147
+ if (result.blocked) {
148
+ console.error(result.reason);
149
+ process.exit(1);
150
+ }
151
+ console.log(`${LOG_PREFIX} Commit allowed`);
152
+ process.exit(0);
153
+ }
154
+ // Guard main() for testability
155
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
156
+ main().catch((error) => {
157
+ console.error(`${LOG_PREFIX} Unexpected error:`, error);
158
+ process.exit(1);
159
+ });
160
+ }
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable security/detect-non-literal-fs-filename */
3
+ /**
4
+ * Init Plan Command (WU-1105)
5
+ *
6
+ * Links plan files to initiatives by setting the `related_plan` field
7
+ * in the initiative YAML.
8
+ *
9
+ * Usage:
10
+ * pnpm init:plan --initiative INIT-001 --plan docs/04-operations/plans/my-plan.md
11
+ * pnpm init:plan --initiative INIT-001 --create # Create new plan template
12
+ *
13
+ * Features:
14
+ * - Validates initiative exists before modifying
15
+ * - Formats plan path as lumenflow:// URI
16
+ * - Idempotent: no error if same plan already linked
17
+ * - Warns if replacing existing plan link
18
+ * - Can create plan templates with --create
19
+ *
20
+ * Context: WU-1105 (INIT-003 Phase 3a: Migrate init:plan command)
21
+ */
22
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
23
+ import { die } from '@lumenflow/core/dist/error-handler.js';
24
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
25
+ import { join, basename } from 'node:path';
26
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
27
+ import { INIT_PATHS } from '@lumenflow/initiatives/dist/initiative-paths.js';
28
+ import { INIT_PATTERNS } from '@lumenflow/initiatives/dist/initiative-constants.js';
29
+ import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
30
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
31
+ import { readInitiative } from '@lumenflow/initiatives/dist/initiative-yaml.js';
32
+ import { parseYAML, stringifyYAML } from '@lumenflow/core/dist/wu-yaml.js';
33
+ import { LOG_PREFIX as CORE_LOG_PREFIX } from '@lumenflow/core/dist/wu-constants.js';
34
+ /** Log prefix for console output */
35
+ export const LOG_PREFIX = CORE_LOG_PREFIX.INIT_PLAN;
36
+ /** Micro-worktree operation name */
37
+ const OPERATION_NAME = 'init-plan';
38
+ /** Standard plans directory relative to repo root */
39
+ const PLANS_DIR = 'docs/04-operations/plans';
40
+ /** LumenFlow URI scheme for plan references */
41
+ const PLAN_URI_SCHEME = 'lumenflow://plans/';
42
+ /**
43
+ * Custom option for plan file path
44
+ */
45
+ const PLAN_OPTION = {
46
+ name: 'plan',
47
+ flags: '--plan <path>',
48
+ description: 'Path to plan file (markdown)',
49
+ };
50
+ /**
51
+ * Custom option for creating new plan template
52
+ */
53
+ const CREATE_OPTION = {
54
+ name: 'create',
55
+ flags: '--create',
56
+ description: 'Create a new plan template instead of linking existing file',
57
+ };
58
+ /**
59
+ * Validate Initiative ID format
60
+ * @param id - Initiative ID to validate
61
+ * @throws Error if format is invalid
62
+ */
63
+ export function validateInitIdFormat(id) {
64
+ if (!INIT_PATTERNS.INIT_ID.test(id)) {
65
+ die(`Invalid Initiative ID format: "${id}"\n\n` +
66
+ `Expected format: INIT-<number> or INIT-NAME (e.g., INIT-001, INIT-TOOLING)`);
67
+ }
68
+ }
69
+ /**
70
+ * Validate plan file path
71
+ * @param planPath - Path to plan file
72
+ * @throws Error if path is invalid or file doesn't exist
73
+ */
74
+ export function validatePlanPath(planPath) {
75
+ if (!planPath.endsWith('.md')) {
76
+ die(`Invalid plan file format: "${planPath}"\n\nPlan files must be markdown (.md)`);
77
+ }
78
+ if (!existsSync(planPath)) {
79
+ die(`Plan file not found: "${planPath}"\n\nUse --create to create a new plan template`);
80
+ }
81
+ }
82
+ /**
83
+ * Format plan path as lumenflow:// URI
84
+ *
85
+ * Extracts the filename (and any subdirectory within plans/) and creates
86
+ * a standardized URI for the plan reference.
87
+ *
88
+ * @param planPath - Path to plan file (can be relative or absolute)
89
+ * @returns lumenflow://plans/<filename> URI
90
+ */
91
+ export function formatPlanUri(planPath) {
92
+ // Try to extract path relative to plans directory
93
+ const plansMarker = '/plans/';
94
+ const plansIndex = planPath.indexOf(plansMarker);
95
+ if (plansIndex !== -1) {
96
+ // Extract everything after /plans/
97
+ const relativePath = planPath.substring(plansIndex + plansMarker.length);
98
+ return `${PLAN_URI_SCHEME}${relativePath}`;
99
+ }
100
+ // Fallback: just use the filename
101
+ const filename = basename(planPath);
102
+ return `${PLAN_URI_SCHEME}${filename}`;
103
+ }
104
+ /**
105
+ * Check if initiative exists and return the document
106
+ * @param initId - Initiative ID to check
107
+ * @returns Initiative document
108
+ * @throws Error if initiative not found
109
+ */
110
+ export function checkInitiativeExists(initId) {
111
+ const initPath = INIT_PATHS.INITIATIVE(initId);
112
+ if (!existsSync(initPath)) {
113
+ die(`Initiative not found: ${initId}\n\nFile does not exist: ${initPath}`);
114
+ }
115
+ return readInitiative(initPath, initId);
116
+ }
117
+ /**
118
+ * Update initiative with plan reference in micro-worktree
119
+ *
120
+ * Uses raw YAML parsing to preserve unknown fields like related_plan
121
+ * that are not in the strict initiative schema.
122
+ *
123
+ * @param worktreePath - Path to micro-worktree
124
+ * @param initId - Initiative ID
125
+ * @param planUri - Plan URI to set
126
+ * @returns True if changes were made, false if already linked
127
+ */
128
+ export function updateInitiativeWithPlan(worktreePath, initId, planUri) {
129
+ const initRelPath = INIT_PATHS.INITIATIVE(initId);
130
+ const initAbsPath = join(worktreePath, initRelPath);
131
+ // Read raw YAML to preserve unknown fields like related_plan
132
+ // (readInitiative strips them via zod schema validation)
133
+ const rawText = readFileSync(initAbsPath, { encoding: 'utf-8' });
134
+ const doc = parseYAML(rawText);
135
+ // Validate ID matches
136
+ if (doc.id !== initId) {
137
+ die(`Initiative YAML id mismatch. Expected ${initId}, found ${doc.id}`);
138
+ }
139
+ // Check for existing plan link
140
+ const existingPlan = doc.related_plan;
141
+ if (existingPlan === planUri) {
142
+ // Already linked to same plan - idempotent
143
+ return false;
144
+ }
145
+ if (existingPlan && existingPlan !== planUri) {
146
+ // Different plan already linked - warn but proceed
147
+ console.warn(`${LOG_PREFIX} Replacing existing related_plan: ${existingPlan} -> ${planUri}`);
148
+ }
149
+ // Update related_plan field
150
+ doc.related_plan = planUri;
151
+ const out = stringifyYAML(doc);
152
+ writeFileSync(initAbsPath, out, { encoding: 'utf-8' });
153
+ console.log(`${LOG_PREFIX} Updated ${initId} with related_plan: ${planUri}`);
154
+ return true;
155
+ }
156
+ /**
157
+ * Create a plan template file
158
+ *
159
+ * @param worktreePath - Path to repo root or worktree
160
+ * @param initId - Initiative ID
161
+ * @param title - Initiative title
162
+ * @returns Path to created file
163
+ * @throws Error if file already exists
164
+ */
165
+ export function createPlanTemplate(worktreePath, initId, title) {
166
+ const slug = title
167
+ .toLowerCase()
168
+ .replace(/[^a-z0-9]+/g, '-')
169
+ .replace(/^-|-$/g, '')
170
+ .substring(0, 30);
171
+ const filename = `${initId}-${slug}.md`;
172
+ const plansDir = join(worktreePath, PLANS_DIR);
173
+ const planPath = join(plansDir, filename);
174
+ if (existsSync(planPath)) {
175
+ die(`Plan file already exists: ${planPath}\n\nUse --plan to link an existing file`);
176
+ }
177
+ // Ensure plans directory exists
178
+ if (!existsSync(plansDir)) {
179
+ mkdirSync(plansDir, { recursive: true });
180
+ }
181
+ const template = `# ${initId} Plan - ${title}
182
+
183
+ ## Goal
184
+
185
+ <!-- What is the primary objective of this initiative? -->
186
+
187
+ ## Scope
188
+
189
+ <!-- What is in scope and out of scope? -->
190
+
191
+ ## Approach
192
+
193
+ <!-- How will you achieve the goal? Key phases or milestones? -->
194
+
195
+ ## Success Criteria
196
+
197
+ <!-- How will you know when this is complete? Measurable outcomes? -->
198
+
199
+ ## Risks
200
+
201
+ <!-- What could go wrong? How will you mitigate? -->
202
+
203
+ ## References
204
+
205
+ - Initiative: ${initId}
206
+ - Created: ${new Date().toISOString().split('T')[0]}
207
+ `;
208
+ writeFileSync(planPath, template, { encoding: 'utf-8' });
209
+ console.log(`${LOG_PREFIX} Created plan template: ${planPath}`);
210
+ return planPath;
211
+ }
212
+ /**
213
+ * Generate commit message for plan link operation
214
+ */
215
+ export function getCommitMessage(initId, planUri) {
216
+ const filename = planUri.replace(PLAN_URI_SCHEME, '');
217
+ return `docs: link plan ${filename} to ${initId.toLowerCase()}`;
218
+ }
219
+ async function main() {
220
+ const args = createWUParser({
221
+ name: 'init-plan',
222
+ description: 'Link a plan file to an initiative',
223
+ options: [WU_OPTIONS.initiative, PLAN_OPTION, CREATE_OPTION],
224
+ required: ['initiative'],
225
+ allowPositionalId: false,
226
+ });
227
+ const initId = args.initiative;
228
+ const planPath = args.plan;
229
+ const shouldCreate = args.create;
230
+ // Validate inputs
231
+ validateInitIdFormat(initId);
232
+ // Check initiative exists first (before any mutations)
233
+ const initDoc = checkInitiativeExists(initId);
234
+ const initTitle = initDoc.title;
235
+ // Determine plan path and URI
236
+ let targetPlanPath;
237
+ let planUri;
238
+ if (shouldCreate) {
239
+ // Create mode - will create template and link it
240
+ console.log(`${LOG_PREFIX} Creating plan template for ${initId}...`);
241
+ // Ensure on main for micro-worktree operations
242
+ await ensureOnMain(getGitForCwd());
243
+ try {
244
+ await withMicroWorktree({
245
+ operation: OPERATION_NAME,
246
+ id: initId,
247
+ logPrefix: LOG_PREFIX,
248
+ pushOnly: true,
249
+ execute: async ({ worktreePath }) => {
250
+ // Create plan template
251
+ targetPlanPath = createPlanTemplate(worktreePath, initId, initTitle);
252
+ planUri = formatPlanUri(targetPlanPath);
253
+ // Update initiative with plan link
254
+ updateInitiativeWithPlan(worktreePath, initId, planUri);
255
+ // Return files to commit
256
+ const planRelPath = targetPlanPath.replace(worktreePath + '/', '');
257
+ return {
258
+ commitMessage: getCommitMessage(initId, planUri),
259
+ files: [planRelPath, INIT_PATHS.INITIATIVE(initId)],
260
+ };
261
+ },
262
+ });
263
+ console.log(`\n${LOG_PREFIX} Transaction complete!`);
264
+ console.log(`\nPlan Linked:`);
265
+ console.log(` Initiative: ${initId}`);
266
+ console.log(` Plan URI: ${planUri}`);
267
+ console.log(` File: ${targetPlanPath}`);
268
+ console.log(`\nNext steps:`);
269
+ console.log(` 1. Edit the plan file with your goals and approach`);
270
+ console.log(` 2. View initiative: pnpm initiative:status ${initId}`);
271
+ }
272
+ catch (error) {
273
+ die(`Transaction failed: ${error.message}\n\n` +
274
+ `Micro-worktree cleanup was attempted automatically.\n` +
275
+ `If issue persists, check for orphaned branches: git branch | grep tmp/${OPERATION_NAME}`);
276
+ }
277
+ }
278
+ else if (planPath) {
279
+ // Link existing file mode
280
+ validatePlanPath(planPath);
281
+ planUri = formatPlanUri(planPath);
282
+ console.log(`${LOG_PREFIX} Linking plan to ${initId}...`);
283
+ // Check for idempotent case before micro-worktree
284
+ const existingPlan = initDoc.related_plan;
285
+ if (existingPlan === planUri) {
286
+ console.log(`${LOG_PREFIX} Plan already linked (idempotent - no changes needed)`);
287
+ console.log(`\n${LOG_PREFIX} ${initId} already has related_plan: ${planUri}`);
288
+ return;
289
+ }
290
+ // Ensure on main for micro-worktree operations
291
+ await ensureOnMain(getGitForCwd());
292
+ try {
293
+ await withMicroWorktree({
294
+ operation: OPERATION_NAME,
295
+ id: initId,
296
+ logPrefix: LOG_PREFIX,
297
+ pushOnly: true,
298
+ execute: async ({ worktreePath }) => {
299
+ // Update initiative with plan link
300
+ const changed = updateInitiativeWithPlan(worktreePath, initId, planUri);
301
+ if (!changed) {
302
+ console.log(`${LOG_PREFIX} No changes detected (concurrent link operation)`);
303
+ }
304
+ return {
305
+ commitMessage: getCommitMessage(initId, planUri),
306
+ files: [INIT_PATHS.INITIATIVE(initId)],
307
+ };
308
+ },
309
+ });
310
+ console.log(`\n${LOG_PREFIX} Transaction complete!`);
311
+ console.log(`\nPlan Linked:`);
312
+ console.log(` Initiative: ${initId}`);
313
+ console.log(` Plan URI: ${planUri}`);
314
+ console.log(` File: ${planPath}`);
315
+ console.log(`\nNext steps:`);
316
+ console.log(` - View initiative: pnpm initiative:status ${initId}`);
317
+ }
318
+ catch (error) {
319
+ die(`Transaction failed: ${error.message}\n\n` +
320
+ `Micro-worktree cleanup was attempted automatically.\n` +
321
+ `If issue persists, check for orphaned branches: git branch | grep tmp/${OPERATION_NAME}`);
322
+ }
323
+ }
324
+ else {
325
+ die('Either --plan or --create is required\n\n' +
326
+ 'Usage:\n' +
327
+ ' pnpm init:plan --initiative INIT-001 --plan docs/04-operations/plans/my-plan.md\n' +
328
+ ' pnpm init:plan --initiative INIT-001 --create');
329
+ }
330
+ }
331
+ // Guard main() for testability - use import.meta.main (WU-1071)
332
+ import { runCLI } from './cli-entry-point.js';
333
+ if (import.meta.main) {
334
+ runCLI(main);
335
+ }
336
+ // Export for testing
337
+ export { main };