dw-kit 1.8.0-rc.2 → 1.9.0

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 (79) hide show
  1. package/.claude/hooks/stop-check.sh +10 -0
  2. package/.claude/rules/dw.md +2 -0
  3. package/.claude/skills/dw-decision/SKILL.md +2 -1
  4. package/.claude/skills/dw-goal/SKILL.md +206 -0
  5. package/.claude/skills/dw-goal-sync/SKILL.md +131 -0
  6. package/.claude/templates/agent-report.md +35 -35
  7. package/.dw/config/agents.yml +8 -0
  8. package/.dw/core/AGENTS.md +53 -53
  9. package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
  10. package/.dw/core/schemas/events/created.schema.json +33 -0
  11. package/.dw/core/schemas/events/debate_agent_failed.schema.json +42 -0
  12. package/.dw/core/schemas/events/debate_agent_replied.schema.json +44 -0
  13. package/.dw/core/schemas/events/debate_agent_started.schema.json +37 -0
  14. package/.dw/core/schemas/events/debate_completed.schema.json +36 -0
  15. package/.dw/core/schemas/events/debate_started.schema.json +47 -0
  16. package/.dw/core/schemas/events/goal_archived.schema.json +32 -0
  17. package/.dw/core/schemas/events/goal_created.schema.json +32 -0
  18. package/.dw/core/schemas/events/goal_field_updated.schema.json +35 -0
  19. package/.dw/core/schemas/events/goal_pivoted.schema.json +36 -0
  20. package/.dw/core/schemas/events/goal_status_changed.schema.json +40 -0
  21. package/.dw/core/schemas/events/goal_task_linked.schema.json +33 -0
  22. package/.dw/core/schemas/events/goal_task_unlinked.schema.json +33 -0
  23. package/.dw/core/schemas/events/index.json +185 -0
  24. package/.dw/core/schemas/events/orchestrator_cancelled.schema.json +29 -0
  25. package/.dw/core/schemas/events/orchestrator_completed.schema.json +38 -0
  26. package/.dw/core/schemas/events/orchestrator_confirm.schema.json +33 -0
  27. package/.dw/core/schemas/events/orchestrator_confirmed.schema.json +33 -0
  28. package/.dw/core/schemas/events/orchestrator_error.schema.json +29 -0
  29. package/.dw/core/schemas/events/orchestrator_pending_dropped.schema.json +29 -0
  30. package/.dw/core/schemas/events/orchestrator_pending_expired.schema.json +32 -0
  31. package/.dw/core/schemas/events/orchestrator_recommend_rejected.schema.json +37 -0
  32. package/.dw/core/schemas/events/orchestrator_recommended.schema.json +33 -0
  33. package/.dw/core/schemas/events/orchestrator_spawn_failed.schema.json +29 -0
  34. package/.dw/core/schemas/events/orchestrator_started.schema.json +33 -0
  35. package/.dw/core/schemas/events/orchestrator_timeout.schema.json +29 -0
  36. package/.dw/core/schemas/events/reconciled.schema.json +29 -0
  37. package/.dw/core/schemas/events/reconciled_stale.schema.json +29 -0
  38. package/.dw/core/schemas/events/session.created.schema.json +39 -0
  39. package/.dw/core/schemas/events/session.reconciled.schema.json +33 -0
  40. package/.dw/core/schemas/events/session.status_changed.schema.json +42 -0
  41. package/.dw/core/schemas/events/spawn_failed.schema.json +29 -0
  42. package/.dw/core/schemas/events/started.schema.json +59 -0
  43. package/.dw/core/schemas/events/stopped.schema.json +33 -0
  44. package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
  45. package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
  46. package/.dw/core/templates/v3/task.md +38 -9
  47. package/.dw/security/advisory-snapshot.json +157 -0
  48. package/LICENSE +201 -21
  49. package/NOTICE +26 -0
  50. package/README.md +5 -2
  51. package/SECURITY.md +87 -0
  52. package/TRADEMARK.md +65 -0
  53. package/bin/dw.mjs +1 -1
  54. package/package.json +13 -5
  55. package/src/cli.mjs +33 -0
  56. package/src/commands/decision-index.mjs +45 -0
  57. package/src/commands/goal-delete.mjs +3 -1
  58. package/src/commands/goal-link.mjs +3 -1
  59. package/src/commands/goal-status.mjs +95 -0
  60. package/src/commands/lint-task.mjs +20 -0
  61. package/src/commands/task-index.mjs +47 -0
  62. package/src/commands/task-migrate.mjs +16 -5
  63. package/src/commands/task-new.mjs +6 -0
  64. package/src/commands/task-summary.mjs +4 -3
  65. package/src/commands/voice.mjs +590 -4
  66. package/src/lib/board-data.mjs +220 -0
  67. package/src/lib/debate.mjs +325 -0
  68. package/src/lib/decision-store.mjs +146 -0
  69. package/src/lib/event-schema.mjs +342 -0
  70. package/src/lib/goal-store.mjs +40 -1
  71. package/src/lib/lint-rules.mjs +10 -1
  72. package/src/lib/orchestrator.mjs +31 -9
  73. package/src/lib/session-store.mjs +36 -4
  74. package/src/lib/task-store.mjs +164 -0
  75. package/src/lib/voice-action.mjs +165 -0
  76. package/src/lib/voice-parser.mjs +13 -0
  77. package/.dw/config/connectors.local.yml +0 -38
  78. package/.dw/core/PILLARS.md +0 -122
  79. package/CLAUDE.md +0 -44
