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,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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, statSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseTimeline } from '../lib/timeline-parser.mjs';
|
|
5
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
6
|
+
|
|
7
|
+
const TASKS_DIR = '.dw/tasks';
|
|
8
|
+
const SECTION_4_MAX_LINES = 400;
|
|
9
|
+
const KEEP_RECENT_ENTRIES = 8;
|
|
10
|
+
|
|
11
|
+
function findV3Tasks(rootDir) {
|
|
12
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
13
|
+
if (!existsSync(tasksRoot)) return [];
|
|
14
|
+
return readdirSync(tasksRoot)
|
|
15
|
+
.filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
|
|
16
|
+
.map((e) => ({ name: e, path: join(tasksRoot, e) }))
|
|
17
|
+
.filter((e) => {
|
|
18
|
+
try { return statSync(e.path).isDirectory() && existsSync(join(e.path, 'task.md')); }
|
|
19
|
+
catch { return false; }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function splitChangelogEntries(text) {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
const entries = [];
|
|
26
|
+
let header = [];
|
|
27
|
+
let current = null;
|
|
28
|
+
let preEntries = [];
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const heading = line.match(/^###\s+/);
|
|
31
|
+
if (heading) {
|
|
32
|
+
if (current) entries.push(current);
|
|
33
|
+
current = { headerLine: line, bodyLines: [] };
|
|
34
|
+
} else if (current) {
|
|
35
|
+
current.bodyLines.push(line);
|
|
36
|
+
} else {
|
|
37
|
+
preEntries.push(line);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (current) entries.push(current);
|
|
41
|
+
return { preEntries, entries };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rotateTimeline(taskDir, opts = {}) {
|
|
45
|
+
const timelineFile = join(taskDir, 'task.md');
|
|
46
|
+
if (!existsSync(timelineFile)) {
|
|
47
|
+
return { ok: false, reason: 'no-timeline' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const content = readFileSync(timelineFile, 'utf8');
|
|
51
|
+
const parsed = parseTimeline(content);
|
|
52
|
+
const sec4 = parsed.sections[4];
|
|
53
|
+
if (!sec4) return { ok: false, reason: 'no-section-4' };
|
|
54
|
+
|
|
55
|
+
const lineCount = sec4.text.split('\n').length;
|
|
56
|
+
if (lineCount <= SECTION_4_MAX_LINES) {
|
|
57
|
+
return { ok: true, rotated: false, lineCount, reason: 'under-cap' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { preEntries, entries } = splitChangelogEntries(sec4.text);
|
|
61
|
+
if (entries.length <= KEEP_RECENT_ENTRIES) {
|
|
62
|
+
return { ok: true, rotated: false, lineCount, reason: 'few-entries' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const keep = entries.slice(0, KEEP_RECENT_ENTRIES);
|
|
66
|
+
const rotated = entries.slice(KEEP_RECENT_ENTRIES);
|
|
67
|
+
|
|
68
|
+
const historyFile = join(taskDir, 'timeline-history.md');
|
|
69
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
70
|
+
|
|
71
|
+
const historyChunk = [
|
|
72
|
+
`<!-- Auto-rotated from task.md Section 4 on ${today} -->`,
|
|
73
|
+
'',
|
|
74
|
+
...rotated.map((e) => [e.headerLine, ...e.bodyLines].join('\n').trimEnd()),
|
|
75
|
+
'',
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
if (opts.dryRun) {
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
rotated: true,
|
|
82
|
+
dryRun: true,
|
|
83
|
+
lineCount,
|
|
84
|
+
historyFile,
|
|
85
|
+
rotatedCount: rotated.length,
|
|
86
|
+
kept: keep.length,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (existsSync(historyFile)) {
|
|
91
|
+
appendFileSync(historyFile, '\n' + historyChunk, 'utf8');
|
|
92
|
+
} else {
|
|
93
|
+
const header = [
|
|
94
|
+
`# Timeline History — ${parsed.title || taskDir.split(/[\\/]/).pop()}`,
|
|
95
|
+
'',
|
|
96
|
+
'<!-- Auto-rotated overflow from task.md Section 4. Newest entries at top. -->',
|
|
97
|
+
'',
|
|
98
|
+
].join('\n');
|
|
99
|
+
writeFileSync(historyFile, header + historyChunk, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newSec4Body = [
|
|
103
|
+
`> Older entries auto-rotated to [\`timeline-history.md\`](./timeline-history.md) on ${today} (kept last ${KEEP_RECENT_ENTRIES}).`,
|
|
104
|
+
'',
|
|
105
|
+
preEntries.join('\n').trim(),
|
|
106
|
+
'',
|
|
107
|
+
...keep.map((e) => [e.headerLine, ...e.bodyLines].join('\n').trimEnd()),
|
|
108
|
+
].join('\n');
|
|
109
|
+
|
|
110
|
+
const sec4Header = '## 4. Timeline / Changelog';
|
|
111
|
+
const sec4Index = content.indexOf(sec4Header);
|
|
112
|
+
const afterSec4 = content.slice(sec4Index + sec4Header.length);
|
|
113
|
+
const nextSecMatch = afterSec4.match(/^##\s+/m);
|
|
114
|
+
const sec4EndIdx = nextSecMatch ? sec4Index + sec4Header.length + nextSecMatch.index : content.length;
|
|
115
|
+
|
|
116
|
+
const newContent = content.slice(0, sec4Index + sec4Header.length) + '\n\n' + newSec4Body + '\n\n' + content.slice(sec4EndIdx);
|
|
117
|
+
writeFileSync(timelineFile, newContent, 'utf8');
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
rotated: true,
|
|
122
|
+
lineCount,
|
|
123
|
+
historyFile,
|
|
124
|
+
rotatedCount: rotated.length,
|
|
125
|
+
kept: keep.length,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function taskRotateCommand(taskName, opts = {}) {
|
|
130
|
+
const rootDir = process.cwd();
|
|
131
|
+
|
|
132
|
+
let targets;
|
|
133
|
+
if (taskName) {
|
|
134
|
+
targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
|
|
135
|
+
} else {
|
|
136
|
+
targets = findV3Tasks(rootDir);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (targets.length === 0) {
|
|
140
|
+
if (!opts.quiet) console.log(chalk.dim(' No v3 tasks to rotate.'));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let rotatedCount = 0;
|
|
145
|
+
for (const t of targets) {
|
|
146
|
+
const r = rotateTimeline(t.path, opts);
|
|
147
|
+
if (r.rotated) {
|
|
148
|
+
rotatedCount++;
|
|
149
|
+
if (!opts.quiet) {
|
|
150
|
+
console.log(chalk.green(` ✓ ${t.name} — rotated ${r.rotatedCount} entries to timeline-history.md (kept ${r.kept})`));
|
|
151
|
+
}
|
|
152
|
+
logEvent({
|
|
153
|
+
event: 'task',
|
|
154
|
+
action: 'rotate.run',
|
|
155
|
+
name: t.name,
|
|
156
|
+
rotated: r.rotatedCount,
|
|
157
|
+
kept: r.kept,
|
|
158
|
+
dry_run: !!opts.dryRun,
|
|
159
|
+
}, rootDir);
|
|
160
|
+
} else if (!opts.quiet && r.ok) {
|
|
161
|
+
console.log(chalk.dim(` · ${t.name} — under cap (${r.lineCount} lines)`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!opts.quiet && rotatedCount === 0) {
|
|
166
|
+
console.log(chalk.dim(` No rotations needed (Section 4 cap: ${SECTION_4_MAX_LINES} lines).`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|
5
|
+
import { parseTimeline, parseSubtaskTracker } from '../lib/timeline-parser.mjs';
|
|
6
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
7
|
+
|
|
8
|
+
const TASKS_DIR = '.dw/tasks';
|
|
9
|
+
|
|
10
|
+
const STATUS_COLOR = {
|
|
11
|
+
Draft: chalk.gray,
|
|
12
|
+
Approved: chalk.cyan,
|
|
13
|
+
'In Progress': chalk.yellow,
|
|
14
|
+
Blocked: chalk.red,
|
|
15
|
+
Paused: chalk.magenta,
|
|
16
|
+
Done: chalk.green,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TRACKER_GLYPH_COLOR = {
|
|
20
|
+
'⬜': chalk.gray,
|
|
21
|
+
'🟡': chalk.yellow,
|
|
22
|
+
'✅': chalk.green,
|
|
23
|
+
'🔴': chalk.red,
|
|
24
|
+
'⏸': chalk.magenta,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function colorStatus(status) {
|
|
28
|
+
const fn = STATUS_COLOR[status] || chalk.white;
|
|
29
|
+
return fn.bold(status);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function colorTrackerStatus(s) {
|
|
33
|
+
for (const [glyph, color] of Object.entries(TRACKER_GLYPH_COLOR)) {
|
|
34
|
+
if (s.startsWith(glyph)) return color(s);
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveTaskDir(taskName, rootDir) {
|
|
40
|
+
if (taskName) return join(rootDir, TASKS_DIR, taskName);
|
|
41
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
42
|
+
if (!existsSync(tasksRoot)) return null;
|
|
43
|
+
const candidates = readdirSync(tasksRoot)
|
|
44
|
+
.filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
|
|
45
|
+
.map((e) => ({
|
|
46
|
+
name: e,
|
|
47
|
+
path: join(tasksRoot, e),
|
|
48
|
+
}))
|
|
49
|
+
.filter((e) => {
|
|
50
|
+
try { return statSync(e.path).isDirectory() && existsSync(join(e.path, 'task.md')); }
|
|
51
|
+
catch { return false; }
|
|
52
|
+
});
|
|
53
|
+
if (candidates.length === 0) return null;
|
|
54
|
+
if (candidates.length === 1) return candidates[0].path;
|
|
55
|
+
candidates.sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs);
|
|
56
|
+
return candidates[0].path;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function taskShowCommand(taskName, opts = {}) {
|
|
60
|
+
const rootDir = process.cwd();
|
|
61
|
+
const taskDir = resolveTaskDir(taskName, rootDir);
|
|
62
|
+
|
|
63
|
+
if (!taskDir) {
|
|
64
|
+
console.error(chalk.red('✗ No v3 task found.'));
|
|
65
|
+
console.error(chalk.dim(' Pass a task name or run from a project with .dw/tasks/{name}/task.md'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const timelineFile = join(taskDir, 'task.md');
|
|
70
|
+
if (!existsSync(timelineFile)) {
|
|
71
|
+
console.error(chalk.red(`✗ Not a v3 task (no task.md): ${taskDir}`));
|
|
72
|
+
console.error(chalk.dim(' Run `dw task migrate` to upgrade v2 spec+tracking to v3.'));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const content = readFileSync(timelineFile, 'utf8');
|
|
77
|
+
const fm = parseFrontmatter(content);
|
|
78
|
+
const parsed = parseTimeline(content);
|
|
79
|
+
|
|
80
|
+
const taskId = fm.task_id || taskDir.split(/[\\/]/).pop();
|
|
81
|
+
logEvent({ event: 'task', action: 'show', name: taskId }, rootDir);
|
|
82
|
+
|
|
83
|
+
const width = 60;
|
|
84
|
+
const hr = chalk.dim('─'.repeat(width));
|
|
85
|
+
|
|
86
|
+
console.log();
|
|
87
|
+
console.log(chalk.bold.cyan(` ${parsed.title || taskId}`));
|
|
88
|
+
console.log(` ${hr}`);
|
|
89
|
+
|
|
90
|
+
const statusLine = colorStatus(fm.status || 'unknown');
|
|
91
|
+
const phase = fm.phase ? chalk.dim(` · ${fm.phase}`) : '';
|
|
92
|
+
console.log(` ${chalk.bold('Status:')} ${statusLine}${phase}`);
|
|
93
|
+
|
|
94
|
+
if (fm.owner) console.log(` ${chalk.bold('Owner:')} ${fm.owner}`);
|
|
95
|
+
if (fm.depth) console.log(` ${chalk.bold('Depth:')} ${fm.depth}`);
|
|
96
|
+
if (fm.related_adr && fm.related_adr !== 'none') {
|
|
97
|
+
console.log(` ${chalk.bold('ADR:')} ${chalk.cyan(fm.related_adr)}`);
|
|
98
|
+
}
|
|
99
|
+
if (fm.target_ship) console.log(` ${chalk.bold('Target:')} ${fm.target_ship}`);
|
|
100
|
+
if (fm.last_updated) console.log(` ${chalk.bold('Updated:')} ${fm.last_updated}`);
|
|
101
|
+
const blockers = fm.blockers || 'none';
|
|
102
|
+
const blockersColor = blockers === 'none' ? chalk.green : chalk.red;
|
|
103
|
+
console.log(` ${chalk.bold('Blockers:')} ${blockersColor(blockers)}`);
|
|
104
|
+
|
|
105
|
+
console.log(` ${hr}`);
|
|
106
|
+
|
|
107
|
+
const tracker = parseSubtaskTracker(parsed.sections[3]?.text);
|
|
108
|
+
if (tracker.length > 0) {
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(chalk.bold(' Subtask Tracker:'));
|
|
111
|
+
const idWidth = Math.max(4, ...tracker.map((r) => r.id.length));
|
|
112
|
+
for (const row of tracker) {
|
|
113
|
+
const id = row.id.padEnd(idWidth);
|
|
114
|
+
const status = colorTrackerStatus(row.status);
|
|
115
|
+
const date = row.date ? chalk.dim(` (${row.date})`) : '';
|
|
116
|
+
const truncatedName = row.name.length > 50 ? row.name.slice(0, 47) + '...' : row.name;
|
|
117
|
+
console.log(` ${chalk.dim(id)} ${status} ${truncatedName}${date}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (opts.verbose && parsed.sections[1]) {
|
|
122
|
+
console.log();
|
|
123
|
+
console.log(chalk.bold(' Snapshot:'));
|
|
124
|
+
const lines = parsed.sections[1].text.split('\n').filter(Boolean).slice(0, 10);
|
|
125
|
+
for (const l of lines) console.log(` ${l}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(chalk.dim(` File: ${timelineFile}`));
|
|
130
|
+
const svgPath = join(taskDir, 'timeline.svg');
|
|
131
|
+
if (existsSync(svgPath)) {
|
|
132
|
+
console.log(chalk.dim(` SVG: ${svgPath}`));
|
|
133
|
+
} else {
|
|
134
|
+
console.log(chalk.dim(` SVG: (not yet rendered — run \`dw task render\` or commit to trigger hook)`));
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|