dw-kit 1.4.0 → 1.7.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude/agents/executor.md +80 -80
  2. package/.claude/hooks/pre-commit-gate.sh +59 -0
  3. package/.claude/hooks/stop-check.sh +111 -31
  4. package/.claude/rules/commit-standards.md +48 -37
  5. package/.claude/rules/dw.md +47 -11
  6. package/.claude/skills/dw-commit/SKILL.md +7 -4
  7. package/.claude/skills/dw-decision/SKILL.md +5 -4
  8. package/.claude/skills/dw-execute/SKILL.md +18 -5
  9. package/.claude/skills/dw-handoff/SKILL.md +8 -3
  10. package/.claude/skills/dw-plan/SKILL.md +15 -2
  11. package/.claude/skills/dw-research/SKILL.md +7 -5
  12. package/.claude/skills/dw-retroactive/SKILL.md +75 -63
  13. package/.claude/skills/dw-task-init/SKILL.md +40 -35
  14. package/.dw/adapters/generic/AGENT.md +171 -169
  15. package/.dw/core/WORKFLOW.md +450 -450
  16. package/.dw/core/schemas/agent-claim.schema.json +127 -0
  17. package/.dw/core/schemas/agent-report.schema.json +72 -0
  18. package/.dw/core/schemas/goal-frontmatter.schema.json +84 -0
  19. package/.dw/core/schemas/task-frontmatter.schema.json +97 -0
  20. package/.dw/core/templates/v3/goal.md +146 -0
  21. package/.dw/core/templates/v3/task.md +188 -0
  22. package/CLAUDE.md +2 -2
  23. package/MIGRATION-v1.5.md +330 -0
  24. package/README.md +17 -0
  25. package/package.json +3 -2
  26. package/src/cli.mjs +312 -0
  27. package/src/commands/agent-claim.mjs +235 -0
  28. package/src/commands/agent-inspect.mjs +123 -0
  29. package/src/commands/doctor.mjs +64 -0
  30. package/src/commands/goal-bump.mjs +50 -0
  31. package/src/commands/goal-delete.mjs +120 -0
  32. package/src/commands/goal-link.mjs +126 -0
  33. package/src/commands/goal-lint.mjs +152 -0
  34. package/src/commands/goal-new.mjs +86 -0
  35. package/src/commands/goal-portfolio.mjs +84 -0
  36. package/src/commands/goal-render.mjs +49 -0
  37. package/src/commands/goal-set.mjs +62 -0
  38. package/src/commands/goal-show.mjs +94 -0
  39. package/src/commands/goal-stubs.mjs +21 -0
  40. package/src/commands/goal-suggest-krs.mjs +139 -0
  41. package/src/commands/goal-summary.mjs +67 -0
  42. package/src/commands/goal-view.mjs +196 -0
  43. package/src/commands/lint-task.mjs +112 -0
  44. package/src/commands/task-migrate.mjs +471 -0
  45. package/src/commands/task-new.mjs +90 -0
  46. package/src/commands/task-render.mjs +235 -0
  47. package/src/commands/task-rotate.mjs +168 -0
  48. package/src/commands/task-show.mjs +137 -0
  49. package/src/commands/task-summary.mjs +68 -0
  50. package/src/commands/task-view.mjs +386 -0
  51. package/src/commands/task-watch.mjs +868 -0
  52. package/src/lib/active-index.mjs +19 -1
  53. package/src/lib/agent-claim.mjs +173 -0
  54. package/src/lib/agent-conflict.mjs +137 -0
  55. package/src/lib/agent-events.mjs +43 -0
  56. package/src/lib/agent-report.mjs +96 -0
  57. package/src/lib/frontmatter.mjs +72 -0
  58. package/src/lib/goal-events.mjs +79 -0
  59. package/src/lib/goal-store.mjs +202 -0
  60. package/src/lib/goal-svg.mjs +293 -0
  61. package/src/lib/goal-watch.mjs +133 -0
  62. package/src/lib/lint-rules.mjs +149 -0
  63. package/src/lib/sse-broker.mjs +91 -0
  64. package/src/lib/timeline-parser.mjs +80 -0
  65. package/src/lib/watch-auth.mjs +64 -0
