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,868 @@
1
+ import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, watch as fsWatch } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createServer } from 'node:http';
4
+ import { execSync } from 'node:child_process';
5
+ import chalk from 'chalk';
6
+ import { taskViewCommand } from './task-view.mjs';
7
+ import { renderGoalPortfolioHtml } from './goal-view.mjs';
8
+ import { readGoalIndex, listGoalIds, syncIndexEntry, readGoal, updateGoalFrontmatter, findLinkedTaskIds, computeGoalProgress, todayIso, nowUtc } from '../lib/goal-store.mjs';
9
+ import { ensureToken, isAuthorized, isWriteEndpoint } from '../lib/watch-auth.mjs';
10
+ import { createSseBroker } from '../lib/sse-broker.mjs';
11
+ import { logGoalEvent } from '../lib/goal-events.mjs';
12
+ import { renderGoalSvg } from '../lib/goal-svg.mjs';
13
+ import { createClaim, persistClaim, loadClaim, transitionClaim, nowIsoUtc } from '../lib/agent-claim.mjs';
14
+ import { logAgentEvent } from '../lib/agent-events.mjs';
15
+ import { logEvent } from '../lib/telemetry.mjs';
16
+
17
+ const VALID_STATUSES = new Set(['Draft', 'Active', 'Achieved', 'Abandoned', 'Pivoted']);
18
+ const MAX_BODY_BYTES = 16 * 1024;
19
+ const MAX_SUMMARY_CHARS = 1000;
20
+ const SETTABLE_FIELDS = new Set(['icon', 'cycle', 'owner', 'target_date', 'parent_goal_id']);
21
+ const FIELD_MAX = { icon: 8, cycle: 32, owner: 64, target_date: 16, parent_goal_id: 40 };
22
+
23
+ function readJsonBody(req) {
24
+ return new Promise((resolve, reject) => {
25
+ let buf = '';
26
+ let aborted = false;
27
+ req.on('data', (chunk) => {
28
+ if (aborted) return;
29
+ buf += chunk;
30
+ if (buf.length > MAX_BODY_BYTES) {
31
+ aborted = true;
32
+ reject(new Error('body too large'));
33
+ }
34
+ });
35
+ req.on('end', () => {
36
+ if (aborted) return;
37
+ try { resolve(buf ? JSON.parse(buf) : {}); }
38
+ catch (e) { reject(new Error('invalid JSON: ' + e.message)); }
39
+ });
40
+ req.on('error', reject);
41
+ });
42
+ }
43
+
44
+ function sendJson(res, status, body) {
45
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
46
+ res.end(JSON.stringify(body));
47
+ }
48
+
49
+ // ── Implicit Agent OS claim for browser editor (S-4) ───────────────────────
50
+ // Server-side map: goalId → claim_id. One implicit human claim per goal
51
+ // (single-editor assumption; multiple browser tabs editing same goal share
52
+ // the same claim and refresh it via /keepalive). Map dies on server restart;
53
+ // stale claims expire naturally via 5min lease.
54
+ const browserClaims = new Map();
55
+ const BROWSER_CLAIM_LEASE_SECONDS = 5 * 60; // 5 minutes (S-4 spec)
56
+
57
+ function goalToTaskId(goalId) {
58
+ return ('goal-' + goalId).toLowerCase();
59
+ }
60
+
61
+ function ensureBrowserClaim(goalId, rootDir) {
62
+ const existing = browserClaims.get(goalId);
63
+ if (existing) {
64
+ try {
65
+ const claim = loadClaim(existing, rootDir);
66
+ if (claim && claim.status !== 'released' && claim.status !== 'expired' && claim.status !== 'invalidated') {
67
+ return existing;
68
+ }
69
+ } catch { /* fall through to create new */ }
70
+ browserClaims.delete(goalId);
71
+ }
72
+ const claim = createClaim({
73
+ taskId: goalToTaskId(goalId),
74
+ agent: { id: 'browser', vendor: 'human', role: 'worker' },
75
+ subtasks: ['ST-0'],
76
+ writeScope: [`.dw/goals/${goalId}/goal.md`],
77
+ readScope: [`.dw/goals/${goalId}/`],
78
+ leaseSeconds: BROWSER_CLAIM_LEASE_SECONDS,
79
+ }, rootDir);
80
+ persistClaim(claim, rootDir);
81
+ browserClaims.set(goalId, claim.claim_id);
82
+ logAgentEvent(goalToTaskId(goalId), {
83
+ event: 'claim_created',
84
+ claim_id: claim.claim_id,
85
+ agent: claim.agent,
86
+ subtasks: claim.subtasks,
87
+ write_scope: claim.write_scope,
88
+ via: 'browser_implicit',
89
+ }, rootDir);
90
+ return claim.claim_id;
91
+ }
92
+
93
+ function refreshBrowserClaim(goalId, rootDir) {
94
+ const claimId = browserClaims.get(goalId);
95
+ if (!claimId) return null;
96
+ const claim = loadClaim(claimId, rootDir);
97
+ if (!claim) { browserClaims.delete(goalId); return null; }
98
+ if (claim.status === 'released' || claim.status === 'expired' || claim.status === 'invalidated') {
99
+ browserClaims.delete(goalId);
100
+ return null;
101
+ }
102
+ // Extend lease: update lease_expires + lease_duration_seconds in-place
103
+ const newExpiry = new Date(Date.now() + BROWSER_CLAIM_LEASE_SECONDS * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
104
+ claim.lease_expires = newExpiry;
105
+ claim.lease_duration_seconds = BROWSER_CLAIM_LEASE_SECONDS;
106
+ const file = join(rootDir, '.dw/cache/agents/claims', `${claim.claim_id}.json`);
107
+ writeFileSync(file, JSON.stringify(claim, null, 2) + '\n', 'utf8');
108
+ return claim.claim_id;
109
+ }
110
+
111
+ function releaseBrowserClaim(goalId, rootDir, reason = 'browser closed') {
112
+ const claimId = browserClaims.get(goalId);
113
+ if (!claimId) return null;
114
+ try {
115
+ transitionClaim(claimId, 'released', { reason }, rootDir);
116
+ logAgentEvent(goalToTaskId(goalId), {
117
+ event: 'claim_released',
118
+ claim_id: claimId,
119
+ reason,
120
+ via: 'browser_implicit',
121
+ }, rootDir);
122
+ } catch { /* claim already gone */ }
123
+ browserClaims.delete(goalId);
124
+ return claimId;
125
+ }
126
+
127
+ function escHtml(s) {
128
+ if (s == null) return '';
129
+ return String(s)
130
+ .replace(/&/g, '&')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;')
133
+ .replace(/"/g, '&quot;')
134
+ .replace(/'/g, '&#39;');
135
+ }
136
+
137
+ function renderGoalEditorHtml(goalId, goal, token, claimId = null) {
138
+ const fm = goal.fm || {};
139
+ const statusOptions = ['Draft', 'Active', 'Achieved', 'Abandoned', 'Pivoted']
140
+ .map((s) => `<option value="${s}"${s === fm.status ? ' selected' : ''}>${s}</option>`)
141
+ .join('');
142
+ const icon = fm.icon || '🎯';
143
+ const cycle = fm.cycle || '';
144
+
145
+ return `<!doctype html>
146
+ <html><head>
147
+ <meta charset="utf-8">
148
+ <title>edit ${escHtml(goalId)} — dw goal</title>
149
+ <style>
150
+ * { box-sizing: border-box; margin: 0; padding: 0; }
151
+ body { font: 14px/1.6 -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; padding: 32px; max-width: 760px; margin: 0 auto; }
152
+ @media (prefers-color-scheme: light) { body { background: #fff; color: #1f2328; } }
153
+ nav { margin-bottom: 24px; display: flex; gap: 16px; justify-content: space-between; align-items: center; }
154
+ nav a { color: #58a6ff; text-decoration: none; font-size: 13px; }
155
+ nav a:hover { text-decoration: underline; }
156
+ h1 { font-size: 22px; margin-bottom: 8px; font-family: ui-monospace, monospace; display: flex; align-items: center; gap: 12px; }
157
+ h1 .icon-display { font-size: 28px; line-height: 1; }
158
+ .meta { color: #6b7280; font-size: 12px; margin-bottom: 24px; }
159
+ label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; color: #8b949e; }
160
+ @media (prefers-color-scheme: light) { label { color: #57606a; } }
161
+ .field { margin-bottom: 24px; }
162
+ .row { display: grid; grid-template-columns: 100px 1fr; gap: 16px; margin-bottom: 24px; }
163
+ input, textarea, select { width: 100%; padding: 10px 12px; background: #161b22; color: inherit; border: 1px solid #30363d; border-radius: 6px; font-family: inherit; font-size: 14px; }
164
+ @media (prefers-color-scheme: light) { input, textarea, select { background: #f6f8fa; border-color: #d0d7de; } }
165
+ input:focus, textarea:focus, select:focus { outline: none; border-color: #58a6ff; }
166
+ textarea { min-height: 120px; resize: vertical; }
167
+ #icon { text-align: center; font-size: 22px; padding: 4px; }
168
+ .counter { font-size: 11px; color: #6b7280; text-align: right; margin-top: 4px; }
169
+ .counter.over { color: #ef4444; }
170
+ .save-status { position: fixed; top: 12px; right: 16px; padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; z-index: 100; }
171
+ .save-status.saved { background: rgba(16,185,129,0.18); color: #10b981; }
172
+ .save-status.saving { background: rgba(245,158,11,0.18); color: #f59e0b; }
173
+ .save-status.error { background: rgba(239,68,68,0.18); color: #ef4444; }
174
+ .save-status.idle { background: rgba(107,114,128,0.18); color: #9ca3af; }
175
+ .notice { padding: 12px; background: rgba(99,102,241,0.12); color: #818cf8; border-radius: 6px; font-size: 12px; margin-bottom: 24px; }
176
+ .progress-block { padding: 14px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 24px; }
177
+ @media (prefers-color-scheme: light) { .progress-block { background: #f6f8fa; border-color: #d0d7de; } }
178
+ .progress-bar-outer { height: 10px; background: rgba(107,114,128,0.25); border-radius: 5px; overflow: hidden; margin: 8px 0; }
179
+ .progress-bar-inner { height: 100%; background: #0ea5e9; transition: width 0.3s; }
180
+ .progress-stats { display: flex; gap: 14px; font-size: 12px; color: #8b949e; flex-wrap: wrap; }
181
+ .progress-stats span strong { color: #c9d1d9; font-weight: 600; }
182
+ </style>
183
+ </head><body>
184
+ <div class="save-status idle" id="save-status">idle</div>
185
+ <nav>
186
+ <a href="/goals/${escHtml(goalId)}">← view</a>
187
+ <a href="/goals">portfolio</a>
188
+ </nav>
189
+ <h1><span class="icon-display" id="icon-display">${escHtml(icon)}</span> ${escHtml(goalId)}</h1>
190
+ <div class="meta">v${escHtml(fm.goal_version || 1)} · owner: ${escHtml(fm.owner || '?')} · target: ${escHtml(fm.target_date || 'TBD')} · last updated: ${escHtml(fm.last_updated || '?')}</div>
191
+
192
+ <div class="progress-block" id="progress-block">
193
+ <label>Aggregated progress (from linked tasks)</label>
194
+ <div class="progress-bar-outer"><div class="progress-bar-inner" id="progress-fill" style="width:0%"></div></div>
195
+ <div class="progress-stats" id="progress-stats">computing…</div>
196
+ </div>
197
+
198
+ <div class="notice">
199
+ Icon + cycle + summary: auto-save 800ms debounce (no version bump) · Status: immediate save + auto-bumps goal_version + emits goal_status_changed
200
+ </div>
201
+
202
+ <div class="row">
203
+ <div>
204
+ <label for="icon">Icon</label>
205
+ <input id="icon" type="text" maxlength="8" value="${escHtml(icon)}">
206
+ </div>
207
+ <div>
208
+ <label for="cycle">Cycle (optional)</label>
209
+ <input id="cycle" type="text" maxlength="32" placeholder="e.g. Q2 2026 · v1.7-cycle · 2026 H1" value="${escHtml(cycle)}">
210
+ </div>
211
+ </div>
212
+
213
+ <div class="field">
214
+ <label for="status">Status</label>
215
+ <select id="status">${statusOptions}</select>
216
+ </div>
217
+
218
+ <div class="field">
219
+ <label for="summary">Summary (≤1000 chars)</label>
220
+ <textarea id="summary" maxlength="1000" placeholder="Short-form for portfolio cards + agent reaction. Mirrored to goals-index.json on save.">${escHtml(fm.summary || '')}</textarea>
221
+ <div class="counter" id="counter">${(fm.summary || '').length} / 1000</div>
222
+ </div>
223
+
224
+ <script>
225
+ (function () {
226
+ var TOKEN = ${JSON.stringify(token)};
227
+ var GOAL_ID = ${JSON.stringify(goalId)};
228
+ var CLAIM_ID = ${JSON.stringify(claimId || '')};
229
+ var DEBOUNCE_MS = 800;
230
+ var KEEPALIVE_MS = 2 * 60 * 1000; // S-4: 2 minutes (lease is 5 min, so 60% safety window)
231
+
232
+ var summaryEl = document.getElementById('summary');
233
+ var statusEl = document.getElementById('status');
234
+ var iconEl = document.getElementById('icon');
235
+ var iconDisplay = document.getElementById('icon-display');
236
+ var cycleEl = document.getElementById('cycle');
237
+ var counterEl = document.getElementById('counter');
238
+ var statusBadge = document.getElementById('save-status');
239
+ var progressFill = document.getElementById('progress-fill');
240
+ var progressStats = document.getElementById('progress-stats');
241
+ var summaryTimer = null;
242
+ var iconTimer = null;
243
+ var cycleTimer = null;
244
+ var inflight = false;
245
+
246
+ function setBadge(state, text) {
247
+ statusBadge.className = 'save-status ' + state;
248
+ statusBadge.textContent = text;
249
+ }
250
+
251
+ function updateCounter() {
252
+ var len = summaryEl.value.length;
253
+ counterEl.textContent = len + ' / 1000';
254
+ counterEl.classList.toggle('over', len > 1000);
255
+ }
256
+
257
+ function commit(field, body) {
258
+ if (inflight) return;
259
+ inflight = true;
260
+ setBadge('saving', 'saving…');
261
+ fetch('/goals/' + GOAL_ID + '/' + field, {
262
+ method: 'POST',
263
+ headers: {
264
+ 'Content-Type': 'application/json',
265
+ 'X-Watch-Token': TOKEN,
266
+ },
267
+ body: JSON.stringify(body),
268
+ }).then(function (r) {
269
+ if (!r.ok) return r.text().then(function (t) { throw new Error(r.status + ': ' + t); });
270
+ return r.json();
271
+ }).then(function () {
272
+ ownCommitAt = Date.now();
273
+ setBadge('saved', 'saved');
274
+ }).catch(function (e) {
275
+ setBadge('error', 'error');
276
+ console.error(e);
277
+ }).finally(function () {
278
+ inflight = false;
279
+ });
280
+ }
281
+
282
+ summaryEl.addEventListener('input', function () {
283
+ updateCounter();
284
+ if (summaryTimer) clearTimeout(summaryTimer);
285
+ summaryTimer = setTimeout(function () {
286
+ commit('summary', { summary: summaryEl.value });
287
+ }, DEBOUNCE_MS);
288
+ });
289
+
290
+ iconEl.addEventListener('input', function () {
291
+ iconDisplay.textContent = iconEl.value || '🎯';
292
+ if (iconTimer) clearTimeout(iconTimer);
293
+ iconTimer = setTimeout(function () {
294
+ commit('field', { field: 'icon', value: iconEl.value || null });
295
+ }, DEBOUNCE_MS);
296
+ });
297
+
298
+ cycleEl.addEventListener('input', function () {
299
+ if (cycleTimer) clearTimeout(cycleTimer);
300
+ cycleTimer = setTimeout(function () {
301
+ commit('field', { field: 'cycle', value: cycleEl.value || null });
302
+ }, DEBOUNCE_MS);
303
+ });
304
+
305
+ statusEl.addEventListener('change', function () {
306
+ commit('status', { status: statusEl.value });
307
+ });
308
+
309
+ // Fetch progress on load
310
+ function refreshProgress() {
311
+ fetch('/goals/' + GOAL_ID + '/progress', { headers: { 'X-Watch-Token': TOKEN } })
312
+ .then(function (r) { return r.json(); })
313
+ .then(function (p) {
314
+ progressFill.style.width = p.percent + '%';
315
+ if (p.total === 0) {
316
+ progressStats.textContent = '(no linked tasks yet — use: dw goal link ' + GOAL_ID + ' <task-id>)';
317
+ } else {
318
+ progressStats.innerHTML =
319
+ '<span><strong>' + p.percent + '%</strong> overall</span>' +
320
+ '<span><strong>' + p.done + '</strong> done</span>' +
321
+ '<span><strong>' + p.in_progress + '</strong> in progress</span>' +
322
+ '<span><strong>' + p.blocked + '</strong> blocked</span>' +
323
+ '<span><strong>' + p.pending + '</strong> pending</span>' +
324
+ '<span style="margin-left:auto;opacity:0.6">of ' + p.total + ' subtasks</span>';
325
+ }
326
+ })
327
+ .catch(function () { progressStats.textContent = 'progress unavailable'; });
328
+ }
329
+ refreshProgress();
330
+
331
+ // SSE — live update when other tabs/agents change this goal
332
+ // Bug fix (2026-05-23): track page-open timestamp + skip events from this tab's own commits
333
+ // to break the backfill-reload loop. Only react to events newer than page-open AND not authored
334
+ // by this browser tab.
335
+ var PAGE_OPENED_TS = new Date().toISOString();
336
+ var ownCommitAt = 0;
337
+ try {
338
+ var sse = new EventSource('/events.sse?token=' + encodeURIComponent(TOKEN));
339
+ sse.onmessage = function (ev) {
340
+ try {
341
+ var data = JSON.parse(ev.data);
342
+ if (!data || data.goal_id !== GOAL_ID) return;
343
+ // Skip events from before page open (initial 100-event backfill)
344
+ if (data.ts && data.ts <= PAGE_OPENED_TS) return;
345
+ // Skip events authored by this tab within last 2s (echoes of own commits)
346
+ if (data.changed_by === 'browser' && (Date.now() - ownCommitAt) < 2000) return;
347
+ // Genuine remote edit — soft refresh badge + reload after 500ms
348
+ setBadge('saved', 'remote update');
349
+ setTimeout(function () { window.location.reload(); }, 500);
350
+ } catch (e) { /* ignore parse errors */ }
351
+ };
352
+ sse.onerror = function () { setBadge('error', 'sse offline'); };
353
+ } catch (e) { /* SSE unsupported */ }
354
+
355
+ // S-4: keepalive ping for implicit Agent OS claim (5min lease, refresh every 2min)
356
+ function pingKeepalive() {
357
+ fetch('/goals/' + GOAL_ID + '/keepalive', {
358
+ method: 'POST',
359
+ headers: { 'X-Watch-Token': TOKEN, 'Content-Type': 'application/json' },
360
+ body: '{}',
361
+ }).catch(function () { /* network blip; next interval retries */ });
362
+ }
363
+ setInterval(pingKeepalive, KEEPALIVE_MS);
364
+
365
+ // S-4: best-effort claim release on tab close / navigation
366
+ window.addEventListener('beforeunload', function () {
367
+ var url = '/goals/' + GOAL_ID + '/claim/release';
368
+ // Use sendBeacon when available (survives navigation); fallback to keepalive fetch
369
+ if (navigator.sendBeacon) {
370
+ var blob = new Blob([JSON.stringify({ token: TOKEN })], { type: 'application/json' });
371
+ navigator.sendBeacon(url + '?token=' + encodeURIComponent(TOKEN), blob);
372
+ } else {
373
+ try {
374
+ fetch(url, {
375
+ method: 'POST',
376
+ headers: { 'X-Watch-Token': TOKEN, 'Content-Type': 'application/json' },
377
+ body: '{}',
378
+ keepalive: true,
379
+ });
380
+ } catch (e) { /* best effort */ }
381
+ }
382
+ });
383
+ })();
384
+ </script>
385
+ </body></html>`;
386
+ }
387
+
388
+ const TASKS_DIR = '.dw/tasks';
389
+ const CACHE_DIR = '.dw/cache/preview';
390
+ const DEBOUNCE_MS = 800;
391
+ const DEFAULT_PORT = 4181;
392
+ const MAX_PORT_TRIES = 20;
393
+
394
+ function findV3Tasks(rootDir) {
395
+ const tasksRoot = join(rootDir, TASKS_DIR);
396
+ if (!existsSync(tasksRoot)) return [];
397
+ return readdirSync(tasksRoot)
398
+ .filter((e) => e !== 'archive' && e !== 'ACTIVE.md')
399
+ .map((e) => ({ name: e, path: join(tasksRoot, e) }))
400
+ .filter((e) => {
401
+ try { return statSync(e.path).isDirectory() && existsSync(join(e.path, 'task.md')); }
402
+ catch { return false; }
403
+ });
404
+ }
405
+
406
+ function findAvailablePort(start) {
407
+ return new Promise((resolve, reject) => {
408
+ let port = start;
409
+ const tryPort = () => {
410
+ if (port - start > MAX_PORT_TRIES) {
411
+ reject(new Error(`Could not find available port in ${start}..${port - 1}`));
412
+ return;
413
+ }
414
+ const tester = createServer();
415
+ tester.once('error', () => {
416
+ tester.close();
417
+ port++;
418
+ tryPort();
419
+ });
420
+ tester.once('listening', () => {
421
+ tester.close(() => resolve(port));
422
+ });
423
+ tester.listen(port);
424
+ };
425
+ tryPort();
426
+ });
427
+ }
428
+
429
+ const HTML_SHELL = (taskName, port) => `<!doctype html>
430
+ <html><head>
431
+ <meta charset="utf-8"><title>dw task watch — ${taskName}</title>
432
+ <style>
433
+ * { box-sizing: border-box; margin: 0; padding: 0; }
434
+ body { font: 13px -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; }
435
+ @media (prefers-color-scheme: light) { body { background: #fff; color: #1f2328; } }
436
+ iframe { width: 100%; height: calc(100vh - 36px); border: 0; }
437
+ .topbar { position: sticky; top: 0; display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: #161b22; border-bottom: 1px solid #30363d; height: 36px; z-index: 50; }
438
+ @media (prefers-color-scheme: light) { .topbar { background: #f6f8fa; border-bottom-color: #d0d7de; } }
439
+ .topbar a { color: #58a6ff; text-decoration: none; font-size: 12px; padding: 3px 8px; border-radius: 4px; }
440
+ .topbar a:hover { background: rgba(88, 166, 255, 0.12); }
441
+ .topbar .ctx { margin-left: auto; color: #6b7280; font-size: 11px; font-family: ui-monospace, monospace; }
442
+ .live { padding: 3px 10px; background: rgba(16, 185, 129, 0.18); color: #10b981; border-radius: 4px; font-size: 11px; letter-spacing: 0.5px; font-weight: 600; }
443
+ .live.stale { background: rgba(245, 158, 11, 0.18); color: #f59e0b; }
444
+ .live.error { background: rgba(220, 38, 38, 0.18); color: #ef4444; }
445
+ </style>
446
+ </head><body>
447
+ <div class="topbar">
448
+ <a href="/preview" target="frame">📄 Task</a>
449
+ <a href="/goals" target="frame">🎯 Goals</a>
450
+ <span class="ctx">${taskName}</span>
451
+ <div class="live" id="status">● LIVE</div>
452
+ </div>
453
+ <iframe id="frame" name="frame" src="/preview"></iframe>
454
+ <script>
455
+ (function () {
456
+ var status = document.getElementById('status');
457
+ var frame = document.getElementById('frame');
458
+ var ws;
459
+ function setStatus(text, cls) { status.textContent = text; status.className = 'live ' + (cls || ''); }
460
+ function reload() {
461
+ setStatus('● RELOADING', 'stale');
462
+ var f = document.createElement('iframe');
463
+ f.id = 'frame';
464
+ f.src = '/preview?ts=' + Date.now();
465
+ f.onload = function () { setStatus('● LIVE', ''); };
466
+ frame.replaceWith(f);
467
+ frame = f;
468
+ }
469
+ function connect() {
470
+ try {
471
+ ws = new WebSocket('ws://localhost:${port}/_ws');
472
+ } catch (e) { setTimeout(connect, 1000); return; }
473
+ ws.onmessage = function (e) { if (e.data === 'reload') reload(); };
474
+ ws.onclose = function () { setStatus('● RECONNECTING', 'error'); setTimeout(connect, 1000); };
475
+ ws.onopen = function () { setStatus('● LIVE', ''); };
476
+ }
477
+ // No native WS without server upgrade — fall back to polling
478
+ var lastTs = 0;
479
+ function poll() {
480
+ fetch('/_ts').then(function (r) { return r.text(); }).then(function (ts) {
481
+ var n = Number(ts);
482
+ if (n > 0 && n > lastTs) {
483
+ if (lastTs > 0) reload();
484
+ lastTs = n;
485
+ }
486
+ }).catch(function () { setStatus('● OFFLINE', 'error'); });
487
+ }
488
+ poll();
489
+ setInterval(poll, 1000);
490
+ })();
491
+ </script>
492
+ </body></html>
493
+ `;
494
+
495
+ function openInBrowser(url) {
496
+ const platform = process.platform;
497
+ try {
498
+ if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore', shell: true });
499
+ else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
500
+ else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
501
+ return true;
502
+ } catch { return false; }
503
+ }
504
+
505
+ export async function taskWatchCommand(taskName, opts = {}) {
506
+ const rootDir = process.cwd();
507
+ let target;
508
+ if (taskName) {
509
+ const path = join(rootDir, TASKS_DIR, taskName);
510
+ if (!existsSync(join(path, 'task.md'))) {
511
+ console.error(chalk.red(`✗ No v3 task "${taskName}" with task.md`));
512
+ process.exit(1);
513
+ }
514
+ target = { name: taskName, path };
515
+ } else {
516
+ const all = findV3Tasks(rootDir);
517
+ if (all.length === 0) {
518
+ console.error(chalk.red('✗ No v3 tasks found.'));
519
+ process.exit(1);
520
+ }
521
+ all.sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs);
522
+ target = all[0];
523
+ console.log(chalk.dim(` No task specified — watching most recent: ${target.name}`));
524
+ }
525
+
526
+ const timelineFile = join(target.path, 'task.md');
527
+ const previewFile = join(rootDir, CACHE_DIR, `${target.name}.html`);
528
+
529
+ await taskViewCommand(target.name, { noOpen: true, open: false });
530
+
531
+ let lastChangeTs = Date.now();
532
+ let debounceTimer = null;
533
+ let regenerating = false;
534
+
535
+ async function regenerate() {
536
+ if (regenerating) return;
537
+ regenerating = true;
538
+ try {
539
+ await taskViewCommand(target.name, { noOpen: true, open: false });
540
+ lastChangeTs = Date.now();
541
+ } catch (e) {
542
+ console.error(chalk.red(` regenerate failed: ${e.message}`));
543
+ } finally {
544
+ regenerating = false;
545
+ }
546
+ }
547
+
548
+ const port = await findAvailablePort(opts.port || DEFAULT_PORT);
549
+ const watchToken = ensureToken(rootDir, { rotate: !!opts.rotateToken });
550
+ const sseBroker = createSseBroker(rootDir);
551
+
552
+ const server = createServer((req, res) => {
553
+ if (isWriteEndpoint(req) && !isAuthorized(req, watchToken)) {
554
+ res.writeHead(401, { 'Content-Type': 'application/json' });
555
+ res.end(JSON.stringify({ error: 'unauthorized', detail: 'Missing or invalid X-Watch-Token header (C-3 auth)' }));
556
+ logEvent({ event: 'goal', action: 'auth.reject', method: req.method, url: req.url }, rootDir);
557
+ return;
558
+ }
559
+ if (req.url === '/events.sse' || req.url.startsWith('/events.sse?')) {
560
+ if (!isAuthorized(req, watchToken)) {
561
+ res.writeHead(401, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({ error: 'unauthorized', detail: 'SSE requires ?token= query or X-Watch-Token header' }));
563
+ return;
564
+ }
565
+ const ok = sseBroker.addClient(req, res);
566
+ logEvent({ event: 'goal', action: 'sse.connect', clients: sseBroker.clientCount(), accepted: ok }, rootDir);
567
+ return;
568
+ }
569
+ if (req.url === '/_ts') {
570
+ res.writeHead(200, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
571
+ res.end(String(lastChangeTs));
572
+ return;
573
+ }
574
+ if (req.url === '/' || req.url.startsWith('/?')) {
575
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
576
+ res.end(HTML_SHELL(target.name, port));
577
+ return;
578
+ }
579
+ if (req.url === '/preview' || req.url.startsWith('/preview?')) {
580
+ if (!existsSync(previewFile)) {
581
+ res.writeHead(404); res.end('preview not generated yet'); return;
582
+ }
583
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
584
+ res.end(readFileSync(previewFile, 'utf8'));
585
+ return;
586
+ }
587
+ if (req.url === '/goals' || req.url.startsWith('/goals?')) {
588
+ for (const id of listGoalIds(rootDir)) syncIndexEntry(id, rootDir);
589
+ const idx = readGoalIndex(rootDir);
590
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
591
+ res.end(renderGoalPortfolioHtml(idx));
592
+ logEvent({ event: 'goal', action: 'view.invoke', count: Object.keys(idx.goals || {}).length, via: 'watch' }, rootDir);
593
+ return;
594
+ }
595
+ const postKeepaliveMatch = req.method === 'POST' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/keepalive\/?$/);
596
+ if (postKeepaliveMatch) {
597
+ const goalId = postKeepaliveMatch[1];
598
+ const claimId = refreshBrowserClaim(goalId, rootDir) || ensureBrowserClaim(goalId, rootDir);
599
+ sendJson(res, 200, { ok: true, goal_id: goalId, claim_id: claimId, lease_seconds: BROWSER_CLAIM_LEASE_SECONDS });
600
+ return;
601
+ }
602
+
603
+ const postReleaseMatch = req.method === 'POST' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/claim\/release\/?$/);
604
+ if (postReleaseMatch) {
605
+ const goalId = postReleaseMatch[1];
606
+ const claimId = releaseBrowserClaim(goalId, rootDir, 'browser beforeunload');
607
+ sendJson(res, 200, { ok: true, goal_id: goalId, released_claim_id: claimId });
608
+ return;
609
+ }
610
+
611
+ const postSummaryMatch = req.method === 'POST' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/summary\/?$/);
612
+ if (postSummaryMatch) {
613
+ const goalId = postSummaryMatch[1];
614
+ ensureBrowserClaim(goalId, rootDir);
615
+ readJsonBody(req).then((body) => {
616
+ const goal = readGoal(goalId, rootDir);
617
+ if (!goal) { sendJson(res, 404, { error: 'not_found', goal_id: goalId }); return; }
618
+ const newSummary = typeof body.summary === 'string' ? body.summary : null;
619
+ if (newSummary && newSummary.length > MAX_SUMMARY_CHARS) {
620
+ sendJson(res, 400, { error: 'summary_too_long', max: MAX_SUMMARY_CHARS, got: newSummary.length });
621
+ return;
622
+ }
623
+ const oldSummary = goal.fm.summary || null;
624
+ updateGoalFrontmatter(goalId, (fm) => { fm.summary = newSummary; fm.last_updated = todayIso(); return fm; }, rootDir);
625
+ syncIndexEntry(goalId, rootDir);
626
+ logGoalEvent({
627
+ event: 'goal_field_updated',
628
+ goal_id: goalId,
629
+ field: 'summary',
630
+ old: oldSummary,
631
+ new: newSummary,
632
+ changed_by: 'browser',
633
+ }, rootDir);
634
+ logEvent({ event: 'goal', action: 'edit.commit', name: goalId, field: 'summary', via: 'http' }, rootDir);
635
+ sendJson(res, 200, { ok: true, goal_id: goalId, field: 'summary', length: (newSummary || '').length });
636
+ }).catch((e) => sendJson(res, 400, { error: 'bad_body', detail: e.message }));
637
+ return;
638
+ }
639
+
640
+ const getSvgMatch = req.method === 'GET' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/svg\/?$/);
641
+ if (getSvgMatch) {
642
+ const goalId = getSvgMatch[1];
643
+ const goal = readGoal(goalId, rootDir);
644
+ if (!goal) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end(`Goal ${goalId} not found`); return; }
645
+ try {
646
+ const svg = renderGoalSvg(goalId, rootDir);
647
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
648
+ res.end(svg);
649
+ logEvent({ event: 'goal', action: 'render.serve', name: goalId, via: 'http' }, rootDir);
650
+ } catch (e) {
651
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
652
+ res.end('render failed: ' + e.message);
653
+ }
654
+ return;
655
+ }
656
+
657
+ const getProgressMatch = req.method === 'GET' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/progress\/?$/);
658
+ if (getProgressMatch) {
659
+ const goalId = getProgressMatch[1];
660
+ const goal = readGoal(goalId, rootDir);
661
+ if (!goal) { sendJson(res, 404, { error: 'not_found', goal_id: goalId }); return; }
662
+ const p = computeGoalProgress(goalId, rootDir);
663
+ sendJson(res, 200, p);
664
+ return;
665
+ }
666
+
667
+ const postFieldMatch = req.method === 'POST' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/field\/?$/);
668
+ if (postFieldMatch) {
669
+ const goalId = postFieldMatch[1];
670
+ ensureBrowserClaim(goalId, rootDir);
671
+ readJsonBody(req).then((body) => {
672
+ const goal = readGoal(goalId, rootDir);
673
+ if (!goal) { sendJson(res, 404, { error: 'not_found', goal_id: goalId }); return; }
674
+ const field = String(body.field || '');
675
+ if (!SETTABLE_FIELDS.has(field)) {
676
+ sendJson(res, 400, { error: 'field_not_settable', field, allowed: [...SETTABLE_FIELDS] });
677
+ return;
678
+ }
679
+ const newValue = body.value === undefined ? null : body.value;
680
+ const limit = FIELD_MAX[field];
681
+ if (newValue && typeof newValue === 'string' && limit && newValue.length > limit) {
682
+ sendJson(res, 400, { error: 'value_too_long', field, max: limit, got: newValue.length });
683
+ return;
684
+ }
685
+ const oldValue = goal.fm[field] !== undefined ? goal.fm[field] : null;
686
+ updateGoalFrontmatter(goalId, (fm) => { fm[field] = newValue; fm.last_updated = todayIso(); return fm; }, rootDir);
687
+ syncIndexEntry(goalId, rootDir);
688
+ logGoalEvent({
689
+ event: 'goal_field_updated',
690
+ goal_id: goalId,
691
+ field,
692
+ old: oldValue,
693
+ new: newValue,
694
+ changed_by: 'browser',
695
+ }, rootDir);
696
+ logEvent({ event: 'goal', action: 'edit.commit', name: goalId, field, via: 'http' }, rootDir);
697
+ sendJson(res, 200, { ok: true, goal_id: goalId, field, value: newValue });
698
+ }).catch((e) => sendJson(res, 400, { error: 'bad_body', detail: e.message }));
699
+ return;
700
+ }
701
+
702
+ const postStatusMatch = req.method === 'POST' && req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/status\/?$/);
703
+ if (postStatusMatch) {
704
+ const goalId = postStatusMatch[1];
705
+ ensureBrowserClaim(goalId, rootDir);
706
+ readJsonBody(req).then((body) => {
707
+ const goal = readGoal(goalId, rootDir);
708
+ if (!goal) { sendJson(res, 404, { error: 'not_found', goal_id: goalId }); return; }
709
+ const newStatus = String(body.status || '');
710
+ if (!VALID_STATUSES.has(newStatus)) {
711
+ sendJson(res, 400, { error: 'invalid_status', allowed: [...VALID_STATUSES] });
712
+ return;
713
+ }
714
+ const oldStatus = goal.fm.status || 'Draft';
715
+ if (oldStatus === newStatus) {
716
+ sendJson(res, 200, { ok: true, noop: true, status: newStatus });
717
+ return;
718
+ }
719
+ const oldVersion = goal.fm.goal_version || 1;
720
+ const newVersion = oldVersion + 1;
721
+ updateGoalFrontmatter(goalId, (fm) => {
722
+ fm.status = newStatus;
723
+ fm.goal_version = newVersion;
724
+ fm.last_updated = todayIso();
725
+ if (newStatus === 'Abandoned' && !fm.archived_at) fm.archived_at = nowUtc();
726
+ if (newStatus !== 'Abandoned' && fm.archived_at) fm.archived_at = null;
727
+ return fm;
728
+ }, rootDir);
729
+ syncIndexEntry(goalId, rootDir);
730
+ logGoalEvent({
731
+ event: 'goal_status_changed',
732
+ goal_id: goalId,
733
+ from_status: oldStatus,
734
+ to_status: newStatus,
735
+ changed_by: 'browser',
736
+ }, rootDir);
737
+ logGoalEvent({
738
+ event: 'goal_pivoted',
739
+ goal_id: goalId,
740
+ from_version: oldVersion,
741
+ to_version: newVersion,
742
+ summary: `status: ${oldStatus} → ${newStatus}`,
743
+ changed_by: 'browser',
744
+ }, rootDir);
745
+ logEvent({ event: 'goal', action: 'edit.commit', name: goalId, field: 'status', via: 'http' }, rootDir);
746
+ sendJson(res, 200, { ok: true, goal_id: goalId, from_status: oldStatus, to_status: newStatus, goal_version: newVersion });
747
+ }).catch((e) => sendJson(res, 400, { error: 'bad_body', detail: e.message }));
748
+ return;
749
+ }
750
+
751
+ const goalEditMatch = req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/edit\/?$/);
752
+ if (goalEditMatch) {
753
+ const goalId = goalEditMatch[1];
754
+ const goal = readGoal(goalId, rootDir);
755
+ if (!goal) { res.writeHead(404); res.end(`Goal ${goalId} not found`); return; }
756
+ const claimId = ensureBrowserClaim(goalId, rootDir);
757
+ res.writeHead(200, {
758
+ 'Content-Type': 'text/html; charset=utf-8',
759
+ 'Cache-Control': 'no-store',
760
+ 'Set-Cookie': `dw_watch_token=${watchToken}; Path=/; SameSite=Strict; HttpOnly`,
761
+ });
762
+ res.end(renderGoalEditorHtml(goalId, goal, watchToken, claimId));
763
+ logEvent({ event: 'goal', action: 'edit.open', name: goalId, claim_id: claimId }, rootDir);
764
+ return;
765
+ }
766
+ const goalDetailMatch = req.url.match(/^\/goals\/(G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?)\/?$/);
767
+ if (goalDetailMatch) {
768
+ const goalId = goalDetailMatch[1];
769
+ const goal = readGoal(goalId, rootDir);
770
+ if (!goal) {
771
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
772
+ res.end(`Goal ${goalId} not found`);
773
+ return;
774
+ }
775
+ const linked = findLinkedTaskIds(goalId, rootDir);
776
+ const icon = goal.fm.icon || '🎯';
777
+ const cycle = goal.fm.cycle || '';
778
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${escHtml(icon)} ${escHtml(goalId)}</title>
779
+ <style>
780
+ body{font:14px/1.6 -apple-system,sans-serif;background:#0d1117;color:#c9d1d9;padding:32px;max-width:960px;margin:0 auto}
781
+ @media(prefers-color-scheme:light){body{background:#fff;color:#1f2328}}
782
+ h1{font-size:24px;margin-bottom:8px;display:flex;align-items:center;gap:12px}
783
+ h1 .icon{font-size:32px;line-height:1}
784
+ .meta{color:#6b7280;font-size:12px;margin-bottom:16px;display:flex;gap:10px;flex-wrap:wrap}
785
+ .meta .cycle{padding:2px 8px;background:rgba(88,166,255,0.15);color:#58a6ff;border-radius:4px;letter-spacing:0.5px}
786
+ .badge{display:inline-block;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;color:#fff;background:#0ea5e9}
787
+ .summary{padding:16px;background:#161b22;border-radius:6px;margin:16px 0;line-height:1.6}
788
+ @media(prefers-color-scheme:light){.summary{background:#f6f8fa}}
789
+ .svg-frame{background:#0d1117;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin:24px 0}
790
+ @media(prefers-color-scheme:light){.svg-frame{border-color:#d0d7de}}
791
+ .svg-frame img{display:block;width:100%;height:auto}
792
+ .linked{margin-top:24px}.linked ul{list-style:none;padding:0}.linked li{margin:4px 0;padding:6px 10px;background:#161b22;border-radius:4px;font-family:ui-monospace,monospace;font-size:13px}
793
+ @media(prefers-color-scheme:light){.linked li{background:#f6f8fa}}
794
+ a{color:#58a6ff;text-decoration:none}a:hover{text-decoration:underline}
795
+ nav{margin-bottom:24px;display:flex;gap:16px;justify-content:space-between;align-items:center}
796
+ .edit-btn{padding:6px 14px;background:#238636;color:#fff;border-radius:4px;font-size:12px;font-weight:600}
797
+ .edit-btn:hover{background:#2ea043;text-decoration:none}
798
+ </style></head><body>
799
+ <nav><a href="/goals">← portfolio</a><a href="/goals/${escHtml(goalId)}/edit" class="edit-btn">edit ✎</a></nav>
800
+ <h1><span class="icon">${escHtml(icon)}</span><span class="badge">${escHtml(goal.fm.status || 'Draft')}</span> ${escHtml(goalId)}</h1>
801
+ <div class="meta">
802
+ <span>Owner: ${escHtml(goal.fm.owner || '?')}</span>
803
+ <span>·</span>
804
+ <span>Target: ${escHtml(goal.fm.target_date || 'TBD')}</span>
805
+ <span>·</span>
806
+ <span>v${escHtml(goal.fm.goal_version || 1)}</span>
807
+ ${cycle ? `<span>·</span><span class="cycle">${escHtml(cycle)}</span>` : ''}
808
+ </div>
809
+ ${goal.fm.summary ? `<div class="summary">${escHtml(goal.fm.summary)}</div>` : '<div class="summary" style="color:#6b7280;font-style:italic">(no summary)</div>'}
810
+
811
+ <div class="svg-frame">
812
+ <img src="/goals/${escHtml(goalId)}/svg" alt="Goal constellation map" loading="lazy">
813
+ </div>
814
+
815
+ <div class="linked"><h2>Linked tasks (${linked.length})</h2>
816
+ ${linked.length === 0 ? '<p style="color:#6b7280">none</p>' : '<ul>' + linked.map((t) => `<li>· ${escHtml(t)}</li>`).join('') + '</ul>'}
817
+ </div>
818
+ </body></html>`;
819
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
820
+ res.end(html);
821
+ return;
822
+ }
823
+ res.writeHead(404); res.end('not found');
824
+ });
825
+
826
+ await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
827
+
828
+ const url = `http://localhost:${port}/`;
829
+
830
+ console.log();
831
+ console.log(chalk.green(` ✓ watch server: ${url}`));
832
+ console.log(chalk.dim(` watching: ${timelineFile}`));
833
+ console.log(chalk.dim(` preview: ${previewFile}`));
834
+ console.log(chalk.dim(` debounce: ${DEBOUNCE_MS}ms · port: ${port}`));
835
+ console.log(chalk.dim(` auth (C-3): X-Watch-Token required for writes; token at .dw/cache/watch.token`));
836
+ console.log(chalk.dim(` token: ${watchToken.slice(0, 8)}…${watchToken.slice(-4)} (${watchToken.length} chars)`));
837
+ console.log(chalk.dim(` Ctrl+C to stop`));
838
+ console.log();
839
+
840
+ const opened = openInBrowser(url);
841
+ if (!opened) {
842
+ console.log(chalk.yellow(` (Browser did not auto-open — paste ${url} manually)`));
843
+ }
844
+
845
+ logEvent({ event: 'task', action: 'watch.start', name: target.name, port }, rootDir);
846
+
847
+ const watcher = fsWatch(target.path, { persistent: true }, (eventType, filename) => {
848
+ if (filename !== 'task.md') return;
849
+ if (debounceTimer) clearTimeout(debounceTimer);
850
+ debounceTimer = setTimeout(() => {
851
+ console.log(chalk.dim(` · ${new Date().toLocaleTimeString()} task.md changed → regenerating`));
852
+ regenerate();
853
+ }, DEBOUNCE_MS);
854
+ });
855
+
856
+ const shutdown = () => {
857
+ console.log();
858
+ console.log(chalk.dim(' Shutting down watch server…'));
859
+ watcher.close();
860
+ sseBroker.shutdown();
861
+ server.close(() => {
862
+ logEvent({ event: 'task', action: 'watch.stop', name: target.name }, rootDir);
863
+ process.exit(0);
864
+ });
865
+ };
866
+ process.on('SIGINT', shutdown);
867
+ process.on('SIGTERM', shutdown);
868
+ }