@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
@@ -15,8 +15,10 @@ import { mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
15
15
  import { dirname, resolve, join } from "node:path";
16
16
  import { createHash } from "node:crypto";
17
17
  import { homedir } from "node:os";
18
+ import { request as httpRequest } from "node:http";
19
+ import { request as httpsRequest } from "node:https";
18
20
  import { classifyBlocks, adversaryBlocks } from "./board.mjs";
19
- import { transcriptUsage, priceUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
21
+ import { transcriptUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
20
22
  import { MODELS_HEADING } from "./models.mjs";
21
23
 
22
24
  let DatabaseSync;
@@ -459,6 +461,44 @@ export function openMemory(dir) {
459
461
  };
460
462
  }
461
463
 
464
+ // Read-only probe for `doug doctor`: can this Node build an FTS5 table in memory? Never throws.
465
+ export function probeSqliteFts5() {
466
+ try {
467
+ const db = new DatabaseSync(":memory:");
468
+ try {
469
+ db.exec("CREATE VIRTUAL TABLE probe USING fts5(x)");
470
+ } finally {
471
+ db.close();
472
+ }
473
+ return { ok: true, reason: null };
474
+ } catch (err) {
475
+ return { ok: false, reason: err && err.message ? err.message : String(err) };
476
+ }
477
+ }
478
+
479
+ // Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
480
+ export function inspectMemory(dir) {
481
+ const file = memoryPath(dir);
482
+ const out = { file, exists: false, userVersion: null, maxUserVersion: 8, quickCheck: null, error: null };
483
+ if (!existsSync(file)) return out;
484
+ out.exists = true;
485
+ let db;
486
+ try {
487
+ db = new DatabaseSync(file, { readOnly: true });
488
+ out.userVersion = db.prepare("PRAGMA user_version").get().user_version;
489
+ out.quickCheck = String(db.prepare("PRAGMA quick_check").get().quick_check);
490
+ } catch (err) {
491
+ out.error = err && err.message ? err.message : String(err);
492
+ } finally {
493
+ try {
494
+ if (db) db.close();
495
+ } catch {
496
+ // already unusable
497
+ }
498
+ }
499
+ return out;
500
+ }
501
+
462
502
  function nowIso() {
463
503
  return new Date().toISOString();
464
504
  }
@@ -787,22 +827,32 @@ function toRecallLesson(lesson, extra) {
787
827
  return { ...rest, hasVector: !!embedding, ...extra };
788
828
  }
789
829
 
790
- // --- Hybrid recall -----------------------------------------------------------------------------------------
791
- // BM25 (FTS5) and cosine-over-stored-vectors fused by reciprocal rank fusion, recency-weighted, scope-boosted,
792
- // then citation-rechecked against checkoutDir before anything is returned — only when checkoutDir is a real
793
- // project root (isProjectRoot); otherwise every candidate is returned unchecked (checked: false in the result)
794
- // rather than either throwing (no checkoutDir given) or marking every row stale forever (a wrong one given).
795
- // Never throws for a provider or checkout problem (a database problem may still throw, same as every other
796
- // export here). See design #2 in the memory-recall card for the step-by-step this implements.
830
+ // --- Dense-ranked recall -----------------------------------------------------------------------------------
831
+ // With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
832
+ // cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
833
+ // 50+50 reciprocal rank fusion); BM25 hits that have no such vector are appended after all vectored lessons, in
834
+ // BM25 order. Without a provider or vectors, BM25 x recency x scope is the ranking (mode keyword-only). Ties
835
+ // break by lesson id ascending. Results are then citation-rechecked against checkoutDir before anything is
836
+ // returned — only when checkoutDir is a real project root (isProjectRoot); otherwise every candidate is
837
+ // returned unchecked (checked: false in the result) rather than either throwing (no checkoutDir given) or
838
+ // marking every row stale forever (a wrong one given). Never throws for a provider or checkout problem (a
839
+ // database problem may still throw, same as every other export here). See design #2 in the memory-recall card.
797
840
  export async function recallLessons(m, query, opts = {}) {
798
- const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30 } = opts;
841
+ const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30, detectedProvider = null } = opts;
799
842
  const nowDate = now instanceof Date ? now : new Date(now);
800
843
  const nowIsoStr = nowDate.toISOString();
801
844
  const nowMs = nowDate.getTime();
802
845
  const checked = isProjectRoot(checkoutDir);
803
846
 
804
847
  // (a) candidates: not superseded, not stale, and confirmed-or-created within staleDays.
805
- const allRows = m.prepare("SELECT * FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").all().map(lessonRowToObject);
848
+ // Light columns only: the embedding blob is never loaded here (card recall-scale-row-load).
849
+ const allRows = m
850
+ .prepare(
851
+ "SELECT id, created, confirmed, scope, embedding_model, embedding_dims, embedding IS NOT NULL AS has_vec FROM lessons WHERE superseded_by IS NULL AND stale IS NULL"
852
+ )
853
+ .all()
854
+ .map(lessonRowToObject);
855
+ const hasVec = (l) => !!l.has_vec;
806
856
  const freshRows = allRows.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
807
857
  const byId = new Map(freshRows.map((l) => [l.id, l]));
808
858
 
@@ -820,16 +870,24 @@ export async function recallLessons(m, query, opts = {}) {
820
870
  bm25Ranked = rows.map((r) => r.id).filter((id) => byId.has(id));
821
871
  }
822
872
 
823
- // (c) vector side, only with a provider and rows stored under the same model+dims, top 50.
873
+ // (c) dense side, only with a provider and rows stored under the same model+dims: cosine (dot of the stored
874
+ // vectors) for every such row, not only BM25 hits.
824
875
  let mode = "keyword-only";
825
876
  let reason = "no provider configured";
826
877
  let providerName = null;
827
- let vectorRanked = [];
878
+ const cosine = new Map();
828
879
  if (provider) {
829
880
  providerName = provider.name;
830
- const vectorRows = freshRows.filter(
831
- (l) => l.embedding && l.embedding_model === provider.model && l.embedding_dims === provider.dims
832
- );
881
+ // Blobs are read only when the light columns say a fresh row holds one under this model/dims.
882
+ const anyMatching = freshRows.some((l) => hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims);
883
+ const vectorRows = !anyMatching
884
+ ? []
885
+ : m
886
+ .prepare(
887
+ "SELECT id, embedding FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ?"
888
+ )
889
+ .all(provider.model, provider.dims)
890
+ .filter((l) => byId.has(l.id));
833
891
  if (!vectorRows.length) {
834
892
  reason = `no vectors stored for ${provider.model}/${provider.dims}`;
835
893
  } else {
@@ -838,49 +896,83 @@ export async function recallLessons(m, query, opts = {}) {
838
896
  reason = embedResult.reason;
839
897
  } else {
840
898
  const qVec = embedResult.vectors[0];
841
- const scored = vectorRows.map((l) => {
899
+ for (const l of vectorRows) {
842
900
  const vec = blobToFloats(l.embedding);
843
901
  let dot = 0;
844
902
  for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
845
- return { id: l.id, score: dot };
846
- });
847
- scored.sort((a, b) => b.score - a.score);
848
- vectorRanked = scored.slice(0, 50).map((s) => s.id);
903
+ cosine.set(l.id, dot);
904
+ }
849
905
  mode = "hybrid";
850
906
  reason = null;
851
907
  }
852
908
  }
853
909
  }
854
910
 
855
- // (d) reciprocal rank fusion, 1/(60+rank) per side, rank starting at 1.
856
- const fused = new Map();
857
- bm25Ranked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
858
- vectorRanked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
911
+ // card recall-fallback-warning: keyword-only is degraded when a provider was given, vectors exist for any
912
+ // model, or the caller detected a usable provider; hybrid reports how many live in-window rows lack a vector.
913
+ // The existence check is bounded and only runs when no other term already decided it.
914
+ const hasAnyVector = (db) =>
915
+ !!db.prepare("SELECT 1 AS x FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL LIMIT 1").get();
916
+ const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
917
+ const coverage = {};
918
+ if (mode === "hybrid") {
919
+ coverage.total = freshRows.length;
920
+ coverage.unvectored = freshRows.filter(
921
+ (l) => !(hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims)
922
+ ).length;
923
+ }
859
924
 
860
- // (e) recency weight, (f) scope boost.
861
- const candidates = [];
862
- for (const [id, rrfScore] of fused.entries()) {
863
- const lesson = byId.get(id);
864
- if (!lesson) continue;
925
+ // (d) scores: recency weight, then scope boost. Dense mode scores cosine; the BM25 order scores 1/(60+rank).
926
+ const bm25Rank = new Map(bm25Ranked.map((id, idx) => [id, idx]));
927
+ const weigh = (lesson, base) => {
865
928
  const ageDays = ageDaysOf(lesson.confirmed || lesson.created, nowMs);
866
- const recency = 0.5 + 0.5 * Math.exp(-ageDays / 90);
867
- let score = rrfScore * recency;
929
+ let score = base * (0.5 + 0.5 * Math.exp(-ageDays / 90));
868
930
  if (files.length && (lesson.scope.length === 0 || scopeMatchesFiles(lesson.scope, files))) score *= 1.5;
931
+ return score;
932
+ };
933
+ const byScoreThenId = (a, b) => b.score - a.score || (a.lesson.id < b.lesson.id ? -1 : a.lesson.id > b.lesson.id ? 1 : 0);
934
+ const sidesOf = (id) => {
869
935
  const sides = [];
870
- if (bm25Ranked.includes(id)) sides.push("bm25");
871
- if (vectorRanked.includes(id)) sides.push("vector");
872
- candidates.push({ lesson, score, sides });
936
+ if (bm25Rank.has(id)) sides.push("bm25");
937
+ if (cosine.has(id)) sides.push("vector");
938
+ return sides;
939
+ };
940
+ let candidates;
941
+ if (mode === "hybrid") {
942
+ // (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
943
+ const dense = [...cosine.entries()].map(([id, dot]) => {
944
+ const lesson = byId.get(id);
945
+ return { lesson, score: weigh(lesson, dot), sides: sidesOf(id) };
946
+ });
947
+ dense.sort(byScoreThenId);
948
+ const appended = bm25Ranked
949
+ .filter((id) => !cosine.has(id))
950
+ .map((id) => ({ lesson: byId.get(id), score: weigh(byId.get(id), 1 / (60 + bm25Rank.get(id) + 1)), sides: sidesOf(id) }));
951
+ candidates = [...dense, ...appended];
952
+ } else {
953
+ candidates = bm25Ranked.map((id) => ({ lesson: byId.get(id), score: weigh(byId.get(id), 1 / (60 + bm25Rank.get(id) + 1)), sides: sidesOf(id) }));
954
+ candidates.sort(byScoreThenId);
873
955
  }
874
- candidates.sort((a, b) => b.score - a.score);
875
956
 
876
957
  // (g) top k. With a real project root, each candidate's citation is re-checked: a failure marks the row
877
958
  // stale and drops it, backfilling from the next candidate so k are returned when enough candidates exist,
878
959
  // and a pass sets confirmed. Without one (checked: false), candidates are taken as-is — nothing is marked
879
960
  // stale or confirmed, since there is nothing trustworthy to check them against.
961
+ // Every lessons column except the blob, derived from the live schema so a later column cannot drop out.
962
+ const loadCols = m
963
+ .prepare("PRAGMA table_info(lessons)")
964
+ .all()
965
+ .map((c) => c.name)
966
+ .filter((n) => n !== "embedding")
967
+ .join(", ");
968
+ const loadById = m.prepare(`SELECT ${loadCols}, embedding IS NOT NULL AS has_vec FROM lessons WHERE id = ?`);
880
969
  const result = [];
881
970
  let idx = 0;
882
971
  while (result.length < k && idx < candidates.length) {
883
972
  const cand = candidates[idx++];
973
+ // Every column except the blob (and the model/dims toRecallLesson strips anyway); hasVector comes from has_vec.
974
+ const { has_vec, ...full } = loadById.get(cand.lesson.id);
975
+ cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
884
976
  if (!checked) {
885
977
  result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
886
978
  continue;
@@ -894,7 +986,176 @@ export async function recallLessons(m, query, opts = {}) {
894
986
  result.push(toRecallLesson({ ...cand.lesson, confirmed: nowIsoStr }, { score: cand.score, sides: cand.sides }));
895
987
  }
896
988
 
897
- return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, lessons: result };
989
+ return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, lessons: result };
990
+ }
991
+
992
+ // Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
993
+ // (LESSON_CHAR_BUDGET): text over budget is cut at the last sentence boundary (". ", "! ", "? ", or a newline)
994
+ // within it, falling back to the last word boundary, then a hard cut, with an ellipsis ("…") appended; the
995
+ // citation (or kind, when there is none) stays on the line either way. A sentence/newline boundary this close
996
+ // to the start (MIN_CUT_KEPT) keeps too little to be worth it — a lesson opening with a heading-then-newline,
997
+ // or a stray early period, would otherwise collapse to almost nothing — so that case falls through to the word
998
+ // boundary (then the hard cut) instead, same as when no such boundary exists at all. That keeps any one lesson
999
+ // — the memory store holds whole files up to ~12k characters, not just short summaries — from ever crowding
1000
+ // out the rest: the running total is kept under LESSONS_CHAR_CAP by skipping (not stopping at) a line that
1001
+ // would overflow it, so a later, shorter lesson still renders even after an earlier one didn't fit. lessonIds
1002
+ // carries exactly the ids of the lines rendered. { text: null, ids: [] } when nothing rendered, the caller's
1003
+ // cue to omit the fields entirely.
1004
+ const LESSONS_CHAR_CAP = 2000;
1005
+ const LESSON_CHAR_BUDGET = 400;
1006
+ const MIN_CUT_KEPT = 40;
1007
+ function cutLessonText(text, budget) {
1008
+ if (text.length <= budget) return text;
1009
+ const slice = text.slice(0, budget);
1010
+ let cut = -1;
1011
+ for (const sep of [". ", "! ", "? "]) {
1012
+ const idx = slice.lastIndexOf(sep);
1013
+ if (idx !== -1) cut = Math.max(cut, idx + sep.length - 1);
1014
+ }
1015
+ const nl = slice.lastIndexOf("\n");
1016
+ if (nl !== -1) cut = Math.max(cut, nl);
1017
+ if (cut < MIN_CUT_KEPT) cut = -1;
1018
+ if (cut === -1) {
1019
+ const sp = slice.lastIndexOf(" ");
1020
+ cut = sp !== -1 ? sp : budget;
1021
+ }
1022
+ return text.slice(0, cut).trimEnd() + "…";
1023
+ }
1024
+ export function renderLessons(lessons) {
1025
+ const text = [];
1026
+ const ids = [];
1027
+ let length = 0;
1028
+ for (const lesson of Array.isArray(lessons) ? lessons : []) {
1029
+ const line = `- [${lesson.id}] ${cutLessonText(lesson.text, LESSON_CHAR_BUDGET)} (${lesson.citation || lesson.kind})`;
1030
+ const nextLength = length + (text.length ? 1 : 0) + line.length;
1031
+ if (nextLength > LESSONS_CHAR_CAP) continue;
1032
+ text.push(line);
1033
+ ids.push(lesson.id);
1034
+ length = nextLength;
1035
+ }
1036
+ return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
1037
+ }
1038
+
1039
+ // The lessons a session starts with when no card query exists (card memory-every-session): live, non-stale
1040
+ // lessons confirmed-or-created within staleDays, ordered by helpful minus harmful desc, then confirmed-or-created
1041
+ // desc, then id ascending. No provider, no citation re-check, nothing written.
1042
+ export function topLessons(m, { k = 5, staleDays = 30, now = new Date() } = {}) {
1043
+ const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
1044
+ const rows = m
1045
+ .prepare(
1046
+ `SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
1047
+ helpful, harmful, superseded_by, stale
1048
+ FROM lessons WHERE superseded_by IS NULL AND stale IS NULL`
1049
+ )
1050
+ .all()
1051
+ .map(lessonRowToObject)
1052
+ .filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
1053
+ const stamp = (l) => new Date(l.confirmed || l.created).getTime() || 0;
1054
+ rows.sort((a, b) => b.helpful - b.harmful - (a.helpful - a.harmful) || stamp(b) - stamp(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1055
+ return rows.slice(0, k);
1056
+ }
1057
+
1058
+ // --- Degraded-recall warnings (card recall-fallback-warning) ------------------------------------------------
1059
+ // The one place that words a degraded recall, for memory.mjs recall / index search and plan.mjs json alike.
1060
+ // `result` is a recallLessons or searchIndex result; `what` is "memory recall" or "code index search"; `code`
1061
+ // picks the chunk wording and the `index build` fix. Returns the stderr line (no newline) or null.
1062
+ export function degradedWarning(result, { what = "memory recall", code = false, detectedProvider = null } = {}) {
1063
+ const noun = code ? "chunks" : "lessons";
1064
+ const rebuild = code ? "run memory.mjs index build" : "run memory.mjs reembed";
1065
+ if (result.mode === "hybrid") {
1066
+ if (result.unvectored > 0) return `WARNING: ${result.unvectored} of ${result.total} ${noun} have no vector; ${rebuild}`;
1067
+ return null;
1068
+ }
1069
+ if (!result.degraded) return null;
1070
+ const reason = result.reason;
1071
+ let fix;
1072
+ let shown = reason;
1073
+ if (reason === "no provider configured") {
1074
+ if (detectedProvider) {
1075
+ shown = `no provider configured, but ${detectedProvider} is available`;
1076
+ fix = "run memory.mjs doctor to enable it";
1077
+ } else {
1078
+ fix = "configure memory.embeddings (memory.mjs doctor)";
1079
+ }
1080
+ } else if (typeof reason === "string" && reason.startsWith("no vectors stored for ")) {
1081
+ fix = rebuild;
1082
+ } else if (reason === "timeout") {
1083
+ fix = "run memory.mjs doctor (a cold model can time out on first use; retry)";
1084
+ } else {
1085
+ fix = "run memory.mjs doctor";
1086
+ }
1087
+ return `WARNING: ${what} degraded to keyword-only: ${shown}; ${fix}`;
1088
+ }
1089
+
1090
+ // The reason shown in a task prompt block when a recall was degraded: the same wording as the warning's reason
1091
+ // part, including the detected-provider form.
1092
+ export function degradedReason(result, detectedProvider = null) {
1093
+ if (result.reason === "no provider configured" && detectedProvider) return `no provider configured, but ${detectedProvider} is available`;
1094
+ return result.reason;
1095
+ }
1096
+
1097
+ // One probe for a usable local embedding provider, for a caller whose config has none. DOUG_OLLAMA_BASE_URL
1098
+ // overrides the default base URL (http://localhost:11434). Returns a short description ("ollama embeddinggemma
1099
+ // at <url>") or null: a failed or negative probe is "not detected", never an error. Ollama only, GET only:
1100
+ // /api/version then /api/tags under ONE shared 1500 ms deadline (never an embed call, never a POST). The model
1101
+ // name matching mirrors embeddings.mjs's modelNameMatches (exact, or the part before ":" on either side); that
1102
+ // helper is not exported and this card may not edit embeddings.mjs, so the rule is repeated here.
1103
+ export async function detectLocalProvider(opts = {}) {
1104
+ try {
1105
+ const baseUrl = process.env.DOUG_OLLAMA_BASE_URL || "http://localhost:11434";
1106
+ const root = baseUrl.replace(/\/$/, "");
1107
+ const want = "embeddinggemma";
1108
+ const base = (n) => String(n ?? "").split(":")[0];
1109
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 1500);
1110
+ const get = opts.fetch ? (url) => fetchGet(opts.fetch, url, signal) : (url) => httpGet(url, signal);
1111
+ const version = await get(`${root}/api/version`);
1112
+ if (!version.ok) return null;
1113
+ const tags = await get(`${root}/api/tags`);
1114
+ if (!tags.ok) return null;
1115
+ const json = JSON.parse(tags.body);
1116
+ const models = Array.isArray(json && json.models) ? json.models : [];
1117
+ const present = models.some((mo) => mo && [mo.name, mo.model].some((n) => n !== undefined && n !== null && (n === want || base(n) === want)));
1118
+ return present ? `ollama ${want} at ${baseUrl}` : null;
1119
+ } catch {
1120
+ return null;
1121
+ }
1122
+ }
1123
+
1124
+ async function fetchGet(fetchImpl, url, signal) {
1125
+ const res = await fetchImpl(url, { signal, headers: { Connection: "close" } });
1126
+ if (!res.ok) {
1127
+ await res.body?.cancel?.().catch(() => {});
1128
+ return { ok: false, body: "" };
1129
+ }
1130
+ return { ok: true, body: await res.text() };
1131
+ }
1132
+
1133
+ // node:http rather than fetch: aborting a fetch whose TCP connect is still pending leaves the connect attempt
1134
+ // (and so the process) alive for ~10 s against an unroutable host; destroying a node:http request tears the
1135
+ // socket down at once, so the CLI exits right after the deadline.
1136
+ function httpGet(url, signal) {
1137
+ return new Promise((resolveP, rejectP) => {
1138
+ const mod = url.startsWith("https:") ? httpsRequest : httpRequest;
1139
+ const req = mod(url, { method: "GET", headers: { Connection: "close" } }, (res) => {
1140
+ if (res.statusCode < 200 || res.statusCode >= 300) {
1141
+ res.destroy();
1142
+ resolveP({ ok: false, body: "" });
1143
+ return;
1144
+ }
1145
+ const chunks = [];
1146
+ res.on("data", (c) => chunks.push(c));
1147
+ res.on("end", () => resolveP({ ok: true, body: Buffer.concat(chunks).toString("utf8") }));
1148
+ res.on("error", rejectP);
1149
+ });
1150
+ const onAbort = () => {
1151
+ req.destroy();
1152
+ rejectP(new Error("timeout"));
1153
+ };
1154
+ if (signal.aborted) return onAbort();
1155
+ signal.addEventListener("abort", onAbort, { once: true });
1156
+ req.on("error", rejectP);
1157
+ req.end();
1158
+ });
898
1159
  }
899
1160
 
900
1161
  function taskAdversary(task, level) {
@@ -1243,14 +1504,25 @@ function reflectionRowToObject(row) {
1243
1504
  };
1244
1505
  }
1245
1506
 
1246
- // Deterministic, no LLM: for each task whose memoryUsed is non-empty, a real block bumps harmful on every used
1247
- // lesson that exists (real wins even when the task also verified), else a first-verified task bumps helpful;
1248
- // neither verified nor real does nothing. An id naming no lesson is reported in `unknown`, never an error.
1249
- // apply:false (a dry run's preview) computes the same result without writing anything.
1250
- function computeReflectCounters(m, blocks, report, { apply }) {
1507
+ // Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
1508
+ // `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
1509
+ // named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
1510
+ // no helpful (real wins even when the task also verified), else a first-verified task bumps helpful on every used
1511
+ // lesson not named harmful; neither verified nor real does nothing. An id naming no lesson is reported in
1512
+ // `unknown`, never an error. apply:false (a dry run's preview) computes the same result without writing anything.
1513
+ function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
1251
1514
  const helpful = [];
1252
1515
  const harmful = [];
1253
1516
  const unknown = [];
1517
+ const named = new Set(harmfulIds);
1518
+ for (const id of named) {
1519
+ if (getLesson(m, id)) {
1520
+ if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
1521
+ harmful.push(id);
1522
+ } else {
1523
+ unknown.push(id);
1524
+ }
1525
+ }
1254
1526
  for (const level of report.levels || []) {
1255
1527
  for (const task of level.tasks || []) {
1256
1528
  const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
@@ -1258,15 +1530,11 @@ function computeReflectCounters(m, blocks, report, { apply }) {
1258
1530
  const real = blocks.some((b) => b.task === task.id && b.class === "real");
1259
1531
  if (real) {
1260
1532
  for (const id of used) {
1261
- if (getLesson(m, id)) {
1262
- if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
1263
- harmful.push(id);
1264
- } else {
1265
- unknown.push(id);
1266
- }
1533
+ if (!getLesson(m, id) && !named.has(id)) unknown.push(id);
1267
1534
  }
1268
1535
  } else if (task.verified === true) {
1269
1536
  for (const id of used) {
1537
+ if (named.has(id)) continue;
1270
1538
  if (getLesson(m, id)) {
1271
1539
  if (apply) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(id);
1272
1540
  helpful.push(id);
@@ -1343,9 +1611,41 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
1343
1611
  } else {
1344
1612
  lines.push("(none yet)");
1345
1613
  }
1614
+ if (report.hand && typeof report.hand === "object") appendHandMaterial(lines, report.hand);
1346
1615
  return lines.join("\n") + "\n";
1347
1616
  }
1348
1617
 
1618
+ // A hand landing's saved material (card memory-every-session): each file capped at HAND_FILE_CHAR_CAP, all of it at
1619
+ // HAND_TOTAL_CHAR_CAP, the landing note last so a budget spent on files still leaves the note.
1620
+ const HAND_FILE_CHAR_CAP = 4000;
1621
+ const HAND_TOTAL_CHAR_CAP = 16000;
1622
+ const HAND_TRUNCATED = "\u2026[truncated]";
1623
+
1624
+ function appendHandMaterial(lines, hand) {
1625
+ lines.push("");
1626
+ lines.push("Hand-track material (briefs, reviewer reports, landing note; the rules above still apply, cite report:<card> or report:<card>/R<n>):");
1627
+ const note = typeof hand.note === "string" && hand.note.trim() ? hand.note : null;
1628
+ const cutNote = note && note.length > HAND_FILE_CHAR_CAP ? note.slice(0, HAND_FILE_CHAR_CAP) + HAND_TRUNCATED : note;
1629
+ let budget = HAND_TOTAL_CHAR_CAP - (cutNote ? cutNote.length : 0);
1630
+ const add = (label, files) => {
1631
+ for (const f of Array.isArray(files) ? files : []) {
1632
+ if (!f || typeof f.text !== "string") continue;
1633
+ if (budget <= 0) break;
1634
+ let text = f.text.length > HAND_FILE_CHAR_CAP ? f.text.slice(0, HAND_FILE_CHAR_CAP) + HAND_TRUNCATED : f.text;
1635
+ if (text.length > budget) text = text.slice(0, budget) + HAND_TRUNCATED;
1636
+ budget -= text.length;
1637
+ lines.push(`--- ${label} ${f.name || ""} ---`);
1638
+ lines.push(text);
1639
+ }
1640
+ };
1641
+ add("brief", hand.briefs);
1642
+ add("reviewer report", hand.reviews);
1643
+ if (cutNote) {
1644
+ lines.push("--- landing note ---");
1645
+ lines.push(cutNote);
1646
+ }
1647
+ }
1648
+
1349
1649
  // Citation filter (C.2): a "report:" citation is checked against `report` (never unchecked here, unlike
1350
1650
  // checkCitation's default), a path:line citation against `dir` via checkCitation; anything else — free text, an
1351
1651
  // empty string, a report: citation shaped wrong — is rejected outright.
@@ -1465,7 +1765,7 @@ function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
1465
1765
  if (existsSync(file)) {
1466
1766
  try {
1467
1767
  const t = transcriptUsage(file);
1468
- const usd = t.model ? priceUsage(t.usage, t.model) : null;
1768
+ const usd = t.usd;
1469
1769
  if (usd !== null) return { usd, usdSource: "transcript" };
1470
1770
  } catch {
1471
1771
  // fall through to the envelope's own number
@@ -1494,6 +1794,7 @@ export async function runReflect(m, report, opts = {}) {
1494
1794
  model = null,
1495
1795
  claudeDir,
1496
1796
  dryRun = false,
1797
+ harmful = [],
1497
1798
  propose,
1498
1799
  now = nowIso(),
1499
1800
  } = opts;
@@ -1507,13 +1808,30 @@ export async function runReflect(m, report, opts = {}) {
1507
1808
  // happen before any write, so it is the very first thing after the idempotency check.
1508
1809
  const blocks = classifyBlocks(report, classes);
1509
1810
 
1811
+ // A harmful id must be one a task recalled; refused before any write, naming it and the recalled ids.
1812
+ const harmfulIds = [...new Set(harmful)];
1813
+ if (harmfulIds.length) {
1814
+ const recalled = new Set();
1815
+ for (const level of report.levels || []) {
1816
+ for (const task of level.tasks || []) {
1817
+ if (Array.isArray(task.memoryUsed)) for (const id of task.memoryUsed) recalled.add(id);
1818
+ }
1819
+ }
1820
+ const outside = harmfulIds.filter((id) => !recalled.has(id));
1821
+ if (outside.length) {
1822
+ throw new Error(
1823
+ `--harmful id(s) not in any task's memoryUsed: ${outside.join(", ")}. memoryUsed ids: ${recalled.size ? [...recalled].join(", ") : "(none)"}.`
1824
+ );
1825
+ }
1826
+ }
1827
+
1510
1828
  let counters;
1511
1829
  if (dryRun) {
1512
- counters = computeReflectCounters(m, blocks, report, { apply: false });
1830
+ counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
1513
1831
  } else {
1514
1832
  m.exec("BEGIN");
1515
1833
  try {
1516
- counters = computeReflectCounters(m, blocks, report, { apply: true });
1834
+ counters = computeReflectCounters(m, blocks, report, { apply: true, harmfulIds });
1517
1835
  m.prepare(
1518
1836
  `INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
1519
1837
  VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
@@ -42,7 +42,8 @@ export function crewOf(plan, task) {
42
42
  // feature, never more than eight. The validator warns; it does not refuse, since the planner may have a reason.
43
43
  export const FEATURE_TASKS = 5;
44
44
  export const TASK_CAP = 8;
45
- export const DEFAULT_BUDGET = { agents: 12, tokens: 400000, wallMinutes: 40 };
45
+ // agents 26 is the minimum that lets the default fixAttempts 5 run with one adversary seat when Codex is down and the Claude fallback runs on every pass (card flow-budget-after-parallel-adversary); a supervisor seat or a fix retry is not covered.
46
+ export const DEFAULT_BUDGET = { agents: 26, tokens: 400000, wallMinutes: 40 };
46
47
 
47
48
  export function loadPlan(dir, relpath = PLAN_RELPATH) {
48
49
  const file = join(dir, relpath);
@@ -559,6 +560,15 @@ export function planWarnings(plan) {
559
560
  else if (n > FEATURE_TASKS) out.push(`${n} tasks: a feature is three to five; merge tasks that share a file group or split the plan`);
560
561
  const files = new Set(plan.tasks.flatMap((t) => (Array.isArray(t.files) ? t.files : [])));
561
562
  if (n > 1 && files.size === 1) out.push(`${n} tasks own the same single file (${[...files][0]}): single-file work is one task`);
563
+ const verifyOwners = [];
564
+ for (const v of Array.isArray(plan.verify) ? plan.verify : []) if (typeof v === "string" && v.trim()) verifyOwners.push([v.trim(), "plan.verify"]);
565
+ for (const t of plan.tasks) if (t && typeof t.verify === "string" && t.verify.trim()) verifyOwners.push([t.verify.trim(), `task ${t.id} verify`]);
566
+ for (const a of Array.isArray(plan.acceptance) ? plan.acceptance : []) {
567
+ if (!a || typeof a !== "object" || typeof a.command !== "string" || !a.command.trim()) continue;
568
+ const cmd = a.command.trim();
569
+ const hit = verifyOwners.find(([c]) => c === cmd);
570
+ if (hit) out.push(`acceptance "${a.text}" command \`${cmd}\` repeats a verify command (${hit[1]}): every verifier, integrate, and land already run it; name a narrower check or make it prose`);
571
+ }
562
572
  return out;
563
573
  }
564
574
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/flow",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "private": true,
5
5
  "description": "Plan, approve, implement one task per worktree, verify, review: a Workflow-tool template and subagents for Claude Code.",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  "agents",
10
10
  "skills",
11
11
  "workflows",
12
+ "hooks",
12
13
  "scripts",
13
14
  "lib",
14
15
  ".claude-plugin"