dw-kit 1.4.0 → 1.6.0-rc.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/.claude/agents/executor.md +80 -80
- package/.claude/hooks/pre-commit-gate.sh +59 -0
- package/.claude/hooks/stop-check.sh +111 -31
- package/.claude/rules/commit-standards.md +48 -37
- package/.claude/rules/dw.md +47 -11
- package/.claude/skills/dw-commit/SKILL.md +7 -4
- package/.claude/skills/dw-decision/SKILL.md +5 -4
- package/.claude/skills/dw-execute/SKILL.md +18 -5
- package/.claude/skills/dw-handoff/SKILL.md +8 -3
- package/.claude/skills/dw-plan/SKILL.md +15 -2
- package/.claude/skills/dw-research/SKILL.md +7 -5
- package/.claude/skills/dw-retroactive/SKILL.md +75 -63
- package/.claude/skills/dw-task-init/SKILL.md +40 -35
- package/.dw/adapters/generic/AGENT.md +171 -169
- package/.dw/core/WORKFLOW.md +450 -450
- package/.dw/core/schemas/agent-claim.schema.json +127 -0
- package/.dw/core/schemas/agent-report.schema.json +72 -0
- package/.dw/core/schemas/task-frontmatter.schema.json +78 -0
- package/.dw/core/templates/v3/task.md +188 -0
- package/CLAUDE.md +2 -2
- package/MIGRATION-v1.5.md +330 -0
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/cli.mjs +161 -0
- package/src/commands/agent-claim.mjs +235 -0
- package/src/commands/agent-inspect.mjs +123 -0
- package/src/commands/doctor.mjs +64 -0
- package/src/commands/lint-task.mjs +112 -0
- package/src/commands/task-migrate.mjs +366 -0
- package/src/commands/task-new.mjs +90 -0
- package/src/commands/task-render.mjs +235 -0
- package/src/commands/task-rotate.mjs +168 -0
- package/src/commands/task-show.mjs +137 -0
- package/src/commands/task-view.mjs +386 -0
- package/src/commands/task-watch.mjs +223 -0
- package/src/lib/active-index.mjs +19 -1
- package/src/lib/agent-claim.mjs +173 -0
- package/src/lib/agent-conflict.mjs +137 -0
- package/src/lib/agent-events.mjs +43 -0
- package/src/lib/agent-report.mjs +96 -0
- package/src/lib/frontmatter.mjs +72 -0
- package/src/lib/lint-rules.mjs +149 -0
- package/src/lib/timeline-parser.mjs +80 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
|
|
5
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
6
|
+
|
|
7
|
+
const TASKS_DIR = '.dw/tasks';
|
|
8
|
+
|
|
9
|
+
function todayIso() {
|
|
10
|
+
return new Date().toISOString().slice(0, 10);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readSection(content, headingPattern) {
|
|
14
|
+
const startRe = new RegExp(`^##\\s+${headingPattern}\\s*$`, 'm');
|
|
15
|
+
const startMatch = content.match(startRe);
|
|
16
|
+
if (!startMatch) return '';
|
|
17
|
+
const startIdx = startMatch.index + startMatch[0].length;
|
|
18
|
+
const rest = content.slice(startIdx);
|
|
19
|
+
const endMatch = rest.match(/^##\s+/m);
|
|
20
|
+
const sectionText = endMatch ? rest.slice(0, endMatch.index) : rest;
|
|
21
|
+
return sectionText.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripFrontmatter(content) {
|
|
25
|
+
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripStatusMarkers(text) {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/\s*✅\s*COMPLETED\s*\d{4}-\d{2}-\d{2}/g, '')
|
|
31
|
+
.replace(/\s*✅\s*DONE\s*\d{4}-\d{2}-\d{2}/g, '')
|
|
32
|
+
.replace(/\s*🟡\s*[A-Z\s]+\s*\d{4}-\d{2}-\d{2}/g, '')
|
|
33
|
+
.replace(/\s*←\s*START HERE/g, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Priority order matters: more specific / higher-severity statuses win.
|
|
37
|
+
const STATUS_KEYWORDS = [
|
|
38
|
+
['Blocked', /\b(blocked|blocking)\b/i],
|
|
39
|
+
['Done', /\b(done|completed?|shipped?|released|ship[- ]?ready|verified|merged)\b/i],
|
|
40
|
+
['Paused', /\b(paused|on\s+hold)\b/i],
|
|
41
|
+
['In Progress', /\b(in\s+progress|wip|active|ongoing|executing)\b/i],
|
|
42
|
+
['Approved', /\b(approved|spec[- ]?approved|plan[- ]?approved)\b/i],
|
|
43
|
+
['Draft', /\b(draft|proposed|new|todo)\b/i],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function normalizeStatus(raw) {
|
|
47
|
+
if (!raw) return 'Draft';
|
|
48
|
+
const s = String(raw).trim();
|
|
49
|
+
for (const [canonical, re] of STATUS_KEYWORDS) {
|
|
50
|
+
if (re.test(s)) return canonical;
|
|
51
|
+
}
|
|
52
|
+
return 'Draft';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeDate(raw) {
|
|
56
|
+
if (!raw) return todayIso();
|
|
57
|
+
const s = String(raw).trim();
|
|
58
|
+
const m = s.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
59
|
+
if (m) return s;
|
|
60
|
+
return todayIso();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeFrontmatter(specFm, trackingFm) {
|
|
64
|
+
const rawStatus = trackingFm.status || specFm.status || '';
|
|
65
|
+
return {
|
|
66
|
+
task_id: trackingFm.task_id || specFm.task_id,
|
|
67
|
+
created: normalizeDate(specFm.created || trackingFm.started),
|
|
68
|
+
last_updated: normalizeDate(trackingFm.last_updated),
|
|
69
|
+
status: normalizeStatus(rawStatus),
|
|
70
|
+
phase: trackingFm.current_phase || specFm.status || 'Migrated from v2',
|
|
71
|
+
owner: specFm.owner || trackingFm.owner || 'unknown',
|
|
72
|
+
depth: specFm.depth || 'standard',
|
|
73
|
+
related_adr: String(specFm.related_adr || 'none').match(/^(ADR-\d{4}|none)$/) ? specFm.related_adr : 'none',
|
|
74
|
+
target_ship: specFm.target_ship || 'TBD',
|
|
75
|
+
schema_version: 'v3.0',
|
|
76
|
+
blockers: trackingFm.blockers || 'none',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripPrivateFields(obj) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
83
|
+
if (k.startsWith('_') || v === undefined) continue;
|
|
84
|
+
out[k] = v;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildV3Body(taskTitle, specContent, trackingContent, mergedFm) {
|
|
90
|
+
const specBody = stripFrontmatter(specContent);
|
|
91
|
+
const trackingBody = stripFrontmatter(trackingContent);
|
|
92
|
+
|
|
93
|
+
const intent = readSection(specBody, 'Intent') || '_TODO: migrate from v2 spec_';
|
|
94
|
+
const whyNow = readSection(specBody, 'Why Now');
|
|
95
|
+
const scopeIn = readSection(specBody, '(?:In )?Scope|Scope.*?In Scope');
|
|
96
|
+
const scopeOut = readSection(specBody, '(?:Out of Scope|Won\'t Contain)');
|
|
97
|
+
const acceptance = readSection(specBody, '(?:Acceptance|Success Criteria|Task Complete When)');
|
|
98
|
+
const risks = readSection(specBody, 'Risks(?:\\s+&\\s+Mitigations)?');
|
|
99
|
+
const deps = readSection(specBody, 'Dependencies');
|
|
100
|
+
|
|
101
|
+
const snapshot = readSection(trackingBody, 'Status Snapshot');
|
|
102
|
+
const trackerTable = readSection(trackingBody, '(?:Subtask Progress|Tracker)');
|
|
103
|
+
const changelog = readSection(trackingBody, 'Changelog');
|
|
104
|
+
const handoff = readSection(trackingBody, 'Handoff(?:\\s+Notes)?');
|
|
105
|
+
const friction = readSection(trackingBody, 'Friction(?:\\s+Journal)?');
|
|
106
|
+
const debate = readSection(trackingBody, 'Agent Debate Log.*');
|
|
107
|
+
|
|
108
|
+
const status = String(mergedFm.status);
|
|
109
|
+
const phase = String(mergedFm.phase);
|
|
110
|
+
const blockers = String(mergedFm.blockers);
|
|
111
|
+
const lastUpdated = String(mergedFm.last_updated);
|
|
112
|
+
|
|
113
|
+
const snapshotLines = [
|
|
114
|
+
`**Phase:** ${phase}`,
|
|
115
|
+
`**Status:** ${status}`,
|
|
116
|
+
`**Owner:** ${String(mergedFm.owner)}`,
|
|
117
|
+
`**Blockers:** ${blockers}`,
|
|
118
|
+
`**Last updated:** ${lastUpdated}`,
|
|
119
|
+
];
|
|
120
|
+
if (snapshot) snapshotLines.push('', '<!-- Migrated from v2 tracking.md Status Snapshot -->', snapshot);
|
|
121
|
+
|
|
122
|
+
const sec2 = [
|
|
123
|
+
'### Intent',
|
|
124
|
+
'',
|
|
125
|
+
intent,
|
|
126
|
+
];
|
|
127
|
+
if (whyNow) sec2.push('', '### Why Now', '', whyNow);
|
|
128
|
+
sec2.push('', '### Subtasks (in scope)', '', stripStatusMarkers(scopeIn || '_TODO: list subtasks_'));
|
|
129
|
+
if (scopeOut) sec2.push('', '### Out of Scope', '', scopeOut);
|
|
130
|
+
if (acceptance) sec2.push('', '### Success Criteria', '', acceptance);
|
|
131
|
+
if (deps) sec2.push('', '### Dependencies', '', deps);
|
|
132
|
+
if (risks) sec2.push('', '### Risk Register', '', risks);
|
|
133
|
+
|
|
134
|
+
const STATUS_LEGEND = 'Status legend: ⬜ Pending · 🟡 In Progress · ✅ Done · 🔴 Blocked · ⏸ Paused';
|
|
135
|
+
const baseSec3 = trackerTable
|
|
136
|
+
|| `| # | Subtask | Status | Date | Notes |\n|---|---------|--------|------|-------|\n| ST-1 | _TODO: populate from v2_ | ⬜ Pending | — | |`;
|
|
137
|
+
const sec3 = baseSec3.includes('Status legend') ? baseSec3 : `${baseSec3}\n\n${STATUS_LEGEND}`;
|
|
138
|
+
|
|
139
|
+
const sec4 = changelog || `### ${todayIso()} — Migrated from v2\n\n**Actions:** Migrated spec.md + tracking.md → task.md via \`dw task migrate\`.\n`;
|
|
140
|
+
|
|
141
|
+
const sec5 = [];
|
|
142
|
+
if (handoff) sec5.push(handoff);
|
|
143
|
+
if (friction) sec5.push('', '### Friction Journal', '', friction);
|
|
144
|
+
const sec5Text = sec5.join('\n') || '_TODO: handoff notes_';
|
|
145
|
+
|
|
146
|
+
const lines = [
|
|
147
|
+
`# Timeline: ${taskTitle}`,
|
|
148
|
+
'',
|
|
149
|
+
'<!-- Migrated from v2 spec.md + tracking.md. Review for status drift markers in Section 2. -->',
|
|
150
|
+
'<!--  -->',
|
|
151
|
+
'<!-- SVG sidecar will be injected by `dw task render` or pre-commit hook (WS-4/WS-5). -->',
|
|
152
|
+
'',
|
|
153
|
+
'## 1. Snapshot',
|
|
154
|
+
'',
|
|
155
|
+
snapshotLines.join('\n'),
|
|
156
|
+
'',
|
|
157
|
+
'## 2. Intent & Scope',
|
|
158
|
+
'',
|
|
159
|
+
sec2.join('\n'),
|
|
160
|
+
'',
|
|
161
|
+
'## 3. Subtask Tracker',
|
|
162
|
+
'',
|
|
163
|
+
sec3,
|
|
164
|
+
'',
|
|
165
|
+
'## 4. Timeline / Changelog',
|
|
166
|
+
'',
|
|
167
|
+
sec4,
|
|
168
|
+
'',
|
|
169
|
+
'## 5. Handoff & Friction',
|
|
170
|
+
'',
|
|
171
|
+
sec5Text,
|
|
172
|
+
'',
|
|
173
|
+
'## 6. Annexes',
|
|
174
|
+
'',
|
|
175
|
+
'- (legacy spec.md.v2bak + tracking.md.v2bak preserved for rollback)',
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
if (debate) {
|
|
179
|
+
lines.push('', '## 7. Debate Log', '', debate);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return lines.join('\n') + '\n';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function lineDiff(oldText, newText) {
|
|
186
|
+
const oldLines = oldText.split('\n');
|
|
187
|
+
const newLines = newText.split('\n');
|
|
188
|
+
const max = Math.max(oldLines.length, newLines.length);
|
|
189
|
+
let diff = '';
|
|
190
|
+
for (let i = 0; i < max; i++) {
|
|
191
|
+
const o = oldLines[i] ?? '';
|
|
192
|
+
const n = newLines[i] ?? '';
|
|
193
|
+
if (o === n) continue;
|
|
194
|
+
if (o && !newLines.includes(o)) diff += chalk.red(`- ${o}\n`);
|
|
195
|
+
if (n && !oldLines.includes(n)) diff += chalk.green(`+ ${n}\n`);
|
|
196
|
+
}
|
|
197
|
+
return diff;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function findV2Tasks(rootDir) {
|
|
201
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
202
|
+
if (!existsSync(tasksRoot)) return [];
|
|
203
|
+
return readdirSync(tasksRoot)
|
|
204
|
+
.filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
|
|
205
|
+
.map((e) => ({ name: e, path: join(tasksRoot, e) }))
|
|
206
|
+
.filter((e) => {
|
|
207
|
+
try {
|
|
208
|
+
if (!statSync(e.path).isDirectory()) return false;
|
|
209
|
+
if (existsSync(join(e.path, 'task.md'))) return false;
|
|
210
|
+
return existsSync(join(e.path, 'spec.md')) || existsSync(join(e.path, 'tracking.md'));
|
|
211
|
+
} catch { return false; }
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function migrateOne(taskDir, opts) {
|
|
216
|
+
const specFile = join(taskDir, 'spec.md');
|
|
217
|
+
const trackingFile = join(taskDir, 'tracking.md');
|
|
218
|
+
const targetFile = join(taskDir, 'task.md');
|
|
219
|
+
const specBak = join(taskDir, 'spec.md.v2bak');
|
|
220
|
+
const trackingBak = join(taskDir, 'tracking.md.v2bak');
|
|
221
|
+
|
|
222
|
+
if (existsSync(targetFile) && !opts.force) {
|
|
223
|
+
console.log(chalk.yellow(` ⚠ ${taskDir} — task.md already exists (use --force to overwrite)`));
|
|
224
|
+
return { ok: false, skipped: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const specContent = existsSync(specFile) ? readFileSync(specFile, 'utf8') : '';
|
|
228
|
+
const trackingContent = existsSync(trackingFile) ? readFileSync(trackingFile, 'utf8') : '';
|
|
229
|
+
|
|
230
|
+
if (!specContent && !trackingContent) {
|
|
231
|
+
console.log(chalk.yellow(` ⚠ ${taskDir} — no v2 files (spec.md/tracking.md) found, skipping`));
|
|
232
|
+
return { ok: false, skipped: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const specFm = specContent ? parseFrontmatter(specContent) : {};
|
|
236
|
+
const trackingFm = trackingContent ? parseFrontmatter(trackingContent) : {};
|
|
237
|
+
const merged = mergeFrontmatter(specFm, trackingFm);
|
|
238
|
+
|
|
239
|
+
const titleMatch = (specContent || trackingContent).match(/^#\s+(?:Spec:|Tracking:)?\s*(.+?)\s*$/m);
|
|
240
|
+
const title = titleMatch ? titleMatch[1] : (merged.task_id || taskDir.split(/[\\/]/).pop());
|
|
241
|
+
|
|
242
|
+
const body = buildV3Body(title, specContent, trackingContent, merged);
|
|
243
|
+
const fmForWrite = stripPrivateFields(merged);
|
|
244
|
+
const v3Content = stringifyFrontmatter(fmForWrite) + '\n' + body;
|
|
245
|
+
|
|
246
|
+
if (opts.dryRun) {
|
|
247
|
+
console.log(chalk.cyan(`\n --- Dry run: ${taskDir} ---`));
|
|
248
|
+
console.log(chalk.dim(' Would write:'));
|
|
249
|
+
console.log(v3Content.split('\n').slice(0, 30).map((l) => ` ${l}`).join('\n'));
|
|
250
|
+
if (v3Content.split('\n').length > 30) {
|
|
251
|
+
console.log(chalk.dim(` ... (${v3Content.split('\n').length - 30} more lines)`));
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, dryRun: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (opts.diff && existsSync(targetFile)) {
|
|
257
|
+
const existing = readFileSync(targetFile, 'utf8');
|
|
258
|
+
if (existing === v3Content) {
|
|
259
|
+
console.log(chalk.dim(` · ${taskDir} — no changes`));
|
|
260
|
+
return { ok: true, noop: true };
|
|
261
|
+
}
|
|
262
|
+
console.log(chalk.cyan(`\n --- Diff: ${taskDir} ---`));
|
|
263
|
+
console.log(lineDiff(existing, v3Content));
|
|
264
|
+
return { ok: true, diff: true };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (existsSync(specFile) && !existsSync(specBak)) copyFileSync(specFile, specBak);
|
|
268
|
+
if (existsSync(trackingFile) && !existsSync(trackingBak)) copyFileSync(trackingFile, trackingBak);
|
|
269
|
+
|
|
270
|
+
writeFileSync(targetFile, v3Content, 'utf8');
|
|
271
|
+
|
|
272
|
+
if (opts.removeV2) {
|
|
273
|
+
if (existsSync(specFile)) unlinkSync(specFile);
|
|
274
|
+
if (existsSync(trackingFile)) unlinkSync(trackingFile);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(chalk.green(` ✓ ${taskDir} — migrated`));
|
|
278
|
+
return { ok: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function rollbackOne(taskDir) {
|
|
282
|
+
const specBak = join(taskDir, 'spec.md.v2bak');
|
|
283
|
+
const trackingBak = join(taskDir, 'tracking.md.v2bak');
|
|
284
|
+
const specFile = join(taskDir, 'spec.md');
|
|
285
|
+
const trackingFile = join(taskDir, 'tracking.md');
|
|
286
|
+
const targetFile = join(taskDir, 'task.md');
|
|
287
|
+
|
|
288
|
+
if (!existsSync(specBak) && !existsSync(trackingBak)) {
|
|
289
|
+
console.log(chalk.yellow(` ⚠ ${taskDir} — no backups to restore`));
|
|
290
|
+
return { ok: false };
|
|
291
|
+
}
|
|
292
|
+
if (existsSync(specBak)) renameSync(specBak, specFile);
|
|
293
|
+
if (existsSync(trackingBak)) renameSync(trackingBak, trackingFile);
|
|
294
|
+
if (existsSync(targetFile)) unlinkSync(targetFile);
|
|
295
|
+
console.log(chalk.green(` ✓ ${taskDir} — rolled back`));
|
|
296
|
+
return { ok: true };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function taskMigrateCommand(taskName, opts = {}) {
|
|
300
|
+
const rootDir = process.cwd();
|
|
301
|
+
|
|
302
|
+
if (opts.rollback) {
|
|
303
|
+
const taskDir = taskName
|
|
304
|
+
? join(rootDir, TASKS_DIR, taskName)
|
|
305
|
+
: null;
|
|
306
|
+
if (taskDir) {
|
|
307
|
+
const r = await rollbackOne(taskDir);
|
|
308
|
+
logEvent({ event: 'task', action: 'migrate.rollback', name: taskName, success: r.ok }, rootDir);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
console.error(chalk.red('✗ --rollback requires a task name'));
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let targets;
|
|
316
|
+
if (taskName) {
|
|
317
|
+
targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
|
|
318
|
+
} else if (opts.all) {
|
|
319
|
+
targets = findV2Tasks(rootDir);
|
|
320
|
+
if (targets.length === 0) {
|
|
321
|
+
console.log(chalk.dim(' No v2 tasks found to migrate.'));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
console.error(chalk.red('✗ Provide a task name or use --all to scan for v2 tasks'));
|
|
326
|
+
console.error(chalk.dim(' Examples:'));
|
|
327
|
+
console.error(chalk.dim(' dw task migrate dw-kit-v2-lean-optimization --dry-run'));
|
|
328
|
+
console.error(chalk.dim(' dw task migrate --all --dry-run'));
|
|
329
|
+
console.error(chalk.dim(' dw task migrate dw-kit-v2-lean-optimization --rollback'));
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
console.log();
|
|
334
|
+
const action = opts.dryRun ? 'Dry-run migrate' : opts.diff ? 'Diff migrate' : 'Migrate';
|
|
335
|
+
console.log(chalk.bold(` ${action} (${targets.length} task${targets.length === 1 ? '' : 's'}):`));
|
|
336
|
+
|
|
337
|
+
const results = [];
|
|
338
|
+
for (const t of targets) {
|
|
339
|
+
const r = await migrateOne(t.path, opts);
|
|
340
|
+
results.push({ name: t.name, ...r });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const okCount = results.filter((r) => r.ok && !r.dryRun && !r.diff && !r.noop && !r.skipped).length;
|
|
344
|
+
const skipped = results.filter((r) => r.skipped).length;
|
|
345
|
+
|
|
346
|
+
logEvent({
|
|
347
|
+
event: 'task',
|
|
348
|
+
action: 'migrate.run',
|
|
349
|
+
count: results.length,
|
|
350
|
+
success: okCount,
|
|
351
|
+
skipped,
|
|
352
|
+
dry_run: !!opts.dryRun,
|
|
353
|
+
}, rootDir);
|
|
354
|
+
|
|
355
|
+
console.log();
|
|
356
|
+
if (opts.dryRun) {
|
|
357
|
+
console.log(chalk.cyan(` Dry-run complete. Re-run without --dry-run to apply.`));
|
|
358
|
+
} else if (opts.diff) {
|
|
359
|
+
console.log(chalk.cyan(` Diff complete.`));
|
|
360
|
+
} else {
|
|
361
|
+
console.log(chalk.green(` ${okCount}/${results.length} migrated, ${skipped} skipped.`));
|
|
362
|
+
console.log(chalk.dim(` Backups saved as spec.md.v2bak / tracking.md.v2bak. Run \`dw task migrate <name> --rollback\` to undo.`));
|
|
363
|
+
console.log(chalk.dim(` Run \`dw active\` to refresh ACTIVE.md.`));
|
|
364
|
+
}
|
|
365
|
+
console.log();
|
|
366
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const TASKS_DIR = '.dw/tasks';
|
|
10
|
+
const TEMPLATE_REL = join('.dw', 'core', 'templates', 'v3', 'task.md');
|
|
11
|
+
const BUNDLED_TEMPLATE = join(__dirname, '..', '..', '.dw', 'core', 'templates', 'v3', 'task.md');
|
|
12
|
+
|
|
13
|
+
function todayIso() {
|
|
14
|
+
return new Date().toISOString().slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function slugify(name) {
|
|
18
|
+
return name
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '')
|
|
23
|
+
.replace(/-{2,}/g, '-')
|
|
24
|
+
.slice(0, 64);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadTemplate(rootDir) {
|
|
28
|
+
const local = join(rootDir, TEMPLATE_REL);
|
|
29
|
+
if (existsSync(local)) return readFileSync(local, 'utf8');
|
|
30
|
+
if (existsSync(BUNDLED_TEMPLATE)) return readFileSync(BUNDLED_TEMPLATE, 'utf8');
|
|
31
|
+
throw new Error(`v3 template not found at ${local} or ${BUNDLED_TEMPLATE}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function taskNewCommand(taskName, opts = {}) {
|
|
35
|
+
const rootDir = process.cwd();
|
|
36
|
+
if (!taskName) {
|
|
37
|
+
console.error(chalk.red('✗ Task name required.'));
|
|
38
|
+
console.error(chalk.dim(' Usage: dw task new <task-name> [--depth quick|standard|thorough] [--title "..."]'));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const slug = slugify(taskName);
|
|
43
|
+
if (!slug) {
|
|
44
|
+
console.error(chalk.red('✗ Invalid task name (empty after slugify).'));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const taskDir = join(rootDir, TASKS_DIR, slug);
|
|
49
|
+
if (existsSync(taskDir)) {
|
|
50
|
+
console.error(chalk.red(`✗ Task folder already exists: ${taskDir}`));
|
|
51
|
+
console.error(chalk.dim(' Pick a different name or remove the existing folder first.'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const template = loadTemplate(rootDir);
|
|
56
|
+
const today = todayIso();
|
|
57
|
+
const title = opts.title || taskName;
|
|
58
|
+
const depth = opts.depth || 'standard';
|
|
59
|
+
const owner = opts.owner || process.env.DW_OWNER || process.env.USER || process.env.USERNAME || 'unknown';
|
|
60
|
+
const relatedAdr = opts.adr || 'none';
|
|
61
|
+
const targetShip = opts.target || 'TBD';
|
|
62
|
+
|
|
63
|
+
let filled = template
|
|
64
|
+
.replace('task_id: {task-name}', `task_id: ${slug}`)
|
|
65
|
+
.replace('created: {YYYY-MM-DD}', `created: "${today}"`)
|
|
66
|
+
.replace('last_updated: {YYYY-MM-DD}', `last_updated: "${today}"`)
|
|
67
|
+
.replace('phase: {free-form phase description}', `phase: Draft`)
|
|
68
|
+
.replace('owner: {name}', `owner: ${owner}`)
|
|
69
|
+
.replace('depth: quick | standard | thorough', `depth: ${depth}`)
|
|
70
|
+
.replace('related_adr: {ADR-NNNN | none}', `related_adr: ${relatedAdr}`)
|
|
71
|
+
.replace('target_ship: {milestone or TBD}', `target_ship: ${targetShip}`);
|
|
72
|
+
|
|
73
|
+
filled = filled.replace('# Timeline: {Task Title}', `# Timeline: ${title}`);
|
|
74
|
+
|
|
75
|
+
mkdirSync(taskDir, { recursive: true });
|
|
76
|
+
const target = join(taskDir, 'task.md');
|
|
77
|
+
writeFileSync(target, filled, 'utf8');
|
|
78
|
+
|
|
79
|
+
logEvent({ event: 'task', action: 'new', name: slug, depth }, rootDir);
|
|
80
|
+
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(chalk.green('✓') + ` Created v3 task: ${chalk.cyan(slug)}`);
|
|
83
|
+
console.log(` ${chalk.dim(target)}`);
|
|
84
|
+
console.log();
|
|
85
|
+
console.log(chalk.bold(' Next steps:'));
|
|
86
|
+
console.log(` 1. Fill in Section 2 (Intent & Scope) — subtasks, success criteria`);
|
|
87
|
+
console.log(` 2. ${chalk.cyan('dw task show ' + slug)} — view snapshot`);
|
|
88
|
+
console.log(` 3. ${chalk.cyan('dw active')} — refresh ACTIVE.md index`);
|
|
89
|
+
console.log();
|
|
90
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|
7
|
+
import { parseTimeline, parseSubtaskTracker } from '../lib/timeline-parser.mjs';
|
|
8
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const TASKS_DIR = '.dw/tasks';
|
|
14
|
+
|
|
15
|
+
function findV3Tasks(rootDir) {
|
|
16
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
17
|
+
if (!existsSync(tasksRoot)) return [];
|
|
18
|
+
return readdirSync(tasksRoot)
|
|
19
|
+
.filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
|
|
20
|
+
.map((e) => ({ name: e, path: join(tasksRoot, e) }))
|
|
21
|
+
.filter((e) => {
|
|
22
|
+
try { return statSync(e.path).isDirectory() && existsSync(join(e.path, 'task.md')); }
|
|
23
|
+
catch { return false; }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractManifest(taskDir) {
|
|
28
|
+
const timelineFile = join(taskDir, 'task.md');
|
|
29
|
+
if (!existsSync(timelineFile)) return null;
|
|
30
|
+
const content = readFileSync(timelineFile, 'utf8');
|
|
31
|
+
const fm = parseFrontmatter(content);
|
|
32
|
+
const parsed = parseTimeline(content);
|
|
33
|
+
const subtasks = parseSubtaskTracker(parsed.sections[3]?.text);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
task_id: fm.task_id || taskDir.split(/[\\/]/).pop(),
|
|
37
|
+
title: parsed.title || fm.task_id,
|
|
38
|
+
status: fm.status || 'Draft',
|
|
39
|
+
phase: fm.phase || '',
|
|
40
|
+
owner: fm.owner || '',
|
|
41
|
+
blockers: fm.blockers || 'none',
|
|
42
|
+
last_updated: fm.last_updated || '',
|
|
43
|
+
related_adr: fm.related_adr || 'none',
|
|
44
|
+
depth: fm.depth || 'standard',
|
|
45
|
+
subtasks,
|
|
46
|
+
schema_version: 'task-manifest@v1',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveRenderer() {
|
|
51
|
+
try {
|
|
52
|
+
const pkgPath = require.resolve('dw-kit-render/package.json', { paths: [process.cwd(), __dirname] });
|
|
53
|
+
const pkgDir = dirname(pkgPath);
|
|
54
|
+
return pkgDir;
|
|
55
|
+
} catch {
|
|
56
|
+
const localCandidate = join(__dirname, '..', '..', 'packages', 'dw-kit-render', 'package.json');
|
|
57
|
+
if (existsSync(localCandidate)) return dirname(localCandidate);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function renderViaSubpackage(manifest, outFile, formats) {
|
|
63
|
+
const pkgDir = resolveRenderer();
|
|
64
|
+
if (!pkgDir) {
|
|
65
|
+
return { ok: false, reason: 'sub-package-absent' };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const moduleUrl = pathToFileURL(join(pkgDir, 'src', 'index.mjs')).href;
|
|
69
|
+
const mod = await import(moduleUrl);
|
|
70
|
+
if (typeof mod.renderTimeline !== 'function') {
|
|
71
|
+
return { ok: false, reason: 'renderTimeline-not-exported' };
|
|
72
|
+
}
|
|
73
|
+
const result = await mod.renderTimeline({ task: manifest, outFile, formats });
|
|
74
|
+
return { ok: true, result };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { ok: false, reason: 'render-error', error: e.message };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildMermaidFallback(manifest) {
|
|
81
|
+
const lines = ['```mermaid', 'gantt', ` title ${manifest.title || manifest.task_id}`, ' dateFormat YYYY-MM-DD', ' axisFormat %m-%d'];
|
|
82
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
83
|
+
let section = 'section Subtasks';
|
|
84
|
+
lines.push(` ${section}`);
|
|
85
|
+
const items = manifest.subtasks || [];
|
|
86
|
+
for (const s of items.slice(0, 20)) {
|
|
87
|
+
const status = (s.status || '').trim();
|
|
88
|
+
const taskName = (s.name || s.id || 'Subtask').replace(/[:|]/g, ' ').slice(0, 60);
|
|
89
|
+
const date = s.date && /^\d{4}-\d{2}-\d{2}$/.test(s.date) ? s.date : today;
|
|
90
|
+
const ganttStatus = status.startsWith('✅') ? 'done' : status.startsWith('🟡') ? 'active' : status.startsWith('🔴') ? 'crit' : '';
|
|
91
|
+
const tag = ganttStatus ? `${ganttStatus}, ` : '';
|
|
92
|
+
lines.push(` ${taskName} :${tag}${date}, 1d`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('```');
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ensureSvgRefInSection1(content) {
|
|
99
|
+
if (content.includes('') && !content.includes('<!--  -->')) {
|
|
100
|
+
return content;
|
|
101
|
+
}
|
|
102
|
+
const commented = '<!--  -->';
|
|
103
|
+
if (content.includes(commented)) {
|
|
104
|
+
return content.replace(commented, '');
|
|
105
|
+
}
|
|
106
|
+
const sec1Match = content.match(/^## 1\. Snapshot$/m);
|
|
107
|
+
if (!sec1Match) return content;
|
|
108
|
+
const insertAt = sec1Match.index;
|
|
109
|
+
return content.slice(0, insertAt) + '\n\n' + content.slice(insertAt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureMermaidInSection4(content, mermaidBlock) {
|
|
113
|
+
const sec4Match = content.match(/^## 4\. Timeline \/ Changelog\s*$/m);
|
|
114
|
+
if (!sec4Match) return content;
|
|
115
|
+
const insertAt = sec4Match.index + sec4Match[0].length;
|
|
116
|
+
const afterHeader = content.slice(insertAt);
|
|
117
|
+
const existingMermaid = afterHeader.match(/```mermaid[\s\S]*?```/);
|
|
118
|
+
if (existingMermaid && existingMermaid.index < 500) {
|
|
119
|
+
const startAbs = insertAt + existingMermaid.index;
|
|
120
|
+
return content.slice(0, startAbs) + mermaidBlock + content.slice(startAbs + existingMermaid[0].length);
|
|
121
|
+
}
|
|
122
|
+
return content.slice(0, insertAt) + '\n\n' + mermaidBlock + '\n' + afterHeader;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function renderOne(taskDir, opts) {
|
|
126
|
+
const manifest = extractManifest(taskDir);
|
|
127
|
+
if (!manifest) {
|
|
128
|
+
return { ok: false, reason: 'no-timeline' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timelineFile = join(taskDir, 'task.md');
|
|
132
|
+
const svgOutFile = join(taskDir, 'timeline.svg');
|
|
133
|
+
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
console.log(chalk.cyan(` --- Dry run: ${taskDir} ---`));
|
|
136
|
+
console.log(chalk.dim(' Manifest:'));
|
|
137
|
+
console.log(JSON.stringify(manifest, null, 2).split('\n').map((l) => ' ' + l).join('\n'));
|
|
138
|
+
return { ok: true, dryRun: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let svgWritten = false;
|
|
142
|
+
let usedFallback = false;
|
|
143
|
+
let renderError = null;
|
|
144
|
+
|
|
145
|
+
if (!opts.mermaidOnly) {
|
|
146
|
+
const r = await renderViaSubpackage(manifest, svgOutFile, opts.formats || ['svg']);
|
|
147
|
+
if (r.ok) {
|
|
148
|
+
svgWritten = true;
|
|
149
|
+
} else {
|
|
150
|
+
renderError = r;
|
|
151
|
+
usedFallback = true;
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
usedFallback = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const currentMd = readFileSync(timelineFile, 'utf8');
|
|
158
|
+
let updatedMd = currentMd;
|
|
159
|
+
|
|
160
|
+
if (svgWritten) {
|
|
161
|
+
updatedMd = ensureSvgRefInSection1(updatedMd);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (usedFallback || opts.alwaysMermaid) {
|
|
165
|
+
const mermaidBlock = buildMermaidFallback(manifest);
|
|
166
|
+
updatedMd = ensureMermaidInSection4(updatedMd, mermaidBlock);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (updatedMd !== currentMd) {
|
|
170
|
+
writeFileSync(timelineFile, updatedMd, 'utf8');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
logEvent({
|
|
174
|
+
event: 'task',
|
|
175
|
+
action: 'render.run',
|
|
176
|
+
name: manifest.task_id,
|
|
177
|
+
svg: svgWritten,
|
|
178
|
+
fallback: usedFallback,
|
|
179
|
+
error: renderError?.reason,
|
|
180
|
+
}, process.cwd());
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
svgWritten,
|
|
185
|
+
usedFallback,
|
|
186
|
+
renderError,
|
|
187
|
+
svgPath: svgWritten ? svgOutFile : null,
|
|
188
|
+
timelineUpdated: updatedMd !== currentMd,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function taskRenderCommand(taskName, opts = {}) {
|
|
193
|
+
const rootDir = process.cwd();
|
|
194
|
+
let targets;
|
|
195
|
+
if (taskName) {
|
|
196
|
+
targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
|
|
197
|
+
} else {
|
|
198
|
+
targets = findV3Tasks(rootDir);
|
|
199
|
+
if (targets.length === 0) {
|
|
200
|
+
console.log(chalk.dim(' No v3 tasks to render.'));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(chalk.bold(` Render ${targets.length} task${targets.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry-run)' : ''}`));
|
|
207
|
+
|
|
208
|
+
let svgCount = 0;
|
|
209
|
+
let fallbackCount = 0;
|
|
210
|
+
|
|
211
|
+
for (const t of targets) {
|
|
212
|
+
const r = await renderOne(t.path, opts);
|
|
213
|
+
if (!r.ok) {
|
|
214
|
+
console.log(chalk.red(` ✗ ${t.name} — ${r.reason || 'failed'}`));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (r.dryRun) continue;
|
|
218
|
+
if (r.svgWritten) {
|
|
219
|
+
console.log(chalk.green(` ✓ ${t.name} — ${r.svgPath}${r.timelineUpdated ? ' (+ md updated)' : ''}`));
|
|
220
|
+
svgCount++;
|
|
221
|
+
} else if (r.usedFallback) {
|
|
222
|
+
const msg = r.renderError?.reason === 'sub-package-absent'
|
|
223
|
+
? 'dw-kit-render absent → Mermaid fallback in Section 4'
|
|
224
|
+
: `render failed (${r.renderError?.reason}) → Mermaid fallback`;
|
|
225
|
+
console.log(chalk.yellow(` ⚠ ${t.name} — ${msg}`));
|
|
226
|
+
fallbackCount++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log();
|
|
231
|
+
if (svgCount) console.log(chalk.green(` SVG: ${svgCount} task${svgCount === 1 ? '' : 's'} rendered`));
|
|
232
|
+
if (fallbackCount) console.log(chalk.yellow(` Mermaid fallback: ${fallbackCount} task${fallbackCount === 1 ? '' : 's'}`));
|
|
233
|
+
if (!svgCount && !fallbackCount && !opts.dryRun) console.log(chalk.dim(` No rendering performed`));
|
|
234
|
+
console.log();
|
|
235
|
+
}
|