@dougharness/cli 1.0.2 → 1.1.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/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +9 -3
- package/dist/board-serve.js.map +1 -1
- package/dist/board.js +10 -2
- package/dist/board.js.map +1 -1
- package/dist/detect/repo.js +14 -1
- package/dist/detect/repo.js.map +1 -1
- package/dist/detect/types.d.ts +2 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +491 -0
- package/dist/doctor.js.map +1 -0
- package/dist/generate/agents.d.ts +7 -0
- package/dist/generate/agents.js +62 -12
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +4 -0
- package/dist/generate/claude-md.js.map +1 -1
- package/dist/generate/config.d.ts +8 -0
- package/dist/generate/config.js +4 -1
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +15 -6
- package/dist/generate/proposal.js.map +1 -1
- package/dist/generate/settings.d.ts +2 -0
- package/dist/generate/settings.js +17 -2
- package/dist/generate/settings.js.map +1 -1
- package/dist/init.d.ts +14 -0
- package/dist/init.js +94 -8
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +28 -0
- package/dist/memory-setup.js +323 -0
- package/dist/memory-setup.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
- package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
- package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
- package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
- package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
- package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
- package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
- package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
- package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
- package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
- package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
- package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
- package/package.json +3 -3
- package/templates/board-app.js +2 -2
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
|
@@ -13,6 +13,21 @@ const DEFAULT_TIMEOUT_MS = 2000;
|
|
|
13
13
|
const OLLAMA_BASE_URL = "http://localhost:11434";
|
|
14
14
|
const DEFAULT_MODEL = "embeddinggemma";
|
|
15
15
|
|
|
16
|
+
// Provider presets (card embedding-providers-unsloth): memory.embeddings.preset fills only the fields the
|
|
17
|
+
// config leaves unset. sendsDims: whether the request carries `dimensions`; requiresKey: an unset key env var
|
|
18
|
+
// is refused without calling the server; modelsList: "v1" lists models with GET /v1/models.
|
|
19
|
+
const PRESETS = {
|
|
20
|
+
ollama: { provider: "openai-compatible", baseUrl: OLLAMA_BASE_URL, apiKeyEnv: undefined, sendsDims: true, requiresKey: false, modelsList: null },
|
|
21
|
+
unsloth: { provider: "openai-compatible", baseUrl: "http://127.0.0.1:8888", apiKeyEnv: "UNSLOTH_API_KEY", sendsDims: false, requiresKey: true, modelsList: "v1" },
|
|
22
|
+
openai: { provider: "openai-compatible", baseUrl: "https://api.openai.com", apiKeyEnv: "OPENAI_API_KEY", sendsDims: true, requiresKey: true, modelsList: "v1" },
|
|
23
|
+
voyage: { provider: "voyage", baseUrl: "https://api.voyageai.com", apiKeyEnv: "VOYAGE_API_KEY", sendsDims: true, requiresKey: true, modelsList: null },
|
|
24
|
+
custom: { provider: undefined, baseUrl: undefined, apiKeyEnv: undefined, sendsDims: true, requiresKey: false, modelsList: null },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function presetOf(e) {
|
|
28
|
+
return e && typeof e.preset === "string" && Object.hasOwn(PRESETS, e.preset) ? PRESETS[e.preset] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
function isValidEmbeddingsConfig(e) {
|
|
17
32
|
return !!(
|
|
18
33
|
e &&
|
|
@@ -44,13 +59,30 @@ export function readMemoryConfig(dir) {
|
|
|
44
59
|
const memory = raw && typeof raw === "object" ? raw.memory : undefined;
|
|
45
60
|
const e = memory && typeof memory === "object" ? memory.embeddings : undefined;
|
|
46
61
|
if (e === null || e === undefined) return { embeddings: null };
|
|
47
|
-
|
|
62
|
+
const resolved = applyPreset(e);
|
|
63
|
+
if (!isValidEmbeddingsConfig(resolved)) {
|
|
48
64
|
process.stderr.write(`[doug] ${CONFIG_RELPATH} memory.embeddings is not a valid shape; treating as unset (keyword-only)\n`);
|
|
49
65
|
return { embeddings: null };
|
|
50
66
|
}
|
|
67
|
+
if (e.preset !== undefined) return {
|
|
68
|
+
embeddings: { preset: e.preset, provider: resolved.provider, baseUrl: resolved.baseUrl, model: resolved.model, dims: resolved.dims, apiKeyEnv: resolved.apiKeyEnv },
|
|
69
|
+
};
|
|
51
70
|
return { embeddings: { provider: e.provider, baseUrl: e.baseUrl, model: e.model, dims: e.dims, apiKeyEnv: e.apiKeyEnv } };
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
// A preset fills provider, baseUrl and apiKeyEnv only where the config leaves them unset. A config with no
|
|
74
|
+
// `preset` key is returned as is.
|
|
75
|
+
function applyPreset(e) {
|
|
76
|
+
if (!e || typeof e !== "object" || e.preset === undefined) return e;
|
|
77
|
+
const p = presetOf(e);
|
|
78
|
+
if (!p) return null;
|
|
79
|
+
const out = { ...e };
|
|
80
|
+
for (const k of ["provider", "baseUrl", "apiKeyEnv"]) {
|
|
81
|
+
if (out[k] === undefined && p[k] !== undefined) out[k] = p[k];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
function isValidIndexConfig(idx) {
|
|
55
87
|
return !!(
|
|
56
88
|
idx &&
|
|
@@ -230,10 +262,28 @@ export async function checkEmbeddingSanity(provider, pairs) {
|
|
|
230
262
|
};
|
|
231
263
|
}
|
|
232
264
|
|
|
233
|
-
// Task prefixes the provider layer must add; the server never adds them. Only the
|
|
234
|
-
//
|
|
265
|
+
// Task prefixes the provider layer must add; the server never adds them. Only the models below carry a
|
|
266
|
+
// prefix; every other model gets none. mxbai and qwen3 prefix queries only, documents get "".
|
|
235
267
|
function prefixFor(model, inputType) {
|
|
236
|
-
|
|
268
|
+
// Match on the lowercased basename after the last "/", so HF-style names (org/name) and Ollama names agree.
|
|
269
|
+
let m = String(model || "").split(/[/\\]/).pop().toLowerCase();
|
|
270
|
+
// LM Studio-style "text-embedding-<name>" ids: match on <name> when it is a prefixed family (not OpenAI's text-embedding-3-*).
|
|
271
|
+
if (m.startsWith("text-embedding-")) {
|
|
272
|
+
const rest = m.slice("text-embedding-".length);
|
|
273
|
+
if (["mxbai-embed-large", "qwen3-embedding", "embeddinggemma", "nomic"].some((f) => rest.startsWith(f))) m = rest;
|
|
274
|
+
}
|
|
275
|
+
// https://huggingface.co/mixedbread-ai/mxbai-embed-large-v1 README ("add the prompt for query (not for
|
|
276
|
+
// documents)"); https://ollama.com/library/mxbai-embed-large
|
|
277
|
+
if (m.startsWith("mxbai-embed-large")) {
|
|
278
|
+
return inputType === "query" ? "Represent this sentence for searching relevant passages: " : "";
|
|
279
|
+
}
|
|
280
|
+
// https://huggingface.co/Qwen/Qwen3-Embedding-0.6B/raw/main/config_sentence_transformers.json (query prompt;
|
|
281
|
+
// "document": ""). No space after "Query:".
|
|
282
|
+
if (m.startsWith("qwen3-embedding")) {
|
|
283
|
+
return inputType === "query"
|
|
284
|
+
? "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:"
|
|
285
|
+
: "";
|
|
286
|
+
}
|
|
237
287
|
if (m.startsWith("embeddinggemma")) {
|
|
238
288
|
return inputType === "query" ? "task: search result | query: " : "title: none | text: ";
|
|
239
289
|
}
|
|
@@ -247,34 +297,73 @@ function isAbortLike(err) {
|
|
|
247
297
|
return err && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
248
298
|
}
|
|
249
299
|
|
|
250
|
-
|
|
300
|
+
// The key never reaches a reason: a server that echoes the Authorization header back in an error body gets
|
|
301
|
+
// every occurrence of the key replaced.
|
|
302
|
+
async function readBodySnippet(res, key) {
|
|
251
303
|
try {
|
|
252
|
-
|
|
304
|
+
let text = await res.text();
|
|
305
|
+
if (key) text = text.split(key).join("[redacted]");
|
|
306
|
+
return text.slice(0, 200);
|
|
253
307
|
} catch {
|
|
254
308
|
return "";
|
|
255
309
|
}
|
|
256
310
|
}
|
|
257
311
|
|
|
258
|
-
|
|
312
|
+
// A provider whose baseUrl host is localhost, 127.0.0.1 or ::1 never requires a key (card local-provider-no-key):
|
|
313
|
+
// the key is sent only when its env var is set, and a 401/403 says the server wants one. An unparseable URL is not local.
|
|
314
|
+
function isLocalUrl(baseUrl) {
|
|
315
|
+
try {
|
|
316
|
+
const h = new URL(baseUrl).hostname;
|
|
317
|
+
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function localKeyReason(e) {
|
|
324
|
+
const preset = presetOf(e);
|
|
325
|
+
const name = e.apiKeyEnv || (preset && preset.apiKeyEnv) || "OPENAI_API_KEY";
|
|
326
|
+
return process.env[name] ? `server rejected the key in ${name}` : `server requires a key: set ${name}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// `state` is the provider instance's memory: { omitDims } turns true once a server has rejected `dimensions`.
|
|
330
|
+
async function embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }, state = { omitDims: false }) {
|
|
331
|
+
const preset = presetOf(e);
|
|
332
|
+
const local = isLocalUrl(e.baseUrl);
|
|
333
|
+
if (preset && preset.requiresKey && !local) {
|
|
334
|
+
const name = e.apiKeyEnv || "OPENAI_API_KEY";
|
|
335
|
+
if (!process.env[name]) return { ok: false, reason: `missing API key: set ${name}` };
|
|
336
|
+
}
|
|
259
337
|
const prefix = prefixFor(e.model, inputType);
|
|
260
338
|
const input = texts.map((t) => prefix + t);
|
|
261
339
|
const url = `${e.baseUrl.replace(/\/$/, "")}/v1/embeddings`;
|
|
262
340
|
const headers = { "content-type": "application/json" };
|
|
263
|
-
const key = process.env[e.apiKeyEnv || "OPENAI_API_KEY"];
|
|
341
|
+
const key = process.env[e.apiKeyEnv || (local && preset && preset.apiKeyEnv) || "OPENAI_API_KEY"];
|
|
264
342
|
if (key) headers.authorization = `Bearer ${key}`;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
method: "POST",
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
343
|
+
const send = async (withDims) => {
|
|
344
|
+
const payload = withDims ? { model: e.model, input, dimensions: e.dims } : { model: e.model, input };
|
|
345
|
+
try {
|
|
346
|
+
return await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs) });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
return { failure: { ok: false, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` } };
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const sendsDims = !(preset && !preset.sendsDims) && !state.omitDims;
|
|
352
|
+
let res = await send(sendsDims);
|
|
353
|
+
if (res.failure) return res.failure;
|
|
354
|
+
let body = null;
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
body = await readBodySnippet(res, key);
|
|
357
|
+
// A server that rejects `dimensions` (400 naming it): retry this batch once without it, remember that.
|
|
358
|
+
if (sendsDims && res.status === 400 && /dimensions/i.test(body)) {
|
|
359
|
+
state.omitDims = true;
|
|
360
|
+
res = await send(false);
|
|
361
|
+
if (res.failure) return res.failure;
|
|
362
|
+
body = res.ok ? null : await readBodySnippet(res, key);
|
|
363
|
+
}
|
|
275
364
|
}
|
|
276
365
|
if (!res.ok) {
|
|
277
|
-
|
|
366
|
+
if (local && (res.status === 401 || res.status === 403)) return { ok: false, reason: localKeyReason(e) };
|
|
278
367
|
return { ok: false, reason: `HTTP ${res.status}${body ? `: ${body}` : ""}` };
|
|
279
368
|
}
|
|
280
369
|
let json;
|
|
@@ -322,7 +411,7 @@ async function embedVoyage(e, texts, inputType, { fetchImpl, timeoutMs }) {
|
|
|
322
411
|
return { ok: false, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` };
|
|
323
412
|
}
|
|
324
413
|
if (!res.ok) {
|
|
325
|
-
const body = await readBodySnippet(res);
|
|
414
|
+
const body = await readBodySnippet(res, key);
|
|
326
415
|
return { ok: false, reason: `HTTP ${res.status}${body ? `: ${body}` : ""}` };
|
|
327
416
|
}
|
|
328
417
|
let json;
|
|
@@ -368,10 +457,11 @@ export function createProvider(cfg, opts = {}) {
|
|
|
368
457
|
if (!e) return null;
|
|
369
458
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
370
459
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
460
|
+
const state = { omitDims: false };
|
|
371
461
|
const sendBatch =
|
|
372
462
|
e.provider === "voyage"
|
|
373
463
|
? (texts, inputType) => embedVoyage(e, texts, inputType, { fetchImpl, timeoutMs })
|
|
374
|
-
: (texts, inputType) => embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs });
|
|
464
|
+
: (texts, inputType) => embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }, state);
|
|
375
465
|
return {
|
|
376
466
|
name: e.provider,
|
|
377
467
|
model: e.model,
|
|
@@ -412,6 +502,53 @@ function isOllamaPresetUrl(baseUrl) {
|
|
|
412
502
|
}
|
|
413
503
|
}
|
|
414
504
|
|
|
505
|
+
// Whether a /v1/models entry is the configured model: the id equals it, or the id's last "/" segment equals or
|
|
506
|
+
// starts with it, case-insensitively.
|
|
507
|
+
function v1ModelMatches(entry, wantModel) {
|
|
508
|
+
const id = entry && typeof entry.id === "string" ? entry.id.toLowerCase() : "";
|
|
509
|
+
const want = String(wantModel).toLowerCase();
|
|
510
|
+
if (!id) return false;
|
|
511
|
+
if (id === want) return true;
|
|
512
|
+
const last = id.split("/").pop();
|
|
513
|
+
return last === want || last.startsWith(want);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The unsloth/openai presets: GET <base>/v1/models with the Bearer key. Never throws; the key is never put in
|
|
517
|
+
// a reason.
|
|
518
|
+
async function probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs }) {
|
|
519
|
+
const kind = e.preset;
|
|
520
|
+
const envName = e.apiKeyEnv || (presetOf(e) && presetOf(e).apiKeyEnv) || "OPENAI_API_KEY";
|
|
521
|
+
const base = { reachable: false, kind, version: null, models: null, modelPresent: false, modelLoaded: null, embedOk: null };
|
|
522
|
+
const key = process.env[envName];
|
|
523
|
+
const local = isLocalUrl(root);
|
|
524
|
+
if (!key && !local) return { ...base, reason: `missing API key: set ${envName}` };
|
|
525
|
+
let res;
|
|
526
|
+
try {
|
|
527
|
+
res = await fetchImpl(`${root}/v1/models`, { headers: key ? { authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
528
|
+
} catch (err) {
|
|
529
|
+
return { ...base, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` };
|
|
530
|
+
}
|
|
531
|
+
if (local && (res.status === 401 || res.status === 403)) return { ...base, reason: localKeyReason(e) };
|
|
532
|
+
if (res.status === 401) return { ...base, reason: `HTTP 401: key rejected or missing; set ${envName}` };
|
|
533
|
+
if (!res.ok) return { ...base, reason: `HTTP ${res.status}` };
|
|
534
|
+
let json;
|
|
535
|
+
try {
|
|
536
|
+
json = await res.json();
|
|
537
|
+
} catch (err) {
|
|
538
|
+
return { ...base, reason: `invalid JSON response: ${err.message}` };
|
|
539
|
+
}
|
|
540
|
+
const entries = Array.isArray(json && json.data) ? json.data : [];
|
|
541
|
+
const match = entries.find((m) => v1ModelMatches(m, wantModel));
|
|
542
|
+
return {
|
|
543
|
+
...base,
|
|
544
|
+
reachable: true,
|
|
545
|
+
models: entries.map((m) => m && m.id).filter((id) => typeof id === "string"),
|
|
546
|
+
modelPresent: !!match,
|
|
547
|
+
modelLoaded: match && typeof match.loaded === "boolean" ? match.loaded : null,
|
|
548
|
+
reason: null,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
415
552
|
// Probes a configured provider, or a local Ollama when nothing is configured. GET /api/version (then /api/tags)
|
|
416
553
|
// is tried first; only when it succeeds is this reported as kind "ollama" with a model list. When /api/version
|
|
417
554
|
// fails — a working llama-server, vLLM, LM Studio, or hosted key never serves it — this falls back to one
|
|
@@ -419,15 +556,24 @@ function isOllamaPresetUrl(baseUrl) {
|
|
|
419
556
|
// reachable non-Ollama provider must never be told to go install Ollama. The one exception: an openai-compatible
|
|
420
557
|
// provider configured at the Ollama preset host:port (localhost/127.0.0.1:11434) that is NOT reachable even via
|
|
421
558
|
// the embed fallback is still reported as kind "ollama" — that combination means a down local Ollama, the one
|
|
422
|
-
// case doctor exists to catch, not a different, genuinely unreachable server.
|
|
559
|
+
// case doctor exists to catch, not a different, genuinely unreachable server. With nothing configured the probe
|
|
560
|
+
// targets DOUG_OLLAMA_BASE_URL when set and non-empty (read at call time), else localhost:11434; a configured
|
|
561
|
+
// baseUrl always wins. Never throws; every branch has a
|
|
423
562
|
// 2s timeout.
|
|
424
563
|
export async function probeProvider(cfg, opts = {}) {
|
|
425
564
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
426
565
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
427
566
|
const e = cfg && cfg.embeddings;
|
|
428
|
-
const baseUrl = e && e.baseUrl ? e.baseUrl : OLLAMA_BASE_URL;
|
|
567
|
+
const baseUrl = e && e.baseUrl ? e.baseUrl : process.env.DOUG_OLLAMA_BASE_URL || OLLAMA_BASE_URL;
|
|
429
568
|
const wantModel = e && e.model ? e.model : DEFAULT_MODEL;
|
|
430
569
|
const root = baseUrl.replace(/\/$/, "");
|
|
570
|
+
const preset = presetOf(e);
|
|
571
|
+
if (preset && preset.modelsList === "v1") return probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs });
|
|
572
|
+
if (preset && e.provider === "voyage") {
|
|
573
|
+
// No models endpoint is documented for Voyage: one embed probe, never the Ollama /api/version path.
|
|
574
|
+
const result = await createProvider(cfg, { fetch: fetchImpl, timeoutMs }).embed(["probe"]);
|
|
575
|
+
return { reachable: result.ok, kind: "voyage", version: null, models: null, modelPresent: null, embedOk: result.ok, reason: result.ok ? null : result.reason };
|
|
576
|
+
}
|
|
431
577
|
const isPresetHost = (!e || e.provider === "openai-compatible") && isOllamaPresetUrl(baseUrl);
|
|
432
578
|
|
|
433
579
|
let version = null;
|
|
@@ -486,6 +632,13 @@ const INSTALL_COMMANDS = {
|
|
|
486
632
|
const PRESET_SNIPPET =
|
|
487
633
|
'"memory": { "embeddings": { "provider": "openai-compatible", "baseUrl": "http://localhost:11434", "model": "embeddinggemma", "dims": 512 } }';
|
|
488
634
|
|
|
635
|
+
const PRESET_SNIPPETS = [
|
|
636
|
+
["ollama", '"memory": { "embeddings": { "preset": "ollama", "model": "embeddinggemma", "dims": 512 } }'],
|
|
637
|
+
["unsloth", '"memory": { "embeddings": { "preset": "unsloth", "model": "embeddinggemma", "dims": 512 } } (a key is only needed if the server enforces auth: UNSLOTH_API_KEY)'],
|
|
638
|
+
["openai", '"memory": { "embeddings": { "preset": "openai", "model": "text-embedding-3-small", "dims": 512 } } (set OPENAI_API_KEY)'],
|
|
639
|
+
["voyage", '"memory": { "embeddings": { "preset": "voyage", "model": "voyage-4", "dims": 512 } } (set VOYAGE_API_KEY)'],
|
|
640
|
+
];
|
|
641
|
+
|
|
489
642
|
function installCommandFor(platform) {
|
|
490
643
|
return INSTALL_COMMANDS[platform] || INSTALL_COMMANDS.linux;
|
|
491
644
|
}
|
|
@@ -515,10 +668,19 @@ export function doctorReport(cfg, probe, platform = process.platform) {
|
|
|
515
668
|
}
|
|
516
669
|
lines.push("Enable it by adding to .doug/config.json:");
|
|
517
670
|
lines.push(` ${PRESET_SNIPPET}`);
|
|
671
|
+
lines.push("Or pick a provider preset (one snippet per preset):");
|
|
672
|
+
for (const [name, snippet] of PRESET_SNIPPETS) lines.push(` ${name}: ${snippet}`);
|
|
518
673
|
return lines.join("\n");
|
|
519
674
|
}
|
|
520
675
|
|
|
521
676
|
lines.push(`memory.embeddings is configured: ${embeddings.provider} ${embeddings.model}/${embeddings.dims} at ${embeddings.baseUrl}.`);
|
|
677
|
+
if (presetOf(embeddings) && PRESETS[embeddings.preset].modelsList === "v1") {
|
|
678
|
+
if (!probe.reachable) lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|
|
679
|
+
else if (probe.modelPresent === false) lines.push(`Reachable but ${embeddings.model} is not listed by ${embeddings.baseUrl}/v1/models.`);
|
|
680
|
+
else if (probe.modelLoaded === false) lines.push(`${embeddings.model} is listed but not loaded; load it on the server.`);
|
|
681
|
+
else lines.push(`Reachable; would use ${embeddings.model}/${embeddings.dims}.`);
|
|
682
|
+
return lines.join("\n");
|
|
683
|
+
}
|
|
522
684
|
if (!probe.reachable) {
|
|
523
685
|
lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|
|
524
686
|
// Only an Ollama-shaped miss (or nothing detected at all) gets install instructions — a real, reachable
|
|
@@ -85,6 +85,39 @@ function run(command, cwd, timeoutMs) {
|
|
|
85
85
|
return { command, ok: !timedOut && r.status === 0, status: r.status, timedOut: !!timedOut, durationMs: Date.now() - started, outputTail: tail((r.stdout || "") + "\n" + (r.stderr || "")) };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// card verified-tree-record: run plan.verify through the project's vendored verify.mjs (--json), which computes the
|
|
89
|
+
// branch worktree's tree, credits a verified-tree record of it, or runs the commands and records the result for
|
|
90
|
+
// the MAIN checkout (--record-dir). Returns the entries land records under `verify` (command, ok, status, timedOut,
|
|
91
|
+
// durationMs, outputTail, plus credited: "verified-tree" when credited), stopping at the first failure like
|
|
92
|
+
// today's loop, or null when the output cannot be read (the caller then runs the loop unchanged).
|
|
93
|
+
function verifyViaVendored(script, dir, work, commands, timeoutMs) {
|
|
94
|
+
try {
|
|
95
|
+
const args = [script, "--json", "--by", "land", "--cwd", work, "--record-dir", dir, "--timeout-ms", String(timeoutMs), ...commands.flatMap((c) => ["--command", c])];
|
|
96
|
+
const r = spawnSync(process.execPath, args, { cwd: work, encoding: "utf8", timeout: timeoutMs * commands.length + 60000, maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
|
|
97
|
+
let out = null;
|
|
98
|
+
try {
|
|
99
|
+
out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
|
|
100
|
+
} catch {
|
|
101
|
+
out = null;
|
|
102
|
+
}
|
|
103
|
+
if (!out || !Array.isArray(out.results) || out.results.length === 0) {
|
|
104
|
+
const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
|
|
105
|
+
return { error: `the vendored verify.mjs ${why}; land does not re-run the commands` };
|
|
106
|
+
}
|
|
107
|
+
return out.results.map((x) => ({
|
|
108
|
+
command: x.command,
|
|
109
|
+
ok: x.ok === true,
|
|
110
|
+
status: x.status,
|
|
111
|
+
timedOut: !!x.timedOut,
|
|
112
|
+
durationMs: x.durationMs,
|
|
113
|
+
outputTail: x.outputTail || "",
|
|
114
|
+
...(out.credited ? { credited: out.credited } : {}),
|
|
115
|
+
}));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { error: `the vendored verify.mjs could not run: ${err && err.message}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
88
121
|
// Returns { ok: true, base, branch, mergeCommit, verify } or { ok: false, reason, verify? }. Never throws on a refusal.
|
|
89
122
|
export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false } = {}) {
|
|
90
123
|
if (!plan) return { ok: false, reason: "no plan" };
|
|
@@ -131,20 +164,46 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
131
164
|
const verify = [];
|
|
132
165
|
const acceptance = [];
|
|
133
166
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
// card verified-tree-record: install always runs through run(). plan.verify goes through the vendored
|
|
168
|
+
// verify.mjs when the project has one (it credits a verified-tree record of the branch's tree and writes one
|
|
169
|
+
// for the main checkout after a green run); otherwise, or when it cannot be read, today's loop runs.
|
|
170
|
+
const failVerify = (r) => {
|
|
171
|
+
const why = r.timedOut ? `timed out after ${timeoutMs} ms` : `exit ${r.status}`;
|
|
172
|
+
return { ok: false, reason: `verify failed on ${branch}: \`${r.command}\` (${why})\n${r.outputTail}`, verify, acceptance: [] };
|
|
173
|
+
};
|
|
174
|
+
if (plan.install) {
|
|
175
|
+
const r = run(plan.install, work, timeoutMs);
|
|
137
176
|
verify.push(r);
|
|
138
|
-
if (!r.ok)
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
if (!r.ok) return failVerify(r);
|
|
178
|
+
}
|
|
179
|
+
const verifyList = plan.verify || [];
|
|
180
|
+
const vendored = join(dir, ".doug/hooks/scripts/verify.mjs");
|
|
181
|
+
let viaVerify = null;
|
|
182
|
+
if (verifyList.length && existsSync(vendored)) viaVerify = verifyViaVendored(vendored, dir, work, verifyList, timeoutMs);
|
|
183
|
+
if (viaVerify && viaVerify.error) {
|
|
184
|
+
return { ok: false, reason: `verify failed on ${branch}: ${viaVerify.error}`, verify, acceptance: [] };
|
|
185
|
+
}
|
|
186
|
+
if (viaVerify) {
|
|
187
|
+
for (const r of viaVerify) {
|
|
188
|
+
verify.push(r);
|
|
189
|
+
if (!r.ok) return failVerify(r);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
for (const command of verifyList) {
|
|
193
|
+
const r = run(command, work, timeoutMs);
|
|
194
|
+
verify.push(r);
|
|
195
|
+
if (!r.ok) return failVerify(r);
|
|
141
196
|
}
|
|
142
197
|
}
|
|
143
198
|
|
|
144
199
|
const acceptanceCommands = acceptanceEntries(plan.acceptance).filter((a) => a.command);
|
|
145
200
|
for (const a of acceptanceCommands) {
|
|
146
|
-
|
|
147
|
-
const
|
|
201
|
+
// Card flow-suite-dedupe: a command identical to a plan.verify command already ran (green, or land returned above); reuse its result.
|
|
202
|
+
const same = verify.slice(plan.install ? 1 : 0).find((v) => String(v.command).trim() === String(a.command).trim());
|
|
203
|
+
const r = same ? null : run(a.command, work, timeoutMs);
|
|
204
|
+
const entry = same
|
|
205
|
+
? { text: a.text, command: a.command, ok: same.ok, status: same.status, timedOut: false, durationMs: 0, outputTail: "", reused: "verify" }
|
|
206
|
+
: { text: a.text, command: a.command, ok: r.ok, status: r.status, timedOut: r.timedOut, durationMs: r.durationMs, outputTail: r.outputTail };
|
|
148
207
|
acceptance.push(entry);
|
|
149
208
|
if (!entry.ok) {
|
|
150
209
|
const why = entry.timedOut ? `timed out after ${timeoutMs} ms` : `exit ${entry.status}`;
|
|
@@ -211,8 +270,8 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
211
270
|
baseWas: baseTip.slice(0, 7),
|
|
212
271
|
branchTip: branchTip.slice(0, 7),
|
|
213
272
|
mergeCommit,
|
|
214
|
-
verify: verify.map((v) => ({ command: v.command, ok: v.ok, durationMs: v.durationMs })),
|
|
215
|
-
acceptance: acceptance.map((a) => ({ text: a.text, command: a.command, ok: a.ok, durationMs: a.durationMs })),
|
|
273
|
+
verify: verify.map((v) => ({ command: v.command, ok: v.ok, durationMs: v.durationMs, ...(v.credited ? { credited: v.credited } : {}) })),
|
|
274
|
+
acceptance: acceptance.map((a) => ({ text: a.text, command: a.command, ok: a.ok, durationMs: a.durationMs, ...(a.reused ? { reused: a.reused } : {}) })),
|
|
216
275
|
};
|
|
217
276
|
savePlan(dir, { ...plan, landed });
|
|
218
277
|
return { ok: true, base, branch, mergeCommit, verify, acceptance, worktrees, branches: branchesResult };
|
|
@@ -48,7 +48,7 @@ function splitGateClauses(gate) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Pure: reads one hand-track gate string into "pass" or "fail" (see the rules in the comment above). Exported
|
|
51
|
-
// for both isGateFailure below and run-report/
|
|
51
|
+
// for both isGateFailure below and run-report/doug-hand's own use of the same reading.
|
|
52
52
|
export function readGate(gate) {
|
|
53
53
|
if (typeof gate !== "string" || !gate.trim()) return "fail";
|
|
54
54
|
const clauses = splitGateClauses(gate);
|
|
@@ -474,13 +474,13 @@ const DEMOTE_RULES = [
|
|
|
474
474
|
];
|
|
475
475
|
|
|
476
476
|
// Review MINOR 7: a `tighten` target is the real path readOwnSkills() (scripts/learn.mjs) read the skill's
|
|
477
|
-
// description from (`skill.file`), not a hardcoded `
|
|
477
|
+
// description from (`skill.file`), not a hardcoded `skills/<name>/SKILL.md` - a caller may
|
|
478
478
|
// point `learn.mjs` at a checkout laid out differently, or at a skill outside this repository entirely. Made
|
|
479
479
|
// relative to `dir` when the file is really inside it (the common case: readable, and matches how every other
|
|
480
480
|
// proposal's `target` reads), else reported absolute rather than a misleading relative path. Falls back to the
|
|
481
|
-
//
|
|
481
|
+
// plugin-relative `skills/<name>/SKILL.md` shape only for a caller (or an older test fixture) that supplies no `file` at all.
|
|
482
482
|
function tightenTarget(dir, skill) {
|
|
483
|
-
if (!skill.file) return `
|
|
483
|
+
if (!skill.file) return `skills/${skill.name}/SKILL.md`;
|
|
484
484
|
const resolved = resolve(skill.file);
|
|
485
485
|
if (dir) {
|
|
486
486
|
const base = resolve(dir);
|