@dougharness/cli 1.4.0 → 1.5.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/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +44 -5
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
- package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
- package/node_modules/@dougharness/flow/lib/memory.mjs +1380 -44
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +162 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +38 -14
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +658 -36
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +6 -2
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/card-queen.js +44 -5
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +5 -3
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +48 -33
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
|
@@ -11,14 +11,15 @@
|
|
|
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 { fileURLToPath } from "node:url";
|
|
15
|
-
import { mkdirSync, existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { mkdirSync, existsSync, readFileSync, statSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
16
16
|
import { dirname, resolve, join } from "node:path";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { request as httpRequest } from "node:http";
|
|
20
20
|
import { request as httpsRequest } from "node:https";
|
|
21
|
-
import { classifyBlocks, adversaryBlocks } from "./board.mjs";
|
|
21
|
+
import { classifyBlocks, adversaryBlocks, addCard } from "./board.mjs";
|
|
22
|
+
import { initialState, currentAction, next as cardLoopNext, briefPrompt } from "./card-loop.mjs";
|
|
22
23
|
import { transcriptUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
|
|
23
24
|
import { MODELS_HEADING } from "./models.mjs";
|
|
24
25
|
|
|
@@ -61,7 +62,15 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
|
|
|
61
62
|
// verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
|
|
62
63
|
// the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
|
|
63
64
|
// blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
|
|
64
|
-
// are null on a flow row.
|
|
65
|
+
// are null on a flow row. Exception (card memory-outcome-arms): `reflect --hand --rounds <n>` (not a dry run)
|
|
66
|
+
// writes passes n and fix_passes n - 1 onto the landing's hand row; without --rounds both stay null, since the
|
|
67
|
+
// default of 1 round is not a measurement. A later `record --hand` of the same commit keeps those values (the
|
|
68
|
+
// upsert only replaces passes and fix_passes when the new row carries passes).
|
|
69
|
+
//
|
|
70
|
+
// memory_config (v10, card memory-outcome-arms): the canonical JSON (keys sorted) of the memoryConfig() the row
|
|
71
|
+
// ran under, copied from condition.json; null on an unstamped row. It travels with the condition columns. A flow
|
|
72
|
+
// row is stamped with memoryConfig({ track: "flow" }) (delivery "index+fetch" with fetchTokens, cards memory-flow-index-fetch, memory-atomic-lessons) when recorded
|
|
73
|
+
// through `memory.mjs record <report.json>`; a hand row carries the hand stamp (delivery "index+fetch").
|
|
65
74
|
//
|
|
66
75
|
// partial (v5, card worker-context-handoff): 1 when the task's own outcome was still partial after its one
|
|
67
76
|
// resume (graceful degradation on a context limit, never a verification failure), 0 when it was not, null on a
|
|
@@ -77,8 +86,10 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
|
|
|
77
86
|
// ran under (`<class>/default` until exploration exists); assigned_by is `'policy'` or `'explore'`;
|
|
78
87
|
// explore_probability is the explore arm's assignment probability, null otherwise. backfilled is 1 only for a
|
|
79
88
|
// row `memory.mjs condition backfill` stamped after the fact, 0 for every other row (including an unstamped
|
|
80
|
-
// hand row, which has never been touched by either path).
|
|
81
|
-
//
|
|
89
|
+
// hand row, which has never been touched by either path).
|
|
90
|
+
// A stopped hand card report yields a stop row (stopOutcomeRow below): track 'hand', run 'stop:<report hash>', stop_class and
|
|
91
|
+
// stop_reason from the report, commit_sha null. On the flow track these seven columns are null: a flow
|
|
92
|
+
// row is never stamped by `condition open`/`record --hand` (its memory_config alone is stamped, see above).
|
|
82
93
|
const OUTCOMES_COLUMNS_SQL = `
|
|
83
94
|
id INTEGER PRIMARY KEY,
|
|
84
95
|
recorded TEXT NOT NULL,
|
|
@@ -124,6 +135,7 @@ const OUTCOMES_COLUMNS_SQL = `
|
|
|
124
135
|
assigned_by TEXT,
|
|
125
136
|
explore_probability REAL,
|
|
126
137
|
backfilled INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
memory_config TEXT,
|
|
127
139
|
UNIQUE(run, task)
|
|
128
140
|
`;
|
|
129
141
|
|
|
@@ -150,7 +162,10 @@ const LESSONS_COLUMNS_SQL = `
|
|
|
150
162
|
embedding_model TEXT,
|
|
151
163
|
embedding_dims INTEGER,
|
|
152
164
|
embedding BLOB,
|
|
153
|
-
stale TEXT
|
|
165
|
+
stale TEXT,
|
|
166
|
+
seen INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
archived TEXT,
|
|
168
|
+
archived_reason TEXT
|
|
154
169
|
`;
|
|
155
170
|
|
|
156
171
|
const LESSONS_TRIGGERS_SQL = `
|
|
@@ -423,6 +438,61 @@ function migrateV8ToV9(db) {
|
|
|
423
438
|
}
|
|
424
439
|
}
|
|
425
440
|
|
|
441
|
+
// v9 had no memory_config column on outcomes (card memory-outcome-arms): the canonical JSON of the memory
|
|
442
|
+
// configuration a row ran under, copied from condition.json's memoryConfig. Additive like v8->v9: one guarded
|
|
443
|
+
// ALTER, then the version bump; old rows stay null. OUTCOMES_COLUMNS_SQL carries memory_config for a v1 store's
|
|
444
|
+
// rebuild, so this step is a no-op there instead of a duplicate-column error.
|
|
445
|
+
const V10_MEMORY_COLUMNS = [["memory_config", "TEXT"]];
|
|
446
|
+
|
|
447
|
+
function migrateV9ToV10(db) {
|
|
448
|
+
db.exec("BEGIN");
|
|
449
|
+
try {
|
|
450
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
451
|
+
for (const [name, type] of V10_MEMORY_COLUMNS) {
|
|
452
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
|
|
453
|
+
}
|
|
454
|
+
db.exec("PRAGMA user_version = 10");
|
|
455
|
+
db.exec("COMMIT");
|
|
456
|
+
} catch (err) {
|
|
457
|
+
db.exec("ROLLBACK");
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// v10 had no seen column on lessons (card memory-reflect-supersede): how many times a reflect pass judged a
|
|
463
|
+
// proposal a repeat of the lesson. One guarded ALTER (LESSONS_COLUMNS_SQL already carries seen for a rebuilt
|
|
464
|
+
// store), then the version bump. seen is not in the no-rewrite trigger's column list.
|
|
465
|
+
function migrateV10ToV11(db) {
|
|
466
|
+
db.exec("BEGIN");
|
|
467
|
+
try {
|
|
468
|
+
const cols = db.prepare("PRAGMA table_info(lessons)").all().map((c) => c.name);
|
|
469
|
+
if (!cols.includes("seen")) db.exec("ALTER TABLE lessons ADD COLUMN seen INTEGER NOT NULL DEFAULT 0;");
|
|
470
|
+
db.exec("PRAGMA user_version = 11");
|
|
471
|
+
db.exec("COMMIT");
|
|
472
|
+
} catch (err) {
|
|
473
|
+
db.exec("ROLLBACK");
|
|
474
|
+
throw err;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// v11 had no archived status on lessons (card memory-feedback-rank): archived is an ISO time (NULL = live) and
|
|
479
|
+
// archived_reason names the counters that met the archive rule. Two guarded ALTERs (LESSONS_COLUMNS_SQL already
|
|
480
|
+
// carries both for a rebuilt store), then the version bump. Neither is in the no-rewrite trigger's column list,
|
|
481
|
+
// and the no-delete trigger is unchanged: an archived row is kept.
|
|
482
|
+
function migrateV11ToV12(db) {
|
|
483
|
+
db.exec("BEGIN");
|
|
484
|
+
try {
|
|
485
|
+
const cols = db.prepare("PRAGMA table_info(lessons)").all().map((c) => c.name);
|
|
486
|
+
if (!cols.includes("archived")) db.exec("ALTER TABLE lessons ADD COLUMN archived TEXT;");
|
|
487
|
+
if (!cols.includes("archived_reason")) db.exec("ALTER TABLE lessons ADD COLUMN archived_reason TEXT;");
|
|
488
|
+
db.exec("PRAGMA user_version = 12");
|
|
489
|
+
db.exec("COMMIT");
|
|
490
|
+
} catch (err) {
|
|
491
|
+
db.exec("ROLLBACK");
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
426
496
|
export function openMemory(dir) {
|
|
427
497
|
const file = memoryPath(dir);
|
|
428
498
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -431,7 +501,7 @@ export function openMemory(dir) {
|
|
|
431
501
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
432
502
|
if (version === 0) {
|
|
433
503
|
db.exec(SCHEMA_SQL);
|
|
434
|
-
db.exec("PRAGMA user_version =
|
|
504
|
+
db.exec("PRAGMA user_version = 12");
|
|
435
505
|
} else if (version === 1) {
|
|
436
506
|
migrateV1ToV2(db);
|
|
437
507
|
migrateV2ToV3(db);
|
|
@@ -441,6 +511,9 @@ export function openMemory(dir) {
|
|
|
441
511
|
migrateV6ToV7(db);
|
|
442
512
|
migrateV7ToV8(db);
|
|
443
513
|
migrateV8ToV9(db);
|
|
514
|
+
migrateV9ToV10(db);
|
|
515
|
+
migrateV10ToV11(db);
|
|
516
|
+
migrateV11ToV12(db);
|
|
444
517
|
} else if (version === 2) {
|
|
445
518
|
migrateV2ToV3(db);
|
|
446
519
|
migrateV3ToV4(db);
|
|
@@ -449,6 +522,9 @@ export function openMemory(dir) {
|
|
|
449
522
|
migrateV6ToV7(db);
|
|
450
523
|
migrateV7ToV8(db);
|
|
451
524
|
migrateV8ToV9(db);
|
|
525
|
+
migrateV9ToV10(db);
|
|
526
|
+
migrateV10ToV11(db);
|
|
527
|
+
migrateV11ToV12(db);
|
|
452
528
|
} else if (version === 3) {
|
|
453
529
|
migrateV3ToV4(db);
|
|
454
530
|
migrateV4ToV5(db);
|
|
@@ -456,29 +532,56 @@ export function openMemory(dir) {
|
|
|
456
532
|
migrateV6ToV7(db);
|
|
457
533
|
migrateV7ToV8(db);
|
|
458
534
|
migrateV8ToV9(db);
|
|
535
|
+
migrateV9ToV10(db);
|
|
536
|
+
migrateV10ToV11(db);
|
|
537
|
+
migrateV11ToV12(db);
|
|
459
538
|
} else if (version === 4) {
|
|
460
539
|
migrateV4ToV5(db);
|
|
461
540
|
migrateV5ToV6(db);
|
|
462
541
|
migrateV6ToV7(db);
|
|
463
542
|
migrateV7ToV8(db);
|
|
464
543
|
migrateV8ToV9(db);
|
|
544
|
+
migrateV9ToV10(db);
|
|
545
|
+
migrateV10ToV11(db);
|
|
546
|
+
migrateV11ToV12(db);
|
|
465
547
|
} else if (version === 5) {
|
|
466
548
|
migrateV5ToV6(db);
|
|
467
549
|
migrateV6ToV7(db);
|
|
468
550
|
migrateV7ToV8(db);
|
|
469
551
|
migrateV8ToV9(db);
|
|
552
|
+
migrateV9ToV10(db);
|
|
553
|
+
migrateV10ToV11(db);
|
|
554
|
+
migrateV11ToV12(db);
|
|
470
555
|
} else if (version === 6) {
|
|
471
556
|
migrateV6ToV7(db);
|
|
472
557
|
migrateV7ToV8(db);
|
|
473
558
|
migrateV8ToV9(db);
|
|
559
|
+
migrateV9ToV10(db);
|
|
560
|
+
migrateV10ToV11(db);
|
|
561
|
+
migrateV11ToV12(db);
|
|
474
562
|
} else if (version === 7) {
|
|
475
563
|
migrateV7ToV8(db);
|
|
476
564
|
migrateV8ToV9(db);
|
|
565
|
+
migrateV9ToV10(db);
|
|
566
|
+
migrateV10ToV11(db);
|
|
567
|
+
migrateV11ToV12(db);
|
|
477
568
|
} else if (version === 8) {
|
|
478
569
|
migrateV8ToV9(db);
|
|
479
|
-
|
|
570
|
+
migrateV9ToV10(db);
|
|
571
|
+
migrateV10ToV11(db);
|
|
572
|
+
migrateV11ToV12(db);
|
|
573
|
+
} else if (version === 9) {
|
|
574
|
+
migrateV9ToV10(db);
|
|
575
|
+
migrateV10ToV11(db);
|
|
576
|
+
migrateV11ToV12(db);
|
|
577
|
+
} else if (version === 10) {
|
|
578
|
+
migrateV10ToV11(db);
|
|
579
|
+
migrateV11ToV12(db);
|
|
580
|
+
} else if (version === 11) {
|
|
581
|
+
migrateV11ToV12(db);
|
|
582
|
+
} else if (version !== 12) {
|
|
480
583
|
db.close();
|
|
481
|
-
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version
|
|
584
|
+
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 12`);
|
|
482
585
|
}
|
|
483
586
|
return {
|
|
484
587
|
file,
|
|
@@ -512,7 +615,7 @@ export function probeSqliteFts5() {
|
|
|
512
615
|
// Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
|
|
513
616
|
export function inspectMemory(dir) {
|
|
514
617
|
const file = memoryPath(dir);
|
|
515
|
-
const out = { file, exists: false, userVersion: null, maxUserVersion:
|
|
618
|
+
const out = { file, exists: false, userVersion: null, maxUserVersion: 12, quickCheck: null, error: null };
|
|
516
619
|
if (!existsSync(file)) return out;
|
|
517
620
|
out.exists = true;
|
|
518
621
|
let db;
|
|
@@ -616,6 +719,20 @@ export function supersedeLesson(m, oldId, newId) {
|
|
|
616
719
|
return getLesson(m, oldId);
|
|
617
720
|
}
|
|
618
721
|
|
|
722
|
+
// The one live-lesson predicate: not superseded, not stale, not archived. Every reader of "live" lessons uses it.
|
|
723
|
+
export const LIVE_LESSON_SQL = "superseded_by IS NULL AND stale IS NULL AND archived IS NULL";
|
|
724
|
+
export const isLiveLesson = (row) => !row.superseded_by && !row.stale && !row.archived;
|
|
725
|
+
|
|
726
|
+
// Restores an archived lesson (card memory-archived-readers): clears archived and archived_reason, the way back
|
|
727
|
+
// from reflect --harmful's archive. Neither column is protected by lessons_no_rewrite; nothing is deleted.
|
|
728
|
+
export function unarchiveLesson(m, id) {
|
|
729
|
+
const row = getLesson(m, id);
|
|
730
|
+
if (!row) throw new Error(`lesson ${id} does not exist`);
|
|
731
|
+
if (!row.archived) throw new Error(`lesson ${id} is not archived`);
|
|
732
|
+
m.prepare("UPDATE lessons SET archived = NULL, archived_reason = NULL WHERE id = ?").run(id);
|
|
733
|
+
return getLesson(m, id);
|
|
734
|
+
}
|
|
735
|
+
|
|
619
736
|
// Clears a stale mark (set by recallLessons's citation re-check) so the lesson is a candidate again — the way
|
|
620
737
|
// back once a checkout is fixed up or the mark turns out to be wrong. `stale` is not one of the columns the
|
|
621
738
|
// lessons_no_rewrite trigger protects, so this is an ordinary UPDATE, not a special case.
|
|
@@ -633,6 +750,42 @@ export function listLessons(m, { includeSuperseded = false } = {}) {
|
|
|
633
750
|
return rows.map(lessonRowToObject);
|
|
634
751
|
}
|
|
635
752
|
|
|
753
|
+
// Ids of every lesson fetched with `lesson show --card`: the {"id": ...} lines of every
|
|
754
|
+
// <dir>/.doug/.state/reports/*/fetched.jsonl. Never throws; unreadable files and bad lines are skipped.
|
|
755
|
+
export function readFetchedLessonIds(dir) {
|
|
756
|
+
const ids = new Set();
|
|
757
|
+
let cards = [];
|
|
758
|
+
try {
|
|
759
|
+
cards = readdirSync(join(dir, ".doug", ".state", "reports"));
|
|
760
|
+
} catch {
|
|
761
|
+
return ids;
|
|
762
|
+
}
|
|
763
|
+
for (const card of cards) {
|
|
764
|
+
let text;
|
|
765
|
+
try {
|
|
766
|
+
text = readFileSync(join(dir, ".doug", ".state", "reports", card, "fetched.jsonl"), "utf8");
|
|
767
|
+
} catch {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
for (const line of text.split("\n")) {
|
|
771
|
+
try {
|
|
772
|
+
const id = JSON.parse(line)?.id;
|
|
773
|
+
if (typeof id === "string") ids.add(id);
|
|
774
|
+
} catch {}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return ids;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Live lessons no run has confirmed (ADR 0019): helpful = 0 and harmful = 0 and never fetched. Read-only.
|
|
781
|
+
export function listUnconfirmedLessons(m, { fetchedIds = new Set() } = {}) {
|
|
782
|
+
return m
|
|
783
|
+
.prepare(`SELECT id, kind, created, text FROM lessons WHERE ${LIVE_LESSON_SQL} AND helpful = 0 AND harmful = 0 ORDER BY created ASC, id ASC`)
|
|
784
|
+
.all()
|
|
785
|
+
.filter((r) => !fetchedIds.has(r.id))
|
|
786
|
+
.map((r) => ({ id: r.id, kind: r.kind, created: r.created, title: lessonTitle(r.text) }));
|
|
787
|
+
}
|
|
788
|
+
|
|
636
789
|
// Whitespace-split tokens, each quoted for FTS5 MATCH and OR'd together; shared by searchLessons and
|
|
637
790
|
// recallLessons's BM25 side, and by lib/code-index.mjs's searchIndex (same FTS5 dialect, one code_chunks_fts
|
|
638
791
|
// table instead of lessons_fts). Returns null for an empty/whitespace-only query (nothing to match).
|
|
@@ -698,7 +851,7 @@ export function lessonsNeedingEmbedding(m, { model, dims }) {
|
|
|
698
851
|
const rows = m
|
|
699
852
|
.prepare(
|
|
700
853
|
`SELECT * FROM lessons
|
|
701
|
-
WHERE
|
|
854
|
+
WHERE ${LIVE_LESSON_SQL}
|
|
702
855
|
AND (embedding IS NULL OR embedding_model IS NOT ? OR embedding_dims IS NOT ?)
|
|
703
856
|
ORDER BY created ASC, id ASC`
|
|
704
857
|
)
|
|
@@ -930,20 +1083,21 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
|
|
|
930
1083
|
// No live row holds a vector under this model/dims: nothing to decode, and no blob is read.
|
|
931
1084
|
const any = m
|
|
932
1085
|
.prepare(
|
|
933
|
-
|
|
1086
|
+
`SELECT 1 AS x FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ? LIMIT 1`
|
|
934
1087
|
)
|
|
935
1088
|
.get(provider.model, provider.dims);
|
|
936
1089
|
if (!any) return null;
|
|
937
1090
|
|
|
938
1091
|
const stmt = m.prepare(
|
|
939
|
-
`SELECT id, created, confirmed, scope,
|
|
1092
|
+
`SELECT id, created, confirmed, scope, harmful, helpful, citation,
|
|
940
1093
|
CASE WHEN embedding_model = ? AND embedding_dims = ? THEN embedding END AS emb
|
|
941
1094
|
FROM lessons
|
|
942
|
-
WHERE
|
|
1095
|
+
WHERE ${LIVE_LESSON_SQL} AND ${SQL_OUTSIDE_WINDOW} = 0`
|
|
943
1096
|
);
|
|
944
1097
|
const ids = [];
|
|
945
1098
|
const anchorMs = [];
|
|
946
1099
|
const scopeRaw = [];
|
|
1100
|
+
const fbInfo = [];
|
|
947
1101
|
const vecs = [];
|
|
948
1102
|
let bytes = 0;
|
|
949
1103
|
const cutoffMs = (cutoffJd - 2440587.5) * 86400000;
|
|
@@ -952,6 +1106,7 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
|
|
|
952
1106
|
ids.push(r.id);
|
|
953
1107
|
anchorMs.push(new Date(r.confirmed || r.created).getTime());
|
|
954
1108
|
scopeRaw.push(r.scope);
|
|
1109
|
+
fbInfo.push({ harmful: r.harmful, helpful: r.helpful, citation: r.citation });
|
|
955
1110
|
const vec = blobToFloats(r.emb);
|
|
956
1111
|
vecs.push(vec);
|
|
957
1112
|
if (vec) bytes += vec.byteLength;
|
|
@@ -964,6 +1119,7 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
|
|
|
964
1119
|
ids,
|
|
965
1120
|
anchorMs: Float64Array.from(anchorMs),
|
|
966
1121
|
scopeRaw,
|
|
1122
|
+
fbInfo,
|
|
967
1123
|
vecs,
|
|
968
1124
|
live: new Uint8Array(ids.length).fill(1),
|
|
969
1125
|
bytes,
|
|
@@ -1007,7 +1163,7 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
|
|
|
1007
1163
|
// count outside the window by exactly ageDaysOf's rule, the live total, and the oldest hidden age in whole days.
|
|
1008
1164
|
export function windowHidden(m, { staleDays = 30, now = new Date() } = {}) {
|
|
1009
1165
|
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
1010
|
-
const rows = m.prepare(
|
|
1166
|
+
const rows = m.prepare(`SELECT created, confirmed FROM lessons WHERE ${LIVE_LESSON_SQL}`).all();
|
|
1011
1167
|
let count = 0;
|
|
1012
1168
|
let oldest = -Infinity;
|
|
1013
1169
|
for (const r of rows) {
|
|
@@ -1035,8 +1191,51 @@ export function windowNote(hidden, staleDays = 30) {
|
|
|
1035
1191
|
// returned unchecked (checked: false in the result) rather than either throwing (no checkoutDir given) or
|
|
1036
1192
|
// marking every row stale forever (a wrong one given). Never throws for a provider or checkout problem (a
|
|
1037
1193
|
// database problem may still throw, same as every other export here). See design #2 in the memory-recall card.
|
|
1194
|
+
// Feedback in recall scoring (card memory-feedback-rank, ADR 0019). harmful (the lead's explicit mark) and the
|
|
1195
|
+
// lesson's evidence class (by citation) multiply the score; helpful enters only as a capped, optionally decayed
|
|
1196
|
+
// term that ships off (helpfulCap 0), because it rises on every verified use and would feed itself. harmful 0,
|
|
1197
|
+
// weight 1 and helpfulCap 0 leave the score exactly as before. The multiplier is applied to the score's
|
|
1198
|
+
// magnitude (score + |score| * (factor - 1)) so a negative cosine is lowered by a factor under 1, not raised.
|
|
1199
|
+
export const RECALL_FEEDBACK = {
|
|
1200
|
+
harmfulPenalty: 0.01,
|
|
1201
|
+
helpfulCap: 0,
|
|
1202
|
+
helpfulStep: 0.05,
|
|
1203
|
+
helpfulDecay: 0,
|
|
1204
|
+
evidenceWeights: { report: 1, history: 1, file: 1, none: 1 },
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
export function evidenceClassOf(lesson) {
|
|
1208
|
+
const c = lesson && typeof lesson.citation === "string" ? lesson.citation : "";
|
|
1209
|
+
if (!c) return "none";
|
|
1210
|
+
if (c.startsWith("report:")) return "report";
|
|
1211
|
+
if (c.startsWith("history:")) return "history";
|
|
1212
|
+
return "file";
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function mergeFeedback(over) {
|
|
1216
|
+
const o = over || {};
|
|
1217
|
+
return { ...RECALL_FEEDBACK, ...o, evidenceWeights: { ...RECALL_FEEDBACK.evidenceWeights, ...(o.evidenceWeights || {}) } };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function feedbackScore(score, info, fb) {
|
|
1221
|
+
if (!info) return score;
|
|
1222
|
+
const harmful = Number(info.harmful) || 0;
|
|
1223
|
+
let factor = 1;
|
|
1224
|
+
if (harmful > 0) factor *= 1 / (1 + fb.harmfulPenalty * harmful);
|
|
1225
|
+
const w = fb.evidenceWeights[evidenceClassOf(info)];
|
|
1226
|
+
if (w !== undefined && w !== 1) factor *= w;
|
|
1227
|
+
if (fb.helpfulCap > 0) {
|
|
1228
|
+
const h = Math.max(0, Number(info.helpful) || 0);
|
|
1229
|
+
const d = fb.helpfulDecay;
|
|
1230
|
+
const eff = d > 0 && d < 1 ? (1 - Math.pow(d, h)) / (1 - d) : h;
|
|
1231
|
+
factor *= 1 + fb.helpfulStep * Math.min(eff, fb.helpfulCap);
|
|
1232
|
+
}
|
|
1233
|
+
return factor === 1 ? score : score + Math.abs(score) * (factor - 1);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1038
1236
|
export async function recallLessons(m, query, opts = {}) {
|
|
1039
1237
|
const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30, detectedProvider = null } = opts;
|
|
1238
|
+
const fb = mergeFeedback(opts.feedback);
|
|
1040
1239
|
const nowDate = now instanceof Date ? now : new Date(now);
|
|
1041
1240
|
const nowIsoStr = nowDate.toISOString();
|
|
1042
1241
|
const nowMs = nowDate.getTime();
|
|
@@ -1050,10 +1249,10 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1050
1249
|
if (match) {
|
|
1051
1250
|
const rows = m
|
|
1052
1251
|
.prepare(
|
|
1053
|
-
`SELECT l.id, l.created, l.confirmed, l.scope,
|
|
1252
|
+
`SELECT l.id, l.created, l.confirmed, l.scope, l.harmful, l.helpful, l.citation,
|
|
1054
1253
|
(l.embedding IS NOT NULL AND l.embedding_model = ? AND l.embedding_dims = ?) AS has_match
|
|
1055
1254
|
FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
1056
|
-
WHERE lessons_fts MATCH ? AND
|
|
1255
|
+
WHERE lessons_fts MATCH ? AND ${LIVE_LESSON_SQL}
|
|
1057
1256
|
ORDER BY bm25(lessons_fts) LIMIT 50`
|
|
1058
1257
|
)
|
|
1059
1258
|
.all(provider ? provider.model : null, provider ? provider.dims : null, match);
|
|
@@ -1061,7 +1260,7 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1061
1260
|
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1062
1261
|
if (!(age <= staleDays)) continue;
|
|
1063
1262
|
bm25Ranked.push(r.id);
|
|
1064
|
-
bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age });
|
|
1263
|
+
bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age, fb: { harmful: r.harmful, helpful: r.helpful, citation: r.citation } });
|
|
1065
1264
|
}
|
|
1066
1265
|
}
|
|
1067
1266
|
|
|
@@ -1123,7 +1322,7 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1123
1322
|
// model, or the caller detected a usable provider; hybrid reports how many live in-window rows lack a vector.
|
|
1124
1323
|
// The existence check is bounded and only runs when no other term already decided it.
|
|
1125
1324
|
const hasAnyVector = (db) =>
|
|
1126
|
-
!!db.prepare(
|
|
1325
|
+
!!db.prepare(`SELECT 1 AS x FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding IS NOT NULL LIMIT 1`).get();
|
|
1127
1326
|
const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
|
|
1128
1327
|
const coverage = {};
|
|
1129
1328
|
if (mode === "hybrid") {
|
|
@@ -1147,21 +1346,27 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1147
1346
|
// object per stored vector.
|
|
1148
1347
|
const bm25Only = (id) => {
|
|
1149
1348
|
const info = bm25Info.get(id);
|
|
1150
|
-
return { id, score: weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), sides: ["bm25"] };
|
|
1349
|
+
return { id, score: feedbackScore(weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), info.fb, fb), sides: ["bm25"] };
|
|
1151
1350
|
};
|
|
1152
1351
|
let candidates;
|
|
1153
1352
|
if (mode === "hybrid") {
|
|
1154
1353
|
// (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
|
|
1155
1354
|
const order = Int32Array.from(cosine);
|
|
1156
1355
|
const scores = new Float64Array(rows.n);
|
|
1157
|
-
for (const r of cosine) scores[r] = weigh(ages[r], rows.scopeRaw[r], cosineScores[r]);
|
|
1356
|
+
for (const r of cosine) scores[r] = feedbackScore(weigh(ages[r], rows.scopeRaw[r], cosineScores[r]), rows.fbInfo[r], fb);
|
|
1158
1357
|
order.sort((x, y) => scores[y] - scores[x] || idCmp(rows.ids[x], rows.ids[y]));
|
|
1159
1358
|
const dense = { length: order.length, at: (j) => {
|
|
1160
1359
|
const r = order[j];
|
|
1161
1360
|
const id = rows.ids[r];
|
|
1162
1361
|
return { id, score: scores[r], sides: bm25Rank.has(id) ? ["bm25", "vector"] : ["vector"] };
|
|
1163
1362
|
} };
|
|
1164
|
-
|
|
1363
|
+
// The tail keeps BM25 order (not the recency-weighted score), reordered only by the feedback factor: the sort
|
|
1364
|
+
// key is the rank score 1/(60+rank) through feedbackScore, so a harmful twin falls below its harmful-0 twin.
|
|
1365
|
+
const appended = bm25Ranked
|
|
1366
|
+
.filter((id) => !bm25Info.get(id).hasMatch)
|
|
1367
|
+
.map((id, pos) => ({ cand: bm25Only(id), pos, key: feedbackScore(1 / (60 + bm25Rank.get(id) + 1), bm25Info.get(id).fb, fb) }))
|
|
1368
|
+
.sort((a, b) => b.key - a.key || a.pos - b.pos)
|
|
1369
|
+
.map((x) => x.cand);
|
|
1165
1370
|
candidates = {
|
|
1166
1371
|
length: dense.length + appended.length,
|
|
1167
1372
|
at: (j) => (j < dense.length ? dense.at(j) : appended[j - dense.length]),
|
|
@@ -1259,6 +1464,72 @@ export function renderLessons(lessons) {
|
|
|
1259
1464
|
return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
|
|
1260
1465
|
}
|
|
1261
1466
|
|
|
1467
|
+
// The lesson index (card memory-progressive-access, ADR 0019 retrieval): one line per lesson in rank order carrying
|
|
1468
|
+
// the id, a one-line title, the kind and the token cost of the WHOLE text (chars / 4, the repo's rule), so the lead
|
|
1469
|
+
// can fetch the full text of the lessons a card needs with `memory.mjs lesson show <id>`. A line that would push the
|
|
1470
|
+
// running total past `cap` is skipped (not stopped at) and its id is reported in `omitted`: no silent filter.
|
|
1471
|
+
const INDEX_TITLE_CHARS = 100;
|
|
1472
|
+
export const lessonTokens = (text) => Math.ceil(String(text).length / 4);
|
|
1473
|
+
export function lessonTitle(text) {
|
|
1474
|
+
const first = String(text).split("\n").find((l) => l.trim() !== "") ?? "";
|
|
1475
|
+
const line = first.trim().replace(/\s+/g, " ");
|
|
1476
|
+
if (line.length <= INDEX_TITLE_CHARS) return line;
|
|
1477
|
+
const slice = line.slice(0, INDEX_TITLE_CHARS);
|
|
1478
|
+
const sp = slice.lastIndexOf(" ");
|
|
1479
|
+
return (sp > 0 ? slice.slice(0, sp) : slice).trimEnd() + "…";
|
|
1480
|
+
}
|
|
1481
|
+
export function renderLessonIndex(lessons, { cap = LESSONS_CHAR_CAP } = {}) {
|
|
1482
|
+
const text = [];
|
|
1483
|
+
const ids = [];
|
|
1484
|
+
const omitted = [];
|
|
1485
|
+
let length = 0;
|
|
1486
|
+
for (const lesson of Array.isArray(lessons) ? lessons : []) {
|
|
1487
|
+
const line = `- [${lesson.id}] ${lessonTitle(lesson.text)} (${lesson.kind}, ~${lessonTokens(lesson.text)} tokens)`;
|
|
1488
|
+
const nextLength = length + (text.length ? 1 : 0) + line.length;
|
|
1489
|
+
if (nextLength > cap) {
|
|
1490
|
+
omitted.push(lesson.id);
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
text.push(line);
|
|
1494
|
+
ids.push(lesson.id);
|
|
1495
|
+
length = nextLength;
|
|
1496
|
+
}
|
|
1497
|
+
return { text: text.length ? text.join("\n") : null, ids, omitted };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// The flow track's delivery (card memory-atomic-lessons): the index, then the full text of the top lessons in
|
|
1501
|
+
// rank order up to FLOW_FETCH_TOKENS (chars / 4, rounded up, per lesson). A lesson whose full text would pass the
|
|
1502
|
+
// remaining budget is skipped (not stopped at), stays in the index, is named on a `not fetched` line and is never
|
|
1503
|
+
// cut. Fetched lessons use the `lesson show` form. `ids` are the FETCHED ids in rank order (what the run credits as
|
|
1504
|
+
// used), `indexIds` every indexed id, `omitted` the ids the index cap dropped, `unfetched` the indexed ids over budget.
|
|
1505
|
+
export const FLOW_FETCH_TOKENS = 4000;
|
|
1506
|
+
export function renderFlowLessons(lessons, { indexCap = LESSONS_CHAR_CAP, fetchTokens = FLOW_FETCH_TOKENS } = {}) {
|
|
1507
|
+
const index = renderLessonIndex(lessons, { cap: indexCap });
|
|
1508
|
+
if (index.text === null) return { text: null, ids: [], indexIds: [], omitted: index.omitted, unfetched: [] };
|
|
1509
|
+
const byId = new Map((Array.isArray(lessons) ? lessons : []).map((l) => [l.id, l]));
|
|
1510
|
+
const fetched = [];
|
|
1511
|
+
const unfetched = [];
|
|
1512
|
+
let used = 0;
|
|
1513
|
+
for (const id of index.ids) {
|
|
1514
|
+
const lesson = byId.get(id);
|
|
1515
|
+
const tokens = lessonTokens(lesson.text);
|
|
1516
|
+
if (used + tokens > fetchTokens) {
|
|
1517
|
+
unfetched.push(id);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
used += tokens;
|
|
1521
|
+
fetched.push(lesson);
|
|
1522
|
+
}
|
|
1523
|
+
const parts = [index.text];
|
|
1524
|
+
if (index.omitted.length) parts.push(`index: omitted ${index.omitted.length} lessons over the ${indexCap}-character index budget: ${index.omitted.join(", ")}`);
|
|
1525
|
+
if (unfetched.length) parts.push(`not fetched (over the ~${fetchTokens}-token fetch budget): ${unfetched.join(", ")}`);
|
|
1526
|
+
let text = parts.join("\n");
|
|
1527
|
+
if (fetched.length) {
|
|
1528
|
+
text += "\n\n" + fetched.map((l) => `lesson ${l.id} (${l.kind}, ~${lessonTokens(l.text)} tokens)\ncitation: ${l.citation || "none"}\n\n${l.text}`).join("\n\n");
|
|
1529
|
+
}
|
|
1530
|
+
return { text, ids: fetched.map((l) => l.id), indexIds: index.ids, omitted: index.omitted, unfetched };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1262
1533
|
// The lessons a session starts with when no card query exists (card memory-every-session): live, non-stale
|
|
1263
1534
|
// lessons confirmed-or-created within staleDays, ordered by helpful minus harmful desc, then confirmed-or-created
|
|
1264
1535
|
// desc, then id ascending. No provider, no citation re-check, nothing written.
|
|
@@ -1268,7 +1539,7 @@ export function topLessons(m, { k = 5, staleDays = 30, now = new Date() } = {})
|
|
|
1268
1539
|
.prepare(
|
|
1269
1540
|
`SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
|
|
1270
1541
|
helpful, harmful, superseded_by, stale
|
|
1271
|
-
FROM lessons WHERE
|
|
1542
|
+
FROM lessons WHERE ${LIVE_LESSON_SQL}`
|
|
1272
1543
|
)
|
|
1273
1544
|
.all()
|
|
1274
1545
|
.map(lessonRowToObject)
|
|
@@ -1424,7 +1695,7 @@ function findPlanTask(plan, id) {
|
|
|
1424
1695
|
}
|
|
1425
1696
|
|
|
1426
1697
|
export function outcomeRows(report, opts) {
|
|
1427
|
-
const { plan = null, classes = [], run = null, reportHash, reportPath = null, card = null, commit = null, cost = null, now = nowIso() } = opts || {};
|
|
1698
|
+
const { plan = null, classes = [], run = null, reportHash, reportPath = null, card = null, commit = null, cost = null, now = nowIso(), memoryConfig: flowMemoryConfig = null } = opts || {};
|
|
1428
1699
|
const allBlocks = classifyBlocks(report, classes).map((b) => ({
|
|
1429
1700
|
id: b.id,
|
|
1430
1701
|
pass: b.pass,
|
|
@@ -1494,8 +1765,9 @@ export function outcomeRows(report, opts) {
|
|
|
1494
1765
|
gate: null,
|
|
1495
1766
|
note: null,
|
|
1496
1767
|
partial: toIntOrNull(task.partial),
|
|
1497
|
-
// The seven condition columns (card outcomes-condition-columns) are
|
|
1498
|
-
//
|
|
1768
|
+
// The seven condition columns (card outcomes-condition-columns) are null on a flow row — see the comment
|
|
1769
|
+
// above OUTCOMES_COLUMNS_SQL; memory_config is the flow stamp when the caller passes one (card
|
|
1770
|
+
// memory-flow-index-fetch). backfilled is 0, never null: it is NOT NULL DEFAULT 0.
|
|
1499
1771
|
harness_commit: null,
|
|
1500
1772
|
config_hash: null,
|
|
1501
1773
|
class: null,
|
|
@@ -1503,6 +1775,7 @@ export function outcomeRows(report, opts) {
|
|
|
1503
1775
|
assigned_by: null,
|
|
1504
1776
|
explore_probability: null,
|
|
1505
1777
|
backfilled: 0,
|
|
1778
|
+
memory_config: flowMemoryConfig ? JSON.stringify(sortKeysDeep(flowMemoryConfig)) : null,
|
|
1506
1779
|
});
|
|
1507
1780
|
}
|
|
1508
1781
|
}
|
|
@@ -1600,29 +1873,322 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1600
1873
|
assigned_by: condition ? condition.assignedBy ?? null : null,
|
|
1601
1874
|
explore_probability: condition ? condition.exploreProbability ?? null : null,
|
|
1602
1875
|
backfilled: 0,
|
|
1876
|
+
memory_config: condition && condition.memoryConfig ? JSON.stringify(sortKeysDeep(condition.memoryConfig)) : null,
|
|
1603
1877
|
};
|
|
1604
1878
|
}
|
|
1605
1879
|
|
|
1880
|
+
function sortKeysDeep(v) {
|
|
1881
|
+
if (Array.isArray(v)) return v.map(sortKeysDeep);
|
|
1882
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeysDeep(v[k])]));
|
|
1883
|
+
return v;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// The memory configuration an outcome row is stamped with (card memory-outcome-arms, ADR 0019 Measurement), so
|
|
1887
|
+
// outcomes can be compared per configuration. Pure: the caller reads the project config. Excludes baseUrl and
|
|
1888
|
+
// apiKeyEnv (where the provider lives, not what it computes). MEMORY_STAGE names the recall/ranking/delivery
|
|
1889
|
+
// generation: a card that changes recall or ranking for every track bumps it, which starts a new arm. A change to one
|
|
1890
|
+
// track's delivery alone (card memory-atomic-lessons: the flow track) changes that track's stamp fields instead.
|
|
1891
|
+
export const MEMORY_STAGE = "progressive-access";
|
|
1892
|
+
|
|
1893
|
+
// `track` names the delivery the row's briefs actually got (card memory-flow-index-fetch): a hand card's briefs
|
|
1894
|
+
// carry the index plus a lead fetch; a flow task's briefs (plan.mjs) carry the index plus the full text of the top
|
|
1895
|
+
// lessons up to FLOW_FETCH_TOKENS (card memory-atomic-lessons), told apart from the hand stamp by fetch/fetchTokens. Any other track throws
|
|
1896
|
+
// rather than claiming a delivery.
|
|
1897
|
+
export function memoryConfig({ embeddings = null, staleDays = 30, track = "hand" } = {}) {
|
|
1898
|
+
if (track !== "hand" && track !== "flow") throw new Error(`memoryConfig: unknown track ${JSON.stringify(track)}`);
|
|
1899
|
+
const emb = embeddings ? { provider: embeddings.provider ?? null, model: embeddings.model ?? null, dims: embeddings.dims ?? null } : null;
|
|
1900
|
+
if (track === "flow") {
|
|
1901
|
+
return { stage: MEMORY_STAGE, embeddings: emb, staleDays, k: 8, delivery: "index+fetch", indexChars: LESSONS_CHAR_CAP, fetch: "top-k", fetchTokens: FLOW_FETCH_TOKENS };
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
stage: MEMORY_STAGE,
|
|
1905
|
+
embeddings: emb,
|
|
1906
|
+
staleDays,
|
|
1907
|
+
k: 8,
|
|
1908
|
+
delivery: "index+fetch",
|
|
1909
|
+
indexChars: LESSONS_CHAR_CAP,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// One stopped hand card report, one row (card memory-record-stops, ADR 0019 gap 3): run = 'stop:<first 12 hex of
|
|
1914
|
+
// the report hash>', so recording the same report again updates the same row, a different stop of the card adds
|
|
1915
|
+
// a row, and a later landing ('hand:<commit>') sits beside it. stop_class is the report's typed `reason` and
|
|
1916
|
+
// stop_reason its prose `detail` (the reason when detail is blank). `blocks` are the blocks.jsonl lines
|
|
1917
|
+
// attributed to the run, kept as {at, hook, agent, class, reason}. Every other column is null as on handOutcomeRow.
|
|
1918
|
+
export function stopOutcomeRow({ card, report, reportHash, reportPath = null, run = null, blocks = [], now = nowIso(), condition = null, memoryConfig: stamp = null } = {}) {
|
|
1919
|
+
if (!card) throw new Error("stopOutcomeRow requires card");
|
|
1920
|
+
if (!report || report.outcome !== "stopped") throw new Error("stopOutcomeRow requires a stopped report");
|
|
1921
|
+
if (!reportHash) throw new Error("stopOutcomeRow requires reportHash");
|
|
1922
|
+
const detail = typeof report.detail === "string" && report.detail.trim() !== "" ? report.detail : null;
|
|
1923
|
+
return {
|
|
1924
|
+
...handOutcomeRow({ card, commit: "x", now, condition }),
|
|
1925
|
+
...(!(condition && condition.memoryConfig) && stamp ? { memory_config: JSON.stringify(sortKeysDeep(stamp)) } : {}),
|
|
1926
|
+
run: `stop:${String(reportHash).slice(0, 12)}`,
|
|
1927
|
+
run_id: run ?? null,
|
|
1928
|
+
report_hash: reportHash,
|
|
1929
|
+
report_path: reportPath,
|
|
1930
|
+
commit_sha: null,
|
|
1931
|
+
stop_class: report.reason ?? null,
|
|
1932
|
+
stop_reason: detail ?? report.reason ?? null,
|
|
1933
|
+
blocks: blocks.map((b) => ({ at: b.at ?? null, hook: b.hook ?? null, agent: b.agent ?? null, class: b.class ?? null, reason: b.reason ?? null })),
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// The agent id a blocks.jsonl line belongs to: a string `agentId` field, else the id in a subagent-stop reason.
|
|
1938
|
+
export function blockAgentId(line) {
|
|
1939
|
+
if (!line || typeof line !== "object") return null;
|
|
1940
|
+
if (typeof line.agentId === "string" && line.agentId !== "") return line.agentId;
|
|
1941
|
+
const m = typeof line.reason === "string" ? /Subagent \S+ \(([^)\s]+)\)/.exec(line.reason) : null;
|
|
1942
|
+
return m ? m[1] : null;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function parseJsonLines(text) {
|
|
1946
|
+
const out = [];
|
|
1947
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
1948
|
+
if (raw.trim() === "") continue;
|
|
1949
|
+
try {
|
|
1950
|
+
const v = JSON.parse(raw);
|
|
1951
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) out.push(v);
|
|
1952
|
+
} catch {
|
|
1953
|
+
// malformed line: skipped
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return out;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Every agentId on any parseable line of the given queen journal texts.
|
|
1960
|
+
export function runAgentIds(journalTexts) {
|
|
1961
|
+
const ids = new Set();
|
|
1962
|
+
for (const t of journalTexts ?? []) for (const v of parseJsonLines(t)) if (typeof v.agentId === "string" && v.agentId !== "") ids.add(v.agentId);
|
|
1963
|
+
return ids;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// The parsed blocks.jsonl lines whose agent id is one of the run's, in file order. A main-session line has none.
|
|
1967
|
+
export function attributeBlocks(ledgerText, agentIds) {
|
|
1968
|
+
return parseJsonLines(ledgerText).filter((v) => {
|
|
1969
|
+
const id = blockAgentId(v);
|
|
1970
|
+
return id !== null && agentIds.has(id);
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Run history (card memory-run-history, ADR 0019 L0): an append-only file per card run under
|
|
1975
|
+
// .doug/.state/history/<card>/<run>.jsonl. Capture depth (ADR 0019 line 30, Open there, decided here for the artifact
|
|
1976
|
+
// slice): copy the text of Doug's own run artifacts (card report, attributed gate blocks and trace denials, each
|
|
1977
|
+
// seat's hand-back from the queen journal, each seat's brief re-rendered by replaying lib/card-loop.mjs); never copy
|
|
1978
|
+
// Claude Code transcripts and never reference into the Claude Code store. Each entry is secret-scanned on its WHOLE
|
|
1979
|
+
// text first (a hit replaces the entire entry), then capped at HISTORY_ENTRY_MAX_BYTES keeping the head; each run
|
|
1980
|
+
// file is capped at HISTORY_RUN_MAX_BYTES with a final 'cap' line counting the dropped entries (no silent filter).
|
|
1981
|
+
// No age bound: files are never deleted or rewritten.
|
|
1982
|
+
export const HISTORY_DIR_RELPATH = ".doug/.state/history";
|
|
1983
|
+
export const HISTORY_ENTRY_MAX_BYTES = 65536;
|
|
1984
|
+
export const HISTORY_RUN_MAX_BYTES = 1048576;
|
|
1985
|
+
|
|
1986
|
+
export function historyEntry({ kind, seat = null, round = null, source, value }, { scan, maxBytes = HISTORY_ENTRY_MAX_BYTES } = {}) {
|
|
1987
|
+
const text0 = typeof value === "string" ? value : String(JSON.stringify(value === undefined ? null : value, null, 2));
|
|
1988
|
+
const bytes = Buffer.byteLength(text0, "utf8");
|
|
1989
|
+
const base = { kind, seat, round, source, bytes };
|
|
1990
|
+
let redacted = null;
|
|
1991
|
+
if (typeof scan !== "function") redacted = "no-scanner";
|
|
1992
|
+
else {
|
|
1993
|
+
try {
|
|
1994
|
+
const hit = scan(text0);
|
|
1995
|
+
if (hit) redacted = typeof hit.rule === "string" && hit.rule !== "" ? hit.rule : "secret";
|
|
1996
|
+
} catch {
|
|
1997
|
+
redacted = "scan-error";
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (redacted !== null) return { ...base, truncated: false, redacted, text: `[redacted: ${redacted}]` };
|
|
2001
|
+
if (bytes <= maxBytes) return { ...base, truncated: false, redacted: null, text: text0 };
|
|
2002
|
+
const buf = Buffer.from(text0, "utf8");
|
|
2003
|
+
let cut = maxBytes;
|
|
2004
|
+
while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--;
|
|
2005
|
+
return { ...base, truncated: true, redacted: null, text: buf.subarray(0, cut).toString("utf8") };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Seat runs of a queen Workflow journal, in started-line order: {seat, round, agentId, result}.
|
|
2009
|
+
export function journalSeatRuns(journalText) {
|
|
2010
|
+
try {
|
|
2011
|
+
const started = [];
|
|
2012
|
+
const results = new Map();
|
|
2013
|
+
for (const v of parseJsonLines(journalText)) {
|
|
2014
|
+
if (v.type === "started" && typeof v.key === "string") started.push(v);
|
|
2015
|
+
else if (v.type === "result" && typeof v.key === "string") results.set(v.key, v.result);
|
|
2016
|
+
}
|
|
2017
|
+
return started.map((v) => {
|
|
2018
|
+
const parts = String(v.label ?? "").split(":");
|
|
2019
|
+
const last = parts[parts.length - 1];
|
|
2020
|
+
const round = /^\d+$/.test(last) ? Number(last) : null;
|
|
2021
|
+
return { seat: parts[0], round, agentId: v.agentId ?? null, result: results.has(v.key) ? results.get(v.key) ?? null : null };
|
|
2022
|
+
});
|
|
2023
|
+
} catch {
|
|
2024
|
+
return [];
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// Each seat run's brief, re-rendered by replaying the card-loop contract over the runs' hand-backs; null from the
|
|
2029
|
+
// first seat that does not match the contract's next seat (or on any error).
|
|
2030
|
+
export function replayBriefs(queenArgs, seatRuns) {
|
|
2031
|
+
const runs = Array.isArray(seatRuns) ? seatRuns : [];
|
|
2032
|
+
const out = runs.map(() => null);
|
|
2033
|
+
try {
|
|
2034
|
+
if (queenArgs === null || typeof queenArgs !== "object" || queenArgs.card === null || typeof queenArgs.card !== "object") return out;
|
|
2035
|
+
let s = initialState(queenArgs.card, queenArgs.options);
|
|
2036
|
+
for (let i = 0; i < runs.length; i++) {
|
|
2037
|
+
const a = currentAction(s);
|
|
2038
|
+
if (a.kind !== "seat" || a.seat !== runs[i].seat) break;
|
|
2039
|
+
out[i] = briefPrompt(a.brief, { repoRoot: queenArgs.repoRoot, worktreePath: queenArgs.worktreePath });
|
|
2040
|
+
s = cardLoopNext(s, runs[i].result).state;
|
|
2041
|
+
}
|
|
2042
|
+
} catch {
|
|
2043
|
+
// out keeps null from the failing seat on
|
|
2044
|
+
}
|
|
2045
|
+
return out;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// The parsed trace lines that are PermissionDenied events of one of the run's agents, in file order.
|
|
2049
|
+
export function attributeDenials(traceText, agentIds) {
|
|
2050
|
+
try {
|
|
2051
|
+
return parseJsonLines(traceText).filter((v) => v.event === "PermissionDenied" && typeof v.agent === "string" && agentIds.has(v.agent));
|
|
2052
|
+
} catch {
|
|
2053
|
+
return [];
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const historySafe = (x) => String(x).replace(/[^A-Za-z0-9_.-]/g, "_").replace(/\.{2,}/g, "_").slice(0, 120) || "_";
|
|
2058
|
+
|
|
2059
|
+
// Writes <dir>/.doug/.state/history/<card>/<run>.jsonl once; an existing file is never touched.
|
|
2060
|
+
export function writeRunHistory(dir, { card, run, entries, scan, maxEntryBytes = HISTORY_ENTRY_MAX_BYTES, maxRunBytes = HISTORY_RUN_MAX_BYTES }) {
|
|
2061
|
+
const path = join(dir, HISTORY_DIR_RELPATH, historySafe(card), historySafe(run) + ".jsonl");
|
|
2062
|
+
const lines = [];
|
|
2063
|
+
let used = 0;
|
|
2064
|
+
let truncated = 0;
|
|
2065
|
+
let dropped = 0;
|
|
2066
|
+
for (const e of entries) {
|
|
2067
|
+
const h = historyEntry(e, { scan, maxBytes: maxEntryBytes });
|
|
2068
|
+
const line = JSON.stringify({ kind: h.kind, card, run, seat: h.seat, round: h.round, source: h.source, bytes: h.bytes, truncated: h.truncated, redacted: h.redacted, text: h.text });
|
|
2069
|
+
const size = Buffer.byteLength(line) + 1;
|
|
2070
|
+
if (dropped === 0 && used + size <= maxRunBytes) {
|
|
2071
|
+
lines.push(line);
|
|
2072
|
+
used += size;
|
|
2073
|
+
if (h.truncated) truncated++;
|
|
2074
|
+
} else dropped++;
|
|
2075
|
+
}
|
|
2076
|
+
if (dropped > 0) lines.push(JSON.stringify({ kind: "cap", card, run, dropped, maxRunBytes }));
|
|
2077
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2078
|
+
try {
|
|
2079
|
+
writeFileSync(path, lines.map((l) => l + "\n").join(""), { flag: "wx" });
|
|
2080
|
+
} catch (e) {
|
|
2081
|
+
if (e && e.code === "EEXIST") return { path, written: false, lines: 0, truncated: 0, dropped: 0 };
|
|
2082
|
+
throw e;
|
|
2083
|
+
}
|
|
2084
|
+
return { path, written: true, lines: lines.length - (dropped > 0 ? 1 : 0), truncated, dropped };
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// The run-history secret scanner both writers use (autopilot report, reflect --hand), so redaction cannot drift:
|
|
2088
|
+
// .doug/config.json `secrets`; enabled === false scans nothing; otherwise the vendored secret-rules scanner. An
|
|
2089
|
+
// import failure returns undefined, which reads every entry as redacted 'no-scanner'.
|
|
2090
|
+
export async function historyScanner(dir) {
|
|
2091
|
+
let secrets = {};
|
|
2092
|
+
try {
|
|
2093
|
+
secrets = JSON.parse(readFileSync(join(dir, ".doug/config.json"), "utf8")).secrets ?? {};
|
|
2094
|
+
} catch {
|
|
2095
|
+
// no config: defaults
|
|
2096
|
+
}
|
|
2097
|
+
if (secrets && secrets.enabled === false) return () => null;
|
|
2098
|
+
try {
|
|
2099
|
+
const sr = await import(pathToFileURL(join(dir, ".doug/hooks/lib/secret-rules.mjs")).href);
|
|
2100
|
+
return (t) => sr.scanText(t, secrets ?? {});
|
|
2101
|
+
} catch {
|
|
2102
|
+
return undefined;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// History fetch (card memory-progressive-access, ADR 0019 L0 citation form history:<card>/<file>:<line>).
|
|
2107
|
+
// <ref> = [history:]<card>[/<run>[.jsonl]][:<line>]; ids are limited to safe name characters so a ref can never leave
|
|
2108
|
+
// the history directory.
|
|
2109
|
+
export function parseHistoryRef(ref) {
|
|
2110
|
+
const bad = () => new Error(`bad history id: ${JSON.stringify(ref)}`);
|
|
2111
|
+
if (typeof ref !== "string") throw bad();
|
|
2112
|
+
let rest = ref.startsWith("history:") ? ref.slice("history:".length) : ref;
|
|
2113
|
+
let line = null;
|
|
2114
|
+
const colon = rest.lastIndexOf(":");
|
|
2115
|
+
if (colon !== -1) {
|
|
2116
|
+
const n = rest.slice(colon + 1);
|
|
2117
|
+
if (!/^[0-9]+$/.test(n) || Number(n) < 1) throw bad();
|
|
2118
|
+
line = Number(n);
|
|
2119
|
+
rest = rest.slice(0, colon);
|
|
2120
|
+
}
|
|
2121
|
+
const parts = rest.split("/");
|
|
2122
|
+
if (parts.length > 2) throw bad();
|
|
2123
|
+
const [card, runRaw] = parts;
|
|
2124
|
+
const run = runRaw === undefined ? null : runRaw.replace(/\.jsonl$/, "");
|
|
2125
|
+
for (const part of [card, run]) {
|
|
2126
|
+
if (part === null) continue;
|
|
2127
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(part) || part.includes("..")) throw bad();
|
|
2128
|
+
}
|
|
2129
|
+
if (line !== null && run === null) throw bad();
|
|
2130
|
+
return { card, run, line };
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
export function readRunHistory(dir, { card, run = null, line = null }) {
|
|
2134
|
+
const root = join(dir, HISTORY_DIR_RELPATH);
|
|
2135
|
+
const cardDir = join(root, card);
|
|
2136
|
+
const parseLines = (path) =>
|
|
2137
|
+
readFileSync(path, "utf8")
|
|
2138
|
+
.split("\n")
|
|
2139
|
+
.filter((l) => l !== "")
|
|
2140
|
+
.map((l) => JSON.parse(l));
|
|
2141
|
+
if (run === null) {
|
|
2142
|
+
if (!existsSync(cardDir)) throw new Error(`no history for card ${card}`);
|
|
2143
|
+
const runs = readdirSync(cardDir)
|
|
2144
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
2145
|
+
.map((f) => f.slice(0, -".jsonl".length))
|
|
2146
|
+
.sort()
|
|
2147
|
+
.map((r) => {
|
|
2148
|
+
const entries = parseLines(join(cardDir, r + ".jsonl")).filter((e) => e.kind !== "cap");
|
|
2149
|
+
return { run: r, entries: entries.length, tokens: entries.reduce((n, e) => n + lessonTokens(e.text ?? ""), 0) };
|
|
2150
|
+
});
|
|
2151
|
+
return { card, runs };
|
|
2152
|
+
}
|
|
2153
|
+
const file = join(cardDir, run + ".jsonl");
|
|
2154
|
+
if (!existsSync(file)) throw new Error(`no history for ${card}/${run}`);
|
|
2155
|
+
const lines = parseLines(file);
|
|
2156
|
+
if (line !== null) {
|
|
2157
|
+
if (line > lines.length) throw new Error(`no line ${line} in ${card}/${run} (${lines.length} lines)`);
|
|
2158
|
+
const e = lines[line - 1];
|
|
2159
|
+
return { ...e, line, tokens: lessonTokens(e.text ?? "") };
|
|
2160
|
+
}
|
|
2161
|
+
const entries = lines.map((e, i) => {
|
|
2162
|
+
if (e.kind === "cap") return { line: i + 1, kind: "cap", dropped: e.dropped, maxRunBytes: e.maxRunBytes };
|
|
2163
|
+
return { line: i + 1, kind: e.kind, seat: e.seat, round: e.round, source: e.source, bytes: e.bytes, truncated: e.truncated, redacted: e.redacted, tokens: lessonTokens(e.text ?? "") };
|
|
2164
|
+
});
|
|
2165
|
+
return { card, run, tokens: entries.reduce((n, e) => n + (e.tokens ?? 0), 0), entries };
|
|
2166
|
+
}
|
|
2167
|
+
|
|
1606
2168
|
const OUTCOME_COLUMNS = [
|
|
1607
2169
|
"recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
|
|
1608
2170
|
"models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
|
|
1609
2171
|
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
|
|
1610
2172
|
"budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
|
|
1611
|
-
"harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
|
|
2173
|
+
"harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled", "memory_config",
|
|
1612
2174
|
];
|
|
1613
2175
|
|
|
1614
2176
|
const JSON_COLUMNS = new Set(["files", "models", "review_issues", "blocks", "budget_spent", "tokens"]);
|
|
1615
2177
|
|
|
1616
|
-
// The
|
|
2178
|
+
// The eight condition columns (seven from card outcomes-condition-columns, plus memory_config) upsert atomically, all eight together, keyed on
|
|
1617
2179
|
// whether the *new* row carries a harness_commit — never column-by-column (a plain per-column COALESCE let a
|
|
1618
2180
|
// re-record with no condition zero out an existing stamp's `backfilled` flag while keeping its old
|
|
1619
2181
|
// harness_commit, mixing half of one stamp with half of another, since `backfilled` defaults to 0, not null,
|
|
1620
2182
|
// when a caller omits it). When `excluded.harness_commit` is null (this row carries no condition), every one of
|
|
1621
|
-
// the
|
|
2183
|
+
// the eight columns keeps the existing row's value untouched; otherwise every one of the eight takes the new
|
|
1622
2184
|
// row's value, including `backfilled` (a fresh `condition open`/`record --hand` stamp legitimately replaces an
|
|
1623
2185
|
// older backfilled placeholder with backfilled 0). This is what keeps a `condition backfill` stamp — or any
|
|
1624
2186
|
// other already-recorded stamp — intact across a later re-record for the same run+task that carries none.
|
|
1625
|
-
const CONDITION_COLUMNS = new Set(["harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled"]);
|
|
2187
|
+
const CONDITION_COLUMNS = new Set(["harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled", "memory_config"]);
|
|
2188
|
+
|
|
2189
|
+
// passes and fix_passes upsert as a pair keyed on the new row's passes (card memory-record-keeps-rounds): a
|
|
2190
|
+
// re-record whose row carries no passes (a hand row) keeps the measured pair `reflect --hand --rounds` wrote.
|
|
2191
|
+
const ROUND_COLUMNS = new Set(["passes", "fix_passes"]);
|
|
1626
2192
|
|
|
1627
2193
|
export function recordOutcomes(m, rows) {
|
|
1628
2194
|
let inserted = 0;
|
|
@@ -1642,7 +2208,9 @@ export function recordOutcomes(m, rows) {
|
|
|
1642
2208
|
.map((c) =>
|
|
1643
2209
|
CONDITION_COLUMNS.has(c)
|
|
1644
2210
|
? `${c} = CASE WHEN excluded.harness_commit IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
|
|
1645
|
-
:
|
|
2211
|
+
: ROUND_COLUMNS.has(c)
|
|
2212
|
+
? `${c} = CASE WHEN excluded.passes IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
|
|
2213
|
+
: `${c} = excluded.${c}`
|
|
1646
2214
|
)
|
|
1647
2215
|
.join(", ");
|
|
1648
2216
|
m.prepare(
|
|
@@ -1680,6 +2248,58 @@ export function listOutcomes(m, { run = null, task = null } = {}) {
|
|
|
1680
2248
|
});
|
|
1681
2249
|
}
|
|
1682
2250
|
|
|
2251
|
+
// Compare outcome arms by memory configuration (card memory-outcome-arms, ADR 0019 Measurement). Reads outcomes and
|
|
2252
|
+
// reflections only. One arm per distinct memory_config, ordered by the arm's earliest recorded row; rows with no
|
|
2253
|
+
// memory_config are counted in `unstamped` and belong to no arm. landings are rows with a commit, stops rows with
|
|
2254
|
+
// no commit and either a stop_class or a `stop:` run (a stop row with no typed cause). An arm is compared (`ready`) once it has `min` landings; until then its four metrics
|
|
2255
|
+
// are null: landRate = landings / (landings + stops); fixRounds = mean fix_passes over landings that have one
|
|
2256
|
+
// (n of them; mean null when n is 0); harmfulPerLanding = harmful marks of the reflections joined to a landing
|
|
2257
|
+
// by card and commit_sha, per landing; costPerLanding = mean usd over priced landings (null when none).
|
|
2258
|
+
export function compareMemoryArms(m, { min = 12 } = {}) {
|
|
2259
|
+
const rows = m.prepare("SELECT * FROM outcomes ORDER BY recorded ASC, id ASC").all();
|
|
2260
|
+
const byConfig = new Map();
|
|
2261
|
+
let unstamped = 0;
|
|
2262
|
+
for (const row of rows) {
|
|
2263
|
+
if (row.memory_config === null || row.memory_config === undefined) {
|
|
2264
|
+
unstamped++;
|
|
2265
|
+
continue;
|
|
2266
|
+
}
|
|
2267
|
+
if (!byConfig.has(row.memory_config)) byConfig.set(row.memory_config, []);
|
|
2268
|
+
byConfig.get(row.memory_config).push(row);
|
|
2269
|
+
}
|
|
2270
|
+
const reflections = m.prepare("SELECT card, commit_sha, counters FROM reflections WHERE card IS NOT NULL AND commit_sha IS NOT NULL").all();
|
|
2271
|
+
const harmfulOf = (r) => {
|
|
2272
|
+
try {
|
|
2273
|
+
const h = JSON.parse(r.counters).harmful;
|
|
2274
|
+
return Array.isArray(h) ? h.length : 0;
|
|
2275
|
+
} catch {
|
|
2276
|
+
return 0;
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
const arms = [];
|
|
2280
|
+
for (const [config, armRows] of byConfig) {
|
|
2281
|
+
const landed = armRows.filter((r) => r.commit_sha !== null && r.commit_sha !== undefined);
|
|
2282
|
+
const stops = armRows.filter((r) => (r.commit_sha === null || r.commit_sha === undefined) && ((r.stop_class !== null && r.stop_class !== undefined) || (typeof r.run === "string" && r.run.startsWith("stop:")))).length;
|
|
2283
|
+
const landings = landed.length;
|
|
2284
|
+
const ready = landings >= min;
|
|
2285
|
+
const arm = { config, landings, stops, ready, landRate: null, fixRounds: null, harmfulPerLanding: null, costPerLanding: null, priced: null };
|
|
2286
|
+
if (ready) {
|
|
2287
|
+
arm.landRate = landings / (landings + stops);
|
|
2288
|
+
const fixes = landed.map((r) => r.fix_passes).filter((v) => typeof v === "number");
|
|
2289
|
+
arm.fixRounds = { mean: fixes.length ? fixes.reduce((a, b) => a + b, 0) / fixes.length : null, n: fixes.length };
|
|
2290
|
+
const keys = new Set(landed.map((r) => `${r.task}\u0000${r.commit_sha}`));
|
|
2291
|
+
let harmful = 0;
|
|
2292
|
+
for (const r of reflections) if (keys.has(`${r.card}\u0000${r.commit_sha}`)) harmful += harmfulOf(r);
|
|
2293
|
+
arm.harmfulPerLanding = harmful / landings;
|
|
2294
|
+
const usds = landed.map((r) => r.usd).filter((v) => typeof v === "number");
|
|
2295
|
+
arm.priced = usds.length;
|
|
2296
|
+
arm.costPerLanding = usds.length ? usds.reduce((a, b) => a + b, 0) / usds.length : null;
|
|
2297
|
+
}
|
|
2298
|
+
arms.push(arm);
|
|
2299
|
+
}
|
|
2300
|
+
return { min, unstamped, arms, comparable: arms.filter((a) => a.ready).length >= 2 };
|
|
2301
|
+
}
|
|
2302
|
+
|
|
1683
2303
|
// --- reflect (card memory-reflect) ------------------------------------------------------------------------------
|
|
1684
2304
|
// A cheap-row LLM pass, run after `memory.mjs record`, that proposes at most 3 lessons from one report and bumps
|
|
1685
2305
|
// helpful/harmful counters on lessons the report's tasks used. Everything here is pure and unit-testable except
|
|
@@ -1704,6 +2324,11 @@ export const REFLECT_SCHEMA = {
|
|
|
1704
2324
|
task: { type: "string" },
|
|
1705
2325
|
scope: { type: "array", items: { type: "string" } },
|
|
1706
2326
|
notDerivableBecause: { type: "string" },
|
|
2327
|
+
// Optional (card memory-reflect-supersede, ADR 0019 card 8): the id of one existing lesson listed in the
|
|
2328
|
+
// prompt that this proposal repeats (nothing new is written, that lesson's seen count rises) or
|
|
2329
|
+
// supersedes (it contradicts or corrects it). Never both; code checks the id is live and listed.
|
|
2330
|
+
repeats: { type: "string" },
|
|
2331
|
+
supersedes: { type: "string" },
|
|
1707
2332
|
// Optional (card reflect-offers-claude-md): the CLAUDE.md bullet this lesson would be, only when it is a
|
|
1708
2333
|
// rule a future session should follow.
|
|
1709
2334
|
claudeMd: {
|
|
@@ -1736,6 +2361,13 @@ function reflectionRowToObject(row) {
|
|
|
1736
2361
|
};
|
|
1737
2362
|
}
|
|
1738
2363
|
|
|
2364
|
+
// The archive rule (card memory-feedback-rank, ADR 0019): harmful at least min and at least helpful. Archived
|
|
2365
|
+
// rows are kept (never deleted) and hidden from recall; ARCHIVE_HARMFUL_MIN is the bench-checked threshold.
|
|
2366
|
+
export const ARCHIVE_HARMFUL_MIN = 2;
|
|
2367
|
+
export function meetsArchiveRule({ harmful, helpful }, min = ARCHIVE_HARMFUL_MIN) {
|
|
2368
|
+
return harmful >= min && harmful >= helpful;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
1739
2371
|
// Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
|
|
1740
2372
|
// `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
|
|
1741
2373
|
// named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
|
|
@@ -1825,6 +2457,12 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1825
2457
|
);
|
|
1826
2458
|
lines.push("- notDerivableBecause must say why the lesson is not something a reader could already get by reading the code; never restate a fact already sitting verbatim in a file.");
|
|
1827
2459
|
lines.push("- never propose a lesson whose text is already covered by one of the existing lessons listed below.");
|
|
2460
|
+
lines.push(
|
|
2461
|
+
"- supersedes is optional: set it to the id of one existing lesson listed below when your new lesson contradicts or corrects that lesson. The old lesson is then retired in favour of yours."
|
|
2462
|
+
);
|
|
2463
|
+
lines.push(
|
|
2464
|
+
"- repeats is optional: set it to the id of one existing lesson listed below when your lesson says the same thing as that lesson. Nothing new is written and that lesson's seen count rises. Never set both repeats and supersedes."
|
|
2465
|
+
);
|
|
1828
2466
|
lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
|
|
1829
2467
|
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
2468
|
const headings = dir ? claudeMdHeadings(dir) : [];
|
|
@@ -1854,7 +2492,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1854
2492
|
}
|
|
1855
2493
|
}
|
|
1856
2494
|
lines.push("");
|
|
1857
|
-
lines.push("Existing lessons (never propose a near-duplicate of one of these):");
|
|
2495
|
+
lines.push("Existing lessons (never propose a near-duplicate of one of these; name one in repeats or supersedes when it applies):");
|
|
1858
2496
|
const shownLessons = mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP);
|
|
1859
2497
|
if (existingLessons.length > REFLECT_PROMPT_LESSON_CAP) {
|
|
1860
2498
|
lines.push(`(showing the ${REFLECT_PROMPT_LESSON_CAP} most recently created/confirmed of ${existingLessons.length} total; older lessons are not shown)`);
|
|
@@ -1967,17 +2605,20 @@ export function jaccard(a, b) {
|
|
|
1967
2605
|
// - Jaccard similarity over lower-cased word tokens (length >= 3) at or above REFLECT_DUPLICATE_JACCARD against
|
|
1968
2606
|
// every non-superseded existing lesson or a row appended earlier in this same pass (`existingLessons` and
|
|
1969
2607
|
// `appendedRows`), so two near-identical proposals in one pass only ever add the first.
|
|
1970
|
-
// Returns { duplicate: <live lesson row> } (
|
|
1971
|
-
|
|
2608
|
+
// Returns { duplicate: <live lesson row> } (an identical hash: a repeat), { similar: <lesson row> } (word overlap,
|
|
2609
|
+
// never merged), { rejected: <superseded lesson row> }, or null (not a dup). skipSimilarity: a model judgement
|
|
2610
|
+
// (supersedes) was given, so word overlap is not consulted.
|
|
2611
|
+
function findReflectDuplicate(m, proposal, { existingLessons, appendedRows, skipSimilarity = false }) {
|
|
1972
2612
|
const proposalId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
1973
2613
|
const collision = getLesson(m, proposalId);
|
|
1974
2614
|
if (collision) return collision.superseded_by ? { rejected: collision } : { duplicate: collision };
|
|
1975
2615
|
for (const row of appendedRows) {
|
|
1976
2616
|
if (row.id === proposalId) return { duplicate: row };
|
|
1977
2617
|
}
|
|
2618
|
+
if (skipSimilarity) return null;
|
|
1978
2619
|
const tokens = reflectTokens(proposal.text);
|
|
1979
2620
|
for (const lesson of [...existingLessons, ...appendedRows]) {
|
|
1980
|
-
if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return {
|
|
2621
|
+
if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return { similar: lesson };
|
|
1981
2622
|
}
|
|
1982
2623
|
return null;
|
|
1983
2624
|
}
|
|
@@ -1985,7 +2626,7 @@ function findReflectDuplicate(m, proposal, { existingLessons, appendedRows }) {
|
|
|
1985
2626
|
// Filters C.1-C.4, in order. C.5 (at most 3 appended) is enforced by the caller, but only against a proposal
|
|
1986
2627
|
// this function has already said ok:true and not a duplicate — a would-be duplicate past the cap still bumps.
|
|
1987
2628
|
// Returns { ok:true }, { ok:true, duplicate:<lesson row> } (bump instead of append), or { ok:false, reason }.
|
|
1988
|
-
function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows }) {
|
|
2629
|
+
function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows, judgement = null }) {
|
|
1989
2630
|
if (!proposal || typeof proposal !== "object") return { ok: false, reason: "proposal is not an object" };
|
|
1990
2631
|
const { kind, text, citation, task, notDerivableBecause } = proposal;
|
|
1991
2632
|
if (kind !== "pitfall" && kind !== "pattern") return { ok: false, reason: `kind must be "pitfall" or "pattern", got ${JSON.stringify(kind)}` };
|
|
@@ -2002,16 +2643,24 @@ function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLes
|
|
|
2002
2643
|
if (typeof notDerivableBecause !== "string" || !notDerivableBecause.trim()) return { ok: false, reason: "notDerivableBecause is empty" };
|
|
2003
2644
|
const derivableCheck = checkReflectDerivable(proposal, dir);
|
|
2004
2645
|
if (!derivableCheck.ok) return derivableCheck;
|
|
2005
|
-
|
|
2646
|
+
// A valid repeats judgement: nothing new is written, the named lesson's seen count rises.
|
|
2647
|
+
if (judgement && judgement.kind === "repeats") return { ok: true, duplicate: judgement.target };
|
|
2648
|
+
const dup = findReflectDuplicate(m, proposal, { existingLessons, appendedRows, skipSimilarity: Boolean(judgement) });
|
|
2006
2649
|
if (dup && dup.rejected) return { ok: false, reason: `addLesson id collision with a superseded lesson ${dup.rejected.id}` };
|
|
2650
|
+
// An identical hash is a code fact, not a similarity score: a repeat of the colliding lesson.
|
|
2007
2651
|
if (dup && dup.duplicate) return { ok: true, duplicate: dup.duplicate };
|
|
2652
|
+
// The similarity score never merges (ADR 0019 card 8): with no judgement a near-duplicate is dropped.
|
|
2653
|
+
if (dup && dup.similar) {
|
|
2654
|
+
return { ok: false, reason: `near-duplicate of ${dup.similar.id} (word overlap >= ${REFLECT_DUPLICATE_JACCARD}); no repeats or supersedes judgement` };
|
|
2655
|
+
}
|
|
2656
|
+
if (judgement && judgement.kind === "supersedes") return { ok: true, supersedes: judgement.target };
|
|
2008
2657
|
return { ok: true };
|
|
2009
2658
|
}
|
|
2010
2659
|
|
|
2011
2660
|
// Cost (D): prefer the real per-message transcript at <claudeDir>/projects/<projectSlug(dir)>/<sessionId>.jsonl,
|
|
2012
2661
|
// same as cost.mjs prices every other agent; fall back to the envelope's own total_cost_usd (a client-side
|
|
2013
2662
|
// estimate) when the transcript is missing or its model is unpriced; null with no source when neither is usable.
|
|
2014
|
-
function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
2663
|
+
export function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
2015
2664
|
const resolvedClaudeDir = claudeDir || join(homedir(), ".claude");
|
|
2016
2665
|
if (sessionId) {
|
|
2017
2666
|
const file = join(resolvedClaudeDir, "projects", projectSlug(dir), `${sessionId}.jsonl`);
|
|
@@ -2133,7 +2782,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2133
2782
|
now = nowIso(),
|
|
2134
2783
|
} = opts;
|
|
2135
2784
|
if (!reportHash) throw new Error("runReflect requires a reportHash");
|
|
2136
|
-
if (typeof propose !== "function") throw new Error("runReflect requires a propose function");
|
|
2785
|
+
if (typeof propose !== "function" && !dryRun) throw new Error("runReflect requires a propose function");
|
|
2137
2786
|
|
|
2138
2787
|
const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(reportHash);
|
|
2139
2788
|
if (existing) return { alreadyReflected: true, row: reflectionRowToObject(existing) };
|
|
@@ -2160,12 +2809,23 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2160
2809
|
}
|
|
2161
2810
|
|
|
2162
2811
|
let counters;
|
|
2812
|
+
const archived = [];
|
|
2163
2813
|
if (dryRun) {
|
|
2164
2814
|
counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
|
|
2165
2815
|
} else {
|
|
2166
2816
|
m.exec("BEGIN");
|
|
2167
2817
|
try {
|
|
2168
2818
|
counters = computeReflectCounters(m, blocks, report, { apply: true, harmfulIds });
|
|
2819
|
+
for (const id of counters.harmful) {
|
|
2820
|
+
const row = getLesson(m, id);
|
|
2821
|
+
if (!row || row.archived || !meetsArchiveRule(row)) continue;
|
|
2822
|
+
m.prepare("UPDATE lessons SET archived = ?, archived_reason = ? WHERE id = ?").run(
|
|
2823
|
+
now,
|
|
2824
|
+
`harmful ${row.harmful}, helpful ${row.helpful}`,
|
|
2825
|
+
id
|
|
2826
|
+
);
|
|
2827
|
+
archived.push(id);
|
|
2828
|
+
}
|
|
2169
2829
|
m.prepare(
|
|
2170
2830
|
`INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
|
|
2171
2831
|
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
|
|
@@ -2180,6 +2840,30 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2180
2840
|
const existingLessons = listLessons(m);
|
|
2181
2841
|
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons, dir });
|
|
2182
2842
|
|
|
2843
|
+
// A dry run with no propose makes no model call: it reports the counters it would credit and the prompt it would send, and spends nothing.
|
|
2844
|
+
if (dryRun && typeof propose !== "function") {
|
|
2845
|
+
return {
|
|
2846
|
+
ok: true,
|
|
2847
|
+
dryRun: true,
|
|
2848
|
+
reportHash,
|
|
2849
|
+
prompt,
|
|
2850
|
+
counters,
|
|
2851
|
+
archived: [],
|
|
2852
|
+
proposed: null,
|
|
2853
|
+
appended: [],
|
|
2854
|
+
bumped: [],
|
|
2855
|
+
rejected: [],
|
|
2856
|
+
claudeMd: [],
|
|
2857
|
+
claudeMdDropped: [],
|
|
2858
|
+
notes: [],
|
|
2859
|
+
superseded: [],
|
|
2860
|
+
usd: 0,
|
|
2861
|
+
usdSource: "none",
|
|
2862
|
+
model,
|
|
2863
|
+
sessionId: null,
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2183
2867
|
let proposeResult = null;
|
|
2184
2868
|
let proposeError = null;
|
|
2185
2869
|
try {
|
|
@@ -2205,6 +2889,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2205
2889
|
dryRun,
|
|
2206
2890
|
reportHash,
|
|
2207
2891
|
counters,
|
|
2892
|
+
archived,
|
|
2208
2893
|
proposed: null,
|
|
2209
2894
|
appended: [],
|
|
2210
2895
|
bumped: [],
|
|
@@ -2213,6 +2898,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2213
2898
|
usdSource: null,
|
|
2214
2899
|
claudeMd: [],
|
|
2215
2900
|
claudeMdDropped: [],
|
|
2901
|
+
notes: [],
|
|
2902
|
+
superseded: [],
|
|
2216
2903
|
model,
|
|
2217
2904
|
sessionId: null,
|
|
2218
2905
|
reason: proposeError.message,
|
|
@@ -2227,13 +2914,25 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2227
2914
|
const suggestions = new Map(); // lesson id -> { lesson text, claudeMd suggestion } for appended lessons only
|
|
2228
2915
|
const bumped = [];
|
|
2229
2916
|
const rejected = [];
|
|
2917
|
+
const notes = [];
|
|
2918
|
+
const superseded = [];
|
|
2919
|
+
const shownIds = new Set(mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP).map((l) => l.id));
|
|
2920
|
+
const supersededThisPass = new Set();
|
|
2230
2921
|
for (const proposal of proposals) {
|
|
2231
|
-
const
|
|
2922
|
+
const judged = judgeReflectProposal(m, proposal, { shownIds, supersededThisPass, notes });
|
|
2923
|
+
if (judged.reject) {
|
|
2924
|
+
rejected.push({ text: proposal && typeof proposal.text === "string" ? proposal.text : null, reason: judged.reject });
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
const verdict = evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows, judgement: judged.judgement });
|
|
2232
2928
|
if (verdict.duplicate) {
|
|
2233
|
-
// The 3-item cap (C.5) limits appends, never evaluation: a
|
|
2234
|
-
// the lesson it
|
|
2235
|
-
if (!dryRun) m.prepare("UPDATE lessons SET
|
|
2929
|
+
// The 3-item cap (C.5) limits appends, never evaluation: a repeat found past the cap still bumps seen on
|
|
2930
|
+
// the lesson it names, it is just never counted against the 3. A repeat bumps seen, never helpful.
|
|
2931
|
+
if (!dryRun) m.prepare("UPDATE lessons SET seen = seen + 1 WHERE id = ?").run(verdict.duplicate.id);
|
|
2236
2932
|
bumped.push(verdict.duplicate.id);
|
|
2933
|
+
if (judged.judgement && judged.judgement.kind === "supersedes") {
|
|
2934
|
+
notes.push(`supersedes ${judged.judgement.target.id} ignored: the proposal is an exact repeat of ${verdict.duplicate.id}`);
|
|
2935
|
+
}
|
|
2237
2936
|
continue;
|
|
2238
2937
|
}
|
|
2239
2938
|
if (!verdict.ok) {
|
|
@@ -2248,6 +2947,10 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2248
2947
|
const dryId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
2249
2948
|
appendedRows.push({ id: dryId, text: proposal.text, kind: proposal.kind });
|
|
2250
2949
|
suggestions.set(dryId, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
2950
|
+
if (verdict.supersedes) {
|
|
2951
|
+
superseded.push({ old: verdict.supersedes.id, new: dryId });
|
|
2952
|
+
supersededThisPass.add(verdict.supersedes.id);
|
|
2953
|
+
}
|
|
2251
2954
|
continue;
|
|
2252
2955
|
}
|
|
2253
2956
|
const reportTask = findReportTask(report, proposal.task);
|
|
@@ -2263,6 +2966,15 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2263
2966
|
});
|
|
2264
2967
|
appendedRows.push(row);
|
|
2265
2968
|
suggestions.set(row.id, { text: proposal.text, claudeMd: proposal.claudeMd });
|
|
2969
|
+
if (verdict.supersedes) {
|
|
2970
|
+
try {
|
|
2971
|
+
supersedeLesson(m, verdict.supersedes.id, row.id);
|
|
2972
|
+
superseded.push({ old: verdict.supersedes.id, new: row.id });
|
|
2973
|
+
supersededThisPass.add(verdict.supersedes.id);
|
|
2974
|
+
} catch (err) {
|
|
2975
|
+
notes.push(`supersedes ${verdict.supersedes.id} not applied: ${err && err.message ? err.message : String(err)}`);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2266
2978
|
}
|
|
2267
2979
|
|
|
2268
2980
|
let claudeMd = [];
|
|
@@ -2298,15 +3010,639 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2298
3010
|
dryRun,
|
|
2299
3011
|
reportHash,
|
|
2300
3012
|
counters,
|
|
3013
|
+
archived,
|
|
2301
3014
|
proposed: proposals.length,
|
|
2302
3015
|
appended: appendedRows.map((r) => r.id),
|
|
2303
3016
|
bumped,
|
|
2304
3017
|
rejected,
|
|
2305
3018
|
claudeMd,
|
|
2306
3019
|
claudeMdDropped,
|
|
3020
|
+
notes,
|
|
3021
|
+
superseded,
|
|
2307
3022
|
usd: cost.usd,
|
|
2308
3023
|
usdSource: cost.usdSource,
|
|
2309
3024
|
model,
|
|
2310
3025
|
sessionId,
|
|
2311
3026
|
};
|
|
2312
3027
|
}
|
|
3028
|
+
|
|
3029
|
+
// The judgement fields of one reflect proposal (card memory-reflect-supersede). A target is valid only when it is
|
|
3030
|
+
// one of the lessons the prompt listed, is live, and was not superseded earlier in this pass. An invalid id is
|
|
3031
|
+
// ignored with a note and the proposal goes on as if the field were absent. Both fields set is a rejection.
|
|
3032
|
+
function judgeReflectProposal(m, proposal, { shownIds, supersededThisPass, notes }) {
|
|
3033
|
+
const field = (name) => (proposal && typeof proposal[name] === "string" && proposal[name].trim() ? proposal[name].trim() : null);
|
|
3034
|
+
const repeatsId = field("repeats");
|
|
3035
|
+
const supersedesId = field("supersedes");
|
|
3036
|
+
if (repeatsId && supersedesId) return { reject: "repeats and supersedes are both set; name one lesson in one field" };
|
|
3037
|
+
const check = (name, id) => {
|
|
3038
|
+
const row = getLesson(m, id);
|
|
3039
|
+
let why = null;
|
|
3040
|
+
if (!row) why = "no such lesson";
|
|
3041
|
+
else if (row.superseded_by || supersededThisPass.has(id)) why = "already superseded";
|
|
3042
|
+
else if (!shownIds.has(id)) why = "not listed in the prompt";
|
|
3043
|
+
if (why) {
|
|
3044
|
+
notes.push(`${name} ${id} ignored: ${why}`);
|
|
3045
|
+
return null;
|
|
3046
|
+
}
|
|
3047
|
+
return row;
|
|
3048
|
+
};
|
|
3049
|
+
if (repeatsId) {
|
|
3050
|
+
const target = check("repeats", repeatsId);
|
|
3051
|
+
return { judgement: target ? { kind: "repeats", target } : null };
|
|
3052
|
+
}
|
|
3053
|
+
if (supersedesId) {
|
|
3054
|
+
const target = check("supersedes", supersedesId);
|
|
3055
|
+
return { judgement: target ? { kind: "supersedes", target } : null };
|
|
3056
|
+
}
|
|
3057
|
+
return { judgement: null };
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// --- failure lessons (card memory-failure-lessons, ADR 0019 lines 39-41 and 60) -------------------------------------
|
|
3061
|
+
// Reflection over the failure slice of the run history: stopped reports, blocks classed `real`, and denials. A
|
|
3062
|
+
// pitfall cites the exact history line (history:<card>/<run>:<line>); a repeat of a pitfall is the model's `repeats`
|
|
3063
|
+
// judgement and lands in an append-only occurrence ledger (never in helpful, never from word similarity), which
|
|
3064
|
+
// learn.mjs counts by distinct cards and sessions.
|
|
3065
|
+
|
|
3066
|
+
export const PITFALL_LEDGER_RELPATH = ".doug/.state/memory/pitfalls.jsonl";
|
|
3067
|
+
|
|
3068
|
+
const FAILURE_ITEM_TEXT_MAX = 4000;
|
|
3069
|
+
const FAILURE_PROMPT_MAX = 16000;
|
|
3070
|
+
const FAILURE_TAIL_MAX = 1000;
|
|
3071
|
+
const FAILURE_APPEND_MAX = 3;
|
|
3072
|
+
|
|
3073
|
+
function parseJsonObject(text) {
|
|
3074
|
+
try {
|
|
3075
|
+
const v = JSON.parse(text);
|
|
3076
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
3077
|
+
} catch {
|
|
3078
|
+
return null;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
function failureItemText(kind, v, raw) {
|
|
3083
|
+
const parts = [];
|
|
3084
|
+
if (kind === "stop") {
|
|
3085
|
+
for (const k of ["reason", "stoppedAt", "detail", "error"]) if (typeof v[k] === "string" && v[k]) parts.push(`${k}: ${v[k]}`);
|
|
3086
|
+
if (!parts.length) parts.push(`report: ${raw}`);
|
|
3087
|
+
} else if (kind === "block") {
|
|
3088
|
+
if (typeof v.reason === "string") parts.push(`reason: ${v.reason}`);
|
|
3089
|
+
for (const f of Array.isArray(v.failures) ? v.failures : []) {
|
|
3090
|
+
if (!f || typeof f !== "object") continue;
|
|
3091
|
+
const tail = typeof f.tail === "string" ? f.tail.slice(-FAILURE_TAIL_MAX) : "";
|
|
3092
|
+
parts.push(`command: ${f.command ?? ""} (exit ${f.exit ?? "?"}${f.timedOut ? ", timed out" : ""})${tail ? `\n${tail}` : ""}`);
|
|
3093
|
+
}
|
|
3094
|
+
} else {
|
|
3095
|
+
parts.push(`tool: ${v.tool ?? ""}`);
|
|
3096
|
+
if (v.detail !== undefined) parts.push(`detail: ${typeof v.detail === "string" ? v.detail : JSON.stringify(v.detail)}`);
|
|
3097
|
+
}
|
|
3098
|
+
return parts.join("\n");
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// The failure slice of one card's run history; never throws. See ADR 0019 lines 40 and 50: a block that is not
|
|
3102
|
+
// `real` is counted in `hidden` by class, and an entry that cannot be parsed in `unreadable`.
|
|
3103
|
+
export function failureSlice(dir, card, { session = null } = {}) {
|
|
3104
|
+
const out = { items: [], hidden: {}, unreadable: 0 };
|
|
3105
|
+
try {
|
|
3106
|
+
const cardName = historySafe(card);
|
|
3107
|
+
const cardDir = join(dir, HISTORY_DIR_RELPATH, cardName);
|
|
3108
|
+
if (!existsSync(cardDir)) return out;
|
|
3109
|
+
const files = readdirSync(cardDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
3110
|
+
for (const file of files) {
|
|
3111
|
+
const run = file.slice(0, -".jsonl".length);
|
|
3112
|
+
let text;
|
|
3113
|
+
try {
|
|
3114
|
+
text = readFileSync(join(cardDir, file), "utf8");
|
|
3115
|
+
} catch {
|
|
3116
|
+
continue;
|
|
3117
|
+
}
|
|
3118
|
+
const entries = [];
|
|
3119
|
+
for (const raw of text.split("\n")) {
|
|
3120
|
+
if (raw === "") continue;
|
|
3121
|
+
const e = parseJsonObject(raw);
|
|
3122
|
+
entries.push(e && typeof e.kind === "string" ? { kind: e.kind, text: typeof e.text === "string" ? e.text : "" } : null);
|
|
3123
|
+
}
|
|
3124
|
+
const parsed = entries.map((e) => (e && (e.kind === "report" || e.kind === "block" || e.kind === "denial") ? parseJsonObject(e.text) : null));
|
|
3125
|
+
const sessionOf = (v) => (v && typeof v.session === "string" && v.session !== "" ? v.session : null);
|
|
3126
|
+
const runSession = parsed.map(sessionOf).find((s) => s !== null) ?? null;
|
|
3127
|
+
entries.forEach((e, i) => {
|
|
3128
|
+
if (!e) return;
|
|
3129
|
+
const v = parsed[i];
|
|
3130
|
+
let kind = null;
|
|
3131
|
+
if (e.kind === "report") {
|
|
3132
|
+
if (!v) return void out.unreadable++;
|
|
3133
|
+
if (v.outcome === "stopped" || (v.ok === false && !v.paused)) kind = "stop";
|
|
3134
|
+
} else if (e.kind === "block") {
|
|
3135
|
+
if (!v) return void out.unreadable++;
|
|
3136
|
+
if (v.class === "real") kind = "block";
|
|
3137
|
+
else {
|
|
3138
|
+
const cls = typeof v.class === "string" && v.class ? v.class : "unclassified";
|
|
3139
|
+
out.hidden[cls] = (out.hidden[cls] ?? 0) + 1;
|
|
3140
|
+
}
|
|
3141
|
+
} else if (e.kind === "denial") kind = "denial";
|
|
3142
|
+
if (!kind) return;
|
|
3143
|
+
const body = v ?? {};
|
|
3144
|
+
out.items.push({
|
|
3145
|
+
ref: `history:${cardName}/${run}:${i + 1}`,
|
|
3146
|
+
kind,
|
|
3147
|
+
card: cardName,
|
|
3148
|
+
run,
|
|
3149
|
+
line: i + 1,
|
|
3150
|
+
session: sessionOf(v) ?? runSession ?? (typeof session === "string" && session !== "" ? session : null),
|
|
3151
|
+
text: failureItemText(kind, body, e.text),
|
|
3152
|
+
});
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
} catch {
|
|
3156
|
+
// fail open: whatever was read so far
|
|
3157
|
+
}
|
|
3158
|
+
return out;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// Occurrence ledger: one JSON line per {lesson, citation} (de-duplicated), append-only.
|
|
3162
|
+
export function readPitfallOccurrences(dir) {
|
|
3163
|
+
let text;
|
|
3164
|
+
try {
|
|
3165
|
+
text = readFileSync(join(dir, PITFALL_LEDGER_RELPATH), "utf8");
|
|
3166
|
+
} catch {
|
|
3167
|
+
return [];
|
|
3168
|
+
}
|
|
3169
|
+
const seen = new Set();
|
|
3170
|
+
const out = [];
|
|
3171
|
+
for (const v of parseJsonLines(text)) {
|
|
3172
|
+
if (typeof v.lesson !== "string" || typeof v.citation !== "string") continue;
|
|
3173
|
+
const key = `${v.lesson}\u0000${v.citation}`;
|
|
3174
|
+
if (seen.has(key)) continue;
|
|
3175
|
+
seen.add(key);
|
|
3176
|
+
out.push(v);
|
|
3177
|
+
}
|
|
3178
|
+
return out;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
export function recordPitfallOccurrence(dir, occ) {
|
|
3182
|
+
const { lesson, citation, card = null, run = null, session = null, at = nowIso() } = occ ?? {};
|
|
3183
|
+
if (typeof lesson !== "string" || typeof citation !== "string") throw new Error("recordPitfallOccurrence needs a lesson and a citation");
|
|
3184
|
+
if (readPitfallOccurrences(dir).some((o) => o.lesson === lesson && o.citation === citation)) return { written: false };
|
|
3185
|
+
const path = join(dir, PITFALL_LEDGER_RELPATH);
|
|
3186
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3187
|
+
appendFileSync(path, JSON.stringify({ lesson, citation, card, run, session, at }) + "\n", "utf8");
|
|
3188
|
+
return { written: true };
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
export const FAILURE_REFLECT_SCHEMA = {
|
|
3192
|
+
type: "object",
|
|
3193
|
+
required: ["lessons"],
|
|
3194
|
+
properties: {
|
|
3195
|
+
lessons: {
|
|
3196
|
+
type: "array",
|
|
3197
|
+
items: {
|
|
3198
|
+
type: "object",
|
|
3199
|
+
required: ["kind", "text", "citation", "notDerivableBecause"],
|
|
3200
|
+
properties: {
|
|
3201
|
+
kind: { enum: ["pitfall"] },
|
|
3202
|
+
text: { type: "string" },
|
|
3203
|
+
citation: { type: "string" },
|
|
3204
|
+
notDerivableBecause: { type: "string" },
|
|
3205
|
+
scope: { type: "array", items: { type: "string" } },
|
|
3206
|
+
repeats: { type: "string" },
|
|
3207
|
+
},
|
|
3208
|
+
},
|
|
3209
|
+
},
|
|
3210
|
+
},
|
|
3211
|
+
};
|
|
3212
|
+
|
|
3213
|
+
export function buildFailureReflectPrompt(slice, { existingLessons = [] } = {}) {
|
|
3214
|
+
const lines = [
|
|
3215
|
+
"You are the reflection pass of a coding harness, reading the FAILURES of one card: stopped runs, gate blocks classed real, and permission denials.",
|
|
3216
|
+
"Propose at most 3 lessons of kind \"pitfall\": a short, specific rule that would have prevented the failure. Each lesson's `citation` must be exactly the ref of one item below (a history:<card>/<run>:<line> string). Do not cite anything else.",
|
|
3217
|
+
"`notDerivableBecause` says why the rule cannot be read from the repository's code or docs. Skip failures that are plain test failures with no transferable lesson.",
|
|
3218
|
+
"When an item is a repeat of an existing pitfall listed below, set `repeats` to that pitfall's id instead of inventing a new rule; do not reword an existing pitfall as a new one.",
|
|
3219
|
+
"",
|
|
3220
|
+
];
|
|
3221
|
+
const items = slice.items ?? [];
|
|
3222
|
+
lines.push(`Failure items (${items.length}):`);
|
|
3223
|
+
let used = 0;
|
|
3224
|
+
let shown = 0;
|
|
3225
|
+
for (const item of items) {
|
|
3226
|
+
let t = String(item.text ?? "");
|
|
3227
|
+
if (t.length > FAILURE_ITEM_TEXT_MAX) t = t.slice(0, FAILURE_ITEM_TEXT_MAX) + " [cut]";
|
|
3228
|
+
const block = `\n[${item.ref}] kind: ${item.kind}\n${t}`;
|
|
3229
|
+
if (used + block.length > FAILURE_PROMPT_MAX) break;
|
|
3230
|
+
lines.push(block);
|
|
3231
|
+
used += block.length;
|
|
3232
|
+
shown++;
|
|
3233
|
+
}
|
|
3234
|
+
if (shown < items.length) lines.push(`\n(${items.length - shown} more item(s) left out by the ${FAILURE_PROMPT_MAX}-character cap)`);
|
|
3235
|
+
const hidden = Object.entries(slice.hidden ?? {});
|
|
3236
|
+
if (hidden.length) lines.push(`\nBlocks not shown (not real): ${hidden.map(([k, n]) => `${k} ${n}`).join(", ")}.`);
|
|
3237
|
+
const pitfalls = existingLessons.filter((l) => l.kind === "pitfall" && !l.superseded_by);
|
|
3238
|
+
lines.push("", `Existing pitfalls (${pitfalls.length}), which \`repeats\` may name:`);
|
|
3239
|
+
for (const l of pitfalls) lines.push(`${l.id}: ${l.text}`);
|
|
3240
|
+
return lines.join("\n");
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
export async function runFailureReflect(m, opts = {}) {
|
|
3244
|
+
const { dir = process.cwd(), card, session = null, propose, dryRun = false, model = null, claudeDir, now = nowIso() } = opts;
|
|
3245
|
+
if (typeof card !== "string" || !card) throw new Error("runFailureReflect requires a card");
|
|
3246
|
+
if (typeof propose !== "function" && !dryRun) throw new Error("runFailureReflect requires a propose function");
|
|
3247
|
+
const slice = failureSlice(dir, card, { session });
|
|
3248
|
+
const base = { items: slice.items.length, hidden: slice.hidden, unreadable: slice.unreadable };
|
|
3249
|
+
if (!slice.items.length) return { ok: true, dryRun, ...base, appended: [], repeated: [], rejected: [] };
|
|
3250
|
+
|
|
3251
|
+
const key = "failures:" + createHash("sha256").update(slice.items.map((i) => i.ref).sort().join("\n")).digest("hex");
|
|
3252
|
+
const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(key);
|
|
3253
|
+
if (existing) return { alreadyReflected: true, ...base, row: reflectionRowToObject(existing) };
|
|
3254
|
+
|
|
3255
|
+
const prompt = buildFailureReflectPrompt(slice, { existingLessons: listLessons(m) });
|
|
3256
|
+
// A dry run with no propose makes no model call: it returns the prompt it would send and spends nothing.
|
|
3257
|
+
if (dryRun && typeof propose !== "function") {
|
|
3258
|
+
return { ok: true, dryRun: true, ...base, prompt, proposed: null, appended: [], repeated: [], rejected: [], usd: 0, usdSource: "none", model };
|
|
3259
|
+
}
|
|
3260
|
+
let result;
|
|
3261
|
+
try {
|
|
3262
|
+
result = await propose(prompt);
|
|
3263
|
+
if (!result || !Array.isArray(result.lessons)) throw new Error("the failure pass returned no lessons array");
|
|
3264
|
+
} catch (err) {
|
|
3265
|
+
return { ok: false, dryRun, ...base, appended: [], repeated: [], rejected: [{ text: null, reason: err.message }], reason: err.message };
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
const byRef = new Map(slice.items.map((i) => [i.ref, i]));
|
|
3269
|
+
const appended = [];
|
|
3270
|
+
const appendedIds = new Set();
|
|
3271
|
+
const repeated = [];
|
|
3272
|
+
const rejected = [];
|
|
3273
|
+
const occur = (lesson, item) => {
|
|
3274
|
+
if (!dryRun) recordPitfallOccurrence(dir, { lesson, citation: item.ref, card: item.card, run: item.run, session: item.session, at: now });
|
|
3275
|
+
if (!repeated.includes(lesson)) repeated.push(lesson);
|
|
3276
|
+
};
|
|
3277
|
+
for (const p of result.lessons) {
|
|
3278
|
+
const text = p && typeof p.text === "string" ? p.text : null;
|
|
3279
|
+
const reject = (reason) => rejected.push({ text, reason });
|
|
3280
|
+
if (!p || typeof p !== "object") {
|
|
3281
|
+
reject("proposal is not an object");
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
3284
|
+
if (p.kind !== "pitfall") {
|
|
3285
|
+
reject(`kind must be "pitfall", got ${JSON.stringify(p.kind)}`);
|
|
3286
|
+
continue;
|
|
3287
|
+
}
|
|
3288
|
+
if (!text || !text.trim()) {
|
|
3289
|
+
reject("text is empty");
|
|
3290
|
+
continue;
|
|
3291
|
+
}
|
|
3292
|
+
if (typeof p.notDerivableBecause !== "string" || !p.notDerivableBecause.trim()) {
|
|
3293
|
+
reject("notDerivableBecause is empty");
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
const item = typeof p.citation === "string" ? byRef.get(p.citation) : undefined;
|
|
3297
|
+
if (!item) {
|
|
3298
|
+
reject(`citation ${JSON.stringify(p.citation)} is not a failure item of this card's history`);
|
|
3299
|
+
continue;
|
|
3300
|
+
}
|
|
3301
|
+
const derivable = checkReflectDerivable(p, dir);
|
|
3302
|
+
if (!derivable.ok) {
|
|
3303
|
+
reject(derivable.reason);
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
if (p.repeats !== undefined && p.repeats !== null && p.repeats !== "") {
|
|
3307
|
+
const target = typeof p.repeats === "string" ? getLesson(m, p.repeats) : null;
|
|
3308
|
+
if (!target || target.kind !== "pitfall" || target.superseded_by) {
|
|
3309
|
+
reject(`repeats ${JSON.stringify(p.repeats)} is not a live pitfall lesson`);
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
occur(target.id, item);
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
const id = computeLessonId({ kind: "pitfall", citation: item.ref, text });
|
|
3316
|
+
const collision = getLesson(m, id);
|
|
3317
|
+
if (collision || appendedIds.has(id)) {
|
|
3318
|
+
if (collision && collision.superseded_by) reject(`addLesson id collision with a superseded lesson ${id}`);
|
|
3319
|
+
else occur(id, item);
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
if (appended.length >= FAILURE_APPEND_MAX) {
|
|
3323
|
+
reject(`at most ${FAILURE_APPEND_MAX} lessons appended per reflection`);
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
if (dryRun) {
|
|
3327
|
+
appended.push(id);
|
|
3328
|
+
appendedIds.add(id);
|
|
3329
|
+
continue;
|
|
3330
|
+
}
|
|
3331
|
+
const row = addLesson(m, {
|
|
3332
|
+
text,
|
|
3333
|
+
kind: "pitfall",
|
|
3334
|
+
scope: Array.isArray(p.scope) ? p.scope : [],
|
|
3335
|
+
citation: item.ref,
|
|
3336
|
+
source: { agent: "reflect", model },
|
|
3337
|
+
card: item.card,
|
|
3338
|
+
});
|
|
3339
|
+
appended.push(row.id);
|
|
3340
|
+
appendedIds.add(row.id);
|
|
3341
|
+
recordPitfallOccurrence(dir, { lesson: row.id, citation: item.ref, card: item.card, run: item.run, session: item.session, at: now });
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
const sessionId = result.sessionId || null;
|
|
3345
|
+
const cost = computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd: typeof result.totalCostUsd === "number" ? result.totalCostUsd : null });
|
|
3346
|
+
if (!dryRun) {
|
|
3347
|
+
m.prepare(
|
|
3348
|
+
`INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
|
|
3349
|
+
VALUES (?, ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3350
|
+
).run(key, now, card, model, JSON.stringify({ helpful: [], harmful: [], unknown: [] }), result.lessons.length, JSON.stringify(appended), JSON.stringify(repeated), JSON.stringify(rejected), cost.usd, cost.usdSource, sessionId);
|
|
3351
|
+
}
|
|
3352
|
+
return { ok: true, dryRun, ...base, proposed: result.lessons.length, appended, repeated, rejected, usd: cost.usd, usdSource: cost.usdSource, model, sessionId, key };
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// The on-demand cited project model (card memory-project-model, ADR 0019 L2): `memory.mjs model regen` asks one
|
|
3356
|
+
// cheap-row model call for statements about this project learned from runs, each citing live lessons or run
|
|
3357
|
+
// history, and keeps only statements whose every citation resolves in code. The whole file stays under the cap.
|
|
3358
|
+
export const PROJECT_MODEL_CHAR_CAP = 2000;
|
|
3359
|
+
export const PROJECT_MODEL_RELPATH = ".doug/project-model.md";
|
|
3360
|
+
const PROJECT_MODEL_SECTIONS = [
|
|
3361
|
+
["failure-patterns", "Failure patterns"],
|
|
3362
|
+
["conventions", "Conventions"],
|
|
3363
|
+
["open-risks", "Open risks"],
|
|
3364
|
+
];
|
|
3365
|
+
const PROJECT_MODEL_LESSON_TEXT_MAX = 500;
|
|
3366
|
+
|
|
3367
|
+
export const PROJECT_MODEL_SCHEMA = {
|
|
3368
|
+
type: "object",
|
|
3369
|
+
required: ["statements"],
|
|
3370
|
+
properties: {
|
|
3371
|
+
statements: {
|
|
3372
|
+
type: "array",
|
|
3373
|
+
items: {
|
|
3374
|
+
type: "object",
|
|
3375
|
+
required: ["section", "text", "citations"],
|
|
3376
|
+
properties: {
|
|
3377
|
+
section: { enum: PROJECT_MODEL_SECTIONS.map(([key]) => key) },
|
|
3378
|
+
text: { type: "string" },
|
|
3379
|
+
citations: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
3380
|
+
},
|
|
3381
|
+
},
|
|
3382
|
+
},
|
|
3383
|
+
},
|
|
3384
|
+
};
|
|
3385
|
+
|
|
3386
|
+
// Every run-history ref `<card>/<run>` under .doug/.state/history, in directory order.
|
|
3387
|
+
export function listRunHistoryRefs(dir) {
|
|
3388
|
+
const root = join(dir, HISTORY_DIR_RELPATH);
|
|
3389
|
+
const refs = [];
|
|
3390
|
+
try {
|
|
3391
|
+
for (const card of readdirSync(root).sort()) {
|
|
3392
|
+
let files = [];
|
|
3393
|
+
try {
|
|
3394
|
+
files = readdirSync(join(root, card)).filter((f) => f.endsWith(".jsonl")).sort();
|
|
3395
|
+
} catch {
|
|
3396
|
+
continue;
|
|
3397
|
+
}
|
|
3398
|
+
for (const f of files) refs.push(`${card}/${f.slice(0, -".jsonl".length)}`);
|
|
3399
|
+
}
|
|
3400
|
+
} catch {
|
|
3401
|
+
return [];
|
|
3402
|
+
}
|
|
3403
|
+
return refs;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
export function buildProjectModelPrompt({ lessons = [], runs = [] } = {}) {
|
|
3407
|
+
const lines = [
|
|
3408
|
+
"You write the project model of a coding harness: what is true about THIS project now, learned from its runs only. Three sections: failure-patterns (what keeps going wrong), conventions (conventions learned from runs), open-risks (what is still unresolved or fragile).",
|
|
3409
|
+
"Write short factual statements. Each statement has a `section` (failure-patterns, conventions or open-risks), a `text`, and `citations`: one or more lesson ids (the 16 hex characters in brackets below) or run-history refs (`<card>/<run>` or `<card>/<run>:<line>`, exactly as listed below). You must cite; a statement whose every citation is not exactly one of the ids or refs below is dropped by code.",
|
|
3410
|
+
"Do not restate CLAUDE.md, the ADRs or board cards: link to them by path or card id instead. A statement that should become a rule is not written here; rules go through the proposal path.",
|
|
3411
|
+
`Keep the whole document under ${PROJECT_MODEL_CHAR_CAP} characters, header included: a handful of dense statements, each worth its line. Prefer statements that cite several lessons.`,
|
|
3412
|
+
"",
|
|
3413
|
+
`Live lessons (${lessons.length}):`,
|
|
3414
|
+
];
|
|
3415
|
+
for (const l of lessons) {
|
|
3416
|
+
const text = String(l.text ?? "").replace(/\s+/g, " ").trim();
|
|
3417
|
+
const shown = text.length > PROJECT_MODEL_LESSON_TEXT_MAX ? text.slice(0, PROJECT_MODEL_LESSON_TEXT_MAX) + "..." : text;
|
|
3418
|
+
lines.push(`- [${l.id}] (${l.kind}) ${shown}`);
|
|
3419
|
+
}
|
|
3420
|
+
lines.push("", `Run history refs (${runs.length}):`);
|
|
3421
|
+
for (const r of runs) lines.push(`- ${r}`);
|
|
3422
|
+
return lines.join("\n") + "\n";
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// A citation resolves when it is the id of a live (not superseded) lesson, or a history ref with a run whose file
|
|
3426
|
+
// (and line, when named) exists. Anything else, including a thrown parse or read, is unresolved. A statement with
|
|
3427
|
+
// any unresolved citation is dropped whole.
|
|
3428
|
+
function citationResolver(m, dir) {
|
|
3429
|
+
return (c) => {
|
|
3430
|
+
if (typeof c !== "string") return false;
|
|
3431
|
+
if (/^[0-9a-f]{16}$/.test(c)) {
|
|
3432
|
+
const row = getLesson(m, c);
|
|
3433
|
+
return !!row && !row.superseded_by;
|
|
3434
|
+
}
|
|
3435
|
+
try {
|
|
3436
|
+
const ref = parseHistoryRef(c);
|
|
3437
|
+
if (ref.run === null) return false;
|
|
3438
|
+
readRunHistory(dir, ref);
|
|
3439
|
+
return true;
|
|
3440
|
+
} catch {
|
|
3441
|
+
return false;
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
export function checkProjectModelCitations(m, dir, statements) {
|
|
3447
|
+
const resolves = citationResolver(m, dir);
|
|
3448
|
+
const kept = [];
|
|
3449
|
+
const dropped = [];
|
|
3450
|
+
for (const s of statements) {
|
|
3451
|
+
const cites = Array.isArray(s && s.citations) ? s.citations : [];
|
|
3452
|
+
if (cites.length > 0 && cites.every(resolves)) kept.push(s);
|
|
3453
|
+
else dropped.push({ text: s && s.text, reason: "unresolved", citations: cites });
|
|
3454
|
+
}
|
|
3455
|
+
return { kept, dropped };
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// The on-demand idea pass (card memory-idea-proposals, ADR 0019 L3): `memory.mjs ideas` asks one cheap-row model
|
|
3459
|
+
// call over live lessons, run history and pitfall occurrences for idea cards and priority reviews. Ideas are
|
|
3460
|
+
// filed to Backlog only, cited, capped per pass; reviews go to a jsonl file for the user. Never Ready.
|
|
3461
|
+
export const IDEA_CAP = 3;
|
|
3462
|
+
export const PRIORITY_REVIEWS_RELPATH = ".doug/.state/memory/priority-reviews.jsonl";
|
|
3463
|
+
const IDEAS_LESSON_TEXT_MAX = 500;
|
|
3464
|
+
|
|
3465
|
+
export const IDEAS_SCHEMA = {
|
|
3466
|
+
type: "object",
|
|
3467
|
+
required: ["ideas", "reviews"],
|
|
3468
|
+
properties: {
|
|
3469
|
+
ideas: {
|
|
3470
|
+
type: "array",
|
|
3471
|
+
items: {
|
|
3472
|
+
type: "object",
|
|
3473
|
+
required: ["id", "title", "goal", "component", "size", "citations"],
|
|
3474
|
+
properties: {
|
|
3475
|
+
id: { type: "string" },
|
|
3476
|
+
title: { type: "string" },
|
|
3477
|
+
goal: { type: "string" },
|
|
3478
|
+
component: { type: "string" },
|
|
3479
|
+
size: { type: "string" },
|
|
3480
|
+
citations: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
3481
|
+
},
|
|
3482
|
+
},
|
|
3483
|
+
},
|
|
3484
|
+
reviews: {
|
|
3485
|
+
type: "array",
|
|
3486
|
+
items: {
|
|
3487
|
+
type: "object",
|
|
3488
|
+
required: ["text", "cards", "citations"],
|
|
3489
|
+
properties: {
|
|
3490
|
+
text: { type: "string" },
|
|
3491
|
+
cards: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
3492
|
+
citations: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
3493
|
+
},
|
|
3494
|
+
},
|
|
3495
|
+
},
|
|
3496
|
+
},
|
|
3497
|
+
};
|
|
3498
|
+
|
|
3499
|
+
export function buildIdeasPrompt({ lessons = [], runs = [], pitfalls = [], cards = [] } = {}) {
|
|
3500
|
+
const lines = [
|
|
3501
|
+
"You look over the lessons and run history of a coding harness and propose follow-up work for its user: idea cards and priority reviews.",
|
|
3502
|
+
"Look for recurring failures, repeated manual steps and unfinished follow-ups. An idea is filed only when it is realistic drift (a form a model or a person would plausibly write) or when one card would close the whole class; otherwise propose nothing.",
|
|
3503
|
+
"Each idea has an `id` (kebab-case, not any card id listed below), `title`, `goal` (name the defect or gap plainly), `component`, `size` and `citations`. Do not duplicate a card listed below.",
|
|
3504
|
+
"Each review (`text`, `cards`, `citations`) groups Backlog cards for the user to look at, for example Backlog cards that share a recurring pitfall. `cards` may name Backlog cards only. Never propose a change to Ready.",
|
|
3505
|
+
"You must cite: `citations` are lesson ids (the 16 hex characters in brackets below) or run-history refs (`<card>/<run>` or `<card>/<run>:<line>`), exactly as listed below. An item with any citation not exactly one of them, or with none, is dropped by code.",
|
|
3506
|
+
"",
|
|
3507
|
+
`Live lessons (${lessons.length}):`,
|
|
3508
|
+
];
|
|
3509
|
+
for (const l of lessons) {
|
|
3510
|
+
const text = String(l.text ?? "").replace(/\s+/g, " ").trim();
|
|
3511
|
+
const shown = text.length > IDEAS_LESSON_TEXT_MAX ? text.slice(0, IDEAS_LESSON_TEXT_MAX) + "..." : text;
|
|
3512
|
+
lines.push(`- [${l.id}] (${l.kind}) ${shown}`);
|
|
3513
|
+
}
|
|
3514
|
+
lines.push("", `Run history refs (${runs.length}):`);
|
|
3515
|
+
for (const r of runs) lines.push(`- ${r}`);
|
|
3516
|
+
lines.push("", `Pitfall occurrences (${pitfalls.length}):`);
|
|
3517
|
+
for (const p of pitfalls) lines.push(`- lesson ${p.lesson} (card ${p.card ?? "?"}, run ${p.run ?? "?"}): ${p.citation ?? ""}`);
|
|
3518
|
+
lines.push("", `Existing Backlog and Ready cards (${cards.length}):`);
|
|
3519
|
+
for (const c of cards) lines.push(`- ${c.id} (${c.column}): ${c.title}`);
|
|
3520
|
+
return lines.join("\n") + "\n";
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
export function checkIdeaCitations(m, dir, items) {
|
|
3524
|
+
const resolves = citationResolver(m, dir);
|
|
3525
|
+
const kept = [];
|
|
3526
|
+
const dropped = [];
|
|
3527
|
+
for (const it of items) {
|
|
3528
|
+
const cites = Array.isArray(it && it.citations) ? it.citations : [];
|
|
3529
|
+
if (cites.length > 0 && cites.every(resolves)) kept.push(it);
|
|
3530
|
+
else dropped.push({ id: it && it.id, text: it && it.text, reason: "unresolved", citations: cites });
|
|
3531
|
+
}
|
|
3532
|
+
return { kept, dropped };
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
export const IDEA_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3536
|
+
|
|
3537
|
+
// Pure: files up to `cap` ideas as new Backlog cards appended after every existing card.
|
|
3538
|
+
export function fileIdeaCards(board, ideas, { cap = IDEA_CAP, date } = {}) {
|
|
3539
|
+
let next = board;
|
|
3540
|
+
const filed = [];
|
|
3541
|
+
const skipped = [];
|
|
3542
|
+
for (const it of ideas) {
|
|
3543
|
+
if (typeof it.id !== "string" || !IDEA_ID_RE.test(it.id)) {
|
|
3544
|
+
skipped.push({ id: it.id, reason: "id is not kebab-case" });
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
if (filed.length >= cap) {
|
|
3548
|
+
skipped.push({ id: it.id, reason: "over cap" });
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3551
|
+
const cites = Array.isArray(it.citations) ? it.citations.join(", ") : "";
|
|
3552
|
+
try {
|
|
3553
|
+
const added = addCard(
|
|
3554
|
+
next,
|
|
3555
|
+
{
|
|
3556
|
+
id: it.id,
|
|
3557
|
+
title: it.title,
|
|
3558
|
+
component: it.component,
|
|
3559
|
+
size: it.size,
|
|
3560
|
+
track: "hand",
|
|
3561
|
+
deps: [],
|
|
3562
|
+
column: "backlog",
|
|
3563
|
+
goal: `Memory-proposed (memory.mjs ideas, ${date}): ${it.goal} Evidence: ${cites}.`,
|
|
3564
|
+
},
|
|
3565
|
+
{ date }
|
|
3566
|
+
);
|
|
3567
|
+
const cards = added.cards.slice();
|
|
3568
|
+
cards[cards.length - 1] = { ...cards[cards.length - 1], source: `memory-proposed: ${cites}` };
|
|
3569
|
+
next = { ...added, cards };
|
|
3570
|
+
filed.push(it.id);
|
|
3571
|
+
} catch (err) {
|
|
3572
|
+
skipped.push({ id: it.id, reason: String(err && err.message ? err.message : err).split("\n").slice(0, 2).join(" ") });
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
return { board: next, filed, skipped };
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
export function readPriorityReviews(dir) {
|
|
3579
|
+
const file = join(dir, PRIORITY_REVIEWS_RELPATH);
|
|
3580
|
+
if (!existsSync(file)) return [];
|
|
3581
|
+
const out = [];
|
|
3582
|
+
try {
|
|
3583
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
3584
|
+
if (!line.trim()) continue;
|
|
3585
|
+
try {
|
|
3586
|
+
const r = JSON.parse(line);
|
|
3587
|
+
if (r && typeof r === "object") out.push(r);
|
|
3588
|
+
} catch {
|
|
3589
|
+
// malformed line: skip
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
} catch {
|
|
3593
|
+
return [];
|
|
3594
|
+
}
|
|
3595
|
+
return out;
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
function reviewKey(r) {
|
|
3599
|
+
const cards = Array.isArray(r && r.cards) ? r.cards : [];
|
|
3600
|
+
return JSON.stringify([[...new Set(cards)].sort(), String((r && r.text) ?? "").replace(/\s+/g, " ").trim()]);
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
export function checkPriorityReviews(board, reviews, { cap = IDEA_CAP, recorded = [] } = {}) {
|
|
3604
|
+
const backlog = new Set(board.cards.filter((c) => c.column === "backlog").map((c) => c.id));
|
|
3605
|
+
const seen = new Set(recorded.map(reviewKey));
|
|
3606
|
+
const kept = [];
|
|
3607
|
+
const dropped = [];
|
|
3608
|
+
for (const r of reviews) {
|
|
3609
|
+
const key = reviewKey(r);
|
|
3610
|
+
if (seen.has(key)) {
|
|
3611
|
+
dropped.push({ text: r && r.text, reason: "already recorded" });
|
|
3612
|
+
continue;
|
|
3613
|
+
}
|
|
3614
|
+
const cards = Array.isArray(r && r.cards) ? r.cards : [];
|
|
3615
|
+
if (cards.length > 0 && cards.every((id) => backlog.has(id)) && kept.length < cap) {
|
|
3616
|
+
kept.push(r);
|
|
3617
|
+
seen.add(key);
|
|
3618
|
+
} else dropped.push({ text: r && r.text, reason: kept.length >= cap && cards.every((id) => backlog.has(id)) ? "over cap" : "not all backlog cards" });
|
|
3619
|
+
}
|
|
3620
|
+
return { kept, dropped };
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
export function renderProjectModel(statements, { date = new Date().toISOString().slice(0, 10) } = {}) {
|
|
3624
|
+
const header =
|
|
3625
|
+
`Generated by \`memory.mjs model regen\` on ${date}; do not edit by hand. Each statement cites a lesson (\`memory.mjs lesson show <id>\`) or a run record (\`memory.mjs history show <ref>\`).`;
|
|
3626
|
+
const build = (list) => {
|
|
3627
|
+
const out = ["# Project model", "", header, ""];
|
|
3628
|
+
for (const [key, title] of PROJECT_MODEL_SECTIONS) {
|
|
3629
|
+
const inSection = list.filter((s) => s.section === key);
|
|
3630
|
+
if (!inSection.length) continue;
|
|
3631
|
+
out.push(`## ${title}`);
|
|
3632
|
+
for (const s of inSection) out.push(`- ${s.text} [${s.citations.join(", ")}]`);
|
|
3633
|
+
}
|
|
3634
|
+
return out.join("\n") + "\n";
|
|
3635
|
+
};
|
|
3636
|
+
const known = statements.filter((s) => PROJECT_MODEL_SECTIONS.some(([key]) => key === s.section));
|
|
3637
|
+
const priority = known.map((s, i) => ({ s, i })).sort((a, b) => b.s.citations.length - a.s.citations.length || a.i - b.i);
|
|
3638
|
+
const chosen = new Set();
|
|
3639
|
+
for (const { s } of priority) {
|
|
3640
|
+
const trial = known.filter((x) => chosen.has(x) || x === s);
|
|
3641
|
+
if (build(trial).length > PROJECT_MODEL_CHAR_CAP) break;
|
|
3642
|
+
chosen.add(s);
|
|
3643
|
+
}
|
|
3644
|
+
const kept = known.filter((s) => chosen.has(s));
|
|
3645
|
+
const overCap = statements.filter((s) => !chosen.has(s));
|
|
3646
|
+
const text = build(kept);
|
|
3647
|
+
return { text, chars: text.length, kept, overCap };
|
|
3648
|
+
}
|