@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.
Files changed (88) hide show
  1. package/dist/bin.js +18 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +14 -7
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +42 -9
  7. package/dist/board.js.map +1 -1
  8. package/dist/detect/repo.js +14 -1
  9. package/dist/detect/repo.js.map +1 -1
  10. package/dist/detect/types.d.ts +2 -0
  11. package/dist/doctor.d.ts +46 -0
  12. package/dist/doctor.js +491 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/generate/agents.d.ts +7 -0
  15. package/dist/generate/agents.js +62 -12
  16. package/dist/generate/agents.js.map +1 -1
  17. package/dist/generate/claude-md.js +4 -0
  18. package/dist/generate/claude-md.js.map +1 -1
  19. package/dist/generate/config.d.ts +8 -0
  20. package/dist/generate/config.js +4 -1
  21. package/dist/generate/config.js.map +1 -1
  22. package/dist/generate/proposal.js +15 -6
  23. package/dist/generate/proposal.js.map +1 -1
  24. package/dist/generate/settings.d.ts +2 -0
  25. package/dist/generate/settings.js +17 -2
  26. package/dist/generate/settings.js.map +1 -1
  27. package/dist/init.d.ts +14 -0
  28. package/dist/init.js +94 -8
  29. package/dist/init.js.map +1 -1
  30. package/dist/memory-setup.d.ts +28 -0
  31. package/dist/memory-setup.js +326 -0
  32. package/dist/memory-setup.js.map +1 -0
  33. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  34. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  35. package/node_modules/@dougharness/flow/agents/planner.md +3 -3
  36. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  37. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  38. package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
  39. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  40. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  41. package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
  43. package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  45. package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
  46. package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
  47. package/node_modules/@dougharness/flow/package.json +2 -1
  48. package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
  49. package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
  50. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  51. package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
  52. package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
  53. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  54. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  55. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  56. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
  57. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  67. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  68. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  69. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  70. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  71. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  72. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  73. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  74. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  75. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  76. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  77. package/node_modules/@dougharness/gates/package.json +1 -1
  78. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  79. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
  80. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  81. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  82. package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
  83. package/package.json +4 -4
  84. package/templates/board-app.js +4 -3
  85. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  86. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  87. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  88. 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
- if (!isValidEmbeddingsConfig(e)) {
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 two models named in the
234
- // research note carry a prefix; every other model gets none.
235
- function prefixFor(model, inputType) {
236
- const m = String(model || "");
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
- async function readBodySnippet(res) {
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
- return (await res.text()).slice(0, 200);
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
- async function embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }) {
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
- let res;
266
- try {
267
- res = await fetchImpl(url, {
268
- method: "POST",
269
- headers,
270
- body: JSON.stringify({ model: e.model, input, dimensions: e.dims }),
271
- signal: AbortSignal.timeout(timeoutMs),
272
- });
273
- } catch (err) {
274
- return { ok: false, reason: isAbortLike(err) ? "timeout" : `request failed: ${err.message}` };
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
- const body = await readBodySnippet(res);
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. Never throws; every branch has a
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
- const commands = [...(plan.install ? [plan.install] : []), ...(plan.verify || [])];
135
- for (const command of commands) {
136
- const r = run(command, work, timeoutMs);
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
- const why = r.timedOut ? `timed out after ${timeoutMs} ms` : `exit ${r.status}`;
140
- return { ok: false, reason: `verify failed on ${branch}: \`${command}\` (${why})\n${r.outputTail}`, verify, acceptance: [] };
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
- const r = run(a.command, work, timeoutMs);
147
- const entry = { text: a.text, command: a.command, ok: r.ok, status: r.status, timedOut: r.timedOut, durationMs: r.durationMs, outputTail: r.outputTail };
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/core-next's own use of the same reading.
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 `plugins/doug-flow/skills/<name>/SKILL.md` - a caller may
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
- // historical hardcoded shape only for a caller (or an older test fixture) that supplies no `file` at all.
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 `plugins/doug-flow/skills/${skill.name}/SKILL.md`;
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);