@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.
- package/dist/bin.js +18 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +14 -7
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +42 -9
- package/dist/board.js.map +1 -1
- package/dist/detect/repo.js +14 -1
- package/dist/detect/repo.js.map +1 -1
- package/dist/detect/types.d.ts +2 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +491 -0
- package/dist/doctor.js.map +1 -0
- package/dist/generate/agents.d.ts +7 -0
- package/dist/generate/agents.js +62 -12
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +4 -0
- package/dist/generate/claude-md.js.map +1 -1
- package/dist/generate/config.d.ts +8 -0
- package/dist/generate/config.js +4 -1
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +15 -6
- package/dist/generate/proposal.js.map +1 -1
- package/dist/generate/settings.d.ts +2 -0
- package/dist/generate/settings.js +17 -2
- package/dist/generate/settings.js.map +1 -1
- package/dist/init.d.ts +14 -0
- package/dist/init.js +94 -8
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +28 -0
- package/dist/memory-setup.js +326 -0
- package/dist/memory-setup.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +3 -3
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
- package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
- package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
- package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
- package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
- package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
- package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
- package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
- package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
- package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
- package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
- package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
- package/package.json +4 -4
- package/templates/board-app.js +4 -3
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
|
@@ -15,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,
|
|
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
|
-
// ---
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
878
|
+
const cosine = new Map();
|
|
828
879
|
if (provider) {
|
|
829
880
|
providerName = provider.name;
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
// (
|
|
861
|
-
const
|
|
862
|
-
|
|
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
|
-
|
|
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 (
|
|
871
|
-
if (
|
|
872
|
-
|
|
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
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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"
|