@dougharness/cli 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +9 -3
- package/dist/board-serve.js.map +1 -1
- package/dist/board.js +10 -2
- 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 -1
- package/dist/generate/config.js +5 -12
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +24 -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 +323 -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/planner.md +2 -2
- 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 +154 -12
- 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 +129 -4
- package/node_modules/@dougharness/flow/lib/data/pairs.json +103 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +189 -27
- package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +61 -4
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +22 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
- package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
- package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +196 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -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 +44 -10
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +10 -11
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
- 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/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/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +5 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +139 -0
- 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/session-start-baseline.mjs +14 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +33 -7
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +794 -67
- 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 +146 -0
- package/package.json +3 -3
- package/templates/board-app.js +2 -2
- 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
|
@@ -15,6 +15,8 @@ import { mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
|
15
15
|
import { dirname, resolve, join } from "node:path";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
+
import { request as httpRequest } from "node:http";
|
|
19
|
+
import { request as httpsRequest } from "node:https";
|
|
18
20
|
import { classifyBlocks, adversaryBlocks } from "./board.mjs";
|
|
19
21
|
import { transcriptUsage, priceUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
|
|
20
22
|
import { MODELS_HEADING } from "./models.mjs";
|
|
@@ -459,6 +461,44 @@ export function openMemory(dir) {
|
|
|
459
461
|
};
|
|
460
462
|
}
|
|
461
463
|
|
|
464
|
+
// Read-only probe for `doug doctor`: can this Node build an FTS5 table in memory? Never throws.
|
|
465
|
+
export function probeSqliteFts5() {
|
|
466
|
+
try {
|
|
467
|
+
const db = new DatabaseSync(":memory:");
|
|
468
|
+
try {
|
|
469
|
+
db.exec("CREATE VIRTUAL TABLE probe USING fts5(x)");
|
|
470
|
+
} finally {
|
|
471
|
+
db.close();
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, reason: null };
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return { ok: false, reason: err && err.message ? err.message : String(err) };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
|
|
480
|
+
export function inspectMemory(dir) {
|
|
481
|
+
const file = memoryPath(dir);
|
|
482
|
+
const out = { file, exists: false, userVersion: null, maxUserVersion: 8, quickCheck: null, error: null };
|
|
483
|
+
if (!existsSync(file)) return out;
|
|
484
|
+
out.exists = true;
|
|
485
|
+
let db;
|
|
486
|
+
try {
|
|
487
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
488
|
+
out.userVersion = db.prepare("PRAGMA user_version").get().user_version;
|
|
489
|
+
out.quickCheck = String(db.prepare("PRAGMA quick_check").get().quick_check);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
out.error = err && err.message ? err.message : String(err);
|
|
492
|
+
} finally {
|
|
493
|
+
try {
|
|
494
|
+
if (db) db.close();
|
|
495
|
+
} catch {
|
|
496
|
+
// already unusable
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
|
|
462
502
|
function nowIso() {
|
|
463
503
|
return new Date().toISOString();
|
|
464
504
|
}
|
|
@@ -787,22 +827,32 @@ function toRecallLesson(lesson, extra) {
|
|
|
787
827
|
return { ...rest, hasVector: !!embedding, ...extra };
|
|
788
828
|
}
|
|
789
829
|
|
|
790
|
-
// ---
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
830
|
+
// --- Dense-ranked recall -----------------------------------------------------------------------------------
|
|
831
|
+
// With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
|
|
832
|
+
// cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
|
|
833
|
+
// 50+50 reciprocal rank fusion); BM25 hits that have no such vector are appended after all vectored lessons, in
|
|
834
|
+
// BM25 order. Without a provider or vectors, BM25 x recency x scope is the ranking (mode keyword-only). Ties
|
|
835
|
+
// break by lesson id ascending. Results are then citation-rechecked against checkoutDir before anything is
|
|
836
|
+
// returned — only when checkoutDir is a real project root (isProjectRoot); otherwise every candidate is
|
|
837
|
+
// returned unchecked (checked: false in the result) rather than either throwing (no checkoutDir given) or
|
|
838
|
+
// marking every row stale forever (a wrong one given). Never throws for a provider or checkout problem (a
|
|
839
|
+
// database problem may still throw, same as every other export here). See design #2 in the memory-recall card.
|
|
797
840
|
export async function recallLessons(m, query, opts = {}) {
|
|
798
|
-
const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30 } = opts;
|
|
841
|
+
const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30, detectedProvider = null } = opts;
|
|
799
842
|
const nowDate = now instanceof Date ? now : new Date(now);
|
|
800
843
|
const nowIsoStr = nowDate.toISOString();
|
|
801
844
|
const nowMs = nowDate.getTime();
|
|
802
845
|
const checked = isProjectRoot(checkoutDir);
|
|
803
846
|
|
|
804
847
|
// (a) candidates: not superseded, not stale, and confirmed-or-created within staleDays.
|
|
805
|
-
|
|
848
|
+
// Light columns only: the embedding blob is never loaded here (card recall-scale-row-load).
|
|
849
|
+
const allRows = m
|
|
850
|
+
.prepare(
|
|
851
|
+
"SELECT id, created, confirmed, scope, embedding_model, embedding_dims, embedding IS NOT NULL AS has_vec FROM lessons WHERE superseded_by IS NULL AND stale IS NULL"
|
|
852
|
+
)
|
|
853
|
+
.all()
|
|
854
|
+
.map(lessonRowToObject);
|
|
855
|
+
const hasVec = (l) => !!l.has_vec;
|
|
806
856
|
const freshRows = allRows.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
|
|
807
857
|
const byId = new Map(freshRows.map((l) => [l.id, l]));
|
|
808
858
|
|
|
@@ -820,16 +870,24 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
820
870
|
bm25Ranked = rows.map((r) => r.id).filter((id) => byId.has(id));
|
|
821
871
|
}
|
|
822
872
|
|
|
823
|
-
// (c)
|
|
873
|
+
// (c) dense side, only with a provider and rows stored under the same model+dims: cosine (dot of the stored
|
|
874
|
+
// vectors) for every such row, not only BM25 hits.
|
|
824
875
|
let mode = "keyword-only";
|
|
825
876
|
let reason = "no provider configured";
|
|
826
877
|
let providerName = null;
|
|
827
|
-
|
|
878
|
+
const cosine = new Map();
|
|
828
879
|
if (provider) {
|
|
829
880
|
providerName = provider.name;
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
881
|
+
// Blobs are read only when the light columns say a fresh row holds one under this model/dims.
|
|
882
|
+
const anyMatching = freshRows.some((l) => hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims);
|
|
883
|
+
const vectorRows = !anyMatching
|
|
884
|
+
? []
|
|
885
|
+
: m
|
|
886
|
+
.prepare(
|
|
887
|
+
"SELECT id, embedding FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ?"
|
|
888
|
+
)
|
|
889
|
+
.all(provider.model, provider.dims)
|
|
890
|
+
.filter((l) => byId.has(l.id));
|
|
833
891
|
if (!vectorRows.length) {
|
|
834
892
|
reason = `no vectors stored for ${provider.model}/${provider.dims}`;
|
|
835
893
|
} else {
|
|
@@ -838,40 +896,63 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
838
896
|
reason = embedResult.reason;
|
|
839
897
|
} else {
|
|
840
898
|
const qVec = embedResult.vectors[0];
|
|
841
|
-
const
|
|
899
|
+
for (const l of vectorRows) {
|
|
842
900
|
const vec = blobToFloats(l.embedding);
|
|
843
901
|
let dot = 0;
|
|
844
902
|
for (let i = 0; i < qVec.length && i < vec.length; i++) dot += qVec[i] * vec[i];
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
scored.sort((a, b) => b.score - a.score);
|
|
848
|
-
vectorRanked = scored.slice(0, 50).map((s) => s.id);
|
|
903
|
+
cosine.set(l.id, dot);
|
|
904
|
+
}
|
|
849
905
|
mode = "hybrid";
|
|
850
906
|
reason = null;
|
|
851
907
|
}
|
|
852
908
|
}
|
|
853
909
|
}
|
|
854
910
|
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
911
|
+
// card recall-fallback-warning: keyword-only is degraded when a provider was given, vectors exist for any
|
|
912
|
+
// model, or the caller detected a usable provider; hybrid reports how many live in-window rows lack a vector.
|
|
913
|
+
// The existence check is bounded and only runs when no other term already decided it.
|
|
914
|
+
const hasAnyVector = (db) =>
|
|
915
|
+
!!db.prepare("SELECT 1 AS x FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL LIMIT 1").get();
|
|
916
|
+
const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
|
|
917
|
+
const coverage = {};
|
|
918
|
+
if (mode === "hybrid") {
|
|
919
|
+
coverage.total = freshRows.length;
|
|
920
|
+
coverage.unvectored = freshRows.filter(
|
|
921
|
+
(l) => !(hasVec(l) && l.embedding_model === provider.model && l.embedding_dims === provider.dims)
|
|
922
|
+
).length;
|
|
923
|
+
}
|
|
859
924
|
|
|
860
|
-
// (
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
const lesson = byId.get(id);
|
|
864
|
-
if (!lesson) continue;
|
|
925
|
+
// (d) scores: recency weight, then scope boost. Dense mode scores cosine; the BM25 order scores 1/(60+rank).
|
|
926
|
+
const bm25Rank = new Map(bm25Ranked.map((id, idx) => [id, idx]));
|
|
927
|
+
const weigh = (lesson, base) => {
|
|
865
928
|
const ageDays = ageDaysOf(lesson.confirmed || lesson.created, nowMs);
|
|
866
|
-
|
|
867
|
-
let score = rrfScore * recency;
|
|
929
|
+
let score = base * (0.5 + 0.5 * Math.exp(-ageDays / 90));
|
|
868
930
|
if (files.length && (lesson.scope.length === 0 || scopeMatchesFiles(lesson.scope, files))) score *= 1.5;
|
|
931
|
+
return score;
|
|
932
|
+
};
|
|
933
|
+
const byScoreThenId = (a, b) => b.score - a.score || (a.lesson.id < b.lesson.id ? -1 : a.lesson.id > b.lesson.id ? 1 : 0);
|
|
934
|
+
const sidesOf = (id) => {
|
|
869
935
|
const sides = [];
|
|
870
|
-
if (
|
|
871
|
-
if (
|
|
872
|
-
|
|
936
|
+
if (bm25Rank.has(id)) sides.push("bm25");
|
|
937
|
+
if (cosine.has(id)) sides.push("vector");
|
|
938
|
+
return sides;
|
|
939
|
+
};
|
|
940
|
+
let candidates;
|
|
941
|
+
if (mode === "hybrid") {
|
|
942
|
+
// (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
|
|
943
|
+
const dense = [...cosine.entries()].map(([id, dot]) => {
|
|
944
|
+
const lesson = byId.get(id);
|
|
945
|
+
return { lesson, score: weigh(lesson, dot), sides: sidesOf(id) };
|
|
946
|
+
});
|
|
947
|
+
dense.sort(byScoreThenId);
|
|
948
|
+
const appended = bm25Ranked
|
|
949
|
+
.filter((id) => !cosine.has(id))
|
|
950
|
+
.map((id) => ({ lesson: byId.get(id), score: weigh(byId.get(id), 1 / (60 + bm25Rank.get(id) + 1)), sides: sidesOf(id) }));
|
|
951
|
+
candidates = [...dense, ...appended];
|
|
952
|
+
} else {
|
|
953
|
+
candidates = bm25Ranked.map((id) => ({ lesson: byId.get(id), score: weigh(byId.get(id), 1 / (60 + bm25Rank.get(id) + 1)), sides: sidesOf(id) }));
|
|
954
|
+
candidates.sort(byScoreThenId);
|
|
873
955
|
}
|
|
874
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
875
956
|
|
|
876
957
|
// (g) top k. With a real project root, each candidate's citation is re-checked: a failure marks the row
|
|
877
958
|
// stale and drops it, backfilling from the next candidate so k are returned when enough candidates exist,
|
|
@@ -881,6 +962,15 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
881
962
|
let idx = 0;
|
|
882
963
|
while (result.length < k && idx < candidates.length) {
|
|
883
964
|
const cand = candidates[idx++];
|
|
965
|
+
// Every column except the blob (and the model/dims toRecallLesson strips anyway); hasVector comes from has_vec.
|
|
966
|
+
const { has_vec, ...full } = m
|
|
967
|
+
.prepare(
|
|
968
|
+
`SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
|
|
969
|
+
helpful, harmful, superseded_by, embedding_model, embedding_dims, stale, embedding IS NOT NULL AS has_vec
|
|
970
|
+
FROM lessons WHERE id = ?`
|
|
971
|
+
)
|
|
972
|
+
.get(cand.lesson.id);
|
|
973
|
+
cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
|
|
884
974
|
if (!checked) {
|
|
885
975
|
result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
|
|
886
976
|
continue;
|
|
@@ -894,7 +984,176 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
894
984
|
result.push(toRecallLesson({ ...cand.lesson, confirmed: nowIsoStr }, { score: cand.score, sides: cand.sides }));
|
|
895
985
|
}
|
|
896
986
|
|
|
897
|
-
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, lessons: result };
|
|
987
|
+
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, lessons: result };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
|
|
991
|
+
// (LESSON_CHAR_BUDGET): text over budget is cut at the last sentence boundary (". ", "! ", "? ", or a newline)
|
|
992
|
+
// within it, falling back to the last word boundary, then a hard cut, with an ellipsis ("…") appended; the
|
|
993
|
+
// citation (or kind, when there is none) stays on the line either way. A sentence/newline boundary this close
|
|
994
|
+
// to the start (MIN_CUT_KEPT) keeps too little to be worth it — a lesson opening with a heading-then-newline,
|
|
995
|
+
// or a stray early period, would otherwise collapse to almost nothing — so that case falls through to the word
|
|
996
|
+
// boundary (then the hard cut) instead, same as when no such boundary exists at all. That keeps any one lesson
|
|
997
|
+
// — the memory store holds whole files up to ~12k characters, not just short summaries — from ever crowding
|
|
998
|
+
// out the rest: the running total is kept under LESSONS_CHAR_CAP by skipping (not stopping at) a line that
|
|
999
|
+
// would overflow it, so a later, shorter lesson still renders even after an earlier one didn't fit. lessonIds
|
|
1000
|
+
// carries exactly the ids of the lines rendered. { text: null, ids: [] } when nothing rendered, the caller's
|
|
1001
|
+
// cue to omit the fields entirely.
|
|
1002
|
+
const LESSONS_CHAR_CAP = 2000;
|
|
1003
|
+
const LESSON_CHAR_BUDGET = 400;
|
|
1004
|
+
const MIN_CUT_KEPT = 40;
|
|
1005
|
+
function cutLessonText(text, budget) {
|
|
1006
|
+
if (text.length <= budget) return text;
|
|
1007
|
+
const slice = text.slice(0, budget);
|
|
1008
|
+
let cut = -1;
|
|
1009
|
+
for (const sep of [". ", "! ", "? "]) {
|
|
1010
|
+
const idx = slice.lastIndexOf(sep);
|
|
1011
|
+
if (idx !== -1) cut = Math.max(cut, idx + sep.length - 1);
|
|
1012
|
+
}
|
|
1013
|
+
const nl = slice.lastIndexOf("\n");
|
|
1014
|
+
if (nl !== -1) cut = Math.max(cut, nl);
|
|
1015
|
+
if (cut < MIN_CUT_KEPT) cut = -1;
|
|
1016
|
+
if (cut === -1) {
|
|
1017
|
+
const sp = slice.lastIndexOf(" ");
|
|
1018
|
+
cut = sp !== -1 ? sp : budget;
|
|
1019
|
+
}
|
|
1020
|
+
return text.slice(0, cut).trimEnd() + "…";
|
|
1021
|
+
}
|
|
1022
|
+
export function renderLessons(lessons) {
|
|
1023
|
+
const text = [];
|
|
1024
|
+
const ids = [];
|
|
1025
|
+
let length = 0;
|
|
1026
|
+
for (const lesson of Array.isArray(lessons) ? lessons : []) {
|
|
1027
|
+
const line = `- [${lesson.id}] ${cutLessonText(lesson.text, LESSON_CHAR_BUDGET)} (${lesson.citation || lesson.kind})`;
|
|
1028
|
+
const nextLength = length + (text.length ? 1 : 0) + line.length;
|
|
1029
|
+
if (nextLength > LESSONS_CHAR_CAP) continue;
|
|
1030
|
+
text.push(line);
|
|
1031
|
+
ids.push(lesson.id);
|
|
1032
|
+
length = nextLength;
|
|
1033
|
+
}
|
|
1034
|
+
return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// The lessons a session starts with when no card query exists (card memory-every-session): live, non-stale
|
|
1038
|
+
// lessons confirmed-or-created within staleDays, ordered by helpful minus harmful desc, then confirmed-or-created
|
|
1039
|
+
// desc, then id ascending. No provider, no citation re-check, nothing written.
|
|
1040
|
+
export function topLessons(m, { k = 5, staleDays = 30, now = new Date() } = {}) {
|
|
1041
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
1042
|
+
const rows = m
|
|
1043
|
+
.prepare(
|
|
1044
|
+
`SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
|
|
1045
|
+
helpful, harmful, superseded_by, stale
|
|
1046
|
+
FROM lessons WHERE superseded_by IS NULL AND stale IS NULL`
|
|
1047
|
+
)
|
|
1048
|
+
.all()
|
|
1049
|
+
.map(lessonRowToObject)
|
|
1050
|
+
.filter((l) => ageDaysOf(l.confirmed || l.created, nowMs) <= staleDays);
|
|
1051
|
+
const stamp = (l) => new Date(l.confirmed || l.created).getTime() || 0;
|
|
1052
|
+
rows.sort((a, b) => b.helpful - b.harmful - (a.helpful - a.harmful) || stamp(b) - stamp(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1053
|
+
return rows.slice(0, k);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// --- Degraded-recall warnings (card recall-fallback-warning) ------------------------------------------------
|
|
1057
|
+
// The one place that words a degraded recall, for memory.mjs recall / index search and plan.mjs json alike.
|
|
1058
|
+
// `result` is a recallLessons or searchIndex result; `what` is "memory recall" or "code index search"; `code`
|
|
1059
|
+
// picks the chunk wording and the `index build` fix. Returns the stderr line (no newline) or null.
|
|
1060
|
+
export function degradedWarning(result, { what = "memory recall", code = false, detectedProvider = null } = {}) {
|
|
1061
|
+
const noun = code ? "chunks" : "lessons";
|
|
1062
|
+
const rebuild = code ? "run memory.mjs index build" : "run memory.mjs reembed";
|
|
1063
|
+
if (result.mode === "hybrid") {
|
|
1064
|
+
if (result.unvectored > 0) return `WARNING: ${result.unvectored} of ${result.total} ${noun} have no vector; ${rebuild}`;
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
if (!result.degraded) return null;
|
|
1068
|
+
const reason = result.reason;
|
|
1069
|
+
let fix;
|
|
1070
|
+
let shown = reason;
|
|
1071
|
+
if (reason === "no provider configured") {
|
|
1072
|
+
if (detectedProvider) {
|
|
1073
|
+
shown = `no provider configured, but ${detectedProvider} is available`;
|
|
1074
|
+
fix = "run memory.mjs doctor to enable it";
|
|
1075
|
+
} else {
|
|
1076
|
+
fix = "configure memory.embeddings (memory.mjs doctor)";
|
|
1077
|
+
}
|
|
1078
|
+
} else if (typeof reason === "string" && reason.startsWith("no vectors stored for ")) {
|
|
1079
|
+
fix = rebuild;
|
|
1080
|
+
} else if (reason === "timeout") {
|
|
1081
|
+
fix = "run memory.mjs doctor (a cold model can time out on first use; retry)";
|
|
1082
|
+
} else {
|
|
1083
|
+
fix = "run memory.mjs doctor";
|
|
1084
|
+
}
|
|
1085
|
+
return `WARNING: ${what} degraded to keyword-only: ${shown}; ${fix}`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// The reason shown in a task prompt block when a recall was degraded: the same wording as the warning's reason
|
|
1089
|
+
// part, including the detected-provider form.
|
|
1090
|
+
export function degradedReason(result, detectedProvider = null) {
|
|
1091
|
+
if (result.reason === "no provider configured" && detectedProvider) return `no provider configured, but ${detectedProvider} is available`;
|
|
1092
|
+
return result.reason;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// One probe for a usable local embedding provider, for a caller whose config has none. DOUG_OLLAMA_BASE_URL
|
|
1096
|
+
// overrides the default base URL (http://localhost:11434). Returns a short description ("ollama embeddinggemma
|
|
1097
|
+
// at <url>") or null: a failed or negative probe is "not detected", never an error. Ollama only, GET only:
|
|
1098
|
+
// /api/version then /api/tags under ONE shared 1500 ms deadline (never an embed call, never a POST). The model
|
|
1099
|
+
// name matching mirrors embeddings.mjs's modelNameMatches (exact, or the part before ":" on either side); that
|
|
1100
|
+
// helper is not exported and this card may not edit embeddings.mjs, so the rule is repeated here.
|
|
1101
|
+
export async function detectLocalProvider(opts = {}) {
|
|
1102
|
+
try {
|
|
1103
|
+
const baseUrl = process.env.DOUG_OLLAMA_BASE_URL || "http://localhost:11434";
|
|
1104
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
1105
|
+
const want = "embeddinggemma";
|
|
1106
|
+
const base = (n) => String(n ?? "").split(":")[0];
|
|
1107
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 1500);
|
|
1108
|
+
const get = opts.fetch ? (url) => fetchGet(opts.fetch, url, signal) : (url) => httpGet(url, signal);
|
|
1109
|
+
const version = await get(`${root}/api/version`);
|
|
1110
|
+
if (!version.ok) return null;
|
|
1111
|
+
const tags = await get(`${root}/api/tags`);
|
|
1112
|
+
if (!tags.ok) return null;
|
|
1113
|
+
const json = JSON.parse(tags.body);
|
|
1114
|
+
const models = Array.isArray(json && json.models) ? json.models : [];
|
|
1115
|
+
const present = models.some((mo) => mo && [mo.name, mo.model].some((n) => n !== undefined && n !== null && (n === want || base(n) === want)));
|
|
1116
|
+
return present ? `ollama ${want} at ${baseUrl}` : null;
|
|
1117
|
+
} catch {
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function fetchGet(fetchImpl, url, signal) {
|
|
1123
|
+
const res = await fetchImpl(url, { signal, headers: { Connection: "close" } });
|
|
1124
|
+
if (!res.ok) {
|
|
1125
|
+
await res.body?.cancel?.().catch(() => {});
|
|
1126
|
+
return { ok: false, body: "" };
|
|
1127
|
+
}
|
|
1128
|
+
return { ok: true, body: await res.text() };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// node:http rather than fetch: aborting a fetch whose TCP connect is still pending leaves the connect attempt
|
|
1132
|
+
// (and so the process) alive for ~10 s against an unroutable host; destroying a node:http request tears the
|
|
1133
|
+
// socket down at once, so the CLI exits right after the deadline.
|
|
1134
|
+
function httpGet(url, signal) {
|
|
1135
|
+
return new Promise((resolveP, rejectP) => {
|
|
1136
|
+
const mod = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
1137
|
+
const req = mod(url, { method: "GET", headers: { Connection: "close" } }, (res) => {
|
|
1138
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1139
|
+
res.destroy();
|
|
1140
|
+
resolveP({ ok: false, body: "" });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const chunks = [];
|
|
1144
|
+
res.on("data", (c) => chunks.push(c));
|
|
1145
|
+
res.on("end", () => resolveP({ ok: true, body: Buffer.concat(chunks).toString("utf8") }));
|
|
1146
|
+
res.on("error", rejectP);
|
|
1147
|
+
});
|
|
1148
|
+
const onAbort = () => {
|
|
1149
|
+
req.destroy();
|
|
1150
|
+
rejectP(new Error("timeout"));
|
|
1151
|
+
};
|
|
1152
|
+
if (signal.aborted) return onAbort();
|
|
1153
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1154
|
+
req.on("error", rejectP);
|
|
1155
|
+
req.end();
|
|
1156
|
+
});
|
|
898
1157
|
}
|
|
899
1158
|
|
|
900
1159
|
function taskAdversary(task, level) {
|
|
@@ -1343,9 +1602,41 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
|
|
|
1343
1602
|
} else {
|
|
1344
1603
|
lines.push("(none yet)");
|
|
1345
1604
|
}
|
|
1605
|
+
if (report.hand && typeof report.hand === "object") appendHandMaterial(lines, report.hand);
|
|
1346
1606
|
return lines.join("\n") + "\n";
|
|
1347
1607
|
}
|
|
1348
1608
|
|
|
1609
|
+
// A hand landing's saved material (card memory-every-session): each file capped at HAND_FILE_CHAR_CAP, all of it at
|
|
1610
|
+
// HAND_TOTAL_CHAR_CAP, the landing note last so a budget spent on files still leaves the note.
|
|
1611
|
+
const HAND_FILE_CHAR_CAP = 4000;
|
|
1612
|
+
const HAND_TOTAL_CHAR_CAP = 16000;
|
|
1613
|
+
const HAND_TRUNCATED = "\u2026[truncated]";
|
|
1614
|
+
|
|
1615
|
+
function appendHandMaterial(lines, hand) {
|
|
1616
|
+
lines.push("");
|
|
1617
|
+
lines.push("Hand-track material (briefs, reviewer reports, landing note; the rules above still apply, cite report:<card> or report:<card>/R<n>):");
|
|
1618
|
+
const note = typeof hand.note === "string" && hand.note.trim() ? hand.note : null;
|
|
1619
|
+
const cutNote = note && note.length > HAND_FILE_CHAR_CAP ? note.slice(0, HAND_FILE_CHAR_CAP) + HAND_TRUNCATED : note;
|
|
1620
|
+
let budget = HAND_TOTAL_CHAR_CAP - (cutNote ? cutNote.length : 0);
|
|
1621
|
+
const add = (label, files) => {
|
|
1622
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
1623
|
+
if (!f || typeof f.text !== "string") continue;
|
|
1624
|
+
if (budget <= 0) break;
|
|
1625
|
+
let text = f.text.length > HAND_FILE_CHAR_CAP ? f.text.slice(0, HAND_FILE_CHAR_CAP) + HAND_TRUNCATED : f.text;
|
|
1626
|
+
if (text.length > budget) text = text.slice(0, budget) + HAND_TRUNCATED;
|
|
1627
|
+
budget -= text.length;
|
|
1628
|
+
lines.push(`--- ${label} ${f.name || ""} ---`);
|
|
1629
|
+
lines.push(text);
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
add("brief", hand.briefs);
|
|
1633
|
+
add("reviewer report", hand.reviews);
|
|
1634
|
+
if (cutNote) {
|
|
1635
|
+
lines.push("--- landing note ---");
|
|
1636
|
+
lines.push(cutNote);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1349
1640
|
// Citation filter (C.2): a "report:" citation is checked against `report` (never unchecked here, unlike
|
|
1350
1641
|
// checkCitation's default), a path:line citation against `dir` via checkCitation; anything else — free text, an
|
|
1351
1642
|
// empty string, a report: citation shaped wrong — is rejected outright.
|
|
@@ -559,6 +559,15 @@ export function planWarnings(plan) {
|
|
|
559
559
|
else if (n > FEATURE_TASKS) out.push(`${n} tasks: a feature is three to five; merge tasks that share a file group or split the plan`);
|
|
560
560
|
const files = new Set(plan.tasks.flatMap((t) => (Array.isArray(t.files) ? t.files : [])));
|
|
561
561
|
if (n > 1 && files.size === 1) out.push(`${n} tasks own the same single file (${[...files][0]}): single-file work is one task`);
|
|
562
|
+
const verifyOwners = [];
|
|
563
|
+
for (const v of Array.isArray(plan.verify) ? plan.verify : []) if (typeof v === "string" && v.trim()) verifyOwners.push([v.trim(), "plan.verify"]);
|
|
564
|
+
for (const t of plan.tasks) if (t && typeof t.verify === "string" && t.verify.trim()) verifyOwners.push([t.verify.trim(), `task ${t.id} verify`]);
|
|
565
|
+
for (const a of Array.isArray(plan.acceptance) ? plan.acceptance : []) {
|
|
566
|
+
if (!a || typeof a !== "object" || typeof a.command !== "string" || !a.command.trim()) continue;
|
|
567
|
+
const cmd = a.command.trim();
|
|
568
|
+
const hit = verifyOwners.find(([c]) => c === cmd);
|
|
569
|
+
if (hit) out.push(`acceptance "${a.text}" command \`${cmd}\` repeats a verify command (${hit[1]}): every verifier, integrate, and land already run it; name a narrower check or make it prose`);
|
|
570
|
+
}
|
|
562
571
|
return out;
|
|
563
572
|
}
|
|
564
573
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/flow",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Plan, approve, implement one task per worktree, verify, review: a Workflow-tool template and subagents for Claude Code.",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"agents",
|
|
10
10
|
"skills",
|
|
11
11
|
"workflows",
|
|
12
|
+
"hooks",
|
|
12
13
|
"scripts",
|
|
13
14
|
"lib",
|
|
14
15
|
".claude-plugin"
|