@dougharness/cli 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bin.js +18 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +14 -7
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +42 -9
  7. package/dist/board.js.map +1 -1
  8. package/dist/detect/repo.js +14 -1
  9. package/dist/detect/repo.js.map +1 -1
  10. package/dist/detect/types.d.ts +2 -0
  11. package/dist/doctor.d.ts +46 -0
  12. package/dist/doctor.js +491 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/generate/agents.d.ts +7 -0
  15. package/dist/generate/agents.js +62 -12
  16. package/dist/generate/agents.js.map +1 -1
  17. package/dist/generate/claude-md.js +4 -0
  18. package/dist/generate/claude-md.js.map +1 -1
  19. package/dist/generate/config.d.ts +8 -0
  20. package/dist/generate/config.js +4 -1
  21. package/dist/generate/config.js.map +1 -1
  22. package/dist/generate/proposal.js +15 -6
  23. package/dist/generate/proposal.js.map +1 -1
  24. package/dist/generate/settings.d.ts +2 -0
  25. package/dist/generate/settings.js +17 -2
  26. package/dist/generate/settings.js.map +1 -1
  27. package/dist/init.d.ts +14 -0
  28. package/dist/init.js +94 -8
  29. package/dist/init.js.map +1 -1
  30. package/dist/memory-setup.d.ts +28 -0
  31. package/dist/memory-setup.js +326 -0
  32. package/dist/memory-setup.js.map +1 -0
  33. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  34. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  35. package/node_modules/@dougharness/flow/agents/planner.md +3 -3
  36. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  37. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  38. package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
  39. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  40. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  41. package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
  43. package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  45. package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
  46. package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
  47. package/node_modules/@dougharness/flow/package.json +2 -1
  48. package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
  49. package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
  50. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  51. package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
  52. package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
  53. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  54. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  55. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  56. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
  57. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  67. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  68. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  69. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  70. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  71. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  72. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  73. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  74. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  75. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  76. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  77. package/node_modules/@dougharness/gates/package.json +1 -1
  78. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  79. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
  80. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  81. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  82. package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
  83. package/package.json +4 -4
  84. package/templates/board-app.js +4 -3
  85. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  86. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  87. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  88. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -60,20 +60,33 @@
60
60
  // memory.mjs lesson unstale <id> [dir] [--json]
61
61
  // clear a lesson's stale mark (set by recall's citation
62
62
  // re-check) so it is a candidate again; refuses on an unknown id
63
+ // memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]
64
+ // (card memory-every-session) recall for a hand-track card's
65
+ // briefs: query = the card's title and goal, k 8. Prints the
66
+ // workflow's lessons header, the rendered `- [id] ...` lines
67
+ // (capped at 2000 chars like plan.mjs), then `memoryUsed: <ids>`;
68
+ // writes .doug/.state/reports/<card-id>/lessons.json. No database
69
+ // -> "No lessons recalled for <id>." (database not created);
70
+ // a database or provider error -> one stderr line, exit 0;
71
+ // unknown card -> exit 1
63
72
  // memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
64
- // hybrid recall (card memory-recall): BM25 over FTS5 fused by
65
- // reciprocal rank fusion with cosine over stored vectors when a
66
- // provider is configured and vectors exist for it, recency-
67
- // weighted, scope-boosted by --files, each returned lesson's
68
- // citation re-checked against [dir] only when [dir] is a real
69
- // project root (contains .doug); keyword-only, and says so, when
70
- // no provider is configured or no vectors match it
73
+ // dense recall (card memory-recall): with a provider and
74
+ // stored vectors, every live lesson is ranked by cosine x
75
+ // recency weight x scope boost (--files), ties by lesson id,
76
+ // then BM25 hits with no vector are appended in BM25 order;
77
+ // without a provider or vectors the ranking is BM25 x recency
78
+ // x scope, a keyword-only fallback that says so. Each returned
79
+ // lesson's citation is re-checked against [dir] only when
80
+ // [dir] is a real project root (contains .doug)
71
81
  // memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
