@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.
- package/.claude/skills/devboard/SKILL.md +111 -0
- package/bin/devboard.mjs +59 -0
- package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
- package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
- package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
- package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
- package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
- package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
- package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
- package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
- package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
- package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
- package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
- package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
- package/dist/assets/Surface-BQKeigoq.js +1 -0
- package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
- package/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/dist/assets/Terminal-yq90NOBX.js +80 -0
- package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
- package/dist/assets/index-BtXUncAU.css +1 -0
- package/dist/assets/index-CQnL8JJ5.js +18 -0
- package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
- package/dist/assets/plus-Cecrm8sZ.js +1 -0
- package/dist/assets/react-dom-DjtJUrx3.js +1 -0
- package/dist/assets/x-B6wVesqk.js +1 -0
- package/dist/index.html +22 -0
- package/package.json +81 -4
- package/server-dist/server/assets/remote-shell.sh +239 -0
- package/server-dist/server/board.js +486 -0
- package/server-dist/server/commands.js +390 -0
- package/server-dist/server/context.js +34 -0
- package/server-dist/server/coordination.js +442 -0
- package/server-dist/server/editor.js +101 -0
- package/server-dist/server/events.js +17 -0
- package/server-dist/server/fsBrowse.js +143 -0
- package/server-dist/server/git.js +258 -0
- package/server-dist/server/harness/config.js +90 -0
- package/server-dist/server/harness/entry.js +98 -0
- package/server-dist/server/harness/intents.js +268 -0
- package/server-dist/server/harness/kbIndex.js +291 -0
- package/server-dist/server/harness/kbStore.js +258 -0
- package/server-dist/server/harness/librarian.js +147 -0
- package/server-dist/server/harness/pack.js +182 -0
- package/server-dist/server/harness/proposals.js +113 -0
- package/server-dist/server/harness/repo.js +150 -0
- package/server-dist/server/harness/scrub.js +30 -0
- package/server-dist/server/harness/service.js +683 -0
- package/server-dist/server/harness/template/README.md +38 -0
- package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
- package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
- package/server-dist/server/harness/template/agents/test-writer.md +16 -0
- package/server-dist/server/harness/template/agents.yaml +41 -0
- package/server-dist/server/harness/template/harness.json +11 -0
- package/server-dist/server/harness/template/kb/team/README.md +4 -0
- package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
- package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
- package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
- package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
- package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
- package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
- package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
- package/server-dist/server/harness/usage.js +82 -0
- package/server-dist/server/hooks.js +222 -0
- package/server-dist/server/http.js +266 -0
- package/server-dist/server/index.js +77 -0
- package/server-dist/server/integrations.js +292 -0
- package/server-dist/server/pollers.js +332 -0
- package/server-dist/server/processes.js +123 -0
- package/server-dist/server/psmux.js +120 -0
- package/server-dist/server/pty.js +93 -0
- package/server-dist/server/remote.js +216 -0
- package/server-dist/server/sessionReaper.js +101 -0
- package/server-dist/server/sessions.js +315 -0
- package/server-dist/server/settings.js +125 -0
- package/server-dist/server/skill.js +29 -0
- package/server-dist/server/tmux.js +217 -0
- package/server-dist/server/transcripts.js +334 -0
- package/server-dist/server/util.js +286 -0
- package/server-dist/src/types/index.js +53 -0
- package/README.md +0 -3
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Storage behind one knowledge base's index. With `node:sqlite` (Node ≥ 22.5)
|
|
2
|
+
// each KB gets its own file, `<data dir>/kb-index/<kb id>.sqlite`: an FTS5
|
|
3
|
+
// table for text search (BM25, title and summary weighted up) and the parsed
|
|
4
|
+
// entries, so a restart re-reads only files whose mtime or size changed.
|
|
5
|
+
// Without it (Node 20) the same interface is served from memory. The markdown
|
|
6
|
+
// files in the harness repo stay the source of truth; a store can be deleted.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { tokenize } from "../transcripts.js";
|
|
10
|
+
import { log } from "../util.js";
|
|
11
|
+
/** Max query terms; more adds cost without changing the top results. */
|
|
12
|
+
const MAX_TERMS = 32;
|
|
13
|
+
/** FTS5 query from free text: each word as a phrase, 3+ letters as a prefix, any may match. */
|
|
14
|
+
export function ftsQuery(text) {
|
|
15
|
+
const terms = [...new Set(tokenize(text))].slice(0, MAX_TERMS);
|
|
16
|
+
// tokenize() keeps only letters and digits, so nothing needs escaping inside the quotes.
|
|
17
|
+
return terms.map((t) => (t.length >= 3 ? `"${t}"*` : `"${t}"`)).join(" OR ");
|
|
18
|
+
}
|
|
19
|
+
let sqlite;
|
|
20
|
+
/** `node:sqlite` when this Node has it, else null. Its one-time "experimental" warning is not shown. */
|
|
21
|
+
export function loadSqlite() {
|
|
22
|
+
if (sqlite !== undefined)
|
|
23
|
+
return sqlite;
|
|
24
|
+
const emit = process.emitWarning;
|
|
25
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
26
|
+
if (String(warning).includes("SQLite is an experimental feature"))
|
|
27
|
+
return;
|
|
28
|
+
return emit.call(process, warning, ...rest);
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
sqlite = process.getBuiltinModule?.("node:sqlite") ?? null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
sqlite = null;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
process.emitWarning = emit;
|
|
38
|
+
}
|
|
39
|
+
return sqlite;
|
|
40
|
+
}
|
|
41
|
+
/** Bump when the schema changes: the store is then rebuilt from the markdown files. */
|
|
42
|
+
const SCHEMA_VERSION = "1";
|
|
43
|
+
const SCHEMA = `
|
|
44
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
46
|
+
id INTEGER PRIMARY KEY,
|
|
47
|
+
rel TEXT NOT NULL UNIQUE,
|
|
48
|
+
mtime REAL NOT NULL,
|
|
49
|
+
size INTEGER NOT NULL,
|
|
50
|
+
entry_id TEXT NOT NULL,
|
|
51
|
+
entry TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
|
|
54
|
+
title, summary, scope, body,
|
|
55
|
+
tokenize = 'unicode61 remove_diacritics 2',
|
|
56
|
+
prefix = '3'
|
|
57
|
+
);
|
|
58
|
+
`;
|
|
59
|
+
/** Column weights for bm25(): title, summary, scope, body. */
|
|
60
|
+
const BM25 = "bm25(fts, 3.0, 2.0, 1.0, 1.0)";
|
|
61
|
+
class SqliteKbStore {
|
|
62
|
+
kind = "sqlite";
|
|
63
|
+
db;
|
|
64
|
+
constructor(lib, file) {
|
|
65
|
+
this.db = new lib.DatabaseSync(file);
|
|
66
|
+
// A throwing constructor leaves no object to close, and an open handle blocks deleting the file on Windows.
|
|
67
|
+
try {
|
|
68
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
|
|
69
|
+
this.db.exec(SCHEMA);
|
|
70
|
+
const version = this.db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
|
|
71
|
+
if (version?.value !== SCHEMA_VERSION) {
|
|
72
|
+
this.db.exec("DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS fts;");
|
|
73
|
+
this.db.exec(SCHEMA);
|
|
74
|
+
this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema', ?)").run(SCHEMA_VERSION);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.db.close();
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
load() {
|
|
83
|
+
const rows = this.db.prepare("SELECT rel, mtime, size, entry FROM files").all();
|
|
84
|
+
return rows.map((r) => ({ rel: r.rel, mtimeMs: r.mtime, size: r.size, entry: JSON.parse(r.entry) }));
|
|
85
|
+
}
|
|
86
|
+
apply(upserts, removed) {
|
|
87
|
+
if (upserts.length === 0 && removed.length === 0)
|
|
88
|
+
return;
|
|
89
|
+
const idOf = this.db.prepare("SELECT id FROM files WHERE rel = ?");
|
|
90
|
+
const delFts = this.db.prepare("DELETE FROM fts WHERE rowid = ?");
|
|
91
|
+
const delFile = this.db.prepare("DELETE FROM files WHERE id = ?");
|
|
92
|
+
const insFile = this.db.prepare("INSERT INTO files (rel, mtime, size, entry_id, entry) VALUES (?, ?, ?, ?, ?) RETURNING id");
|
|
93
|
+
const insFts = this.db.prepare("INSERT INTO fts (rowid, title, summary, scope, body) VALUES (?, ?, ?, ?, ?)");
|
|
94
|
+
const drop = (rel) => {
|
|
95
|
+
const row = idOf.get(rel);
|
|
96
|
+
if (!row)
|
|
97
|
+
return;
|
|
98
|
+
delFts.run(row.id);
|
|
99
|
+
delFile.run(row.id);
|
|
100
|
+
};
|
|
101
|
+
this.db.exec("BEGIN");
|
|
102
|
+
try {
|
|
103
|
+
for (const rel of removed)
|
|
104
|
+
drop(rel);
|
|
105
|
+
for (const f of upserts) {
|
|
106
|
+
drop(f.rel);
|
|
107
|
+
const e = f.entry;
|
|
108
|
+
const { id } = insFile.get(f.rel, f.mtimeMs, f.size, e.id, JSON.stringify(e));
|
|
109
|
+
insFts.run(id, e.title, e.summary, e.scope.join(" "), e.body);
|
|
110
|
+
}
|
|
111
|
+
this.db.exec("COMMIT");
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
this.db.exec("ROLLBACK");
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
searchText(text, limit) {
|
|
119
|
+
const q = ftsQuery(text);
|
|
120
|
+
if (!q)
|
|
121
|
+
return [];
|
|
122
|
+
const rows = this.db
|
|
123
|
+
.prepare(`SELECT f.entry_id AS id FROM fts JOIN files f ON f.id = fts.rowid WHERE fts MATCH ? ORDER BY ${BM25} LIMIT ?`)
|
|
124
|
+
.all(q, limit);
|
|
125
|
+
return rows.map((r) => r.id);
|
|
126
|
+
}
|
|
127
|
+
close() {
|
|
128
|
+
try {
|
|
129
|
+
this.db.close();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// already closed
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── Memory (Node without node:sqlite) ─────────────────────────────────────────
|
|
137
|
+
const K1 = 1.2;
|
|
138
|
+
const B = 0.4;
|
|
139
|
+
/** BM25 over in-memory postings; prefix terms via a sorted vocabulary. */
|
|
140
|
+
class MemoryKbStore {
|
|
141
|
+
kind = "memory";
|
|
142
|
+
docs = new Map();
|
|
143
|
+
postings = null;
|
|
144
|
+
vocab = [];
|
|
145
|
+
avgLen = 0;
|
|
146
|
+
load() {
|
|
147
|
+
return [...this.docs.values()].map((d) => d.file);
|
|
148
|
+
}
|
|
149
|
+
apply(upserts, removed) {
|
|
150
|
+
for (const rel of removed)
|
|
151
|
+
this.docs.delete(rel);
|
|
152
|
+
for (const f of upserts) {
|
|
153
|
+
// Title and summary count more than the body, like the SQLite column weights.
|
|
154
|
+
const e = f.entry;
|
|
155
|
+
const tokens = tokenize(`${e.title} ${e.title} ${e.title} ${e.summary} ${e.summary} ${e.scope.join(" ")} ${e.body}`);
|
|
156
|
+
const tf = new Map();
|
|
157
|
+
for (const t of tokens)
|
|
158
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
159
|
+
this.docs.set(f.rel, { file: f, tf, len: tokens.length });
|
|
160
|
+
}
|
|
161
|
+
if (upserts.length || removed.length) {
|
|
162
|
+
this.postings = null;
|
|
163
|
+
this.build(); // now, so the first query (often a SessionStart hook) does not pay for it
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
build() {
|
|
167
|
+
if (this.postings)
|
|
168
|
+
return this.postings;
|
|
169
|
+
const postings = new Map();
|
|
170
|
+
let total = 0;
|
|
171
|
+
for (const d of this.docs.values()) {
|
|
172
|
+
total += d.len;
|
|
173
|
+
for (const [term, n] of d.tf) {
|
|
174
|
+
let p = postings.get(term);
|
|
175
|
+
if (!p)
|
|
176
|
+
postings.set(term, (p = new Map()));
|
|
177
|
+
p.set(d, n);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.vocab = [...postings.keys()].sort();
|
|
181
|
+
this.avgLen = this.docs.size ? total / this.docs.size : 0;
|
|
182
|
+
return (this.postings = postings);
|
|
183
|
+
}
|
|
184
|
+
searchText(text, limit) {
|
|
185
|
+
const terms = [...new Set(tokenize(text))].slice(0, MAX_TERMS);
|
|
186
|
+
const postings = this.build();
|
|
187
|
+
const n = this.docs.size;
|
|
188
|
+
if (terms.length === 0 || n === 0)
|
|
189
|
+
return [];
|
|
190
|
+
const scores = new Map();
|
|
191
|
+
for (const term of terms) {
|
|
192
|
+
const tfByDoc = new Map();
|
|
193
|
+
for (const t of matchingTerms(this.vocab, term)) {
|
|
194
|
+
for (const [d, c] of postings.get(t))
|
|
195
|
+
tfByDoc.set(d, (tfByDoc.get(d) ?? 0) + c);
|
|
196
|
+
}
|
|
197
|
+
if (tfByDoc.size === 0)
|
|
198
|
+
continue;
|
|
199
|
+
const idf = Math.log((n - tfByDoc.size + 0.5) / (tfByDoc.size + 0.5) + 1);
|
|
200
|
+
for (const [d, tf] of tfByDoc) {
|
|
201
|
+
const norm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * d.len) / this.avgLen));
|
|
202
|
+
scores.set(d, (scores.get(d) ?? 0) + idf * norm);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [...scores.entries()]
|
|
206
|
+
.sort((a, b) => b[1] - a[1])
|
|
207
|
+
.slice(0, limit)
|
|
208
|
+
.map(([d]) => d.file.entry.id);
|
|
209
|
+
}
|
|
210
|
+
close() {
|
|
211
|
+
this.docs.clear();
|
|
212
|
+
this.postings = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Vocabulary terms a query term matches: itself, or every term it prefixes (3+ characters). */
|
|
216
|
+
function matchingTerms(vocab, term) {
|
|
217
|
+
let lo = 0;
|
|
218
|
+
let hi = vocab.length;
|
|
219
|
+
while (lo < hi) {
|
|
220
|
+
const mid = (lo + hi) >> 1;
|
|
221
|
+
if (vocab[mid] < term)
|
|
222
|
+
lo = mid + 1;
|
|
223
|
+
else
|
|
224
|
+
hi = mid;
|
|
225
|
+
}
|
|
226
|
+
if (term.length < 3)
|
|
227
|
+
return vocab[lo] === term ? [term] : [];
|
|
228
|
+
const out = [];
|
|
229
|
+
for (let i = lo; i < vocab.length && vocab[i].startsWith(term); i++)
|
|
230
|
+
out.push(vocab[i]);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
// ── Opening ──────────────────────────────────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* The store for one KB: SQLite under `dir` when possible, else memory. A file
|
|
236
|
+
* SQLite cannot open (corrupt, wrong format) is deleted and rebuilt.
|
|
237
|
+
*/
|
|
238
|
+
export function openStore(dir, kbId) {
|
|
239
|
+
const lib = dir ? loadSqlite() : null;
|
|
240
|
+
if (!dir || !lib)
|
|
241
|
+
return new MemoryKbStore();
|
|
242
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
243
|
+
const file = path.join(dir, `${kbId.replace(/[^\w.-]/g, "_")}.sqlite`);
|
|
244
|
+
try {
|
|
245
|
+
return new SqliteKbStore(lib, file);
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
log.warn("harness", `index ${file} unusable (${e instanceof Error ? e.message : e}); rebuilding`);
|
|
249
|
+
for (const f of [file, `${file}-wal`, `${file}-shm`])
|
|
250
|
+
fs.rmSync(f, { force: true });
|
|
251
|
+
try {
|
|
252
|
+
return new SqliteKbStore(lib, file);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return new MemoryKbStore();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The librarian turns finished work into proposed knowledge entries. It reads
|
|
2
|
+
// the tail of a session transcript (or, when a project is onboarded, the
|
|
3
|
+
// project's own docs), scrubs it, asks a small model through `claude -p`, and
|
|
4
|
+
// returns proposals for a person to review. It never writes to a KB itself.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { KNOWLEDGE_TYPES } from "../../src/types/index.js";
|
|
8
|
+
import { log, readLines, run } from "../util.js";
|
|
9
|
+
import { isKnowledgeType } from "./entry.js";
|
|
10
|
+
import { scrub } from "./scrub.js";
|
|
11
|
+
const MAX_PROPOSALS = 5;
|
|
12
|
+
export const LIBRARIAN_PROMPT_HEAD = "You are the librarian of a team knowledge base";
|
|
13
|
+
export function buildPrompt(input) {
|
|
14
|
+
const what = input.mode === "session"
|
|
15
|
+
? `Below is the end of a Claude Code session${input.cardTitle ? ` for the task "${input.cardTitle}"` : ""} in the project "${input.project}".`
|
|
16
|
+
: `Below are the README, CLAUDE.md, docs and recent commit history of the project "${input.project}", which was just added.`;
|
|
17
|
+
return [
|
|
18
|
+
`${LIBRARIAN_PROMPT_HEAD} for software projects.`,
|
|
19
|
+
what,
|
|
20
|
+
"",
|
|
21
|
+
`Extract at most ${MAX_PROPOSALS} pieces of durable project knowledge that would help a future agent working in this repository.`,
|
|
22
|
+
"Keep only what is true beyond this one task: decisions and their reasons, conventions, gotchas (things that broke and why), what a component is for, project terms, and how to run or debug things.",
|
|
23
|
+
"Skip task status, anything obvious from reading the code, personal preferences, secrets, and anything you are unsure about.",
|
|
24
|
+
"",
|
|
25
|
+
`Types: ${KNOWLEDGE_TYPES.join(", ")}.`,
|
|
26
|
+
"scope: repo-relative files or folders (folders end with /) the knowledge applies to; [] when project-wide.",
|
|
27
|
+
"summary: one sentence under 200 characters. body: a few short paragraphs in markdown.",
|
|
28
|
+
"",
|
|
29
|
+
"Already in the knowledge base (do not repeat):",
|
|
30
|
+
...(input.existingTitles.length ? input.existingTitles.slice(0, 200).map((t) => `- ${t}`) : ["- (nothing yet)"]),
|
|
31
|
+
"",
|
|
32
|
+
'Answer with JSON only, no prose: {"proposals":[{"type":"gotcha","title":"…","summary":"…","body":"…","scope":["…"],"reason":"why this is worth keeping"}]}',
|
|
33
|
+
'Answer {"proposals":[]} when nothing qualifies.',
|
|
34
|
+
"",
|
|
35
|
+
"<material>",
|
|
36
|
+
input.material,
|
|
37
|
+
"</material>",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
/** Pull the first JSON object out of a model reply and keep only valid drafts. */
|
|
41
|
+
export function parseDrafts(reply) {
|
|
42
|
+
const start = reply.indexOf("{");
|
|
43
|
+
const end = reply.lastIndexOf("}");
|
|
44
|
+
if (start < 0 || end <= start)
|
|
45
|
+
return [];
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(reply.slice(start, end + 1));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const list = Array.isArray(parsed?.proposals) ? parsed.proposals : [];
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const p of list) {
|
|
56
|
+
if (!p || !isKnowledgeType(p.type))
|
|
57
|
+
continue;
|
|
58
|
+
const title = typeof p.title === "string" ? p.title.trim() : "";
|
|
59
|
+
const body = typeof p.body === "string" ? p.body.trim() : "";
|
|
60
|
+
if (!title || !body)
|
|
61
|
+
continue;
|
|
62
|
+
out.push({
|
|
63
|
+
type: p.type,
|
|
64
|
+
title,
|
|
65
|
+
summary: typeof p.summary === "string" ? p.summary.trim() : title,
|
|
66
|
+
body,
|
|
67
|
+
scope: Array.isArray(p.scope) ? p.scope.filter((s) => typeof s === "string") : [],
|
|
68
|
+
reason: typeof p.reason === "string" ? p.reason : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out.slice(0, MAX_PROPOSALS);
|
|
72
|
+
}
|
|
73
|
+
/** Run the prompt through `claude -p`. Resolves [] (and logs) on any failure. */
|
|
74
|
+
export async function runLibrarian(input, model, cwd) {
|
|
75
|
+
const prompt = buildPrompt({ ...input, material: scrub(input.material) });
|
|
76
|
+
// No persisted session: a transcript would show up as a board card and be distilled again.
|
|
77
|
+
const r = await run("claude", ["-p", "--no-session-persistence", "--model", model, "--output-format", "text"], {
|
|
78
|
+
input: prompt,
|
|
79
|
+
cwd,
|
|
80
|
+
timeoutMs: 300_000,
|
|
81
|
+
});
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
log.warn("librarian", `claude -p failed: ${(r.stderr || r.stdout).trim().slice(0, 300)}`);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return parseDrafts(r.stdout);
|
|
87
|
+
}
|
|
88
|
+
/** The text of user and assistant turns, last `maxChars` characters. */
|
|
89
|
+
export async function transcriptTail(jsonlPath, maxChars) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
for await (const line of readLines(jsonlPath)) {
|
|
92
|
+
let obj;
|
|
93
|
+
try {
|
|
94
|
+
obj = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const role = obj?.message?.role ?? obj?.type;
|
|
100
|
+
if (role !== "user" && role !== "assistant")
|
|
101
|
+
continue;
|
|
102
|
+
const content = obj?.message?.content;
|
|
103
|
+
const text = typeof content === "string"
|
|
104
|
+
? content
|
|
105
|
+
: Array.isArray(content)
|
|
106
|
+
? content
|
|
107
|
+
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
108
|
+
.map((b) => b.text)
|
|
109
|
+
.join("\n")
|
|
110
|
+
: "";
|
|
111
|
+
if (text.trim())
|
|
112
|
+
parts.push(`${role}: ${text.trim()}`);
|
|
113
|
+
}
|
|
114
|
+
const all = parts.join("\n\n");
|
|
115
|
+
return all.length > maxChars ? all.slice(all.length - maxChars) : all;
|
|
116
|
+
}
|
|
117
|
+
/** README, CLAUDE.md, docs/*.md and the last 50 commit subjects, capped at `maxChars`. */
|
|
118
|
+
export async function projectMaterial(root, maxChars) {
|
|
119
|
+
const files = ["README.md", "CLAUDE.md", "CONTRIBUTING.md", "ARCHITECTURE.md"];
|
|
120
|
+
try {
|
|
121
|
+
const docs = await fs.promises.readdir(path.join(root, "docs"));
|
|
122
|
+
files.push(...docs.filter((f) => f.endsWith(".md")).map((f) => `docs/${f}`));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// no docs folder
|
|
126
|
+
}
|
|
127
|
+
const parts = [];
|
|
128
|
+
let budget = maxChars;
|
|
129
|
+
for (const rel of files) {
|
|
130
|
+
if (budget <= 0)
|
|
131
|
+
break;
|
|
132
|
+
let text;
|
|
133
|
+
try {
|
|
134
|
+
text = await fs.promises.readFile(path.join(root, rel), "utf8");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const chunk = `## ${rel}\n${text.slice(0, Math.min(budget, 12_000))}`;
|
|
140
|
+
parts.push(chunk);
|
|
141
|
+
budget -= chunk.length;
|
|
142
|
+
}
|
|
143
|
+
const logOut = await run("git", ["log", "-50", "--format=%s"], { cwd: root, timeoutMs: 20_000 });
|
|
144
|
+
if (logOut.ok && budget > 0)
|
|
145
|
+
parts.push(`## Recent commits\n${logOut.stdout.slice(0, budget)}`);
|
|
146
|
+
return parts.join("\n\n");
|
|
147
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The agent pack: skills, subagents and the SessionStart hook from the
|
|
2
|
+
// harness repo, installed at user level (~/.claude) so every project on the
|
|
3
|
+
// machine can use them. On the server they are symlinked (a `git pull`
|
|
4
|
+
// updates them); member machines get copies through `devboard harness install`.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { IS_WINDOWS, log } from "../util.js";
|
|
9
|
+
const MAX_PACK_FILE = 256 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Hook command for SessionStart: posts the hook payload to the server's
|
|
12
|
+
* `kb_session_context` and prints the result. `{"result":X}` is unwrapped
|
|
13
|
+
* with sed so the script needs nothing beyond bash and curl. Any failure
|
|
14
|
+
* prints nothing, so a server that is down never blocks a session.
|
|
15
|
+
*/
|
|
16
|
+
export function hookScriptContent(serverUrl, token) {
|
|
17
|
+
const q = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
18
|
+
return [
|
|
19
|
+
"#!/usr/bin/env bash",
|
|
20
|
+
"# Managed by DevBoard (knowledge harness). Edits here will be overwritten.",
|
|
21
|
+
"set -u",
|
|
22
|
+
`URL=\${DEVBOARD_URL:-${q(serverUrl)}}`,
|
|
23
|
+
`TOKEN=\${DEVBOARD_TOKEN:-${q(token ?? "")}}`,
|
|
24
|
+
"PAYLOAD=$(cat)",
|
|
25
|
+
'AUTH=(); [ -n "$TOKEN" ] && AUTH=(-H "Authorization: Bearer $TOKEN")',
|
|
26
|
+
'RESP=$(curl -fsS --max-time 5 "${AUTH[@]}" -H "Content-Type: application/json" \\',
|
|
27
|
+
' --data-binary "$PAYLOAD" "$URL/api/invoke/kb_session_context" 2>/dev/null) || exit 0',
|
|
28
|
+
"printf '%s' \"$RESP\" | sed -e 's/^{\"result\"://' -e 's/}$//'",
|
|
29
|
+
"",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
export const HOOK_SCRIPT_NAME = "kb-hook.sh";
|
|
33
|
+
/** Add `command` to `event` in a Claude settings object, idempotently. */
|
|
34
|
+
export function mergeHookCommand(data, event, command) {
|
|
35
|
+
data ??= {};
|
|
36
|
+
const hooks = (data.hooks ??= {});
|
|
37
|
+
const groups = (hooks[event] ??= []);
|
|
38
|
+
if (groups.length === 0)
|
|
39
|
+
groups.push({ hooks: [] });
|
|
40
|
+
const inner = (groups[0].hooks ??= []);
|
|
41
|
+
if (!inner.some((h) => h?.command === command))
|
|
42
|
+
inner.push({ type: "command", command });
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
export function claudeDir() {
|
|
46
|
+
return path.join(os.homedir(), ".claude");
|
|
47
|
+
}
|
|
48
|
+
async function writeSettingsHook(command) {
|
|
49
|
+
const settingsPath = path.join(claudeDir(), "settings.json");
|
|
50
|
+
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
51
|
+
let data = {};
|
|
52
|
+
try {
|
|
53
|
+
data = JSON.parse(await fs.promises.readFile(settingsPath, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e?.code !== "ENOENT")
|
|
57
|
+
await fs.promises.rename(settingsPath, `${settingsPath}.bak`).catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
await fs.promises.writeFile(settingsPath, JSON.stringify(mergeHookCommand(data, "SessionStart", command), null, 2));
|
|
60
|
+
}
|
|
61
|
+
/** Install the hook script that points at this server. */
|
|
62
|
+
export async function installHook(dataDir, serverUrl, token) {
|
|
63
|
+
const script = path.join(dataDir, HOOK_SCRIPT_NAME);
|
|
64
|
+
await fs.promises.mkdir(dataDir, { recursive: true });
|
|
65
|
+
await fs.promises.writeFile(script, hookScriptContent(serverUrl, token), { mode: 0o700 });
|
|
66
|
+
await writeSettingsHook(IS_WINDOWS ? script.replaceAll("\\", "/") : script);
|
|
67
|
+
return script;
|
|
68
|
+
}
|
|
69
|
+
async function sameContent(a, b) {
|
|
70
|
+
try {
|
|
71
|
+
return (await fs.promises.readFile(a)).equals(await fs.promises.readFile(b));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Symlink each pack skill and subagent into ~/.claude, never replacing a user's own. */
|
|
78
|
+
export async function linkPack(repoPath) {
|
|
79
|
+
const linked = [];
|
|
80
|
+
const skipped = [];
|
|
81
|
+
const link = async (src, dest, type) => {
|
|
82
|
+
try {
|
|
83
|
+
const cur = await fs.promises.lstat(dest).catch(() => null);
|
|
84
|
+
if (cur) {
|
|
85
|
+
// A copy made by the EPERM fallback below: identical content is ours.
|
|
86
|
+
if (type === "file" && cur.isFile() && (await sameContent(src, dest)))
|
|
87
|
+
return;
|
|
88
|
+
const target = cur.isSymbolicLink() ? await fs.promises.readlink(dest) : null;
|
|
89
|
+
if (target && path.resolve(path.dirname(dest), target) === src)
|
|
90
|
+
return;
|
|
91
|
+
// A dangling link (e.g. made from an unexpanded "~" path) is ours to replace.
|
|
92
|
+
const dangling = !!target && !fs.existsSync(dest);
|
|
93
|
+
if (!dangling && (!target || !target.startsWith(repoPath))) {
|
|
94
|
+
skipped.push(dest);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await fs.promises.rm(dest, { force: true });
|
|
98
|
+
}
|
|
99
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
100
|
+
try {
|
|
101
|
+
await fs.promises.symlink(src, dest, IS_WINDOWS ? (type === "dir" ? "junction" : "file") : undefined);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
// Windows file symlinks need admin or Developer Mode; copy instead.
|
|
105
|
+
if (e?.code !== "EPERM" || type !== "file")
|
|
106
|
+
throw e;
|
|
107
|
+
await fs.promises.copyFile(src, dest);
|
|
108
|
+
}
|
|
109
|
+
linked.push(dest);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
log.warn("harness", `link ${dest} failed: ${e instanceof Error ? e.message : e}`);
|
|
113
|
+
skipped.push(dest);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
for (const name of await listDirs(path.join(repoPath, "skills"))) {
|
|
117
|
+
const src = path.join(repoPath, "skills", name);
|
|
118
|
+
if (fs.existsSync(path.join(src, "SKILL.md")))
|
|
119
|
+
await link(src, path.join(claudeDir(), "skills", name), "dir");
|
|
120
|
+
}
|
|
121
|
+
for (const file of await listFiles(path.join(repoPath, "agents"), ".md")) {
|
|
122
|
+
await link(path.join(repoPath, "agents", file), path.join(claudeDir(), "agents", file), "file");
|
|
123
|
+
}
|
|
124
|
+
return { linked, skipped };
|
|
125
|
+
}
|
|
126
|
+
/** Everything a member machine needs, as file contents. */
|
|
127
|
+
export async function buildBundle(repoPath, version) {
|
|
128
|
+
const skills = [];
|
|
129
|
+
for (const name of await listDirs(path.join(repoPath, "skills"))) {
|
|
130
|
+
const root = path.join(repoPath, "skills", name);
|
|
131
|
+
if (!fs.existsSync(path.join(root, "SKILL.md")))
|
|
132
|
+
continue;
|
|
133
|
+
skills.push({ name, files: await readTree(root) });
|
|
134
|
+
}
|
|
135
|
+
const agents = [];
|
|
136
|
+
for (const file of await listFiles(path.join(repoPath, "agents"), ".md")) {
|
|
137
|
+
agents.push({ path: file, content: await fs.promises.readFile(path.join(repoPath, "agents", file), "utf8") });
|
|
138
|
+
}
|
|
139
|
+
return { version, skills, agents };
|
|
140
|
+
}
|
|
141
|
+
async function readTree(root) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const walk = async (dir) => {
|
|
144
|
+
for (const it of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
145
|
+
if (it.name.startsWith("."))
|
|
146
|
+
continue;
|
|
147
|
+
const abs = path.join(dir, it.name);
|
|
148
|
+
if (it.isDirectory())
|
|
149
|
+
await walk(abs);
|
|
150
|
+
else if (it.isFile() && (await fs.promises.stat(abs)).size <= MAX_PACK_FILE) {
|
|
151
|
+
out.push({
|
|
152
|
+
path: path.relative(root, abs).replaceAll("\\", "/"),
|
|
153
|
+
content: await fs.promises.readFile(abs, "utf8"),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
await walk(root);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
async function listDirs(dir) {
|
|
162
|
+
try {
|
|
163
|
+
return (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
164
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
165
|
+
.map((d) => d.name)
|
|
166
|
+
.sort();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function listFiles(dir, ext) {
|
|
173
|
+
try {
|
|
174
|
+
return (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
175
|
+
.filter((d) => d.isFile() && d.name.endsWith(ext) && d.name.toLowerCase() !== "readme.md")
|
|
176
|
+
.map((d) => d.name)
|
|
177
|
+
.sort();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|