draftgo-cli 3.0.49 → 3.0.52

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.
@@ -0,0 +1,408 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const log = require('../logger');
7
+ const runtimeFiles = require('../runtimeFiles');
8
+
9
+ const TASK_ROOT = path.join('.draftgo', 'Task');
10
+ const TASK_FILENAME = 'Task.md';
11
+ const REQUIRED_ITEM_FIELDS = ['内容', '负责人', '验收'];
12
+
13
+ function root(projectDir) { return path.join(projectDir, TASK_ROOT); }
14
+ function taskDir(projectDir, id) { return path.join(root(projectDir), String(id)); }
15
+ function markdownFile(projectDir, id) { return path.join(taskDir(projectDir, id), TASK_FILENAME); }
16
+ function legacyFile(projectDir, id) { return path.join(taskDir(projectDir, id), 'task.yaml'); }
17
+
18
+ function atomicWrite(file, value, options = {}) {
19
+ fs.mkdirSync(path.dirname(file), { recursive: true });
20
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
21
+ fs.writeFileSync(temp, value, 'utf8');
22
+ try { runtimeFiles.replaceFile(temp, file, options.rename, options.wait); } catch (error) {
23
+ try { fs.rmSync(temp, { force: true }); } catch { /* retain the original rename error */ }
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ function withLock(projectDir, taskId, action) {
29
+ const file = path.join(taskDir(projectDir, taskId), '.lock');
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ let descriptor;
32
+ for (let attempt = 0; attempt < 50; attempt += 1) {
33
+ try { descriptor = fs.openSync(file, 'wx'); break; } catch (error) {
34
+ if (error.code !== 'EEXIST') throw error;
35
+ const started = Date.now(); while (Date.now() - started < 20) { /* short lock wait */ }
36
+ }
37
+ }
38
+ if (descriptor === undefined) throw new Error(`Task ${taskId} is locked by another Agent.`);
39
+ try {
40
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
41
+ const target = markdownFile(projectDir, taskId);
42
+ const before = fs.existsSync(target) ? digest(fs.readFileSync(target)) : null;
43
+ return action(before);
44
+ } finally {
45
+ fs.closeSync(descriptor);
46
+ fs.rmSync(file, { force: true });
47
+ }
48
+ }
49
+
50
+ function digest(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
51
+ function normalizeNewlines(value) { return String(value || '').replace(/\r\n/g, '\n'); }
52
+ function slugify(value) {
53
+ return String(value || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'task';
54
+ }
55
+ function today(flags = {}) { return String(flags.date || new Date().toISOString().slice(0, 10)); }
56
+ function uniqueTaskId(projectDir, title, flags = {}) {
57
+ const requested = String(flags.id || '').trim();
58
+ let base;
59
+ if (/^\[\d{4}-\d{2}-\d{2}\][a-z0-9][a-z0-9-]*$/.test(requested)) base = requested;
60
+ else base = `[${today(flags)}]${slugify(requested || title)}`;
61
+ let candidate = base; let suffix = 2;
62
+ while (fs.existsSync(taskDir(projectDir, candidate))) { candidate = `${base}-${suffix}`; suffix += 1; }
63
+ return candidate;
64
+ }
65
+ function field(flags, names, fallback = '') {
66
+ for (const name of names) if (flags[name] !== undefined) return String(flags[name]).trim();
67
+ return fallback;
68
+ }
69
+ function lines(value, fallback = '- 暂无。') {
70
+ const text = String(value || '').trim();
71
+ if (!text) return fallback;
72
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
73
+ .map((line) => line.startsWith('- ') ? line : `- ${line}`).join('\n');
74
+ }
75
+
76
+ function template(task) {
77
+ return `# ${task.title}\n\n`+
78
+ `- 状态:active\n- 创建时间:${task.created_at}\n- 更新时间:${task.updated_at}\n\n`+
79
+ `## 用户原始需求\n\n${task.original_requirement}\n\n`+
80
+ `## 明确后的需求\n\n${task.clarified_requirement}\n\n`+
81
+ `## 预计效果\n\n${lines(task.expected_effect)}\n\n`+
82
+ `## 本次范围\n\n${lines(task.scope)}\n\n`+
83
+ `## 不在本次范围\n\n${lines(task.out_of_scope)}\n\n`+
84
+ '## 开发清单\n\n' +
85
+ '暂无。\n\n' +
86
+ '## 关键决策\n\n- 暂无。\n\n' +
87
+ '## 阻塞问题\n\n- 暂无。\n\n' +
88
+ '## 最终验收\n\n' +
89
+ '- [ ] 所有开发项完成\n' +
90
+ '- [ ] 完成验证\n' +
91
+ '- [ ] changelog 已写入\n';
92
+ }
93
+
94
+ function section(body, name) {
95
+ const pattern = new RegExp(`^## ${escapeRegExp(name)}\\s*$`, 'm');
96
+ const match = pattern.exec(body);
97
+ if (!match) return '';
98
+ const start = match.index + match[0].length;
99
+ const rest = body.slice(start);
100
+ const next = /^##\s+/m.exec(rest);
101
+ return rest.slice(0, next ? next.index : rest.length).trim();
102
+ }
103
+ function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
104
+ function metadata(body, name) {
105
+ const match = body.match(new RegExp(`^- ${escapeRegExp(name)}[::]\\s*(.*)$`, 'm'));
106
+ return match ? match[1].trim() : '';
107
+ }
108
+ function parseChecklist(text, final = false) {
109
+ const body = normalizeNewlines(text); const entries = [];
110
+ const pattern = /^- \[([ xX])\]\s+(.+)$/gm; const matches = [...body.matchAll(pattern)];
111
+ for (let index = 0; index < matches.length; index += 1) {
112
+ const match = matches[index]; const start = match.index; const end = index + 1 < matches.length ? matches[index + 1].index : body.length;
113
+ const block = body.slice(start, end).trimEnd();
114
+ if (final) {
115
+ entries.push({ title: match[2].trim(), completed: match[1].toLowerCase() === 'x', block });
116
+ continue;
117
+ }
118
+ const heading = match[2].trim().match(/^(DG-\d{3})\s+(.+)$/);
119
+ if (!heading) continue;
120
+ const fields = {};
121
+ for (const line of block.split('\n').slice(1)) {
122
+ const detail = line.match(/^\s{2,}-\s+([^::]+)[::]\s*(.*)$/);
123
+ if (detail) fields[detail[1].trim()] = detail[2].trim();
124
+ }
125
+ entries.push({ id: heading[1], title: heading[2], completed: match[1].toLowerCase() === 'x', status: fields['状态'] || (match[1].toLowerCase() === 'x' ? 'completed' : 'pending'), fields, block });
126
+ }
127
+ return entries;
128
+ }
129
+
130
+ function parseMarkdown(file, id) {
131
+ const body = normalizeNewlines(fs.readFileSync(file, 'utf8'));
132
+ const title = (body.match(/^#\s+(.+)$/m) || [null, id])[1].trim();
133
+ const items = parseChecklist(section(body, '开发清单'));
134
+ const acceptance = parseChecklist(section(body, '最终验收'), true);
135
+ return {
136
+ schema_version: 2, format: 'markdown', id, title, status: metadata(body, '状态') || 'active',
137
+ created_at: metadata(body, '创建时间'), updated_at: metadata(body, '更新时间'),
138
+ original_requirement: section(body, '用户原始需求'), clarified_requirement: section(body, '明确后的需求'),
139
+ expected_effect: section(body, '预计效果'), scope: section(body, '本次范围'), out_of_scope: section(body, '不在本次范围'),
140
+ decisions: section(body, '关键决策'), blockers: section(body, '阻塞问题'), items, acceptance,
141
+ completed_items: items.filter((item) => item.completed).length, total_items: items.length,
142
+ body, file,
143
+ };
144
+ }
145
+
146
+ function unquote(value) {
147
+ const text = String(value || '').trim();
148
+ if (text === '[]') return [];
149
+ try { return JSON.parse(text); } catch { return text.replace(/^['"]|['"]$/g, ''); }
150
+ }
151
+ function parseLegacy(file, id) {
152
+ const body = normalizeNewlines(fs.readFileSync(file, 'utf8')); const result = { schema_version: 1, format: 'legacy', id, items: [], body, file };
153
+ let current = null;
154
+ for (const line of body.split('\n')) {
155
+ const item = line.match(/^\s*-\s+id:\s*(.+)$/);
156
+ if (item) { current = { id: unquote(item[1]) }; result.items.push(current); continue; }
157
+ const value = line.match(/^(\w+):\s*(.*)$/) || line.match(/^\s{4}(\w+):\s*(.*)$/);
158
+ if (!value) continue;
159
+ if (!line.startsWith(' ') && value[1] === 'items') continue;
160
+ const target = line.startsWith(' ') && current ? current : result;
161
+ target[value[1]] = unquote(value[2]);
162
+ }
163
+ result.completed_items = result.items.filter((item) => item.status === 'completed').length;
164
+ result.total_items = result.items.length;
165
+ return result;
166
+ }
167
+
168
+ function resolveTaskId(projectDir, value) {
169
+ const requested = String(value || '').trim();
170
+ if (!requested) throw new Error('Task id is required.');
171
+ if (fs.existsSync(taskDir(projectDir, requested))) return requested;
172
+ const directory = root(projectDir);
173
+ if (!fs.existsSync(directory)) throw new Error(`Task ${requested} does not exist.`);
174
+ const shortName = new RegExp(`^\\[\\d{4}-\\d{2}-\\d{2}\\]${escapeRegExp(requested)}(?:-\\d+)?$`);
175
+ const matches = fs.readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory() && (entry.name === requested || shortName.test(entry.name)));
176
+ if (matches.length === 1) return matches[0].name;
177
+ if (matches.length > 1) throw new Error(`Task ${requested} is ambiguous; use its full directory name.`);
178
+ throw new Error(`Task ${requested} does not exist.`);
179
+ }
180
+ function loadTask(projectDir, value) {
181
+ const id = resolveTaskId(projectDir, value);
182
+ const modern = markdownFile(projectDir, id); const legacy = legacyFile(projectDir, id);
183
+ if (fs.existsSync(modern)) return parseMarkdown(modern, id);
184
+ if (fs.existsSync(legacy)) return parseLegacy(legacy, id);
185
+ throw new Error(`Task ${id} has no ${TASK_FILENAME} or task.yaml.`);
186
+ }
187
+ function publicTask(task) {
188
+ const result = { ...task }; delete result.body; delete result.file; return result;
189
+ }
190
+ function print(flags, value) {
191
+ if (flags.output === 'json') console.log(JSON.stringify(Array.isArray(value) ? value.map(publicTask) : publicTask(value), null, 2));
192
+ }
193
+ function taskActor(flags) { return String(flags.owner || flags.assignee || process.env.DRAFTGO_AGENT_ID || '').trim(); }
194
+ function assertModern(task, operation) {
195
+ if (task.format === 'legacy') throw new Error(`Legacy Task ${task.id} is read-only; run task migrate ${task.id} before ${operation}.`);
196
+ }
197
+ function saveBody(task, body, beforeHash) {
198
+ const current = fs.readFileSync(task.file);
199
+ if (beforeHash && digest(current) !== beforeHash) throw new Error(`Task ${task.id} changed while it was being updated; retry the command.`);
200
+ atomicWrite(task.file, body.endsWith('\n') ? body : `${body}\n`);
201
+ }
202
+ function replaceMetadata(body, name, value) {
203
+ const pattern = new RegExp(`^- ${escapeRegExp(name)}[::].*$`, 'm');
204
+ if (!pattern.test(body)) throw new Error(`Task.md is missing metadata field ${name}.`);
205
+ return body.replace(pattern, `- ${name}:${value}`);
206
+ }
207
+ function touch(task, body, status) {
208
+ let result = replaceMetadata(body, '更新时间', new Date().toISOString());
209
+ if (status) result = replaceMetadata(result, '状态', status);
210
+ return result;
211
+ }
212
+ function replaceSection(body, name, content) {
213
+ const pattern = new RegExp(`(^## ${escapeRegExp(name)}\\s*$\\n)([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, 'm');
214
+ if (!pattern.test(body)) throw new Error(`Task.md is missing section ${name}.`);
215
+ return body.replace(pattern, `$1\n${content.trim()}\n\n`);
216
+ }
217
+ function replaceItemBlock(task, itemId, replacement) {
218
+ const checklist = section(task.body, '开发清单'); const item = task.items.find((entry) => entry.id === itemId);
219
+ if (!item) throw new Error(`Task item ${itemId} does not exist.`);
220
+ return replaceSection(task.body, '开发清单', checklist.replace(item.block, replacement));
221
+ }
222
+ function upsertItemField(block, name, value) {
223
+ const pattern = new RegExp(`^ - ${escapeRegExp(name)}[::].*$`, 'm');
224
+ if (pattern.test(block)) return block.replace(pattern, ` - ${name}:${value}`);
225
+ return `${block.trimEnd()}\n - ${name}:${value}`;
226
+ }
227
+ function validateItemFields(item) {
228
+ const missing = REQUIRED_ITEM_FIELDS.filter((name) => !String(item.fields[name] || '').trim());
229
+ if (missing.length) throw new Error(`${item.id} is missing required field(s): ${missing.join(', ')}.`);
230
+ }
231
+
232
+ function create(projectDir, positional, flags) {
233
+ const title = field(flags, ['title'], positional.join(' '));
234
+ if (!title) throw new Error('task create requires a title or --title.');
235
+ const original = field(flags, ['original', 'original-requirement', 'description']);
236
+ const clarified = field(flags, ['clarified', 'clarified-requirement'], original);
237
+ const expected = field(flags, ['expected-effect', 'effect']);
238
+ if (!original) throw new Error('task create requires --original with the user requirement.');
239
+ if (!clarified) throw new Error('task create requires --clarified with the executable requirement.');
240
+ if (!expected) throw new Error('task create requires --expected-effect.');
241
+ const id = uniqueTaskId(projectDir, title, flags); const now = new Date().toISOString();
242
+ const task = { id, title, created_at: now, updated_at: now, original_requirement: original, clarified_requirement: clarified,
243
+ expected_effect: expected, scope: field(flags, ['scope']), out_of_scope: field(flags, ['out-of-scope']) };
244
+ fs.mkdirSync(taskDir(projectDir, id), { recursive: true }); atomicWrite(markdownFile(projectDir, id), template(task));
245
+ const loaded = loadTask(projectDir, id); print(flags, loaded); if (flags.output !== 'json') log.ok(`Created task ${id}`); return 0;
246
+ }
247
+
248
+ function list(projectDir, flags) {
249
+ const tasks = []; const directory = root(projectDir);
250
+ if (fs.existsSync(directory)) for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
251
+ if (!entry.isDirectory()) continue;
252
+ try { tasks.push(loadTask(projectDir, entry.name)); } catch { /* ignore non-task directories */ }
253
+ }
254
+ tasks.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
255
+ print(flags, tasks);
256
+ if (flags.output !== 'json') tasks.forEach((task) => log.info(`${task.id} [${task.status}] ${task.title} (${task.completed_items}/${task.total_items})${task.format === 'legacy' ? ' [legacy]' : ''}`));
257
+ return 0;
258
+ }
259
+
260
+ function show(projectDir, taskId, flags) {
261
+ const task = loadTask(projectDir, taskId); print(flags, task);
262
+ if (flags.output !== 'json') {
263
+ const next = task.items.find((item) => !item.completed);
264
+ log.info(`${task.id} [${task.status}] ${task.title}\nProgress: ${task.completed_items}/${task.total_items}\n${next ? `Next: ${next.id} ${next.title}` : 'Next: final acceptance'}`);
265
+ }
266
+ return 0;
267
+ }
268
+
269
+ function nextItemId(items) {
270
+ const highest = items.reduce((max, item) => Math.max(max, Number(item.id.match(/^DG-(\d+)$/)?.[1] || 0)), 0);
271
+ return `DG-${String(highest + 1).padStart(3, '0')}`;
272
+ }
273
+ function add(projectDir, taskId, positional, flags) {
274
+ const resolved = resolveTaskId(projectDir, taskId);
275
+ return withLock(projectDir, resolved, (before) => {
276
+ const task = loadTask(projectDir, resolved); assertModern(task, 'add');
277
+ const title = field(flags, ['title'], positional.join(' ')); const content = field(flags, ['content']); const acceptance = field(flags, ['acceptance']);
278
+ const owner = field(flags, ['assignee'], taskActor(flags));
279
+ if (!title) throw new Error('task add requires a title or --title.');
280
+ if (!content) throw new Error('task add requires --content.');
281
+ if (!owner) throw new Error('task add requires --assignee or owner identity.');
282
+ if (!acceptance) throw new Error('task add requires --acceptance.');
283
+ const id = String(flags.id || nextItemId(task.items));
284
+ if (!/^DG-\d{3}$/.test(id)) throw new Error('Task item id must use DG-001 format.');
285
+ if (task.items.some((item) => item.id === id)) throw new Error(`Task item ${id} already exists.`);
286
+ let block = `- [ ] ${id} ${title}\n - 内容:${content}\n - 负责人:${owner}`;
287
+ const moduleLogic = field(flags, ['module-logic']); const userJourney = field(flags, ['user-journey']);
288
+ if (moduleLogic) block += `\n - 模块逻辑:${moduleLogic}`;
289
+ if (userJourney) block += `\n - 用户路线:${userJourney}`;
290
+ block += `\n - 验收:${acceptance}`;
291
+ const current = section(task.body, '开发清单'); const contentSection = current === '暂无。' ? block : `${current.trimEnd()}\n\n${block}`;
292
+ const body = touch(task, replaceSection(task.body, '开发清单', contentSection), 'active'); saveBody(task, body, before);
293
+ const updated = loadTask(projectDir, resolved); const item = updated.items.find((entry) => entry.id === id); print(flags, item);
294
+ if (flags.output !== 'json') log.ok(`Added ${id}`); return 0;
295
+ });
296
+ }
297
+
298
+ function mutate(projectDir, taskId, itemId, operation, flags, trailing = []) {
299
+ const resolved = resolveTaskId(projectDir, taskId);
300
+ return withLock(projectDir, resolved, (before) => {
301
+ const task = loadTask(projectDir, resolved); assertModern(task, operation); const item = task.items.find((entry) => entry.id === itemId);
302
+ if (!item) throw new Error(`Task item ${itemId} does not exist.`); validateItemFields(item);
303
+ const actor = taskActor(flags); if (!actor) throw new Error(`${itemId} requires an owner identity; pass --owner/--assignee or set DRAFTGO_AGENT_ID.`);
304
+ const assigned = item.fields['负责人']; if (assigned && assigned !== actor && operation !== 'claim') throw new Error(`${itemId} can only be updated by owner ${assigned}.`);
305
+ if (operation === 'claim' && assigned && assigned !== actor) throw new Error(`${itemId} is already owned by ${assigned}.`);
306
+ let block = item.block;
307
+ if (operation === 'complete') {
308
+ const evidence = field(flags, ['evidence'], trailing.join(' '));
309
+ if (!evidence) throw new Error(`${itemId} requires --evidence before completion.`);
310
+ block = block.replace(/^- \[[ xX]\]/, '- [x]'); block = upsertItemField(block, '状态', 'completed'); block = upsertItemField(block, '完成结果', evidence);
311
+ } else if (operation === 'block') {
312
+ const reason = field(flags, ['evidence'], trailing.join(' ')); if (!reason) throw new Error(`${itemId} block requires a reason.`);
313
+ block = block.replace(/^- \[[ xX]\]/, '- [ ]'); block = upsertItemField(block, '状态', 'blocked'); block = upsertItemField(block, '阻塞原因', reason);
314
+ } else if (operation === 'reopen') {
315
+ block = block.replace(/^- \[[ xX]\]/, '- [ ]'); block = upsertItemField(block, '状态', 'pending');
316
+ } else {
317
+ block = upsertItemField(block, '负责人', actor); block = upsertItemField(block, '状态', operation === 'start' ? 'in_progress' : 'claimed');
318
+ }
319
+ let body = replaceItemBlock(task, itemId, block); let reloaded = { ...task, body };
320
+ const items = parseChecklist(section(body, '开发清单'));
321
+ if (items.length && items.every((entry) => entry.completed)) body = updateAcceptanceBody(body, '所有开发项完成', true, 'CLI automatically confirmed all development items.');
322
+ else body = updateAcceptanceBody(body, '所有开发项完成', false, 'Development work is still open.');
323
+ body = touch(reloaded, body, 'active'); saveBody(task, body, before);
324
+ const updated = loadTask(projectDir, resolved).items.find((entry) => entry.id === itemId); print(flags, updated);
325
+ if (flags.output !== 'json') log.ok(`${itemId}: ${updated.status}`); return 0;
326
+ });
327
+ }
328
+
329
+ function updateAcceptanceBody(body, title, completed, evidence) {
330
+ const current = section(body, '最终验收'); const entries = parseChecklist(current, true); const target = entries.find((entry) => entry.title === title);
331
+ if (!target) throw new Error(`Final acceptance item ${title} does not exist.`);
332
+ let block = target.block.replace(/^- \[[ xX]\]/, completed ? '- [x]' : '- [ ]');
333
+ if (evidence) block = upsertItemField(block, '验证结果', evidence);
334
+ return replaceSection(body, '最终验收', current.replace(target.block, block));
335
+ }
336
+ function accept(projectDir, taskId, positional, flags) {
337
+ const resolved = resolveTaskId(projectDir, taskId); const title = field(flags, ['item'], positional.join(' '));
338
+ if (!title) throw new Error('task accept requires a final acceptance item title.');
339
+ return withLock(projectDir, resolved, (before) => {
340
+ const task = loadTask(projectDir, resolved); assertModern(task, 'accept');
341
+ const evidence = field(flags, ['evidence']); if (!evidence) throw new Error('task accept requires --evidence.');
342
+ const body = touch(task, updateAcceptanceBody(task.body, title, true, evidence), 'active'); saveBody(task, body, before);
343
+ const updated = loadTask(projectDir, resolved); print(flags, updated.acceptance.find((entry) => entry.title === title));
344
+ if (flags.output !== 'json') log.ok(`Accepted: ${title}`); return 0;
345
+ });
346
+ }
347
+
348
+ function finish(projectDir, taskId, flags) {
349
+ const resolved = resolveTaskId(projectDir, taskId);
350
+ return withLock(projectDir, resolved, (before) => {
351
+ const task = loadTask(projectDir, resolved); assertModern(task, 'finish');
352
+ if (task.items.some((item) => !item.completed)) throw new Error(`Cannot finish ${task.id}; development items remain incomplete.`);
353
+ const pending = task.acceptance.filter((item) => !item.completed);
354
+ if (pending.length) throw new Error(`Cannot finish ${task.id}; final acceptance remains incomplete: ${pending.map((item) => item.title).join(', ')}.`);
355
+ const body = touch(task, task.body, 'completed'); saveBody(task, body, before);
356
+ const updated = loadTask(projectDir, resolved); print(flags, updated); if (flags.output !== 'json') log.ok(`Finished task ${resolved}`); return 0;
357
+ });
358
+ }
359
+
360
+ function legacyItemDetails(projectDir, task) {
361
+ return task.items.map((item) => {
362
+ const file = path.join(taskDir(projectDir, task.id), 'items', `${item.id}.md`);
363
+ const body = fs.existsSync(file) ? normalizeNewlines(fs.readFileSync(file, 'utf8')) : '';
364
+ const evidence = section(body, 'Evidence').split('\n').map((line) => line.replace(/^-\s*/, '').trim()).filter(Boolean);
365
+ return { ...item, title: item.title || item.id, evidence };
366
+ });
367
+ }
368
+ function migrate(projectDir, taskId, flags) {
369
+ const resolved = resolveTaskId(projectDir, taskId);
370
+ return withLock(projectDir, resolved, () => {
371
+ const task = loadTask(projectDir, resolved);
372
+ if (task.format !== 'legacy') throw new Error(`Task ${resolved} already uses ${TASK_FILENAME}.`);
373
+ const items = legacyItemDetails(projectDir, task); const now = new Date().toISOString();
374
+ const original = field(flags, ['original'], task.title || resolved); const clarified = field(flags, ['clarified'], original);
375
+ const expected = field(flags, ['expected-effect'], '保留历史任务的目标、进度和验收证据,并使用单文件继续维护。');
376
+ const defaultOwner = task.owner || taskActor(flags);
377
+ const modern = { title: task.title || resolved, created_at: task.created_at || now, updated_at: now, original_requirement: original,
378
+ clarified_requirement: clarified, expected_effect: expected, scope: '', out_of_scope: '' };
379
+ let body = template(modern); const blocks = items.map((item, index) => {
380
+ const id = /^DG-\d{3}$/.test(item.id) ? item.id : `DG-${String(index + 1).padStart(3, '0')}`;
381
+ const owner = item.owner || defaultOwner || 'unassigned'; const evidence = item.evidence.join('; ') || 'Legacy task state migrated.';
382
+ return `- [${item.status === 'completed' ? 'x' : ' '}] ${id} ${item.title}\n - 内容:从历史 Task 迁移。\n - 负责人:${owner}\n - 验收:保留历史工作项状态与证据。\n - 状态:${item.status || 'pending'}${item.status === 'completed' ? `\n - 完成结果:${evidence}` : ''}`;
383
+ });
384
+ body = replaceSection(body, '开发清单', blocks.length ? blocks.join('\n\n') : '暂无。');
385
+ if (blocks.length && items.every((item) => item.status === 'completed')) body = updateAcceptanceBody(body, '所有开发项完成', true, 'Migrated from completed legacy items.');
386
+ atomicWrite(markdownFile(projectDir, resolved), body);
387
+ const updated = loadTask(projectDir, resolved); print(flags, updated); if (flags.output !== 'json') log.ok(`Migrated task ${resolved} to ${TASK_FILENAME}; legacy files were retained.`); return 0;
388
+ });
389
+ }
390
+
391
+ async function task(projectDir, positional, flags = {}) {
392
+ const operation = positional[0] || 'list';
393
+ if (operation === 'create') return create(projectDir, positional.slice(1), flags);
394
+ if (operation === 'list') return list(projectDir, flags);
395
+ if (operation === 'show' || operation === 'status') return show(projectDir, positional[1], flags);
396
+ if (operation === 'add') return add(projectDir, positional[1], positional.slice(2), flags);
397
+ if (operation === 'accept') return accept(projectDir, positional[1], positional.slice(2), flags);
398
+ if (operation === 'finish') return finish(projectDir, positional[1], flags);
399
+ if (operation === 'migrate') return migrate(projectDir, positional[1], flags);
400
+ if (['claim', 'start', 'complete', 'block', 'reopen'].includes(operation)) return mutate(projectDir, positional[1], positional[2], operation, flags, positional.slice(3));
401
+ throw new Error(`Unknown task operation ${operation}.`);
402
+ }
403
+
404
+ module.exports = task;
405
+ module.exports.atomicWrite = atomicWrite;
406
+ module.exports.loadTask = loadTask;
407
+ module.exports.parseMarkdown = parseMarkdown;
408
+ module.exports.uniqueTaskId = uniqueTaskId;
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const log = require('../logger');
4
+ const check = require('./check');
5
+ const verifyUi = require('./verifyUi');
6
+ const { canonicalResourceType } = require('../worktree/types');
7
+
8
+ function mode(value, fallback, flag) {
9
+ const normalized = String(value == null ? fallback : value).toLowerCase();
10
+ if (!['auto', 'always', 'never'].includes(normalized)) throw new Error(`${flag} must be auto, always, or never.`);
11
+ return normalized;
12
+ }
13
+
14
+ function resources(positional) {
15
+ if (!positional.length) return [];
16
+ const [rawType, ...ids] = positional;
17
+ if (!ids.length) throw new Error('Usage: draftgo verify [<pages|nav|docs> <id...>] [--url <url>]');
18
+ const resourceType = canonicalResourceType(rawType);
19
+ return ids.map((resourceId) => ({ resourceType, resourceId: String(resourceId) }));
20
+ }
21
+
22
+ function viewports(flags) {
23
+ const value = String(flags.viewport || '').toLowerCase();
24
+ if (!value) return [{ name: 'custom', width: flags.width, height: flags.height }];
25
+ if (value === 'mobile') return [{ name: 'mobile', width: 390, height: 844 }];
26
+ if (value === 'desktop') return [{ name: 'desktop', width: 1440, height: 900 }];
27
+ if (value === 'both') return [
28
+ { name: 'mobile', width: 390, height: 844 },
29
+ { name: 'desktop', width: 1440, height: 900 },
30
+ ];
31
+ throw new Error('--viewport must be mobile, desktop, or both.');
32
+ }
33
+
34
+ async function verify(projectDir, positional = [], flags = {}) {
35
+ let selected;
36
+ let uiMode;
37
+ let remoteMode;
38
+ try {
39
+ selected = resources(positional);
40
+ uiMode = mode(flags.ui, 'auto', '--ui');
41
+ remoteMode = flags.remote ? 'always' : 'never';
42
+ } catch (error) {
43
+ log.err(error.message);
44
+ return 1;
45
+ }
46
+
47
+ log.title('draftgo verify');
48
+ const checkCode = await check(projectDir, [], {
49
+ strict: flags.strict,
50
+ remote: remoteMode === 'always',
51
+ resourceKeys: selected.length
52
+ ? selected.map((entry) => `${entry.resourceType}:${entry.resourceId}`)
53
+ : null,
54
+ });
55
+ if (checkCode !== 0) return checkCode;
56
+
57
+ const url = String(flags.url || '').trim();
58
+ const runUi = uiMode === 'always' || (uiMode === 'auto' && Boolean(url));
59
+ if (!runUi) {
60
+ if (uiMode === 'always' && !url) {
61
+ log.err('--ui always requires --url <http://localhost:port/path>.');
62
+ return 1;
63
+ }
64
+ log.ok('Unified verification passed (UI skipped).');
65
+ return 0;
66
+ }
67
+ if (selected.length > 1) {
68
+ log.err('One --url can assert at most one resource. Run verify once per route for UI validation.');
69
+ return 1;
70
+ }
71
+
72
+ let targets;
73
+ try { targets = viewports(flags); }
74
+ catch (error) { log.err(error.message); return 1; }
75
+ for (const viewport of targets) {
76
+ const uiFlags = { ...flags, url, width: viewport.width, height: viewport.height, 'mobile-check': 'always' };
77
+ if (remoteMode === 'always' && selected.length === 1 && !uiFlags.resource) {
78
+ uiFlags.resource = `${selected[0].resourceType}:${selected[0].resourceId}`;
79
+ }
80
+ const code = await verifyUi(projectDir, [url], uiFlags);
81
+ if (code !== 0) return code;
82
+ }
83
+ log.ok(`Unified verification passed (${targets.map((item) => item.name).join(', ')}).`);
84
+ return 0;
85
+ }
86
+
87
+ module.exports = verify;
88
+ module.exports.mode = mode;
89
+ module.exports.resources = resources;
90
+ module.exports.viewports = viewports;
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const runtimeFiles = require('../runtimeFiles');
5
6
  const { spawnSync } = require('child_process');
6
7
  const log = require('../logger');
7
8
  const { configPath, loadProjectConfig } = require('../projectConfig');
@@ -162,6 +163,51 @@ function parseResourceSpec(value) {
162
163
  return { resourceType: canonicalResourceType(raw.slice(0, separator)), resourceId: raw.slice(separator + 1) };
163
164
  }
164
165
 
166
+ async function selectFrames(page, mode = 'auto') {
167
+ const top = page.mainFrame ? page.mainFrame() : page;
168
+ const selected = [{ frame: top, label: 'top' }];
169
+ if (mode === 'top') return selected;
170
+ if (!['auto', 'all'].includes(mode)) {
171
+ const handle = await page.locator(mode).first().elementHandle().catch(() => null);
172
+ const frame = handle && await handle.contentFrame().catch(() => null);
173
+ if (!frame) throw new Error(`Frame not found: ${mode}`);
174
+ return [{ frame, label: `selector:${mode}` }];
175
+ }
176
+ const frames = page.frames ? page.frames() : [];
177
+ for (let index = 0; index < frames.length; index += 1) {
178
+ const frame = frames[index];
179
+ if (frame === top) continue;
180
+ let visible = true;
181
+ if (mode === 'auto') {
182
+ const element = await frame.frameElement().catch(() => null);
183
+ visible = Boolean(element && await element.isVisible().catch(() => false));
184
+ }
185
+ if (visible) selected.push({
186
+ frame,
187
+ label: `frame:${frame.name && frame.name() ? frame.name() : index}`,
188
+ });
189
+ }
190
+ return selected;
191
+ }
192
+
193
+ async function inspectFrame(frame) {
194
+ return frame.evaluate(() => {
195
+ const body = document.body;
196
+ const root = document.documentElement;
197
+ return {
198
+ bodyTextLength: body ? (body.innerText || '').trim().length : 0,
199
+ bodyHeight: body ? body.getBoundingClientRect().height : 0,
200
+ hasVisibleMedia: body ? Array.from(body.querySelectorAll('img,svg,canvas,video,iframe,input,button')).some((element) => {
201
+ const rect = element.getBoundingClientRect();
202
+ return rect.width > 1 && rect.height > 1;
203
+ }) : false,
204
+ horizontalOverflow: root.scrollWidth > window.innerWidth + 1,
205
+ scrollWidth: root.scrollWidth,
206
+ viewportWidth: window.innerWidth,
207
+ };
208
+ });
209
+ }
210
+
165
211
  async function verifyRemoteResource(projectDir, spec) {
166
212
  const { resourceType, resourceId } = parseResourceSpec(spec);
167
213
  const entry = getEntry(loadManifest(projectDir), resourceType, resourceId);
@@ -220,7 +266,7 @@ async function verifyUi(projectDir, positional, flags = {}) {
220
266
  const width = numberFlag(flags.width, 390, 240, 3840);
221
267
  const height = numberFlag(flags.height, 844, 320, 2160);
222
268
  const waitMs = numberFlag(flags['wait-ms'], 500, 0, 10000);
223
- const screenshotMode = String(flags.screenshot || 'on-failure').toLowerCase();
269
+ const screenshotMode = String(flags.screenshot || 'never').toLowerCase();
224
270
  if (!['on-failure', 'always', 'never'].includes(screenshotMode)) {
225
271
  log.err('--screenshot 只支持 on-failure、always、never。');
226
272
  return 1;
@@ -256,41 +302,38 @@ async function verifyUi(projectDir, positional, flags = {}) {
256
302
 
257
303
  const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
258
304
  if (waitMs) await page.waitForTimeout(waitMs);
259
- const state = await page.evaluate(() => {
260
- const body = document.body;
261
- const root = document.documentElement;
262
- return {
263
- title: document.title,
264
- bodyTextLength: body ? (body.innerText || '').trim().length : 0,
265
- bodyHeight: body ? body.getBoundingClientRect().height : 0,
266
- hasVisibleMedia: body ? Array.from(body.querySelectorAll('img,svg,canvas,video,iframe,input,button')).some((element) => {
267
- const rect = element.getBoundingClientRect();
268
- return rect.width > 1 && rect.height > 1;
269
- }) : false,
270
- horizontalOverflow: root.scrollWidth > window.innerWidth + 1,
271
- scrollWidth: root.scrollWidth,
272
- viewportWidth: window.innerWidth,
273
- };
274
- });
305
+ const frames = await selectFrames(page, String(flags.frame || 'auto'));
306
+ const states = [];
307
+ for (const item of frames) states.push({ ...item, state: await inspectFrame(item.frame) });
275
308
 
276
309
  const issues = [];
277
310
  if (response && response.status() >= 400) issues.push(`页面返回 HTTP ${response.status()}`);
278
- if (!state.bodyHeight || (!state.bodyTextLength && !state.hasVisibleMedia)) issues.push('页面疑似空白');
279
- if (state.horizontalOverflow) issues.push(`页面横向溢出:scrollWidth=${state.scrollWidth}, viewport=${state.viewportWidth}`);
311
+ if (!states.some(({ state }) => state.bodyHeight && (state.bodyTextLength || state.hasVisibleMedia))) issues.push('页面疑似空白');
312
+ for (const { label, state } of states) {
313
+ if (state.horizontalOverflow) issues.push(`${label} 横向溢出:scrollWidth=${state.scrollWidth}, viewport=${state.viewportWidth}`);
314
+ }
280
315
  if (consoleErrors.length) issues.push(`console error ${consoleErrors.length} 条`);
281
316
  if (pageErrors.length) issues.push(`page error ${pageErrors.length} 条`);
282
317
  if (flags.selector) {
283
- const visible = await page.locator(String(flags.selector)).first().isVisible().catch(() => false);
284
- if (!visible) issues.push(`关键元素不可见:${flags.selector}`);
318
+ let match = null;
319
+ for (const item of frames) {
320
+ if (await item.frame.locator(String(flags.selector)).first().isVisible().catch(() => false)) {
321
+ match = item.label;
322
+ break;
323
+ }
324
+ }
325
+ if (!match) issues.push(`关键元素在已检查 frame 中不可见:${flags.selector}`);
326
+ else log.info(`Selector ${flags.selector}: visible in ${match}`);
285
327
  }
286
328
 
287
329
  const shouldScreenshot = screenshotMode === 'always' || (screenshotMode === 'on-failure' && issues.length > 0);
288
330
  let screenshotPath = null;
289
331
  if (shouldScreenshot) {
290
- const dir = path.join(projectDir, '.draftgo', 'artifacts');
332
+ const dir = path.join(projectDir, '.draftgo', 'artifacts', 'ui');
291
333
  fs.mkdirSync(dir, { recursive: true });
292
334
  screenshotPath = path.join(dir, `ui-check-${process.pid}-${Date.now()}.png`);
293
335
  await page.screenshot({ path: screenshotPath, fullPage: true });
336
+ runtimeFiles.register(projectDir, screenshotPath, 'ui-artifact', 'verify-ui');
294
337
  }
295
338
 
296
339
  if (issues.length) {
@@ -301,7 +344,7 @@ async function verifyUi(projectDir, positional, flags = {}) {
301
344
  return 1;
302
345
  }
303
346
 
304
- log.ok(`UI smoke check 通过:${width}x${height},${decision.reason}`);
347
+ log.ok(`UI smoke check 通过:${width}x${height},${frames.length} frame(s),${decision.reason}`);
305
348
  if (screenshotPath) log.info(`截图:${screenshotPath}`);
306
349
  return 0;
307
350
  } catch (err) {
@@ -322,3 +365,5 @@ module.exports.executableCandidates = executableCandidates;
322
365
  module.exports.playwrightCacheCandidates = playwrightCacheCandidates;
323
366
  module.exports.parseResourceSpec = parseResourceSpec;
324
367
  module.exports.verifyRemoteResource = verifyRemoteResource;
368
+ module.exports.selectFrames = selectFrames;
369
+ module.exports.inspectFrame = inspectFrame;
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const log = require('../logger');
4
+ const verify = require('./verify');
5
+
6
+ async function verifyUiCompat(projectDir, positional = [], flags = {}) {
7
+ const url = String(flags.url || positional[0] || '').trim();
8
+ log.warn('`draftgo verify-ui` is deprecated; use `draftgo verify --url <url> --ui always`.');
9
+ return verify(projectDir, [], {
10
+ ...flags,
11
+ url,
12
+ ui: 'always',
13
+ });
14
+ }
15
+
16
+ module.exports = verifyUiCompat;