@dougharness/cli 1.0.1 → 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.
Files changed (89) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -1
  19. package/dist/generate/config.js +5 -12
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +24 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/data/pairs.json +103 -0
  41. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +189 -27
  43. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +61 -4
  45. package/node_modules/@dougharness/flow/lib/memory-import.mjs +22 -4
  46. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  47. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  48. package/node_modules/@dougharness/flow/package.json +2 -1
  49. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  50. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  51. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  52. package/node_modules/@dougharness/flow/scripts/memory.mjs +196 -39
  53. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  54. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  55. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  56. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  57. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +10 -11
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  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/block-ledger.mjs +58 -1
  67. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  68. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  69. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  70. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  71. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  72. package/node_modules/@dougharness/gates/lib/state.mjs +5 -0
  73. package/node_modules/@dougharness/gates/lib/test-totals.mjs +139 -0
  74. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  75. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  76. package/node_modules/@dougharness/gates/package.json +1 -1
  77. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  78. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +14 -0
  79. package/node_modules/@dougharness/gates/scripts/statusline.mjs +33 -7
  80. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +794 -67
  81. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  82. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  83. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  84. package/package.json +3 -3
  85. package/templates/board-app.js +2 -2
  86. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  87. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  88. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  89. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -15,7 +15,7 @@ import { unifiedDiff, writeProposals, countLines, insertBulletUnderHeading, remo
15
15
  const DECISIONS_RELDIR = "docs/decisions";
16
16
  const RULES_RELDIR = ".claude/rules";
17
17
  const CLAUDE_MD_RELPATH = "CLAUDE.md";
18
- const GATES_SCRIPTS_RELDIR = "plugins/doug-gates/scripts";
18
+ const HOOK_SCRIPTS_RELDIR = ".doug/hooks/scripts";
19
19
 
20
20
  // Scans docs/decisions/NNNN-*.md and returns the next zero-padded number ("0001" when the directory is empty
21
21
  // or missing). A file that does not match the NNNN-*.md shape (a stray README, say) is ignored rather than
@@ -185,7 +185,7 @@ function validateRemoveEntry(entry, oldLines, dir) {
185
185
  if (!nonEmptyString(entry.source)) return { ok: false, reason: "each remove entry needs a non-empty string \"source\"" };
186
186
  if (!nonEmptyString(entry.enforcedBy)) return { ok: false, reason: "each remove entry needs a non-empty string \"enforcedBy\"" };
187
187
  if (entry.enforcedBy.endsWith(".mjs")) {
188
- const relHook = `${GATES_SCRIPTS_RELDIR}/${entry.enforcedBy}`;
188
+ const relHook = `${HOOK_SCRIPTS_RELDIR}/${entry.enforcedBy}`;
189
189
  if (!existsSync(join(dir, relHook))) return { ok: false, reason: `enforcedBy names a hook that does not exist: ${relHook}` };
190
190
  }
191
191
  return { ok: true };
@@ -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 &&
@@ -131,10 +163,10 @@ function l2normalize(vec) {
131
163
  // pairs and a dozen known-unrelated pairs, embedded together, must separate by a stated margin or the
132
164
  // configured embedder is not doing semantic work. Failing loudly here catches that before reembed commits to
133
165
  // it.
134
- // This path lies outside plugins/doug-flow, at the repo root — so it is outside a Doug plugin's own `files`
135
- // list. A standalone-published copy of this plugin (installed on its own, without the rest of this repo
136
- // checked out alongside it) will not have it; pass `--pairs <file>` in that case.
137
- export const DEFAULT_SANITY_PAIRS = join(dirname(fileURLToPath(import.meta.url)), "../../../evals/memory/pairs.json");
166
+ // This path lives inside plugins/doug-flow/lib, under this plugin's own `files` list in package.json, so a
167
+ // standalone-published copy of this plugin (installed on its own, without the rest of this repo checked out
168
+ // alongside it) still has it.
169
+ export const DEFAULT_SANITY_PAIRS = join(dirname(fileURLToPath(import.meta.url)), "data/pairs.json");
138
170
 
139
171
  function meanOf(nums) {
140
172
  return nums.reduce((a, b) => a + b, 0) / nums.length;
@@ -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 two models named in the
234
- // research note carry a prefix; every other model gets none.
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
- const m = String(model || "");
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
- async function readBodySnippet(res) {
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
- return (await res.text()).slice(0, 200);
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
- async function embedOpenAICompatible(e, texts, inputType, { fetchImpl, timeoutMs }) {
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
- 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}` };
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
- const body = await readBodySnippet(res);
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. Never throws; every branch has a
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
- const commands = [...(plan.install ? [plan.install] : []), ...(plan.verify || [])];
135
- for (const command of commands) {
136
- const r = run(command, work, timeoutMs);
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
- 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: [] };
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
- 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 };
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 };
@@ -14,12 +14,69 @@ import { listOutcomes, listLessons, jaccard, reflectTokens, REFLECT_DUPLICATE_JA
14
14
 
15
15
  // --- collectSignals ----------------------------------------------------------------------------------------
16
16
 
17
+ // card op-learn-gate-shape: a hand row's gate text is free-form per project ("typecheck 0; unit 639 passed" for
18
+ // Doug itself, "pytest 0" or "ruff 0; pytest 2" for a pytest project, "npm test 1" for an npm one, ...), so
19
+ // reading it as a failure unless it starts with Doug's own literal "typecheck 0" misclassified every other
20
+ // project's green gate as a failure. readGate reads each top-level ";"/","-separated clause instead: a
21
+ // ";" or "," inside a clause's own parenthesised note (e.g. "pytest 0 (41 passed, 2 skipped)") never splits
22
+ // that clause, since the note is free text and may itself carry commas. Within a clause: a `<n> passed`
23
+ // count is neutral, a clause naming a fail/error word is a failure, and a clause ending in an exit code (an
24
+ // optional parenthesised note allowed) is a pass or a failure by that code - independent of Doug's own shape.
25
+ const PASSED_COUNT_RE = /^.*\S\s+\d+\s+passed$/i;
26
+ const FAIL_WORD_RE = /\b(fail|fails|failed|failure|failures|error|errors)\b/i;
27
+ const EXIT_CLAUSE_RE = /^(.*\S)\s+(\d+)(\s*\([^)]*\))?$/;
28
+
29
+ // Splits on a top-level ";" or "," only - one inside parentheses (depth tracked, not just "the last paren")
30
+ // stays part of its clause, so a parenthesised note's own comma (a pass/skip breakdown) never fractures the
31
+ // clause it belongs to.
32
+ function splitGateClauses(gate) {
33
+ const clauses = [];
34
+ let depth = 0;
35
+ let current = "";
36
+ for (const ch of gate) {
37
+ if (ch === "(") depth += 1;
38
+ else if (ch === ")") depth = Math.max(0, depth - 1);
39
+ if ((ch === ";" || ch === ",") && depth === 0) {
40
+ clauses.push(current);
41
+ current = "";
42
+ } else {
43
+ current += ch;
44
+ }
45
+ }
46
+ clauses.push(current);
47
+ return clauses.map((c) => c.trim()).filter((c) => c.length > 0);
48
+ }
49
+
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/doug-hand's own use of the same reading.
52
+ export function readGate(gate) {
53
+ if (typeof gate !== "string" || !gate.trim()) return "fail";
54
+ const clauses = splitGateClauses(gate);
55
+ let hasFailingClause = false;
56
+ let hasPassingExitClause = false;
57
+ for (const clause of clauses) {
58
+ if (PASSED_COUNT_RE.test(clause)) continue; // a) a pass count - neutral
59
+ if (FAIL_WORD_RE.test(clause)) {
60
+ hasFailingClause = true; // b) a fail/error word - failing, checked before c)
61
+ continue;
62
+ }
63
+ const m = EXIT_CLAUSE_RE.exec(clause);
64
+ if (m) {
65
+ if (Number(m[2]) !== 0) hasFailingClause = true; // c) a non-zero exit clause - failing
66
+ else hasPassingExitClause = true; // c) a zero exit clause - passing
67
+ continue;
68
+ }
69
+ // d) anything else - neutral
70
+ }
71
+ return !hasFailingClause && hasPassingExitClause ? "pass" : "fail";
72
+ }
73
+
17
74
  // A hand row's own gate text is the only record of pass/fail on that track (no independent verifier runs on
18
75
  // it); a flow row's `verified` column is that record on the flow track. See memory.mjs's OUTCOMES_COLUMNS_SQL
19
76
  // comment for why the two tracks are never averaged together.
20
77
  function isGateFailure(row) {
21
78
  if (row.track === "flow") return row.verified === 0;
22
- if (row.track === "hand") return !(typeof row.gate === "string" && row.gate.startsWith("typecheck 0"));
79
+ if (row.track === "hand") return readGate(row.gate) === "fail";
23
80
  return false;
24
81
  }
25
82
 
@@ -417,13 +474,13 @@ const DEMOTE_RULES = [
417
474
  ];
418
475
 
419
476
  // Review MINOR 7: a `tighten` target is the real path readOwnSkills() (scripts/learn.mjs) read the skill's
420
- // 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
421
478
  // point `learn.mjs` at a checkout laid out differently, or at a skill outside this repository entirely. Made
422
479
  // relative to `dir` when the file is really inside it (the common case: readable, and matches how every other
423
480
  // proposal's `target` reads), else reported absolute rather than a misleading relative path. Falls back to the
424
- // 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.
425
482
  function tightenTarget(dir, skill) {
426
- if (!skill.file) return `plugins/doug-flow/skills/${skill.name}/SKILL.md`;
483
+ if (!skill.file) return `skills/${skill.name}/SKILL.md`;
427
484
  const resolved = resolve(skill.file);
428
485
  if (dir) {
429
486
  const base = resolve(dir);
@@ -6,7 +6,7 @@ import { basename, join, resolve } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { createHash } from "node:crypto";
8
8
  import { projectSlug } from "./cost.mjs";
9
- import { addLesson, getLesson, listLessons, supersedeLesson } from "./memory.mjs";
9
+ import { addLesson, getLesson, listLessons, supersedeLesson, unstaleLesson } from "./memory.mjs";
10
10
 
11
11
  export function autoMemoryDir(projectDir, claudeDir = join(homedir(), ".claude")) {
12
12
  return join(claudeDir, "projects", projectSlug(projectDir), "memory");
@@ -77,10 +77,11 @@ export function parseFrontmatter(text) {
77
77
 
78
78
  export function importAutoMemory(m, opts = {}) {
79
79
  const { projectDir, claudeDir, now } = opts;
80
- void now; // accepted for interface consistency; every timestamp here is measured (data.modified or file mtime)
80
+ // `now` (defaults to the current time) is only the retirement timestamp written to a stale row; every other
81
+ // timestamp here is measured (data.modified or file mtime).
81
82
  const dir = autoMemoryDir(projectDir, claudeDir);
82
83
  if (!existsSync(dir)) {
83
- return { dir, found: false, added: 0, updated: 0, unchanged: 0, skipped: 0, files: [] };
84
+ return { dir, found: false, added: 0, updated: 0, unchanged: 0, skipped: 0, retired: 0, files: [] };
84
85
  }
85
86
  const names = readdirSync(dir, { withFileTypes: true })
86
87
  .filter((e) => e.isFile() && e.name.endsWith(".md") && e.name !== "MEMORY.md")
@@ -105,6 +106,7 @@ export function importAutoMemory(m, opts = {}) {
105
106
  const id = createHash("sha256").update(`auto-memory\n${name}\n${text}`).digest("hex").slice(0, 16);
106
107
  const existing = getLesson(m, id);
107
108
  if (existing) {
109
+ if (existing.stale) unstaleLesson(m, id);
108
110
  unchanged++;
109
111
  files.push({ file: name, id, action: "unchanged" });
110
112
  continue;
@@ -141,5 +143,21 @@ export function importAutoMemory(m, opts = {}) {
141
143
  files.push({ file: name, id, action: "added" });
142
144
  }
143
145
  }
144
- return { dir, found: true, added, updated, unchanged, skipped, files };
146
+ // Retire (mark stale) every live row this importer wrote (source_agent "auto-memory") whose file is no longer
147
+ // under the directory: a rewritten file supersedes its prior row above, but a deleted file left no trace here
148
+ // to react to. Keyed on basename, the same key the per-file loop above uses to find a prior row, so a row
149
+ // whose citation dir differs from this run's dir but whose basename is still present is not retired (card
150
+ // memory-import-retires-deleted).
151
+ const nameSet = new Set(names);
152
+ const retiredAt = (now ? new Date(now) : new Date()).toISOString();
153
+ let retired = 0;
154
+ for (const lesson of listLessons(m)) {
155
+ if (lesson.source_agent !== "auto-memory") continue;
156
+ if (lesson.stale) continue;
157
+ if (nameSet.has(basename(lesson.citation))) continue;
158
+ m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(retiredAt, lesson.id);
159
+ retired++;
160
+ files.push({ file: basename(lesson.citation), id: lesson.id, action: "retired" });
161
+ }
162
+ return { dir, found: true, added, updated, unchanged, skipped, retired, files };
145
163
  }