@lumenflow/cli 2.3.2 → 2.5.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 (135) hide show
  1. package/dist/__tests__/init-config-lanes.test.js +131 -0
  2. package/dist/__tests__/init-docs-structure.test.js +119 -0
  3. package/dist/__tests__/init-lane-inference.test.js +125 -0
  4. package/dist/__tests__/init-onboarding-docs.test.js +132 -0
  5. package/dist/__tests__/init-quick-ref.test.js +145 -0
  6. package/dist/__tests__/init-scripts.test.js +96 -0
  7. package/dist/__tests__/init-template-portability.test.js +97 -0
  8. package/dist/__tests__/init.test.js +199 -3
  9. package/dist/__tests__/initiative-add-wu.test.js +420 -0
  10. package/dist/__tests__/initiative-plan-replacement.test.js +162 -0
  11. package/dist/__tests__/initiative-remove-wu.test.js +458 -0
  12. package/dist/__tests__/onboarding-smoke-test.test.js +211 -0
  13. package/dist/__tests__/path-centralization-cli.test.js +234 -0
  14. package/dist/__tests__/plan-create.test.js +126 -0
  15. package/dist/__tests__/plan-edit.test.js +157 -0
  16. package/dist/__tests__/plan-link.test.js +239 -0
  17. package/dist/__tests__/plan-promote.test.js +181 -0
  18. package/dist/__tests__/wu-create-strict.test.js +118 -0
  19. package/dist/__tests__/wu-edit-strict.test.js +109 -0
  20. package/dist/__tests__/wu-validate-strict.test.js +113 -0
  21. package/dist/flow-bottlenecks.js +4 -2
  22. package/dist/flow-report.js +3 -2
  23. package/dist/gates.js +202 -2
  24. package/dist/init.js +720 -40
  25. package/dist/initiative-add-wu.js +112 -16
  26. package/dist/initiative-plan.js +3 -2
  27. package/dist/initiative-remove-wu.js +248 -0
  28. package/dist/mem-context.js +0 -0
  29. package/dist/metrics-snapshot.js +3 -2
  30. package/dist/onboarding-smoke-test.js +400 -0
  31. package/dist/plan-create.js +199 -0
  32. package/dist/plan-edit.js +235 -0
  33. package/dist/plan-link.js +233 -0
  34. package/dist/plan-promote.js +231 -0
  35. package/dist/rotate-progress.js +8 -5
  36. package/dist/spawn-list.js +4 -3
  37. package/dist/state-bootstrap.js +6 -4
  38. package/dist/state-doctor-fix.js +5 -4
  39. package/dist/state-doctor.js +32 -2
  40. package/dist/trace-gen.js +6 -3
  41. package/dist/wu-block.js +16 -5
  42. package/dist/wu-claim.js +15 -9
  43. package/dist/wu-create.js +50 -2
  44. package/dist/wu-deps.js +3 -1
  45. package/dist/wu-done.js +14 -5
  46. package/dist/wu-edit.js +35 -0
  47. package/dist/wu-infer-lane.js +3 -1
  48. package/dist/wu-spawn.js +8 -0
  49. package/dist/wu-unblock.js +34 -2
  50. package/dist/wu-validate.js +25 -17
  51. package/package.json +12 -6
  52. package/templates/core/AGENTS.md.template +2 -2
  53. package/dist/__tests__/init-plan.test.js +0 -340
  54. package/dist/agent-issues-query.d.ts +0 -16
  55. package/dist/agent-log-issue.d.ts +0 -10
  56. package/dist/agent-session-end.d.ts +0 -10
  57. package/dist/agent-session.d.ts +0 -10
  58. package/dist/backlog-prune.d.ts +0 -84
  59. package/dist/cli-entry-point.d.ts +0 -8
  60. package/dist/deps-add.d.ts +0 -91
  61. package/dist/deps-remove.d.ts +0 -17
  62. package/dist/docs-sync.d.ts +0 -50
  63. package/dist/file-delete.d.ts +0 -84
  64. package/dist/file-edit.d.ts +0 -82
  65. package/dist/file-read.d.ts +0 -92
  66. package/dist/file-write.d.ts +0 -90
  67. package/dist/flow-bottlenecks.d.ts +0 -16
  68. package/dist/flow-report.d.ts +0 -16
  69. package/dist/gates.d.ts +0 -94
  70. package/dist/git-branch.d.ts +0 -65
  71. package/dist/git-diff.d.ts +0 -58
  72. package/dist/git-log.d.ts +0 -69
  73. package/dist/git-status.d.ts +0 -58
  74. package/dist/guard-locked.d.ts +0 -62
  75. package/dist/guard-main-branch.d.ts +0 -50
  76. package/dist/guard-worktree-commit.d.ts +0 -59
  77. package/dist/index.d.ts +0 -10
  78. package/dist/init-plan.d.ts +0 -80
  79. package/dist/init-plan.js +0 -337
  80. package/dist/init.d.ts +0 -46
  81. package/dist/initiative-add-wu.d.ts +0 -22
  82. package/dist/initiative-bulk-assign-wus.d.ts +0 -16
  83. package/dist/initiative-create.d.ts +0 -28
  84. package/dist/initiative-edit.d.ts +0 -34
  85. package/dist/initiative-list.d.ts +0 -12
  86. package/dist/initiative-status.d.ts +0 -11
  87. package/dist/lumenflow-upgrade.d.ts +0 -103
  88. package/dist/mem-checkpoint.d.ts +0 -16
  89. package/dist/mem-cleanup.d.ts +0 -29
  90. package/dist/mem-create.d.ts +0 -17
  91. package/dist/mem-export.d.ts +0 -10
  92. package/dist/mem-inbox.d.ts +0 -35
  93. package/dist/mem-init.d.ts +0 -15
  94. package/dist/mem-ready.d.ts +0 -16
  95. package/dist/mem-signal.d.ts +0 -16
  96. package/dist/mem-start.d.ts +0 -16
  97. package/dist/mem-summarize.d.ts +0 -22
  98. package/dist/mem-triage.d.ts +0 -22
  99. package/dist/metrics-cli.d.ts +0 -90
  100. package/dist/metrics-snapshot.d.ts +0 -18
  101. package/dist/orchestrate-init-status.d.ts +0 -11
  102. package/dist/orchestrate-initiative.d.ts +0 -12
  103. package/dist/orchestrate-monitor.d.ts +0 -11
  104. package/dist/release.d.ts +0 -117
  105. package/dist/rotate-progress.d.ts +0 -48
  106. package/dist/session-coordinator.d.ts +0 -74
  107. package/dist/spawn-list.d.ts +0 -16
  108. package/dist/state-bootstrap.d.ts +0 -92
  109. package/dist/sync-templates.d.ts +0 -52
  110. package/dist/trace-gen.d.ts +0 -84
  111. package/dist/validate-agent-skills.d.ts +0 -50
  112. package/dist/validate-agent-sync.d.ts +0 -36
  113. package/dist/validate-backlog-sync.d.ts +0 -37
  114. package/dist/validate-skills-spec.d.ts +0 -40
  115. package/dist/validate.d.ts +0 -60
  116. package/dist/wu-block.d.ts +0 -16
  117. package/dist/wu-claim.d.ts +0 -74
  118. package/dist/wu-cleanup.d.ts +0 -35
  119. package/dist/wu-create.d.ts +0 -69
  120. package/dist/wu-delete.d.ts +0 -21
  121. package/dist/wu-deps.d.ts +0 -13
  122. package/dist/wu-done.d.ts +0 -225
  123. package/dist/wu-edit.d.ts +0 -63
  124. package/dist/wu-infer-lane.d.ts +0 -17
  125. package/dist/wu-preflight.d.ts +0 -47
  126. package/dist/wu-prune.d.ts +0 -16
  127. package/dist/wu-recover.d.ts +0 -37
  128. package/dist/wu-release.d.ts +0 -19
  129. package/dist/wu-repair.d.ts +0 -60
  130. package/dist/wu-spawn-completion.d.ts +0 -10
  131. package/dist/wu-spawn.d.ts +0 -192
  132. package/dist/wu-status.d.ts +0 -25
  133. package/dist/wu-unblock.d.ts +0 -16
  134. package/dist/wu-unlock-lane.d.ts +0 -19
  135. package/dist/wu-validate.d.ts +0 -16
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console -- CLI tool requires console output */
3
+ /* eslint-disable security/detect-non-literal-fs-filename */
4
+ /**
5
+ * Plan Edit Command (WU-1313)
6
+ *
7
+ * Edits existing plan files in the repo-native plansDir.
8
+ * Supports updating or appending to specific sections.
9
+ *
10
+ * Usage:
11
+ * pnpm plan:edit --id WU-1313 --section Goal --content "New goal content"
12
+ * pnpm plan:edit --id WU-1313 --section Risks --append "- New risk"
13
+ *
14
+ * Features:
15
+ * - Updates specific sections by name
16
+ * - Supports append mode for list sections (Risks, etc.)
17
+ * - Uses micro-worktree isolation for atomic commits
18
+ * - Validates section exists before editing
19
+ *
20
+ * Context: WU-1313 (INIT-013 Plan Tooling)
21
+ */
22
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
23
+ import { die } from '@lumenflow/core/dist/error-handler.js';
24
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
27
+ import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
28
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
29
+ import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
30
+ import { LOG_PREFIX as CORE_LOG_PREFIX } from '@lumenflow/core/dist/wu-constants.js';
31
+ /** Log prefix for console output */
32
+ export const LOG_PREFIX = CORE_LOG_PREFIX.PLAN_EDIT ?? '[plan:edit]';
33
+ /** Micro-worktree operation name */
34
+ const OPERATION_NAME = 'plan-edit';
35
+ /** WU ID pattern */
36
+ const WU_ID_PATTERN = /^WU-\d+$/;
37
+ /** Initiative ID pattern */
38
+ const INIT_ID_PATTERN = /^INIT-[A-Z0-9]+$/i;
39
+ /**
40
+ * Get the path to a plan file from its ID
41
+ *
42
+ * @param id - WU or Initiative ID
43
+ * @returns Path to plan file
44
+ * @throws Error if plan not found
45
+ */
46
+ export function getPlanPath(id) {
47
+ const plansDir = WU_PATHS.PLANS_DIR();
48
+ const planPath = join(plansDir, `${id}-plan.md`);
49
+ if (!existsSync(planPath)) {
50
+ die(`Plan not found for ${id}\n\n` +
51
+ `Expected path: ${planPath}\n\n` +
52
+ `Create it first with: pnpm plan:create --id ${id} --title "Title"`);
53
+ }
54
+ return planPath;
55
+ }
56
+ /**
57
+ * Update a section in a plan file
58
+ *
59
+ * Replaces all content between the section heading and the next heading.
60
+ *
61
+ * @param planPath - Path to plan file
62
+ * @param section - Section name (without ##)
63
+ * @param content - New content for the section
64
+ * @returns True if changes were made, false if section not found
65
+ */
66
+ export function updatePlanSection(planPath, section, content) {
67
+ if (!existsSync(planPath)) {
68
+ die(`Plan file not found: ${planPath}`);
69
+ }
70
+ const text = readFileSync(planPath, { encoding: 'utf-8' });
71
+ const lines = text.split('\n');
72
+ // Find the section heading
73
+ const sectionHeading = `## ${section}`;
74
+ const headingIndex = lines.findIndex((line) => line.trim() === sectionHeading);
75
+ if (headingIndex === -1) {
76
+ console.log(`${LOG_PREFIX} Section "${section}" not found in plan`);
77
+ return false;
78
+ }
79
+ // Find the next heading (end of this section)
80
+ let nextHeadingIndex = lines.findIndex((line, i) => i > headingIndex && line.trim().startsWith('## '));
81
+ if (nextHeadingIndex === -1) {
82
+ nextHeadingIndex = lines.length;
83
+ }
84
+ // Replace the section content (keep the heading, replace content until next heading)
85
+ const beforeSection = lines.slice(0, headingIndex + 1);
86
+ const afterSection = lines.slice(nextHeadingIndex);
87
+ const newLines = [...beforeSection, '', content, '', ...afterSection];
88
+ writeFileSync(planPath, newLines.join('\n'), { encoding: 'utf-8' });
89
+ console.log(`${LOG_PREFIX} Updated "${section}" section in plan`);
90
+ return true;
91
+ }
92
+ /**
93
+ * Append content to a section in a plan file
94
+ *
95
+ * Adds content at the end of the section, before the next heading.
96
+ *
97
+ * @param planPath - Path to plan file
98
+ * @param section - Section name (without ##)
99
+ * @param content - Content to append
100
+ * @returns True if changes were made, false if section not found
101
+ */
102
+ export function appendToSection(planPath, section, content) {
103
+ if (!existsSync(planPath)) {
104
+ die(`Plan file not found: ${planPath}`);
105
+ }
106
+ const text = readFileSync(planPath, { encoding: 'utf-8' });
107
+ const lines = text.split('\n');
108
+ // Find the section heading
109
+ const sectionHeading = `## ${section}`;
110
+ const headingIndex = lines.findIndex((line) => line.trim() === sectionHeading);
111
+ if (headingIndex === -1) {
112
+ console.log(`${LOG_PREFIX} Section "${section}" not found in plan`);
113
+ return false;
114
+ }
115
+ // Find the next heading (end of this section)
116
+ let nextHeadingIndex = lines.findIndex((line, i) => i > headingIndex && line.trim().startsWith('## '));
117
+ if (nextHeadingIndex === -1) {
118
+ nextHeadingIndex = lines.length;
119
+ }
120
+ // Find the last non-empty line before the next heading
121
+ let insertIndex = nextHeadingIndex;
122
+ for (let i = nextHeadingIndex - 1; i > headingIndex; i--) {
123
+ if (lines[i].trim() !== '') {
124
+ insertIndex = i + 1;
125
+ break;
126
+ }
127
+ }
128
+ // Insert the new content
129
+ lines.splice(insertIndex, 0, content);
130
+ writeFileSync(planPath, lines.join('\n'), { encoding: 'utf-8' });
131
+ console.log(`${LOG_PREFIX} Appended to "${section}" section in plan`);
132
+ return true;
133
+ }
134
+ /**
135
+ * Generate commit message for plan edit operation
136
+ *
137
+ * @param id - WU or Initiative ID
138
+ * @param section - Section that was edited
139
+ * @returns Commit message
140
+ */
141
+ export function getCommitMessage(id, section) {
142
+ const idLower = id.toLowerCase();
143
+ return `docs: update ${section} section in ${idLower} plan`;
144
+ }
145
+ async function main() {
146
+ const SECTION_OPTION = {
147
+ name: 'section',
148
+ flags: '--section <name>',
149
+ description: 'Section name to edit (Goal, Scope, Approach, etc.)',
150
+ };
151
+ const CONTENT_OPTION = {
152
+ name: 'content',
153
+ flags: '--content <text>',
154
+ description: 'New content for the section',
155
+ };
156
+ const APPEND_OPTION = {
157
+ name: 'append',
158
+ flags: '--append <text>',
159
+ description: 'Content to append to the section (instead of replace)',
160
+ };
161
+ const args = createWUParser({
162
+ name: 'plan-edit',
163
+ description: 'Edit a section in a plan file',
164
+ options: [WU_OPTIONS.id, SECTION_OPTION, CONTENT_OPTION, APPEND_OPTION],
165
+ required: ['id', 'section'],
166
+ allowPositionalId: true,
167
+ });
168
+ const id = args.id;
169
+ const section = args.section;
170
+ const content = args.content;
171
+ const appendContent = args.append;
172
+ // Validate we have either content or append
173
+ if (!content && !appendContent) {
174
+ die('Either --content or --append is required');
175
+ }
176
+ // Validate ID format
177
+ if (!WU_ID_PATTERN.test(id) && !INIT_ID_PATTERN.test(id)) {
178
+ die(`Invalid ID format: "${id}"\n\n` + `Expected format: WU-XXX or INIT-XXX`);
179
+ }
180
+ console.log(`${LOG_PREFIX} Editing plan for ${id}...`);
181
+ // Ensure on main for micro-worktree operations
182
+ await ensureOnMain(getGitForCwd());
183
+ try {
184
+ await withMicroWorktree({
185
+ operation: OPERATION_NAME,
186
+ id,
187
+ logPrefix: LOG_PREFIX,
188
+ pushOnly: true,
189
+ execute: async ({ worktreePath }) => {
190
+ const planRelPath = join(WU_PATHS.PLANS_DIR(), `${id}-plan.md`);
191
+ const planAbsPath = join(worktreePath, planRelPath);
192
+ if (!existsSync(planAbsPath)) {
193
+ die(`Plan not found for ${id}\n\n` +
194
+ `Expected path: ${planRelPath}\n\n` +
195
+ `Create it first with: pnpm plan:create --id ${id} --title "Title"`);
196
+ }
197
+ let changed;
198
+ if (appendContent) {
199
+ changed = appendToSection(planAbsPath, section, appendContent);
200
+ }
201
+ else if (content) {
202
+ changed = updatePlanSection(planAbsPath, section, content);
203
+ }
204
+ else {
205
+ // This shouldn't happen due to earlier validation, but satisfy TS
206
+ changed = false;
207
+ }
208
+ if (!changed) {
209
+ console.warn(`${LOG_PREFIX} Section "${section}" not found - no changes made`);
210
+ }
211
+ return {
212
+ commitMessage: getCommitMessage(id, section),
213
+ files: [planRelPath],
214
+ };
215
+ },
216
+ });
217
+ console.log(`\n${LOG_PREFIX} Plan edited successfully!`);
218
+ console.log(`\nEdit Details:`);
219
+ console.log(` ID: ${id}`);
220
+ console.log(` Section: ${section}`);
221
+ console.log(` Mode: ${appendContent ? 'append' : 'replace'}`);
222
+ }
223
+ catch (error) {
224
+ die(`Plan edit failed: ${error.message}\n\n` +
225
+ `Micro-worktree cleanup was attempted automatically.\n` +
226
+ `If issue persists, check for orphaned branches: git branch | grep tmp/${OPERATION_NAME}`);
227
+ }
228
+ }
229
+ // Guard main() for testability
230
+ import { runCLI } from './cli-entry-point.js';
231
+ if (import.meta.main) {
232
+ void runCLI(main);
233
+ }
234
+ // Export for testing
235
+ export { main };
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console -- CLI tool requires console output */
3
+ /* eslint-disable security/detect-non-literal-fs-filename */
4
+ /**
5
+ * Plan Link Command (WU-1313)
6
+ *
7
+ * Links plan files to WUs (via spec_refs) or initiatives (via related_plan).
8
+ * This replaces the initiative:plan command for linking to initiatives.
9
+ *
10
+ * Usage:
11
+ * pnpm plan:link --id WU-1313 --plan lumenflow://plans/WU-1313-plan.md
12
+ * pnpm plan:link --id INIT-001 --plan lumenflow://plans/INIT-001-plan.md
13
+ *
14
+ * Features:
15
+ * - Auto-detects target type (WU or Initiative) from ID format
16
+ * - Updates spec_refs for WUs, related_plan for initiatives
17
+ * - Validates plan file exists before linking
18
+ * - Uses micro-worktree isolation for atomic commits
19
+ * - Idempotent: no error if already linked
20
+ *
21
+ * Context: WU-1313 (INIT-013 Plan Tooling)
22
+ */
23
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
24
+ import { die } from '@lumenflow/core/dist/error-handler.js';
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
28
+ import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
29
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
30
+ import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
31
+ import { INIT_PATHS } from '@lumenflow/initiatives/dist/initiative-paths.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.PLAN_LINK ?? '[plan:link]';
36
+ /** Micro-worktree operation name */
37
+ const OPERATION_NAME = 'plan-link';
38
+ /** LumenFlow URI scheme for plan references */
39
+ const PLAN_URI_SCHEME = 'lumenflow://plans/';
40
+ /** WU ID pattern */
41
+ const WU_ID_PATTERN = /^WU-\d+$/;
42
+ /** Initiative ID pattern */
43
+ const INIT_ID_PATTERN = /^INIT-[A-Z0-9]+$/i;
44
+ /**
45
+ * Resolve the target type from an ID
46
+ *
47
+ * @param id - ID to check (WU-XXX or INIT-XXX)
48
+ * @returns 'wu' or 'initiative'
49
+ * @throws Error if ID format is invalid
50
+ */
51
+ export function resolveTargetType(id) {
52
+ if (!id) {
53
+ die(`ID is required\n\nExpected format: WU-XXX or INIT-XXX`);
54
+ }
55
+ if (WU_ID_PATTERN.test(id)) {
56
+ return 'wu';
57
+ }
58
+ if (INIT_ID_PATTERN.test(id)) {
59
+ return 'initiative';
60
+ }
61
+ die(`Invalid ID format: "${id}"\n\n` +
62
+ `Expected format:\n` +
63
+ ` - WU ID: WU-<number> (e.g., WU-1313)\n` +
64
+ ` - Initiative ID: INIT-<alphanumeric> (e.g., INIT-001, INIT-TOOLING)`);
65
+ // TypeScript requires a return here even though die() never returns
66
+ return 'wu';
67
+ }
68
+ /**
69
+ * Validate that the plan file exists
70
+ *
71
+ * @param worktreePath - Path to repo root or worktree
72
+ * @param planUri - Plan URI (lumenflow://plans/...)
73
+ * @throws Error if plan file doesn't exist
74
+ */
75
+ export function validatePlanExists(worktreePath, planUri) {
76
+ // Extract filename from URI
77
+ const filename = planUri.replace(PLAN_URI_SCHEME, '');
78
+ const plansDir = join(worktreePath, WU_PATHS.PLANS_DIR());
79
+ const planPath = join(plansDir, filename);
80
+ if (!existsSync(planPath)) {
81
+ die(`Plan file not found: ${planPath}\n\n` +
82
+ `Create it first with: pnpm plan:create --id <ID> --title "Title"`);
83
+ }
84
+ }
85
+ /**
86
+ * Link a plan to a WU by updating spec_refs
87
+ *
88
+ * @param worktreePath - Path to repo root or worktree
89
+ * @param wuId - WU ID
90
+ * @param planUri - Plan URI
91
+ * @returns True if changes were made, false if already linked
92
+ */
93
+ export function linkPlanToWU(worktreePath, wuId, planUri) {
94
+ const wuRelPath = WU_PATHS.WU(wuId);
95
+ const wuAbsPath = join(worktreePath, wuRelPath);
96
+ if (!existsSync(wuAbsPath)) {
97
+ die(`WU not found: ${wuId}\n\nFile does not exist: ${wuAbsPath}`);
98
+ }
99
+ // Read raw YAML to preserve all fields
100
+ const rawText = readFileSync(wuAbsPath, { encoding: 'utf-8' });
101
+ const doc = parseYAML(rawText);
102
+ // Check for existing spec_refs
103
+ let specRefs = doc.spec_refs;
104
+ if (!specRefs) {
105
+ specRefs = [];
106
+ }
107
+ // Check if already linked
108
+ if (specRefs.includes(planUri)) {
109
+ console.log(`${LOG_PREFIX} Plan already linked to ${wuId} (idempotent)`);
110
+ return false;
111
+ }
112
+ // Add new plan URI
113
+ specRefs.push(planUri);
114
+ doc.spec_refs = specRefs;
115
+ // Write back
116
+ const out = stringifyYAML(doc, { lineWidth: -1 });
117
+ writeFileSync(wuAbsPath, out, { encoding: 'utf-8' });
118
+ console.log(`${LOG_PREFIX} Linked plan to ${wuId}: ${planUri}`);
119
+ return true;
120
+ }
121
+ /**
122
+ * Link a plan to an initiative by updating related_plan
123
+ *
124
+ * @param worktreePath - Path to repo root or worktree
125
+ * @param initId - Initiative ID
126
+ * @param planUri - Plan URI
127
+ * @returns True if changes were made, false if already linked
128
+ */
129
+ export function linkPlanToInitiative(worktreePath, initId, planUri) {
130
+ const initRelPath = INIT_PATHS.INITIATIVE(initId);
131
+ const initAbsPath = join(worktreePath, initRelPath);
132
+ if (!existsSync(initAbsPath)) {
133
+ die(`Initiative not found: ${initId}\n\nFile does not exist: ${initAbsPath}`);
134
+ }
135
+ // Read raw YAML to preserve all fields
136
+ const rawText = readFileSync(initAbsPath, { encoding: 'utf-8' });
137
+ const doc = parseYAML(rawText);
138
+ // Check for existing related_plan
139
+ const existingPlan = doc.related_plan;
140
+ if (existingPlan === planUri) {
141
+ console.log(`${LOG_PREFIX} Plan already linked to ${initId} (idempotent)`);
142
+ return false;
143
+ }
144
+ if (existingPlan && existingPlan !== planUri) {
145
+ console.warn(`${LOG_PREFIX} Replacing existing related_plan: ${existingPlan} -> ${planUri}`);
146
+ }
147
+ // Update related_plan
148
+ doc.related_plan = planUri;
149
+ // Write back
150
+ const out = stringifyYAML(doc, { lineWidth: -1 });
151
+ writeFileSync(initAbsPath, out, { encoding: 'utf-8' });
152
+ console.log(`${LOG_PREFIX} Linked plan to ${initId}: ${planUri}`);
153
+ return true;
154
+ }
155
+ /**
156
+ * Generate commit message for plan link operation
157
+ *
158
+ * @param id - WU or Initiative ID
159
+ * @param planUri - Plan URI
160
+ * @returns Commit message
161
+ */
162
+ export function getCommitMessage(id, planUri) {
163
+ const idLower = id.toLowerCase();
164
+ const filename = planUri.replace(PLAN_URI_SCHEME, '');
165
+ return `docs: link plan ${filename} to ${idLower}`;
166
+ }
167
+ async function main() {
168
+ const PLAN_OPTION = {
169
+ name: 'plan',
170
+ flags: '--plan <uri>',
171
+ description: 'Plan URI (lumenflow://plans/...)',
172
+ };
173
+ const args = createWUParser({
174
+ name: 'plan-link',
175
+ description: 'Link a plan file to a WU or initiative',
176
+ options: [WU_OPTIONS.id, PLAN_OPTION],
177
+ required: ['id', 'plan'],
178
+ allowPositionalId: true,
179
+ });
180
+ const id = args.id;
181
+ const planUri = args.plan;
182
+ // Resolve target type
183
+ const targetType = resolveTargetType(id);
184
+ console.log(`${LOG_PREFIX} Linking plan to ${targetType === 'wu' ? 'WU' : 'initiative'} ${id}...`);
185
+ // Ensure on main for micro-worktree operations
186
+ await ensureOnMain(getGitForCwd());
187
+ try {
188
+ await withMicroWorktree({
189
+ operation: OPERATION_NAME,
190
+ id,
191
+ logPrefix: LOG_PREFIX,
192
+ pushOnly: true,
193
+ execute: async ({ worktreePath }) => {
194
+ // Validate plan exists
195
+ validatePlanExists(worktreePath, planUri);
196
+ // Link plan based on target type
197
+ let changed;
198
+ let filePath;
199
+ if (targetType === 'wu') {
200
+ changed = linkPlanToWU(worktreePath, id, planUri);
201
+ filePath = WU_PATHS.WU(id);
202
+ }
203
+ else {
204
+ changed = linkPlanToInitiative(worktreePath, id, planUri);
205
+ filePath = INIT_PATHS.INITIATIVE(id);
206
+ }
207
+ if (!changed) {
208
+ console.log(`${LOG_PREFIX} No changes needed (already linked)`);
209
+ }
210
+ return {
211
+ commitMessage: getCommitMessage(id, planUri),
212
+ files: [filePath],
213
+ };
214
+ },
215
+ });
216
+ console.log(`\n${LOG_PREFIX} Plan linked successfully!`);
217
+ console.log(`\nLink Details:`);
218
+ console.log(` Target: ${id} (${targetType})`);
219
+ console.log(` Plan: ${planUri}`);
220
+ }
221
+ catch (error) {
222
+ die(`Plan linking failed: ${error.message}\n\n` +
223
+ `Micro-worktree cleanup was attempted automatically.\n` +
224
+ `If issue persists, check for orphaned branches: git branch | grep tmp/${OPERATION_NAME}`);
225
+ }
226
+ }
227
+ // Guard main() for testability
228
+ import { runCLI } from './cli-entry-point.js';
229
+ if (import.meta.main) {
230
+ void runCLI(main);
231
+ }
232
+ // Export for testing
233
+ export { main };
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console -- CLI tool requires console output */
3
+ /* eslint-disable security/detect-non-literal-fs-filename */
4
+ /**
5
+ * Plan Promote Command (WU-1313)
6
+ *
7
+ * Promotes a plan from draft to approved status.
8
+ * Validates that required sections are complete before approving.
9
+ *
10
+ * Usage:
11
+ * pnpm plan:promote --id WU-1313
12
+ * pnpm plan:promote --id INIT-001 --force # Skip validation
13
+ *
14
+ * Features:
15
+ * - Validates plan completeness (non-empty required sections)
16
+ * - Adds approved status and timestamp
17
+ * - Uses micro-worktree isolation for atomic commits
18
+ * - Idempotent: no error if already approved
19
+ *
20
+ * Context: WU-1313 (INIT-013 Plan Tooling)
21
+ */
22
+ import { getGitForCwd } from '@lumenflow/core/dist/git-adapter.js';
23
+ import { die } from '@lumenflow/core/dist/error-handler.js';
24
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
27
+ import { ensureOnMain } from '@lumenflow/core/dist/wu-helpers.js';
28
+ import { withMicroWorktree } from '@lumenflow/core/dist/micro-worktree.js';
29
+ import { WU_PATHS } from '@lumenflow/core/dist/wu-paths.js';
30
+ import { todayISO } from '@lumenflow/core/dist/date-utils.js';
31
+ import { LOG_PREFIX as CORE_LOG_PREFIX } from '@lumenflow/core/dist/wu-constants.js';
32
+ /** Log prefix for console output */
33
+ export const LOG_PREFIX = CORE_LOG_PREFIX.PLAN_PROMOTE ?? '[plan:promote]';
34
+ /** Micro-worktree operation name */
35
+ const OPERATION_NAME = 'plan-promote';
36
+ /** WU ID pattern */
37
+ const WU_ID_PATTERN = /^WU-\d+$/;
38
+ /** Initiative ID pattern */
39
+ const INIT_ID_PATTERN = /^INIT-[A-Z0-9]+$/i;
40
+ /** Required sections that must have content */
41
+ const REQUIRED_SECTIONS = ['Goal', 'Scope', 'Approach'];
42
+ /** Status marker patterns */
43
+ const STATUS_APPROVED_PATTERN = /^Status:\s*approved/im;
44
+ /**
45
+ * Get the path to a plan file from its ID
46
+ *
47
+ * @param id - WU or Initiative ID
48
+ * @returns Path to plan file
49
+ * @throws Error if plan not found
50
+ */
51
+ export function getPlanPath(id) {
52
+ const plansDir = WU_PATHS.PLANS_DIR();
53
+ const planPath = join(plansDir, `${id}-plan.md`);
54
+ if (!existsSync(planPath)) {
55
+ die(`Plan not found for ${id}\n\n` +
56
+ `Expected path: ${planPath}\n\n` +
57
+ `Create it first with: pnpm plan:create --id ${id} --title "Title"`);
58
+ }
59
+ return planPath;
60
+ }
61
+ /**
62
+ * Validate that a plan has all required sections with content
63
+ *
64
+ * @param planPath - Path to plan file
65
+ * @returns Validation result with valid flag and errors array
66
+ */
67
+ export function validatePlanComplete(planPath) {
68
+ if (!existsSync(planPath)) {
69
+ return { valid: false, errors: ['Plan file not found'] };
70
+ }
71
+ const text = readFileSync(planPath, { encoding: 'utf-8' });
72
+ const errors = [];
73
+ for (const section of REQUIRED_SECTIONS) {
74
+ const sectionHeading = `## ${section}`;
75
+ const headingIndex = text.indexOf(sectionHeading);
76
+ if (headingIndex === -1) {
77
+ errors.push(`Missing required section: ${section}`);
78
+ continue;
79
+ }
80
+ // Find the content between this heading and the next
81
+ const afterHeading = text.substring(headingIndex + sectionHeading.length);
82
+ const nextHeadingIndex = afterHeading.indexOf('\n## ');
83
+ const sectionContent = nextHeadingIndex >= 0 ? afterHeading.substring(0, nextHeadingIndex) : afterHeading;
84
+ // Check if content is just whitespace, empty, or only comments
85
+ const trimmedContent = sectionContent
86
+ .split('\n')
87
+ .filter((line) => !line.trim().startsWith('<!--') && line.trim() !== '-->')
88
+ .join('\n')
89
+ .trim();
90
+ if (trimmedContent === '' || trimmedContent.length < 10) {
91
+ errors.push(`${section} section is empty or too short`);
92
+ }
93
+ }
94
+ return {
95
+ valid: errors.length === 0,
96
+ errors,
97
+ };
98
+ }
99
+ /**
100
+ * Promote a plan to approved status
101
+ *
102
+ * Adds Status: approved and Approved: date after the Created line.
103
+ *
104
+ * @param planPath - Path to plan file
105
+ * @returns True if changes were made, false if already approved
106
+ */
107
+ export function promotePlan(planPath) {
108
+ if (!existsSync(planPath)) {
109
+ die(`Plan file not found: ${planPath}`);
110
+ }
111
+ const text = readFileSync(planPath, { encoding: 'utf-8' });
112
+ // Check if already approved
113
+ if (STATUS_APPROVED_PATTERN.test(text)) {
114
+ console.log(`${LOG_PREFIX} Plan already approved (idempotent)`);
115
+ return false;
116
+ }
117
+ // Find the Created: line and insert status after it
118
+ // Use specific pattern to avoid backtracking (sonarjs/slow-regex)
119
+ const createdPattern = /^Created:\s*\S.*$/m;
120
+ const createdMatch = createdPattern.exec(text);
121
+ const today = todayISO();
122
+ const statusLines = `Status: approved\nApproved: ${today}`;
123
+ let newText;
124
+ if (createdMatch && createdMatch.index !== undefined) {
125
+ // Insert after Created: line
126
+ const insertPos = createdMatch.index + createdMatch[0].length;
127
+ newText = text.substring(0, insertPos) + '\n' + statusLines + text.substring(insertPos);
128
+ }
129
+ else {
130
+ // No Created: line found, insert after first heading
131
+ const firstHeadingPattern = /^# .+$/m;
132
+ const firstHeadingMatch = firstHeadingPattern.exec(text);
133
+ if (firstHeadingMatch && firstHeadingMatch.index !== undefined) {
134
+ const insertPos = firstHeadingMatch.index + firstHeadingMatch[0].length;
135
+ newText =
136
+ text.substring(0, insertPos) + '\n\n' + statusLines + '\n' + text.substring(insertPos);
137
+ }
138
+ else {
139
+ // Fallback: prepend to file
140
+ newText = statusLines + '\n\n' + text;
141
+ }
142
+ }
143
+ writeFileSync(planPath, newText, { encoding: 'utf-8' });
144
+ console.log(`${LOG_PREFIX} Plan promoted to approved status`);
145
+ return true;
146
+ }
147
+ /**
148
+ * Generate commit message for plan promote operation
149
+ *
150
+ * @param id - WU or Initiative ID
151
+ * @returns Commit message
152
+ */
153
+ export function getCommitMessage(id) {
154
+ const idLower = id.toLowerCase();
155
+ return `docs: promote ${idLower} plan to approved`;
156
+ }
157
+ async function main() {
158
+ const FORCE_OPTION = {
159
+ name: 'force',
160
+ flags: '-f, --force',
161
+ description: 'Skip validation and promote anyway',
162
+ };
163
+ const args = createWUParser({
164
+ name: 'plan-promote',
165
+ description: 'Promote a plan to approved status',
166
+ options: [WU_OPTIONS.id, FORCE_OPTION],
167
+ required: ['id'],
168
+ allowPositionalId: true,
169
+ });
170
+ const id = args.id;
171
+ const force = args.force;
172
+ // Validate ID format
173
+ if (!WU_ID_PATTERN.test(id) && !INIT_ID_PATTERN.test(id)) {
174
+ die(`Invalid ID format: "${id}"\n\n` + `Expected format: WU-XXX or INIT-XXX`);
175
+ }
176
+ console.log(`${LOG_PREFIX} Promoting plan for ${id}...`);
177
+ // Ensure on main for micro-worktree operations
178
+ await ensureOnMain(getGitForCwd());
179
+ try {
180
+ await withMicroWorktree({
181
+ operation: OPERATION_NAME,
182
+ id,
183
+ logPrefix: LOG_PREFIX,
184
+ pushOnly: true,
185
+ execute: async ({ worktreePath }) => {
186
+ const planRelPath = join(WU_PATHS.PLANS_DIR(), `${id}-plan.md`);
187
+ const planAbsPath = join(worktreePath, planRelPath);
188
+ if (!existsSync(planAbsPath)) {
189
+ die(`Plan not found for ${id}\n\n` +
190
+ `Expected path: ${planRelPath}\n\n` +
191
+ `Create it first with: pnpm plan:create --id ${id} --title "Title"`);
192
+ }
193
+ // Validate plan completeness (unless force)
194
+ if (!force) {
195
+ const validation = validatePlanComplete(planAbsPath);
196
+ if (!validation.valid) {
197
+ const errorList = validation.errors.map((e) => ` - ${e}`).join('\n');
198
+ die(`Plan validation failed:\n\n${errorList}\n\n` +
199
+ `Fix these issues or use --force to skip validation.`);
200
+ }
201
+ console.log(`${LOG_PREFIX} Plan validation passed`);
202
+ }
203
+ // Promote the plan
204
+ const changed = promotePlan(planAbsPath);
205
+ if (!changed) {
206
+ console.log(`${LOG_PREFIX} No changes needed (already approved)`);
207
+ }
208
+ return {
209
+ commitMessage: getCommitMessage(id),
210
+ files: [planRelPath],
211
+ };
212
+ },
213
+ });
214
+ console.log(`\n${LOG_PREFIX} Plan promoted successfully!`);
215
+ console.log(`\nPromotion Details:`);
216
+ console.log(` ID: ${id}`);
217
+ console.log(` Status: approved`);
218
+ }
219
+ catch (error) {
220
+ die(`Plan promotion failed: ${error.message}\n\n` +
221
+ `Micro-worktree cleanup was attempted automatically.\n` +
222
+ `If issue persists, check for orphaned branches: git branch | grep tmp/${OPERATION_NAME}`);
223
+ }
224
+ }
225
+ // Guard main() for testability
226
+ import { runCLI } from './cli-entry-point.js';
227
+ if (import.meta.main) {
228
+ void runCLI(main);
229
+ }
230
+ // Export for testing
231
+ export { main };