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