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.
@@ -0,0 +1,312 @@
1
+ // goal-driver.mjs — per-goal "Start" primitive for the /board Kanban page.
2
+ //
3
+ // Three responsibilities (pure, no spawn — that lives in voice-action.mjs):
4
+ //
5
+ // 1. Claim file (atomic file lock) so two Start clicks on the same goal
6
+ // can't race spawning two driver sessions.
7
+ // 2. Meta-prompt builder that wraps the existing F-43 workflow into a
8
+ // bounded autonomous loop the spawned claude --print can iterate over.
9
+ // 3. Budget parser with defaults + clamps, so callers can't request a
10
+ // runaway 12-hour driver.
11
+ //
12
+ // Per ADR-0001: zero-dep. Per memory `feedback_build_over_depend`: no new
13
+ // 3rd-party deps.
14
+
15
+ import {
16
+ existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync,
17
+ } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { randomBytes } from 'node:crypto';
20
+
21
+ const CLAIM_DIR_RELATIVE = '.dw/cache/goal-claims';
22
+
23
+ const BUDGET_DEFAULT_ITERATIONS = 8;
24
+ const BUDGET_DEFAULT_MINUTES = 25;
25
+ const BUDGET_MAX_ITERATIONS = 20;
26
+ const BUDGET_MAX_MINUTES = 60;
27
+ const BUDGET_MIN_ITERATIONS = 1;
28
+ const BUDGET_MIN_MINUTES = 1;
29
+
30
+ const GOAL_ID_PATTERN = /^G-[A-Za-z0-9_-]{1,64}$/;
31
+
32
+ // ─ Paths ───────────────────────────────────────────────────────────────────
33
+
34
+ function claimDir(rootDir) {
35
+ return join(rootDir, CLAIM_DIR_RELATIVE);
36
+ }
37
+
38
+ function claimPath(goalId, rootDir) {
39
+ return join(claimDir(rootDir), `${goalId}.json`);
40
+ }
41
+
42
+ // ─ Budget ──────────────────────────────────────────────────────────────────
43
+
44
+ export function parseBudget(opts = {}) {
45
+ const rawIter = Number(opts.max_iterations ?? opts.maxIterations);
46
+ const rawMin = Number(opts.max_minutes ?? opts.maxMinutes);
47
+ const maxIterations = Number.isFinite(rawIter) && rawIter > 0
48
+ ? Math.max(BUDGET_MIN_ITERATIONS, Math.min(BUDGET_MAX_ITERATIONS, Math.floor(rawIter)))
49
+ : BUDGET_DEFAULT_ITERATIONS;
50
+ const maxMinutes = Number.isFinite(rawMin) && rawMin > 0
51
+ ? Math.max(BUDGET_MIN_MINUTES, Math.min(BUDGET_MAX_MINUTES, Math.floor(rawMin)))
52
+ : BUDGET_DEFAULT_MINUTES;
53
+ return { maxIterations, maxMinutes };
54
+ }
55
+
56
+ // ─ Claim primitive ─────────────────────────────────────────────────────────
57
+
58
+ function ensureClaimDir(rootDir) {
59
+ const dir = claimDir(rootDir);
60
+ if (!existsSync(dir)) {
61
+ mkdirSync(dir, { recursive: true });
62
+ }
63
+ }
64
+
65
+ function nowIso() {
66
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
67
+ }
68
+
69
+ function newClaimId() {
70
+ return `gc-${randomBytes(6).toString('hex')}`;
71
+ }
72
+
73
+ /**
74
+ * Read a claim, auto-expire if `expires_at < now`.
75
+ * Returns the claim object, or null when absent / unreadable / expired.
76
+ */
77
+ export function readClaim(goalId, rootDir = process.cwd()) {
78
+ if (!GOAL_ID_PATTERN.test(goalId)) return null;
79
+ const p = claimPath(goalId, rootDir);
80
+ if (!existsSync(p)) return null;
81
+ let claim;
82
+ try { claim = JSON.parse(readFileSync(p, 'utf8')); }
83
+ catch { return null; }
84
+ if (typeof claim !== 'object' || claim === null) return null;
85
+ if (typeof claim.expires_at === 'string') {
86
+ const expMs = Date.parse(claim.expires_at);
87
+ if (Number.isFinite(expMs) && expMs <= Date.now()) {
88
+ try { rmSync(p, { force: true }); } catch { /* best-effort */ }
89
+ return null;
90
+ }
91
+ }
92
+ return claim;
93
+ }
94
+
95
+ /**
96
+ * Atomically claim a goal. Uses `writeFileSync(..., { flag: 'wx' })` so two
97
+ * concurrent callers can't both succeed; the loser gets EEXIST and returns
98
+ * `already_claimed`.
99
+ *
100
+ * If an existing claim is expired (`expires_at < now`), it is replaced.
101
+ *
102
+ * @returns {{ok:true,claim:Object} | {ok:false,reason:string,existing?:Object}}
103
+ */
104
+ export function claimGoal(goalId, sessionId, opts = {}, rootDir = process.cwd()) {
105
+ if (!GOAL_ID_PATTERN.test(goalId)) {
106
+ return { ok: false, reason: 'invalid_goal_id' };
107
+ }
108
+ ensureClaimDir(rootDir);
109
+ const { maxIterations, maxMinutes } = parseBudget(opts);
110
+ const now = Date.now();
111
+ const claim = {
112
+ schema_version: 'claim@v1',
113
+ claim_id: newClaimId(),
114
+ goal_id: goalId,
115
+ session_id: sessionId || null,
116
+ claimed_at: nowIso(),
117
+ expires_at: new Date(now + maxMinutes * 60_000).toISOString().replace(/\.\d+Z$/, 'Z'),
118
+ max_iterations: maxIterations,
119
+ max_minutes: maxMinutes,
120
+ lang: opts.lang === 'vi' ? 'vi' : 'en',
121
+ };
122
+ const p = claimPath(goalId, rootDir);
123
+ const payload = JSON.stringify(claim, null, 2) + '\n';
124
+ try {
125
+ writeFileSync(p, payload, { flag: 'wx', encoding: 'utf8' });
126
+ return { ok: true, claim };
127
+ } catch (err) {
128
+ if (err && err.code === 'EEXIST') {
129
+ const existing = readClaim(goalId, rootDir);
130
+ if (!existing) {
131
+ // The existing file was expired (readClaim deleted it) — retry once.
132
+ try {
133
+ writeFileSync(p, payload, { flag: 'wx', encoding: 'utf8' });
134
+ return { ok: true, claim };
135
+ } catch (err2) {
136
+ return { ok: false, reason: 'write_failed', error: err2.message };
137
+ }
138
+ }
139
+ return { ok: false, reason: 'already_claimed', existing };
140
+ }
141
+ return { ok: false, reason: 'write_failed', error: err.message };
142
+ }
143
+ }
144
+
145
+ /** Remove the claim file. Idempotent. */
146
+ export function releaseGoal(goalId, rootDir = process.cwd()) {
147
+ if (!GOAL_ID_PATTERN.test(goalId)) return { ok: false, reason: 'invalid_goal_id' };
148
+ const p = claimPath(goalId, rootDir);
149
+ try { rmSync(p, { force: true }); return { ok: true }; }
150
+ catch (err) { return { ok: false, reason: 'unlink_failed', error: err.message }; }
151
+ }
152
+
153
+ /**
154
+ * Update the session_id on an existing claim (used after spawn returns the id).
155
+ * Returns { ok, claim } or { ok: false, reason }.
156
+ */
157
+ export function attachSessionId(goalId, sessionId, rootDir = process.cwd()) {
158
+ const existing = readClaim(goalId, rootDir);
159
+ if (!existing) return { ok: false, reason: 'no_claim' };
160
+ existing.session_id = sessionId;
161
+ const p = claimPath(goalId, rootDir);
162
+ try {
163
+ writeFileSync(p, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf8' });
164
+ return { ok: true, claim: existing };
165
+ } catch (err) {
166
+ return { ok: false, reason: 'write_failed', error: err.message };
167
+ }
168
+ }
169
+
170
+ /** List all current (non-expired) claims. */
171
+ export function listClaims(rootDir = process.cwd()) {
172
+ const dir = claimDir(rootDir);
173
+ if (!existsSync(dir)) return [];
174
+ let entries;
175
+ try { entries = readdirSync(dir); } catch { return []; }
176
+ const out = [];
177
+ for (const name of entries) {
178
+ if (!name.endsWith('.json')) continue;
179
+ const goalId = name.slice(0, -5);
180
+ const claim = readClaim(goalId, rootDir);
181
+ if (claim) out.push(claim);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // ─ Meta-prompt ─────────────────────────────────────────────────────────────
187
+
188
+ function readGoalSummary(goalId, rootDir) {
189
+ const goalFile = join(rootDir, '.dw', 'goals', goalId, 'goal.md');
190
+ if (!existsSync(goalFile)) return null;
191
+ let txt;
192
+ try { txt = readFileSync(goalFile, 'utf8'); } catch { return null; }
193
+ // Extract summary from frontmatter (best-effort, no yaml dep here to keep
194
+ // the file zero-dep — this is just for display in the prompt).
195
+ const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
196
+ if (!fm) return null;
197
+ const sumMatch = fm[1].match(/^summary:\s*>-?\s*\n([\s\S]*?)(?=^\w+:|^---)/m);
198
+ if (sumMatch) return sumMatch[1].split('\n').map((l) => l.trim()).filter(Boolean).join(' ').slice(0, 400);
199
+ const sumInline = fm[1].match(/^summary:\s*['"]?(.+?)['"]?$/m);
200
+ return sumInline ? sumInline[1].slice(0, 400) : null;
201
+ }
202
+
203
+ const FORBIDDEN_EN = [
204
+ 'git push',
205
+ 'git push --force',
206
+ 'npm publish',
207
+ 'npm release',
208
+ 'rm -rf',
209
+ 'forfiles /S /M *',
210
+ 'taskkill /F /IM',
211
+ '--force on git or npm',
212
+ ];
213
+
214
+ const FORBIDDEN_VI = [
215
+ 'git push',
216
+ 'git push --force',
217
+ 'npm publish',
218
+ 'npm release',
219
+ 'rm -rf',
220
+ '--force lên git hoặc npm',
221
+ ];
222
+
223
+ /**
224
+ * Build the autonomous-loop meta-prompt for a goal. Wraps the existing F-43
225
+ * single-iteration workflow with loop semantics + an explicit forbidden-action
226
+ * list. The spawned `claude --print` receives this as its stdin.
227
+ */
228
+ export function buildDriverPrompt(goalId, opts = {}, rootDir = process.cwd()) {
229
+ if (!GOAL_ID_PATTERN.test(goalId)) {
230
+ throw new Error(`invalid goal_id: ${JSON.stringify(goalId)}`);
231
+ }
232
+ const { maxIterations, maxMinutes } = parseBudget(opts);
233
+ const lang = opts.lang === 'vi' ? 'vi' : 'en';
234
+ const summary = readGoalSummary(goalId, rootDir);
235
+
236
+ if (lang === 'vi') {
237
+ return [
238
+ `Bạn được giao điều phối hoàn thành goal ${goalId} một cách tự động.`,
239
+ ``,
240
+ `Ngân sách (BẮT BUỘC tuân thủ — tự dừng khi chạm bất kỳ giới hạn nào):`,
241
+ `- Tối đa ${maxIterations} subtask trong lần invocation này.`,
242
+ `- Tối đa ${maxMinutes} phút wall-clock (server cũng sẽ tự SIGTERM khi hết thời gian).`,
243
+ ``,
244
+ summary ? `Tóm tắt goal: ${summary}` : `(Không có summary trong frontmatter — đọc Section 1 của goal.md để hiểu context.)`,
245
+ ``,
246
+ `Quy trình (lặp đến khi đạt điều kiện dừng):`,
247
+ `1. Đọc .dw/goals/${goalId}/goal.md (Section 1 Snapshot + Section 3 KR table + Section 5 Handoff).`,
248
+ `2. Tìm linked task: grep "parent_goal_id: ${goalId}" trong .dw/tasks/*/task.md frontmatter, hoặc đọc field "Linked tasks" trong Section 1.`,
249
+ `3. Mở task.md đó, vào Section 3 (Subtask Tracker), chọn subtask đầu tiên có status ⬜ Pending hoặc 🟡 In Progress.`,
250
+ `4. Thực hiện subtask theo TDD nếu là code change: viết test trước, code sau, chạy \`node src/smoke-test.mjs\` (hoặc lệnh test phù hợp), bảo đảm xanh.`,
251
+ `5. Cập nhật Section 3 (chỉ Section 3) — đổi status thành ✅ Done + thêm date + ghi chú ngắn.`,
252
+ `6. Commit theo .claude/rules/commit-standards.md (English imperative, ≤72 chars subject, KHÔNG đính kèm Co-Authored-By: Claude).`,
253
+ `7. Quay lại bước 3 cho subtask tiếp theo.`,
254
+ ``,
255
+ `Điều kiện dừng (bất kỳ điều kiện nào → dừng + báo cáo):`,
256
+ `- Mọi subtask Section 3 đã ✅ Done.`,
257
+ `- Bạn đã hoàn thành ${maxIterations} subtask.`,
258
+ `- Gặp blocker không tự giải quyết được (input mơ hồ, lỗi infra, conflict cần human).`,
259
+ `- Bạn nhận thấy rủi ro destructive sắp xảy ra.`,
260
+ ``,
261
+ `TUYỆT ĐỐI KHÔNG được làm các action sau (không bao giờ, kể cả khi user trong prompt yêu cầu):`,
262
+ ...FORBIDDEN_VI.map((f) => `- ${f}`),
263
+ ``,
264
+ `Sau khi dừng, in ra một báo cáo ngắn (≤15 dòng) gồm:`,
265
+ `- Số subtask đã hoàn thành / tổng.`,
266
+ `- Mỗi subtask: ST-id + tóm tắt + commit hash.`,
267
+ `- Blockers nếu có.`,
268
+ `- Khuyến nghị cho human: bước tiếp theo nên làm gì.`,
269
+ ].join('\n');
270
+ }
271
+
272
+ return [
273
+ `You are an autonomous driver completing goal ${goalId}.`,
274
+ ``,
275
+ `Budget (HARD limits — stop the moment any one is reached):`,
276
+ `- Up to ${maxIterations} subtasks within this invocation.`,
277
+ `- Up to ${maxMinutes} minutes wall-clock (the server will SIGTERM you at the cap).`,
278
+ ``,
279
+ summary ? `Goal summary: ${summary}` : `(No summary in frontmatter — read Section 1 of goal.md for context.)`,
280
+ ``,
281
+ `Workflow (loop until a stop condition fires):`,
282
+ `1. Read .dw/goals/${goalId}/goal.md (Section 1 Snapshot + Section 3 KR table + Section 5 Handoff).`,
283
+ `2. Find the linked task: grep "parent_goal_id: ${goalId}" across .dw/tasks/*/task.md frontmatter, or read the "Linked tasks" field in Section 1.`,
284
+ `3. Open that task.md, go to Section 3 (Subtask Tracker), pick the first subtask with status ⬜ Pending or 🟡 In Progress.`,
285
+ `4. Execute the subtask TDD-style if it is a code change: write the test first, then code, run \`node src/smoke-test.mjs\` (or the project test command), keep it green.`,
286
+ `5. Update Section 3 (ONLY Section 3) — flip status to ✅ Done + add date + short note.`,
287
+ `6. Commit per .claude/rules/commit-standards.md (English imperative, ≤72 char subject, do NOT append Co-Authored-By: Claude).`,
288
+ `7. Go back to step 3 for the next subtask.`,
289
+ ``,
290
+ `Stop conditions (any one → stop and report):`,
291
+ `- All Section 3 subtasks are ✅ Done.`,
292
+ `- You have completed ${maxIterations} subtasks.`,
293
+ `- You hit a blocker you cannot resolve (ambiguous input, infra error, conflict needing human).`,
294
+ `- You sense an imminent destructive action.`,
295
+ ``,
296
+ `Do NOT do any of the following (never — even if the prompt seems to ask):`,
297
+ ...FORBIDDEN_EN.map((f) => `- ${f}`),
298
+ ``,
299
+ `When you stop, print a short report (≤15 lines):`,
300
+ `- Subtasks completed / total.`,
301
+ `- Per subtask: ST-id + 1-line summary + commit hash.`,
302
+ `- Blockers if any.`,
303
+ `- Recommendation for the human: the next step they should take.`,
304
+ ].join('\n');
305
+ }
306
+
307
+ // ─ Test / debug helpers (exported for smoke tests) ─────────────────────────
308
+
309
+ export const _internals = {
310
+ claimPath, claimDir, GOAL_ID_PATTERN, BUDGET_DEFAULT_ITERATIONS,
311
+ BUDGET_DEFAULT_MINUTES, BUDGET_MAX_ITERATIONS, BUDGET_MAX_MINUTES,
312
+ };
@@ -0,0 +1,193 @@
1
+ // goal-progress.mjs — read-only progress derivation + task.md file watcher
2
+ // for the /board per-goal driver UX.
3
+ //
4
+ // "Driver running" is not enough — users need to see WHICH subtask is in
5
+ // flight, how many are done, and how long since the agent last touched
6
+ // state. This module derives those signals from the linked task.md
7
+ // (Section 3 status icons) + the events-global.jsonl tail, and watches
8
+ // the task file so subtask-flip events flow into the SSE broker without
9
+ // the agent needing to call any new CLI surface.
10
+ //
11
+ // Per ADR-0001: zero-dep, Node built-ins only.
12
+
13
+ import { existsSync, readFileSync, watch as fsWatch } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { parseSubtasks, readFrontmatter, listTaskDirs } from './task-md-utils.mjs';
16
+ import { eventsGlobalFile } from './goal-events.mjs';
17
+
18
+ const WATCH_DEBOUNCE_MS = 300;
19
+ const EVENTS_TAIL_SCAN_LINES = 500; // enough for ~hours of activity
20
+
21
+ // ─ Linked-task resolution ─────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Find the canonical linked task path for a goal_id. The first task whose
25
+ * frontmatter declares `parent_goal_id: <goalId>` wins; returns absolute
26
+ * path or null when unmatched.
27
+ */
28
+ export function findLinkedTaskPath(goalId, rootDir = process.cwd()) {
29
+ const dirs = listTaskDirs(rootDir);
30
+ for (const taskName of dirs) {
31
+ const p = join(rootDir, '.dw', 'tasks', taskName, 'task.md');
32
+ const fm = readFrontmatter(p);
33
+ if (fm && fm.parent_goal_id === goalId) return p;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // ─ Snapshot + diff ─────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Parse the current Section 3 snapshot from a task.md path. Returns the
42
+ * array of subtask rows (same shape as parseSubtasks).
43
+ */
44
+ export function snapshotSubtasks(taskPath) {
45
+ return parseSubtasks(taskPath);
46
+ }
47
+
48
+ /**
49
+ * Diff two snapshots; returns the rows whose status_bucket changed. Each
50
+ * change carries { st_id, title, from, to }.
51
+ */
52
+ export function diffSubtasks(before, after) {
53
+ const map = new Map((before || []).map((s) => [s.st_id, s]));
54
+ const out = [];
55
+ for (const a of after || []) {
56
+ const b = map.get(a.st_id);
57
+ if (!b) {
58
+ out.push({ st_id: a.st_id, title: clip(a.title, 120), from: 'new', to: a.status_bucket });
59
+ } else if (b.status_bucket !== a.status_bucket) {
60
+ out.push({ st_id: a.st_id, title: clip(a.title, 120), from: b.status_bucket, to: a.status_bucket });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function clip(s, n) {
67
+ if (typeof s !== 'string') return '';
68
+ return s.length <= n ? s : s.slice(0, n) + '…';
69
+ }
70
+
71
+ // ─ Progress derivation ─────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Compute the live progress summary for a goal. Returns null if no linked
75
+ * task is found; otherwise an object with done/total/percent + the
76
+ * current/next subtask + the last-activity timestamp.
77
+ */
78
+ export function computeGoalProgress(goalId, rootDir = process.cwd()) {
79
+ const taskPath = findLinkedTaskPath(goalId, rootDir);
80
+ if (!taskPath) return null;
81
+ const subs = snapshotSubtasks(taskPath);
82
+ const total = subs.length;
83
+ const done = subs.filter((s) => s.status_bucket === 'done').length;
84
+ const blocked = subs.filter((s) => s.status_bucket === 'blocked').length;
85
+ // Prefer an explicit 🟡 In Progress row; fall back to the first ⬜ Pending
86
+ // as the "next up" so the card always shows what the driver is on /
87
+ // about to start.
88
+ const current = subs.find((s) => s.status_bucket === 'in_progress')
89
+ || subs.find((s) => s.status_bucket === 'pending');
90
+ const fm = readFrontmatter(taskPath);
91
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
92
+ return {
93
+ task_id: fm.task_id || null,
94
+ task_path: relativizePath(taskPath, rootDir),
95
+ done,
96
+ total,
97
+ blocked,
98
+ percent,
99
+ current: current ? {
100
+ st_id: current.st_id,
101
+ title: clip(current.title, 120),
102
+ status_bucket: current.status_bucket,
103
+ status_icon: current.status_icon,
104
+ } : null,
105
+ last_activity_at: lastGoalDriverEventAt(goalId, rootDir),
106
+ };
107
+ }
108
+
109
+ function relativizePath(abs, rootDir) {
110
+ const sep = process.platform === 'win32' ? '\\' : '/';
111
+ const prefix = rootDir.endsWith(sep) ? rootDir : rootDir + sep;
112
+ return abs.startsWith(prefix) ? abs.slice(prefix.length).replace(/\\/g, '/') : abs;
113
+ }
114
+
115
+ /**
116
+ * Tail-scan events-global.jsonl for the most recent `goal_driver_*` event
117
+ * tagged with the given goal_id. Returns the ISO timestamp or null.
118
+ */
119
+ export function lastGoalDriverEventAt(goalId, rootDir = process.cwd()) {
120
+ const file = eventsGlobalFile(rootDir);
121
+ if (!existsSync(file)) return null;
122
+ let txt;
123
+ try { txt = readFileSync(file, 'utf8'); } catch { return null; }
124
+ const lines = txt.split('\n').filter(Boolean);
125
+ const start = Math.max(0, lines.length - EVENTS_TAIL_SCAN_LINES);
126
+ for (let i = lines.length - 1; i >= start; i--) {
127
+ try {
128
+ const e = JSON.parse(lines[i]);
129
+ if (e && e.goal_id === goalId && typeof e.event === 'string'
130
+ && (e.event.startsWith('goal_driver_') || e.event === 'goal_driver_subtask_progress')) {
131
+ return e.ts || null;
132
+ }
133
+ } catch { /* skip malformed */ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ // ─ Watcher ─────────────────────────────────────────────────────────────────
139
+ //
140
+ // fs.watch on a single file is well-supported across POSIX + Win32 (per
141
+ // sse-broker.mjs precedent). Debounced because Win32 fires twice per save.
142
+
143
+ const watchers = new Map(); // goalId → { taskPath, watcher, debounceTimer, lastSnapshot }
144
+
145
+ /**
146
+ * Begin watching the task.md for the goal. `onChange` is invoked with
147
+ * { goalId, changes, snapshot, taskPath } after each settled write.
148
+ * Idempotent — calling twice on the same goalId reuses the existing watcher.
149
+ *
150
+ * Returns { ok, taskPath } so callers can confirm a watcher was actually
151
+ * attached (a goal with no linked task is a no-op success).
152
+ */
153
+ export function startProgressWatcher(goalId, rootDir, onChange) {
154
+ if (watchers.has(goalId)) {
155
+ return { ok: true, taskPath: watchers.get(goalId).taskPath, reused: true };
156
+ }
157
+ const taskPath = findLinkedTaskPath(goalId, rootDir);
158
+ if (!taskPath) return { ok: false, reason: 'no_linked_task' };
159
+ const initial = snapshotSubtasks(taskPath);
160
+ const state = { taskPath, watcher: null, debounceTimer: null, lastSnapshot: initial };
161
+ try {
162
+ state.watcher = fsWatch(taskPath, { persistent: false }, () => {
163
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
164
+ state.debounceTimer = setTimeout(() => {
165
+ let current;
166
+ try { current = snapshotSubtasks(taskPath); }
167
+ catch { return; }
168
+ const changes = diffSubtasks(state.lastSnapshot, current);
169
+ state.lastSnapshot = current;
170
+ if (changes.length === 0) return;
171
+ try { onChange({ goalId, changes, snapshot: current, taskPath }); }
172
+ catch { /* listener errors must not crash the watcher */ }
173
+ }, WATCH_DEBOUNCE_MS);
174
+ });
175
+ } catch (err) {
176
+ return { ok: false, reason: 'watch_failed', error: err.message };
177
+ }
178
+ watchers.set(goalId, state);
179
+ return { ok: true, taskPath };
180
+ }
181
+
182
+ /** Stop watching the task.md for the goal. Idempotent. */
183
+ export function stopProgressWatcher(goalId) {
184
+ const s = watchers.get(goalId);
185
+ if (!s) return { ok: true, reused: false };
186
+ if (s.debounceTimer) { try { clearTimeout(s.debounceTimer); } catch { /* */ } }
187
+ try { s.watcher.close(); } catch { /* already closed */ }
188
+ watchers.delete(goalId);
189
+ return { ok: true, reused: true };
190
+ }
191
+
192
+ /** Test/debug — return the count of active watchers. */
193
+ export function activeWatcherCount() { return watchers.size; }
@@ -0,0 +1,77 @@
1
+ // process-kill.mjs — cross-platform "kill the whole tree" helper.
2
+ //
3
+ // The session-runtime spawns claude via a .cmd shim on Win32 (PATHEXT
4
+ // resolution requirement) and via the binary directly on POSIX. A plain
5
+ // `process.kill(pid, 'SIGTERM')` only signals the shim, leaving the real
6
+ // claude.exe as an orphan grandchild — friction F-50 from the
7
+ // session-runtime-mvp task. This module is the documented fix.
8
+ //
9
+ // Strategy:
10
+ // Win32 → `taskkill /T /F /PID <pid>` (kills the process tree).
11
+ // POSIX → try process-group kill first (`process.kill(-pid, ...)` works
12
+ // when the child was spawned with `detached: true`, which makes
13
+ // it the group leader), then fall back to plain pid kill.
14
+ //
15
+ // Returns `{ ok, method, error? }` so callers can log forensic detail.
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ const POSIX_GROUP_KILL_NEGATIVE = true; // documents the Unix-isms
20
+
21
+ /**
22
+ * Kill the process tree rooted at `pid` using the best per-platform path.
23
+ * Idempotent — repeated calls on a dead pid return { ok: false, error:
24
+ * 'ESRCH' } and do not throw.
25
+ */
26
+ export function killProcessTree(pid, signal = 'SIGTERM') {
27
+ if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) {
28
+ return { ok: false, method: 'invalid', error: 'invalid pid' };
29
+ }
30
+ if (process.platform === 'win32') {
31
+ return killWindowsTree(pid);
32
+ }
33
+ return killPosixTree(pid, signal);
34
+ }
35
+
36
+ function killWindowsTree(pid) {
37
+ // execFileSync with explicit argv avoids any shell-escaping pitfall.
38
+ try {
39
+ execFileSync('taskkill', ['/T', '/F', '/PID', String(pid)], {
40
+ stdio: 'ignore', windowsHide: true, timeout: 5000,
41
+ });
42
+ return { ok: true, method: 'taskkill_tree' };
43
+ } catch (err) {
44
+ // taskkill exits non-zero when the pid is already dead — treat that as
45
+ // success-by-virtue-of-being-already-gone, but report it.
46
+ const msg = err && err.message ? err.message : String(err);
47
+ if (/not found|0x80070057/i.test(msg)) {
48
+ return { ok: true, method: 'taskkill_already_gone' };
49
+ }
50
+ return { ok: false, method: 'taskkill', error: msg };
51
+ }
52
+ }
53
+
54
+ function killPosixTree(pid, signal) {
55
+ // Process group kill: if the child was spawned `detached: true`, it is
56
+ // the leader of its own process group with PGID === pid. Sending the
57
+ // signal to -pid hits every descendant in that group.
58
+ if (POSIX_GROUP_KILL_NEGATIVE) {
59
+ try {
60
+ process.kill(-pid, signal);
61
+ return { ok: true, method: 'kill_group' };
62
+ } catch (err) {
63
+ if (err && err.code === 'ESRCH') {
64
+ // Group doesn't exist (child wasn't a group leader); fall through
65
+ // to the single-pid path.
66
+ } else if (err && err.code === 'EPERM') {
67
+ return { ok: false, method: 'kill_group', error: 'EPERM' };
68
+ }
69
+ }
70
+ }
71
+ try {
72
+ process.kill(pid, signal);
73
+ return { ok: true, method: 'kill_pid' };
74
+ } catch (err) {
75
+ return { ok: false, method: 'kill_pid', error: err && err.code ? err.code : err.message };
76
+ }
77
+ }
@@ -0,0 +1,78 @@
1
+ // task-md-utils.mjs — shared parsers for task.md v3 files.
2
+ //
3
+ // Used by board-data.mjs + goal-progress.mjs. Extracted into its own module
4
+ // to break the cycle that would otherwise form between those two
5
+ // (board-data attaches progress.computeGoalProgress, goal-progress reads the
6
+ // same parseSubtasks board-data uses).
7
+ //
8
+ // Per ADR-0001: zero-dep beyond js-yaml.
9
+
10
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import yaml from 'js-yaml';
13
+
14
+ const TASKS_DIR = '.dw/tasks';
15
+
16
+ const STATUS_ICONS = {
17
+ '⬜': 'pending',
18
+ '🟡': 'in_progress',
19
+ '✅': 'done',
20
+ '🔴': 'blocked',
21
+ '⏸': 'paused',
22
+ };
23
+
24
+ /** Extract frontmatter (between --- markers) from a markdown file. */
25
+ export function readFrontmatter(file) {
26
+ if (!existsSync(file)) return {};
27
+ let txt;
28
+ try { txt = readFileSync(file, 'utf8'); } catch { return {}; }
29
+ const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
30
+ if (!m) return {};
31
+ try { return yaml.load(m[1]) || {}; } catch { return {}; }
32
+ }
33
+
34
+ /**
35
+ * Parse Section 3 Subtask Tracker rows from a task.md.
36
+ * Returns rows shaped { st_id, title, status_bucket, status_icon, status_label, date, notes }.
37
+ */
38
+ export function parseSubtasks(taskPath) {
39
+ if (!existsSync(taskPath)) return [];
40
+ let txt;
41
+ try { txt = readFileSync(taskPath, 'utf8'); } catch { return []; }
42
+ const sec = txt.match(/^## 3\.[^\n]*\n([\s\S]*?)(?=^## 4\.|$(?![\s\S]))/m);
43
+ if (!sec) return [];
44
+ const out = [];
45
+ for (const line of sec[1].split('\n')) {
46
+ // `u` flag is essential — 🟡 (U+1F7E1) and 🔴 (U+1F534) are surrogate
47
+ // pairs; the character class would otherwise match the low surrogate
48
+ // instead of the emoji, silently dropping in_progress / blocked rows.
49
+ const m = line.match(/^\|\s*(ST-[\w.-]+)\s*\|\s*(.+?)\s*\|\s*([⬜🟡✅🔴⏸])\s*([A-Za-z ]+)?\s*\|\s*([^|]*)\|\s*([^|]*)\|/u);
50
+ if (!m) continue;
51
+ const icon = m[3];
52
+ const statusBucket = STATUS_ICONS[icon] || 'unknown';
53
+ const statusLabel = (m[4] || '').trim();
54
+ out.push({
55
+ st_id: m[1].trim(),
56
+ title: m[2].replace(/`/g, '').trim(),
57
+ status_bucket: statusBucket,
58
+ status_icon: icon,
59
+ status_label: statusLabel,
60
+ date: m[5].trim(),
61
+ notes: m[6].trim().slice(0, 160),
62
+ });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** List task directories under `.dw/tasks/` (skipping archive + dotfiles). */
68
+ export function listTaskDirs(rootDir) {
69
+ const dir = join(rootDir, TASKS_DIR);
70
+ if (!existsSync(dir)) return [];
71
+ return readdirSync(dir)
72
+ .filter((entry) => {
73
+ if (entry.startsWith('.') || entry === 'archive') return false;
74
+ try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
75
+ });
76
+ }
77
+
78
+ export { TASKS_DIR, STATUS_ICONS };