72
- // [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
82
+ // [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>]
73
83
  // [--model <name>] [--max-usd <n>] [--dry-run] [--json]
74
84
  // runs after record: applies deterministic helpful/harmful
75
85
  // counters to lessons named in each task's memoryUsed (committed
76
- // even if the LLM pass below fails), then a cheap-row `claude -p
86
+ // even if the LLM pass below fails; a real block alone bumps no
87
+ // harmful: each --harmful <id>, a recalled lesson that misled the
88
+ // work, gets harmful +1 and no helpful; an id outside every
89
+ // task's memoryUsed exits 1 before any write), then a cheap-row `claude -p
77
90
  // --json-schema` pass proposes at most 3 lessons from the report,
78
91
  // filtered in code (a pitfall needs a block classed real, a
79
92
  // pattern needs a first-pass verified task, the citation must be
@@ -82,10 +95,17 @@
82
95
  // helpful instead of appending); idempotent on the report's
83
96
  // sha256 (a second reflect on the same report is a no-op, exit
84
97
  // 0); --dry-run applies nothing but still runs the LLM pass and
85
- // prints what would happen; --hand is refused, since a hand
86
- // landing has no report. Cost is measured from the same local
98
+ // prints what would happen. Cost is measured from the same local
87
99
  // transcripts cost.mjs reads, falling back to the CLI's own
88
100
  // cost estimate. Needs Node >=22.16 (node:sqlite).
101
+ // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<class>[: <reason>]"]...
102
+ // [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
103
+ // (card memory-every-session) the same pass for a hand landing:
104
+ // assembles .doug/.state/reports/<card-id>/hand-report.json from
105
+ // the saved briefs/, reviews/ and lessons.json (memoryUsed = its
106
+ // ids; verified true; passes = --rounds, default 1; one fixed
107
+ // blocker ledger entry per --review id), then reflects on it
108
+ // exactly as above. --commit is required (exit 2). No schema change.
89
109
  // memory.mjs doctor [dir] [--json]
90
110
  // probe the configured embedding provider (memory.embeddings in
91
111
  // .doug/config.json), or a local Ollama at localhost:11434 when
@@ -165,7 +185,7 @@
165
185
  // lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
166
186
  // provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
167
187
  // row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
168
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
188
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
169
189
  import { resolve, join, dirname } from "node:path";
170
190
  import { createHash } from "node:crypto";
171
191
  import { spawnSync } from "node:child_process";
