@dougharness/cli 1.0.2 → 1.1.1
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 +18 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +14 -7
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +42 -9
- 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 +326 -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/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +3 -3
- 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 +171 -13
- 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 +155 -7
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
- package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
- package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
- package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
- package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
- 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/bash-rules.mjs +21 -14
- 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/gate-lock.mjs +94 -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 +558 -197
- 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 +201 -0
- package/package.json +4 -4
- package/templates/board-app.js +4 -3
- 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/core-next/SKILL.md +0 -92
- 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,30 @@ 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
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
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 "".
|
|
267
|
+
export const PREFIXED_MODELS = ["embeddinggemma", "nomic-embed-text", "mxbai-embed-large", "qwen3-embedding"];
|
|
268
|
+
|
|
269
|
+
export function prefixFor(model, inputType) {
|
|
270
|
+
// Match on the lowercased basename after the last "/", so HF-style names (org/name) and Ollama names agree.
|
|
271
|
+
let m = String(model || "").split(/[/\\]/).pop().toLowerCase();
|
|
272
|
+
// LM Studio-style "text-embedding-<name>" ids: match on <name> when it is a prefixed family (not OpenAI's text-embedding-3-*).
|
|
273
|
+
if (m.startsWith("text-embedding-")) {
|
|
274
|
+
const rest = m.slice("text-embedding-".length);
|
|
275
|
+
if (["mxbai-embed-large", "qwen3-embedding", "embeddinggemma", "nomic"].some((f) => rest.startsWith(f))) m = rest;
|
|
276
|
+
}
|
|
277
|
+
// https://huggingface.co/mixedbread-ai/mxbai-embed-large-v1 README ("add the prompt for query (not for
|
|
278
|
+
// documents)"); https://ollama.com/library/mxbai-embed-large
|
|
279
|
+
if (m.startsWith("mxbai-embed-large")) {
|
|
280
|
+
return inputType === "query" ? "Represent this sentence for searching relevant passages: " : "";
|
|
281
|
+
}
|
|
282
|
+
// https://huggingface.co/Qwen/Qwen3-Embedding-0.6B/raw/main/config_sentence_transformers.json (query prompt;
|
|
283
|
+
// "document": ""). No space after "Query:".
|
|
284
|
+
if (m.startsWith("qwen3-embedding")) {
|
|
285
|
+
return inputType === "query"
|
|
286
|
+
? "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:"
|
|
287
|
+
: "";
|
|
288
|
+
}
|
|
237
289
|
if (m.startsWith("embeddinggemma")) {
|
|
238
290
|
return inputType === "query" ? "task: search result | query: " : "title: none | text: ";
|
|
239
291
|
}
|
|
@@ -247,34 +299,73 @@ function isAbortLike(err) {
|
|
|
247
299
|
return err && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
248
300
|
}
|
|
249
301
|
|
|
250
|
-
|
|
302
|
+
// The key never reaches a reason: a server that echoes the Authorization header back in an error body gets
|
|
303
|
+
// every occurrence of the key replaced.
|
|
304
|
+
async function readBodySnippet(res, key) {
|
|
251
305
|
try {
|
|
252
|
-
|
|
306
|
+
let text = await res.text();
|
|
307
|
+
if (key) text = text.split(key).join("[redacted]");
|
|
308
|
+
return text.slice(0, 200);
|
|
253
309
|
} catch {
|
|
254
310
|
return "";
|
|
255
311
|
}
|
|
256
312
|
}
|
|
257
313
|
|
|
258
|
-
|
|
314
|
+
// A provider whose baseUrl host is localhost, 127.0.0.1 or ::1 never requires a key (card local-provider-no-key):
|
|
315
|
+
// 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.
|
|
316
|
+
function isLocalUrl(baseUrl) {
|
|
317
|
+
try {
|
|
318
|
+
const h = new URL(baseUrl).hostname;
|
|
319
|
+
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function localKeyReason(e) {
|
|
326
|
+
const preset = presetOf(e);
|
|
327
|
+
const name = e.apiKeyEnv || (preset && preset.apiKeyEnv) || "OPENAI_API_KEY";
|
|
328
|
+
return process.env[name] ? `server rejected the key in ${name}` : `server requires a key: set ${name}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `state` is the provider instance's memory: { omitDims } turns true once a server has rejected `dimensions`.
|
|
332
|
+
async function embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }, state = { omitDims: false }) {
|
|
333
|
+
const preset = presetOf(e);
|
|
334
|
+
const local = isLocalUrl(e.baseUrl);
|
|
335
|
+
if (preset && preset.requiresKey && !local) {
|
|
336
|
+
const name = e.apiKeyEnv || "OPENAI_API_KEY";
|
|
337
|
+
if (!process.env[name]) return { ok: false, reason: `missing API key: set ${name}` };
|
|
338
|
+
}
|
|
259
339
|
const prefix = prefixFor(e.model, inputType);
|
|
260
340
|
const input = texts.map((t) => prefix + t);
|
|
261
341
|
const url = `${e.baseUrl.replace(/\/$/, "")}/v1/embeddings`;
|
|
262
342
|
const headers = { "content-type": "application/json" };
|
|
263
|
-
const key = process.env[e.apiKeyEnv || "OPENAI_API_KEY"];
|
|
343
|
+
const key = process.env[e.apiKeyEnv || (local && preset && preset.apiKeyEnv) || "OPENAI_API_KEY"];
|
|
264
344
|
if (key) headers.authorization = `Bearer ${key}`;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
method: "POST",
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
345
|
+
const send = async (withDims) => {
|
|
346
|
+
const payload = withDims ? { model: e.model, input, dimensions: e.dims } : { model: e.model, input };
|
|
347
|
+
try {
|
|
348
|
+
return await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs) });
|
|
349
|
+
} catch (err) {
|
|
350
|
+
return { failure: { ok: false, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` } };
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const sendsDims = !(preset && !preset.sendsDims) && !state.omitDims;
|
|
354
|
+
let res = await send(sendsDims);
|
|
355
|
+
if (res.failure) return res.failure;
|
|
356
|
+
let body = null;
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
body = await readBodySnippet(res, key);
|
|
359
|
+
// A server that rejects `dimensions` (400 naming it): retry this batch once without it, remember that.
|
|
360
|
+
if (sendsDims && res.status === 400 && /dimensions/i.test(body)) {
|
|
361
|
+
state.omitDims = true;
|
|
362
|
+
res = await send(false);
|
|
363
|
+
if (res.failure) return res.failure;
|
|
364
|
+
body = res.ok ? null : await readBodySnippet(res, key);
|
|
365
|
+
}
|
|
275
366
|
}
|
|
276
367
|
if (!res.ok) {
|
|
277
|
-
|
|
368
|
+
if (local && (res.status === 401 || res.status === 403)) return { ok: false, reason: localKeyReason(e) };
|
|
278
369
|
return { ok: false, reason: `HTTP ${res.status}${body ? `: ${body}` : ""}` };
|
|
279
370
|
}
|
|
280
371
|
let json;
|
|
@@ -322,7 +413,7 @@ async function embedVoyage(e, texts, inputType, { fetchImpl, timeoutMs }) {
|
|
|
322
413
|
return { ok: false, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` };
|
|
323
414
|
}
|
|
324
415
|
if (!res.ok) {
|
|
325
|
-
const body = await readBodySnippet(res);
|
|
416
|
+
const body = await readBodySnippet(res, key);
|
|
326
417
|
return { ok: false, reason: `HTTP ${res.status}${body ? `: ${body}` : ""}` };
|
|
327
418
|
}
|
|
328
419
|
let json;
|
|
@@ -368,10 +459,11 @@ export function createProvider(cfg, opts = {}) {
|
|
|
368
459
|
if (!e) return null;
|
|
369
460
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
370
461
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
462
|
+
const state = { omitDims: false };
|
|
371
463
|
const sendBatch =
|
|
372
464
|
e.provider === "voyage"
|
|
373
465
|
? (texts, inputType) => embedVoyage(e, texts, inputType, { fetchImpl, timeoutMs })
|
|
374
|
-
: (texts, inputType) => embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs });
|
|
466
|
+
: (texts, inputType) => embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }, state);
|
|
375
467
|
return {
|
|
376
468
|
name: e.provider,
|
|
377
469
|
model: e.model,
|
|
@@ -412,6 +504,53 @@ function isOllamaPresetUrl(baseUrl) {
|
|
|
412
504
|
}
|
|
413
505
|
}
|
|
414
506
|
|
|
507
|
+
// Whether a /v1/models entry is the configured model: the id equals it, or the id's last "/" segment equals or
|
|
508
|
+
// starts with it, case-insensitively.
|
|
509
|
+
function v1ModelMatches(entry, wantModel) {
|
|
510
|
+
const id = entry && typeof entry.id === "string" ? entry.id.toLowerCase() : "";
|
|
511
|
+
const want = String(wantModel).toLowerCase();
|
|
512
|
+
if (!id) return false;
|
|
513
|
+
if (id === want) return true;
|
|
514
|
+
const last = id.split("/").pop();
|
|
515
|
+
return last === want || last.startsWith(want);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The unsloth/openai presets: GET <base>/v1/models with the Bearer key. Never throws; the key is never put in
|
|
519
|
+
// a reason.
|
|
520
|
+
async function probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs }) {
|
|
521
|
+
const kind = e.preset;
|
|
522
|
+
const envName = e.apiKeyEnv || (presetOf(e) && presetOf(e).apiKeyEnv) || "OPENAI_API_KEY";
|
|
523
|
+
const base = { reachable: false, kind, version: null, models: null, modelPresent: false, modelLoaded: null, embedOk: null };
|
|
524
|
+
const key = process.env[envName];
|
|
525
|
+
const local = isLocalUrl(root);
|
|
526
|
+
if (!key && !local) return { ...base, reason: `missing API key: set ${envName}` };
|
|
527
|
+
let res;
|
|
528
|
+
try {
|
|
529
|
+
res = await fetchImpl(`${root}/v1/models`, { headers: key ? { authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
530
|
+
} catch (err) {
|
|
531
|
+
return { ...base, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` };
|
|
532
|
+
}
|
|
533
|
+
if (local && (res.status === 401 || res.status === 403)) return { ...base, reason: localKeyReason(e) };
|
|
534
|
+
if (res.status === 401) return { ...base, reason: `HTTP 401: key rejected or missing; set ${envName}` };
|
|
535
|
+
if (!res.ok) return { ...base, reason: `HTTP ${res.status}` };
|
|
536
|
+
let json;
|
|
537
|
+
try {
|
|
538
|
+
json = await res.json();
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return { ...base, reason: `invalid JSON response: ${err.message}` };
|
|
541
|
+
}
|
|
542
|
+
const entries = Array.isArray(json && json.data) ? json.data : [];
|
|
543
|
+
const match = entries.find((m) => v1ModelMatches(m, wantModel));
|
|
544
|
+
return {
|
|
545
|
+
...base,
|
|
546
|
+
reachable: true,
|
|
547
|
+
models: entries.map((m) => m && m.id).filter((id) => typeof id === "string"),
|
|
548
|
+
modelPresent: !!match,
|
|
549
|
+
modelLoaded: match && typeof match.loaded === "boolean" ? match.loaded : null,
|
|
550
|
+
reason: null,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
415
554
|
// Probes a configured provider, or a local Ollama when nothing is configured. GET /api/version (then /api/tags)
|
|
416
555
|
// is tried first; only when it succeeds is this reported as kind "ollama" with a model list. When /api/version
|
|
417
556
|
// fails — a working llama-server, vLLM, LM Studio, or hosted key never serves it — this falls back to one
|
|
@@ -419,15 +558,24 @@ function isOllamaPresetUrl(baseUrl) {
|
|
|
419
558
|
// reachable non-Ollama provider must never be told to go install Ollama. The one exception: an openai-compatible
|
|
420
559
|
// provider configured at the Ollama preset host:port (localhost/127.0.0.1:11434) that is NOT reachable even via
|
|
421
560
|
// 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.
|
|
561
|
+
// case doctor exists to catch, not a different, genuinely unreachable server. With nothing configured the probe
|
|
562
|
+
// targets DOUG_OLLAMA_BASE_URL when set and non-empty (read at call time), else localhost:11434; a configured
|
|
563
|
+
// baseUrl always wins. Never throws; every branch has a
|
|
423
564
|
// 2s timeout.
|
|
424
565
|
export async function probeProvider(cfg, opts = {}) {
|
|
425
566
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
426
567
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
427
568
|
const e = cfg && cfg.embeddings;
|
|
428
|
-
const baseUrl = e && e.baseUrl ? e.baseUrl : OLLAMA_BASE_URL;
|
|
569
|
+
const baseUrl = e && e.baseUrl ? e.baseUrl : process.env.DOUG_OLLAMA_BASE_URL || OLLAMA_BASE_URL;
|
|
429
570
|
const wantModel = e && e.model ? e.model : DEFAULT_MODEL;
|
|
430
571
|
const root = baseUrl.replace(/\/$/, "");
|
|
572
|
+
const preset = presetOf(e);
|
|
573
|
+
if (preset && preset.modelsList === "v1") return probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs });
|
|
574
|
+
if (preset && e.provider === "voyage") {
|
|
575
|
+
// No models endpoint is documented for Voyage: one embed probe, never the Ollama /api/version path.
|
|
576
|
+
const result = await createProvider(cfg, { fetch: fetchImpl, timeoutMs }).embed(["probe"]);
|
|
577
|
+
return { reachable: result.ok, kind: "voyage", version: null, models: null, modelPresent: null, embedOk: result.ok, reason: result.ok ? null : result.reason };
|
|
578
|
+
}
|
|
431
579
|
const isPresetHost = (!e || e.provider === "openai-compatible") && isOllamaPresetUrl(baseUrl);
|
|
432
580
|
|
|
433
581
|
let version = null;
|
|
@@ -486,6 +634,13 @@ const INSTALL_COMMANDS = {
|
|
|
486
634
|
const PRESET_SNIPPET =
|
|
487
635
|
'"memory": { "embeddings": { "provider": "openai-compatible", "baseUrl": "http://localhost:11434", "model": "embeddinggemma", "dims": 512 } }';
|
|
488
636
|
|
|
637
|
+
const PRESET_SNIPPETS = [
|
|
638
|
+
["ollama", '"memory": { "embeddings": { "preset": "ollama", "model": "embeddinggemma", "dims": 512 } }'],
|
|
639
|
+
["unsloth", '"memory": { "embeddings": { "preset": "unsloth", "model": "embeddinggemma", "dims": 512 } } (a key is only needed if the server enforces auth: UNSLOTH_API_KEY)'],
|
|
640
|
+
["openai", '"memory": { "embeddings": { "preset": "openai", "model": "text-embedding-3-small", "dims": 512 } } (set OPENAI_API_KEY)'],
|
|
641
|
+
["voyage", '"memory": { "embeddings": { "preset": "voyage", "model": "voyage-4", "dims": 512 } } (set VOYAGE_API_KEY)'],
|
|
642
|
+
];
|
|
643
|
+
|
|
489
644
|
function installCommandFor(platform) {
|
|
490
645
|
return INSTALL_COMMANDS[platform] || INSTALL_COMMANDS.linux;
|
|
491
646
|
}
|
|
@@ -515,10 +670,19 @@ export function doctorReport(cfg, probe, platform = process.platform) {
|
|
|
515
670
|
}
|
|
516
671
|
lines.push("Enable it by adding to .doug/config.json:");
|
|
517
672
|
lines.push(` ${PRESET_SNIPPET}`);
|
|
673
|
+
lines.push("Or pick a provider preset (one snippet per preset):");
|
|
674
|
+
for (const [name, snippet] of PRESET_SNIPPETS) lines.push(` ${name}: ${snippet}`);
|
|
518
675
|
return lines.join("\n");
|
|
519
676
|
}
|
|
520
677
|
|
|
521
678
|
lines.push(`memory.embeddings is configured: ${embeddings.provider} ${embeddings.model}/${embeddings.dims} at ${embeddings.baseUrl}.`);
|
|
679
|
+
if (presetOf(embeddings) && PRESETS[embeddings.preset].modelsList === "v1") {
|
|
680
|
+
if (!probe.reachable) lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|
|
681
|
+
else if (probe.modelPresent === false) lines.push(`Reachable but ${embeddings.model} is not listed by ${embeddings.baseUrl}/v1/models.`);
|
|
682
|
+
else if (probe.modelLoaded === false) lines.push(`${embeddings.model} is listed but not loaded; load it on the server.`);
|
|
683
|
+
else lines.push(`Reachable; would use ${embeddings.model}/${embeddings.dims}.`);
|
|
684
|
+
return lines.join("\n");
|
|
685
|
+
}
|
|
522
686
|
if (!probe.reachable) {
|
|
523
687
|
lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|
|
524
688
|
// Only an Ollama-shaped miss (or nothing detected at all) gets install instructions — a real, reachable
|
|
@@ -85,6 +85,44 @@ 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
|
+
// verify.mjs may wait timeoutMs x n on a held gate.lock and then run n commands of timeoutMs each.
|
|
94
|
+
export function vendoredVerifySpawnTimeoutMs(timeoutMs, n) {
|
|
95
|
+
return 2 * timeoutMs * n + 60000;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function verifyViaVendored(script, dir, work, commands, timeoutMs) {
|
|
99
|
+
try {
|
|
100
|
+
const args = [script, "--json", "--by", "land", "--cwd", work, "--record-dir", dir, "--timeout-ms", String(timeoutMs), ...commands.flatMap((c) => ["--command", c])];
|
|
101
|
+
const r = spawnSync(process.execPath, args, { cwd: work, encoding: "utf8", timeout: vendoredVerifySpawnTimeoutMs(timeoutMs, commands.length), maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
|
|
102
|
+
let out = null;
|
|
103
|
+
try {
|
|
104
|
+
out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
|
|
105
|
+
} catch {
|
|
106
|
+
out = null;
|
|
107
|
+
}
|
|
108
|
+
if (!out || !Array.isArray(out.results) || out.results.length === 0) {
|
|
109
|
+
const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
|
|
110
|
+
return { error: `the vendored verify.mjs ${why}; land does not re-run the commands` };
|
|
111
|
+
}
|
|
112
|
+
return out.results.map((x) => ({
|
|
113
|
+
command: x.command,
|
|
114
|
+
ok: x.ok === true,
|
|
115
|
+
status: x.status,
|
|
116
|
+
timedOut: !!x.timedOut,
|
|
117
|
+
durationMs: x.durationMs,
|
|
118
|
+
outputTail: x.outputTail || "",
|
|
119
|
+
...(out.credited ? { credited: out.credited } : {}),
|
|
120
|
+
}));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { error: `the vendored verify.mjs could not run: ${err && err.message}` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
// Returns { ok: true, base, branch, mergeCommit, verify } or { ok: false, reason, verify? }. Never throws on a refusal.
|
|
89
127
|
export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false } = {}) {
|
|
90
128
|
if (!plan) return { ok: false, reason: "no plan" };
|
|
@@ -131,20 +169,46 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
131
169
|
const verify = [];
|
|
132
170
|
const acceptance = [];
|
|
133
171
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
// card verified-tree-record: install always runs through run(). plan.verify goes through the vendored
|
|
173
|
+
// verify.mjs when the project has one (it credits a verified-tree record of the branch's tree and writes one
|
|
174
|
+
// for the main checkout after a green run); otherwise, or when it cannot be read, today's loop runs.
|
|
175
|
+
const failVerify = (r) => {
|
|
176
|
+
const why = r.timedOut ? `timed out after ${timeoutMs} ms` : `exit ${r.status}`;
|
|
177
|
+
return { ok: false, reason: `verify failed on ${branch}: \`${r.command}\` (${why})\n${r.outputTail}`, verify, acceptance: [] };
|
|
178
|
+
};
|
|
179
|
+
if (plan.install) {
|
|
180
|
+
const r = run(plan.install, work, timeoutMs);
|
|
137
181
|
verify.push(r);
|
|
138
|
-
if (!r.ok)
|
|
139
|
-
|
|
140
|
-
|
|
182
|
+
if (!r.ok) return failVerify(r);
|
|
183
|
+
}
|
|
184
|
+
const verifyList = plan.verify || [];
|
|
185
|
+
const vendored = join(dir, ".doug/hooks/scripts/verify.mjs");
|
|
186
|
+
let viaVerify = null;
|
|
187
|
+
if (verifyList.length && existsSync(vendored)) viaVerify = verifyViaVendored(vendored, dir, work, verifyList, timeoutMs);
|
|
188
|
+
if (viaVerify && viaVerify.error) {
|
|
189
|
+
return { ok: false, reason: `verify failed on ${branch}: ${viaVerify.error}`, verify, acceptance: [] };
|
|
190
|
+
}
|
|
191
|
+
if (viaVerify) {
|
|
192
|
+
for (const r of viaVerify) {
|
|
193
|
+
verify.push(r);
|
|
194
|
+
if (!r.ok) return failVerify(r);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
for (const command of verifyList) {
|
|
198
|
+
const r = run(command, work, timeoutMs);
|
|
199
|
+
verify.push(r);
|
|
200
|
+
if (!r.ok) return failVerify(r);
|
|
141
201
|
}
|
|
142
202
|
}
|
|
143
203
|
|
|
144
204
|
const acceptanceCommands = acceptanceEntries(plan.acceptance).filter((a) => a.command);
|
|
145
205
|
for (const a of acceptanceCommands) {
|
|
146
|
-
|
|
147
|
-
const
|
|
206
|
+
// Card flow-suite-dedupe: a command identical to a plan.verify command already ran (green, or land returned above); reuse its result.
|
|
207
|
+
const same = verify.slice(plan.install ? 1 : 0).find((v) => String(v.command).trim() === String(a.command).trim());
|
|
208
|
+
const r = same ? null : run(a.command, work, timeoutMs);
|
|
209
|
+
const entry = same
|
|
210
|
+
? { text: a.text, command: a.command, ok: same.ok, status: same.status, timedOut: false, durationMs: 0, outputTail: "", reused: "verify" }
|
|
211
|
+
: { text: a.text, command: a.command, ok: r.ok, status: r.status, timedOut: r.timedOut, durationMs: r.durationMs, outputTail: r.outputTail };
|
|
148
212
|
acceptance.push(entry);
|
|
149
213
|
if (!entry.ok) {
|
|
150
214
|
const why = entry.timedOut ? `timed out after ${timeoutMs} ms` : `exit ${entry.status}`;
|
|
@@ -211,8 +275,8 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
211
275
|
baseWas: baseTip.slice(0, 7),
|
|
212
276
|
branchTip: branchTip.slice(0, 7),
|
|
213
277
|
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 })),
|
|
278
|
+
verify: verify.map((v) => ({ command: v.command, ok: v.ok, durationMs: v.durationMs, ...(v.credited ? { credited: v.credited } : {}) })),
|
|
279
|
+
acceptance: acceptance.map((a) => ({ text: a.text, command: a.command, ok: a.ok, durationMs: a.durationMs, ...(a.reused ? { reused: a.reused } : {}) })),
|
|
216
280
|
};
|
|
217
281
|
savePlan(dir, { ...plan, landed });
|
|
218
282
|
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);
|