dw-kit 1.4.0 → 1.7.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.
Files changed (65) hide show
  1. package/.claude/agents/executor.md +80 -80
  2. package/.claude/hooks/pre-commit-gate.sh +59 -0
  3. package/.claude/hooks/stop-check.sh +111 -31
  4. package/.claude/rules/commit-standards.md +48 -37
  5. package/.claude/rules/dw.md +47 -11
  6. package/.claude/skills/dw-commit/SKILL.md +7 -4
  7. package/.claude/skills/dw-decision/SKILL.md +5 -4
  8. package/.claude/skills/dw-execute/SKILL.md +18 -5
  9. package/.claude/skills/dw-handoff/SKILL.md +8 -3
  10. package/.claude/skills/dw-plan/SKILL.md +15 -2
  11. package/.claude/skills/dw-research/SKILL.md +7 -5
  12. package/.claude/skills/dw-retroactive/SKILL.md +75 -63
  13. package/.claude/skills/dw-task-init/SKILL.md +40 -35
  14. package/.dw/adapters/generic/AGENT.md +171 -169
  15. package/.dw/core/WORKFLOW.md +450 -450
  16. package/.dw/core/schemas/agent-claim.schema.json +127 -0
  17. package/.dw/core/schemas/agent-report.schema.json +72 -0
  18. package/.dw/core/schemas/goal-frontmatter.schema.json +84 -0
  19. package/.dw/core/schemas/task-frontmatter.schema.json +97 -0
  20. package/.dw/core/templates/v3/goal.md +146 -0
  21. package/.dw/core/templates/v3/task.md +188 -0
  22. package/CLAUDE.md +2 -2
  23. package/MIGRATION-v1.5.md +330 -0
  24. package/README.md +17 -0
  25. package/package.json +3 -2
  26. package/src/cli.mjs +312 -0
  27. package/src/commands/agent-claim.mjs +235 -0
  28. package/src/commands/agent-inspect.mjs +123 -0
  29. package/src/commands/doctor.mjs +64 -0
  30. package/src/commands/goal-bump.mjs +50 -0
  31. package/src/commands/goal-delete.mjs +120 -0
  32. package/src/commands/goal-link.mjs +126 -0
  33. package/src/commands/goal-lint.mjs +152 -0
  34. package/src/commands/goal-new.mjs +86 -0
  35. package/src/commands/goal-portfolio.mjs +84 -0
  36. package/src/commands/goal-render.mjs +49 -0
  37. package/src/commands/goal-set.mjs +62 -0
  38. package/src/commands/goal-show.mjs +94 -0
  39. package/src/commands/goal-stubs.mjs +21 -0
  40. package/src/commands/goal-suggest-krs.mjs +139 -0
  41. package/src/commands/goal-summary.mjs +67 -0
  42. package/src/commands/goal-view.mjs +196 -0
  43. package/src/commands/lint-task.mjs +112 -0
  44. package/src/commands/task-migrate.mjs +471 -0
  45. package/src/commands/task-new.mjs +90 -0
  46. package/src/commands/task-render.mjs +235 -0
  47. package/src/commands/task-rotate.mjs +168 -0
  48. package/src/commands/task-show.mjs +137 -0
  49. package/src/commands/task-summary.mjs +68 -0
  50. package/src/commands/task-view.mjs +386 -0
  51. package/src/commands/task-watch.mjs +868 -0
  52. package/src/lib/active-index.mjs +19 -1
  53. package/src/lib/agent-claim.mjs +173 -0
  54. package/src/lib/agent-conflict.mjs +137 -0
  55. package/src/lib/agent-events.mjs +43 -0
  56. package/src/lib/agent-report.mjs +96 -0
  57. package/src/lib/frontmatter.mjs +72 -0
  58. package/src/lib/goal-events.mjs +79 -0
  59. package/src/lib/goal-store.mjs +202 -0
  60. package/src/lib/goal-svg.mjs +293 -0
  61. package/src/lib/goal-watch.mjs +133 -0
  62. package/src/lib/lint-rules.mjs +149 -0
  63. package/src/lib/sse-broker.mjs +91 -0
  64. package/src/lib/timeline-parser.mjs +80 -0
  65. package/src/lib/watch-auth.mjs +64 -0
