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,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
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|
6
|
+
import { parseTimeline, parseSubtaskTracker, splitChangelogEntries } from '../lib/timeline-parser.mjs';
|
|
7
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
8
|
+
|
|
9
|
+
const TASKS_DIR = '.dw/tasks';
|
|
10
|
+
const CACHE_DIR = '.dw/cache/preview';
|
|
11
|
+
|
|
12
|
+
const STATUS_COLORS = {
|
|
13
|
+
Draft: '#6b7280',
|
|
14
|
+
Approved: '#0891b2',
|
|
15
|
+
'In Progress': '#f59e0b',
|
|
16
|
+
Blocked: '#dc2626',
|
|
17
|
+
Paused: '#a855f7',
|
|
18
|
+
Done: '#10b981',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const TRACKER_GLYPH_META = {
|
|
22
|
+
'⬜': { color: '#6b7280', label: 'Pending', pct: 0 },
|
|
23
|
+
'🟡': { color: '#f59e0b', label: 'In Progress', pct: 50 },
|
|
24
|
+
'✅': { color: '#10b981', label: 'Done', pct: 100 },
|
|
25
|
+
'🔴': { color: '#dc2626', label: 'Blocked', pct: 25 },
|
|
26
|
+
'⏸': { color: '#a855f7', label: 'Paused', pct: 30 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function findV3Tasks(rootDir) {
|
|
30
|
+
const tasksRoot = join(rootDir, TASKS_DIR);
|
|
31
|
+
if (!existsSync(tasksRoot)) return [];
|
|
32
|
+
return readdirSync(tasksRoot)
|
|
33
|
+
.filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
|
|
34
|
+
.map((e) => ({ name: e, path: join(tasksRoot, e) }))
|
|
35
|
+
.filter((e) => {
|
|
36
|
+
try { return statSync(e.path).isDirectory() && existsSync(join(e.path, 'task.md')); }
|
|
37
|
+
catch { return false; }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeHtml(s) {
|
|
42
|
+
return String(s)
|
|
43
|
+
.replace(/&/g, '&')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, ''');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getGlyph(status) {
|
|
51
|
+
if (!status) return '⬜';
|
|
52
|
+
const m = status.trim().match(/^(⬜|🟡|✅|🔴|⏸)/);
|
|
53
|
+
return m ? m[1] : '⬜';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSnapshotCard(fm, parsed, taskName) {
|
|
57
|
+
const status = fm.status || 'Draft';
|
|
58
|
+
const color = STATUS_COLORS[status] || STATUS_COLORS.Draft;
|
|
59
|
+
const blockers = (fm.blockers && fm.blockers !== 'none') ? fm.blockers : null;
|
|
60
|
+
const subtaskRows = parseSubtaskTracker(parsed.sections[3]?.text) || [];
|
|
61
|
+
const total = subtaskRows.length;
|
|
62
|
+
const done = subtaskRows.filter((r) => getGlyph(r.status) === '✅').length;
|
|
63
|
+
const inProgress = subtaskRows.filter((r) => getGlyph(r.status) === '🟡').length;
|
|
64
|
+
const blocked = subtaskRows.filter((r) => getGlyph(r.status) === '🔴').length;
|
|
65
|
+
const pending = total - done - inProgress - blocked;
|
|
66
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
67
|
+
|
|
68
|
+
return `
|
|
69
|
+
<div class="card snapshot">
|
|
70
|
+
<div class="snap-head">
|
|
71
|
+
<span class="badge" style="background:${color};">${escapeHtml(status)}</span>
|
|
72
|
+
<h1>${escapeHtml(parsed.title || taskName)}</h1>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="snap-meta">
|
|
75
|
+
${fm.phase ? `<span>${escapeHtml(fm.phase)}</span>` : ''}
|
|
76
|
+
${fm.owner ? `<span>· ${escapeHtml(fm.owner)}</span>` : ''}
|
|
77
|
+
${fm.related_adr && fm.related_adr !== 'none' ? `<span>· <strong>${escapeHtml(fm.related_adr)}</strong></span>` : ''}
|
|
78
|
+
${fm.last_updated ? `<span class="muted">· updated ${escapeHtml(fm.last_updated)}</span>` : ''}
|
|
79
|
+
</div>
|
|
80
|
+
${blockers ? `<div class="blocker"><strong>⚠ BLOCKERS</strong> ${escapeHtml(blockers)}</div>` : ''}
|
|
81
|
+
<div class="progress">
|
|
82
|
+
<div class="bar"><div class="fill" style="width:${pct}%; background:${color};"></div></div>
|
|
83
|
+
<div class="counts">
|
|
84
|
+
<span class="ct done">✅ ${done}</span>
|
|
85
|
+
<span class="ct prog">🟡 ${inProgress}</span>
|
|
86
|
+
<span class="ct blk">🔴 ${blocked}</span>
|
|
87
|
+
<span class="ct pend">⬜ ${pending}</span>
|
|
88
|
+
<span class="ct tot">${pct}% (${done}/${total})</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildTrackerCard(parsed) {
|
|
96
|
+
const rows = parseSubtaskTracker(parsed.sections[3]?.text) || [];
|
|
97
|
+
if (rows.length === 0) {
|
|
98
|
+
return `<div class="card empty">No subtasks yet — populate Section 3 in <code>task.md</code>.</div>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const items = rows.map((r) => {
|
|
102
|
+
const glyph = getGlyph(r.status);
|
|
103
|
+
const meta = TRACKER_GLYPH_META[glyph] || TRACKER_GLYPH_META['⬜'];
|
|
104
|
+
return `
|
|
105
|
+
<div class="track-row">
|
|
106
|
+
<span class="dot" style="background:${meta.color};" title="${escapeHtml(meta.label)}">${glyph}</span>
|
|
107
|
+
<span class="tid">${escapeHtml(r.id || '')}</span>
|
|
108
|
+
<span class="tname">${escapeHtml((r.name || '').slice(0, 80))}</span>
|
|
109
|
+
${r.date && r.date !== '—' ? `<span class="tdate">${escapeHtml(r.date)}</span>` : ''}
|
|
110
|
+
</div>
|
|
111
|
+
`;
|
|
112
|
+
}).join('');
|
|
113
|
+
|
|
114
|
+
return `
|
|
115
|
+
<div class="card">
|
|
116
|
+
<h2>Subtasks <span class="muted">(${rows.length})</span></h2>
|
|
117
|
+
<div class="tracker">${items}</div>
|
|
118
|
+
</div>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildRecentActivityCard(parsed) {
|
|
123
|
+
const entries = splitChangelogEntries(parsed.sections[4]?.text || '');
|
|
124
|
+
if (entries.length === 0) {
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
const recent = entries.slice(0, 3).map((e) => {
|
|
128
|
+
const heading = e.heading || 'Untitled';
|
|
129
|
+
const bodyText = e.lines.join(' ').replace(/\s+/g, ' ').replace(/[*_`]/g, '').trim();
|
|
130
|
+
const preview = bodyText.slice(0, 140);
|
|
131
|
+
return `
|
|
132
|
+
<div class="activity-row">
|
|
133
|
+
<span class="aheading">${escapeHtml(heading.slice(0, 100))}</span>
|
|
134
|
+
${preview ? `<span class="apreview">${escapeHtml(preview)}${bodyText.length > 140 ? '…' : ''}</span>` : ''}
|
|
135
|
+
</div>
|
|
136
|
+
`;
|
|
137
|
+
}).join('');
|
|
138
|
+
|
|
139
|
+
const moreCount = Math.max(0, entries.length - 3);
|
|
140
|
+
const moreNote = moreCount > 0 ? `<div class="more">+ ${moreCount} earlier session${moreCount === 1 ? '' : 's'} — see <code>task.md</code> Section 4</div>` : '';
|
|
141
|
+
|
|
142
|
+
return `
|
|
143
|
+
<div class="card">
|
|
144
|
+
<h2>Recent activity <span class="muted">(${entries.length} session${entries.length === 1 ? '' : 's'})</span></h2>
|
|
145
|
+
<div class="activity">${recent}</div>
|
|
146
|
+
${moreNote}
|
|
147
|
+
</div>
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildHandoffCard(parsed) {
|
|
152
|
+
const text = parsed.sections[5]?.text;
|
|
153
|
+
if (!text) return '';
|
|
154
|
+
|
|
155
|
+
const bullets = [];
|
|
156
|
+
const re = /\*\*(Read first|Current state|Don't do|Watch out|For next session)[^:*]*:\*\*\s*([^\n]+)/gi;
|
|
157
|
+
let m;
|
|
158
|
+
while ((m = re.exec(text)) !== null) {
|
|
159
|
+
bullets.push({ label: m[1].trim(), value: m[2].trim() });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (bullets.length === 0) {
|
|
163
|
+
const lines = text.split('\n').filter((l) => l.trim().startsWith('-')).slice(0, 4);
|
|
164
|
+
if (lines.length === 0) return '';
|
|
165
|
+
const items = lines.map((l) => `<li>${escapeHtml(l.replace(/^-\s*/, '').slice(0, 140))}</li>`).join('');
|
|
166
|
+
return `
|
|
167
|
+
<div class="card">
|
|
168
|
+
<h2>Handoff</h2>
|
|
169
|
+
<ul class="handoff">${items}</ul>
|
|
170
|
+
</div>
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const items = bullets.slice(0, 5).map((b) => `
|
|
175
|
+
<div class="hand-row">
|
|
176
|
+
<span class="hlabel">${escapeHtml(b.label)}</span>
|
|
177
|
+
<span class="hval">${escapeHtml(b.value.slice(0, 200))}</span>
|
|
178
|
+
</div>
|
|
179
|
+
`).join('');
|
|
180
|
+
|
|
181
|
+
return `
|
|
182
|
+
<div class="card">
|
|
183
|
+
<h2>Handoff</h2>
|
|
184
|
+
<div class="handoff">${items}</div>
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildOpenLinks(taskDir, taskName, hasSvg) {
|
|
190
|
+
const md = join(taskDir, 'task.md').replace(/\\/g, '/');
|
|
191
|
+
const svg = hasSvg ? join(taskDir, 'timeline.svg').replace(/\\/g, '/') : null;
|
|
192
|
+
return `
|
|
193
|
+
<div class="links">
|
|
194
|
+
<a href="file://${md}" class="link primary">📄 Open <code>task.md</code> for full details</a>
|
|
195
|
+
${svg ? `<a href="file://${svg}" class="link">🖼️ SVG sidecar</a>` : ''}
|
|
196
|
+
</div>
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const PAGE_CSS = `
|
|
201
|
+
:root {
|
|
202
|
+
--bg: #0d1117;
|
|
203
|
+
--panel: #161b22;
|
|
204
|
+
--border: #30363d;
|
|
205
|
+
--text: #c9d1d9;
|
|
206
|
+
--muted: #6b7280;
|
|
207
|
+
--accent: #58a6ff;
|
|
208
|
+
}
|
|
209
|
+
@media (prefers-color-scheme: light) {
|
|
210
|
+
:root {
|
|
211
|
+
--bg: #ffffff;
|
|
212
|
+
--panel: #f6f8fa;
|
|
213
|
+
--border: #d0d7de;
|
|
214
|
+
--text: #1f2328;
|
|
215
|
+
--muted: #57606a;
|
|
216
|
+
--accent: #0969da;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
* { box-sizing: border-box; }
|
|
220
|
+
body {
|
|
221
|
+
margin: 0;
|
|
222
|
+
padding: 24px;
|
|
223
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
224
|
+
background: var(--bg);
|
|
225
|
+
color: var(--text);
|
|
226
|
+
max-width: 880px;
|
|
227
|
+
margin: 0 auto;
|
|
228
|
+
}
|
|
229
|
+
h1, h2 { margin: 0; }
|
|
230
|
+
.muted { color: var(--muted); font-weight: normal; }
|
|
231
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; margin-bottom: 12px; }
|
|
232
|
+
.card h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 10px; font-weight: 600; }
|
|
233
|
+
.card.empty { color: var(--muted); font-size: 13px; padding: 12px 18px; }
|
|
234
|
+
.card code { background: var(--bg); padding: 1px 6px; border-radius: 3px; font-size: 12px; border: 1px solid var(--border); }
|
|
235
|
+
.snapshot { padding: 18px 22px; }
|
|
236
|
+
.snap-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
|
237
|
+
.badge { padding: 4px 10px; border-radius: 4px; color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; }
|
|
238
|
+
.snap-head h1 { font-size: 18px; flex: 1; }
|
|
239
|
+
.snap-meta { font-size: 12px; color: var(--muted); }
|
|
240
|
+
.snap-meta strong { color: var(--text); }
|
|
241
|
+
.blocker { margin-top: 10px; padding: 8px 12px; background: rgba(220, 38, 38, 0.1); border: 1px solid rgba(220, 38, 38, 0.4); border-radius: 6px; color: #ff7b72; font-size: 13px; }
|
|
242
|
+
.blocker strong { color: #f87171; margin-right: 6px; }
|
|
243
|
+
.progress { margin-top: 12px; }
|
|
244
|
+
.bar { height: 8px; background: rgba(110, 118, 129, 0.2); border-radius: 4px; overflow: hidden; margin-bottom: 6px; }
|
|
245
|
+
.fill { height: 100%; }
|
|
246
|
+
.counts { display: flex; gap: 14px; font-size: 12px; align-items: center; }
|
|
247
|
+
.ct.done { color: #10b981; }
|
|
248
|
+
.ct.prog { color: #f59e0b; }
|
|
249
|
+
.ct.blk { color: #dc2626; }
|
|
250
|
+
.ct.pend { color: var(--muted); }
|
|
251
|
+
.ct.tot { margin-left: auto; font-weight: 600; color: var(--text); }
|
|
252
|
+
.tracker { display: flex; flex-direction: column; gap: 4px; }
|
|
253
|
+
.track-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 2px 0; }
|
|
254
|
+
.dot { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 4px; font-size: 12px; color: #fff; flex-shrink: 0; }
|
|
255
|
+
.tid { font-weight: 600; color: var(--muted); width: 60px; flex-shrink: 0; font-size: 12px; }
|
|
256
|
+
.tname { flex: 1; }
|
|
257
|
+
.tdate { color: var(--muted); font-size: 11px; }
|
|
258
|
+
.activity { display: flex; flex-direction: column; gap: 8px; }
|
|
259
|
+
.activity-row { display: flex; flex-direction: column; gap: 2px; }
|
|
260
|
+
.aheading { font-size: 13px; font-weight: 600; }
|
|
261
|
+
.apreview { font-size: 12px; color: var(--muted); }
|
|
262
|
+
.more { font-size: 12px; color: var(--muted); margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
|
263
|
+
.handoff { display: flex; flex-direction: column; gap: 6px; }
|
|
264
|
+
.handoff.ul { padding-left: 20px; }
|
|
265
|
+
.hand-row { display: flex; gap: 8px; font-size: 13px; }
|
|
266
|
+
.hlabel { font-weight: 600; color: var(--muted); min-width: 100px; flex-shrink: 0; font-size: 12px; }
|
|
267
|
+
.hval { flex: 1; }
|
|
268
|
+
ul.handoff li { font-size: 13px; margin-bottom: 4px; }
|
|
269
|
+
.links { display: flex; gap: 12px; padding: 12px 0 4px; flex-wrap: wrap; }
|
|
270
|
+
.link { color: var(--accent); text-decoration: none; font-size: 13px; padding: 8px 14px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
|
|
271
|
+
.link:hover { border-color: var(--accent); }
|
|
272
|
+
.link.primary { font-weight: 600; }
|
|
273
|
+
.link code { background: transparent; border: none; padding: 0; font-size: 12px; }
|
|
274
|
+
.footer { color: var(--muted); font-size: 11px; padding: 12px 0; text-align: center; }
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
function buildHtml(taskName, taskDir, fm, parsed) {
|
|
278
|
+
const hasSvg = existsSync(join(taskDir, 'timeline.svg'));
|
|
279
|
+
return `<!doctype html>
|
|
280
|
+
<html lang="en">
|
|
281
|
+
<head>
|
|
282
|
+
<meta charset="utf-8">
|
|
283
|
+
<title>${escapeHtml(parsed.title || taskName)} — dw task</title>
|
|
284
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
285
|
+
<style>${PAGE_CSS}</style>
|
|
286
|
+
</head>
|
|
287
|
+
<body>
|
|
288
|
+
${buildSnapshotCard(fm, parsed, taskName)}
|
|
289
|
+
${buildTrackerCard(parsed)}
|
|
290
|
+
${buildRecentActivityCard(parsed)}
|
|
291
|
+
${buildHandoffCard(parsed)}
|
|
292
|
+
${buildOpenLinks(taskDir, taskName, hasSvg)}
|
|
293
|
+
<div class="footer">
|
|
294
|
+
<code>dw task view ${escapeHtml(taskName)}</code> — ADR-0008 · lean preview, full details in <code>task.md</code>
|
|
295
|
+
</div>
|
|
296
|
+
</body>
|
|
297
|
+
</html>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function openInBrowser(filePath) {
|
|
301
|
+
const platform = process.platform;
|
|
302
|
+
try {
|
|
303
|
+
if (platform === 'win32') {
|
|
304
|
+
execSync(`start "" "${filePath}"`, { stdio: 'ignore', shell: true });
|
|
305
|
+
} else if (platform === 'darwin') {
|
|
306
|
+
execSync(`open "${filePath}"`, { stdio: 'ignore' });
|
|
307
|
+
} else {
|
|
308
|
+
execSync(`xdg-open "${filePath}"`, { stdio: 'ignore' });
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function viewOne(taskName, taskDir, opts) {
|
|
317
|
+
const rootDir = process.cwd();
|
|
318
|
+
const timelineFile = join(taskDir, 'task.md');
|
|
319
|
+
if (!existsSync(timelineFile)) {
|
|
320
|
+
return { ok: false, reason: 'no-timeline' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const content = readFileSync(timelineFile, 'utf8');
|
|
324
|
+
const fm = parseFrontmatter(content);
|
|
325
|
+
const parsed = parseTimeline(content);
|
|
326
|
+
const html = buildHtml(taskName, taskDir, fm, parsed);
|
|
327
|
+
|
|
328
|
+
const cacheDir = join(rootDir, CACHE_DIR);
|
|
329
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
330
|
+
const outFile = join(cacheDir, `${taskName}.html`);
|
|
331
|
+
writeFileSync(outFile, html, 'utf8');
|
|
332
|
+
|
|
333
|
+
const shouldOpen = opts.open !== false && !opts.noOpen;
|
|
334
|
+
logEvent({
|
|
335
|
+
event: 'task',
|
|
336
|
+
action: 'view.invoke',
|
|
337
|
+
name: taskName,
|
|
338
|
+
opened: shouldOpen,
|
|
339
|
+
}, rootDir);
|
|
340
|
+
|
|
341
|
+
let opened = false;
|
|
342
|
+
if (shouldOpen) {
|
|
343
|
+
opened = openInBrowser(outFile);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { ok: true, outFile, opened };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function taskViewCommand(taskName, opts = {}) {
|
|
350
|
+
const rootDir = process.cwd();
|
|
351
|
+
let targets;
|
|
352
|
+
if (taskName) {
|
|
353
|
+
targets = [{ name: taskName, path: join(rootDir, TASKS_DIR, taskName) }];
|
|
354
|
+
} else {
|
|
355
|
+
const all = findV3Tasks(rootDir);
|
|
356
|
+
if (all.length === 0) {
|
|
357
|
+
console.log(chalk.dim(' No v3 tasks to view.'));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (all.length === 1) {
|
|
361
|
+
targets = all;
|
|
362
|
+
} else {
|
|
363
|
+
all.sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs);
|
|
364
|
+
targets = [all[0]];
|
|
365
|
+
console.log(chalk.dim(` No task specified — picking most recent: ${all[0].name}`));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
console.log();
|
|
370
|
+
for (const t of targets) {
|
|
371
|
+
const r = await viewOne(t.name, t.path, opts);
|
|
372
|
+
if (!r.ok) {
|
|
373
|
+
console.log(chalk.red(` ✗ ${t.name} — ${r.reason}`));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
console.log(chalk.green(` ✓ ${t.name}`));
|
|
377
|
+
console.log(chalk.dim(` HTML: ${r.outFile}`));
|
|
378
|
+
const shouldOpen = opts.open !== false && !opts.noOpen;
|
|
379
|
+
if (r.opened) {
|
|
380
|
+
console.log(chalk.dim(` Browser opened`));
|
|
381
|
+
} else if (shouldOpen) {
|
|
382
|
+
console.log(chalk.yellow(` Could not auto-open browser — open the file manually`));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
console.log();
|
|
386
|
+
}
|