@dougharness/cli 1.1.1 → 1.3.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/board-serve.js +40 -6
- package/dist/board-serve.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +70 -46
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- 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/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
- package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
- package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
- package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
- package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
- package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -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";
|
|
@@ -55,7 +56,8 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
|
|
|
55
56
|
// row. On a hand row the following columns are NULL because they do not exist on that track — never because
|
|
56
57
|
// they measured zero: run_id, spec_hash, files, size, shape, models, implemented, verified, reviewed,
|
|
57
58
|
// review_issues, review_issue_count, adversary_verdict, adversary_blocked, adversary_source, blocks, passes,
|
|
58
|
-
// fix_passes, stop_reason, in_scope, budget_spent, tokens,
|
|
59
|
+
// fix_passes, stop_reason, stop_class, in_scope, budget_spent, tokens, codex_usd, report_hash, report_path, partial (usd too,
|
|
60
|
+
// except that a hand landing recorded with --cost carries its measured session cost there; card usd-budget-size-class). In particular
|
|
59
61
|
// verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
|
|
60
62
|
// the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
|
|
61
63
|
// blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
|
|
@@ -108,6 +110,7 @@ const OUTCOMES_COLUMNS_SQL = `
|
|
|
108
110
|
tokens TEXT,
|
|
109
111
|
usd REAL,
|
|
110
112
|
codex_usd REAL,
|
|
113
|
+
stop_class TEXT,
|
|
111
114
|
commit_sha TEXT,
|
|
112
115
|
track TEXT NOT NULL DEFAULT 'flow',
|
|
113
116
|
wall_clock TEXT,
|
|
@@ -399,6 +402,27 @@ function migrateV7ToV8(db) {
|
|
|
399
402
|
}
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
// v8 had no stop_class column on outcomes (card memory-stop-class): the typed cause of a stop, from
|
|
406
|
+
// task.stopClass, so stops can be counted by cause across runs. Additive like v7->v8: one guarded ALTER, then
|
|
407
|
+
// the version bump; old rows stay null. OUTCOMES_COLUMNS_SQL carries stop_class for a v1 store's rebuild, so
|
|
408
|
+
// this step is a no-op there instead of a duplicate-column error.
|
|
409
|
+
const V9_STOP_COLUMNS = [["stop_class", "TEXT"]];
|
|
410
|
+
|
|
411
|
+
function migrateV8ToV9(db) {
|
|
412
|
+
db.exec("BEGIN");
|
|
413
|
+
try {
|
|
414
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
415
|
+
for (const [name, type] of V9_STOP_COLUMNS) {
|
|
416
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
|
|
417
|
+
}
|
|
418
|
+
db.exec("PRAGMA user_version = 9");
|
|
419
|
+
db.exec("COMMIT");
|
|
420
|
+
} catch (err) {
|
|
421
|
+
db.exec("ROLLBACK");
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
402
426
|
export function openMemory(dir) {
|
|
403
427
|
const file = memoryPath(dir);
|
|
404
428
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -407,7 +431,7 @@ export function openMemory(dir) {
|
|
|
407
431
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
408
432
|
if (version === 0) {
|
|
409
433
|
db.exec(SCHEMA_SQL);
|
|
410
|
-
db.exec("PRAGMA user_version =
|
|
434
|
+
db.exec("PRAGMA user_version = 9");
|
|
411
435
|
} else if (version === 1) {
|
|
412
436
|
migrateV1ToV2(db);
|
|
413
437
|
migrateV2ToV3(db);
|
|
@@ -416,6 +440,7 @@ export function openMemory(dir) {
|
|
|
416
440
|
migrateV5ToV6(db);
|
|
417
441
|
migrateV6ToV7(db);
|
|
418
442
|
migrateV7ToV8(db);
|
|
443
|
+
migrateV8ToV9(db);
|
|
419
444
|
} else if (version === 2) {
|
|
420
445
|
migrateV2ToV3(db);
|
|
421
446
|
migrateV3ToV4(db);
|
|
@@ -423,29 +448,37 @@ export function openMemory(dir) {
|
|
|
423
448
|
migrateV5ToV6(db);
|
|
424
449
|
migrateV6ToV7(db);
|
|
425
450
|
migrateV7ToV8(db);
|
|
451
|
+
migrateV8ToV9(db);
|
|
426
452
|
} else if (version === 3) {
|
|
427
453
|
migrateV3ToV4(db);
|
|
428
454
|
migrateV4ToV5(db);
|
|
429
455
|
migrateV5ToV6(db);
|
|
430
456
|
migrateV6ToV7(db);
|
|
431
457
|
migrateV7ToV8(db);
|
|
458
|
+
migrateV8ToV9(db);
|
|
432
459
|
} else if (version === 4) {
|
|
433
460
|
migrateV4ToV5(db);
|
|
434
461
|
migrateV5ToV6(db);
|
|
435
462
|
migrateV6ToV7(db);
|
|
436
463
|
migrateV7ToV8(db);
|
|
464
|
+
migrateV8ToV9(db);
|
|
437
465
|
} else if (version === 5) {
|
|
438
466
|
migrateV5ToV6(db);
|
|
439
467
|
migrateV6ToV7(db);
|
|
440
468
|
migrateV7ToV8(db);
|
|
469
|
+
migrateV8ToV9(db);
|
|
441
470
|
} else if (version === 6) {
|
|
442
471
|
migrateV6ToV7(db);
|
|
443
472
|
migrateV7ToV8(db);
|
|
473
|
+
migrateV8ToV9(db);
|
|
444
474
|
} else if (version === 7) {
|
|
445
475
|
migrateV7ToV8(db);
|
|
446
|
-
|
|
476
|
+
migrateV8ToV9(db);
|
|
477
|
+
} else if (version === 8) {
|
|
478
|
+
migrateV8ToV9(db);
|
|
479
|
+
} else if (version !== 9) {
|
|
447
480
|
db.close();
|
|
448
|
-
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version
|
|
481
|
+
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 9`);
|
|
449
482
|
}
|
|
450
483
|
return {
|
|
451
484
|
file,
|
|
@@ -479,7 +512,7 @@ export function probeSqliteFts5() {
|
|
|
479
512
|
// Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
|
|
480
513
|
export function inspectMemory(dir) {
|
|
481
514
|
const file = memoryPath(dir);
|
|
482
|
-
const out = { file, exists: false, userVersion: null, maxUserVersion:
|
|
515
|
+
const out = { file, exists: false, userVersion: null, maxUserVersion: 9, quickCheck: null, error: null };
|
|
483
516
|
if (!existsSync(file)) return out;
|
|
484
517
|
out.exists = true;
|
|
485
518
|
let db;
|
|
@@ -637,6 +670,11 @@ function floatsToBlob(vector) {
|
|
|
637
670
|
function blobToFloats(blob) {
|
|
638
671
|
if (!blob) return null;
|
|
639
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
|
+
}
|
|
640
678
|
const copy = new Uint8Array(bytes.length);
|
|
641
679
|
copy.set(bytes);
|
|
642
680
|
return new Float32Array(copy.buffer);
|
|
@@ -719,7 +757,7 @@ export function checkCitation(lesson, checkoutDir, { report } = {}) {
|
|
|
719
757
|
}
|
|
720
758
|
const taskId = rest.slice(0, slash);
|
|
721
759
|
const blockId = rest.slice(slash + 1);
|
|
722
|
-
const found = adversaryBlocks(report).some((b) => b.task === taskId && b.id === blockId);
|
|
760
|
+
const found = adversaryBlocks(report).some((b) => b.task === taskId && (b.id === blockId || b.id === `${taskId}/${blockId}`));
|
|
723
761
|
return found ? { ok: true, reason: "block holds" } : { ok: false, reason: `no block "${blockId}" of task "${taskId}" in this report` };
|
|
724
762
|
}
|
|
725
763
|
const match = citation.match(CITATION_RE);
|
|
@@ -827,6 +865,144 @@ function toRecallLesson(lesson, extra) {
|
|
|
827
865
|
return { ...rest, hasVector: !!embedding, ...extra };
|
|
828
866
|
}
|
|
829
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
|
+
|
|
830
1006
|
// --- Dense-ranked recall -----------------------------------------------------------------------------------
|
|
831
1007
|
// With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
|
|
832
1008
|
// cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
|
|
@@ -844,51 +1020,60 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
844
1020
|
const nowMs = nowDate.getTime();
|
|
845
1021
|
const checked = isProjectRoot(checkoutDir);
|
|
846
1022
|
|
|
847
|
-
// (a)
|
|
848
|
-
//
|
|
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;
|
|
856
|
-
const freshRows = allRows.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
|
|
857
|
-
const byId = new Map(freshRows.map((l) => [l.id, l]));
|
|
858
|
-
|
|
859
|
-
// (b) BM25 side, top 50.
|
|
1023
|
+
// (a)+(b) BM25 side, top 50: live (not superseded, not stale) hits, kept when confirmed-or-created is within
|
|
1024
|
+
// staleDays. The whole-table work below (the dense side) runs only with a provider (card recall-hybrid-scale).
|
|
860
1025
|
const match = ftsMatchQuery(query);
|
|
861
1026
|
let bm25Ranked = [];
|
|
1027
|
+
const bm25Info = new Map();
|
|
862
1028
|
if (match) {
|
|
863
1029
|
const rows = m
|
|
864
1030
|
.prepare(
|
|
865
|
-
`SELECT l.id
|
|
1031
|
+
`SELECT l.id, l.created, l.confirmed, l.scope,
|
|
1032
|
+
(l.embedding IS NOT NULL AND l.embedding_model = ? AND l.embedding_dims = ?) AS has_match
|
|
1033
|
+
FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
866
1034
|
WHERE lessons_fts MATCH ? AND l.superseded_by IS NULL AND l.stale IS NULL
|
|
867
1035
|
ORDER BY bm25(lessons_fts) LIMIT 50`
|
|
868
1036
|
)
|
|
869
|
-
.all(match);
|
|
870
|
-
|
|
1037
|
+
.all(provider ? provider.model : null, provider ? provider.dims : null, match);
|
|
1038
|
+
for (const r of rows) {
|
|
1039
|
+
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1040
|
+
if (!(age <= staleDays)) continue;
|
|
1041
|
+
bm25Ranked.push(r.id);
|
|
1042
|
+
bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age });
|
|
1043
|
+
}
|
|
871
1044
|
}
|
|
872
1045
|
|
|
873
1046
|
// (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.
|
|
1047
|
+
// vectors) for every such row, not only BM25 hits. The rows come from recallRows: decoded once per database,
|
|
1048
|
+
// model and dims and reused until a write (this connection's or another's) changes the database.
|
|
875
1049
|
let mode = "keyword-only";
|
|
876
1050
|
let reason = "no provider configured";
|
|
877
1051
|
let providerName = null;
|
|
878
|
-
|
|
1052
|
+
let rows = null;
|
|
1053
|
+
let ages = null;
|
|
1054
|
+
let cosine = null;
|
|
1055
|
+
let cosineScores = null;
|
|
1056
|
+
let freshTotal = 0;
|
|
1057
|
+
let freshVectored = 0;
|
|
1058
|
+
let sigBefore = null;
|
|
879
1059
|
if (provider) {
|
|
880
1060
|
providerName = provider.name;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1061
|
+
sigBefore = recallSignature(m);
|
|
1062
|
+
rows = recallRows(m, provider, nowMs, staleDays, sigBefore);
|
|
1063
|
+
ages = new Float64Array(rows ? rows.n : 0);
|
|
1064
|
+
for (let i = 0; rows && i < rows.n; i++) {
|
|
1065
|
+
if (!rows.live[i]) continue;
|
|
1066
|
+
const t = rows.anchorMs[i];
|
|
1067
|
+
const age = Number.isFinite(t) ? (nowMs - t) / 86400000 : 0;
|
|
1068
|
+
ages[i] = age;
|
|
1069
|
+
if (age <= staleDays) {
|
|
1070
|
+
freshTotal++;
|
|
1071
|
+
if (rows.vecs[i]) freshVectored++;
|
|
1072
|
+
} else {
|
|
1073
|
+
ages[i] = Infinity; // out of the window; never a candidate
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (!freshVectored) {
|
|
892
1077
|
reason = `no vectors stored for ${provider.model}/${provider.dims}`;
|
|
893
1078
|
} else {
|
|
894
1079
|
const embedResult = await provider.embed([String(query || "")], { inputType: "query" });
|
|
@@ -896,11 +1081,15 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
896
1081
|
reason = embedResult.reason;
|
|
897
1082
|
} else {
|
|
898
1083
|
const qVec = embedResult.vectors[0];
|
|
899
|
-
|
|
900
|
-
|
|
1084
|
+
cosine = [];
|
|
1085
|
+
cosineScores = new Float64Array(rows.n);
|
|
1086
|
+
for (let r = 0; r < rows.n; r++) {
|
|
1087
|
+
const vec = rows.vecs[r];
|
|
1088
|
+
if (!vec || ages[r] === Infinity || !rows.live[r]) continue;
|
|
901
1089
|
let dot = 0;
|
|
902
1090
|
for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
|
|
903
|
-
|
|
1091
|
+
cosineScores[r] = dot;
|
|
1092
|
+
cosine.push(r);
|
|
904
1093
|
}
|
|
905
1094
|
mode = "hybrid";
|
|
906
1095
|
reason = null;
|
|
@@ -916,42 +1105,49 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
916
1105
|
const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
|
|
917
1106
|
const coverage = {};
|
|
918
1107
|
if (mode === "hybrid") {
|
|
919
|
-
coverage.total =
|
|
920
|
-
coverage.unvectored =
|
|
921
|
-
(l) => !(hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims)
|
|
922
|
-
).length;
|
|
1108
|
+
coverage.total = freshTotal;
|
|
1109
|
+
coverage.unvectored = freshTotal - freshVectored;
|
|
923
1110
|
}
|
|
924
1111
|
|
|
925
1112
|
// (d) scores: recency weight, then scope boost. Dense mode scores cosine; the BM25 order scores 1/(60+rank).
|
|
926
1113
|
const bm25Rank = new Map(bm25Ranked.map((id, idx) => [id, idx]));
|
|
927
|
-
const weigh = (
|
|
928
|
-
const ageDays = ageDaysOf(lesson.confirmed || lesson.created, nowMs);
|
|
1114
|
+
const weigh = (ageDays, scopeRaw, base) => {
|
|
929
1115
|
let score = base * (0.5 + 0.5 * Math.exp(-ageDays / 90));
|
|
930
|
-
if (files.length
|
|
1116
|
+
if (files.length) {
|
|
1117
|
+
const scope = parseJsonColumn(scopeRaw, []);
|
|
1118
|
+
if (scope.length === 0 || scopeMatchesFiles(scope, files)) score *= 1.5;
|
|
1119
|
+
}
|
|
931
1120
|
return score;
|
|
932
1121
|
};
|
|
933
|
-
const
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1122
|
+
const idCmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
1123
|
+
const byScoreThenId = (a, b) => b.score - a.score || idCmp(a.id, b.id);
|
|
1124
|
+
// Candidates in rank order, each { id, score, sides }, read through at(j) so the dense side never builds one
|
|
1125
|
+
// object per stored vector.
|
|
1126
|
+
const bm25Only = (id) => {
|
|
1127
|
+
const info = bm25Info.get(id);
|
|
1128
|
+
return { id, score: weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), sides: ["bm25"] };
|
|
939
1129
|
};
|
|
940
1130
|
let candidates;
|
|
941
1131
|
if (mode === "hybrid") {
|
|
942
1132
|
// (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
dense.
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1133
|
+
const order = Int32Array.from(cosine);
|
|
1134
|
+
const scores = new Float64Array(rows.n);
|
|
1135
|
+
for (const r of cosine) scores[r] = weigh(ages[r], rows.scopeRaw[r], cosineScores[r]);
|
|
1136
|
+
order.sort((x, y) => scores[y] - scores[x] || idCmp(rows.ids[x], rows.ids[y]));
|
|
1137
|
+
const dense = { length: order.length, at: (j) => {
|
|
1138
|
+
const r = order[j];
|
|
1139
|
+
const id = rows.ids[r];
|
|
1140
|
+
return { id, score: scores[r], sides: bm25Rank.has(id) ? ["bm25", "vector"] : ["vector"] };
|
|
1141
|
+
} };
|
|
1142
|
+
const appended = bm25Ranked.filter((id) => !bm25Info.get(id).hasMatch).map(bm25Only);
|
|
1143
|
+
candidates = {
|
|
1144
|
+
length: dense.length + appended.length,
|
|
1145
|
+
at: (j) => (j < dense.length ? dense.at(j) : appended[j - dense.length]),
|
|
1146
|
+
};
|
|
952
1147
|
} else {
|
|
953
|
-
|
|
954
|
-
|
|
1148
|
+
const list = bm25Ranked.map(bm25Only);
|
|
1149
|
+
list.sort(byScoreThenId);
|
|
1150
|
+
candidates = { length: list.length, at: (j) => list[j] };
|
|
955
1151
|
}
|
|
956
1152
|
|
|
957
1153
|
// (g) top k. With a real project root, each candidate's citation is re-checked: a failure marks the row
|
|
@@ -967,11 +1163,12 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
967
1163
|
.join(", ");
|
|
968
1164
|
const loadById = m.prepare(`SELECT ${loadCols}, embedding IS NOT NULL AS has_vec FROM lessons WHERE id = ?`);
|
|
969
1165
|
const result = [];
|
|
1166
|
+
const written = []; // each write below, so the cached rows can follow them
|
|
970
1167
|
let idx = 0;
|
|
971
1168
|
while (result.length < k && idx < candidates.length) {
|
|
972
|
-
const cand = candidates
|
|
1169
|
+
const cand = candidates.at(idx++);
|
|
973
1170
|
// 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.
|
|
1171
|
+
const { has_vec, ...full } = loadById.get(cand.id);
|
|
975
1172
|
cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
|
|
976
1173
|
if (!checked) {
|
|
977
1174
|
result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
|
|
@@ -979,12 +1176,15 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
979
1176
|
}
|
|
980
1177
|
const check = checkCitation(cand.lesson, checkoutDir);
|
|
981
1178
|
if (!check.ok) {
|
|
982
|
-
m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1179
|
+
const res = m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1180
|
+
written.push({ id: cand.lesson.id, stale: true, changes: Number(res.changes) });
|
|
983
1181
|
continue;
|
|
984
1182
|
}
|
|
985
|
-
m.prepare("UPDATE lessons SET confirmed = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1183
|
+
const res = m.prepare("UPDATE lessons SET confirmed = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
1184
|
+
written.push({ id: cand.lesson.id, stale: false, changes: Number(res.changes) });
|
|
986
1185
|
result.push(toRecallLesson({ ...cand.lesson, confirmed: nowIsoStr }, { score: cand.score, sides: cand.sides }));
|
|
987
1186
|
}
|
|
1187
|
+
if (rows && written.length) followOwnWrites(m, rows, written, nowIsoStr, sigBefore);
|
|
988
1188
|
|
|
989
1189
|
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, lessons: result };
|
|
990
1190
|
}
|
|
@@ -1259,6 +1459,7 @@ export function outcomeRows(report, opts) {
|
|
|
1259
1459
|
passes,
|
|
1260
1460
|
fix_passes: fixPasses,
|
|
1261
1461
|
stop_reason: task.stopReason ?? null,
|
|
1462
|
+
stop_class: task.stopClass ?? null,
|
|
1262
1463
|
in_scope: toIntOrNull(task.inScope),
|
|
1263
1464
|
budget_spent: (task.budget && task.budget.spent) || null,
|
|
1264
1465
|
tokens,
|
|
@@ -1329,7 +1530,7 @@ export function conditionConfigHash(dir) {
|
|
|
1329
1530
|
// null when none was found: null fills every one of the seven condition columns with null, exactly like a row
|
|
1330
1531
|
// that has never been stamped. `backfilled` is always 0 here — only `memory.mjs condition backfill`, which never
|
|
1331
1532
|
// goes through this function, ever sets it to 1.
|
|
1332
|
-
export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null } = {}) {
|
|
1533
|
+
export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null, cost = null } = {}) {
|
|
1333
1534
|
if (!card) throw new Error("handOutcomeRow requires card");
|
|
1334
1535
|
if (!commit) throw new Error("handOutcomeRow requires commit");
|
|
1335
1536
|
return {
|
|
@@ -1357,10 +1558,11 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1357
1558
|
passes: null,
|
|
1358
1559
|
fix_passes: null,
|
|
1359
1560
|
stop_reason: null,
|
|
1561
|
+
stop_class: null,
|
|
1360
1562
|
in_scope: null,
|
|
1361
1563
|
budget_spent: null,
|
|
1362
1564
|
tokens: null,
|
|
1363
|
-
usd: null,
|
|
1565
|
+
usd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
|
|
1364
1566
|
codex_usd: null,
|
|
1365
1567
|
commit_sha: commit,
|
|
1366
1568
|
track: "hand",
|
|
@@ -1381,7 +1583,7 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1381
1583
|
const OUTCOME_COLUMNS = [
|
|
1382
1584
|
"recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
|
|
1383
1585
|
"models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
|
|
1384
|
-
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "in_scope",
|
|
1586
|
+
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
|
|
1385
1587
|
"budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
|
|
1386
1588
|
"harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
|
|
1387
1589
|
];
|
|
@@ -1479,6 +1681,13 @@ export const REFLECT_SCHEMA = {
|
|
|
1479
1681
|
task: { type: "string" },
|
|
1480
1682
|
scope: { type: "array", items: { type: "string" } },
|
|
1481
1683
|
notDerivableBecause: { type: "string" },
|
|
1684
|
+
// Optional (card reflect-offers-claude-md): the CLAUDE.md bullet this lesson would be, only when it is a
|
|
1685
|
+
// rule a future session should follow.
|
|
1686
|
+
claudeMd: {
|
|
1687
|
+
type: "object",
|
|
1688
|
+
required: ["heading", "line"],
|
|
1689
|
+
properties: { heading: { type: "string" }, line: { type: "string" } },
|
|
1690
|
+
},
|
|
1482
1691
|
},
|
|
1483
1692
|
},
|
|
1484
1693
|
},
|
|
@@ -1507,7 +1716,7 @@ function reflectionRowToObject(row) {
|
|
|
1507
1716
|
// Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
|
|
1508
1717
|
// `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
|
|
1509
1718
|
// 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
|
|
1719
|
+
// no helpful (real wins even when the task also verified; except on the hand track, whose reviewer blocks never withhold it), else a first-verified task bumps helpful on every used
|
|
1511
1720
|
// lesson not named harmful; neither verified nor real does nothing. An id naming no lesson is reported in
|
|
1512
1721
|
// `unknown`, never an error. apply:false (a dry run's preview) computes the same result without writing anything.
|
|
1513
1722
|
function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
|
|
@@ -1527,7 +1736,9 @@ function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
|
|
|
1527
1736
|
for (const task of level.tasks || []) {
|
|
1528
1737
|
const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
|
|
1529
1738
|
if (!used.length) continue;
|
|
1530
|
-
|
|
1739
|
+
// A hand report's blocks are reviewer blockers a later round fixed (card reflect-review-key-text): they are no
|
|
1740
|
+
// evidence the lesson misled, so they never withhold helpful; only --harmful marks that.
|
|
1741
|
+
const real = report.track !== "hand" && blocks.some((b) => b.task === task.id && b.class === "real");
|
|
1531
1742
|
if (real) {
|
|
1532
1743
|
for (const id of used) {
|
|
1533
1744
|
if (!getLesson(m, id) && !named.has(id)) unknown.push(id);
|
|
@@ -1562,13 +1773,25 @@ function mostRecentLessons(lessons, limit) {
|
|
|
1562
1773
|
return withTime.slice(0, limit).map((x) => x.lesson);
|
|
1563
1774
|
}
|
|
1564
1775
|
|
|
1776
|
+
// The `## ` headings of CLAUDE.md at dir ([] when it is missing or unreadable).
|
|
1777
|
+
function claudeMdHeadings(dir) {
|
|
1778
|
+
try {
|
|
1779
|
+
return readFileSync(join(dir, "CLAUDE.md"), "utf8")
|
|
1780
|
+
.split("\n")
|
|
1781
|
+
.filter((l) => l.startsWith("## "))
|
|
1782
|
+
.map((l) => l.trim());
|
|
1783
|
+
} catch {
|
|
1784
|
+
return [];
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1565
1788
|
// The prompt for the cheap-row pass: the rules in plain words, every task's facts (owned files from the plan when
|
|
1566
1789
|
// it names the task, verified/passes/stopReason, review and verifier findings, its blocks with class and reason,
|
|
1567
1790
|
// memoryUsed), the id and text of the most recently created/confirmed existing lessons (so the model avoids
|
|
1568
1791
|
// re-proposing them; capped at REFLECT_PROMPT_LESSON_CAP, said so in the prompt when it is), and the citation
|
|
1569
1792
|
// forms it may use. Filter C.4's duplicate check still runs against the full, uncapped set of existing lessons —
|
|
1570
1793
|
// this cap only bounds what the model itself sees.
|
|
1571
|
-
export function buildReflectPrompt(report, { blocks = [], plan = null, existingLessons = [] } = {}) {
|
|
1794
|
+
export function buildReflectPrompt(report, { blocks = [], plan = null, existingLessons = [], dir = null } = {}) {
|
|
1572
1795
|
const lines = [];
|
|
1573
1796
|
lines.push("You are proposing at most 3 lessons for a coding harness's memory store, from one workflow report.");
|
|
1574
1797
|
lines.push("Every rule below is enforced again in code after you answer, so follow it exactly:");
|
|
@@ -1581,6 +1804,13 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1581
1804
|
lines.push("- never propose a lesson whose text is already covered by one of the existing lessons listed below.");
|
|
1582
1805
|
lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
|
|
1583
1806
|
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.");
|
|
1807
|
+
const headings = dir ? claudeMdHeadings(dir) : [];
|
|
1808
|
+
if (headings.length) {
|
|
1809
|
+
lines.push(
|
|
1810
|
+
"- 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."
|
|
1811
|
+
);
|
|
1812
|
+
lines.push(` CLAUDE.md headings: ${headings.join(" | ")}`);
|
|
1813
|
+
}
|
|
1584
1814
|
lines.push("");
|
|
1585
1815
|
lines.push("Tasks in this report:");
|
|
1586
1816
|
for (const level of report.levels || []) {
|
|
@@ -1595,7 +1825,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1595
1825
|
if (Array.isArray(task.verifierFindings) && task.verifierFindings.length) lines.push(` verifier findings: ${JSON.stringify(task.verifierFindings)}`);
|
|
1596
1826
|
if (taskBlocks.length) {
|
|
1597
1827
|
lines.push(" blocks:");
|
|
1598
|
-
for (const b of taskBlocks) lines.push(` - report:${task.id}/${b.id} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
|
|
1828
|
+
for (const b of taskBlocks) lines.push(` - report:${b.id.startsWith(`${task.id}/`) ? b.id : `${task.id}/${b.id}`} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
|
|
1599
1829
|
}
|
|
1600
1830
|
lines.push(` memoryUsed: ${used.length ? used.join(", ") : "none"}`);
|
|
1601
1831
|
}
|
|
@@ -1776,6 +2006,87 @@ function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
|
1776
2006
|
return { usd: null, usdSource: null };
|
|
1777
2007
|
}
|
|
1778
2008
|
|
|
2009
|
+
// Does a lesson already have a home (card reflect-offers-claude-md)? home:true when the cheap pass gave no usable
|
|
2010
|
+
// CLAUDE.md suggestion, when a hook under .doug/hooks/scripts already enforces it (the lesson text or the
|
|
2011
|
+
// suggestion names a script as a whole word, with or without .mjs), or when an existing CLAUDE.md bullet already
|
|
2012
|
+
// contains at least 60% of the suggestion line's tokens (containment over the suggestion's tokens, reflectTokens;
|
|
2013
|
+
// a line with no tokens counts as covered). { home:false } means: offer a candidate.
|
|
2014
|
+
export const CLAUDE_MD_COVERED_CONTAINMENT = 0.6;
|
|
2015
|
+
|
|
2016
|
+
export function claudeMdHome({ dir, lesson, suggestion } = {}) {
|
|
2017
|
+
if (!suggestion || typeof suggestion !== "object" || typeof suggestion.heading !== "string" || typeof suggestion.line !== "string") {
|
|
2018
|
+
return { home: true, why: "no suggestion" };
|
|
2019
|
+
}
|
|
2020
|
+
const haystack = `${lesson && typeof lesson.text === "string" ? lesson.text : ""}\n${suggestion.line}`;
|
|
2021
|
+
let scripts = [];
|
|
2022
|
+
try {
|
|
2023
|
+
scripts = readdirSync(join(dir, ".doug", "hooks", "scripts"))
|
|
2024
|
+
.filter((n) => n.endsWith(".mjs"))
|
|
2025
|
+
.map((n) => n.slice(0, -4));
|
|
2026
|
+
} catch {
|
|
2027
|
+
scripts = [];
|
|
2028
|
+
}
|
|
2029
|
+
for (const name of scripts) {
|
|
2030
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2031
|
+
if (new RegExp(`(?<![\\w-])${escaped}(?:\\.mjs)?(?![\\w-])`).test(haystack)) {
|
|
2032
|
+
return { home: true, why: `enforced by the ${name} gate script` };
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
const tokens = reflectTokens(suggestion.line);
|
|
2036
|
+
if (tokens.size === 0) return { home: true, why: "the suggestion line has no content words" };
|
|
2037
|
+
let text = "";
|
|
2038
|
+
try {
|
|
2039
|
+
text = readFileSync(join(dir, "CLAUDE.md"), "utf8");
|
|
2040
|
+
} catch {
|
|
2041
|
+
return { home: false };
|
|
2042
|
+
}
|
|
2043
|
+
let inFence = false;
|
|
2044
|
+
for (const existing of text.split("\n")) {
|
|
2045
|
+
if (/^\s*(```|~~~)/.test(existing)) {
|
|
2046
|
+
inFence = !inFence;
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
if (inFence || !existing.startsWith("- ")) continue;
|
|
2050
|
+
const have = reflectTokens(existing);
|
|
2051
|
+
let shared = 0;
|
|
2052
|
+
for (const t of tokens) if (have.has(t)) shared++;
|
|
2053
|
+
if (shared / tokens.size >= CLAUDE_MD_COVERED_CONTAINMENT) return { home: true, why: `already covered by CLAUDE.md line: ${existing}` };
|
|
2054
|
+
}
|
|
2055
|
+
return { home: false };
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// A standing CLAUDE.md offer for each APPENDED lesson with no home (card reflect-offers-claude-md). Runs for the
|
|
2059
|
+
// report form and the --hand form alike (the hand CLI assembles a report and calls runReflect). The edits file is
|
|
2060
|
+
// checked with proposeClaudeMd in dryRun, written only when ok (and never under --dry-run) to
|
|
2061
|
+
// .doug/.state/learn/claude-md-candidates/<lesson id>.json; a refusal is dropped with its reason. CLAUDE.md is never
|
|
2062
|
+
// written and learn.mjs apply never runs: the printed command only makes a diff.
|
|
2063
|
+
async function offerClaudeMdCandidates({ dir, dryRun, reportHash, card, appendedRows, suggestions }) {
|
|
2064
|
+
const claudeMd = [];
|
|
2065
|
+
const claudeMdDropped = [];
|
|
2066
|
+
const { proposeClaudeMd } = await import("./decisions.mjs");
|
|
2067
|
+
const script = resolve(dirname(fileURLToPath(import.meta.url)), "../scripts/memory.mjs");
|
|
2068
|
+
for (const row of appendedRows) {
|
|
2069
|
+
const s = suggestions.get(row.id);
|
|
2070
|
+
if (!s || claudeMdHome({ dir, lesson: { text: s.text }, suggestion: s.claudeMd }).home) continue;
|
|
2071
|
+
const edits = {
|
|
2072
|
+
note: `reflect ${card || reportHash.slice(0, 12)}: lesson ${row.id}`,
|
|
2073
|
+
add: [{ heading: s.claudeMd.heading, line: s.claudeMd.line, source: `lesson ${row.id}` }],
|
|
2074
|
+
};
|
|
2075
|
+
const checked = proposeClaudeMd({ dir, edits, dryRun: true });
|
|
2076
|
+
if (!checked.ok) {
|
|
2077
|
+
claudeMdDropped.push({ lessonId: row.id, reason: checked.reason });
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
const file = join(dir, ".doug/.state/learn/claude-md-candidates", `${row.id}.json`);
|
|
2081
|
+
if (!dryRun) {
|
|
2082
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2083
|
+
writeFileSync(file, JSON.stringify(edits, null, 2) + "\n", "utf8");
|
|
2084
|
+
}
|
|
2085
|
+
claudeMd.push({ lessonId: row.id, file, command: `node ${script} claude-md propose --file ${file}` });
|
|
2086
|
+
}
|
|
2087
|
+
return { claudeMd, claudeMdDropped };
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1779
2090
|
// The whole reflect pass (card memory-reflect): counters first (committed even if the LLM pass fails), then the
|
|
1780
2091
|
// cheap-row pass via `propose`, then the filters in C, then cost. Idempotent on report_hash: a second call on the
|
|
1781
2092
|
// same report does nothing and returns { alreadyReflected: true, row }. `propose(prompt)` resolves to
|
|
@@ -1844,7 +2155,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1844
2155
|
}
|
|
1845
2156
|
|
|
1846
2157
|
const existingLessons = listLessons(m);
|
|
1847
|
-
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons });
|
|
2158
|
+
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons, dir });
|
|
1848
2159
|
|
|
1849
2160
|
let proposeResult = null;
|
|
1850
2161
|
let proposeError = null;
|
|
@@ -1877,6 +2188,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1877
2188
|
rejected,
|
|
1878
2189
|
usd: null,
|
|
1879
2190
|
usdSource: null,
|
|
2191
|
+
claudeMd: [],
|
|
2192
|
+
claudeMdDropped: [],
|
|
1880
2193
|
model,
|
|
1881
2194
|
sessionId: null,
|
|
1882
2195
|
reason: proposeError.message,
|
|
@@ -1888,6 +2201,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1888
2201
|
const totalCostUsd = typeof proposeResult.totalCostUsd === "number" ? proposeResult.totalCostUsd : null;
|
|
1889
2202
|
|
|
1890
2203
|
const appendedRows = [];
|
|
2204
|
+
const suggestions = new Map(); // lesson id -> { lesson text, claudeMd suggestion } for appended lessons only
|
|
1891
2205
|
const bumped = [];
|
|
1892
2206
|
const rejected = [];
|
|
1893
2207
|
for (const proposal of proposals) {
|
|
@@ -1908,7 +2222,9 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1908
2222
|
continue;
|
|
1909
2223
|
}
|
|
1910
2224
|
if (dryRun) {
|
|
1911
|
-
|
|
2225
|
+
const dryId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
2226
|
+
appendedRows.push({ id: dryId, text: proposal.text, kind: proposal.kind });
|
|
2227
|
+
suggestions.set(dryId, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
1912
2228
|
continue;
|
|
1913
2229
|
}
|
|
1914
2230
|
const reportTask = findReportTask(report, proposal.task);
|
|
@@ -1923,6 +2239,18 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1923
2239
|
commit: commit != null ? commit : (reportTask && reportTask.commit) || null,
|
|
1924
2240
|
});
|
|
1925
2241
|
appendedRows.push(row);
|
|
2242
|
+
suggestions.set(row.id, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
let claudeMd = [];
|
|
2246
|
+
let claudeMdDropped = [];
|
|
2247
|
+
try {
|
|
2248
|
+
({ claudeMd, claudeMdDropped } = await offerClaudeMdCandidates({ dir, dryRun, reportHash, card, appendedRows, suggestions }));
|
|
2249
|
+
} catch (err) {
|
|
2250
|
+
// Fail open: the offer is advisory, so an error never leaves the reflection half-written.
|
|
2251
|
+
const message = err && err.message ? err.message : String(err);
|
|
2252
|
+
claudeMd = [];
|
|
2253
|
+
claudeMdDropped = appendedRows.map((r) => ({ lessonId: r.id, reason: `claude-md offer failed: ${message}` }));
|
|
1926
2254
|
}
|
|
1927
2255
|
|
|
1928
2256
|
const cost = computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd });
|
|
@@ -1951,6 +2279,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
1951
2279
|
appended: appendedRows.map((r) => r.id),
|
|
1952
2280
|
bumped,
|
|
1953
2281
|
rejected,
|
|
2282
|
+
claudeMd,
|
|
2283
|
+
claudeMdDropped,
|
|
1954
2284
|
usd: cost.usd,
|
|
1955
2285
|
usdSource: cost.usdSource,
|
|
1956
2286
|
model,
|