@lumenflow/cli 2.4.0 → 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 (124) 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 +7 -2
  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/gates.js +22 -0
  23. package/dist/init.js +580 -87
  24. package/dist/initiative-add-wu.js +112 -16
  25. package/dist/initiative-remove-wu.js +248 -0
  26. package/dist/onboarding-smoke-test.js +400 -0
  27. package/dist/plan-create.js +199 -0
  28. package/dist/plan-edit.js +235 -0
  29. package/dist/plan-link.js +233 -0
  30. package/dist/plan-promote.js +231 -0
  31. package/dist/wu-block.js +16 -5
  32. package/dist/wu-claim.js +15 -9
  33. package/dist/wu-create.js +50 -2
  34. package/dist/wu-deps.js +3 -1
  35. package/dist/wu-done.js +14 -5
  36. package/dist/wu-edit.js +35 -0
  37. package/dist/wu-spawn.js +8 -0
  38. package/dist/wu-unblock.js +34 -2
  39. package/dist/wu-validate.js +25 -17
  40. package/package.json +10 -6
  41. package/templates/core/AGENTS.md.template +2 -2
  42. package/dist/__tests__/init-plan.test.js +0 -340
  43. package/dist/agent-issues-query.d.ts +0 -16
  44. package/dist/agent-log-issue.d.ts +0 -10
  45. package/dist/agent-session-end.d.ts +0 -10
  46. package/dist/agent-session.d.ts +0 -10
  47. package/dist/backlog-prune.d.ts +0 -84
  48. package/dist/cli-entry-point.d.ts +0 -8
  49. package/dist/deps-add.d.ts +0 -91
  50. package/dist/deps-remove.d.ts +0 -17
  51. package/dist/docs-sync.d.ts +0 -50
  52. package/dist/file-delete.d.ts +0 -84
  53. package/dist/file-edit.d.ts +0 -82
  54. package/dist/file-read.d.ts +0 -92
  55. package/dist/file-write.d.ts +0 -90
  56. package/dist/flow-bottlenecks.d.ts +0 -16
  57. package/dist/flow-report.d.ts +0 -16
  58. package/dist/gates.d.ts +0 -94
  59. package/dist/git-branch.d.ts +0 -65
  60. package/dist/git-diff.d.ts +0 -58
  61. package/dist/git-log.d.ts +0 -69
  62. package/dist/git-status.d.ts +0 -58
  63. package/dist/guard-locked.d.ts +0 -62
  64. package/dist/guard-main-branch.d.ts +0 -50
  65. package/dist/guard-worktree-commit.d.ts +0 -59
  66. package/dist/index.d.ts +0 -10
  67. package/dist/init-plan.d.ts +0 -80
  68. package/dist/init-plan.js +0 -337
  69. package/dist/init.d.ts +0 -46
  70. package/dist/initiative-add-wu.d.ts +0 -22
  71. package/dist/initiative-bulk-assign-wus.d.ts +0 -16
  72. package/dist/initiative-create.d.ts +0 -28
  73. package/dist/initiative-edit.d.ts +0 -34
  74. package/dist/initiative-list.d.ts +0 -12
  75. package/dist/initiative-status.d.ts +0 -11
  76. package/dist/lumenflow-upgrade.d.ts +0 -103
  77. package/dist/mem-checkpoint.d.ts +0 -16
  78. package/dist/mem-cleanup.d.ts +0 -29
  79. package/dist/mem-create.d.ts +0 -17
  80. package/dist/mem-export.d.ts +0 -10
  81. package/dist/mem-inbox.d.ts +0 -35
  82. package/dist/mem-init.d.ts +0 -15
  83. package/dist/mem-ready.d.ts +0 -16
  84. package/dist/mem-signal.d.ts +0 -16
  85. package/dist/mem-start.d.ts +0 -16
  86. package/dist/mem-summarize.d.ts +0 -22
  87. package/dist/mem-triage.d.ts +0 -22
  88. package/dist/metrics-cli.d.ts +0 -90
  89. package/dist/metrics-snapshot.d.ts +0 -18
  90. package/dist/orchestrate-init-status.d.ts +0 -11
  91. package/dist/orchestrate-initiative.d.ts +0 -12
  92. package/dist/orchestrate-monitor.d.ts +0 -11
  93. package/dist/release.d.ts +0 -117
  94. package/dist/rotate-progress.d.ts +0 -48
  95. package/dist/session-coordinator.d.ts +0 -74
  96. package/dist/spawn-list.d.ts +0 -16
  97. package/dist/state-bootstrap.d.ts +0 -92
  98. package/dist/sync-templates.d.ts +0 -52
  99. package/dist/trace-gen.d.ts +0 -84
  100. package/dist/validate-agent-skills.d.ts +0 -50
  101. package/dist/validate-agent-sync.d.ts +0 -36
  102. package/dist/validate-backlog-sync.d.ts +0 -37
  103. package/dist/validate-skills-spec.d.ts +0 -40
  104. package/dist/validate.d.ts +0 -60
  105. package/dist/wu-block.d.ts +0 -16
  106. package/dist/wu-claim.d.ts +0 -74
  107. package/dist/wu-cleanup.d.ts +0 -35
  108. package/dist/wu-create.d.ts +0 -69
  109. package/dist/wu-delete.d.ts +0 -21
  110. package/dist/wu-deps.d.ts +0 -13
  111. package/dist/wu-done.d.ts +0 -225
  112. package/dist/wu-edit.d.ts +0 -63
  113. package/dist/wu-infer-lane.d.ts +0 -17
  114. package/dist/wu-preflight.d.ts +0 -47
  115. package/dist/wu-prune.d.ts +0 -16
  116. package/dist/wu-recover.d.ts +0 -37
  117. package/dist/wu-release.d.ts +0 -19
  118. package/dist/wu-repair.d.ts +0 -60
  119. package/dist/wu-spawn-completion.d.ts +0 -10
  120. package/dist/wu-spawn.d.ts +0 -192
  121. package/dist/wu-status.d.ts +0 -25
  122. package/dist/wu-unblock.d.ts +0 -16
  123. package/dist/wu-unlock-lane.d.ts +0 -19
  124. package/dist/wu-validate.d.ts +0 -16
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tests for initiative:plan replacement by plan:link (WU-1313)
3
+ *
4
+ * Validates that the existing initiative:plan functionality is preserved
5
+ * when replaced by plan:link --target INIT-XXX.
6
+ *
7
+ * TDD: These tests are written BEFORE the implementation.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { parseYAML, stringifyYAML } from '@lumenflow/core/dist/wu-yaml.js';
14
+ /** Test constants - avoid sonarjs/no-duplicate-string */
15
+ const TEST_INIT_DIR = 'docs/04-operations/tasks/initiatives';
16
+ const TEST_INIT_ID = 'INIT-001';
17
+ const TEST_INIT_PLAN_URI = `lumenflow://plans/${TEST_INIT_ID}-plan.md`;
18
+ const TEST_PLANS_DIR = 'docs/04-operations/plans';
19
+ // eslint-disable-next-line sonarjs/publicly-writable-directories -- test constant for bad path detection
20
+ const TEST_LUMENFLOW_HOME_BAD = '/tmp/lumenflow-home-should-not-be-used';
21
+ const TEST_INIT_SLUG = 'test-initiative';
22
+ const TEST_INIT_TITLE = 'Test Initiative';
23
+ const TEST_STATUS_OPEN = 'open';
24
+ const TEST_DATE = '2026-01-25';
25
+ // Mock modules before importing
26
+ vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
27
+ getGitForCwd: vi.fn(() => ({
28
+ branch: vi.fn().mockResolvedValue({ current: 'main' }),
29
+ status: vi.fn().mockResolvedValue({ isClean: () => true }),
30
+ })),
31
+ }));
32
+ vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
33
+ ensureOnMain: vi.fn().mockResolvedValue(undefined),
34
+ }));
35
+ vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
36
+ withMicroWorktree: vi.fn(async ({ execute }) => {
37
+ const tempDir = join(tmpdir(), `init-plan-replace-test-${Date.now()}`);
38
+ mkdirSync(tempDir, { recursive: true });
39
+ return execute({ worktreePath: tempDir });
40
+ }),
41
+ }));
42
+ describe('initiative:plan replaced by plan:link', () => {
43
+ let tempDir;
44
+ let originalCwd;
45
+ beforeEach(() => {
46
+ tempDir = join(tmpdir(), `init-plan-replace-test-${Date.now()}`);
47
+ mkdirSync(tempDir, { recursive: true });
48
+ originalCwd = process.cwd();
49
+ });
50
+ afterEach(() => {
51
+ process.chdir(originalCwd);
52
+ if (existsSync(tempDir)) {
53
+ rmSync(tempDir, { recursive: true, force: true });
54
+ }
55
+ vi.clearAllMocks();
56
+ });
57
+ describe('plan:link for initiatives (backwards compatibility)', () => {
58
+ it('should link plan to initiative via related_plan field', async () => {
59
+ const { linkPlanToInitiative } = await import('../plan-link.js');
60
+ // Setup mock initiative file
61
+ const initDir = join(tempDir, ...TEST_INIT_DIR.split('/'));
62
+ mkdirSync(initDir, { recursive: true });
63
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
64
+ const initDoc = {
65
+ id: TEST_INIT_ID,
66
+ slug: TEST_INIT_SLUG,
67
+ title: TEST_INIT_TITLE,
68
+ status: TEST_STATUS_OPEN,
69
+ created: TEST_DATE,
70
+ };
71
+ writeFileSync(initPath, stringifyYAML(initDoc));
72
+ // Link plan (same operation as initiative:plan --initiative --plan)
73
+ const changed = linkPlanToInitiative(tempDir, TEST_INIT_ID, TEST_INIT_PLAN_URI);
74
+ expect(changed).toBe(true);
75
+ // Verify the file was updated
76
+ const updated = parseYAML(readFileSync(initPath, 'utf-8'));
77
+ expect(updated.related_plan).toBe(TEST_INIT_PLAN_URI);
78
+ });
79
+ it('should create plan and link in single operation', async () => {
80
+ const { createPlan } = await import('../plan-create.js');
81
+ const { linkPlanToInitiative } = await import('../plan-link.js');
82
+ // Setup mock initiative file
83
+ const initDir = join(tempDir, ...TEST_INIT_DIR.split('/'));
84
+ mkdirSync(initDir, { recursive: true });
85
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
86
+ const initDoc = {
87
+ id: TEST_INIT_ID,
88
+ slug: TEST_INIT_SLUG,
89
+ title: TEST_INIT_TITLE,
90
+ status: TEST_STATUS_OPEN,
91
+ created: TEST_DATE,
92
+ };
93
+ writeFileSync(initPath, stringifyYAML(initDoc));
94
+ // Create plan (like initiative:plan --create)
95
+ const planPath = createPlan(tempDir, TEST_INIT_ID, TEST_INIT_TITLE);
96
+ expect(existsSync(planPath)).toBe(true);
97
+ // Link plan
98
+ const changed = linkPlanToInitiative(tempDir, TEST_INIT_ID, TEST_INIT_PLAN_URI);
99
+ expect(changed).toBe(true);
100
+ // Verify both plan file and initiative were updated
101
+ const updated = parseYAML(readFileSync(initPath, 'utf-8'));
102
+ expect(updated.related_plan).toBe(TEST_INIT_PLAN_URI);
103
+ });
104
+ });
105
+ describe('initiative:plan deprecation', () => {
106
+ it('should warn when using deprecated initiative:plan command', async () => {
107
+ // The initiative:plan command should still work but warn about deprecation
108
+ // and suggest using plan:link instead
109
+ const initPlan = await import('../initiative-plan.js');
110
+ expect(typeof initPlan.main).toBe('function');
111
+ // The deprecation warning will be in the main function
112
+ });
113
+ });
114
+ describe('plan:link auto-detection', () => {
115
+ it('should auto-detect INIT target and call linkPlanToInitiative', async () => {
116
+ const { resolveTargetType } = await import('../plan-link.js');
117
+ expect(resolveTargetType(TEST_INIT_ID)).toBe('initiative');
118
+ expect(resolveTargetType('INIT-TOOLING')).toBe('initiative');
119
+ });
120
+ it('should auto-detect WU target and call linkPlanToWU', async () => {
121
+ const { resolveTargetType } = await import('../plan-link.js');
122
+ expect(resolveTargetType('WU-1313')).toBe('wu');
123
+ expect(resolveTargetType('WU-001')).toBe('wu');
124
+ });
125
+ });
126
+ });
127
+ describe('plan storage defaults to repo plansDir', () => {
128
+ let tempDir;
129
+ let originalCwd;
130
+ beforeEach(() => {
131
+ tempDir = join(tmpdir(), `plan-storage-test-${Date.now()}`);
132
+ mkdirSync(tempDir, { recursive: true });
133
+ originalCwd = process.cwd();
134
+ });
135
+ afterEach(() => {
136
+ process.chdir(originalCwd);
137
+ if (existsSync(tempDir)) {
138
+ rmSync(tempDir, { recursive: true, force: true });
139
+ }
140
+ vi.clearAllMocks();
141
+ });
142
+ it('should create plans in repo directories.plansDir, not LUMENFLOW_HOME', async () => {
143
+ const { createPlan } = await import('../plan-create.js');
144
+ // Set LUMENFLOW_HOME to a different location (should be ignored)
145
+ const oldLfHome = process.env.LUMENFLOW_HOME;
146
+ process.env.LUMENFLOW_HOME = TEST_LUMENFLOW_HOME_BAD;
147
+ try {
148
+ const planPath = createPlan(tempDir, 'WU-1313', 'Test Plan');
149
+ // Plan should be in repo plansDir, not LUMENFLOW_HOME
150
+ expect(planPath).toContain(TEST_PLANS_DIR);
151
+ expect(planPath).not.toContain(TEST_LUMENFLOW_HOME_BAD);
152
+ }
153
+ finally {
154
+ if (oldLfHome === undefined) {
155
+ delete process.env.LUMENFLOW_HOME;
156
+ }
157
+ else {
158
+ process.env.LUMENFLOW_HOME = oldLfHome;
159
+ }
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Tests for initiative:remove-wu command (WU-1328)
3
+ *
4
+ * The initiative:remove-wu command unlinks a WU from an initiative bidirectionally:
5
+ * 1. Removes `initiative` field from WU YAML
6
+ * 2. Removes WU ID from initiative `wus: []` array
7
+ *
8
+ * Uses micro-worktree isolation for atomic operations.
9
+ *
10
+ * TDD: These tests are written BEFORE the implementation.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
13
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { parseYAML, stringifyYAML } from '@lumenflow/core/dist/wu-yaml.js';
17
+ // Test constants to avoid lint warnings about duplicate strings
18
+ const TEST_WU_ID = 'WU-123';
19
+ const TEST_WU_ID_2 = 'WU-456';
20
+ const TEST_WU_ID_3 = 'WU-789';
21
+ const TEST_INIT_ID = 'INIT-001';
22
+ const TEST_INIT_ID_2 = 'INIT-002';
23
+ const TEST_LANE = 'Framework: CLI';
24
+ const WU_REL_PATH = 'docs/04-operations/tasks/wu';
25
+ const INIT_REL_PATH = 'docs/04-operations/tasks/initiatives';
26
+ const TEST_INIT_SLUG = 'test-initiative';
27
+ const TEST_INIT_TITLE = 'Test Initiative';
28
+ const TEST_INIT_STATUS = 'open';
29
+ const TEST_DATE = '2026-01-25';
30
+ // Pre-import the module to ensure coverage tracking includes the module itself
31
+ beforeAll(async () => {
32
+ await import('../initiative-remove-wu.js');
33
+ });
34
+ // Mock modules before importing the module under test
35
+ const mockGit = {
36
+ branch: vi.fn().mockResolvedValue({ current: 'main' }),
37
+ status: vi.fn().mockResolvedValue({ isClean: () => true }),
38
+ };
39
+ vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
40
+ getGitForCwd: vi.fn(() => mockGit),
41
+ }));
42
+ vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
43
+ ensureOnMain: vi.fn().mockResolvedValue(undefined),
44
+ }));
45
+ vi.mock('@lumenflow/core/dist/micro-worktree.js', async (importOriginal) => {
46
+ const actual = await importOriginal();
47
+ return {
48
+ ...actual,
49
+ withMicroWorktree: vi.fn(async ({ execute }) => {
50
+ // Simulate micro-worktree by executing in temp dir
51
+ const tempDir = join(tmpdir(), `init-remove-wu-test-${Date.now()}`);
52
+ mkdirSync(tempDir, { recursive: true });
53
+ try {
54
+ await execute({ worktreePath: tempDir });
55
+ }
56
+ finally {
57
+ // Cleanup handled by test
58
+ }
59
+ }),
60
+ };
61
+ });
62
+ describe('initiative:remove-wu command', () => {
63
+ let tempDir;
64
+ let originalCwd;
65
+ beforeEach(() => {
66
+ tempDir = join(tmpdir(), `init-remove-wu-test-${Date.now()}`);
67
+ mkdirSync(tempDir, { recursive: true });
68
+ originalCwd = process.cwd();
69
+ });
70
+ afterEach(() => {
71
+ process.chdir(originalCwd);
72
+ if (existsSync(tempDir)) {
73
+ rmSync(tempDir, { recursive: true, force: true });
74
+ }
75
+ vi.clearAllMocks();
76
+ });
77
+ describe('validateInitIdFormat', () => {
78
+ it('should accept valid INIT-NNN format', async () => {
79
+ const { validateInitIdFormat } = await import('../initiative-remove-wu.js');
80
+ // Should not throw
81
+ expect(() => validateInitIdFormat('INIT-001')).not.toThrow();
82
+ expect(() => validateInitIdFormat('INIT-123')).not.toThrow();
83
+ });
84
+ it('should accept valid INIT-NAME format', async () => {
85
+ const { validateInitIdFormat } = await import('../initiative-remove-wu.js');
86
+ expect(() => validateInitIdFormat('INIT-TOOLING')).not.toThrow();
87
+ expect(() => validateInitIdFormat('INIT-A1')).not.toThrow();
88
+ });
89
+ it('should reject invalid formats', async () => {
90
+ const { validateInitIdFormat } = await import('../initiative-remove-wu.js');
91
+ expect(() => validateInitIdFormat('init-001')).toThrow();
92
+ expect(() => validateInitIdFormat('INIT001')).toThrow();
93
+ expect(() => validateInitIdFormat('WU-001')).toThrow();
94
+ expect(() => validateInitIdFormat('')).toThrow();
95
+ });
96
+ });
97
+ describe('validateWuIdFormat', () => {
98
+ it('should accept valid WU-NNN format', async () => {
99
+ const { validateWuIdFormat } = await import('../initiative-remove-wu.js');
100
+ expect(() => validateWuIdFormat('WU-123')).not.toThrow();
101
+ expect(() => validateWuIdFormat('WU-1')).not.toThrow();
102
+ expect(() => validateWuIdFormat('WU-99999')).not.toThrow();
103
+ });
104
+ it('should reject invalid formats', async () => {
105
+ const { validateWuIdFormat } = await import('../initiative-remove-wu.js');
106
+ expect(() => validateWuIdFormat('wu-123')).toThrow();
107
+ expect(() => validateWuIdFormat('WU123')).toThrow();
108
+ expect(() => validateWuIdFormat('INIT-001')).toThrow();
109
+ expect(() => validateWuIdFormat('')).toThrow();
110
+ });
111
+ });
112
+ describe('checkWUExists', () => {
113
+ it('should return WU doc if found', async () => {
114
+ const { checkWUExists } = await import('../initiative-remove-wu.js');
115
+ // Create a mock WU file
116
+ const wuDir = join(tempDir, WU_REL_PATH);
117
+ mkdirSync(wuDir, { recursive: true });
118
+ const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
119
+ const wuDoc = {
120
+ id: TEST_WU_ID,
121
+ title: 'Test WU',
122
+ lane: TEST_LANE,
123
+ status: 'ready',
124
+ initiative: TEST_INIT_ID,
125
+ };
126
+ writeFileSync(wuPath, stringifyYAML(wuDoc));
127
+ process.chdir(tempDir);
128
+ const result = checkWUExists(TEST_WU_ID);
129
+ expect(result.id).toBe(TEST_WU_ID);
130
+ expect(result.initiative).toBe(TEST_INIT_ID);
131
+ });
132
+ it('should throw if WU not found', async () => {
133
+ const { checkWUExists } = await import('../initiative-remove-wu.js');
134
+ process.chdir(tempDir);
135
+ expect(() => checkWUExists('WU-999')).toThrow();
136
+ });
137
+ });
138
+ describe('checkInitiativeExists', () => {
139
+ it('should return initiative doc if found', async () => {
140
+ const { checkInitiativeExists } = await import('../initiative-remove-wu.js');
141
+ // Create a mock initiative file
142
+ const initDir = join(tempDir, INIT_REL_PATH);
143
+ mkdirSync(initDir, { recursive: true });
144
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
145
+ const initDoc = {
146
+ id: TEST_INIT_ID,
147
+ slug: TEST_INIT_SLUG,
148
+ title: TEST_INIT_TITLE,
149
+ status: TEST_INIT_STATUS,
150
+ created: TEST_DATE,
151
+ wus: [TEST_WU_ID, TEST_WU_ID_2],
152
+ };
153
+ writeFileSync(initPath, stringifyYAML(initDoc));
154
+ process.chdir(tempDir);
155
+ const result = checkInitiativeExists(TEST_INIT_ID);
156
+ expect(result.id).toBe(TEST_INIT_ID);
157
+ expect(result.wus).toContain(TEST_WU_ID);
158
+ });
159
+ it('should throw if initiative not found', async () => {
160
+ const { checkInitiativeExists } = await import('../initiative-remove-wu.js');
161
+ process.chdir(tempDir);
162
+ expect(() => checkInitiativeExists('INIT-999')).toThrow();
163
+ });
164
+ });
165
+ describe('checkWUIsLinked', () => {
166
+ it('should return true if WU is linked to initiative', async () => {
167
+ const { checkWUIsLinked } = await import('../initiative-remove-wu.js');
168
+ const wuDoc = { initiative: TEST_INIT_ID };
169
+ const initDoc = { wus: [TEST_WU_ID, TEST_WU_ID_2] };
170
+ expect(checkWUIsLinked(wuDoc, initDoc, TEST_WU_ID, TEST_INIT_ID)).toBe(true);
171
+ });
172
+ it('should return false if WU is not linked (no initiative field)', async () => {
173
+ const { checkWUIsLinked } = await import('../initiative-remove-wu.js');
174
+ const wuDoc = { initiative: undefined }; // No initiative field
175
+ const initDoc = { wus: [TEST_WU_ID_2] };
176
+ expect(checkWUIsLinked(wuDoc, initDoc, TEST_WU_ID, TEST_INIT_ID)).toBe(false);
177
+ });
178
+ it('should return false if WU is not in initiative wus list', async () => {
179
+ const { checkWUIsLinked } = await import('../initiative-remove-wu.js');
180
+ const wuDoc = { initiative: TEST_INIT_ID };
181
+ const initDoc = { wus: [TEST_WU_ID_2] }; // TEST_WU_ID not in list
182
+ expect(checkWUIsLinked(wuDoc, initDoc, TEST_WU_ID, TEST_INIT_ID)).toBe(false);
183
+ });
184
+ it('should return false if WU is linked to different initiative', async () => {
185
+ const { checkWUIsLinked } = await import('../initiative-remove-wu.js');
186
+ const wuDoc = { initiative: TEST_INIT_ID_2 }; // Different initiative
187
+ const initDoc = { wus: [TEST_WU_ID] };
188
+ expect(checkWUIsLinked(wuDoc, initDoc, TEST_WU_ID, TEST_INIT_ID)).toBe(false);
189
+ });
190
+ });
191
+ describe('updateWUInWorktree (remove initiative)', () => {
192
+ it('should remove initiative field from WU', async () => {
193
+ const { updateWUInWorktree } = await import('../initiative-remove-wu.js');
194
+ // Setup mock WU
195
+ const wuDir = join(tempDir, WU_REL_PATH);
196
+ mkdirSync(wuDir, { recursive: true });
197
+ const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
198
+ const wuDoc = {
199
+ id: TEST_WU_ID,
200
+ title: 'Test WU',
201
+ lane: TEST_LANE,
202
+ status: 'in_progress',
203
+ initiative: TEST_INIT_ID,
204
+ };
205
+ writeFileSync(wuPath, stringifyYAML(wuDoc));
206
+ // Update WU
207
+ const changed = updateWUInWorktree(tempDir, TEST_WU_ID, TEST_INIT_ID);
208
+ expect(changed).toBe(true);
209
+ // Verify the file was updated
210
+ const updated = parseYAML(readFileSync(wuPath, 'utf-8'));
211
+ expect(updated.initiative).toBeUndefined();
212
+ });
213
+ it('should return false if initiative field does not exist (idempotent)', async () => {
214
+ const { updateWUInWorktree } = await import('../initiative-remove-wu.js');
215
+ // Setup mock WU without initiative field
216
+ const wuDir = join(tempDir, WU_REL_PATH);
217
+ mkdirSync(wuDir, { recursive: true });
218
+ const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
219
+ const wuDoc = {
220
+ id: TEST_WU_ID,
221
+ title: 'Test WU',
222
+ lane: TEST_LANE,
223
+ status: 'ready',
224
+ };
225
+ writeFileSync(wuPath, stringifyYAML(wuDoc));
226
+ // Update WU
227
+ const changed = updateWUInWorktree(tempDir, TEST_WU_ID, TEST_INIT_ID);
228
+ expect(changed).toBe(false);
229
+ });
230
+ it('should return false if initiative field is different (idempotent)', async () => {
231
+ const { updateWUInWorktree } = await import('../initiative-remove-wu.js');
232
+ // Setup mock WU with different initiative
233
+ const wuDir = join(tempDir, WU_REL_PATH);
234
+ mkdirSync(wuDir, { recursive: true });
235
+ const wuPath = join(wuDir, `${TEST_WU_ID}.yaml`);
236
+ const wuDoc = {
237
+ id: TEST_WU_ID,
238
+ title: 'Test WU',
239
+ lane: TEST_LANE,
240
+ status: 'ready',
241
+ initiative: TEST_INIT_ID_2, // Different initiative
242
+ };
243
+ writeFileSync(wuPath, stringifyYAML(wuDoc));
244
+ // Try to remove different initiative - should not change
245
+ const changed = updateWUInWorktree(tempDir, TEST_WU_ID, TEST_INIT_ID);
246
+ expect(changed).toBe(false);
247
+ // Verify the file was NOT updated
248
+ const updated = parseYAML(readFileSync(wuPath, 'utf-8'));
249
+ expect(updated.initiative).toBe(TEST_INIT_ID_2);
250
+ });
251
+ });
252
+ describe('updateInitiativeInWorktree (remove WU)', () => {
253
+ it('should remove WU from initiative wus list', async () => {
254
+ const { updateInitiativeInWorktree } = await import('../initiative-remove-wu.js');
255
+ // Setup mock initiative
256
+ const initDir = join(tempDir, INIT_REL_PATH);
257
+ mkdirSync(initDir, { recursive: true });
258
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
259
+ const initDoc = {
260
+ id: TEST_INIT_ID,
261
+ slug: TEST_INIT_SLUG,
262
+ title: TEST_INIT_TITLE,
263
+ status: TEST_INIT_STATUS,
264
+ created: TEST_DATE,
265
+ wus: [TEST_WU_ID, TEST_WU_ID_2, TEST_WU_ID_3],
266
+ };
267
+ writeFileSync(initPath, stringifyYAML(initDoc));
268
+ // Update initiative
269
+ const changed = updateInitiativeInWorktree(tempDir, TEST_INIT_ID, TEST_WU_ID_2);
270
+ expect(changed).toBe(true);
271
+ // Verify the file was updated
272
+ const updated = parseYAML(readFileSync(initPath, 'utf-8'));
273
+ expect(updated.wus).toEqual([TEST_WU_ID, TEST_WU_ID_3]);
274
+ expect(updated.wus).not.toContain(TEST_WU_ID_2);
275
+ });
276
+ it('should return false if WU not in list (idempotent)', async () => {
277
+ const { updateInitiativeInWorktree } = await import('../initiative-remove-wu.js');
278
+ // Setup mock initiative without the WU
279
+ const initDir = join(tempDir, INIT_REL_PATH);
280
+ mkdirSync(initDir, { recursive: true });
281
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
282
+ const initDoc = {
283
+ id: TEST_INIT_ID,
284
+ slug: TEST_INIT_SLUG,
285
+ title: TEST_INIT_TITLE,
286
+ status: TEST_INIT_STATUS,
287
+ created: TEST_DATE,
288
+ wus: [TEST_WU_ID, TEST_WU_ID_3],
289
+ };
290
+ writeFileSync(initPath, stringifyYAML(initDoc));
291
+ // Try to remove WU that's not in list
292
+ const changed = updateInitiativeInWorktree(tempDir, TEST_INIT_ID, TEST_WU_ID_2);
293
+ expect(changed).toBe(false);
294
+ });
295
+ it('should handle empty wus array', async () => {
296
+ const { updateInitiativeInWorktree } = await import('../initiative-remove-wu.js');
297
+ // Setup mock initiative with empty wus array
298
+ const initDir = join(tempDir, INIT_REL_PATH);
299
+ mkdirSync(initDir, { recursive: true });
300
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
301
+ const initDoc = {
302
+ id: TEST_INIT_ID,
303
+ slug: TEST_INIT_SLUG,
304
+ title: TEST_INIT_TITLE,
305
+ status: TEST_INIT_STATUS,
306
+ created: TEST_DATE,
307
+ wus: [],
308
+ };
309
+ writeFileSync(initPath, stringifyYAML(initDoc));
310
+ // Try to remove WU from empty list
311
+ const changed = updateInitiativeInWorktree(tempDir, TEST_INIT_ID, TEST_WU_ID);
312
+ expect(changed).toBe(false);
313
+ });
314
+ it('should handle missing wus array', async () => {
315
+ const { updateInitiativeInWorktree } = await import('../initiative-remove-wu.js');
316
+ // Setup mock initiative without wus array
317
+ const initDir = join(tempDir, INIT_REL_PATH);
318
+ mkdirSync(initDir, { recursive: true });
319
+ const initPath = join(initDir, `${TEST_INIT_ID}.yaml`);
320
+ const initDoc = {
321
+ id: TEST_INIT_ID,
322
+ slug: TEST_INIT_SLUG,
323
+ title: TEST_INIT_TITLE,
324
+ status: TEST_INIT_STATUS,
325
+ created: TEST_DATE,
326
+ // No wus field
327
+ };
328
+ writeFileSync(initPath, stringifyYAML(initDoc));
329
+ // Try to remove WU from non-existent list
330
+ const changed = updateInitiativeInWorktree(tempDir, TEST_INIT_ID, TEST_WU_ID);
331
+ expect(changed).toBe(false);
332
+ });
333
+ });
334
+ describe('LOG_PREFIX', () => {
335
+ it('should use correct log prefix', async () => {
336
+ const { LOG_PREFIX } = await import('../initiative-remove-wu.js');
337
+ expect(LOG_PREFIX).toBe('[initiative:remove-wu]');
338
+ });
339
+ });
340
+ describe('OPERATION_NAME', () => {
341
+ it('should have correct operation name', async () => {
342
+ const { OPERATION_NAME } = await import('../initiative-remove-wu.js');
343
+ expect(OPERATION_NAME).toBe('initiative-remove-wu');
344
+ });
345
+ });
346
+ });
347
+ describe('initiative:remove-wu CLI integration', () => {
348
+ it('should require --initiative and --wu flags', async () => {
349
+ // This test verifies that the CLI requires both flags
350
+ const { WU_OPTIONS } = await import('@lumenflow/core/dist/arg-parser.js');
351
+ expect(WU_OPTIONS.initiative).toBeDefined();
352
+ expect(WU_OPTIONS.initiative.flags).toContain('--initiative');
353
+ expect(WU_OPTIONS.wu).toBeDefined();
354
+ expect(WU_OPTIONS.wu.flags).toContain('--wu');
355
+ });
356
+ it('should export main function for CLI entry', async () => {
357
+ const initRemoveWu = await import('../initiative-remove-wu.js');
358
+ expect(typeof initRemoveWu.main).toBe('function');
359
+ });
360
+ it('should export all required functions', async () => {
361
+ const initRemoveWu = await import('../initiative-remove-wu.js');
362
+ expect(typeof initRemoveWu.validateInitIdFormat).toBe('function');
363
+ expect(typeof initRemoveWu.validateWuIdFormat).toBe('function');
364
+ expect(typeof initRemoveWu.checkWUExists).toBe('function');
365
+ expect(typeof initRemoveWu.checkInitiativeExists).toBe('function');
366
+ expect(typeof initRemoveWu.checkWUIsLinked).toBe('function');
367
+ expect(typeof initRemoveWu.updateWUInWorktree).toBe('function');
368
+ expect(typeof initRemoveWu.updateInitiativeInWorktree).toBe('function');
369
+ expect(typeof initRemoveWu.LOG_PREFIX).toBe('string');
370
+ expect(typeof initRemoveWu.OPERATION_NAME).toBe('string');
371
+ });
372
+ });
373
+ /**
374
+ * Note on main() function testing:
375
+ *
376
+ * The main() function is intentionally not unit-tested because:
377
+ * 1. It calls die() which invokes process.exit() - difficult to mock without complex test infrastructure
378
+ * 2. It involves micro-worktree operations with git
379
+ * 3. All business logic functions it calls ARE thoroughly tested above
380
+ *
381
+ * The main() function is integration/orchestration code that composes the tested helper functions.
382
+ * Integration testing via subprocess (pnpm initiative:remove-wu) is the appropriate testing strategy for main().
383
+ *
384
+ * Coverage statistics:
385
+ * - All exported helper functions: ~100% coverage
386
+ * - main() function: Not unit tested (orchestration code)
387
+ * - Overall file coverage: ~50% (acceptable for CLI commands)
388
+ */
389
+ /**
390
+ * WU-1333: Retry handling tests for initiative:remove-wu
391
+ *
392
+ * When origin/main moves during operation, the micro-worktree layer handles retry.
393
+ * When retries are exhausted, the error message should include actionable next steps.
394
+ */
395
+ describe('initiative:remove-wu retry handling (WU-1333)', () => {
396
+ describe('isRetryExhaustionError', () => {
397
+ it('should detect retry exhaustion from error message', async () => {
398
+ const { isRetryExhaustionError } = await import('../initiative-remove-wu.js');
399
+ // Should detect retry exhaustion error
400
+ const retryError = new Error('Push failed after 3 attempts. Origin main may have significant traffic.');
401
+ expect(isRetryExhaustionError(retryError)).toBe(true);
402
+ });
403
+ it('should detect retry exhaustion with any attempt count', async () => {
404
+ const { isRetryExhaustionError } = await import('../initiative-remove-wu.js');
405
+ // Different attempt counts should still match
406
+ const error5 = new Error('Push failed after 5 attempts. Something.');
407
+ expect(isRetryExhaustionError(error5)).toBe(true);
408
+ const error1 = new Error('Push failed after 1 attempts. Something.');
409
+ expect(isRetryExhaustionError(error1)).toBe(true);
410
+ });
411
+ it('should not match other errors', async () => {
412
+ const { isRetryExhaustionError } = await import('../initiative-remove-wu.js');
413
+ const otherError = new Error('Some other error');
414
+ expect(isRetryExhaustionError(otherError)).toBe(false);
415
+ const networkError = new Error('Network unreachable');
416
+ expect(isRetryExhaustionError(networkError)).toBe(false);
417
+ });
418
+ });
419
+ describe('formatRetryExhaustionError', () => {
420
+ it('should include actionable next steps', async () => {
421
+ const { formatRetryExhaustionError } = await import('../initiative-remove-wu.js');
422
+ const retryError = new Error('Push failed after 3 attempts. Origin main may have significant traffic.');
423
+ const formatted = formatRetryExhaustionError(retryError, TEST_WU_ID, TEST_INIT_ID);
424
+ // Should include the original error
425
+ expect(formatted).toContain('Push failed after 3 attempts');
426
+ // Should include next steps heading
427
+ expect(formatted).toContain('Next steps:');
428
+ // Should include actionable suggestions
429
+ expect(formatted).toContain('Wait a few seconds and retry');
430
+ expect(formatted).toContain('initiative:remove-wu');
431
+ });
432
+ it('should include the retry command', async () => {
433
+ const { formatRetryExhaustionError } = await import('../initiative-remove-wu.js');
434
+ const retryError = new Error('Push failed after 3 attempts.');
435
+ const formatted = formatRetryExhaustionError(retryError, TEST_WU_ID, TEST_INIT_ID);
436
+ // Should include command to retry
437
+ expect(formatted).toContain(`--wu ${TEST_WU_ID}`);
438
+ expect(formatted).toContain(`--initiative ${TEST_INIT_ID}`);
439
+ });
440
+ it('should suggest checking for concurrent agents', async () => {
441
+ const { formatRetryExhaustionError } = await import('../initiative-remove-wu.js');
442
+ const retryError = new Error('Push failed after 3 attempts.');
443
+ const formatted = formatRetryExhaustionError(retryError, TEST_WU_ID, TEST_INIT_ID);
444
+ // Should mention concurrent agents as possible cause
445
+ expect(formatted).toMatch(/concurrent|agent|traffic/i);
446
+ });
447
+ });
448
+ describe('exports for WU-1333', () => {
449
+ it('should export isRetryExhaustionError function', async () => {
450
+ const mod = await import('../initiative-remove-wu.js');
451
+ expect(typeof mod.isRetryExhaustionError).toBe('function');
452
+ });
453
+ it('should export formatRetryExhaustionError function', async () => {
454
+ const mod = await import('../initiative-remove-wu.js');
455
+ expect(typeof mod.formatRetryExhaustionError).toBe('function');
456
+ });
457
+ });
458
+ });