dw-kit 1.3.6 → 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-archive/SKILL.md +14 -0
- 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-review/SKILL.md +33 -2
- package/.claude/skills/dw-task-init/SKILL.md +40 -35
- package/.dw/adapters/generic/AGENT.md +171 -169
- package/.dw/config/config.schema.json +149 -121
- package/.dw/config/dw.config.yml +14 -0
- 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 +18 -0
- package/package.json +4 -2
- package/src/cli.mjs +176 -0
- package/src/commands/agent-claim.mjs +235 -0
- package/src/commands/agent-inspect.mjs +123 -0
- package/src/commands/doctor.mjs +105 -1
- package/src/commands/lint-task.mjs +112 -0
- package/src/commands/review-render.mjs +255 -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/config.mjs +120 -104
- package/src/lib/frontmatter.mjs +72 -0
- package/src/lib/lint-rules.mjs +149 -0
- package/src/lib/review/manifest-schema.json +149 -0
- package/src/lib/review/manifest-validator.mjs +93 -0
- package/src/lib/review/scope-slug.mjs +68 -0
- package/src/lib/timeline-parser.mjs +80 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { header, ok, info, log, warn, err } from '../lib/ui.mjs';
|
|
8
|
+
import { loadConfigWithLocal, getReviewRendererConfig } from '../lib/config.mjs';
|
|
9
|
+
import { readManifest } from '../lib/review/manifest-validator.mjs';
|
|
10
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
11
|
+
|
|
12
|
+
const RENDER_PACKAGE = 'dw-kit-render';
|
|
13
|
+
const INSTALL_HINT = ` Install renderer with: ${chalk.cyan('npm install -g dw-kit-render')}\n Or run: ${chalk.cyan('dw doctor')} for environment check.`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `dw review render <manifest>` — invoked by /dw:review --visual after writing manifest.
|
|
17
|
+
* See ADR-0007 for architecture.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} manifestPath - path to manifest.json (relative or absolute)
|
|
20
|
+
* @param {{format?: string, strategy?: string, quiet?: boolean}} opts
|
|
21
|
+
*/
|
|
22
|
+
export async function reviewRenderCommand(manifestPath, opts = {}) {
|
|
23
|
+
const projectDir = process.cwd();
|
|
24
|
+
const absManifest = isAbsolute(manifestPath) ? manifestPath : resolve(projectDir, manifestPath);
|
|
25
|
+
const startedAt = performance.now();
|
|
26
|
+
|
|
27
|
+
if (!opts.quiet) header('dw review render');
|
|
28
|
+
|
|
29
|
+
// 1. Load + validate manifest.
|
|
30
|
+
const parseResult = readManifest(absManifest);
|
|
31
|
+
if (!parseResult.ok) {
|
|
32
|
+
err(`Manifest invalid: ${absManifest}`);
|
|
33
|
+
for (const e of parseResult.errors.slice(0, 10)) {
|
|
34
|
+
log(` ${chalk.dim(e.path || '/')} — ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'invalid-manifest' }, projectDir);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const manifest = parseResult.manifest;
|
|
40
|
+
|
|
41
|
+
// 2. Resolve config + strategy.
|
|
42
|
+
const config = loadConfigWithLocal(join(projectDir, '.dw', 'config')) || {};
|
|
43
|
+
const rendererCfg = getReviewRendererConfig(config);
|
|
44
|
+
const strategy = opts.strategy || rendererCfg.strategy;
|
|
45
|
+
const formats = parseFormats(opts.format) || rendererCfg.formats;
|
|
46
|
+
|
|
47
|
+
// 3. Resolve output directory.
|
|
48
|
+
const outDir = resolveOutputDir(rendererCfg.output_dir, manifest, projectDir);
|
|
49
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
if (!opts.quiet) {
|
|
52
|
+
info('Render context');
|
|
53
|
+
log(` Manifest : ${absManifest}`);
|
|
54
|
+
log(` Scope : ${manifest.scope} (slug: ${manifest.scope_slug || '—'})`);
|
|
55
|
+
log(` Findings : ${manifest.findings.length}`);
|
|
56
|
+
log(` Strategy : ${strategy}`);
|
|
57
|
+
log(` Formats : ${formats.join(', ')}`);
|
|
58
|
+
log(` Output dir : ${outDir}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Resolve renderer (dw-kit-render package).
|
|
62
|
+
const rendererResolved = strategy === 'markdown-only' ? null : await tryResolveRenderer(projectDir);
|
|
63
|
+
const finalStrategy = strategy === 'markdown-only'
|
|
64
|
+
? 'markdown-only'
|
|
65
|
+
: (rendererResolved ? 'plugin' : (strategy === 'plugin' ? 'plugin-missing' : 'fallback-markdown'));
|
|
66
|
+
|
|
67
|
+
// Plugin strategy was requested but package is missing — fail loudly.
|
|
68
|
+
if (strategy === 'plugin' && !rendererResolved) {
|
|
69
|
+
err(`Strategy 'plugin' requested but '${RENDER_PACKAGE}' is not installed.`);
|
|
70
|
+
log(INSTALL_HINT);
|
|
71
|
+
logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'no-renderer', strategy, formats }, projectDir);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let artifacts = { svg: [], png: [] };
|
|
76
|
+
let renderErrors = [];
|
|
77
|
+
|
|
78
|
+
if (rendererResolved) {
|
|
79
|
+
try {
|
|
80
|
+
if (!opts.quiet) info('Rendering with dw-kit-render');
|
|
81
|
+
const result = await rendererResolved.render({
|
|
82
|
+
manifest,
|
|
83
|
+
outDir,
|
|
84
|
+
formats,
|
|
85
|
+
theme: rendererCfg.theme,
|
|
86
|
+
font: rendererCfg.font,
|
|
87
|
+
});
|
|
88
|
+
artifacts = {
|
|
89
|
+
svg: Array.isArray(result?.svgPaths) ? result.svgPaths : [],
|
|
90
|
+
png: Array.isArray(result?.pngPaths) ? result.pngPaths : [],
|
|
91
|
+
};
|
|
92
|
+
} catch (e) {
|
|
93
|
+
renderErrors.push(e.message || String(e));
|
|
94
|
+
warn(`Renderer error: ${e.message || e}`);
|
|
95
|
+
warn('Falling back to markdown-only summary.');
|
|
96
|
+
}
|
|
97
|
+
} else if (strategy !== 'markdown-only') {
|
|
98
|
+
if (!opts.quiet) {
|
|
99
|
+
warn(`'${RENDER_PACKAGE}' not found — emitting markdown summary only.`);
|
|
100
|
+
log(INSTALL_HINT);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 5. Write summary.md (always — works without renderer too).
|
|
105
|
+
const summaryPath = join(outDir, 'summary.md');
|
|
106
|
+
writeFileSync(summaryPath, buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }), 'utf-8');
|
|
107
|
+
|
|
108
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
109
|
+
|
|
110
|
+
if (!opts.quiet) {
|
|
111
|
+
console.log();
|
|
112
|
+
info('Artifacts');
|
|
113
|
+
log(` Summary : ${summaryPath}`);
|
|
114
|
+
if (artifacts.svg.length) log(` SVG : ${artifacts.svg.length} file(s)`);
|
|
115
|
+
if (artifacts.png.length) log(` PNG : ${artifacts.png.length} file(s)`);
|
|
116
|
+
if (!artifacts.svg.length && !artifacts.png.length) log(` ${chalk.dim('(markdown only — install dw-kit-render for images)')}`);
|
|
117
|
+
console.log();
|
|
118
|
+
if (renderErrors.length) {
|
|
119
|
+
warn(`${renderErrors.length} render error(s) — see above`);
|
|
120
|
+
} else {
|
|
121
|
+
ok(`Done in ${durationMs}ms`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
logEvent({
|
|
126
|
+
event: 'review_render',
|
|
127
|
+
action: renderErrors.length ? 'partial' : 'success',
|
|
128
|
+
strategy: finalStrategy,
|
|
129
|
+
formats,
|
|
130
|
+
findings: manifest.findings.length,
|
|
131
|
+
duration_ms: durationMs,
|
|
132
|
+
fallback_reason: rendererResolved ? null : (strategy === 'markdown-only' ? 'config-markdown-only' : 'no-renderer'),
|
|
133
|
+
}, projectDir);
|
|
134
|
+
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseFormats(value) {
|
|
139
|
+
if (!value) return null;
|
|
140
|
+
if (value === 'both') return ['svg', 'png'];
|
|
141
|
+
if (value === 'svg' || value === 'png') return [value];
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveOutputDir(configDir, manifest, projectDir) {
|
|
146
|
+
const baseDir = isAbsolute(configDir) ? configDir : join(projectDir, configDir);
|
|
147
|
+
const slug = manifest.scope_slug || manifest.scope || 'review';
|
|
148
|
+
return join(baseDir, slug);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function tryResolveRenderer(projectDir) {
|
|
152
|
+
// Test-only escape hatch: tests use this to force the markdown fallback even
|
|
153
|
+
// when a local renderer is dev-linked from a sibling packages/ dir.
|
|
154
|
+
if (process.env.DW_REVIEW_NO_RENDERER === '1') return null;
|
|
155
|
+
|
|
156
|
+
// Resolution order: project node_modules → CLI's own node_modules (global -g case).
|
|
157
|
+
// We use createRequire to get a *resolution* path, then dynamic-import that URL
|
|
158
|
+
// so the renderer can be ESM-only (Phase 1 ships ESM only).
|
|
159
|
+
for (const anchor of [join(projectDir, 'package.json'), import.meta.url]) {
|
|
160
|
+
try {
|
|
161
|
+
const req = createRequire(anchor);
|
|
162
|
+
const entry = req.resolve(RENDER_PACKAGE);
|
|
163
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
164
|
+
const render = mod?.render || mod?.default?.render;
|
|
165
|
+
if (typeof render !== 'function') {
|
|
166
|
+
throw new Error("dw-kit-render module did not export a 'render' function");
|
|
167
|
+
}
|
|
168
|
+
return { render };
|
|
169
|
+
} catch {
|
|
170
|
+
// try next anchor
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
const counts = countBySeverity(manifest.findings);
|
|
179
|
+
const slug = manifest.scope_slug || manifest.scope;
|
|
180
|
+
|
|
181
|
+
lines.push(`# Review summary — ${manifest.scope}`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
lines.push(`- Generated: ${manifest.generated_at}`);
|
|
184
|
+
if (manifest.review_meta?.diff_base) lines.push(`- Diff base: ${manifest.review_meta.diff_base}`);
|
|
185
|
+
if (manifest.review_meta?.files_reviewed != null) lines.push(`- Files reviewed: ${manifest.review_meta.files_reviewed}`);
|
|
186
|
+
if (manifest.task_id) lines.push(`- Task: \`${manifest.task_id}\``);
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push(`**Severity counts:** critical=${counts.critical}, warning=${counts.warning}, suggestion=${counts.suggestion}`);
|
|
189
|
+
lines.push('');
|
|
190
|
+
|
|
191
|
+
if (!manifest.findings.length) {
|
|
192
|
+
lines.push('No findings.');
|
|
193
|
+
lines.push('');
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lines.push('## Findings');
|
|
198
|
+
lines.push('');
|
|
199
|
+
for (const f of manifest.findings) {
|
|
200
|
+
const loc = formatLocation(f.location);
|
|
201
|
+
lines.push(`### [${f.severity.toUpperCase()}] ${f.title}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
lines.push(`- File: \`${loc}\``);
|
|
204
|
+
if (f.rule_ref) lines.push(`- Rule: ${f.rule_ref}`);
|
|
205
|
+
|
|
206
|
+
const svg = artifacts.svg.find((p) => p.endsWith(`finding-${f.id}.svg`));
|
|
207
|
+
const png = artifacts.png.find((p) => p.endsWith(`finding-${f.id}.png`));
|
|
208
|
+
if (svg) lines.push(`- SVG: \`${relativeTo(svg, outDir)}\``);
|
|
209
|
+
if (png) lines.push(`- PNG: \`${relativeTo(png, outDir)}\``);
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push(f.body);
|
|
212
|
+
lines.push('');
|
|
213
|
+
if (f.code_snippet) {
|
|
214
|
+
const lang = f.language || '';
|
|
215
|
+
lines.push('```' + lang);
|
|
216
|
+
lines.push(f.code_snippet);
|
|
217
|
+
lines.push('```');
|
|
218
|
+
lines.push('');
|
|
219
|
+
}
|
|
220
|
+
if (f.fix) {
|
|
221
|
+
lines.push(`**Fix:** ${f.fix}`);
|
|
222
|
+
lines.push('');
|
|
223
|
+
}
|
|
224
|
+
lines.push('---');
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lines.push(`*Manifest: \`${relativeTo(join(outDir, 'manifest.json'), projectDir)}\`*`);
|
|
229
|
+
lines.push('');
|
|
230
|
+
return lines.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function countBySeverity(findings) {
|
|
234
|
+
const out = { critical: 0, warning: 0, suggestion: 0 };
|
|
235
|
+
for (const f of findings) {
|
|
236
|
+
if (out[f.severity] != null) out[f.severity]++;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatLocation(loc) {
|
|
242
|
+
if (!loc) return '?';
|
|
243
|
+
if (loc.line_start && loc.line_end && loc.line_start !== loc.line_end) {
|
|
244
|
+
return `${loc.file}:${loc.line_start}-${loc.line_end}`;
|
|
245
|
+
}
|
|
246
|
+
if (loc.line_start) return `${loc.file}:${loc.line_start}`;
|
|
247
|
+
return loc.file;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function relativeTo(target, base) {
|
|
251
|
+
const t = target.replace(/\\/g, '/');
|
|
252
|
+
const b = base.replace(/\\/g, '/').replace(/\/$/, '');
|
|
253
|
+
if (t.startsWith(b + '/')) return t.slice(b.length + 1);
|
|
254
|
+
return t;
|
|
255
|
+
}
|
|
@@ -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
|
+
}
|