@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.
Files changed (79) hide show
  1. package/.claude/skills/devboard/SKILL.md +111 -0
  2. package/bin/devboard.mjs +59 -0
  3. package/dist/assets/CardDetailView-CVWOsYyA.js +24 -0
  4. package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
  5. package/dist/assets/DiffPane-bQsGoT7y.js +22 -0
  6. package/dist/assets/KnowledgeView-C5KHiBRG.js +3 -0
  7. package/dist/assets/ListBoardView-CzJcCLXS.js +3 -0
  8. package/dist/assets/NewTaskDialog-BuxvfgNU.js +1 -0
  9. package/dist/assets/OnboardingWizard-8WLFjTd8.js +1 -0
  10. package/dist/assets/ProcessManagerView-BIYjQpoP.js +1 -0
  11. package/dist/assets/ProposalCard-BbPobl1f.js +1 -0
  12. package/dist/assets/SearchOverlay-B4aywpIh.js +1 -0
  13. package/dist/assets/SettingsView-spBB3EwB.js +1 -0
  14. package/dist/assets/SetupProjectView-BuUDqDYU.js +1 -0
  15. package/dist/assets/Surface-BQKeigoq.js +1 -0
  16. package/dist/assets/TaskDescriptionEditor-Cv0nlU3w.js +58 -0
  17. package/dist/assets/Terminal-BrP-ENHg.css +1 -0
  18. package/dist/assets/Terminal-DJJcg-t6.js +80 -0
  19. package/dist/assets/chevron-right-BsVThPz4.js +1 -0
  20. package/dist/assets/index-DNKB6XwG.css +1 -0
  21. package/dist/assets/index-gWSenOqD.js +18 -0
  22. package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
  23. package/dist/assets/plus-XSPxArj5.js +1 -0
  24. package/dist/assets/react-dom-DjtJUrx3.js +1 -0
  25. package/dist/assets/x-CdEDbLMR.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 +424 -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/git.js +258 -0
  36. package/server-dist/server/harness/config.js +90 -0
  37. package/server-dist/server/harness/entry.js +98 -0
  38. package/server-dist/server/harness/intents.js +268 -0
  39. package/server-dist/server/harness/kbIndex.js +291 -0
  40. package/server-dist/server/harness/kbStore.js +258 -0
  41. package/server-dist/server/harness/librarian.js +147 -0
  42. package/server-dist/server/harness/pack.js +182 -0
  43. package/server-dist/server/harness/proposals.js +113 -0
  44. package/server-dist/server/harness/repo.js +150 -0
  45. package/server-dist/server/harness/scrub.js +30 -0
  46. package/server-dist/server/harness/service.js +683 -0
  47. package/server-dist/server/harness/template/README.md +38 -0
  48. package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
  49. package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
  50. package/server-dist/server/harness/template/agents/test-writer.md +16 -0
  51. package/server-dist/server/harness/template/agents.yaml +41 -0
  52. package/server-dist/server/harness/template/harness.json +11 -0
  53. package/server-dist/server/harness/template/kb/team/README.md +4 -0
  54. package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
  55. package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
  56. package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
  57. package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
  58. package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
  59. package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
  60. package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
  61. package/server-dist/server/harness/usage.js +82 -0
  62. package/server-dist/server/hooks.js +222 -0
  63. package/server-dist/server/http.js +266 -0
  64. package/server-dist/server/index.js +77 -0
  65. package/server-dist/server/integrations.js +292 -0
  66. package/server-dist/server/pollers.js +332 -0
  67. package/server-dist/server/processes.js +123 -0
  68. package/server-dist/server/psmux.js +120 -0
  69. package/server-dist/server/pty.js +93 -0
  70. package/server-dist/server/remote.js +216 -0
  71. package/server-dist/server/sessionReaper.js +101 -0
  72. package/server-dist/server/sessions.js +315 -0
  73. package/server-dist/server/settings.js +125 -0
  74. package/server-dist/server/skill.js +29 -0
  75. package/server-dist/server/tmux.js +217 -0
  76. package/server-dist/server/transcripts.js +335 -0
  77. package/server-dist/server/util.js +286 -0
  78. package/server-dist/src/types/index.js +53 -0
  79. package/README.md +0 -3