@@ -187,7 +207,7 @@ import {
187
207
 
188
208
  const argv = process.argv.slice(2);
189
209
  const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
190
- const REPEATABLE = new Set(["adversary"]);
210
+ const REPEATABLE = new Set(["adversary", "review", "harmful"]);
191
211
  const opts = {};
192
212
  const positional = [];
193
213
  for (let i = 0; i < argv.length; i++) {
@@ -595,27 +615,89 @@ try {
595
615
  }
596
616
  case "reflect": {
597
617
  const usage =
598
- 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
599
- if (opts.hand) fail(`${usage}\n--hand is not accepted: a hand landing has no report.`, 2);
600
- const [reportFile] = rest;
601
- if (!reportFile) fail(usage, 2);
602
- const dir = dirFrom(rest, 1);
603
- const reportPath = resolve(reportFile);
604
- if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
618
+ 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
619
+ let dir;
620
+ let reportPath;
605
621
  let reportText;
606
- try {
607
- reportText = readFileSync(reportPath, "utf8");
608
- } catch (err) {
609
- fail(`cannot read report file ${reportPath}: ${err.message}`);
610
- }
611
622
  let report;
612
- try {
613
- report = unwrapReport(JSON.parse(reportText));
614
- } catch (err) {
615
- fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
623
+ let classes;
624
+ let handCard = null;
625
+ if (opts.hand) {
626
+ const handUsage =
627
+ 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
628
+ const [id] = rest;
629
+ if (!id || !opts.commit) fail(handUsage, 2);
630
+ handCard = id;
631
+ dir = dirFrom(rest, 1);
632
+ let rounds = 1;
633
+ if (opts.rounds !== undefined) {
634
+ rounds = Number(opts.rounds);
635
+ if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
636
+ }
637
+ try {
638
+ classes = parseAdversaryClasses(opts.review || []);
639
+ } catch (err) {
640
+ fail(`${handUsage}\n${err.message}`, 2);
641
+ }
642
+ const base = join(dir, ".doug", ".state", "reports", id);
643
+ const readDirFiles = (sub) => {
644
+ const d = join(base, sub);
645
+ if (!existsSync(d)) return [];
646
+ return readdirSync(d)
647
+ .filter((n) => !n.startsWith("."))
648
+ .sort()
649
+ .map((name) => {
650
+ try {
651
+ return { name, text: readFileSync(join(d, name), "utf8") };
652
+ } catch {
653
+ return null;
654
+ }
655
+ })
656
+ .filter(Boolean);
657
+ };
658
+ let memoryUsed = [];
659
+ try {
660
+ const saved = JSON.parse(readFileSync(join(base, "lessons.json"), "utf8"));
661
+ if (saved && Array.isArray(saved.ids)) memoryUsed = saved.ids.filter((x) => typeof x === "string");
662
+ } catch {
663
+ memoryUsed = [];
664
+ }
665
+ const ledger = classes.map((c) => {
666
+ return { id: c.id, stage: "adversary", seat: "reviewer", severity: "blocker", status: "fixed", description: c.reason || `reviewer finding ${c.id}` };
667
+ });
668
+ const sortKeys = (v) =>
669
+ Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
670
+ const handReport = sortKeys({
671
+ track: "hand",
672
+ card: id,
673
+ commit: opts.commit,
674
+ levels: [{ index: 0, tasks: [{ id, card: id, commit: opts.commit, verified: true, passes: rounds, stopReason: null, memoryUsed, ledger }] }],
675
+ hand: { briefs: readDirFiles("briefs"), reviews: readDirFiles("reviews"), note: textOrNull(opts.note) },
676
+ });
677
+ reportText = JSON.stringify(handReport, null, 2) + "\n";
678
+ reportPath = join(base, "hand-report.json");
679
+ mkdirSync(base, { recursive: true });
680
+ writeFileSync(reportPath, reportText, "utf8");
681
+ report = handReport;
682
+ } else {
683
+ const [reportFile] = rest;
684
+ if (!reportFile) fail(usage, 2);
685
+ dir = dirFrom(rest, 1);
686
+ reportPath = resolve(reportFile);
687
+ if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
688
+ try {
689
+ reportText = readFileSync(reportPath, "utf8");
690
+ } catch (err) {
691
+ fail(`cannot read report file ${reportPath}: ${err.message}`);
692
+ }
693
+ try {
694
+ report = unwrapReport(JSON.parse(reportText));
695
+ } catch (err) {
696
+ fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
697
+ }
698
+ classes = parseAdversaryClasses(opts.adversary || []);
616
699
  }
617
700
  const reportHash = createHash("sha256").update(reportText).digest("hex");
618
- const classes = parseAdversaryClasses(opts.adversary || []);
619
701
  const plan = loadPlan(dir);
620
702
  const models = loadModels(dir);
621
703
  const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
@@ -640,11 +722,12 @@ try {
640
722
  classes,
641
723
  plan,
642
724
  run: opts.run ?? null,
643
- card: opts.card ?? null,
725
+ card: handCard ?? opts.card ?? null,
644
726
  commit: opts.commit ?? null,
645
727
  model,
646
728
  claudeDir,
647
729
  dryRun,
730
+ harmful: opts.harmful || [],
648
731
  propose,
649
732
  });
650
733
  if (!dryRun && result.ok && result.appended && result.appended.length) {
@@ -843,14 +926,18 @@ try {
843
926
  }
844
927
  const provider = createProvider(readMemoryConfig(dir));
845
928
  const staleDays = readMemoryStaleDays(dir);
846
- const { openMemory, recallLessons } = await import("../lib/memory.mjs");
929
+ const { openMemory, recallLessons, degradedWarning, detectLocalProvider } = await import("../lib/memory.mjs");
930
+ // Probed once, and only when no provider is configured; a failed probe is just "not detected".
931
+ const detectedProvider = provider ? null : await detectLocalProvider();
847
932
  const m = openMemory(dir);
848
933
  let result;
849
934
  try {
850
- result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays });
935
+ result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays, detectedProvider });
851
936
  } finally {
852
937
  m.close();
853
938
  }
