dw-kit 1.9.2 → 1.9.3

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.
@@ -83,6 +83,12 @@ import { buildSessionTreeData } from '../lib/session-tree.mjs';
83
83
  import { startDebate, resolveDebateRoster } from '../lib/debate.mjs';
84
84
  import { createSseBroker } from '../lib/sse-broker.mjs';
85
85
  import { buildBoardData } from '../lib/board-data.mjs';
86
+ import {
87
+ claimGoal, releaseGoal, attachSessionId, buildDriverPrompt, parseBudget, readClaim, listClaims,
88
+ } from '../lib/goal-driver.mjs';
89
+ import { startProgressWatcher, stopProgressWatcher } from '../lib/goal-progress.mjs';
90
+ import { logGoalEvent } from '../lib/goal-events.mjs';
91
+ import { killProcessTree } from '../lib/process-kill.mjs';
86
92
 
87
93
  const CACHE_DIR = '.dw/cache';
88
94
  const VOICE_TOKEN_PATH = '.dw/cache/voice.token';
@@ -989,6 +995,50 @@ if (document.readyState === 'loading') {
989
995
  </body></html>`;
990
996
  }
991
997
 
998
+ // ─ goal-driver-mvp ST-8: claim reconcile + tree-kill ─
999
+ //
1000
+ // Reconcile = sweep `.dw/cache/goal-claims/*.json`, drop any claim whose
1001
+ // linked session has terminated (status in exited/completed/failed/stopped
1002
+ // OR pid is dead). Without this, an agent that finishes cleanly leaves
1003
+ // the "Running…" pill stuck on the board until expires_at TTL fires.
1004
+ // Runs lazily on each /voice/board fetch + at the top of /voice/goal-start
1005
+ // so re-clicking Start always finds a fresh slot.
1006
+ //
1007
+ // Returns { released: string[] } so the caller can log how many stale
1008
+ // claims were collected on this pass.
1009
+ function reconcileGoalDrivers(rootDir) {
1010
+ const out = { released: [] };
1011
+ let claims;
1012
+ try { claims = listClaims(rootDir); }
1013
+ catch { return out; }
1014
+ const GRACE_MS = 5_000; // don't release a claim younger than this (spawn race)
1015
+ const now = Date.now();
1016
+ for (const claim of claims) {
1017
+ if (!claim.session_id) continue;
1018
+ let s;
1019
+ try { s = getSession(claim.session_id, rootDir); }
1020
+ catch { continue; }
1021
+ if (!s) continue;
1022
+ const terminal = ['exited', 'completed', 'failed', 'stopped'].includes(s.status);
1023
+ const pidDead = s.pid ? !isAlive(s.pid) : true;
1024
+ if (!(terminal && pidDead)) continue;
1025
+ const claimedMs = Date.parse(claim.claimed_at);
1026
+ if (Number.isFinite(claimedMs) && (now - claimedMs) < GRACE_MS) continue;
1027
+ releaseGoal(claim.goal_id, rootDir);
1028
+ stopProgressWatcher(claim.goal_id);
1029
+ logGoalEvent({
1030
+ event: 'goal_driver_orphan_released',
1031
+ goal_id: claim.goal_id,
1032
+ session_id: claim.session_id,
1033
+ claim_id: claim.claim_id,
1034
+ session_status: s.status,
1035
+ reason: 'session_terminal_pid_dead',
1036
+ }, rootDir);
1037
+ out.released.push(claim.goal_id);
1038
+ }
1039
+ return out;
1040
+ }
1041
+
992
1042
  // ─ F-49 Đợt 2: Kanban board page ─
993
1043
  //
994
1044
  // Self-contained HTML at /board?t=<token>. Polls /voice/board for JSON, then
@@ -1047,6 +1097,25 @@ function boardHtml(token) {
1047
1097
  .empty { color: var(--dim); font-style: italic; font-size: 12px; padding: 8px 0; }
1048
1098
  .toast { position: fixed; bottom: 16px; right: 16px; background: var(--card); border: 1px solid var(--accent); padding: 10px 14px; border-radius: 6px; font-size: 13px; max-width: 360px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
1049
1099
  .toast.err { border-color: var(--err); }
1100
+ .gtopline { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
1101
+ .gtopline .ghead { min-width: 0; }
1102
+ .goal-actions { display: flex; gap: 6px; flex-shrink: 0; }
1103
+ .goal-start-btn { background: var(--accent); color: #0d1117; border: 0; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; }
1104
+ .goal-start-btn:hover { filter: brightness(1.15); }
1105
+ .goal-start-btn:disabled { cursor: not-allowed; opacity: 0.85; }
1106
+ .goal-start-btn.running { background: var(--warn); color: #0d1117; animation: gd-pulse 1.4s ease-in-out infinite; }
1107
+ .goal-stop-btn { background: var(--err); color: #fff; border: 0; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; }
1108
+ .goal-stop-btn:hover { filter: brightness(1.15); }
1109
+ @keyframes gd-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
1110
+ .gd-cap { color: var(--dim); font-size: 10px; margin-top: 2px; }
1111
+ .gd-progress { margin-top: 6px; padding: 6px 8px; background: rgba(210, 153, 34, 0.08); border-left: 2px solid var(--warn); border-radius: 3px; }
1112
+ .gd-progress .current { color: var(--warn); font-size: 12px; font-weight: 600; }
1113
+ .gd-progress .current.bucket-pending { color: var(--dim); }
1114
+ .gd-progress .stats { color: var(--dim); font-size: 11px; margin-top: 2px; }
1115
+ .gd-progress .stats .alive { color: var(--ok); }
1116
+ .gd-progress .stats .stale { color: var(--err); }
1117
+ .gd-progress .pbar { height: 4px; background: var(--border); border-radius: 2px; margin-top: 4px; overflow: hidden; }
1118
+ .gd-progress .pbar .pfill { height: 100%; background: var(--warn); transition: width 0.4s; }
1050
1119
  </style>
1051
1120
  </head><body>
1052
1121
  <div class="topbar">
@@ -1132,13 +1201,96 @@ function render(d) {
1132
1201
  for (const g of d.goals) {
1133
1202
  const gc = document.createElement('div');
1134
1203
  gc.className = 'goal-card cycle-' + (g.cycle || '');
1204
+ gc.dataset.goalId = g.goal_id;
1135
1205
  const gid = document.createElement('div'); gid.className = 'gid';
1136
1206
  gid.textContent = (g.icon ? g.icon + ' ' : '') + g.goal_id;
1137
1207
  const gt = document.createElement('div'); gt.className = 'gtitle';
1138
1208
  gt.textContent = g.title;
1139
1209
  const gm = document.createElement('div'); gm.className = 'gmeta';
1140
1210
  gm.textContent = g.status + (g.target_date ? ' · ' + g.target_date : '') + (g.cycle ? ' · ' + g.cycle : '');
1141
- gc.appendChild(gid); gc.appendChild(gt); gc.appendChild(gm);
1211
+ // ST-3: Start button per goal — disabled while claim is active.
1212
+ const topline = document.createElement('div'); topline.className = 'gtopline';
1213
+ const ghead = document.createElement('div'); ghead.className = 'ghead';
1214
+ ghead.appendChild(gid); ghead.appendChild(gt);
1215
+ topline.appendChild(ghead);
1216
+ const startBtn = document.createElement('button');
1217
+ startBtn.type = 'button';
1218
+ startBtn.className = 'goal-start-btn' + (g.claim ? ' running' : '');
1219
+ startBtn.disabled = !!g.claim;
1220
+ const lang = (navigator.language || 'en').toLowerCase().startsWith('vi') ? 'vi' : 'en';
1221
+ if (g.claim) {
1222
+ startBtn.textContent = lang === 'vi' ? 'Đang chạy…' : 'Running…';
1223
+ startBtn.title = lang === 'vi'
1224
+ ? ('Session ' + (g.claim.session_id || '').slice(-8) + ' · cap ' + g.claim.max_minutes + ' phút')
1225
+ : ('Session ' + (g.claim.session_id || '').slice(-8) + ' · cap ' + g.claim.max_minutes + 'm');
1226
+ } else {
1227
+ startBtn.textContent = lang === 'vi' ? 'Bắt đầu ▶' : 'Start ▶';
1228
+ startBtn.title = lang === 'vi'
1229
+ ? 'Spawn agent ngầm drive goal tới hoàn thành (cap 25 phút)'
1230
+ : 'Spawn background driver to complete this goal (25-min cap)';
1231
+ }
1232
+ startBtn.onclick = (ev) => { ev.stopPropagation(); triggerGoalStart(g, lang); };
1233
+ const actions = document.createElement('div'); actions.className = 'goal-actions';
1234
+ actions.appendChild(startBtn);
1235
+ if (g.claim) {
1236
+ // Stop button only shown while a claim is active. Clicking it sends
1237
+ // SIGTERM (tree-kill server-side), releases the claim, and re-renders.
1238
+ const stopBtn = document.createElement('button');
1239
+ stopBtn.type = 'button';
1240
+ stopBtn.className = 'goal-stop-btn';
1241
+ stopBtn.textContent = lang === 'vi' ? 'Dừng ◼' : 'Stop ◼';
1242
+ stopBtn.title = lang === 'vi'
1243
+ ? 'Gửi SIGTERM cho session + release claim'
1244
+ : 'SIGTERM the session + release claim';
1245
+ stopBtn.onclick = (ev) => { ev.stopPropagation(); triggerGoalStop(g, lang); };
1246
+ actions.appendChild(stopBtn);
1247
+ }
1248
+ topline.appendChild(actions);
1249
+ gc.appendChild(topline);
1250
+ gc.appendChild(gm);
1251
+ if (g.claim) {
1252
+ const cap = document.createElement('div'); cap.className = 'gd-cap';
1253
+ cap.textContent = (lang === 'vi' ? 'driver hết hạn lúc ' : 'driver expires ') + g.claim.expires_at;
1254
+ gc.appendChild(cap);
1255
+ // Live progress chunk: current subtask + X/Y + last-activity timer.
1256
+ if (g.claim.progress) {
1257
+ const pr = g.claim.progress;
1258
+ const wrap = document.createElement('div'); wrap.className = 'gd-progress';
1259
+ const cur = document.createElement('div');
1260
+ cur.className = 'current bucket-' + (pr.current ? pr.current.status_bucket : 'idle');
1261
+ if (pr.current) {
1262
+ const icon = pr.current.status_bucket === 'in_progress' ? '▶ ' : '⬜ ';
1263
+ cur.textContent = icon + pr.current.st_id + ' · ' + pr.current.title;
1264
+ } else {
1265
+ cur.textContent = (lang === 'vi' ? 'Không có subtask đang chạy' : 'No subtask currently in flight');
1266
+ }
1267
+ const stats = document.createElement('div'); stats.className = 'stats';
1268
+ const doneStr = pr.done + '/' + pr.total + (lang === 'vi' ? ' xong' : ' done')
1269
+ + ' · ' + pr.percent + '%';
1270
+ stats.textContent = doneStr;
1271
+ const sep = document.createElement('span'); sep.textContent = ' · ';
1272
+ stats.appendChild(sep);
1273
+ const ago = document.createElement('span');
1274
+ const aliveInfo = formatActivity(pr.last_activity_at || g.claim.claimed_at, lang);
1275
+ ago.className = aliveInfo.stale ? 'stale' : 'alive';
1276
+ ago.textContent = aliveInfo.text;
1277
+ stats.appendChild(ago);
1278
+ if (pr.blocked > 0) {
1279
+ const blk = document.createElement('span');
1280
+ blk.style.color = 'var(--err)';
1281
+ blk.textContent = ' · ' + pr.blocked + (lang === 'vi' ? ' blocked' : ' blocked');
1282
+ stats.appendChild(blk);
1283
+ }
1284
+ const bar = document.createElement('div'); bar.className = 'pbar';
1285
+ const fill = document.createElement('div'); fill.className = 'pfill';
1286
+ fill.style.width = pr.percent + '%';
1287
+ bar.appendChild(fill);
1288
+ wrap.appendChild(cur);
1289
+ wrap.appendChild(stats);
1290
+ wrap.appendChild(bar);
1291
+ gc.appendChild(wrap);
1292
+ }
1293
+ }
1142
1294
  if (g.progress && Number.isFinite(g.progress.percent)) {
1143
1295
  const bar = document.createElement('div'); bar.className = 'progress-bar';
1144
1296
  const fill = document.createElement('div'); fill.className = 'fill';
@@ -1212,6 +1364,71 @@ function bucketForStatus(s) {
1212
1364
  return 'terminal';
1213
1365
  }
1214
1366
 
1367
+ // Returns { text, stale } — relative-time label + a flag the UI can use to
1368
+ // flip "alive" green to "stale" red after 90s of silence.
1369
+ function formatActivity(iso, lang) {
1370
+ const L = lang === 'vi' ? 'vi' : 'en';
1371
+ if (!iso) return { text: L === 'vi' ? 'chưa có hoạt động' : 'no activity yet', stale: true };
1372
+ const ms = Date.now() - Date.parse(iso);
1373
+ if (!Number.isFinite(ms) || ms < 0) return { text: iso, stale: false };
1374
+ const sec = Math.floor(ms / 1000);
1375
+ const STALE_AT_MS = 90 * 1000;
1376
+ const stale = ms > STALE_AT_MS;
1377
+ let unit;
1378
+ if (sec < 60) unit = sec + 's';
1379
+ else if (sec < 3600) unit = Math.floor(sec / 60) + 'm';
1380
+ else if (sec < 86400) unit = Math.floor(sec / 3600) + 'h';
1381
+ else unit = Math.floor(sec / 86400) + 'd';
1382
+ return {
1383
+ text: L === 'vi' ? ('hoạt động ' + unit + ' trước') : (unit + ' ago'),
1384
+ stale,
1385
+ };
1386
+ }
1387
+
1388
+ function triggerGoalStop(goal, lang) {
1389
+ const L = lang === 'vi' ? 'vi' : 'en';
1390
+ if (!confirm(L === 'vi'
1391
+ ? ('Dừng driver cho ' + goal.goal_id + '? Session sẽ bị SIGTERM.')
1392
+ : ('Stop driver for ' + goal.goal_id + '? The session will be SIGTERMed.'))) return;
1393
+ showToast((L === 'vi' ? 'Đang dừng: ' : 'Stopping: ') + goal.goal_id + '…');
1394
+ fetch('/voice/goal-stop', {
1395
+ method: 'POST',
1396
+ headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
1397
+ body: JSON.stringify({ goal_id: goal.goal_id }),
1398
+ }).then((r) => r.json().then((j) => ({ status: r.status, j }))).then(({ status, j }) => {
1399
+ if (j.ok) {
1400
+ showToast((L === 'vi' ? 'Đã dừng · ' : 'Stopped · ') + (j.kill_method || ''));
1401
+ setTimeout(loadBoard, 400);
1402
+ } else if (status === 404) {
1403
+ showToast(L === 'vi' ? 'Không có driver đang chạy' : 'No driver running', true);
1404
+ setTimeout(loadBoard, 200);
1405
+ } else {
1406
+ showToast((L === 'vi' ? 'Lỗi: ' : 'Failed: ') + (j.error || 'unknown'), true);
1407
+ }
1408
+ }).catch((e) => showToast((L === 'vi' ? 'Lỗi mạng: ' : 'Network error: ') + e.message, true));
1409
+ }
1410
+
1411
+ function triggerGoalStart(goal, lang) {
1412
+ const L = lang === 'vi' ? 'vi' : 'en';
1413
+ showToast((L === 'vi' ? 'Đang khởi động driver: ' : 'Driving: ') + goal.goal_id + '…');
1414
+ fetch('/voice/goal-start', {
1415
+ method: 'POST',
1416
+ headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
1417
+ body: JSON.stringify({ goal_id: goal.goal_id, lang: L }),
1418
+ }).then((r) => r.json().then((j) => ({ status: r.status, j }))).then(({ status, j }) => {
1419
+ if (j.ok) {
1420
+ const tail = (j.session_id || '').slice(-8);
1421
+ showToast((L === 'vi' ? 'Driver chạy · ' : 'Driver running · ')
1422
+ + tail + ' · cap ' + j.budget.max_minutes + (L === 'vi' ? ' phút' : 'm'));
1423
+ setTimeout(loadBoard, 400);
1424
+ } else if (status === 409) {
1425
+ showToast(L === 'vi' ? 'Đã có driver chạy — xem cột Sessions' : 'Already driving — see Sessions column', true);
1426
+ } else {
1427
+ showToast((L === 'vi' ? 'Lỗi: ' : 'Failed: ') + (j.error || 'unknown'), true);
1428
+ }
1429
+ }).catch((e) => showToast((L === 'vi' ? 'Lỗi mạng: ' : 'Network error: ') + e.message, true));
1430
+ }
1431
+
1215
1432
  function triggerSpawnForTask(task, goal) {
1216
1433
  const text = 'start agent for task ' + task.task_id + ' under goal ' + goal.goal_id;
1217
1434
  showToast('Asking orchestrator: ' + text);
@@ -1266,7 +1483,7 @@ es.addEventListener('message', (ev) => {
1266
1483
  let p; try { p = JSON.parse(ev.data); } catch { return; }
1267
1484
  if (!p || !p.event) return;
1268
1485
  const n = p.event;
1269
- if (n.startsWith('session.') || n.startsWith('goal.') || n === 'debate_completed' || n === 'debate_started') {
1486
+ if (n.startsWith('session.') || n.startsWith('goal.') || n.startsWith('goal_driver_') || n === 'debate_completed' || n === 'debate_started') {
1270
1487
  scheduleRefresh();
1271
1488
  }
1272
1489
  });
@@ -1686,6 +1903,151 @@ export async function voiceCommand(opts = {}) {
1686
1903
  res.end(JSON.stringify({ ok: true, data: { roster, timeout_ms } }));
1687
1904
  return;
1688
1905
  }
1906
+ // goal-driver-mvp ST-2: POST /voice/goal-start spawns an autonomous
1907
+ // claude driver session for the supplied goal_id, gated by a per-goal
1908
+ // claim file (.dw/cache/goal-claims/<id>.json). The spawned agent runs
1909
+ // the F-43 workflow in a budgeted loop; we SIGTERM at the wall-clock
1910
+ // cap and release the claim. See task.md + ADR-0014 (whitelist).
1911
+ if (req.method === 'POST' && req.url === '/voice/goal-start') {
1912
+ if (req.headers['x-voice-token'] !== token) {
1913
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1914
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
1915
+ return;
1916
+ }
1917
+ let body = '';
1918
+ req.setEncoding('utf8');
1919
+ req.on('data', (c) => { body += c; if (body.length > 8192) req.destroy(); });
1920
+ req.on('end', async () => {
1921
+ let parsed;
1922
+ try { parsed = JSON.parse(body); }
1923
+ catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'bad json' })); return; }
1924
+ const goalId = (parsed.goal_id || '').trim();
1925
+ if (!/^G-[A-Za-z0-9_-]{1,64}$/.test(goalId)) {
1926
+ res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'invalid goal_id' })); return;
1927
+ }
1928
+ const goalFile = join(rootDir, '.dw', 'goals', goalId, 'goal.md');
1929
+ if (!existsSync(goalFile)) {
1930
+ res.writeHead(404); res.end(JSON.stringify({ ok: false, error: `goal not found: ${goalId}` })); return;
1931
+ }
1932
+ const lang = (parsed.lang || 'en').toString().startsWith('vi') ? 'vi' : 'en';
1933
+ const budgetOpts = {
1934
+ max_iterations: parsed.max_iterations,
1935
+ max_minutes: parsed.max_minutes,
1936
+ lang,
1937
+ };
1938
+ // ST-8: sweep stale claims first so re-Start on a goal whose
1939
+ // previous driver exited cleanly works without manual cleanup.
1940
+ reconcileGoalDrivers(rootDir);
1941
+ const claimResult = claimGoal(goalId, null, budgetOpts, rootDir);
1942
+ if (!claimResult.ok) {
1943
+ const code = claimResult.reason === 'already_claimed' ? 409 : 400;
1944
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1945
+ res.end(JSON.stringify({ ok: false, error: claimResult.reason, existing: claimResult.existing || null }));
1946
+ return;
1947
+ }
1948
+ const claim = claimResult.claim;
1949
+ const prompt = buildDriverPrompt(goalId, budgetOpts, rootDir);
1950
+ let spawnResult;
1951
+ try {
1952
+ spawnResult = await executeAction({
1953
+ name: 'start_session',
1954
+ args: { agent: 'claude', goal: prompt },
1955
+ rootDir,
1956
+ lang: lang === 'vi' ? 'vi-VN' : 'en-US',
1957
+ });
1958
+ } catch (e) {
1959
+ releaseGoal(goalId, rootDir);
1960
+ res.writeHead(500); res.end(JSON.stringify({ ok: false, error: `spawn failed: ${e.message}` }));
1961
+ return;
1962
+ }
1963
+ if (!spawnResult.ok) {
1964
+ releaseGoal(goalId, rootDir);
1965
+ res.writeHead(500); res.end(JSON.stringify({ ok: false, error: spawnResult.display || 'spawn failed' }));
1966
+ return;
1967
+ }
1968
+ const sessionId = spawnResult.data && spawnResult.data.id;
1969
+ if (sessionId) attachSessionId(goalId, sessionId, rootDir);
1970
+ logGoalEvent({
1971
+ event: 'goal_driver_started',
1972
+ goal_id: goalId,
1973
+ session_id: sessionId || null,
1974
+ claim_id: claim.claim_id,
1975
+ max_iterations: claim.max_iterations,
1976
+ max_minutes: claim.max_minutes,
1977
+ lang,
1978
+ }, rootDir);
1979
+ // Surface live progress: watch the linked task.md and emit a
1980
+ // goal_driver_subtask_progress event whenever Section 3 status
1981
+ // icons change. The events flow through the existing SSE broker
1982
+ // (events-global.jsonl watcher) to the board UI.
1983
+ const watchResult = startProgressWatcher(goalId, rootDir, ({ changes, snapshot }) => {
1984
+ const done = snapshot.filter((s) => s.status_bucket === 'done').length;
1985
+ const total = snapshot.length;
1986
+ const current = snapshot.find((s) => s.status_bucket === 'in_progress')
1987
+ || snapshot.find((s) => s.status_bucket === 'pending');
1988
+ logGoalEvent({
1989
+ event: 'goal_driver_subtask_progress',
1990
+ goal_id: goalId,
1991
+ session_id: sessionId || null,
1992
+ claim_id: claim.claim_id,
1993
+ changes,
1994
+ done,
1995
+ total,
1996
+ current: current ? { st_id: current.st_id, title: current.title.slice(0, 120) } : null,
1997
+ }, rootDir);
1998
+ });
1999
+ if (!watchResult.ok && watchResult.reason !== 'no_linked_task') {
2000
+ logGoalEvent({
2001
+ event: 'goal_driver_watch_failed',
2002
+ goal_id: goalId,
2003
+ claim_id: claim.claim_id,
2004
+ reason: watchResult.reason,
2005
+ }, rootDir);
2006
+ }
2007
+ // Wall-clock kill switch — the cap inside the prompt is advisory;
2008
+ // this setTimeout is the enforcer. Released claim + SIGTERM on
2009
+ // fire; unref so an idle server doesn't keep the process alive
2010
+ // just for the timer.
2011
+ const killTimer = setTimeout(() => {
2012
+ try {
2013
+ const s = sessionId ? getSession(sessionId, rootDir) : null;
2014
+ if (s && s.pid && isAlive(s.pid)) {
2015
+ // F-50 fix: tree-kill so the .cmd wrapper's claude.exe
2016
+ // grandchild on Win32 doesn't survive as an orphan.
2017
+ const killResult = killProcessTree(s.pid, 'SIGTERM');
2018
+ updateSessionStatus(sessionId, { status: 'stopped' }, rootDir);
2019
+ appendEvent(sessionId, {
2020
+ event: 'stopped', signal: 'SIGTERM',
2021
+ by: 'goal-driver-budget', kill_method: killResult.method,
2022
+ }, rootDir);
2023
+ }
2024
+ logGoalEvent({
2025
+ event: 'goal_driver_terminated',
2026
+ goal_id: goalId,
2027
+ session_id: sessionId || null,
2028
+ claim_id: claim.claim_id,
2029
+ reason: 'budget_exhausted',
2030
+ }, rootDir);
2031
+ } catch { /* best-effort */ }
2032
+ stopProgressWatcher(goalId);
2033
+ releaseGoal(goalId, rootDir);
2034
+ }, claim.max_minutes * 60_000);
2035
+ if (typeof killTimer.unref === 'function') killTimer.unref();
2036
+
2037
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2038
+ res.end(JSON.stringify({
2039
+ ok: true,
2040
+ session_id: sessionId,
2041
+ claim_id: claim.claim_id,
2042
+ goal_id: goalId,
2043
+ budget: { max_iterations: claim.max_iterations, max_minutes: claim.max_minutes },
2044
+ expires_at: claim.expires_at,
2045
+ lang,
2046
+ }));
2047
+ });
2048
+ return;
2049
+ }
2050
+
1689
2051
  // F-49 Đợt 2: Kanban board JSON + HTML page.
