@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,1274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI for the outcome log and lessons store (card memory-lib), on top of lib/memory.mjs. node:sqlite is loaded
|
|
3
|
+
// lazily, only once a subcommand needs it, so a Node without it (<22.16) prints the lib's one clear error and
|
|
4
|
+
// exits 1 instead of a stack trace.
|
|
5
|
+
// memory.mjs record <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
|
|
6
|
+
// [--adversary "<id>=<class>[: <reason>]"]... [--claude-dir <path>] [--json]
|
|
7
|
+
// append one outcomes row per task in the report (upsert on
|
|
8
|
+
// run+task); with --run, per-task tokens and USD come from the
|
|
9
|
+
// same local transcripts lib/cost.mjs reads (null, with a note on
|
|
10
|
+
// stderr, when the run's journal cannot be found)
|
|
11
|
+
// memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]
|
|
12
|
+
// record a hand-track landing (card id, commit, wall clock, gate
|
|
13
|
+
// line, note; nothing else is invented) as one outcomes row keyed
|
|
14
|
+
// hand:<commit> (upsert on the same commit); --commit is required;
|
|
15
|
+
// --run, --adversary, --claude-dir, and a report path are not
|
|
16
|
+
// accepted with --hand. Reads
|
|
17
|
+
// .doug/.state/reports/<card-id>/condition.json (card
|
|
18
|
+
// outcomes-condition-columns) when present and fills the seven
|
|
19
|
+
// condition columns from it; without one the row still records
|
|
20
|
+
// (exit 0) and an extra stdout line says the landing is
|
|
21
|
+
// unstamped (--json: "stamped": true|false instead)
|
|
22
|
+
// memory.mjs condition open <card-id> [dir] [--json]
|
|
23
|
+
// (card outcomes-condition-columns) writes
|
|
24
|
+
// .doug/.state/reports/<card-id>/condition.json: harnessCommit
|
|
25
|
+
// (git HEAD), configHash (conditionConfigHash: .doug/config.json
|
|
26
|
+
// plus the CLAUDE.md Models table text), class and arm
|
|
27
|
+
// (<class>/default) from the card's board record, assignedBy
|
|
28
|
+
// "policy", exploreProbability null, openedAt. Refuses (exit 1,
|
|
29
|
+
// nothing written) when the file already exists, the card is
|
|
30
|
+
// missing or has no class, or there is no git HEAD
|
|
31
|
+
// memory.mjs condition backfill [dir] [--json]
|
|
32
|
+
// (card outcomes-condition-columns) for every outcomes row with
|
|
33
|
+
// harness_commit null: sets harness_commit to the last commit at
|
|
34
|
+
// or before the row's recorded timestamp (git log --before,
|
|
35
|
+
// first-parent on main), class from the row's card on the board
|
|
36
|
+
// when it has one, backfilled 1; leaves a row untouched (and
|
|
37
|
+
// uncounted) when git finds no such commit; idempotent (a second
|
|
38
|
+
// run finds nothing left to stamp). Prints "Backfilled <n>
|
|
39
|
+
// outcomes rows with a harness commit." (--json: {"backfilled": n})
|
|
40
|
+
// memory.mjs outcomes [dir] [--run <id>] [--task <id>] [--json]
|
|
41
|
+
// list recorded outcomes, filtered by run and/or task
|
|
42
|
+
// memory.mjs lessons [dir] [--search <text>] [--k <n>] [--all] [--json]
|
|
43
|
+
// list lessons, or full-text search them (--search); --all
|
|
44
|
+
// includes superseded rows
|
|
45
|
+
// memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]
|
|
46
|
+
// [--scope <glob,glob>] [--citation <text>] [--card <id>]
|
|
47
|
+
// [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]
|
|
48
|
+
// add a lesson directly (card out-of-workflow-access): the
|
|
49
|
+
// markdown auto-memory roundtrip can only produce kind feedback
|
|
50
|
+
// or project, never pitfall or pattern. --source-agent defaults
|
|
51
|
+
// to "lead" and REFUSES "auto-memory", which is the import's own
|
|
52
|
+
// namespace (memory-import.mjs looks up the file name under the
|
|
53
|
+
// auto-memory dir plus source_agent "auto-memory" to decide what
|
|
54
|
+
// to supersede); a hand-authored lesson must never claim it. Not
|
|
55
|
+
// the "lessons" listing subcommand above.
|
|
56
|
+
// memory.mjs import [dir] [--claude-dir <path>] [--json]
|
|
57
|
+
// read Claude Code auto memory (~/.claude/projects/<slug>/memory)
|
|
58
|
+
// idempotently into lessons (source auto-memory); never writes
|
|
59
|
+
// anything under the auto-memory directory
|
|
60
|
+
// memory.mjs lesson unstale <id> [dir] [--json]
|
|
61
|
+
// clear a lesson's stale mark (set by recall's citation
|
|
62
|
+
// re-check) so it is a candidate again; refuses on an unknown id
|
|
63
|
+
// memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
|
|
64
|
+
// hybrid recall (card memory-recall): BM25 over FTS5 fused by
|
|
65
|
+
// reciprocal rank fusion with cosine over stored vectors when a
|
|
66
|
+
// provider is configured and vectors exist for it, recency-
|
|
67
|
+
// weighted, scope-boosted by --files, each returned lesson's
|
|
68
|
+
// citation re-checked against [dir] only when [dir] is a real
|
|
69
|
+
// project root (contains .doug); keyword-only, and says so, when
|
|
70
|
+
// no provider is configured or no vectors match it
|
|
71
|
+
// memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
|
|
72
|
+
// [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
|
|
73
|
+
// [--model <name>] [--max-usd <n>] [--dry-run] [--json]
|
|
74
|
+
// runs after record: applies deterministic helpful/harmful
|
|
75
|
+
// counters to lessons named in each task's memoryUsed (committed
|
|
76
|
+
// even if the LLM pass below fails), then a cheap-row `claude -p
|
|
77
|
+
// --json-schema` pass proposes at most 3 lessons from the report,
|
|
78
|
+
// filtered in code (a pitfall needs a block classed real, a
|
|
79
|
+
// pattern needs a first-pass verified task, the citation must be
|
|
80
|
+
// checkable, the text must not already be derivable from the
|
|
81
|
+
// checkout, and a near-duplicate bumps the existing lesson's
|
|
82
|
+
// helpful instead of appending); idempotent on the report's
|
|
83
|
+
// sha256 (a second reflect on the same report is a no-op, exit
|
|
84
|
+
// 0); --dry-run applies nothing but still runs the LLM pass and
|
|
85
|
+
// prints what would happen; --hand is refused, since a hand
|
|
86
|
+
// landing has no report. Cost is measured from the same local
|
|
87
|
+
// transcripts cost.mjs reads, falling back to the CLI's own
|
|
88
|
+
// cost estimate. Needs Node >=22.16 (node:sqlite).
|
|
89
|
+
// memory.mjs doctor [dir] [--json]
|
|
90
|
+
// probe the configured embedding provider (memory.embeddings in
|
|
91
|
+
// .doug/config.json), or a local Ollama at localhost:11434 when
|
|
92
|
+
// nothing is configured; print what was found, the model it
|
|
93
|
+
// would use, and the one command to install what is missing
|
|
94
|
+
// memory.mjs reembed [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]
|
|
95
|
+
// (re)embed every lesson not yet embedded under the configured
|
|
96
|
+
// provider's model/dims, in batches (progress on stderr);
|
|
97
|
+
// refuses with a clear line when no provider is configured or
|
|
98
|
+
// when --batch is not an integer >= 1. Runs the embed-check
|
|
99
|
+
// cosine sanity check first whenever the store holds no lesson
|
|
100
|
+
// embedded under the configured model/dims while at least one
|
|
101
|
+
// lesson exists (a model/dims change, including the first embed
|
|
102
|
+
// ever); on a failed check it embeds nothing and exits 1.
|
|
103
|
+
// --check forces the check on any reembed; --no-check skips it
|
|
104
|
+
// (prints one stderr line) for a deliberately non-semantic test
|
|
105
|
+
// double
|
|
106
|
+
// memory.mjs index build [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]
|
|
107
|
+
// (card semantic-index) incrementally (re)build the opt-in
|
|
108
|
+
// semantic code index: hash every git-tracked, indexable file,
|
|
109
|
+
// re-chunk and re-embed only what changed, drop rows for files no
|
|
110
|
+
// longer indexable. With a provider configured, embeds pending
|
|
111
|
+
// chunks in batches (progress on stderr like reembed), running
|
|
112
|
+
// the embed-check cosine sanity check first on a model/dims pair
|
|
113
|
+
// the index has never used (--check/--no-check/--pairs identical
|
|
114
|
+
// to reembed); unlike reembed, a down provider or a failed check
|
|
115
|
+
// never makes build exit nonzero (exit 0, keyword-only, reason in
|
|
116
|
+
// the line) - only a non-git dir or a database problem does
|
|
117
|
+
// memory.mjs index status [dir] [--json]
|
|
118
|
+
// the index's size (files, chunks, text bytes, database bytes,
|
|
119
|
+
// how many chunks are embedded under the configured provider) and
|
|
120
|
+
// its refresh cost (files added/changed/removed and exactly how
|
|
121
|
+
// many chunks the next build would embed, estimated seconds from
|
|
122
|
+
// the last build's measured rate); never built is not an error
|
|
123
|
+
// memory.mjs index search <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
|
|
124
|
+
// hybrid search over the code index (card semantic-index): BM25
|
|
125
|
+
// over FTS5 fused by reciprocal rank fusion with cosine over
|
|
126
|
+
// stored vectors when a provider is configured and vectors exist
|
|
127
|
+
// for it; --files (comma-separated globs) restricts candidates by
|
|
128
|
+
// path; keyword-only, and says so, with no provider or no vectors
|
|
129
|
+
// memory.mjs embed-check [dir] [--pairs <file>] [--json]
|
|
130
|
+
// cosine sanity check (card memory-measure): embeds the dozen
|
|
131
|
+
// paraphrase and dozen unrelated lesson pairs in evals/memory/
|
|
132
|
+
// pairs.json (or --pairs) against the configured provider and
|
|
133
|
+
// confirms paraphrases score higher than unrelated pairs by the
|
|
134
|
+
// file's stated margin; exit 0 ok, 1 on a failed check or an
|
|
135
|
+
// unreachable/unconfigured provider, 2 on usage; refuses when
|
|
136
|
+
// the pairs file is missing
|
|
137
|
+
// memory.mjs decision propose --title <text> --file <path> [--date YYYY-MM-DD] [dir] [--json]
|
|
138
|
+
// (card memory-decisions) proposes a new ADR at
|
|
139
|
+
// docs/decisions/<NNNN>-<slug>.md as a diff under
|
|
140
|
+
// .doug/.state/learn/ (never a tracked file); refuses when the
|
|
141
|
+
// target already exists or --title/--file is empty. --file -
|
|
142
|
+
// reads the body from stdin. Approve and write it with
|
|
143
|
+
// `learn.mjs apply <diff>` - docs/decisions/ is a proposal-only
|
|
144
|
+
// path (proposalPaths in .doug/config.json), refused by every
|
|
145
|
+
// other tool
|
|
146
|
+
// memory.mjs decision amend <NNNN> (--text <text> | --file <path>) [--date YYYY-MM-DD] [dir] [--json]
|
|
147
|
+
// proposes appending a dated "## Amendment" section to the one
|
|
148
|
+
// existing docs/decisions/<NNNN>-*.md; refuses when none or
|
|
149
|
+
// several ADRs match <NNNN>, or the text is empty
|
|
150
|
+
// memory.mjs rule propose <name> --file <path> [--paths <glob,glob>] [dir] [--json]
|
|
151
|
+
// proposes creating or replacing .claude/rules/<name>.md (also
|
|
152
|
+
// proposal-only); with --paths the rule's frontmatter scopes it
|
|
153
|
+
// to those globs (loads only when a matching file is read), with
|
|
154
|
+
// none it loads unconditionally at session start (Claude Code's
|
|
155
|
+
// own `paths:` contract - see docs/memory.md)
|
|
156
|
+
// memory.mjs claude-md propose --file <path|-> [dir] [--json]
|
|
157
|
+
// proposes a diff to CLAUDE.md from an edits JSON file/stdin of
|
|
158
|
+
// source-cited adds/removes ({ add: [{heading,line,source}],
|
|
159
|
+
// remove: [{line,enforcedBy,source}], note }); writes only a diff
|
|
160
|
+
// under .doug/.state/learn/, never CLAUDE.md itself; refuses when
|
|
161
|
+
// an edit would touch the Models section or the fenced Commands
|
|
162
|
+
// block, or would push CLAUDE.md over 60 lines. Approve and write
|
|
163
|
+
// it with `learn.mjs apply <diff>`
|
|
164
|
+
// lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
|
|
165
|
+
// provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
|
|
166
|
+
// row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
|
|
167
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
168
|
+
import { resolve, join, dirname } from "node:path";
|
|
169
|
+
import { createHash } from "node:crypto";
|
|
170
|
+
import { spawnSync } from "node:child_process";
|
|
171
|
+
import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
|
|
172
|
+
import { loadPlan, unwrapReport } from "../lib/plan.mjs";
|
|
173
|
+
import { costRun } from "../lib/cost.mjs";
|
|
174
|
+
import { loadModels } from "../lib/models.mjs";
|
|
175
|
+
import {
|
|
176
|
+
readMemoryConfig,
|
|
177
|
+
readMemoryStaleDays,
|
|
178
|
+
readIndexConfig,
|
|
179
|
+
createProvider,
|
|
180
|
+
probeProvider,
|
|
181
|
+
doctorReport,
|
|
182
|
+
checkEmbeddingSanity,
|
|
183
|
+
loadSanityPairs,
|
|
184
|
+
DEFAULT_SANITY_PAIRS,
|
|
185
|
+
} from "../lib/embeddings.mjs";
|
|
186
|
+
|
|
187
|
+
const argv = process.argv.slice(2);
|
|
188
|
+
const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
|
|
189
|
+
const REPEATABLE = new Set(["adversary"]);
|
|
190
|
+
const opts = {};
|
|
191
|
+
const positional = [];
|
|
192
|
+
for (let i = 0; i < argv.length; i++) {
|
|
193
|
+
if (!argv[i].startsWith("--")) {
|
|
194
|
+
positional.push(argv[i]);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const name = argv[i].slice(2);
|
|
198
|
+
if (FLAGS.has(name)) {
|
|
199
|
+
opts[name] = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (REPEATABLE.has(name)) (opts[name] ||= []).push(argv[i + 1] ?? "");
|
|
203
|
+
else opts[name] = argv[i + 1] ?? "";
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
const [cmd, ...rest] = positional;
|
|
207
|
+
|
|
208
|
+
function fail(msg, code = 1) {
|
|
209
|
+
process.stderr.write(msg + "\n");
|
|
210
|
+
process.exit(code);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function dirFrom(args, n) {
|
|
214
|
+
return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function verifiedWord(v) {
|
|
218
|
+
return v === 1 ? "yes" : v === 0 ? "no" : "?";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function partialWord(v) {
|
|
222
|
+
return v === 1 ? "yes" : "no";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function tokensText(tokens) {
|
|
226
|
+
if (tokens === null || tokens === undefined) return "null";
|
|
227
|
+
if (typeof tokens.input === "number" && typeof tokens.output === "number") return `${tokens.input}/${tokens.output}`;
|
|
228
|
+
if (typeof tokens.total === "number") return `${tokens.total}`;
|
|
229
|
+
return "null";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function usdText(usd) {
|
|
233
|
+
return typeof usd === "number" ? usd.toFixed(2) : "null";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function commitText(sha) {
|
|
237
|
+
return sha ? String(sha).slice(0, 7) : "none";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// An empty or whitespace-only flag value (a flag given with no following value parses as "") is not text; store
|
|
241
|
+
// null instead of the empty string.
|
|
242
|
+
function textOrNull(value) {
|
|
243
|
+
if (value === undefined || value === null) return null;
|
|
244
|
+
return String(value).trim() ? value : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Embeds exactly the given lesson ids (the row(s) this add/import just wrote), best-effort: no provider
|
|
248
|
+
// configured is silent; a provider failure is one stderr line. Deliberately scoped to `ids`, not the whole
|
|
249
|
+
// backlog — `reembed` is the command for catching up rows an earlier add/import left pending.
|
|
250
|
+
//
|
|
251
|
+
// (reviewer MAJOR 1, card memory-measure) Never the first commit to a new model/dims pair: when the store has
|
|
252
|
+
// no active lesson embedded under the configured model/dims yet (embeddingChangeDetected — the same rule
|
|
253
|
+
// `reembed` uses to decide whether to run its cosine sanity check), this leaves the row(s) pending instead of
|
|
254
|
+
// embedding them, so a plain `lesson add`/`import` can never quietly establish a bad model/dims pair as
|
|
255
|
+
// "already in use" and skip `reembed`'s check on the next run — only `reembed`, past that check, may commit a
|
|
256
|
+
// store to a model/dims it has never used before.
|
|
257
|
+
async function bestEffortEmbed(m, dir, ids) {
|
|
258
|
+
if (!ids || !ids.length) return;
|
|
259
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
260
|
+
if (!provider) return;
|
|
261
|
+
if (embeddingChangeDetected(m, provider)) {
|
|
262
|
+
process.stderr.write(
|
|
263
|
+
`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`
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const { getLesson, setLessonEmbedding } = await import("../lib/memory.mjs");
|
|
268
|
+
const rows = ids.map((id) => getLesson(m, id)).filter(Boolean);
|
|
269
|
+
if (!rows.length) return;
|
|
270
|
+
const result = await provider.embed(
|
|
271
|
+
rows.map((r) => r.text),
|
|
272
|
+
{ inputType: "document" }
|
|
273
|
+
);
|
|
274
|
+
if (!result.ok) {
|
|
275
|
+
process.stderr.write(`Note: embedding with ${provider.model}/${provider.dims} failed for ${rows.length} lesson(s): ${result.reason}\n`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (let i = 0; i < rows.length; i++) {
|
|
279
|
+
setLessonEmbedding(m, rows[i].id, { model: provider.model, dims: provider.dims, vector: result.vectors[i] });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// `claude -p --output-format json` is documented to print one JSON object, but in practice a notice or warning
|
|
284
|
+
// line can land before or after it on stdout. Scans for every top-level {...} object (brace-balanced, respecting
|
|
285
|
+
// quoted strings and escapes) and parses the LAST one — the one line among many that matters is the JSON
|
|
286
|
+
// envelope, and it is always the outermost/last such object claude prints. Throws when none is found or the last
|
|
287
|
+
// candidate does not parse.
|
|
288
|
+
function parseLastJsonObject(text) {
|
|
289
|
+
const candidates = [];
|
|
290
|
+
let depth = 0;
|
|
291
|
+
let start = -1;
|
|
292
|
+
let inString = false;
|
|
293
|
+
let escape = false;
|
|
294
|
+
for (let i = 0; i < text.length; i++) {
|
|
295
|
+
const c = text[i];
|
|
296
|
+
if (inString) {
|
|
297
|
+
if (escape) escape = false;
|
|
298
|
+
else if (c === "\\") escape = true;
|
|
299
|
+
else if (c === '"') inString = false;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (c === '"') {
|
|
303
|
+
inString = true;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (c === "{") {
|
|
307
|
+
if (depth === 0) start = i;
|
|
308
|
+
depth++;
|
|
309
|
+
} else if (c === "}") {
|
|
310
|
+
if (depth > 0) {
|
|
311
|
+
depth--;
|
|
312
|
+
if (depth === 0 && start !== -1) {
|
|
313
|
+
candidates.push(text.slice(start, i + 1));
|
|
314
|
+
start = -1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!candidates.length) throw new Error("no JSON object found in the output");
|
|
320
|
+
return JSON.parse(candidates[candidates.length - 1]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The real spawn behind reflect's injectable `propose(prompt)`: `claude -p` with the reflect schema, prompt on
|
|
324
|
+
// stdin, a 180s timeout, and a 64MB stdout buffer (a report's prompt plus a model's proposals can run long).
|
|
325
|
+
// Throws on any failure the spec names (spawn error, timeout, unparseable stdout, is_error, a missing
|
|
326
|
+
// structured_output.lessons array) so runReflect's single catch handles every case uniformly.
|
|
327
|
+
function makePropose({ claudeBin, model, maxUsd, dir }) {
|
|
328
|
+
return async (prompt) => {
|
|
329
|
+
const { REFLECT_SCHEMA } = await import("../lib/memory.mjs");
|
|
330
|
+
const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
|
|
331
|
+
const res = spawnSync(claudeBin, args, { input: prompt, encoding: "utf8", cwd: dir, timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
|
|
332
|
+
if (res.error) throw new Error(`claude spawn failed: ${res.error.message}`);
|
|
333
|
+
if (res.signal) throw new Error(`claude was killed by signal ${res.signal} (likely the 180s timeout)`);
|
|
334
|
+
if (res.status !== 0) throw new Error(`claude exited ${res.status}: ${(res.stderr || "").trim().slice(0, 500)}`);
|
|
335
|
+
let envelope;
|
|
336
|
+
try {
|
|
337
|
+
envelope = parseLastJsonObject(res.stdout);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
throw new Error(`claude printed no parseable JSON: ${err.message}`);
|
|
340
|
+
}
|
|
341
|
+
if (envelope.is_error) throw new Error(`claude reported is_error${envelope.result ? `: ${envelope.result}` : ""}`);
|
|
342
|
+
const lessons = envelope.structured_output && envelope.structured_output.lessons;
|
|
343
|
+
if (!Array.isArray(lessons)) throw new Error("claude's structured_output.lessons is missing");
|
|
344
|
+
return {
|
|
345
|
+
lessons,
|
|
346
|
+
sessionId: envelope.session_id || null,
|
|
347
|
+
totalCostUsd: typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : null,
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Four decimals so a cheap-row (haiku) pass, typically a fraction of a cent, never reads as a misleading $0.00.
|
|
353
|
+
function costLine(usd, source) {
|
|
354
|
+
return typeof usd === "number" ? `$${usd.toFixed(4)} (${source ?? "none"})` : "cost not measured";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function idsList(ids) {
|
|
358
|
+
return ids.length ? ids.join(", ") : "none";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function reviewIssueCountText(count) {
|
|
362
|
+
return count === null || count === undefined ? "null" : count;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function blocksText(blocks) {
|
|
366
|
+
return Array.isArray(blocks) ? blocks.length : "null";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A flow row has no track-specific handling below, so its line is unchanged. A hand row has no review, adversary
|
|
370
|
+
// blocks, or verifier (track === "hand"), so those columns print "null" rather than a false zero, and the row
|
|
371
|
+
// gains the columns a flow row has no equivalent for: hand (from track), wall <wall_clock>, gate <gate>.
|
|
372
|
+
// partial (card worker-context-handoff) prints only when set (1 or 0), right after verified: without this, a
|
|
373
|
+
// partial task's "verified no" reads as a verification failure, which the card's goal forbids.
|
|
374
|
+
function outcomeLine(row) {
|
|
375
|
+
const adv = row.adversary_verdict ?? "none";
|
|
376
|
+
const parts = [
|
|
377
|
+
row.run,
|
|
378
|
+
row.task,
|
|
379
|
+
`verified ${verifiedWord(row.verified)}`,
|
|
380
|
+
...(row.partial === 1 || row.partial === 0 ? [`partial ${partialWord(row.partial)}`] : []),
|
|
381
|
+
`review ${reviewIssueCountText(row.review_issue_count)} issue(s)`,
|
|
382
|
+
`adversary ${adv} (${blocksText(row.blocks)} block(s))`,
|
|
383
|
+
`passes ${row.passes ?? "null"}`,
|
|
384
|
+
`tokens ${tokensText(row.tokens)}`,
|
|
385
|
+
`usd ${usdText(row.usd)}`,
|
|
386
|
+
`commit ${commitText(row.commit_sha)}`,
|
|
387
|
+
];
|
|
388
|
+
if (row.track === "hand") {
|
|
389
|
+
parts.splice(2, 0, "hand");
|
|
390
|
+
parts.push(`wall ${row.wall_clock ?? "null"}`);
|
|
391
|
+
parts.push(`gate ${row.gate ?? "null"}`);
|
|
392
|
+
}
|
|
393
|
+
return parts.join(" ");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function lessonLine(row) {
|
|
397
|
+
const suffix = row.superseded_by ? ` (superseded by ${row.superseded_by})` : "";
|
|
398
|
+
return `${row.id} ${row.kind} ${row.text}${suffix}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// One-line (ok) or multi-line (FAILED, one offending pair per line) rendering of a checkEmbeddingSanity()
|
|
402
|
+
// result, shared by `embed-check` and `reembed`'s pre-check.
|
|
403
|
+
function sanityLine(result) {
|
|
404
|
+
if (result.ok) {
|
|
405
|
+
return (
|
|
406
|
+
`embed-check: ok (gap ${result.gap.toFixed(2)} >= margin ${result.margin}; ` +
|
|
407
|
+
`paraphrase mean ${result.paraphrase.mean.toFixed(2)}, unrelated mean ${result.unrelated.mean.toFixed(2)})`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const lines = [`embed-check: FAILED (${result.reason})`];
|
|
411
|
+
for (const f of result.failures || []) {
|
|
412
|
+
lines.push(` ${JSON.stringify(f.pair)} cosine ${f.cosine.toFixed(4)}`);
|
|
413
|
+
}
|
|
414
|
+
return lines.join("\n");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// True when the store holds at least one active (not superseded, not stale) lesson but none of them are
|
|
418
|
+
// embedded under provider.model/provider.dims — a model or dims change, including the very first embed ever
|
|
419
|
+
// under any model. Mirrors lessonsNeedingEmbedding's WHERE clause in lib/memory.mjs so "no lesson matches"
|
|
420
|
+
// means the same thing here as it does there.
|
|
421
|
+
function embeddingChangeDetected(m, provider) {
|
|
422
|
+
const total = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
|
|
423
|
+
if (total === 0) return false;
|
|
424
|
+
const matching = m
|
|
425
|
+
.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding_model = ? AND embedding_dims = ?")
|
|
426
|
+
.get(provider.model, provider.dims).n;
|
|
427
|
+
return matching === 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Loads the sanity pairs file for `embed-check`/`reembed`, refusing (never throwing past the outer catch) when
|
|
431
|
+
// it is missing so the message always names the path and --pairs.
|
|
432
|
+
function loadPairsOrFail(opts) {
|
|
433
|
+
const pairsPath = opts.pairs ? resolve(opts.pairs) : DEFAULT_SANITY_PAIRS;
|
|
434
|
+
if (!existsSync(pairsPath)) {
|
|
435
|
+
fail(`no sanity pairs file at ${pairsPath}; pass --pairs <file> to point at a different one.`, 1);
|
|
436
|
+
}
|
|
437
|
+
return loadSanityPairs(pairsPath);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Today's local date as YYYY-MM-DD, the default `--date` for `decision propose`/`decision amend` (card
|
|
441
|
+
// memory-decisions).
|
|
442
|
+
function today() {
|
|
443
|
+
return new Date().toISOString().slice(0, 10);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Reads a --file argument's text: "-" reads stdin (fd 0), anything else a real file path.
|
|
447
|
+
function readTextArg(path) {
|
|
448
|
+
return path === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(path), "utf8");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Shared printer for decisions.mjs's propose*() results (card memory-decisions): refused -> the reason on
|
|
452
|
+
// stderr, exit 1 (plus the result as --json on stdout, mirroring `apply`'s own refusal shape); ok -> the
|
|
453
|
+
// proposal line in learn.mjs's own shape, the diff path, and how to apply it - the user approves before
|
|
454
|
+
// anything is written to docs/decisions/ or .claude/rules/, exactly like `learn.mjs propose`/`apply`.
|
|
455
|
+
function printProposalResult(result, jsonOut) {
|
|
456
|
+
if (!result.ok) {
|
|
457
|
+
if (jsonOut) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
458
|
+
process.stderr.write(`${result.reason}\n`);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
if (jsonOut) {
|
|
462
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const { proposal, diffPath } = result;
|
|
466
|
+
process.stdout.write(`${proposal.id} ${proposal.kind} ${proposal.target}: ${proposal.reason}\n`);
|
|
467
|
+
process.stdout.write(`diff: ${diffPath}\n`);
|
|
468
|
+
process.stdout.write(`apply with: learn.mjs apply ${diffPath}\n`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// `index build`'s own pairs loader: an explicit --pairs that is missing fails clearly (same as loadPairsOrFail,
|
|
472
|
+
// same exit code), but with no --pairs given at all, a missing default file (a standalone-published copy of
|
|
473
|
+
// this plugin outside this checkout - DEFAULT_SANITY_PAIRS lives at the repo root) is not a hard failure: build
|
|
474
|
+
// still runs, and buildIndex itself reports a check failure only if a model/dims pair actually new to the code
|
|
475
|
+
// index shows up needing one.
|
|
476
|
+
function loadIndexPairs(opts) {
|
|
477
|
+
const pairsPath = opts.pairs ? resolve(opts.pairs) : DEFAULT_SANITY_PAIRS;
|
|
478
|
+
if (!existsSync(pairsPath)) {
|
|
479
|
+
if (opts.pairs) fail(`no sanity pairs file at ${pairsPath}; pass --pairs <file> to point at a different one.`, 1);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return loadSanityPairs(pairsPath);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
switch (cmd) {
|
|
487
|
+
case "record": {
|
|
488
|
+
if (opts.hand) {
|
|
489
|
+
const handUsage =
|
|
490
|
+
'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]';
|
|
491
|
+
const [id, ...extra] = rest;
|
|
492
|
+
if (!id || !opts.commit) fail(handUsage, 2);
|
|
493
|
+
if (id.endsWith(".json") || existsSync(resolve(id))) {
|
|
494
|
+
fail(`${handUsage}\n--hand takes a card id, not a report.`, 2);
|
|
495
|
+
}
|
|
496
|
+
if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
|
|
497
|
+
fail(handUsage, 2);
|
|
498
|
+
}
|
|
499
|
+
const dir = dirFrom(rest, 1);
|
|
500
|
+
// condition.json (card outcomes-condition-columns): read when `condition open` wrote one before the
|
|
501
|
+
// brief; a stamp missing entirely (the common case until core-next-hand-report wires this in) is not a
|
|
502
|
+
// parse error, only a JSON.parse failure on a file that does exist is.
|
|
503
|
+
const conditionFile = join(dir, ".doug", ".state", "reports", id, "condition.json");
|
|
504
|
+
let condition = null;
|
|
505
|
+
if (existsSync(conditionFile)) {
|
|
506
|
+
try {
|
|
507
|
+
condition = JSON.parse(readFileSync(conditionFile, "utf8"));
|
|
508
|
+
} catch (err) {
|
|
509
|
+
fail(`cannot read ${conditionFile}: ${err.message}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const { openMemory, recordOutcomes, handOutcomeRow } = await import("../lib/memory.mjs");
|
|
513
|
+
const row = handOutcomeRow({ card: id, commit: opts.commit, wallClock: textOrNull(opts.wall), gate: textOrNull(opts.gate), note: textOrNull(opts.note), condition });
|
|
514
|
+
const m = openMemory(dir);
|
|
515
|
+
let result;
|
|
516
|
+
try {
|
|
517
|
+
result = recordOutcomes(m, [row]);
|
|
518
|
+
} finally {
|
|
519
|
+
m.close();
|
|
520
|
+
}
|
|
521
|
+
const stamped = !!condition;
|
|
522
|
+
if (opts.json) {
|
|
523
|
+
process.stdout.write(
|
|
524
|
+
JSON.stringify({ run: row.run, card: id, commit: opts.commit, inserted: result.inserted, updated: result.updated, stamped }, null, 2) + "\n"
|
|
525
|
+
);
|
|
526
|
+
} else {
|
|
527
|
+
process.stdout.write(
|
|
528
|
+
`Recorded a hand-track landing for ${id} at ${opts.commit} in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
|
|
529
|
+
);
|
|
530
|
+
if (!stamped) {
|
|
531
|
+
process.stdout.write(
|
|
532
|
+
`No condition.json for ${id}: this landing is unstamped (memory.mjs condition open ${id} writes one before the brief).\n`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
const [reportFile] = rest;
|
|
539
|
+
if (!reportFile) {
|
|
540
|
+
fail(
|
|
541
|
+
'usage: memory.mjs record <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-dir <path>] [--json]',
|
|
542
|
+
2
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const dir = dirFrom(rest, 1);
|
|
546
|
+
const reportPath = resolve(reportFile);
|
|
547
|
+
if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
|
|
548
|
+
let reportText;
|
|
549
|
+
try {
|
|
550
|
+
reportText = readFileSync(reportPath, "utf8");
|
|
551
|
+
} catch (err) {
|
|
552
|
+
fail(`cannot read report file ${reportPath}: ${err.message}`);
|
|
553
|
+
}
|
|
554
|
+
let report;
|
|
555
|
+
try {
|
|
556
|
+
report = unwrapReport(JSON.parse(reportText));
|
|
557
|
+
} catch (err) {
|
|
558
|
+
fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
|
|
559
|
+
}
|
|
560
|
+
const reportHash = createHash("sha256").update(reportText).digest("hex");
|
|
561
|
+
const classes = parseAdversaryClasses(opts.adversary || []);
|
|
562
|
+
const plan = loadPlan(dir);
|
|
563
|
+
const run = opts.run || null;
|
|
564
|
+
const runLabel = run || `report:${reportHash.slice(0, 12)}`;
|
|
565
|
+
let cost = null;
|
|
566
|
+
if (run) {
|
|
567
|
+
try {
|
|
568
|
+
cost = costRun({ runId: run, projectDir: dir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined });
|
|
569
|
+
} catch (err) {
|
|
570
|
+
if (!/^no journal for run /.test(err.message)) throw err;
|
|
571
|
+
process.stderr.write(`Note: no journal for run ${run}; tokens and USD recorded as null.\n`);
|
|
572
|
+
cost = null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Lazy: a Node without node:sqlite must fail loudly here, not with a stack trace, and never before this point.
|
|
576
|
+
const { openMemory, recordOutcomes, outcomeRows } = await import("../lib/memory.mjs");
|
|
577
|
+
const rows = outcomeRows(report, { plan, classes, run, reportHash, reportPath, card: opts.card ?? null, commit: opts.commit ?? null, cost });
|
|
578
|
+
const m = openMemory(dir);
|
|
579
|
+
let result;
|
|
580
|
+
try {
|
|
581
|
+
result = recordOutcomes(m, rows);
|
|
582
|
+
} finally {
|
|
583
|
+
m.close();
|
|
584
|
+
}
|
|
585
|
+
const tasks = rows.map((r) => r.task);
|
|
586
|
+
if (opts.json) {
|
|
587
|
+
process.stdout.write(JSON.stringify({ run: runLabel, inserted: result.inserted, updated: result.updated, tasks }, null, 2) + "\n");
|
|
588
|
+
} else {
|
|
589
|
+
process.stdout.write(
|
|
590
|
+
`Recorded ${rows.length} outcome(s) for ${runLabel} in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
case "reflect": {
|
|
596
|
+
const usage =
|
|
597
|
+
'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
|
|
598
|
+
if (opts.hand) fail(`${usage}\n--hand is not accepted: a hand landing has no report.`, 2);
|
|
599
|
+
const [reportFile] = rest;
|
|
600
|
+
if (!reportFile) fail(usage, 2);
|
|
601
|
+
const dir = dirFrom(rest, 1);
|
|
602
|
+
const reportPath = resolve(reportFile);
|
|
603
|
+
if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
|
|
604
|
+
let reportText;
|
|
605
|
+
try {
|
|
606
|
+
reportText = readFileSync(reportPath, "utf8");
|
|
607
|
+
} catch (err) {
|
|
608
|
+
fail(`cannot read report file ${reportPath}: ${err.message}`);
|
|
609
|
+
}
|
|
610
|
+
let report;
|
|
611
|
+
try {
|
|
612
|
+
report = unwrapReport(JSON.parse(reportText));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
|
|
615
|
+
}
|
|
616
|
+
const reportHash = createHash("sha256").update(reportText).digest("hex");
|
|
617
|
+
const classes = parseAdversaryClasses(opts.adversary || []);
|
|
618
|
+
const plan = loadPlan(dir);
|
|
619
|
+
const models = loadModels(dir);
|
|
620
|
+
const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
|
|
621
|
+
let maxUsd = 0.5;
|
|
622
|
+
if (opts["max-usd"] !== undefined) {
|
|
623
|
+
maxUsd = Number(opts["max-usd"]);
|
|
624
|
+
if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
|
|
625
|
+
}
|
|
626
|
+
const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
|
|
627
|
+
const claudeDir = opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined;
|
|
628
|
+
const dryRun = !!opts["dry-run"];
|
|
629
|
+
const runLabel = opts.run || `report:${reportHash.slice(0, 12)}`;
|
|
630
|
+
|
|
631
|
+
const { openMemory, runReflect } = await import("../lib/memory.mjs");
|
|
632
|
+
const propose = makePropose({ claudeBin, model, maxUsd, dir });
|
|
633
|
+
const m = openMemory(dir);
|
|
634
|
+
let result;
|
|
635
|
+
try {
|
|
636
|
+
result = await runReflect(m, report, {
|
|
637
|
+
reportHash,
|
|
638
|
+
dir,
|
|
639
|
+
classes,
|
|
640
|
+
plan,
|
|
641
|
+
run: opts.run ?? null,
|
|
642
|
+
card: opts.card ?? null,
|
|
643
|
+
commit: opts.commit ?? null,
|
|
644
|
+
model,
|
|
645
|
+
claudeDir,
|
|
646
|
+
dryRun,
|
|
647
|
+
propose,
|
|
648
|
+
});
|
|
649
|
+
if (!dryRun && result.ok && result.appended && result.appended.length) {
|
|
650
|
+
await bestEffortEmbed(m, dir, result.appended);
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
m.close();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (result.alreadyReflected) {
|
|
657
|
+
if (opts.json) {
|
|
658
|
+
process.stdout.write(JSON.stringify(result.row, null, 2) + "\n");
|
|
659
|
+
} else {
|
|
660
|
+
process.stdout.write(`Already reflected on ${result.row.recorded} for ${runLabel}; nothing to do.\n`);
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
process.stderr.write(`reflect: the cheap-row pass failed for ${runLabel}: ${result.reason}\n`);
|
|
667
|
+
if (opts.json) process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (opts.json) {
|
|
672
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
673
|
+
} else {
|
|
674
|
+
const c = result.counters;
|
|
675
|
+
const prefix = result.dryRun ? "(dry run) " : "";
|
|
676
|
+
process.stdout.write(
|
|
677
|
+
`${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
|
|
678
|
+
`proposed ${result.proposed}, appended ${result.appended.length} (${idsList(result.appended)}), bumped ${result.bumped.length} (${idsList(result.bumped)}), ` +
|
|
679
|
+
`rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
|
|
680
|
+
);
|
|
681
|
+
for (const r of result.rejected) {
|
|
682
|
+
process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
case "outcomes": {
|
|
688
|
+
const dir = dirFrom(rest, 0);
|
|
689
|
+
const { openMemory, listOutcomes } = await import("../lib/memory.mjs");
|
|
690
|
+
const m = openMemory(dir);
|
|
691
|
+
let rows;
|
|
692
|
+
try {
|
|
693
|
+
rows = listOutcomes(m, { run: opts.run ?? null, task: opts.task ?? null });
|
|
694
|
+
} finally {
|
|
695
|
+
m.close();
|
|
696
|
+
}
|
|
697
|
+
if (opts.json) {
|
|
698
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
699
|
+
} else if (!rows.length) {
|
|
700
|
+
process.stdout.write("no outcomes recorded\n");
|
|
701
|
+
} else {
|
|
702
|
+
process.stdout.write(rows.map(outcomeLine).join("\n") + "\n");
|
|
703
|
+
}
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case "lessons": {
|
|
707
|
+
const dir = dirFrom(rest, 0);
|
|
708
|
+
const { openMemory, listLessons, searchLessons } = await import("../lib/memory.mjs");
|
|
709
|
+
const m = openMemory(dir);
|
|
710
|
+
let rows;
|
|
711
|
+
try {
|
|
712
|
+
const includeSuperseded = !!opts.all;
|
|
713
|
+
rows =
|
|
714
|
+
opts.search !== undefined
|
|
715
|
+
? searchLessons(m, opts.search, { k: opts.k !== undefined ? Number(opts.k) : undefined, includeSuperseded })
|
|
716
|
+
: listLessons(m, { includeSuperseded });
|
|
717
|
+
} finally {
|
|
718
|
+
m.close();
|
|
719
|
+
}
|
|
720
|
+
if (opts.json) {
|
|
721
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
722
|
+
} else if (!rows.length) {
|
|
723
|
+
process.stdout.write("no lessons\n");
|
|
724
|
+
} else {
|
|
725
|
+
process.stdout.write(rows.map(lessonLine).join("\n") + "\n");
|
|
726
|
+
}
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
case "lesson": {
|
|
730
|
+
const usage =
|
|
731
|
+
"usage: memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]\n" +
|
|
732
|
+
" [--scope <glob,glob>] [--citation <text>] [--card <id>]\n" +
|
|
733
|
+
" [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]\n" +
|
|
734
|
+
" memory.mjs lesson unstale <id> [dir] [--json]";
|
|
735
|
+
const [sub, ...subrest] = rest;
|
|
736
|
+
if (sub === "unstale") {
|
|
737
|
+
const [id] = subrest;
|
|
738
|
+
if (!id) fail(usage, 2);
|
|
739
|
+
const dir = dirFrom(subrest, 1);
|
|
740
|
+
const { openMemory, unstaleLesson } = await import("../lib/memory.mjs");
|
|
741
|
+
const m = openMemory(dir);
|
|
742
|
+
let row;
|
|
743
|
+
try {
|
|
744
|
+
row = unstaleLesson(m, id);
|
|
745
|
+
} finally {
|
|
746
|
+
m.close();
|
|
747
|
+
}
|
|
748
|
+
if (opts.json) {
|
|
749
|
+
process.stdout.write(JSON.stringify(row, null, 2) + "\n");
|
|
750
|
+
} else {
|
|
751
|
+
process.stdout.write(`Cleared the stale mark on lesson ${row.id}.\n`);
|
|
752
|
+
}
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (sub !== "add") fail(usage, 2);
|
|
756
|
+
const dir = dirFrom(subrest, 0);
|
|
757
|
+
const sourceAgent = String(opts["source-agent"] !== undefined ? opts["source-agent"] : "lead").trim();
|
|
758
|
+
if (sourceAgent.toLowerCase() === "auto-memory") {
|
|
759
|
+
fail(
|
|
760
|
+
'--source-agent auto-memory is refused: "auto-memory" is the namespace memory-import.mjs uses to find ' +
|
|
761
|
+
'the row it may supersede (file name under the auto-memory dir + source_agent "auto-memory"); a ' +
|
|
762
|
+
"hand-authored lesson must not claim it. " +
|
|
763
|
+
'Use a different --source-agent (default "lead").',
|
|
764
|
+
2
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const scope = opts.scope
|
|
768
|
+
? String(opts.scope)
|
|
769
|
+
.split(",")
|
|
770
|
+
.map((s) => s.trim())
|
|
771
|
+
.filter(Boolean)
|
|
772
|
+
: [];
|
|
773
|
+
const { openMemory, addLesson, getLesson } = await import("../lib/memory.mjs");
|
|
774
|
+
const m = openMemory(dir);
|
|
775
|
+
let row;
|
|
776
|
+
try {
|
|
777
|
+
row = addLesson(m, {
|
|
778
|
+
text: opts.text,
|
|
779
|
+
kind: opts.kind,
|
|
780
|
+
scope,
|
|
781
|
+
citation: textOrNull(opts.citation),
|
|
782
|
+
source: { agent: sourceAgent, model: textOrNull(opts["source-model"]) },
|
|
783
|
+
card: textOrNull(opts.card),
|
|
784
|
+
commit: textOrNull(opts.commit),
|
|
785
|
+
});
|
|
786
|
+
await bestEffortEmbed(m, dir, [row.id]);
|
|
787
|
+
row = getLesson(m, row.id);
|
|
788
|
+
} finally {
|
|
789
|
+
m.close();
|
|
790
|
+
}
|
|
791
|
+
if (opts.json) {
|
|
792
|
+
process.stdout.write(JSON.stringify(row, null, 2) + "\n");
|
|
793
|
+
} else {
|
|
794
|
+
process.stdout.write(`Added lesson ${row.id} (${row.kind}) in .doug/.state/memory/memory.db.\n`);
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case "import": {
|
|
799
|
+
const dir = dirFrom(rest, 0);
|
|
800
|
+
const claudeDir = opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined;
|
|
801
|
+
const { openMemory } = await import("../lib/memory.mjs");
|
|
802
|
+
const { importAutoMemory } = await import("../lib/memory-import.mjs");
|
|
803
|
+
const m = openMemory(dir);
|
|
804
|
+
let result;
|
|
805
|
+
try {
|
|
806
|
+
result = importAutoMemory(m, { projectDir: dir, claudeDir });
|
|
807
|
+
const newIds = result.files.filter((f) => f.action === "added" || f.action === "updated").map((f) => f.id);
|
|
808
|
+
await bestEffortEmbed(m, dir, newIds);
|
|
809
|
+
} finally {
|
|
810
|
+
m.close();
|
|
811
|
+
}
|
|
812
|
+
if (!result.found) {
|
|
813
|
+
process.stdout.write(`no auto memory at ${result.dir}\n`);
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
if (opts.json) {
|
|
817
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
818
|
+
} else {
|
|
819
|
+
process.stdout.write(
|
|
820
|
+
`Imported ${result.added + result.updated} lesson(s) from ${result.dir}: ${result.added} added, ${result.updated} updated (old row superseded), ${result.unchanged} unchanged, ${result.skipped} skipped (empty).\n`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case "recall": {
|
|
826
|
+
const usage = "usage: memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]";
|
|
827
|
+
const [query, ...extra] = rest;
|
|
828
|
+
if (!query) fail(usage, 2);
|
|
829
|
+
const dir = dirFrom(extra, 0);
|
|
830
|
+
const files = opts.files
|
|
831
|
+
? String(opts.files)
|
|
832
|
+
.split(",")
|
|
833
|
+
.map((s) => s.trim())
|
|
834
|
+
.filter(Boolean)
|
|
835
|
+
: [];
|
|
836
|
+
let k;
|
|
837
|
+
if (opts.k !== undefined) {
|
|
838
|
+
k = Number(opts.k);
|
|
839
|
+
if (!Number.isInteger(k) || k < 1) {
|
|
840
|
+
fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
844
|
+
const staleDays = readMemoryStaleDays(dir);
|
|
845
|
+
const { openMemory, recallLessons } = await import("../lib/memory.mjs");
|
|
846
|
+
const m = openMemory(dir);
|
|
847
|
+
let result;
|
|
848
|
+
try {
|
|
849
|
+
result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays });
|
|
850
|
+
} finally {
|
|
851
|
+
m.close();
|
|
852
|
+
}
|
|
853
|
+
if (opts.json) {
|
|
854
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
855
|
+
} else {
|
|
856
|
+
const header =
|
|
857
|
+
result.mode === "hybrid"
|
|
858
|
+
? `recall: hybrid via ${result.provider} ${provider.model}/${provider.dims}`
|
|
859
|
+
: `recall: keyword-only (${result.reason})`;
|
|
860
|
+
const lines = [header, ...result.lessons.map((l) => `${l.id} ${l.score.toFixed(4)} ${l.kind} ${l.text}`)];
|
|
861
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
862
|
+
}
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case "doctor": {
|
|
866
|
+
const dir = dirFrom(rest, 0);
|
|
867
|
+
const cfg = readMemoryConfig(dir);
|
|
868
|
+
const probe = await probeProvider(cfg);
|
|
869
|
+
if (opts.json) {
|
|
870
|
+
process.stdout.write(JSON.stringify({ embeddings: cfg.embeddings, probe }, null, 2) + "\n");
|
|
871
|
+
} else {
|
|
872
|
+
process.stdout.write(doctorReport(cfg, probe) + "\n");
|
|
873
|
+
}
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case "embed-check": {
|
|
877
|
+
const usage = "usage: memory.mjs embed-check [dir] [--pairs <file>] [--json]";
|
|
878
|
+
// Validated before the provider check below (card memory-docs-drift #1): a usage error must exit 2 with
|
|
879
|
+
// no provider needed, never surface as the provider's own exit-1 "not configured" message.
|
|
880
|
+
for (const key of Object.keys(opts)) {
|
|
881
|
+
if (key !== "pairs" && key !== "json") fail(`${usage}\n--${key} is not a recognized flag.`, 2);
|
|
882
|
+
}
|
|
883
|
+
if (opts.pairs !== undefined && (opts.pairs === "" || String(opts.pairs).startsWith("--"))) {
|
|
884
|
+
fail(`${usage}\n--pairs requires a value.`, 2);
|
|
885
|
+
}
|
|
886
|
+
if (rest.length > 1) {
|
|
887
|
+
fail(`${usage}\nexpected at most one positional argument (dir), got ${rest.length}.`, 2);
|
|
888
|
+
}
|
|
889
|
+
const dir = dirFrom(rest, 0);
|
|
890
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
891
|
+
if (!provider) {
|
|
892
|
+
fail("no embedding provider configured (memory.embeddings in .doug/config.json is unset); nothing to check. Run `memory.mjs doctor` for how to enable one.", 1);
|
|
893
|
+
}
|
|
894
|
+
const pairs = loadPairsOrFail(opts);
|
|
895
|
+
const result = await checkEmbeddingSanity(provider, pairs);
|
|
896
|
+
if (opts.json) {
|
|
897
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
898
|
+
} else {
|
|
899
|
+
process.stdout.write(sanityLine(result) + "\n");
|
|
900
|
+
}
|
|
901
|
+
if (!result.ok) process.exit(1);
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
case "reembed": {
|
|
905
|
+
const usage = "usage: memory.mjs reembed [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]";
|
|
906
|
+
const dir = dirFrom(rest, 0);
|
|
907
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
908
|
+
if (!provider) {
|
|
909
|
+
fail("no embedding provider configured (memory.embeddings in .doug/config.json is unset); nothing to reembed. Run `memory.mjs doctor` for how to enable one.", 1);
|
|
910
|
+
}
|
|
911
|
+
let batch;
|
|
912
|
+
if (opts.batch !== undefined) {
|
|
913
|
+
batch = Number(opts.batch);
|
|
914
|
+
if (!Number.isInteger(batch) || batch < 1) {
|
|
915
|
+
fail(`${usage}\n--batch must be an integer >= 1, got ${JSON.stringify(opts.batch)}.`, 2);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (opts.check && opts["no-check"]) {
|
|
919
|
+
fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
|
|
920
|
+
}
|
|
921
|
+
const { openMemory, embedLessons } = await import("../lib/memory.mjs");
|
|
922
|
+
const m = openMemory(dir);
|
|
923
|
+
let result;
|
|
924
|
+
let checkStatus;
|
|
925
|
+
try {
|
|
926
|
+
if (opts["no-check"]) {
|
|
927
|
+
process.stderr.write("embed-check: skipped (--no-check)\n");
|
|
928
|
+
checkStatus = "skipped";
|
|
929
|
+
} else if (opts.check || embeddingChangeDetected(m, provider)) {
|
|
930
|
+
const pairs = loadPairsOrFail(opts);
|
|
931
|
+
const check = await checkEmbeddingSanity(provider, pairs);
|
|
932
|
+
if (!check.ok) {
|
|
933
|
+
process.stdout.write(sanityLine(check) + "\n");
|
|
934
|
+
throw new Error(
|
|
935
|
+
"embed-check failed; refusing to reembed. Pass --no-check to skip (see docs/memory.md), or --pairs to point at a different pairs file."
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
checkStatus = "ok";
|
|
939
|
+
} else {
|
|
940
|
+
checkStatus = "not-needed";
|
|
941
|
+
}
|
|
942
|
+
result = await embedLessons(m, provider, {
|
|
943
|
+
batch,
|
|
944
|
+
onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
|
|
945
|
+
});
|
|
946
|
+
} finally {
|
|
947
|
+
m.close();
|
|
948
|
+
}
|
|
949
|
+
if (opts.json) {
|
|
950
|
+
process.stdout.write(JSON.stringify({ ...result, check: checkStatus }, null, 2) + "\n");
|
|
951
|
+
} else {
|
|
952
|
+
process.stdout.write(
|
|
953
|
+
`Embedded ${result.embedded}/${result.total} lesson(s) with ${provider.model}/${provider.dims} (${result.failed} failed).\n`
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
case "index": {
|
|
959
|
+
const [sub, ...subrest] = rest;
|
|
960
|
+
if (sub === "build") {
|
|
961
|
+
const usage = "usage: memory.mjs index build [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]";
|
|
962
|
+
if (opts.check && opts["no-check"]) {
|
|
963
|
+
fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
|
|
964
|
+
}
|
|
965
|
+
let batch;
|
|
966
|
+
if (opts.batch !== undefined) {
|
|
967
|
+
batch = Number(opts.batch);
|
|
968
|
+
if (!Number.isInteger(batch) || batch < 1) {
|
|
969
|
+
fail(`${usage}\n--batch must be an integer >= 1, got ${JSON.stringify(opts.batch)}.`, 2);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const dir = dirFrom(subrest, 0);
|
|
973
|
+
const cfg = readIndexConfig(dir);
|
|
974
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
975
|
+
// Tri-state: false skips the sanity gate entirely (--no-check); "force" runs it on this build regardless
|
|
976
|
+
// of whether the model/dims pair is new (--check); true (default) leaves buildIndex's own auto-detect in
|
|
977
|
+
// charge, the same "new to this store" gate reembed applies to lessons.
|
|
978
|
+
const check = opts["no-check"] ? false : opts.check ? "force" : true;
|
|
979
|
+
const pairs = check === false ? null : loadIndexPairs(opts);
|
|
980
|
+
const { openMemory } = await import("../lib/memory.mjs");
|
|
981
|
+
const { buildIndex } = await import("../lib/code-index.mjs");
|
|
982
|
+
const m = openMemory(dir);
|
|
983
|
+
let result;
|
|
984
|
+
try {
|
|
985
|
+
result = await buildIndex(m, dir, {
|
|
986
|
+
cfg,
|
|
987
|
+
provider,
|
|
988
|
+
batch,
|
|
989
|
+
check,
|
|
990
|
+
pairs,
|
|
991
|
+
onProgress: ({ embedded, total }) => process.stderr.write(`indexed ${embedded}/${total}\n`),
|
|
992
|
+
});
|
|
993
|
+
} finally {
|
|
994
|
+
m.close();
|
|
995
|
+
}
|
|
996
|
+
if (opts.json) {
|
|
997
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
998
|
+
} else {
|
|
999
|
+
const pending = result.chunksEmbedded + result.chunksFailed;
|
|
1000
|
+
const keywordOnly = !provider || result.check === "failed" || (pending > 0 && result.chunksEmbedded === 0);
|
|
1001
|
+
const base = `index: ${result.files} files, ${result.chunks} chunks (${result.filesChanged} changed, ${result.filesRemoved} removed)`;
|
|
1002
|
+
if (keywordOnly) {
|
|
1003
|
+
process.stdout.write(`${base}; keyword-only (${result.reason})\n`);
|
|
1004
|
+
} else {
|
|
1005
|
+
// review MINOR 2: print embedSeconds to one decimal, the same way `status` prints its own seconds.
|
|
1006
|
+
// review MINOR 3: a partial failure (some batches embedded, some failed) must never hide the
|
|
1007
|
+
// provider's reason - it stays out of `reason` only when nothing at all embedded (the keyword-only
|
|
1008
|
+
// branch above already reports it there).
|
|
1009
|
+
let line = `${base}; embedded ${result.chunksEmbedded} of ${pending} under ${provider.model}/${provider.dims} in ${result.embedSeconds.toFixed(1)}s`;
|
|
1010
|
+
if (result.chunksFailed > 0) line += `; ${result.chunksFailed} failed (${result.reason})`;
|
|
1011
|
+
process.stdout.write(line + "\n");
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
if (sub === "status") {
|
|
1017
|
+
const dir = dirFrom(subrest, 0);
|
|
1018
|
+
const cfg = readIndexConfig(dir);
|
|
1019
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
1020
|
+
const { openMemory } = await import("../lib/memory.mjs");
|
|
1021
|
+
const { indexStatus } = await import("../lib/code-index.mjs");
|
|
1022
|
+
const m = openMemory(dir);
|
|
1023
|
+
let result;
|
|
1024
|
+
try {
|
|
1025
|
+
result = indexStatus(m, dir, { cfg, provider });
|
|
1026
|
+
} finally {
|
|
1027
|
+
m.close();
|
|
1028
|
+
}
|
|
1029
|
+
if (opts.json) {
|
|
1030
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1031
|
+
} else {
|
|
1032
|
+
const lines = [
|
|
1033
|
+
`index: ${result.files} files, ${result.chunks} chunks, ${result.textBytes} bytes of text, ${result.dbBytes} bytes on disk`,
|
|
1034
|
+
provider
|
|
1035
|
+
? `embedded: ${result.embedded}/${result.chunks} under ${provider.model}/${provider.dims}`
|
|
1036
|
+
: "embedded: none (no provider configured)",
|
|
1037
|
+
result.built
|
|
1038
|
+
? `built: ${result.built.finished} at ${result.built.headSha ?? "unknown"} (${result.built.current ? "current" : "behind HEAD"})`
|
|
1039
|
+
: "built: never",
|
|
1040
|
+
];
|
|
1041
|
+
// review MINOR 1: use indexStatus's own carried `rate` rather than recomputing chunksToEmbed/seconds
|
|
1042
|
+
// (which is meaningless, and prints "about 0.0s at 0.0 chunks/s", when nothing is pending at all); with
|
|
1043
|
+
// nothing pending, print "0 chunks to embed" and skip the time clause entirely.
|
|
1044
|
+
const r = result.refresh;
|
|
1045
|
+
const prefix = `refresh: ${r.added} added, ${r.changed} changed, ${r.removed} removed; `;
|
|
1046
|
+
let refreshLine;
|
|
1047
|
+
if (r.chunksToEmbed === 0) {
|
|
1048
|
+
refreshLine = `${prefix}0 chunks to embed`;
|
|
1049
|
+
} else if (r.seconds !== null) {
|
|
1050
|
+
refreshLine = `${prefix}${r.chunksToEmbed} chunks to embed, about ${r.seconds.toFixed(1)}s at ${r.rate.toFixed(1)} chunks/s`;
|
|
1051
|
+
} else {
|
|
1052
|
+
refreshLine = `${prefix}${r.chunksToEmbed} chunks to embed, time unmeasured (${r.rateReason})`;
|
|
1053
|
+
}
|
|
1054
|
+
lines.push(refreshLine);
|
|
1055
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1056
|
+
}
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
if (sub === "search") {
|
|
1060
|
+
const usage = "usage: memory.mjs index search <query> [dir] [--files <glob,glob>] [--k <n>] [--json]";
|
|
1061
|
+
const [query, ...extra] = subrest;
|
|
1062
|
+
if (!query) fail(usage, 2);
|
|
1063
|
+
const dir = dirFrom(extra, 0);
|
|
1064
|
+
const files = opts.files
|
|
1065
|
+
? String(opts.files)
|
|
1066
|
+
.split(",")
|
|
1067
|
+
.map((s) => s.trim())
|
|
1068
|
+
.filter(Boolean)
|
|
1069
|
+
: [];
|
|
1070
|
+
let k;
|
|
1071
|
+
if (opts.k !== undefined) {
|
|
1072
|
+
k = Number(opts.k);
|
|
1073
|
+
if (!Number.isInteger(k) || k < 1) {
|
|
1074
|
+
fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
1078
|
+
const { openMemory } = await import("../lib/memory.mjs");
|
|
1079
|
+
const { searchIndex } = await import("../lib/code-index.mjs");
|
|
1080
|
+
const m = openMemory(dir);
|
|
1081
|
+
let result;
|
|
1082
|
+
try {
|
|
1083
|
+
result = await searchIndex(m, query, { k, files, provider });
|
|
1084
|
+
} finally {
|
|
1085
|
+
m.close();
|
|
1086
|
+
}
|
|
1087
|
+
if (opts.json) {
|
|
1088
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1089
|
+
} else {
|
|
1090
|
+
const header =
|
|
1091
|
+
result.mode === "hybrid"
|
|
1092
|
+
? `index: hybrid via ${result.provider} ${provider.model}/${provider.dims}`
|
|
1093
|
+
: `index: keyword-only (${result.reason})`;
|
|
1094
|
+
const lines = [
|
|
1095
|
+
header,
|
|
1096
|
+
...result.chunks.map((c) => {
|
|
1097
|
+
const firstLine = c.text.split("\n").find((l) => l.trim()) || "";
|
|
1098
|
+
const snippet = firstLine.trim().slice(0, 80);
|
|
1099
|
+
return `${c.path}:${c.startLine}-${c.endLine} ${c.score.toFixed(4)} ${snippet}`;
|
|
1100
|
+
}),
|
|
1101
|
+
];
|
|
1102
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
1103
|
+
}
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
fail("usage: memory.mjs index <build|status|search> ...", 2);
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
case "decision": {
|
|
1110
|
+
const usage =
|
|
1111
|
+
"usage: memory.mjs decision propose --title <text> --file <path> [--date YYYY-MM-DD] [dir] [--json]\n" +
|
|
1112
|
+
" memory.mjs decision amend <NNNN> (--text <text> | --file <path>) [--date YYYY-MM-DD] [dir] [--json]";
|
|
1113
|
+
const [sub, ...subrest] = rest;
|
|
1114
|
+
const { proposeDecision, proposeAmendment } = await import("../lib/decisions.mjs");
|
|
1115
|
+
if (sub === "propose") {
|
|
1116
|
+
if (!opts.title || !opts.file) fail(usage, 2);
|
|
1117
|
+
const dir = dirFrom(subrest, 0);
|
|
1118
|
+
const result = proposeDecision({ dir, title: opts.title, body: readTextArg(opts.file), date: textOrNull(opts.date) || today() });
|
|
1119
|
+
printProposalResult(result, opts.json);
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
if (sub === "amend") {
|
|
1123
|
+
const [id, ...idRest] = subrest;
|
|
1124
|
+
if (!id || (!opts.text && !opts.file)) fail(usage, 2);
|
|
1125
|
+
const dir = dirFrom(idRest, 0);
|
|
1126
|
+
const text = opts.file ? readTextArg(opts.file) : opts.text;
|
|
1127
|
+
const result = proposeAmendment({ dir, id, text, date: textOrNull(opts.date) || today() });
|
|
1128
|
+
printProposalResult(result, opts.json);
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
fail(usage, 2);
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
case "rule": {
|
|
1135
|
+
const usage = "usage: memory.mjs rule propose <name> --file <path> [--paths <glob,glob>] [dir] [--json]";
|
|
1136
|
+
const [sub, ...subrest] = rest;
|
|
1137
|
+
if (sub !== "propose") fail(usage, 2);
|
|
1138
|
+
const [name, ...nameRest] = subrest;
|
|
1139
|
+
if (!name || !opts.file) fail(usage, 2);
|
|
1140
|
+
const dir = dirFrom(nameRest, 0);
|
|
1141
|
+
const paths = opts.paths
|
|
1142
|
+
? String(opts.paths)
|
|
1143
|
+
.split(",")
|
|
1144
|
+
.map((s) => s.trim())
|
|
1145
|
+
.filter(Boolean)
|
|
1146
|
+
: [];
|
|
1147
|
+
const { proposeRule } = await import("../lib/decisions.mjs");
|
|
1148
|
+
const result = proposeRule({ dir, name, paths, body: readTextArg(opts.file) });
|
|
1149
|
+
printProposalResult(result, opts.json);
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
case "condition": {
|
|
1153
|
+
const usage = "usage: memory.mjs condition open <card-id> [dir] [--json]\n memory.mjs condition backfill [dir] [--json]";
|
|
1154
|
+
const [sub, ...subrest] = rest;
|
|
1155
|
+
if (sub === "open") {
|
|
1156
|
+
const [id, ...idRest] = subrest;
|
|
1157
|
+
if (!id) fail(usage, 2);
|
|
1158
|
+
const dir = dirFrom(idRest, 0);
|
|
1159
|
+
let card;
|
|
1160
|
+
try {
|
|
1161
|
+
const board = loadBoard(dir);
|
|
1162
|
+
card = findCard(board, id);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
fail(`cannot open condition for "${id}": ${err.message}`);
|
|
1165
|
+
}
|
|
1166
|
+
if (!card.class) {
|
|
1167
|
+
fail(`cannot open condition for "${id}": the card has no class; give it one on the board first.`);
|
|
1168
|
+
}
|
|
1169
|
+
const relFile = `.doug/.state/reports/${id}/condition.json`;
|
|
1170
|
+
const file = join(dir, relFile);
|
|
1171
|
+
if (existsSync(file)) {
|
|
1172
|
+
fail(`${relFile} already exists; refusing to overwrite. Remove it first if you mean to reopen this landing's condition.`);
|
|
1173
|
+
}
|
|
1174
|
+
const headRes = spawnSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" });
|
|
1175
|
+
if (headRes.status !== 0 || !headRes.stdout.trim()) {
|
|
1176
|
+
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)"}.`);
|
|
1177
|
+
}
|
|
1178
|
+
const { conditionConfigHash } = await import("../lib/memory.mjs");
|
|
1179
|
+
const written = {
|
|
1180
|
+
harnessCommit: headRes.stdout.trim(),
|
|
1181
|
+
configHash: conditionConfigHash(dir),
|
|
1182
|
+
class: card.class,
|
|
1183
|
+
arm: `${card.class}/default`,
|
|
1184
|
+
assignedBy: "policy",
|
|
1185
|
+
exploreProbability: null,
|
|
1186
|
+
openedAt: new Date().toISOString(),
|
|
1187
|
+
};
|
|
1188
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1189
|
+
writeFileSync(file, JSON.stringify(written, null, 2) + "\n");
|
|
1190
|
+
if (opts.json) {
|
|
1191
|
+
process.stdout.write(JSON.stringify(written, null, 2) + "\n");
|
|
1192
|
+
} else {
|
|
1193
|
+
process.stdout.write(`Wrote ${relFile}\n`);
|
|
1194
|
+
}
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
if (sub === "backfill") {
|
|
1198
|
+
const dir = dirFrom(subrest, 0);
|
|
1199
|
+
let board = null;
|
|
1200
|
+
try {
|
|
1201
|
+
board = loadBoard(dir);
|
|
1202
|
+
} catch {
|
|
1203
|
+
board = null;
|
|
1204
|
+
}
|
|
1205
|
+
// `main` must resolve before the per-row loop: without this, a missing repo or a missing `main` ref
|
|
1206
|
+
// makes every `git log` call below fail the same way an honest "no commit at or before this row's
|
|
1207
|
+
// timestamp" does (empty stdout), so every row would be silently, wrongly left uncounted with no word
|
|
1208
|
+
// said about why. Checked once, not once per row.
|
|
1209
|
+
const mainRes = spawnSync("git", ["rev-parse", "--verify", "--quiet", "main^{commit}"], { cwd: dir, encoding: "utf8" });
|
|
1210
|
+
const hasMain = mainRes.status === 0;
|
|
1211
|
+
if (!hasMain) {
|
|
1212
|
+
process.stderr.write(`no "main" ref in ${dir}; nothing to backfill against.\n`);
|
|
1213
|
+
}
|
|
1214
|
+
const { openMemory } = await import("../lib/memory.mjs");
|
|
1215
|
+
const m = openMemory(dir);
|
|
1216
|
+
let backfilled = 0;
|
|
1217
|
+
try {
|
|
1218
|
+
if (hasMain) {
|
|
1219
|
+
const rows = m.prepare("SELECT id, recorded, card FROM outcomes WHERE harness_commit IS NULL").all();
|
|
1220
|
+
for (const row of rows) {
|
|
1221
|
+
const gitRes = spawnSync(
|
|
1222
|
+
"git",
|
|
1223
|
+
["log", "-1", "--first-parent", "--format=%H", `--before=${row.recorded}`, "main", "--"],
|
|
1224
|
+
{ cwd: dir, encoding: "utf8" }
|
|
1225
|
+
);
|
|
1226
|
+
const sha = gitRes.status === 0 ? gitRes.stdout.trim() : "";
|
|
1227
|
+
if (!sha) continue;
|
|
1228
|
+
let cardClass = null;
|
|
1229
|
+
if (board && row.card) {
|
|
1230
|
+
try {
|
|
1231
|
+
cardClass = findCard(board, row.card).class ?? null;
|
|
1232
|
+
} catch {
|
|
1233
|
+
cardClass = null;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
m.prepare("UPDATE outcomes SET harness_commit = ?, backfilled = 1, class = COALESCE(?, class) WHERE id = ?").run(sha, cardClass, row.id);
|
|
1237
|
+
backfilled++;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
} finally {
|
|
1241
|
+
m.close();
|
|
1242
|
+
}
|
|
1243
|
+
if (opts.json) {
|
|
1244
|
+
process.stdout.write(JSON.stringify({ backfilled }, null, 2) + "\n");
|
|
1245
|
+
} else {
|
|
1246
|
+
process.stdout.write(`Backfilled ${backfilled} outcomes rows with a harness commit.\n`);
|
|
1247
|
+
}
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
fail(usage, 2);
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
case "claude-md": {
|
|
1254
|
+
const usage = "usage: memory.mjs claude-md propose --file <path|-> [dir] [--json]";
|
|
1255
|
+
const [sub, ...subrest] = rest;
|
|
1256
|
+
if (sub !== "propose" || !opts.file) fail(usage, 2);
|
|
1257
|
+
const dir = dirFrom(subrest, 0);
|
|
1258
|
+
let edits;
|
|
1259
|
+
try {
|
|
1260
|
+
edits = JSON.parse(readTextArg(opts.file));
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
fail(`${opts.file} is not valid JSON: ${err.message}`, 1);
|
|
1263
|
+
}
|
|
1264
|
+
const { proposeClaudeMd } = await import("../lib/decisions.mjs");
|
|
1265
|
+
const result = proposeClaudeMd({ dir, edits });
|
|
1266
|
+
printProposalResult(result, opts.json);
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
default:
|
|
1270
|
+
fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
|
|
1271
|
+
}
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
fail(err.message);
|
|
1274
|
+
}
|