939
+ const recallWarning = degradedWarning(result, { what: "memory recall", detectedProvider });
940
+ if (recallWarning) process.stderr.write(recallWarning + "\n");
854
941
  if (opts.json) {
855
942
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
856
943
  } else {
@@ -863,6 +950,67 @@ try {
863
950
  }
864
951
  break;
865
952
  }
953
+ case "brief-lessons": {
954
+ const usage = "usage: memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]";
955
+ const [id, ...extra] = rest;
956
+ if (!id) fail(usage, 2);
957
+ const dir = dirFrom(extra, 0);
958
+ let k = 8;
959
+ if (opts.k !== undefined) {
960
+ k = Number(opts.k);
961
+ if (!Number.isInteger(k) || k < 1) fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
962
+ }
963
+ let card;
964
+ try {
965
+ card = findCard(loadBoard(dir), id);
966
+ } catch (err) {
967
+ fail(`no card ${id}: ${err.message}`);
968
+ }
969
+ const query = `${card.title}\n${card.goal ?? ""}`;
970
+ const header = "Lessons from earlier runs on this repository (memory; each cites where it was learned):";
971
+ const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, recalledAt: new Date().toISOString() };
972
+ let error = null;
973
+ if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
974
+ try {
975
+ const provider = createProvider(readMemoryConfig(dir));
976
+ const staleDays = readMemoryStaleDays(dir);
977
+ const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider, renderLessons } = await import("../lib/memory.mjs");
978
+ const detectedProvider = provider ? null : await detectLocalProvider();
979
+ const m = openMemory(dir);
980
+ try {
981
+ const result = await recallLessons(m, query, { k, checkoutDir: dir, provider, staleDays, detectedProvider });
982
+ const warning = degradedWarning(result, { what: "memory recall", detectedProvider });
983
+ if (warning) process.stderr.write(warning + "\n");
984
+ const rendered = renderLessons(result.lessons);
985
+ saved.mode = result.mode;
986
+ saved.reason = result.degraded ? degradedReason(result, detectedProvider) : null;
987
+ saved.ids = rendered.ids;
988
+ saved.text = rendered.text === null ? null : result.degraded ? `(recall was keyword-only: ${saved.reason})\n${rendered.text}` : rendered.text;
989
+ } finally {
990
+ m.close();
991
+ }
992
+ } catch (err) {
993
+ error = err.message;
994
+ saved.mode = null;
995
+ saved.reason = null;
996
+ saved.ids = [];
997
+ saved.text = null;
998
+ saved.error = error;
999
+ process.stderr.write(`[doug] memory recall skipped: ${error}\n`);
1000
+ }
1001
+ }
1002
+ const reportDir = join(dir, ".doug", ".state", "reports", id);
1003
+ mkdirSync(reportDir, { recursive: true });
1004
+ writeFileSync(join(reportDir, "lessons.json"), JSON.stringify(saved, null, 2) + "\n", "utf8");
1005
+ if (opts.json) {
1006
+ process.stdout.write(JSON.stringify(saved, null, 2) + "\n");
1007
+ } else if (saved.ids.length) {
1008
+ process.stdout.write([header, saved.text, `memoryUsed: ${saved.ids.join(", ")}`].join("\n") + "\n");
1009
+ } else {
1010
+ process.stdout.write(`No lessons recalled for ${id}.\n`);
1011
+ }
1012
+ break;
1013
+ }
866
1014
  case "doctor": {
867
1015
  const dir = dirFrom(rest, 0);
868
1016
  const cfg = readMemoryConfig(dir);
@@ -919,10 +1067,12 @@ try {
919
1067
  if (opts.check && opts["no-check"]) {
920
1068
  fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
921
1069
  }
922
- const { openMemory, embedLessons } = await import("../lib/memory.mjs");
1070
+ const { openMemory, embedLessons, lessonsNeedingEmbedding } = await import("../lib/memory.mjs");
923
1071
  const m = openMemory(dir);
924
1072
  let result;
925
1073
  let checkStatus;
1074
+ let stillMissing = 0;
1075
+ let liveTotal = 0;
926
1076
  try {
927
1077
  if (opts["no-check"]) {
928
1078
  process.stderr.write("embed-check: skipped (--no-check)\n");
@@ -944,6 +1094,8 @@ try {
944
1094
  batch,
945
1095
  onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
946
1096
  });
1097
+ stillMissing = lessonsNeedingEmbedding(m, { model: provider.model, dims: provider.dims }).length;
1098
+ liveTotal = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
947
1099
  } finally {
948
1100
  m.close();
949
1101
  }
@@ -954,6 +1106,11 @@ try {
954
1106
  `Embedded ${result.embedded}/${result.total} lesson(s) with ${provider.model}/${provider.dims} (${result.failed} failed).\n`
955
1107
  );
956
1108
  }
1109
+ if (stillMissing > 0) {
1110
+ process.stderr.write(
1111
+ `WARNING: ${stillMissing} of ${liveTotal} lessons still have no vector for ${provider.model}/${provider.dims}; retry with memory.mjs reembed --batch 4\n`
1112
+ );
1113
+ }
957
1114
  break;
958
1115
  }
