@luuthuong/devboard 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/devboard/SKILL.md +111 -0
- package/bin/devboard.mjs +59 -0
- package/dist/assets/CardDetailView-CVWOsYyA.js +24 -0
- package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
- package/dist/assets/DiffPane-bQsGoT7y.js +22 -0
- package/dist/assets/KnowledgeView-C5KHiBRG.js +3 -0
- package/dist/assets/ListBoardView-CzJcCLXS.js +3 -0
- package/dist/assets/NewTaskDialog-BuxvfgNU.js +1 -0
- package/dist/assets/OnboardingWizard-8WLFjTd8.js +1 -0
- package/dist/assets/ProcessManagerView-BIYjQpoP.js +1 -0
- package/dist/assets/ProposalCard-BbPobl1f.js +1 -0
- package/dist/assets/SearchOverlay-B4aywpIh.js +1 -0
- package/dist/assets/SettingsView-spBB3EwB.js +1 -0
- package/dist/assets/SetupProjectView-BuUDqDYU.js +1 -0
- package/dist/assets/Surface-BQKeigoq.js +1 -0
- package/dist/assets/TaskDescriptionEditor-Cv0nlU3w.js +58 -0
- package/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/dist/assets/Terminal-DJJcg-t6.js +80 -0
- package/dist/assets/chevron-right-BsVThPz4.js +1 -0
- package/dist/assets/index-DNKB6XwG.css +1 -0
- package/dist/assets/index-gWSenOqD.js +18 -0
- package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
- package/dist/assets/plus-XSPxArj5.js +1 -0
- package/dist/assets/react-dom-DjtJUrx3.js +1 -0
- package/dist/assets/x-CdEDbLMR.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 +424 -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/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 +335 -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
|
+
// 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
|
+
}
|