@dougharness/cli 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
- package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
- package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// can supersede a lesson (superseded_by) but never delete or rewrite its text — two triggers on the table enforce
|
|
12
12
|
// that in the database itself, not just in this module's API.
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
|
-
import {
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { mkdirSync, existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
15
16
|
import { dirname, resolve, join } from "node:path";
|
|
16
17
|
import { createHash } from "node:crypto";
|
|
17
18
|
import { homedir } from "node:os";
|
|
@@ -669,6 +670,11 @@ function floatsToBlob(vector) {
|
|
|
669
670
|
function blobToFloats(blob) {
|
|
670
671
|
if (!blob) return null;
|
|
671
672
|
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
|
673
|
+
// A 4-aligned view over the row's own buffer needs no copy; any other offset or length keeps the copy (a
|
|
674
|
+
// misaligned Float32Array view throws, and so does a length that is not a multiple of 4, as before).
|
|
675
|
+
if (bytes.byteOffset % 4 === 0 && bytes.byteLength % 4 === 0) {
|
|
676
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
|
677
|
+
}
|
|
672
678
|
const copy = new Uint8Array(bytes.length);
|
|
673
679
|
copy.set(bytes);
|
|
674
680
|
return new Float32Array(copy.buffer);
|
|
@@ -859,6 +865,166 @@ function toRecallLesson(lesson, extra) {
|
|
|
859
865
|
return { ...rest, hasVector: !!embedding, ...extra };
|
|
860
866
|
}
|
|
861
867
|
|
|
868
|
+
// --- Recall row cache (card recall-hybrid-scale) ----------------------------------------------------------------
|
|
869
|
+
// The dense side of recallLessons needs every live lesson's recency anchor, scope and (under the provider's
|
|
870
|
+
// model/dims) vector. Decoding them is the cost of a hybrid recall at scale, so the decoded rows are kept in
|
|
871
|
+
// module scope per database file, model and dims, and reused while the database is unchanged. "Unchanged" is
|
|
872
|
+
// PRAGMA data_version (moves when ANOTHER connection commits) together with total_changes() (this connection's own
|
|
873
|
+
// row changes); both are per connection, so an entry belongs to the connection that built it and any other
|
|
874
|
+
// connection rebuilds. Bounded: RECALL_CACHE_MAX_ENTRIES entries and RECALL_CACHE_MAX_BYTES of vectors in all,
|
|
875
|
+
// oldest evicted first, an entry larger than the byte bound never stored (100,000 x 768 floats is about 300 MB).
|
|
876
|
+
const RECALL_CACHE_MAX_ENTRIES = 3;
|
|
877
|
+
const RECALL_CACHE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
878
|
+
const RECALL_WINDOW_MARGIN_DAYS = 2;
|
|
879
|
+
const recallCache = new Map();
|
|
880
|
+
|
|
881
|
+
export function clearRecallCache() {
|
|
882
|
+
recallCache.clear();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function recallSignature(m) {
|
|
886
|
+
return {
|
|
887
|
+
dv: m.prepare("PRAGMA data_version").get().data_version,
|
|
888
|
+
tc: m.prepare("SELECT total_changes() AS c").get().c,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// SQL for "this row is certainly outside the window" (1) or "keep it" (0, also for NULL): the anchor is JS's
|
|
893
|
+
// `confirmed || created` (NULLIF drops the empty string), it must be a whole ISO form both SQLite and JS read
|
|
894
|
+
// (date, date-time, optional .mmm, optional Z; real calendar date, hour < 24, minute and second < 60; checked by
|
|
895
|
+
// a fuzz of 60,000 odd strings against ageDaysOf) and older than the cutoff. The first term is a cheap string
|
|
896
|
+
// comparison that every such row passes (ISO text sorts as time), so the costly date functions run only on rows
|
|
897
|
+
// that look old; its parameter is the day after the cutoff's date.
|
|
898
|
+
const SQL_ANCHOR = "COALESCE(NULLIF(confirmed, ''), created)";
|
|
899
|
+
const SQL_ISO_FORMS = (() => {
|
|
900
|
+
const d = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]";
|
|
901
|
+
const t = "T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]";
|
|
902
|
+
return [d, d + t, d + t + "Z", d + t + ".[0-9][0-9][0-9]", d + t + ".[0-9][0-9][0-9]Z"]
|
|
903
|
+
.map((f) => `${SQL_ANCHOR} GLOB '${f}'`)
|
|
904
|
+
.join(" OR ");
|
|
905
|
+
})();
|
|
906
|
+
const SQL_OUTSIDE_WINDOW = `IFNULL(${SQL_ANCHOR} < ?
|
|
907
|
+
AND (${SQL_ISO_FORMS})
|
|
908
|
+
AND date(${SQL_ANCHOR}) = substr(${SQL_ANCHOR}, 1, 10)
|
|
909
|
+
AND (length(${SQL_ANCHOR}) = 10 OR (substr(${SQL_ANCHOR}, 12, 2) < '24' AND substr(${SQL_ANCHOR}, 15, 2) < '60' AND substr(${SQL_ANCHOR}, 18, 2) < '60'))
|
|
910
|
+
AND julianday(${SQL_ANCHOR}) < ?, 0)`;
|
|
911
|
+
|
|
912
|
+
// The julian-day cutoff below which recallRows may drop a row in SQL. Margin: SQLite reads a zone-less
|
|
913
|
+
// date-time as UTC and JS as local time, so a row is only dropped when it is clearly outside the window.
|
|
914
|
+
function recallCutoffJd(nowMs, staleDays) {
|
|
915
|
+
const jd = nowMs / 86400000 + 2440587.5 - staleDays - RECALL_WINDOW_MARGIN_DAYS;
|
|
916
|
+
return Number.isFinite(jd) ? jd : -1e18;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Live rows with decoded vectors for provider.model/provider.dims. The SQL window drops only a row whose
|
|
920
|
+
// confirmed-or-created is a plain ISO date SQLite and JS agree on and that is older than the cutoff; every other
|
|
921
|
+
// row (unparseable text, forms only JS parses, a date within the margin) is kept and the JS rule in recallLessons
|
|
922
|
+
// decides, so the window is exactly ageDaysOf's.
|
|
923
|
+
function recallRows(m, provider, nowMs, staleDays, sig) {
|
|
924
|
+
const cutoffJd = recallCutoffJd(nowMs, staleDays);
|
|
925
|
+
const key = m.file ? `${m.file}\u0000${provider.model}\u0000${provider.dims}` : null;
|
|
926
|
+
const hit = key ? recallCache.get(key) : null;
|
|
927
|
+
if (hit && hit.owner === m && hit.sig.dv === sig.dv && hit.sig.tc === sig.tc && !Number.isNaN(staleDays) && hit.cutoffJd <= cutoffJd) return hit;
|
|
928
|
+
if (hit) recallCache.delete(key);
|
|
929
|
+
|
|
930
|
+
// No live row holds a vector under this model/dims: nothing to decode, and no blob is read.
|
|
931
|
+
const any = m
|
|
932
|
+
.prepare(
|
|
933
|
+
"SELECT 1 AS x FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ? LIMIT 1"
|
|
934
|
+
)
|
|
935
|
+
.get(provider.model, provider.dims);
|
|
936
|
+
if (!any) return null;
|
|
937
|
+
|
|
938
|
+
const stmt = m.prepare(
|
|
939
|
+
`SELECT id, created, confirmed, scope,
|
|
940
|
+
CASE WHEN embedding_model = ? AND embedding_dims = ? THEN embedding END AS emb
|
|
941
|
+
FROM lessons
|
|
942
|
+
WHERE superseded_by IS NULL AND stale IS NULL AND ${SQL_OUTSIDE_WINDOW} = 0`
|
|
943
|
+
);
|
|
944
|
+
const ids = [];
|
|
945
|
+
const anchorMs = [];
|
|
946
|
+
const scopeRaw = [];
|
|
947
|
+
const vecs = [];
|
|
948
|
+
let bytes = 0;
|
|
949
|
+
const cutoffMs = (cutoffJd - 2440587.5) * 86400000;
|
|
950
|
+
const looksOld = Number.isFinite(cutoffMs) && cutoffMs > -6e13 && cutoffMs < 8e15 ? new Date(cutoffMs + 86400000).toISOString().slice(0, 10) : "0000-00-00";
|
|
951
|
+
for (const r of stmt.iterate(provider.model, provider.dims, looksOld, cutoffJd)) {
|
|
952
|
+
ids.push(r.id);
|
|
953
|
+
anchorMs.push(new Date(r.confirmed || r.created).getTime());
|
|
954
|
+
scopeRaw.push(r.scope);
|
|
955
|
+
const vec = blobToFloats(r.emb);
|
|
956
|
+
vecs.push(vec);
|
|
957
|
+
if (vec) bytes += vec.byteLength;
|
|
958
|
+
}
|
|
959
|
+
const entry = {
|
|
960
|
+
owner: m,
|
|
961
|
+
sig,
|
|
962
|
+
cutoffJd,
|
|
963
|
+
n: ids.length,
|
|
964
|
+
ids,
|
|
965
|
+
anchorMs: Float64Array.from(anchorMs),
|
|
966
|
+
scopeRaw,
|
|
967
|
+
vecs,
|
|
968
|
+
live: new Uint8Array(ids.length).fill(1),
|
|
969
|
+
bytes,
|
|
970
|
+
};
|
|
971
|
+
if (key && bytes <= RECALL_CACHE_MAX_BYTES) {
|
|
972
|
+
recallCache.set(key, entry);
|
|
973
|
+
let total = 0;
|
|
974
|
+
for (const e of recallCache.values()) total += e.bytes;
|
|
975
|
+
for (const [k2, e] of recallCache) {
|
|
976
|
+
if (recallCache.size <= RECALL_CACHE_MAX_ENTRIES && total <= RECALL_CACHE_MAX_BYTES) break;
|
|
977
|
+
if (e === entry) continue;
|
|
978
|
+
recallCache.delete(k2);
|
|
979
|
+
total -= e.bytes;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return entry;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// recallLessons's own citation writes (stale mark, confirmed stamp) are applied to the entry it just used, and
|
|
986
|
+
// the entry takes the new signature, so a recall with a checkout does not throw its own cache away. If anything
|
|
987
|
+
// else changed the database meanwhile (data_version moved, or total_changes moved by more than these writes) the
|
|
988
|
+
// entry is dropped instead.
|
|
989
|
+
function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
|
|
990
|
+
const sigAfter = recallSignature(m);
|
|
991
|
+
const ours = written.reduce((n, w) => n + w.changes, 0);
|
|
992
|
+
if (sigAfter.dv !== sigBefore.dv || sigAfter.tc !== sigBefore.tc + ours || entry.owner !== m) {
|
|
993
|
+
for (const [k, e] of recallCache) if (e === entry) recallCache.delete(k);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const confirmedMs = new Date(confirmedIso).getTime();
|
|
997
|
+
for (const w of written) {
|
|
998
|
+
const i = entry.ids.indexOf(w.id);
|
|
999
|
+
if (i < 0) continue;
|
|
1000
|
+
if (w.stale) entry.live[i] = 0;
|
|
1001
|
+
else entry.anchorMs[i] = confirmedMs;
|
|
1002
|
+
}
|
|
1003
|
+
entry.sig = sigAfter;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// What the stale window hides (card recall-window-report): over live rows (not superseded, not stale), the
|
|
1007
|
+
// count outside the window by exactly ageDaysOf's rule, the live total, and the oldest hidden age in whole days.
|
|
1008
|
+
export function windowHidden(m, { staleDays = 30, now = new Date() } = {}) {
|
|
1009
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
1010
|
+
const rows = m.prepare("SELECT created, confirmed FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").all();
|
|
1011
|
+
let count = 0;
|
|
1012
|
+
let oldest = -Infinity;
|
|
1013
|
+
for (const r of rows) {
|
|
1014
|
+
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1015
|
+
if (age <= staleDays) continue;
|
|
1016
|
+
count++;
|
|
1017
|
+
if (age > oldest) oldest = age;
|
|
1018
|
+
}
|
|
1019
|
+
return { count, total: rows.length, oldestDays: count ? Math.floor(oldest) : null };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// The one line that says so; null when the window hid nothing.
|
|
1023
|
+
export function windowNote(hidden, staleDays = 30) {
|
|
1024
|
+
if (!hidden || !hidden.count) return null;
|
|
1025
|
+
return `window: hid ${hidden.count} of ${hidden.total} live lessons older than ${staleDays} days (oldest ${hidden.oldestDays} days)`;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
862
1028
|
// --- Dense-ranked recall -----------------------------------------------------------------------------------
|
|
863
1029
|
// With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
|
|
864
1030
|
// cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
|
|
@@ -876,51 +1042,60 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
876
1042
|
const nowMs = nowDate.getTime();
|
|
877
1043
|
const checked = isProjectRoot(checkoutDir);
|
|
878
1044
|
|
|
879
|
-
// (a)
|
|
880
|
-
//
|
|
881
|
-
const allRows = m
|
|
882
|
-
.prepare(
|
|
883
|
-
"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"
|
|
884
|
-
)
|
|
885
|
-
.all()
|
|
886
|
-
.map(lessonRowToObject);
|
|
887
|
-
const hasVec = (l) => !!l.has_vec;
|
|
888
|
-
const freshRows = allRows.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
|
|
889
|
-
const byId = new Map(freshRows.map((l) => [l.id, l]));
|
|
890
|
-
|
|
891
|
-
// (b) BM25 side, top 50.
|
|
1045
|
+
// (a)+(b) BM25 side, top 50: live (not superseded, not stale) hits, kept when confirmed-or-created is within
|
|
1046
|
+
// staleDays. The whole-table work below (the dense side) runs only with a provider (card recall-hybrid-scale).
|
|
892
1047
|
const match = ftsMatchQuery(query);
|
|
893
1048
|
let bm25Ranked = [];
|
|
1049
|
+
const bm25Info = new Map();
|
|
894
1050
|
if (match) {
|
|
895
1051
|
const rows = m
|
|
896
1052
|
.prepare(
|
|
897
|
-
`SELECT l.id
|
|
1053
|
+
`SELECT l.id, l.created, l.confirmed, l.scope,
|
|
1054
|
+
(l.embedding IS NOT NULL AND l.embedding_model = ? AND l.embedding_dims = ?) AS has_match
|
|
1055
|
+
FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
898
1056
|
WHERE lessons_fts MATCH ? AND l.superseded_by IS NULL AND l.stale IS NULL
|
|
899
1057
|
ORDER BY bm25(lessons_fts) LIMIT 50`
|
|
900
1058
|
)
|
|
901
|
-
.all(match);
|
|
902
|
-
|
|
1059
|
+
.all(provider ? provider.model : null, provider ? provider.dims : null, match);
|
|
1060
|
+
for (const r of rows) {
|
|
1061
|
+
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1062
|
+
if (!(age <= staleDays)) continue;
|
|
1063
|
+
bm25Ranked.push(r.id);
|
|
1064
|
+
bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age });
|
|
1065
|
+
}
|
|
903
1066
|
}
|
|
904
1067
|
|
|
905
1068
|
// (c) dense side, only with a provider and rows stored under the same model+dims: cosine (dot of the stored
|
|
906
|
-
// vectors) for every such row, not only BM25 hits.
|
|
1069
|
+
// vectors) for every such row, not only BM25 hits. The rows come from recallRows: decoded once per database,
|
|
1070
|
+
// model and dims and reused until a write (this connection's or another's) changes the database.
|
|
907
1071
|
let mode = "keyword-only";
|
|
908
1072
|
let reason = "no provider configured";
|
|
909
1073
|
let providerName = null;
|
|
910
|
-
|
|
1074
|
+
let rows = null;
|
|
1075
|
+
let ages = null;
|
|
1076
|
+
let cosine = null;
|
|
1077
|
+
let cosineScores = null;
|
|
1078
|
+
let freshTotal = 0;
|
|
1079
|
+
let freshVectored = 0;
|
|
1080
|
+
let sigBefore = null;
|
|
911
1081
|
if (provider) {
|
|
912
1082
|
providerName = provider.name;
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
.
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1083
|
+
sigBefore = recallSignature(m);
|
|
1084
|
+
rows = recallRows(m, provider, nowMs, staleDays, sigBefore);
|
|
1085
|
+
ages = new Float64Array(rows ? rows.n : 0);
|
|
1086
|
+
for (let i = 0; rows && i < rows.n; i++) {
|
|
1087
|
+
if (!rows.live[i]) continue;
|
|
1088
|
+
const t = rows.anchorMs[i];
|
|
1089
|
+
const age = Number.isFinite(t) ? (nowMs - t) / 86400000 : 0;
|
|
1090
|
+
ages[i] = age;
|
|
1091
|
+
if (age <= staleDays) {
|
|
1092
|
+
freshTotal++;
|
|
1093
|
+
if (rows.vecs[i]) freshVectored++;
|
|
1094
|
+
} else {
|
|
1095
|
+
ages[i] = Infinity; // out of the window; never a candidate
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (!freshVectored) {
|
|
924
1099
|
reason = `no vectors stored for ${provider.model}/${provider.dims}`;
|
|
925
1100
|
} else {
|
|
926
1101
|
const embedResult = await provider.embed([String(query || "")], { inputType: "query" });
|
|
@@ -928,11 +1103,15 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
928
1103
|
reason = embedResult.reason;
|
|
929
1104
|
} else {
|
|
930
1105
|
const qVec = embedResult.vectors[0];
|
|
931
|
-
|
|
932
|
-
|
|
1106
|
+
cosine = [];
|
|
1107
|
+
cosineScores = new Float64Array(rows.n);
|
|
1108
|
+
for (let r = 0; r < rows.n; r++) {
|
|
1109
|
+
const vec = rows.vecs[r];
|
|
1110
|
+
if (!vec || ages[r] === Infinity || !rows.live[r]) continue;
|
|
933
1111
|
let dot = 0;
|
|
934
1112
|
for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
|
|
935
|
-
|
|
1113
|
+
cosineScores[r] = dot;
|
|
1114
|
+
cosine.push(r);
|
|
936
1115
|
}
|
|
937
1116
|
mode = "hybrid";
|
|
938
1117
|
reason = null;
|
|
@@ -948,42 +1127,49 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
948
1127
|
const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
|
|
949
1128
|
const coverage = {};
|
|
950
1129
|
if (mode === "hybrid") {
|
|
951
|
-
coverage.total =
|
|
952
|
-
coverage.unvectored =
|
|
953
|
-
(l) => !(hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims)
|
|
954
|
-
).length;
|
|
1130
|
+
coverage.total = freshTotal;
|
|
1131
|
+
coverage.unvectored = freshTotal - freshVectored;
|
|
955
1132
|
}
|
|
956
1133
|
|
|
957
1134
|
// (d) scores: recency weight, then scope boost. Dense mode scores cosine; the BM25 order scores 1/(60+rank).
|
|
958
1135
|
const bm25Rank = new Map(bm25Ranked.map((id, idx) => [id, idx]));
|
|
959
|
-
const weigh = (
|
|
960
|
-
const ageDays = ageDaysOf(lesson.confirmed || lesson.created, nowMs);
|
|
1136
|
+
const weigh = (ageDays, scopeRaw, base) => {
|
|
961
1137
|
let score = base * (0.5 + 0.5 * Math.exp(-ageDays / 90));
|
|
962
|
-
if (files.length
|
|
1138
|
+
if (files.length) {
|
|
1139
|
+
const scope = parseJsonColumn(scopeRaw, []);
|
|
1140
|
+
if (scope.length === 0 || scopeMatchesFiles(scope, files)) score *= 1.5;
|
|
1141
|
+
}
|
|
963
1142
|
return score;
|
|
964
1143
|
};
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1144
|
+
const idCmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
1145
|
+
const byScoreThenId = (a, b) => b.score - a.score || idCmp(a.id, b.id);
|
|
1146
|
+
// Candidates in rank order, each { id, score, sides }, read through at(j) so the dense side never builds one
|
|
1147
|
+
// object per stored vector.
|
|
1148
|
+
const bm25Only = (id) => {
|
|
1149
|
+
const info = bm25Info.get(id);
|
|
1150
|
+
return { id, score: weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), sides: ["bm25"] };
|
|
971
1151
|
};
|
|
972
1152
|
let candidates;
|
|
973
1153
|
if (mode === "hybrid") {
|
|
974
1154
|
// (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
dense.
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1155
|
+
const order = Int32Array.from(cosine);
|
|
1156
|
+
const scores = new Float64Array(rows.n);
|
|
1157
|
+
for (const r of cosine) scores[r] = weigh(ages[r], rows.scopeRaw[r], cosineScores[r]);
|
|
1158
|
+
order.sort((x, y) => scores[y] - scores[x] || idCmp(rows.ids[x], rows.ids[y]));
|
|
1159
|
+
const dense = { length: order.length, at: (j) => {
|
|
1160
|
+
const r = order[j];
|
|
1161
|
+
const id = rows.ids[r];
|
|
1162
|
+
return { id, score: scores[r], sides: bm25Rank.has(id) ? ["bm25", "vector"] : ["vector"] };
|
|
1163
|
+
} };
|
|
1164
|
+
const appended = bm25Ranked.filter((id) => !bm25Info.get(id).hasMatch).map(bm25Only);
|
|
1165
|
+
candidates = {
|
|
1166
|
+
length: dense.length + appended.length,
|
|
1167
|
+
at: (j) => (j < dense.length ? dense.at(j) : appended[j - dense.length]),
|
|
1168
|
+
};
|
|
984
1169
|
} else {
|
|
985
|
-
|
|
986
|
-
|
|
1170
|
+
const list = bm25Ranked.map(bm25Only);
|
|
1171
|
+
list.sort(byScoreThenId);
|
|
1172
|
+
candidates = { length: list.length, at: (j) => list[j] };
|
|
987
1173
|
}
|
|
988
1174
|
|
|
989
1175
|
// (g) top k. With a real project root, each candidate's citation is re-checked: a failure marks the row
|
|
@@ -999,11 +1185,12 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
999
1185
|
.join(", ");
|
|
1000
1186
|
const loadById = m.prepare(`SELECT ${loadCols}, embedding IS NOT NULL AS has_vec FROM lessons WHERE id = ?`);
|
|
1001
1187
|
const result = [];
|
|
1188
|
+
const written = []; // each write below, so the cached rows can follow them
|
|
1002
1189
|
let idx = 0;
|
|
1003
1190
|
while (result.length < k && idx < candidates.length) {
|
|
1004
|
-
const cand = candidates
|
|
1191
|
+
const cand = candidates.at(idx++);
|
|
1005
1192
|
// Every column except the blob (and the model/dims toRecallLesson strips anyway); hasVector comes from has_vec.
|
|
1006
|
-
const { has_vec, ...full } = loadById.get(cand.
|
|
1193
|
+
const { has_vec, ...full } = loadById.get(cand.id);
|
|
1007
1194
|
cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
|
|
1008
1195
|
if (!checked) {
|
|
1009
1196
|
result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
|
|
@@ -1011,14 +1198,18 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1011
1198
|
}
|
|
1012
1199
|
const check = checkCitation(cand.lesson, checkoutDir);
|
|
1013
1200
|
if (!check.ok) {
|
|
1014
|
-
m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1201
|
+
const res = m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1202
|
+
written.push({ id: cand.lesson.id, stale: true, changes: Number(res.changes) });
|
|
1015
1203
|
continue;
|
|
1016
1204
|
}
|
|
1017
|
-
m.prepare("UPDATE lessons SET confirmed = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1205
|
+
const res = m.prepare("UPDATE lessons SET confirmed = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1206
|
+
written.push({ id: cand.lesson.id, stale: false, changes: Number(res.changes) });
|
|
1018
1207
|
result.push(toRecallLesson({ ...cand.lesson, confirmed: nowIsoStr }, { score: cand.score, sides: cand.sides }));
|
|
1019
1208
|
}
|
|
1209
|
+
if (rows && written.length) followOwnWrites(m, rows, written, nowIsoStr, sigBefore);
|
|
1020
1210
|
|
|
1021
|
-
|
|
1211
|
+
const hidden = windowHidden(m, { staleDays, now: nowDate });
|
|
1212
|
+
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, hidden, lessons: result };
|
|
1022
1213
|
}
|
|
1023
1214
|
|
|
1024
1215
|
// Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
|
|
@@ -1513,6 +1704,13 @@ export const REFLECT_SCHEMA = {
|
|
|
1513
1704
|
task: { type: "string" },
|
|
1514
1705
|
scope: { type: "array", items: { type: "string" } },
|
|
1515
1706
|
notDerivableBecause: { type: "string" },
|
|
1707
|
+
// Optional (card reflect-offers-claude-md): the CLAUDE.md bullet this lesson would be, only when it is a
|
|
1708
|
+
// rule a future session should follow.
|
|
1709
|
+
claudeMd: {
|
|
1710
|
+
type: "object",
|
|
1711
|
+
required: ["heading", "line"],
|
|
1712
|
+
properties: { heading: { type: "string" }, line: { type: "string" } },
|
|
1713
|
+
},
|
|
1516
1714
|
},
|
|
1517
1715
|
},
|
|
1518
1716
|
},
|
|
@@ -1598,13 +1796,25 @@ function mostRecentLessons(lessons, limit) {
|
|
|
1598
1796
|
return withTime.slice(0, limit).map((x) => x.lesson);
|
|
1599
1797
|
}
|
|
1600
1798
|
|
|
1799
|
+
// The `## ` headings of CLAUDE.md at dir ([] when it is missing or unreadable).
|
|
1800
|
+
function claudeMdHeadings(dir) {
|
|
1801
|
+
try {
|
|
1802
|
+
return readFileSync(join(dir, "CLAUDE.md"), "utf8")
|
|
1803
|
+
.split("\n")
|
|
1804
|
+
.filter((l) => l.startsWith("## "))
|
|
1805
|
+
.map((l) => l.trim());
|
|
1806
|
+
} catch {
|
|
1807
|
+
return [];
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1601
1811
|
// The prompt for the cheap-row pass: the rules in plain words, every task's facts (owned files from the plan when
|
|
1602
1812
|
// it names the task, verified/passes/stopReason, review and verifier findings, its blocks with class and reason,
|
|
1603
1813
|
// memoryUsed), the id and text of the most recently created/confirmed existing lessons (so the model avoids
|
|
1604
1814
|
// re-proposing them; capped at REFLECT_PROMPT_LESSON_CAP, said so in the prompt when it is), and the citation
|
|
1605
1815
|
// forms it may use. Filter C.4's duplicate check still runs against the full, uncapped set of existing lessons —
|
|
1606
1816
|
// this cap only bounds what the model itself sees.
|
|
1607
|
-
export function buildReflectPrompt(report, { blocks = [], plan = null, existingLessons = [] } = {}) {
|
|
1817
|
+
export function buildReflectPrompt(report, { blocks = [], plan = null, existingLessons = [], dir = null } = {}) {
|
|
1608
1818
|
const lines = [];
|
|
1609
1819
|
lines.push("You are proposing at most 3 lessons for a coding harness's memory store, from one workflow report.");
|
|
1610
1820
|
lines.push("Every rule below is enforced again in code after you answer, so follow it exactly:");
|
|
@@ -1617,6 +1827,13 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1617
1827
|
lines.push("- never propose a lesson whose text is already covered by one of the existing lessons listed below.");
|
|
1618
1828
|
lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
|
|
1619
1829
|
lines.push("- a task marked partial=true below ran out of context, not verification; propose no lesson that blames its code or its tests for that.");
|
|
1830
|
+
const headings = dir ? claudeMdHeadings(dir) : [];
|
|
1831
|
+
if (headings.length) {
|
|
1832
|
+
lines.push(
|
|
1833
|
+
"- claudeMd is optional: add { heading, line } to a lesson only when it is a rule a future session should follow. line is exactly one bullet starting with \"- \", heading is one of the existing CLAUDE.md headings below, and never ## Models or ## Commands. Omit it otherwise."
|
|
1834
|
+
);
|
|
1835
|
+
lines.push(` CLAUDE.md headings: ${headings.join(" | ")}`);
|
|
1836
|
+
}
|
|
1620
1837
|
lines.push("");
|
|
1621
1838
|
lines.push("Tasks in this report:");
|
|
1622
1839
|
for (const level of report.levels || []) {
|
|
@@ -1812,6 +2029,87 @@ function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
|
1812
2029
|
return { usd: null, usdSource: null };
|
|
1813
2030
|
}
|
|
1814
2031
|
|
|
2032
|
+
// Does a lesson already have a home (card reflect-offers-claude-md)? home:true when the cheap pass gave no usable
|
|
2033
|
+
// CLAUDE.md suggestion, when a hook under .doug/hooks/scripts already enforces it (the lesson text or the
|
|
2034
|
+
// suggestion names a script as a whole word, with or without .mjs), or when an existing CLAUDE.md bullet already
|
|
2035
|
+
// contains at least 60% of the suggestion line's tokens (containment over the suggestion's tokens, reflectTokens;
|
|
2036
|
+
// a line with no tokens counts as covered). { home:false } means: offer a candidate.
|
|
2037
|
+
export const CLAUDE_MD_COVERED_CONTAINMENT = 0.6;
|
|
2038
|
+
|
|
2039
|
+
export function claudeMdHome({ dir, lesson, suggestion } = {}) {
|
|
2040
|
+
if (!suggestion || typeof suggestion !== "object" || typeof suggestion.heading !== "string" || typeof suggestion.line !== "string") {
|
|
2041
|
+
return { home: true, why: "no suggestion" };
|
|
2042
|
+
}
|
|
2043
|
+
const haystack = `${lesson && typeof lesson.text === "string" ? lesson.text : ""}\n${suggestion.line}`;
|
|
2044
|
+
let scripts = [];
|
|
2045
|
+
try {
|
|
2046
|
+
scripts = readdirSync(join(dir, ".doug", "hooks", "scripts"))
|
|
2047
|
+
.filter((n) => n.endsWith(".mjs"))
|
|
2048
|
+
.map((n) => n.slice(0, -4));
|
|
2049
|
+
} catch {
|
|
2050
|
+
scripts = [];
|
|
2051
|
+
}
|
|
2052
|
+
for (const name of scripts) {
|
|
2053
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2054
|
+
if (new RegExp(`(?<![\\w-])${escaped}(?:\\.mjs)?(?![\\w-])`).test(haystack)) {
|
|
2055
|
+
return { home: true, why: `enforced by the ${name} gate script` };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
const tokens = reflectTokens(suggestion.line);
|
|
2059
|
+
if (tokens.size === 0) return { home: true, why: "the suggestion line has no content words" };
|
|
2060
|
+
let text = "";
|
|
2061
|
+
try {
|
|
2062
|
+
text = readFileSync(join(dir, "CLAUDE.md"), "utf8");
|
|
2063
|
+
} catch {
|
|
2064
|
+
return { home: false };
|
|
2065
|
+
}
|
|
2066
|
+
let inFence = false;
|
|
2067
|
+
for (const existing of text.split("\n")) {
|
|
2068
|
+
if (/^\s*(```|~~~)/.test(existing)) {
|
|
2069
|
+
inFence = !inFence;
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
if (inFence || !existing.startsWith("- ")) continue;
|
|
2073
|
+
const have = reflectTokens(existing);
|
|
2074
|
+
let shared = 0;
|
|
2075
|
+
for (const t of tokens) if (have.has(t)) shared++;
|
|
2076
|
+
if (shared / tokens.size >= CLAUDE_MD_COVERED_CONTAINMENT) return { home: true, why: `already covered by CLAUDE.md line: ${existing}` };
|
|
2077
|
+
}
|
|
2078
|
+
return { home: false };
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// A standing CLAUDE.md offer for each APPENDED lesson with no home (card reflect-offers-claude-md). Runs for the
|
|
2082
|
+
// report form and the --hand form alike (the hand CLI assembles a report and calls runReflect). The edits file is
|
|
2083
|
+
// checked with proposeClaudeMd in dryRun, written only when ok (and never under --dry-run) to
|
|
2084
|
+
// .doug/.state/learn/claude-md-candidates/<lesson id>.json; a refusal is dropped with its reason. CLAUDE.md is never
|
|
2085
|
+
// written and learn.mjs apply never runs: the printed command only makes a diff.
|
|
2086
|
+
async function offerClaudeMdCandidates({ dir, dryRun, reportHash, card, appendedRows, suggestions }) {
|
|
2087
|
+
const claudeMd = [];
|
|
2088
|
+
const claudeMdDropped = [];
|
|
2089
|
+
const { proposeClaudeMd } = await import("./decisions.mjs");
|
|
2090
|
+
const script = resolve(dirname(fileURLToPath(import.meta.url)), "../scripts/memory.mjs");
|
|
2091
|
+
for (const row of appendedRows) {
|
|
2092
|
+
const s = suggestions.get(row.id);
|
|
2093
|
+
if (!s || claudeMdHome({ dir, lesson: { text: s.text }, suggestion: s.claudeMd }).home) continue;
|
|
2094
|
+
const edits = {
|
|
2095
|
+
note: `reflect ${card || reportHash.slice(0, 12)}: lesson ${row.id}`,
|
|
2096
|
+
add: [{ heading: s.claudeMd.heading, line: s.claudeMd.line, source: `lesson ${row.id}` }],
|
|
2097
|
+
};
|
|
2098
|
+
const checked = proposeClaudeMd({ dir, edits, dryRun: true });
|
|
2099
|
+
if (!checked.ok) {
|
|
2100
|
+
claudeMdDropped.push({ lessonId: row.id, reason: checked.reason });
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
const file = join(dir, ".doug/.state/learn/claude-md-candidates", `${row.id}.json`);
|
|
2104
|
+
if (!dryRun) {
|
|
2105
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2106
|
+
writeFileSync(file, JSON.stringify(edits, null, 2) + "\n", "utf8");
|
|
2107
|
+
}
|
|
2108
|
+
claudeMd.push({ lessonId: row.id, file, command: `node ${script} claude-md propose --file ${file}` });
|
|
2109
|
+
}
|
|
2110
|
+
return { claudeMd, claudeMdDropped };
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1815
2113
|
// The whole reflect pass (card memory-reflect): counters first (committed even if the LLM pass fails), then the
|
|
1816
2114
|
// cheap-row pass via `propose`, then the filters in C, then cost. Idempotent on report_hash: a second call on the
|
|
1817
2115
|
// same report does nothing and returns { alreadyReflected: true, row }. `propose(prompt)` resolves to
|
|
@@ -1880,7 +2178,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1880
2178
|
}
|
|
1881
2179
|
|
|
1882
2180
|
const existingLessons = listLessons(m);
|
|
1883
|
-
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons });
|
|
2181
|
+
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons, dir });
|
|
1884
2182
|
|
|
1885
2183
|
let proposeResult = null;
|
|
1886
2184
|
let proposeError = null;
|
|
@@ -1913,6 +2211,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1913
2211
|
rejected,
|
|
1914
2212
|
usd: null,
|
|
1915
2213
|
usdSource: null,
|
|
2214
|
+
claudeMd: [],
|
|
2215
|
+
claudeMdDropped: [],
|
|
1916
2216
|
model,
|
|
1917
2217
|
sessionId: null,
|
|
1918
2218
|
reason: proposeError.message,
|
|
@@ -1924,6 +2224,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1924
2224
|
const totalCostUsd = typeof proposeResult.totalCostUsd === "number" ? proposeResult.totalCostUsd : null;
|
|
1925
2225
|
|
|
1926
2226
|
const appendedRows = [];
|
|
2227
|
+
const suggestions = new Map(); // lesson id -> { lesson text, claudeMd suggestion } for appended lessons only
|
|
1927
2228
|
const bumped = [];
|
|
1928
2229
|
const rejected = [];
|
|
1929
2230
|
for (const proposal of proposals) {
|
|
@@ -1944,7 +2245,9 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1944
2245
|
continue;
|
|
1945
2246
|
}
|
|
1946
2247
|
if (dryRun) {
|
|
1947
|
-
|
|
2248
|
+
const dryId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
2249
|
+
appendedRows.push({ id: dryId, text: proposal.text, kind: proposal.kind });
|
|
2250
|
+
suggestions.set(dryId, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
1948
2251
|
continue;
|
|
1949
2252
|
}
|
|
1950
2253
|
const reportTask = findReportTask(report, proposal.task);
|
|
@@ -1959,6 +2262,18 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1959
2262
|
commit: commit != null ? commit : (reportTask && reportTask.commit) || null,
|
|
1960
2263
|
});
|
|
1961
2264
|
appendedRows.push(row);
|
|
2265
|
+
suggestions.set(row.id, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
let claudeMd = [];
|
|
2269
|
+
let claudeMdDropped = [];
|
|
2270
|
+
try {
|
|
2271
|
+
({ claudeMd, claudeMdDropped } = await offerClaudeMdCandidates({ dir, dryRun, reportHash, card, appendedRows, suggestions }));
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
// Fail open: the offer is advisory, so an error never leaves the reflection half-written.
|
|
2274
|
+
const message = err && err.message ? err.message : String(err);
|
|
2275
|
+
claudeMd = [];
|
|
2276
|
+
claudeMdDropped = appendedRows.map((r) => ({ lessonId: r.id, reason: `claude-md offer failed: ${message}` }));
|
|
1962
2277
|
}
|
|
1963
2278
|
|
|
1964
2279
|
const cost = computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd });
|
|
@@ -1987,6 +2302,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1987
2302
|
appended: appendedRows.map((r) => r.id),
|
|
1988
2303
|
bumped,
|
|
1989
2304
|
rejected,
|
|
2305
|
+
claudeMd,
|
|
2306
|
+
claudeMdDropped,
|
|
1990
2307
|
usd: cost.usd,
|
|
1991
2308
|
usdSource: cost.usdSource,
|
|
1992
2309
|
model,
|
|
@@ -24,7 +24,7 @@ export const SIZES = ["S", "M", "L"];
|
|
|
24
24
|
export const GATES = ["auto", "human"];
|
|
25
25
|
// Crew per role (card crew-sizing): researchers before planning, reviewers and adversaries after implementation,
|
|
26
26
|
// default one each. More coders is a split into more tasks, so there is no coders row.
|
|
27
|
-
export const CREW_ROLES = ["researchers", "reviewers", "adversaries"];
|
|
27
|
+
export const CREW_ROLES = ["researchers", "reviewers", "adversaries", "testers"];
|
|
28
28
|
|
|
29
29
|
// Error strings for a crew object at `where` (the plan or a task); [] when valid or absent.
|
|
30
30
|
function crewErrors(crew, where) {
|