@luuthuong/devboard 0.0.0-stage → 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.
Files changed (80) hide show
  1. package/.claude/skills/devboard/SKILL.md +111 -0
  2. package/bin/devboard.mjs +59 -0
  3. package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
  4. package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
  5. package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
  6. package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
  7. package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
  8. package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
  9. package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
  10. package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
  11. package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
  12. package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
  13. package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
  14. package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
  15. package/dist/assets/Surface-BQKeigoq.js +1 -0
  16. package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
  17. package/dist/assets/Terminal-BrP-ENHg.css +1 -0
  18. package/dist/assets/Terminal-yq90NOBX.js +80 -0
  19. package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
  20. package/dist/assets/index-BtXUncAU.css +1 -0
  21. package/dist/assets/index-CQnL8JJ5.js +18 -0
  22. package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
  23. package/dist/assets/plus-Cecrm8sZ.js +1 -0
  24. package/dist/assets/react-dom-DjtJUrx3.js +1 -0
  25. package/dist/assets/x-B6wVesqk.js +1 -0
  26. package/dist/index.html +22 -0
  27. package/package.json +81 -4
  28. package/server-dist/server/assets/remote-shell.sh +239 -0
  29. package/server-dist/server/board.js +486 -0
  30. package/server-dist/server/commands.js +390 -0
  31. package/server-dist/server/context.js +34 -0
  32. package/server-dist/server/coordination.js +442 -0
  33. package/server-dist/server/editor.js +101 -0
  34. package/server-dist/server/events.js +17 -0
  35. package/server-dist/server/fsBrowse.js +143 -0
  36. package/server-dist/server/git.js +258 -0
  37. package/server-dist/server/harness/config.js +90 -0
  38. package/server-dist/server/harness/entry.js +98 -0
  39. package/server-dist/server/harness/intents.js +268 -0
  40. package/server-dist/server/harness/kbIndex.js +291 -0
  41. package/server-dist/server/harness/kbStore.js +258 -0
  42. package/server-dist/server/harness/librarian.js +147 -0
  43. package/server-dist/server/harness/pack.js +182 -0
  44. package/server-dist/server/harness/proposals.js +113 -0
  45. package/server-dist/server/harness/repo.js +150 -0
  46. package/server-dist/server/harness/scrub.js +30 -0
  47. package/server-dist/server/harness/service.js +683 -0
  48. package/server-dist/server/harness/template/README.md +38 -0
  49. package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
  50. package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
  51. package/server-dist/server/harness/template/agents/test-writer.md +16 -0
  52. package/server-dist/server/harness/template/agents.yaml +41 -0
  53. package/server-dist/server/harness/template/harness.json +11 -0
  54. package/server-dist/server/harness/template/kb/team/README.md +4 -0
  55. package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
  56. package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
  57. package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
  58. package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
  59. package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
  60. package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
  61. package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
  62. package/server-dist/server/harness/usage.js +82 -0
  63. package/server-dist/server/hooks.js +222 -0
  64. package/server-dist/server/http.js +266 -0
  65. package/server-dist/server/index.js +77 -0
  66. package/server-dist/server/integrations.js +292 -0
  67. package/server-dist/server/pollers.js +332 -0
  68. package/server-dist/server/processes.js +123 -0
  69. package/server-dist/server/psmux.js +120 -0
  70. package/server-dist/server/pty.js +93 -0
  71. package/server-dist/server/remote.js +216 -0
  72. package/server-dist/server/sessionReaper.js +101 -0
  73. package/server-dist/server/sessions.js +315 -0
  74. package/server-dist/server/settings.js +125 -0
  75. package/server-dist/server/skill.js +29 -0
  76. package/server-dist/server/tmux.js +217 -0
  77. package/server-dist/server/transcripts.js +334 -0
  78. package/server-dist/server/util.js +286 -0
  79. package/server-dist/src/types/index.js +53 -0
  80. package/README.md +0 -3
