loopat 0.1.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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,810 @@
1
+ import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"
2
+ import { join, basename } from "node:path"
3
+ import { existsSync } from "node:fs"
4
+ import { execFile } from "node:child_process"
5
+ import { promisify } from "node:util"
6
+ import { workspaceNotesDir } from "./paths"
7
+ import { createLoop, patchLoopMeta, type LoopMeta } from "./loops"
8
+
9
+ const execFileP = promisify(execFile)
10
+
11
+ // Auto-commit kanban writes so loop-worktree pushes don't silently wipe
12
+ // them via the post-receive reset --hard hook. Scoped to focus/ to avoid
13
+ // racing with vaultWrite's own commits on other paths under notes/.
14
+ async function commitFocus(msg: string): Promise<void> {
15
+ const dir = workspaceNotesDir()
16
+ if (!existsSync(join(dir, ".git"))) return
17
+ const env = {
18
+ ...process.env,
19
+ GIT_AUTHOR_NAME: "loopat",
20
+ GIT_AUTHOR_EMAIL: "ui@loopat.local",
21
+ GIT_COMMITTER_NAME: "loopat",
22
+ GIT_COMMITTER_EMAIL: "ui@loopat.local",
23
+ }
24
+ try {
25
+ await execFileP("git", ["-C", dir, "add", "--", "focus/"], { env })
26
+ await execFileP("git", ["-C", dir, "commit", "-m", `kanban: ${msg}`], { env })
27
+ } catch { /* nothing to commit, or transient — best-effort */ }
28
+ }
29
+
30
+ // ── types ──
31
+
32
+ export type KanbanCard = {
33
+ cid: string
34
+ text: string
35
+ done: boolean
36
+ assignee?: string
37
+ priority?: string
38
+ due?: string
39
+ loopId?: string
40
+ topics: string[]
41
+ description: string
42
+ subtasks: { text: string; done: boolean }[]
43
+ }
44
+
45
+ export type KanbanColumn = {
46
+ filename: string
47
+ title: string
48
+ cards: KanbanCard[]
49
+ }
50
+
51
+ // ── board path helpers ──
52
+
53
+ function boardsRoot(): string {
54
+ return join(workspaceNotesDir(), "focus", "boards")
55
+ }
56
+
57
+ function boardDir(board: string): string {
58
+ return join(boardsRoot(), board)
59
+ }
60
+
61
+ // ── regex ──
62
+
63
+ const BULLET_CARD_RE = /^(\s*)- \[([ xX])\]\s+(.*)$/
64
+ const META_RE = /^\s*>\s*([\w-]+):\s*(.*)$/
65
+ const INDENT_BULLET_RE = /^\s+-\s*\[([ xX])\]\s+(.*)$/
66
+ const TOPIC_RE = /(?<![\w])#([A-Za-z0-9][\w-]*)/g
67
+
68
+ function extractTopics(text: string): string[] {
69
+ const seen = new Set<string>()
70
+ for (const m of text.matchAll(TOPIC_RE)) {
71
+ seen.add(m[1].toLowerCase())
72
+ }
73
+ return [...seen]
74
+ }
75
+
76
+ function hashCid(s: string): string {
77
+ let h = 0
78
+ for (let i = 0; i < s.length; i++) {
79
+ h = (h * 31 + s.charCodeAt(i)) & 0xffffffff
80
+ }
81
+ return h.toString(36)
82
+ }
83
+
84
+ // ── column parser ──
85
+
86
+ /** Parse a single column markdown file into its column + cards. */
87
+ function parseColumnFile(filename: string, body: string): KanbanColumn {
88
+ const lines = body.split("\n")
89
+ let title = basename(filename, ".md")
90
+ const cards: KanbanCard[] = []
91
+
92
+ let inFrontmatter = false
93
+ let i = 0
94
+
95
+ for (; i < lines.length; i++) {
96
+ const line = lines[i]
97
+
98
+ // skip YAML frontmatter
99
+ if (i === 0 && line.trim() === "---") {
100
+ inFrontmatter = true
101
+ continue
102
+ }
103
+ if (inFrontmatter) {
104
+ if (line.trim() === "---") { inFrontmatter = false }
105
+ continue
106
+ }
107
+
108
+ // first non-frontmatter # heading → column title
109
+ const h1 = line.match(/^#\s+(.*)$/)
110
+ if (h1) {
111
+ title = h1[1].trim() || title
112
+ i++
113
+ break
114
+ }
115
+
116
+ // if we hit content without a heading, stay at this index
117
+ if (line.trim() && !line.startsWith("#")) {
118
+ break
119
+ }
120
+ }
121
+
122
+ // parse cards: each top-level - [ ] starts a card, indented content belongs to it
123
+ for (; i < lines.length; i++) {
124
+ const line = lines[i]
125
+ const bm = line.match(BULLET_CARD_RE)
126
+ if (!bm) continue
127
+
128
+ const indent = bm[1]
129
+ // only top-level bullets are cards
130
+ if (indent !== "") continue
131
+
132
+ const done = bm[2].toLowerCase() === "x"
133
+ const text = bm[3].trim()
134
+ const cid = hashCid(text)
135
+
136
+ let assignee: string | undefined
137
+ let priority: string | undefined
138
+ let due: string | undefined
139
+ let loopId: string | undefined
140
+ const descLines: string[] = []
141
+ const subtasks: { text: string; done: boolean }[] = []
142
+
143
+ // consume indented sub-content for this card
144
+ let j = i + 1
145
+ while (j < lines.length) {
146
+ const sub = lines[j]
147
+ // stop at next top-level bullet or next heading
148
+ if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break
149
+ if (sub.trim() === "") { j++; continue }
150
+
151
+ // only process indented lines
152
+ if (!sub.startsWith(" ") && !sub.startsWith("\t")) { j++; continue }
153
+
154
+ const mm = sub.match(META_RE)
155
+ if (mm) {
156
+ const k = mm[1].toLowerCase()
157
+ const v = mm[2].trim()
158
+ if (k === "assignee") assignee = v || undefined
159
+ else if (k === "priority") priority = v || undefined
160
+ else if (k === "due") due = v || undefined
161
+ else if (k === "loop") {
162
+ // accept either "loop: <id>" or "loop: <label> [<id>]"
163
+ const bm = v.match(/\[([^\]]+)\]\s*$/)
164
+ loopId = (bm ? bm[1] : v).trim() || undefined
165
+ }
166
+ j++
167
+ continue
168
+ }
169
+
170
+ const ib = sub.match(INDENT_BULLET_RE)
171
+ if (ib) {
172
+ subtasks.push({ text: ib[2].trim(), done: ib[1].toLowerCase() === "x" })
173
+ j++
174
+ continue
175
+ }
176
+
177
+ descLines.push(sub.trim())
178
+ j++
179
+ }
180
+
181
+ cards.push({
182
+ cid,
183
+ text,
184
+ done,
185
+ assignee,
186
+ priority,
187
+ due,
188
+ loopId,
189
+ topics: extractTopics(text),
190
+ description: descLines.join("\n").trim(),
191
+ subtasks,
192
+ })
193
+ }
194
+
195
+ return { filename, title, cards }
196
+ }
197
+
198
+ // ── board management ──
199
+
200
+ export async function listBoards(): Promise<string[]> {
201
+ try {
202
+ const entries = await readdir(boardsRoot())
203
+ const dirs: string[] = []
204
+ for (const e of entries) {
205
+ if (e.startsWith(".")) continue
206
+ try {
207
+ const s = await readdir(join(boardsRoot(), e))
208
+ if (s) dirs.push(e) // it's a directory
209
+ } catch { /* skip non-directories */ }
210
+ }
211
+ // "default" always first
212
+ return dirs.sort((a, b) => {
213
+ if (a === "default") return -1
214
+ if (b === "default") return 1
215
+ return a.localeCompare(b)
216
+ })
217
+ } catch {
218
+ return ["default"]
219
+ }
220
+ }
221
+
222
+ export async function createBoard(name: string): Promise<boolean> {
223
+ if (!name || name.startsWith(".") || /[/\\]/.test(name)) return false
224
+ try {
225
+ await mkdir(boardDir(name), { recursive: true })
226
+ return true
227
+ } catch {
228
+ return false
229
+ }
230
+ }
231
+
232
+ export async function renameBoard(oldName: string, newName: string): Promise<boolean> {
233
+ if (!newName || newName.startsWith(".") || /[/\\]/.test(newName)) return false
234
+ try {
235
+ await rename(boardDir(oldName), boardDir(newName))
236
+ await commitFocus(`renameBoard ${oldName} → ${newName}`)
237
+ return true
238
+ } catch {
239
+ return false
240
+ }
241
+ }
242
+
243
+ // ── read / list ──
244
+
245
+ export async function listKanbanColumns(board = "default"): Promise<KanbanColumn[]> {
246
+ const dir = boardDir(board)
247
+ let entries: string[] = []
248
+ try { entries = await readdir(dir) } catch { return [] }
249
+
250
+ const cols: KanbanColumn[] = []
251
+ for (const f of entries) {
252
+ if (!f.endsWith(".md")) continue
253
+ try {
254
+ const body = await readFile(join(dir, f), "utf8")
255
+ cols.push(parseColumnFile(f, body))
256
+ } catch { /* skip unreadable */ }
257
+ }
258
+
259
+ // sort: predefined order first, then alphabetical
260
+ const ORDER = ["backlog", "todo", "in-progress", "done"]
261
+ cols.sort((a, b) => {
262
+ const ai = ORDER.indexOf(basename(a.filename, ".md"))
263
+ const bi = ORDER.indexOf(basename(b.filename, ".md"))
264
+ if (ai >= 0 && bi >= 0) return ai - bi
265
+ if (ai >= 0) return -1
266
+ if (bi >= 0) return 1
267
+ return a.title.localeCompare(b.title)
268
+ })
269
+ return cols
270
+ }
271
+
272
+ async function readColumnRaw(board: string, filename: string): Promise<{ body: string; path: string } | null> {
273
+ const safe = basename(filename)
274
+ if (!safe || safe.startsWith(".")) return null
275
+ const path = join(boardDir(board), safe)
276
+ try {
277
+ const body = await readFile(path, "utf8")
278
+ return { body, path }
279
+ } catch {
280
+ return null
281
+ }
282
+ }
283
+
284
+ // ── card helpers: find card lines in raw body ──
285
+
286
+ function findCardRange(body: string, cid: string): { start: number; end: number; text: string } | null {
287
+ const lines = body.split("\n")
288
+ for (let i = 0; i < lines.length; i++) {
289
+ const bm = lines[i].match(BULLET_CARD_RE)
290
+ if (!bm || bm[1] !== "") continue
291
+ const text = bm[3].trim()
292
+ if (hashCid(text) !== cid) continue
293
+
294
+ // found the card at line i; find where its sub-content ends
295
+ let end = i + 1
296
+ while (end < lines.length) {
297
+ const sub = lines[end]
298
+ if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break
299
+ end++
300
+ }
301
+ return { start: i, end, text }
302
+ }
303
+ return null
304
+ }
305
+
306
+ // ── mutations ──
307
+
308
+ export async function addCard(board: string, filename: string, opts: {
309
+ text: string
310
+ assignee?: string
311
+ priority?: string
312
+ due?: string
313
+ topics?: string[]
314
+ description?: string
315
+ }): Promise<{ ok: boolean; cid?: string }> {
316
+ const raw = await readColumnRaw(board, filename)
317
+ const dir = boardDir(board)
318
+ if (!raw) {
319
+ // create new column file
320
+ const title = basename(filename, ".md")
321
+ const body = `# ${title}\n\n- [ ] ${opts.text}\n`
322
+ await mkdir(dir, { recursive: true })
323
+ await writeFile(join(dir, basename(filename)), body)
324
+ await commitFocus(`addCard ${board}/${filename}`)
325
+ return { ok: true, cid: hashCid(opts.text) }
326
+ }
327
+
328
+ let lines = raw.body.split("\n")
329
+
330
+ // build card line with topics
331
+ const topicStr = opts.topics?.length ? " " + opts.topics.map((t) => `#${t}`).join(" ") : ""
332
+ const cardLine = `- [ ] ${opts.text}${topicStr}`
333
+
334
+ // append metadata sub-lines
335
+ const subLines: string[] = []
336
+ if (opts.assignee) subLines.push(` > assignee: ${opts.assignee}`)
337
+ if (opts.priority) subLines.push(` > priority: ${opts.priority}`)
338
+ if (opts.due) subLines.push(` > due: ${opts.due}`)
339
+ if (opts.description) {
340
+ for (const dl of opts.description.split("\n")) {
341
+ subLines.push(` ${dl}`)
342
+ }
343
+ }
344
+
345
+ // append card at end, with blank line separator if there are existing cards
346
+ const hasCards = lines.some((l) => /^-\s*\[[ xX]\]/.test(l))
347
+ if (hasCards) lines.push("")
348
+ lines.push(cardLine)
349
+ for (const sl of subLines) lines.push(sl)
350
+ // ensure trailing newline
351
+ if (lines[lines.length - 1] !== "") lines.push("")
352
+
353
+ await writeFile(raw.path, lines.join("\n"))
354
+ await commitFocus(`addCard ${board}/${filename}`)
355
+ return { ok: true, cid: hashCid(opts.text) }
356
+ }
357
+
358
+ export async function toggleCard(board: string, filename: string, cid: string): Promise<boolean> {
359
+ const raw = await readColumnRaw(board, filename)
360
+ if (!raw) return false
361
+
362
+ const lines = raw.body.split("\n")
363
+ for (let i = 0; i < lines.length; i++) {
364
+ const bm = lines[i].match(BULLET_CARD_RE)
365
+ if (!bm || bm[1] !== "") continue
366
+ if (hashCid(bm[3].trim()) !== cid) continue
367
+
368
+ const ch = bm[2].toLowerCase() === "x" ? " " : "x"
369
+ lines[i] = lines[i].replace(/\[[ xX]\]/, `[${ch}]`)
370
+ await writeFile(raw.path, lines.join("\n"))
371
+ await commitFocus(`toggleCard ${board}/${filename}`)
372
+ return true
373
+ }
374
+ return false
375
+ }
376
+
377
+ export async function deleteCard(board: string, filename: string, cid: string): Promise<boolean> {
378
+ const raw = await readColumnRaw(board, filename)
379
+ if (!raw) return false
380
+
381
+ const range = findCardRange(raw.body, cid)
382
+ if (!range) return false
383
+
384
+ const lines = raw.body.split("\n")
385
+ // remove trailing blank line if card was the last content
386
+ while (range.end < lines.length && lines[range.end].trim() === "") range.end++
387
+ lines.splice(range.start, range.end - range.start)
388
+
389
+ // clean up trailing double-blanks
390
+ const newBody = lines.join("\n").replace(/\n{3,}/g, "\n\n")
391
+ await writeFile(raw.path, newBody)
392
+ await commitFocus(`deleteCard ${board}/${filename}`)
393
+ return true
394
+ }
395
+
396
+ export async function moveCard(board: string, fromFile: string, cid: string, toFile: string, toIndex?: number): Promise<boolean> {
397
+ const fromRaw = await readColumnRaw(board, fromFile)
398
+ if (!fromRaw) return false
399
+
400
+ const range = findCardRange(fromRaw.body, cid)
401
+ if (!range) return false
402
+
403
+ const fromLines = fromRaw.body.split("\n")
404
+
405
+ // extract card lines (including sub-content)
406
+ const cardLines: string[] = []
407
+ for (let i = range.start; i < range.end; i++) {
408
+ cardLines.push(fromLines[i])
409
+ }
410
+
411
+ // remove from source
412
+ let end = range.end
413
+ while (end < fromLines.length && fromLines[end].trim() === "") end++
414
+ fromLines.splice(range.start, end - range.start)
415
+ const newFromBody = fromLines.join("\n").replace(/\n{3,}/g, "\n\n")
416
+ await writeFile(fromRaw.path, newFromBody)
417
+
418
+ // insert into target at position
419
+ const toRaw = await readColumnRaw(board, toFile)
420
+ const dir = boardDir(board)
421
+ if (!toRaw) {
422
+ // create target column
423
+ const title = basename(toFile, ".md")
424
+ const body = `# ${title}\n\n${cardLines.join("\n")}\n`
425
+ await mkdir(dir, { recursive: true })
426
+ await writeFile(join(dir, basename(toFile)), body)
427
+ await commitFocus(`moveCard ${board}/${fromFile} → ${toFile}`)
428
+ return true
429
+ }
430
+
431
+ const toLines = toRaw.body.split("\n")
432
+
433
+ if (toIndex !== undefined && toIndex >= 0) {
434
+ // find the line index of the toIndex-th card
435
+ let cardCount = 0
436
+ let insertAt = toLines.length
437
+ for (let i = 0; i < toLines.length; i++) {
438
+ const bm = toLines[i].match(BULLET_CARD_RE)
439
+ if (bm && bm[1] === "") {
440
+ if (cardCount === toIndex) {
441
+ insertAt = i
442
+ break
443
+ }
444
+ cardCount++
445
+ }
446
+ }
447
+ // insert before the target card
448
+ const linesToInsert = [...cardLines]
449
+ if (insertAt < toLines.length) {
450
+ // add blank line separator before the card we're inserting before
451
+ if (insertAt > 0 && toLines[insertAt - 1].trim() !== "") {
452
+ linesToInsert.push("")
453
+ }
454
+ }
455
+ toLines.splice(insertAt, 0, ...linesToInsert)
456
+ } else {
457
+ // append to end
458
+ if (toLines[toLines.length - 1] !== "") toLines.push("")
459
+ toLines.push(...cardLines)
460
+ }
461
+ if (toLines[toLines.length - 1] !== "") toLines.push("")
462
+ await writeFile(toRaw.path, toLines.join("\n"))
463
+ await commitFocus(`moveCard ${board}/${fromFile} → ${toFile}`)
464
+ return true
465
+ }
466
+
467
+ export async function updateCardMeta(board: string, filename: string, cid: string, patch: {
468
+ text?: string; assignee?: string; priority?: string; due?: string
469
+ }): Promise<boolean> {
470
+ const raw = await readColumnRaw(board, filename)
471
+ if (!raw) return false
472
+
473
+ const range = findCardRange(raw.body, cid)
474
+ if (!range) return false
475
+
476
+ const lines = raw.body.split("\n")
477
+ const cardText = range.text
478
+
479
+ // Update the card text line itself
480
+ if (patch.text !== undefined && patch.text !== cardText) {
481
+ const bm = lines[range.start].match(BULLET_CARD_RE)
482
+ if (bm) {
483
+ const ch = bm[2].toLowerCase() === "x" ? "x" : " "
484
+ lines[range.start] = `- [${ch}] ${patch.text}`
485
+ }
486
+ }
487
+
488
+ // Update/add metadata sub-lines within the card's range
489
+ if (patch.assignee !== undefined || patch.priority !== undefined || patch.due !== undefined) {
490
+ const metaPatch: Record<string, string | undefined> = {
491
+ assignee: patch.assignee,
492
+ priority: patch.priority,
493
+ due: patch.due,
494
+ }
495
+
496
+ // find existing meta lines and update them
497
+ const seen = new Set<string>()
498
+ for (let i = range.start + 1; i < range.end; i++) {
499
+ const mm = lines[i].match(META_RE)
500
+ if (!mm) continue
501
+ const k = mm[1].toLowerCase()
502
+ if (k in metaPatch) {
503
+ seen.add(k)
504
+ const v = metaPatch[k]
505
+ if (v !== undefined) {
506
+ lines[i] = lines[i].replace(/^(\s*)> .*$/, `$1> ${k}: ${v}`)
507
+ } else {
508
+ lines[i] = ""
509
+ }
510
+ }
511
+ }
512
+
513
+ // add missing meta keys after card line
514
+ for (const [k, v] of Object.entries(metaPatch)) {
515
+ if (!seen.has(k) && v !== undefined) {
516
+ lines.splice(range.start + 1, 0, ` > ${k}: ${v}`)
517
+ }
518
+ }
519
+ }
520
+
521
+ await writeFile(raw.path, lines.join("\n"))
522
+ await commitFocus(`updateCardMeta ${board}/${filename}`)
523
+ return true
524
+ }
525
+
526
+ /** Replace the entire card block (bullet line + indented content) in a column file. */
527
+ export async function updateCardBlock(board: string, filename: string, cid: string, newBlock: string): Promise<boolean> {
528
+ const raw = await readColumnRaw(board, filename)
529
+ if (!raw) return false
530
+ const range = findCardRange(raw.body, cid)
531
+ if (!range) return false
532
+ const lines = raw.body.split("\n")
533
+ // Also swallow trailing blank lines after the card block
534
+ let end = range.end
535
+ while (end < lines.length && lines[end].trim() === "") end++
536
+ lines.splice(range.start, end - range.start, ...newBlock.split("\n"))
537
+ // Clean up excessive blanks
538
+ const newBody = lines.join("\n").replace(/\n{3,}/g, "\n\n")
539
+ await writeFile(raw.path, newBody)
540
+ await commitFocus(`updateCardBlock ${board}/${filename}`)
541
+ return true
542
+ }
543
+
544
+ export async function assignDriverForCard(
545
+ board: string,
546
+ filename: string,
547
+ cid: string,
548
+ userId: string
549
+ ): Promise<{ ok: boolean; loopId?: string }> {
550
+ const cols = await listKanbanColumns(board)
551
+ const col = cols.find((c) => c.filename === filename)
552
+ const card = col?.cards.find((c) => c.cid === cid)
553
+ if (!card || !card.loopId) return { ok: false }
554
+
555
+ const updated = await patchLoopMeta(card.loopId, { driver: userId } as Partial<LoopMeta>)
556
+ if (!updated) return { ok: false }
557
+
558
+ await updateCardMeta(board, filename, cid, { assignee: userId })
559
+ return { ok: true, loopId: card.loopId }
560
+ }
561
+
562
+ export async function linkLoopToCard(
563
+ board: string,
564
+ filename: string,
565
+ cid: string,
566
+ loopId: string,
567
+ userId: string
568
+ ): Promise<boolean> {
569
+ const raw = await readColumnRaw(board, filename)
570
+ if (!raw) return false
571
+
572
+ const range = findCardRange(raw.body, cid)
573
+ if (!range) return false
574
+
575
+ const lines = raw.body.split("\n")
576
+ lines.splice(range.start + 1, 0, ` > loop: ${loopId}`)
577
+ await writeFile(raw.path, lines.join("\n"))
578
+ await commitFocus(`linkLoopToCard ${board}/${filename}`)
579
+
580
+ await patchLoopMeta(loopId, { driver: userId } as Partial<LoopMeta>)
581
+ return true
582
+ }
583
+
584
+ export async function createLoopFromCard(
585
+ board: string,
586
+ filename: string,
587
+ cid: string,
588
+ userId: string
589
+ ): Promise<{ ok: boolean; loopId?: string }> {
590
+ const cols = await listKanbanColumns(board)
591
+ const col = cols.find((c) => c.filename === filename)
592
+ const card = col?.cards.find((c) => c.cid === cid)
593
+ if (!card) return { ok: false }
594
+
595
+ const loop = await createLoop({
596
+ title: card.text,
597
+ createdBy: userId,
598
+ // profiles: undefined → loop runs with base + personal CLAUDE.md only.
599
+ // Kanban-spawned loops can be promoted to specific profiles later by
600
+ // editing meta.config.profiles. (Was: sandbox: pickDefaultSandbox().)
601
+ })
602
+ // Set driver to the creating user
603
+ await patchLoopMeta(loop.id, { driver: userId } as Partial<LoopMeta>)
604
+
605
+ // add loop association as a meta line
606
+ const raw = await readColumnRaw(board, filename)
607
+ if (raw) {
608
+ const range = findCardRange(raw.body, cid)
609
+ if (range) {
610
+ const lines = raw.body.split("\n")
611
+ lines.splice(range.start + 1, 0, ` > loop: ${loop.id}`)
612
+ await writeFile(raw.path, lines.join("\n"))
613
+ await commitFocus(`createLoopFromCard ${board}/${filename}`)
614
+ }
615
+ }
616
+
617
+ return { ok: true, loopId: loop.id }
618
+ }
619
+
620
+ export async function createColumn(board: string, filename: string, title?: string): Promise<boolean> {
621
+ const safe = basename(filename)
622
+ if (!safe || safe.startsWith(".")) return false
623
+ const dir = boardDir(board)
624
+ await mkdir(dir, { recursive: true })
625
+ const path = join(dir, safe)
626
+ const displayTitle = title || basename(safe, ".md")
627
+ await writeFile(path, `# ${displayTitle}\n\n`)
628
+ await commitFocus(`createColumn ${board}/${safe}`)
629
+ return true
630
+ }
631
+
632
+ /** Persist card order within a column file. */
633
+ export async function reorderCards(board: string, filename: string, orderedCids: string[]): Promise<boolean> {
634
+ const raw = await readColumnRaw(board, filename)
635
+ if (!raw) return false
636
+
637
+ const lines = raw.body.split("\n")
638
+
639
+ // Extract card blocks: each card is its bullet line + all indented sub-lines
640
+ interface CardBlock { cid: string; start: number; end: number; lines: string[] }
641
+ const blocks: CardBlock[] = []
642
+
643
+ for (let i = 0; i < lines.length; i++) {
644
+ const bm = lines[i].match(BULLET_CARD_RE)
645
+ if (!bm || bm[1] !== "") continue
646
+ const text = bm[3].trim()
647
+ const cid = hashCid(text)
648
+
649
+ let end = i + 1
650
+ while (end < lines.length) {
651
+ const sub = lines[end]
652
+ if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break
653
+ end++
654
+ }
655
+
656
+ blocks.push({
657
+ cid,
658
+ start: i,
659
+ end,
660
+ lines: lines.slice(i, end),
661
+ })
662
+ }
663
+
664
+ // Reorder blocks
665
+ const cidOrder = new Map(orderedCids.map((c, i) => [c, i]))
666
+ const notInOrder = blocks.filter((b) => !cidOrder.has(b.cid))
667
+ const ordered = blocks
668
+ .filter((b) => cidOrder.has(b.cid))
669
+ .sort((a, b) => (cidOrder.get(a.cid) ?? 0) - (cidOrder.get(b.cid) ?? 0))
670
+ const reordered = [...ordered, ...notInOrder]
671
+
672
+ // Find the content before first card and after last card
673
+ const firstCardIdx = blocks.reduce((min, b) => Math.min(min, b.start), Infinity)
674
+ const prefix = lines.slice(0, firstCardIdx)
675
+
676
+ // Rebuild: prefix + reordered card blocks
677
+ const result = [...prefix]
678
+ for (const b of reordered) {
679
+ for (const l of b.lines) result.push(l)
680
+ }
681
+
682
+ // Trim trailing blanks
683
+ while (result.length && result[result.length - 1].trim() === "") result.pop()
684
+ result.push("")
685
+
686
+ await writeFile(raw.path, result.join("\n"))
687
+ await commitFocus(`reorderCards ${board}/${filename}`)
688
+ return true
689
+ }
690
+
691
+ // ── column config (config.yaml per board) ──
692
+
693
+ export type KanbanColumnConfig = {
694
+ file: string
695
+ color?: string
696
+ }
697
+
698
+ export type KanbanConfig = {
699
+ columns: KanbanColumnConfig[]
700
+ }
701
+
702
+ function configPath(board: string): string {
703
+ return join(boardDir(board), "config.yaml")
704
+ }
705
+
706
+ export async function readKanbanConfig(board: string): Promise<KanbanConfig | null> {
707
+ try {
708
+ const raw = await readFile(configPath(board), "utf8")
709
+ return parseYaml(raw)
710
+ } catch {
711
+ return null
712
+ }
713
+ }
714
+
715
+ async function writeKanbanConfig(board: string, cfg: KanbanConfig): Promise<void> {
716
+ await mkdir(boardDir(board), { recursive: true })
717
+ const lines = ["columns:"]
718
+ for (const c of cfg.columns) {
719
+ lines.push(` - file: ${c.file}`)
720
+ if (c.color) lines.push(` color: "${c.color}"`)
721
+ }
722
+ await writeFile(configPath(board), lines.join("\n") + "\n")
723
+ }
724
+
725
+ /** Minimal YAML parser for our simple config format. */
726
+ function parseYaml(raw: string): KanbanConfig {
727
+ const cfg: KanbanConfig = { columns: [] }
728
+ let cur: Partial<KanbanColumnConfig> | null = null
729
+ for (const line of raw.split("\n")) {
730
+ const seq = line.match(/^\s*-\s+file:\s*(\S+)/)
731
+ if (seq) {
732
+ if (cur?.file) { cfg.columns.push({ file: cur.file, color: cur.color }) }
733
+ cur = { file: seq[1] }
734
+ continue
735
+ }
736
+ if (cur) {
737
+ const color = line.match(/^\s+color:\s*"?([^"]*)"?/)
738
+ if (color) { cur.color = color[1] || undefined }
739
+ }
740
+ }
741
+ if (cur?.file) cfg.columns.push({ file: cur.file, color: cur.color })
742
+ return cfg
743
+ }
744
+
745
+ /** Save column order to config. Creates/updates config.yaml. */
746
+ export async function saveColumnOrder(board: string, orderedFiles: string[]): Promise<void> {
747
+ const existing = await readKanbanConfig(board)
748
+ const colorMap = new Map((existing?.columns ?? []).map((c) => [c.file, c.color]))
749
+ const columns: KanbanColumnConfig[] = orderedFiles.map((f) => {
750
+ const entry: KanbanColumnConfig = { file: f }
751
+ const color = colorMap.get(f)
752
+ if (color) entry.color = color
753
+ return entry
754
+ })
755
+ await writeKanbanConfig(board, { columns })
756
+ await commitFocus(`saveColumnOrder ${board}`)
757
+ }
758
+
759
+ /** Update column color in config. */
760
+ export async function setColumnColor(board: string, filename: string, color: string): Promise<void> {
761
+ const cfg = await readKanbanConfig(board)
762
+ const existing = cfg?.columns ?? []
763
+ const found = existing.find((c) => c.file === filename)
764
+ if (found) {
765
+ found.color = color
766
+ } else {
767
+ existing.push({ file: filename, color })
768
+ }
769
+ await writeKanbanConfig(board, { columns: existing })
770
+ await commitFocus(`setColumnColor ${board}/${filename}`)
771
+ }
772
+
773
+ /** Delete a column file. All cards in the column are lost (caller should archive first). */
774
+ export async function deleteColumn(board: string, filename: string): Promise<boolean> {
775
+ const safe = basename(filename)
776
+ try {
777
+ await unlink(join(boardDir(board), safe))
778
+ } catch {
779
+ return false
780
+ }
781
+ const cfg = await readKanbanConfig(board)
782
+ if (cfg) {
783
+ cfg.columns = cfg.columns.filter((c) => c.file !== safe)
784
+ await writeKanbanConfig(board, cfg)
785
+ }
786
+ await commitFocus(`deleteColumn ${board}/${safe}`)
787
+ return true
788
+ }
789
+
790
+ /** Rename a column file on disk and update config if present. */
791
+ export async function renameColumn(board: string, fromFile: string, toFile: string): Promise<boolean> {
792
+ const safeFrom = basename(fromFile)
793
+ const safeTo = basename(toFile)
794
+ if (!safeTo || safeTo.startsWith(".") || !safeTo.endsWith(".md")) return false
795
+ const dir = boardDir(board)
796
+ try {
797
+ await rename(join(dir, safeFrom), join(dir, safeTo))
798
+ } catch {
799
+ return false
800
+ }
801
+ // update config if present
802
+ const cfg = await readKanbanConfig(board)
803
+ if (cfg) {
804
+ const found = cfg.columns.find((c) => c.file === safeFrom)
805
+ if (found) found.file = safeTo
806
+ await writeKanbanConfig(board, cfg)
807
+ }
808
+ await commitFocus(`renameColumn ${board}/${safeFrom} → ${safeTo}`)
809
+ return true
810
+ }