@@ -0,0 +1,220 @@
1
+ // board-data.mjs — pure data builder for the `dw voice` Kanban board.
2
+ //
3
+ // Scans .dw/goals/*/goal.md, .dw/tasks/*/task.md (Section 3 Subtask Tracker),
4
+ // and the session index. Returns a structured JSON object the browser page
5
+ // can render as columns + cards. No I/O beyond reading these files; the
6
+ // function is sync and unit-testable.
7
+ //
8
+ // Shape:
9
+ // {
10
+ // goals: [{
11
+ // goal_id, title, status, summary, icon, progress: {percent, ...},
12
+ // tasks: [{
13
+ // task_id, status, phase, parent_goal_id, target_ship,
14
+ // subtasks: [{ st_id, title, status_bucket, status_icon, date, notes }],
15
+ // path: '.dw/tasks/<id>/task.md',
16
+ // }],
17
+ // }],
18
+ // orphan_tasks: [...], // tasks with no parent_goal_id or unknown parent
19
+ // sessions: [...], // running + recent terminal (from session index)
20
+ // totals: { goals_active, tasks_active, subtasks_pending, subtasks_in_progress, subtasks_done, sessions_running },
21
+ // generated_at: ISO,
22
+ // }
23
+ //
24
+ // Per ADR-0001: zero-dep, Node built-ins + js-yaml.
25
+
26
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
27
+ import { join, basename } from 'node:path';
28
+ import yaml from 'js-yaml';
29
+ import { listSessions } from './session-store.mjs';
30
+
31
+ const GOALS_INDEX_PATH = '.dw/goals/goals-index.json';
32
+ const GOALS_DIR = '.dw/goals';
33
+ const TASKS_DIR = '.dw/tasks';
34
+ const MAX_TASKS_PER_GOAL = 20;
35
+ const MAX_SESSIONS = 20;
36
+
37
+ // ─ Helpers ─────────────────────────────────────────────────────────────────
38
+
39
+ function safeReadGoalsIndex(rootDir) {
40
+ const p = join(rootDir, GOALS_INDEX_PATH);
41
+ if (!existsSync(p)) return { goals: {} };
42
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { goals: {} }; }
43
+ }
44
+
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
+ }
101
+
102
+ // ─ Public API ──────────────────────────────────────────────────────────────
103
+
104
+ export function buildBoardData(rootDir, opts = {}) {
105
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
106
+ const idx = safeReadGoalsIndex(rootDir);
107
+ const goalsIndexEntries = Object.entries(idx.goals || {});
108
+
109
+ // Collect task -> goal mapping by scanning each task's frontmatter.
110
+ const taskDirs = listTaskDirs(rootDir);
111
+ const allTasks = [];
112
+ for (const taskName of taskDirs) {
113
+ const taskPath = join(rootDir, TASKS_DIR, taskName, 'task.md');
114
+ const fm = readFrontmatter(taskPath);
115
+ if (!fm || !fm.task_id) continue;
116
+ const subtasks = parseSubtasks(taskPath).slice(0, 30);
117
+ allTasks.push({
118
+ task_id: fm.task_id,
119
+ status: fm.status || 'Unknown',
120
+ phase: fm.phase || '',
121
+ parent_goal_id: fm.parent_goal_id || null,
122
+ target_ship: fm.target_ship || null,
123
+ last_updated: fm.last_updated || null,
124
+ schema_version: fm.schema_version || '',
125
+ path: `.dw/tasks/${taskName}/task.md`,
126
+ subtasks,
127
+ });
128
+ }
129
+
130
+ // Bucket tasks under goals.
131
+ const byGoal = new Map(); // goal_id → [task,...]
132
+ const orphans = [];
133
+ for (const t of allTasks) {
134
+ const pg = t.parent_goal_id;
135
+ if (pg && pg !== 'none' && idx.goals && idx.goals[pg]) {
136
+ if (!byGoal.has(pg)) byGoal.set(pg, []);
137
+ byGoal.get(pg).push(t);
138
+ } else {
139
+ orphans.push(t);
140
+ }
141
+ }
142
+
143
+ // Build goal cards.
144
+ const goals = [];
145
+ for (const [goal_id, g] of goalsIndexEntries) {
146
+ if (g.archived_at) continue; // hide archived
147
+ const tasks = (byGoal.get(goal_id) || []).slice(0, MAX_TASKS_PER_GOAL);
148
+ goals.push({
149
+ goal_id,
150
+ title: g.title || goal_id,
151
+ status: g.status || 'Active',
152
+ summary: (g.summary || '').slice(0, 280),
153
+ icon: g.icon || '',
154
+ cycle: g.cycle || '',
155
+ target_date: g.target_date || null,
156
+ progress: g.progress || null,
157
+ last_updated: g.last_updated || null,
158
+ linked_task_ids: g.linked_task_ids || [],
159
+ tasks,
160
+ });
161
+ }
162
+
163
+ // Sort goals: Active first, then by last_updated desc, with North-Star (cycle:north-star) pinned to top.
164
+ goals.sort((a, b) => {
165
+ const aN = a.cycle === 'north-star' ? 0 : 1;
166
+ const bN = b.cycle === 'north-star' ? 0 : 1;
167
+ if (aN !== bN) return aN - bN;
168
+ const aA = a.status === 'Active' ? 0 : 1;
169
+ const bA = b.status === 'Active' ? 0 : 1;
170
+ if (aA !== bA) return aA - bA;
171
+ return (b.last_updated || '').localeCompare(a.last_updated || '');
172
+ });
173
+
174
+ // Sessions (running first, then recent terminal capped at MAX_SESSIONS).
175
+ let sessions = [];
176
+ try {
177
+ sessions = listSessions({}, rootDir).slice(0, MAX_SESSIONS);
178
+ } catch { /* ignore */ }
179
+ sessions = sessions.map((s) => ({
180
+ session_id: s.session_id,
181
+ agent: s.agent,
182
+ status: s.status,
183
+ goal_preview: typeof s.goal === 'string' ? s.goal.slice(0, 80) : '',
184
+ started_at: s.started_at,
185
+ workspace_path: s.workspace_path,
186
+ }));
187
+
188
+ // Totals.
189
+ const totals = {
190
+ goals_active: goals.filter((g) => g.status === 'Active').length,
191
+ goals_total: goals.length,
192
+ tasks_active: allTasks.filter((t) => t.status === 'Active').length,
193
+ tasks_total: allTasks.length,
194
+ subtasks_pending: 0,
195
+ subtasks_in_progress: 0,
196
+ subtasks_done: 0,
197
+ subtasks_blocked: 0,
198
+ sessions_running: sessions.filter((s) => s.status === 'running').length,
199
+ sessions_total: sessions.length,
200
+ };
201
+ for (const t of allTasks) {
202
+ for (const st of t.subtasks) {
203
+ if (st.status_bucket === 'pending') totals.subtasks_pending++;
204
+ else if (st.status_bucket === 'in_progress') totals.subtasks_in_progress++;
205
+ else if (st.status_bucket === 'done') totals.subtasks_done++;
206
+ else if (st.status_bucket === 'blocked') totals.subtasks_blocked++;
207
+ }
208
+ }
209
+
210
+ return {
211
+ goals,
212
+ orphan_tasks: orphans.slice(0, MAX_TASKS_PER_GOAL),
213
+ sessions,
214
+ totals,
215
+ generated_at: new Date(now).toISOString().replace(/\.\d+Z$/, 'Z'),
216
+ };
217
+ }
218
+
219
+ // Exposed for tests.
220
+ export const _internals = { parseSubtasks, readFrontmatter, listTaskDirs };
@@ -0,0 +1,325 @@
1
+ // debate.mjs — multi-agent live debate (ADR-0015, KR-D of G-rgoal-realtime-orch).
2
+ //
3
+ // Spawn 2+ agents in parallel with a shared debate prompt; stream each agent's
4
+ // reply to events-global.jsonl as soon as it lands so the SSE broker can fan
5
+ // it out to the browser. No automated arbitration — the user is the tie-breaker
6
+ // for MVP. v2.0+ may add consensus scoring.
7
+ //
8
+ // Per ADR-0001: zero-dep, Node built-ins only. Reuses sse-broker.mjs +
9
+ // goal-events.mjs (events-global.jsonl write pattern).
10
+
11
+ import { readFileSync, existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { randomBytes } from 'node:crypto';
14
+ import yaml from 'js-yaml';
15
+ import {
16
+ createSession, updateSessionStatus, appendEvent, listSessions,
17
+ } from './session-store.mjs';
18
+ import { resolveCommandPath, spawnAgent } from './spawn-helpers.mjs';
19
+ import { logGoalEvent } from './goal-events.mjs';
20
+ import { collectContext } from './orchestrator.mjs';
21
+
22
+ const AGENTS_CONFIG_PATH = '.dw/config/agents.yml';
23
+ const DEFAULT_TIMEOUT_MS = 30000;
24
+ const MAX_TOPIC_CHARS = 1000;
25
+ const PROMPT_MAX_CHARS = 8000;
26
+
27
+ function loadAgentsConfig(rootDir) {
28
+ const p = join(rootDir, AGENTS_CONFIG_PATH);
29
+ if (!existsSync(p)) return { agents: {} };
30
+ return yaml.load(readFileSync(p, 'utf8')) || { agents: {} };
31
+ }
32
+
33
+ /**
34
+ * Resolve the debate roster from agents.yml.
35
+ *
36
+ * Config shape (under top-level `voice_debate:`):
37
+ * roster: [claude, codex]
38
+ * timeout_ms: 30000
39
+ *
40
+ * Falls back to [claude, codex] when unset. Validates that every roster
41
+ * entry has a corresponding `agents.{name}` definition.
42
+ */
43
+ export function resolveDebateRoster(rootDir) {
44
+ const cfg = loadAgentsConfig(rootDir);
45
+ const wanted = (cfg.voice_debate && Array.isArray(cfg.voice_debate.roster) && cfg.voice_debate.roster.length > 0)
46
+ ? cfg.voice_debate.roster
47
+ : ['claude', 'codex'];
48
+ const available = wanted.filter((name) => cfg.agents && cfg.agents[name] && cfg.agents[name].command);
49
+ return {
50
+ roster: available,
51
+ timeout_ms: Number(cfg.voice_debate?.timeout_ms) || DEFAULT_TIMEOUT_MS,
52
+ agents: cfg.agents || {},
53
+ };
54
+ }
55
+
56
+ export function generateDebateId() {
57
+ return 'D-' + randomBytes(6).toString('hex');
58
+ }
59
+
60
+ /**
61
+ * Compose the debate system prompt seen by EACH agent. We tell every agent
62
+ * who their peer is so they can phrase replies as "I agree / I disagree
63
+ * because ..." rather than monologue. Workspace context is included via
64
+ * collectContext() so agents can ground their answer in real state.
65
+ */
66
+ export function buildDebatePrompt({ topic, agentName, peerNames, lang = 'en-US', rootDir }) {
67
+ const ctx = collectContext(rootDir, topic, lang);
68
+ const langHint = lang.startsWith('vi')
69
+ ? `Người dùng nói tiếng Việt. Trả lời tiếng Việt, ≤3 câu, không preamble.`
70
+ : `Reply in ${lang}, ≤3 sentences, no preamble.`;
71
+ const peers = peerNames.join(', ');
72
+ const prompt = [
73
+ `You are ${agentName}, one of ${peerNames.length + 1} AI agents debating live in a voice session.`,
74
+ `Your peers on this debate: ${peers}.`,
75
+ `The user wants multiple perspectives, then chooses themselves — do NOT defer to your peer; state your own view.`,
76
+ ``,
77
+ langHint,
78
+ ``,
79
+ `Workspace context (read-only):`,
80
+ `- sessions running: ${ctx.sessionsRunning} / total: ${ctx.sessionsTotal}`,
81
+ `- goals active: ${ctx.goalsActive} / total: ${ctx.goalsTotal}`,
82
+ ``,
83
+ `Debate topic from the user: ${topic}`,
84
+ ``,
85
+ `Reply with YOUR position. Be concrete. If you'd recommend an action, end with [ACTION: <name> args] following ADR-0014 — but only ONE agent's action will execute (first user-confirm wins).`,
86
+ ].join('\n').slice(0, PROMPT_MAX_CHARS);
87
+ return prompt;
88
+ }
89
+
90
+ /**
91
+ * Kick off a debate. Spawns every roster agent in parallel; each agent's
92
+ * lifecycle (started / replied / failed / timed_out) writes to
93
+ * .dw/events-global.jsonl tagged with `debate_id` so the SSE broker can
94
+ * fan frames to the browser. Also creates a parent audit session
95
+ * `voice-debate:<topic-slug>` so `dw session show <id>` works for replay.
96
+ *
97
+ * Returns synchronously after spawning: { ok, debate_id, agents: [...], sessionId }.
98
+ * Replies arrive on the SSE stream, not in this return value.
99
+ */
100
+ export function startDebate({ topic, rootDir, lang = 'en-US', timeoutMsOverride }) {
101
+ if (!topic || typeof topic !== 'string') {
102
+ return { ok: false, error: 'debate topic missing' };
103
+ }
104
+ const cleanTopic = topic.trim().slice(0, MAX_TOPIC_CHARS);
105
+ if (!cleanTopic) return { ok: false, error: 'debate topic empty after trim' };
106
+
107
+ const { roster, timeout_ms, agents } = resolveDebateRoster(rootDir);
108
+ if (roster.length < 2) {
109
+ return { ok: false, error: `debate needs ≥2 agents on PATH; available: ${roster.join(',') || '(none)'}` };
110
+ }
111
+
112
+ const debate_id = generateDebateId();
113
+ const timeoutMs = Number(timeoutMsOverride) || timeout_ms;
114
+
115
+ // Parent audit session — one .dw/cache/sessions/ entry per debate.
116
+ const topicSlug = cleanTopic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'debate';
117
+ const auditSession = createSession({
118
+ agent: `voice-debate:${topicSlug}`,
119
+ goal: `[debate] ${cleanTopic}`,
120
+ workspacePath: rootDir,
121
+ command: ['debate', ...roster],
122
+ }, rootDir);
123
+
124
+ // Global event: debate_started.
125
+ logGoalEvent({
126
+ event: 'debate_started',
127
+ debate_id,
128
+ topic: cleanTopic,
129
+ roster,
130
+ lang,
131
+ audit_session_id: auditSession.session_id,
132
+ }, rootDir);
133
+ appendEvent(auditSession.session_id, {
134
+ event: 'debate_started', debate_id, topic: cleanTopic, roster, lang,
135
+ }, rootDir);
136
+
137
+ // Spawn each agent in parallel — fire-and-forget at the dispatch level;
138
+ // sse-broker tails events-global.jsonl so each agent's completion frame
139
+ // reaches the browser asynchronously.
140
+ const agentMeta = [];
141
+ for (const agentName of roster) {
142
+ const def = agents[agentName];
143
+ const peerNames = roster.filter((a) => a !== agentName);
144
+ const prompt = buildDebatePrompt({ topic: cleanTopic, agentName, peerNames, lang, rootDir });
145
+ const meta = spawnDebateAgent({
146
+ debate_id, agentName, def, prompt, timeoutMs, rootDir, auditSessionId: auditSession.session_id,
147
+ });
148
+ agentMeta.push(meta);
149
+ }
150
+
151
+ return {
152
+ ok: true,
153
+ debate_id,
154
+ agents: agentMeta.map((m) => ({ agent: m.agent, ok: m.ok, pid: m.pid || null, error: m.error || null })),
155
+ sessionId: auditSession.session_id,
156
+ timeout_ms: timeoutMs,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Spawn one agent for a debate. Captures stdout/stderr in-memory (debate
162
+ * replies are short, ≤4KB after our prompt caps); on exit writes a
163
+ * `debate_agent_replied` or `debate_agent_failed` event with the full
164
+ * reply + timing so the SSE broker can broadcast it.
165
+ *
166
+ * Synchronous return: { agent, ok, pid?, error? }. Reply arrives later
167
+ * via events-global.jsonl.
168
+ */
169
+ function spawnDebateAgent({ debate_id, agentName, def, prompt, timeoutMs, rootDir, auditSessionId }) {
170
+ if (!def || !def.command) {
171
+ logGoalEvent({
172
+ event: 'debate_agent_failed', debate_id, agent: agentName,
173
+ error: `agent "${agentName}" not configured in agents.yml`,
174
+ }, rootDir);
175
+ return { agent: agentName, ok: false, error: 'not configured' };
176
+ }
177
+ const resolved = resolveCommandPath(def.command);
178
+ if (!resolved.found && process.platform === 'win32') {
179
+ logGoalEvent({
180
+ event: 'debate_agent_failed', debate_id, agent: agentName,
181
+ error: `agent CLI "${def.command}" not on PATH`,
182
+ }, rootDir);
183
+ return { agent: agentName, ok: false, error: 'not on PATH' };
184
+ }
185
+
186
+ const args = [...(def.args || [])];
187
+ const mode = def.goal_mode || 'trailing-arg';
188
+ if (mode === 'trailing-arg') args.push(prompt);
189
+
190
+ const startedAt = Date.now();
191
+ let child;
192
+ try {
193
+ child = spawnAgent(resolved, args, {
194
+ cwd: rootDir,
195
+ stdio: [mode === 'stdin' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
196
+ env: { ...process.env, ...(def.env || {}) },
197
+ windowsHide: true,
198
+ });
199
+ } catch (e) {
200
+ logGoalEvent({
201
+ event: 'debate_agent_failed', debate_id, agent: agentName, error: `spawn failed: ${e.message}`,
202
+ }, rootDir);
203
+ return { agent: agentName, ok: false, error: e.message };
204
+ }
205
+
206
+ logGoalEvent({
207
+ event: 'debate_agent_started', debate_id, agent: agentName, pid: child.pid,
208
+ }, rootDir);
209
+ appendEvent(auditSessionId, {
210
+ event: 'debate_agent_started', debate_id, agent: agentName, pid: child.pid,
211
+ }, rootDir);
212
+
213
+ if (mode === 'stdin') {
214
+ try { child.stdin.write(prompt + '\n'); child.stdin.end(); }
215
+ catch { /* harmless — child may already have exited */ }
216
+ }
217
+
218
+ let stdout = '';
219
+ let stderr = '';
220
+ let settled = false;
221
+ const timer = setTimeout(() => {
222
+ if (settled) return;
223
+ settled = true;
224
+ try { child.kill('SIGTERM'); } catch { /* harmless */ }
225
+ logGoalEvent({
226
+ event: 'debate_agent_failed', debate_id, agent: agentName,
227
+ error: `timeout after ${timeoutMs}ms`,
228
+ ms: Date.now() - startedAt,
229
+ }, rootDir);
230
+ appendEvent(auditSessionId, {
231
+ event: 'debate_agent_failed', debate_id, agent: agentName,
232
+ error: 'timeout', ms: Date.now() - startedAt,
233
+ }, rootDir);
234
+ }, timeoutMs);
235
+
236
+ child.stdout.on('data', (c) => { stdout += c.toString(); });
237
+ child.stderr.on('data', (c) => { stderr += c.toString(); });
238
+
239
+ child.on('exit', (code) => {
240
+ if (settled) return;
241
+ settled = true;
242
+ clearTimeout(timer);
243
+ const ms = Date.now() - startedAt;
244
+ const reply = stdout.trim();
245
+ if (code === 0 && reply.length > 0) {
246
+ logGoalEvent({
247
+ event: 'debate_agent_replied',
248
+ debate_id, agent: agentName, ms,
249
+ reply: reply.slice(0, 4000),
250
+ reply_chars: reply.length,
251
+ }, rootDir);
252
+ appendEvent(auditSessionId, {
253
+ event: 'debate_agent_replied', debate_id, agent: agentName, ms,
254
+ reply_preview: reply.slice(0, 4000),
255
+ }, rootDir);
256
+ } else {
257
+ logGoalEvent({
258
+ event: 'debate_agent_failed',
259
+ debate_id, agent: agentName,
260
+ error: (stderr.trim() || `exit ${code} with empty stdout`).slice(0, 500),
261
+ exit_code: code, ms,
262
+ }, rootDir);
263
+ appendEvent(auditSessionId, {
264
+ event: 'debate_agent_failed', debate_id, agent: agentName,
265
+ error: stderr.slice(0, 500) || `exit ${code}`,
266
+ exit_code: code, ms,
267
+ }, rootDir);
268
+ }
269
+ maybeMarkComplete({ debate_id, auditSessionId, rootDir });
270
+ });
271
+
272
+ child.on('error', (e) => {
273
+ if (settled) return;
274
+ settled = true;
275
+ clearTimeout(timer);
276
+ logGoalEvent({
277
+ event: 'debate_agent_failed', debate_id, agent: agentName, error: e.message,
278
+ }, rootDir);
279
+ appendEvent(auditSessionId, {
280
+ event: 'debate_agent_failed', debate_id, agent: agentName, error: e.message,
281
+ }, rootDir);
282
+ maybeMarkComplete({ debate_id, auditSessionId, rootDir });
283
+ });
284
+
285
+ return { agent: agentName, ok: true, pid: child.pid };
286
+ }
287
+
288
+ /**
289
+ * Inspect events-global.jsonl and emit `debate_completed` once every
290
+ * roster agent has either replied or failed. Cheap O(tail) scan.
291
+ */
292
+ function maybeMarkComplete({ debate_id, auditSessionId, rootDir }) {
293
+ try {
294
+ const file = join(rootDir, '.dw/events-global.jsonl');
295
+ if (!existsSync(file)) return;
296
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-500);
297
+ let rosterSize = 0;
298
+ let terminalCount = 0;
299
+ const agentsTerminal = new Set();
300
+ for (const line of lines) {
301
+ try {
302
+ const e = JSON.parse(line);
303
+ if (e.debate_id !== debate_id) continue;
304
+ if (e.event === 'debate_started') rosterSize = Array.isArray(e.roster) ? e.roster.length : 0;
305
+ if ((e.event === 'debate_agent_replied' || e.event === 'debate_agent_failed') && e.agent && !agentsTerminal.has(e.agent)) {
306
+ agentsTerminal.add(e.agent);
307
+ terminalCount++;
308
+ }
309
+ if (e.event === 'debate_completed') return; // already marked
310
+ } catch { /* skip */ }
311
+ }
312
+ if (rosterSize > 0 && terminalCount >= rosterSize) {
313
+ logGoalEvent({
314
+ event: 'debate_completed', debate_id, agents: Array.from(agentsTerminal),
315
+ }, rootDir);
316
+ appendEvent(auditSessionId, {
317
+ event: 'debate_completed', debate_id, agents: Array.from(agentsTerminal),
318
+ }, rootDir);
319
+ updateSessionStatus(auditSessionId, { status: 'completed' }, rootDir);
320
+ }
321
+ } catch { /* best-effort */ }
322
+ }
323
+
324
+ // Exposed for tests
325
+ export const _internals = { buildDebatePrompt, spawnDebateAgent, maybeMarkComplete };
@@ -0,0 +1,146 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // DW Document Schema + Index v1.0 — decisions (ADR) half (ADR-0017 v1.1).
6
+ // ADRs use TWO metadata styles in the wild:
7
+ // - YAML frontmatter (early ADRs, e.g. ADR-0001)
8
+ // - markdown headers `# ADR-NNNN: Title` + `## Status:` / `## Date:` / ...
9
+ // The parser tolerates both so external adapters consume one normalized index.
10
+
11
+ const DECISIONS_DIR = '.dw/decisions';
12
+ const INDEX_FILE = '.dw/decisions/decisions-index.json';
13
+ const SCHEMA_VERSION = 'decisions-index@v1';
14
+ const STATUS_KEYWORDS = ['Proposed', 'Accepted', 'Deprecated', 'Superseded'];
15
+
16
+ export function decisionsDir(rootDir = process.cwd()) {
17
+ return join(rootDir, DECISIONS_DIR);
18
+ }
19
+
20
+ export function decisionIndexFile(rootDir = process.cwd()) {
21
+ return join(rootDir, INDEX_FILE);
22
+ }
23
+
24
+ function nowUtc() {
25
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
26
+ }
27
+
28
+ function emptyIndex() {
29
+ return { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), decisions: {} };
30
+ }
31
+
32
+ export function readDecisionIndex(rootDir = process.cwd()) {
33
+ const file = decisionIndexFile(rootDir);
34
+ if (!existsSync(file)) return emptyIndex();
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
37
+ if (!parsed || typeof parsed !== 'object' || !parsed.decisions) return emptyIndex();
38
+ return parsed;
39
+ } catch {
40
+ return emptyIndex();
41
+ }
42
+ }
43
+
44
+ export function writeDecisionIndex(index, rootDir = process.cwd()) {
45
+ const file = decisionIndexFile(rootDir);
46
+ const dir = dirname(file);
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
48
+ const updated = { ...index, schema_version: SCHEMA_VERSION, last_updated: nowUtc() };
49
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
50
+ }
51
+
52
+ // .dw/decisions/NNNN-slug.md → { id: 'ADR-NNNN', num: 'NNNN', file }
53
+ export function listDecisionFiles(rootDir = process.cwd()) {
54
+ const dir = decisionsDir(rootDir);
55
+ if (!existsSync(dir)) return [];
56
+ const out = [];
57
+ for (const name of readdirSync(dir)) {
58
+ if (!name.endsWith('.md')) continue;
59
+ if (name.startsWith('_')) continue; // _template.md
60
+ const m = name.match(/^(\d{3,4})-/);
61
+ if (!m) continue;
62
+ out.push({ id: `ADR-${m[1]}`, num: m[1], file: join(dir, name), rel: `${DECISIONS_DIR}/${name}` });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function firstDate(s) {
68
+ if (!s) return null;
69
+ const m = String(s).match(/\d{4}-\d{2}-\d{2}/);
70
+ return m ? m[0] : null;
71
+ }
72
+
73
+ function normalizeStatus(raw) {
74
+ if (!raw) return { status: 'Proposed', superseded_by: null };
75
+ const text = String(raw);
76
+ const kw = STATUS_KEYWORDS.find((k) => new RegExp(`^\\s*${k}`, 'i').test(text));
77
+ const status = kw || (/superseded/i.test(text) ? 'Superseded' : 'Proposed');
78
+ const sb = text.match(/superseded by\s+(ADR-\d{3,4})/i);
79
+ return { status, superseded_by: sb ? sb[1] : null };
80
+ }
81
+
82
+ function adrRefs(s, selfId) {
83
+ if (!s) return [];
84
+ const refs = [...String(s).matchAll(/ADR-\d{3,4}/g)].map((m) => m[0]);
85
+ return [...new Set(refs)].filter((r) => r !== selfId);
86
+ }
87
+
88
+ function mdHeader(content, label) {
89
+ const m = content.match(new RegExp(`^##\\s+${label}:\\s*(.+)$`, 'm'));
90
+ return m ? m[1].trim() : null;
91
+ }
92
+
93
+ // Tolerant extractor: YAML frontmatter wins; falls back to markdown headers.
94
+ export function parseDecisionMeta(id, content) {
95
+ const fm = parseFrontmatter(content);
96
+ const hasFm = fm && (fm.status || fm.title || fm.id);
97
+
98
+ const title = (hasFm && fm.title)
99
+ || (content.match(/^#\s+ADR-\d{3,4}:\s*(.+?)\s*$/m)?.[1])
100
+ || (content.match(/^#\s+(.+?)\s*$/m)?.[1])
101
+ || id;
102
+
103
+ const rawStatus = (hasFm && fm.status) || mdHeader(content, 'Status');
104
+ const { status, superseded_by } = normalizeStatus(rawStatus);
105
+
106
+ const date = firstDate((hasFm && fm.date) || mdHeader(content, 'Date'));
107
+ const deciders = ((hasFm && fm.deciders) || mdHeader(content, 'Deciders') || null);
108
+
109
+ const relatedSrc = `${(hasFm && fm.related) || mdHeader(content, 'Related') || ''} ${rawStatus || ''}`;
110
+ const related = adrRefs(relatedSrc, id);
111
+ const supersedes = hasFm && fm.supersedes && fm.supersedes !== 'null'
112
+ ? adrRefs(String(fm.supersedes), id)
113
+ : [];
114
+
115
+ return {
116
+ title: String(title).trim(),
117
+ status,
118
+ status_raw: rawStatus ? String(rawStatus).trim() : null,
119
+ date,
120
+ deciders: deciders ? String(deciders).trim() : null,
121
+ related,
122
+ supersedes,
123
+ superseded_by: superseded_by || (hasFm && fm['superseded-by'] && fm['superseded-by'] !== 'null' ? String(fm['superseded-by']) : null),
124
+ };
125
+ }
126
+
127
+ function decisionEntry(file, id, rootDir) {
128
+ if (!existsSync(file)) return null;
129
+ const content = readFileSync(file, 'utf8');
130
+ const meta = parseDecisionMeta(id, content);
131
+ return { ...meta, file: `${DECISIONS_DIR}/${file.split(/[\\/]/).pop()}` };
132
+ }
133
+
134
+ export function rebuildDecisionIndex(rootDir = process.cwd()) {
135
+ const index = emptyIndex();
136
+ for (const d of listDecisionFiles(rootDir)) {
137
+ const entry = decisionEntry(d.file, d.id, rootDir);
138
+ if (entry) index.decisions[d.id] = entry;
139
+ }
140
+ const existing = readDecisionIndex(rootDir);
141
+ if (JSON.stringify(existing.decisions) === JSON.stringify(index.decisions)) {
142
+ return existing;
143
+ }
144
+ writeDecisionIndex(index, rootDir);
145
+ return index;
146
+ }