@@ -0,0 +1,335 @@
1
+ // Transcript reading, file-level session ops (fork / truncate / move) and BM25
2
+ // search. Ports transcript_reader.rs, session_ops.rs, session_mover.rs, bm25.rs.
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { readLines, takeChars } from "./util.js";
8
+ // ── Transcript ───────────────────────────────────────────────────────────────
9
+ const PAGE_SIZE = 20;
10
+ function parseContentBlocks(obj) {
11
+ const blocks = [];
12
+ const content = obj?.message?.content;
13
+ if (!Array.isArray(content)) {
14
+ if (typeof content === "string")
15
+ blocks.push({ kind: "text", text: content });
16
+ return blocks;
17
+ }
18
+ for (const block of content) {
19
+ const kind = typeof block?.type === "string" ? block.type : "text";
20
+ switch (kind) {
21
+ case "text":
22
+ blocks.push({ kind: "text", text: typeof block.text === "string" ? block.text : "" });
23
+ break;
24
+ case "tool_use":
25
+ blocks.push({ kind: "tool_use", text: `${block.name ?? "tool"}: ${JSON.stringify(block.input ?? null)}` });
26
+ break;
27
+ case "tool_result": {
28
+ const c = block.content;
29
+ const text = typeof c === "string" ? c : typeof c?.[0]?.text === "string" ? c[0].text : "";
30
+ blocks.push({ kind: "tool_result", text });
31
+ break;
32
+ }
33
+ case "thinking":
34
+ blocks.push({ kind: "thinking", text: typeof block.thinking === "string" ? block.thinking : "" });
35
+ break;
36
+ default:
37
+ break;
38
+ }
39
+ }
40
+ return blocks;
41
+ }
42
+ async function* turnObjects(filePath) {
43
+ for await (const line of readLines(filePath)) {
44
+ if (!line)
45
+ continue;
46
+ let obj;
47
+ try {
48
+ obj = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ if (obj?.type === "user" || obj?.type === "assistant")
54
+ yield obj;
55
+ }
56
+ }
57
+ export async function readTranscript(filePath, offset) {
58
+ if (!fs.existsSync(filePath))
59
+ return { turns: [], totalTurns: 0, hasMore: false, nextOffset: 0 };
60
+ const all = [];
61
+ let index = 0;
62
+ for await (const obj of turnObjects(filePath)) {
63
+ const contentBlocks = parseContentBlocks(obj);
64
+ const textPreview = takeChars(contentBlocks
65
+ .filter((b) => b.kind === "text")
66
+ .map((b) => b.text)
67
+ .join(" "), 200);
68
+ all.push({
69
+ index: index++,
70
+ role: obj.type,
71
+ textPreview,
72
+ timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
73
+ contentBlocks,
74
+ });
75
+ }
76
+ const total = all.length;
77
+ const hasMore = offset + PAGE_SIZE < total;
78
+ return {
79
+ turns: all.slice(offset, offset + PAGE_SIZE),
80
+ totalTurns: total,
81
+ hasMore,
82
+ nextOffset: hasMore ? offset + PAGE_SIZE : total,
83
+ };
84
+ }
85
+ export async function searchTranscriptTurns(filePath, query) {
86
+ if (!fs.existsSync(filePath))
87
+ return [];
88
+ const q = query.toLowerCase();
89
+ const matches = [];
90
+ let index = 0;
91
+ for await (const obj of turnObjects(filePath)) {
92
+ if (parseContentBlocks(obj).some((b) => b.text.toLowerCase().includes(q)))
93
+ matches.push(index);
94
+ index++;
95
+ }
96
+ return matches;
97
+ }
98
+ /** Text of the latest assistant turn, capped for an OS notification body. */
99
+ export async function readLastAssistantMessage(filePath, maxChars) {
100
+ if (!fs.existsSync(filePath))
101
+ return null;
102
+ let last = null;
103
+ for await (const obj of turnObjects(filePath)) {
104
+ if (obj.type !== "assistant")
105
+ continue;
106
+ const text = parseContentBlocks(obj)
107
+ .filter((b) => b.kind === "text")
108
+ .map((b) => b.text)
109
+ .join(" ")
110
+ .trim();
111
+ if (text)
112
+ last = text;
113
+ }
114
+ if (last === null)
115
+ return null;
116
+ const chars = Array.from(last);
117
+ return chars.length > maxChars ? chars.slice(0, maxChars).join("") + "…" : last;
118
+ }
119
+ // ── fork / truncate / move ───────────────────────────────────────────────────
120
+ /** Copy a session under a fresh UUID, rewriting the old id. Returns the new id. */
121
+ export async function forkSession(sessionPath, targetDir) {
122
+ if (!fs.existsSync(sessionPath))
123
+ throw new Error(`session file not found: ${sessionPath}`);
124
+ const dir = targetDir || path.dirname(sessionPath);
125
+ await fs.promises.mkdir(dir, { recursive: true });
126
+ const newId = crypto.randomUUID().toLowerCase();
127
+ const oldId = path.basename(sessionPath, path.extname(sessionPath));
128
+ const newPath = path.join(dir, `${newId}.jsonl`);
129
+ const contents = await fs.promises.readFile(sessionPath, "utf8");
130
+ await fs.promises.writeFile(newPath, contents.replaceAll(`"${oldId}"`, `"${newId}"`));
131
+ // Preserve the source mtime so the activity detector doesn't flag the fork as active.
132
+ try {
133
+ const st = await fs.promises.stat(sessionPath);
134
+ await fs.promises.utimes(newPath, st.atime, st.mtime);
135
+ }
136
+ catch {
137
+ // best effort
138
+ }
139
+ return newId;
140
+ }
141
+ /** Keep the first `turnCount` user/assistant turns; original is saved as `.bkp`. */
142
+ export async function truncateSession(sessionPath, turnCount) {
143
+ if (!fs.existsSync(sessionPath))
144
+ throw new Error(`session file not found: ${sessionPath}`);
145
+ if (turnCount < 1)
146
+ throw new Error("turn_count must be >= 1");
147
+ const bkp = `${sessionPath}.bkp`;
148
+ await fs.promises.rm(bkp, { force: true });
149
+ await fs.promises.copyFile(sessionPath, bkp);
150
+ const contents = await fs.promises.readFile(sessionPath, "utf8");
151
+ const kept = [];
152
+ let seen = 0;
153
+ let hitTarget = false;
154
+ for (const line of contents.split(/\r?\n/)) {
155
+ let role;
156
+ try {
157
+ role = JSON.parse(line)?.type;
158
+ }
159
+ catch {
160
+ role = undefined;
161
+ }
162
+ const isTurn = role === "user" || role === "assistant";
163
+ if (hitTarget && isTurn)
164
+ break;
165
+ if (isTurn) {
166
+ seen++;
167
+ if (seen >= turnCount)
168
+ hitTarget = true;
169
+ }
170
+ kept.push(line);
171
+ }
172
+ // A trailing "" from the final newline would add a spurious blank line.
173
+ while (kept.length && kept[kept.length - 1] === "")
174
+ kept.pop();
175
+ await fs.promises.writeFile(sessionPath, kept.length ? kept.join("\n") + "\n" : "");
176
+ }
177
+ /** `/` `\` and `.` → `-`, matching Claude's projects-dir naming. */
178
+ export function encodeProjectPath(p) {
179
+ return p.replaceAll("\\", "/").replaceAll("/", "-").replaceAll(".", "-");
180
+ }
181
+ /** Move a session jsonl into another project's directory, rewriting `cwd`. */
182
+ export async function moveSession(sessionId, fromPath, toProjectPath) {
183
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
184
+ const targetDir = path.join(projectsDir, encodeProjectPath(toProjectPath));
185
+ const targetPath = path.join(targetDir, `${sessionId}.jsonl`);
186
+ await fs.promises.mkdir(targetDir, { recursive: true });
187
+ const content = await fs.promises.readFile(fromPath, "utf8");
188
+ const lines = content.split("\n").map((line) => {
189
+ if (!line.trim())
190
+ return line;
191
+ try {
192
+ const obj = JSON.parse(line);
193
+ if (obj && typeof obj === "object" && "cwd" in obj)
194
+ obj.cwd = toProjectPath;
195
+ return JSON.stringify(obj);
196
+ }
197
+ catch {
198
+ return line;
199
+ }
200
+ });
201
+ await fs.promises.writeFile(targetPath, lines.join("\n"));
202
+ if (path.resolve(fromPath) !== path.resolve(targetPath))
203
+ await fs.promises.rm(fromPath, { force: true });
204
+ return targetPath;
205
+ }
206
+ // ── BM25 ─────────────────────────────────────────────────────────────────────
207
+ const K1 = 1.2;
208
+ const B = 0.4;
209
+ export function tokenize(text) {
210
+ return text
211
+ .toLowerCase()
212
+ .split(/[^\p{L}\p{N}]+/u)
213
+ .filter((s) => s.length >= 2);
214
+ }
215
+ export function bm25Score(terms, docTokens, avgDocLength, docCount, docFreqs, recencyBoost) {
216
+ const docLength = docTokens.length;
217
+ if (docLength === 0 || avgDocLength === 0)
218
+ return 0;
219
+ const tf = new Map();
220
+ for (const t of docTokens)
221
+ tf.set(t, (tf.get(t) ?? 0) + 1);
222
+ let total = 0;
223
+ for (const term of terms) {
224
+ let termFreq = 0;
225
+ let dfCount = 0;
226
+ if (term.length >= 3) {
227
+ for (const [k, v] of tf)
228
+ if (k.startsWith(term))
229
+ termFreq += v;
230
+ for (const [k, v] of docFreqs)
231
+ if (k.startsWith(term))
232
+ dfCount += v;
233
+ }
234
+ else {
235
+ termFreq = tf.get(term) ?? 0;
236
+ dfCount = docFreqs.get(term) ?? 0;
237
+ }
238
+ if (termFreq === 0)
239
+ continue;
240
+ const df = Math.max(dfCount, 0.5);
241
+ const idf = Math.log((docCount - df + 0.5) / (df + 0.5) + 1);
242
+ const tfNorm = (termFreq * (K1 + 1)) / (termFreq + K1 * (1 - B + (B * docLength) / avgDocLength));
243
+ total += idf * tfNorm;
244
+ }
245
+ return total * recencyBoost;
246
+ }
247
+ /** 3.0 today → 1.0 at ≥30 days, linear. */
248
+ export function recencyBoost(ageSecs) {
249
+ const days = ageSecs / 86_400;
250
+ if (days <= 0)
251
+ return 3;
252
+ if (days >= 30)
253
+ return 1;
254
+ return 3 - (2 * days) / 30;
255
+ }
256
+ export async function searchSessions(allSessions, query) {
257
+ const q = query.trim();
258
+ if (!q)
259
+ return allSessions.slice(0, 20);
260
+ const terms = tokenize(q);
261
+ if (terms.length === 0) {
262
+ const ql = q.toLowerCase();
263
+ return allSessions
264
+ .filter((s) => s.id.toLowerCase().includes(ql) ||
265
+ (s.firstPrompt ?? "").toLowerCase().includes(ql) ||
266
+ (s.projectPath ?? "").toLowerCase().includes(ql))
267
+ .slice(0, 20);
268
+ }
269
+ const docs = [];
270
+ for (const s of allSessions) {
271
+ if (!s.jsonlPath)
272
+ continue;
273
+ let content;
274
+ try {
275
+ content = await fs.promises.readFile(s.jsonlPath, "utf8");
276
+ }
277
+ catch {
278
+ continue;
279
+ }
280
+ const tokens = tokenize(content);
281
+ if (tokens.length === 0)
282
+ continue;
283
+ docs.push({ session: s, tokens, ageSecs: (Date.now() - Date.parse(s.modifiedTime)) / 1000 });
284
+ }
285
+ if (docs.length === 0)
286
+ return [];
287
+ const docCount = docs.length;
288
+ const avgDocLength = docs.reduce((n, d) => n + d.tokens.length, 0) / docCount;
289
+ const docFreqs = new Map();
290
+ for (const d of docs)
291
+ for (const t of new Set(d.tokens))
292
+ docFreqs.set(t, (docFreqs.get(t) ?? 0) + 1);
293
+ return docs
294
+ .map((d) => ({
295
+ score: bm25Score(terms, d.tokens, avgDocLength, docCount, docFreqs, recencyBoost(d.ageSecs)),
296
+ session: d.session,
297
+ }))
298
+ .filter((x) => x.score > 0)
299
+ .sort((a, b) => b.score - a.score)
300
+ .slice(0, 20)
301
+ .map((x) => x.session);
302
+ }
303
+ /** Token usage summed from the `message.usage` of every assistant line. Claude
304
+ * writes one line per content block of a message, all repeating the same usage,
305
+ * so only the last line of each message id counts. */
306
+ export async function readTranscriptUsage(filePath) {
307
+ if (!fs.existsSync(filePath))
308
+ return null;
309
+ const byMessage = new Map();
310
+ let last = null;
311
+ let model = "";
312
+ let anon = 0;
313
+ for await (const obj of turnObjects(filePath)) {
314
+ if (obj.type !== "assistant")
315
+ continue;
316
+ const u = obj.message?.usage;
317
+ if (!u)
318
+ continue;
319
+ const input = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
320
+ const entry = { input, output: u.output_tokens ?? 0 };
321
+ byMessage.set(obj.message.id ?? `anon-${anon++}`, entry);
322
+ last = entry;
323
+ if (typeof obj.message.model === "string" && !obj.message.model.startsWith("<"))
324
+ model = obj.message.model;
325
+ }
326
+ if (!last)
327
+ return null;
328
+ let inputTokens = 0;
329
+ let outputTokens = 0;
330
+ for (const e of byMessage.values()) {
331
+ inputTokens += e.input;
332
+ outputTokens += e.output;
333
+ }
334
+ return { contextTokens: last.input + last.output, inputTokens, outputTokens, model };
335
+ }
@@ -0,0 +1,286 @@
1
+ // Shared plumbing: data dir, logging, KSUID, subprocess helpers, a mutex.
2
+ // Ports coordination_store::devboard_data_dir, logging.rs and ksuid.rs.
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+ export const IS_WINDOWS = process.platform === "win32";
9
+ /** %APPDATA%\devboard on Windows, ~/.devboard elsewhere. */
10
+ export function devboardDataDir() {
11
+ if (process.env.DEVBOARD_DATA_DIR)
12
+ return process.env.DEVBOARD_DATA_DIR;
13
+ if (IS_WINDOWS) {
14
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
15
+ return path.join(appData, "devboard");
16
+ }
17
+ return path.join(os.homedir(), ".devboard");
18
+ }
19
+ // ── Logging ──────────────────────────────────────────────────────────────────
20
+ const MAX_LOG_SIZE = 10 * 1024 * 1024;
21
+ const KEEP_AFTER_ROTATION = 5 * 1024 * 1024;
22
+ let logPathCache = null;
23
+ function logPath() {
24
+ if (logPathCache)
25
+ return logPathCache;
26
+ const dir = path.join(devboardDataDir(), "logs");
27
+ try {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ }
30
+ catch {
31
+ // logging must never throw
32
+ }
33
+ const p = path.join(dir, "devboard.log");
34
+ try {
35
+ const st = fs.statSync(p);
36
+ if (st.size > MAX_LOG_SIZE) {
37
+ const data = fs.readFileSync(p);
38
+ const tail = data.subarray(Math.max(0, data.length - KEEP_AFTER_ROTATION));
39
+ const nl = tail.indexOf(0x0a);
40
+ fs.writeFileSync(p, nl >= 0 ? tail.subarray(nl + 1) : tail);
41
+ }
42
+ }
43
+ catch {
44
+ // no log yet
45
+ }
46
+ logPathCache = p;
47
+ return p;
48
+ }
49
+ // Console output is for humans: local time, coloured level, aligned subsystem.
50
+ // The log file keeps the plain ISO format so it stays greppable.
51
+ const useColor = !process.env.NO_COLOR && (process.env.FORCE_COLOR ? true : Boolean(process.stderr.isTTY));
52
+ const ANSI = {
53
+ reset: "\x1b[0m",
54
+ dim: "\x1b[2m",
55
+ DEBUG: "\x1b[90m",
56
+ INFO: "\x1b[32m",
57
+ WARN: "\x1b[33m",
58
+ ERROR: "\x1b[1;31m",
59
+ };
60
+ const SUBSYSTEM_COLORS = [36, 35, 34, 96, 95, 94];
61
+ const SUBSYSTEM_WIDTH = 10;
62
+ function subsystemColor(name) {
63
+ let h = 0;
64
+ for (let i = 0; i < name.length; i++)
65
+ h = (h * 31 + name.charCodeAt(i)) >>> 0;
66
+ return SUBSYSTEM_COLORS[h % SUBSYSTEM_COLORS.length];
67
+ }
68
+ function consoleLine(level, subsystem, message) {
69
+ const d = new Date();
70
+ const p2 = (n) => String(n).padStart(2, "0");
71
+ const time = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
72
+ const lvl = level.padEnd(5);
73
+ const sub = subsystem.padEnd(SUBSYSTEM_WIDTH);
74
+ if (!useColor)
75
+ return `${time} ${lvl} ${sub} ${message}\n`;
76
+ const msg = level === "ERROR" || level === "WARN" ? `${ANSI[level]}${message}${ANSI.reset}` : message;
77
+ return (`${ANSI.dim}${time}${ANSI.reset} ${ANSI[level]}${lvl}${ANSI.reset} ` +
78
+ `\x1b[${subsystemColor(subsystem)}m${sub}${ANSI.reset} ${msg}\n`);
79
+ }
80
+ function writeLog(level, subsystem, message) {
81
+ const line = `[${new Date().toISOString()}] [${level}] [${subsystem}] ${message}\n`;
82
+ process.stderr.write(consoleLine(level, subsystem, message));
83
+ try {
84
+ fs.appendFileSync(logPath(), line);
85
+ }
86
+ catch {
87
+ // drop the line rather than crash
88
+ }
89
+ }
90
+ const debugEnabled = process.env.DEVBOARD_DEBUG_LOGS === "1" || process.env.DEVBOARD_DEBUG === "1";
91
+ export const log = {
92
+ info: (subsystem, message) => writeLog("INFO", subsystem, message),
93
+ warn: (subsystem, message) => writeLog("WARN", subsystem, message),
94
+ error: (subsystem, message) => writeLog("ERROR", subsystem, message),
95
+ debug: (subsystem, message) => {
96
+ if (debugEnabled)
97
+ writeLog("DEBUG", subsystem, message);
98
+ },
99
+ };
100
+ // ── KSUID ────────────────────────────────────────────────────────────────────
101
+ // 4-byte big-endian timestamp (seconds since 1_400_000_000) + 16 random bytes,
102
+ // base62-encoded to 27 chars. Bit-for-bit compatible with the Swift/Rust ports.
103
+ const KSUID_EPOCH = 1_400_000_000;
104
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
105
+ export function ksuid(prefix) {
106
+ const ts = Math.max(0, Math.floor(Date.now() / 1000) - KSUID_EPOCH);
107
+ const bytes = Buffer.alloc(20);
108
+ bytes.writeUInt32BE(ts, 0);
109
+ crypto.randomFillSync(bytes, 4);
110
+ const num = Uint8Array.from(bytes);
111
+ const out = new Array(27).fill("0");
112
+ for (let slot = 26; slot >= 0; slot--) {
113
+ let remainder = 0;
114
+ for (let i = 0; i < num.length; i++) {
115
+ const value = num[i] + remainder * 256;
116
+ num[i] = Math.floor(value / 62);
117
+ remainder = value % 62;
118
+ }
119
+ out[slot] = BASE62[remainder];
120
+ }
121
+ const encoded = out.join("");
122
+ return prefix ? `${prefix}_${encoded}` : encoded;
123
+ }
124
+ /** Run a command to completion. Never rejects: spawn failures resolve `ok: false`. */
125
+ export function run(cmd, args, opts = {}) {
126
+ return new Promise((resolve) => {
127
+ const spawnOpts = {
128
+ cwd: opts.cwd,
129
+ env: opts.env ?? process.env,
130
+ windowsHide: true,
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ };
133
+ let child;
134
+ try {
135
+ child = spawn(cmd, args, spawnOpts);
136
+ }
137
+ catch (e) {
138
+ resolve({ ok: false, code: null, stdout: "", stderr: String(e) });
139
+ return;
140
+ }
141
+ const out = [];
142
+ const err = [];
143
+ let timer;
144
+ let settled = false;
145
+ const finish = (r) => {
146
+ if (settled)
147
+ return;
148
+ settled = true;
149
+ if (timer)
150
+ clearTimeout(timer);
151
+ resolve(r);
152
+ };
153
+ if (opts.timeoutMs) {
154
+ timer = setTimeout(() => {
155
+ child.kill();
156
+ finish({ ok: false, code: null, stdout: Buffer.concat(out).toString("utf8"), stderr: "timed out" });
157
+ }, opts.timeoutMs);
158
+ }
159
+ child.stdout?.on("data", (d) => out.push(d));
160
+ child.stderr?.on("data", (d) => err.push(d));
161
+ child.on("error", (e) => finish({ ok: false, code: null, stdout: "", stderr: String(e) }));
162
+ child.on("close", (code) => finish({
163
+ ok: code === 0,
164
+ code,
165
+ stdout: Buffer.concat(out).toString("utf8"),
166
+ stderr: Buffer.concat(err).toString("utf8"),
167
+ }));
168
+ // A command that exits before reading stdin raises EPIPE; ignore it.
169
+ child.stdin?.on("error", () => { });
170
+ if (opts.input !== undefined)
171
+ child.stdin?.end(opts.input);
172
+ else
173
+ child.stdin?.end();
174
+ });
175
+ }
176
+ /** Run a user-supplied command line through the platform shell (merge templates). */
177
+ export function runShell(command, cwd) {
178
+ return new Promise((resolve) => {
179
+ const child = spawn(command, { cwd, shell: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
180
+ const out = [];
181
+ const err = [];
182
+ child.stdout?.on("data", (d) => out.push(d));
183
+ child.stderr?.on("data", (d) => err.push(d));
184
+ child.on("error", (e) => resolve({ ok: false, code: null, stdout: "", stderr: String(e) }));
185
+ child.on("close", (code) => resolve({
186
+ ok: code === 0,
187
+ code,
188
+ stdout: Buffer.concat(out).toString("utf8"),
189
+ stderr: Buffer.concat(err).toString("utf8"),
190
+ }));
191
+ });
192
+ }
193
+ /** Fire-and-forget a detached process (editors, browsers). */
194
+ export function spawnDetached(cmd, args, cwd) {
195
+ try {
196
+ const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", windowsHide: true });
197
+ child.on("error", () => { });
198
+ child.unref();
199
+ return true;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ /** Locate an executable on PATH (adds .exe/.cmd/.bat on Windows). */
206
+ export function findOnPath(name) {
207
+ const exts = IS_WINDOWS ? ["", ".exe", ".cmd", ".bat"] : [""];
208
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
209
+ if (!dir)
210
+ continue;
211
+ for (const ext of exts) {
212
+ const candidate = path.join(dir, name + ext);
213
+ try {
214
+ if (fs.statSync(candidate).isFile())
215
+ return candidate;
216
+ }
217
+ catch {
218
+ // keep looking
219
+ }
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+ // ── Mutex ────────────────────────────────────────────────────────────────────
225
+ /** Serialises async critical sections (read-modify-write of JSON stores). */
226
+ export class Mutex {
227
+ tail = Promise.resolve();
228
+ run(fn) {
229
+ const next = this.tail.then(fn, fn);
230
+ this.tail = next.catch(() => { });
231
+ return next;
232
+ }
233
+ }
234
+ // ── File helpers ─────────────────────────────────────────────────────────────
235
+ export async function fileExists(p) {
236
+ try {
237
+ await fs.promises.access(p);
238
+ return true;
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
244
+ /** Write via tmp file + rename so readers never see a half-written file. */
245
+ export async function writeFileAtomic(p, data) {
246
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
247
+ const tmp = `${p}.tmp-${crypto.randomBytes(6).toString("hex")}`;
248
+ await fs.promises.writeFile(tmp, data);
249
+ try {
250
+ await fs.promises.rename(tmp, p);
251
+ }
252
+ catch (e) {
253
+ await fs.promises.rm(tmp, { force: true });
254
+ throw e;
255
+ }
256
+ }
257
+ export async function readJsonFile(p) {
258
+ try {
259
+ return JSON.parse(await fs.promises.readFile(p, "utf8"));
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /** Reads a file line-by-line without loading it all (used for big transcripts). */
266
+ export async function* readLines(p) {
267
+ const stream = fs.createReadStream(p, { encoding: "utf8" });
268
+ let buf = "";
269
+ for await (const chunk of stream) {
270
+ buf += chunk;
271
+ let idx;
272
+ while ((idx = buf.indexOf("\n")) >= 0) {
273
+ yield buf.slice(0, idx).replace(/\r$/, "");
274
+ buf = buf.slice(idx + 1);
275
+ }
276
+ }
277
+ if (buf.length > 0)
278
+ yield buf.replace(/\r$/, "");
279
+ }
280
+ export function errorMessage(e) {
281
+ return e instanceof Error ? e.message : String(e);
282
+ }
283
+ /** Truncate to `n` Unicode code points (Rust `.chars().take(n)`). */
284
+ export function takeChars(s, n) {
285
+ return Array.from(s).slice(0, n).join("");
286
+ }
@@ -0,0 +1,53 @@
1
+ // TypeScript types mirroring Rust/Swift domain entities
2
+ export const COLUMN_DISPLAY = {
3
+ backlog: "Backlog",
4
+ in_progress: "In Progress",
5
+ in_review: "In Review",
6
+ done: "Done",
7
+ all_sessions: "All Sessions",
8
+ };
9
+ /** Status label for a card: archived cards read "Archived", not their column name. */
10
+ export function statusLabel(link) {
11
+ return link.manuallyArchived ? "Archived" : COLUMN_DISPLAY[link.column];
12
+ }
13
+ export const COLUMNS = ["backlog", "in_progress", "in_review", "done", "all_sessions"];
14
+ /** Lanes drawn on the board; "All Sessions" lives in the left sidebar instead. */
15
+ export const BOARD_COLUMNS = COLUMNS.filter((col) => col !== "all_sessions");
16
+ export const AIDLC_STAGES = ["requirements", "units", "design", "code", "review"];
17
+ export const KNOWLEDGE_TYPES = [
18
+ "decision",
19
+ "convention",
20
+ "gotcha",
21
+ "component",
22
+ "glossary",
23
+ "runbook",
24
+ ];
25
+ export const ASSISTANT_DISPLAY = {
26
+ claude: "Claude Code",
27
+ };
28
+ export const ASSISTANT_CLI = {
29
+ claude: "claude",
30
+ };
31
+ /** Mirrors macOS APIService.needsSeparator — a `--` separator before the
32
+ * assistant's own flags is required iff a launcher prefix or model flag is
33
+ * set. */
34
+ export function apiServiceNeedsSeparator(svc) {
35
+ return Boolean(svc.launcherPrefix || svc.modelFlag);
36
+ }
37
+ /** Resolves an APIService for a launch. Per-card override → per-assistant
38
+ * default → undefined. Mirrors the Rust `Settings::resolve_api_service`
39
+ * logic; both must stay in sync. */
40
+ export function resolveAPIService(settings, cardOverride, assistantId) {
41
+ const services = settings.apiServices ?? [];
42
+ if (cardOverride) {
43
+ const hit = services.find((s) => s.id === cardOverride);
44
+ if (hit)
45
+ return hit;
46
+ // stale override — fall through so a deleted service doesn't brick the card
47
+ }
48
+ const defaults = settings.defaultAPIServiceIds ?? {};
49
+ const defaultId = defaults[assistantId];
50
+ if (!defaultId)
51
+ return undefined;
52
+ return services.find((s) => s.id === defaultId);
53
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.