959
1116
  case "index": {
@@ -1076,15 +1233,18 @@ try {
1076
1233
  }
1077
1234
  }
1078
1235
  const provider = createProvider(readMemoryConfig(dir));
1079
- const { openMemory } = await import("../lib/memory.mjs");
1236
+ const { openMemory, degradedWarning, detectLocalProvider } = await import("../lib/memory.mjs");
1080
1237
  const { searchIndex } = await import("../lib/code-index.mjs");
1238
+ const detectedProvider = provider ? null : await detectLocalProvider();
1081
1239
  const m = openMemory(dir);
1082
1240
  let result;
1083
1241
  try {
1084
- result = await searchIndex(m, query, { k, files, provider });
1242
+ result = await searchIndex(m, query, { k, files, provider, detectedProvider });
1085
1243
  } finally {
1086
1244
  m.close();
1087
1245
  }
1246
+ const searchWarning = degradedWarning(result, { what: "code index search", code: true, detectedProvider });
1247
+ if (searchWarning) process.stderr.write(searchWarning + "\n");
1088
1248
  if (opts.json) {
1089
1249
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1090
1250
  } else {
@@ -1268,7 +1428,7 @@ try {
1268
1428
  break;
1269
1429
  }
1270
1430
  default:
1271
- fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
1431
+ fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
1272
1432
  }
