@dougharness/cli 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- package/templates/board-app.js +533 -0
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
// Outcome log and lessons store for Doug (card memory-lib). The only module in the repository that loads
|
|
2
|
+
// node:sqlite or names DatabaseSync; every other module reaches this store through the functions exported here.
|
|
3
|
+
// Requires Node >=22.16 (node:sqlite with FTS5 built in, nodejs PR 57621); a wrong Node throws one clear error
|
|
4
|
+
// at load time rather than failing silently later.
|
|
5
|
+
//
|
|
6
|
+
// node:sqlite is experimental on this Node and logs an ExperimentalWarning on first load; we wrap
|
|
7
|
+
// process.emitWarning around the require so that one specific warning never reaches stderr, and restore the
|
|
8
|
+
// original emitWarning immediately after, so no other warning in the process is ever swallowed.
|
|
9
|
+
//
|
|
10
|
+
// `lessons` is add-only by design: rows are memory the harness has already staked decisions on, so a later card
|
|
11
|
+
// can supersede a lesson (superseded_by) but never delete or rewrite its text — two triggers on the table enforce
|
|
12
|
+
// that in the database itself, not just in this module's API.
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { dirname, resolve, join } from "node:path";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { classifyBlocks, adversaryBlocks } from "./board.mjs";
|
|
19
|
+
import { transcriptUsage, priceUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
|
|
20
|
+
import { MODELS_HEADING } from "./models.mjs";
|
|
21
|
+
|
|
22
|
+
let DatabaseSync;
|
|
23
|
+
{
|
|
24
|
+
const originalEmitWarning = process.emitWarning;
|
|
25
|
+
process.emitWarning = function (warning, type, ...rest) {
|
|
26
|
+
const name = warning && warning.name ? warning.name : typeof type === "string" ? type : type && type.type;
|
|
27
|
+
const message = warning && warning.message ? warning.message : String(warning);
|
|
28
|
+
if (name === "ExperimentalWarning" && /SQLite/.test(message)) return;
|
|
29
|
+
return originalEmitWarning.call(process, warning, type, ...rest);
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
({ DatabaseSync } = require("node:sqlite"));
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error("memory needs Node >=22.16 for node:sqlite; this is Node " + process.versions.node);
|
|
36
|
+
} finally {
|
|
37
|
+
process.emitWarning = originalEmitWarning;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MEMORY_DB_RELPATH = ".doug/.state/memory/memory.db";
|
|
42
|
+
|
|
43
|
+
export function memoryPath(dir) {
|
|
44
|
+
return `${dir}/${MEMORY_DB_RELPATH}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
|
|
48
|
+
|
|
49
|
+
// outcomes is one row per (run, task), UNIQUE(run, task). A flow row (track = 'flow') comes from a workflow
|
|
50
|
+
// report via outcomeRows/recordOutcomes, run keyed by the workflow run id or report:<hash>. A hand row
|
|
51
|
+
// (track = 'hand') comes from a hand-track landing via handOutcomeRow, run keyed 'hand:<commit>' so recording
|
|
52
|
+
// the same commit again updates the same row (idempotent) and a card landing in a second commit gets a second
|
|
53
|
+
// row. On a hand row the following columns are NULL because they do not exist on that track — never because
|
|
54
|
+
// they measured zero: run_id, spec_hash, files, size, shape, models, implemented, verified, reviewed,
|
|
55
|
+
// review_issues, review_issue_count, adversary_verdict, adversary_blocked, adversary_source, blocks, passes,
|
|
56
|
+
// fix_passes, stop_reason, in_scope, budget_spent, tokens, usd, codex_usd, report_hash, report_path, partial. In particular
|
|
57
|
+
// verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
|
|
58
|
+
// the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
|
|
59
|
+
// blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
|
|
60
|
+
// are null on a flow row.
|
|
61
|
+
//
|
|
62
|
+
// partial (v5, card worker-context-handoff): 1 when the task's own outcome was still partial after its one
|
|
63
|
+
// resume (graceful degradation on a context limit, never a verification failure), 0 when it was not, null on a
|
|
64
|
+
// hand row. A partial task's `verified` still reads whatever the report says (usually 0, since the checks never
|
|
65
|
+
// ran); read `partial` alongside it before treating a 0 there as a defect.
|
|
66
|
+
//
|
|
67
|
+
// harness_commit, config_hash, class, arm, assigned_by, explore_probability, backfilled (v7, card
|
|
68
|
+
// outcomes-condition-columns): which harness version and which experimental condition a row ran under, the
|
|
69
|
+
// prerequisite for any before-and-after comparison across harness changes. harness_commit is the HEAD sha at the
|
|
70
|
+
// moment the condition was opened (or, for a backfilled row, the last commit at or before the row's `recorded`
|
|
71
|
+
// timestamp); config_hash is conditionConfigHash(dir) (below) at that same moment, null on a backfilled row
|
|
72
|
+
// (there is nothing to recompute it from after the fact); class and arm name the card's class and the arm it
|
|
73
|
+
// ran under (`<class>/default` until exploration exists); assigned_by is `'policy'` or `'explore'`;
|
|
74
|
+
// explore_probability is the explore arm's assignment probability, null otherwise. backfilled is 1 only for a
|
|
75
|
+
// row `memory.mjs condition backfill` stamped after the fact, 0 for every other row (including an unstamped
|
|
76
|
+
// hand row, which has never been touched by either path). On today's flow track these seven columns are always
|
|
77
|
+
// null: a flow row is never stamped by `condition open`/`record --hand` — that wiring is a later card.
|
|
78
|
+
const OUTCOMES_COLUMNS_SQL = `
|
|
79
|
+
id INTEGER PRIMARY KEY,
|
|
80
|
+
recorded TEXT NOT NULL,
|
|
81
|
+
run TEXT NOT NULL,
|
|
82
|
+
run_id TEXT,
|
|
83
|
+
report_hash TEXT,
|
|
84
|
+
report_path TEXT,
|
|
85
|
+
card TEXT,
|
|
86
|
+
task TEXT NOT NULL,
|
|
87
|
+
spec_hash TEXT,
|
|
88
|
+
files TEXT,
|
|
89
|
+
size TEXT,
|
|
90
|
+
shape TEXT,
|
|
91
|
+
models TEXT,
|
|
92
|
+
implemented INTEGER,
|
|
93
|
+
verified INTEGER,
|
|
94
|
+
reviewed INTEGER,
|
|
95
|
+
review_issues TEXT,
|
|
96
|
+
review_issue_count INTEGER,
|
|
97
|
+
adversary_verdict TEXT,
|
|
98
|
+
adversary_blocked INTEGER,
|
|
99
|
+
adversary_source TEXT,
|
|
100
|
+
blocks TEXT NOT NULL DEFAULT '[]',
|
|
101
|
+
passes INTEGER,
|
|
102
|
+
fix_passes INTEGER,
|
|
103
|
+
stop_reason TEXT,
|
|
104
|
+
in_scope INTEGER,
|
|
105
|
+
budget_spent TEXT,
|
|
106
|
+
tokens TEXT,
|
|
107
|
+
usd REAL,
|
|
108
|
+
codex_usd REAL,
|
|
109
|
+
commit_sha TEXT,
|
|
110
|
+
track TEXT NOT NULL DEFAULT 'flow',
|
|
111
|
+
wall_clock TEXT,
|
|
112
|
+
gate TEXT,
|
|
113
|
+
note TEXT,
|
|
114
|
+
partial INTEGER,
|
|
115
|
+
harness_commit TEXT,
|
|
116
|
+
config_hash TEXT,
|
|
117
|
+
class TEXT,
|
|
118
|
+
arm TEXT,
|
|
119
|
+
assigned_by TEXT,
|
|
120
|
+
explore_probability REAL,
|
|
121
|
+
backfilled INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
UNIQUE(run, task)
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
// stale (v3): an ISO date set when recallLessons's citation re-check fails against a checkout; a stale row is
|
|
126
|
+
// excluded from recall until a new lesson supersedes it. It is not in the no-rewrite trigger's column list
|
|
127
|
+
// below, same as confirmed/helpful/harmful/superseded_by/embedding*: those columns record the store learning
|
|
128
|
+
// about a lesson, never the lesson's own claim, which stays add-only.
|
|
129
|
+
const LESSONS_COLUMNS_SQL = `
|
|
130
|
+
id TEXT PRIMARY KEY,
|
|
131
|
+
text TEXT NOT NULL,
|
|
132
|
+
kind TEXT NOT NULL CHECK (kind IN ('feedback','project','pitfall','pattern')),
|
|
133
|
+
scope TEXT NOT NULL DEFAULT '[]',
|
|
134
|
+
citation TEXT,
|
|
135
|
+
source_agent TEXT NOT NULL,
|
|
136
|
+
source_model TEXT,
|
|
137
|
+
task TEXT,
|
|
138
|
+
card TEXT,
|
|
139
|
+
commit_sha TEXT,
|
|
140
|
+
created TEXT NOT NULL,
|
|
141
|
+
confirmed TEXT,
|
|
142
|
+
helpful INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
harmful INTEGER NOT NULL DEFAULT 0,
|
|
144
|
+
superseded_by TEXT REFERENCES lessons(id),
|
|
145
|
+
embedding_model TEXT,
|
|
146
|
+
embedding_dims INTEGER,
|
|
147
|
+
embedding BLOB,
|
|
148
|
+
stale TEXT
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
const LESSONS_TRIGGERS_SQL = `
|
|
152
|
+
CREATE TRIGGER lessons_no_delete BEFORE DELETE ON lessons
|
|
153
|
+
BEGIN
|
|
154
|
+
SELECT RAISE(ABORT, 'lessons are add-only; supersede instead');
|
|
155
|
+
END;
|
|
156
|
+
CREATE TRIGGER lessons_no_rewrite BEFORE UPDATE OF text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created ON lessons
|
|
157
|
+
BEGIN
|
|
158
|
+
SELECT RAISE(ABORT, 'lessons are never rewritten; supersede instead');
|
|
159
|
+
END;
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
// reflections (v4): one row per report reflected on, keyed by the report's sha256 so a second `memory.mjs
|
|
163
|
+
// reflect` on the same report is a no-op (see runReflect). counters/appended/bumped/rejected are JSON columns
|
|
164
|
+
// (never denormalized into their own tables — this is a small append log, not a query surface). proposed is the
|
|
165
|
+
// LLM pass's raw proposal count before filtering; null on proposed means the LLM pass never completed (the row
|
|
166
|
+
// was inserted with the counters already applied, before the pass ran, and never updated after a crash).
|
|
167
|
+
const REFLECTIONS_COLUMNS_SQL = `
|
|
168
|
+
report_hash TEXT PRIMARY KEY,
|
|
169
|
+
recorded TEXT NOT NULL,
|
|
170
|
+
run TEXT,
|
|
171
|
+
card TEXT,
|
|
172
|
+
commit_sha TEXT,
|
|
173
|
+
model TEXT,
|
|
174
|
+
counters TEXT NOT NULL,
|
|
175
|
+
proposed INTEGER,
|
|
176
|
+
appended TEXT,
|
|
177
|
+
bumped TEXT,
|
|
178
|
+
rejected TEXT,
|
|
179
|
+
usd REAL,
|
|
180
|
+
usd_source TEXT,
|
|
181
|
+
session_id TEXT
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
// code_files/code_chunks/code_chunks_fts/code_index_builds (v6, card semantic-index): the opt-in semantic code
|
|
185
|
+
// index lib/code-index.mjs builds and reads. Unlike lessons, this is a cache, not a record: buildIndex freely
|
|
186
|
+
// deletes and rewrites a chunk's row when its text changes, and no add-only trigger protects any of these four
|
|
187
|
+
// tables. A chunk's id is the first 16 hex of sha256(`${path}\n${content_hash}`), so a chunk whose text does not
|
|
188
|
+
// move keeps its row and its embedding across rebuilds even as its line range shifts. Vectors are stored exactly
|
|
189
|
+
// as lesson vectors are (see "Embeddings on lesson rows" below): Float32Array bytes, embedding_model/dims
|
|
190
|
+
// travelling with them, a query only ever comparing rows sharing the same pair. code_index_builds is one row per
|
|
191
|
+
// `memory.mjs index build`, the record indexStatus's refresh-cost estimate reads (embed_seconds and
|
|
192
|
+
// chunks_embedded on the most recent row with embed_seconds > 0 give the chunks/second rate).
|
|
193
|
+
const CODE_FILES_COLUMNS_SQL = `
|
|
194
|
+
path TEXT PRIMARY KEY,
|
|
195
|
+
hash TEXT NOT NULL,
|
|
196
|
+
bytes INTEGER NOT NULL,
|
|
197
|
+
chunks INTEGER NOT NULL,
|
|
198
|
+
indexed TEXT NOT NULL
|
|
199
|
+
`;
|
|
200
|
+
|
|
201
|
+
const CODE_CHUNKS_COLUMNS_SQL = `
|
|
202
|
+
id TEXT PRIMARY KEY,
|
|
203
|
+
path TEXT NOT NULL,
|
|
204
|
+
start_line INTEGER NOT NULL,
|
|
205
|
+
end_line INTEGER NOT NULL,
|
|
206
|
+
content_hash TEXT NOT NULL,
|
|
207
|
+
text TEXT NOT NULL,
|
|
208
|
+
indexed TEXT NOT NULL,
|
|
209
|
+
embedding_model TEXT,
|
|
210
|
+
embedding_dims INTEGER,
|
|
211
|
+
embedding BLOB
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
const CODE_INDEX_BUILDS_COLUMNS_SQL = `
|
|
215
|
+
id INTEGER PRIMARY KEY,
|
|
216
|
+
started TEXT NOT NULL,
|
|
217
|
+
finished TEXT NOT NULL,
|
|
218
|
+
head_sha TEXT,
|
|
219
|
+
files INTEGER NOT NULL,
|
|
220
|
+
chunks INTEGER NOT NULL,
|
|
221
|
+
files_changed INTEGER NOT NULL,
|
|
222
|
+
chunks_embedded INTEGER NOT NULL,
|
|
223
|
+
chunks_failed INTEGER NOT NULL,
|
|
224
|
+
embed_seconds REAL,
|
|
225
|
+
provider TEXT,
|
|
226
|
+
model TEXT,
|
|
227
|
+
dims INTEGER,
|
|
228
|
+
reason TEXT
|
|
229
|
+
`;
|
|
230
|
+
|
|
231
|
+
const CODE_INDEX_SCHEMA_SQL = `
|
|
232
|
+
CREATE TABLE code_files (${CODE_FILES_COLUMNS_SQL});
|
|
233
|
+
CREATE TABLE code_chunks (${CODE_CHUNKS_COLUMNS_SQL});
|
|
234
|
+
CREATE VIRTUAL TABLE code_chunks_fts USING fts5(id UNINDEXED, path, text);
|
|
235
|
+
CREATE TABLE code_index_builds (${CODE_INDEX_BUILDS_COLUMNS_SQL});
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
const SCHEMA_SQL = `
|
|
239
|
+
CREATE TABLE outcomes (${OUTCOMES_COLUMNS_SQL});
|
|
240
|
+
CREATE TABLE lessons (${LESSONS_COLUMNS_SQL});
|
|
241
|
+
CREATE VIRTUAL TABLE lessons_fts USING fts5(id UNINDEXED, text);
|
|
242
|
+
${LESSONS_TRIGGERS_SQL}
|
|
243
|
+
CREATE TABLE reflections (${REFLECTIONS_COLUMNS_SQL});
|
|
244
|
+
${CODE_INDEX_SCHEMA_SQL}
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
// v1 had no track/wall_clock/gate/note columns and NOT NULL report_hash / review_issue_count. Migrating rebuilds
|
|
248
|
+
// the outcomes table under a temp name inside a transaction, carries the v1 columns across (the four new columns
|
|
249
|
+
// take their defaults, so every migrated row reads track = 'flow'), then drops the old table and renames.
|
|
250
|
+
const V1_OUTCOME_COLUMNS = [
|
|
251
|
+
"id", "recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size",
|
|
252
|
+
"shape", "models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count",
|
|
253
|
+
"adversary_verdict", "adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason",
|
|
254
|
+
"in_scope", "budget_spent", "tokens", "usd", "commit_sha",
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
function migrateV1ToV2(db) {
|
|
258
|
+
db.exec("BEGIN");
|
|
259
|
+
try {
|
|
260
|
+
db.exec(`CREATE TABLE outcomes_v2 (${OUTCOMES_COLUMNS_SQL});`);
|
|
261
|
+
db.exec(
|
|
262
|
+
`INSERT INTO outcomes_v2 (${V1_OUTCOME_COLUMNS.join(", ")}) SELECT ${V1_OUTCOME_COLUMNS.join(", ")} FROM outcomes;`
|
|
263
|
+
);
|
|
264
|
+
db.exec("DROP TABLE outcomes;");
|
|
265
|
+
db.exec("ALTER TABLE outcomes_v2 RENAME TO outcomes;");
|
|
266
|
+
db.exec("PRAGMA user_version = 2");
|
|
267
|
+
db.exec("COMMIT");
|
|
268
|
+
} catch (err) {
|
|
269
|
+
db.exec("ROLLBACK");
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// v2 lessons had no `stale` column. Same transactional rebuild pattern as v1->v2; unlike outcomes, lessons
|
|
275
|
+
// carries two triggers, and SQLite drops a table's triggers when the table is dropped, so they are recreated
|
|
276
|
+
// against the renamed table before commit.
|
|
277
|
+
const V2_LESSON_COLUMNS = [
|
|
278
|
+
"id", "text", "kind", "scope", "citation", "source_agent", "source_model", "task", "card", "commit_sha",
|
|
279
|
+
"created", "confirmed", "helpful", "harmful", "superseded_by", "embedding_model", "embedding_dims", "embedding",
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
function migrateV2ToV3(db) {
|
|
283
|
+
db.exec("BEGIN");
|
|
284
|
+
try {
|
|
285
|
+
db.exec(`CREATE TABLE lessons_v3 (${LESSONS_COLUMNS_SQL});`);
|
|
286
|
+
db.exec(
|
|
287
|
+
`INSERT INTO lessons_v3 (${V2_LESSON_COLUMNS.join(", ")}) SELECT ${V2_LESSON_COLUMNS.join(", ")} FROM lessons;`
|
|
288
|
+
);
|
|
289
|
+
db.exec("DROP TABLE lessons;");
|
|
290
|
+
db.exec("ALTER TABLE lessons_v3 RENAME TO lessons;");
|
|
291
|
+
db.exec(LESSONS_TRIGGERS_SQL);
|
|
292
|
+
db.exec("PRAGMA user_version = 3");
|
|
293
|
+
db.exec("COMMIT");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
db.exec("ROLLBACK");
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// v3 had no reflections table. Unlike v1->v2 and v2->v3, nothing existing is rebuilt: adding a table needs no
|
|
301
|
+
// rebuild-under-a-temp-name dance, just the CREATE plus the version bump, in the same transactional pattern.
|
|
302
|
+
function migrateV3ToV4(db) {
|
|
303
|
+
db.exec("BEGIN");
|
|
304
|
+
try {
|
|
305
|
+
db.exec(`CREATE TABLE reflections (${REFLECTIONS_COLUMNS_SQL});`);
|
|
306
|
+
db.exec("PRAGMA user_version = 4");
|
|
307
|
+
db.exec("COMMIT");
|
|
308
|
+
} catch (err) {
|
|
309
|
+
db.exec("ROLLBACK");
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// v4 had no `partial` column on outcomes (card worker-context-handoff). Additive like v3->v4: no rebuild under a
|
|
315
|
+
// temp name, just the ALTER and the version bump. Existing rows read partial = NULL (they predate the concept).
|
|
316
|
+
// The column may already be there: OUTCOMES_COLUMNS_SQL is the one shared "current schema" constant, so a v1
|
|
317
|
+
// store's rebuild in migrateV1ToV2 (which uses it) already carries every column added since, partial included;
|
|
318
|
+
// this guard makes the step a no-op there instead of a duplicate-column error, while still doing the ALTER for a
|
|
319
|
+
// genuine v4 store that only ever had the v4 columns.
|
|
320
|
+
function migrateV4ToV5(db) {
|
|
321
|
+
db.exec("BEGIN");
|
|
322
|
+
try {
|
|
323
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
324
|
+
if (!cols.includes("partial")) db.exec("ALTER TABLE outcomes ADD COLUMN partial INTEGER;");
|
|
325
|
+
db.exec("PRAGMA user_version = 5");
|
|
326
|
+
db.exec("COMMIT");
|
|
327
|
+
} catch (err) {
|
|
328
|
+
db.exec("ROLLBACK");
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// v5 had no code index tables (card semantic-index). Additive like v3->v4: no rebuild under a temp name, just
|
|
334
|
+
// the four CREATEs and the version bump. No add-only trigger is added for these tables — the code index is a
|
|
335
|
+
// cache, never add-only.
|
|
336
|
+
function migrateV5ToV6(db) {
|
|
337
|
+
db.exec("BEGIN");
|
|
338
|
+
try {
|
|
339
|
+
db.exec(CODE_INDEX_SCHEMA_SQL);
|
|
340
|
+
db.exec("PRAGMA user_version = 6");
|
|
341
|
+
db.exec("COMMIT");
|
|
342
|
+
} catch (err) {
|
|
343
|
+
db.exec("ROLLBACK");
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// v6 had none of the seven condition columns on outcomes (card outcomes-condition-columns). Additive like
|
|
349
|
+
// v4->v5: no rebuild under a temp name, just the guarded ALTERs and the version bump. Each column's guard mirrors
|
|
350
|
+
// migrateV4ToV5's: OUTCOMES_COLUMNS_SQL already carries every column added since for a v1 store's rebuild in
|
|
351
|
+
// migrateV1ToV2, so this step must be a no-op there instead of a duplicate-column error, while still doing the
|
|
352
|
+
// ALTERs for a genuine v6 store.
|
|
353
|
+
const V7_CONDITION_COLUMNS = [
|
|
354
|
+
["harness_commit", "TEXT"],
|
|
355
|
+
["config_hash", "TEXT"],
|
|
356
|
+
["class", "TEXT"],
|
|
357
|
+
["arm", "TEXT"],
|
|
358
|
+
["assigned_by", "TEXT"],
|
|
359
|
+
["explore_probability", "REAL"],
|
|
360
|
+
["backfilled", "INTEGER NOT NULL DEFAULT 0"],
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
function migrateV6ToV7(db) {
|
|
364
|
+
db.exec("BEGIN");
|
|
365
|
+
try {
|
|
366
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
367
|
+
for (const [name, type] of V7_CONDITION_COLUMNS) {
|
|
368
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
|
|
369
|
+
}
|
|
370
|
+
db.exec("PRAGMA user_version = 7");
|
|
371
|
+
db.exec("COMMIT");
|
|
372
|
+
} catch (err) {
|
|
373
|
+
db.exec("ROLLBACK");
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// v7 had no codex_usd column on outcomes (card run-report-codex-cost): the Codex adversary's own cost, priced
|
|
379
|
+
// separately from Claude's usd and never folded into it. Additive like v6->v7: one guarded ALTER, then the
|
|
380
|
+
// version bump. The guard mirrors migrateV6ToV7's: OUTCOMES_COLUMNS_SQL already carries codex_usd for a v1
|
|
381
|
+
// store's rebuild in migrateV1ToV2, so this step must be a no-op there instead of a duplicate-column error,
|
|
382
|
+
// while still doing the ALTER for a genuine v7 store.
|
|
383
|
+
const V8_CODEX_COLUMNS = [["codex_usd", "REAL"]];
|
|
384
|
+
|
|
385
|
+
function migrateV7ToV8(db) {
|
|
386
|
+
db.exec("BEGIN");
|
|
387
|
+
try {
|
|
388
|
+
const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
|
|
389
|
+
for (const [name, type] of V8_CODEX_COLUMNS) {
|
|
390
|
+
if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
|
|
391
|
+
}
|
|
392
|
+
db.exec("PRAGMA user_version = 8");
|
|
393
|
+
db.exec("COMMIT");
|
|
394
|
+
} catch (err) {
|
|
395
|
+
db.exec("ROLLBACK");
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function openMemory(dir) {
|
|
401
|
+
const file = memoryPath(dir);
|
|
402
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
403
|
+
const db = new DatabaseSync(file);
|
|
404
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
405
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
406
|
+
if (version === 0) {
|
|
407
|
+
db.exec(SCHEMA_SQL);
|
|
408
|
+
db.exec("PRAGMA user_version = 8");
|
|
409
|
+
} else if (version === 1) {
|
|
410
|
+
migrateV1ToV2(db);
|
|
411
|
+
migrateV2ToV3(db);
|
|
412
|
+
migrateV3ToV4(db);
|
|
413
|
+
migrateV4ToV5(db);
|
|
414
|
+
migrateV5ToV6(db);
|
|
415
|
+
migrateV6ToV7(db);
|
|
416
|
+
migrateV7ToV8(db);
|
|
417
|
+
} else if (version === 2) {
|
|
418
|
+
migrateV2ToV3(db);
|
|
419
|
+
migrateV3ToV4(db);
|
|
420
|
+
migrateV4ToV5(db);
|
|
421
|
+
migrateV5ToV6(db);
|
|
422
|
+
migrateV6ToV7(db);
|
|
423
|
+
migrateV7ToV8(db);
|
|
424
|
+
} else if (version === 3) {
|
|
425
|
+
migrateV3ToV4(db);
|
|
426
|
+
migrateV4ToV5(db);
|
|
427
|
+
migrateV5ToV6(db);
|
|
428
|
+
migrateV6ToV7(db);
|
|
429
|
+
migrateV7ToV8(db);
|
|
430
|
+
} else if (version === 4) {
|
|
431
|
+
migrateV4ToV5(db);
|
|
432
|
+
migrateV5ToV6(db);
|
|
433
|
+
migrateV6ToV7(db);
|
|
434
|
+
migrateV7ToV8(db);
|
|
435
|
+
} else if (version === 5) {
|
|
436
|
+
migrateV5ToV6(db);
|
|
437
|
+
migrateV6ToV7(db);
|
|
438
|
+
migrateV7ToV8(db);
|
|
439
|
+
} else if (version === 6) {
|
|
440
|
+
migrateV6ToV7(db);
|
|
441
|
+
migrateV7ToV8(db);
|
|
442
|
+
} else if (version === 7) {
|
|
443
|
+
migrateV7ToV8(db);
|
|
444
|
+
} else if (version !== 8) {
|
|
445
|
+
db.close();
|
|
446
|
+
throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 8`);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
file,
|
|
450
|
+
exec(sql) {
|
|
451
|
+
return db.exec(sql);
|
|
452
|
+
},
|
|
453
|
+
prepare(sql) {
|
|
454
|
+
return db.prepare(sql);
|
|
455
|
+
},
|
|
456
|
+
close() {
|
|
457
|
+
db.close();
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function nowIso() {
|
|
463
|
+
return new Date().toISOString();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function asJson(value, fallback) {
|
|
467
|
+
return JSON.stringify(value === undefined ? fallback : value);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parseJsonColumn(value, fallback) {
|
|
471
|
+
if (value === null || value === undefined) return fallback;
|
|
472
|
+
try {
|
|
473
|
+
return JSON.parse(value);
|
|
474
|
+
} catch {
|
|
475
|
+
return fallback;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function toIntOrNull(value) {
|
|
480
|
+
return value === null || value === undefined ? null : value ? 1 : 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function lessonRowToObject(row) {
|
|
484
|
+
if (!row) return null;
|
|
485
|
+
return { ...row, scope: parseJsonColumn(row.scope, []) };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function computeLessonId({ kind, citation, text }) {
|
|
489
|
+
const hash = createHash("sha256").update(`${kind}\n${citation || ""}\n${text}`).digest("hex");
|
|
490
|
+
return hash.slice(0, 16);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function addLesson(m, opts) {
|
|
494
|
+
const { text, kind, scope = [], citation = null, source, task = null, card = null, commit = null, created = nowIso(), id } = opts || {};
|
|
495
|
+
if (typeof text !== "string" || !text.trim()) throw new Error("addLesson requires non-empty text");
|
|
496
|
+
if (!LESSON_KINDS.includes(kind)) throw new Error(`addLesson kind must be one of ${LESSON_KINDS.join(", ")}, got ${kind}`);
|
|
497
|
+
if (!Array.isArray(scope) || scope.some((s) => typeof s !== "string")) throw new Error("addLesson scope must be an array of strings");
|
|
498
|
+
if (!source || typeof source.agent !== "string" || !source.agent.trim()) throw new Error("addLesson requires source.agent");
|
|
499
|
+
const lessonId = id || computeLessonId({ kind, citation, text });
|
|
500
|
+
const existing = getLesson(m, lessonId);
|
|
501
|
+
if (existing) throw new Error(`lesson ${lessonId} already exists`);
|
|
502
|
+
const row = {
|
|
503
|
+
id: lessonId,
|
|
504
|
+
text,
|
|
505
|
+
kind,
|
|
506
|
+
scope: asJson(scope, []),
|
|
507
|
+
citation,
|
|
508
|
+
source_agent: source.agent,
|
|
509
|
+
source_model: source.model ?? null,
|
|
510
|
+
task,
|
|
511
|
+
card,
|
|
512
|
+
commit_sha: commit,
|
|
513
|
+
created,
|
|
514
|
+
};
|
|
515
|
+
m.exec("BEGIN");
|
|
516
|
+
try {
|
|
517
|
+
m.prepare(
|
|
518
|
+
`INSERT INTO lessons (id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created)
|
|
519
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
520
|
+
).run(row.id, row.text, row.kind, row.scope, row.citation, row.source_agent, row.source_model, row.task, row.card, row.commit_sha, row.created);
|
|
521
|
+
m.prepare(`INSERT INTO lessons_fts (id, text) VALUES (?, ?)`).run(row.id, row.text);
|
|
522
|
+
m.exec("COMMIT");
|
|
523
|
+
} catch (err) {
|
|
524
|
+
m.exec("ROLLBACK");
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
return getLesson(m, lessonId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function getLesson(m, id) {
|
|
531
|
+
const row = m.prepare("SELECT * FROM lessons WHERE id = ?").get(id);
|
|
532
|
+
return lessonRowToObject(row);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function supersedeLesson(m, oldId, newId) {
|
|
536
|
+
if (oldId === newId) throw new Error("supersedeLesson requires two different lesson ids");
|
|
537
|
+
const oldRow = getLesson(m, oldId);
|
|
538
|
+
if (!oldRow) throw new Error(`lesson ${oldId} does not exist`);
|
|
539
|
+
const newRow = getLesson(m, newId);
|
|
540
|
+
if (!newRow) throw new Error(`lesson ${newId} does not exist`);
|
|
541
|
+
if (oldRow.superseded_by) throw new Error(`lesson ${oldId} is already superseded by ${oldRow.superseded_by}`);
|
|
542
|
+
m.prepare("UPDATE lessons SET superseded_by = ? WHERE id = ?").run(newId, oldId);
|
|
543
|
+
return getLesson(m, oldId);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Clears a stale mark (set by recallLessons's citation re-check) so the lesson is a candidate again — the way
|
|
547
|
+
// back once a checkout is fixed up or the mark turns out to be wrong. `stale` is not one of the columns the
|
|
548
|
+
// lessons_no_rewrite trigger protects, so this is an ordinary UPDATE, not a special case.
|
|
549
|
+
export function unstaleLesson(m, id) {
|
|
550
|
+
const row = getLesson(m, id);
|
|
551
|
+
if (!row) throw new Error(`lesson ${id} does not exist`);
|
|
552
|
+
m.prepare("UPDATE lessons SET stale = NULL WHERE id = ?").run(id);
|
|
553
|
+
return getLesson(m, id);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function listLessons(m, { includeSuperseded = false } = {}) {
|
|
557
|
+
const rows = includeSuperseded
|
|
558
|
+
? m.prepare("SELECT * FROM lessons ORDER BY created ASC, id ASC").all()
|
|
559
|
+
: m.prepare("SELECT * FROM lessons WHERE superseded_by IS NULL ORDER BY created ASC, id ASC").all();
|
|
560
|
+
return rows.map(lessonRowToObject);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Whitespace-split tokens, each quoted for FTS5 MATCH and OR'd together; shared by searchLessons and
|
|
564
|
+
// recallLessons's BM25 side, and by lib/code-index.mjs's searchIndex (same FTS5 dialect, one code_chunks_fts
|
|
565
|
+
// table instead of lessons_fts). Returns null for an empty/whitespace-only query (nothing to match).
|
|
566
|
+
export function ftsMatchQuery(query) {
|
|
567
|
+
const tokens = String(query || "")
|
|
568
|
+
.split(/\s+/)
|
|
569
|
+
.map((t) => t.trim())
|
|
570
|
+
.filter(Boolean);
|
|
571
|
+
if (!tokens.length) return null;
|
|
572
|
+
return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function searchLessons(m, query, { k = 8, includeSuperseded = false } = {}) {
|
|
576
|
+
const match = ftsMatchQuery(query);
|
|
577
|
+
if (!match) return [];
|
|
578
|
+
const rows = m
|
|
579
|
+
.prepare(
|
|
580
|
+
`SELECT l.* FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
581
|
+
WHERE lessons_fts MATCH ? AND (? OR l.superseded_by IS NULL)
|
|
582
|
+
ORDER BY bm25(lessons_fts) LIMIT ?`
|
|
583
|
+
)
|
|
584
|
+
.all(match, includeSuperseded ? 1 : 0, k);
|
|
585
|
+
return rows.map(lessonRowToObject);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- Embeddings on lesson rows -------------------------------------------------------------------------------
|
|
589
|
+
// A vector is stored as the raw bytes of a Float32Array (native-endian; store and load run on the same machine,
|
|
590
|
+
// this database is never copied cross-architecture). embedding_model/embedding_dims travel with it so a query
|
|
591
|
+
// only ever compares vectors from the same model+dims pair (memory-recall design #2).
|
|
592
|
+
|
|
593
|
+
function floatsToBlob(vector) {
|
|
594
|
+
return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function blobToFloats(blob) {
|
|
598
|
+
if (!blob) return null;
|
|
599
|
+
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
|
600
|
+
const copy = new Uint8Array(bytes.length);
|
|
601
|
+
copy.set(bytes);
|
|
602
|
+
return new Float32Array(copy.buffer);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function setLessonEmbedding(m, id, { model, dims, vector }) {
|
|
606
|
+
if (!model || !dims) throw new Error("setLessonEmbedding requires model and dims");
|
|
607
|
+
if (!(vector instanceof Float32Array)) throw new Error("setLessonEmbedding requires vector to be a Float32Array");
|
|
608
|
+
m.prepare("UPDATE lessons SET embedding_model = ?, embedding_dims = ?, embedding = ? WHERE id = ?").run(
|
|
609
|
+
model,
|
|
610
|
+
dims,
|
|
611
|
+
floatsToBlob(vector),
|
|
612
|
+
id
|
|
613
|
+
);
|
|
614
|
+
return getLesson(m, id);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Rows still needing an embedding for this model+dims pair: not superseded, not stale, and either never
|
|
618
|
+
// embedded or embedded under a different model or dims (a space from another model is not comparable).
|
|
619
|
+
export function lessonsNeedingEmbedding(m, { model, dims }) {
|
|
620
|
+
const rows = m
|
|
621
|
+
.prepare(
|
|
622
|
+
`SELECT * FROM lessons
|
|
623
|
+
WHERE superseded_by IS NULL AND stale IS NULL
|
|
624
|
+
AND (embedding IS NULL OR embedding_model IS NOT ? OR embedding_dims IS NOT ?)
|
|
625
|
+
ORDER BY created ASC, id ASC`
|
|
626
|
+
)
|
|
627
|
+
.all(model, dims);
|
|
628
|
+
return rows.map(lessonRowToObject);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Embeds every row lessonsNeedingEmbedding finds for provider.model/provider.dims, batch texts at a time. A
|
|
632
|
+
// batch the provider fails to embed is counted in `failed` and left unembedded (never thrown); the next batch
|
|
633
|
+
// still runs. onProgress, when given, is called after each batch with the running totals.
|
|
634
|
+
export async function embedLessons(m, provider, { batch = 32, onProgress } = {}) {
|
|
635
|
+
if (!provider) throw new Error("embedLessons requires a provider");
|
|
636
|
+
const todo = lessonsNeedingEmbedding(m, { model: provider.model, dims: provider.dims });
|
|
637
|
+
let embedded = 0;
|
|
638
|
+
let failed = 0;
|
|
639
|
+
let reason = null;
|
|
640
|
+
for (let i = 0; i < todo.length; i += batch) {
|
|
641
|
+
const chunk = todo.slice(i, i + batch);
|
|
642
|
+
const result = await provider.embed(
|
|
643
|
+
chunk.map((l) => l.text),
|
|
644
|
+
{ inputType: "document" }
|
|
645
|
+
);
|
|
646
|
+
if (!result.ok) {
|
|
647
|
+
failed += chunk.length;
|
|
648
|
+
reason = result.reason;
|
|
649
|
+
} else {
|
|
650
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
651
|
+
setLessonEmbedding(m, chunk[j].id, { model: provider.model, dims: provider.dims, vector: result.vectors[j] });
|
|
652
|
+
embedded++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (onProgress) onProgress({ embedded, total: todo.length, failed, reason });
|
|
656
|
+
}
|
|
657
|
+
return { total: todo.length, embedded, failed, reason };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// --- Citation re-check -----------------------------------------------------------------------------------------
|
|
661
|
+
// A citation of the form "path:line" or 'path:line "quoted text"' is checked against a checkout: the file
|
|
662
|
+
// exists, the line exists, and when quoted text is present the line still contains it. A "report:" citation or
|
|
663
|
+
// any free text that doesn't match the path:line shape passes untouched when no `report` is given — there is
|
|
664
|
+
// nothing to re-check it against. With a `report` (card memory-reflect), a "report:<task>" citation is checked
|
|
665
|
+
// against that report's tasks and "report:<task>/<block-id>" against its adversary blocks; that is stricter than
|
|
666
|
+
// the pass-through above, so callers that want it must opt in by passing `{ report }`.
|
|
667
|
+
const CITATION_RE = /^(?<path>[^:\s]+):(?<line>\d+)(?:\s+"(?<text>.+)")?$/;
|
|
668
|
+
|
|
669
|
+
export function checkCitation(lesson, checkoutDir, { report } = {}) {
|
|
670
|
+
const citation = lesson && lesson.citation;
|
|
671
|
+
if (!citation || typeof citation !== "string") return { ok: true, reason: "no citation" };
|
|
672
|
+
if (citation.startsWith("report:")) {
|
|
673
|
+
if (!report) return { ok: true, reason: "report citation" };
|
|
674
|
+
const rest = citation.slice("report:".length);
|
|
675
|
+
const slash = rest.indexOf("/");
|
|
676
|
+
if (slash === -1) {
|
|
677
|
+
const found = (report.levels || []).some((l) => (l.tasks || []).some((t) => t.id === rest));
|
|
678
|
+
return found ? { ok: true, reason: "task holds" } : { ok: false, reason: `no task "${rest}" in this report` };
|
|
679
|
+
}
|
|
680
|
+
const taskId = rest.slice(0, slash);
|
|
681
|
+
const blockId = rest.slice(slash + 1);
|
|
682
|
+
const found = adversaryBlocks(report).some((b) => b.task === taskId && b.id === blockId);
|
|
683
|
+
return found ? { ok: true, reason: "block holds" } : { ok: false, reason: `no block "${blockId}" of task "${taskId}" in this report` };
|
|
684
|
+
}
|
|
685
|
+
const match = citation.match(CITATION_RE);
|
|
686
|
+
if (!match) return { ok: true, reason: "free text citation" };
|
|
687
|
+
const { path: relPath, line: lineStr, text } = match.groups;
|
|
688
|
+
const lineNum = Number(lineStr);
|
|
689
|
+
const filePath = resolve(checkoutDir, relPath);
|
|
690
|
+
if (!existsSync(filePath)) return { ok: false, reason: `file not found: ${relPath}` };
|
|
691
|
+
let content;
|
|
692
|
+
try {
|
|
693
|
+
content = readFileSync(filePath, "utf8");
|
|
694
|
+
} catch (err) {
|
|
695
|
+
return { ok: false, reason: `cannot read ${relPath}: ${err.message}` };
|
|
696
|
+
}
|
|
697
|
+
const lines = content.split(/\r?\n/);
|
|
698
|
+
const lineContent = lines[lineNum - 1];
|
|
699
|
+
if (lineContent === undefined) return { ok: false, reason: `line ${lineNum} does not exist in ${relPath}` };
|
|
700
|
+
if (text && !lineContent.includes(text)) {
|
|
701
|
+
return { ok: false, reason: `line ${lineNum} of ${relPath} no longer contains the cited text` };
|
|
702
|
+
}
|
|
703
|
+
return { ok: true, reason: "citation holds" };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// --- Minimal glob matcher for scope boost -----------------------------------------------------------------
|
|
707
|
+
// Supports **, *, ? only (no {a,b}, no negation); a scope/files entry with none of those characters is an
|
|
708
|
+
// exact-string match, never treated as a pattern.
|
|
709
|
+
// Exported so lib/code-index.mjs's own path-glob matching (cfg.include/exclude, --files) reuses this exact
|
|
710
|
+
// dialect instead of a second one (card semantic-index): both live in this plugin's lib/, so there is no
|
|
711
|
+
// cross-plugin boundary excusing a duplicate the way learn.mjs's local copy of doug-gates/lib/glob.mjs is.
|
|
712
|
+
export function isGlobPattern(s) {
|
|
713
|
+
return /[*?]/.test(s);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function globToRegExp(glob) {
|
|
717
|
+
let re = "";
|
|
718
|
+
let i = 0;
|
|
719
|
+
while (i < glob.length) {
|
|
720
|
+
const c = glob[i];
|
|
721
|
+
if (c === "*") {
|
|
722
|
+
if (glob[i + 1] === "*") {
|
|
723
|
+
// "**/" also matches zero directories (embeddings.mjs-style, matching doug-gates/lib/glob.mjs); a
|
|
724
|
+
// trailing "**" with nothing after it matches everything, directories included.
|
|
725
|
+
if (glob[i + 2] === "/") {
|
|
726
|
+
re += "(?:.*/)?";
|
|
727
|
+
i += 3;
|
|
728
|
+
} else {
|
|
729
|
+
re += ".*";
|
|
730
|
+
i += 2;
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
re += "[^/]*";
|
|
734
|
+
i += 1;
|
|
735
|
+
}
|
|
736
|
+
} else if (c === "?") {
|
|
737
|
+
re += ".";
|
|
738
|
+
i += 1;
|
|
739
|
+
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
740
|
+
re += "\\" + c;
|
|
741
|
+
i += 1;
|
|
742
|
+
} else {
|
|
743
|
+
re += c;
|
|
744
|
+
i += 1;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return new RegExp("^" + re + "$");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// A scope entry that is a glob is tested against each file; a file entry that is a glob is tested against each
|
|
751
|
+
// scope literal; equal strings match either way.
|
|
752
|
+
function scopeMatchesFiles(scope, files) {
|
|
753
|
+
for (const s of scope) {
|
|
754
|
+
for (const f of files) {
|
|
755
|
+
if (s === f) return true;
|
|
756
|
+
if (isGlobPattern(s) && globToRegExp(s).test(f)) return true;
|
|
757
|
+
if (isGlobPattern(f) && globToRegExp(f).test(s)) return true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// An unparseable created/confirmed value must never silently drop a lesson from recall (a NaN age compares
|
|
764
|
+
// false against every staleDays bound): treat it as fresh, age 0, rather than excluding the row.
|
|
765
|
+
function ageDaysOf(anchor, nowMs) {
|
|
766
|
+
const t = new Date(anchor).getTime();
|
|
767
|
+
return Number.isFinite(t) ? (nowMs - t) / 86400000 : 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// checkoutDir is a real project root only when it exists and contains a .doug directory — a wrong or partial
|
|
771
|
+
// checkout (or none given) must never be trusted to mark every candidate stale. isProjectRoot gates whether
|
|
772
|
+
// recallLessons re-checks citations at all; when it doesn't, lessons are returned unchecked (checked: false).
|
|
773
|
+
function isProjectRoot(checkoutDir) {
|
|
774
|
+
if (!checkoutDir) return false;
|
|
775
|
+
try {
|
|
776
|
+
return statSync(resolve(checkoutDir, ".doug")).isDirectory();
|
|
777
|
+
} catch {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Strips the embedding BLOB and its model/dims off a lesson before it leaves the module (recall's JSON output
|
|
783
|
+
// would otherwise carry a multi-KB blob per lesson): hasVector says whether one is stored, without saying what
|
|
784
|
+
// it is.
|
|
785
|
+
function toRecallLesson(lesson, extra) {
|
|
786
|
+
const { embedding, embedding_model, embedding_dims, ...rest } = lesson;
|
|
787
|
+
return { ...rest, hasVector: !!embedding, ...extra };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// --- Hybrid recall -----------------------------------------------------------------------------------------
|
|
791
|
+
// BM25 (FTS5) and cosine-over-stored-vectors fused by reciprocal rank fusion, recency-weighted, scope-boosted,
|
|
792
|
+
// then citation-rechecked against checkoutDir before anything is returned — only when checkoutDir is a real
|
|
793
|
+
// project root (isProjectRoot); otherwise every candidate is returned unchecked (checked: false in the result)
|
|
794
|
+
// rather than either throwing (no checkoutDir given) or marking every row stale forever (a wrong one given).
|
|
795
|
+
// Never throws for a provider or checkout problem (a database problem may still throw, same as every other
|
|
796
|
+
// export here). See design #2 in the memory-recall card for the step-by-step this implements.
|
|
797
|
+
export async function recallLessons(m, query, opts = {}) {
|
|
798
|
+
const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30 } = opts;
|
|
799
|
+
const nowDate = now instanceof Date ? now : new Date(now);
|
|
800
|
+
const nowIsoStr = nowDate.toISOString();
|
|
801
|
+
const nowMs = nowDate.getTime();
|
|
802
|
+
const checked = isProjectRoot(checkoutDir);
|
|
803
|
+
|
|
804
|
+
// (a) candidates: not superseded, not stale, and confirmed-or-created within staleDays.
|
|
805
|
+
const allRows = m.prepare("SELECT * FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").all().map(lessonRowToObject);
|
|
806
|
+
const freshRows = allRows.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
|
|
807
|
+
const byId = new Map(freshRows.map((l) => [l.id, l]));
|
|
808
|
+
|
|
809
|
+
// (b) BM25 side, top 50.
|
|
810
|
+
const match = ftsMatchQuery(query);
|
|
811
|
+
let bm25Ranked = [];
|
|
812
|
+
if (match) {
|
|
813
|
+
const rows = m
|
|
814
|
+
.prepare(
|
|
815
|
+
`SELECT l.id FROM lessons_fts f JOIN lessons l ON l.id = f.id
|
|
816
|
+
WHERE lessons_fts MATCH ? AND l.superseded_by IS NULL AND l.stale IS NULL
|
|
817
|
+
ORDER BY bm25(lessons_fts) LIMIT 50`
|
|
818
|
+
)
|
|
819
|
+
.all(match);
|
|
820
|
+
bm25Ranked = rows.map((r) => r.id).filter((id) => byId.has(id));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// (c) vector side, only with a provider and rows stored under the same model+dims, top 50.
|
|
824
|
+
let mode = "keyword-only";
|
|
825
|
+
let reason = "no provider configured";
|
|
826
|
+
let providerName = null;
|
|
827
|
+
let vectorRanked = [];
|
|
828
|
+
if (provider) {
|
|
829
|
+
providerName = provider.name;
|
|
830
|
+
const vectorRows = freshRows.filter(
|
|
831
|
+
(l) => l.embedding && l.embedding_model === provider.model && l.embedding_dims === provider.dims
|
|
832
|
+
);
|
|
833
|
+
if (!vectorRows.length) {
|
|
834
|
+
reason = `no vectors stored for ${provider.model}/${provider.dims}`;
|
|
835
|
+
} else {
|
|
836
|
+
const embedResult = await provider.embed([String(query || "")], { inputType: "query" });
|
|
837
|
+
if (!embedResult.ok) {
|
|
838
|
+
reason = embedResult.reason;
|
|
839
|
+
} else {
|
|
840
|
+
const qVec = embedResult.vectors[0];
|
|
841
|
+
const scored = vectorRows.map((l) => {
|
|
842
|
+
const vec = blobToFloats(l.embedding);
|
|
843
|
+
let dot = 0;
|
|
844
|
+
for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
|
|
845
|
+
return { id: l.id, score: dot };
|
|
846
|
+
});
|
|
847
|
+
scored.sort((a, b) => b.score - a.score);
|
|
848
|
+
vectorRanked = scored.slice(0, 50).map((s) => s.id);
|
|
849
|
+
mode = "hybrid";
|
|
850
|
+
reason = null;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// (d) reciprocal rank fusion, 1/(60+rank) per side, rank starting at 1.
|
|
856
|
+
const fused = new Map();
|
|
857
|
+
bm25Ranked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
|
|
858
|
+
vectorRanked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
|
|
859
|
+
|
|
860
|
+
// (e) recency weight, (f) scope boost.
|
|
861
|
+
const candidates = [];
|
|
862
|
+
for (const [id, rrfScore] of fused.entries()) {
|
|
863
|
+
const lesson = byId.get(id);
|
|
864
|
+
if (!lesson) continue;
|
|
865
|
+
const ageDays = ageDaysOf(lesson.confirmed || lesson.created, nowMs);
|
|
866
|
+
const recency = 0.5 + 0.5 * Math.exp(-ageDays / 90);
|
|
867
|
+
let score = rrfScore * recency;
|
|
868
|
+
if (files.length && (lesson.scope.length === 0 || scopeMatchesFiles(lesson.scope, files))) score *= 1.5;
|
|
869
|
+
const sides = [];
|
|
870
|
+
if (bm25Ranked.includes(id)) sides.push("bm25");
|
|
871
|
+
if (vectorRanked.includes(id)) sides.push("vector");
|
|
872
|
+
candidates.push({ lesson, score, sides });
|
|
873
|
+
}
|
|
874
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
875
|
+
|
|
876
|
+
// (g) top k. With a real project root, each candidate's citation is re-checked: a failure marks the row
|
|
877
|
+
// stale and drops it, backfilling from the next candidate so k are returned when enough candidates exist,
|
|
878
|
+
// and a pass sets confirmed. Without one (checked: false), candidates are taken as-is — nothing is marked
|
|
879
|
+
// stale or confirmed, since there is nothing trustworthy to check them against.
|
|
880
|
+
const result = [];
|
|
881
|
+
let idx = 0;
|
|
882
|
+
while (result.length < k && idx < candidates.length) {
|
|
883
|
+
const cand = candidates[idx++];
|
|
884
|
+
if (!checked) {
|
|
885
|
+
result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const check = checkCitation(cand.lesson, checkoutDir);
|
|
889
|
+
if (!check.ok) {
|
|
890
|
+
m.prepare("UPDATE lessons SET stale = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
m.prepare("UPDATE lessons SET confirmed = ? WHERE id = ?").run(nowIsoStr, cand.lesson.id);
|
|
894
|
+
result.push(toRecallLesson({ ...cand.lesson, confirmed: nowIsoStr }, { score: cand.score, sides: cand.sides }));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, lessons: result };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function taskAdversary(task, level) {
|
|
901
|
+
if (task.adversary && typeof task.adversary === "object" && task.adversary.ran) {
|
|
902
|
+
return { verdict: task.adversary.verdict ?? null, blocked: toIntOrNull(task.adversary.blocked), source: "task" };
|
|
903
|
+
}
|
|
904
|
+
const la = level && level.levelAdversary;
|
|
905
|
+
if (la && la.ran && Array.isArray(la.tasks) && la.tasks.includes(task.id)) {
|
|
906
|
+
return { verdict: la.verdict ?? null, blocked: toIntOrNull(la.blocked), source: "level" };
|
|
907
|
+
}
|
|
908
|
+
return { verdict: null, blocked: null, source: null };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Prices one adversary entry's usage ({inputTokens, outputTokens}, the shape ReviewResult.usage carries into a
|
|
912
|
+
// report) at the CODEX_DEFAULT_MODEL row of CODEX_PRICES (card run-report-codex-cost). Null, never estimated,
|
|
913
|
+
// when the usage is missing or malformed or the model has no priced row.
|
|
914
|
+
function priceAdversaryUsage(usage) {
|
|
915
|
+
if (!usage || typeof usage !== "object" || typeof usage.inputTokens !== "number" || typeof usage.outputTokens !== "number") return null;
|
|
916
|
+
const p = CODEX_PRICES[CODEX_DEFAULT_MODEL];
|
|
917
|
+
if (!p) return null;
|
|
918
|
+
return (usage.inputTokens * p.input + usage.outputTokens * p.output) / 1e6;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// codex_usd for one outcome row (card run-report-codex-cost): the task's own adversary usage when it has one
|
|
922
|
+
// (adv.source === "task"); the level adversary's usage only when the level has exactly one task, since a shared
|
|
923
|
+
// level adversary's cost cannot be split across tasks without double-counting it; null otherwise (never split,
|
|
924
|
+
// never counted twice).
|
|
925
|
+
function taskCodexUsd(task, level, adv) {
|
|
926
|
+
if (adv.source === "task") return priceAdversaryUsage(task.adversary && task.adversary.usage);
|
|
927
|
+
if (adv.source === "level") {
|
|
928
|
+
const la = level && level.levelAdversary;
|
|
929
|
+
if (la && Array.isArray(la.tasks) && la.tasks.length === 1 && la.tasks[0] === task.id) {
|
|
930
|
+
return priceAdversaryUsage(la.usage);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function findPlanTask(plan, id) {
|
|
937
|
+
if (!plan) return null;
|
|
938
|
+
const list = Array.isArray(plan.tasks) ? plan.tasks : [];
|
|
939
|
+
return list.find((t) => t.id === id) || null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function outcomeRows(report, opts) {
|
|
943
|
+
const { plan = null, classes = [], run = null, reportHash, reportPath = null, card = null, commit = null, cost = null, now = nowIso() } = opts || {};
|
|
944
|
+
const allBlocks = classifyBlocks(report, classes).map((b) => ({
|
|
945
|
+
id: b.id,
|
|
946
|
+
pass: b.pass,
|
|
947
|
+
status: b.status,
|
|
948
|
+
description: b.description,
|
|
949
|
+
class: b.class,
|
|
950
|
+
reason: b.reason,
|
|
951
|
+
task: b.task,
|
|
952
|
+
}));
|
|
953
|
+
const rows = [];
|
|
954
|
+
for (const level of report.levels || []) {
|
|
955
|
+
for (const task of level.tasks || []) {
|
|
956
|
+
const runValue = run || `report:${String(reportHash).slice(0, 12)}`;
|
|
957
|
+
const planTask = findPlanTask(plan, task.id);
|
|
958
|
+
const adv = taskAdversary(task, level);
|
|
959
|
+
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
|
|
960
|
+
const passes = task.passes ?? attempts.length ?? null;
|
|
961
|
+
const fixPasses = passes === null ? null : Math.max(0, passes - 1);
|
|
962
|
+
const blocks = allBlocks.filter((b) => b.task === task.id).map(({ task: _t, ...rest }) => rest);
|
|
963
|
+
let tokens = null;
|
|
964
|
+
let usd = null;
|
|
965
|
+
if (cost && cost.tasks) {
|
|
966
|
+
const match = cost.tasks.find((t) => t.name === task.id);
|
|
967
|
+
if (match) {
|
|
968
|
+
tokens = { ...match.usage, agents: match.agents, source: "transcripts" };
|
|
969
|
+
usd = match.unpriced === 0 ? match.usd : null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (tokens === null) {
|
|
973
|
+
const wfTokens = task.budget && task.budget.spent && typeof task.budget.spent.tokens === "number" ? task.budget.spent.tokens : null;
|
|
974
|
+
tokens = wfTokens === null ? null : { total: wfTokens, source: "workflow" };
|
|
975
|
+
}
|
|
976
|
+
rows.push({
|
|
977
|
+
recorded: now,
|
|
978
|
+
run: runValue,
|
|
979
|
+
run_id: run || null,
|
|
980
|
+
report_hash: reportHash,
|
|
981
|
+
report_path: reportPath,
|
|
982
|
+
card: task.card ?? card ?? (plan && plan.card) ?? null,
|
|
983
|
+
task: task.id,
|
|
984
|
+
spec_hash: task.specHash ?? null,
|
|
985
|
+
files: planTask && planTask.files ? planTask.files : null,
|
|
986
|
+
size: task.size ?? null,
|
|
987
|
+
shape: task.shape ?? null,
|
|
988
|
+
models: task.models ?? null,
|
|
989
|
+
implemented: toIntOrNull(task.implemented),
|
|
990
|
+
verified: toIntOrNull(task.verified),
|
|
991
|
+
reviewed: toIntOrNull(task.reviewed),
|
|
992
|
+
review_issues: Array.isArray(task.reviewIssues) ? task.reviewIssues : [],
|
|
993
|
+
review_issue_count: Array.isArray(task.reviewIssues) ? task.reviewIssues.length : 0,
|
|
994
|
+
adversary_verdict: adv.verdict,
|
|
995
|
+
adversary_blocked: adv.blocked,
|
|
996
|
+
adversary_source: adv.source,
|
|
997
|
+
blocks,
|
|
998
|
+
passes,
|
|
999
|
+
fix_passes: fixPasses,
|
|
1000
|
+
stop_reason: task.stopReason ?? null,
|
|
1001
|
+
in_scope: toIntOrNull(task.inScope),
|
|
1002
|
+
budget_spent: (task.budget && task.budget.spent) || null,
|
|
1003
|
+
tokens,
|
|
1004
|
+
usd,
|
|
1005
|
+
codex_usd: taskCodexUsd(task, level, adv),
|
|
1006
|
+
commit_sha: commit ?? task.commit ?? null,
|
|
1007
|
+
track: "flow",
|
|
1008
|
+
wall_clock: null,
|
|
1009
|
+
gate: null,
|
|
1010
|
+
note: null,
|
|
1011
|
+
partial: toIntOrNull(task.partial),
|
|
1012
|
+
// The seven condition columns (card outcomes-condition-columns) are always null on a flow row today —
|
|
1013
|
+
// see the comment above OUTCOMES_COLUMNS_SQL. backfilled is 0, never null: it is NOT NULL DEFAULT 0.
|
|
1014
|
+
harness_commit: null,
|
|
1015
|
+
config_hash: null,
|
|
1016
|
+
class: null,
|
|
1017
|
+
arm: null,
|
|
1018
|
+
assigned_by: null,
|
|
1019
|
+
explore_probability: null,
|
|
1020
|
+
backfilled: 0,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return rows;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// --- Condition hashing (card outcomes-condition-columns) --------------------------------------------------
|
|
1028
|
+
// "The Models table text": the contiguous lines starting with "|" in the first table after the "## Models"
|
|
1029
|
+
// heading in a CLAUDE.md's markdown, joined with "\n" — empty string when there is no heading (or no markdown
|
|
1030
|
+
// at all). Reuses MODELS_HEADING from lib/models.mjs so this reads the same heading parseModelsSection does,
|
|
1031
|
+
// rather than a second regex that could drift from it.
|
|
1032
|
+
function modelsTableText(markdown) {
|
|
1033
|
+
if (typeof markdown !== "string") return "";
|
|
1034
|
+
const lines = markdown.split(/\r?\n/);
|
|
1035
|
+
const start = lines.findIndex((l) => MODELS_HEADING.test(l.trim()));
|
|
1036
|
+
if (start < 0) return "";
|
|
1037
|
+
const tableLines = [];
|
|
1038
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
1039
|
+
const line = lines[i];
|
|
1040
|
+
if (/^#{1,6}\s/.test(line.trim())) break;
|
|
1041
|
+
if (line.trim().startsWith("|")) tableLines.push(line);
|
|
1042
|
+
else if (tableLines.length) break;
|
|
1043
|
+
}
|
|
1044
|
+
return tableLines.join("\n");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// The condition's configHash (card outcomes-condition-columns, brief F8): hex sha256 of `.doug/config.json`'s
|
|
1048
|
+
// utf8 bytes (or "" when the file is absent) plus "\n" plus the Models table text (above, "" when CLAUDE.md or
|
|
1049
|
+
// its Models heading is absent). Deterministic and side-effect-free — a table cell edit changes it, prose
|
|
1050
|
+
// anywhere else in CLAUDE.md (outside the table) does not; `.doug/config.json` is hashed whole (its full utf8
|
|
1051
|
+
// bytes), so any change anywhere in that file changes the hash, and a config.json edit changes it with CLAUDE.md
|
|
1052
|
+
// held fixed.
|
|
1053
|
+
export function conditionConfigHash(dir) {
|
|
1054
|
+
const configPath = join(dir, ".doug", "config.json");
|
|
1055
|
+
const configText = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
1056
|
+
const claudeMdPath = join(dir, "CLAUDE.md");
|
|
1057
|
+
const markdown = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, "utf8") : "";
|
|
1058
|
+
return createHash("sha256").update(`${configText}\n${modelsTableText(markdown)}`).digest("hex");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// One hand-track landing, one row: run = 'hand:<commit>' (re-recording the same commit updates the same row;
|
|
1062
|
+
// a card that lands in two commits gets two rows), task = card. It does not read the board, a plan, or a
|
|
1063
|
+
// report — it records only the values it is given. See the comment above OUTCOMES_COLUMNS_SQL for which
|
|
1064
|
+
// columns are null on this track and why.
|
|
1065
|
+
//
|
|
1066
|
+
// `condition` (card outcomes-condition-columns) is the parsed condition.json a caller read from
|
|
1067
|
+
// .doug/.state/reports/<card>/condition.json (see conditionConfigHash below and the CLI's `condition open`), or
|
|
1068
|
+
// null when none was found: null fills every one of the seven condition columns with null, exactly like a row
|
|
1069
|
+
// that has never been stamped. `backfilled` is always 0 here — only `memory.mjs condition backfill`, which never
|
|
1070
|
+
// goes through this function, ever sets it to 1.
|
|
1071
|
+
export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null } = {}) {
|
|
1072
|
+
if (!card) throw new Error("handOutcomeRow requires card");
|
|
1073
|
+
if (!commit) throw new Error("handOutcomeRow requires commit");
|
|
1074
|
+
return {
|
|
1075
|
+
recorded: now,
|
|
1076
|
+
run: `hand:${commit}`,
|
|
1077
|
+
run_id: null,
|
|
1078
|
+
report_hash: null,
|
|
1079
|
+
report_path: null,
|
|
1080
|
+
card,
|
|
1081
|
+
task: card,
|
|
1082
|
+
spec_hash: null,
|
|
1083
|
+
files: null,
|
|
1084
|
+
size: null,
|
|
1085
|
+
shape: null,
|
|
1086
|
+
models: null,
|
|
1087
|
+
implemented: null,
|
|
1088
|
+
verified: null,
|
|
1089
|
+
reviewed: null,
|
|
1090
|
+
review_issues: null,
|
|
1091
|
+
review_issue_count: null,
|
|
1092
|
+
adversary_verdict: null,
|
|
1093
|
+
adversary_blocked: null,
|
|
1094
|
+
adversary_source: null,
|
|
1095
|
+
blocks: null,
|
|
1096
|
+
passes: null,
|
|
1097
|
+
fix_passes: null,
|
|
1098
|
+
stop_reason: null,
|
|
1099
|
+
in_scope: null,
|
|
1100
|
+
budget_spent: null,
|
|
1101
|
+
tokens: null,
|
|
1102
|
+
usd: null,
|
|
1103
|
+
codex_usd: null,
|
|
1104
|
+
commit_sha: commit,
|
|
1105
|
+
track: "hand",
|
|
1106
|
+
wall_clock: wallClock,
|
|
1107
|
+
gate,
|
|
1108
|
+
note,
|
|
1109
|
+
partial: null,
|
|
1110
|
+
harness_commit: condition ? condition.harnessCommit ?? null : null,
|
|
1111
|
+
config_hash: condition ? condition.configHash ?? null : null,
|
|
1112
|
+
class: condition ? condition.class ?? null : null,
|
|
1113
|
+
arm: condition ? condition.arm ?? null : null,
|
|
1114
|
+
assigned_by: condition ? condition.assignedBy ?? null : null,
|
|
1115
|
+
explore_probability: condition ? condition.exploreProbability ?? null : null,
|
|
1116
|
+
backfilled: 0,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const OUTCOME_COLUMNS = [
|
|
1121
|
+
"recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
|
|
1122
|
+
"models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
|
|
1123
|
+
"adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "in_scope",
|
|
1124
|
+
"budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
|
|
1125
|
+
"harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
|
|
1126
|
+
];
|
|
1127
|
+
|
|
1128
|
+
const JSON_COLUMNS = new Set(["files", "models", "review_issues", "blocks", "budget_spent", "tokens"]);
|
|
1129
|
+
|
|
1130
|
+
// The seven condition columns (card outcomes-condition-columns) upsert atomically, all seven together, keyed on
|
|
1131
|
+
// whether the *new* row carries a harness_commit — never column-by-column (a plain per-column COALESCE let a
|
|
1132
|
+
// re-record with no condition zero out an existing stamp's `backfilled` flag while keeping its old
|
|
1133
|
+
// harness_commit, mixing half of one stamp with half of another, since `backfilled` defaults to 0, not null,
|
|
1134
|
+
// when a caller omits it). When `excluded.harness_commit` is null (this row carries no condition), every one of
|
|
1135
|
+
// the seven columns keeps the existing row's value untouched; otherwise every one of the seven takes the new
|
|
1136
|
+
// row's value, including `backfilled` (a fresh `condition open`/`record --hand` stamp legitimately replaces an
|
|
1137
|
+
// older backfilled placeholder with backfilled 0). This is what keeps a `condition backfill` stamp — or any
|
|
1138
|
+
// other already-recorded stamp — intact across a later re-record for the same run+task that carries none.
|
|
1139
|
+
const CONDITION_COLUMNS = new Set(["harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled"]);
|
|
1140
|
+
|
|
1141
|
+
export function recordOutcomes(m, rows) {
|
|
1142
|
+
let inserted = 0;
|
|
1143
|
+
let updated = 0;
|
|
1144
|
+
m.exec("BEGIN");
|
|
1145
|
+
try {
|
|
1146
|
+
for (const row of rows) {
|
|
1147
|
+
const existing = m.prepare("SELECT id FROM outcomes WHERE run = ? AND task = ?").get(row.run, row.task);
|
|
1148
|
+
// `backfilled` is NOT NULL DEFAULT 0 (v7): a caller built before this column existed (a row object that
|
|
1149
|
+
// simply doesn't mention it) must default to 0, its own column default, not null — every other column
|
|
1150
|
+
// still defaults to null.
|
|
1151
|
+
const values = OUTCOME_COLUMNS.map((c) =>
|
|
1152
|
+
JSON_COLUMNS.has(c) ? asJson(row[c], c === "blocks" ? [] : null) : c === "backfilled" ? row[c] ?? 0 : row[c] ?? null
|
|
1153
|
+
);
|
|
1154
|
+
const placeholders = OUTCOME_COLUMNS.map(() => "?").join(", ");
|
|
1155
|
+
const updateSet = OUTCOME_COLUMNS.filter((c) => c !== "run" && c !== "task")
|
|
1156
|
+
.map((c) =>
|
|
1157
|
+
CONDITION_COLUMNS.has(c)
|
|
1158
|
+
? `${c} = CASE WHEN excluded.harness_commit IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
|
|
1159
|
+
: `${c} = excluded.${c}`
|
|
1160
|
+
)
|
|
1161
|
+
.join(", ");
|
|
1162
|
+
m.prepare(
|
|
1163
|
+
`INSERT INTO outcomes (${OUTCOME_COLUMNS.join(", ")}) VALUES (${placeholders})
|
|
1164
|
+
ON CONFLICT(run, task) DO UPDATE SET ${updateSet}`
|
|
1165
|
+
).run(...values);
|
|
1166
|
+
if (existing) updated++;
|
|
1167
|
+
else inserted++;
|
|
1168
|
+
}
|
|
1169
|
+
m.exec("COMMIT");
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
m.exec("ROLLBACK");
|
|
1172
|
+
throw err;
|
|
1173
|
+
}
|
|
1174
|
+
return { inserted, updated };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
export function listOutcomes(m, { run = null, task = null } = {}) {
|
|
1178
|
+
const clauses = [];
|
|
1179
|
+
const args = [];
|
|
1180
|
+
if (run !== null) {
|
|
1181
|
+
clauses.push("run = ?");
|
|
1182
|
+
args.push(run);
|
|
1183
|
+
}
|
|
1184
|
+
if (task !== null) {
|
|
1185
|
+
clauses.push("task = ?");
|
|
1186
|
+
args.push(task);
|
|
1187
|
+
}
|
|
1188
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1189
|
+
const rows = m.prepare(`SELECT * FROM outcomes ${where} ORDER BY recorded ASC, task ASC`).all(...args);
|
|
1190
|
+
return rows.map((row) => {
|
|
1191
|
+
const out = { ...row };
|
|
1192
|
+
for (const c of JSON_COLUMNS) out[c] = parseJsonColumn(row[c], c === "blocks" ? [] : null);
|
|
1193
|
+
return out;
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// --- reflect (card memory-reflect) ------------------------------------------------------------------------------
|
|
1198
|
+
// A cheap-row LLM pass, run after `memory.mjs record`, that proposes at most 3 lessons from one report and bumps
|
|
1199
|
+
// helpful/harmful counters on lessons the report's tasks used. Everything here is pure and unit-testable except
|
|
1200
|
+
// the spawn itself, which the caller supplies as `propose` (scripts/memory.mjs's thin CLI wrapper spawns the real
|
|
1201
|
+
// `claude` binary; a test passes a fake). Lessons stay add-only: nothing here ever rewrites or deletes a row.
|
|
1202
|
+
|
|
1203
|
+
export const REFLECT_DUPLICATE_JACCARD = 0.6;
|
|
1204
|
+
|
|
1205
|
+
export const REFLECT_SCHEMA = {
|
|
1206
|
+
type: "object",
|
|
1207
|
+
required: ["lessons"],
|
|
1208
|
+
properties: {
|
|
1209
|
+
lessons: {
|
|
1210
|
+
type: "array",
|
|
1211
|
+
items: {
|
|
1212
|
+
type: "object",
|
|
1213
|
+
required: ["kind", "text", "citation", "task", "notDerivableBecause"],
|
|
1214
|
+
properties: {
|
|
1215
|
+
kind: { enum: ["pitfall", "pattern"] },
|
|
1216
|
+
text: { type: "string" },
|
|
1217
|
+
citation: { type: "string" },
|
|
1218
|
+
task: { type: "string" },
|
|
1219
|
+
scope: { type: "array", items: { type: "string" } },
|
|
1220
|
+
notDerivableBecause: { type: "string" },
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
function findReportTask(report, taskId) {
|
|
1228
|
+
for (const level of report.levels || []) {
|
|
1229
|
+
const found = (level.tasks || []).find((t) => t.id === taskId);
|
|
1230
|
+
if (found) return found;
|
|
1231
|
+
}
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function reflectionRowToObject(row) {
|
|
1236
|
+
if (!row) return null;
|
|
1237
|
+
return {
|
|
1238
|
+
...row,
|
|
1239
|
+
counters: parseJsonColumn(row.counters, { helpful: [], harmful: [], unknown: [] }),
|
|
1240
|
+
appended: parseJsonColumn(row.appended, []),
|
|
1241
|
+
bumped: parseJsonColumn(row.bumped, []),
|
|
1242
|
+
rejected: parseJsonColumn(row.rejected, []),
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Deterministic, no LLM: for each task whose memoryUsed is non-empty, a real block bumps harmful on every used
|
|
1247
|
+
// lesson that exists (real wins even when the task also verified), else a first-verified task bumps helpful;
|
|
1248
|
+
// neither verified nor real does nothing. An id naming no lesson is reported in `unknown`, never an error.
|
|
1249
|
+
// apply:false (a dry run's preview) computes the same result without writing anything.
|
|
1250
|
+
function computeReflectCounters(m, blocks, report, { apply }) {
|
|
1251
|
+
const helpful = [];
|
|
1252
|
+
const harmful = [];
|
|
1253
|
+
const unknown = [];
|
|
1254
|
+
for (const level of report.levels || []) {
|
|
1255
|
+
for (const task of level.tasks || []) {
|
|
1256
|
+
const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
|
|
1257
|
+
if (!used.length) continue;
|
|
1258
|
+
const real = blocks.some((b) => b.task === task.id && b.class === "real");
|
|
1259
|
+
if (real) {
|
|
1260
|
+
for (const id of used) {
|
|
1261
|
+
if (getLesson(m, id)) {
|
|
1262
|
+
if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
|
|
1263
|
+
harmful.push(id);
|
|
1264
|
+
} else {
|
|
1265
|
+
unknown.push(id);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
} else if (task.verified === true) {
|
|
1269
|
+
for (const id of used) {
|
|
1270
|
+
if (getLesson(m, id)) {
|
|
1271
|
+
if (apply) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(id);
|
|
1272
|
+
helpful.push(id);
|
|
1273
|
+
} else {
|
|
1274
|
+
unknown.push(id);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return { helpful, harmful, unknown };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// The store can grow past what a cheap-row prompt should carry; buildReflectPrompt shows only the most recently
|
|
1284
|
+
// created/confirmed REFLECT_PROMPT_LESSON_CAP of them (recency is what a fresh proposal is most likely to
|
|
1285
|
+
// collide with) and says so in the prompt, rather than growing the prompt — and the pass's price — unbounded.
|
|
1286
|
+
export const REFLECT_PROMPT_LESSON_CAP = 200;
|
|
1287
|
+
|
|
1288
|
+
function mostRecentLessons(lessons, limit) {
|
|
1289
|
+
const withTime = lessons.map((l) => {
|
|
1290
|
+
const t = new Date(l.confirmed || l.created).getTime();
|
|
1291
|
+
return { lesson: l, t: Number.isFinite(t) ? t : 0 };
|
|
1292
|
+
});
|
|
1293
|
+
withTime.sort((a, b) => b.t - a.t);
|
|
1294
|
+
return withTime.slice(0, limit).map((x) => x.lesson);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// The prompt for the cheap-row pass: the rules in plain words, every task's facts (owned files from the plan when
|
|
1298
|
+
// it names the task, verified/passes/stopReason, review and verifier findings, its blocks with class and reason,
|
|
1299
|
+
// memoryUsed), the id and text of the most recently created/confirmed existing lessons (so the model avoids
|
|
1300
|
+
// re-proposing them; capped at REFLECT_PROMPT_LESSON_CAP, said so in the prompt when it is), and the citation
|
|
1301
|
+
// forms it may use. Filter C.4's duplicate check still runs against the full, uncapped set of existing lessons —
|
|
1302
|
+
// this cap only bounds what the model itself sees.
|
|
1303
|
+
export function buildReflectPrompt(report, { blocks = [], plan = null, existingLessons = [] } = {}) {
|
|
1304
|
+
const lines = [];
|
|
1305
|
+
lines.push("You are proposing at most 3 lessons for a coding harness's memory store, from one workflow report.");
|
|
1306
|
+
lines.push("Every rule below is enforced again in code after you answer, so follow it exactly:");
|
|
1307
|
+
lines.push('- kind "pitfall" is valid only for a task that has at least one block classed real below.');
|
|
1308
|
+
lines.push('- kind "pattern" is valid only for a task that verified true on its first pass (passes 1).');
|
|
1309
|
+
lines.push(
|
|
1310
|
+
'- citation must be exactly one of: "report:<task>/<block-id>" naming a block below, "report:<task>" naming a task below, or "path:line" / \'path:line "quoted text"\' naming a real line in this checkout.'
|
|
1311
|
+
);
|
|
1312
|
+
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.");
|
|
1313
|
+
lines.push("- never propose a lesson whose text is already covered by one of the existing lessons listed below.");
|
|
1314
|
+
lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
|
|
1315
|
+
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.");
|
|
1316
|
+
lines.push("");
|
|
1317
|
+
lines.push("Tasks in this report:");
|
|
1318
|
+
for (const level of report.levels || []) {
|
|
1319
|
+
for (const task of level.tasks || []) {
|
|
1320
|
+
const planTask = plan && Array.isArray(plan.tasks) ? plan.tasks.find((t) => t.id === task.id) : null;
|
|
1321
|
+
const ownedFiles = planTask && Array.isArray(planTask.files) ? planTask.files : [];
|
|
1322
|
+
const taskBlocks = blocks.filter((b) => b.task === task.id);
|
|
1323
|
+
const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
|
|
1324
|
+
lines.push(`- ${task.id}: verified=${task.verified === true} passes=${task.passes ?? "null"} stopReason=${task.stopReason ?? "null"} partial=${task.partial === true}`);
|
|
1325
|
+
if (ownedFiles.length) lines.push(` owned files: ${ownedFiles.join(", ")}`);
|
|
1326
|
+
if (Array.isArray(task.reviewIssues) && task.reviewIssues.length) lines.push(` review issues: ${JSON.stringify(task.reviewIssues)}`);
|
|
1327
|
+
if (Array.isArray(task.verifierFindings) && task.verifierFindings.length) lines.push(` verifier findings: ${JSON.stringify(task.verifierFindings)}`);
|
|
1328
|
+
if (taskBlocks.length) {
|
|
1329
|
+
lines.push(" blocks:");
|
|
1330
|
+
for (const b of taskBlocks) lines.push(` - report:${task.id}/${b.id} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
|
|
1331
|
+
}
|
|
1332
|
+
lines.push(` memoryUsed: ${used.length ? used.join(", ") : "none"}`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
lines.push("");
|
|
1336
|
+
lines.push("Existing lessons (never propose a near-duplicate of one of these):");
|
|
1337
|
+
const shownLessons = mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP);
|
|
1338
|
+
if (existingLessons.length > REFLECT_PROMPT_LESSON_CAP) {
|
|
1339
|
+
lines.push(`(showing the ${REFLECT_PROMPT_LESSON_CAP} most recently created/confirmed of ${existingLessons.length} total; older lessons are not shown)`);
|
|
1340
|
+
}
|
|
1341
|
+
if (shownLessons.length) {
|
|
1342
|
+
for (const l of shownLessons) lines.push(`- ${l.id} (${l.kind}): ${l.text}`);
|
|
1343
|
+
} else {
|
|
1344
|
+
lines.push("(none yet)");
|
|
1345
|
+
}
|
|
1346
|
+
return lines.join("\n") + "\n";
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Citation filter (C.2): a "report:" citation is checked against `report` (never unchecked here, unlike
|
|
1350
|
+
// checkCitation's default), a path:line citation against `dir` via checkCitation; anything else — free text, an
|
|
1351
|
+
// empty string, a report: citation shaped wrong — is rejected outright.
|
|
1352
|
+
function checkReflectCitation(citation, { dir, report }) {
|
|
1353
|
+
if (typeof citation !== "string" || !citation.trim()) return { ok: false, reason: "citation is empty" };
|
|
1354
|
+
if (citation.startsWith("report:")) return checkCitation({ citation }, dir, { report });
|
|
1355
|
+
if (CITATION_RE.test(citation)) return checkCitation({ citation }, dir);
|
|
1356
|
+
return { ok: false, reason: "citation is neither a report: form nor a path:line form" };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function normalizeReflectText(s) {
|
|
1360
|
+
return String(s || "")
|
|
1361
|
+
.replace(/\s+/g, " ")
|
|
1362
|
+
.trim()
|
|
1363
|
+
.toLowerCase();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Not-derivable filter (C.3): the lesson text must not already sit verbatim (whitespace-normalised, case-folded)
|
|
1367
|
+
// in the cited file (for a path:line citation) or in CLAUDE.md at dir — a fact already in the checkout is not a
|
|
1368
|
+
// lesson worth remembering.
|
|
1369
|
+
function checkReflectDerivable(proposal, dir) {
|
|
1370
|
+
const normalizedText = normalizeReflectText(proposal.text);
|
|
1371
|
+
const candidates = [];
|
|
1372
|
+
const match = typeof proposal.citation === "string" ? proposal.citation.match(CITATION_RE) : null;
|
|
1373
|
+
if (match) candidates.push(match.groups.path);
|
|
1374
|
+
candidates.push("CLAUDE.md");
|
|
1375
|
+
for (const relPath of candidates) {
|
|
1376
|
+
const filePath = resolve(dir, relPath);
|
|
1377
|
+
if (!existsSync(filePath)) continue;
|
|
1378
|
+
let content;
|
|
1379
|
+
try {
|
|
1380
|
+
content = readFileSync(filePath, "utf8");
|
|
1381
|
+
} catch {
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (normalizedText && normalizeReflectText(content).includes(normalizedText)) {
|
|
1385
|
+
return { ok: false, reason: `the lesson text appears verbatim in ${relPath}` };
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return { ok: true, reason: "not derivable" };
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
export function reflectTokens(text) {
|
|
1392
|
+
return new Set(
|
|
1393
|
+
String(text || "")
|
|
1394
|
+
.toLowerCase()
|
|
1395
|
+
.split(/[^a-z0-9]+/)
|
|
1396
|
+
.filter((t) => t.length >= 3)
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export function jaccard(a, b) {
|
|
1401
|
+
if (!a.size && !b.size) return 0;
|
|
1402
|
+
let inter = 0;
|
|
1403
|
+
for (const t of a) if (b.has(t)) inter++;
|
|
1404
|
+
const union = a.size + b.size - inter;
|
|
1405
|
+
return union === 0 ? 0 : inter / union;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Near-duplicate filter (C.4). Two ways a proposal is a duplicate:
|
|
1409
|
+
// - an addLesson id collision (same kind+citation+text hash as a row that already exists in the store, live or
|
|
1410
|
+
// superseded) — checked against the store directly with getLesson, not just the live candidate list, so a
|
|
1411
|
+
// collision with an old superseded lesson is never missed. A collision with a *superseded* row cannot be
|
|
1412
|
+
// bumped (there is nothing live to bump) and cannot be appended either (addLesson would throw: the id is
|
|
1413
|
+
// already taken), so it is rejected with a reason instead — never left to throw out of runReflect.
|
|
1414
|
+
// - Jaccard similarity over lower-cased word tokens (length >= 3) at or above REFLECT_DUPLICATE_JACCARD against
|
|
1415
|
+
// every non-superseded existing lesson or a row appended earlier in this same pass (`existingLessons` and
|
|
1416
|
+
// `appendedRows`), so two near-identical proposals in one pass only ever add the first.
|
|
1417
|
+
// Returns { duplicate: <live lesson row> } (bump), { rejected: <superseded lesson row> }, or null (not a dup).
|
|
1418
|
+
function findReflectDuplicate(m, proposal, { existingLessons, appendedRows }) {
|
|
1419
|
+
const proposalId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
|
|
1420
|
+
const collision = getLesson(m, proposalId);
|
|
1421
|
+
if (collision) return collision.superseded_by ? { rejected: collision } : { duplicate: collision };
|
|
1422
|
+
for (const row of appendedRows) {
|
|
1423
|
+
if (row.id === proposalId) return { duplicate: row };
|
|
1424
|
+
}
|
|
1425
|
+
const tokens = reflectTokens(proposal.text);
|
|
1426
|
+
for (const lesson of [...existingLessons, ...appendedRows]) {
|
|
1427
|
+
if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return { duplicate: lesson };
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Filters C.1-C.4, in order. C.5 (at most 3 appended) is enforced by the caller, but only against a proposal
|
|
1433
|
+
// this function has already said ok:true and not a duplicate — a would-be duplicate past the cap still bumps.
|
|
1434
|
+
// Returns { ok:true }, { ok:true, duplicate:<lesson row> } (bump instead of append), or { ok:false, reason }.
|
|
1435
|
+
function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows }) {
|
|
1436
|
+
if (!proposal || typeof proposal !== "object") return { ok: false, reason: "proposal is not an object" };
|
|
1437
|
+
const { kind, text, citation, task, notDerivableBecause } = proposal;
|
|
1438
|
+
if (kind !== "pitfall" && kind !== "pattern") return { ok: false, reason: `kind must be "pitfall" or "pattern", got ${JSON.stringify(kind)}` };
|
|
1439
|
+
if (typeof text !== "string" || !text.trim()) return { ok: false, reason: "text is empty" };
|
|
1440
|
+
const reportTask = typeof task === "string" ? findReportTask(report, task) : null;
|
|
1441
|
+
if (!reportTask) return { ok: false, reason: `no task "${task}" in this report` };
|
|
1442
|
+
if (kind === "pitfall") {
|
|
1443
|
+
if (!blocks.some((b) => b.task === task && b.class === "real")) return { ok: false, reason: `task "${task}" has no block classed real` };
|
|
1444
|
+
} else if (reportTask.verified !== true || reportTask.passes !== 1) {
|
|
1445
|
+
return { ok: false, reason: `task "${task}" did not verify on its first pass` };
|
|
1446
|
+
}
|
|
1447
|
+
const citationCheck = checkReflectCitation(citation, { dir, report });
|
|
1448
|
+
if (!citationCheck.ok) return { ok: false, reason: `citation: ${citationCheck.reason}` };
|
|
1449
|
+
if (typeof notDerivableBecause !== "string" || !notDerivableBecause.trim()) return { ok: false, reason: "notDerivableBecause is empty" };
|
|
1450
|
+
const derivableCheck = checkReflectDerivable(proposal, dir);
|
|
1451
|
+
if (!derivableCheck.ok) return derivableCheck;
|
|
1452
|
+
const dup = findReflectDuplicate(m, proposal, { existingLessons, appendedRows });
|
|
1453
|
+
if (dup && dup.rejected) return { ok: false, reason: `addLesson id collision with a superseded lesson ${dup.rejected.id}` };
|
|
1454
|
+
if (dup && dup.duplicate) return { ok: true, duplicate: dup.duplicate };
|
|
1455
|
+
return { ok: true };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Cost (D): prefer the real per-message transcript at <claudeDir>/projects/<projectSlug(dir)>/<sessionId>.jsonl,
|
|
1459
|
+
// same as cost.mjs prices every other agent; fall back to the envelope's own total_cost_usd (a client-side
|
|
1460
|
+
// estimate) when the transcript is missing or its model is unpriced; null with no source when neither is usable.
|
|
1461
|
+
function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
|
|
1462
|
+
const resolvedClaudeDir = claudeDir || join(homedir(), ".claude");
|
|
1463
|
+
if (sessionId) {
|
|
1464
|
+
const file = join(resolvedClaudeDir, "projects", projectSlug(dir), `${sessionId}.jsonl`);
|
|
1465
|
+
if (existsSync(file)) {
|
|
1466
|
+
try {
|
|
1467
|
+
const t = transcriptUsage(file);
|
|
1468
|
+
const usd = t.model ? priceUsage(t.usage, t.model) : null;
|
|
1469
|
+
if (usd !== null) return { usd, usdSource: "transcript" };
|
|
1470
|
+
} catch {
|
|
1471
|
+
// fall through to the envelope's own number
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof totalCostUsd === "number") return { usd: totalCostUsd, usdSource: "claude" };
|
|
1476
|
+
return { usd: null, usdSource: null };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// The whole reflect pass (card memory-reflect): counters first (committed even if the LLM pass fails), then the
|
|
1480
|
+
// cheap-row pass via `propose`, then the filters in C, then cost. Idempotent on report_hash: a second call on the
|
|
1481
|
+
// same report does nothing and returns { alreadyReflected: true, row }. `propose(prompt)` resolves to
|
|
1482
|
+
// { lessons, sessionId, totalCostUsd } or throws/rejects on any failure (a spawn error, a timeout, unparseable
|
|
1483
|
+
// output, is_error, or a missing structured_output.lessons array) — this function never spawns anything itself,
|
|
1484
|
+
// so a test's fake `propose` never touches a real `claude` binary.
|
|
1485
|
+
export async function runReflect(m, report, opts = {}) {
|
|
1486
|
+
const {
|
|
1487
|
+
reportHash,
|
|
1488
|
+
dir = process.cwd(),
|
|
1489
|
+
classes = [],
|
|
1490
|
+
plan = null,
|
|
1491
|
+
run = null,
|
|
1492
|
+
card = null,
|
|
1493
|
+
commit = null,
|
|
1494
|
+
model = null,
|
|
1495
|
+
claudeDir,
|
|
1496
|
+
dryRun = false,
|
|
1497
|
+
propose,
|
|
1498
|
+
now = nowIso(),
|
|
1499
|
+
} = opts;
|
|
1500
|
+
if (!reportHash) throw new Error("runReflect requires a reportHash");
|
|
1501
|
+
if (typeof propose !== "function") throw new Error("runReflect requires a propose function");
|
|
1502
|
+
|
|
1503
|
+
const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(reportHash);
|
|
1504
|
+
if (existing) return { alreadyReflected: true, row: reflectionRowToObject(existing) };
|
|
1505
|
+
|
|
1506
|
+
// classifyBlocks throws on an --adversary id the report does not have, the same way record fails; this must
|
|
1507
|
+
// happen before any write, so it is the very first thing after the idempotency check.
|
|
1508
|
+
const blocks = classifyBlocks(report, classes);
|
|
1509
|
+
|
|
1510
|
+
let counters;
|
|
1511
|
+
if (dryRun) {
|
|
1512
|
+
counters = computeReflectCounters(m, blocks, report, { apply: false });
|
|
1513
|
+
} else {
|
|
1514
|
+
m.exec("BEGIN");
|
|
1515
|
+
try {
|
|
1516
|
+
counters = computeReflectCounters(m, blocks, report, { apply: true });
|
|
1517
|
+
m.prepare(
|
|
1518
|
+
`INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
|
|
1519
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
|
|
1520
|
+
).run(reportHash, now, run, card, commit, model, JSON.stringify(counters));
|
|
1521
|
+
m.exec("COMMIT");
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
m.exec("ROLLBACK");
|
|
1524
|
+
throw err;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const existingLessons = listLessons(m);
|
|
1529
|
+
const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons });
|
|
1530
|
+
|
|
1531
|
+
let proposeResult = null;
|
|
1532
|
+
let proposeError = null;
|
|
1533
|
+
try {
|
|
1534
|
+
proposeResult = await propose(prompt);
|
|
1535
|
+
if (!proposeResult || !Array.isArray(proposeResult.lessons)) throw new Error("the cheap-row pass returned no lessons array");
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
proposeError = err;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (proposeError) {
|
|
1541
|
+
const rejected = [{ text: null, reason: proposeError.message }];
|
|
1542
|
+
if (!dryRun) {
|
|
1543
|
+
m.prepare(`UPDATE reflections SET proposed = ?, appended = ?, bumped = ?, rejected = ? WHERE report_hash = ?`).run(
|
|
1544
|
+
null,
|
|
1545
|
+
JSON.stringify([]),
|
|
1546
|
+
JSON.stringify([]),
|
|
1547
|
+
JSON.stringify(rejected),
|
|
1548
|
+
reportHash
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
return {
|
|
1552
|
+
ok: false,
|
|
1553
|
+
dryRun,
|
|
1554
|
+
reportHash,
|
|
1555
|
+
counters,
|
|
1556
|
+
proposed: null,
|
|
1557
|
+
appended: [],
|
|
1558
|
+
bumped: [],
|
|
1559
|
+
rejected,
|
|
1560
|
+
usd: null,
|
|
1561
|
+
usdSource: null,
|
|
1562
|
+
model,
|
|
1563
|
+
sessionId: null,
|
|
1564
|
+
reason: proposeError.message,
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const proposals = proposeResult.lessons;
|
|
1569
|
+
const sessionId = proposeResult.sessionId || null;
|
|
1570
|
+
const totalCostUsd = typeof proposeResult.totalCostUsd === "number" ? proposeResult.totalCostUsd : null;
|
|
1571
|
+
|
|
1572
|
+
const appendedRows = [];
|
|
1573
|
+
const bumped = [];
|
|
1574
|
+
const rejected = [];
|
|
1575
|
+
for (const proposal of proposals) {
|
|
1576
|
+
const verdict = evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows });
|
|
1577
|
+
if (verdict.duplicate) {
|
|
1578
|
+
// The 3-item cap (C.5) limits appends, never evaluation: a near-duplicate found past the cap still bumps
|
|
1579
|
+
// the lesson it matches, it is just never counted against the 3.
|
|
1580
|
+
if (!dryRun) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(verdict.duplicate.id);
|
|
1581
|
+
bumped.push(verdict.duplicate.id);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (!verdict.ok) {
|
|
1585
|
+
rejected.push({ text: proposal && typeof proposal.text === "string" ? proposal.text : null, reason: verdict.reason });
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (appendedRows.length >= 3) {
|
|
1589
|
+
rejected.push({ text: proposal && typeof proposal.text === "string" ? proposal.text : null, reason: "at most 3 lessons appended per reflection" });
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (dryRun) {
|
|
1593
|
+
appendedRows.push({ id: computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text }), text: proposal.text, kind: proposal.kind });
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
const reportTask = findReportTask(report, proposal.task);
|
|
1597
|
+
const row = addLesson(m, {
|
|
1598
|
+
text: proposal.text,
|
|
1599
|
+
kind: proposal.kind,
|
|
1600
|
+
scope: Array.isArray(proposal.scope) ? proposal.scope : [],
|
|
1601
|
+
citation: proposal.citation || null,
|
|
1602
|
+
source: { agent: "reflect", model },
|
|
1603
|
+
task: proposal.task,
|
|
1604
|
+
card: card != null ? card : (reportTask && reportTask.card) || null,
|
|
1605
|
+
commit: commit != null ? commit : (reportTask && reportTask.commit) || null,
|
|
1606
|
+
});
|
|
1607
|
+
appendedRows.push(row);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const cost = computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd });
|
|
1611
|
+
|
|
1612
|
+
if (!dryRun) {
|
|
1613
|
+
m.prepare(
|
|
1614
|
+
`UPDATE reflections SET proposed = ?, appended = ?, bumped = ?, rejected = ?, usd = ?, usd_source = ?, session_id = ? WHERE report_hash = ?`
|
|
1615
|
+
).run(
|
|
1616
|
+
proposals.length,
|
|
1617
|
+
JSON.stringify(appendedRows.map((r) => r.id)),
|
|
1618
|
+
JSON.stringify(bumped),
|
|
1619
|
+
JSON.stringify(rejected),
|
|
1620
|
+
cost.usd,
|
|
1621
|
+
cost.usdSource,
|
|
1622
|
+
sessionId,
|
|
1623
|
+
reportHash
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
return {
|
|
1628
|
+
ok: true,
|
|
1629
|
+
dryRun,
|
|
1630
|
+
reportHash,
|
|
1631
|
+
counters,
|
|
1632
|
+
proposed: proposals.length,
|
|
1633
|
+
appended: appendedRows.map((r) => r.id),
|
|
1634
|
+
bumped,
|
|
1635
|
+
rejected,
|
|
1636
|
+
usd: cost.usd,
|
|
1637
|
+
usdSource: cost.usdSource,
|
|
1638
|
+
model,
|
|
1639
|
+
sessionId,
|
|
1640
|
+
};
|
|
1641
|
+
}
|