@@ -0,0 +1,268 @@
1
+ // AI-DLC on the board. An Intent is one piece of work that goes through
2
+ // Inception (requirements → units) and Construction (one card per unit:
3
+ // design → code → review). Every stage ends at a human gate: the agent asks
4
+ // for review (`devboard gate request`), the card waits in "requires
5
+ // attention", and only a person's approve or reject moves it on.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { newLinkBase } from "../coordination.js";
9
+ import { Mutex, devboardDataDir, ksuid, writeFileAtomic } from "../util.js";
10
+ import { slugify } from "./config.js";
11
+ const nowIso = () => new Date().toISOString();
12
+ export class IntentStore {
13
+ filePath;
14
+ lock = new Mutex();
15
+ constructor(base) {
16
+ this.filePath = path.join(base ?? devboardDataDir(), "intents.json");
17
+ }
18
+ async list() {
19
+ try {
20
+ const parsed = JSON.parse(await fs.promises.readFile(this.filePath, "utf8"));
21
+ return Array.isArray(parsed?.intents) ? parsed.intents : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ async get(id) {
28
+ return (await this.list()).find((i) => i.id === id);
29
+ }
30
+ upsert(intent) {
31
+ return this.lock.run(async () => {
32
+ const all = await this.list();
33
+ const idx = all.findIndex((i) => i.id === intent.id);
34
+ intent.updatedAt = nowIso();
35
+ if (idx >= 0)
36
+ all[idx] = intent;
37
+ else
38
+ all.push(intent);
39
+ await writeFileAtomic(this.filePath, JSON.stringify({ intents: all }, null, 2));
40
+ return intent;
41
+ });
42
+ }
43
+ }
44
+ // ── Prompts ──────────────────────────────────────────────────────────────────
45
+ const GATE_HINT = 'When this stage is done, run `devboard gate request --note "<one line>"` and stop: a person reviews before the next stage.';
46
+ export function inceptionPrompt(intent) {
47
+ return [
48
+ `AI-DLC Inception for: ${intent.title}`,
49
+ "",
50
+ intent.body,
51
+ "",
52
+ `Use the aidlc-requirements skill. Write ${intent.artifactsDir}/requirements.md (user stories, acceptance criteria, open questions).`,
53
+ "Commit it on this card's branch. Do not write code yet.",
54
+ GATE_HINT,
55
+ ].join("\n");
56
+ }
57
+ export function unitsPrompt(intent) {
58
+ return [
59
+ "Requirements approved.",
60
+ `Use the aidlc-units skill. Write ${intent.artifactsDir}/units.md: one "## <unit title>" section per unit of work, each with its scope (files or folders), dependencies and acceptance criteria.`,
61
+ "Units must be small enough for one pull request each.",
62
+ GATE_HINT,
63
+ ].join("\n");
64
+ }
65
+ export function unitPrompt(intent, unit) {
66
+ return [
67
+ `AI-DLC unit of "${intent.title}": ${unit.title}`,
68
+ "",
69
+ unit.body,
70
+ "",
71
+ `Context: ${intent.artifactsDir}/requirements.md and ${intent.artifactsDir}/units.md.`,
72
+ `Use the aidlc-design skill. Write ${intent.artifactsDir}/${unit.slug}/design.md. Do not write code yet.`,
73
+ GATE_HINT,
74
+ ].join("\n");
75
+ }
76
+ export const codePrompt = (intent) => [
77
+ "Design approved.",
78
+ `Use the aidlc-implement skill to implement the design, with tests, then the aidlc-pr skill to open a pull request that links ${intent.artifactsDir}.`,
79
+ "The pull request is the next gate: a person merges it.",
80
+ ].join("\n");
81
+ export const rejectPrompt = (stage, note) => `The ${stage} stage was sent back by the reviewer:\n\n${note}\n\nRevise it, then run \`devboard gate request\` again.`;
82
+ /** Each `## heading` in units.md is one unit; text up to the next `##` is its body. */
83
+ export function parseUnits(markdown) {
84
+ const units = [];
85
+ let cur = null;
86
+ const flush = () => {
87
+ if (!cur)
88
+ return;
89
+ const slug = slugify(cur.title);
90
+ units.push({ title: cur.title, slug: uniqueSlug(slug, units), body: cur.lines.join("\n").trim() });
91
+ };
92
+ for (const line of markdown.split(/\r?\n/)) {
93
+ const m = /^##\s+(.+?)\s*#*\s*$/.exec(line);
94
+ if (m) {
95
+ flush();
96
+ cur = { title: m[1], lines: [] };
97
+ }
98
+ else if (cur) {
99
+ cur.lines.push(line);
100
+ }
101
+ }
102
+ flush();
103
+ return units;
104
+ }
105
+ function uniqueSlug(slug, taken) {
106
+ let s = slug;
107
+ for (let n = 2; taken.some((u) => u.slug === s); n++)
108
+ s = `${slug}-${n}`;
109
+ return s;
110
+ }
111
+ /** The checkout the card works in: its worktree, else the project. */
112
+ export function cardCheckout(link) {
113
+ return link.worktreeLink?.path ?? link.projectPath;
114
+ }
115
+ export async function createIntent(deps, args) {
116
+ const id = ksuid("intent");
117
+ const slug = `${new Date().toISOString().slice(0, 10)}-${slugify(args.title).slice(0, 40)}`;
118
+ const intent = {
119
+ id,
120
+ projectPath: args.projectPath,
121
+ title: args.title.trim(),
122
+ body: args.body.trim(),
123
+ phase: "inception",
124
+ artifactsDir: `docs/aidlc/${slug}`,
125
+ unitCardIds: [],
126
+ issueUrl: args.issueUrl,
127
+ createdAt: nowIso(),
128
+ updatedAt: nowIso(),
129
+ };
130
+ const card = newLinkBase();
131
+ card.name = `Inception: ${intent.title}`;
132
+ card.projectPath = intent.projectPath;
133
+ card.promptBody = inceptionPrompt(intent);
134
+ card.intentId = id;
135
+ card.stage = "requirements";
136
+ await deps.store.upsertLink(card);
137
+ intent.inceptionCardId = card.id;
138
+ await deps.intents.upsert(intent);
139
+ return { intent, card };
140
+ }
141
+ /** The agent finished a stage: park the card until a person decides. */
142
+ export async function requestGate(deps, cardId, note) {
143
+ let out;
144
+ await deps.store.update((links) => {
145
+ const link = links.find((l) => l.id === cardId);
146
+ if (!link)
147
+ throw new Error(`card ${cardId} not found`);
148
+ if (!link.stage)
149
+ throw new Error("this card is not part of an AI-DLC intent");
150
+ link.gate = { stage: link.stage, status: "pending", note: note?.trim() || undefined, at: nowIso() };
151
+ link.column = "in_progress";
152
+ link.manualOverrides.column = true;
153
+ link.updatedAt = nowIso();
154
+ out = link;
155
+ return links;
156
+ });
157
+ return out;
158
+ }
159
+ function queuePrompt(link, body) {
160
+ (link.queuedPrompts ??= []).push({ id: ksuid("qp"), body, sendAutomatically: true });
161
+ }
162
+ export async function approveGate(deps, cardId) {
163
+ const links = await deps.store.readLinks();
164
+ const link = links.find((l) => l.id === cardId);
165
+ if (!link?.intentId || !link.stage)
166
+ throw new Error("this card has no AI-DLC stage to approve");
167
+ if (link.gate?.status !== "pending")
168
+ throw new Error("no review was requested for this stage");
169
+ const intent = await deps.intents.get(link.intentId);
170
+ if (!intent)
171
+ throw new Error(`intent ${link.intentId} not found`);
172
+ const created = [];
173
+ let units = [];
174
+ if (link.stage === "units") {
175
+ const checkout = cardCheckout(link);
176
+ const file = checkout ? path.join(checkout, intent.artifactsDir, "units.md") : "";
177
+ let text = "";
178
+ try {
179
+ text = await fs.promises.readFile(file, "utf8");
180
+ }
181
+ catch {
182
+ throw new Error(`${intent.artifactsDir}/units.md was not found in the card's checkout`);
183
+ }
184
+ units = parseUnits(text);
185
+ if (units.length === 0)
186
+ throw new Error("units.md has no '## <unit>' sections");
187
+ }
188
+ await deps.store.update((all) => {
189
+ const l = all.find((x) => x.id === cardId);
190
+ l.gate = { ...l.gate, status: "approved", at: nowIso() };
191
+ l.manualOverrides.column = true;
192
+ l.updatedAt = nowIso();
193
+ switch (l.stage) {
194
+ case "requirements":
195
+ l.stage = "units";
196
+ l.column = "in_progress";
197
+ queuePrompt(l, unitsPrompt(intent));
198
+ break;
199
+ case "units":
200
+ // Inception is finished; each unit becomes its own card.
201
+ l.column = "done";
202
+ for (const u of units) {
203
+ const card = newLinkBase();
204
+ card.name = u.title;
205
+ card.projectPath = intent.projectPath;
206
+ card.promptBody = unitPrompt(intent, u);
207
+ card.intentId = intent.id;
208
+ card.stage = "design";
209
+ all.push(card);
210
+ created.push(card);
211
+ }
212
+ break;
213
+ case "design":
214
+ l.stage = "code";
215
+ l.column = "in_progress";
216
+ queuePrompt(l, codePrompt(intent));
217
+ break;
218
+ default:
219
+ // code / review end at the pull request merge, not at a gate.
220
+ l.column = "in_progress";
221
+ }
222
+ return all;
223
+ });
224
+ if (created.length) {
225
+ intent.unitCardIds.push(...created.map((c) => c.id));
226
+ intent.phase = "construction";
227
+ await deps.intents.upsert(intent);
228
+ }
229
+ const card = (await deps.store.readLinks()).find((l) => l.id === cardId);
230
+ return { card, created, intent };
231
+ }
232
+ export async function rejectGate(deps, cardId, note) {
233
+ const reason = note.trim();
234
+ if (!reason)
235
+ throw new Error("say why the stage is sent back");
236
+ let out;
237
+ await deps.store.update((links) => {
238
+ const l = links.find((x) => x.id === cardId);
239
+ if (!l?.stage || l.gate?.status !== "pending")
240
+ throw new Error("no review is pending on this card");
241
+ l.gate = { stage: l.stage, status: "rejected", note: reason, at: nowIso() };
242
+ l.column = "in_progress";
243
+ l.manualOverrides.column = true;
244
+ queuePrompt(l, rejectPrompt(l.stage, reason));
245
+ l.updatedAt = nowIso();
246
+ out = l;
247
+ return links;
248
+ });
249
+ return out;
250
+ }
251
+ /** Mark intents done once every unit card is done. Returns the intents that changed. */
252
+ export async function settleIntents(deps) {
253
+ const links = new Map((await deps.store.readLinks()).map((l) => [l.id, l]));
254
+ const changed = [];
255
+ for (const intent of await deps.intents.list()) {
256
+ if (intent.phase !== "construction" || intent.unitCardIds.length === 0)
257
+ continue;
258
+ const allDone = intent.unitCardIds.every((id) => {
259
+ const l = links.get(id);
260
+ return !l || l.column === "done";
261
+ });
262
+ if (allDone) {
263
+ intent.phase = "done";
264
+ changed.push(await deps.intents.upsert(intent));
265
+ }
266
+ }
267
+ return changed;
268
+ }
@@ -0,0 +1,291 @@
1
+ // Index over every knowledge base: an in-memory scope index (path → entries)
2
+ // and text search in each KB's store (SQLite FTS5, or memory on old Node),
3
+ // fused with reciprocal rank fusion. Files are re-read only when their mtime
4
+ // or size changes, and with SQLite that holds across restarts too. The
5
+ // markdown files stay the source of truth; the index can be thrown away.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { tokenize } from "../transcripts.js";
9
+ import { normalizeScope, parseEntry } from "./entry.js";
10
+ import { openStore } from "./kbStore.js";
11
+ /** Bigger = more useful when injected. Kept close to 1 so they break ties, not rankings. */
12
+ const TYPE_WEIGHT = {
13
+ gotcha: 1.1,
14
+ convention: 1.08,
15
+ decision: 1.05,
16
+ runbook: 1.0,
17
+ component: 1.0,
18
+ glossary: 0.9,
19
+ };
20
+ /** Small k: candidate lists are short, so rank 1 vs 2 should matter. */
21
+ const RRF_K = 10;
22
+ /** Text candidates fetched per KB before routing and supersede filters. */
23
+ const TEXT_CANDIDATES = 200;
24
+ export class KbIndex {
25
+ storeDir;
26
+ sources = new Map();
27
+ stores = new Map();
28
+ docs = new Map(); // kbId → file → doc
29
+ byId = new Map();
30
+ superseded = new Set();
31
+ stale = new Set();
32
+ usefulness = new Map();
33
+ /** `storeDir`: where per-KB SQLite files live; omit for a memory-only index. */
34
+ constructor(storeDir) {
35
+ this.storeDir = storeDir;
36
+ }
37
+ setSources(sources) {
38
+ this.sources = new Map(sources.map((s) => [s.id, s]));
39
+ for (const id of [...this.docs.keys()]) {
40
+ if (this.sources.has(id))
41
+ continue;
42
+ this.docs.delete(id);
43
+ this.stores.get(id)?.close();
44
+ this.stores.delete(id);
45
+ }
46
+ this.rebuildMaps();
47
+ }
48
+ listSources() {
49
+ return [...this.sources.values()];
50
+ }
51
+ /** "sqlite" when every store is on disk, else "memory". */
52
+ storeKind() {
53
+ return [...this.stores.values()].every((s) => s.kind === "sqlite") && this.stores.size ? "sqlite" : "memory";
54
+ }
55
+ /** Re-read changed files in every source. Returns how many entries changed. */
56
+ async reindex() {
57
+ let changed = 0;
58
+ for (const src of this.sources.values())
59
+ changed += await this.reindexSource(src);
60
+ if (changed)
61
+ this.rebuildMaps();
62
+ return changed;
63
+ }
64
+ store(kbId) {
65
+ let s = this.stores.get(kbId);
66
+ if (!s)
67
+ this.stores.set(kbId, (s = openStore(this.storeDir, kbId)));
68
+ return s;
69
+ }
70
+ async reindexSource(src) {
71
+ const store = this.store(src.id);
72
+ // First pass after start: what the store already holds (nothing for memory).
73
+ const prev = this.docs.get(src.id) ?? new Map(store.load().map((f) => [f.rel, f]));
74
+ const next = new Map();
75
+ const upserts = [];
76
+ for (const rel of await listMarkdown(src.root)) {
77
+ const abs = path.join(src.root, rel);
78
+ let st;
79
+ try {
80
+ st = await fs.promises.stat(abs);
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ const old = prev.get(rel);
86
+ if (old && old.mtimeMs === st.mtimeMs && old.size === st.size) {
87
+ next.set(rel, old);
88
+ continue;
89
+ }
90
+ const entry = parseEntry(await fs.promises.readFile(abs, "utf8"), src.id, rel);
91
+ if (!entry)
92
+ continue;
93
+ const file = { rel, mtimeMs: st.mtimeMs, size: st.size, entry };
94
+ next.set(rel, file);
95
+ upserts.push(file);
96
+ }
97
+ const removed = [...prev.keys()].filter((k) => !next.has(k));
98
+ store.apply(upserts, removed);
99
+ const firstLoad = !this.docs.has(src.id);
100
+ this.docs.set(src.id, next);
101
+ // Loading from the store is a change for this process even when the files are not.
102
+ return upserts.length + removed.length + (firstLoad ? next.size - upserts.length : 0);
103
+ }
104
+ rebuildMaps() {
105
+ this.byId = new Map();
106
+ this.superseded = new Set();
107
+ for (const m of this.docs.values()) {
108
+ for (const f of m.values()) {
109
+ this.byId.set(f.entry.id, f.entry);
110
+ for (const id of f.entry.supersedes)
111
+ this.superseded.add(id);
112
+ }
113
+ }
114
+ }
115
+ /** Close every store (tests, shutdown). */
116
+ close() {
117
+ for (const s of this.stores.values())
118
+ s.close();
119
+ this.stores.clear();
120
+ this.docs.clear();
121
+ this.rebuildMaps();
122
+ }
123
+ all(kbId) {
124
+ const kbs = kbId ? [this.docs.get(kbId)].filter(Boolean) : [...this.docs.values()];
125
+ return kbs.flatMap((m) => [...m.values()].map((d) => d.entry));
126
+ }
127
+ get(id) {
128
+ return this.byId.get(id);
129
+ }
130
+ /** Superseded by another indexed entry: it never surfaces. */
131
+ isSuperseded(id) {
132
+ return this.superseded.has(id);
133
+ }
134
+ count(kbId) {
135
+ return this.docs.get(kbId)?.size ?? 0;
136
+ }
137
+ setStale(ids) {
138
+ this.stale = new Set(ids);
139
+ }
140
+ isStale(id) {
141
+ return this.stale.has(id);
142
+ }
143
+ /** Multipliers from usage stats (1 = neutral). */
144
+ setUsefulness(scores) {
145
+ this.usefulness = scores;
146
+ }
147
+ search(q) {
148
+ const limit = q.limit ?? 10;
149
+ const files = (q.files ?? []).map(normalizeScope).filter(Boolean);
150
+ const text = q.text?.trim() ?? "";
151
+ const kbIds = new Set(q.kbIds ?? []);
152
+ // Routing: a KB is searched when it is named, has no `covers`, or its covers match.
153
+ const only = q.onlyKbs?.length ? new Set(q.onlyKbs) : null;
154
+ const searched = [...this.sources.values()].filter((s) => only ? only.has(s.id) : kbIds.has(s.id) || s.covers.length === 0 || coversMatch(s.covers, files, text));
155
+ // Superseded entries never surface.
156
+ const live = searched.flatMap((s) => [...(this.docs.get(s.id)?.values() ?? [])].map((f) => f.entry).filter((e) => !this.superseded.has(e.id)));
157
+ if (live.length === 0)
158
+ return [];
159
+ const liveIds = new Set(live.map((e) => e.id));
160
+ const byScope = rankByScope(live, files);
161
+ // Each KB ranks its own text matches; the lists are interleaved by rank.
162
+ const byText = text
163
+ ? interleave(searched.map((s) => this.store(s.id)
164
+ .searchText(text, TEXT_CANDIDATES)
165
+ .filter((id) => liveIds.has(id))
166
+ .map((id) => this.byId.get(id))))
167
+ : [];
168
+ // No signal at all (a fresh session): fall back to the named KBs' project-wide primer.
169
+ const byPrior = byScope.length || byText.length ? [] : rankByPrior(live.filter((e) => kbIds.has(e.kbId)));
170
+ const fused = new Map();
171
+ const add = (ranked, via) => ranked.forEach((entry, rank) => {
172
+ const cur = fused.get(entry.id) ?? { entry, score: 0, via: new Set() };
173
+ cur.score += 1 / (RRF_K + rank + 1);
174
+ cur.via.add(via);
175
+ fused.set(entry.id, cur);
176
+ });
177
+ add(byScope, "scope");
178
+ add(byText, "text");
179
+ add(byPrior, "primer");
180
+ return [...fused.values()]
181
+ .map(({ entry: e, score, via }) => {
182
+ const weight = (this.sources.get(e.kbId)?.weight ?? 1) *
183
+ TYPE_WEIGHT[e.type] *
184
+ (this.usefulness.get(e.id) ?? 1) *
185
+ (this.stale.has(e.id) ? 0.6 : 1) *
186
+ (e.confidence === "proposed" ? 0.8 : 1);
187
+ return { entry: e, score: score * weight, via: [...via], stale: this.stale.has(e.id) };
188
+ })
189
+ .sort((a, b) => b.score - a.score)
190
+ .slice(0, limit);
191
+ }
192
+ }
193
+ /** Round-robin by rank: every KB's best match before any KB's second. */
194
+ function interleave(lists) {
195
+ const out = [];
196
+ const longest = Math.max(0, ...lists.map((l) => l.length));
197
+ for (let i = 0; i < longest; i++)
198
+ for (const l of lists)
199
+ if (i < l.length)
200
+ out.push(l[i]);
201
+ return out;
202
+ }
203
+ /** How specifically an entry's scope covers a file: exact file > nearest folder. */
204
+ export function scopeSpecificity(scope, file) {
205
+ if (!scope || !file)
206
+ return 0;
207
+ if (scope === file)
208
+ return 3;
209
+ const dir = scope.endsWith("/") ? scope : `${scope}/`;
210
+ if (file.startsWith(dir)) {
211
+ const depth = file.slice(dir.length).split("/").length; // 1 = file directly inside
212
+ return 2 / depth;
213
+ }
214
+ return 0;
215
+ }
216
+ function rankByScope(entries, files) {
217
+ if (files.length === 0)
218
+ return [];
219
+ return entries
220
+ .map((d) => ({
221
+ d,
222
+ s: Math.max(0, ...d.scope.flatMap((sc) => files.map((f) => scopeSpecificity(sc, f)))),
223
+ }))
224
+ .filter((x) => x.s > 0)
225
+ .sort((a, b) => b.s - a.s)
226
+ .map((x) => x.d);
227
+ }
228
+ /** Project-wide entries first, then by type; used when the query says nothing. */
229
+ function rankByPrior(entries) {
230
+ return [...entries].sort((a, b) => Number(a.scope.length > 0) - Number(b.scope.length > 0) ||
231
+ TYPE_WEIGHT[b.type] - TYPE_WEIGHT[a.type] ||
232
+ a.title.localeCompare(b.title));
233
+ }
234
+ export function coversMatch(covers, files, text) {
235
+ const lower = text.toLowerCase();
236
+ return covers.some((c) => {
237
+ const cov = c.trim().toLowerCase();
238
+ if (!cov)
239
+ return false;
240
+ if (cov.includes("/") || cov.includes("*")) {
241
+ const prefix = cov.replace(/\*\*?.*$/, "");
242
+ return files.some((f) => f.toLowerCase().startsWith(prefix));
243
+ }
244
+ return tokenize(lower).includes(cov) || files.some((f) => f.toLowerCase().includes(cov));
245
+ });
246
+ }
247
+ async function listMarkdown(root) {
248
+ const out = [];
249
+ const walk = async (dir) => {
250
+ let items;
251
+ try {
252
+ items = await fs.promises.readdir(dir, { withFileTypes: true });
253
+ }
254
+ catch {
255
+ return;
256
+ }
257
+ for (const it of items) {
258
+ if (it.name.startsWith("."))
259
+ continue;
260
+ const abs = path.join(dir, it.name);
261
+ if (it.isDirectory())
262
+ await walk(abs);
263
+ else if (it.name.endsWith(".md") && it.name.toLowerCase() !== "readme.md") {
264
+ out.push(path.relative(root, abs).replaceAll("\\", "/"));
265
+ }
266
+ }
267
+ };
268
+ await walk(root);
269
+ return out.sort();
270
+ }
271
+ /** Compact lines for SessionStart: type, title, summary and how to read more. */
272
+ export function formatContext(hits, tokenBudget) {
273
+ if (hits.length === 0)
274
+ return "";
275
+ const header = "Project knowledge (from the team knowledge base). Read an entry with `devboard kb show <id>`; " +
276
+ "search with `devboard kb search <words>`. Do not copy these notes into the repository.";
277
+ const lines = [];
278
+ let used = approxTokens(header);
279
+ for (const h of hits) {
280
+ const e = h.entry;
281
+ const line = `- [${e.type}] ${e.title} — ${e.summary} (${e.id}${h.stale ? ", may be outdated" : ""})`;
282
+ const cost = approxTokens(line);
283
+ if (used + cost > tokenBudget)
284
+ break;
285
+ lines.push(line);
286
+ used += cost;
287
+ }
288
+ return lines.length ? `${header}\n${lines.join("\n")}` : "";
289
+ }
290
+ /** ~4 characters per token is close enough for a budget. */
291
+ export const approxTokens = (s) => Math.ceil(s.length / 4);