@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,390 @@
1
+ // The RPC surface: every command the UI used to `invoke()` through Tauri.
2
+ // Arguments arrive as the same camelCase objects; return values are JSON.
3
+ import { notifyUser } from "./context.js";
4
+ import { emit } from "./events.js";
5
+ import { fsList, fsSearch } from "./fsBrowse.js";
6
+ import { openInEditor, openInTerminal } from "./editor.js";
7
+ import { commandExists, createWorktree, getDiff, ghIsAuthed, githubBaseUrl, listWorktrees, removeWorktree, } from "./git.js";
8
+ import { currentContextTokens, installStatusline, mergeBlocked, mergeIntoTarget, readAllContextUsage, readContextUsage, ensureLauncher, saveAttachmentBytes, saveImageBytes, statuslineInstalled, uninstallStatusline, } from "./integrations.js";
9
+ import { approveGate, createIntent, rejectGate, requestGate } from "./harness/intents.js";
10
+ import { getProcessState, killClaudeProcess } from "./processes.js";
11
+ import { defaultIgnores, deployRemoteShell, ensureDaemon, flushSync, prereqs, rawSyncStatus, resetSync, startSync, stopSync, syncStatus, } from "./remote.js";
12
+ import { installPsmux, psmuxStatus } from "./psmux.js";
13
+ import { killCardSession } from "./sessionReaper.js";
14
+ import * as tmux from "./tmux.js";
15
+ const backendFor = (runtime) => (runtime === "windows" ? "psmux" : "wsl");
16
+ import { forkSession, moveSession, readTranscript, readTranscriptUsage, searchSessions, searchTranscriptTurns, truncateSession, } from "./transcripts.js";
17
+ import { errorMessage, ksuid, log, runShell } from "./util.js";
18
+ const nowIso = () => new Date().toISOString();
19
+ /** Resolve a session's transcript path: the card's link first, then discovery. */
20
+ async function sessionPathFor(ctx, sessionId) {
21
+ const links = await ctx.store.readLinks();
22
+ const linked = links.find((l) => l.sessionLink?.sessionId === sessionId)?.sessionLink?.sessionPath;
23
+ if (linked)
24
+ return linked;
25
+ const found = (await ctx.discovery.discoverSessions()).find((s) => s.id === sessionId)?.jsonlPath;
26
+ if (!found)
27
+ throw new Error(`Session ${sessionId} not found`);
28
+ return found;
29
+ }
30
+ async function configuredProjectPaths(settings) {
31
+ const s = await settings.read().catch(() => null);
32
+ return (s?.projects ?? []).flatMap((p) => [p.path, p.repoRoot].filter((x) => !!x));
33
+ }
34
+ export function buildCommands(ctx) {
35
+ const { store, settings, discovery, board, harness } = ctx;
36
+ return {
37
+ // ── Board / cards ────────────────────────────────────────────────────────
38
+ async get_board_state() {
39
+ await board.refresh(discovery, store, await configuredProjectPaths(settings));
40
+ return board.toDto();
41
+ },
42
+ move_card: ({ cardId, column }) => store.moveCard(cardId, column),
43
+ reorder_cards: ({ orderedIds }) => store.reorderCards(orderedIds),
44
+ mark_card_opened: ({ cardId }) => store.markCardOpened(cardId),
45
+ set_card_pinned: ({ cardId, isPinned }) => store.setCardPinned(cardId, isPinned),
46
+ reorder_pinned_cards: ({ orderedIds }) => store.reorderPinnedCards(orderedIds),
47
+ async create_card({ prompt, title, project, promptImagePaths, apiServiceId }) {
48
+ // Launching is driven client-side: the UI selects the card and starts its terminal.
49
+ return store.createCard({ prompt, title, project, assistantId: "claude", promptImagePaths, apiServiceId });
50
+ },
51
+ update_card_task: ({ cardId, name, promptBody, promptImagePaths }) => store.updateCardTask(cardId, { name: name ?? "", promptBody, promptImagePaths }),
52
+ set_card_api_service: ({ cardId, apiServiceId }) => store.setCardApiService(cardId, apiServiceId ?? null),
53
+ set_card_runtime: ({ cardId, runtime }) => store.setCardRuntime(cardId, runtime ?? null),
54
+ async delete_card({ cardId }) {
55
+ await killCardSession(ctx, cardId); // before the card (and its runtime) is forgotten
56
+ return store.removeLink(cardId);
57
+ },
58
+ async archive_card({ cardId }) {
59
+ await killCardSession(ctx, cardId);
60
+ return store.archiveLink(cardId);
61
+ },
62
+ rename_card: ({ cardId, name }) => store.renameLink(cardId, name),
63
+ async merge_cards({ sourceCardId, targetCardId }) {
64
+ await store.update((links) => {
65
+ const source = links.find((l) => l.id === sourceCardId);
66
+ const target = links.find((l) => l.id === targetCardId);
67
+ if (!source)
68
+ throw new Error(`source card ${sourceCardId} not found`);
69
+ if (!target)
70
+ throw new Error(`target card ${targetCardId} not found`);
71
+ const blocked = mergeBlocked(source, target);
72
+ if (blocked)
73
+ throw new Error(blocked);
74
+ if (source.queuedPrompts?.length) {
75
+ log.warn("merge", `dropping ${source.queuedPrompts.length} queued prompt(s) from source card ${source.id}`);
76
+ }
77
+ mergeIntoTarget(source, target);
78
+ log.info("merge", `merged ${sourceCardId.slice(0, 8)} → ${targetCardId.slice(0, 8)}`);
79
+ return links.filter((l) => l.id !== sourceCardId);
80
+ });
81
+ },
82
+ async move_card_to_project({ cardId, targetProjectPath }) {
83
+ // Move the transcript first (outside the store lock — it touches big files).
84
+ const link = (await store.readLinks()).find((l) => l.id === cardId);
85
+ if (!link)
86
+ throw new Error(`card ${cardId} not found`);
87
+ let newSessionPath;
88
+ const sl = link.sessionLink;
89
+ if (sl?.sessionPath && sl.sessionId) {
90
+ try {
91
+ newSessionPath = await moveSession(sl.sessionId, sl.sessionPath, targetProjectPath);
92
+ }
93
+ catch (e) {
94
+ // Keep going — the link metadata still gets updated.
95
+ log.warn("move-card", `failed to move session jsonl for ${cardId}: ${errorMessage(e)}`);
96
+ }
97
+ }
98
+ await store.update((links) => {
99
+ const l = links.find((x) => x.id === cardId);
100
+ if (!l)
101
+ throw new Error(`card ${cardId} not found`);
102
+ l.projectPath = targetProjectPath;
103
+ if (l.sessionLink && newSessionPath)
104
+ l.sessionLink.sessionPath = newSessionPath;
105
+ l.updatedAt = nowIso();
106
+ return links;
107
+ });
108
+ },
109
+ // ── Browser tabs (persisted state only; rendering is client-side) ───────
110
+ add_browser_tab: ({ cardId, url }) => store.addBrowserTab(cardId, url),
111
+ remove_browser_tab: ({ cardId, tabId }) => store.removeBrowserTab(cardId, tabId),
112
+ reorder_browser_tabs: ({ cardId, orderedIds }) => store.reorderBrowserTabs(cardId, orderedIds),
113
+ update_browser_tab: ({ cardId, tabId, url, title }) => store.updateBrowserTab(cardId, tabId, url, title),
114
+ // ── Queued prompts ──────────────────────────────────────────────────────
115
+ add_queued_prompt: ({ cardId, body, sendAutomatically, imagePaths }) => store.addQueuedPrompt(cardId, {
116
+ id: ksuid("prompt"),
117
+ body,
118
+ sendAutomatically,
119
+ ...(imagePaths ? { imagePaths } : {}),
120
+ }),
121
+ update_queued_prompt: ({ cardId, promptId, body, sendAutomatically, imagePaths, setImagePaths }) => store.updateQueuedPrompt(cardId, promptId, body, sendAutomatically,
122
+ // Without setImagePaths the attachments are left alone.
123
+ setImagePaths ? (imagePaths && imagePaths.length ? imagePaths : null) : undefined),
124
+ remove_queued_prompt: ({ cardId, promptId }) => store.removeQueuedPrompt(cardId, promptId),
125
+ /** True when a self-compact nudge is stale (usage already fell below its
126
+ * threshold). False on every uncertainty — a benign nudge beats a lost one. */
127
+ async should_drop_self_compact_prompt({ cardId, promptId }) {
128
+ const s = await settings.read().catch(() => null);
129
+ if (!s?.selfCompact?.enabled)
130
+ return false;
131
+ const link = (await store.readLinks()).find((l) => l.id === cardId);
132
+ const sessionId = link?.sessionLink?.sessionId;
133
+ const prompt = link?.queuedPrompts?.find((p) => p.id === promptId);
134
+ if (!sessionId || !prompt)
135
+ return false;
136
+ const queueRules = s.selfCompact.rules.filter((r) => r.action === "queuePrompt");
137
+ let threshold;
138
+ if (prompt.selfCompactThresholdTokens !== undefined) {
139
+ threshold = prompt.selfCompactThresholdTokens;
140
+ }
141
+ else {
142
+ const body = prompt.body.trim();
143
+ if (body)
144
+ threshold = queueRules.find((r) => r.message.trim() === body)?.thresholdTokens;
145
+ }
146
+ if (threshold === undefined)
147
+ return false;
148
+ // No statusline JSON yet → assume stale (matches macOS).
149
+ const usage = readContextUsage(sessionId);
150
+ return usage ? currentContextTokens(usage) < threshold : true;
151
+ },
152
+ install_self_compact_statusline: () => installStatusline(),
153
+ uninstall_self_compact_statusline: () => uninstallStatusline(),
154
+ self_compact_statusline_installed: () => statuslineInstalled(),
155
+ context_usages: () => readAllContextUsage(),
156
+ /** Statusline snapshot when there is one, else a transcript-derived estimate. */
157
+ async session_usage({ sessionId }) {
158
+ const snap = readAllContextUsage()[sessionId];
159
+ if (snap)
160
+ return { ...snap, estimated: false };
161
+ const u = await readTranscriptUsage(await sessionPathFor(ctx, sessionId));
162
+ if (!u)
163
+ return null;
164
+ // The transcript never records the window size: assume 200k, or 1M once it is exceeded.
165
+ const contextWindowSize = u.contextTokens > 200_000 ? 1_000_000 : 200_000;
166
+ return {
167
+ usedPercentage: (u.contextTokens / contextWindowSize) * 100,
168
+ tokens: u.contextTokens,
169
+ contextWindowSize,
170
+ inputTokens: u.inputTokens,
171
+ outputTokens: u.outputTokens,
172
+ costUsd: null,
173
+ model: u.model,
174
+ estimated: true,
175
+ };
176
+ },
177
+ // ── Settings / sessions ─────────────────────────────────────────────────
178
+ get_settings: () => settings.read(),
179
+ async save_settings({ settings: next }) {
180
+ await settings.write(next);
181
+ // Project list gates which discovered sessions show; apply it right away.
182
+ await board.refresh(discovery, store, await configuredProjectPaths(settings));
183
+ emit("board-updated", board.toDto());
184
+ // The harness repo path may have changed.
185
+ void harness.reload().then(() => emit("harness-updated", {}));
186
+ },
187
+ async search_sessions({ query }) {
188
+ return searchSessions(await discovery.discoverSessions(), query);
189
+ },
190
+ async get_transcript({ sessionId, offset, limit }) {
191
+ return readTranscript(await sessionPathFor(ctx, sessionId), offset, typeof limit === "number" ? limit : undefined);
192
+ },
193
+ async search_transcript({ sessionId, query }) {
194
+ return searchTranscriptTurns(await sessionPathFor(ctx, sessionId), query);
195
+ },
196
+ fork_session: ({ sessionPath, targetDir }) => forkSession(sessionPath, targetDir),
197
+ truncate_session: ({ sessionPath, turnCount }) => truncateSession(sessionPath, turnCount),
198
+ async discover_projects() {
199
+ const seen = new Set();
200
+ for (const s of await discovery.discoverSessions())
201
+ if (s.projectPath)
202
+ seen.add(s.projectPath);
203
+ return [...seen].sort();
204
+ },
205
+ open_in_editor: ({ path: p, editor }) => openInEditor(p, editor),
206
+ open_in_terminal: ({ wslScript, argv, cmdLine }) => openInTerminal({
207
+ wslScript: wslScript ? String(wslScript) : undefined,
208
+ argv: Array.isArray(argv) ? argv.map(String) : undefined,
209
+ cmdLine: cmdLine ? String(cmdLine) : undefined,
210
+ }),
211
+ // ── Git / GitHub ────────────────────────────────────────────────────────
212
+ resolve_github_base_url: ({ projectPath }) => githubBaseUrl(projectPath),
213
+ list_worktrees: ({ repoRoot }) => listWorktrees(repoRoot),
214
+ create_worktree: ({ repoRoot, name }) => createWorktree(repoRoot, name),
215
+ remove_worktree: ({ path: p, repoRoot, force }) => removeWorktree(p, repoRoot, !!force),
216
+ get_diff: ({ path: p, isWorktree }) => getDiff(p, !!isWorktree),
217
+ async merge_pr({ projectPath, number }) {
218
+ const template = (await settings.read()).github.mergeCommand;
219
+ const command = template.replaceAll("${number}", String(number));
220
+ if (!command.trim())
221
+ throw new Error("merge command template is empty");
222
+ log.info("merge-pr", `running \`${command}\` in ${projectPath}`);
223
+ const r = await runShell(command, projectPath);
224
+ const stdout = r.stdout.trim();
225
+ const stderr = r.stderr.trim();
226
+ if (!r.ok) {
227
+ log.warn("merge-pr", `merge failed (exit ${r.code}): ${r.stderr}`);
228
+ throw new Error(stderr || stdout || `merge exited with status ${r.code}`);
229
+ }
230
+ return stdout || `Merged PR #${number}`;
231
+ },
232
+ async check_dependencies() {
233
+ const [claude, git, gh] = await Promise.all([commandExists("claude"), commandExists("git"), commandExists("gh")]);
234
+ return {
235
+ claudeAvailable: claude,
236
+ gitAvailable: git,
237
+ ghAvailable: gh,
238
+ ghAuthenticated: gh ? await ghIsAuthed() : false,
239
+ };
240
+ },
241
+ // ── Processes / tmux ────────────────────────────────────────────────────
242
+ async get_process_state() {
243
+ const s = await settings.read();
244
+ return getProcessState(s.projects.map((p) => p.repoRoot ?? p.path));
245
+ },
246
+ kill_claude_process: ({ pid }) => killClaudeProcess(pid),
247
+ tmux_available: () => tmux.isAvailable(),
248
+ psmux_status: () => psmuxStatus(),
249
+ psmux_install: () => installPsmux(),
250
+ // `runtime: "windows"` runs these against psmux; anything else against tmux in WSL.
251
+ async tmux_ensure_session({ name, cwdWindows, command, runtime }) {
252
+ if (backendFor(runtime) === "psmux")
253
+ return tmux.ensureWindowsSession(name, cwdWindows, command ?? "");
254
+ return tmux.ensureSession(name, await tmux.windowsPathToWsl(cwdWindows), command ?? "");
255
+ },
256
+ tmux_send_prompt: ({ target, text, runtime }) => tmux.sendPrompt(target, text, backendFor(runtime)),
257
+ tmux_paste: ({ target, text, runtime }) => tmux.pasteText(target, text, backendFor(runtime)),
258
+ tmux_capture: ({ target, runtime }) => tmux.capturePane(target, backendFor(runtime)),
259
+ tmux_kill_session: ({ name, runtime }) => tmux.killSession(name, backendFor(runtime)),
260
+ async tmux_new_window({ session, cwdWindows, command, runtime }) {
261
+ const be = backendFor(runtime);
262
+ const cwd = cwdWindows && be === "wsl" ? await tmux.windowsPathToWsl(cwdWindows) : (cwdWindows ?? null);
263
+ return tmux.newWindow(session, cwd, command, be);
264
+ },
265
+ tmux_kill_window: ({ target, runtime }) => tmux.killWindow(target, backendFor(runtime)),
266
+ tmux_list_windows: ({ session, runtime }) => tmux.listWindows(session, backendFor(runtime)),
267
+ // ── Remote ──────────────────────────────────────────────────────────────
268
+ remote_prereqs: () => prereqs(),
269
+ remote_deploy_shell: () => deployRemoteShell(),
270
+ mutagen_status: () => syncStatus(),
271
+ mutagen_raw_status: () => rawSyncStatus(),
272
+ async mutagen_start() {
273
+ const remote = (await settings.read()).remote;
274
+ if (!remote)
275
+ throw new Error("Remote settings not configured");
276
+ if (!remote.host || !remote.remotePath || !remote.localPath) {
277
+ throw new Error("Remote host / remotePath / localPath must all be set");
278
+ }
279
+ await ensureDaemon();
280
+ await startSync(remote.localPath, remote.host, remote.remotePath, remote.syncIgnores ?? defaultIgnores());
281
+ },
282
+ mutagen_stop: () => stopSync(),
283
+ mutagen_reset: () => resetSync(),
284
+ mutagen_flush: () => flushSync(),
285
+ // ── Knowledge harness ───────────────────────────────────────────────────
286
+ kb_status: () => harness.status(),
287
+ async kb_reload() {
288
+ await harness.reload();
289
+ return harness.status();
290
+ },
291
+ async kb_sync() {
292
+ await harness.sync();
293
+ return harness.status();
294
+ },
295
+ kb_search: ({ text, files, cwd, kbIds, onlyKbs, limit }) => harness.searchFrom(cwd ?? undefined, { text, files, kbIds, onlyKbs, limit: limit ?? 10 }),
296
+ kb_show: ({ id }) => harness.show(id),
297
+ kb_entries: ({ kbId }) => harness.index.all(kbId ?? undefined),
298
+ // Called by the SessionStart hook with Claude's own (snake_case) payload.
299
+ async kb_session_context(args) {
300
+ const out = await harness.sessionContext(args);
301
+ // A card that was just given entries now has a Knowledge tab to show.
302
+ if ("hookSpecificOutput" in out)
303
+ emit("harness-updated", { injected: true });
304
+ return out;
305
+ },
306
+ kb_project_commands: ({ cwd }) => harness.projectCommands(cwd),
307
+ /** Which harness project (if any) each board project folder belongs to. */
308
+ async harness_projects({ paths }) {
309
+ const out = [];
310
+ for (const p of (paths ?? []))
311
+ out.push({ path: p, projectId: (await harness.projectFor(p))?.project?.id });
312
+ return out;
313
+ },
314
+ async kb_propose({ type, title, summary, body, scope, kbId, cwd, source, reason, supersedes }) {
315
+ const p = await harness.propose({ type, title, summary, body, scope, kbId, cwd, source, reason, supersedes });
316
+ emit("harness-updated", { proposals: 1 });
317
+ await notifyUser(ctx, { title: "Knowledge to review", body: p.title, threadId: "knowledge-review" });
318
+ return p;
319
+ },
320
+ kb_proposals: ({ status, cardId }) => harness.proposals.list(status ?? undefined, cardId ? { cardId } : undefined),
321
+ kb_card_knowledge: ({ cardId }) => harness.cardKnowledge(cardId),
322
+ kb_pending_by_card: () => harness.pendingByCard(),
323
+ async kb_proposal_approve({ id, edits }) {
324
+ const p = await harness.approveProposal(id, edits ?? undefined);
325
+ emit("harness-updated", {});
326
+ return p;
327
+ },
328
+ async kb_proposal_undo({ id }) {
329
+ const r = await harness.undoApproval(id);
330
+ emit("harness-updated", {});
331
+ return r;
332
+ },
333
+ async kb_proposal_reject({ id, note }) {
334
+ const p = await harness.rejectProposal(id, note);
335
+ emit("harness-updated", {});
336
+ return p;
337
+ },
338
+ async kb_maintain() {
339
+ return harness.maintain(await configuredProjectPaths(settings));
340
+ },
341
+ harness_init_repo: ({ repoPath }) => harness.initRepo(repoPath),
342
+ harness_install_pack: () => harness.installPack(),
343
+ harness_bundle: ({ serverUrl }) => harness.bundle(serverUrl),
344
+ async project_onboard({ projectPath, commands }) {
345
+ const r = await harness.onboard(projectPath, commands ?? undefined);
346
+ emit("harness-updated", {});
347
+ return r;
348
+ },
349
+ // ── AI-DLC ──────────────────────────────────────────────────────────────
350
+ intent_list: () => harness.intents.list(),
351
+ intent_get: ({ id }) => harness.intents.get(id),
352
+ async intent_create({ projectPath, title, body, issueUrl }) {
353
+ if (!projectPath || !title?.trim())
354
+ throw new Error("projectPath and title are required");
355
+ const r = await createIntent({ store, intents: harness.intents }, { projectPath, title, body: body ?? "", issueUrl });
356
+ emit("harness-updated", {});
357
+ return r;
358
+ },
359
+ async gate_request({ cardId, note }) {
360
+ const card = await requestGate({ store, intents: harness.intents }, cardId, note);
361
+ const intent = card.intentId ? await harness.intents.get(card.intentId) : undefined;
362
+ await notifyUser(ctx, {
363
+ title: `Review needed: ${card.stage}`,
364
+ body: `${intent?.title ?? card.name ?? card.id}${card.gate?.note ? ` — ${card.gate.note}` : ""}`,
365
+ threadId: card.id,
366
+ });
367
+ emit("harness-updated", {});
368
+ return card;
369
+ },
370
+ async gate_approve({ cardId }) {
371
+ const r = await approveGate({ store, intents: harness.intents }, cardId);
372
+ emit("harness-updated", {});
373
+ return r;
374
+ },
375
+ async gate_reject({ cardId, note }) {
376
+ const card = await rejectGate({ store, intents: harness.intents }, cardId, note ?? "");
377
+ emit("harness-updated", {});
378
+ return card;
379
+ },
380
+ // ── Web-only helpers ────────────────────────────────────────────────────
381
+ fs_list: ({ path: dir }) => fsList(dir),
382
+ fs_search: ({ query, root }) => fsSearch(String(query ?? ""), root),
383
+ launcher_info: () => ensureLauncher(),
384
+ };
385
+ }
386
+ /** Binary-upload endpoints (HTTP, not JSON-RPC). */
387
+ export const uploads = {
388
+ image: (bytes) => saveImageBytes(bytes),
389
+ file: (bytes, name) => saveAttachmentBytes(bytes, name),
390
+ };
@@ -0,0 +1,34 @@
1
+ // Shared application context + notification dispatch.
2
+ import { BoardState } from "./board.js";
3
+ import { HarnessService } from "./harness/service.js";
4
+ import { CoordinationStore } from "./coordination.js";
5
+ import { emit } from "./events.js";
6
+ import { pushoverSend } from "./integrations.js";
7
+ import { SessionDiscovery } from "./sessions.js";
8
+ import { SettingsStore } from "./settings.js";
9
+ import { log } from "./util.js";
10
+ /** `serverUrl` / `token`: how hooks on this machine reach this server. */
11
+ export function createContext(opts) {
12
+ const store = new CoordinationStore();
13
+ const settings = new SettingsStore();
14
+ return {
15
+ store,
16
+ settings,
17
+ discovery: new SessionDiscovery(),
18
+ board: new BoardState(),
19
+ harness: new HarnessService({ store, settings, serverUrl: opts.serverUrl, token: opts.token }),
20
+ };
21
+ }
22
+ /** Browser notification (via the `notification` event) + Pushover, each gated
23
+ * by the same settings toggles the desktop app honoured. */
24
+ export async function notifyUser(ctx, opts) {
25
+ const settings = await ctx.settings.read().catch(() => null);
26
+ const osEnabled = settings?.notifications.notificationsEnabled ?? true;
27
+ if (osEnabled)
28
+ emit("notification", { title: opts.title, body: opts.body, tag: opts.threadId ?? undefined });
29
+ const n = settings?.notifications;
30
+ if (n?.pushoverEnabled && n.pushoverToken && n.pushoverUserKey) {
31
+ // Fire-and-forget: Pushover is best-effort and must not block callers.
32
+ pushoverSend(n.pushoverToken, n.pushoverUserKey, opts.title, opts.body, opts.threadId ?? undefined).then(() => log.info("pushover", `sent (${opts.threadId ?? "-"})`), (e) => log.warn("pushover", `send failed (${opts.threadId ?? "-"}): ${e}`));
33
+ }
34
+ }