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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
+
});
|