@howlil/ez-agents 3.0.0 → 3.4.1
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/README.md +295 -714
- package/bin/install.js +446 -78
- package/commands/ez/auth.md +87 -0
- package/commands/ez/join-discord.md +1 -1
- package/ez-agents/bin/ez-tools.cjs +120 -2
- package/ez-agents/bin/lib/assistant-adapter.cjs +62 -3
- package/ez-agents/bin/lib/audit-exec.cjs +20 -8
- package/ez-agents/bin/lib/auth.cjs +2 -1
- package/ez-agents/bin/lib/circuit-breaker.cjs +1 -1
- package/ez-agents/bin/lib/commands.cjs +42 -23
- package/ez-agents/bin/lib/config.cjs +18 -11
- package/ez-agents/bin/lib/core.cjs +42 -25
- package/ez-agents/bin/lib/file-lock.cjs +3 -3
- package/ez-agents/bin/lib/fs-utils.cjs +1 -1
- package/ez-agents/bin/lib/git-utils.cjs +1 -1
- package/ez-agents/bin/lib/health-check.cjs +2 -3
- package/ez-agents/bin/lib/index.cjs +1 -1
- package/ez-agents/bin/lib/init.cjs +70 -23
- package/ez-agents/bin/lib/logger.cjs +11 -4
- package/ez-agents/bin/lib/model-provider.cjs +124 -29
- package/ez-agents/bin/lib/phase.cjs +39 -22
- package/ez-agents/bin/lib/planning-write.cjs +107 -0
- package/ez-agents/bin/lib/retry.cjs +1 -1
- package/ez-agents/bin/lib/roadmap.cjs +3 -2
- package/ez-agents/bin/lib/safe-exec.cjs +1 -1
- package/ez-agents/bin/lib/safe-path.cjs +1 -1
- package/ez-agents/bin/lib/state.cjs +24 -9
- package/ez-agents/bin/lib/temp-file.cjs +1 -1
- package/ez-agents/bin/lib/template.cjs +2 -1
- package/ez-agents/bin/lib/test-file-lock.cjs +1 -1
- package/ez-agents/bin/lib/test-graceful.cjs +2 -2
- package/ez-agents/bin/lib/test-logger.cjs +2 -2
- package/ez-agents/bin/lib/test-temp-file.cjs +1 -1
- package/ez-agents/bin/lib/timeout-exec.cjs +4 -3
- package/ez-agents/bin/lib/verify.cjs +54 -25
- package/ez-agents/references/continuation-format.md +1 -1
- package/ez-agents/workflows/add-tests.md +2 -2
- package/ez-agents/workflows/add-todo.md +1 -1
- package/ez-agents/workflows/autonomous.md +15 -15
- package/ez-agents/workflows/diagnose-issues.md +1 -1
- package/ez-agents/workflows/discuss-phase.md +3 -3
- package/ez-agents/workflows/execute-phase.md +2 -2
- package/ez-agents/workflows/health.md +1 -1
- package/ez-agents/workflows/help.md +2 -2
- package/ez-agents/workflows/map-codebase.md +1 -1
- package/ez-agents/workflows/new-milestone.md +5 -5
- package/ez-agents/workflows/new-project.md +12 -10
- package/ez-agents/workflows/plan-phase.md +8 -8
- package/ez-agents/workflows/progress.md +1 -1
- package/ez-agents/workflows/set-profile.md +1 -1
- package/ez-agents/workflows/settings.md +9 -9
- package/ez-agents/workflows/stats.md +1 -1
- package/ez-agents/workflows/ui-phase.md +3 -3
- package/ez-agents/workflows/ui-review.md +2 -2
- package/ez-agents/workflows/update.md +1 -1
- package/ez-agents/workflows/validate-phase.md +3 -3
- package/ez-agents/workflows/verify-work.md +3 -3
- package/package.json +1 -1
- package/scripts/build-hooks.js +1 -1
- package/scripts/fix-qwen-installation.js +144 -0
- package/README.zh-CN.md +0 -702
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* EZ Model Provider — Unified API for multiple AI providers
|
|
5
5
|
*
|
|
6
6
|
* Supports: Anthropic, Moonshot (Kimi), Alibaba (Qwen), OpenAI
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* const ModelProvider = require('./model-provider.cjs');
|
|
10
|
-
* const model = new ModelProvider({ provider: 'anthropic', model: 'sonnet' });
|
|
11
|
-
* const response = await model.chat([{ role: 'user', content: 'Hello' }]);
|
|
12
7
|
*/
|
|
13
8
|
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const { URL } = require('url');
|
|
14
11
|
const Logger = require('./logger.cjs');
|
|
15
12
|
const logger = new Logger();
|
|
16
13
|
|
|
@@ -24,11 +21,37 @@ class ModelProvider {
|
|
|
24
21
|
this.model = config.model || 'sonnet';
|
|
25
22
|
this.apiKey = config.apiKey || process.env[`${this.provider.toUpperCase()}_API_KEY`];
|
|
26
23
|
|
|
27
|
-
if (!this.apiKey) {
|
|
24
|
+
if (!this.apiKey && this.provider !== 'anthropic') {
|
|
28
25
|
logger.warn('API key not configured', { provider: this.provider });
|
|
29
26
|
}
|
|
30
27
|
}
|
|
31
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Helper for HTTP requests
|
|
31
|
+
*/
|
|
32
|
+
_httpRequest(options, data) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const req = https.request(options, (res) => {
|
|
35
|
+
let body = '';
|
|
36
|
+
res.on('data', (chunk) => body += chunk);
|
|
37
|
+
res.on('end', () => {
|
|
38
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(body));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
resolve(body);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on('error', reject);
|
|
50
|
+
if (data) req.write(JSON.stringify(data));
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
/**
|
|
33
56
|
* Send chat message
|
|
34
57
|
* @param {Object[]} messages - Chat messages
|
|
@@ -48,6 +71,7 @@ class ModelProvider {
|
|
|
48
71
|
case 'moonshot':
|
|
49
72
|
return this._chatMoonshot(messages, options);
|
|
50
73
|
case 'alibaba':
|
|
74
|
+
case 'qwen':
|
|
51
75
|
return this._chatQwen(messages, options);
|
|
52
76
|
case 'openai':
|
|
53
77
|
return this._chatOpenAI(messages, options);
|
|
@@ -60,10 +84,10 @@ class ModelProvider {
|
|
|
60
84
|
* Anthropic Claude API
|
|
61
85
|
*/
|
|
62
86
|
async _chatAnthropic(messages, options) {
|
|
63
|
-
//
|
|
87
|
+
// Anthropic usually requires their SDK or complex headers
|
|
64
88
|
logger.debug('Anthropic chat', { model: this.model });
|
|
65
89
|
return {
|
|
66
|
-
content: '[Anthropic response placeholder]',
|
|
90
|
+
content: '[Anthropic response placeholder - requires SDK]',
|
|
67
91
|
provider: 'anthropic',
|
|
68
92
|
model: this.model
|
|
69
93
|
};
|
|
@@ -73,39 +97,111 @@ class ModelProvider {
|
|
|
73
97
|
* Moonshot (Kimi) API
|
|
74
98
|
*/
|
|
75
99
|
async _chatMoonshot(messages, options) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
const modelName = this.model === 'sonnet' ? 'moonshot-v1-8k' : this.model;
|
|
101
|
+
const data = {
|
|
102
|
+
model: modelName,
|
|
103
|
+
messages: messages,
|
|
104
|
+
temperature: options.temperature || 0.3
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const reqOptions = {
|
|
108
|
+
hostname: 'api.moonshot.cn',
|
|
109
|
+
path: '/v1/chat/completions',
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
'Authorization': `Bearer ${this.apiKey}`
|
|
114
|
+
}
|
|
82
115
|
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const response = await this._httpRequest(reqOptions, data);
|
|
119
|
+
return {
|
|
120
|
+
content: response.choices[0].message.content,
|
|
121
|
+
provider: 'moonshot',
|
|
122
|
+
model: modelName
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error('Moonshot API error', { error: error.message });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
83
128
|
}
|
|
84
129
|
|
|
85
130
|
/**
|
|
86
|
-
* Alibaba Qwen API
|
|
131
|
+
* Alibaba Qwen API (DashScope)
|
|
87
132
|
*/
|
|
88
133
|
async _chatQwen(messages, options) {
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
134
|
+
// Map generic model names to Qwen specific ones
|
|
135
|
+
let modelName = this.model;
|
|
136
|
+
if (modelName === 'sonnet' || modelName === 'gpt-4') modelName = 'qwen-max';
|
|
137
|
+
if (modelName === 'haiku' || modelName === 'gpt-3.5-turbo') modelName = 'qwen-plus';
|
|
138
|
+
|
|
139
|
+
const data = {
|
|
140
|
+
model: modelName,
|
|
141
|
+
input: {
|
|
142
|
+
messages: messages
|
|
143
|
+
},
|
|
144
|
+
parameters: {
|
|
145
|
+
result_format: 'message',
|
|
146
|
+
temperature: options.temperature || 0.3
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const reqOptions = {
|
|
151
|
+
hostname: 'dashscope.aliyuncs.com',
|
|
152
|
+
path: '/api/v1/services/aigc/text-generation/generation',
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'Authorization': `Bearer ${this.apiKey}`
|
|
157
|
+
}
|
|
95
158
|
};
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const response = await this._httpRequest(reqOptions, data);
|
|
162
|
+
return {
|
|
163
|
+
content: response.output.choices[0].message.content,
|
|
164
|
+
provider: 'alibaba',
|
|
165
|
+
model: modelName
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.error('Qwen API error', { error: error.message });
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
96
171
|
}
|
|
97
172
|
|
|
98
173
|
/**
|
|
99
174
|
* OpenAI API
|
|
100
175
|
*/
|
|
101
176
|
async _chatOpenAI(messages, options) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
177
|
+
const modelName = this.model === 'sonnet' ? 'gpt-4-turbo' : this.model;
|
|
178
|
+
const data = {
|
|
179
|
+
model: modelName,
|
|
180
|
+
messages: messages,
|
|
181
|
+
temperature: options.temperature || 0.3
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const reqOptions = {
|
|
185
|
+
hostname: 'api.openai.com',
|
|
186
|
+
path: '/v1/chat/completions',
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: {
|
|
189
|
+
'Content-Type': 'application/json',
|
|
190
|
+
'Authorization': `Bearer ${this.apiKey}`
|
|
191
|
+
}
|
|
108
192
|
};
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const response = await this._httpRequest(reqOptions, data);
|
|
196
|
+
return {
|
|
197
|
+
content: response.choices[0].message.content,
|
|
198
|
+
provider: 'openai',
|
|
199
|
+
model: modelName
|
|
200
|
+
};
|
|
201
|
+
} catch (error) {
|
|
202
|
+
logger.error('OpenAI API error', { error: error.message });
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
109
205
|
}
|
|
110
206
|
|
|
111
207
|
/**
|
|
@@ -114,7 +210,6 @@ class ModelProvider {
|
|
|
114
210
|
* @returns {number} - Approximate token count
|
|
115
211
|
*/
|
|
116
212
|
countTokens(text) {
|
|
117
|
-
// Rough estimate: 1 token ≈ 4 characters
|
|
118
213
|
return Math.ceil(text.length / 4);
|
|
119
214
|
}
|
|
120
215
|
|
|
@@ -7,6 +7,8 @@ const path = require('path');
|
|
|
7
7
|
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { writeStateMd } = require('./state.cjs');
|
|
10
|
+
const { safePlanningWriteSync } = require('./planning-write.cjs');
|
|
11
|
+
const { defaultLogger: logger } = require('./logger.cjs');
|
|
10
12
|
|
|
11
13
|
function cmdPhasesList(cwd, options, raw) {
|
|
12
14
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
@@ -79,8 +81,9 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
79
81
|
|
|
80
82
|
// Default: list directories
|
|
81
83
|
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
|
|
82
|
-
} catch (
|
|
83
|
-
error('Failed to list phases
|
|
84
|
+
} catch (err) {
|
|
85
|
+
logger.error('Failed to list phases in cmdPhasesList', { error: err.message });
|
|
86
|
+
error('Failed to list phases: ' + err.message);
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -144,8 +147,9 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
144
147
|
raw,
|
|
145
148
|
nextDecimal
|
|
146
149
|
);
|
|
147
|
-
} catch (
|
|
148
|
-
error('Failed to calculate next decimal phase
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.error('Failed to calculate next decimal phase in cmdPhaseNextDecimal', { basePhase, error: err.message });
|
|
152
|
+
error('Failed to calculate next decimal phase: ' + err.message);
|
|
149
153
|
}
|
|
150
154
|
}
|
|
151
155
|
|
|
@@ -188,7 +192,8 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
188
192
|
};
|
|
189
193
|
|
|
190
194
|
output(result, raw, result.directory);
|
|
191
|
-
} catch {
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger.warn('Failed to read phases directory in cmdFindPhase', { phasesDir, error: err.message });
|
|
192
197
|
output(notFound, raw, '');
|
|
193
198
|
}
|
|
194
199
|
}
|
|
@@ -217,8 +222,8 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
217
222
|
phaseDir = path.join(phasesDir, match);
|
|
218
223
|
phaseDirName = match;
|
|
219
224
|
}
|
|
220
|
-
} catch {
|
|
221
|
-
|
|
225
|
+
} catch (err) {
|
|
226
|
+
logger.warn('Failed to enumerate phase directories in cmdPhasePlanIndex', { phasesDir, error: err.message });
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
if (!phaseDir) {
|
|
@@ -337,10 +342,10 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|
|
337
342
|
|
|
338
343
|
// Create directory with .gitkeep so git tracks empty folders
|
|
339
344
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
340
|
-
|
|
345
|
+
safePlanningWriteSync(path.join(dirPath, '.gitkeep'), '');
|
|
341
346
|
|
|
342
347
|
// Build phase entry
|
|
343
|
-
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /
|
|
348
|
+
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /ez-plan-phase ${newPhaseNum} to break down)\n`;
|
|
344
349
|
|
|
345
350
|
// Find insertion point: before last "---" or at end
|
|
346
351
|
let updatedContent;
|
|
@@ -351,7 +356,7 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|
|
351
356
|
updatedContent = content + phaseEntry;
|
|
352
357
|
}
|
|
353
358
|
|
|
354
|
-
|
|
359
|
+
safePlanningWriteSync(roadmapPath, updatedContent);
|
|
355
360
|
|
|
356
361
|
const result = {
|
|
357
362
|
phase_number: newPhaseNum,
|
|
@@ -399,7 +404,9 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
399
404
|
const dm = dir.match(decimalPattern);
|
|
400
405
|
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
|
401
406
|
}
|
|
402
|
-
} catch {
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.warn('Failed to enumerate decimal siblings in cmdPhaseInsert', { phasesDir, normalizedBase, error: err.message });
|
|
409
|
+
}
|
|
403
410
|
|
|
404
411
|
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
|
405
412
|
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
|
@@ -408,10 +415,10 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
408
415
|
|
|
409
416
|
// Create directory with .gitkeep so git tracks empty folders
|
|
410
417
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
411
|
-
|
|
418
|
+
safePlanningWriteSync(path.join(dirPath, '.gitkeep'), '');
|
|
412
419
|
|
|
413
420
|
// Build phase entry
|
|
414
|
-
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /
|
|
421
|
+
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /ez-plan-phase ${decimalPhase} to break down)\n`;
|
|
415
422
|
|
|
416
423
|
// Insert after the target phase section
|
|
417
424
|
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
|
@@ -432,7 +439,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
432
439
|
}
|
|
433
440
|
|
|
434
441
|
const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
|
|
435
|
-
|
|
442
|
+
safePlanningWriteSync(roadmapPath, updatedContent);
|
|
436
443
|
|
|
437
444
|
const result = {
|
|
438
445
|
phase_number: decimalPhase,
|
|
@@ -468,7 +475,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
468
475
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
469
476
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
470
477
|
targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
|
|
471
|
-
} catch {
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.warn('Failed to locate target phase directory in cmdPhaseRemove', { phasesDir, normalized, error: err.message });
|
|
480
|
+
}
|
|
472
481
|
|
|
473
482
|
// Check for executed work (SUMMARY.md files)
|
|
474
483
|
if (targetDir && !force) {
|
|
@@ -536,7 +545,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
536
545
|
}
|
|
537
546
|
}
|
|
538
547
|
}
|
|
539
|
-
} catch {
|
|
548
|
+
} catch (err) {
|
|
549
|
+
logger.warn('Failed to renumber decimal phase directories in cmdPhaseRemove', { normalized, error: err.message });
|
|
550
|
+
}
|
|
540
551
|
|
|
541
552
|
} else {
|
|
542
553
|
// Integer removal: renumber all subsequent integer phases
|
|
@@ -596,7 +607,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
596
607
|
}
|
|
597
608
|
}
|
|
598
609
|
}
|
|
599
|
-
} catch {
|
|
610
|
+
} catch (err) {
|
|
611
|
+
logger.warn('Failed to renumber integer phase directories in cmdPhaseRemove', { normalized, error: err.message });
|
|
612
|
+
}
|
|
600
613
|
}
|
|
601
614
|
|
|
602
615
|
// Update ROADMAP.md
|
|
@@ -663,7 +676,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
663
676
|
}
|
|
664
677
|
}
|
|
665
678
|
|
|
666
|
-
|
|
679
|
+
safePlanningWriteSync(roadmapPath, roadmapContent);
|
|
667
680
|
|
|
668
681
|
// Update STATE.md phase count
|
|
669
682
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
@@ -751,7 +764,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
751
764
|
`$1${summaryCount}/${planCount} plans complete`
|
|
752
765
|
);
|
|
753
766
|
|
|
754
|
-
|
|
767
|
+
safePlanningWriteSync(roadmapPath, roadmapContent);
|
|
755
768
|
|
|
756
769
|
// Update REQUIREMENTS.md traceability for this phase's requirements
|
|
757
770
|
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
|
@@ -783,7 +796,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
783
796
|
);
|
|
784
797
|
}
|
|
785
798
|
|
|
786
|
-
|
|
799
|
+
safePlanningWriteSync(reqPath, reqContent);
|
|
787
800
|
requirementsUpdated = true;
|
|
788
801
|
}
|
|
789
802
|
}
|
|
@@ -815,7 +828,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
815
828
|
}
|
|
816
829
|
}
|
|
817
830
|
}
|
|
818
|
-
} catch {
|
|
831
|
+
} catch (err) {
|
|
832
|
+
logger.warn('Failed to locate next phase from disk in cmdPhaseComplete', { phasesDir, phaseNum, error: err.message });
|
|
833
|
+
}
|
|
819
834
|
|
|
820
835
|
// Fallback: if filesystem found no next phase, check ROADMAP.md
|
|
821
836
|
// for phases that are defined but not yet planned (no directory on disk)
|
|
@@ -832,7 +847,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
832
847
|
break;
|
|
833
848
|
}
|
|
834
849
|
}
|
|
835
|
-
} catch {
|
|
850
|
+
} catch (err) {
|
|
851
|
+
logger.warn('Failed to locate next phase from ROADMAP fallback in cmdPhaseComplete', { roadmapPath, phaseNum, error: err.message });
|
|
852
|
+
}
|
|
836
853
|
}
|
|
837
854
|
|
|
838
855
|
// Update STATE.md
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning Write — lock/temp-safe writer for .planning mutations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { Worker } = require('worker_threads');
|
|
9
|
+
const { withLock } = require('./file-lock.cjs');
|
|
10
|
+
const { createTempFile, cleanupTemp } = require('./temp-file.cjs');
|
|
11
|
+
|
|
12
|
+
function normalizeTimeoutError(err, filePath) {
|
|
13
|
+
if (!err) return err;
|
|
14
|
+
const message = err.message || '';
|
|
15
|
+
if (message.includes('File locked:')) {
|
|
16
|
+
return err;
|
|
17
|
+
}
|
|
18
|
+
if (message.includes('timed out') || err.code === 'ELOCKED') {
|
|
19
|
+
return new Error(`File locked: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
return err;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function safePlanningWrite(filePath, content, options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
timeoutMs = 30000,
|
|
27
|
+
tempPrefix = 'ez-write-',
|
|
28
|
+
} = options;
|
|
29
|
+
|
|
30
|
+
let tempPath = null;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await withLock(filePath, async () => {
|
|
34
|
+
tempPath = await createTempFile(tempPrefix, path.dirname(filePath), content);
|
|
35
|
+
await fs.promises.rename(tempPath, filePath);
|
|
36
|
+
}, { timeout: timeoutMs });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw normalizeTimeoutError(err, filePath);
|
|
39
|
+
} finally {
|
|
40
|
+
if (tempPath) {
|
|
41
|
+
try {
|
|
42
|
+
await cleanupTemp(tempPath);
|
|
43
|
+
} catch {
|
|
44
|
+
// best effort cleanup only
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safePlanningWriteSync(filePath, content, options = {}) {
|
|
51
|
+
const signalBuffer = new SharedArrayBuffer(4);
|
|
52
|
+
const signal = new Int32Array(signalBuffer);
|
|
53
|
+
const errorPath = path.join(os.tmpdir(), `ez-planning-write-error-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.log`);
|
|
54
|
+
const modulePath = __filename;
|
|
55
|
+
|
|
56
|
+
const workerCode = `
|
|
57
|
+
const fs = require('fs');
|
|
58
|
+
const { workerData } = require('worker_threads');
|
|
59
|
+
const signal = new Int32Array(workerData.signalBuffer);
|
|
60
|
+
const { safePlanningWrite } = require(workerData.modulePath);
|
|
61
|
+
(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await safePlanningWrite(workerData.filePath, workerData.content, workerData.options);
|
|
64
|
+
Atomics.store(signal, 0, 1);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(workerData.errorPath, err && (err.stack || err.message) ? (err.stack || err.message) : String(err), 'utf-8');
|
|
68
|
+
} catch {}
|
|
69
|
+
Atomics.store(signal, 0, 2);
|
|
70
|
+
} finally {
|
|
71
|
+
Atomics.notify(signal, 0);
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const worker = new Worker(workerCode, {
|
|
77
|
+
eval: true,
|
|
78
|
+
workerData: { modulePath, filePath, content, options, signalBuffer, errorPath },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
while (Atomics.load(signal, 0) === 0) {
|
|
83
|
+
Atomics.wait(signal, 0, 0, 100);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
worker.terminate().catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const status = Atomics.load(signal, 0);
|
|
90
|
+
if (status === 2) {
|
|
91
|
+
let message = `safePlanningWriteSync failed for ${filePath}`;
|
|
92
|
+
try {
|
|
93
|
+
if (fs.existsSync(errorPath)) {
|
|
94
|
+
message = fs.readFileSync(errorPath, 'utf-8') || message;
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
try { fs.rmSync(errorPath, { force: true }); } catch {}
|
|
98
|
+
throw new Error(message);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try { fs.rmSync(errorPath, { force: true }); } catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
safePlanningWrite,
|
|
106
|
+
safePlanningWriteSync,
|
|
107
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs');
|
|
8
|
+
const { safePlanningWriteSync } = require('./planning-write.cjs');
|
|
8
9
|
|
|
9
10
|
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
|
10
11
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
@@ -158,7 +159,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
158
159
|
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
|
|
159
160
|
|
|
160
161
|
// If roadmap marks phase complete, trust that over disk file structure.
|
|
161
|
-
// Phases completed before
|
|
162
|
+
// Phases completed before EZ tracking (or via external tools) may lack
|
|
162
163
|
// the standard PLAN/SUMMARY pairs but are still done.
|
|
163
164
|
if (roadmapComplete && diskStatus !== 'complete') {
|
|
164
165
|
diskStatus = 'complete';
|
|
@@ -286,7 +287,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
286
287
|
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
|
|
290
|
+
safePlanningWriteSync(roadmapPath, roadmapContent);
|
|
290
291
|
|
|
291
292
|
output({
|
|
292
293
|
updated: true,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* EZ Safe Exec — Secure command execution with allowlist and validation
|
|
5
5
|
*
|
|
6
6
|
* Prevents command injection by:
|
|
7
7
|
* - Using execFile instead of execSync with string concatenation
|
|
@@ -6,6 +6,8 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
9
|
+
const { safePlanningWriteSync } = require('./planning-write.cjs');
|
|
10
|
+
const { defaultLogger: logger } = require('./logger.cjs');
|
|
9
11
|
|
|
10
12
|
// Shared helper: extract a field value from STATE.md content.
|
|
11
13
|
// Supports both **Field:** bold and plain Field: format.
|
|
@@ -26,7 +28,9 @@ function cmdStateLoad(cwd, raw) {
|
|
|
26
28
|
let stateRaw = '';
|
|
27
29
|
try {
|
|
28
30
|
stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8');
|
|
29
|
-
} catch {
|
|
31
|
+
} catch (err) {
|
|
32
|
+
logger.warn('Failed to read STATE.md in cmdStateLoad', { planningDir, error: err.message });
|
|
33
|
+
}
|
|
30
34
|
|
|
31
35
|
const configExists = fs.existsSync(path.join(planningDir, 'config.json'));
|
|
32
36
|
const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
|
|
@@ -102,7 +106,8 @@ function cmdStateGet(cwd, section, raw) {
|
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
output({ error: `Section or field "${section}" not found` }, raw, '');
|
|
105
|
-
} catch {
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.error('STATE.md not found in cmdStateGet', { statePath, error: err.message });
|
|
106
111
|
error('STATE.md not found');
|
|
107
112
|
}
|
|
108
113
|
}
|
|
@@ -146,7 +151,8 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
149
|
-
} catch {
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.error('Failed to patch STATE.md', { statePath, error: err.message });
|
|
150
156
|
error('STATE.md not found');
|
|
151
157
|
}
|
|
152
158
|
}
|
|
@@ -174,7 +180,8 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
174
180
|
} else {
|
|
175
181
|
output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
|
|
176
182
|
}
|
|
177
|
-
} catch {
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logger.error('Failed to update STATE.md', { statePath, field, error: err.message });
|
|
178
185
|
output({ updated: false, reason: 'STATE.md not found' });
|
|
179
186
|
}
|
|
180
187
|
}
|
|
@@ -325,6 +332,7 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
325
332
|
summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
|
|
326
333
|
rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
|
|
327
334
|
} catch (err) {
|
|
335
|
+
logger.error('Failed to read decision text', { error: err.message });
|
|
328
336
|
output({ added: false, reason: err.message }, raw, 'false');
|
|
329
337
|
return;
|
|
330
338
|
}
|
|
@@ -360,6 +368,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
|
|
|
360
368
|
try {
|
|
361
369
|
blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
|
|
362
370
|
} catch (err) {
|
|
371
|
+
logger.error('Failed to read blocker text', { error: err.message });
|
|
363
372
|
output({ added: false, reason: err.message }, raw, 'false');
|
|
364
373
|
return;
|
|
365
374
|
}
|
|
@@ -574,7 +583,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
574
583
|
const info = getMilestoneInfo(cwd);
|
|
575
584
|
milestone = info.version;
|
|
576
585
|
milestoneName = info.name;
|
|
577
|
-
} catch {
|
|
586
|
+
} catch (err) {
|
|
587
|
+
logger.warn('Failed to resolve milestone info while building state frontmatter', { error: err.message });
|
|
588
|
+
}
|
|
578
589
|
}
|
|
579
590
|
|
|
580
591
|
let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
@@ -609,7 +620,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
609
620
|
totalPlans = diskTotalPlans;
|
|
610
621
|
completedPlans = diskTotalSummaries;
|
|
611
622
|
}
|
|
612
|
-
} catch {
|
|
623
|
+
} catch (err) {
|
|
624
|
+
logger.warn('Failed to compute phase progress from disk while building state frontmatter', { error: err.message });
|
|
625
|
+
}
|
|
613
626
|
}
|
|
614
627
|
|
|
615
628
|
let progressPercent = null;
|
|
@@ -637,7 +650,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
637
650
|
normalizedStatus = 'executing';
|
|
638
651
|
}
|
|
639
652
|
|
|
640
|
-
const fm = {
|
|
653
|
+
const fm = {
|
|
654
|
+
ez_state_version: '1.0',
|
|
655
|
+
};
|
|
641
656
|
|
|
642
657
|
if (milestone) fm.milestone = milestone;
|
|
643
658
|
if (milestoneName) fm.milestone_name = milestoneName;
|
|
@@ -676,9 +691,9 @@ function syncStateFrontmatter(content, cwd) {
|
|
|
676
691
|
* Write STATE.md with synchronized YAML frontmatter.
|
|
677
692
|
* All STATE.md writes should use this instead of raw writeFileSync.
|
|
678
693
|
*/
|
|
679
|
-
function writeStateMd(statePath, content, cwd) {
|
|
694
|
+
function writeStateMd(statePath, content, cwd, options = {}) {
|
|
680
695
|
const synced = syncStateFrontmatter(content, cwd);
|
|
681
|
-
|
|
696
|
+
safePlanningWriteSync(statePath, synced, options);
|
|
682
697
|
}
|
|
683
698
|
|
|
684
699
|
function cmdStateJson(cwd, raw) {
|