@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,683 @@
1
+ // The knowledge harness on the shared server: owns the clone of the private
2
+ // harness repo, the index, the proposal queue, the librarian and the AI-DLC
3
+ // intents. Commands and pollers call into this one object.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseDocument } from "yaml";
8
+ import { defaultHarnessSettings } from "../settings.js";
9
+ import { Mutex, devboardDataDir, errorMessage, log, readLines, run, writeFileAtomic } from "../util.js";
10
+ import { CONFIG_FILE, emptyConfig, findProject, ownerRepo, readConfig, repoIdentity, serializeConfig, slugify, } from "./config.js";
11
+ import { ENTRY_SCHEMA_VERSION, entryFileName, isoDay, newEntryId, serializeEntry } from "./entry.js";
12
+ import { IntentStore } from "./intents.js";
13
+ import { KbIndex, formatContext, scopeSpecificity } from "./kbIndex.js";
14
+ import { LIBRARIAN_PROMPT_HEAD, projectMaterial, runLibrarian, transcriptTail } from "./librarian.js";
15
+ import { HOOK_SCRIPT_NAME, buildBundle, hookScriptContent, installHook, linkPack } from "./pack.js";
16
+ import { ProposalStore } from "./proposals.js";
17
+ import { ensureClone, expandHome, fileChangeTimes, isGitRepo, proposeChange, pull, retractChange } from "./repo.js";
18
+ import { InjectionLog, UsageStats } from "./usage.js";
19
+ export const PERSONAL_KB = "personal";
20
+ /** Default routing for every session in a registered project: which harness skill or subagent fits which work. */
21
+ const WORKFLOW_HINT = [
22
+ "Team workflow (harness). Pick what fits the task; skip it for small questions and read-only lookups.",
23
+ "- New feature or non-trivial change: use the `aidlc-requirements`, `aidlc-design` and `aidlc-implement` skills in that order, skipping stages the prompt already settles.",
24
+ "- Before committing code you wrote: run the `code-reviewer` subagent on the diff and fix what it finds.",
25
+ "- Changes touching auth, input parsing, file paths, process execution, network endpoints or secrets: also run `security-reviewer`.",
26
+ "- Tests for an approved design: use the `test-writer` subagent.",
27
+ "- Code is done and checks pass: use `aidlc-pr`. After an incident fix: use `aidlc-postmortem`.",
28
+ "- Something broke in a surprising way, or you learned a convention or gotcha: use the `project-kb` skill.",
29
+ ].join("\n");
30
+ const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "template");
31
+ const IDENTITY_TTL_MS = 60_000;
32
+ /** Re-distill a session only once its transcript grew this much. */
33
+ const DISTILL_MIN_GROWTH = 20_000;
34
+ export class HarnessService {
35
+ opts;
36
+ index;
37
+ proposals;
38
+ intents;
39
+ usage;
40
+ injections;
41
+ dataDir;
42
+ config = emptyConfig();
43
+ repoPath;
44
+ lastPull;
45
+ lastError;
46
+ librarianLock = new Mutex();
47
+ reloadLock = new Mutex();
48
+ identityCache = new Map();
49
+ /** Onboarding pull requests opened by this process and not merged yet, by repo root. */
50
+ pendingOnboards = new Map();
51
+ constructor(opts) {
52
+ this.opts = opts;
53
+ this.dataDir = opts.dataDir ?? devboardDataDir();
54
+ this.index = new KbIndex(path.join(this.dataDir, "kb-index"));
55
+ this.proposals = new ProposalStore(this.dataDir);
56
+ this.intents = new IntentStore(this.dataDir);
57
+ this.usage = new UsageStats(this.dataDir);
58
+ this.injections = new InjectionLog(this.dataDir);
59
+ }
60
+ async settings() {
61
+ const s = await this.opts.settings.read().catch(() => null);
62
+ const h = s?.harness;
63
+ const d = defaultHarnessSettings();
64
+ return { ...d, ...h, librarian: { ...d.librarian, ...h?.librarian } };
65
+ }
66
+ get configured() {
67
+ return !!this.repoPath;
68
+ }
69
+ /** Re-read settings and harness.json, and rebuild the index. Safe to call often. */
70
+ reload() {
71
+ return this.reloadLock.run(async () => {
72
+ const s = await this.settings();
73
+ this.lastError = undefined;
74
+ if (!s.repoPath) {
75
+ this.repoPath = undefined;
76
+ this.config = emptyConfig();
77
+ this.index.setSources([this.personalSource()]);
78
+ await this.index.reindex();
79
+ return;
80
+ }
81
+ try {
82
+ const repoPath = expandHome(s.repoPath);
83
+ await ensureClone(repoPath, s.gitUrl);
84
+ this.repoPath = repoPath;
85
+ this.config = readConfig(repoPath);
86
+ }
87
+ catch (e) {
88
+ this.lastError = errorMessage(e);
89
+ log.warn("harness", `reload failed: ${this.lastError}`);
90
+ this.repoPath = undefined;
91
+ this.config = emptyConfig();
92
+ }
93
+ const repo = this.repoPath;
94
+ const sources = [this.personalSource()];
95
+ if (repo) {
96
+ for (const kb of this.config.kbs) {
97
+ sources.push({ id: kb.id, root: path.join(repo, kb.path), weight: kb.weight ?? 1, covers: kb.covers ?? [] });
98
+ }
99
+ }
100
+ this.index.setSources(sources);
101
+ const changed = await this.index.reindex();
102
+ this.index.setUsefulness(await this.usage.multipliers());
103
+ if (changed)
104
+ log.info("harness", `indexed ${changed} changed entr${changed === 1 ? "y" : "ies"}`);
105
+ });
106
+ }
107
+ personalSource() {
108
+ // Personal notes rank below the team's and only come from this machine.
109
+ return { id: PERSONAL_KB, root: path.join(this.dataDir, "kb"), weight: 0.5, covers: [] };
110
+ }
111
+ /** `git pull` the harness repo and reindex when it moved. */
112
+ async sync() {
113
+ if (!this.repoPath)
114
+ return false;
115
+ try {
116
+ const moved = await pull(this.repoPath);
117
+ this.lastPull = new Date().toISOString();
118
+ if (moved) {
119
+ await this.reload();
120
+ await this.installPack().catch((e) => log.warn("harness", `pack install failed: ${errorMessage(e)}`));
121
+ }
122
+ return moved;
123
+ }
124
+ catch (e) {
125
+ this.lastError = errorMessage(e);
126
+ log.warn("harness", `pull failed: ${this.lastError}`);
127
+ return false;
128
+ }
129
+ }
130
+ async status() {
131
+ return {
132
+ configured: this.configured,
133
+ repoPath: this.repoPath,
134
+ kbs: this.index.listSources().map((s) => ({
135
+ id: s.id,
136
+ root: s.root,
137
+ entries: this.index.count(s.id),
138
+ stale: this.index.all(s.id).filter((e) => this.index.isStale(e.id)).length,
139
+ })),
140
+ projects: this.config.projects.map((p) => ({ id: p.id, kbId: p.kbId, name: p.name })),
141
+ pendingProposals: (await this.proposals.list("pending")).length,
142
+ lastPull: this.lastPull,
143
+ indexStore: this.index.storeKind(),
144
+ error: this.lastError,
145
+ };
146
+ }
147
+ // ── Setup ──────────────────────────────────────────────────────────────────
148
+ /** Create a new harness repo from the bundled template (an empty or missing folder only). */
149
+ async initRepo(repoPath) {
150
+ const target = path.resolve(expandHome(repoPath));
151
+ const existing = await fs.promises.readdir(target).catch(() => []);
152
+ if (existing.some((f) => f !== ".git"))
153
+ throw new Error(`${target} is not empty`);
154
+ await fs.promises.mkdir(target, { recursive: true });
155
+ await fs.promises.cp(TEMPLATE_DIR, target, { recursive: true });
156
+ if (!(await isGitRepo(target)))
157
+ await run("git", ["init", "-q", "-b", "main"], { cwd: target });
158
+ await run("git", ["add", "-A"], { cwd: target });
159
+ await run("git", [
160
+ "-c",
161
+ "user.name=DevBoard",
162
+ "-c",
163
+ "user.email=devboard@localhost",
164
+ "commit",
165
+ "-q",
166
+ "-m",
167
+ "chore: create harness",
168
+ ], { cwd: target });
169
+ const files = (await run("git", ["ls-files"], { cwd: target })).stdout.split("\n").filter(Boolean);
170
+ return { repoPath: target, files };
171
+ }
172
+ /** Link skills and subagents into ~/.claude, install the SessionStart hook, link agents.yaml. */
173
+ async installPack() {
174
+ if (!this.repoPath)
175
+ throw new Error("no harness repo configured");
176
+ const { linked, skipped } = await linkPack(this.repoPath);
177
+ const hook = await installHook(this.dataDir, this.opts.serverUrl, this.opts.token);
178
+ const agentsLink = path.join(this.dataDir, "agents.yaml");
179
+ if (!fs.existsSync(agentsLink) && fs.existsSync(path.join(this.repoPath, "agents.yaml"))) {
180
+ const src = path.join(this.repoPath, "agents.yaml");
181
+ // Windows file symlinks need admin or Developer Mode; fall back to a copy.
182
+ await fs.promises
183
+ .symlink(src, agentsLink)
184
+ .catch(() => fs.promises
185
+ .copyFile(src, agentsLink)
186
+ .catch((e) => log.warn("harness", `agents.yaml link failed: ${errorMessage(e)}`)));
187
+ }
188
+ return { linked, skipped, hook };
189
+ }
190
+ /** Skills, subagents and a hook script, for `devboard harness install` on a member machine. */
191
+ async bundle(serverUrl) {
192
+ if (!this.repoPath)
193
+ throw new Error("no harness repo configured");
194
+ const version = (await run("git", ["rev-parse", "--short", "HEAD"], { cwd: this.repoPath })).stdout.trim();
195
+ // The member's own token is filled in by the CLI; it is never sent back here.
196
+ return { ...(await buildBundle(this.repoPath, version)), hookScript: hookScriptContent(serverUrl, undefined) };
197
+ }
198
+ hookScriptName() {
199
+ return HOOK_SCRIPT_NAME;
200
+ }
201
+ // ── Projects ───────────────────────────────────────────────────────────────
202
+ /** The repo `dir` belongs to and its harness project. Repo identity is cached for a minute (it costs two git calls). */
203
+ async projectFor(dir) {
204
+ const key = path.resolve(dir);
205
+ const hit = this.identityCache.get(key);
206
+ let id;
207
+ if (hit && Date.now() - hit.at < IDENTITY_TTL_MS) {
208
+ id = hit.id;
209
+ }
210
+ else {
211
+ id = await repoIdentity(dir);
212
+ this.identityCache.set(key, { id, at: Date.now() });
213
+ if (this.identityCache.size > 500)
214
+ this.identityCache.delete(this.identityCache.keys().next().value);
215
+ }
216
+ if (!id)
217
+ return null;
218
+ return { id, project: findProject(this.config, id) };
219
+ }
220
+ async projectCommands(cwd) {
221
+ const p = await this.projectFor(cwd);
222
+ return { project: p?.project?.id, ...(p?.project?.commands ?? {}) };
223
+ }
224
+ /**
225
+ * Register a project: harness.json entry, kb/<id>/, the long-lived agents'
226
+ * repo lists — one pull request — then a bootstrap librarian pass.
227
+ */
228
+ async onboard(projectPath, commands) {
229
+ const repo = this.repoPath;
230
+ if (!repo)
231
+ throw new Error("no harness repo configured");
232
+ const found = await this.projectFor(projectPath);
233
+ if (!found)
234
+ throw new Error(`${projectPath} is not a git repository`);
235
+ if (found.project)
236
+ return { project: found.project, mode: "none", alreadyRegistered: true };
237
+ // Its pull request is open but not merged yet: do not open a second one.
238
+ const pending = this.pendingOnboards.get(found.id.root);
239
+ if (pending)
240
+ return pending;
241
+ const id = slugify(found.id.name);
242
+ const kbId = this.config.kbs.some((k) => k.id === id) ? `${id}-${Date.now().toString(36)}` : id;
243
+ const project = {
244
+ id: kbId,
245
+ name: found.id.name,
246
+ remote: found.id.remote,
247
+ kbId,
248
+ commands: commands && Object.values(commands).some(Boolean) ? commands : undefined,
249
+ };
250
+ const result = await proposeChange(repo, {
251
+ branch: `onboard/${kbId}`,
252
+ message: `feat: add project ${found.id.name}`,
253
+ prTitle: `Add project ${found.id.name} to the harness`,
254
+ prBody: [
255
+ `Registers \`${found.id.remote ?? found.id.name}\` with the knowledge harness:`,
256
+ "",
257
+ `- \`harness.json\`: project \`${kbId}\` with its own knowledge base \`kb/${kbId}/\``,
258
+ "- `agents.yaml`: the librarian, triage and ops-watcher agents now watch it",
259
+ "",
260
+ "Bootstrap proposals from the project's docs wait for review on the board.",
261
+ ].join("\n"),
262
+ }, async (dir) => {
263
+ const config = readConfig(dir);
264
+ if (!config.kbs.some((k) => k.id === kbId))
265
+ config.kbs.push({ id: kbId, path: `kb/${kbId}` });
266
+ config.projects.push(project);
267
+ await fs.promises.writeFile(path.join(dir, CONFIG_FILE), serializeConfig(config));
268
+ await fs.promises.mkdir(path.join(dir, "kb", kbId), { recursive: true });
269
+ await fs.promises.writeFile(path.join(dir, "kb", kbId, "README.md"), `# ${found.id.name}\n\nKnowledge for \`${found.id.remote ?? found.id.name}\`.\n`);
270
+ await addRepoToAgents(path.join(dir, "agents.yaml"), ownerRepo(found.id.remote));
271
+ });
272
+ const out = { project, url: result.url, mode: result.mode, alreadyRegistered: false };
273
+ if (result.mode === "local")
274
+ await this.reload();
275
+ else
276
+ this.pendingOnboards.set(found.id.root, out);
277
+ // Bootstrap in the background: it can take minutes.
278
+ void this.bootstrap(found.id.root, kbId, found.id.name).catch((e) => log.warn("librarian", `bootstrap failed: ${errorMessage(e)}`));
279
+ return out;
280
+ }
281
+ bootstrap(root, kbId, name) {
282
+ return this.librarianLock.run(async () => {
283
+ const s = await this.settings();
284
+ if (!s.librarian.enabled)
285
+ return [];
286
+ const material = await projectMaterial(root, s.librarian.maxTranscriptChars);
287
+ if (!material.trim())
288
+ return [];
289
+ const drafts = await runLibrarian({ mode: "bootstrap", project: name, material, existingTitles: this.index.all(kbId).map((e) => e.title) }, s.librarian.model, root);
290
+ return this.proposals.add(drafts.map((d) => ({ ...d, kbId, origin: "bootstrap" })));
291
+ });
292
+ }
293
+ // ── Retrieval ──────────────────────────────────────────────────────────────
294
+ search(q) {
295
+ return this.index.search(q);
296
+ }
297
+ /** Search in the context of a checkout: its project's KB is always included. */
298
+ async searchFrom(cwd, q) {
299
+ if (q.onlyKbs?.length)
300
+ return this.index.search({ ...q, kbIds: q.onlyKbs });
301
+ const kbIds = new Set(q.kbIds ?? []);
302
+ kbIds.add(PERSONAL_KB);
303
+ if (cwd) {
304
+ const p = await this.projectFor(cwd);
305
+ if (p?.project)
306
+ kbIds.add(p.project.kbId);
307
+ }
308
+ return this.index.search({ ...q, kbIds: [...kbIds] });
309
+ }
310
+ async show(id) {
311
+ const e = this.index.get(id);
312
+ if (!e)
313
+ throw new Error(`entry ${id} not found`);
314
+ await this.usage.record([id], "shown");
315
+ return e;
316
+ }
317
+ /**
318
+ * SessionStart hook: the entries most relevant to this session, as hook
319
+ * output. Unregistered projects get `{}` so the hook is a no-op there.
320
+ */
321
+ async sessionContext(args) {
322
+ const cwd = args.cwd;
323
+ if (!cwd)
324
+ return {};
325
+ const p = await this.projectFor(cwd);
326
+ if (!p?.project)
327
+ return {};
328
+ const s = await this.settings();
329
+ const link = await this.cardFor(args.session_id ?? args.sessionId, cwd);
330
+ const text = [link?.name, link?.promptBody].filter(Boolean).join("\n");
331
+ const files = await touchedFiles(cwd);
332
+ const hits = this.index.search({
333
+ text,
334
+ files,
335
+ kbIds: [p.project.kbId, PERSONAL_KB],
336
+ limit: s.injectTopK,
337
+ });
338
+ const kbContext = formatContext(hits, s.injectTokenBudget);
339
+ const context = [kbContext, WORKFLOW_HINT].filter(Boolean).join("\n\n");
340
+ const injected = kbContext ? hits.slice(0, kbContext.split("\n").length - 1).map((h) => h.entry.id) : [];
341
+ await this.usage.record(injected, "injected");
342
+ const sid = args.session_id ?? args.sessionId;
343
+ const key = link ? `card:${link.id}` : sid ? `session:${sid}` : undefined;
344
+ if (key)
345
+ await this.injections.record(key, injected);
346
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } };
347
+ }
348
+ async cardFor(sessionId, cwd) {
349
+ const links = await this.opts.store.readLinks();
350
+ return ((sessionId ? links.find((l) => l.sessionLink?.sessionId === sessionId) : undefined) ??
351
+ links.find((l) => l.worktreeLink?.path && path.resolve(l.worktreeLink.path) === path.resolve(cwd)));
352
+ }
353
+ /** What a card's work did with the knowledge base: its proposals, what it was given, what to re-check. */
354
+ async cardKnowledge(cardId) {
355
+ const link = (await this.opts.store.readLinks()).find((l) => l.id === cardId);
356
+ if (!link)
357
+ throw new Error(`card ${cardId} not found`);
358
+ const sessionIds = link.sessionLink?.sessionId ? [link.sessionLink.sessionId] : [];
359
+ const proposals = await this.proposals.list(undefined, { cardId, sessionIds });
360
+ const ids = await this.injections.idsFor([`card:${cardId}`, ...sessionIds.map((s) => `session:${s}`)]);
361
+ const hit = (e) => ({ entry: e, score: 0, via: [], stale: this.index.isStale(e.id) });
362
+ // A corrected (superseded) entry is no longer what the team believes: leave it out.
363
+ const injected = ids
364
+ .map((id) => this.index.get(id))
365
+ .filter((e) => !!e && !this.index.isSuperseded(e.id))
366
+ .map(hit);
367
+ // Only a card's own worktree says what *it* changed; the shared project checkout does not.
368
+ let review = [];
369
+ const checkout = link.worktreeLink?.path;
370
+ const p = checkout && fs.existsSync(checkout) ? await this.projectFor(checkout) : null;
371
+ if (checkout && p?.project) {
372
+ const files = await touchedFiles(checkout);
373
+ if (files.length) {
374
+ review = this.index
375
+ .search({ files, kbIds: [p.project.kbId], limit: 50 })
376
+ .filter((h) => h.stale && h.via.includes("scope"));
377
+ }
378
+ }
379
+ return { proposals, injected, review };
380
+ }
381
+ /** Pending proposals per card, matched like cardKnowledge does: by card id, else by session. */
382
+ async pendingByCard() {
383
+ const bySession = new Map();
384
+ for (const l of await this.opts.store.readLinks()) {
385
+ if (l.sessionLink?.sessionId)
386
+ bySession.set(l.sessionLink.sessionId, l.id);
387
+ }
388
+ const out = {};
389
+ for (const p of await this.proposals.list("pending")) {
390
+ const id = p.source?.cardId ?? (p.source?.sessionId ? bySession.get(p.source.sessionId) : undefined);
391
+ if (id)
392
+ out[id] = (out[id] ?? 0) + 1;
393
+ }
394
+ return out;
395
+ }
396
+ // ── Proposals ──────────────────────────────────────────────────────────────
397
+ /** A proposal from a person or an agent. `kbId` defaults to the cwd's project KB. */
398
+ async propose(input) {
399
+ let kbId = input.kbId;
400
+ if (!kbId && input.cwd)
401
+ kbId = (await this.projectFor(input.cwd))?.project?.kbId;
402
+ if (!kbId)
403
+ kbId = PERSONAL_KB;
404
+ const [added] = await this.proposals.add([{ ...input, kbId, origin: "manual" }]);
405
+ if (!added)
406
+ throw new Error("a proposal with this title is already waiting for review");
407
+ return added;
408
+ }
409
+ /** Approve: personal entries are written locally; team entries go to the harness repo by PR. */
410
+ async approveProposal(id, edits) {
411
+ const p = await this.proposals.get(id);
412
+ if (!p)
413
+ throw new Error(`proposal ${id} not found`);
414
+ if (p.status !== "pending")
415
+ throw new Error(`proposal is already ${p.status}`);
416
+ const merged = { ...p, ...pickEdits(edits) };
417
+ const entryId = newEntryId();
418
+ const content = serializeEntry({
419
+ id: entryId,
420
+ type: merged.type,
421
+ title: merged.title,
422
+ summary: merged.summary,
423
+ body: merged.body,
424
+ scope: merged.scope,
425
+ source: merged.source,
426
+ confidence: "confirmed",
427
+ lastVerified: isoDay(),
428
+ supersedes: merged.supersedes ?? [],
429
+ schemaVersion: ENTRY_SCHEMA_VERSION,
430
+ });
431
+ const rel = entryFileName(merged.type, merged.title, entryId);
432
+ let prUrl;
433
+ if (merged.kbId === PERSONAL_KB) {
434
+ await writeFileAtomic(path.join(this.dataDir, "kb", rel), content);
435
+ }
436
+ else {
437
+ const kb = this.config.kbs.find((k) => k.id === merged.kbId);
438
+ if (!kb || !this.repoPath)
439
+ throw new Error(`knowledge base ${merged.kbId} is not configured`);
440
+ const result = await proposeChange(this.repoPath, {
441
+ branch: `kb/${entryId.toLowerCase()}`,
442
+ message: `docs(kb): ${merged.type}: ${merged.title}`,
443
+ prTitle: `kb(${merged.kbId}): ${merged.title}`,
444
+ prBody: [
445
+ `New **${merged.type}** for \`${merged.kbId}\`.`,
446
+ "",
447
+ `> ${merged.summary}`,
448
+ "",
449
+ merged.reason ? `Why: ${merged.reason}` : "",
450
+ `Origin: ${merged.origin}${merged.source?.cardId ? ` (card ${merged.source.cardId})` : ""}`,
451
+ ]
452
+ .filter((l) => l !== "")
453
+ .join("\n"),
454
+ }, async (dir) => {
455
+ const file = path.join(dir, kb.path, rel);
456
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
457
+ await fs.promises.writeFile(file, content);
458
+ });
459
+ prUrl = result.url ?? (result.mode === "local" ? `local:${result.commit.slice(0, 10)}` : result.branch);
460
+ }
461
+ const updated = await this.proposals.update(id, (x) => {
462
+ Object.assign(x, pickEdits(edits));
463
+ x.status = "approved";
464
+ x.decidedAt = new Date().toISOString();
465
+ x.entryId = entryId;
466
+ x.prUrl = prUrl;
467
+ });
468
+ await this.reload();
469
+ return updated;
470
+ }
471
+ /**
472
+ * Undo an approval and put the proposal back in the queue. Personal entries are deleted;
473
+ * team entries have their pull request closed, or a revert pull request opened once merged.
474
+ */
475
+ async undoApproval(id) {
476
+ const p = await this.proposals.get(id);
477
+ if (!p)
478
+ throw new Error(`proposal ${id} not found`);
479
+ if (p.status !== "approved" || !p.entryId)
480
+ throw new Error("only an approved proposal can be undone");
481
+ const rel = entryFileName(p.type, p.title, p.entryId);
482
+ let revertUrl;
483
+ if (p.kbId === PERSONAL_KB) {
484
+ await fs.promises.rm(path.join(this.dataDir, "kb", rel), { force: true });
485
+ }
486
+ else {
487
+ const kb = this.config.kbs.find((k) => k.id === p.kbId);
488
+ if (!kb || !this.repoPath)
489
+ throw new Error(`knowledge base ${p.kbId} is not configured`);
490
+ const r = await retractChange(this.repoPath, path.join(kb.path, rel), { prUrl: p.prUrl, branch: `kb/${p.entryId.toLowerCase()}` }, {
491
+ branch: `kb/revert-${p.entryId.toLowerCase()}`,
492
+ message: `docs(kb): remove ${p.type}: ${p.title}`,
493
+ prTitle: `kb(${p.kbId}): remove ${p.title}`,
494
+ prBody: `Reverts the approval of **${p.title}** (\`${p.entryId}\`).`,
495
+ });
496
+ const v = r.revert;
497
+ revertUrl = v && (v.url ?? (v.mode === "local" ? `local:${v.commit.slice(0, 10)}` : v.branch));
498
+ }
499
+ const updated = await this.proposals.update(id, (x) => {
500
+ x.status = "pending";
501
+ x.decidedAt = undefined;
502
+ x.entryId = undefined;
503
+ x.prUrl = undefined;
504
+ x.decisionNote = undefined;
505
+ });
506
+ await this.reload();
507
+ return { proposal: updated, revertUrl };
508
+ }
509
+ rejectProposal(id, note) {
510
+ return this.proposals.update(id, (x) => {
511
+ if (x.status !== "pending")
512
+ throw new Error(`proposal is already ${x.status}`);
513
+ x.status = "rejected";
514
+ x.decidedAt = new Date().toISOString();
515
+ x.decisionNote = note?.trim() || undefined;
516
+ });
517
+ }
518
+ // ── Librarian ──────────────────────────────────────────────────────────────
519
+ /**
520
+ * Distill a session into proposals. Skips unregistered projects and sessions
521
+ * whose transcript barely grew since the last pass.
522
+ */
523
+ distillSession(args) {
524
+ return this.librarianLock.run(async () => {
525
+ const s = await this.settings();
526
+ if (!s.librarian.enabled || !this.repoPath)
527
+ return [];
528
+ const size = (await fs.promises.stat(args.transcriptPath).catch(() => null))?.size ?? 0;
529
+ const seen = await this.readDistilled();
530
+ if (size - (seen[args.sessionId] ?? 0) < DISTILL_MIN_GROWTH)
531
+ return [];
532
+ const cwd = await transcriptCwd(args.transcriptPath);
533
+ const p = cwd ? await this.projectFor(cwd) : null;
534
+ if (!p?.project)
535
+ return [];
536
+ const link = (await this.opts.store.readLinks()).find((l) => l.id === args.cardId || l.sessionLink?.sessionId === args.sessionId);
537
+ const material = await transcriptTail(args.transcriptPath, s.librarian.maxTranscriptChars);
538
+ // Sessions left behind by earlier librarian runs must not be distilled again.
539
+ if (material.includes(LIBRARIAN_PROMPT_HEAD)) {
540
+ seen[args.sessionId] = size;
541
+ await writeFileAtomic(this.distilledPath(), JSON.stringify(seen, null, 2));
542
+ return [];
543
+ }
544
+ const drafts = await runLibrarian({
545
+ mode: "session",
546
+ project: p.project.name ?? p.project.id,
547
+ material,
548
+ cardTitle: link?.name,
549
+ existingTitles: this.index.all(p.project.kbId).map((e) => e.title),
550
+ }, s.librarian.model, cwd);
551
+ const source = { sessionId: args.sessionId, cardId: link?.id, pr: link?.prLinks[0]?.url };
552
+ const added = await this.proposals.add(drafts.map((d) => ({ ...d, kbId: p.project.kbId, source, origin: "librarian" })));
553
+ seen[args.sessionId] = size;
554
+ await writeFileAtomic(this.distilledPath(), JSON.stringify(seen, null, 2));
555
+ if (added.length)
556
+ log.info("librarian", `${added.length} proposal(s) from session ${args.sessionId.slice(0, 8)}`);
557
+ return added;
558
+ });
559
+ }
560
+ distilledPath() {
561
+ return path.join(this.dataDir, "kb-distilled.json");
562
+ }
563
+ async readDistilled() {
564
+ try {
565
+ return JSON.parse(await fs.promises.readFile(this.distilledPath(), "utf8"));
566
+ }
567
+ catch {
568
+ return {};
569
+ }
570
+ }
571
+ // ── Maintenance ────────────────────────────────────────────────────────────
572
+ /** Flag entries whose scoped files changed after `lastVerified`, and duplicate titles. */
573
+ async maintain(projectPaths) {
574
+ const rootsByKb = new Map();
575
+ for (const dir of projectPaths) {
576
+ const p = await this.projectFor(dir).catch(() => null);
577
+ if (p?.project)
578
+ rootsByKb.set(p.project.kbId, p.id.root);
579
+ }
580
+ // One `git log` per repo (since its oldest verification), then match scopes in memory.
581
+ const stale = [];
582
+ for (const [kbId, root] of rootsByKb) {
583
+ const entries = this.index.all(kbId).filter((e) => e.lastVerified && e.scope.length);
584
+ if (entries.length === 0)
585
+ continue;
586
+ const since = entries.map((e) => verifiedAt(e.lastVerified)).reduce((a, b) => Math.min(a, b));
587
+ const changes = await fileChangeTimes(root, new Date(since).toISOString());
588
+ for (const e of entries) {
589
+ const after = verifiedAt(e.lastVerified);
590
+ const changed = [...changes]
591
+ .filter(([file, at]) => at >= after && e.scope.some((sc) => scopeSpecificity(sc, file) > 0))
592
+ .map(([file]) => file);
593
+ if (changed.length)
594
+ stale.push({ id: e.id, title: e.title, kbId: e.kbId, changed });
595
+ }
596
+ }
597
+ this.index.setStale(stale.map((s) => s.id));
598
+ const byTitle = new Map();
599
+ for (const e of this.index.all()) {
600
+ const key = `${e.kbId}\u0000${e.title.toLowerCase().replace(/\W+/g, " ").trim()}`;
601
+ byTitle.set(key, [...(byTitle.get(key) ?? []), e.id]);
602
+ }
603
+ const duplicates = [...byTitle.entries()]
604
+ .filter(([, ids]) => ids.length > 1)
605
+ .map(([key, ids]) => ({ title: key.split("\u0000")[1], ids }));
606
+ this.index.setUsefulness(await this.usage.multipliers());
607
+ return { stale, duplicates };
608
+ }
609
+ }
610
+ function pickEdits(edits) {
611
+ if (!edits)
612
+ return {};
613
+ const out = {};
614
+ if (edits.title?.trim())
615
+ out.title = edits.title.trim();
616
+ if (edits.summary?.trim())
617
+ out.summary = edits.summary.trim();
618
+ if (edits.body?.trim())
619
+ out.body = edits.body.trim();
620
+ if (edits.type)
621
+ out.type = edits.type;
622
+ if (Array.isArray(edits.scope))
623
+ out.scope = edits.scope;
624
+ return out;
625
+ }
626
+ /** Files changed on this checkout's branch relative to its upstream default branch. */
627
+ async function touchedFiles(cwd) {
628
+ // origin/HEAD is often unset on a server clone; fall back to the usual default branches.
629
+ let range = "HEAD";
630
+ for (const ref of ["origin/HEAD", "@{upstream}", "origin/main", "origin/master"]) {
631
+ const base = await run("git", ["merge-base", "HEAD", ref], { cwd, timeoutMs: 10_000 });
632
+ if (base.ok && base.stdout.trim()) {
633
+ range = base.stdout.trim();
634
+ break;
635
+ }
636
+ }
637
+ const r = await run("git", ["diff", "--name-only", range], { cwd, timeoutMs: 10_000 });
638
+ return r.ok ? r.stdout.split(/\r?\n/).filter(Boolean).slice(0, 200) : [];
639
+ }
640
+ /** `lastVerified` as a time: a bare date means the start of that day (UTC). */
641
+ function verifiedAt(lastVerified) {
642
+ const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(lastVerified) ? `${lastVerified}T00:00:00Z` : lastVerified);
643
+ return Number.isNaN(t) ? 0 : t;
644
+ }
645
+ /** The working directory recorded in a transcript's first entries. */
646
+ async function transcriptCwd(jsonlPath) {
647
+ let n = 0;
648
+ for await (const line of readLines(jsonlPath)) {
649
+ if (++n > 50)
650
+ break;
651
+ try {
652
+ const cwd = JSON.parse(line)?.cwd;
653
+ if (typeof cwd === "string" && cwd)
654
+ return cwd;
655
+ }
656
+ catch {
657
+ // skip
658
+ }
659
+ }
660
+ return undefined;
661
+ }
662
+ /** Add `owner/name` to the triage and ops-watcher agents, keeping the file's comments. */
663
+ async function addRepoToAgents(file, repo) {
664
+ if (!repo || !fs.existsSync(file))
665
+ return;
666
+ const doc = parseDocument(await fs.promises.readFile(file, "utf8"));
667
+ const agents = doc.get("agents");
668
+ if (!agents?.items)
669
+ return;
670
+ for (const item of agents.items) {
671
+ const slug = item.get?.("slug");
672
+ if (slug !== "triage" && slug !== "ops-watcher" && slug !== "librarian")
673
+ continue;
674
+ let repos = item.get("repos");
675
+ if (!repos?.items) {
676
+ item.set("repos", doc.createNode([]));
677
+ repos = item.get("repos");
678
+ }
679
+ if (!repos.items.some((r) => (r?.value ?? r) === repo))
680
+ repos.add(doc.createNode(repo));
681
+ }
682
+ await fs.promises.writeFile(file, doc.toString());
683
+ }