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.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- 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
|
+
}
|