@@ -0,0 +1,112 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import chalk from 'chalk';
4
+ import yaml from 'js-yaml';
5
+ import { lintTimeline, findAllTaskDirs } from '../lib/lint-rules.mjs';
6
+ import { logEvent } from '../lib/telemetry.mjs';
7
+
8
+ const TASKS_DIR = '.dw/tasks';
9
+
10
+ function getLintLevel(rootDir) {
11
+ try {
12
+ const cfgPath = join(rootDir, '.dw', 'config', 'dw.config.yml');
13
+ if (!existsSync(cfgPath)) return 'strict';
14
+ const cfg = yaml.load(readFileSync(cfgPath, 'utf8'), { schema: yaml.CORE_SCHEMA });
15
+ return cfg?.task?.lint ?? cfg?.task_lint ?? 'strict';
16
+ } catch {
17
+ return 'strict';
18
+ }
19
+ }
20
+
21
+ function severityColor(sev) {
22
+ if (sev === 'error') return chalk.red;
23
+ if (sev === 'warning') return chalk.yellow;
24
+ return chalk.dim;
25
+ }
26
+
27
+ function printViolations(taskDir, result) {
28
+ if (result.violations.length === 0) {
29
+ console.log(chalk.green(` ✓ ${taskDir} — clean`));
30
+ return;
31
+ }
32
+ console.log();
33
+ console.log(chalk.bold(` ${taskDir}`));
34
+ for (const v of result.violations) {
35
+ const sev = severityColor(v.severity)(`[${v.severity}]`);
36
+ const rule = chalk.dim(`(${v.rule})`);
37
+ const lineRef = v.line ? chalk.dim(`:${v.line}`) : '';
38
+ console.log(` ${sev} ${v.message} ${rule}${lineRef}`);
39
+ }
40
+ }
41
+
42
+ export async function lintTaskCommand(taskName, opts = {}) {
43
+ const rootDir = process.cwd();
44
+ const level = opts.level || getLintLevel(rootDir);
45
+
46
+ if (level === 'off') {
47
+ console.log(chalk.dim(' Task lint disabled (task_lint: off in dw.config.yml). Skipping.'));
48
+ return;
49
+ }
50
+
51
+ let targets;
52
+ if (taskName) {
53
+ targets = [join(rootDir, TASKS_DIR, taskName)];
54
+ } else {
55
+ targets = findAllTaskDirs(rootDir);
56
+ }
57
+
58
+ if (targets.length === 0) {
59
+ console.log(chalk.dim(' No v3 tasks to lint.'));
60
+ return;
61
+ }
62
+
63
+ console.log();
64
+ console.log(chalk.bold(` Linting ${targets.length} task${targets.length === 1 ? '' : 's'} (level: ${level})`));
65
+
66
+ let totalErrors = 0;
67
+ let totalWarnings = 0;
68
+ let totalClean = 0;
69
+
70
+ for (const dir of targets) {
71
+ const result = lintTimeline(dir);
72
+ printViolations(dir, result);
73
+ const errs = result.violations.filter((v) => v.severity === 'error').length;
74
+ const warns = result.violations.filter((v) => v.severity === 'warning').length;
75
+ totalErrors += errs;
76
+ totalWarnings += warns;
77
+ if (result.violations.length === 0) totalClean++;
78
+
79
+ for (const v of result.violations) {
80
+ logEvent({
81
+ event: 'task',
82
+ action: 'lint.violation',
83
+ rule: v.rule,
84
+ severity: v.severity,
85
+ }, rootDir);
86
+ }
87
+ }
88
+
89
+ console.log();
90
+ const summary = ` ${totalClean} clean · ${totalWarnings} warnings · ${totalErrors} errors`;
91
+ if (totalErrors > 0) {
92
+ console.log(chalk.red(summary));
93
+ } else if (totalWarnings > 0) {
94
+ console.log(chalk.yellow(summary));
95
+ } else {
96
+ console.log(chalk.green(summary));
97
+ }
98
+ console.log();
99
+
100
+ logEvent({
101
+ event: 'task',
102
+ action: 'lint.run',
103
+ targets: targets.length,
104
+ errors: totalErrors,
105
+ warnings: totalWarnings,
106
+ level,
107
+ }, rootDir);
108
+
109
+ if (level === 'strict' && totalErrors > 0) {
110
+ process.exit(1);
111
+ }
112
+ }
@@ -0,0 +1,471 @@
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
+ '<!-- ![Timeline](./timeline.svg) -->',
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
+ function findV3Tasks(rootDir, targetSchemaVersion = 'v3.0') {
300
+ const tasksRoot = join(rootDir, TASKS_DIR);
301
+ if (!existsSync(tasksRoot)) return [];
302
+ return readdirSync(tasksRoot)
303
+ .filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
304
+ .map((e) => ({ name: e, path: join(tasksRoot, e) }))
305
+ .filter((e) => {
306
+ try {
307
+ if (!statSync(e.path).isDirectory()) return false;
308
+ const taskFile = join(e.path, 'task.md');
309
+ if (!existsSync(taskFile)) return false;
310
+ const fm = parseFrontmatter(readFileSync(taskFile, 'utf8'));
311
+ return fm.schema_version === targetSchemaVersion;
312
+ } catch { return false; }
313
+ });
314
+ }
315
+
316
+ async function migrateOneToV31(taskDir, opts) {
317
+ const taskFile = join(taskDir, 'task.md');
318
+ if (!existsSync(taskFile)) {
319
+ console.log(chalk.yellow(` ⚠ ${taskDir} — task.md not found, skipping`));
320
+ return { ok: false, skipped: true };
321
+ }
322
+ const content = readFileSync(taskFile, 'utf8');
323
+ const fm = parseFrontmatter(content);
324
+ if (fm.schema_version === 'v3.1') {
325
+ console.log(chalk.dim(` · ${taskDir} — already v3.1`));
326
+ return { ok: true, noop: true };
327
+ }
328
+ if (fm.schema_version !== 'v3.0') {
329
+ console.log(chalk.yellow(` ⚠ ${taskDir} — schema_version=${fm.schema_version || 'missing'}, expected v3.0 — skipping`));
330
+ return { ok: false, skipped: true };
331
+ }
332
+
333
+ const updated = { ...fm, schema_version: 'v3.1' };
334
+ if (!('parent_goal_id' in updated)) updated.parent_goal_id = null;
335
+ if (!('contributing_goal_ids' in updated)) updated.contributing_goal_ids = [];
336
+ if (!('summary' in updated)) updated.summary = null;
337
+
338
+ const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
339
+ const newContent = stringifyFrontmatter(updated) + body;
340
+
341
+ if (opts.dryRun) {
342
+ console.log(chalk.cyan(` · ${taskDir} — would bump v3.0 → v3.1 (adds parent_goal_id, contributing_goal_ids, summary)`));
343
+ return { ok: true, dryRun: true };
344
+ }
345
+
346
+ writeFileSync(taskFile, newContent, 'utf8');
347
+ console.log(chalk.green(` ✓ ${taskDir} — bumped v3.0 → v3.1`));
348
+ return { ok: true };
349
+ }
350
+
351
+ export async function taskMigrateCommand(taskName, opts = {}) {
352
+ const rootDir = process.cwd();
353
+
354
+ if (opts.toV31) {
355
+ let targets;
356
+ if (taskName) {
357
+ targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
358
+ } else if (opts.all) {
359
+ targets = findV3Tasks(rootDir, 'v3.0');
360
+ if (targets.length === 0) {
361
+ console.log(chalk.dim(' No v3.0 tasks found to bump to v3.1.'));
362
+ return;
363
+ }
364
+ } else {
365
+ console.error(chalk.red('✗ --to-v3.1 requires a task name or --all'));
366
+ console.error(chalk.dim(' Examples:'));
367
+ console.error(chalk.dim(' dw task migrate --to-v3.1 --all --dry-run'));
368
+ console.error(chalk.dim(' dw task migrate goals-management-layer --to-v3.1'));
369
+ process.exit(1);
370
+ }
371
+
372
+ console.log();
373
+ const action = opts.dryRun ? 'Dry-run bump' : 'Bump';
374
+ console.log(chalk.bold(` ${action} schema v3.0 → v3.1 (${targets.length} task${targets.length === 1 ? '' : 's'}):`));
375
+
376
+ const results = [];
377
+ for (const t of targets) {
378
+ const r = await migrateOneToV31(t.path, opts);
379
+ results.push({ name: t.name, ...r });
380
+ }
381
+ const bumpedCount = results.filter((r) => r.ok && !r.dryRun && !r.noop && !r.skipped).length;
382
+ const noop = results.filter((r) => r.noop).length;
383
+ const skipped = results.filter((r) => r.skipped).length;
384
+
385
+ logEvent({
386
+ event: 'task',
387
+ action: 'migrate.to-v3.1',
388
+ count: results.length,
389
+ success: bumpedCount,
390
+ noop,
391
+ skipped,
392
+ dry_run: !!opts.dryRun,
393
+ }, rootDir);
394
+
395
+ console.log();
396
+ if (opts.dryRun) {
397
+ console.log(chalk.cyan(` Dry-run complete. Re-run without --dry-run to apply.`));
398
+ } else {
399
+ console.log(chalk.green(` ${bumpedCount}/${results.length} bumped, ${noop} already v3.1, ${skipped} skipped.`));
400
+ console.log(chalk.dim(` v3.1 adds optional parent_goal_id, contributing_goal_ids, summary fields per ADR-0010.`));
401
+ console.log(chalk.dim(` Run \`dw task lint\` to verify.`));
402
+ }
403
+ console.log();
404
+ return;
405
+ }
406
+
407
+ if (opts.rollback) {
408
+ const taskDir = taskName
409
+ ? join(rootDir, TASKS_DIR, taskName)
410
+ : null;
411
+ if (taskDir) {
412
+ const r = await rollbackOne(taskDir);
413
+ logEvent({ event: 'task', action: 'migrate.rollback', name: taskName, success: r.ok }, rootDir);
414
+ return;
415
+ }
416
+ console.error(chalk.red('✗ --rollback requires a task name'));
417
+ process.exit(1);
418
+ }
419
+
420
+ let targets;
421
+ if (taskName) {
422
+ targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
423
+ } else if (opts.all) {
424
+ targets = findV2Tasks(rootDir);
425
+ if (targets.length === 0) {
426
+ console.log(chalk.dim(' No v2 tasks found to migrate.'));
427
+ return;
428
+ }
429
+ } else {
430
+ console.error(chalk.red('✗ Provide a task name or use --all to scan for v2 tasks'));
431
+ console.error(chalk.dim(' Examples:'));
432
+ console.error(chalk.dim(' dw task migrate dw-kit-v2-lean-optimization --dry-run'));
433
+ console.error(chalk.dim(' dw task migrate --all --dry-run'));
434
+ console.error(chalk.dim(' dw task migrate dw-kit-v2-lean-optimization --rollback'));
435
+ process.exit(1);
436
+ }
437
+
438
+ console.log();
439
+ const action = opts.dryRun ? 'Dry-run migrate' : opts.diff ? 'Diff migrate' : 'Migrate';
440
+ console.log(chalk.bold(` ${action} (${targets.length} task${targets.length === 1 ? '' : 's'}):`));
441
+
442
+ const results = [];
443
+ for (const t of targets) {
444
+ const r = await migrateOne(t.path, opts);
445
+ results.push({ name: t.name, ...r });
446
+ }
447
+
448
+ const okCount = results.filter((r) => r.ok && !r.dryRun && !r.diff && !r.noop && !r.skipped).length;
449
+ const skipped = results.filter((r) => r.skipped).length;
450
+
451
+ logEvent({
452
+ event: 'task',
453
+ action: 'migrate.run',
454
+ count: results.length,
455
+ success: okCount,
456
+ skipped,
457
+ dry_run: !!opts.dryRun,
458
+ }, rootDir);
459
+
460
+ console.log();
461
+ if (opts.dryRun) {
462
+ console.log(chalk.cyan(` Dry-run complete. Re-run without --dry-run to apply.`));
463
+ } else if (opts.diff) {
464
+ console.log(chalk.cyan(` Diff complete.`));
465
+ } else {
466
+ console.log(chalk.green(` ${okCount}/${results.length} migrated, ${skipped} skipped.`));
467
+ console.log(chalk.dim(` Backups saved as spec.md.v2bak / tracking.md.v2bak. Run \`dw task migrate <name> --rollback\` to undo.`));
468
+ console.log(chalk.dim(` Run \`dw active\` to refresh ACTIVE.md.`));
469
+ }
470
+ console.log();
471
+ }
@@ -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
+ }