1690
2052
  if (req.method === 'GET' && req.url.startsWith('/voice/board')) {
1691
2053
  const urlObj = new URL(req.url, `http://localhost:${port}`);
@@ -1695,6 +2057,10 @@ export async function voiceCommand(opts = {}) {
1695
2057
  return;
1696
2058
  }
1697
2059
  try {
2060
+ // ST-8: lazy reconcile drops stale claims whose session has
2061
+ // exited cleanly. Without this, "Running…" sticks until the
2062
+ // wall-clock TTL fires (up to max_minutes after natural exit).
2063
+ reconcileGoalDrivers(rootDir);
1698
2064
  const data = buildBoardData(rootDir);
1699
2065
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1700
2066
  res.end(JSON.stringify({ ok: true, data }, null, 2));
@@ -1704,6 +2070,69 @@ export async function voiceCommand(opts = {}) {
1704
2070
  }
1705
2071
  return;
1706
2072
  }
2073
+ // goal-driver-mvp ST-8: POST /voice/goal-stop kills the driver
2074
+ // session (tree-kill so the Win32 cmd.exe→claude.exe grandchild
2075
+ // dies too), releases the claim, stops the watcher, and logs a
2076
+ // goal_driver_stopped event for forensics.
2077
+ if (req.method === 'POST' && req.url === '/voice/goal-stop') {
2078
+ if (req.headers['x-voice-token'] !== token) {
2079
+ res.writeHead(401, { 'Content-Type': 'application/json' });
2080
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
2081
+ return;
2082
+ }
2083
+ let body = '';
2084
+ req.setEncoding('utf8');
2085
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
2086
+ req.on('end', () => {
2087
+ let parsed;
2088
+ try { parsed = JSON.parse(body); }
2089
+ catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'bad json' })); return; }
2090
+ const goalId = (parsed.goal_id || '').trim();
2091
+ if (!/^G-[A-Za-z0-9_-]{1,64}$/.test(goalId)) {
2092
+ res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'invalid goal_id' })); return;
2093
+ }
2094
+ const claim = readClaim(goalId, rootDir);
2095
+ if (!claim) {
2096
+ res.writeHead(404); res.end(JSON.stringify({ ok: false, error: 'not_driving' })); return;
2097
+ }
2098
+ let killResult = { ok: true, method: 'no_session' };
2099
+ let sessionStatus = 'unknown';
2100
+ if (claim.session_id) {
2101
+ const s = getSession(claim.session_id, rootDir);
2102
+ if (s) {
2103
+ sessionStatus = s.status;
2104
+ if (s.pid && isAlive(s.pid)) {
2105
+ killResult = killProcessTree(s.pid, 'SIGTERM');
2106
+ updateSessionStatus(claim.session_id, { status: 'stopped' }, rootDir);
2107
+ appendEvent(claim.session_id, {
2108
+ event: 'stopped', signal: 'SIGTERM',
2109
+ by: 'goal-driver-stop-button', kill_method: killResult.method,
2110
+ }, rootDir);
2111
+ }
2112
+ }
2113
+ }
2114
+ stopProgressWatcher(goalId);
2115
+ releaseGoal(goalId, rootDir);
2116
+ logGoalEvent({
2117
+ event: 'goal_driver_stopped',
2118
+ goal_id: goalId,
2119
+ session_id: claim.session_id || null,
2120
+ claim_id: claim.claim_id,
2121
+ kill_method: killResult.method,
2122
+ kill_ok: killResult.ok,
2123
+ prior_session_status: sessionStatus,
2124
+ by: 'stop_button',
2125
+ }, rootDir);
2126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2127
+ res.end(JSON.stringify({
2128
+ ok: true,
2129
+ goal_id: goalId,
2130
+ kill_method: killResult.method,
2131
+ session_id: claim.session_id || null,
2132
+ }));
2133
+ });
2134
+ return;
2135
+ }
1707
2136
  if (req.method === 'GET' && (req.url === '/board' || req.url.startsWith('/board?'))) {
1708
2137
  // Token authorization happens in JSON fetch — the HTML itself is
1709
2138
  // public scaffolding (matches the / behavior).
@@ -25,8 +25,10 @@
25
25
 
26
26
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
27
27
  import { join, basename } from 'node:path';
28
- import yaml from 'js-yaml';
29
28
  import { listSessions } from './session-store.mjs';
29
+ import { readClaim } from './goal-driver.mjs';
30
+ import { computeGoalProgress } from './goal-progress.mjs';
31
+ import { parseSubtasks, readFrontmatter, listTaskDirs } from './task-md-utils.mjs';
30
32
 
31
33
  const GOALS_INDEX_PATH = '.dw/goals/goals-index.json';
32
34
  const GOALS_DIR = '.dw/goals';
@@ -42,62 +44,9 @@ function safeReadGoalsIndex(rootDir) {
42
44
  try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { goals: {} }; }
43
45
  }