@@ -0,0 +1,202 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter, stringifyFrontmatter } from './frontmatter.mjs';
4
+
5
+ const GOALS_DIR = '.dw/goals';
6
+ const INDEX_FILE = '.dw/goals/goals-index.json';
7
+ const TASKS_DIR = '.dw/tasks';
8
+
9
+ export function goalsDir(rootDir = process.cwd()) {
10
+ return join(rootDir, GOALS_DIR);
11
+ }
12
+
13
+ export function goalDir(goalId, rootDir = process.cwd()) {
14
+ return join(rootDir, GOALS_DIR, goalId);
15
+ }
16
+
17
+ export function goalFile(goalId, rootDir = process.cwd()) {
18
+ return join(rootDir, GOALS_DIR, goalId, 'goal.md');
19
+ }
20
+
21
+ export function goalIndexFile(rootDir = process.cwd()) {
22
+ return join(rootDir, INDEX_FILE);
23
+ }
24
+
25
+ export function readGoalIndex(rootDir = process.cwd()) {
26
+ const file = goalIndexFile(rootDir);
27
+ if (!existsSync(file)) {
28
+ return {
29
+ schema_version: 'goals-index@v1',
30
+ last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
31
+ goals: {},
32
+ };
33
+ }
34
+ try {
35
+ return JSON.parse(readFileSync(file, 'utf8'));
36
+ } catch {
37
+ return { schema_version: 'goals-index@v1', last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), goals: {} };
38
+ }
39
+ }
40
+
41
+ export function writeGoalIndex(index, rootDir = process.cwd()) {
42
+ const file = goalIndexFile(rootDir);
43
+ const dir = dirname(file);
44
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
45
+ const updated = {
46
+ ...index,
47
+ last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
48
+ };
49
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
50
+ }
51
+
52
+ export function readGoal(goalId, rootDir = process.cwd()) {
53
+ const file = goalFile(goalId, rootDir);
54
+ if (!existsSync(file)) return null;
55
+ const content = readFileSync(file, 'utf8');
56
+ const fm = parseFrontmatter(content);
57
+ return { fm, content, file };
58
+ }
59
+
60
+ export function writeGoal(goalId, frontmatter, body, rootDir = process.cwd()) {
61
+ const file = goalFile(goalId, rootDir);
62
+ const dir = dirname(file);
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+ writeFileSync(file, stringifyFrontmatter(frontmatter) + body, 'utf8');
65
+ }
66
+
67
+ export function updateGoalFrontmatter(goalId, mutator, rootDir = process.cwd()) {
68
+ const existing = readGoal(goalId, rootDir);
69
+ if (!existing) throw new Error(`Goal ${goalId} not found`);
70
+ const next = mutator({ ...existing.fm });
71
+ const body = existing.content.replace(/^---\n[\s\S]*?\n---\n?/, '');
72
+ writeGoal(goalId, next, body, rootDir);
73
+ return next;
74
+ }
75
+
76
+ export function listGoalIds(rootDir = process.cwd()) {
77
+ const dir = goalsDir(rootDir);
78
+ if (!existsSync(dir)) return [];
79
+ return readdirSync(dir)
80
+ .filter((entry) => {
81
+ try {
82
+ const path = join(dir, entry);
83
+ return statSync(path).isDirectory() && existsSync(join(path, 'goal.md'));
84
+ } catch { return false; }
85
+ });
86
+ }
87
+
88
+ export function syncIndexEntry(goalId, rootDir = process.cwd()) {
89
+ const goal = readGoal(goalId, rootDir);
90
+ if (!goal) return null;
91
+ const index = readGoalIndex(rootDir);
92
+ const linkedTasks = findLinkedTaskIds(goalId, rootDir);
93
+ const progress = computeGoalProgress(goalId, rootDir, linkedTasks);
94
+ index.goals[goalId] = {
95
+ title: extractTitle(goal.content) || goalId,
96
+ status: goal.fm.status || 'Draft',
97
+ owner: goal.fm.owner || 'unknown',
98
+ icon: goal.fm.icon || '🎯',
99
+ cycle: goal.fm.cycle || null,
100
+ summary: goal.fm.summary || null,
101
+ target_date: goal.fm.target_date || null,
102
+ goal_version: goal.fm.goal_version || 1,
103
+ archived_at: goal.fm.archived_at || null,
104
+ parent_goal_id: goal.fm.parent_goal_id || null,
105
+ linked_task_ids: linkedTasks,
106
+ progress,
107
+ last_updated: goal.fm.last_updated || new Date().toISOString().slice(0, 10),
108
+ };
109
+ writeGoalIndex(index, rootDir);
110
+ return index.goals[goalId];
111
+ }
112
+
113
+ export function computeGoalProgress(goalId, rootDir = process.cwd(), linkedTasksList = null) {
114
+ const linkedTasks = linkedTasksList || findLinkedTaskIds(goalId, rootDir);
115
+ if (linkedTasks.length === 0) return { percent: 0, total: 0, done: 0, in_progress: 0, blocked: 0, pending: 0 };
116
+
117
+ let total = 0;
118
+ let done = 0;
119
+ let inProgress = 0;
120
+ let blocked = 0;
121
+ let pending = 0;
122
+
123
+ for (const taskId of linkedTasks) {
124
+ const taskFile = join(rootDir, TASKS_DIR, taskId, 'task.md');
125
+ if (!existsSync(taskFile)) continue;
126
+ const content = readFileSync(taskFile, 'utf8');
127
+ const tracker = extractTrackerSection(content);
128
+ for (const row of parseTrackerRows(tracker)) {
129
+ total++;
130
+ if (row.status.includes('✅') || /Done/i.test(row.status)) done++;
131
+ else if (row.status.includes('🟡') || /In Progress/i.test(row.status)) inProgress++;
132
+ else if (row.status.includes('🔴') || /Blocked/i.test(row.status)) blocked++;
133
+ else pending++;
134
+ }
135
+ }
136
+
137
+ const percent = total === 0 ? 0 : Math.round((done / total) * 100);
138
+ return { percent, total, done, in_progress: inProgress, blocked, pending };
139
+ }
140
+
141
+ function extractTrackerSection(content) {
142
+ const m = content.match(/##\s+3\.\s+Subtask Tracker[\s\S]*?(?=^##\s+\d|\Z)/m);
143
+ return m ? m[0] : '';
144
+ }
145
+
146
+ function parseTrackerRows(section) {
147
+ const rows = [];
148
+ const lines = section.split('\n');
149
+ for (const line of lines) {
150
+ // Match table rows like: | ST-N | Description | Status | Date | Notes |
151
+ if (!/^\|\s*(ST|WS)-/.test(line)) continue;
152
+ const cells = line.split('|').map((c) => c.trim());
153
+ if (cells.length < 4) continue;
154
+ // cells[0] = "" (before first |), [1] = ID, [2] = subtask, [3] = status
155
+ rows.push({ id: cells[1], subtask: cells[2], status: cells[3] || '', date: cells[4] || '', notes: cells[5] || '' });
156
+ }
157
+ return rows;
158
+ }
159
+
160
+ export function removeIndexEntry(goalId, rootDir = process.cwd()) {
161
+ const index = readGoalIndex(rootDir);
162
+ if (index.goals[goalId]) {
163
+ delete index.goals[goalId];
164
+ writeGoalIndex(index, rootDir);
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ export function findLinkedTaskIds(goalId, rootDir = process.cwd()) {
171
+ const tasksRoot = join(rootDir, TASKS_DIR);
172
+ if (!existsSync(tasksRoot)) return [];
173
+ const linked = [];
174
+ for (const entry of readdirSync(tasksRoot)) {
175
+ if (entry === 'archive' || entry === 'ACTIVE.md') continue;
176
+ const taskFile = join(tasksRoot, entry, 'task.md');
177
+ if (!existsSync(taskFile)) continue;
178
+ try {
179
+ const fm = parseFrontmatter(readFileSync(taskFile, 'utf8'));
180
+ if (fm.parent_goal_id === goalId) linked.push(entry);
181
+ else if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) linked.push(entry);
182
+ } catch { /* skip */ }
183
+ }
184
+ return linked;
185
+ }
186
+
187
+ function extractTitle(content) {
188
+ const m = content.match(/^#\s+Goal:\s+(.+?)\s*$/m) || content.match(/^#\s+(.+?)\s*$/m);
189
+ return m ? m[1].trim() : null;
190
+ }
191
+
192
+ export function todayIso() {
193
+ return new Date().toISOString().slice(0, 10);
194
+ }
195
+
196
+ export function nowUtc() {
197
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
198
+ }
199
+
200
+ export function validateGoalId(goalId) {
201
+ return /^G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?$/.test(goalId);
202
+ }
@@ -0,0 +1,293 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readGoal, findLinkedTaskIds, computeGoalProgress } from './goal-store.mjs';
4
+
5
+ const TASKS_DIR = '.dw/tasks';
6
+
7
+ const STATUS_COLOR = {
8
+ Draft: '#6b7280',
9
+ Active: '#0ea5e9',
10
+ Achieved: '#10b981',
11
+ Abandoned: '#ef4444',
12
+ Pivoted: '#f59e0b',
13
+ Pending: '#6b7280',
14
+ 'In Progress': '#f59e0b',
15
+ Blocked: '#dc2626',
16
+ Done: '#10b981',
17
+ Paused: '#a855f7',
18
+ };
19
+
20
+ function esc(s) {
21
+ if (s == null) return '';
22
+ return String(s)
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+
30
+ function extractKeyResults(content) {
31
+ // Find "## 3. Key Results & Linked Tasks Tracker" → "### Key Results" → table
32
+ const krSectionMatch = content.match(/###\s+Key Results[\s\S]*?(?=^###\s+|^##\s+|\Z)/m);
33
+ if (!krSectionMatch) return [];
34
+ const krBlock = krSectionMatch[0];
35
+ const krs = [];
36
+ for (const line of krBlock.split('\n')) {
37
+ if (!/^\|\s*KR-/.test(line)) continue;
38
+ const cells = line.split('|').map((c) => c.trim());
39
+ if (cells.length < 5) continue;
40
+ krs.push({
41
+ id: cells[1],
42
+ description: cells[2],
43
+ target: cells[3] || '',
44
+ current: cells[4] || '',
45
+ status: cells[5] || '⬜ Pending',
46
+ });
47
+ }
48
+ return krs;
49
+ }
50
+
51
+ function statusFromGlyph(status) {
52
+ if (status.includes('✅') || /Done|Achieved/i.test(status)) return 'Done';
53
+ if (status.includes('🟡') || /In Progress/i.test(status)) return 'In Progress';
54
+ if (status.includes('🔴') || /Blocked/i.test(status)) return 'Blocked';
55
+ if (status.includes('⏸') || /Paused/i.test(status)) return 'Paused';
56
+ return 'Pending';
57
+ }
58
+
59
+ function readTaskInfo(taskId, rootDir) {
60
+ const file = join(rootDir, TASKS_DIR, taskId, 'task.md');
61
+ if (!existsSync(file)) return { id: taskId, status: 'Unknown', subtaskCount: 0, doneCount: 0 };
62
+ const content = readFileSync(file, 'utf8');
63
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
64
+ let status = 'Draft';
65
+ if (fmMatch) {
66
+ const statusLine = fmMatch[1].match(/^status:\s*(.+?)$/m);
67
+ if (statusLine) status = statusLine[1].trim();
68
+ }
69
+ // Count subtasks in Section 3
70
+ const trackerMatch = content.match(/##\s+3\.\s+Subtask Tracker[\s\S]*?(?=^##\s+\d|\Z)/m);
71
+ let subtaskCount = 0;
72
+ let doneCount = 0;
73
+ if (trackerMatch) {
74
+ for (const line of trackerMatch[0].split('\n')) {
75
+ if (!/^\|\s*(ST|WS)-/.test(line)) continue;
76
+ subtaskCount++;
77
+ if (line.includes('✅')) doneCount++;
78
+ }
79
+ }
80
+ return { id: taskId, status, subtaskCount, doneCount };
81
+ }
82
+
83
+ /**
84
+ * Render goal as constellation SVG.
85
+ * - Center: goal node (large) with icon + ID + progress arc
86
+ * - Inner orbit: Key Results (parsed from Section 3 table)
87
+ * - Outer orbit per KR: linked tasks
88
+ *
89
+ * Pure SVG string — no external deps.
90
+ *
91
+ * @param {string} goalId
92
+ * @param {string} rootDir
93
+ * @returns {string} SVG markup
94
+ */
95
+ export function renderGoalSvg(goalId, rootDir = process.cwd()) {
96
+ const goal = readGoal(goalId, rootDir);
97
+ if (!goal) {
98
+ return errorSvg(`Goal ${goalId} not found`);
99
+ }
100
+
101
+ const linkedTasks = findLinkedTaskIds(goalId, rootDir);
102
+ const taskInfos = linkedTasks.map((t) => readTaskInfo(t, rootDir));
103
+ const krs = extractKeyResults(goal.content);
104
+ const progress = computeGoalProgress(goalId, rootDir, linkedTasks);
105
+
106
+ const W = 880;
107
+ const H = 600;
108
+ const cx = W / 2;
109
+ const cy = H / 2;
110
+ const goalRadius = 70;
111
+ const krOrbit = 180;
112
+ const taskOrbit = 80; // from each KR
113
+
114
+ const goalColor = STATUS_COLOR[goal.fm.status] || STATUS_COLOR.Draft;
115
+ const icon = goal.fm.icon || '🎯';
116
+
117
+ // KR satellite positions on circle around center
118
+ const krCount = Math.max(krs.length, 1);
119
+ const krNodes = krs.map((kr, i) => {
120
+ const angle = (Math.PI * 2 * i) / krCount - Math.PI / 2;
121
+ return {
122
+ ...kr,
123
+ x: cx + Math.cos(angle) * krOrbit,
124
+ y: cy + Math.sin(angle) * krOrbit,
125
+ angle,
126
+ status_canonical: statusFromGlyph(kr.status),
127
+ };
128
+ });
129
+
130
+ // Task leaves distributed evenly around their nearest KR (or directly around goal if no KRs)
131
+ const taskNodes = [];
132
+ if (krs.length === 0 && taskInfos.length > 0) {
133
+ // Tasks orbit goal directly when no KRs defined
134
+ const tCount = taskInfos.length;
135
+ taskInfos.forEach((t, i) => {
136
+ const angle = (Math.PI * 2 * i) / tCount - Math.PI / 2;
137
+ taskNodes.push({
138
+ ...t,
139
+ parentX: cx,
140
+ parentY: cy,
141
+ x: cx + Math.cos(angle) * (krOrbit + 30),
142
+ y: cy + Math.sin(angle) * (krOrbit + 30),
143
+ });
144
+ });
145
+ } else if (krs.length > 0 && taskInfos.length > 0) {
146
+ // Distribute tasks around KR satellites (round-robin)
147
+ taskInfos.forEach((t, i) => {
148
+ const krIdx = i % krNodes.length;
149
+ const kr = krNodes[krIdx];
150
+ const tasksOnThisKr = taskInfos.filter((_, j) => j % krNodes.length === krIdx).length;
151
+ const localIdx = Math.floor(i / krNodes.length);
152
+ const tAngle = kr.angle + ((localIdx + 1) * (Math.PI / 4) - Math.PI / 8);
153
+ taskNodes.push({
154
+ ...t,
155
+ parentX: kr.x,
156
+ parentY: kr.y,
157
+ x: kr.x + Math.cos(tAngle) * taskOrbit,
158
+ y: kr.y + Math.sin(tAngle) * taskOrbit,
159
+ });
160
+ });
161
+ }
162
+
163
+ // Progress arc around goal center
164
+ const arcRadius = goalRadius + 8;
165
+ const progressArc = renderProgressArc(cx, cy, arcRadius, progress.percent, goalColor);
166
+
167
+ return `<?xml version="1.0" encoding="UTF-8"?>
168
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="system-ui, -apple-system, sans-serif">
169
+ <defs>
170
+ <radialGradient id="goalGlow" cx="50%" cy="50%" r="50%">
171
+ <stop offset="0%" stop-color="${goalColor}" stop-opacity="0.30"/>
172
+ <stop offset="100%" stop-color="${goalColor}" stop-opacity="0"/>
173
+ </radialGradient>
174
+ <filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
175
+ <feGaussianBlur stdDeviation="3" result="b"/>
176
+ <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
177
+ </filter>
178
+ </defs>
179
+
180
+ <rect width="${W}" height="${H}" fill="#0d1117"/>
181
+
182
+ <!-- background dot grid for constellation feel -->
183
+ ${renderDots(W, H)}
184
+
185
+ <!-- glow around goal center -->
186
+ <circle cx="${cx}" cy="${cy}" r="${goalRadius + 60}" fill="url(#goalGlow)"/>
187
+
188
+ <!-- connection lines: goal → KRs -->
189
+ ${krNodes.map((kr) => `<line x1="${cx}" y1="${cy}" x2="${kr.x}" y2="${kr.y}" stroke="${STATUS_COLOR[kr.status_canonical] || '#6b7280'}" stroke-opacity="0.4" stroke-width="1.5" stroke-dasharray="${kr.status_canonical === 'Done' ? '0' : '4 4'}"/>`).join('\n ')}
190
+
191
+ <!-- connection lines: KR/goal → tasks -->
192
+ ${taskNodes.map((t) => `<line x1="${t.parentX}" y1="${t.parentY}" x2="${t.x}" y2="${t.y}" stroke="${STATUS_COLOR[t.status] || '#6b7280'}" stroke-opacity="0.25" stroke-width="1"/>`).join('\n ')}
193
+
194
+ <!-- task leaf nodes -->
195
+ ${taskNodes.map((t) => renderTaskLeaf(t)).join('\n ')}
196
+
197
+ <!-- KR satellite nodes -->
198
+ ${krNodes.map((kr) => renderKrSatellite(kr)).join('\n ')}
199
+
200
+ <!-- goal center node -->
201
+ ${progressArc}
202
+ <circle cx="${cx}" cy="${cy}" r="${goalRadius}" fill="#161b22" stroke="${goalColor}" stroke-width="2.5" filter="url(#softGlow)"/>
203
+ <text x="${cx}" y="${cy - 8}" text-anchor="middle" font-size="36" dy="0.35em">${esc(icon)}</text>
204
+ <text x="${cx}" y="${cy + 22}" text-anchor="middle" font-size="11" font-weight="700" fill="#c9d1d9" font-family="ui-monospace, monospace">${esc(goalId)}</text>
205
+ <text x="${cx}" y="${cy + 38}" text-anchor="middle" font-size="9" fill="${goalColor}" font-weight="600" letter-spacing="0.5">${esc(goal.fm.status || 'Draft').toUpperCase()}</text>
206
+
207
+ <!-- title bar -->
208
+ <text x="32" y="36" font-size="18" font-weight="700" fill="#c9d1d9">${esc(icon)} ${esc(goalId)}</text>
209
+ <text x="32" y="58" font-size="12" fill="#8b949e">${esc(extractTitle(goal.content) || '')}</text>
210
+ ${goal.fm.cycle ? `<text x="32" y="76" font-size="10" fill="#58a6ff" letter-spacing="1">${esc(goal.fm.cycle).toUpperCase()}</text>` : ''}
211
+
212
+ <!-- stats bottom-right -->
213
+ <g transform="translate(${W - 32}, ${H - 60})">
214
+ <text text-anchor="end" font-size="11" fill="#8b949e">${krNodes.length} KR · ${taskNodes.length} tasks</text>
215
+ <text text-anchor="end" y="16" font-size="11" fill="#8b949e">${progress.percent}% (${progress.done}/${progress.total} subtasks)</text>
216
+ <text text-anchor="end" y="32" font-size="10" fill="#6b7280">v${goal.fm.goal_version || 1} · ${esc(goal.fm.target_date || 'TBD')}</text>
217
+ </g>
218
+
219
+ <!-- legend -->
220
+ <g transform="translate(32, ${H - 76})" font-size="9" fill="#8b949e">
221
+ ${renderLegendItem(0, 'Done', STATUS_COLOR.Done)}
222
+ ${renderLegendItem(58, 'In Progress', STATUS_COLOR['In Progress'])}
223
+ ${renderLegendItem(140, 'Blocked', STATUS_COLOR.Blocked)}
224
+ ${renderLegendItem(202, 'Pending', STATUS_COLOR.Pending)}
225
+ </g>
226
+ </svg>`;
227
+ }
228
+
229
+ function renderDots(W, H) {
230
+ const dots = [];
231
+ for (let x = 20; x < W; x += 40) {
232
+ for (let y = 20; y < H; y += 40) {
233
+ dots.push(`<circle cx="${x}" cy="${y}" r="0.6" fill="#30363d" opacity="0.5"/>`);
234
+ }
235
+ }
236
+ return dots.join('');
237
+ }
238
+
239
+ function renderProgressArc(cx, cy, r, percent, color) {
240
+ if (percent <= 0) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.15" stroke-width="3"/>`;
241
+ if (percent >= 100) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="3"/>`;
242
+ const angle = (percent / 100) * Math.PI * 2;
243
+ const startX = cx;
244
+ const startY = cy - r;
245
+ const endX = cx + Math.sin(angle) * r;
246
+ const endY = cy - Math.cos(angle) * r;
247
+ const large = angle > Math.PI ? 1 : 0;
248
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.15" stroke-width="3"/>
249
+ <path d="M ${startX} ${startY} A ${r} ${r} 0 ${large} 1 ${endX} ${endY}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round"/>`;
250
+ }
251
+
252
+ function renderKrSatellite(kr) {
253
+ const color = STATUS_COLOR[kr.status_canonical] || STATUS_COLOR.Pending;
254
+ return `<g>
255
+ <circle cx="${kr.x}" cy="${kr.y}" r="22" fill="#161b22" stroke="${color}" stroke-width="2"/>
256
+ <text x="${kr.x}" y="${kr.y}" text-anchor="middle" dy="0.35em" font-size="10" font-weight="700" font-family="ui-monospace, monospace" fill="${color}">${esc(kr.id)}</text>
257
+ <title>${esc(kr.id)}: ${esc(kr.description)} (target: ${esc(kr.target)}, current: ${esc(kr.current)})</title>
258
+ </g>`;
259
+ }
260
+
261
+ function renderTaskLeaf(t) {
262
+ const color = STATUS_COLOR[t.status] || STATUS_COLOR.Draft;
263
+ const r = 10;
264
+ // Show task name truncated below
265
+ return `<g>
266
+ <circle cx="${t.x}" cy="${t.y}" r="${r}" fill="${color}" fill-opacity="0.25" stroke="${color}" stroke-width="1.5"/>
267
+ <text x="${t.x}" y="${t.y}" text-anchor="middle" dy="0.35em" font-size="8" font-weight="700" fill="${color}">${t.doneCount}/${t.subtaskCount}</text>
268
+ <text x="${t.x}" y="${t.y + r + 12}" text-anchor="middle" font-size="9" fill="#8b949e">${esc(truncate(t.id, 18))}</text>
269
+ <title>${esc(t.id)} — ${esc(t.status)} (${t.doneCount}/${t.subtaskCount} subtasks done)</title>
270
+ </g>`;
271
+ }
272
+
273
+ function renderLegendItem(x, label, color) {
274
+ return `<g transform="translate(${x}, 0)"><circle cx="4" cy="4" r="4" fill="${color}"/><text x="12" y="7">${esc(label)}</text></g>`;
275
+ }
276
+
277
+ function truncate(s, n) {
278
+ if (!s) return '';
279
+ return s.length <= n ? s : s.slice(0, n - 1) + '…';
280
+ }
281
+
282
+ function extractTitle(content) {
283
+ const m = content.match(/^#\s+Goal:\s+(.+?)\s*$/m) || content.match(/^#\s+(.+?)\s*$/m);
284
+ return m ? m[1].trim() : null;
285
+ }
286
+
287
+ function errorSvg(msg) {
288
+ return `<?xml version="1.0" encoding="UTF-8"?>
289
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 200" width="600" height="200">
290
+ <rect width="600" height="200" fill="#0d1117"/>
291
+ <text x="300" y="100" text-anchor="middle" dy="0.35em" font-size="14" fill="#ef4444" font-family="system-ui">${esc(msg)}</text>
292
+ </svg>`;
293
+ }
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync, statSync, watch as fsWatch } from 'node:fs';
2
+ import { eventsGlobalFile } from './goal-events.mjs';
3
+
4
+ /**
5
+ * Subscribe to .dw/events-global.jsonl for goal events.
6
+ *
7
+ * Tier 1 vendor-neutral primitive per ADR-0010 W-6: this is the ONLY mechanism
8
+ * non-Claude agents (Codex, Gemini, CI jobs, cron) should use. Tier 2 SSE is
9
+ * browser-UI-only.
10
+ *
11
+ * Usage:
12
+ * import { watchGoalEvents } from 'dw-kit/lib/goal-watch.mjs';
13
+ *
14
+ * const unsubscribe = watchGoalEvents((event) => {
15
+ * if (event.event === 'goal_status_changed' && event.goal_id === myGoal) {
16
+ * // re-plan, re-read goal.md, etc.
17
+ * }
18
+ * }, { rootDir: process.cwd(), backfill: 0 });
19
+ *
20
+ * // later: unsubscribe();
21
+ *
22
+ * @param {(event: object) => void} handler - called for each new event line
23
+ * @param {object} [opts]
24
+ * @param {string} [opts.rootDir=process.cwd()]
25
+ * @param {number} [opts.backfill=0] - number of past events to replay on subscribe
26
+ * (0 = only new events from this moment forward)
27
+ * @param {(string) => boolean} [opts.filter] - optional event-type or goal-id filter
28
+ * receives the parsed event; return true to deliver
29
+ * @returns {() => void} unsubscribe function
30
+ */
31
+ export function watchGoalEvents(handler, opts = {}) {
32
+ const rootDir = opts.rootDir || process.cwd();
33
+ const backfill = opts.backfill || 0;
34
+ const filter = typeof opts.filter === 'function' ? opts.filter : null;
35
+ const file = eventsGlobalFile(rootDir);
36
+
37
+ let lastBytes = 0;
38
+ let watcher = null;
39
+ let closed = false;
40
+
41
+ function emit(event) {
42
+ if (closed) return;
43
+ if (filter && !filter(event)) return;
44
+ try { handler(event); } catch (e) { /* user handler error; don't break the loop */ }
45
+ }
46
+
47
+ function readNewLines() {
48
+ if (!existsSync(file)) return;
49
+ let content;
50
+ try { content = readFileSync(file, 'utf8'); } catch { return; }
51
+ if (content.length <= lastBytes) {
52
+ lastBytes = content.length;
53
+ return;
54
+ }
55
+ const newSegment = content.slice(lastBytes);
56
+ lastBytes = content.length;
57
+ const lines = newSegment.split('\n').filter(Boolean);
58
+ for (const line of lines) {
59
+ try { emit(JSON.parse(line)); } catch { /* malformed line; skip */ }
60
+ }
61
+ }
62
+
63
+ // Initial backfill
64
+ if (existsSync(file)) {
65
+ try {
66
+ const initial = readFileSync(file, 'utf8');
67
+ const allLines = initial.split('\n').filter(Boolean);
68
+ if (backfill > 0) {
69
+ const slice = allLines.slice(-backfill);
70
+ for (const line of slice) {
71
+ try { emit(JSON.parse(line)); } catch { /* skip */ }
72
+ }
73
+ }
74
+ lastBytes = initial.length;
75
+ } catch {
76
+ lastBytes = 0;
77
+ }
78
+ }
79
+
80
+ try {
81
+ watcher = fsWatch(file, { persistent: true }, () => readNewLines());
82
+ } catch {
83
+ // fs.watch unsupported on some platforms (rare); fall back to polling
84
+ const pollInterval = setInterval(readNewLines, 1000);
85
+ return () => { closed = true; clearInterval(pollInterval); };
86
+ }
87
+
88
+ return () => {
89
+ closed = true;
90
+ if (watcher) {
91
+ try { watcher.close(); } catch { /* ignore */ }
92
+ watcher = null;
93
+ }
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Convenience: subscribe only to events for a specific goal_id.
99
+ */
100
+ export function watchGoal(goalId, handler, opts = {}) {
101
+ return watchGoalEvents(handler, {
102
+ ...opts,
103
+ filter: (ev) => ev.goal_id === goalId && (!opts.filter || opts.filter(ev)),
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Convenience: get an async iterator over goal events. Use with `for await`.
109
+ *
110
+ * Example:
111
+ * for await (const event of iterateGoalEvents({ goalId: 'G-001' })) {
112
+ * if (event.event === 'goal_status_changed') break;
113
+ * }
114
+ */
115
+ export async function* iterateGoalEvents(opts = {}) {
116
+ const queue = [];
117
+ let resolveNext = null;
118
+
119
+ const unsubscribe = watchGoalEvents((ev) => {
120
+ if (opts.goalId && ev.goal_id !== opts.goalId) return;
121
+ queue.push(ev);
122
+ if (resolveNext) { resolveNext(); resolveNext = null; }
123
+ }, opts);
124
+
125
+ try {
126
+ while (true) {
127
+ while (queue.length > 0) yield queue.shift();
128
+ await new Promise((r) => { resolveNext = r; });
129
+ }
130
+ } finally {
131
+ unsubscribe();
132
+ }
133
+ }