@dougharness/cli 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- package/dist/generate/agents.js +4 -0
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +1 -1
- package/dist/generate/claude-md.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/coder.md +2 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
- package/node_modules/@dougharness/flow/agents/planner.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
- package/node_modules/@dougharness/flow/agents/tester.md +2 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +54 -5
- package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/decisions.mjs +3 -5
- package/node_modules/@dougharness/flow/lib/learn.mjs +105 -57
- package/node_modules/@dougharness/flow/lib/memory.mjs +1418 -57
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +178 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +43 -15
- package/node_modules/@dougharness/flow/scripts/learn.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/memory.mjs +870 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +4 -0
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +54 -5
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +6 -3
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +110 -125
- package/node_modules/@dougharness/gates/scripts/verify.mjs +47 -13
- package/package.json +3 -3
|
@@ -19,13 +19,26 @@
|
|
|
19
19
|
// condition columns from it; without one the row still records
|
|
20
20
|
// (exit 0) and an extra stdout line says the landing is
|
|
21
21
|
// unstamped (--json: "stamped": true|false instead)
|
|
22
|
+
// memory.mjs record <card-id> --hand --stop <reason> [dir] [--detail <text>] [--json]
|
|
23
|
+
// record a stopped by-hand run as exactly one stop row (run
|
|
24
|
+
// stop:<12 hex>, commit null, stop_class <reason>, stop_reason
|
|
25
|
+
// --detail or the reason); the same stop recorded again updates
|
|
26
|
+
// that row. Condition columns and memory_config come from
|
|
27
|
+
// condition.json when present, else the row is unstamped (extra
|
|
28
|
+
// stdout line; --json: "stamped": false). --stop with no value
|
|
29
|
+
// or with --commit exits 2 and writes nothing
|
|
22
30
|
// memory.mjs condition open <card-id> [dir] [--json]
|
|
23
31
|
// (card outcomes-condition-columns) writes
|
|
24
32
|
// .doug/.state/reports/<card-id>/condition.json: harnessCommit
|
|
25
33
|
// (git HEAD), configHash (conditionConfigHash: .doug/config.json
|
|
26
34
|
// plus the CLAUDE.md Models table text), class and arm
|
|
27
35
|
// (<class>/default) from the card's board record, assignedBy
|
|
28
|
-
// "policy", exploreProbability null, openedAt
|
|
36
|
+
// "policy", exploreProbability null, openedAt, memoryConfig (card
|
|
37
|
+
// memory-outcome-arms: memoryConfig() from the project's
|
|
38
|
+
// embeddings config and staleDays, track "hand": delivery
|
|
39
|
+
// index+fetch; a flow row from `record <report.json>` is
|
|
40
|
+
// stamped with track "flow": delivery index+fetch, fetch top-k,
|
|
41
|
+
// fetchTokens 4000). Refuses (exit 1,
|
|
29
42
|
// nothing written) when the file already exists, the card is
|
|
30
43
|
// missing or has no class, or there is no git HEAD
|
|
31
44
|
// memory.mjs condition backfill [dir] [--json]
|
|
@@ -39,9 +52,22 @@
|
|
|
39
52
|
// outcomes rows with a harness commit." (--json: {"backfilled": n})
|
|
40
53
|
// memory.mjs outcomes [dir] [--run <id>] [--task <id>] [--json]
|
|
41
54
|
// list recorded outcomes, filtered by run and/or task
|
|
55
|
+
// memory.mjs arms [dir] [--min <n>] [--json]
|
|
56
|
+
// (card memory-outcome-arms) compares outcome arms by the memory
|
|
57
|
+
// configuration stamped on each row (outcomes.memory_config):
|
|
58
|
+
// landings, stops, then stop-or-land rate, fix rounds, harmful
|
|
59
|
+
// marks per landing and cost per landing once an arm has --min
|
|
60
|
+
// landings (default 12; an integer >= 1), else "not compared: <n>
|
|
61
|
+
// of <min> landings"; a last line says whether two arms are
|
|
62
|
+
// comparable. Reads existing rows only: no database -> "No
|
|
63
|
+
// outcomes recorded." (exit 0, database not created); no claude
|
|
64
|
+
// call, no network request
|
|
42
65
|
// memory.mjs lessons [dir] [--search <text>] [--k <n>] [--all] [--json]
|
|
43
66
|
// list lessons, or full-text search them (--search); --all
|
|
44
67
|
// includes superseded rows
|
|
68
|
+
// memory.mjs lessons --unconfirmed [dir] [--json]
|
|
69
|
+
// read-only review list: live lessons with helpful 0, harmful 0
|
|
70
|
+
// and no `lesson show --card` fetch; id, kind, created, title
|
|
45
71
|
// memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]
|
|
46
72
|
// [--scope <glob,glob>] [--citation <text>] [--card <id>]
|
|
47
73
|
// [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]
|
|
@@ -60,11 +86,34 @@
|
|
|
60
86
|
// memory.mjs lesson unstale <id> [dir] [--json]
|
|
61
87
|
// clear a lesson's stale mark (set by recall's citation
|
|
62
88
|
// re-check) so it is a candidate again; refuses on an unknown id
|
|
89
|
+
// memory.mjs lesson unarchive <id> [dir] [--json]
|
|
90
|
+
// restore an archived lesson (clears archived and
|
|
91
|
+
// archived_reason); refuses on an unknown or non-archived id
|
|
92
|
+
// memory.mjs lesson show <id> [dir] [--card <card-id>] [--json]
|
|
93
|
+
// (card memory-progressive-access) one lesson in full: cost
|
|
94
|
+
// line, citation line, superseded/stale status when set, a
|
|
95
|
+
// blank line, then the whole text, never cut. Read-only unless
|
|
96
|
+
// --card <card-id> (a plain id, else exit 2): then, after the
|
|
97
|
+
// lesson is found and printed, one JSON line {id, at} is
|
|
98
|
+
// appended to .doug/.state/reports/<card-id>/fetched.jsonl
|
|
99
|
+
// (reflect --hand credits only those). No
|
|
100
|
+
// store or unknown id -> one stderr line, exit 1 (nothing
|
|
101
|
+
// created); no id -> usage, exit 2. --json: the row with
|
|
102
|
+
// hasVector and tokens instead of the embedding blob
|
|
103
|
+
// memory.mjs history show <ref> [dir] [--json]
|
|
104
|
+
// (card memory-progressive-access) fetch run history (L0) by
|
|
105
|
+
// id: <card> lists its runs, <card>/<run> lists the run's
|
|
106
|
+
// entries with token costs, <card>/<run>:<line> prints one
|
|
107
|
+
// entry whole (a leading history: and a .jsonl suffix are
|
|
108
|
+
// accepted). Needs no memory database; bad id -> exit 2,
|
|
109
|
+
// missing -> one stderr line, exit 1
|
|
63
110
|
// memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]
|
|
64
111
|
// (card memory-every-session) recall for a hand-track card's
|
|
65
112
|
// briefs: query = the card's title and goal, k 8. Prints the
|
|
66
|
-
// workflow's lessons header, the
|
|
67
|
-
// (capped at 2000 chars
|
|
113
|
+
// workflow's lessons header, the index `- [id] title (kind,
|
|
114
|
+
// ~n tokens)` lines (capped at 2000 chars; the lead fetches
|
|
115
|
+
// full text with `lesson show`), an `index: omitted` line
|
|
116
|
+
// naming lessons the cap dropped, then the window line
|
|
68
117
|
// when lessons were hidden, then `memoryUsed: <ids>`;
|
|
69
118
|
// writes .doug/.state/reports/<card-id>/lessons.json. No database
|
|
70
119
|
// -> "No lessons recalled for <id>." (database not created);
|
|
@@ -92,11 +141,13 @@
|
|
|
92
141
|
// filtered in code (a pitfall needs a block classed real, a
|
|
93
142
|
// pattern needs a first-pass verified task, the citation must be
|
|
94
143
|
// checkable, the text must not already be derivable from the
|
|
95
|
-
// checkout, and a
|
|
96
|
-
//
|
|
144
|
+
// checkout, and a proposal may name one listed lesson
|
|
145
|
+
// it repeats (that lesson's seen rises, nothing is appended) or
|
|
146
|
+
// supersedes (the new lesson is written and the old retired); a
|
|
147
|
+
// near-duplicate with neither is rejected); idempotent on the report's
|
|
97
148
|
// sha256 (a second reflect on the same report is a no-op, exit
|
|
98
|
-
// 0); --dry-run applies nothing
|
|
99
|
-
// prints
|
|
149
|
+
// 0); --dry-run applies nothing, makes no model call, and
|
|
150
|
+
// prints the counters it would credit and the prompt it would send. Cost is measured from the same local
|
|
100
151
|
// transcripts cost.mjs reads, falling back to the CLI's own
|
|
101
152
|
// cost estimate. For each appended lesson with no CLAUDE.md home
|
|
102
153
|
// it prints `claude-md candidate for lesson <id>: <command>`
|
|
@@ -108,9 +159,51 @@
|
|
|
108
159
|
// (card memory-every-session) the same pass for a hand landing:
|
|
109
160
|
// assembles .doug/.state/reports/<card-id>/hand-report.json from
|
|
110
161
|
// the saved briefs/, reviews/ and lessons.json (memoryUsed = its
|
|
111
|
-
// ids; verified true; passes = --rounds, default 1; one fixed
|
|
162
|
+
// ids that the card's fetched.jsonl lists; verified true; passes = --rounds, default 1; one fixed
|
|
112
163
|
// blocker ledger entry per --review id), then reflects on it
|
|
113
164
|
// exactly as above. --commit is required (exit 2). No schema change.
|
|
165
|
+
// memory.mjs reflect --failures <card-id> [dir] [--session <id>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--detach] [--dry-run] [--json]
|
|
166
|
+
// (card memory-failure-lessons) the failure pass: reads the card's
|
|
167
|
+
// run history for stopped reports, blocks classed real and
|
|
168
|
+
// denials, prints the item count with the blocks it hid by class
|
|
169
|
+
// and the entries it could not read, then a cheap-row pass proposes
|
|
170
|
+
// pitfall lessons citing a history ref of one item; a proposal's
|
|
171
|
+
// `repeats` names a live pitfall and records an occurrence in
|
|
172
|
+
// .doug/.state/memory/pitfalls.jsonl (no new lesson, no helpful).
|
|
173
|
+
// Exclusive with --hand and a report path (exit 2); idempotent on
|
|
174
|
+
// the slice; --dry-run makes no model call and prints the prompt it would send;
|
|
175
|
+
// --detach runs it as job failures-<card>; autopilot and doug-hand start it
|
|
176
|
+
// so when a card stops, --max-usd 0.5 on the cheap row (user, 2026-10-28).
|
|
177
|
+
// A real block with the same class on a file a live pitfall's recorded
|
|
178
|
+
// occurrence names is a code-side repeat (via 'file'); empty files never match
|
|
179
|
+
// memory.mjs ideas [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--cap <n>] [--dry-run] [--json]
|
|
180
|
+
// (card memory-idea-proposals, ADR 0019 L3) one cheap-row `claude
|
|
181
|
+
// -p --json-schema` call (--max-usd default 0.5) over the live
|
|
182
|
+
// lessons, run history, pitfall occurrences and the Backlog and
|
|
183
|
+
// Ready cards files at most --cap (default 3) idea cards to
|
|
184
|
+
// Backlog only, source `memory-proposed: <citations>`, and appends
|
|
185
|
+
// priority reviews naming Backlog cards to
|
|
186
|
+
// .doug/.state/memory/priority-reviews.jsonl. An item with an
|
|
187
|
+
// unresolved citation is dropped; a duplicate or invalid idea is
|
|
188
|
+
// skipped. Never touches Ready. No store or no board exits 1 with
|
|
189
|
+
// no call; a failed call exits 1 and writes nothing. --dry-run
|
|
190
|
+
// makes no model call, writes neither file and prints the prompt
|
|
191
|
+
// it would send and the files it would write
|
|
192
|
+
// memory.mjs model regen [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
|
|
193
|
+
// (card memory-project-model, ADR 0019 L2) one cheap-row `claude
|
|
194
|
+
// -p --json-schema` call (--max-usd default 0.5) over the live
|
|
195
|
+
// lessons and the run-history index writes .doug/project-model.md:
|
|
196
|
+
// failure patterns, conventions and open risks, at most 2000
|
|
197
|
+
// characters in all. A statement with any citation that does not
|
|
198
|
+
// resolve in code (a live lesson id, or a run-history ref whose
|
|
199
|
+
// file and line exist) is dropped whole; over the cap the most
|
|
200
|
+
// cited statements stay. Prints one line with the counts, the
|
|
201
|
+
// drops and the cost. A failed call or zero surviving statements
|
|
202
|
+
// exits 1 and leaves an existing file untouched; no store exits 1
|
|
203
|
+
// with no call. --dry-run makes no model call, writes nothing and prints the prompt
|
|
204
|
+
// memory.mjs model show [dir]
|
|
205
|
+
// prints .doug/project-model.md verbatim, read-only; missing ->
|
|
206
|
+
// one stderr line, exit 1
|
|
114
207
|
// memory.mjs doctor [dir] [--json]
|
|
115
208
|
// probe the configured embedding provider (memory.embeddings in
|
|
116
209
|
// .doug/config.json), or a local Ollama at localhost:11434 when
|
|
@@ -185,16 +278,16 @@
|
|
|
185
278
|
// remove: [{line,enforcedBy,source}], note }); writes only a diff
|
|
186
279
|
// under .doug/.state/learn/, never CLAUDE.md itself; refuses when
|
|
187
280
|
// an edit would touch the Models section or the fenced Commands
|
|
188
|
-
// block, or would push CLAUDE.md over
|
|
281
|
+
// block, or would push CLAUDE.md over 200 lines. Approve and write
|
|
189
282
|
// it with `learn.mjs apply <diff>`
|
|
190
283
|
// lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
|
|
191
284
|
// provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
|
|
192
285
|
// row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
|
|
193
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
|
286
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, appendFileSync, statSync, openSync, closeSync, renameSync, linkSync, unlinkSync } from "node:fs";
|
|
194
287
|
import { resolve, join, dirname } from "node:path";
|
|
195
288
|
import { createHash } from "node:crypto";
|
|
196
|
-
import { spawnSync } from "node:child_process";
|
|
197
|
-
import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
|
|
289
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
290
|
+
import { parseAdversaryClasses, loadBoard, saveBoard, findCard } from "../lib/board.mjs";
|
|
198
291
|
import { planFor, unwrapReport } from "../lib/plan.mjs";
|
|
199
292
|
import { costRun } from "../lib/cost.mjs";
|
|
200
293
|
import { loadModels } from "../lib/models.mjs";
|
|
@@ -211,7 +304,7 @@ import {
|
|
|
211
304
|
} from "../lib/embeddings.mjs";
|
|
212
305
|
|
|
213
306
|
const argv = process.argv.slice(2);
|
|
214
|
-
const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
|
|
307
|
+
const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check", "unconfirmed", "detach", "earlier"]);
|
|
215
308
|
const REPEATABLE = new Set(["adversary", "review", "harmful"]);
|
|
216
309
|
const opts = {};
|
|
217
310
|
const positional = [];
|
|
@@ -236,8 +329,145 @@ function fail(msg, code = 1) {
|
|
|
236
329
|
process.exit(code);
|
|
237
330
|
}
|
|
238
331
|
|
|
332
|
+
// Card detach-background-chores: start `reflect` (or any command) as a detached job and read it later with
|
|
333
|
+
// `memory.mjs job <name>`. State lives in <dir>/.doug/.state/jobs/<name>.json (pid, argv, startedAt, and on exit
|
|
334
|
+
// exitCode/signal/endedAt, written by the runner); stdout and stderr go to <name>.log, never to the caller's
|
|
335
|
+
// pipes, so the caller returns at once.
|
|
336
|
+
function jobPaths(dir, name) {
|
|
337
|
+
const jobs = join(dir, ".doug", ".state", "jobs");
|
|
338
|
+
return { jobs, file: join(jobs, `${name}.json`), log: join(jobs, `${name}.log`) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readJobFile(file) {
|
|
342
|
+
try {
|
|
343
|
+
return JSON.parse(readFileSync(file, "utf8")) || {};
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A job file is replaced whole (write a temp file, rename it over), so a concurrent reader never sees a truncated
|
|
350
|
+
// file. `ifAbsent` links the temp file in instead, so the start command never overwrites a result the runner wrote
|
|
351
|
+
// (startDetachedJob removes an ended job's old file first, so the new start record is linked in fresh).
|
|
352
|
+
function writeJobFile(file, state, ifAbsent = false) {
|
|
353
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
354
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
355
|
+
try {
|
|
356
|
+
if (ifAbsent) linkSync(tmp, file);
|
|
357
|
+
else renameSync(tmp, file);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (!ifAbsent || !err || err.code !== "EEXIST") throw err;
|
|
360
|
+
} finally {
|
|
361
|
+
if (ifAbsent) {
|
|
362
|
+
try {
|
|
363
|
+
unlinkSync(tmp);
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function startDetachedJob(dir, name, command) {
|
|
370
|
+
const { jobs, file, log } = jobPaths(dir, name);
|
|
371
|
+
mkdirSync(jobs, { recursive: true });
|
|
372
|
+
// A name is reusable once its job ended or its process is gone; a live job holding it is never touched.
|
|
373
|
+
const held = readJobFile(file);
|
|
374
|
+
if (held && held.endedAt === undefined && held.exitCode === undefined && !held.signal && pidAlive(held.pid)) {
|
|
375
|
+
fail(`job ${name} is already running (pid ${held.pid}); read it with: memory.mjs job ${name}`, 1);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
unlinkSync(file);
|
|
379
|
+
} catch {}
|
|
380
|
+
const fd = openSync(log, "w");
|
|
381
|
+
const startedAt = new Date().toISOString();
|
|
382
|
+
let child;
|
|
383
|
+
try {
|
|
384
|
+
child = spawn(process.execPath, [process.argv[1], "__jobrun", dir, name, "--", ...command], {
|
|
385
|
+
detached: true,
|
|
386
|
+
stdio: ["ignore", fd, fd],
|
|
387
|
+
cwd: process.cwd(),
|
|
388
|
+
env: process.env,
|
|
389
|
+
});
|
|
390
|
+
child.unref();
|
|
391
|
+
} finally {
|
|
392
|
+
closeSync(fd);
|
|
393
|
+
}
|
|
394
|
+
writeJobFile(file, { name, argv: command, pid: child.pid, startedAt }, true);
|
|
395
|
+
return { name, pid: child.pid };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The detached runner: runs `command` with the log as its stdout/stderr, then records how it ended.
|
|
399
|
+
async function runJob(dir, name, command) {
|
|
400
|
+
const { file } = jobPaths(dir, name);
|
|
401
|
+
// Only this runner's own start record (same pid) lends startedAt and argv; nothing else carries over.
|
|
402
|
+
const prior = readJobFile(file) || {};
|
|
403
|
+
const own = prior.pid === process.pid;
|
|
404
|
+
const state = { name, argv: own && prior.argv ? prior.argv : command, startedAt: own && prior.startedAt ? prior.startedAt : new Date().toISOString(), pid: process.pid };
|
|
405
|
+
writeJobFile(file, state);
|
|
406
|
+
const end = await new Promise((resolveEnd) => {
|
|
407
|
+
const c = spawn(command[0], command.slice(1), { stdio: "inherit", cwd: process.cwd(), env: process.env });
|
|
408
|
+
c.on("error", (err) => {
|
|
409
|
+
process.stderr.write(`job: cannot start ${command[0]}: ${err.message}\n`);
|
|
410
|
+
resolveEnd({ exitCode: 127, signal: null });
|
|
411
|
+
});
|
|
412
|
+
c.on("close", (code, signal) => resolveEnd({ exitCode: code, signal }));
|
|
413
|
+
});
|
|
414
|
+
const done = { ...state, exitCode: end.exitCode, signal: end.signal, endedAt: new Date().toISOString() };
|
|
415
|
+
writeJobFile(file, done);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// One job's state as { state: "pending"|"done"|"failed", line, st }; null when there is no job file.
|
|
419
|
+
function jobStatus(dir, name) {
|
|
420
|
+
const { file, log } = jobPaths(dir, name);
|
|
421
|
+
const st = readJobFile(file);
|
|
422
|
+
if (!st) return null;
|
|
423
|
+
let lines = [];
|
|
424
|
+
try {
|
|
425
|
+
lines = readFileSync(log, "utf8").split("\n");
|
|
426
|
+
} catch {
|
|
427
|
+
lines = [];
|
|
428
|
+
}
|
|
429
|
+
const ended = st.endedAt !== undefined || st.exitCode !== undefined || st.signal;
|
|
430
|
+
const lastLine = () => lines.map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
|
|
431
|
+
if (!ended) {
|
|
432
|
+
if (pidAlive(st.pid)) return { state: "pending", line: `${name}: pending (started ${st.startedAt}, pid ${st.pid})`, st };
|
|
433
|
+
return { state: "failed", line: `${name}: failed (the job process ${st.pid} is gone and wrote no result)${lastLine() ? `; last output: ${lastLine()}` : ""}`, st };
|
|
434
|
+
}
|
|
435
|
+
if (st.exitCode === 0 && !st.signal) {
|
|
436
|
+
const secs = st.endedAt && st.startedAt ? Math.max(0, Math.round((Date.parse(st.endedAt) - Date.parse(st.startedAt)) / 1000)) : null;
|
|
437
|
+
return { state: "done", line: `${jobSummaryLine(lines)}${secs === null ? "" : ` (job ${name}, ${secs} s)`}`, st };
|
|
438
|
+
}
|
|
439
|
+
return { state: "failed", line: `${name}: failed (${st.signal ? `signal ${st.signal}` : `exit ${st.exitCode}`})${lastLine() ? `: ${lastLine()}` : ""}`, st };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Mark a finished job as read, so a later landing's `job --earlier` does not report it again.
|
|
443
|
+
function markJobReported(dir, name, st) {
|
|
444
|
+
try {
|
|
445
|
+
writeJobFile(jobPaths(dir, name).file, { ...st, reportedAt: new Date().toISOString() });
|
|
446
|
+
} catch {}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function pidAlive(pid) {
|
|
450
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
451
|
+
try {
|
|
452
|
+
process.kill(pid, 0);
|
|
453
|
+
return true;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return !!err && err.code === "EPERM";
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function jobSummaryLine(lines) {
|
|
460
|
+
const nonEmpty = lines.filter((l) => l.trim());
|
|
461
|
+
const reflected = nonEmpty.filter((l) => /(?:^|\) )(?:Reflected on |Already reflected on )/.test(l));
|
|
462
|
+
if (reflected.length) return reflected[reflected.length - 1];
|
|
463
|
+
const flush = nonEmpty.filter((l) => !/^\s/.test(l));
|
|
464
|
+
return (flush.length ? flush : nonEmpty).slice(-1)[0] || "";
|
|
465
|
+
}
|
|
466
|
+
|
|
239
467
|
function dirFrom(args, n) {
|
|
240
|
-
|
|
468
|
+
const d = resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
|
|
469
|
+
if (!existsSync(join(d, ".doug"))) fail(`not a Doug project: ${d} has no .doug/`, 1);
|
|
470
|
+
return d;
|
|
241
471
|
}
|
|
242
472
|
|
|
243
473
|
function verifiedWord(v) {
|
|
@@ -284,7 +514,8 @@ async function bestEffortEmbed(m, dir, ids) {
|
|
|
284
514
|
if (!ids || !ids.length) return;
|
|
285
515
|
const provider = createProvider(readMemoryConfig(dir));
|
|
286
516
|
if (!provider) return;
|
|
287
|
-
|
|
517
|
+
const { LIVE_LESSON_SQL } = await import("../lib/memory.mjs");
|
|
518
|
+
if (embeddingChangeDetected(m, provider, LIVE_LESSON_SQL)) {
|
|
288
519
|
process.stderr.write(
|
|
289
520
|
`Note: ${provider.model}/${provider.dims} is new to this store; leaving ${ids.length} lesson(s) pending. Run \`memory.mjs reembed\` first — it runs the cosine sanity check before this store commits to a new model.\n`
|
|
290
521
|
);
|
|
@@ -350,10 +581,10 @@ function parseLastJsonObject(text) {
|
|
|
350
581
|
// stdin, a 180s timeout, and a 64MB stdout buffer (a report's prompt plus a model's proposals can run long).
|
|
351
582
|
// Throws on any failure the spec names (spawn error, timeout, unparseable stdout, is_error, a missing
|
|
352
583
|
// structured_output.lessons array) so runReflect's single catch handles every case uniformly.
|
|
353
|
-
function makePropose({ claudeBin, model, maxUsd, dir }) {
|
|
584
|
+
function makePropose({ claudeBin, model, maxUsd, dir, schema = null, resultKey = "lessons", extraKeys = [] }) {
|
|
354
585
|
return async (prompt) => {
|
|
355
586
|
const { REFLECT_SCHEMA } = await import("../lib/memory.mjs");
|
|
356
|
-
const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
|
|
587
|
+
const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(schema ?? REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
|
|
357
588
|
const res = spawnSync(claudeBin, args, { input: prompt, encoding: "utf8", cwd: dir, timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
|
|
358
589
|
if (res.error) throw new Error(`claude spawn failed: ${res.error.message}`);
|
|
359
590
|
if (res.signal) throw new Error(`claude was killed by signal ${res.signal} (likely the 180s timeout)`);
|
|
@@ -365,10 +596,11 @@ function makePropose({ claudeBin, model, maxUsd, dir }) {
|
|
|
365
596
|
throw new Error(`claude printed no parseable JSON: ${err.message}`);
|
|
366
597
|
}
|
|
367
598
|
if (envelope.is_error) throw new Error(`claude reported is_error${envelope.result ? `: ${envelope.result}` : ""}`);
|
|
368
|
-
const
|
|
369
|
-
if (!Array.isArray(
|
|
599
|
+
const items = envelope.structured_output && envelope.structured_output[resultKey];
|
|
600
|
+
if (!Array.isArray(items)) throw new Error(`claude's structured_output.${resultKey} is missing`);
|
|
370
601
|
return {
|
|
371
|
-
|
|
602
|
+
[resultKey]: items,
|
|
603
|
+
...Object.fromEntries(extraKeys.map((k) => [k, Array.isArray(envelope.structured_output[k]) ? envelope.structured_output[k] : []])),
|
|
372
604
|
sessionId: envelope.session_id || null,
|
|
373
605
|
totalCostUsd: typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : null,
|
|
374
606
|
};
|
|
@@ -384,6 +616,10 @@ function idsList(ids) {
|
|
|
384
616
|
return ids.length ? ids.join(", ") : "none";
|
|
385
617
|
}
|
|
386
618
|
|
|
619
|
+
function supersededList(pairs) {
|
|
620
|
+
return pairs.length ? pairs.map((p) => `${p.old}->${p.new}`).join(", ") : "none";
|
|
621
|
+
}
|
|
622
|
+
|
|
387
623
|
function reviewIssueCountText(count) {
|
|
388
624
|
return count === null || count === undefined ? "null" : count;
|
|
389
625
|
}
|
|
@@ -405,7 +641,7 @@ function outcomeLine(row) {
|
|
|
405
641
|
`verified ${verifiedWord(row.verified)}`,
|
|
406
642
|
...(row.partial === 1 || row.partial === 0 ? [`partial ${partialWord(row.partial)}`] : []),
|
|
407
643
|
`review ${reviewIssueCountText(row.review_issue_count)} issue(s)`,
|
|
408
|
-
`adversary ${adv} (${blocksText(row.blocks)} block(s))`,
|
|
644
|
+
String(row.run).startsWith("stop:") ? `gate blocks ${blocksText(row.blocks)}` : `adversary ${adv} (${blocksText(row.blocks)} block(s))`,
|
|
409
645
|
`passes ${row.passes ?? "null"}`,
|
|
410
646
|
`tokens ${tokensText(row.tokens)}`,
|
|
411
647
|
`usd ${usdText(row.usd)}`,
|
|
@@ -444,11 +680,11 @@ function sanityLine(result) {
|
|
|
444
680
|
// embedded under provider.model/provider.dims — a model or dims change, including the very first embed ever
|
|
445
681
|
// under any model. Mirrors lessonsNeedingEmbedding's WHERE clause in lib/memory.mjs so "no lesson matches"
|
|
446
682
|
// means the same thing here as it does there.
|
|
447
|
-
function embeddingChangeDetected(m, provider) {
|
|
448
|
-
const total = m.prepare(
|
|
683
|
+
function embeddingChangeDetected(m, provider, LIVE_LESSON_SQL) {
|
|
684
|
+
const total = m.prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL}`).get().n;
|
|
449
685
|
if (total === 0) return false;
|
|
450
686
|
const matching = m
|
|
451
|
-
.prepare(
|
|
687
|
+
.prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding_model = ? AND embedding_dims = ?`)
|
|
452
688
|
.get(provider.model, provider.dims).n;
|
|
453
689
|
return matching === 0;
|
|
454
690
|
}
|
|
@@ -515,6 +751,56 @@ try {
|
|
|
515
751
|
const handUsage =
|
|
516
752
|
'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--cost <usd>] [--json]';
|
|
517
753
|
const [id, ...extra] = rest;
|
|
754
|
+
if (opts.stop !== undefined) {
|
|
755
|
+
// Card memory-hand-stop-rows: a stopped by-hand run records exactly one stop row.
|
|
756
|
+
const stopUsage =
|
|
757
|
+
'usage: memory.mjs record <card-id> --hand --stop <reason> [dir] [--detail <text>] [--json]';
|
|
758
|
+
if (!id || String(opts.stop).trim() === "" || opts.commit !== undefined) fail(stopUsage, 2);
|
|
759
|
+
if (id.endsWith(".json") || existsSync(resolve(id))) fail(`${stopUsage}\n--hand takes a card id, not a report.`, 2);
|
|
760
|
+
if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
|
|
761
|
+
fail(stopUsage, 2);
|
|
762
|
+
}
|
|
763
|
+
const dir = dirFrom(rest, 1);
|
|
764
|
+
const conditionFile = join(dir, ".doug", ".state", "reports", id, "condition.json");
|
|
765
|
+
let condition = null;
|
|
766
|
+
if (existsSync(conditionFile)) {
|
|
767
|
+
try {
|
|
768
|
+
condition = JSON.parse(readFileSync(conditionFile, "utf8"));
|
|
769
|
+
} catch (err) {
|
|
770
|
+
fail(`cannot read ${conditionFile}: ${err.message}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const reason = String(opts.stop).trim();
|
|
774
|
+
const detail = textOrNull(opts.detail);
|
|
775
|
+
const reportHash = createHash("sha256")
|
|
776
|
+
.update(JSON.stringify({ card: id, reason, detail, openedAt: condition?.openedAt ?? null }))
|
|
777
|
+
.digest("hex");
|
|
778
|
+
const { openMemory, recordOutcomes, stopOutcomeRow } = await import("../lib/memory.mjs");
|
|
779
|
+
const row = stopOutcomeRow({ card: id, report: { outcome: "stopped", reason, detail }, reportHash, blocks: [], condition });
|
|
780
|
+
const m = openMemory(dir);
|
|
781
|
+
let result;
|
|
782
|
+
try {
|
|
783
|
+
result = recordOutcomes(m, [row]);
|
|
784
|
+
} finally {
|
|
785
|
+
m.close();
|
|
786
|
+
}
|
|
787
|
+
const stamped = !!condition;
|
|
788
|
+
if (opts.json) {
|
|
789
|
+
process.stdout.write(
|
|
790
|
+
JSON.stringify({ run: row.run, card: id, inserted: result.inserted, updated: result.updated, stamped }, null, 2) + "\n"
|
|
791
|
+
);
|
|
792
|
+
} else {
|
|
793
|
+
process.stdout.write(
|
|
794
|
+
`Recorded a hand-track stop for ${id} (${reason}) in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
|
|
795
|
+
);
|
|
796
|
+
if (!stamped) {
|
|
797
|
+
process.stdout.write(
|
|
798
|
+
`No condition.json for ${id}: this stop is unstamped (memory.mjs condition open ${id} writes one before the brief).\n`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
518
804
|
if (!id || !opts.commit) fail(handUsage, 2);
|
|
519
805
|
if (id.endsWith(".json") || existsSync(resolve(id))) {
|
|
520
806
|
fail(`${handUsage}\n--hand takes a card id, not a report.`, 2);
|
|
@@ -600,8 +886,9 @@ try {
|
|
|
600
886
|
}
|
|
601
887
|
}
|
|
602
888
|
// Lazy: a Node without node:sqlite must fail loudly here, not with a stack trace, and never before this point.
|
|
603
|
-
const { openMemory, recordOutcomes, outcomeRows } = await import("../lib/memory.mjs");
|
|
604
|
-
const
|
|
889
|
+
const { openMemory, recordOutcomes, outcomeRows, memoryConfig } = await import("../lib/memory.mjs");
|
|
890
|
+
const flowConfig = memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "flow" });
|
|
891
|
+
const rows = outcomeRows(report, { plan, classes, run, reportHash, reportPath, card: opts.card ?? null, commit: opts.commit ?? null, cost, memoryConfig: flowConfig });
|
|
605
892
|
const m = openMemory(dir);
|
|
606
893
|
let result;
|
|
607
894
|
try {
|
|
@@ -622,12 +909,90 @@ try {
|
|
|
622
909
|
case "reflect": {
|
|
623
910
|
const usage =
|
|
624
911
|
'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
|
|
912
|
+
if (opts.failures !== undefined) {
|
|
913
|
+
const failuresUsage =
|
|
914
|
+
"usage: memory.mjs reflect --failures <card-id> [dir] [--session <id>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--detach] [--dry-run] [--json]";
|
|
915
|
+
const card = textOrNull(opts.failures);
|
|
916
|
+
if (!card || opts.hand) fail(`${failuresUsage}\n--failures takes a card id and is exclusive with --hand and a report path.`, 2);
|
|
917
|
+
if (rest.length > 1) fail(failuresUsage, 2);
|
|
918
|
+
if (rest.length === 1) {
|
|
919
|
+
// A lone positional that is a file, or names a .json report, is a report path, not a dir.
|
|
920
|
+
let isFile = /\.json$/i.test(rest[0]);
|
|
921
|
+
try {
|
|
922
|
+
if (statSync(resolve(rest[0])).isFile()) isFile = true;
|
|
923
|
+
} catch {
|
|
924
|
+
// missing path: judged by its name above
|
|
925
|
+
}
|
|
926
|
+
if (isFile) fail(`${failuresUsage}\n--failures takes a card id and is exclusive with --hand and a report path.`, 2);
|
|
927
|
+
}
|
|
928
|
+
const fdir = dirFrom(rest, 0);
|
|
929
|
+
if (opts.detach) {
|
|
930
|
+
const started = startDetachedJob(fdir, `failures-${card}`, [process.execPath, process.argv[1], ...argv.filter((a) => a !== "--detach")]);
|
|
931
|
+
process.stdout.write(`${started.name}: started detached (pid ${started.pid}); read it with: memory.mjs job ${started.name}\n`);
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
const fmodels = loadModels(fdir);
|
|
935
|
+
const fmodel = textOrNull(opts.model) || (fmodels.tiers.cheap && fmodels.tiers.cheap.model) || "haiku";
|
|
936
|
+
let fmaxUsd = 0.5;
|
|
937
|
+
if (opts["max-usd"] !== undefined) {
|
|
938
|
+
fmaxUsd = Number(opts["max-usd"]);
|
|
939
|
+
if (!Number.isFinite(fmaxUsd) || fmaxUsd <= 0) fail(`${failuresUsage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
|
|
940
|
+
}
|
|
941
|
+
const fClaudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
|
|
942
|
+
const fDry = !!opts["dry-run"];
|
|
943
|
+
const { openMemory, runFailureReflect, FAILURE_REFLECT_SCHEMA } = await import("../lib/memory.mjs");
|
|
944
|
+
const m = openMemory(fdir);
|
|
945
|
+
let fres;
|
|
946
|
+
try {
|
|
947
|
+
fres = await runFailureReflect(m, {
|
|
948
|
+
dir: fdir,
|
|
949
|
+
card,
|
|
950
|
+
session: textOrNull(opts.session),
|
|
951
|
+
model: fmodel,
|
|
952
|
+
claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined,
|
|
953
|
+
dryRun: fDry,
|
|
954
|
+
propose: fDry ? null : makePropose({ claudeBin: fClaudeBin, model: fmodel, maxUsd: fmaxUsd, dir: fdir, schema: FAILURE_REFLECT_SCHEMA }),
|
|
955
|
+
});
|
|
956
|
+
if (!fDry && fres.ok && fres.appended && fres.appended.length) await bestEffortEmbed(m, fdir, fres.appended);
|
|
957
|
+
} finally {
|
|
958
|
+
m.close();
|
|
959
|
+
}
|
|
960
|
+
if (fres.alreadyReflected) {
|
|
961
|
+
if (opts.json) process.stdout.write(JSON.stringify(fres.row, null, 2) + "\n");
|
|
962
|
+
else process.stdout.write(`Already reflected on ${fres.row.recorded} for the failures of ${card}; nothing to do.\n`);
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
if (fres.ok === false) {
|
|
966
|
+
process.stderr.write(`reflect: the failure pass failed for ${card}: ${fres.reason}\n`);
|
|
967
|
+
if (opts.json) process.stdout.write(JSON.stringify(fres, null, 2) + "\n");
|
|
968
|
+
process.exit(1);
|
|
969
|
+
}
|
|
970
|
+
if (opts.json) {
|
|
971
|
+
process.stdout.write(JSON.stringify(fres, null, 2) + "\n");
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
const hiddenText = Object.entries(fres.hidden).map(([k, n]) => `${k} ${n}`).join(", ") || "none";
|
|
975
|
+
const prefix = fDry ? "(dry run) " : "";
|
|
976
|
+
process.stdout.write(`${prefix}Failures of ${card}: ${fres.items} item(s); hidden blocks: ${hiddenText}; unreadable: ${fres.unreadable}\n`);
|
|
977
|
+
if (fres.items === 0) break;
|
|
978
|
+
if (fDry && fres.proposed === null) {
|
|
979
|
+
process.stdout.write(`${prefix}no model call; the prompt that would be sent:\n${fres.prompt}\n`);
|
|
980
|
+
process.stdout.write(`${prefix}Would append pitfall lessons to the memory store, .doug/.state/memory/pitfalls.jsonl and a reflections row; ${costLine(fres.usd, fres.usdSource)}\n`);
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
process.stdout.write(
|
|
984
|
+
`${prefix}Reflected: proposed ${fres.proposed}, ${fDry ? "would append" : "appended"} ${fres.appended.length} (${idsList(fres.appended)}), repeated ${fres.repeated.length} (${idsList(fres.repeated)}), rejected ${fres.rejected.length}; model ${fres.model}; ${costLine(fres.usd, fres.usdSource)}\n`
|
|
985
|
+
);
|
|
986
|
+
for (const r of fres.rejected) process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
625
989
|
let dir;
|
|
626
990
|
let reportPath;
|
|
627
991
|
let reportText;
|
|
628
992
|
let report;
|
|
629
993
|
let classes;
|
|
630
994
|
let handCard = null;
|
|
995
|
+
let rounds = 1;
|
|
631
996
|
if (opts.hand) {
|
|
632
997
|
const handUsage =
|
|
633
998
|
'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "<R<n> or <card-id>/R<n>>=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
|
|
@@ -635,7 +1000,6 @@ try {
|
|
|
635
1000
|
if (!id || !opts.commit) fail(handUsage, 2);
|
|
636
1001
|
handCard = id;
|
|
637
1002
|
dir = dirFrom(rest, 1);
|
|
638
|
-
let rounds = 1;
|
|
639
1003
|
if (opts.rounds !== undefined) {
|
|
640
1004
|
rounds = Number(opts.rounds);
|
|
641
1005
|
if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
|
|
@@ -655,6 +1019,11 @@ try {
|
|
|
655
1019
|
} catch (err) {
|
|
656
1020
|
fail(`${handUsage}\n${err.message}`, 2);
|
|
657
1021
|
}
|
|
1022
|
+
if (opts.detach) {
|
|
1023
|
+
const started = startDetachedJob(dir, `reflect-${id}`, [process.execPath, process.argv[1], ...argv.filter((a) => a !== "--detach")]);
|
|
1024
|
+
process.stdout.write(`${started.name}: started detached (pid ${started.pid}); read it with: memory.mjs job ${started.name}\n`);
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
658
1027
|
const base = join(dir, ".doug", ".state", "reports", id);
|
|
659
1028
|
const readDirFiles = (sub) => {
|
|
660
1029
|
const d = join(base, sub);
|
|
@@ -678,6 +1047,16 @@ try {
|
|
|
678
1047
|
} catch {
|
|
679
1048
|
memoryUsed = [];
|
|
680
1049
|
}
|
|
1050
|
+
const fetched = new Set();
|
|
1051
|
+
try {
|
|
1052
|
+
for (const line of readFileSync(join(base, "fetched.jsonl"), "utf8").split("\n")) {
|
|
1053
|
+
try {
|
|
1054
|
+
const rec = JSON.parse(line);
|
|
1055
|
+
if (rec && typeof rec.id === "string") fetched.add(rec.id);
|
|
1056
|
+
} catch {}
|
|
1057
|
+
}
|
|
1058
|
+
} catch {}
|
|
1059
|
+
memoryUsed = [...new Set(memoryUsed.filter((x) => fetched.has(x)))];
|
|
681
1060
|
const ledger = classes.map((c) => {
|
|
682
1061
|
return { id: c.id, stage: "adversary", seat: "reviewer", severity: "blocker", status: "fixed", description: c.reason || `reviewer finding ${c.id}` };
|
|
683
1062
|
});
|
|
@@ -695,6 +1074,23 @@ try {
|
|
|
695
1074
|
mkdirSync(base, { recursive: true });
|
|
696
1075
|
writeFileSync(reportPath, reportText, "utf8");
|
|
697
1076
|
report = handReport;
|
|
1077
|
+
if (!opts["dry-run"]) {
|
|
1078
|
+
try {
|
|
1079
|
+
const { historyScanner, writeRunHistory } = await import("../lib/memory.mjs");
|
|
1080
|
+
const entries = [{ kind: "report", source: "hand-report.json", value: handReport }];
|
|
1081
|
+
for (const f of readDirFiles("briefs")) {
|
|
1082
|
+
const mm = /^(\d+)-(.+)\.md$/.exec(f.name);
|
|
1083
|
+
entries.push({ kind: "brief", seat: mm ? mm[2] : null, round: mm ? Number(mm[1]) : null, source: `briefs/${f.name}`, value: f.text });
|
|
1084
|
+
}
|
|
1085
|
+
for (const f of readDirFiles("reviews")) {
|
|
1086
|
+
const mm = /^(\d+)\.md$/.exec(f.name);
|
|
1087
|
+
entries.push({ kind: "review", seat: "reviewer", round: mm ? Number(mm[1]) : null, source: `reviews/${f.name}`, value: f.text });
|
|
1088
|
+
}
|
|
1089
|
+
writeRunHistory(dir, { card: id, run: `hand:${opts.commit}`, entries, scan: await historyScanner(dir) });
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
process.stderr.write(`reflect: warning: cannot write the run history for ${id}: ${err.message || err}\n`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
698
1094
|
} else {
|
|
699
1095
|
const [reportFile] = rest;
|
|
700
1096
|
if (!reportFile) fail(usage, 2);
|
|
@@ -712,6 +1108,12 @@ try {
|
|
|
712
1108
|
fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
|
|
713
1109
|
}
|
|
714
1110
|
classes = parseAdversaryClasses(opts.adversary || []);
|
|
1111
|
+
if (opts.detach) {
|
|
1112
|
+
const tag = textOrNull(opts.card) || textOrNull(opts.run) || createHash("sha256").update(reportText).digest("hex").slice(0, 12);
|
|
1113
|
+
const started = startDetachedJob(dir, `reflect-${tag}`, [process.execPath, process.argv[1], ...argv.filter((a) => a !== "--detach")]);
|
|
1114
|
+
process.stdout.write(`${started.name}: started detached (pid ${started.pid}); read it with: memory.mjs job ${started.name}\n`);
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
715
1117
|
}
|
|
716
1118
|
const reportHash = createHash("sha256").update(reportText).digest("hex");
|
|
717
1119
|
const plan = planFor(dir, opts.card);
|
|
@@ -728,7 +1130,7 @@ try {
|
|
|
728
1130
|
const runLabel = opts.run || `report:${reportHash.slice(0, 12)}`;
|
|
729
1131
|
|
|
730
1132
|
const { openMemory, runReflect } = await import("../lib/memory.mjs");
|
|
731
|
-
const propose = makePropose({ claudeBin, model, maxUsd, dir });
|
|
1133
|
+
const propose = dryRun ? null : makePropose({ claudeBin, model, maxUsd, dir });
|
|
732
1134
|
const m = openMemory(dir);
|
|
733
1135
|
let result;
|
|
734
1136
|
try {
|
|
@@ -749,6 +1151,10 @@ try {
|
|
|
749
1151
|
if (!dryRun && result.ok && result.appended && result.appended.length) {
|
|
750
1152
|
await bestEffortEmbed(m, dir, result.appended);
|
|
751
1153
|
}
|
|
1154
|
+
// Card memory-outcome-arms: only an explicit --rounds is a measurement; the default of 1 is never written.
|
|
1155
|
+
if (handCard && !dryRun && opts.rounds !== undefined && (result.ok || result.alreadyReflected)) {
|
|
1156
|
+
m.prepare("UPDATE outcomes SET passes = ?, fix_passes = ? WHERE run = ? AND task = ?").run(rounds, rounds - 1, `hand:${opts.commit}`, handCard);
|
|
1157
|
+
}
|
|
752
1158
|
} finally {
|
|
753
1159
|
m.close();
|
|
754
1160
|
}
|
|
@@ -773,11 +1179,20 @@ try {
|
|
|
773
1179
|
} else {
|
|
774
1180
|
const c = result.counters;
|
|
775
1181
|
const prefix = result.dryRun ? "(dry run) " : "";
|
|
1182
|
+
if (result.dryRun && result.proposed === null) {
|
|
1183
|
+
process.stdout.write(
|
|
1184
|
+
`${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
|
|
1185
|
+
`cheap-row pass not run (dry run, no model call); ${costLine(result.usd, result.usdSource)}\n`
|
|
1186
|
+
);
|
|
1187
|
+
process.stdout.write(`${prefix}the prompt that would be sent:\n${result.prompt}\n`);
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
776
1190
|
process.stdout.write(
|
|
777
1191
|
`${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
|
|
778
1192
|
`proposed ${result.proposed}, appended ${result.appended.length} (${idsList(result.appended)}), bumped ${result.bumped.length} (${idsList(result.bumped)}), ` +
|
|
779
|
-
`rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
|
|
1193
|
+
`superseded ${result.superseded.length} (${supersededList(result.superseded)}), rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
|
|
780
1194
|
);
|
|
1195
|
+
for (const n of result.notes || []) process.stdout.write(` note: ${n}\n`);
|
|
781
1196
|
for (const r of result.rejected) {
|
|
782
1197
|
process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
|
|
783
1198
|
}
|
|
@@ -792,6 +1207,59 @@ try {
|
|
|
792
1207
|
}
|
|
793
1208
|
break;
|
|
794
1209
|
}
|
|
1210
|
+
case "__jobrun": {
|
|
1211
|
+
const raw = process.argv.slice(2);
|
|
1212
|
+
const sep = raw.indexOf("--");
|
|
1213
|
+
if (sep < 3 || sep === raw.length - 1) fail("usage: memory.mjs __jobrun <dir> <name> -- <command> [args...]", 2);
|
|
1214
|
+
await runJob(raw[1], raw[2], raw.slice(sep + 1));
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
case "job": {
|
|
1218
|
+
const usage = "usage: memory.mjs job <name> [dir] [--wait <seconds>] | memory.mjs job --earlier [dir]";
|
|
1219
|
+
if (opts.earlier) {
|
|
1220
|
+
// Earlier landings' jobs nobody has read yet: pending, failed, or finished with the summary never printed.
|
|
1221
|
+
const edir = dirFrom(rest, 0);
|
|
1222
|
+
const { jobs } = jobPaths(edir, "x");
|
|
1223
|
+
let names = [];
|
|
1224
|
+
try {
|
|
1225
|
+
names = readdirSync(jobs).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5));
|
|
1226
|
+
} catch {
|
|
1227
|
+
names = [];
|
|
1228
|
+
}
|
|
1229
|
+
let failed = false;
|
|
1230
|
+
let any = false;
|
|
1231
|
+
for (const n of names.sort()) {
|
|
1232
|
+
const js = jobStatus(edir, n);
|
|
1233
|
+
if (!js || js.st.reportedAt) continue;
|
|
1234
|
+
any = true;
|
|
1235
|
+
process.stdout.write(`earlier job ${js.line}\n`);
|
|
1236
|
+
if (js.state === "failed") failed = true;
|
|
1237
|
+
if (js.state !== "pending") markJobReported(edir, n, js.st);
|
|
1238
|
+
}
|
|
1239
|
+
if (!any) process.stdout.write("no earlier jobs unread\n");
|
|
1240
|
+
if (failed) process.exit(1);
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
const [name] = rest;
|
|
1244
|
+
if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) fail(usage, 2);
|
|
1245
|
+
let waitSec = 0;
|
|
1246
|
+
if (opts.wait !== undefined) {
|
|
1247
|
+
waitSec = /^\d+$/.test(String(opts.wait)) ? Number(opts.wait) : NaN;
|
|
1248
|
+
if (!Number.isInteger(waitSec)) fail(`${usage}\n--wait must be a whole number of seconds, got ${JSON.stringify(opts.wait)}.`, 2);
|
|
1249
|
+
}
|
|
1250
|
+
const jdir = dirFrom(rest, 1);
|
|
1251
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
1252
|
+
let js = jobStatus(jdir, name);
|
|
1253
|
+
if (!js) fail(`${name}: no such job in ${jdir}`, 1);
|
|
1254
|
+
while (js.state === "pending" && Date.now() < deadline) {
|
|
1255
|
+
await new Promise((r) => setTimeout(r, Math.min(500, Math.max(1, deadline - Date.now()))));
|
|
1256
|
+
js = jobStatus(jdir, name) || js;
|
|
1257
|
+
}
|
|
1258
|
+
process.stdout.write(`${js.line}\n`);
|
|
1259
|
+
if (js.state !== "pending") markJobReported(jdir, name, js.st);
|
|
1260
|
+
if (js.state === "failed") process.exit(1);
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
795
1263
|
case "outcomes": {
|
|
796
1264
|
const dir = dirFrom(rest, 0);
|
|
797
1265
|
const { openMemory, listOutcomes } = await import("../lib/memory.mjs");
|
|
@@ -811,8 +1279,70 @@ try {
|
|
|
811
1279
|
}
|
|
812
1280
|
break;
|
|
813
1281
|
}
|
|
1282
|
+
case "arms": {
|
|
1283
|
+
const armsUsage = "usage: memory.mjs arms [dir] [--min <n>] [--json]";
|
|
1284
|
+
let min = 12;
|
|
1285
|
+
if (opts.min !== undefined) {
|
|
1286
|
+
min = /^\d+$/.test(String(opts.min)) ? Number(opts.min) : NaN;
|
|
1287
|
+
if (!Number.isInteger(min) || min < 1) fail(`${armsUsage}\n--min must be an integer >= 1, got ${JSON.stringify(opts.min)}.`, 2);
|
|
1288
|
+
}
|
|
1289
|
+
const dir = dirFrom(rest, 0);
|
|
1290
|
+
const { memoryPath, openMemory, compareMemoryArms } = await import("../lib/memory.mjs");
|
|
1291
|
+
if (!existsSync(memoryPath(dir))) {
|
|
1292
|
+
process.stdout.write("No outcomes recorded.\n");
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
const m = openMemory(dir);
|
|
1296
|
+
let res;
|
|
1297
|
+
try {
|
|
1298
|
+
res = compareMemoryArms(m, { min });
|
|
1299
|
+
} finally {
|
|
1300
|
+
m.close();
|
|
1301
|
+
}
|
|
1302
|
+
if (opts.json) {
|
|
1303
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
const num = (v, d = 2) => (typeof v === "number" ? v.toFixed(d) : "n/a");
|
|
1307
|
+
const label = (cfg) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const c = JSON.parse(cfg);
|
|
1310
|
+
const e = c.embeddings ? `${c.embeddings.provider}/${c.embeddings.model}/${c.embeddings.dims}` : "keyword-only";
|
|
1311
|
+
return `${c.stage} ${e} staleDays ${c.staleDays}`;
|
|
1312
|
+
} catch {
|
|
1313
|
+
return cfg;
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
const lines = res.arms.map((a) => {
|
|
1317
|
+
const head = `${label(a.config)}: ${a.landings} landings, ${a.stops} stops`;
|
|
1318
|
+
if (!a.ready) return `${head}; not compared: ${a.landings} of ${res.min} landings`;
|
|
1319
|
+
const fix = a.fixRounds.n ? `${num(a.fixRounds.mean)} (n ${a.fixRounds.n})` : "n/a (none measured)";
|
|
1320
|
+
return `${head}; land rate ${num(a.landRate)}; fix rounds ${fix}; harmful per landing ${num(a.harmfulPerLanding)}; cost per landing ${typeof a.costPerLanding === "number" ? `$${a.costPerLanding.toFixed(2)}` : "n/a"} (${a.priced} priced)`;
|
|
1321
|
+
});
|
|
1322
|
+
if (res.unstamped) lines.push(`${res.unstamped} rows carry no memory configuration and are in no arm.`);
|
|
1323
|
+
lines.push(res.comparable ? "Two or more arms are comparable." : "Not comparable yet: fewer than two arms have enough landings.");
|
|
1324
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
814
1327
|
case "lessons": {
|
|
815
1328
|
const dir = dirFrom(rest, 0);
|
|
1329
|
+
if (opts.unconfirmed) {
|
|
1330
|
+
if (opts.search !== undefined || opts.all) fail("usage: memory.mjs lessons --unconfirmed [dir] [--json] (not with --search or --all)", 2);
|
|
1331
|
+
let list = [];
|
|
1332
|
+
if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
|
|
1333
|
+
const { openMemory, listUnconfirmedLessons, readFetchedLessonIds } = await import("../lib/memory.mjs");
|
|
1334
|
+
const m = openMemory(dir);
|
|
1335
|
+
try {
|
|
1336
|
+
list = listUnconfirmedLessons(m, { fetchedIds: readFetchedLessonIds(dir) });
|
|
1337
|
+
} finally {
|
|
1338
|
+
m.close();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
if (opts.json) process.stdout.write(JSON.stringify(list, null, 2) + "\n");
|
|
1342
|
+
else if (!list.length) process.stdout.write("no unconfirmed lessons\n");
|
|
1343
|
+
else process.stdout.write(list.map((r) => `${r.id} ${r.kind} ${r.created} ${r.title}`).join("\n") + "\n");
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
816
1346
|
const { openMemory, listLessons, searchLessons } = await import("../lib/memory.mjs");
|
|
817
1347
|
const m = openMemory(dir);
|
|
818
1348
|
let rows;
|
|
@@ -839,7 +1369,9 @@ try {
|
|
|
839
1369
|
"usage: memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]\n" +
|
|
840
1370
|
" [--scope <glob,glob>] [--citation <text>] [--card <id>]\n" +
|
|
841
1371
|
" [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]\n" +
|
|
842
|
-
" memory.mjs lesson unstale <id> [dir] [--json]"
|
|
1372
|
+
" memory.mjs lesson unstale <id> [dir] [--json]\n" +
|
|
1373
|
+
" memory.mjs lesson unarchive <id> [dir] [--json]\n" +
|
|
1374
|
+
" memory.mjs lesson show <id> [dir] [--card <card-id>] [--json]";
|
|
843
1375
|
const [sub, ...subrest] = rest;
|
|
844
1376
|
if (sub === "unstale") {
|
|
845
1377
|
const [id] = subrest;
|
|
@@ -860,6 +1392,57 @@ try {
|
|
|
860
1392
|
}
|
|
861
1393
|
break;
|
|
862
1394
|
}
|
|
1395
|
+
if (sub === "unarchive") {
|
|
1396
|
+
const [id] = subrest;
|
|
1397
|
+
if (!id) fail(usage, 2);
|
|
1398
|
+
const dir = dirFrom(subrest, 1);
|
|
1399
|
+
const { openMemory, unarchiveLesson } = await import("../lib/memory.mjs");
|
|
1400
|
+
const m = openMemory(dir);
|
|
1401
|
+
let row;
|
|
1402
|
+
try {
|
|
1403
|
+
row = unarchiveLesson(m, id);
|
|
1404
|
+
} finally {
|
|
1405
|
+
m.close();
|
|
1406
|
+
}
|
|
1407
|
+
if (opts.json) {
|
|
1408
|
+
process.stdout.write(JSON.stringify(row, null, 2) + "\n");
|
|
1409
|
+
} else {
|
|
1410
|
+
process.stdout.write(`Restored archived lesson ${row.id}.\n`);
|
|
1411
|
+
}
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
if (sub === "show") {
|
|
1415
|
+
const [id] = subrest;
|
|
1416
|
+
if (!id) fail(usage, 2);
|
|
1417
|
+
if (opts.card !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(opts.card))) fail(usage, 2);
|
|
1418
|
+
const dir = dirFrom(subrest, 1);
|
|
1419
|
+
if (!existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) fail(`no lesson ${id}: no memory store`);
|
|
1420
|
+
const { openMemory, getLesson, lessonTokens } = await import("../lib/memory.mjs");
|
|
1421
|
+
const m = openMemory(dir);
|
|
1422
|
+
let row;
|
|
1423
|
+
try {
|
|
1424
|
+
row = getLesson(m, id);
|
|
1425
|
+
} finally {
|
|
1426
|
+
m.close();
|
|
1427
|
+
}
|
|
1428
|
+
if (!row) fail(`no lesson ${id}`);
|
|
1429
|
+
const tokens = lessonTokens(row.text);
|
|
1430
|
+
if (opts.json) {
|
|
1431
|
+
const { embedding, ...rest2 } = row;
|
|
1432
|
+
process.stdout.write(JSON.stringify({ ...rest2, hasVector: embedding != null, tokens }, null, 2) + "\n");
|
|
1433
|
+
} else {
|
|
1434
|
+
const head = [`lesson ${row.id} (${row.kind}, ~${tokens} tokens)`, `citation: ${row.citation || "none"}`];
|
|
1435
|
+
if (row.superseded_by) head.push(`superseded by ${row.superseded_by}`);
|
|
1436
|
+
if (row.stale) head.push(`stale since ${row.stale}`);
|
|
1437
|
+
process.stdout.write(head.join("\n") + "\n\n" + row.text + "\n");
|
|
1438
|
+
}
|
|
1439
|
+
if (opts.card !== undefined) {
|
|
1440
|
+
const fdir = join(dir, ".doug", ".state", "reports", String(opts.card));
|
|
1441
|
+
mkdirSync(fdir, { recursive: true });
|
|
1442
|
+
appendFileSync(join(fdir, "fetched.jsonl"), JSON.stringify({ id: row.id, at: new Date().toISOString() }) + "\n");
|
|
1443
|
+
}
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
863
1446
|
if (sub !== "add") fail(usage, 2);
|
|
864
1447
|
const dir = dirFrom(subrest, 0);
|
|
865
1448
|
const sourceAgent = String(opts["source-agent"] !== undefined ? opts["source-agent"] : "lead").trim();
|
|
@@ -993,21 +1576,22 @@ try {
|
|
|
993
1576
|
}
|
|
994
1577
|
const query = `${card.title}\n${card.goal ?? ""}`;
|
|
995
1578
|
const header = "Lessons from earlier runs on this repository (memory; each cites where it was learned):";
|
|
996
|
-
const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, recalledAt: new Date().toISOString(), hidden: null };
|
|
1579
|
+
const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, omitted: [], recalledAt: new Date().toISOString(), hidden: null };
|
|
997
1580
|
let note = null;
|
|
998
1581
|
let error = null;
|
|
999
1582
|
if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
|
|
1000
1583
|
try {
|
|
1001
1584
|
const provider = createProvider(readMemoryConfig(dir));
|
|
1002
1585
|
const staleDays = readMemoryStaleDays(dir);
|
|
1003
|
-
const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider,
|
|
1586
|
+
const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider, renderLessonIndex, windowNote } = await import("../lib/memory.mjs");
|
|
1004
1587
|
const detectedProvider = provider ? null : await detectLocalProvider();
|
|
1005
1588
|
const m = openMemory(dir);
|
|
1006
1589
|
try {
|
|
1007
1590
|
const result = await recallLessons(m, query, { k, checkoutDir: dir, provider, staleDays, detectedProvider });
|
|
1008
1591
|
const warning = degradedWarning(result, { what: "memory recall", detectedProvider });
|
|
1009
1592
|
if (warning) process.stderr.write(warning + "\n");
|
|
1010
|
-
const rendered =
|
|
1593
|
+
const rendered = renderLessonIndex(result.lessons);
|
|
1594
|
+
saved.omitted = rendered.omitted;
|
|
1011
1595
|
saved.mode = result.mode;
|
|
1012
1596
|
saved.hidden = result.hidden ?? null;
|
|
1013
1597
|
note = windowNote(result.hidden, staleDays);
|
|
@@ -1023,6 +1607,7 @@ try {
|
|
|
1023
1607
|
saved.reason = null;
|
|
1024
1608
|
saved.ids = [];
|
|
1025
1609
|
saved.text = null;
|
|
1610
|
+
saved.omitted = [];
|
|
1026
1611
|
saved.hidden = null;
|
|
1027
1612
|
note = null;
|
|
1028
1613
|
saved.error = error;
|
|
@@ -1035,12 +1620,257 @@ try {
|
|
|
1035
1620
|
if (opts.json) {
|
|
1036
1621
|
process.stdout.write(JSON.stringify(saved, null, 2) + "\n");
|
|
1037
1622
|
} else if (saved.ids.length) {
|
|
1038
|
-
|
|
1623
|
+
const omittedLine = saved.omitted.length
|
|
1624
|
+
? [`index: omitted ${saved.omitted.length} lessons over the 2000-character index budget: ${saved.omitted.join(", ")}`]
|
|
1625
|
+
: [];
|
|
1626
|
+
process.stdout.write([header, saved.text, ...omittedLine, ...(note ? [note] : []), `memoryUsed: ${saved.ids.join(", ")}`].join("\n") + "\n");
|
|
1039
1627
|
} else {
|
|
1040
1628
|
process.stdout.write(`No lessons recalled for ${id}.\n${note ? note + "\n" : ""}`);
|
|
1041
1629
|
}
|
|
1042
1630
|
break;
|
|
1043
1631
|
}
|
|
1632
|
+
case "history": {
|
|
1633
|
+
const usage = "usage: memory.mjs history show <card>[/<run>[:<line>]] [dir] [--json]";
|
|
1634
|
+
const [sub, ref, ...extra] = rest;
|
|
1635
|
+
if (sub !== "show" || !ref) fail(usage, 2);
|
|
1636
|
+
const dir = dirFrom(extra, 0);
|
|
1637
|
+
const { parseHistoryRef, readRunHistory } = await import("../lib/memory.mjs");
|
|
1638
|
+
let parsed;
|
|
1639
|
+
try {
|
|
1640
|
+
parsed = parseHistoryRef(ref);
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
fail(`${err.message}\n${usage}`, 2);
|
|
1643
|
+
}
|
|
1644
|
+
let data;
|
|
1645
|
+
try {
|
|
1646
|
+
data = readRunHistory(dir, parsed);
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
fail(err.message);
|
|
1649
|
+
}
|
|
1650
|
+
if (opts.json) {
|
|
1651
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
1652
|
+
} else if (parsed.line !== null) {
|
|
1653
|
+
const e = data;
|
|
1654
|
+
const out = [`history ${parsed.card}/${parsed.run}:${parsed.line} (${e.kind}, seat ${e.seat ?? "-"}, round ${e.round ?? "-"}, ${e.source}, ~${e.tokens} tokens)`];
|
|
1655
|
+
if (e.truncated) out.push(`truncated at capture: ${e.bytes} bytes, head kept`);
|
|
1656
|
+
if (e.redacted) out.push(`redacted: ${e.redacted}`);
|
|
1657
|
+
process.stdout.write(out.join("\n") + "\n\n" + (e.text ?? "") + "\n");
|
|
1658
|
+
} else if (parsed.run !== null) {
|
|
1659
|
+
const out = [`history ${data.card}/${data.run}: ${data.entries.length} entries, ~${data.tokens} tokens`];
|
|
1660
|
+
for (const e of data.entries) {
|
|
1661
|
+
if (e.kind === "cap") {
|
|
1662
|
+
out.push(`${e.line} cap dropped ${e.dropped} entries over ${e.maxRunBytes} bytes`);
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
out.push(
|
|
1666
|
+
`${e.line} ${e.kind} ${e.seat ?? "-"} ${e.round ?? "-"} ${e.source} ~${e.tokens} tokens` +
|
|
1667
|
+
(e.truncated ? ` truncated from ${e.bytes} bytes` : "") +
|
|
1668
|
+
(e.redacted ? ` redacted: ${e.redacted}` : "")
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
1672
|
+
} else {
|
|
1673
|
+
const out = [`history ${data.card}: ${data.runs.length} runs`];
|
|
1674
|
+
for (const r of data.runs) out.push(`${data.card}/${r.run} ${r.entries} entries ~${r.tokens} tokens`);
|
|
1675
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
1676
|
+
}
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
case "ideas": {
|
|
1680
|
+
const usage = "usage: memory.mjs ideas [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--cap <n>] [--dry-run] [--json]";
|
|
1681
|
+
const dir = dirFrom(rest, 0);
|
|
1682
|
+
const lib = await import("../lib/memory.mjs");
|
|
1683
|
+
const models = loadModels(dir);
|
|
1684
|
+
const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
|
|
1685
|
+
let maxUsd = 0.5;
|
|
1686
|
+
if (opts["max-usd"] !== undefined) {
|
|
1687
|
+
maxUsd = Number(opts["max-usd"]);
|
|
1688
|
+
if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
|
|
1689
|
+
}
|
|
1690
|
+
let cap = lib.IDEA_CAP;
|
|
1691
|
+
if (opts.cap !== undefined) {
|
|
1692
|
+
cap = Number(opts.cap);
|
|
1693
|
+
if (!Number.isInteger(cap) || cap < 1) fail(`${usage}\n--cap must be an integer of at least 1, got ${JSON.stringify(opts.cap)}.`, 2);
|
|
1694
|
+
}
|
|
1695
|
+
const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
|
|
1696
|
+
const dryRun = !!opts["dry-run"];
|
|
1697
|
+
if (!existsSync(lib.memoryPath(dir))) fail("no memory store: nothing to propose ideas from");
|
|
1698
|
+
let board;
|
|
1699
|
+
try {
|
|
1700
|
+
board = loadBoard(dir);
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
fail(`ideas: ${err.message}`);
|
|
1703
|
+
}
|
|
1704
|
+
const m = lib.openMemory(dir);
|
|
1705
|
+
let call;
|
|
1706
|
+
let ideaCheck;
|
|
1707
|
+
let reviewCheck;
|
|
1708
|
+
let dryPrompt = null;
|
|
1709
|
+
try {
|
|
1710
|
+
const prompt = lib.buildIdeasPrompt({
|
|
1711
|
+
lessons: lib.listLessons(m),
|
|
1712
|
+
runs: lib.listRunHistoryRefs(dir),
|
|
1713
|
+
pitfalls: lib.readPitfallOccurrences(dir),
|
|
1714
|
+
cards: board.cards.filter((c) => c.column === "backlog" || c.column === "ready"),
|
|
1715
|
+
});
|
|
1716
|
+
if (dryRun) {
|
|
1717
|
+
dryPrompt = prompt;
|
|
1718
|
+
} else {
|
|
1719
|
+
try {
|
|
1720
|
+
call = await makePropose({ claudeBin, model, maxUsd, dir, schema: lib.IDEAS_SCHEMA, resultKey: "ideas", extraKeys: ["reviews"] })(prompt);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
fail(`ideas: the model call failed: ${err.message}`);
|
|
1723
|
+
}
|
|
1724
|
+
ideaCheck = lib.checkIdeaCitations(m, dir, call.ideas);
|
|
1725
|
+
reviewCheck = lib.checkIdeaCitations(m, dir, call.reviews);
|
|
1726
|
+
}
|
|
1727
|
+
} finally {
|
|
1728
|
+
m.close();
|
|
1729
|
+
}
|
|
1730
|
+
if (dryRun) {
|
|
1731
|
+
const writes = [".doug/board.json", lib.PRIORITY_REVIEWS_RELPATH];
|
|
1732
|
+
if (opts.json) {
|
|
1733
|
+
process.stdout.write(JSON.stringify({ dryRun: true, prompt: dryPrompt, wouldWrite: writes, usd: 0, usdSource: "none" }, null, 2) + "\n");
|
|
1734
|
+
} else {
|
|
1735
|
+
process.stdout.write(`(dry run) no model call; the prompt that would be sent:\n${dryPrompt}\n`);
|
|
1736
|
+
for (const w of writes) process.stdout.write(`Would write ${w}\n`);
|
|
1737
|
+
process.stdout.write(`(dry run) Would file idea cards and reviews from the model's proposals; ${costLine(0, "none")}\n`);
|
|
1738
|
+
}
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1741
|
+
const cost = lib.computeReflectCost({ dir, sessionId: call.sessionId, totalCostUsd: call.totalCostUsd });
|
|
1742
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1743
|
+
try {
|
|
1744
|
+
board = loadBoard(dir);
|
|
1745
|
+
} catch (err) {
|
|
1746
|
+
fail(`ideas: ${err.message}`);
|
|
1747
|
+
}
|
|
1748
|
+
const filed = lib.fileIdeaCards(board, ideaCheck.kept, { cap, date });
|
|
1749
|
+
const reviewsChecked = lib.checkPriorityReviews(board, reviewCheck.kept, { cap, recorded: lib.readPriorityReviews(dir) });
|
|
1750
|
+
const at = new Date().toISOString();
|
|
1751
|
+
const reviewLines = reviewsChecked.kept.map((r) => ({ at, text: r.text, cards: r.cards, citations: r.citations }));
|
|
1752
|
+
const droppedReviews = [...reviewCheck.dropped, ...reviewsChecked.dropped];
|
|
1753
|
+
if (!dryRun) {
|
|
1754
|
+
if (filed.filed.length) saveBoard(dir, filed.board);
|
|
1755
|
+
if (reviewLines.length) {
|
|
1756
|
+
const file = join(dir, lib.PRIORITY_REVIEWS_RELPATH);
|
|
1757
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1758
|
+
appendFileSync(file, reviewLines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8");
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
const filedCards = filed.filed.map((id) => {
|
|
1762
|
+
const c = filed.board.cards.find((x) => x.id === id);
|
|
1763
|
+
return { id, title: c.title };
|
|
1764
|
+
});
|
|
1765
|
+
if (opts.json) {
|
|
1766
|
+
process.stdout.write(
|
|
1767
|
+
JSON.stringify(
|
|
1768
|
+
{ filed: filedCards, skipped: filed.skipped, reviews: reviewLines, droppedReviews, unresolved: ideaCheck.dropped, usd: cost.usd, usdSource: cost.usdSource, dryRun },
|
|
1769
|
+
null,
|
|
1770
|
+
2
|
|
1771
|
+
) + "\n"
|
|
1772
|
+
);
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
const out = [];
|
|
1776
|
+
for (const c of filedCards) out.push(`${dryRun ? "would file" : "filed"} ${c.id}: ${c.title}`);
|
|
1777
|
+
for (const s2 of filed.skipped) out.push(`skipped ${s2.id}: ${s2.reason}`);
|
|
1778
|
+
for (const u of ideaCheck.dropped) out.push(`skipped ${u.id}: unresolved citation`);
|
|
1779
|
+
for (const r of reviewLines) out.push(`${dryRun ? "would record" : "recorded"} review: ${r.text} (${r.cards.join(", ")})`);
|
|
1780
|
+
out.push(
|
|
1781
|
+
`${dryRun ? "Would file" : "Filed"} ${filedCards.length} idea cards to Backlog, ${filed.skipped.length + ideaCheck.dropped.length} skipped, ${reviewLines.length} reviews kept, ${droppedReviews.length} dropped; ${costLine(cost.usd, cost.usdSource)}`
|
|
1782
|
+
);
|
|
1783
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1786
|
+
case "model": {
|
|
1787
|
+
const usage = "usage: memory.mjs model regen [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json] | memory.mjs model show [dir]";
|
|
1788
|
+
const [sub, ...subrest] = rest;
|
|
1789
|
+
if (sub !== "regen" && sub !== "show") fail(usage, 2);
|
|
1790
|
+
const dir = dirFrom(subrest, 0);
|
|
1791
|
+
const lib = await import("../lib/memory.mjs");
|
|
1792
|
+
const modelPath = join(dir, lib.PROJECT_MODEL_RELPATH);
|
|
1793
|
+
if (sub === "show") {
|
|
1794
|
+
if (!existsSync(modelPath)) fail(`no project model at ${lib.PROJECT_MODEL_RELPATH}: run \`memory.mjs model regen\``);
|
|
1795
|
+
process.stdout.write(readFileSync(modelPath, "utf8"));
|
|
1796
|
+
break;
|
|
1797
|
+
}
|
|
1798
|
+
const models = loadModels(dir);
|
|
1799
|
+
const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
|
|
1800
|
+
let maxUsd = 0.5;
|
|
1801
|
+
if (opts["max-usd"] !== undefined) {
|
|
1802
|
+
maxUsd = Number(opts["max-usd"]);
|
|
1803
|
+
if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
|
|
1804
|
+
}
|
|
1805
|
+
const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
|
|
1806
|
+
const dryRun = !!opts["dry-run"];
|
|
1807
|
+
if (!existsSync(lib.memoryPath(dir))) fail("no memory store: nothing to build a project model from");
|
|
1808
|
+
const m = lib.openMemory(dir);
|
|
1809
|
+
let call;
|
|
1810
|
+
let checked;
|
|
1811
|
+
let dryPrompt = null;
|
|
1812
|
+
try {
|
|
1813
|
+
const lessons = lib.listLessons(m);
|
|
1814
|
+
const prompt = lib.buildProjectModelPrompt({ lessons, runs: lib.listRunHistoryRefs(dir) });
|
|
1815
|
+
if (dryRun) {
|
|
1816
|
+
dryPrompt = prompt;
|
|
1817
|
+
} else {
|
|
1818
|
+
try {
|
|
1819
|
+
call = await makePropose({ claudeBin, model, maxUsd, dir, schema: lib.PROJECT_MODEL_SCHEMA, resultKey: "statements" })(prompt);
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
fail(`model regen: the model call failed: ${err.message}`);
|
|
1822
|
+
}
|
|
1823
|
+
checked = lib.checkProjectModelCitations(m, dir, call.statements);
|
|
1824
|
+
}
|
|
1825
|
+
} finally {
|
|
1826
|
+
m.close();
|
|
1827
|
+
}
|
|
1828
|
+
if (dryRun) {
|
|
1829
|
+
if (opts.json) {
|
|
1830
|
+
process.stdout.write(JSON.stringify({ path: modelPath, dryRun: true, prompt: dryPrompt, usd: 0, usdSource: "none" }, null, 2) + "\n");
|
|
1831
|
+
} else {
|
|
1832
|
+
process.stdout.write(`(dry run) no model call; the prompt that would be sent:\n${dryPrompt}\n`);
|
|
1833
|
+
process.stdout.write(`Would write ${lib.PROJECT_MODEL_RELPATH} from the model's statements; ${costLine(0, "none")}\n`);
|
|
1834
|
+
}
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1837
|
+
const cost = lib.computeReflectCost({ dir, sessionId: call.sessionId, totalCostUsd: call.totalCostUsd });
|
|
1838
|
+
const rendered = lib.renderProjectModel(checked.kept);
|
|
1839
|
+
const counts = `dropped ${checked.dropped.length} unresolved, ${rendered.overCap.length} over cap`;
|
|
1840
|
+
if (!rendered.kept.length) {
|
|
1841
|
+
fail(`model regen: nothing was written: no statement survived (${call.statements.length} proposed; ${counts}); ${costLine(cost.usd, cost.usdSource)}`);
|
|
1842
|
+
}
|
|
1843
|
+
if (!dryRun) {
|
|
1844
|
+
mkdirSync(dirname(modelPath), { recursive: true });
|
|
1845
|
+
writeFileSync(modelPath, rendered.text, "utf8");
|
|
1846
|
+
}
|
|
1847
|
+
if (opts.json) {
|
|
1848
|
+
process.stdout.write(
|
|
1849
|
+
JSON.stringify(
|
|
1850
|
+
{
|
|
1851
|
+
path: modelPath,
|
|
1852
|
+
statements: rendered.kept.length,
|
|
1853
|
+
chars: rendered.chars,
|
|
1854
|
+
cap: lib.PROJECT_MODEL_CHAR_CAP,
|
|
1855
|
+
unresolved: checked.dropped.length,
|
|
1856
|
+
overCap: rendered.overCap.length,
|
|
1857
|
+
dropped: checked.dropped,
|
|
1858
|
+
usd: cost.usd,
|
|
1859
|
+
usdSource: cost.usdSource,
|
|
1860
|
+
dryRun,
|
|
1861
|
+
},
|
|
1862
|
+
null,
|
|
1863
|
+
2
|
|
1864
|
+
) + "\n"
|
|
1865
|
+
);
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
if (dryRun) process.stdout.write(rendered.text + "\n");
|
|
1869
|
+
process.stdout.write(
|
|
1870
|
+
`${dryRun ? "Would write" : "Wrote"} ${lib.PROJECT_MODEL_RELPATH}: ${rendered.kept.length} statements, ${rendered.chars} characters (cap ${lib.PROJECT_MODEL_CHAR_CAP}); ${counts}; ${costLine(cost.usd, cost.usdSource)}\n`
|
|
1871
|
+
);
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1044
1874
|
case "doctor": {
|
|
1045
1875
|
const dir = dirFrom(rest, 0);
|
|
1046
1876
|
const cfg = readMemoryConfig(dir);
|
|
@@ -1097,7 +1927,7 @@ try {
|
|
|
1097
1927
|
if (opts.check && opts["no-check"]) {
|
|
1098
1928
|
fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
|
|
1099
1929
|
}
|
|
1100
|
-
const { openMemory, embedLessons, lessonsNeedingEmbedding } = await import("../lib/memory.mjs");
|
|
1930
|
+
const { openMemory, embedLessons, lessonsNeedingEmbedding, LIVE_LESSON_SQL } = await import("../lib/memory.mjs");
|
|
1101
1931
|
const m = openMemory(dir);
|
|
1102
1932
|
let result;
|
|
1103
1933
|
let checkStatus;
|
|
@@ -1107,7 +1937,7 @@ try {
|
|
|
1107
1937
|
if (opts["no-check"]) {
|
|
1108
1938
|
process.stderr.write("embed-check: skipped (--no-check)\n");
|
|
1109
1939
|
checkStatus = "skipped";
|
|
1110
|
-
} else if (opts.check || embeddingChangeDetected(m, provider)) {
|
|
1940
|
+
} else if (opts.check || embeddingChangeDetected(m, provider, LIVE_LESSON_SQL)) {
|
|
1111
1941
|
const pairs = loadPairsOrFail(opts);
|
|
1112
1942
|
const check = await checkEmbeddingSanity(provider, pairs);
|
|
1113
1943
|
if (!check.ok) {
|
|
@@ -1125,7 +1955,7 @@ try {
|
|
|
1125
1955
|
onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
|
|
1126
1956
|
});
|
|
1127
1957
|
stillMissing = lessonsNeedingEmbedding(m, { model: provider.model, dims: provider.dims }).length;
|
|
1128
|
-
liveTotal = m.prepare(
|
|
1958
|
+
liveTotal = m.prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL}`).get().n;
|
|
1129
1959
|
} finally {
|
|
1130
1960
|
m.close();
|
|
1131
1961
|
}
|
|
@@ -1366,7 +2196,7 @@ try {
|
|
|
1366
2196
|
if (headRes.status !== 0 || !headRes.stdout.trim()) {
|
|
1367
2197
|
fail(`cannot open condition for "${id}": no git HEAD in ${dir}${headRes.stderr && headRes.stderr.trim() ? ` (${headRes.stderr.trim()})` : " (not a git repository, or no commits yet)"}.`);
|
|
1368
2198
|
}
|
|
1369
|
-
const { conditionConfigHash } = await import("../lib/memory.mjs");
|
|
2199
|
+
const { conditionConfigHash, memoryConfig } = await import("../lib/memory.mjs");
|
|
1370
2200
|
const written = {
|
|
1371
2201
|
harnessCommit: headRes.stdout.trim(),
|
|
1372
2202
|
configHash: conditionConfigHash(dir),
|
|
@@ -1375,6 +2205,7 @@ try {
|
|
|
1375
2205
|
assignedBy: "policy",
|
|
1376
2206
|
exploreProbability: null,
|
|
1377
2207
|
openedAt: new Date().toISOString(),
|
|
2208
|
+
memoryConfig: memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "hand" }),
|
|
1378
2209
|
};
|
|
1379
2210
|
mkdirSync(dirname(file), { recursive: true });
|
|
1380
2211
|
writeFileSync(file, JSON.stringify(written, null, 2) + "\n");
|
|
@@ -1458,7 +2289,7 @@ try {
|
|
|
1458
2289
|
break;
|
|
1459
2290
|
}
|
|
1460
2291
|
default:
|
|
1461
|
-
fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
|
|
2292
|
+
fail("usage: memory.mjs <record|reflect|job|outcomes|arms|lessons|lesson|history|model|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
|
|
1462
2293
|
}
|
|
1463
2294
|
} catch (err) {
|
|
1464
2295
|
fail(err.message);
|