@dougharness/cli 1.1.0 → 1.2.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 +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +45 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +75 -48
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
- package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
- package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
- package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
- package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
- package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
- package/package.json +4 -4
- package/templates/board-app.js +48 -1
|
@@ -3,12 +3,39 @@
|
|
|
3
3
|
// /v1/embeddings endpoint (Ollama, llama-server, vLLM, LM Studio, a hosted OpenAI-shaped key) and Voyage AI's
|
|
4
4
|
// own endpoint. Product first: memory.embeddings in .doug/config.json is null by default, and every function
|
|
5
5
|
// here treats "no provider" or "provider unreachable" as a normal outcome, never a thrown error.
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
9
12
|
|
|
10
13
|
const CONFIG_RELPATH = ".doug/config.json";
|
|
11
14
|
const VALID_PROVIDERS = new Set(["openai-compatible", "voyage"]);
|
|
15
|
+
|
|
16
|
+
// The bundled local model (card memory-bundled-embedding, ADR 0006 amendment 2026-10-08): the runtime is
|
|
17
|
+
// installed into a user-level cache and loaded by dynamic import, never a dependency of any Doug package.
|
|
18
|
+
export const LOCAL_MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX";
|
|
19
|
+
export const LOCAL_DTYPE = "q8";
|
|
20
|
+
export const LOCAL_RUNTIME_PACKAGE = "@huggingface/transformers@4.3.1";
|
|
21
|
+
export const LOCAL_QUERY_PREFIX = "task: search result | query: ";
|
|
22
|
+
export const LOCAL_DOCUMENT_PREFIX = "title: none | text: ";
|
|
23
|
+
const LOCAL_DIMS = 512;
|
|
24
|
+
|
|
25
|
+
export function localCacheDir(env = process.env, home = homedir()) {
|
|
26
|
+
if (env.DOUG_CACHE_DIR) return env.DOUG_CACHE_DIR;
|
|
27
|
+
return join(env.XDG_CACHE_HOME || join(home, ".cache"), "doug");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function localPaths(cacheDir) {
|
|
31
|
+
const modelsDir = join(cacheDir, "models");
|
|
32
|
+
return {
|
|
33
|
+
cacheDir,
|
|
34
|
+
runtimeDir: join(cacheDir, "runtime"),
|
|
35
|
+
modelsDir,
|
|
36
|
+
modelFile: join(modelsDir, "onnx-community", "embeddinggemma-300m-ONNX", "onnx", "model_quantized.onnx"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
12
39
|
const DEFAULT_TIMEOUT_MS = 2000;
|
|
13
40
|
const OLLAMA_BASE_URL = "http://localhost:11434";
|
|
14
41
|
const DEFAULT_MODEL = "embeddinggemma";
|
|
@@ -21,6 +48,7 @@ const PRESETS = {
|
|
|
21
48
|
unsloth: { provider: "openai-compatible", baseUrl: "http://127.0.0.1:8888", apiKeyEnv: "UNSLOTH_API_KEY", sendsDims: false, requiresKey: true, modelsList: "v1" },
|
|
22
49
|
openai: { provider: "openai-compatible", baseUrl: "https://api.openai.com", apiKeyEnv: "OPENAI_API_KEY", sendsDims: true, requiresKey: true, modelsList: "v1" },
|
|
23
50
|
voyage: { provider: "voyage", baseUrl: "https://api.voyageai.com", apiKeyEnv: "VOYAGE_API_KEY", sendsDims: true, requiresKey: true, modelsList: null },
|
|
51
|
+
local: { provider: "local", baseUrl: undefined, apiKeyEnv: undefined, sendsDims: false, requiresKey: false, modelsList: null },
|
|
24
52
|
custom: { provider: undefined, baseUrl: undefined, apiKeyEnv: undefined, sendsDims: true, requiresKey: false, modelsList: null },
|
|
25
53
|
};
|
|
26
54
|
|
|
@@ -60,7 +88,8 @@ export function readMemoryConfig(dir) {
|
|
|
60
88
|
const e = memory && typeof memory === "object" ? memory.embeddings : undefined;
|
|
61
89
|
if (e === null || e === undefined) return { embeddings: null };
|
|
62
90
|
const resolved = applyPreset(e);
|
|
63
|
-
|
|
91
|
+
const isLocal = e.preset === "local";
|
|
92
|
+
if (isLocal ? !isValidLocalConfig(resolved) : !isValidEmbeddingsConfig(resolved)) {
|
|
64
93
|
process.stderr.write(`[doug] ${CONFIG_RELPATH} memory.embeddings is not a valid shape; treating as unset (keyword-only)\n`);
|
|
65
94
|
return { embeddings: null };
|
|
66
95
|
}
|
|
@@ -80,9 +109,18 @@ function applyPreset(e) {
|
|
|
80
109
|
for (const k of ["provider", "baseUrl", "apiKeyEnv"]) {
|
|
81
110
|
if (out[k] === undefined && p[k] !== undefined) out[k] = p[k];
|
|
82
111
|
}
|
|
112
|
+
if (e.preset === "local") {
|
|
113
|
+
if (out.model === undefined) out.model = LOCAL_MODEL_ID;
|
|
114
|
+
if (out.dims === undefined) out.dims = LOCAL_DIMS;
|
|
115
|
+
}
|
|
83
116
|
return out;
|
|
84
117
|
}
|
|
85
118
|
|
|
119
|
+
// The local runtime runs one model at one width: a config that names anything else is invalid.
|
|
120
|
+
function isValidLocalConfig(r) {
|
|
121
|
+
return !!(r && r.provider === "local" && r.model === LOCAL_MODEL_ID && r.dims === LOCAL_DIMS);
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
function isValidIndexConfig(idx) {
|
|
87
125
|
return !!(
|
|
88
126
|
idx &&
|
|
@@ -264,7 +302,9 @@ export async function checkEmbeddingSanity(provider, pairs) {
|
|
|
264
302
|
|
|
265
303
|
// Task prefixes the provider layer must add; the server never adds them. Only the models below carry a
|
|
266
304
|
// prefix; every other model gets none. mxbai and qwen3 prefix queries only, documents get "".
|
|
267
|
-
|
|
305
|
+
export const PREFIXED_MODELS = ["embeddinggemma", "nomic-embed-text", "mxbai-embed-large", "qwen3-embedding"];
|
|
306
|
+
|
|
307
|
+
export function prefixFor(model, inputType) {
|
|
268
308
|
// Match on the lowercased basename after the last "/", so HF-style names (org/name) and Ollama names agree.
|
|
269
309
|
let m = String(model || "").split(/[/\\]/).pop().toLowerCase();
|
|
270
310
|
// LM Studio-style "text-embedding-<name>" ids: match on <name> when it is a prefixed family (not OpenAI's text-embedding-3-*).
|
|
@@ -450,11 +490,96 @@ async function embedBatched(texts, inputType, sendBatch) {
|
|
|
450
490
|
return { ok: true, vectors };
|
|
451
491
|
}
|
|
452
492
|
|
|
493
|
+
async function defaultLoadTransformers(runtimeDir) {
|
|
494
|
+
const req = createRequire(join(runtimeDir, "noop.js"));
|
|
495
|
+
return import(pathToFileURL(req.resolve("@huggingface/transformers")).href);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The in-process provider: the transformers module comes from opts.loadTransformers (the test seam) or the
|
|
499
|
+
// runtime dir under the cache. Loads lazily on the first embed and keeps the model for the provider's life.
|
|
500
|
+
// Recall never reaches the network (allowRemoteModels false); only setup's download passes allowRemote.
|
|
501
|
+
function createLocalProvider(e, opts) {
|
|
502
|
+
const paths = localPaths(opts.cacheDir ?? localCacheDir());
|
|
503
|
+
const loadTransformers = opts.loadTransformers ?? (() => defaultLoadTransformers(paths.runtimeDir));
|
|
504
|
+
let loaded = null;
|
|
505
|
+
const load = async () => {
|
|
506
|
+
if (loaded) return loaded;
|
|
507
|
+
let mod;
|
|
508
|
+
try {
|
|
509
|
+
mod = await loadTransformers();
|
|
510
|
+
} catch {
|
|
511
|
+
return { failure: { ok: false, reason: `local embedding runtime not installed at ${paths.runtimeDir}: run doug memory setup` } };
|
|
512
|
+
}
|
|
513
|
+
mod.env.cacheDir = paths.modelsDir;
|
|
514
|
+
mod.env.allowRemoteModels = opts.allowRemote === true;
|
|
515
|
+
const tokenizer = await mod.AutoTokenizer.from_pretrained(LOCAL_MODEL_ID);
|
|
516
|
+
const model = await mod.AutoModel.from_pretrained(LOCAL_MODEL_ID, { dtype: LOCAL_DTYPE });
|
|
517
|
+
loaded = { tokenizer, model };
|
|
518
|
+
return loaded;
|
|
519
|
+
};
|
|
520
|
+
const sendBatch = async (texts, inputType) => {
|
|
521
|
+
const l = await load();
|
|
522
|
+
if (l.failure) return l.failure;
|
|
523
|
+
const prefix = inputType === "query" ? LOCAL_QUERY_PREFIX : LOCAL_DOCUMENT_PREFIX;
|
|
524
|
+
const inputs = l.tokenizer(texts.map((t) => prefix + t), { padding: true, truncation: true });
|
|
525
|
+
const { sentence_embedding } = await l.model(inputs);
|
|
526
|
+
const vectors = [];
|
|
527
|
+
for (const row of sentence_embedding.tolist()) {
|
|
528
|
+
if (row.length < LOCAL_DIMS) return { ok: false, reason: `wrong dims: got ${row.length}, expected ${LOCAL_DIMS}` };
|
|
529
|
+
vectors.push(l2normalize(Float32Array.from(row.slice(0, LOCAL_DIMS))));
|
|
530
|
+
}
|
|
531
|
+
return { ok: true, vectors };
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
name: "local",
|
|
535
|
+
model: LOCAL_MODEL_ID,
|
|
536
|
+
dims: LOCAL_DIMS,
|
|
537
|
+
async embed(texts, { inputType = "document" } = {}) {
|
|
538
|
+
try {
|
|
539
|
+
return await embedBatched(texts, inputType, sendBatch);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return { ok: false, reason: `local embedding failed: ${err.message}` };
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const LOCAL_CONFIG = { embeddings: { preset: "local", provider: "local", model: LOCAL_MODEL_ID, dims: LOCAL_DIMS } };
|
|
548
|
+
|
|
549
|
+
// Installs the pinned runtime into <cacheDir>/runtime with the npm CLI, CPU only (the CUDA download is skipped on
|
|
550
|
+
// every platform). Never throws.
|
|
551
|
+
export function installLocalRuntime({ cacheDir, spawnImpl = spawn, platform = process.platform } = {}) {
|
|
552
|
+
return new Promise((resolve) => {
|
|
553
|
+
try {
|
|
554
|
+
const { runtimeDir } = localPaths(cacheDir ?? localCacheDir());
|
|
555
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
556
|
+
const child = spawnImpl(
|
|
557
|
+
platform === "win32" ? "npm.cmd" : "npm",
|
|
558
|
+
["install", "--prefix", runtimeDir, "--no-audit", "--no-fund", LOCAL_RUNTIME_PACKAGE],
|
|
559
|
+
{ stdio: "inherit", env: { ...process.env, ONNXRUNTIME_NODE_INSTALL: "skip" } },
|
|
560
|
+
);
|
|
561
|
+
child.on("error", (err) => resolve({ ok: false, reason: `npm install failed to start: ${err.message}` }));
|
|
562
|
+
child.on("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, reason: `npm install exited with code ${code}` }));
|
|
563
|
+
} catch (err) {
|
|
564
|
+
resolve({ ok: false, reason: `npm install failed: ${err.message}` });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// One document embed with remote models allowed: from_pretrained downloads the tokenizer and q8 model into the
|
|
570
|
+
// models dir. Never throws.
|
|
571
|
+
export async function downloadLocalModel({ cacheDir, loadTransformers } = {}) {
|
|
572
|
+
const provider = createProvider(LOCAL_CONFIG, { cacheDir, loadTransformers, allowRemote: true });
|
|
573
|
+
const r = await provider.embed(["probe"], { inputType: "document" });
|
|
574
|
+
return r.ok ? { ok: true } : { ok: false, reason: r.reason };
|
|
575
|
+
}
|
|
576
|
+
|
|
453
577
|
// cfg is the { embeddings } shape readMemoryConfig returns. Returns null when nothing is configured (the
|
|
454
578
|
// caller's cue to run keyword-only); otherwise { name, model, dims, embed(texts, { inputType }) }.
|
|
455
579
|
export function createProvider(cfg, opts = {}) {
|
|
456
580
|
const e = cfg && cfg.embeddings;
|
|
457
581
|
if (!e) return null;
|
|
582
|
+
if (e.provider === "local") return createLocalProvider(e, opts);
|
|
458
583
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
459
584
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
460
585
|
const state = { omitDims: false };
|
|
@@ -567,6 +692,14 @@ export async function probeProvider(cfg, opts = {}) {
|
|
|
567
692
|
const baseUrl = e && e.baseUrl ? e.baseUrl : process.env.DOUG_OLLAMA_BASE_URL || OLLAMA_BASE_URL;
|
|
568
693
|
const wantModel = e && e.model ? e.model : DEFAULT_MODEL;
|
|
569
694
|
const root = baseUrl.replace(/\/$/, "");
|
|
695
|
+
if (e && e.provider === "local") {
|
|
696
|
+
const { cacheDir, modelFile } = localPaths(opts.cacheDir ?? localCacheDir());
|
|
697
|
+
const modelPresent = existsSync(modelFile);
|
|
698
|
+
const base = { kind: "local", cacheDir, modelFile, modelPresent };
|
|
699
|
+
if (!modelPresent) return { ...base, reachable: false, embedOk: false, reason: "model file missing" };
|
|
700
|
+
const result = await createProvider(cfg, { cacheDir, loadTransformers: opts.loadTransformers }).embed(["probe"]);
|
|
701
|
+
return { ...base, reachable: result.ok, embedOk: result.ok, reason: result.ok ? null : result.reason };
|
|
702
|
+
}
|
|
570
703
|
const preset = presetOf(e);
|
|
571
704
|
if (preset && preset.modelsList === "v1") return probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs });
|
|
572
705
|
if (preset && e.provider === "voyage") {
|
|
@@ -673,6 +806,16 @@ export function doctorReport(cfg, probe, platform = process.platform) {
|
|
|
673
806
|
return lines.join("\n");
|
|
674
807
|
}
|
|
675
808
|
|
|
809
|
+
if (embeddings.provider === "local") {
|
|
810
|
+
lines.push(`memory.embeddings is configured: local ${embeddings.model}/${embeddings.dims} (bundled, in-process).`);
|
|
811
|
+
lines.push(`Cache: ${probe.cacheDir}`);
|
|
812
|
+
lines.push(`Model file: ${probe.modelFile} (${probe.modelPresent ? "present" : "missing"})`);
|
|
813
|
+
lines.push(`Dims: ${embeddings.dims}`);
|
|
814
|
+
if (!probe.modelPresent) lines.push("Run doug memory setup to install it.");
|
|
815
|
+
else if (probe.embedOk) lines.push("Test embed: ok");
|
|
816
|
+
else lines.push(`Test embed failed: ${probe.reason || "unknown error"}`);
|
|
817
|
+
return lines.join("\n");
|
|
818
|
+
}
|
|
676
819
|
lines.push(`memory.embeddings is configured: ${embeddings.provider} ${embeddings.model}/${embeddings.dims} at ${embeddings.baseUrl}.`);
|
|
677
820
|
if (presetOf(embeddings) && PRESETS[embeddings.preset].modelsList === "v1") {
|
|
678
821
|
if (!probe.reachable) lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);
|
|
@@ -22,7 +22,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
22
22
|
import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
|
-
import { acceptanceEntries, integrationBranchFor, savePlan } from "./plan.mjs";
|
|
25
|
+
import { acceptanceEntries, integrationBranchFor, integrationWorktreeFor, savePlan, PLAN_RELPATH } from "./plan.mjs";
|
|
26
26
|
import { removeFinishedWorktrees } from "./worktrees.mjs";
|
|
27
27
|
|
|
28
28
|
export const INTEGRATION_WORKTREE = ".claude/worktrees/doug-integration";
|
|
@@ -90,10 +90,15 @@ function run(command, cwd, timeoutMs) {
|
|
|
90
90
|
// the MAIN checkout (--record-dir). Returns the entries land records under `verify` (command, ok, status, timedOut,
|
|
91
91
|
// durationMs, outputTail, plus credited: "verified-tree" when credited), stopping at the first failure like
|
|
92
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
|
+
|
|
93
98
|
function verifyViaVendored(script, dir, work, commands, timeoutMs) {
|
|
94
99
|
try {
|
|
95
100
|
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
|
|
101
|
+
const r = spawnSync(process.execPath, args, { cwd: work, encoding: "utf8", timeout: vendoredVerifySpawnTimeoutMs(timeoutMs, commands.length), maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
|
|
97
102
|
let out = null;
|
|
98
103
|
try {
|
|
99
104
|
out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
|
|
@@ -104,7 +109,7 @@ function verifyViaVendored(script, dir, work, commands, timeoutMs) {
|
|
|
104
109
|
const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
|
|
105
110
|
return { error: `the vendored verify.mjs ${why}; land does not re-run the commands` };
|
|
106
111
|
}
|
|
107
|
-
|
|
112
|
+
const rows = out.results.map((x) => ({
|
|
108
113
|
command: x.command,
|
|
109
114
|
ok: x.ok === true,
|
|
110
115
|
status: x.status,
|
|
@@ -113,13 +118,20 @@ function verifyViaVendored(script, dir, work, commands, timeoutMs) {
|
|
|
113
118
|
outputTail: x.outputTail || "",
|
|
114
119
|
...(out.credited ? { credited: out.credited } : {}),
|
|
115
120
|
}));
|
|
121
|
+
if (typeof out.lockWaitMs === "number") rows.lockWaitMs = out.lockWaitMs;
|
|
122
|
+
return rows;
|
|
116
123
|
} catch (err) {
|
|
117
124
|
return { error: `the vendored verify.mjs could not run: ${err && err.message}` };
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
// Returns { ok: true, base, branch, mergeCommit, verify } or { ok: false, reason, verify? }. Never throws on a refusal.
|
|
122
|
-
|
|
129
|
+
// Card parallel-card-queens B5: rebase (set by plan.mjs land --card) rebases the integration branch onto the base
|
|
130
|
+
// when the base has moved, in the worktree that has the branch checked out, and verifies the rebased tree; a
|
|
131
|
+
// conflict is aborted and refused as "rebase-conflict: <files>". ownOnly removes only this plan's own task
|
|
132
|
+
// branches' worktrees and its own integration worktree (no sweep of worktrees merged into base). planRelpath is
|
|
133
|
+
// where plan.landed is saved (the per-card plan under --card). The result carries lockWaitMs from verify.mjs.
|
|
134
|
+
export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false, rebase = false, ownOnly = false, planRelpath = PLAN_RELPATH } = {}) {
|
|
123
135
|
if (!plan) return { ok: false, reason: "no plan" };
|
|
124
136
|
if (plan.status !== "done") return { ok: false, reason: `plan status is "${plan.status}", not "done"; run plan.mjs done after a green doug-implement run` };
|
|
125
137
|
if (plan.landed) return { ok: false, reason: `plan was already landed as ${plan.landed.mergeCommit} on ${plan.landed.at}` };
|
|
@@ -147,15 +159,17 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
147
159
|
if (dirty.length) return { ok: false, reason: `tracked files are modified: ${dirty.join(", ")}; commit or stash them first` };
|
|
148
160
|
|
|
149
161
|
// Verify on the branch itself, in the integration worktree if the workflow left it, else a temporary one.
|
|
150
|
-
const existing = join(dir,
|
|
162
|
+
const existing = join(dir, integrationWorktreeFor(plan));
|
|
151
163
|
const reuse = existsSync(existing) && tryGit(existing, ["symbolic-ref", "--short", "-q", "HEAD"]) === branch;
|
|
152
164
|
let work = reuse ? existing : null;
|
|
153
165
|
let temp = null;
|
|
166
|
+
const needsRebase = rebase && tryGit(dir, ["merge-base", "--is-ancestor", base, branch]) === null;
|
|
154
167
|
if (!work) {
|
|
155
168
|
temp = mkdtempSync(join(tmpdir(), "doug-land-"));
|
|
156
169
|
work = join(temp, "wt");
|
|
157
170
|
try {
|
|
158
|
-
|
|
171
|
+
// A rebase moves the branch ref, so the temporary worktree then has the branch itself checked out.
|
|
172
|
+
git(dir, needsRebase ? ["worktree", "add", work, branch] : ["worktree", "add", "--detach", work, branch]);
|
|
159
173
|
} catch (err) {
|
|
160
174
|
rmSync(temp, { recursive: true, force: true });
|
|
161
175
|
return { ok: false, reason: `could not create a worktree for ${branch}: ${tail(err.stderr || err.message, 3)}` };
|
|
@@ -163,7 +177,16 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
163
177
|
}
|
|
164
178
|
const verify = [];
|
|
165
179
|
const acceptance = [];
|
|
180
|
+
let lockWaitMs;
|
|
166
181
|
try {
|
|
182
|
+
if (needsRebase) {
|
|
183
|
+
const rb = tryGitCapture(work, ["rebase", base]);
|
|
184
|
+
if (!rb.ok) {
|
|
185
|
+
const files = (tryGit(work, ["diff", "--name-only", "--diff-filter=U"]) || "").split("\n").filter(Boolean);
|
|
186
|
+
tryGit(work, ["rebase", "--abort"]);
|
|
187
|
+
return { ok: false, reason: `rebase-conflict: ${files.length ? files.join(", ") : tail(rb.error.stderr || rb.error.message, 3)}` };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
// card verified-tree-record: install always runs through run(). plan.verify goes through the vendored
|
|
168
191
|
// verify.mjs when the project has one (it credits a verified-tree record of the branch's tree and writes one
|
|
169
192
|
// for the main checkout after a green run); otherwise, or when it cannot be read, today's loop runs.
|
|
@@ -184,6 +207,7 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
184
207
|
return { ok: false, reason: `verify failed on ${branch}: ${viaVerify.error}`, verify, acceptance: [] };
|
|
185
208
|
}
|
|
186
209
|
if (viaVerify) {
|
|
210
|
+
if (typeof viaVerify.lockWaitMs === "number") lockWaitMs = viaVerify.lockWaitMs;
|
|
187
211
|
for (const r of viaVerify) {
|
|
188
212
|
verify.push(r);
|
|
189
213
|
if (!r.ok) return failVerify(r);
|
|
@@ -244,7 +268,7 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
244
268
|
removedWorktrees.push(existingReal);
|
|
245
269
|
}
|
|
246
270
|
const taskBranches = (plan.tasks || []).flatMap((t) => [`doug/task-${t.id}`, ...(typeof t.reuse === "string" ? [t.reuse] : [])]);
|
|
247
|
-
const wtResult = removeFinishedWorktrees(dir, { branches: taskBranches, mergedInto: base });
|
|
271
|
+
const wtResult = removeFinishedWorktrees(dir, { branches: taskBranches, mergedInto: ownOnly ? null : base });
|
|
248
272
|
const worktrees = { removed: [...removedWorktrees, ...wtResult.removed], kept: wtResult.kept };
|
|
249
273
|
|
|
250
274
|
const branchesResult = { deleted: [], kept: [] };
|
|
@@ -273,6 +297,6 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
|
|
|
273
297
|
verify: verify.map((v) => ({ command: v.command, ok: v.ok, durationMs: v.durationMs, ...(v.credited ? { credited: v.credited } : {}) })),
|
|
274
298
|
acceptance: acceptance.map((a) => ({ text: a.text, command: a.command, ok: a.ok, durationMs: a.durationMs, ...(a.reused ? { reused: a.reused } : {}) })),
|
|
275
299
|
};
|
|
276
|
-
savePlan(dir, { ...plan, landed });
|
|
277
|
-
return { ok: true, base, branch, mergeCommit, verify, acceptance, worktrees, branches: branchesResult };
|
|
300
|
+
savePlan(dir, { ...plan, landed }, planRelpath);
|
|
301
|
+
return { ok: true, base, branch, mergeCommit, verify, acceptance, worktrees, branches: branchesResult, ...(lockWaitMs !== undefined ? { lockWaitMs } : {}) };
|
|
278
302
|
}
|
|
@@ -18,7 +18,7 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { request as httpRequest } from "node:http";
|
|
19
19
|
import { request as httpsRequest } from "node:https";
|
|
20
20
|
import { classifyBlocks, adversaryBlocks } from "./board.mjs";
|
|
21
|
-
import { transcriptUsage,
|
|
21
|
+
import { transcriptUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
|
|
22
22
|
import { MODELS_HEADING } from "./models.mjs";
|
|
23
23
|
|
|
24
24
|
let DatabaseSync;
|
|
@@ -55,7 +55,8 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
|
|
|
55
55
|
// row. On a hand row the following columns are NULL because they do not exist on that track — never because
|
|
56
56
|
// they measured zero: run_id, spec_hash, files, size, shape, models, implemented, verified, reviewed,
|
|
57
57
|
// review_issues, review_issue_count, adversary_verdict, adversary_blocked, adversary_source, blocks, passes,
|
|
58
|
-
// fix_passes, stop_reason, in_scope, budget_spent, tokens,
|
|
58
|
+
// fix_passes, stop_reason, stop_class, in_scope, budget_spent, tokens, codex_usd, report_hash, report_path, partial (usd too,
|
|
59
|
+
// except that a hand landing recorded with --cost carries its measured session cost there; card usd-budget-size-class). In particular
|
|
59
60
|
// verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
|
|
60
61
|
// the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
|
|
61
62
|
// blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
|
|
@@ -108,6 +109,7 @@ const OUTCOMES_COLUMNS_SQL = `
|
|
|
108
109
|
tokens TEXT,
|
|
109
110
|
usd REAL,
|
|
110
111
|
codex_usd REAL,
|
|
112
|
+
stop_class TEXT,
|
|
111
113
|
commit_sha TEXT,
|
|
112
114
|
track TEXT NOT NULL DEFAULT 'flow',
|
|
113
115
|
wall_clock TEXT,
|
|
@@ -399,6 +401,27 @@ function migrateV7ToV8(db) {
|
|
|
399
401
|
}
|
|
400
402
|
}
|
|
401
403
|
|
|
404
|
+
// v8 had no stop_class column on outcomes (card memory-stop-class): the typed cause of a stop, from
|
|
405
|
+
// task.stopClass, so stops can be counted by cause across runs. Additive like v7->v8: one guarded ALTER, then
|
|
406
|
+
// the version bump; old rows stay null. OUTCOMES_COLUMNS_SQL carries stop_class for a v1 store's rebuild, so
|
|
407
|
+
// this step is a no-op there instead of a duplicate-column error.
|
|
408
|
+
const V9_STOP_COLUMNS = [["stop_class", "TEXT"]];
|
|
409
|
+
|
|
410
|
+
function migrateV8ToV9(db) {
|
|
411
|
+
db.exec("BEGIN");
|
|
412
|
+
try {
|
|
413
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
414
|
+
for (const [name, type] of V9_STOP_COLUMNS) {
|
|
415
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
|
|
416
|
+
}
|
|
417
|
+
db.exec("PRAGMA user_version = 9");
|
|
418
|
+
db.exec("COMMIT");
|
|
419
|
+
} catch (err) {
|
|
420
|
+
db.exec("ROLLBACK");
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
402
425
|
export function openMemory(dir) {
|
|
403
426
|
const file = memoryPath(dir);
|
|
404
427
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -407,7 +430,7 @@ export function openMemory(dir) {
|
|
|
407
430
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
408
431
|
if (version === 0) {
|
|
409
432
|
db.exec(SCHEMA_SQL);
|
|
410
|
-
db.exec("PRAGMA user_version =
|
|
433
|
+
db.exec("PRAGMA user_version = 9");
|
|
411
434
|
} else if (version === 1) {
|
|
412
435
|
migrateV1ToV2(db);
|
|
413
436
|
migrateV2ToV3(db);
|
|
@@ -416,6 +439,7 @@ export function openMemory(dir) {
|
|
|
416
439
|
migrateV5ToV6(db);
|
|
417
440
|
migrateV6ToV7(db);
|
|
418
441
|
migrateV7ToV8(db);
|
|
442
|
+
migrateV8ToV9(db);
|
|
419
443
|
} else if (version === 2) {
|
|
420
444
|
migrateV2ToV3(db);
|
|
421
445
|
migrateV3ToV4(db);
|
|
@@ -423,29 +447,37 @@ export function openMemory(dir) {
|
|
|
423
447
|
migrateV5ToV6(db);
|
|
424
448
|
migrateV6ToV7(db);
|
|
425
449
|
migrateV7ToV8(db);
|
|
450
|
+
migrateV8ToV9(db);
|
|
426
451
|
} else if (version === 3) {
|
|
427
452
|
migrateV3ToV4(db);
|
|
428
453
|
migrateV4ToV5(db);
|
|
429
454
|
migrateV5ToV6(db);
|
|
430
455
|
migrateV6ToV7(db);
|
|
431
456
|
migrateV7ToV8(db);
|
|
457
|
+
migrateV8ToV9(db);
|
|
432
458
|
} else if (version === 4) {
|
|
433
459
|
migrateV4ToV5(db);
|
|
434
460
|
migrateV5ToV6(db);
|
|
435
461
|
migrateV6ToV7(db);
|
|
436
462
|
migrateV7ToV8(db);
|
|
463
|
+
migrateV8ToV9(db);
|
|
437
464
|
} else if (version === 5) {
|
|
438
465
|
migrateV5ToV6(db);
|
|
439
466
|
migrateV6ToV7(db);
|
|
440
467
|
migrateV7ToV8(db);
|
|
468
|
+
migrateV8ToV9(db);
|
|
441
469
|
} else if (version === 6) {
|
|
442
470
|
migrateV6ToV7(db);
|
|
443
471
|
migrateV7ToV8(db);
|
|
472
|
+
migrateV8ToV9(db);
|
|
444
473
|
} else if (version === 7) {
|
|
445
474
|
migrateV7ToV8(db);
|
|
446
|
-
|
|
475
|
+
migrateV8ToV9(db);
|
|
476
|
+
} else if (version === 8) {
|
|
477
|
+
migrateV8ToV9(db);
|
|
478
|
+
} else if (version !== 9) {
|
|
447
479
|
db.close();
|
|
448
|
-
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version
|
|
480
|
+
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 9`);
|
|
449
481
|
}
|
|
450
482
|
return {
|
|
451
483
|
file,
|
|
@@ -479,7 +511,7 @@ export function probeSqliteFts5() {
|
|
|
479
511
|
// Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
|
|
480
512
|
export function inspectMemory(dir) {
|
|
481
513
|
const file = memoryPath(dir);
|
|
482
|
-
const out = { file, exists: false, userVersion: null, maxUserVersion:
|
|
514
|
+
const out = { file, exists: false, userVersion: null, maxUserVersion: 9, quickCheck: null, error: null };
|
|
483
515
|
if (!existsSync(file)) return out;
|
|
484
516
|
out.exists = true;
|
|
485
517
|
let db;
|
|
@@ -719,7 +751,7 @@ export function checkCitation(lesson, checkoutDir, { report } = {}) {
|
|
|
719
751
|
}
|
|
720
752
|
const taskId = rest.slice(0, slash);
|
|
721
753
|
const blockId = rest.slice(slash + 1);
|
|
722
|
-
const found = adversaryBlocks(report).some((b) => b.task === taskId && b.id === blockId);
|
|
754
|
+
const found = adversaryBlocks(report).some((b) => b.task === taskId && (b.id === blockId || b.id === `${taskId}/${blockId}`));
|
|
723
755
|
return found ? { ok: true, reason: "block holds" } : { ok: false, reason: `no block "${blockId}" of task "${taskId}" in this report` };
|
|
724
756
|
}
|
|
725
757
|
const match = citation.match(CITATION_RE);
|
|
@@ -958,18 +990,20 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
958
990
|
// stale and drops it, backfilling from the next candidate so k are returned when enough candidates exist,
|
|
959
991
|
// and a pass sets confirmed. Without one (checked: false), candidates are taken as-is — nothing is marked
|
|
960
992
|
// stale or confirmed, since there is nothing trustworthy to check them against.
|
|
993
|
+
// Every lessons column except the blob, derived from the live schema so a later column cannot drop out.
|
|
994
|
+
const loadCols = m
|
|
995
|
+
.prepare("PRAGMA table_info(lessons)")
|
|
996
|
+
.all()
|
|
997
|
+
.map((c) => c.name)
|
|
998
|
+
.filter((n) => n !== "embedding")
|
|
999
|
+
.join(", ");
|
|
1000
|
+
const loadById = m.prepare(`SELECT ${loadCols}, embedding IS NOT NULL AS has_vec FROM lessons WHERE id = ?`);
|
|
961
1001
|
const result = [];
|
|
962
1002
|
let idx = 0;
|
|
963
1003
|
while (result.length < k && idx < candidates.length) {
|
|
964
1004
|
const cand = candidates[idx++];
|
|
965
1005
|
// Every column except the blob (and the model/dims toRecallLesson strips anyway); hasVector comes from has_vec.
|
|
966
|
-
const { has_vec, ...full } =
|
|
967
|
-
.prepare(
|
|
968
|
-
`SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
|
|
969
|
-
helpful, harmful, superseded_by, embedding_model, embedding_dims, stale, embedding IS NOT NULL AS has_vec
|
|
970
|
-
FROM lessons WHERE id = ?`
|
|
971
|
-
)
|
|
972
|
-
.get(cand.lesson.id);
|
|
1006
|
+
const { has_vec, ...full } = loadById.get(cand.lesson.id);
|
|
973
1007
|
cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
|
|
974
1008
|
if (!checked) {
|
|
975
1009
|
result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
|
|
@@ -1257,6 +1291,7 @@ export function outcomeRows(report, opts) {
|
|
|
1257
1291
|
passes,
|
|
1258
1292
|
fix_passes: fixPasses,
|
|
1259
1293
|
stop_reason: task.stopReason ?? null,
|
|
1294
|
+
stop_class: task.stopClass ?? null,
|
|
1260
1295
|
in_scope: toIntOrNull(task.inScope),
|
|
1261
1296
|
budget_spent: (task.budget && task.budget.spent) || null,
|
|
1262
1297
|
tokens,
|
|
@@ -1327,7 +1362,7 @@ export function conditionConfigHash(dir) {
|
|
|
1327
1362
|
// null when none was found: null fills every one of the seven condition columns with null, exactly like a row
|
|
1328
1363
|
// that has never been stamped. `backfilled` is always 0 here — only `memory.mjs condition backfill`, which never
|
|
1329
1364
|
// goes through this function, ever sets it to 1.
|
|
1330
|
-
export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null } = {}) {
|
|
1365
|
+
export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null, cost = null } = {}) {
|
|
1331
1366
|
if (!card) throw new Error("handOutcomeRow requires card");
|
|
1332
1367
|
if (!commit) throw new Error("handOutcomeRow requires commit");
|
|
1333
1368
|
return {
|
|
@@ -1355,10 +1390,11 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1355
1390
|
passes: null,
|
|
1356
1391
|
fix_passes: null,
|
|
1357
1392
|
stop_reason: null,
|
|
1393
|
+
stop_class: null,
|
|
1358
1394
|
in_scope: null,
|
|
1359
1395
|
budget_spent: null,
|
|
1360
1396
|
tokens: null,
|
|
1361
|
-
usd: null,
|
|
1397
|
+
usd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
|
|
1362
1398
|
codex_usd: null,
|
|
1363
1399
|
commit_sha: commit,
|
|
1364
1400
|
track: "hand",
|
|
@@ -1379,7 +1415,7 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1379
1415
|
const OUTCOME_COLUMNS = [
|
|
1380
1416
|
"recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
|
|
1381
1417
|
"models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
|
|
1382
|
-
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "in_scope",
|
|
1418
|
+
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
|
|
1383
1419
|
"budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
|
|
1384
1420
|
"harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
|
|
1385
1421
|
];
|
|
@@ -1502,30 +1538,39 @@ function reflectionRowToObject(row) {
|
|
|
1502
1538
|
};
|
|
1503
1539
|
}
|
|
1504
1540
|
|
|
1505
|
-
// Deterministic, no LLM
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
|
|
1541
|
+
// Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
|
|
1542
|
+
// `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
|
|
1543
|
+
// named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
|
|
1544
|
+
// no helpful (real wins even when the task also verified; except on the hand track, whose reviewer blocks never withhold it), else a first-verified task bumps helpful on every used
|
|
1545
|
+
// lesson not named harmful; neither verified nor real does nothing. An id naming no lesson is reported in
|
|
1546
|
+
// `unknown`, never an error. apply:false (a dry run's preview) computes the same result without writing anything.
|
|
1547
|
+
function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
|
|
1510
1548
|
const helpful = [];
|
|
1511
1549
|
const harmful = [];
|
|
1512
1550
|
const unknown = [];
|
|
1551
|
+
const named = new Set(harmfulIds);
|
|
1552
|
+
for (const id of named) {
|
|
1553
|
+
if (getLesson(m, id)) {
|
|
1554
|
+
if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
|
|
1555
|
+
harmful.push(id);
|
|
1556
|
+
} else {
|
|
1557
|
+
unknown.push(id);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1513
1560
|
for (const level of report.levels || []) {
|
|
1514
1561
|
for (const task of level.tasks || []) {
|
|
1515
1562
|
const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
|
|
1516
1563
|
if (!used.length) continue;
|
|
1517
|
-
|
|
1564
|
+
// A hand report's blocks are reviewer blockers a later round fixed (card reflect-review-key-text): they are no
|
|
1565
|
+
// evidence the lesson misled, so they never withhold helpful; only --harmful marks that.
|
|
1566
|
+
const real = report.track !== "hand" && blocks.some((b) => b.task === task.id && b.class === "real");
|
|
1518
1567
|
if (real) {
|
|
1519
1568
|
for (const id of used) {
|
|
1520
|
-
if (getLesson(m, id))
|
|
1521
|
-
if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
|
|
1522
|
-
harmful.push(id);
|
|
1523
|
-
} else {
|
|
1524
|
-
unknown.push(id);
|
|
1525
|
-
}
|
|
1569
|
+
if (!getLesson(m, id) && !named.has(id)) unknown.push(id);
|
|
1526
1570
|
}
|
|
1527
1571
|
} else if (task.verified === true) {
|
|
1528
1572
|
for (const id of used) {
|
|
1573
|
+
if (named.has(id)) continue;
|
|
1529
1574
|
if (getLesson(m, id)) {
|
|
1530
1575
|
if (apply) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(id);
|
|
1531
1576
|
helpful.push(id);
|
|
@@ -1586,7 +1631,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1586
1631
|
if (Array.isArray(task.verifierFindings) && task.verifierFindings.length) lines.push(` verifier findings: ${JSON.stringify(task.verifierFindings)}`);
|
|
1587
1632
|
if (taskBlocks.length) {
|
|
1588
1633
|
lines.push(" blocks:");
|
|
1589
|
-
for (const b of taskBlocks) lines.push(` - report:${task.id}/${b.id} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
|
|
1634
|
+
for (const b of taskBlocks) lines.push(` - report:${b.id.startsWith(`${task.id}/`) ? b.id : `${task.id}/${b.id}`} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
|
|
1590
1635
|
}
|
|
1591
1636
|
lines.push(` memoryUsed: ${used.length ? used.join(", ") : "none"}`);
|
|
1592
1637
|
}
|
|
@@ -1756,7 +1801,7 @@ function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
|
1756
1801
|
if (existsSync(file)) {
|
|
1757
1802
|
try {
|
|
1758
1803
|
const t = transcriptUsage(file);
|
|
1759
|
-
const usd = t.
|
|
1804
|
+
const usd = t.usd;
|
|
1760
1805
|
if (usd !== null) return { usd, usdSource: "transcript" };
|
|
1761
1806
|
} catch {
|
|
1762
1807
|
// fall through to the envelope's own number
|
|
@@ -1785,6 +1830,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1785
1830
|
model = null,
|
|
1786
1831
|
claudeDir,
|
|
1787
1832
|
dryRun = false,
|
|
1833
|
+
harmful = [],
|
|
1788
1834
|
propose,
|
|
1789
1835
|
now = nowIso(),
|
|
1790
1836
|
} = opts;
|
|
@@ -1798,13 +1844,30 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1798
1844
|
// happen before any write, so it is the very first thing after the idempotency check.
|
|
1799
1845
|
const blocks = classifyBlocks(report, classes);
|
|
1800
1846
|
|
|
1847
|
+
// A harmful id must be one a task recalled; refused before any write, naming it and the recalled ids.
|
|
1848
|
+
const harmfulIds = [...new Set(harmful)];
|
|
1849
|
+
if (harmfulIds.length) {
|
|
1850
|
+
const recalled = new Set();
|
|
1851
|
+
for (const level of report.levels || []) {
|
|
1852
|
+
for (const task of level.tasks || []) {
|
|
1853
|
+
if (Array.isArray(task.memoryUsed)) for (const id of task.memoryUsed) recalled.add(id);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const outside = harmfulIds.filter((id) => !recalled.has(id));
|
|
1857
|
+
if (outside.length) {
|
|
1858
|
+
throw new Error(
|
|
1859
|
+
`--harmful id(s) not in any task's memoryUsed: ${outside.join(", ")}. memoryUsed ids: ${recalled.size ? [...recalled].join(", ") : "(none)"}.`
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1801
1864
|
let counters;
|
|
1802
1865
|
if (dryRun) {
|
|
1803
|
-
counters = computeReflectCounters(m, blocks, report, { apply: false });
|
|
1866
|
+
counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
|
|
1804
1867
|
} else {
|
|
1805
1868
|
m.exec("BEGIN");
|
|
1806
1869
|
try {
|
|
1807
|
-
counters = computeReflectCounters(m, blocks, report, { apply: true });
|
|
1870
|
+
counters = computeReflectCounters(m, blocks, report, { apply: true, harmfulIds });
|
|
1808
1871
|
m.prepare(
|
|
1809
1872
|
`INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
|
|
1810
1873
|
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
|