@dougharness/cli 1.3.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/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -0
- package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- 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 +1403 -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 +355 -13
- package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
- 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/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- 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 +130 -42
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -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,
|
|
@@ -1003,6 +1159,28 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
|
|
|
1003
1159
|
entry.sig = sigAfter;
|
|
1004
1160
|
}
|
|
1005
1161
|
|
|
1162
|
+
// What the stale window hides (card recall-window-report): over live rows (not superseded, not stale), the
|
|
1163
|
+
// count outside the window by exactly ageDaysOf's rule, the live total, and the oldest hidden age in whole days.
|
|
1164
|
+
export function windowHidden(m, { staleDays = 30, now = new Date() } = {}) {
|
|
1165
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
1166
|
+
const rows = m.prepare(`SELECT created, confirmed FROM lessons WHERE ${LIVE_LESSON_SQL}`).all();
|
|
1167
|
+
let count = 0;
|
|
1168
|
+
let oldest = -Infinity;
|
|
1169
|
+
for (const r of rows) {
|
|
1170
|
+
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1171
|
+
if (age <= staleDays) continue;
|
|
1172
|
+
count++;
|
|
1173
|
+
if (age > oldest) oldest = age;
|
|
1174
|
+
}
|
|
1175
|
+
return { count, total: rows.length, oldestDays: count ? Math.floor(oldest) : null };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// The one line that says so; null when the window hid nothing.
|
|
1179
|
+
export function windowNote(hidden, staleDays = 30) {
|
|
1180
|
+
if (!hidden || !hidden.count) return null;
|
|
1181
|
+
return `window: hid ${hidden.count} of ${hidden.total} live lessons older than ${staleDays} days (oldest ${hidden.oldestDays} days)`;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1006
1184
|
// --- Dense-ranked recall -----------------------------------------------------------------------------------
|
|
1007
1185
|
// With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
|
|
1008
1186
|
// cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
|
|
@@ -1013,8 +1191,51 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
|
|
|
1013
1191
|
// returned unchecked (checked: false in the result) rather than either throwing (no checkoutDir given) or
|
|
1014
1192
|
// marking every row stale forever (a wrong one given). Never throws for a provider or checkout problem (a
|
|
1015
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
|
+
|
|
1016
1236
|
export async function recallLessons(m, query, opts = {}) {
|
|
1017
1237
|
const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30, detectedProvider = null } = opts;
|
|
1238
|
+
const fb = mergeFeedback(opts.feedback);
|
|
1018
1239
|
const nowDate = now instanceof Date ? now : new Date(now);
|
|
1019
1240
|
const nowIsoStr = nowDate.toISOString();
|
|
1020
1241
|
const nowMs = nowDate.getTime();
|
|
@@ -1028,10 +1249,10 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1028
1249
|
if (match) {
|
|
1029
1250
|
const rows = m
|
|
1030
1251
|
.prepare(
|
|
1031
|
-
`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,
|
|
1032
1253
|
(l.embedding IS NOT NULL AND l.embedding_model = ? AND l.embedding_dims = ?) AS has_match
|
|
1033
1254
|
FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
1034
|
-
WHERE lessons_fts MATCH ? AND
|
|
1255
|
+
WHERE lessons_fts MATCH ? AND ${LIVE_LESSON_SQL}
|
|
1035
1256
|
ORDER BY bm25(lessons_fts) LIMIT 50`
|
|
1036
1257
|
)
|
|
1037
1258
|
.all(provider ? provider.model : null, provider ? provider.dims : null, match);
|
|
@@ -1039,7 +1260,7 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1039
1260
|
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1040
1261
|
if (!(age <= staleDays)) continue;
|
|
1041
1262
|
bm25Ranked.push(r.id);
|
|
1042
|
-
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 } });
|
|
1043
1264
|
}
|
|
1044
1265
|
}
|
|
1045
1266
|
|
|
@@ -1101,7 +1322,7 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1101
1322
|
// model, or the caller detected a usable provider; hybrid reports how many live in-window rows lack a vector.
|
|
1102
1323
|
// The existence check is bounded and only runs when no other term already decided it.
|
|
1103
1324
|
const hasAnyVector = (db) =>
|
|
1104
|
-
!!db.prepare(
|
|
1325
|
+
!!db.prepare(`SELECT 1 AS x FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding IS NOT NULL LIMIT 1`).get();
|
|
1105
1326
|
const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
|
|
1106
1327
|
const coverage = {};
|
|
1107
1328
|
if (mode === "hybrid") {
|
|
@@ -1125,21 +1346,27 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1125
1346
|
// object per stored vector.
|
|
1126
1347
|
const bm25Only = (id) => {
|
|
1127
1348
|
const info = bm25Info.get(id);
|
|
1128
|
-
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"] };
|
|
1129
1350
|
};
|
|
1130
1351
|
let candidates;
|
|
1131
1352
|
if (mode === "hybrid") {
|
|
1132
1353
|
// (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
|
|
1133
1354
|
const order = Int32Array.from(cosine);
|
|
1134
1355
|
const scores = new Float64Array(rows.n);
|
|
1135
|
-
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);
|
|
1136
1357
|
order.sort((x, y) => scores[y] - scores[x] || idCmp(rows.ids[x], rows.ids[y]));
|
|
1137
1358
|
const dense = { length: order.length, at: (j) => {
|
|
1138
1359
|
const r = order[j];
|
|
1139
1360
|
const id = rows.ids[r];
|
|
1140
1361
|
return { id, score: scores[r], sides: bm25Rank.has(id) ? ["bm25", "vector"] : ["vector"] };
|
|
1141
1362
|
} };
|
|
1142
|
-
|
|
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);
|
|
1143
1370
|
candidates = {
|
|
1144
1371
|
length: dense.length + appended.length,
|
|
1145
1372
|
at: (j) => (j < dense.length ? dense.at(j) : appended[j - dense.length]),
|
|
@@ -1186,7 +1413,8 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1186
1413
|
}
|
|
1187
1414
|
if (rows && written.length) followOwnWrites(m, rows, written, nowIsoStr, sigBefore);
|
|
1188
1415
|
|
|
1189
|
-
|
|
1416
|
+
const hidden = windowHidden(m, { staleDays, now: nowDate });
|
|
1417
|
+
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, hidden, lessons: result };
|
|
1190
1418
|
}
|
|
1191
1419
|
|
|
1192
1420
|
// Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
|
|
@@ -1236,6 +1464,72 @@ export function renderLessons(lessons) {
|
|
|
1236
1464
|
return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
|
|
1237
1465
|
}
|
|
1238
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
|
+
|
|
1239
1533
|
// The lessons a session starts with when no card query exists (card memory-every-session): live, non-stale
|
|
1240
1534
|
// lessons confirmed-or-created within staleDays, ordered by helpful minus harmful desc, then confirmed-or-created
|
|
1241
1535
|
// desc, then id ascending. No provider, no citation re-check, nothing written.
|
|
@@ -1245,7 +1539,7 @@ export function topLessons(m, { k = 5, staleDays = 30, now = new Date() } = {})
|
|
|
1245
1539
|
.prepare(
|
|
1246
1540
|
`SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
|
|
1247
1541
|
helpful, harmful, superseded_by, stale
|
|
1248
|
-
FROM lessons WHERE
|
|
1542
|
+
FROM lessons WHERE ${LIVE_LESSON_SQL}`
|
|
1249
1543
|
)
|
|
1250
1544
|
.all()
|
|
1251
1545
|
.map(lessonRowToObject)
|
|
@@ -1401,7 +1695,7 @@ function findPlanTask(plan, id) {
|
|
|
1401
1695
|
}
|
|
1402
1696
|
|
|
1403
1697
|
export function outcomeRows(report, opts) {
|
|
1404
|
-
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 || {};
|
|
1405
1699
|
const allBlocks = classifyBlocks(report, classes).map((b) => ({
|
|
1406
1700
|
id: b.id,
|
|
1407
1701
|
pass: b.pass,
|
|
@@ -1471,8 +1765,9 @@ export function outcomeRows(report, opts) {
|
|
|
1471
1765
|
gate: null,
|
|
1472
1766
|
note: null,
|
|
1473
1767
|
partial: toIntOrNull(task.partial),
|
|
1474
|
-
// The seven condition columns (card outcomes-condition-columns) are
|
|
1475
|
-
//
|
|
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.
|
|
1476
1771
|
harness_commit: null,
|
|
1477
1772
|
config_hash: null,
|
|
1478
1773
|
class: null,
|
|
@@ -1480,6 +1775,7 @@ export function outcomeRows(report, opts) {
|
|
|
1480
1775
|
assigned_by: null,
|
|
1481
1776
|
explore_probability: null,
|
|
1482
1777
|
backfilled: 0,
|
|
1778
|
+
memory_config: flowMemoryConfig ? JSON.stringify(sortKeysDeep(flowMemoryConfig)) : null,
|
|
1483
1779
|
});
|
|
1484
1780
|
}
|
|
1485
1781
|
}
|
|
@@ -1577,29 +1873,322 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
|
|
|
1577
1873
|
assigned_by: condition ? condition.assignedBy ?? null : null,
|
|
1578
1874
|
explore_probability: condition ? condition.exploreProbability ?? null : null,
|
|
1579
1875
|
backfilled: 0,
|
|
1876
|
+
memory_config: condition && condition.memoryConfig ? JSON.stringify(sortKeysDeep(condition.memoryConfig)) : null,
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
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,
|
|
1580
1910
|
};
|
|
1581
1911
|
}
|
|
1582
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
|
+
|
|
1583
2168
|
const OUTCOME_COLUMNS = [
|
|
1584
2169
|
"recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
|
|
1585
2170
|
"models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
|
|
1586
2171
|
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
|
|
1587
2172
|
"budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
|
|
1588
|
-
"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",
|
|
1589
2174
|
];
|
|
1590
2175
|
|
|
1591
2176
|
const JSON_COLUMNS = new Set(["files", "models", "review_issues", "blocks", "budget_spent", "tokens"]);
|
|
1592
2177
|
|
|
1593
|
-
// The
|
|
2178
|
+
// The eight condition columns (seven from card outcomes-condition-columns, plus memory_config) upsert atomically, all eight together, keyed on
|
|
1594
2179
|
// whether the *new* row carries a harness_commit — never column-by-column (a plain per-column COALESCE let a
|
|
1595
2180
|
// re-record with no condition zero out an existing stamp's `backfilled` flag while keeping its old
|
|
1596
2181
|
// harness_commit, mixing half of one stamp with half of another, since `backfilled` defaults to 0, not null,
|
|
1597
2182
|
// when a caller omits it). When `excluded.harness_commit` is null (this row carries no condition), every one of
|
|
1598
|
-
// the
|
|
2183
|
+
// the eight columns keeps the existing row's value untouched; otherwise every one of the eight takes the new
|
|
1599
2184
|
// row's value, including `backfilled` (a fresh `condition open`/`record --hand` stamp legitimately replaces an
|
|
1600
2185
|
// older backfilled placeholder with backfilled 0). This is what keeps a `condition backfill` stamp — or any
|
|
1601
2186
|
// other already-recorded stamp — intact across a later re-record for the same run+task that carries none.
|
|
1602
|
-
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"]);
|
|
1603
2192
|
|
|
1604
2193
|
export function recordOutcomes(m, rows) {
|
|
1605
2194
|
let inserted = 0;
|
|
@@ -1619,7 +2208,9 @@ export function recordOutcomes(m, rows) {
|
|
|
1619
2208
|
.map((c) =>
|
|
1620
2209
|
CONDITION_COLUMNS.has(c)
|
|
1621
2210
|
? `${c} = CASE WHEN excluded.harness_commit IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
|
|
1622
|
-
:
|
|
2211
|
+
: ROUND_COLUMNS.has(c)
|
|
2212
|
+
? `${c} = CASE WHEN excluded.passes IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
|
|
2213
|
+
: `${c} = excluded.${c}`
|
|
1623
2214
|
)
|
|
1624
2215
|
.join(", ");
|
|
1625
2216
|
m.prepare(
|
|
@@ -1657,6 +2248,58 @@ export function listOutcomes(m, { run = null, task = null } = {}) {
|
|
|
1657
2248
|
});
|
|
1658
2249
|
}
|
|
1659
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
|
+
|
|
1660
2303
|
// --- reflect (card memory-reflect) ------------------------------------------------------------------------------
|
|
1661
2304
|
// A cheap-row LLM pass, run after `memory.mjs record`, that proposes at most 3 lessons from one report and bumps
|
|
1662
2305
|
// helpful/harmful counters on lessons the report's tasks used. Everything here is pure and unit-testable except
|
|
@@ -1681,6 +2324,11 @@ export const REFLECT_SCHEMA = {
|
|
|
1681
2324
|
task: { type: "string" },
|
|
1682
2325
|
scope: { type: "array", items: { type: "string" } },
|
|
1683
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" },
|
|
1684
2332
|
// Optional (card reflect-offers-claude-md): the CLAUDE.md bullet this lesson would be, only when it is a
|
|
1685
2333
|
// rule a future session should follow.
|
|
1686
2334
|
claudeMd: {
|
|
@@ -1713,6 +2361,13 @@ function reflectionRowToObject(row) {
|
|
|
1713
2361
|
};
|
|
1714
2362
|
}
|
|
1715
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
|
+
|
|
1716
2371
|
// Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
|
|
1717
2372
|
// `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
|
|
1718
2373
|
// named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
|
|
@@ -1802,6 +2457,12 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1802
2457
|
);
|
|
1803
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.");
|
|
1804
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
|
+
);
|
|
1805
2466
|
lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
|
|
1806
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.");
|
|
1807
2468
|
const headings = dir ? claudeMdHeadings(dir) : [];
|
|
@@ -1831,7 +2492,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1831
2492
|
}
|
|
1832
2493
|
}
|
|
1833
2494
|
lines.push("");
|
|
1834
|
-
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):");
|
|
1835
2496
|
const shownLessons = mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP);
|
|
1836
2497
|
if (existingLessons.length > REFLECT_PROMPT_LESSON_CAP) {
|
|
1837
2498
|
lines.push(`(showing the ${REFLECT_PROMPT_LESSON_CAP} most recently created/confirmed of ${existingLessons.length} total; older lessons are not shown)`);
|
|
@@ -1944,17 +2605,20 @@ export function jaccard(a, b) {
|
|
|
1944
2605
|
// - Jaccard similarity over lower-cased word tokens (length >= 3) at or above REFLECT_DUPLICATE_JACCARD against
|
|
1945
2606
|
// every non-superseded existing lesson or a row appended earlier in this same pass (`existingLessons` and
|
|
1946
2607
|
// `appendedRows`), so two near-identical proposals in one pass only ever add the first.
|
|
1947
|
-
// Returns { duplicate: <live lesson row> } (
|
|
1948
|
-
|
|
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 }) {
|
|
1949
2612
|
const proposalId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
1950
2613
|
const collision = getLesson(m, proposalId);
|
|
1951
2614
|
if (collision) return collision.superseded_by ? { rejected: collision } : { duplicate: collision };
|
|
1952
2615
|
for (const row of appendedRows) {
|
|
1953
2616
|
if (row.id === proposalId) return { duplicate: row };
|
|
1954
2617
|
}
|
|
2618
|
+
if (skipSimilarity) return null;
|
|
1955
2619
|
const tokens = reflectTokens(proposal.text);
|
|
1956
2620
|
for (const lesson of [...existingLessons, ...appendedRows]) {
|
|
1957
|
-
if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return {
|
|
2621
|
+
if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return { similar: lesson };
|
|
1958
2622
|
}
|
|
1959
2623
|
return null;
|
|
1960
2624
|
}
|
|
@@ -1962,7 +2626,7 @@ function findReflectDuplicate(m, proposal, { existingLessons, appendedRows }) {
|
|
|
1962
2626
|
// Filters C.1-C.4, in order. C.5 (at most 3 appended) is enforced by the caller, but only against a proposal
|
|
1963
2627
|
// this function has already said ok:true and not a duplicate — a would-be duplicate past the cap still bumps.
|
|
1964
2628
|
// Returns { ok:true }, { ok:true, duplicate:<lesson row> } (bump instead of append), or { ok:false, reason }.
|
|
1965
|
-
function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows }) {
|
|
2629
|
+
function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows, judgement = null }) {
|
|
1966
2630
|
if (!proposal || typeof proposal !== "object") return { ok: false, reason: "proposal is not an object" };
|
|
1967
2631
|
const { kind, text, citation, task, notDerivableBecause } = proposal;
|
|
1968
2632
|
if (kind !== "pitfall" && kind !== "pattern") return { ok: false, reason: `kind must be "pitfall" or "pattern", got ${JSON.stringify(kind)}` };
|
|
@@ -1979,16 +2643,24 @@ function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLes
|
|
|
1979
2643
|
if (typeof notDerivableBecause !== "string" || !notDerivableBecause.trim()) return { ok: false, reason: "notDerivableBecause is empty" };
|
|
1980
2644
|
const derivableCheck = checkReflectDerivable(proposal, dir);
|
|
1981
2645
|
if (!derivableCheck.ok) return derivableCheck;
|
|
1982
|
-
|
|
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) });
|
|
1983
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.
|
|
1984
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 };
|
|
1985
2657
|
return { ok: true };
|
|
1986
2658
|
}
|
|
1987
2659
|
|
|
1988
2660
|
// Cost (D): prefer the real per-message transcript at <claudeDir>/projects/<projectSlug(dir)>/<sessionId>.jsonl,
|
|
1989
2661
|
// same as cost.mjs prices every other agent; fall back to the envelope's own total_cost_usd (a client-side
|
|
1990
2662
|
// estimate) when the transcript is missing or its model is unpriced; null with no source when neither is usable.
|
|
1991
|
-
function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
2663
|
+
export function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
1992
2664
|
const resolvedClaudeDir = claudeDir || join(homedir(), ".claude");
|
|
1993
2665
|
if (sessionId) {
|
|
1994
2666
|
const file = join(resolvedClaudeDir, "projects", projectSlug(dir), `${sessionId}.jsonl`);
|
|
@@ -2110,7 +2782,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2110
2782
|
now = nowIso(),
|
|
2111
2783
|
} = opts;
|
|
2112
2784
|
if (!reportHash) throw new Error("runReflect requires a reportHash");
|
|
2113
|
-
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");
|
|
2114
2786
|
|
|
2115
2787
|
const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(reportHash);
|
|
2116
2788
|
if (existing) return { alreadyReflected: true, row: reflectionRowToObject(existing) };
|
|
@@ -2137,12 +2809,23 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2137
2809
|
}
|
|
2138
2810
|
|
|
2139
2811
|
let counters;
|
|
2812
|
+
const archived = [];
|
|
2140
2813
|
if (dryRun) {
|
|
2141
2814
|
counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
|
|
2142
2815
|
} else {
|
|
2143
2816
|
m.exec("BEGIN");
|
|
2144
2817
|
try {
|
|
2145
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
|
+
}
|
|
2146
2829
|
m.prepare(
|
|
2147
2830
|
`INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
|
|
2148
2831
|
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
|
|
@@ -2157,6 +2840,30 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2157
2840
|
const existingLessons = listLessons(m);
|
|
2158
2841
|
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons, dir });
|
|
2159
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
|
+
|
|
2160
2867
|
let proposeResult = null;
|
|
2161
2868
|
let proposeError = null;
|
|
2162
2869
|
try {
|
|
@@ -2182,6 +2889,7 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2182
2889
|
dryRun,
|
|
2183
2890
|
reportHash,
|
|
2184
2891
|
counters,
|
|
2892
|
+
archived,
|
|
2185
2893
|
proposed: null,
|
|
2186
2894
|
appended: [],
|
|
2187
2895
|
bumped: [],
|
|
@@ -2190,6 +2898,8 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2190
2898
|
usdSource: null,
|
|
2191
2899
|
claudeMd: [],
|
|
2192
2900
|
claudeMdDropped: [],
|
|
2901
|
+
notes: [],
|
|
2902
|
+
superseded: [],
|
|
2193
2903
|
model,
|
|
2194
2904
|
sessionId: null,
|
|
2195
2905
|
reason: proposeError.message,
|
|
@@ -2204,13 +2914,25 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2204
2914
|
const suggestions = new Map(); // lesson id -> { lesson text, claudeMd suggestion } for appended lessons only
|
|
2205
2915
|
const bumped = [];
|
|
2206
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();
|
|
2207
2921
|
for (const proposal of proposals) {
|
|
2208
|
-
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 });
|
|
2209
2928
|
if (verdict.duplicate) {
|
|
2210
|
-
// The 3-item cap (C.5) limits appends, never evaluation: a
|
|
2211
|
-
// the lesson it
|
|
2212
|
-
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);
|
|
2213
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
|
+
}
|
|
2214
2936
|
continue;
|
|
2215
2937
|
}
|
|
2216
2938
|
if (!verdict.ok) {
|
|
@@ -2225,6 +2947,10 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2225
2947
|
const dryId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
2226
2948
|
appendedRows.push({ id: dryId, text: proposal.text, kind: proposal.kind });
|
|
2227
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
|
+
}
|
|
2228
2954
|
continue;
|
|
2229
2955
|
}
|
|
2230
2956
|
const reportTask = findReportTask(report, proposal.task);
|
|
@@ -2240,6 +2966,15 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2240
2966
|
});
|
|
2241
2967
|
appendedRows.push(row);
|
|
2242
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
|
+
}
|
|
2243
2978
|
}
|
|
2244
2979
|
|
|
2245
2980
|
let claudeMd = [];
|
|
@@ -2275,15 +3010,639 @@ export async function runReflect(m, report, opts = {}) {
|
|
|
2275
3010
|
dryRun,
|
|
2276
3011
|
reportHash,
|
|
2277
3012
|
counters,
|
|
3013
|
+
archived,
|
|
2278
3014
|
proposed: proposals.length,
|
|
2279
3015
|
appended: appendedRows.map((r) => r.id),
|
|
2280
3016
|
bumped,
|
|
2281
3017
|
rejected,
|
|
2282
3018
|
claudeMd,
|
|
2283
3019
|
claudeMdDropped,
|
|
3020
|
+
notes,
|
|
3021
|
+
superseded,
|
|
2284
3022
|
usd: cost.usd,
|
|
2285
3023
|
usdSource: cost.usdSource,
|
|
2286
3024
|
model,
|
|
2287
3025
|
sessionId,
|
|
2288
3026
|
};
|
|
2289
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
|
+
}
|