@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,143 @@
1
+ // Directory browsing for the in-browser folder picker: listing, quick places
2
+ // and a bounded name search. Directories only; never reads file contents.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ const IS_WINDOWS = process.platform === "win32";
7
+ function isDir(p) {
8
+ try {
9
+ return fs.statSync(p).isDirectory();
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ function hasGit(dir) {
16
+ return fs.promises.access(path.join(dir, ".git")).then(() => true, () => false);
17
+ }
18
+ const PLACE_NAMES = ["Desktop", "Documents", "Downloads", "repos", "source", "code", "dev", "projects", "workspace"];
19
+ function places() {
20
+ const home = os.homedir();
21
+ const out = [{ name: "Home", path: home }];
22
+ const seen = new Set();
23
+ for (const name of PLACE_NAMES) {
24
+ const p = path.join(home, name);
25
+ // Case-insensitive filesystems report "Repos" and "repos" as the same folder.
26
+ if (seen.has(p.toLowerCase()) || !isDir(p))
27
+ continue;
28
+ seen.add(p.toLowerCase());
29
+ out.push({ name, path: p });
30
+ }
31
+ return out;
32
+ }
33
+ function roots() {
34
+ const list = IS_WINDOWS
35
+ ? "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
36
+ .split("")
37
+ .map((l) => `${l}:\\`)
38
+ .filter((r) => fs.existsSync(r))
39
+ : ["/"];
40
+ return list.map((r) => ({ name: r, path: r, isDir: true }));
41
+ }
42
+ /** `~`, `~/x` and an empty path resolve against the user's home; anything else is taken as given. */
43
+ function expand(dir) {
44
+ if (dir === "~")
45
+ return os.homedir();
46
+ if (dir.startsWith("~/") || dir.startsWith("~\\"))
47
+ return path.join(os.homedir(), dir.slice(2));
48
+ return dir;
49
+ }
50
+ /**
51
+ * List the subfolders of `dir`. A missing `dir` is the home folder (so the
52
+ * picker opens where the user works); `""` is the machine's roots (drives).
53
+ */
54
+ export async function fsList(dir) {
55
+ const home = os.homedir();
56
+ if (dir === "")
57
+ return { path: "", parent: null, home, places: places(), entries: roots() };
58
+ const resolved = path.resolve(expand(dir || home));
59
+ const dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
60
+ const entries = dirents
61
+ .filter((d) => d.isDirectory() || (d.isSymbolicLink() && isDir(path.join(resolved, d.name))))
62
+ .map((d) => ({ name: d.name, path: path.join(resolved, d.name), isDir: true }))
63
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
64
+ if (entries.length <= 400) {
65
+ await Promise.all(entries.map(async (e) => {
66
+ if (await hasGit(e.path))
67
+ e.isRepo = true;
68
+ }));
69
+ }
70
+ const parent = path.dirname(resolved);
71
+ return { path: resolved, parent: parent === resolved ? "" : parent, home, places: places(), entries };
72
+ }
73
+ const SKIP = new Set([
74
+ "node_modules",
75
+ "appdata",
76
+ "library",
77
+ "target",
78
+ "dist",
79
+ "build",
80
+ "venv",
81
+ "__pycache__",
82
+ "vendor",
83
+ "windows",
84
+ "program files",
85
+ "program files (x86)",
86
+ "programdata",
87
+ "$recycle.bin",
88
+ "system volume information",
89
+ ]);
90
+ /**
91
+ * Find folders under `root` (default: home) whose name contains every word of
92
+ * `query`. Breadth-first, so shallow matches win, and bounded by depth, folder
93
+ * count and time so a huge disk can't stall the server.
94
+ */
95
+ export async function fsSearch(query, root) {
96
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
97
+ if (tokens.length === 0)
98
+ return [];
99
+ const start = path.resolve(expand(root || os.homedir()));
100
+ const deadline = Date.now() + 1500;
101
+ const MAX_VISITED = 8000;
102
+ const MAX_DEPTH = 5;
103
+ const hits = [];
104
+ let level = [start];
105
+ let visited = 0;
106
+ for (let depth = 0; depth <= MAX_DEPTH && level.length > 0; depth++) {
107
+ const next = [];
108
+ // Read one level in small parallel batches, checking the clock between them.
109
+ for (let i = 0; i < level.length; i += 24) {
110
+ if (Date.now() > deadline || visited > MAX_VISITED)
111
+ break;
112
+ const batch = level.slice(i, i + 24);
113
+ const results = await Promise.all(batch.map((d) => fs.promises.readdir(d, { withFileTypes: true }).catch(() => [])));
114
+ results.forEach((dirents, j) => {
115
+ for (const d of dirents) {
116
+ if (!d.isDirectory())
117
+ continue;
118
+ const lower = d.name.toLowerCase();
119
+ if (d.name.startsWith(".") || SKIP.has(lower))
120
+ continue;
121
+ visited++;
122
+ const full = path.join(batch[j], d.name);
123
+ if (tokens.every((t) => lower.includes(t))) {
124
+ const score = (lower === tokens.join(" ") ? 0 : lower.startsWith(tokens[0]) ? 1 : 2) + depth * 0.25;
125
+ hits.push({ name: d.name, path: full, parent: batch[j], isRepo: false, score });
126
+ }
127
+ next.push(full);
128
+ }
129
+ });
130
+ }
131
+ if (hits.length >= 60 || Date.now() > deadline || visited > MAX_VISITED)
132
+ break;
133
+ level = next;
134
+ }
135
+ hits.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
136
+ const top = hits.slice(0, 30);
137
+ await Promise.all(top.map(async (h) => {
138
+ h.isRepo = await hasGit(h.path);
139
+ }));
140
+ // Project roots first within the same relevance band.
141
+ top.sort((a, b) => Math.floor(a.score) - Math.floor(b.score) || Number(b.isRepo) - Number(a.isRepo) || a.score - b.score);
142
+ return top.map(({ score: _score, ...h }) => h);
143
+ }
@@ -0,0 +1,258 @@
1
+ // Git + GitHub CLI adapters. Ports git_worktree.rs, git_remote.rs and gh_cli.rs.
2
+ import path from "node:path";
3
+ import { findOnPath, IS_WINDOWS, run } from "./util.js";
4
+ import { isWsl } from "./sessions.js";
5
+ export async function createWorktree(repoRoot, name) {
6
+ const wtPath = path.join(repoRoot, ".worktrees", name);
7
+ const r = await run("git", ["worktree", "add", "-b", name, wtPath], { cwd: repoRoot });
8
+ if (!r.ok)
9
+ throw new Error(`git worktree add failed: ${r.stderr}`);
10
+ return { path: wtPath, branch: name, isMain: false };
11
+ }
12
+ /** `repoRoot` omitted → derive it from the worktree path. */
13
+ export async function removeWorktree(wtPath, repoRoot, force) {
14
+ const args = ["worktree", "remove"];
15
+ if (force)
16
+ args.push("--force");
17
+ args.push(wtPath);
18
+ let root = repoRoot;
19
+ if (!root) {
20
+ const idx = [wtPath.indexOf("\\.worktrees\\"), wtPath.indexOf("/.worktrees/")].find((i) => i >= 0);
21
+ root = idx !== undefined ? wtPath.slice(0, idx) : path.dirname(wtPath) || ".";
22
+ }
23
+ const r = await run("git", args, { cwd: root });
24
+ if (!r.ok)
25
+ throw new Error(`git worktree remove failed: ${r.stderr}`);
26
+ }
27
+ export async function listWorktrees(repoRoot) {
28
+ const r = await run("git", ["worktree", "list", "--porcelain"], { cwd: repoRoot });
29
+ if (!r.ok)
30
+ return [];
31
+ const out = [];
32
+ let curPath;
33
+ let curBranch;
34
+ let first = true;
35
+ for (const line of r.stdout.split(/\r?\n/)) {
36
+ if (line.startsWith("worktree ")) {
37
+ if (curPath !== undefined) {
38
+ out.push({ path: curPath, branch: curBranch, isMain: first });
39
+ first = false;
40
+ curBranch = undefined;
41
+ }
42
+ curPath = line.slice("worktree ".length);
43
+ }
44
+ else if (line.startsWith("branch ")) {
45
+ curBranch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
46
+ }
47
+ }
48
+ if (curPath !== undefined)
49
+ out.push({ path: curPath, branch: curBranch, isMain: first });
50
+ return out;
51
+ }
52
+ const MAX_DIFF_BYTES = 2_000_000;
53
+ const MAX_UNTRACKED_BYTES = 200_000;
54
+ /** Merge-base with the repo's default branch, for worktree cards; null when none is found. */
55
+ async function defaultBranchBase(cwd) {
56
+ const head = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
57
+ const candidates = [head.ok ? head.stdout.trim() : "", "origin/main", "origin/master", "main", "master"].filter(Boolean);
58
+ for (const name of candidates) {
59
+ const mb = await run("git", ["merge-base", "HEAD", name], { cwd });
60
+ if (mb.ok && mb.stdout.trim())
61
+ return { sha: mb.stdout.trim(), name };
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * Code changes of a card's session: for a worktree, everything since it
67
+ * branched off the default branch; otherwise uncommitted changes vs HEAD.
68
+ * Untracked files are included as additions.
69
+ */
70
+ export async function getDiff(cwd, isWorktree) {
71
+ const inside = await run("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
72
+ if (!inside.ok)
73
+ throw new Error("not a git repository");
74
+ const wtBase = isWorktree ? await defaultBranchBase(cwd) : null;
75
+ const baseRef = wtBase?.sha ?? "HEAD";
76
+ const r = await run("git", ["diff", "--no-color", "--no-ext-diff", baseRef], { cwd });
77
+ if (!r.ok)
78
+ throw new Error(r.stderr.trim() || "git diff failed");
79
+ let diff = r.stdout;
80
+ const untracked = await run("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd });
81
+ if (untracked.ok) {
82
+ for (const file of untracked.stdout.split("\0").filter(Boolean)) {
83
+ if (diff.length > MAX_DIFF_BYTES)
84
+ break;
85
+ // --no-index exits 1 when the files differ, which is always the case here.
86
+ const nul = IS_WINDOWS ? "NUL" : "/dev/null";
87
+ const d = await run("git", ["diff", "--no-color", "--no-index", "--", nul, file], { cwd });
88
+ if (d.stdout)
89
+ diff += (diff && !diff.endsWith("\n") ? "\n" : "") + d.stdout.slice(0, MAX_UNTRACKED_BYTES);
90
+ }
91
+ }
92
+ const truncated = diff.length > MAX_DIFF_BYTES;
93
+ return { diff: truncated ? diff.slice(0, MAX_DIFF_BYTES) : diff, base: wtBase?.name ?? "HEAD", truncated };
94
+ }
95
+ // ── Remote URL ───────────────────────────────────────────────────────────────
96
+ const baseUrlCache = new Map();
97
+ export function parseGithubUrl(remote) {
98
+ let idx = remote.indexOf("github.com:");
99
+ if (idx >= 0) {
100
+ const p = remote.slice(idx + "github.com:".length).replace(/(\.git)+$/, "");
101
+ return p ? `https://github.com/${p}` : null;
102
+ }
103
+ idx = remote.indexOf("github.com/");
104
+ if (idx >= 0) {
105
+ const p = remote.slice(idx + "github.com/".length).replace(/(\.git)+$/, "");
106
+ return p ? `https://github.com/${p}` : null;
107
+ }
108
+ return null;
109
+ }
110
+ export async function githubBaseUrl(projectPath) {
111
+ if (baseUrlCache.has(projectPath))
112
+ return baseUrlCache.get(projectPath) ?? null;
113
+ const r = await run("git", ["remote", "get-url", "origin"], { cwd: projectPath });
114
+ const url = r.ok ? parseGithubUrl(r.stdout.trim()) : null;
115
+ baseUrlCache.set(projectPath, url);
116
+ return url;
117
+ }
118
+ export const issueUrl = (base, n) => `${base}/issues/${n}`;
119
+ export const prUrl = (base, n) => `${base}/pull/${n}`;
120
+ /** On WSL fall back to gh.exe when gh is only installed on the Windows side. */
121
+ function ghBin() {
122
+ if (!IS_WINDOWS && isWsl())
123
+ return findOnPath("gh") ? "gh" : "gh.exe";
124
+ return "gh";
125
+ }
126
+ function countApprovals(reviews) {
127
+ if (!Array.isArray(reviews) || reviews.length === 0)
128
+ return undefined;
129
+ const latest = new Map();
130
+ for (const r of reviews) {
131
+ const author = r?.author?.login ?? "";
132
+ if (author)
133
+ latest.set(author, r?.state ?? "");
134
+ }
135
+ return [...latest.values()].filter((s) => s === "APPROVED").length;
136
+ }
137
+ function parseCheckRuns(rollup) {
138
+ if (!Array.isArray(rollup))
139
+ return [];
140
+ const out = [];
141
+ for (const c of rollup) {
142
+ const name = c?.name ?? c?.context;
143
+ if (typeof name !== "string")
144
+ continue;
145
+ const conclusion = c?.conclusion || c?.state || undefined;
146
+ out.push({ name, conclusion: conclusion || undefined });
147
+ }
148
+ return out;
149
+ }
150
+ export async function fetchPrs(repoRoot) {
151
+ const r = await run(ghBin(), [
152
+ "pr",
153
+ "list",
154
+ "--json",
155
+ "number,title,url,state,headRefName,body,mergeStateStatus,reviewDecision,reviews,statusCheckRollup",
156
+ "--limit",
157
+ "100",
158
+ ], { cwd: repoRoot });
159
+ if (!r.ok)
160
+ return [];
161
+ let prs;
162
+ try {
163
+ prs = JSON.parse(r.stdout);
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ const out = [];
169
+ for (const pr of prs) {
170
+ if (typeof pr?.number !== "number" || typeof pr.title !== "string" || typeof pr.url !== "string")
171
+ continue;
172
+ if (typeof pr.headRefName !== "string")
173
+ continue;
174
+ out.push({
175
+ number: pr.number,
176
+ title: pr.title,
177
+ url: pr.url,
178
+ state: pr.state ?? "OPEN",
179
+ headRef: pr.headRefName,
180
+ body: pr.body || undefined,
181
+ mergeStateStatus: pr.mergeStateStatus ?? undefined,
182
+ reviewDecision: pr.reviewDecision || undefined,
183
+ approvalCount: countApprovals(pr.reviews),
184
+ checkRuns: parseCheckRuns(pr.statusCheckRollup),
185
+ });
186
+ }
187
+ return out;
188
+ }
189
+ function parseOwnerRepo(url) {
190
+ const idx = url.indexOf("github.com/");
191
+ if (idx < 0)
192
+ return null;
193
+ const [owner, repo] = url.slice(idx + "github.com/".length).split("/");
194
+ return owner && repo ? [owner, repo] : null;
195
+ }
196
+ /** Unresolved review-thread counts per PR number, via one GraphQL query. */
197
+ export async function fetchUnresolvedThreads(prs) {
198
+ const out = new Map();
199
+ if (prs.length === 0)
200
+ return out;
201
+ const parsed = parseOwnerRepo(prs[0].url);
202
+ if (!parsed)
203
+ return out;
204
+ const [owner, repo] = parsed;
205
+ const aliases = prs
206
+ .map((pr) => `pr${pr.number}: pullRequest(number: ${pr.number}) { reviewThreads(first: 100) { nodes { isResolved } } }`)
207
+ .join("\n");
208
+ const query = `query { repository(owner: "${owner}", name: "${repo}") { ${aliases} } }`;
209
+ const r = await run(ghBin(), ["api", "graphql", "-f", `query=${query}`]);
210
+ if (!r.ok)
211
+ return out;
212
+ try {
213
+ const repoNode = JSON.parse(r.stdout)?.data?.repository;
214
+ if (!repoNode || typeof repoNode !== "object")
215
+ return out;
216
+ for (const [alias, node] of Object.entries(repoNode)) {
217
+ if (!alias.startsWith("pr"))
218
+ continue;
219
+ const n = Number(alias.slice(2));
220
+ if (!Number.isInteger(n))
221
+ continue;
222
+ const nodes = node?.reviewThreads?.nodes ?? [];
223
+ out.set(n, nodes.filter((x) => x?.isResolved !== true).length);
224
+ }
225
+ }
226
+ catch {
227
+ // best effort
228
+ }
229
+ return out;
230
+ }
231
+ export async function fetchIssues(repoRoot, filter) {
232
+ const r = await run(ghBin(), ["issue", "list", "--search", filter, "--json", "number,title,url,body", "--limit", "100"], {
233
+ cwd: repoRoot,
234
+ });
235
+ if (!r.ok)
236
+ return [];
237
+ let issues;
238
+ try {
239
+ issues = JSON.parse(r.stdout);
240
+ }
241
+ catch {
242
+ return [];
243
+ }
244
+ return issues
245
+ .filter((i) => typeof i?.number === "number" && typeof i.title === "string" && typeof i.url === "string")
246
+ .map((i) => ({
247
+ number: i.number,
248
+ title: i.title,
249
+ url: i.url,
250
+ body: typeof i.body === "string" ? i.body : undefined,
251
+ }));
252
+ }
253
+ export async function ghIsAuthed() {
254
+ return (await run("gh", ["auth", "status"])).ok;
255
+ }
256
+ export async function commandExists(name) {
257
+ return (await run(IS_WINDOWS ? "where" : "which", [name])).ok;
258
+ }
@@ -0,0 +1,90 @@
1
+ // harness.json — lives at the root of the private harness repo and is the
2
+ // same for every member: which knowledge bases exist, which project repos map
3
+ // to which KB, and the check commands the AI-DLC skills run per project.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { run } from "../util.js";
7
+ export const CONFIG_FILE = "harness.json";
8
+ export function emptyConfig() {
9
+ return { schemaVersion: 1, kbs: [], projects: [] };
10
+ }
11
+ export function parseConfig(text) {
12
+ const raw = JSON.parse(text);
13
+ const kbs = Array.isArray(raw?.kbs)
14
+ ? raw.kbs
15
+ .filter((k) => typeof k?.id === "string" && typeof k?.path === "string")
16
+ .map((k) => ({
17
+ id: k.id,
18
+ path: k.path,
19
+ weight: typeof k.weight === "number" ? k.weight : undefined,
20
+ covers: Array.isArray(k.covers) ? k.covers.filter((c) => typeof c === "string") : undefined,
21
+ }))
22
+ : [];
23
+ const projects = Array.isArray(raw?.projects)
24
+ ? raw.projects
25
+ .filter((p) => typeof p?.id === "string" && typeof p?.kbId === "string")
26
+ .map((p) => ({
27
+ id: p.id,
28
+ name: typeof p.name === "string" ? p.name : undefined,
29
+ remote: typeof p.remote === "string" ? normalizeRemote(p.remote) : undefined,
30
+ kbId: p.kbId,
31
+ commands: p.commands && typeof p.commands === "object" ? p.commands : undefined,
32
+ }))
33
+ : [];
34
+ return { schemaVersion: typeof raw?.schemaVersion === "number" ? raw.schemaVersion : 1, kbs, projects };
35
+ }
36
+ export function readConfig(repoPath) {
37
+ try {
38
+ return parseConfig(fs.readFileSync(path.join(repoPath, CONFIG_FILE), "utf8"));
39
+ }
40
+ catch (e) {
41
+ if (e?.code === "ENOENT")
42
+ return emptyConfig();
43
+ throw new Error(`${CONFIG_FILE} is not valid: ${e?.message ?? e}`);
44
+ }
45
+ }
46
+ export function serializeConfig(c) {
47
+ return JSON.stringify(c, null, 2) + "\n";
48
+ }
49
+ /** "git@github.com:Acme/Pay.git" and "https://github.com/acme/pay" → "github.com/acme/pay". */
50
+ export function normalizeRemote(url) {
51
+ let s = url.trim().toLowerCase();
52
+ s = s.replace(/^[a-z+]+:\/\//, "").replace(/^[^@/]+@/, "");
53
+ s = s.replace(/^([^/:]+):(?!\d)/, "$1/");
54
+ s = s.replace(/\/+$/, "").replace(/\.git$/, "");
55
+ return s;
56
+ }
57
+ /** Identify the repo a directory belongs to. Worktrees resolve to their main repo. */
58
+ export async function repoIdentity(dir) {
59
+ const common = await run("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
60
+ cwd: dir,
61
+ timeoutMs: 10_000,
62
+ });
63
+ if (!common.ok)
64
+ return null;
65
+ const gitDir = common.stdout.trim();
66
+ const root = path.basename(gitDir) === ".git" ? path.dirname(gitDir) : gitDir;
67
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd: dir, timeoutMs: 10_000 });
68
+ const remote = origin.ok && origin.stdout.trim() ? normalizeRemote(origin.stdout) : undefined;
69
+ return { root, remote, name: remote ? remote.split("/").pop() : path.basename(root) };
70
+ }
71
+ /** A project matches by remote; projects without a remote match by name. */
72
+ export function findProject(config, id) {
73
+ return ((id.remote ? config.projects.find((p) => p.remote === id.remote) : undefined) ??
74
+ config.projects.find((p) => !p.remote && p.id === slugify(id.name)));
75
+ }
76
+ /** ASCII slug; accents are dropped ("Đặt hàng" → "dat-hang"). */
77
+ export function slugify(s, fallback = "project") {
78
+ return (s
79
+ .normalize("NFKD")
80
+ .replace(/[\u0300-\u036f]/g, "")
81
+ .replace(/[đĐ]/g, "d")
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9]+/g, "-")
84
+ .replace(/^-+|-+$/g, "") || fallback);
85
+ }
86
+ /** "owner/name" for GitHub remotes — the form agents.yaml expects. */
87
+ export function ownerRepo(remote) {
88
+ const m = remote ? /^github\.com\/([\w.-]+\/[\w.-]+)$/.exec(remote) : null;
89
+ return m?.[1];
90
+ }
@@ -0,0 +1,98 @@
1
+ // Knowledge entries on disk: one markdown file per entry with a YAML
2
+ // frontmatter. The harness repo is the source of truth; everything else
3
+ // (index, usage stats) is derived from these files.
4
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+ import { KNOWLEDGE_TYPES } from "../../src/types/index.js";
6
+ import { ksuid } from "../util.js";
7
+ import { slugify } from "./config.js";
8
+ export const ENTRY_SCHEMA_VERSION = 1;
9
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
10
+ const asString = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
11
+ const asStrings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && !!x.trim()).map((x) => x.trim()) : [];
12
+ export function isKnowledgeType(v) {
13
+ return typeof v === "string" && KNOWLEDGE_TYPES.includes(v);
14
+ }
15
+ /** Repo-relative, forward slashes, no leading "./" or "/". Folders keep a trailing "/". */
16
+ export function normalizeScope(p) {
17
+ let s = p.trim().replaceAll("\\", "/");
18
+ while (s.startsWith("./"))
19
+ s = s.slice(2);
20
+ s = s.replace(/^\/+/, "").replace(/\/{2,}/g, "/");
21
+ return s;
22
+ }
23
+ /** Parse one entry file. Returns null when the frontmatter is missing or unusable. */
24
+ export function parseEntry(text, kbId, filePath) {
25
+ const m = FRONTMATTER.exec(text);
26
+ if (!m)
27
+ return null;
28
+ let fm;
29
+ try {
30
+ fm = parseYaml(m[1]) ?? {};
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ const id = asString(fm.id);
36
+ const title = asString(fm.title);
37
+ if (!id || !title || !isKnowledgeType(fm.type))
38
+ return null;
39
+ const body = text.slice(m[0].length).trim();
40
+ const source = fm.source && typeof fm.source === "object" ? fm.source : undefined;
41
+ return {
42
+ id,
43
+ type: fm.type,
44
+ title,
45
+ summary: asString(fm.summary) ?? firstLine(body) ?? title,
46
+ body,
47
+ scope: asStrings(fm.scope).map(normalizeScope),
48
+ source: source
49
+ ? {
50
+ cardId: asString(source.cardId),
51
+ sessionId: asString(source.sessionId),
52
+ commit: asString(source.commit),
53
+ pr: asString(source.pr),
54
+ }
55
+ : undefined,
56
+ confidence: fm.confidence === "proposed" ? "proposed" : "confirmed",
57
+ lastVerified: asString(fm.lastVerified) ?? (fm.lastVerified instanceof Date ? isoDay(fm.lastVerified) : undefined),
58
+ supersedes: asStrings(fm.supersedes),
59
+ schemaVersion: typeof fm.schemaVersion === "number" ? fm.schemaVersion : ENTRY_SCHEMA_VERSION,
60
+ kbId,
61
+ filePath,
62
+ };
63
+ }
64
+ export function serializeEntry(e) {
65
+ const fm = {
66
+ id: e.id,
67
+ type: e.type,
68
+ title: e.title,
69
+ summary: e.summary,
70
+ scope: e.scope,
71
+ confidence: e.confidence,
72
+ lastVerified: e.lastVerified,
73
+ supersedes: e.supersedes,
74
+ schemaVersion: e.schemaVersion,
75
+ };
76
+ const source = e.source && Object.fromEntries(Object.entries(e.source).filter(([, v]) => v));
77
+ if (source && Object.keys(source).length)
78
+ fm.source = source;
79
+ for (const k of Object.keys(fm))
80
+ if (fm[k] === undefined)
81
+ delete fm[k];
82
+ return `---\n${stringifyYaml(fm).trimEnd()}\n---\n\n${e.body.trim()}\n`;
83
+ }
84
+ export function newEntryId() {
85
+ return ksuid("kb");
86
+ }
87
+ /** `<type>/<slug>.md` — stable and readable in a PR diff. */
88
+ export function entryFileName(type, title, id) {
89
+ const slug = slugify(title, id.toLowerCase()).slice(0, 60).replace(/-+$/, "");
90
+ return `${type}/${slug}.md`;
91
+ }
92
+ export const isoDay = (d = new Date()) => d.toISOString().slice(0, 10);
93
+ function firstLine(body) {
94
+ return body
95
+ .split(/\r?\n/)
96
+ .map((l) => l.replace(/^#+\s*/, "").trim())
97
+ .find(Boolean);
98
+ }