dw-kit 1.9.2 → 1.10.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.
- package/.claude/rules/dw.md +14 -3
- package/.claude/skills/dw-flow/SKILL.md +35 -2
- package/.claude/skills/dw-goal/SKILL.md +22 -1
- package/.dw/config/config.schema.json +24 -0
- package/.dw/config/dw.config.yml +14 -0
- package/.dw/core/HARNESS.md +157 -0
- package/.dw/core/schemas/events/gate_auto_approved.schema.json +36 -0
- package/.dw/core/schemas/events/hard_stop.schema.json +35 -0
- package/.dw/core/schemas/events/index.json +28 -2
- package/.dw/core/schemas/events/overnight_wrapup.schema.json +29 -0
- package/.dw/core/schemas/events/parked.schema.json +39 -0
- package/.dw/core/schemas/events/subtask_completed.schema.json +35 -0
- package/.dw/core/schemas/overnight-digest.schema.json +113 -0
- package/.dw/core/templates/autopilot-cron.sh +28 -0
- package/README.md +152 -121
- package/package.json +4 -1
- package/src/cli.mjs +63 -0
- package/src/commands/autopilot.mjs +65 -0
- package/src/commands/doctor.mjs +17 -1
- package/src/commands/overnight.mjs +35 -0
- package/src/commands/trust.mjs +79 -0
- package/src/commands/voice.mjs +431 -2
- package/src/lib/autopilot-contract.mjs +97 -0
- package/src/lib/board-data.mjs +23 -57
- package/src/lib/config.mjs +56 -0
- package/src/lib/event-schema.mjs +28 -0
- package/src/lib/goal-driver.mjs +312 -0
- package/src/lib/goal-events.mjs +4 -1
- package/src/lib/goal-progress.mjs +193 -0
- package/src/lib/overnight-digest.mjs +241 -0
- package/src/lib/process-kill.mjs +77 -0
- package/src/lib/task-md-utils.mjs +78 -0
- package/src/lib/tls-helpers.mjs +28 -6
- package/src/lib/trust.mjs +139 -0
|
@@ -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,241 @@
|
|
|
1
|
+
// Morning-review digest builder for Overnight Autopilot (ADR-0023).
|
|
2
|
+
//
|
|
3
|
+
// An unattended overnight run emits events to .dw/events-global.jsonl; this
|
|
4
|
+
// module turns a run's events into ONE scannable artifact a human reads cold
|
|
5
|
+
// the next morning. Pure functions (buildDigest/summarizeRun) so the output is
|
|
6
|
+
// deterministic and unit-testable; writeDigest handles the filesystem.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const DIGEST_SCHEMA_VERSION = 'overnight-digest@v1';
|
|
12
|
+
export const DIGEST_FILE = 'OVERNIGHT.md';
|
|
13
|
+
|
|
14
|
+
// Event types the autopilot loop emits (subset of events-global.jsonl).
|
|
15
|
+
const EV_DONE = 'subtask_completed';
|
|
16
|
+
const EV_AUTO = 'gate_auto_approved';
|
|
17
|
+
const EV_PARK = 'parked';
|
|
18
|
+
const EV_STOP = 'hard_stop';
|
|
19
|
+
const EV_WRAP = 'overnight_wrapup';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reduce a run's events into counts + grouped items.
|
|
23
|
+
* @param {object[]} events
|
|
24
|
+
*/
|
|
25
|
+
export function summarizeRun(events = []) {
|
|
26
|
+
const done = [];
|
|
27
|
+
const autoApproved = [];
|
|
28
|
+
const parked = [];
|
|
29
|
+
const hardStops = [];
|
|
30
|
+
let stopReason = 'unknown';
|
|
31
|
+
let wrapupSeen = false;
|
|
32
|
+
|
|
33
|
+
for (const e of events) {
|
|
34
|
+
switch (e && e.event) {
|
|
35
|
+
case EV_DONE: done.push(e); break;
|
|
36
|
+
case EV_AUTO: autoApproved.push(e); break;
|
|
37
|
+
case EV_PARK: parked.push(e); break;
|
|
38
|
+
case EV_STOP: hardStops.push(e); break;
|
|
39
|
+
case EV_WRAP: wrapupSeen = true; if (e.stop_reason) stopReason = e.stop_reason; break;
|
|
40
|
+
default: break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
counts: {
|
|
46
|
+
done: done.length,
|
|
47
|
+
parked: parked.length,
|
|
48
|
+
hardStops: hardStops.length,
|
|
49
|
+
autoApproved: autoApproved.length,
|
|
50
|
+
},
|
|
51
|
+
stopReason,
|
|
52
|
+
wrapupSeen,
|
|
53
|
+
done,
|
|
54
|
+
autoApproved,
|
|
55
|
+
parked,
|
|
56
|
+
hardStops,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The machine-readable read-contract object (overnight-digest@v1). OVERNIGHT.md
|
|
62
|
+
* is for humans; this is what the schema describes and adapters consume.
|
|
63
|
+
*/
|
|
64
|
+
// Coerce a possibly-missing/odd value to a string or undefined — so projected
|
|
65
|
+
// items always satisfy the overnight-digest@v1 schema regardless of raw event
|
|
66
|
+
// shape (round-3 verify: malformed events must not produce schema-invalid JSON).
|
|
67
|
+
const str = (v) => (v == null ? undefined : String(v));
|
|
68
|
+
const strArr = (v) => (Array.isArray(v) ? v.map(String) : undefined);
|
|
69
|
+
const int = (v) => { const n = Math.trunc(Number(v)); return Number.isFinite(n) ? n : undefined; };
|
|
70
|
+
const nnInt = (v) => { const n = int(v); return n != null && n >= 0 ? n : undefined; }; // schema minimum:0
|
|
71
|
+
const bool = (v) => (typeof v === 'boolean' ? v : undefined);
|
|
72
|
+
const prune = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
73
|
+
|
|
74
|
+
// Project an evidence object to the schema-typed shape so OVERNIGHT.json stays
|
|
75
|
+
// valid even if an event stored loose types (e.g. tests.exit as a string).
|
|
76
|
+
function projectEvidence(ev) {
|
|
77
|
+
if (!ev || typeof ev !== 'object') return undefined;
|
|
78
|
+
const out = {};
|
|
79
|
+
if (ev.tests && typeof ev.tests === 'object') {
|
|
80
|
+
out.tests = prune({ command: str(ev.tests.command), passed: int(ev.tests.passed), failed: int(ev.tests.failed), exit: int(ev.tests.exit) });
|
|
81
|
+
}
|
|
82
|
+
if (ev.review && typeof ev.review === 'object') {
|
|
83
|
+
out.review = prune({ critical: nnInt(ev.review.critical), warning: nnInt(ev.review.warning), suggestion: nnInt(ev.review.suggestion) });
|
|
84
|
+
}
|
|
85
|
+
if (ev.diff && typeof ev.diff === 'object') {
|
|
86
|
+
out.diff = prune({ files: int(ev.diff.files), insertions: int(ev.diff.insertions), deletions: int(ev.diff.deletions), within_scope: bool(ev.diff.within_scope) });
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length ? out : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildDigestData({ events = [], date = new Date().toISOString().slice(0, 10), goal = null } = {}) {
|
|
92
|
+
const s = summarizeRun(events);
|
|
93
|
+
return {
|
|
94
|
+
schema_version: DIGEST_SCHEMA_VERSION,
|
|
95
|
+
date,
|
|
96
|
+
goal,
|
|
97
|
+
counts: s.counts,
|
|
98
|
+
stopReason: s.stopReason,
|
|
99
|
+
wrapupSeen: s.wrapupSeen,
|
|
100
|
+
done: s.done.map((e) => prune({ subtask: str(e.subtask), commit: str(e.commit), summary: str(e.summary) })),
|
|
101
|
+
autoApproved: s.autoApproved.map((e) => prune({ gate: str(e.gate), task: e.task == null ? null : String(e.task), reason: str(e.reason), evidence: projectEvidence(e.evidence) })),
|
|
102
|
+
parked: s.parked.map((e) => prune({ item: str(e.item), question: str(e.question), options: strArr(e.options) })),
|
|
103
|
+
hardStops: s.hardStops.map((e) => prune({ gate: str(e.gate), blocker: str(e.blocker), reason: str(e.reason) })),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function line(...parts) { return parts.join(' '); }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render the morning-review digest as Markdown.
|
|
111
|
+
* @param {object} params
|
|
112
|
+
* @param {object[]} params.events run events
|
|
113
|
+
* @param {string} [params.date] ISO date string for the header
|
|
114
|
+
* @param {string} [params.goal] goal id/title the run pursued
|
|
115
|
+
*/
|
|
116
|
+
export function buildDigest({ events = [], date = new Date().toISOString().slice(0, 10), goal = null } = {}) {
|
|
117
|
+
const s = summarizeRun(events);
|
|
118
|
+
const out = [];
|
|
119
|
+
|
|
120
|
+
out.push(`# Overnight Autopilot — ${date}`);
|
|
121
|
+
out.push('');
|
|
122
|
+
out.push(`> ${DIGEST_SCHEMA_VERSION} · auto-generated from \`.dw/events-global.jsonl\`. Read cold, act on the **⏸ Parked** section, then review the diff.`);
|
|
123
|
+
if (goal) out.push(`> Goal: ${goal}`);
|
|
124
|
+
out.push('');
|
|
125
|
+
|
|
126
|
+
// 1. Verdict line
|
|
127
|
+
out.push('## Verdict');
|
|
128
|
+
out.push(line(
|
|
129
|
+
`✅ ${s.counts.done} done`,
|
|
130
|
+
`· ⏸ ${s.counts.parked} parked`,
|
|
131
|
+
`· 🛑 ${s.counts.hardStops} hard-stops`,
|
|
132
|
+
`· ⚡ ${s.counts.autoApproved} auto-approved`,
|
|
133
|
+
`· ⌛ stopped: ${s.stopReason}`,
|
|
134
|
+
));
|
|
135
|
+
// No wrap-up event despite work having happened = the loop may have died
|
|
136
|
+
// mid-run without the graceful stop ADR-0023 promises. Surface that loudly.
|
|
137
|
+
// Zero-activity (no events at all) is "no run", not a crash — don't warn
|
|
138
|
+
// (round-2 verify: 5.4 false-positive fix).
|
|
139
|
+
const hadActivity = s.counts.done + s.counts.parked + s.counts.hardStops + s.counts.autoApproved > 0;
|
|
140
|
+
if (!s.wrapupSeen && hadActivity) {
|
|
141
|
+
out.push('');
|
|
142
|
+
out.push('> 🛑 **NO WRAP-UP event found — the run may have crashed.** Inspect the working tree and tests manually before resuming; an irreversible step may have been left half-done.');
|
|
143
|
+
}
|
|
144
|
+
out.push('');
|
|
145
|
+
|
|
146
|
+
// 2. Done
|
|
147
|
+
out.push('## ✅ Done');
|
|
148
|
+
if (s.done.length === 0) {
|
|
149
|
+
out.push('_Nothing completed this run._');
|
|
150
|
+
} else {
|
|
151
|
+
for (const e of s.done) {
|
|
152
|
+
const sha = e.commit ? ` \`${String(e.commit).slice(0, 9)}\`` : '';
|
|
153
|
+
out.push(`- ${e.subtask || e.summary || 'subtask'}${sha}${e.summary && e.subtask ? ` — ${e.summary}` : ''}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
out.push('');
|
|
157
|
+
|
|
158
|
+
// 3. Auto-approved
|
|
159
|
+
out.push('## ⚡ Auto-approved (Trust Mode)');
|
|
160
|
+
if (s.autoApproved.length === 0) {
|
|
161
|
+
out.push('_No gates were auto-approved._');
|
|
162
|
+
} else {
|
|
163
|
+
for (const e of s.autoApproved) {
|
|
164
|
+
out.push(`- **${e.gate || 'gate'}**${e.task ? ` (${e.task})` : ''} — ${e.reason || 'trusted'}`);
|
|
165
|
+
// EVD verify (ADR-0023 §evidence): show the BASIS, not just the outcome,
|
|
166
|
+
// so a human can spot a call they'd have made differently.
|
|
167
|
+
const ev = e.evidence;
|
|
168
|
+
if (ev && typeof ev === 'object') {
|
|
169
|
+
const bits = [];
|
|
170
|
+
if (ev.tests) bits.push(`tests ${ev.tests.passed ?? '?'}/${ev.tests.failed ?? '?'} ${Number(ev.tests.exit) === 0 ? '✓' : '✗'}`);
|
|
171
|
+
if (ev.review) bits.push(`${ev.review.critical ?? '?'} Critical · ${ev.review.warning ?? 0} Warning`);
|
|
172
|
+
if (ev.diff) bits.push(`diff +${ev.diff.insertions ?? '?'}/−${ev.diff.deletions ?? '?'} in ${ev.diff.files ?? '?'} files${ev.diff.within_scope === false ? ' ⚠ OUT OF SCOPE' : ''}`);
|
|
173
|
+
if (bits.length) out.push(` basis: ${bits.join(' · ')}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
out.push('');
|
|
178
|
+
|
|
179
|
+
// 4. Parked for you — the section the human acts on
|
|
180
|
+
out.push('## ⏸ Parked for you');
|
|
181
|
+
if (s.parked.length === 0) {
|
|
182
|
+
out.push('_Nothing parked — the agent did not hit an ambiguous or irreversible decision._');
|
|
183
|
+
} else {
|
|
184
|
+
for (const e of s.parked) {
|
|
185
|
+
out.push(`- **${e.item || 'decision'}** — ${e.question || 'needs your call'}`);
|
|
186
|
+
if (Array.isArray(e.options) && e.options.length) {
|
|
187
|
+
out.push(` - options: ${e.options.join(' · ')}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
out.push('');
|
|
192
|
+
|
|
193
|
+
// 5. Hard-stops hit
|
|
194
|
+
out.push('## 🛑 Hard-stops hit');
|
|
195
|
+
if (s.hardStops.length === 0) {
|
|
196
|
+
out.push('_No Guard or Critical hard-stop fired._');
|
|
197
|
+
} else {
|
|
198
|
+
for (const e of s.hardStops) {
|
|
199
|
+
out.push(`- **${e.gate || e.blocker || 'hard-stop'}** — ${e.reason || e.blocker || 'blocked'}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
out.push('');
|
|
203
|
+
|
|
204
|
+
// 6. Review next
|
|
205
|
+
out.push('## Review next');
|
|
206
|
+
out.push('```bash');
|
|
207
|
+
out.push('git log --oneline --since="12 hours ago" # what landed overnight');
|
|
208
|
+
out.push('git diff main...HEAD # full diff to review');
|
|
209
|
+
out.push('dw goal status # outcome progress');
|
|
210
|
+
out.push('# resume the loop after you clear parked items:');
|
|
211
|
+
out.push('dw goal --auto=full --trust');
|
|
212
|
+
out.push('```');
|
|
213
|
+
out.push('');
|
|
214
|
+
|
|
215
|
+
return out.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Write the digest to OVERNIGHT.md, rotating any prior run into
|
|
220
|
+
* .dw/cache/overnight/{date}.md so history is kept but the root stays a single
|
|
221
|
+
* current file.
|
|
222
|
+
*/
|
|
223
|
+
export function writeDigest(rootDir, content, { date = new Date().toISOString().slice(0, 10) } = {}) {
|
|
224
|
+
const target = join(rootDir, DIGEST_FILE);
|
|
225
|
+
if (existsSync(target)) {
|
|
226
|
+
const archiveDir = join(rootDir, '.dw', 'cache', 'overnight');
|
|
227
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
228
|
+
// Avoid clobbering a prior same-day run's archive (review round 2: W-1).
|
|
229
|
+
let archive = join(archiveDir, `${date}.md`);
|
|
230
|
+
for (let n = 1; existsSync(archive); n++) archive = join(archiveDir, `${date}-${n}.md`);
|
|
231
|
+
try {
|
|
232
|
+
renameSync(target, archive);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
try { process.stderr.write(`⚠ overnight: could not rotate prior ${DIGEST_FILE} (${e.message}) — overwriting.\n`); } catch { /* stderr closed */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const dir = dirname(target);
|
|
238
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
239
|
+
writeFileSync(target, content, 'utf8');
|
|
240
|
+
return target;
|
|
241
|
+
}
|
|
@@ -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 };
|
package/src/lib/tls-helpers.mjs
CHANGED
|
@@ -10,12 +10,33 @@
|
|
|
10
10
|
//
|
|
11
11
|
// F-24 fix (G-rgoal-realtime-orch KR-A production-readiness).
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, delimiter } from 'node:path';
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { networkInterfaces } from 'node:os';
|
|
17
17
|
import { resolveCommandPath } from './spawn-helpers.mjs';
|
|
18
18
|
|
|
19
|
+
// Whether `cmd` is actually an executable on PATH.
|
|
20
|
+
//
|
|
21
|
+
// `resolveCommandPath` is spawn-oriented: on POSIX it returns `found: true`
|
|
22
|
+
// unconditionally (spawn resolves PATH itself), so it CANNOT answer "is this
|
|
23
|
+
// binary installed?" — trusting its `.found` here false-positived `mkcert` on
|
|
24
|
+
// any Linux box without it. So on POSIX we walk PATH and check for an
|
|
25
|
+
// executable regular file; on Win32 `resolveCommandPath` already does a real
|
|
26
|
+
// PATH+PATHEXT walk, so we defer to it.
|
|
27
|
+
function commandInstalled(cmd) {
|
|
28
|
+
if (process.platform === 'win32') return resolveCommandPath(cmd).found;
|
|
29
|
+
const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean);
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
const p = join(dir, cmd);
|
|
32
|
+
try {
|
|
33
|
+
const st = statSync(p);
|
|
34
|
+
if (st.isFile() && (st.mode & 0o111) !== 0) return true; // any exec bit
|
|
35
|
+
} catch { /* not here — keep looking */ }
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
const TLS_DIR_REL = '.dw/cache/voice/tls';
|
|
20
41
|
const KEY_FILE = 'key.pem';
|
|
21
42
|
const CERT_FILE = 'cert.pem';
|
|
@@ -29,10 +50,11 @@ const CERT_DAYS = 3650; // 10y — local cert; we don't rotate often
|
|
|
29
50
|
* Returns { name: 'mkcert' | 'openssl' | 'none', path: string | null }
|
|
30
51
|
*/
|
|
31
52
|
export function detectTlsProvider() {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
for (const name of ['mkcert', 'openssl']) {
|
|
54
|
+
if (!commandInstalled(name)) continue;
|
|
55
|
+
const r = resolveCommandPath(name); // path + needsShell details
|
|
56
|
+
return { name, path: r.path, needsShell: r.needsShell };
|
|
57
|
+
}
|
|
36
58
|
return { name: 'none', path: null, needsShell: false };
|
|
37
59
|
}
|
|
38
60
|
|