tlc-claude-code 1.8.5 → 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 (138) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Plan Writer - CRUD operations for tasks in PLAN.md files
3
+ *
4
+ * Provides functions to update task status, content, and create new tasks.
5
+ * All writes are atomic (write to temp file, then rename).
6
+ *
7
+ * Uses dependency injection for fs to enable testability.
8
+ */
9
+
10
+ /**
11
+ * Create a plan writer with injected dependencies
12
+ * @param {object} deps
13
+ * @param {object} deps.fs - Node.js fs module (or mock)
14
+ * @returns {{ updateTaskStatus, updateTaskContent, createTask }}
15
+ */
16
+ function createPlanWriter({ fs }) {
17
+ /**
18
+ * Write content atomically: write to .tmp, then rename
19
+ */
20
+ function atomicWrite(filePath, content) {
21
+ const tmpPath = filePath + '.tmp';
22
+ fs.writeFileSync(tmpPath, content, 'utf-8');
23
+ fs.renameSync(tmpPath, filePath);
24
+ }
25
+
26
+ /**
27
+ * Find all task headings in PLAN.md content
28
+ * Returns array of { num, match, index, fullMatch, title, statusMarker }
29
+ */
30
+ function findTasks(content) {
31
+ const tasks = [];
32
+ const regex = /###\s+Task\s+(\d+):\s+(.+?)\s*\[([^\]]*)\]/g;
33
+ let match;
34
+ while ((match = regex.exec(content)) !== null) {
35
+ tasks.push({
36
+ num: parseInt(match[1]),
37
+ title: match[2].trim(),
38
+ statusMarker: match[3],
39
+ fullMatch: match[0],
40
+ index: match.index,
41
+ });
42
+ }
43
+ return tasks;
44
+ }
45
+
46
+ /**
47
+ * Build a status marker string from status and owner
48
+ */
49
+ function buildStatusMarker(status, owner) {
50
+ if (status === 'done') {
51
+ return owner ? `x@${owner}` : 'x';
52
+ } else if (status === 'in_progress') {
53
+ return owner ? `>@${owner}` : '>';
54
+ }
55
+ return ' ';
56
+ }
57
+
58
+ /**
59
+ * Update a task's status marker in PLAN.md
60
+ * @param {string} planPath - Path to PLAN.md file
61
+ * @param {number} taskNum - Task number to update
62
+ * @param {string} newStatus - 'pending' | 'in_progress' | 'done'
63
+ * @param {string|null} owner - Username for claim/complete
64
+ */
65
+ function updateTaskStatus(planPath, taskNum, newStatus, owner) {
66
+ const content = fs.readFileSync(planPath, 'utf-8');
67
+ const tasks = findTasks(content);
68
+ const task = tasks.find((t) => t.num === taskNum);
69
+
70
+ if (!task) {
71
+ throw new Error(`Task ${taskNum} not found in ${planPath}`);
72
+ }
73
+
74
+ const newMarker = buildStatusMarker(newStatus, owner);
75
+ const newHeading = `### Task ${taskNum}: ${task.title} [${newMarker}]`;
76
+ const updated = content.replace(task.fullMatch, newHeading);
77
+
78
+ atomicWrite(planPath, updated);
79
+ }
80
+
81
+ /**
82
+ * Update a task's content (title, acceptance criteria)
83
+ * @param {string} planPath - Path to PLAN.md file
84
+ * @param {number} taskNum - Task number to update
85
+ * @param {object} updates - { title?, acceptanceCriteria? }
86
+ */
87
+ function updateTaskContent(planPath, taskNum, updates) {
88
+ let content = fs.readFileSync(planPath, 'utf-8');
89
+ const tasks = findTasks(content);
90
+ const task = tasks.find((t) => t.num === taskNum);
91
+
92
+ if (!task) {
93
+ throw new Error(`Task ${taskNum} not found in ${planPath}`);
94
+ }
95
+
96
+ // Update title if provided
97
+ if (updates.title) {
98
+ const newHeading = `### Task ${taskNum}: ${updates.title} [${task.statusMarker}]`;
99
+ content = content.replace(task.fullMatch, newHeading);
100
+ }
101
+
102
+ // Update acceptance criteria if provided
103
+ if (updates.acceptanceCriteria && Array.isArray(updates.acceptanceCriteria)) {
104
+ // Find the acceptance criteria section for this task
105
+ const taskStart = content.indexOf(`### Task ${taskNum}:`);
106
+ const nextTaskMatch = content.slice(taskStart + 1).match(/\n###\s+Task\s+\d+:/);
107
+ const nextSectionMatch = content.slice(taskStart + 1).match(/\n---/);
108
+ let taskEnd = content.length;
109
+ if (nextTaskMatch) taskEnd = taskStart + 1 + nextTaskMatch.index;
110
+ if (nextSectionMatch && taskStart + 1 + nextSectionMatch.index < taskEnd) {
111
+ taskEnd = taskStart + 1 + nextSectionMatch.index;
112
+ }
113
+
114
+ const taskSection = content.slice(taskStart, taskEnd);
115
+
116
+ // Find and replace acceptance criteria block
117
+ const acMatch = taskSection.match(
118
+ /(\*\*Acceptance Criteria:\*\*\n)((?:- \[[ x]\] .+\n?)*)/
119
+ );
120
+ if (acMatch) {
121
+ const newCriteria = updates.acceptanceCriteria
122
+ .map((c) => `- [ ] ${c}`)
123
+ .join('\n');
124
+ const newSection = taskSection.replace(
125
+ acMatch[0],
126
+ `**Acceptance Criteria:**\n${newCriteria}\n`
127
+ );
128
+ content = content.slice(0, taskStart) + newSection + content.slice(taskEnd);
129
+ }
130
+ }
131
+
132
+ atomicWrite(planPath, content);
133
+ }
134
+
135
+ /**
136
+ * Create a new task in PLAN.md
137
+ * @param {string} planPath - Path to PLAN.md file
138
+ * @param {object} taskData - { title, goal, acceptanceCriteria?, testCases? }
139
+ * @returns {{ num: number, title: string, status: string }}
140
+ */
141
+ function createTask(planPath, taskData) {
142
+ let content;
143
+ try {
144
+ content = fs.readFileSync(planPath, 'utf-8');
145
+ } catch {
146
+ content = '# Plan\n\n## Tasks\n';
147
+ }
148
+
149
+ const tasks = findTasks(content);
150
+ const nextNum = tasks.length > 0 ? Math.max(...tasks.map((t) => t.num)) + 1 : 1;
151
+
152
+ // Build task section
153
+ const lines = [];
154
+ lines.push(`\n### Task ${nextNum}: ${taskData.title} [ ]`);
155
+ lines.push('');
156
+ if (taskData.goal) {
157
+ lines.push(`**Goal:** ${taskData.goal}`);
158
+ lines.push('');
159
+ }
160
+ if (taskData.acceptanceCriteria && taskData.acceptanceCriteria.length > 0) {
161
+ lines.push('**Acceptance Criteria:**');
162
+ for (const criterion of taskData.acceptanceCriteria) {
163
+ lines.push(`- [ ] ${criterion}`);
164
+ }
165
+ lines.push('');
166
+ }
167
+ if (taskData.testCases && taskData.testCases.length > 0) {
168
+ lines.push('**Test Cases:**');
169
+ for (const testCase of taskData.testCases) {
170
+ lines.push(`- ${testCase}`);
171
+ }
172
+ lines.push('');
173
+ }
174
+ lines.push('---');
175
+ lines.push('');
176
+
177
+ const taskBlock = lines.join('\n');
178
+
179
+ // Find insertion point: before ## Dependencies or at end of ## Tasks section
180
+ const depsIndex = content.indexOf('\n## Dependencies');
181
+ if (depsIndex > -1) {
182
+ content = content.slice(0, depsIndex) + taskBlock + content.slice(depsIndex);
183
+ } else {
184
+ // Just append
185
+ content = content.trimEnd() + '\n' + taskBlock;
186
+ }
187
+
188
+ atomicWrite(planPath, content);
189
+
190
+ return { num: nextNum, title: taskData.title, status: 'pending' };
191
+ }
192
+
193
+ return { updateTaskStatus, updateTaskContent, createTask };
194
+ }
195
+
196
+ module.exports = { createPlanWriter };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @file plan-writer.test.js
3
+ * @description Tests for the Plan Writer module (Phase 76, Task 5).
4
+ *
5
+ * Tests the factory function `createPlanWriter(deps)` which accepts injected
6
+ * dependencies (fs) and returns functions for updating task status, content,
7
+ * and creating new tasks in PLAN.md files.
8
+ *
9
+ * TDD: RED phase — these tests are written BEFORE the implementation.
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { createPlanWriter } from './plan-writer.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock factories
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function createMockFs(files = {}) {
19
+ const store = { ...files };
20
+ return {
21
+ existsSync: vi.fn((p) => p in store),
22
+ readFileSync: vi.fn((p) => {
23
+ if (p in store) return store[p];
24
+ throw new Error(`ENOENT: no such file or directory, open '${p}'`);
25
+ }),
26
+ writeFileSync: vi.fn((p, content) => {
27
+ store[p] = content;
28
+ }),
29
+ renameSync: vi.fn((src, dest) => {
30
+ if (src in store) {
31
+ store[dest] = store[src];
32
+ delete store[src];
33
+ }
34
+ }),
35
+ mkdirSync: vi.fn(),
36
+ _store: store,
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Sample PLAN.md content
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const SAMPLE_PLAN = `# Phase 5: User Dashboard - Plan
45
+
46
+ ## Overview
47
+
48
+ Build the user dashboard.
49
+
50
+ ## Tasks
51
+
52
+ ### Task 1: Create layout component [ ]
53
+
54
+ **Goal:** Build the main layout
55
+
56
+ **Acceptance Criteria:**
57
+ - [ ] Has sidebar
58
+ - [ ] Has header
59
+ - [ ] Responsive on mobile
60
+
61
+ ---
62
+
63
+ ### Task 2: Implement data fetching [>@alice]
64
+
65
+ **Goal:** Fetch user data from API
66
+
67
+ **Acceptance Criteria:**
68
+ - [ ] Fetches on mount
69
+ - [ ] Shows loading state
70
+
71
+ ---
72
+
73
+ ### Task 3: Build stat cards [x@bob]
74
+
75
+ **Goal:** Display statistics
76
+
77
+ **Acceptance Criteria:**
78
+ - [x] Shows user count
79
+ - [x] Shows revenue
80
+
81
+ ---
82
+
83
+ ## Dependencies
84
+
85
+ Task 2 depends on Task 1.
86
+ `;
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Tests
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('plan-writer', () => {
93
+ describe('updateTaskStatus', () => {
94
+ it('changes [ ] to [>@alice] in heading', () => {
95
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
96
+ const writer = createPlanWriter({ fs: mockFs });
97
+
98
+ writer.updateTaskStatus('/project/PLAN.md', 1, 'in_progress', 'alice');
99
+
100
+ const updated = mockFs._store['/project/PLAN.md'];
101
+ expect(updated).toContain('### Task 1: Create layout component [>@alice]');
102
+ expect(updated).not.toContain('### Task 1: Create layout component [ ]');
103
+ });
104
+
105
+ it('changes [>@alice] to [x@alice] in heading', () => {
106
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
107
+ const writer = createPlanWriter({ fs: mockFs });
108
+
109
+ writer.updateTaskStatus('/project/PLAN.md', 2, 'done', 'alice');
110
+
111
+ const updated = mockFs._store['/project/PLAN.md'];
112
+ expect(updated).toContain('### Task 2: Implement data fetching [x@alice]');
113
+ expect(updated).not.toContain('[>@alice]');
114
+ });
115
+
116
+ it('changes [x@bob] back to [ ] (reset)', () => {
117
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
118
+ const writer = createPlanWriter({ fs: mockFs });
119
+
120
+ writer.updateTaskStatus('/project/PLAN.md', 3, 'pending', null);
121
+
122
+ const updated = mockFs._store['/project/PLAN.md'];
123
+ expect(updated).toContain('### Task 3: Build stat cards [ ]');
124
+ });
125
+
126
+ it('preserves other tasks when updating one', () => {
127
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
128
+ const writer = createPlanWriter({ fs: mockFs });
129
+
130
+ writer.updateTaskStatus('/project/PLAN.md', 1, 'in_progress', 'alice');
131
+
132
+ const updated = mockFs._store['/project/PLAN.md'];
133
+ // Other tasks unchanged
134
+ expect(updated).toContain('### Task 2: Implement data fetching [>@alice]');
135
+ expect(updated).toContain('### Task 3: Build stat cards [x@bob]');
136
+ });
137
+
138
+ it('writes atomically (temp file + rename)', () => {
139
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
140
+ const writer = createPlanWriter({ fs: mockFs });
141
+
142
+ writer.updateTaskStatus('/project/PLAN.md', 1, 'in_progress', 'alice');
143
+
144
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
145
+ expect.stringContaining('.tmp'),
146
+ expect.any(String),
147
+ 'utf-8'
148
+ );
149
+ expect(mockFs.renameSync).toHaveBeenCalled();
150
+ });
151
+
152
+ it('throws for invalid task number', () => {
153
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
154
+ const writer = createPlanWriter({ fs: mockFs });
155
+
156
+ expect(() => {
157
+ writer.updateTaskStatus('/project/PLAN.md', 99, 'done', 'alice');
158
+ }).toThrow(/task.*99.*not found/i);
159
+ });
160
+
161
+ it('throws for nonexistent file', () => {
162
+ const mockFs = createMockFs({});
163
+ const writer = createPlanWriter({ fs: mockFs });
164
+
165
+ expect(() => {
166
+ writer.updateTaskStatus('/project/PLAN.md', 1, 'done', 'alice');
167
+ }).toThrow();
168
+ });
169
+ });
170
+
171
+ describe('updateTaskContent', () => {
172
+ it('updates task title', () => {
173
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
174
+ const writer = createPlanWriter({ fs: mockFs });
175
+
176
+ writer.updateTaskContent('/project/PLAN.md', 1, {
177
+ title: 'Create responsive layout',
178
+ });
179
+
180
+ const updated = mockFs._store['/project/PLAN.md'];
181
+ expect(updated).toContain('### Task 1: Create responsive layout [ ]');
182
+ expect(updated).not.toContain('Create layout component');
183
+ });
184
+
185
+ it('updates acceptance criteria', () => {
186
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
187
+ const writer = createPlanWriter({ fs: mockFs });
188
+
189
+ writer.updateTaskContent('/project/PLAN.md', 1, {
190
+ acceptanceCriteria: ['Has sidebar', 'Has header', 'Has footer', 'Responsive on mobile'],
191
+ });
192
+
193
+ const updated = mockFs._store['/project/PLAN.md'];
194
+ expect(updated).toContain('- [ ] Has footer');
195
+ expect(updated).toContain('- [ ] Has sidebar');
196
+ });
197
+
198
+ it('preserves surrounding markdown structure', () => {
199
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
200
+ const writer = createPlanWriter({ fs: mockFs });
201
+
202
+ writer.updateTaskContent('/project/PLAN.md', 1, {
203
+ title: 'Updated title',
204
+ });
205
+
206
+ const updated = mockFs._store['/project/PLAN.md'];
207
+ // Header and footer sections still present
208
+ expect(updated).toContain('# Phase 5: User Dashboard - Plan');
209
+ expect(updated).toContain('## Dependencies');
210
+ expect(updated).toContain('Task 2 depends on Task 1.');
211
+ });
212
+
213
+ it('throws for invalid task number', () => {
214
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
215
+ const writer = createPlanWriter({ fs: mockFs });
216
+
217
+ expect(() => {
218
+ writer.updateTaskContent('/project/PLAN.md', 99, { title: 'New' });
219
+ }).toThrow(/task.*99.*not found/i);
220
+ });
221
+ });
222
+
223
+ describe('createTask', () => {
224
+ it('appends new task with correct format', () => {
225
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
226
+ const writer = createPlanWriter({ fs: mockFs });
227
+
228
+ const result = writer.createTask('/project/PLAN.md', {
229
+ title: 'Add loading states',
230
+ goal: 'Show loading spinners during data fetch',
231
+ acceptanceCriteria: ['Spinner shown on mount', 'Spinner hidden after load'],
232
+ testCases: ['Loading state renders spinner', 'Spinner disappears after data loads'],
233
+ });
234
+
235
+ const updated = mockFs._store['/project/PLAN.md'];
236
+ expect(updated).toContain('### Task 4: Add loading states [ ]');
237
+ expect(updated).toContain('**Goal:** Show loading spinners during data fetch');
238
+ expect(updated).toContain('- [ ] Spinner shown on mount');
239
+ expect(updated).toContain('- Spinner disappears after data loads');
240
+ expect(result.num).toBe(4);
241
+ });
242
+
243
+ it('generates next task number correctly', () => {
244
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
245
+ const writer = createPlanWriter({ fs: mockFs });
246
+
247
+ const result = writer.createTask('/project/PLAN.md', {
248
+ title: 'New task',
249
+ goal: 'Do something',
250
+ });
251
+
252
+ expect(result.num).toBe(4); // 3 existing tasks, so next is 4
253
+ });
254
+
255
+ it('creates task in empty plan', () => {
256
+ const emptyPlan = `# Phase 1: Setup - Plan
257
+
258
+ ## Overview
259
+
260
+ Initial setup.
261
+
262
+ ## Tasks
263
+
264
+ ## Dependencies
265
+
266
+ None.
267
+ `;
268
+ const mockFs = createMockFs({ '/project/PLAN.md': emptyPlan });
269
+ const writer = createPlanWriter({ fs: mockFs });
270
+
271
+ const result = writer.createTask('/project/PLAN.md', {
272
+ title: 'First task',
273
+ goal: 'Get started',
274
+ });
275
+
276
+ const updated = mockFs._store['/project/PLAN.md'];
277
+ expect(updated).toContain('### Task 1: First task [ ]');
278
+ expect(result.num).toBe(1);
279
+ });
280
+
281
+ it('writes atomically', () => {
282
+ const mockFs = createMockFs({ '/project/PLAN.md': SAMPLE_PLAN });
283
+ const writer = createPlanWriter({ fs: mockFs });
284
+
285
+ writer.createTask('/project/PLAN.md', {
286
+ title: 'New task',
287
+ goal: 'Do something',
288
+ });
289
+
290
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
291
+ expect.stringContaining('.tmp'),
292
+ expect.any(String),
293
+ 'utf-8'
294
+ );
295
+ expect(mockFs.renameSync).toHaveBeenCalled();
296
+ });
297
+ });
298
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Port guard - checks if a port is available before server startup.
3
+ *
4
+ * Detects port conflicts and reports which process holds the port.
5
+ * Designed for use with launchd ThrottleInterval to prevent restart spam.
6
+ *
7
+ * @module port-guard
8
+ */
9
+
10
+ const net = require('net');
11
+
12
+ /**
13
+ * Check if a port is available.
14
+ *
15
+ * @param {number} port - Port number to check
16
+ * @returns {Promise<{available: boolean, port: number, pid?: number, command?: string}>}
17
+ */
18
+ async function checkPort(port) {
19
+ return new Promise((resolve) => {
20
+ const server = net.createServer();
21
+
22
+ server.once('error', (err) => {
23
+ if (err.code === 'EADDRINUSE') {
24
+ resolve({ available: false, port });
25
+ } else {
26
+ // Unexpected error — treat as unavailable
27
+ resolve({ available: false, port });
28
+ }
29
+ });
30
+
31
+ server.once('listening', () => {
32
+ // Port is free — close the test server
33
+ const addr = server.address();
34
+ const actualPort = addr ? addr.port : port;
35
+ server.close(() => {
36
+ resolve({ available: true, port: actualPort });
37
+ });
38
+ });
39
+
40
+ server.listen(port);
41
+ });
42
+ }
43
+
44
+ module.exports = { checkPort };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Port guard tests - Phase 83 Task 2
3
+ */
4
+
5
+ import { describe, it, expect, vi, afterEach } from 'vitest';
6
+ import net from 'net';
7
+
8
+ import { checkPort } from './port-guard.js';
9
+
10
+ describe('port-guard', () => {
11
+ let tempServer;
12
+
13
+ afterEach(() => {
14
+ if (tempServer) {
15
+ tempServer.close();
16
+ tempServer = null;
17
+ }
18
+ });
19
+
20
+ it('returns available:true when port is free', async () => {
21
+ // Use a high ephemeral port unlikely to be in use
22
+ const result = await checkPort(0);
23
+ expect(result.available).toBe(true);
24
+ });
25
+
26
+ it('returns available:false when port is occupied', async () => {
27
+ // Occupy a port first
28
+ tempServer = net.createServer();
29
+ await new Promise((resolve, reject) => {
30
+ tempServer.listen(0, resolve);
31
+ tempServer.on('error', reject);
32
+ });
33
+ const port = tempServer.address().port;
34
+
35
+ const result = await checkPort(port);
36
+ expect(result.available).toBe(false);
37
+ });
38
+
39
+ it('includes pid info when port is occupied (best effort)', async () => {
40
+ tempServer = net.createServer();
41
+ await new Promise((resolve, reject) => {
42
+ tempServer.listen(0, resolve);
43
+ tempServer.on('error', reject);
44
+ });
45
+ const port = tempServer.address().port;
46
+
47
+ const result = await checkPort(port);
48
+ expect(result.available).toBe(false);
49
+ // pid is best-effort (may not be available on all platforms)
50
+ expect(result).toHaveProperty('port', port);
51
+ });
52
+
53
+ it('handles EADDRINUSE gracefully', async () => {
54
+ tempServer = net.createServer();
55
+ await new Promise((resolve, reject) => {
56
+ tempServer.listen(0, resolve);
57
+ tempServer.on('error', reject);
58
+ });
59
+ const port = tempServer.address().port;
60
+
61
+ // Should not throw
62
+ const result = await checkPort(port);
63
+ expect(result.available).toBe(false);
64
+ });
65
+ });