@dougharness/cli 1.0.2 → 1.1.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/dist/bin.js +18 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +14 -7
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +42 -9
- package/dist/board.js.map +1 -1
- package/dist/detect/repo.js +14 -1
- package/dist/detect/repo.js.map +1 -1
- package/dist/detect/types.d.ts +2 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +491 -0
- package/dist/doctor.js.map +1 -0
- package/dist/generate/agents.d.ts +7 -0
- package/dist/generate/agents.js +62 -12
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +4 -0
- package/dist/generate/claude-md.js.map +1 -1
- package/dist/generate/config.d.ts +8 -0
- package/dist/generate/config.js +4 -1
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +15 -6
- package/dist/generate/proposal.js.map +1 -1
- package/dist/generate/settings.d.ts +2 -0
- package/dist/generate/settings.js +17 -2
- package/dist/generate/settings.js.map +1 -1
- package/dist/init.d.ts +14 -0
- package/dist/init.js +94 -8
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +28 -0
- package/dist/memory-setup.js +326 -0
- package/dist/memory-setup.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +3 -3
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
- package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
- package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
- package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
- package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
- package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
- package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
- package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
- package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
- package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
- package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
- package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
- package/package.json +4 -4
- package/templates/board-app.js +4 -3
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
|
@@ -60,20 +60,33 @@
|
|
|
60
60
|
// memory.mjs lesson unstale <id> [dir] [--json]
|
|
61
61
|
// clear a lesson's stale mark (set by recall's citation
|
|
62
62
|
// re-check) so it is a candidate again; refuses on an unknown id
|
|
63
|
+
// memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]
|
|
64
|
+
// (card memory-every-session) recall for a hand-track card's
|
|
65
|
+
// briefs: query = the card's title and goal, k 8. Prints the
|
|
66
|
+
// workflow's lessons header, the rendered `- [id] ...` lines
|
|
67
|
+
// (capped at 2000 chars like plan.mjs), then `memoryUsed: <ids>`;
|
|
68
|
+
// writes .doug/.state/reports/<card-id>/lessons.json. No database
|
|
69
|
+
// -> "No lessons recalled for <id>." (database not created);
|
|
70
|
+
// a database or provider error -> one stderr line, exit 0;
|
|
71
|
+
// unknown card -> exit 1
|
|
63
72
|
// memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
73
|
+
// dense recall (card memory-recall): with a provider and
|
|
74
|
+
// stored vectors, every live lesson is ranked by cosine x
|
|
75
|
+
// recency weight x scope boost (--files), ties by lesson id,
|
|
76
|
+
// then BM25 hits with no vector are appended in BM25 order;
|
|
77
|
+
// without a provider or vectors the ranking is BM25 x recency
|
|
78
|
+
// x scope, a keyword-only fallback that says so. Each returned
|
|
79
|
+
// lesson's citation is re-checked against [dir] only when
|
|
80
|
+
// [dir] is a real project root (contains .doug)
|
|
71
81
|
// memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
|
|
72
|
-
// [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
|
|
82
|
+
// [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>]
|
|
73
83
|
// [--model <name>] [--max-usd <n>] [--dry-run] [--json]
|
|
74
84
|
// runs after record: applies deterministic helpful/harmful
|
|
75
85
|
// counters to lessons named in each task's memoryUsed (committed
|
|
76
|
-
// even if the LLM pass below fails
|
|
86
|
+
// even if the LLM pass below fails; a real block alone bumps no
|
|
87
|
+
// harmful: each --harmful <id>, a recalled lesson that misled the
|
|
88
|
+
// work, gets harmful +1 and no helpful; an id outside every
|
|
89
|
+
// task's memoryUsed exits 1 before any write), then a cheap-row `claude -p
|
|
77
90
|
// --json-schema` pass proposes at most 3 lessons from the report,
|
|
78
91
|
// filtered in code (a pitfall needs a block classed real, a
|
|
79
92
|
// pattern needs a first-pass verified task, the citation must be
|
|
@@ -82,10 +95,17 @@
|
|
|
82
95
|
// helpful instead of appending); idempotent on the report's
|
|
83
96
|
// sha256 (a second reflect on the same report is a no-op, exit
|
|
84
97
|
// 0); --dry-run applies nothing but still runs the LLM pass and
|
|
85
|
-
// prints what would happen
|
|
86
|
-
// landing has no report. Cost is measured from the same local
|
|
98
|
+
// prints what would happen. Cost is measured from the same local
|
|
87
99
|
// transcripts cost.mjs reads, falling back to the CLI's own
|
|
88
100
|
// cost estimate. Needs Node >=22.16 (node:sqlite).
|
|
101
|
+
// memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<class>[: <reason>]"]...
|
|
102
|
+
// [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
|
|
103
|
+
// (card memory-every-session) the same pass for a hand landing:
|
|
104
|
+
// assembles .doug/.state/reports/<card-id>/hand-report.json from
|
|
105
|
+
// the saved briefs/, reviews/ and lessons.json (memoryUsed = its
|
|
106
|
+
// ids; verified true; passes = --rounds, default 1; one fixed
|
|
107
|
+
// blocker ledger entry per --review id), then reflects on it
|
|
108
|
+
// exactly as above. --commit is required (exit 2). No schema change.
|
|
89
109
|
// memory.mjs doctor [dir] [--json]
|
|
90
110
|
// probe the configured embedding provider (memory.embeddings in
|
|
91
111
|
// .doug/config.json), or a local Ollama at localhost:11434 when
|
|
@@ -165,7 +185,7 @@
|
|
|
165
185
|
// lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
|
|
166
186
|
// provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
|
|
167
187
|
// row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
|
|
168
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
188
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
|
169
189
|
import { resolve, join, dirname } from "node:path";
|
|
170
190
|
import { createHash } from "node:crypto";
|
|
171
191
|
import { spawnSync } from "node:child_process";
|
|
@@ -187,7 +207,7 @@ import {
|
|
|
187
207
|
|
|
188
208
|
const argv = process.argv.slice(2);
|
|
189
209
|
const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
|
|
190
|
-
const REPEATABLE = new Set(["adversary"]);
|
|
210
|
+
const REPEATABLE = new Set(["adversary", "review", "harmful"]);
|
|
191
211
|
const opts = {};
|
|
192
212
|
const positional = [];
|
|
193
213
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -595,27 +615,89 @@ try {
|
|
|
595
615
|
}
|
|
596
616
|
case "reflect": {
|
|
597
617
|
const usage =
|
|
598
|
-
'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]';
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (!reportFile) fail(usage, 2);
|
|
602
|
-
const dir = dirFrom(rest, 1);
|
|
603
|
-
const reportPath = resolve(reportFile);
|
|
604
|
-
if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
|
|
618
|
+
'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]';
|
|
619
|
+
let dir;
|
|
620
|
+
let reportPath;
|
|
605
621
|
let reportText;
|
|
606
|
-
try {
|
|
607
|
-
reportText = readFileSync(reportPath, "utf8");
|
|
608
|
-
} catch (err) {
|
|
609
|
-
fail(`cannot read report file ${reportPath}: ${err.message}`);
|
|
610
|
-
}
|
|
611
622
|
let report;
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
623
|
+
let classes;
|
|
624
|
+
let handCard = null;
|
|
625
|
+
if (opts.hand) {
|
|
626
|
+
const handUsage =
|
|
627
|
+
'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
|
|
628
|
+
const [id] = rest;
|
|
629
|
+
if (!id || !opts.commit) fail(handUsage, 2);
|
|
630
|
+
handCard = id;
|
|
631
|
+
dir = dirFrom(rest, 1);
|
|
632
|
+
let rounds = 1;
|
|
633
|
+
if (opts.rounds !== undefined) {
|
|
634
|
+
rounds = Number(opts.rounds);
|
|
635
|
+
if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
classes = parseAdversaryClasses(opts.review || []);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
fail(`${handUsage}\n${err.message}`, 2);
|
|
641
|
+
}
|
|
642
|
+
const base = join(dir, ".doug", ".state", "reports", id);
|
|
643
|
+
const readDirFiles = (sub) => {
|
|
644
|
+
const d = join(base, sub);
|
|
645
|
+
if (!existsSync(d)) return [];
|
|
646
|
+
return readdirSync(d)
|
|
647
|
+
.filter((n) => !n.startsWith("."))
|
|
648
|
+
.sort()
|
|
649
|
+
.map((name) => {
|
|
650
|
+
try {
|
|
651
|
+
return { name, text: readFileSync(join(d, name), "utf8") };
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
.filter(Boolean);
|
|
657
|
+
};
|
|
658
|
+
let memoryUsed = [];
|
|
659
|
+
try {
|
|
660
|
+
const saved = JSON.parse(readFileSync(join(base, "lessons.json"), "utf8"));
|
|
661
|
+
if (saved && Array.isArray(saved.ids)) memoryUsed = saved.ids.filter((x) => typeof x === "string");
|
|
662
|
+
} catch {
|
|
663
|
+
memoryUsed = [];
|
|
664
|
+
}
|
|
665
|
+
const ledger = classes.map((c) => {
|
|
666
|
+
return { id: c.id, stage: "adversary", seat: "reviewer", severity: "blocker", status: "fixed", description: c.reason || `reviewer finding ${c.id}` };
|
|
667
|
+
});
|
|
668
|
+
const sortKeys = (v) =>
|
|
669
|
+
Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
|
|
670
|
+
const handReport = sortKeys({
|
|
671
|
+
track: "hand",
|
|
672
|
+
card: id,
|
|
673
|
+
commit: opts.commit,
|
|
674
|
+
levels: [{ index: 0, tasks: [{ id, card: id, commit: opts.commit, verified: true, passes: rounds, stopReason: null, memoryUsed, ledger }] }],
|
|
675
|
+
hand: { briefs: readDirFiles("briefs"), reviews: readDirFiles("reviews"), note: textOrNull(opts.note) },
|
|
676
|
+
});
|
|
677
|
+
reportText = JSON.stringify(handReport, null, 2) + "\n";
|
|
678
|
+
reportPath = join(base, "hand-report.json");
|
|
679
|
+
mkdirSync(base, { recursive: true });
|
|
680
|
+
writeFileSync(reportPath, reportText, "utf8");
|
|
681
|
+
report = handReport;
|
|
682
|
+
} else {
|
|
683
|
+
const [reportFile] = rest;
|
|
684
|
+
if (!reportFile) fail(usage, 2);
|
|
685
|
+
dir = dirFrom(rest, 1);
|
|
686
|
+
reportPath = resolve(reportFile);
|
|
687
|
+
if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
|
|
688
|
+
try {
|
|
689
|
+
reportText = readFileSync(reportPath, "utf8");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
fail(`cannot read report file ${reportPath}: ${err.message}`);
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
report = unwrapReport(JSON.parse(reportText));
|
|
695
|
+
} catch (err) {
|
|
696
|
+
fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
|
|
697
|
+
}
|
|
698
|
+
classes = parseAdversaryClasses(opts.adversary || []);
|
|
616
699
|
}
|
|
617
700
|
const reportHash = createHash("sha256").update(reportText).digest("hex");
|
|
618
|
-
const classes = parseAdversaryClasses(opts.adversary || []);
|
|
619
701
|
const plan = loadPlan(dir);
|
|
620
702
|
const models = loadModels(dir);
|
|
621
703
|
const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
|
|
@@ -640,11 +722,12 @@ try {
|
|
|
640
722
|
classes,
|
|
641
723
|
plan,
|
|
642
724
|
run: opts.run ?? null,
|
|
643
|
-
card: opts.card ?? null,
|
|
725
|
+
card: handCard ?? opts.card ?? null,
|
|
644
726
|
commit: opts.commit ?? null,
|
|
645
727
|
model,
|
|
646
728
|
claudeDir,
|
|
647
729
|
dryRun,
|
|
730
|
+
harmful: opts.harmful || [],
|
|
648
731
|
propose,
|
|
649
732
|
});
|
|
650
733
|
if (!dryRun && result.ok && result.appended && result.appended.length) {
|
|
@@ -843,14 +926,18 @@ try {
|
|
|
843
926
|
}
|
|
844
927
|
const provider = createProvider(readMemoryConfig(dir));
|
|
845
928
|
const staleDays = readMemoryStaleDays(dir);
|
|
846
|
-
const { openMemory, recallLessons } = await import("../lib/memory.mjs");
|
|
929
|
+
const { openMemory, recallLessons, degradedWarning, detectLocalProvider } = await import("../lib/memory.mjs");
|
|
930
|
+
// Probed once, and only when no provider is configured; a failed probe is just "not detected".
|
|
931
|
+
const detectedProvider = provider ? null : await detectLocalProvider();
|
|
847
932
|
const m = openMemory(dir);
|
|
848
933
|
let result;
|
|
849
934
|
try {
|
|
850
|
-
result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays });
|
|
935
|
+
result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays, detectedProvider });
|
|
851
936
|
} finally {
|
|
852
937
|
m.close();
|
|
853
938
|
}
|
|
939
|
+
const recallWarning = degradedWarning(result, { what: "memory recall", detectedProvider });
|
|
940
|
+
if (recallWarning) process.stderr.write(recallWarning + "\n");
|
|
854
941
|
if (opts.json) {
|
|
855
942
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
856
943
|
} else {
|
|
@@ -863,6 +950,67 @@ try {
|
|
|
863
950
|
}
|
|
864
951
|
break;
|
|
865
952
|
}
|
|
953
|
+
case "brief-lessons": {
|
|
954
|
+
const usage = "usage: memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]";
|
|
955
|
+
const [id, ...extra] = rest;
|
|
956
|
+
if (!id) fail(usage, 2);
|
|
957
|
+
const dir = dirFrom(extra, 0);
|
|
958
|
+
let k = 8;
|
|
959
|
+
if (opts.k !== undefined) {
|
|
960
|
+
k = Number(opts.k);
|
|
961
|
+
if (!Number.isInteger(k) || k < 1) fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
|
|
962
|
+
}
|
|
963
|
+
let card;
|
|
964
|
+
try {
|
|
965
|
+
card = findCard(loadBoard(dir), id);
|
|
966
|
+
} catch (err) {
|
|
967
|
+
fail(`no card ${id}: ${err.message}`);
|
|
968
|
+
}
|
|
969
|
+
const query = `${card.title}\n${card.goal ?? ""}`;
|
|
970
|
+
const header = "Lessons from earlier runs on this repository (memory; each cites where it was learned):";
|
|
971
|
+
const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, recalledAt: new Date().toISOString() };
|
|
972
|
+
let error = null;
|
|
973
|
+
if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
|
|
974
|
+
try {
|
|
975
|
+
const provider = createProvider(readMemoryConfig(dir));
|
|
976
|
+
const staleDays = readMemoryStaleDays(dir);
|
|
977
|
+
const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider, renderLessons } = await import("../lib/memory.mjs");
|
|
978
|
+
const detectedProvider = provider ? null : await detectLocalProvider();
|
|
979
|
+
const m = openMemory(dir);
|
|
980
|
+
try {
|
|
981
|
+
const result = await recallLessons(m, query, { k, checkoutDir: dir, provider, staleDays, detectedProvider });
|
|
982
|
+
const warning = degradedWarning(result, { what: "memory recall", detectedProvider });
|
|
983
|
+
if (warning) process.stderr.write(warning + "\n");
|
|
984
|
+
const rendered = renderLessons(result.lessons);
|
|
985
|
+
saved.mode = result.mode;
|
|
986
|
+
saved.reason = result.degraded ? degradedReason(result, detectedProvider) : null;
|
|
987
|
+
saved.ids = rendered.ids;
|
|
988
|
+
saved.text = rendered.text === null ? null : result.degraded ? `(recall was keyword-only: ${saved.reason})\n${rendered.text}` : rendered.text;
|
|
989
|
+
} finally {
|
|
990
|
+
m.close();
|
|
991
|
+
}
|
|
992
|
+
} catch (err) {
|
|
993
|
+
error = err.message;
|
|
994
|
+
saved.mode = null;
|
|
995
|
+
saved.reason = null;
|
|
996
|
+
saved.ids = [];
|
|
997
|
+
saved.text = null;
|
|
998
|
+
saved.error = error;
|
|
999
|
+
process.stderr.write(`[doug] memory recall skipped: ${error}\n`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const reportDir = join(dir, ".doug", ".state", "reports", id);
|
|
1003
|
+
mkdirSync(reportDir, { recursive: true });
|
|
1004
|
+
writeFileSync(join(reportDir, "lessons.json"), JSON.stringify(saved, null, 2) + "\n", "utf8");
|
|
1005
|
+
if (opts.json) {
|
|
1006
|
+
process.stdout.write(JSON.stringify(saved, null, 2) + "\n");
|
|
1007
|
+
} else if (saved.ids.length) {
|
|
1008
|
+
process.stdout.write([header, saved.text, `memoryUsed: ${saved.ids.join(", ")}`].join("\n") + "\n");
|
|
1009
|
+
} else {
|
|
1010
|
+
process.stdout.write(`No lessons recalled for ${id}.\n`);
|
|
1011
|
+
}
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
866
1014
|
case "doctor": {
|
|
867
1015
|
const dir = dirFrom(rest, 0);
|
|
868
1016
|
const cfg = readMemoryConfig(dir);
|
|
@@ -919,10 +1067,12 @@ try {
|
|
|
919
1067
|
if (opts.check && opts["no-check"]) {
|
|
920
1068
|
fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
|
|
921
1069
|
}
|
|
922
|
-
const { openMemory, embedLessons } = await import("../lib/memory.mjs");
|
|
1070
|
+
const { openMemory, embedLessons, lessonsNeedingEmbedding } = await import("../lib/memory.mjs");
|
|
923
1071
|
const m = openMemory(dir);
|
|
924
1072
|
let result;
|
|
925
1073
|
let checkStatus;
|
|
1074
|
+
let stillMissing = 0;
|
|
1075
|
+
let liveTotal = 0;
|
|
926
1076
|
try {
|
|
927
1077
|
if (opts["no-check"]) {
|
|
928
1078
|
process.stderr.write("embed-check: skipped (--no-check)\n");
|
|
@@ -944,6 +1094,8 @@ try {
|
|
|
944
1094
|
batch,
|
|
945
1095
|
onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
|
|
946
1096
|
});
|
|
1097
|
+
stillMissing = lessonsNeedingEmbedding(m, { model: provider.model, dims: provider.dims }).length;
|
|
1098
|
+
liveTotal = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
|
|
947
1099
|
} finally {
|
|
948
1100
|
m.close();
|
|
949
1101
|
}
|
|
@@ -954,6 +1106,11 @@ try {
|
|
|
954
1106
|
`Embedded ${result.embedded}/${result.total} lesson(s) with ${provider.model}/${provider.dims} (${result.failed} failed).\n`
|
|
955
1107
|
);
|
|
956
1108
|
}
|
|
1109
|
+
if (stillMissing > 0) {
|
|
1110
|
+
process.stderr.write(
|
|
1111
|
+
`WARNING: ${stillMissing} of ${liveTotal} lessons still have no vector for ${provider.model}/${provider.dims}; retry with memory.mjs reembed --batch 4\n`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
957
1114
|
break;
|
|
958
1115
|
}
|
|
959
1116
|
case "index": {
|
|
@@ -1076,15 +1233,18 @@ try {
|
|
|
1076
1233
|
}
|
|
1077
1234
|
}
|
|
1078
1235
|
const provider = createProvider(readMemoryConfig(dir));
|
|
1079
|
-
const { openMemory } = await import("../lib/memory.mjs");
|
|
1236
|
+
const { openMemory, degradedWarning, detectLocalProvider } = await import("../lib/memory.mjs");
|
|
1080
1237
|
const { searchIndex } = await import("../lib/code-index.mjs");
|
|
1238
|
+
const detectedProvider = provider ? null : await detectLocalProvider();
|
|
1081
1239
|
const m = openMemory(dir);
|
|
1082
1240
|
let result;
|
|
1083
1241
|
try {
|
|
1084
|
-
result = await searchIndex(m, query, { k, files, provider });
|
|
1242
|
+
result = await searchIndex(m, query, { k, files, provider, detectedProvider });
|
|
1085
1243
|
} finally {
|
|
1086
1244
|
m.close();
|
|
1087
1245
|
}
|
|
1246
|
+
const searchWarning = degradedWarning(result, { what: "code index search", code: true, detectedProvider });
|
|
1247
|
+
if (searchWarning) process.stderr.write(searchWarning + "\n");
|
|
1088
1248
|
if (opts.json) {
|
|
1089
1249
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1090
1250
|
} else {
|
|
@@ -1268,7 +1428,7 @@ try {
|
|
|
1268
1428
|
break;
|
|
1269
1429
|
}
|
|
1270
1430
|
default:
|
|
1271
|
-
fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
|
|
1431
|
+
fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
|
|
1272
1432
|
}
|
|
1273
1433
|
} catch (err) {
|
|
1274
1434
|
fail(err.message);
|
|
@@ -51,6 +51,7 @@ import { landPlan, INTEGRATION_WORKTREE } from "../lib/land.mjs";
|
|
|
51
51
|
import { removeFinishedWorktrees, retireTaskWorktrees, retireIntegrationWorktree, branchHead, isGitRepo } from "../lib/worktrees.mjs";
|
|
52
52
|
import { readMemoryConfig, readMemoryStaleDays, readIndexConfig, createProvider } from "../lib/embeddings.mjs";
|
|
53
53
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
54
|
+
import { execFileSync } from "node:child_process";
|
|
54
55
|
|
|
55
56
|
// The project's install command from .doug/config.json, used when the plan does not name one.
|
|
56
57
|
function configInstall(d) {
|
|
@@ -88,60 +89,34 @@ function configDeleteBranches(d) {
|
|
|
88
89
|
return false;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
// card plan-base-branch-default: the branch checked out in d, or on a detached HEAD its full sha (noted on
|
|
93
|
+
// stderr); null when d is not a git repository or git fails.
|
|
94
|
+
function currentBase(d) {
|
|
95
|
+
const git = (...args) => execFileSync("git", args, { cwd: d, encoding: "utf8", stdio: "pipe" }).trim();
|
|
96
|
+
try {
|
|
97
|
+
const b = git("symbolic-ref", "--short", "-q", "HEAD");
|
|
98
|
+
if (b) return b;
|
|
99
|
+
} catch {}
|
|
100
|
+
try {
|
|
101
|
+
const sha = git("rev-parse", "--verify", "-q", "HEAD");
|
|
102
|
+
if (!sha) return null;
|
|
103
|
+
process.stderr.write(`[doug] plan.mjs: detached HEAD; baseBranch set to ${sha}\n`);
|
|
104
|
+
return sha;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
91
109
|
function withDefaults(p, d) {
|
|
92
110
|
let out = p;
|
|
111
|
+
if (typeof out.baseBranch !== "string" || !out.baseBranch.trim()) {
|
|
112
|
+
const base = currentBase(d);
|
|
113
|
+
if (base) out = { ...out, baseBranch: base };
|
|
114
|
+
}
|
|
93
115
|
if (!out.install && configInstall(d)) out = { ...out, install: configInstall(d) };
|
|
94
116
|
const hasCommand = out.adversary && typeof out.adversary === "object" && typeof out.adversary.command === "string" && out.adversary.command.trim();
|
|
95
117
|
if (out.adversary !== false && !hasCommand && configAdversary(d)) out = { ...out, adversary: { ...(out.adversary && typeof out.adversary === "object" ? out.adversary : {}), command: configAdversary(d) } };
|
|
96
118
|
return out;
|
|
97
119
|
}
|
|
98
|
-
// Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
|
|
99
|
-
// (LESSON_CHAR_BUDGET): text over budget is cut at the last sentence boundary (". ", "! ", "? ", or a newline)
|
|
100
|
-
// within it, falling back to the last word boundary, then a hard cut, with an ellipsis ("…") appended; the
|
|
101
|
-
// citation (or kind, when there is none) stays on the line either way. A sentence/newline boundary this close
|
|
102
|
-
// to the start (MIN_CUT_KEPT) keeps too little to be worth it — a lesson opening with a heading-then-newline,
|
|
103
|
-
// or a stray early period, would otherwise collapse to almost nothing — so that case falls through to the word
|
|
104
|
-
// boundary (then the hard cut) instead, same as when no such boundary exists at all. That keeps any one lesson
|
|
105
|
-
// — the memory store holds whole files up to ~12k characters, not just short summaries — from ever crowding
|
|
106
|
-
// out the rest: the running total is kept under LESSONS_CHAR_CAP by skipping (not stopping at) a line that
|
|
107
|
-
// would overflow it, so a later, shorter lesson still renders even after an earlier one didn't fit. lessonIds
|
|
108
|
-
// carries exactly the ids of the lines rendered. { text: null, ids: [] } when nothing rendered, the caller's
|
|
109
|
-
// cue to omit the fields entirely.
|
|
110
|
-
const LESSONS_CHAR_CAP = 2000;
|
|
111
|
-
const LESSON_CHAR_BUDGET = 400;
|
|
112
|
-
const MIN_CUT_KEPT = 40;
|
|
113
|
-
function cutLessonText(text, budget) {
|
|
114
|
-
if (text.length <= budget) return text;
|
|
115
|
-
const slice = text.slice(0, budget);
|
|
116
|
-
let cut = -1;
|
|
117
|
-
for (const sep of [". ", "! ", "? "]) {
|
|
118
|
-
const idx = slice.lastIndexOf(sep);
|
|
119
|
-
if (idx !== -1) cut = Math.max(cut, idx + sep.length - 1);
|
|
120
|
-
}
|
|
121
|
-
const nl = slice.lastIndexOf("\n");
|
|
122
|
-
if (nl !== -1) cut = Math.max(cut, nl);
|
|
123
|
-
if (cut < MIN_CUT_KEPT) cut = -1;
|
|
124
|
-
if (cut === -1) {
|
|
125
|
-
const sp = slice.lastIndexOf(" ");
|
|
126
|
-
cut = sp !== -1 ? sp : budget;
|
|
127
|
-
}
|
|
128
|
-
return text.slice(0, cut).trimEnd() + "…";
|
|
129
|
-
}
|
|
130
|
-
function renderLessons(lessons) {
|
|
131
|
-
const text = [];
|
|
132
|
-
const ids = [];
|
|
133
|
-
let length = 0;
|
|
134
|
-
for (const lesson of Array.isArray(lessons) ? lessons : []) {
|
|
135
|
-
const line = `- [${lesson.id}] ${cutLessonText(lesson.text, LESSON_CHAR_BUDGET)} (${lesson.citation || lesson.kind})`;
|
|
136
|
-
const nextLength = length + (text.length ? 1 : 0) + line.length;
|
|
137
|
-
if (nextLength > LESSONS_CHAR_CAP) continue;
|
|
138
|
-
text.push(line);
|
|
139
|
-
ids.push(lesson.id);
|
|
140
|
-
length = nextLength;
|
|
141
|
-
}
|
|
142
|
-
return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
120
|
// Mirrors lib/memory.mjs's exported MEMORY_DB_RELPATH by value, not by import: importing that module loads
|
|
146
121
|
// node:sqlite at its top level (review MAJOR/MINOR 4), so the mere existence check below must not pull it in
|
|
147
122
|
// on a Node that lacks node:sqlite, or every plan.mjs json without a database would eat that module's load
|
|
@@ -149,14 +124,27 @@ function renderLessons(lessons) {
|
|
|
149
124
|
const MEMORY_DB_RELPATH = ".doug/.state/memory/memory.db";
|
|
150
125
|
|
|
151
126
|
// Wraps a provider so a down or slow embeddings endpoint is ever hit once per `plan.mjs json` run: the first
|
|
152
|
-
// embed() failure is cached and handed to every later caller with no further request
|
|
153
|
-
//
|
|
127
|
+
// embed() failure is cached and handed to every later caller with no further request (the degraded-recall
|
|
128
|
+
// WARNING for it comes from warnOnce below, once per distinct line). Without this, a plan with many tasks rediscovers the same dead endpoint
|
|
154
129
|
// once per task, each paying its own request or timeout (review MAJOR: measured 26.8s for 8 tasks against a
|
|
155
130
|
// black-holed baseUrl). null in, null out: recallLessons already treats a null provider as keyword-only.
|
|
131
|
+
// card recall-fallback-warning: each distinct degraded-recall warning is printed once per run, not once per task;
|
|
132
|
+
// and the local-provider probe (only when no provider is configured) runs once per run too.
|
|
133
|
+
const warnedLines = new Set();
|
|
134
|
+
function warnOnce(line) {
|
|
135
|
+
if (!line || warnedLines.has(line)) return;
|
|
136
|
+
warnedLines.add(line);
|
|
137
|
+
process.stderr.write(line + "\n");
|
|
138
|
+
}
|
|
139
|
+
let detectedOnce = null;
|
|
140
|
+
function detectOnce(provider) {
|
|
141
|
+
if (provider) return Promise.resolve(null);
|
|
142
|
+
if (!detectedOnce) detectedOnce = import("../lib/memory.mjs").then((mod) => mod.detectLocalProvider());
|
|
143
|
+
return detectedOnce;
|
|
144
|
+
}
|
|
156
145
|
function cachingProvider(provider) {
|
|
157
146
|
if (!provider) return null;
|
|
158
147
|
let failure = null;
|
|
159
|
-
let warned = false;
|
|
160
148
|
return {
|
|
161
149
|
name: provider.name,
|
|
162
150
|
model: provider.model,
|
|
@@ -164,13 +152,7 @@ function cachingProvider(provider) {
|
|
|
164
152
|
async embed(texts, opts) {
|
|
165
153
|
if (failure) return failure;
|
|
166
154
|
const result = await provider.embed(texts, opts);
|
|
167
|
-
if (!result.ok)
|
|
168
|
-
failure = result;
|
|
169
|
-
if (!warned) {
|
|
170
|
-
warned = true;
|
|
171
|
-
process.stderr.write(`[doug] memory recall: embeddings provider unavailable (${result.reason}); keyword-only\n`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
155
|
+
if (!result.ok) failure = result;
|
|
174
156
|
return result;
|
|
175
157
|
},
|
|
176
158
|
};
|
|
@@ -187,15 +169,20 @@ function cachingProvider(provider) {
|
|
|
187
169
|
async function attachLessons(plan, dir, provider) {
|
|
188
170
|
if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
|
|
189
171
|
try {
|
|
190
|
-
const { openMemory, recallLessons } = await import("../lib/memory.mjs");
|
|
172
|
+
const { openMemory, recallLessons, degradedWarning, degradedReason, renderLessons } = await import("../lib/memory.mjs");
|
|
191
173
|
const staleDays = readMemoryStaleDays(dir);
|
|
192
174
|
const m = openMemory(dir);
|
|
193
175
|
try {
|
|
176
|
+
const detectedProvider = await detectOnce(provider);
|
|
177
|
+
// An empty lesson store has nothing to degrade (a code-index-only database, say): no warning for it.
|
|
178
|
+
const hasLessons = m.prepare("SELECT 1 FROM lessons WHERE superseded_by IS NULL AND stale IS NULL LIMIT 1").get() !== undefined;
|
|
194
179
|
const tasks = [];
|
|
195
180
|
for (const task of plan.tasks) {
|
|
196
|
-
const taskRecall = await recallLessons(m, `${task.title}\n${task.spec}`, { files: task.files, k: 8, checkoutDir: dir, provider, staleDays });
|
|
181
|
+
const taskRecall = await recallLessons(m, `${task.title}\n${task.spec}`, { files: task.files, k: 8, checkoutDir: dir, provider, staleDays, detectedProvider });
|
|
182
|
+
if (hasLessons) warnOnce(degradedWarning(taskRecall, { what: "memory recall", detectedProvider }));
|
|
197
183
|
const taskLessons = renderLessons(taskRecall.lessons);
|
|
198
|
-
|
|
184
|
+
const lessonsText = taskLessons.text !== null && taskRecall.degraded ? `(recall was keyword-only: ${degradedReason(taskRecall, detectedProvider)})\n${taskLessons.text}` : taskLessons.text;
|
|
185
|
+
tasks.push(taskLessons.text === null ? task : { ...task, lessons: lessonsText, lessonIds: taskLessons.ids });
|
|
199
186
|
}
|
|
200
187
|
return { ...plan, tasks };
|
|
201
188
|
} finally {
|
|
@@ -207,7 +194,7 @@ async function attachLessons(plan, dir, provider) {
|
|
|
207
194
|
}
|
|
208
195
|
}
|
|
209
196
|
|
|
210
|
-
// Renders code chunks the same skip-not-stop way renderLessons renders lessons, one line per chunk:
|
|
197
|
+
// Renders code chunks the same skip-not-stop way renderLessons (lib/memory.mjs) renders lessons, one line per chunk:
|
|
211
198
|
// "- <path>:<start>-<end> <first non-blank line of the chunk, trimmed to 120 chars>". codeContextIds carries
|
|
212
199
|
// exactly the ids of the lines kept, in rank order. { text: null, ids: [] } when nothing rendered, the caller's
|
|
213
200
|
// cue to omit the fields entirely.
|
|
@@ -243,17 +230,20 @@ async function attachCodeContext(plan, dir, provider) {
|
|
|
243
230
|
if (!readIndexConfig(dir).enabled) return plan;
|
|
244
231
|
if (!existsSync(join(dir, MEMORY_DB_RELPATH))) return plan;
|
|
245
232
|
try {
|
|
246
|
-
const { openMemory } = await import("../lib/memory.mjs");
|
|
233
|
+
const { openMemory, degradedWarning, degradedReason } = await import("../lib/memory.mjs");
|
|
247
234
|
const { searchIndex } = await import("../lib/code-index.mjs");
|
|
248
235
|
const m = openMemory(dir);
|
|
249
236
|
try {
|
|
250
237
|
const chunkCount = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
|
|
251
238
|
if (chunkCount === 0) return plan;
|
|
239
|
+
const detectedProvider = await detectOnce(provider);
|
|
252
240
|
const tasks = [];
|
|
253
241
|
for (const task of plan.tasks) {
|
|
254
|
-
const result = await searchIndex(m, `${task.title}\n${task.spec}`, { k: 6, provider });
|
|
242
|
+
const result = await searchIndex(m, `${task.title}\n${task.spec}`, { k: 6, provider, detectedProvider });
|
|
243
|
+
warnOnce(degradedWarning(result, { what: "code index search", code: true, detectedProvider }));
|
|
255
244
|
const rendered = renderCodeContext(result.chunks);
|
|
256
|
-
|
|
245
|
+
const codeText = rendered.text !== null && result.degraded ? `(code search was keyword-only: ${degradedReason(result, detectedProvider)})\n${rendered.text}` : rendered.text;
|
|
246
|
+
tasks.push(rendered.text === null ? task : { ...task, codeContext: codeText, codeContextIds: rendered.ids });
|
|
257
247
|
}
|
|
258
248
|
return { ...plan, tasks };
|
|
259
249
|
} finally {
|