@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.
Files changed (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. 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
- if (!isValidEmbeddingsConfig(resolved)) {
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
- function prefixFor(model, inputType) {
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 * commands.length + 60000, maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
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
- return out.results.map((x) => ({
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
- export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false } = {}) {
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, INTEGRATION_WORKTREE);
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
- git(dir, ["worktree", "add", "--detach", work, branch]);
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, priceUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
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, usd, codex_usd, report_hash, report_path, partial. In particular
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 = 8");
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
- } else if (version !== 8) {
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 8`);
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: 8, quickCheck: null, error: null };
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 } = m
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: for each task whose memoryUsed is non-empty, a real block bumps harmful on every used
1506
- // lesson that exists (real wins even when the task also verified), else a first-verified task bumps helpful;
1507
- // neither verified nor real does nothing. An id naming no lesson is reported in `unknown`, never an error.
1508
- // apply:false (a dry run's preview) computes the same result without writing anything.
1509
- function computeReflectCounters(m, blocks, report, { apply }) {
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
- const real = blocks.some((b) => b.task === task.id && b.class === "real");
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.model ? priceUsage(t.usage, t.model) : null;
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)`