@dougharness/cli 1.1.1 → 1.2.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/dist/board-serve.js +40 -6
- package/dist/board-serve.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +70 -46
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
- package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
- package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
- package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
- package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// The parallel-card coordinator (card parallel-card-queens, ADR 0015): the pure decisions main makes while
|
|
2
|
+
// several card queens run in one session. Which cards may run together (D1 disjointness), how many slots are
|
|
3
|
+
// free, what a queen's finished run means, which done card lands next, which autopilot caps are reached.
|
|
4
|
+
// Pure and import-free: the decisions are testable with fakes, and a host that is not Claude Code can reuse them.
|
|
5
|
+
|
|
6
|
+
export const WORKERS_MAX_DEFAULT = 3;
|
|
7
|
+
export const WORKERS_MAX_LIMIT = 16;
|
|
8
|
+
|
|
9
|
+
// The configured workers.max when it is an integer from 1 to 16; the default silently when the key (or
|
|
10
|
+
// workers) is absent; the default plus one warn() call otherwise.
|
|
11
|
+
export function workersMax(config, warn = () => {}) {
|
|
12
|
+
const v = config && config.workers ? config.workers.max : undefined;
|
|
13
|
+
if (v === undefined) return WORKERS_MAX_DEFAULT;
|
|
14
|
+
if (Number.isInteger(v) && v >= 1 && v <= WORKERS_MAX_LIMIT) return v;
|
|
15
|
+
warn(`workers.max must be an integer from 1 to ${WORKERS_MAX_LIMIT}; using ${WORKERS_MAX_DEFAULT}`);
|
|
16
|
+
return WORKERS_MAX_DEFAULT;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Paths every card records into; they never make two cards conflict.
|
|
20
|
+
const IGNORED_PATHS = [".doug/board.json", "docs/live-runs.md", "board.json", "live-runs.md"];
|
|
21
|
+
// A goal naming one of these runs alone: parallel cards would collide on the file (by basename), or on the
|
|
22
|
+
// config (by full path).
|
|
23
|
+
const SOLO_BASENAMES = ["package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "CLAUDE.md"];
|
|
24
|
+
const SOLO_PATHS = [".doug/config.json"];
|
|
25
|
+
|
|
26
|
+
function norm(p) {
|
|
27
|
+
return String(p)
|
|
28
|
+
.replace(/\\/g, "/")
|
|
29
|
+
.replace(/\/{2,}/g, "/")
|
|
30
|
+
.replace(/^(\.\/)+/, "")
|
|
31
|
+
.replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseName(p) {
|
|
35
|
+
const parts = norm(p).split("/");
|
|
36
|
+
return parts[parts.length - 1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Extensions seen in this repository; a token whose last segment has another one (Date.now, claude.ai, v1.2)
|
|
40
|
+
// is prose, not a path.
|
|
41
|
+
const FILE_EXTS = ["mjs", "js", "ts", "tsx", "json", "md", "yaml", "yml", "sh", "toml", "lock", "txt", "jsonl", "py", "db", "html", "css"];
|
|
42
|
+
const PATH_CHARS = /^[A-Za-z0-9_.\-/*?@]+$/;
|
|
43
|
+
// Prose pairs that look like a/b when no roots list is given.
|
|
44
|
+
const PROSE_PAIRS = ["and/or", "either/or", "yes/no", "input/output", "read/write", "true/false", "he/she"];
|
|
45
|
+
|
|
46
|
+
// A name that is only a dot and a word (.state, .git): a dot-directory, not a file with an extension.
|
|
47
|
+
function isDotName(p) {
|
|
48
|
+
return /^\.[^.]+$/.test(baseName(p));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasFileExt(t) {
|
|
52
|
+
if (isDotName(t)) return false;
|
|
53
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(t));
|
|
54
|
+
return !!m && FILE_EXTS.includes(m[1].toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// a/{b,c} -> a/b, a/c (first group, recursively). An unclosed brace is left as it is.
|
|
58
|
+
function expandBraces(t) {
|
|
59
|
+
const open = t.indexOf("{");
|
|
60
|
+
const close = open < 0 ? -1 : t.indexOf("}", open);
|
|
61
|
+
if (open < 0 || close < 0) return [t];
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const alt of t.slice(open + 1, close).split(",")) out.push(...expandBraces(t.slice(0, open) + alt + t.slice(close + 1)));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cleanToken(raw) {
|
|
68
|
+
let t = raw;
|
|
69
|
+
for (let i = 0; i < 4; i++) {
|
|
70
|
+
const before = t;
|
|
71
|
+
t = t.replace(/^[([<"'`]+/, "").replace(/['\u2019]s$/, "").replace(/:\d+(-\d+)?$/, "");
|
|
72
|
+
t = t.replace(/[)\]>"'`,;:!?.]+$/, "");
|
|
73
|
+
if (t.endsWith("}") && !t.includes("{")) t = t.slice(0, -1);
|
|
74
|
+
if (t === before) break;
|
|
75
|
+
}
|
|
76
|
+
return t;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The file and directory paths a goal names. Sentence punctuation, a trailing :line or :line-line and a
|
|
80
|
+
// possessive 's are dropped; a/{b,c} expands to a/b and a/c; a trailing slash (a directory) is kept. Slash
|
|
81
|
+
// commands, @scopes and prose (and/or, Date.now) are not paths. A slash token with no extension and no
|
|
82
|
+
// trailing slash counts only when its first segment is in opts.roots; with no roots given it is kept.
|
|
83
|
+
export function goalPaths(goal, opts) {
|
|
84
|
+
if (typeof goal !== "string") return [];
|
|
85
|
+
const roots = opts && Array.isArray(opts.roots) ? opts.roots : null;
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const raw of goal.split(/\s+/)) {
|
|
88
|
+
const cleaned = cleanToken(raw);
|
|
89
|
+
if (!cleaned || cleaned.startsWith("/") || cleaned.startsWith("@") || cleaned.includes("://")) continue;
|
|
90
|
+
for (const t of expandBraces(cleaned)) {
|
|
91
|
+
if (!t || !PATH_CHARS.test(t) || !/[A-Za-z]/.test(t)) continue;
|
|
92
|
+
let ok;
|
|
93
|
+
if (!t.includes("/")) ok = hasFileExt(t);
|
|
94
|
+
else if (t.endsWith("/") || hasFileExt(t)) ok = true;
|
|
95
|
+
else if (roots) ok = roots.includes(t.split("/")[0]);
|
|
96
|
+
else ok = !PROSE_PAIRS.includes(t.toLowerCase()) && (isDotName(t) || !baseName(t).includes("."));
|
|
97
|
+
if (ok && !out.includes(t)) out.push(t);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handPaths(card, opts) {
|
|
104
|
+
return goalPaths(card.goal, opts).filter((p) => !IGNORED_PATHS.includes(norm(p)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function soloFile(card, opts) {
|
|
108
|
+
for (const p of goalPaths(card.goal, opts)) {
|
|
109
|
+
const n = norm(p);
|
|
110
|
+
if (SOLO_PATHS.includes(n) || SOLO_BASENAMES.includes(baseName(n))) return n;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDirEntry(p) {
|
|
116
|
+
return /\/$/.test(String(p)) || isDotName(p) || !baseName(p).includes(".");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function covers(dir, file) {
|
|
120
|
+
const d = norm(dir);
|
|
121
|
+
const f = norm(file);
|
|
122
|
+
return d !== "" && (f === d || f.startsWith(d + "/"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Two goal paths overlap: the same path, one inside the other, or the same file name.
|
|
126
|
+
function pathsOverlap(p, q) {
|
|
127
|
+
if (covers(p, q) || covers(q, p)) return true;
|
|
128
|
+
return !isDirEntry(p) && !isDirEntry(q) && baseName(p) === baseName(q);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const GLOB_CHARS = /[*?{]/;
|
|
132
|
+
|
|
133
|
+
function globToRegExp(glob) {
|
|
134
|
+
let re = "";
|
|
135
|
+
const g = norm(glob);
|
|
136
|
+
let inBrace = false;
|
|
137
|
+
for (let i = 0; i < g.length; i++) {
|
|
138
|
+
const c = g[i];
|
|
139
|
+
if (c === "*") {
|
|
140
|
+
if (g[i + 1] === "*") {
|
|
141
|
+
i++;
|
|
142
|
+
if (g[i + 1] === "/") {
|
|
143
|
+
i++;
|
|
144
|
+
re += "(?:.*/)?";
|
|
145
|
+
} else re += ".*";
|
|
146
|
+
} else re += "[^/]*";
|
|
147
|
+
} else if (c === "?") re += "[^/]";
|
|
148
|
+
else if (c === "{" && g.indexOf("}", i) >= 0) {
|
|
149
|
+
inBrace = true;
|
|
150
|
+
re += "(?:";
|
|
151
|
+
} else if (c === "}" && inBrace) {
|
|
152
|
+
inBrace = false;
|
|
153
|
+
re += ")";
|
|
154
|
+
} else if (c === "," && inBrace) re += "|";
|
|
155
|
+
else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
156
|
+
}
|
|
157
|
+
return new RegExp("^" + re + "$");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The directory a glob is rooted in: its literal prefix up to the last slash before the first glob character.
|
|
161
|
+
function globRoot(glob) {
|
|
162
|
+
const g = norm(glob);
|
|
163
|
+
const at = g.search(GLOB_CHARS);
|
|
164
|
+
const lead = at < 0 ? g : g.slice(0, at);
|
|
165
|
+
const cut = lead.lastIndexOf("/");
|
|
166
|
+
return cut < 0 ? "" : lead.slice(0, cut);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Do two plan ownership entries (exact file, directory or glob) overlap? Same semantics as scope.mjs's
|
|
170
|
+
// ownsPath, tried both ways; a glob against anything also counts when their directories nest.
|
|
171
|
+
function entriesOverlap(a, b) {
|
|
172
|
+
const x = norm(a);
|
|
173
|
+
const y = norm(b);
|
|
174
|
+
if (!x || !y) return false;
|
|
175
|
+
const gx = GLOB_CHARS.test(x);
|
|
176
|
+
const gy = GLOB_CHARS.test(y);
|
|
177
|
+
if (!gx && !gy) return covers(x, y) || covers(y, x);
|
|
178
|
+
if (gx && !gy && globToRegExp(x).test(y)) return true;
|
|
179
|
+
if (gy && !gx && globToRegExp(y).test(x)) return true;
|
|
180
|
+
const rx = gx ? globRoot(x) : x;
|
|
181
|
+
const ry = gy ? globRoot(y) : y;
|
|
182
|
+
if (rx === "" || ry === "") return true;
|
|
183
|
+
return covers(rx, ry) || covers(ry, rx);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ownedFiles(card) {
|
|
187
|
+
const out = [];
|
|
188
|
+
const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
|
|
189
|
+
for (const t of tasks) for (const f of (t && t.files) || []) if (typeof f === "string") out.push(f);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function taskIds(card) {
|
|
194
|
+
const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
|
|
195
|
+
return tasks.map((t) => t && t.id).filter((id) => typeof id === "string");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function conflict(b, why) {
|
|
199
|
+
return { with: b.id, why };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function flowWithFlow(a, b) {
|
|
203
|
+
if (!a.plan || !b.plan) return conflict(b, `the plan of ${a.plan ? b.id : a.id} is not known yet`);
|
|
204
|
+
const ids = taskIds(b);
|
|
205
|
+
const shared = taskIds(a).find((id) => ids.includes(id));
|
|
206
|
+
if (shared) return conflict(b, `both plans have a task id "${shared}"`);
|
|
207
|
+
const mine = ownedFiles(a);
|
|
208
|
+
const theirs = ownedFiles(b);
|
|
209
|
+
for (const f of mine) for (const g of theirs) if (entriesOverlap(f, g)) return conflict(b, `both plans own ${norm(f) === norm(g) ? norm(f) : `${norm(f)} and ${norm(g)}`}`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function flowWithHand(f, h, opts) {
|
|
214
|
+
if (!f.plan) return conflict(h, `the plan of ${f.id} is not known yet`);
|
|
215
|
+
if (soloFile(h, opts)) return conflict(h, `${h.id} names ${soloFile(h, opts)}, so it runs alone`);
|
|
216
|
+
for (const p of handPaths(h, opts)) {
|
|
217
|
+
for (const o of ownedFiles(f)) {
|
|
218
|
+
const bare = !norm(p).includes("/");
|
|
219
|
+
if (entriesOverlap(o, p) || (bare && !GLOB_CHARS.test(o) && baseName(o) === norm(p))) {
|
|
220
|
+
return conflict(h, `the plan owns ${norm(o)}, which the goal names (${norm(p)})`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!f.component || !h.component) return conflict(h, "a card with no component conflicts with everything");
|
|
225
|
+
if (f.component === h.component) return conflict(h, `same component ${f.component}`);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function handWithHand(a, b, opts) {
|
|
230
|
+
const sa = soloFile(a, opts);
|
|
231
|
+
if (sa) return conflict(b, `${a.id} names ${sa}, so it runs alone`);
|
|
232
|
+
const sb = soloFile(b, opts);
|
|
233
|
+
if (sb) return conflict(b, `${b.id} names ${sb}, so it runs alone`);
|
|
234
|
+
const pa = handPaths(a, opts);
|
|
235
|
+
const pb = handPaths(b, opts);
|
|
236
|
+
for (const p of pa) for (const q of pb) if (pathsOverlap(p, q)) return conflict(b, `both goals name ${norm(p) === norm(q) ? norm(p) : `${norm(p)} and ${norm(q)}`}`);
|
|
237
|
+
if (!a.component || !b.component) return conflict(b, "a card with no component conflicts with everything");
|
|
238
|
+
if (a.component === b.component) return conflict(b, `same component ${a.component}`);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// null when the two cards may run together, else { with: b.id, why }. A flow card carries its plan as `.plan`.
|
|
243
|
+
export function cardConflict(a, b, opts) {
|
|
244
|
+
if (!a || !b || typeof a !== "object" || typeof b !== "object") return { with: b && b.id !== undefined ? b.id : null, why: "a card is missing" };
|
|
245
|
+
if (a.track === "flow" && b.track === "flow") return flowWithFlow(a, b);
|
|
246
|
+
if (a.track === "flow") return flowWithHand(a, b, opts);
|
|
247
|
+
if (b.track === "flow") {
|
|
248
|
+
const c = flowWithHand(b, a, opts);
|
|
249
|
+
return c && conflict(b, c.why);
|
|
250
|
+
}
|
|
251
|
+
return handWithHand(a, b, opts);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// "hand" or "flow" for a card a queen can run; "alone" for a swarm or a decision card, which runs by itself.
|
|
255
|
+
export function queenKind(card) {
|
|
256
|
+
if (!card) return "alone";
|
|
257
|
+
if (card.swarm || card.class === "decision") return "alone";
|
|
258
|
+
return card.track === "flow" ? "flow" : "hand";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Which ready cards to start now. A flow card without a plan comes back as needsPlan (its owned files are
|
|
262
|
+
// unknown until a planner runs); with a plan it is checked like any card. A card is launched when it conflicts
|
|
263
|
+
// with nothing in flight and nothing launched before it; otherwise it is held against what it met first.
|
|
264
|
+
// needsPlan and launch together never exceed `slots`; a held card takes no slot.
|
|
265
|
+
export function pickCards(arg) {
|
|
266
|
+
const { ready = [], inFlight = [], slots = 0, plans = {}, roots } = arg && typeof arg === "object" ? arg : {};
|
|
267
|
+
const out = { launch: [], needsPlan: [], held: [], alone: [] };
|
|
268
|
+
const opts = { roots };
|
|
269
|
+
const planOf = (c) => (plans && typeof plans === "object" && Object.hasOwn(plans, c.id) ? plans[c.id] : null);
|
|
270
|
+
const withPlan = (c) => (c.track === "flow" && planOf(c) ? { ...c, plan: planOf(c) } : c);
|
|
271
|
+
const running = (inFlight || []).filter((c) => c && typeof c === "object").map(withPlan);
|
|
272
|
+
const launched = [];
|
|
273
|
+
let blocked = false;
|
|
274
|
+
for (const raw of ready || []) {
|
|
275
|
+
if (!raw || typeof raw !== "object") continue;
|
|
276
|
+
if (queenKind(raw) === "alone") {
|
|
277
|
+
// An alone card does not starve: the first one runnable in board order stops later launches. It is returned
|
|
278
|
+
// only when nothing is in flight, launched in this pick, or waiting for a plan.
|
|
279
|
+
if (running.length === 0 && out.launch.length === 0 && out.needsPlan.length === 0) out.alone.push(raw.id);
|
|
280
|
+
blocked = true;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (blocked) continue;
|
|
284
|
+
if (out.launch.length + out.needsPlan.length >= slots) continue;
|
|
285
|
+
if (raw.track === "flow" && !planOf(raw)) {
|
|
286
|
+
out.needsPlan.push(raw.id);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const card = withPlan(raw);
|
|
290
|
+
let met = null;
|
|
291
|
+
for (const other of [...running, ...launched]) {
|
|
292
|
+
met = cardConflict(card, other, opts);
|
|
293
|
+
if (met) break;
|
|
294
|
+
}
|
|
295
|
+
if (met) out.held.push({ id: raw.id, with: met.with, why: met.why });
|
|
296
|
+
else {
|
|
297
|
+
out.launch.push(raw.id);
|
|
298
|
+
launched.push(card);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseResult(result) {
|
|
305
|
+
if (typeof result === "string") {
|
|
306
|
+
try {
|
|
307
|
+
return JSON.parse(result);
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return result && typeof result === "object" ? result : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// What a queen's finished run means for main: land it, resume it (the run died, or its result is unreadable, or
|
|
316
|
+
// a seat's agent returned null), or ask the user (the other queens keep running meanwhile).
|
|
317
|
+
export function classifyNotification(kind, arg) {
|
|
318
|
+
const { status, result } = arg || {};
|
|
319
|
+
if (status !== "completed") return { action: "resume", reason: `run status ${status}` };
|
|
320
|
+
const r = parseResult(result);
|
|
321
|
+
if (!r) return { action: "resume", reason: "result unreadable" };
|
|
322
|
+
if (kind === "flow") {
|
|
323
|
+
if (r.ok === true) return { action: "land" };
|
|
324
|
+
if (r.paused) {
|
|
325
|
+
const p = r.paused;
|
|
326
|
+
return { action: "ask", reason: `paused at a ${p.gate || "human"} gate (level ${p.level})` };
|
|
327
|
+
}
|
|
328
|
+
if (r.ok === false) return { action: "ask", reason: "the run stopped" + (r.stoppedAtLevel !== undefined ? ` at level ${r.stoppedAtLevel}` : "") };
|
|
329
|
+
return { action: "ask", reason: "unrecognised result" };
|
|
330
|
+
}
|
|
331
|
+
// The queen's real result (card-queen.js) carries outcome; the wrapper shape carries kind. Both are read.
|
|
332
|
+
const verdict = r.kind ?? r.outcome;
|
|
333
|
+
if (verdict === "done") return { action: "land" };
|
|
334
|
+
if (verdict === "stopped") {
|
|
335
|
+
if (r.reason === "agent-null") return { action: "resume", reason: "agent-null" };
|
|
336
|
+
return { action: "ask", reason: r.reason };
|
|
337
|
+
}
|
|
338
|
+
return { action: "ask", reason: "unrecognised result" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The done cards waiting to land, first in first out by doneAt. One lands at a time: while a card is landing
|
|
342
|
+
// nothing else is next.
|
|
343
|
+
export function landQueue(state) {
|
|
344
|
+
const cards = state && Array.isArray(state.cards) ? state.cards : [];
|
|
345
|
+
const done = cards
|
|
346
|
+
.filter((c) => c && typeof c === "object")
|
|
347
|
+
.map((c, i) => ({ c, i }))
|
|
348
|
+
.filter(({ c }) => c.state === "done")
|
|
349
|
+
.sort((x, y) => {
|
|
350
|
+
const a = x.c.doneAt || "";
|
|
351
|
+
const b = y.c.doneAt || "";
|
|
352
|
+
return a < b ? -1 : a > b ? 1 : x.i - y.i;
|
|
353
|
+
})
|
|
354
|
+
.map(({ c }) => c.id);
|
|
355
|
+
const busy = cards.some((c) => c && c.state === "landing");
|
|
356
|
+
return { next: busy || done.length === 0 ? null : done[0], queue: done };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The names of the caps whose used value is at or over the cap. An unset cap is ignored.
|
|
360
|
+
export function capsReached(caps, used) {
|
|
361
|
+
const out = [];
|
|
362
|
+
for (const name of ["usd", "wallMin", "contextPct"]) {
|
|
363
|
+
const cap = caps ? caps[name] : undefined;
|
|
364
|
+
const v = used ? used[name] : undefined;
|
|
365
|
+
if (typeof cap === "number" && Number.isFinite(cap) && typeof v === "number" && v >= cap) out.push(name);
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
@@ -77,7 +77,7 @@ export function priceUsage(usage, model) {
|
|
|
77
77
|
|
|
78
78
|
// The transcript writes one line per content block of an API response, each carrying that response's usage (and
|
|
79
79
|
// the early blocks a partial output count), so usage is taken once per message id, the largest value seen per field.
|
|
80
|
-
export function transcriptUsage(file) {
|
|
80
|
+
export function transcriptUsage(file, { since = null, until = null } = {}) {
|
|
81
81
|
const perMessage = new Map();
|
|
82
82
|
const models = new Map();
|
|
83
83
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
@@ -90,6 +90,13 @@ export function transcriptUsage(file) {
|
|
|
90
90
|
}
|
|
91
91
|
const m = o && o.message;
|
|
92
92
|
if (o.type !== "assistant" || !m || !m.usage) continue;
|
|
93
|
+
if (since !== null || until !== null) {
|
|
94
|
+
// A window (card usd-budget-size-class) keeps only the lines whose timestamp falls in it, inclusive at both
|
|
95
|
+
// ends. A line with no readable timestamp cannot be placed and is kept: counting it is the conservative
|
|
96
|
+
// choice for a budget.
|
|
97
|
+
const at = Date.parse(o.timestamp);
|
|
98
|
+
if (!Number.isNaN(at) && ((since !== null && at < since) || (until !== null && at > until))) continue;
|
|
99
|
+
}
|
|
93
100
|
const id = m.id || o.requestId || o.uuid;
|
|
94
101
|
const u = m.usage;
|
|
95
102
|
const cur = perMessage.get(id) || { ...emptyUsage(), model: m.model || null };
|
|
@@ -468,7 +475,7 @@ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".c
|
|
|
468
475
|
// <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
|
|
469
476
|
// the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
|
|
470
477
|
// Workflow journal.jsonl files carry no usage lines and add nothing.
|
|
471
|
-
export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
|
|
478
|
+
export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd(), since = null, until = null } = {}) {
|
|
472
479
|
const top = sessionTranscriptPath({ session, claudeDir, projectDir });
|
|
473
480
|
const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
|
|
474
481
|
const id = basename(top, ".jsonl");
|
|
@@ -485,23 +492,26 @@ export function costSession({ session, claudeDir = join(homedir(), ".claude"), p
|
|
|
485
492
|
const usage = emptyUsage();
|
|
486
493
|
let usd = 0;
|
|
487
494
|
let unpriced = 0;
|
|
495
|
+
let counted = 0;
|
|
496
|
+
const windowed = since !== null || until !== null;
|
|
488
497
|
const notes = [];
|
|
489
498
|
for (const f of files) {
|
|
490
|
-
const t = transcriptUsage(f);
|
|
499
|
+
const t = transcriptUsage(f, { since, until });
|
|
491
500
|
addUsage(usage, t.usage);
|
|
492
501
|
if (!t.messages) continue;
|
|
502
|
+
counted++;
|
|
493
503
|
const price = t.usd;
|
|
494
504
|
if (price === null) {
|
|
495
505
|
unpriced++;
|
|
496
506
|
notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
|
|
497
507
|
} else usd += price;
|
|
498
508
|
}
|
|
499
|
-
return { session: id, files: files.length, total: { usage, usd, unpriced, agents: files.length }, pricesDated: PRICES_DATED, notes };
|
|
509
|
+
return { session: id, files: windowed ? counted : files.length, windowed, total: { usage, usd, unpriced, agents: windowed ? counted : files.length }, pricesDated: PRICES_DATED, notes };
|
|
500
510
|
}
|
|
501
511
|
|
|
502
512
|
export function renderSession(result) {
|
|
503
513
|
const t = result.total;
|
|
504
|
-
const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}.`];
|
|
514
|
+
const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}${result.windowed ? " in window" : ""}.`];
|
|
505
515
|
lines.push(`Session total: ${fmtInt(t.usage.input)} input, ${fmtInt(t.usage.output)} output, ${fmtInt(t.usage.cacheRead)} cache read, ${fmtInt(t.usage.cacheWrite)} cache write tokens; ${fmtUsd(t.usd)}${t.unpriced ? ` (${t.unpriced} transcript(s) unpriced)` : ""} at list prices dated ${result.pricesDated}.`);
|
|
506
516
|
for (const n of result.notes) lines.push(`Note: ${n}.`);
|
|
507
517
|
return lines.join("\n") + "\n";
|
|
@@ -3,12 +3,39 @@
|
|
|
3
3
|
// /v1/embeddings endpoint (Ollama, llama-server, vLLM, LM Studio, a hosted OpenAI-shaped key) and Voyage AI's
|
|
4
4
|
// own endpoint. Product first: memory.embeddings in .doug/config.json is null by default, and every function
|
|
5
5
|
// here treats "no provider" or "provider unreachable" as a normal outcome, never a thrown error.
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
9
12
|
|
|
10
13
|
const CONFIG_RELPATH = ".doug/config.json";
|
|
11
14
|
const VALID_PROVIDERS = new Set(["openai-compatible", "voyage"]);
|
|
15
|
+
|
|
16
|
+
// The bundled local model (card memory-bundled-embedding, ADR 0006 amendment 2026-10-08): the runtime is
|
|
17
|
+
// installed into a user-level cache and loaded by dynamic import, never a dependency of any Doug package.
|
|
18
|
+
export const LOCAL_MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX";
|
|
19
|
+
export const LOCAL_DTYPE = "q8";
|
|
20
|
+
export const LOCAL_RUNTIME_PACKAGE = "@huggingface/transformers@4.3.1";
|
|
21
|
+
export const LOCAL_QUERY_PREFIX = "task: search result | query: ";
|
|
22
|
+
export const LOCAL_DOCUMENT_PREFIX = "title: none | text: ";
|
|
23
|
+
const LOCAL_DIMS = 512;
|
|
24
|
+
|
|
25
|
+
export function localCacheDir(env = process.env, home = homedir()) {
|
|
26
|
+
if (env.DOUG_CACHE_DIR) return env.DOUG_CACHE_DIR;
|
|
27
|
+
return join(env.XDG_CACHE_HOME || join(home, ".cache"), "doug");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function localPaths(cacheDir) {
|
|
31
|
+
const modelsDir = join(cacheDir, "models");
|
|
32
|
+
return {
|
|
33
|
+
cacheDir,
|
|
34
|
+
runtimeDir: join(cacheDir, "runtime"),
|
|
35
|
+
modelsDir,
|
|
36
|
+
modelFile: join(modelsDir, "onnx-community", "embeddinggemma-300m-ONNX", "onnx", "model_quantized.onnx"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
12
39
|
const DEFAULT_TIMEOUT_MS = 2000;
|
|
13
40
|
const OLLAMA_BASE_URL = "http://localhost:11434";
|
|
14
41
|
const DEFAULT_MODEL = "embeddinggemma";
|
|
@@ -21,6 +48,7 @@ const PRESETS = {
|
|
|
21
48
|
unsloth: { provider: "openai-compatible", baseUrl: "http://127.0.0.1:8888", apiKeyEnv: "UNSLOTH_API_KEY", sendsDims: false, requiresKey: true, modelsList: "v1" },
|
|
22
49
|
openai: { provider: "openai-compatible", baseUrl: "https://api.openai.com", apiKeyEnv: "OPENAI_API_KEY", sendsDims: true, requiresKey: true, modelsList: "v1" },
|
|
23
50
|
voyage: { provider: "voyage", baseUrl: "https://api.voyageai.com", apiKeyEnv: "VOYAGE_API_KEY", sendsDims: true, requiresKey: true, modelsList: null },
|
|
51
|
+
local: { provider: "local", baseUrl: undefined, apiKeyEnv: undefined, sendsDims: false, requiresKey: false, modelsList: null },
|
|
24
52
|
custom: { provider: undefined, baseUrl: undefined, apiKeyEnv: undefined, sendsDims: true, requiresKey: false, modelsList: null },
|
|
25
53
|
};
|
|
26
54
|
|
|
@@ -60,7 +88,8 @@ export function readMemoryConfig(dir) {
|
|
|
60
88
|
const e = memory && typeof memory === "object" ? memory.embeddings : undefined;
|
|
61
89
|
if (e === null || e === undefined) return { embeddings: null };
|
|
62
90
|
const resolved = applyPreset(e);
|
|
63
|
-
|
|
91
|
+
const isLocal = e.preset === "local";
|
|
92
|
+
if (isLocal ? !isValidLocalConfig(resolved) : !isValidEmbeddingsConfig(resolved)) {
|
|
64
93
|
process.stderr.write(`[doug] ${CONFIG_RELPATH} memory.embeddings is not a valid shape; treating as unset (keyword-only)\n`);
|
|
65
94
|
return { embeddings: null };
|
|
66
95
|
}
|
|
@@ -80,9 +109,18 @@ function applyPreset(e) {
|
|
|
80
109
|
for (const k of ["provider", "baseUrl", "apiKeyEnv"]) {
|
|
81
110
|
if (out[k] === undefined && p[k] !== undefined) out[k] = p[k];
|
|
82
111
|
}
|
|
112
|
+
if (e.preset === "local") {
|
|
113
|
+
if (out.model === undefined) out.model = LOCAL_MODEL_ID;
|
|
114
|
+
if (out.dims === undefined) out.dims = LOCAL_DIMS;
|
|
115
|
+
}
|
|
83
116
|
return out;
|
|
84
117
|
}
|
|
85
118
|
|
|
119
|
+
// The local runtime runs one model at one width: a config that names anything else is invalid.
|
|
120
|
+
function isValidLocalConfig(r) {
|
|
121
|
+
return !!(r && r.provider === "local" && r.model === LOCAL_MODEL_ID && r.dims === LOCAL_DIMS);
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
function isValidIndexConfig(idx) {
|
|
87
125
|
return !!(
|
|
88
126
|
idx &&
|
|
@@ -452,11 +490,96 @@ async function embedBatched(texts, inputType, sendBatch) {
|
|
|
452
490
|
return { ok: true, vectors };
|
|
453
491
|
}
|
|
454
492
|
|
|
493
|
+
async function defaultLoadTransformers(runtimeDir) {
|
|
494
|
+
const req = createRequire(join(runtimeDir, "noop.js"));
|
|
495
|
+
return import(pathToFileURL(req.resolve("@huggingface/transformers")).href);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The in-process provider: the transformers module comes from opts.loadTransformers (the test seam) or the
|
|
499
|
+
// runtime dir under the cache. Loads lazily on the first embed and keeps the model for the provider's life.
|
|
500
|
+
// Recall never reaches the network (allowRemoteModels false); only setup's download passes allowRemote.
|
|
501
|
+
function createLocalProvider(e, opts) {
|
|
502
|
+
const paths = localPaths(opts.cacheDir ?? localCacheDir());
|
|
503
|
+
const loadTransformers = opts.loadTransformers ?? (() => defaultLoadTransformers(paths.runtimeDir));
|
|
504
|
+
let loaded = null;
|
|
505
|
+
const load = async () => {
|
|
506
|
+
if (loaded) return loaded;
|
|
507
|
+
let mod;
|
|
508
|
+
try {
|
|
509
|
+
mod = await loadTransformers();
|
|
510
|
+
} catch {
|
|
511
|
+
return { failure: { ok: false, reason: `local embedding runtime not installed at ${paths.runtimeDir}: run doug memory setup` } };
|
|
512
|
+
}
|
|
513
|
+
mod.env.cacheDir = paths.modelsDir;
|
|
514
|
+
mod.env.allowRemoteModels = opts.allowRemote === true;
|
|
515
|
+
const tokenizer = await mod.AutoTokenizer.from_pretrained(LOCAL_MODEL_ID);
|
|
516
|
+
const model = await mod.AutoModel.from_pretrained(LOCAL_MODEL_ID, { dtype: LOCAL_DTYPE });
|
|
517
|
+
loaded = { tokenizer, model };
|
|
518
|
+
return loaded;
|
|
519
|
+
};
|
|
520
|
+
const sendBatch = async (texts, inputType) => {
|
|
521
|
+
const l = await load();
|
|
522
|
+
if (l.failure) return l.failure;
|
|
523
|
+
const prefix = inputType === "query" ? LOCAL_QUERY_PREFIX : LOCAL_DOCUMENT_PREFIX;
|
|
524
|
+
const inputs = l.tokenizer(texts.map((t) => prefix + t), { padding: true, truncation: true });
|
|
525
|
+
const { sentence_embedding } = await l.model(inputs);
|
|
526
|
+
const vectors = [];
|
|
527
|
+
for (const row of sentence_embedding.tolist()) {
|
|
528
|
+
if (row.length < LOCAL_DIMS) return { ok: false, reason: `wrong dims: got ${row.length}, expected ${LOCAL_DIMS}` };
|
|
529
|
+
vectors.push(l2normalize(Float32Array.from(row.slice(0, LOCAL_DIMS))));
|
|
530
|
+
}
|
|
531
|
+
return { ok: true, vectors };
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
name: "local",
|
|
535
|
+
model: LOCAL_MODEL_ID,
|
|
536
|
+
dims: LOCAL_DIMS,
|
|
537
|
+
async embed(texts, { inputType = "document" } = {}) {
|
|
538
|
+
try {
|
|
539
|
+
return await embedBatched(texts, inputType, sendBatch);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return { ok: false, reason: `local embedding failed: ${err.message}` };
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const LOCAL_CONFIG = { embeddings: { preset: "local", provider: "local", model: LOCAL_MODEL_ID, dims: LOCAL_DIMS } };
|
|
548
|
+
|
|
549
|
+
// Installs the pinned runtime into <cacheDir>/runtime with the npm CLI, CPU only (the CUDA download is skipped on
|
|
550
|
+
// every platform). Never throws.
|
|
551
|
+
export function installLocalRuntime({ cacheDir, spawnImpl = spawn, platform = process.platform } = {}) {
|
|
552
|
+
return new Promise((resolve) => {
|
|
553
|
+
try {
|
|
554
|
+
const { runtimeDir } = localPaths(cacheDir ?? localCacheDir());
|
|
555
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
556
|
+
const child = spawnImpl(
|
|
557
|
+
platform === "win32" ? "npm.cmd" : "npm",
|
|
558
|
+
["install", "--prefix", runtimeDir, "--no-audit", "--no-fund", LOCAL_RUNTIME_PACKAGE],
|
|
559
|
+
{ stdio: "inherit", env: { ...process.env, ONNXRUNTIME_NODE_INSTALL: "skip" } },
|
|
560
|
+
);
|
|
561
|
+
child.on("error", (err) => resolve({ ok: false, reason: `npm install failed to start: ${err.message}` }));
|
|
562
|
+
child.on("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, reason: `npm install exited with code ${code}` }));
|
|
563
|
+
} catch (err) {
|
|
564
|
+
resolve({ ok: false, reason: `npm install failed: ${err.message}` });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// One document embed with remote models allowed: from_pretrained downloads the tokenizer and q8 model into the
|
|
570
|
+
// models dir. Never throws.
|
|
571
|
+
export async function downloadLocalModel({ cacheDir, loadTransformers } = {}) {
|
|
572
|
+
const provider = createProvider(LOCAL_CONFIG, { cacheDir, loadTransformers, allowRemote: true });
|
|
573
|
+
const r = await provider.embed(["probe"], { inputType: "document" });
|
|
574
|
+
return r.ok ? { ok: true } : { ok: false, reason: r.reason };
|
|
575
|
+
}
|
|
576
|
+
|
|
455
577
|
// cfg is the { embeddings } shape readMemoryConfig returns. Returns null when nothing is configured (the
|
|
456
578
|
// caller's cue to run keyword-only); otherwise { name, model, dims, embed(texts, { inputType }) }.
|
|
457
579
|
export function createProvider(cfg, opts = {}) {
|
|
458
580
|
const e = cfg && cfg.embeddings;
|
|
459
581
|
if (!e) return null;
|
|
582
|
+
if (e.provider === "local") return createLocalProvider(e, opts);
|
|
460
583
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
461
584
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
462
585
|
const state = { omitDims: false };
|
|
@@ -569,6 +692,14 @@ export async function probeProvider(cfg, opts = {}) {
|
|
|
569
692
|
const baseUrl = e && e.baseUrl ? e.baseUrl : process.env.DOUG_OLLAMA_BASE_URL || OLLAMA_BASE_URL;
|
|
570
693
|
const wantModel = e && e.model ? e.model : DEFAULT_MODEL;
|
|
571
694
|
const root = baseUrl.replace(/\/$/, "");
|
|
695
|
+
if (e && e.provider === "local") {
|
|
696
|
+
const { cacheDir, modelFile } = localPaths(opts.cacheDir ?? localCacheDir());
|
|
697
|
+
const modelPresent = existsSync(modelFile);
|
|
698
|
+
const base = { kind: "local", cacheDir, modelFile, modelPresent };
|
|
699
|
+
if (!modelPresent) return { ...base, reachable: false, embedOk: false, reason: "model file missing" };
|
|
700
|
+
const result = await createProvider(cfg, { cacheDir, loadTransformers: opts.loadTransformers }).embed(["probe"]);
|
|
701
|
+
return { ...base, reachable: result.ok, embedOk: result.ok, reason: result.ok ? null : result.reason };
|
|
702
|
+
}
|
|
572
703
|
const preset = presetOf(e);
|
|
573
704
|
if (preset && preset.modelsList === "v1") return probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs });
|
|
574
705
|
if (preset && e.provider === "voyage") {
|
|
@@ -675,6 +806,16 @@ export function doctorReport(cfg, probe, platform = process.platform) {
|
|
|
675
806
|
return lines.join("\n");
|
|
676
807
|
}
|
|
677
808
|
|
|
809
|
+
if (embeddings.provider === "local") {
|
|
810
|
+
lines.push(`memory.embeddings is configured: local ${embeddings.model}/${embeddings.dims} (bundled, in-process).`);
|
|
811
|
+
lines.push(`Cache: ${probe.cacheDir}`);
|
|
812
|
+
lines.push(`Model file: ${probe.modelFile} (${probe.modelPresent ? "present" : "missing"})`);
|
|
813
|
+
lines.push(`Dims: ${embeddings.dims}`);
|
|
814
|
+
if (!probe.modelPresent) lines.push("Run doug memory setup to install it.");
|
|
815
|
+
else if (probe.embedOk) lines.push("Test embed: ok");
|
|
816
|
+
else lines.push(`Test embed failed: ${probe.reason || "unknown error"}`);
|
|
817
|
+
return lines.join("\n");
|
|
818
|
+
}
|
|
678
819
|
lines.push(`memory.embeddings is configured: ${embeddings.provider} ${embeddings.model}/${embeddings.dims} at ${embeddings.baseUrl}.`);
|
|
679
820
|
if (presetOf(embeddings) && PRESETS[embeddings.preset].modelsList === "v1") {
|
|
680
821
|
if (!probe.reachable) lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|