1273
1433
  } catch (err) {
1274
1434
  fail(err.message);
@@ -51,6 +51,7 @@ import { landPlan, INTEGRATION_WORKTREE } from "../lib/land.mjs";
51
51
  import { removeFinishedWorktrees, retireTaskWorktrees, retireIntegrationWorktree, branchHead, isGitRepo } from "../lib/worktrees.mjs";
52
52
  import { readMemoryConfig, readMemoryStaleDays, readIndexConfig, createProvider } from "../lib/embeddings.mjs";
53
53
  import { existsSync, readFileSync, statSync } from "node:fs";
54
+ import { execFileSync } from "node:child_process";
54
55
 
55
56
  // The project's install command from .doug/config.json, used when the plan does not name one.
56
57
  function configInstall(d) {
@@ -88,60 +89,34 @@ function configDeleteBranches(d) {
88
89
  return false;
89
90
  }
90
91
  }
92
+ // card plan-base-branch-default: the branch checked out in d, or on a detached HEAD its full sha (noted on
93
+ // stderr); null when d is not a git repository or git fails.
94
+ function currentBase(d) {
95
+ const git = (...args) => execFileSync("git", args, { cwd: d, encoding: "utf8", stdio: "pipe" }).trim();
96
+ try {
97
+ const b = git("symbolic-ref", "--short", "-q", "HEAD");
98
+ if (b) return b;
99
+ } catch {}
100
+ try {
101
+ const sha = git("rev-parse", "--verify", "-q", "HEAD");
102
+ if (!sha) return null;
103
+ process.stderr.write(`[doug] plan.mjs: detached HEAD; baseBranch set to ${sha}\n`);
104
+ return sha;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
91
109
  function withDefaults(p, d) {
92
110
  let out = p;
111
+ if (typeof out.baseBranch !== "string" || !out.baseBranch.trim()) {
112
+ const base = currentBase(d);
113
+ if (base) out = { ...out, baseBranch: base };
114
+ }
93
115
  if (!out.install && configInstall(d)) out = { ...out, install: configInstall(d) };
94
116
  const hasCommand = out.adversary && typeof out.adversary === "object" && typeof out.adversary.command === "string" && out.adversary.command.trim();
95
117
  if (out.adversary !== false && !hasCommand && configAdversary(d)) out = { ...out, adversary: { ...(out.adversary && typeof out.adversary === "object" ? out.adversary : {}), command: configAdversary(d) } };
96
118
  return out;
97
119
  }
98
- // Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
99
- // (LESSON_CHAR_BUDGET): text over budget is cut at the last sentence boundary (". ", "! ", "? ", or a newline)
100
- // within it, falling back to the last word boundary, then a hard cut, with an ellipsis ("…") appended; the
101
- // citation (or kind, when there is none) stays on the line either way. A sentence/newline boundary this close
102
- // to the start (MIN_CUT_KEPT) keeps too little to be worth it — a lesson opening with a heading-then-newline,
103
- // or a stray early period, would otherwise collapse to almost nothing — so that case falls through to the word
104
- // boundary (then the hard cut) instead, same as when no such boundary exists at all. That keeps any one lesson
105
- // — the memory store holds whole files up to ~12k characters, not just short summaries — from ever crowding
106
- // out the rest: the running total is kept under LESSONS_CHAR_CAP by skipping (not stopping at) a line that
107
- // would overflow it, so a later, shorter lesson still renders even after an earlier one didn't fit. lessonIds
108
- // carries exactly the ids of the lines rendered. { text: null, ids: [] } when nothing rendered, the caller's
109
- // cue to omit the fields entirely.
110
- const LESSONS_CHAR_CAP = 2000;
111
- const LESSON_CHAR_BUDGET = 400;
112
- const MIN_CUT_KEPT = 40;
113
- function cutLessonText(text, budget) {
114
- if (text.length <= budget) return text;
115
- const slice = text.slice(0, budget);
116
- let cut = -1;
117
- for (const sep of [". ", "! ", "? "]) {
118
- const idx = slice.lastIndexOf(sep);
119
- if (idx !== -1) cut = Math.max(cut, idx + sep.length - 1);
120
- }
121
- const nl = slice.lastIndexOf("\n");
122
- if (nl !== -1) cut = Math.max(cut, nl);
123
- if (cut < MIN_CUT_KEPT) cut = -1;
124
- if (cut === -1) {
125
- const sp = slice.lastIndexOf(" ");
126
- cut = sp !== -1 ? sp : budget;
127
- }
128
- return text.slice(0, cut).trimEnd() + "…";
129
- }
130
- function renderLessons(lessons) {
131
- const text = [];
132
- const ids = [];
133
- let length = 0;
134
- for (const lesson of Array.isArray(lessons) ? lessons : []) {
135
- const line = `- [${lesson.id}] ${cutLessonText(lesson.text, LESSON_CHAR_BUDGET)} (${lesson.citation || lesson.kind})`;
136
- const nextLength = length + (text.length ? 1 : 0) + line.length;
137
- if (nextLength > LESSONS_CHAR_CAP) continue;
138
- text.push(line);
139
- ids.push(lesson.id);
140
- length = nextLength;
141
- }
142
- return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
143
- }
144
-
145
120
  // Mirrors lib/memory.mjs's exported MEMORY_DB_RELPATH by value, not by import: importing that module loads
146
121
  // node:sqlite at its top level (review MAJOR/MINOR 4), so the mere existence check below must not pull it in
147
122
  // on a Node that lacks node:sqlite, or every plan.mjs json without a database would eat that module's load
@@ -149,14 +124,27 @@ function renderLessons(lessons) {
149
124
  const MEMORY_DB_RELPATH = ".doug/.state/memory/memory.db";
150
125
 
151
126
  // Wraps a provider so a down or slow embeddings endpoint is ever hit once per `plan.mjs json` run: the first
152
- // embed() failure is cached and handed to every later caller with no further request, and the run prints
153
- // exactly one stderr line about it. Without this, a plan with many tasks rediscovers the same dead endpoint
127
+ // embed() failure is cached and handed to every later caller with no further request (the degraded-recall
128
+ // WARNING for it comes from warnOnce below, once per distinct line). Without this, a plan with many tasks rediscovers the same dead endpoint
154
129
  // once per task, each paying its own request or timeout (review MAJOR: measured 26.8s for 8 tasks against a
155
130
  // black-holed baseUrl). null in, null out: recallLessons already treats a null provider as keyword-only.
131
+ // card recall-fallback-warning: each distinct degraded-recall warning is printed once per run, not once per task;
132
+ // and the local-provider probe (only when no provider is configured) runs once per run too.
133
+ const warnedLines = new Set();
134
+ function warnOnce(line) {
135
+ if (!line || warnedLines.has(line)) return;
136
+ warnedLines.add(line);
137
+ process.stderr.write(line + "\n");
138
+ }
139
+ let detectedOnce = null;
140
+ function detectOnce(provider) {
141
+ if (provider) return Promise.resolve(null);
142
+ if (!detectedOnce) detectedOnce = import("../lib/memory.mjs").then((mod) => mod.detectLocalProvider());
143
+ return detectedOnce;
144
+ }
156
145
  function cachingProvider(provider) {
157
146
  if (!provider) return null;
158
147
  let failure = null;
159
- let warned = false;
160
148
  return {
161
149
  name: provider.name,
162
150
  model: provider.model,
@@ -164,13 +152,7 @@ function cachingProvider(provider) {
164
152
  async embed(texts, opts) {
165
153
  if (failure) return failure;
166
154
  const result = await provider.embed(texts, opts);
167
- if (!result.ok) {
168
- failure = result;
169
- if (!warned) {
170
- warned = true;
171
- process.stderr.write(`[doug] memory recall: embeddings provider unavailable (${result.reason}); keyword-only\n`);
172
- }
173
- }
155
+ if (!result.ok) failure = result;
174
156
  return result;
175
157
  },
176
158
  };
@@ -187,15 +169,20 @@ function cachingProvider(provider) {
187
169
  async function attachLessons(plan, dir, provider) {
188
170
  if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
189
171
  try {
190
- const { openMemory, recallLessons } = await import("../lib/memory.mjs");
172
+ const { openMemory, recallLessons, degradedWarning, degradedReason, renderLessons } = await import("../lib/memory.mjs");
191
173
  const staleDays = readMemoryStaleDays(dir);
192
174
  const m = openMemory(dir);
193
175
  try {
176
+ const detectedProvider = await detectOnce(provider);
177
+ // An empty lesson store has nothing to degrade (a code-index-only database, say): no warning for it.
178
+ const hasLessons = m.prepare("SELECT 1 FROM lessons WHERE superseded_by IS NULL AND stale IS NULL LIMIT 1").get() !== undefined;
194
179
  const tasks = [];
195
180
  for (const task of plan.tasks) {
196
- const taskRecall = await recallLessons(m, `${task.title}\n${task.spec}`, { files: task.files, k: 8, checkoutDir: dir, provider, staleDays });
181
+ const taskRecall = await recallLessons(m, `${task.title}\n${task.spec}`, { files: task.files, k: 8, checkoutDir: dir, provider, staleDays, detectedProvider });
182
+ if (hasLessons) warnOnce(degradedWarning(taskRecall, { what: "memory recall", detectedProvider }));
197
183
  const taskLessons = renderLessons(taskRecall.lessons);
198
- tasks.push(taskLessons.text === null ? task : { ...task, lessons: taskLessons.text, lessonIds: taskLessons.ids });
184
+ const lessonsText = taskLessons.text !== null && taskRecall.degraded ? `(recall was keyword-only: ${degradedReason(taskRecall, detectedProvider)})\n${taskLessons.text}` : taskLessons.text;
185
+ tasks.push(taskLessons.text === null ? task : { ...task, lessons: lessonsText, lessonIds: taskLessons.ids });
199
186
  }
200
187
  return { ...plan, tasks };
201
188
  } finally {
@@ -207,7 +194,7 @@ async function attachLessons(plan, dir, provider) {
207
194
  }
208
195
  }
209
196
 
210
- // Renders code chunks the same skip-not-stop way renderLessons renders lessons, one line per chunk:
197
+ // Renders code chunks the same skip-not-stop way renderLessons (lib/memory.mjs) renders lessons, one line per chunk:
211
198
  // "- <path>:<start>-<end> <first non-blank line of the chunk, trimmed to 120 chars>". codeContextIds carries
212
199
  // exactly the ids of the lines kept, in rank order. { text: null, ids: [] } when nothing rendered, the caller's
213
200
  // cue to omit the fields entirely.
@@ -243,17 +230,20 @@ async function attachCodeContext(plan, dir, provider) {
243
230
  if (!readIndexConfig(dir).enabled) return plan;
244
231
  if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
245
232
  try {
246
- const { openMemory } = await import("../lib/memory.mjs");
233
+ const { openMemory, degradedWarning, degradedReason } = await import("../lib/memory.mjs");
247
234
  const { searchIndex } = await import("../lib/code-index.mjs");
248
235
  const m = openMemory(dir);
249
236
  try {
250
237
  const chunkCount = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
251
238
  if (chunkCount === 0) return plan;
239
+ const detectedProvider = await detectOnce(provider);
252
240
  const tasks = [];
253
241
  for (const task of plan.tasks) {
254
- const result = await searchIndex(m, `${task.title}\n${task.spec}`, { k: 6, provider });
242
+ const result = await searchIndex(m, `${task.title}\n${task.spec}`, { k: 6, provider, detectedProvider });
243
+ warnOnce(degradedWarning(result, { what: "code index search", code: true, detectedProvider }));
255
244
  const rendered = renderCodeContext(result.chunks);
256
- tasks.push(rendered.text === null ? task : { ...task, codeContext: rendered.text, codeContextIds: rendered.ids });
245
+ const codeText = rendered.text !== null && result.degraded ? `(code search was keyword-only: ${degradedReason(result, detectedProvider)})\n${rendered.text}` : rendered.text;
246
+ tasks.push(rendered.text === null ? task : { ...task, codeContext: codeText, codeContextIds: rendered.ids });
257
247
  }
258
248
  return { ...plan, tasks };
259
249
  } finally {