44
46
 
45
- // Extract frontmatter (between --- markers) from a markdown file. Returns
46
- // an object via js-yaml, or {} if absent / malformed.
47
- function readFrontmatter(file) {
48
- if (!existsSync(file)) return {};
49
- let txt;
50
- try { txt = readFileSync(file, 'utf8'); } catch { return {}; }
51
- const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
52
- if (!m) return {};
53
- try { return yaml.load(m[1]) || {}; } catch { return {}; }
54
- }
55
-
56
- // Section 3 Subtask Tracker parser. Matches the `| ST-N | title | status | date | notes |`
57
- // row pattern used across the repo. Returns an array of subtask rows.
58
- const STATUS_ICONS = {
59
- '⬜': 'pending',
60
- '🟡': 'in_progress',
61
- '✅': 'done',
62
- '🔴': 'blocked',
63
- '⏸': 'paused',
64
- };
65
- function parseSubtasks(taskPath) {
66
- if (!existsSync(taskPath)) return [];
67
- let txt;
68
- try { txt = readFileSync(taskPath, 'utf8'); } catch { return []; }
69
- const sec = txt.match(/^## 3\.[^\n]*\n([\s\S]*?)(?=^## 4\.|$(?![\s\S]))/m);
70
- if (!sec) return [];
71
- const out = [];
72
- for (const line of sec[1].split('\n')) {
73
- // | ST-1 | "Subtask title" | ✅ Done | 2026-05-25 | notes |
74
- const m = line.match(/^\|\s*(ST-[\w.-]+)\s*\|\s*(.+?)\s*\|\s*([⬜🟡✅🔴⏸])\s*([A-Za-z ]+)?\s*\|\s*([^|]*)\|\s*([^|]*)\|/);
75
- if (!m) continue;
76
- const icon = m[3];
77
- const statusBucket = STATUS_ICONS[icon] || 'unknown';
78
- const statusLabel = (m[4] || '').trim();
79
- out.push({
80
- st_id: m[1].trim(),
81
- title: m[2].replace(/`/g, '').trim(),
82
- status_bucket: statusBucket,
83
- status_icon: icon,
84
- status_label: statusLabel,
85
- date: m[5].trim(),
86
- notes: m[6].trim().slice(0, 160),
87
- });
88
- }
89
- return out;
90
- }
91
-
92
- function listTaskDirs(rootDir) {
93
- const dir = join(rootDir, TASKS_DIR);
94
- if (!existsSync(dir)) return [];
95
- return readdirSync(dir)
96
- .filter((entry) => {
97
- if (entry.startsWith('.') || entry === 'archive') return false;
98
- try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
99
- });
100
- }
47
+ // Parsers (parseSubtasks / readFrontmatter / listTaskDirs) live in
48
+ // task-md-utils.mjs so goal-progress.mjs can share them without forming a
49
+ // circular import via board-data.mjs.
101
50
 
102
51
  // ─ Public API ──────────────────────────────────────────────────────────────
103
52
 
@@ -145,6 +94,10 @@ export function buildBoardData(rootDir, opts = {}) {
145
94
  for (const [goal_id, g] of goalsIndexEntries) {
146
95
  if (g.archived_at) continue; // hide archived
147
96
  const tasks = (byGoal.get(goal_id) || []).slice(0, MAX_TASKS_PER_GOAL);
97
+ // goal-driver-mvp ST-6: attach live claim state (or null) so the board
98
+ // UI can render "Running…" disabled state on the Start button without
99
+ // a second roundtrip. readClaim auto-expires stale entries.
100
+ const claim = readClaim(goal_id, rootDir);
148
101
  goals.push({
149
102
  goal_id,
150
103
  title: g.title || goal_id,
@@ -157,6 +110,18 @@ export function buildBoardData(rootDir, opts = {}) {
157
110
  last_updated: g.last_updated || null,
158
111
  linked_task_ids: g.linked_task_ids || [],
159
112
  tasks,
113
+ claim: claim ? {
114
+ claim_id: claim.claim_id,
115
+ session_id: claim.session_id || null,
116
+ claimed_at: claim.claimed_at,
117
+ expires_at: claim.expires_at,
118
+ max_iterations: claim.max_iterations,
119
+ max_minutes: claim.max_minutes,
120
+ // ST-7 (goal-driver-mvp v2): live progress derived from the linked
121
+ // task.md Section 3 + last goal_driver_* event timestamp. null when
122
+ // no linked task is wired up.
123
+ progress: claim ? computeGoalProgress(goal_id, rootDir) : null,
124
+ } : null,
160
125
  });
161
126
  }
162
127
 
@@ -197,6 +162,7 @@ export function buildBoardData(rootDir, opts = {}) {
197
162
  subtasks_blocked: 0,
198
163
  sessions_running: sessions.filter((s) => s.status === 'running').length,
199
164
  sessions_total: sessions.length,
165
+ goals_driving: goals.filter((g) => g.claim).length,
200
166
  };
201
167
  for (const t of allTasks) {
202
168
  for (const st of t.subtasks) {