Shows whether your active Karea task is in sync with what this session did: hint-line chip, behind band, changed-in-Karea alerts, task pane, sync receipts, a…

Karea for Claude Code: mods, skills and plugins.
/plugin marketplace add starecz/karea-agentic
/plugin install karea@karea-agentic
| Plugin | What it does |
|---|---|
karea | Shows whether your active Karea task is in sync with the session: hint-line chip, "behind" band with Sync now, changed-in-Karea alerts, task pane and a /ksync command. Includes the Karea skill. |
karea works/ksync pick KA123, or automatic: a session started from Karea Connect (env var KAREA_TASK) picks its task, and a resumed session gets back the task it had.karea_task_changes, only edits by people in the browser or through Karea AI). After a gap of over an hour it re-reads the task once. Servers without karea_task_changes fall back to comparing task snapshots.claude plugin validate plugins/karea
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/karea
MIT, see LICENSE.
hooks/register.tsx 1010 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActiveTask, ContextAge, HistoryRow, Touched } from '../types'
5import {
6 AWAY_MS,
7 HELP,
8 IDLE_POLL_MS,
9 addEvents,
10 addPending,
11 ago,
12 changeLine,
13 TONE_COLORS,
14 chip,
15 chipView,
16 ASK_MEETING,
17 NO_MEETING,
18 meetingLabel,
19 parseMeetings,
20 pickMeeting,
21 questionIds,
22 linkInstruction,
23 kareaParams,
24 contextAgesOf,
25 diffSnapshots,
26 mcpText,
27 parseContextText,
28 parseViewTask,
29 snapshotOf,
30 contextChanges,
31 freshness,
32 humanChanges,
33 isKareaWrite,
34 isShortId,
35 kareaAction,
36 markSynced,
37 parseCommand,
38 pickContext,
39 progressEvents,
40 recapText,
41 receipt,
42 rereadContext,
43 briefContext,
44 shortIdFromResult,
45 staleEntries,
46 syncAllPrompt,
47 syncPrompt,
48 ANALYZE_SYSTEM,
49 analyzePrompt,
50 parseFindings,
51 trackOpenDecisions,
52 taskRefOf,
53 upsertTouched,
54 changeKey,
55 changesWindow,
56 isUnknownAction,
57 parseTaskChanges,
58 personChanges,
59 statusFromChanges,
60 taskChangeLine,
61 subtaskRefusal,
62 subtaskAsk,
63 subtaskAnswerContext,
64 CLOSE_ALL,
65 CLOSE_ONLY,
66 KEEP_OPEN,
67} from './rules'
68import type { Meeting, Preset, ViewedTask } from './rules'
69
70type Api = { baseUrl: string; apiKey: string }
71
72class KareaError extends Error {}
73
74async function call<T>(
75 $: EngineInterface,
76 api: Api,
77 path: string,
78 init: { method?: string; body?: unknown } = {},
79): Promise<T> {
80 const res = await $.http.fetch(`${api.baseUrl.replace(/\/+$/, '')}${path}`, {
81 method: init.method ?? 'GET',
82 headers: { Authorization: `Bearer ${api.apiKey}`, 'Content-Type': 'application/json' },
83 ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
84 })
85 if (!res.ok) {
86 let message = `HTTP ${res.status}`
87 try {
88 const parsed = JSON.parse(res.text) as { error?: string }
89 if (parsed.error) message = parsed.error
90 } catch {
91 // body was not JSON
92 }
93 throw new KareaError(message)
94 }
95 return JSON.parse(res.text) as T
96}
97
98type ContextResponse = {
99 id: string
100 title: string
101 entries: { title: string; updatedAt: string; lastEditedMode: string }[]
102}
103
104async function getContext($: EngineInterface, api: Api, ref: string) {
105 const data = await call<ContextResponse>($, api, `/api/tasks/context?task=${encodeURIComponent(ref)}`)
106 const entries: ContextAge[] = data.entries.map(entry => ({
107 title: entry.title,
108 updatedAt: Date.parse(entry.updatedAt),
109 lastEditedMode: entry.lastEditedMode,
110 }))
111 return { id: data.id, title: data.title, entries }
112}
113
114async function getTask($: EngineInterface, api: Api, id: string) {
115 return call<{ id: string; title: string; status: string; displayId?: string | null }>($, api, `/api/tasks/${id}`)
116}
117
118async function getHistory($: EngineInterface, api: Api, id: string) {
119 return call<HistoryRow[]>($, api, `/api/tasks/${id}/history`)
120}
121
122async function setStatus($: EngineInterface, api: Api, id: string, status: string) {
123 return call<unknown>($, api, `/api/tasks/${id}`, { method: 'PATCH', body: { status } })
124}
125
126async function linkSession($: EngineInterface, api: Api, id: string, sessionId: string, label: string) {
127 return call<unknown>($, api, `/api/tasks/${id}/ai-sessions`, {
128 method: 'POST',
129 body: { provider: 'claude-code', sessionId, label },
130 })
131}
132
133function taskUrl(api: Api, id: string): string {
134 return `${api.baseUrl.replace(/\/+$/, '')}/dashboard/task/${id}`
135}
136
137const PANE = 'karea-task'
138
139const active = atom({ plugin: 'karea', key: 'active' } as const, null)
140const pending = atom({ plugin: 'karea', key: 'pending' } as const, [])
141const events = atom({ plugin: 'karea', key: 'events' } as const, [])
142const lastSyncAt = atom({ plugin: 'karea', key: 'lastSyncAt' } as const, 0)
143const remote = atom({ plugin: 'karea', key: 'remote' } as const, null)
144const watermark = atom({ plugin: 'karea', key: 'watermark' } as const, 0)
145const contextAges = atom({ plugin: 'karea', key: 'contextAges' } as const, [])
146const touched = atom({ plugin: 'karea', key: 'touched' } as const, [])
147const lastPromptAt = atom({ plugin: 'karea', key: 'lastPromptAt' } as const, 0)
148const needsReread = atom({ plugin: 'karea', key: 'needsReread' } as const, false)
149const clearArmed = atom({ plugin: 'karea', key: 'clearArmed' } as const, false)
150const snapshot = atom({ plugin: 'karea', key: 'snapshot' } as const, null)
151const chipNow = atom({ plugin: 'karea', key: 'chipNow' } as const, null)
152const lastPrompt = atom({ plugin: 'karea', key: 'lastPrompt' } as const, '')
153const turnWrote = atom({ plugin: 'karea', key: 'turnWrote' } as const, false)
154const earlier = atom({ plugin: 'karea', key: 'earlier' } as const, null)
155const openDecisions = atom({ plugin: 'karea', key: 'openDecisions' } as const, [])
156const lastCheckAt = atom({ plugin: 'karea', key: 'lastCheckAt' } as const, 0)
157const seenChanges = atom({ plugin: 'karea', key: 'seenChanges' } as const, [])
158// Picked from KAREA_TASK at start: the first prompt carries the brief (Karea Connect sends none when this mod is on).
159const briefPending = atom({ plugin: 'karea', key: 'briefPending' } as const, false)
160
161// The task picked in each session, kept in the plugin's store so a closed and resumed session gets it back.
162const SESSION_TASKS = 'sessionTasks'
163const MAX_SESSIONS = 200
164type SavedTask = ActiveTask | 'off'
165
166async function currentSessionId($: EngineInterface): Promise<string> {
167 try {
168 return await $.session.id()
169 } catch {
170 return ''
171 }
172}
173
174async function savedTask($: EngineInterface, id: string): Promise<SavedTask | undefined> {
175 if (id === '') return undefined
176 try {
177 const all = (await $.store.get(SESSION_TASKS)) as Record<string, SavedTask> | undefined
178 return all?.[id]
179 } catch {
180 return undefined
181 }
182}
183
184async function saveTask($: EngineInterface, task: SavedTask) {
185 const id = await currentSessionId($)
186 if (id === '') return
187 try {
188 const all = ((await $.store.get(SESSION_TASKS)) as Record<string, SavedTask> | undefined) ?? {}
189 const { [id]: _previous, ...rest } = all
190 const kept = Object.entries(rest).slice(-(MAX_SESSIONS - 1))
191 await $.store.set(SESSION_TASKS, { ...Object.fromEntries(kept), [id]: task })
192 } catch {
193 // remembering the task is a convenience
194 }
195}
196
197type Config = {
198 api: Api
199 hasApi: boolean
200 preset: Preset
201 extraPatterns: string
202 pollMs: number
203 showReceipts: boolean
204 detectDecisions: boolean
205 mcpServer: string
206 writeStatusFile: boolean
207}
208
209let cfg: Config = {
210 api: { baseUrl: 'https://karea.app', apiKey: '' },
211 hasApi: false,
212 preset: 'git',
213 extraPatterns: '',
214 pollMs: 300000,
215 showReceipts: true,
216 detectDecisions: false,
217 mcpServer: '',
218 writeStatusFile: false,
219}
220
221async function refreshChip($: EngineInterface) {
222 const task = await read($, active)
223 const input = {
224 shortid: task?.shortid ?? '',
225 pending: (await read($, pending)).length,
226 remote: (await read($, remote))?.lines.length ?? 0,
227 lastSyncAt: await read($, lastSyncAt),
228 now: await $.clock.now(),
229 title: task?.title ?? '',
230 }
231 const view = chipView(input)
232 await update($, chipNow, current =>
233 current && current.tone === view.tone && current.mark === view.mark && current.label === view.label
234 ? current
235 : view,
236 )
237 const text = chip(input)
238 if (!cfg.writeStatusFile) return
239 try {
240 const home = (await $.env.get('HOME')) ?? ''
241 await $.fs.write(`${home}/.claude/karea/status-${await $.session.id()}`, task ? text : '')
242 } catch {
243 // the status file is a convenience
244 }
245}
246
247async function rememberTouched($: EngineInterface) {
248 const task = await read($, active)
249 if (!task) return
250 const entry: Touched = { shortid: task.shortid, title: task.title, pending: await read($, pending) }
251 await update($, touched, list => upsertTouched(list, entry))
252}
253
254async function loadContextAges($: EngineInterface, ref: string) {
255 if (!cfg.hasApi) return undefined
256 try {
257 const data = await getContext($, cfg.api, ref)
258 await update($, contextAges, () => data.entries)
259 return data
260 } catch {
261 return undefined
262 }
263}
264
265const MCP_SERVERS = ['karea', 'claude.ai Karea']
266const MEETING_SERVERS = MCP_SERVERS
267let QUESTION_MEETING_SETTING = ''
268let mcpServerInUse = ''
269
270async function kareaMcpRaw(
271 $: EngineInterface,
272 tool: string,
273 action: string,
274 params: Record<string, unknown>,
275): Promise<{ text: string; isError: boolean } | null> {
276 const candidates = cfg.mcpServer ? [cfg.mcpServer] : mcpServerInUse ? [mcpServerInUse] : MCP_SERVERS
277 const failures: string[] = []
278 for (const server of candidates) {
279 try {
280 const result = await $.mcp.call(server, tool, { action, params })
281 mcpServerInUse = server
282 return { text: mcpText(result), isError: result.isError === true }
283 } catch (error) {
284 failures.push(`${server}: ${error instanceof Error ? error.message : String(error)}`)
285 continue
286 }
287 }
288 if (tool !== 'karea_read') $.ui.log(`${action} failed: ${failures.join('; ').slice(0, 200)}`)
289 return null
290}
291
292async function kareaMcp(
293 $: EngineInterface,
294 tool: string,
295 action: string,
296 params: Record<string, unknown>,
297): Promise<string | undefined | null> {
298 const result = await kareaMcpRaw($, tool, action, params)
299 if (result === null) return null
300 return result.isError ? undefined : result.text
301}
302
303// Set once the connected server turns karea_task_changes down; then polls compare snapshots instead.
304let taskChangesUnknown = false
305
306// Asks Karea only for what changed since the last check. False when the server does not know the action.
307async function pollChanges($: EngineInterface, task: ActiveTask, now: number): Promise<boolean> {
308 const ref = task.id || task.shortid
309 const minutes = changesWindow(await read($, lastCheckAt), now)
310 if (minutes === undefined) {
311 const viewed = await kareaMcp($, 'karea_read', 'karea_view_task', { task: ref })
312 const found = viewed ? parseViewTask(viewed) : undefined
313 if (!found) return true
314 await update($, active, current =>
315 current ? { ...current, id: found.id, title: found.title || current.title, status: found.status || current.status } : current,
316 )
317 await update($, lastCheckAt, () => now)
318 await update($, seenChanges, () => [])
319 return true
320 }
321 const result = await kareaMcpRaw($, 'karea_read', 'karea_task_changes', { task: ref, sinceMinutes: minutes })
322 if (result === null) return true
323 if (isUnknownAction(result.text)) {
324 taskChangesUnknown = true
325 return false
326 }
327 if (result.isError) return true
328 const rows = parseTaskChanges(result.text)
329 const fresh = personChanges(rows, await read($, watermark), await read($, seenChanges))
330 await update($, seenChanges, list => [...list, ...rows.map(changeKey).filter(key => !list.includes(key))].slice(-100))
331 await update($, lastCheckAt, () => now)
332 const status = statusFromChanges(rows)
333 if (status) await update($, active, current => (current ? { ...current, status } : current))
334 await flagRemote($, task.shortid, fresh.map(taskChangeLine), now)
335 return true
336}
337
338async function mcpSnapshot($: EngineInterface, ref: string) {
339 const viewed = await kareaMcp($, 'karea_read', 'karea_view_task', { task: ref })
340 if (viewed === null) return null
341 const task: ViewedTask | undefined = viewed ? parseViewTask(viewed) : undefined
342 if (!task) return undefined
343 const context = await kareaMcp($, 'karea_read', 'karea_get_context', { task: task.id })
344 const entries = context ? parseContextText(context) : []
345 await update($, contextAges, () => contextAgesOf(entries))
346 return { task, snap: snapshotOf(task, entries) }
347}
348
349async function flagRemote($: EngineInterface, shortid: string, changes: string[], now: number) {
350 if (changes.length === 0) return
351 const previous = (await read($, remote))?.lines ?? []
352 const lines = [...previous, ...changes.filter(line => !previous.includes(line))].slice(-6)
353 await update($, remote, () => ({ at: now, lines }))
354 await update($, watermark, () => now)
355 $.ui.toast(`${shortid} changed in Karea: ${changes[0]}${changes.length > 1 ? ` (+${changes.length - 1})` : ''}`)
356 await refreshChip($)
357}
358
359async function activate($: EngineInterface, ref: string, setInProgress: boolean): Promise<string> {
360 const previous = await read($, active)
361 const leftBehind = previous && previous.shortid !== ref ? await read($, pending) : []
362 await rememberTouched($)
363
364 let task: ActiveTask = { shortid: ref, id: '', title: '', status: '' }
365 const lines: string[] = []
366 if (cfg.hasApi) {
367 const data = await loadContextAges($, ref)
368 if (!data) return `Could not find ${ref} in Karea. Check the ID and your API key.`
369 const full = await getTask($, cfg.api, data.id).catch(() => undefined)
370 task = { shortid: full?.displayId || ref, id: data.id, title: data.title, status: full?.status ?? '' }
371 if (setInProgress && task.status !== 'in_progress') {
372 await setStatus($, cfg.api, task.id, 'in_progress').catch(() => undefined)
373 lines.push(`status ${task.status || 'open'} → in progress`)
374 task = { ...task, status: 'in_progress' }
375 }
376 const linked = await linkSession($, cfg.api, task.id, await $.session.id(), 'Claude Code').then(
377 () => true,
378 () => false,
379 )
380 if (linked) lines.push('session linked')
381 const ages = await read($, contextAges)
382 const at = await $.clock.now()
383 if (ages.length > 0)
384 lines.push(`context: ${ages.map(entry => `${entry.title} (${ago(at - entry.updatedAt)})`).join(' · ')}`)
385 } else {
386 const found = await mcpSnapshot($, ref)
387 if (found === undefined)
388 return `Could not find ${ref} in Karea. Check the ID, and that the Karea MCP server is connected (/mcp).`
389 if (found === null) {
390 await update($, contextAges, () => [])
391 await update($, snapshot, () => null)
392 } else {
393 task = {
394 shortid: found.task.shortid || ref,
395 id: found.task.id,
396 title: found.task.title,
397 status: found.task.status,
398 }
399 if (setInProgress) {
400 const edited = await kareaMcp($, 'karea_tasks_write', 'karea_edit_task', {
401 task: task.id,
402 ...(task.status === 'in_progress' ? {} : { status: 'in_progress' }),
403 aiSessionId: await $.session.id(),
404 toolType: 'claude-code',
405 sessionLabel: 'Claude Code',
406 })
407 if (typeof edited === 'string') {
408 if (task.status !== 'in_progress') lines.push(`status ${task.status || 'open'} → in progress`)
409 lines.push('session linked')
410 task = { ...task, status: 'in_progress' }
411 }
412 }
413 const fresh = setInProgress ? await mcpSnapshot($, task.id) : found
414 await update($, snapshot, () => fresh?.snap ?? found.snap)
415 const ages = await read($, contextAges)
416 const at = await $.clock.now()
417 if (ages.length > 0)
418 lines.push(`context: ${ages.map(entry => `${entry.title} (${ago(at - entry.updatedAt)})`).join(' · ')}`)
419 }
420 }
421
422 const now = await $.clock.now()
423 await update($, active, () => task)
424 const carried = (await read($, touched)).find(item => item.shortid === task.shortid)?.pending ?? []
425 await update($, pending, () => carried)
426 await update($, events, () => [])
427 await update($, lastSyncAt, () => 0)
428 await update($, remote, () => null)
429 await update($, watermark, () => now)
430 await update($, lastCheckAt, () => (task.id ? now : 0))
431 await update($, seenChanges, () => [])
432 await update($, lastPromptAt, previous => Math.max(previous, now))
433 await rememberTouched($)
434 await refreshChip($)
435 await saveTask($, task)
436
437 const head = `Active: ${task.shortid}${task.title ? ` ${task.title}` : ''}`
438 const warn =
439 leftBehind.length > 0 && previous
440 ? `\n${previous.shortid} still has ${leftBehind.length} unsynced event${leftBehind.length === 1 ? '' : 's'} (${leftBehind.join(', ')}). /ksync recap lists it.`
441 : ''
442 return `${head}${lines.length > 0 ? `\n ${lines.join('\n ')}` : ''}${warn}`
443}
444
445async function poll($: EngineInterface) {
446 const task = await read($, active)
447 if (!task) return
448 const now = await $.clock.now()
449 if (now - (await read($, lastPromptAt)) > IDLE_POLL_MS) return
450
451 if (!cfg.hasApi) {
452 if (!taskChangesUnknown && (await pollChanges($, task, now))) return
453 const fresh = await mcpSnapshot($, task.id || task.shortid)
454 if (!fresh) return
455 await update($, active, current =>
456 current
457 ? { ...current, id: fresh.task.id, title: fresh.task.title || current.title, status: fresh.task.status }
458 : current,
459 )
460 const before = await read($, snapshot)
461 await update($, snapshot, () => fresh.snap)
462 if (before) await flagRemote($, task.shortid, diffSnapshots(before, fresh.snap), now)
463 return
464 }
465
466 if (!task.id) return
467 const since = await read($, watermark)
468 try {
469 const [rows, ctx] = await Promise.all([getHistory($, cfg.api, task.id), loadContextAges($, task.id)])
470 const changes = [...humanChanges(rows, since).map(changeLine), ...contextChanges(ctx?.entries ?? [], since)]
471 const status = rows.find(row => row.field === 'status' && Date.parse(row.createdAt) > since)?.newValue
472 if (status) await update($, active, current => (current ? { ...current, status } : current))
473 await flagRemote($, task.shortid, changes, now)
474 } catch {
475 // offline or Karea unreachable: try again next period
476 }
477}
478
479async function analyzeTurn($: EngineInterface, prompt: string, answer: string) {
480 const context = await read($, earlier)
481 const openBefore = await read($, openDecisions)
482 await update($, earlier, () => ({ prompt: prompt.slice(0, 1500), answer: answer.slice(0, 3000) }))
483 const reply = await $.model
484 .complete({
485 model: 'haiku',
486 system: ANALYZE_SYSTEM,
487 prompt: analyzePrompt(prompt, answer, context ?? undefined, openBefore),
488 maxTokens: 300,
489 timeoutMs: 30000,
490 })
491 .catch(() => undefined)
492 if (!reply) return
493 if (reply?.isAnswered) await update($, openDecisions, open => trackOpenDecisions(open, reply.text))
494 if (!reply.isAnswered || (await read($, turnWrote)) || !(await read($, active))) return
495 const found = parseFindings(reply.text, openBefore)
496 if (found.length === 0) return
497 const now = await $.clock.now()
498 await update($, pending, list => addPending(list, found))
499 await update($, events, list => addEvents(list, found, now))
500 await rememberTouched($)
501 await refreshChip($)
502}
503
504async function kareaSyncLoaded($: EngineInterface): Promise<boolean> {
505 const dirs = (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? ''
506 return /(^|[/:])karea-sync(:|$)/.test(dirs)
507}
508
509async function upcomingMeetings($: EngineInterface): Promise<Meeting[]> {
510 const now = await $.clock.now()
511 const from = new Date(now - 60000).toISOString()
512 const to = new Date(now + 21 * 86400000).toISOString()
513 for (const server of MEETING_SERVERS) {
514 try {
515 const result = await $.mcp.call(server, 'karea_read', { action: 'karea_list_meetings', params: { from, to } })
516 if (result.isError) return []
517 return parseMeetings(result.content.map(block => block.text ?? '').join('\n'))
518 } catch {
519 continue
520 }
521 }
522 return []
523}
524
525async function linkQuestionToMeeting($: EngineInterface, meetingId: string, questionId: string): Promise<string> {
526 const reasons: string[] = []
527 for (const server of MEETING_SERVERS) {
528 try {
529 const result = await $.mcp.call(server, 'karea_meetings_write', {
530 action: 'karea_link_question_to_meeting',
531 params: { meetingId, questionId },
532 })
533 if (!result.isError) return ''
534 reasons.push(`${server}: ${result.content.map(block => block.text ?? '').join(' ').slice(0, 160)}`)
535 } catch (error) {
536 reasons.push(`${server}: ${error instanceof Error ? error.message.slice(0, 160) : 'call failed'}`)
537 }
538 }
539 return reasons.join('; ') || 'no Karea MCP server answered'
540}
541
542async function meetingForQuestion($: EngineInterface, tool: string, input: Record<string, unknown>) {
543 if (kareaAction(tool, input) !== 'karea_create_question') return undefined
544 const params = kareaParams(input)
545 const project = typeof params.projectId === 'string' ? params.projectId : ''
546 return chooseMeeting($, QUESTION_MEETING_SETTING, project)
547}
548
549async function linkCreatedQuestion(
550 $: EngineInterface,
551 meeting: Meeting | undefined,
552 resultText: string | undefined,
553): Promise<string | undefined> {
554 if (!meeting) return undefined
555 const question = questionIds(resultText)
556 if (!question) return undefined
557 const failure = await linkQuestionToMeeting($, meeting.id, question.id)
558 if (failure === '') {
559 $.ui.log(`✓ ${question.shortid} linked to ${meetingLabel(meeting)}`)
560 return undefined
561 }
562 $.ui.log(`${question.shortid}: could not link it to ${meetingLabel(meeting)} (${failure}); asking Claude to link it`)
563 return linkInstruction(question.shortid, question.id, meeting.id, meetingLabel(meeting))
564}
565
566async function chooseMeeting($: EngineInterface, setting: string, project: string): Promise<Meeting | undefined> {
567 if (setting.trim().toLowerCase() === 'none') return undefined
568 const meetings = await upcomingMeetings($)
569 const now = await $.clock.now()
570 if (setting.trim() !== '') return pickMeeting(meetings, setting, project, now)
571 const seen = new Set<string>()
572 const next = meetings.filter(m => m.startAt > now && !seen.has(m.title) && (seen.add(m.title), true)).slice(0, 3)
573 if (next.length === 0) return undefined
574 const labels = next.map(meetingLabel)
575 try {
576 const answer = await $.ui.ask(ASK_MEETING, { options: [...labels, NO_MEETING], header: 'Meeting' })
577 return next[labels.indexOf(answer)]
578 } catch {
579 return undefined
580 }
581}
582
583// Session start: the task this session had before it was closed, else the one Karea Connect started it for.
584async function restoreTask($: EngineInterface) {
585 const saved = await savedTask($, await currentSessionId($))
586 if (saved === 'off') return
587 const current = await read($, active)
588 const now = await $.clock.now()
589 if (saved) {
590 if (current?.shortid === saved.shortid) return
591 const carried = (await read($, touched)).find(item => item.shortid === saved.shortid)?.pending ?? []
592 await update($, active, () => saved)
593 await update($, pending, () => carried)
594 await update($, remote, () => null)
595 await update($, watermark, () => now)
596 await update($, lastCheckAt, () => 0)
597 return
598 }
599 if (current) return
600 const ref = ((await $.env.get('KAREA_TASK')) ?? '').trim().toUpperCase()
601 if (!isShortId(ref)) return
602 await update($, active, () => ({ shortid: ref, id: '', title: '', status: '' }))
603 await update($, briefPending, () => true)
604 $.clock.after(0, () => void autoPick($, ref))
605}
606
607async function autoPick($: EngineInterface, ref: string) {
608 const text = await activate($, ref, false)
609 const task = await read($, active)
610 if (text.startsWith('Could not find') || !task) {
611 await saveTask($, { shortid: ref, id: '', title: '', status: '' })
612 return
613 }
614 $.ui.log(`Karea task ${task.shortid}${task.title ? ` ${task.title}` : ''} is active in this session (from Karea Connect).`)
615}
616
617export const register: Register = (on, options) => {
618 QUESTION_MEETING_SETTING = String(options.questionMeeting ?? '')
619
620 const api: Api = { baseUrl: String(options.baseUrl ?? 'https://karea.app'), apiKey: String(options.apiKey ?? '') }
621 cfg = {
622 api,
623 hasApi: api.apiKey.trim() !== '',
624 preset: String(options.preset ?? 'git') as Preset,
625 extraPatterns: String(options.extraPatterns ?? ''),
626 pollMs: Math.max(60, Number(options.pollSeconds ?? 300)) * 1000,
627 showReceipts: options.receipts !== false,
628 detectDecisions: options.detectDecisions === true,
629 mcpServer: String(options.mcpServer ?? '').trim(),
630 writeStatusFile: options.statusFile === true,
631 }
632 const { hasApi, preset, extraPatterns, pollMs, showReceipts } = cfg
633
634 on('session.start', async ($, e, next) => {
635 await $.command
636 .register({
637 name: 'ksync',
638 description: 'Karea task sync: pick, status, sync, review, pane, recap, off',
639 argumentHint: '[pick KA123 | status | sync [all] | review | pane | recap | off]',
640 })
641 .catch(() => $.ui.log('karea: /ksync is taken by another command here; use the band and pane instead.'))
642 $.clock.every(pollMs, () => void poll($))
643 $.clock.every(60000, () => void refreshChip($))
644 $.ui.status(undefined)
645 await update($, pending, list => list.filter(item => !item.startsWith('open question:')))
646 await restoreTask($)
647 await refreshChip($)
648 return next(e)
649 })
650
651 on('command.run', { command: 'ksync' }, async ($, e) => {
652 const cmd = parseCommand(e.args)
653 const task = await read($, active)
654
655 if (cmd.verb === 'help') return { text: HELP }
656
657 if (cmd.verb === 'pick') {
658 const text = await activate($, cmd.ref, true)
659 const picked = await read($, active)
660 return picked ? { text, context: [pickContext(picked.shortid, picked.id !== '')] } : { text }
661 }
662
663 if (cmd.verb === 'off') {
664 await rememberTouched($)
665 await update($, active, () => null)
666 await update($, pending, () => [])
667 await update($, remote, () => null)
668 await refreshChip($)
669 await saveTask($, 'off')
670 return { text: 'No active Karea task.' }
671 }
672
673 if (cmd.verb === 'recap') {
674 await rememberTouched($)
675 return { text: recapText(await read($, touched), task?.shortid ?? '') }
676 }
677
678 if (cmd.verb === 'sync') {
679 if (cmd.isAll) {
680 await rememberTouched($)
681 const behind = (await read($, touched)).filter(item => item.pending.length > 0)
682 if (behind.length === 0) return { text: 'Every Karea task touched in this session is synced.' }
683 void $.prompt.submit({ text: syncAllPrompt(behind), asUser: true })
684 return { text: `Syncing ${behind.map(item => item.shortid).join(', ')}.` }
685 }
686 if (!task) return { text: 'No active Karea task. Run /ksync pick KA123 first.' }
687 void $.prompt.submit({ text: syncPrompt(task.shortid, await read($, pending)), asUser: true })
688 return { text: `Syncing ${task.shortid}.` }
689 }
690
691 if (cmd.verb === 'review') {
692 if (!task) return { text: 'No active Karea task.' }
693 if (!hasApi) {
694 const edited = await kareaMcp($, 'karea_tasks_write', 'karea_edit_task', {
695 task: task.id || task.shortid,
696 status: 'review',
697 })
698 if (typeof edited !== 'string') {
699 return {
700 text: `Asking Claude to set ${task.shortid} to review.`,
701 context: [`Set Karea task ${task.shortid} to status review.`],
702 }
703 }
704 await update($, snapshot, () => null)
705 await update($, active, current => (current ? { ...current, status: 'review' } : current))
706 return { text: `${task.shortid} is in review.` }
707 }
708 if (!task.id) return { text: `Run /ksync pick ${task.shortid} first.` }
709 await setStatus($, api, task.id, 'review')
710 await update($, active, current => (current ? { ...current, status: 'review' } : current))
711 return { text: `${task.shortid} is in review.` }
712 }
713
714 if (cmd.verb === 'pane') {
715 if (task && hasApi) await loadContextAges($, task.id || task.shortid)
716 else if (task) await mcpSnapshot($, task.id || task.shortid)
717 await $.ui.open({ id: PANE, title: 'Karea' })
718 return { text: 'Karea pane opened.' }
719 }
720
721 if (!task) return { text: 'No active Karea task. Run /ksync pick KA123.' }
722 const list = await read($, pending)
723 const changes = (await read($, remote))?.lines ?? []
724 const lines = [
725 `${task.shortid} ${task.title}${task.status ? ` · ${task.status}` : ''}`,
726 list.length > 0 ? `Not synced yet: ${list.join(', ')}` : 'Nothing waiting to be synced.',
727 ...(changes.length > 0 ? [`Changed in Karea: ${changes.join('; ')}`] : []),
728 ...(task.id ? [taskUrl(api, task.id)] : []),
729 ]
730 return { text: lines.join('\n') }
731 })
732
733 on('command.run', { command: 'clear' }, async ($, e, next) => {
734 const task = await read($, active)
735 const list = await read($, pending)
736 if (!task || list.length === 0 || (await read($, clearArmed))) {
737 await update($, clearArmed, () => false)
738 return next(e)
739 }
740 await update($, clearArmed, () => true)
741 return {
742 text: `${task.shortid} has ${list.length} unsynced event${list.length === 1 ? '' : 's'} (${list.join(', ')}). Run /ksync sync first, or /clear again to clear anyway.`,
743 }
744 }).catch(($, e, next) => next(e))
745
746 on('session.end', async ($, e, next) => {
747 await rememberTouched($)
748 return next(e)
749 })
750
751 on('prompt.submit', async ($, e, next) => {
752 const kind = e.origin.kind
753 if (kind !== 'composer' && kind !== 'bridge') return next(e)
754
755 await update($, lastPrompt, () => e.text)
756 await update($, turnWrote, () => false)
757 const now = await $.clock.now()
758 const previous = await read($, lastPromptAt)
759 const wasCompacted = await read($, needsReread)
760 const changes = (await read($, remote))?.lines ?? []
761 await update($, lastPromptAt, () => now)
762 await update($, needsReread, () => false)
763
764 const task = await read($, active)
765 if (!task) return next(e)
766 if (await read($, briefPending)) {
767 await update($, briefPending, () => false)
768 return next({ ...e, context: [...(e.context ?? []), briefContext(task.shortid)] })
769 }
770
771 const awayMinutes = previous === 0 ? 0 : Math.round((now - previous) / 60000)
772 const reason =
773 changes.length > 0
774 ? `It was changed in Karea since Claude last read it: ${changes.join('; ')}.`
775 : wasCompacted
776 ? 'The conversation was just compacted.'
777 : awayMinutes * 60000 >= AWAY_MS
778 ? `The user was away for ${awayMinutes} minutes.`
779 : undefined
780 if (reason === undefined) return next(e)
781
782 if (changes.length > 0) {
783 await update($, remote, () => null)
784 await update($, watermark, () => now)
785 await refreshChip($)
786 }
787 return next({ ...e, context: [...(e.context ?? []), rereadContext(task.shortid, reason)] })
788 }).catch(($, e, next) => next(e))
789
790 on('turn.complete', async ($, e, next) => {
791 const done = await next(e)
792 if (!cfg.detectDecisions || e.agentId !== undefined || e.reason !== 'answer') return done
793 if ((await read($, turnWrote)) || !(await read($, active))) return done
794 const prompt = await read($, lastPrompt)
795 const { answer } = e
796 $.clock.after(0, () => void analyzeTurn($, prompt, answer))
797 return done
798 }).catch(($, e, next) => next(e))
799
800 on('session.compact', async ($, e, next) => {
801 const done = await next(e)
802 await update($, needsReread, () => true)
803 return done
804 }).catch(($, e, next) => next(e))
805
806 on('tool.call', async ($, e, next) => {
807 const meeting = await meetingForQuestion($, String(e.tool), e as unknown as Record<string, unknown>)
808 const ran = await next(e)
809 if (ran.deny !== undefined || ran.isError === true) return ran
810 const linkContext = await linkCreatedQuestion($, meeting, ran.text)
811 if (linkContext) return { ...ran, context: [...(ran.context ?? []), linkContext] }
812
813 const tool = String(e.tool)
814 const input = e as unknown as Record<string, unknown>
815
816 if (isKareaWrite(tool, input)) {
817 const action = kareaAction(tool, input) ?? ''
818 const refusal = subtaskRefusal(ran.text)
819 if (refusal) {
820 const answer = await $.ui
821 .ask(subtaskAsk(refusal), { options: [CLOSE_ALL, CLOSE_ONLY, KEEP_OPEN], header: 'Subtasks' })
822 .catch(() => undefined)
823 $.ui.log(`Karea kept "${refusal.title}" open: it has ${refusal.count} open subtask${refusal.count === 1 ? '' : 's'}${answer ? ` (you chose: ${answer})` : ''}.`)
824 return { ...ran, context: [...(ran.context ?? []), subtaskAnswerContext(answer, action, tool)] }
825 }
826 const ref = taskRefOf(input) ?? shortIdFromResult(ran.text)
827 let task = await read($, active)
828
829 if (!task && ref && isShortId(ref) && /link_session|doing|edit_task|set_context|add_note/.test(action)) {
830 await activate($, ref, false)
831 task = await read($, active)
832 }
833
834 await update($, turnWrote, () => true)
835 const isActive = task !== null && (ref === undefined || ref === task.shortid || ref === task.id)
836 if (task && isActive) {
837 const now = await $.clock.now()
838 await update($, pending, () => [])
839 await update($, events, list => markSynced(list))
840 await update($, lastSyncAt, () => now)
841 await update($, watermark, () => now)
842 await update($, clearArmed, () => false)
843 await update($, snapshot, () => null)
844 await rememberTouched($)
845 await refreshChip($)
846 } else if (ref) {
847 await update($, touched, list => list.map(item => (item.shortid === ref ? { ...item, pending: [] } : item)))
848 }
849
850 if (showReceipts && action !== '') $.ui.log(`✓ ${ref ?? task?.shortid ?? 'Karea'} · ${receipt(action, input)}`)
851 return ran
852 }
853
854 if (e.tool === 'Bash') {
855 const out = (ran.result ?? {}) as {
856 stdout?: string
857 stderr?: string
858 backgroundTaskId?: string
859 interrupted?: boolean
860 }
861 if (out.backgroundTaskId || out.interrupted) return ran
862 const found = progressEvents(e.command, `${out.stdout ?? ''}\n${out.stderr ?? ''}`, preset, extraPatterns)
863 if (found.length > 0 && (await read($, active))) {
864 const now = await $.clock.now()
865 await update($, pending, list => addPending(list, found))
866 await update($, events, list => addEvents(list, found, now))
867 await rememberTouched($)
868 await refreshChip($)
869 }
870 }
871 return ran
872 }).catch(($, e, next) => next(e))
873
874 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
875 const below = await next(e)
876 if (await kareaSyncLoaded($)) return below
877 const task = await read($, active)
878 const view =
879 (await read($, chipNow)) ??
880 chipView({ shortid: task?.shortid ?? '', pending: 0, remote: 0, lastSyncAt: 0, now: 0, title: task?.title ?? '' })
881 const { Box, Text } = $.ui.resolve(e)
882 return (
883 <Box flexDirection="row" justifyContent="space-between">
884 <Box flexGrow={1}>{below}</Box>
885 <Text color={TONE_COLORS[view.tone]}>
886 {view.mark} {view.label}
887 </Text>
888 </Box>
889 )
890 }).catch(($, e, next) => next(e))
891
892 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
893 const below = await next(e)
894 if (e.props.hasSurvey || e.props.isWorking || (await kareaSyncLoaded($))) return below
895 const task = await read($, active)
896 if (!task) return below
897 const list = await read($, pending)
898 const changes = (await read($, remote))?.lines ?? []
899 const armed = await read($, clearArmed)
900 if (list.length === 0 && changes.length === 0) return below
901
902 const { Box, Text, Button } = $.ui.resolve(e)
903 const syncNow = () => void $.prompt.submit({ text: syncPrompt(task.shortid, list), asUser: true })
904 return (
905 <Box flexDirection="column">
906 {changes.length > 0 && (
907 <Box flexDirection="column">
908 <Box flexDirection="row" flexWrap="wrap">
909 <Text color="error">◆ {task.shortid} changed in Karea </Text>
910 <Button
911 key="reread"
912 label="Re-read now"
913 variant="primary"
914 onPress={() =>
915 void $.prompt.submit({
916 text: `Re-read Karea task ${task.shortid}, then tell me in one line what changed.`,
917 asUser: true,
918 })
919 }
920 />
921 <Button key="later" label="Later" onPress={() => update($, remote, () => null)} />
922 </Box>
923 {changes.map(line => (
924 <Text dimColor> · {line}</Text>
925 ))}
926 </Box>
927 )}
928 {list.length > 0 && (
929 <Box flexDirection="row" flexWrap="wrap">
930 <Text color="warning">Karea {task.shortid} behind: </Text>
931 <Text>{list.join(' · ')} </Text>
932 <Button key="sync" label="Sync now" variant="primary" onPress={syncNow} />
933 {armed ? (
934 <Button key="clear" label="Clear anyway" onPress={() => void $.command.run({ command: 'clear' })} />
935 ) : (
936 <Button key="dismiss" label="Dismiss" onPress={() => update($, pending, () => [])} />
937 )}
938 </Box>
939 )}
940 {below}
941 </Box>
942 )
943 })
944
945 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
946 const { Box, Text, Button, Link } = $.ui.resolve(e)
947 const task = await read($, active)
948 if (!task) return <Text dimColor>No active Karea task. Run /ksync pick KA123.</Text>
949
950 const now = await $.clock.now()
951 const ages = await read($, contextAges)
952 const timeline = await read($, events)
953 const list = await read($, pending)
954 const lastLocal = timeline.filter(event => !event.isSynced).at(-1)?.at ?? 0
955 const stale = staleEntries(ages, lastLocal)
956 const room = Math.max(3, (e.viewport?.rows ?? 24) - 12 - ages.length)
957
958 return (
959 <Box flexDirection="column">
960 <Text bold>
961 {task.shortid} {task.title}
962 </Text>
963 {task.status !== '' && <Text dimColor>{task.status}</Text>}
964 <Text> </Text>
965 <Text dimColor>CONTEXT</Text>
966 {ages.length === 0 && <Text dimColor> No Context entries yet.</Text>}
967 {ages.map(entry => {
968 const filled = freshness(entry.updatedAt, now)
969 const isStale = stale.has(entry.title)
970 return (
971 <Box flexDirection="row">
972 <Text> {entry.title.padEnd(12).slice(0, 12)} </Text>
973 <Text color={isStale ? 'warning' : 'success'}>{'█'.repeat(filled)}</Text>
974 <Text dimColor>
975 {'░'.repeat(10 - filled)} {ago(now - entry.updatedAt)}
976 </Text>
977 {isStale && <Text color="warning"> stale</Text>}
978 </Box>
979 )
980 })}
981 <Text> </Text>
982 <Text dimColor>THIS SESSION</Text>
983 {timeline.length === 0 && <Text dimColor> Nothing yet.</Text>}
984 {timeline.slice(-room).map(event => (
985 <Box flexDirection="row">
986 <Text color={event.isSynced ? 'success' : 'warning'}> {event.isSynced ? '✓' : '•'} </Text>
987 <Text>{event.label} </Text>
988 <Text dimColor>{ago(now - event.at)}</Text>
989 </Box>
990 ))}
991 <Text> </Text>
992 <Box flexDirection="row" flexWrap="wrap">
993 <Button
994 key="pane-sync"
995 label={list.length > 0 ? `Sync now (${list.length})` : 'Sync now'}
996 variant="primary"
997 onPress={() => void $.prompt.submit({ text: syncPrompt(task.shortid, list), asUser: true })}
998 />
999 <Button
1000 key="pane-review"
1001 label="Mark review"
1002 onPress={() => void $.command.run({ command: 'ksync', args: 'review' })}
1003 />
1004 {task.id !== '' && <Link href={taskUrl(api, task.id)} label="Open in Karea" />}
1005 </Box>
1006 </Box>
1007 )
1008 })
1009}
1010hooks/rules.ts 676 lines1import type { ContextAge, HistoryRow, LocalEvent, Touched } from '../types'
2
3export const AWAY_MS = 45 * 60 * 1000
4export const MAX_PENDING = 8
5export const MAX_EVENTS = 30
6export const IDLE_POLL_MS = 30 * 60 * 1000
7
8const SHORT_ID = /^[A-Za-z]{1,8}\d+$/
9
10export function isShortId(ref: string): boolean {
11 return SHORT_ID.test(ref.trim())
12}
13
14function org(command: string): string {
15 const match = command.match(/(?:\s-o|--target-org)(?:=|\s+)("[^"]+"|'[^']+'|[^\s;&|]+)/)
16 return match?.[1] ? match[1].replace(/^["']|["']$/g, '') : 'default org'
17}
18
19const GIT_OPTS = String.raw`(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[\w-]+(?:=\S+)?))*`
20
21type Rule = {
22 pattern: RegExp
23 label: string | ((command: string) => string)
24 ok?: RegExp
25 bad?: RegExp
26}
27
28const GIT_FAILED = /\[rejected\]|\bfatal:|\berror:|nothing to commit|Everything up-to-date/i
29
30const GIT: Rule[] = [
31 { pattern: new RegExp(String.raw`\bgit${GIT_OPTS}\s+commit\b`), label: 'committed', ok: /\[[^\]\n]+ [0-9a-f]{7,}\]/, bad: GIT_FAILED },
32 { pattern: new RegExp(String.raw`\bgit${GIT_OPTS}\s+push\b`), label: 'pushed', ok: /\s->\s|\[new (branch|tag)\]/, bad: GIT_FAILED },
33]
34
35const GENERAL: Rule[] = [
36 { pattern: /\bgh\s+pr\s+create\b/, label: 'opened a PR', ok: /https:\/\/\S+\/pull\/\d+/ },
37 { pattern: /\bgh\s+pr\s+merge\b/, label: 'merged a PR', bad: /\b(failed|error)\b/i },
38 { pattern: /\b(vercel|netlify)\b[^|;&]*\b(deploy|--prod)\b/, label: 'deployed', bad: /\b(error|failed)\b/i },
39 { pattern: /\bfly\s+deploy\b/, label: 'deployed', bad: /\b(error|failed)\b/i },
40 { pattern: /\bterraform\s+apply\b/, label: 'applied Terraform', ok: /Apply complete!/ },
41 { pattern: /\b(prisma\s+migrate\s+(deploy|dev)|rails\s+db:migrate|alembic\s+upgrade|manage\.py\s+migrate)\b/, label: 'ran migrations', bad: /\b(error|failed)\b/i },
42]
43
44const SALESFORCE: Rule[] = [
45 { pattern: /\bsf\s+project\s+deploy\s+(start|quick|resume)\b/, label: c => `deployed to ${org(c)}`, ok: /Status:\s*Succeeded|Deploy Succeeded/i, bad: /Status:\s*Failed|Component Failures|\bError\b/ },
46 { pattern: /\bsf\s+data\s+(create|update|delete|upsert|import)\b/, label: c => `changed data in ${org(c)}`, bad: /\b(error|failed)\b/i },
47]
48
49export type Preset = 'git' | 'general' | 'salesforce' | 'both'
50
51function rulesFor(preset: Preset): Rule[] {
52 if (preset === 'git') return GIT
53 if (preset === 'general') return [...GIT, ...GENERAL]
54 if (preset === 'salesforce') return [...GIT, ...SALESFORCE]
55 return [...GIT, ...GENERAL, ...SALESFORCE]
56}
57
58export function progressEvents(command: string, output: string, preset: Preset = 'git', extra = ''): string[] {
59 const events: string[] = []
60 for (const rule of [...rulesFor(preset), ...customRules(extra)]) {
61 if (!rule.pattern.test(command)) continue
62 if (rule.ok && !rule.ok.test(output)) continue
63 if (rule.bad && rule.bad.test(output)) continue
64 const text = typeof rule.label === 'string' ? rule.label : rule.label(command)
65 if (!events.includes(text)) events.push(text)
66 }
67 return events
68}
69
70export function customRules(spec: string): Rule[] {
71 const rules: Rule[] = []
72 for (const part of spec.split(';')) {
73 const [label, source] = part.split('=').map(item => item?.trim() ?? '')
74 if (!label || !source) continue
75 try {
76 rules.push({ pattern: new RegExp(source), label })
77 } catch {
78 continue
79 }
80 }
81 return rules
82}
83
84export const ANALYZE_SYSTEM = [
85 'You read the LATEST exchange between a developer and a coding assistant working on a tracked task, and you fill in a JSON form about it. An EARLIER exchange and a list of OPEN DECISIONS (raised earlier in the conversation, not settled yet) may be given only as context: report nothing that was already settled there.',
86 'decisions: choices made in this exchange. For each, madeBy is "developer" when the developer made it, asked for it or agreed to it, or "assistant" when it is only the assistant\'s proposal. kind is one of:',
87 '"solution" (changes the architecture, design or approach of the solution),',
88 '"scope" (changes what the task delivers or its acceptance criteria),',
89 '"external" (based on what someone outside the session said, e.g. a coworker or the client said X so we do Y),',
90 '"workaround" (an unusual or unorthodox way of doing something, chosen for a stated reason),',
91 '"execution" (a go-ahead to run, deploy, test, retry or perform a step, or to continue: e.g. "ok, run those scripts"),',
92 '"detail" (a small implementation or wording choice that does not change the outcome).',
93 'why holds the stated reason, empty when none was given. settled is true only when the decision still stands at the end of the latest exchange: false when the assistant questioned it, pushed back, explained why it did otherwise, or asked the developer to confirm. A short reply like "yes" or "do it" settles a decision proposed or questioned earlier. When the latest exchange settles or drops one of the OPEN DECISIONS, report it with resolves set to that open decision\'s exact text, and settled true (decided) or false with kind "detail" (dropped). Leave out recaps of earlier decisions.',
94 'questions: questions left unanswered at the end of the exchange. For each, answeredBy says who can answer it: "developer" (the developer can answer or decide it, including anything about the codebase, scripts, setup, environments, requirements defaults or what to do next), "assistant" (it can be found out by reading code, running something, checking an org or testing, even if nobody has done it yet), "external" (only a specific person or group outside this session can answer it: a client, a stakeholder, another team, a product owner), or "future" (see below). For "external", party names who, as written in the exchange; leave it empty when nobody is named.',
95 '"future" is rare and strict: a "why does X happen" unknown that stayed UNRESOLVED AFTER REAL INVESTIGATION in this exchange (things were actually tried or checked and none explained it) AND is likely to come back later. For it, tried lists what was already tried, and recurs says why it will come back. It is never: something nobody has looked into yet, a todo or next step, a bug to fix, a missing tool, token or access, a requirement or default to decide, or a scope question.',
96 'Reply with JSON only, each text under 14 words: {"decisions":[{"text":"...","madeBy":"developer","kind":"solution","why":"...","settled":true,"resolves":""}],"questions":[{"text":"...","answeredBy":"developer","party":"","tried":"","recurs":""}]}',
97].join(' ')
98
99export type Exchange = { prompt: string; answer: string }
100
101export function analyzePrompt(prompt: string, answer: string, earlier?: Exchange, open: readonly { text: string }[] = []): string {
102 const latest = `LATEST EXCHANGE\nDEVELOPER:\n${prompt.slice(0, 3000)}\n\nASSISTANT:\n${answer.slice(0, 8000)}`
103 const parts: string[] = []
104 if (open.length > 0) parts.push(`OPEN DECISIONS (context only)\n${open.map(item => `- ${item.text}`).join('\n')}`)
105 if (earlier) {
106 parts.push(`EARLIER EXCHANGE (context only)\nDEVELOPER:\n${earlier.prompt.slice(0, 1500)}\n\nASSISTANT:\n${earlier.answer.slice(0, 3000)}`)
107 }
108 return [...parts, latest].join('\n\n')
109}
110
111const OUTCOME_KINDS = new Set(['solution', 'scope', 'external', 'workaround'])
112const ASSISTANT_OR_DEV = /\b(developer|dev|assistant|claude|ai|me|you|us|we|team member)\b/i
113
114export function parseFindings(text: string, open: readonly { text: string }[] = []): string[] {
115 const json = text.match(/\{[\s\S]*\}/)?.[0]
116 if (!json) return []
117 try {
118 const data = JSON.parse(json) as { decisions?: unknown; questions?: unknown }
119 const items = (value: unknown) =>
120 Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => item !== null && typeof item === 'object') : []
121 const textOf = (item: Record<string, unknown>) => (typeof item.text === 'string' ? item.text.trim() : '')
122 const decisions = items(data.decisions)
123 .filter(
124 item =>
125 item.madeBy === 'developer' &&
126 item.settled === true &&
127 (OUTCOME_KINDS.has(String(item.kind)) || open.some(o => o.text === item.resolves)) &&
128 textOf(item) !== '',
129 )
130 .slice(0, 3)
131 .map(item => `decision: ${clip(textOf(item), 60)}`)
132 const filled = (value: unknown) => typeof value === 'string' && value.trim().length >= 8
133 const questions = items(data.questions)
134 .filter(item => {
135 if (textOf(item) === '') return false
136 if (item.answeredBy === 'future') return filled(item.tried) && filled(item.recurs)
137 const party = typeof item.party === 'string' ? item.party.trim() : ''
138 return item.answeredBy === 'external' && party !== '' && !ASSISTANT_OR_DEV.test(party)
139 })
140 .slice(0, 3)
141 .map(item =>
142 item.answeredBy === 'future'
143 ? `open question for later: ${clip(textOf(item), 70)}`
144 : `open question for ${clip(String(item.party), 24)}: ${clip(textOf(item), 50)}`,
145 )
146 return [...decisions, ...questions]
147 } catch {
148 return []
149 }
150}
151
152export function addPending(pending: readonly string[], events: readonly string[]): string[] {
153 const merged = [...pending.filter(item => !events.includes(item)), ...events]
154 return merged.slice(-MAX_PENDING)
155}
156
157export function addEvents(list: readonly LocalEvent[], labels: readonly string[], at: number): LocalEvent[] {
158 return [...list, ...labels.map(label => ({ at, label, isSynced: false }))].slice(-MAX_EVENTS)
159}
160
161export function markSynced(list: readonly LocalEvent[]): LocalEvent[] {
162 return list.map(event => (event.isSynced ? event : { ...event, isSynced: true }))
163}
164
165const KAREA_TOOL = /karea/i
166const WRITE_ACTION = /^karea_(create|edit|close|done|doing|quick|add|set|answer|link|unlink|delete|toggle|upload|update|mark|snooze|dismiss)/
167
168export function kareaAction(tool: string, input: Record<string, unknown>): string | undefined {
169 if (!KAREA_TOOL.test(tool)) return undefined
170 if (typeof input.action === 'string') return input.action
171 const legacy = tool.match(/__(karea_\w+)$/)
172 return legacy?.[1]
173}
174
175export function isKareaWrite(tool: string, input: Record<string, unknown>): boolean {
176 if (!KAREA_TOOL.test(tool)) return false
177 if (/_write$/i.test(tool) || /__karea_delete$/i.test(tool) || /set_context$/i.test(tool)) return true
178 const action = kareaAction(tool, input)
179 return action !== undefined && WRITE_ACTION.test(action)
180}
181
182export function kareaParams(input: Record<string, unknown>): Record<string, unknown> {
183 const params = input.params
184 return params !== null && typeof params === 'object' ? (params as Record<string, unknown>) : input
185}
186
187export function taskRefOf(input: Record<string, unknown>): string | undefined {
188 const params = kareaParams(input)
189 for (const key of ['task', 'parent', 'taskId']) {
190 const value = params[key]
191 if (typeof value === 'string' && value.trim() !== '') return value.trim()
192 }
193 return undefined
194}
195
196export function shortIdFromResult(text: string | undefined): string | undefined {
197 return text?.match(/Short ID:\s*([A-Za-z]{1,8}\d+)/)?.[1]
198}
199
200const ACTION_LABELS: Record<string, string> = {
201 karea_set_context: 'context',
202 karea_set_markdown: 'document',
203 karea_add_note: 'note',
204 karea_edit_note: 'note edited',
205 karea_edit_task: 'task',
206 karea_edit_tasks: 'tasks',
207 karea_close_task: 'closed',
208 karea_done: 'closed',
209 karea_create_task: 'created',
210 karea_create_subtask: 'subtask',
211 karea_doing: 'created, in progress',
212 karea_quick_task: 'logged',
213 karea_add_requisite: 'requisite',
214 karea_toggle_requisite: 'requisite toggled',
215 karea_link_session: 'session linked',
216 karea_answer_question: 'question answered',
217 karea_create_question: 'question',
218}
219
220function clip(text: string, max = 48): string {
221 const flat = text.replace(/\s+/g, ' ').trim()
222 return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat
223}
224
225export function receipt(action: string, input: Record<string, unknown>): string {
226 const params = kareaParams(input)
227 const parts: string[] = []
228 const label = ACTION_LABELS[action] ?? action.replace(/^karea_/, '').replace(/_/g, ' ')
229 if (action === 'karea_set_context' && typeof params.title === 'string') {
230 parts.push(`context ${params.title}`)
231 } else if ((action === 'karea_add_note' || action === 'karea_edit_note') && typeof params.content === 'string') {
232 parts.push(`note "${clip(params.content)}"`)
233 } else {
234 parts.push(label)
235 }
236 if (typeof params.status === 'string') parts.push(`status ${params.status}`)
237 if (typeof params.note === 'string') parts.push(`note "${clip(params.note)}"`)
238 if (typeof params.resolution === 'string') parts.push(`"${clip(params.resolution)}"`)
239 return parts.join(' · ')
240}
241
242export function ago(ms: number): string {
243 const minutes = Math.max(0, Math.round(ms / 60000))
244 if (minutes < 1) return 'just now'
245 if (minutes < 60) return `${minutes}m ago`
246 const hours = Math.round(minutes / 60)
247 if (hours < 48) return `${hours}h ago`
248 return `${Math.round(hours / 24)}d ago`
249}
250
251export type ChipInput = {
252 shortid: string
253 pending: number
254 remote: number
255 lastSyncAt: number
256 now: number
257 title?: string
258}
259
260export type ChipTone = 'synced' | 'behind' | 'remote' | 'none'
261export type ChipView = { tone: ChipTone; mark: string; label: string }
262
263export const TITLE_CHARS = 25
264
265export function shortTitle(title: string): string {
266 const flat = title.replace(/\s+/g, ' ').trim()
267 return flat.length > TITLE_CHARS ? `${flat.slice(0, TITLE_CHARS).trimEnd()}…` : flat
268}
269
270export function chipView({ shortid, pending, remote, lastSyncAt, now, title = '' }: ChipInput): ChipView {
271 if (shortid === '') return { tone: 'none', mark: '○', label: 'no Karea task · /ksync pick' }
272 const name = title ? `${shortid} ${shortTitle(title)}` : shortid
273 if (remote > 0) return { tone: 'remote', mark: '◆', label: `${name} · changed in Karea` }
274 if (pending > 0) return { tone: 'behind', mark: '◐', label: `${name} · ${pending} unsynced` }
275 if (lastSyncAt === 0) return { tone: 'synced', mark: '●', label: name }
276 return { tone: 'synced', mark: '●', label: `${name} · synced ${ago(now - lastSyncAt)}` }
277}
278
279export function chip(input: ChipInput): string {
280 const view = chipView(input)
281 return `${view.mark} ${view.label}`
282}
283
284export const KAREA_GREEN = '#2BD4BD'
285
286export const TONE_COLORS: Record<ChipTone, string> = {
287 synced: KAREA_GREEN,
288 behind: 'warning',
289 remote: 'error',
290 none: 'inactive',
291}
292
293const FIELD_LABELS: Record<string, string> = {
294 slaDeadline: 'deadline',
295 assignedToId: 'assignee',
296 categoryId: 'category',
297 customStatus: 'status',
298 reviewStage: 'review stage',
299 note_added: 'new note',
300 note_edited: 'note edited',
301 closedAt: 'closed',
302}
303
304const QUIET_FIELDS = new Set(['closedById', 'customStatus', 'mcpHintClickedAt', '_recap_generated', '_email_overdue'])
305
306const PERSON_MODES = new Set(['user', 'ai'])
307
308export function humanChanges(rows: readonly HistoryRow[], since: number): HistoryRow[] {
309 return rows.filter(row => PERSON_MODES.has(row.mode) && Date.parse(row.createdAt) > since && !QUIET_FIELDS.has(row.field))
310}
311
312export function changeLine(row: HistoryRow): string {
313 const field = FIELD_LABELS[row.field] ?? row.field.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
314 const next = row.newValue ? clip(row.newValue, 40) : ''
315 const prev = row.oldValue ? clip(row.oldValue, 20) : ''
316 if (row.field === 'note_added' || row.field === '_note') return `new note: "${next}"`
317 if (prev && next) return `${field} ${prev} → ${next}`
318 if (next) return `${field}: ${next}`
319 return field
320}
321
322export function contextChanges(entries: readonly ContextAge[], since: number): string[] {
323 return entries
324 .filter(entry => PERSON_MODES.has(entry.lastEditedMode) && entry.updatedAt > since)
325 .map(entry => `context "${entry.title}" edited`)
326}
327
328export function staleEntries(entries: readonly ContextAge[], lastLocalEventAt: number): Set<string> {
329 return new Set(entries.filter(entry => entry.updatedAt < lastLocalEventAt).map(entry => entry.title))
330}
331
332export function freshness(updatedAt: number, now: number, width = 10): number {
333 const hours = (now - updatedAt) / 3600000
334 const filled = Math.round(width * Math.max(0, 1 - hours / 24))
335 return Math.max(1, Math.min(width, filled))
336}
337
338export function syncRules(pending: readonly string[]): string {
339 const questions = pending.filter(item => item.startsWith('open question for '))
340 const rules: string[] = []
341 if (pending.some(item => item.startsWith('decision: '))) {
342 rules.push("Write each listed decision to the task's Decisions context entry with its reasoning.")
343 }
344 if (questions.length > 0) {
345 rules.push(
346 `For the listed open question${questions.length === 1 ? '' : 's'} (${questions.length}), ask me first (one AskUserQuestion) which to create as Karea open questions linked to the task; create only the ones I approve, and no others.`,
347 )
348 }
349 rules.push(
350 'Do NOT create any other Karea open questions: technical loose ends, investigation items, doubts and tooling wishes go in a "Loose ends" context entry instead. Zero new questions is the normal outcome.',
351 )
352 return rules.join(' ')
353}
354
355export function syncPrompt(shortid: string, pending: readonly string[]): string {
356 const what = pending.length > 0 ? ` (${pending.join('; ')})` : ''
357 return `Update Karea task ${shortid} with this session's progress since its last update${what}: refresh its context and add a note. ${syncRules(pending)} Then reply in one line.`
358}
359
360export function syncAllPrompt(tasks: readonly Touched[]): string {
361 const list = tasks.map(task => `${task.shortid} (${task.pending.join('; ')})`).join('; ')
362 return `Update each of these Karea tasks with this session's progress since its last update: ${list}. For each, refresh its context and add a note. ${syncRules(tasks.flatMap(task => task.pending))} Then reply with one line per task.`
363}
364
365export function rereadContext(shortid: string, reason: string): string {
366 return `Active Karea task: ${shortid}. ${reason} Before acting on this message, re-read the task in Karea, as it may have changed: call karea_read with karea_view_task, then karea_get_context, karea_list_notes and karea_list_questions (task ${shortid}).`
367}
368
369/** A session Karea Connect started on a task: on the first prompt, Claude reads the task itself. */
370export function briefContext(shortid: string): string {
371 return `This session was started from Karea Connect on Karea task ${shortid}, its active task. Before answering, read the task with the Karea tools: karea_read with karea_view_task, then karea_get_context, karea_list_notes and karea_list_questions (task ${shortid}). Then do what the user asks; if they did not ask for anything specific, sum the task up in two or three lines and wait for their instructions.`
372}
373
374export function pickContext(shortid: string, hasApi: boolean): string {
375 return hasApi
376 ? `The user made Karea task ${shortid} the active task for this session. Read its context before working on it.`
377 : `The user made Karea task ${shortid} the active task for this session. Using the Karea tools: set it to in_progress if it is not already, link this session to it, and read its context before working on it.`
378}
379
380export function upsertTouched(list: readonly Touched[], entry: Touched): Touched[] {
381 const rest = list.filter(item => item.shortid !== entry.shortid)
382 return [...rest, entry].slice(-20)
383}
384
385export function recapText(list: readonly Touched[], activeShortid: string): string {
386 if (list.length === 0) return 'No Karea tasks touched in this session yet.'
387 const lines = list.map(task => {
388 const mark = task.pending.length > 0 ? '◐' : '●'
389 const state = task.pending.length > 0 ? `${task.pending.length} pending (${task.pending.join(', ')})` : 'synced'
390 const active = task.shortid === activeShortid ? ' (active)' : ''
391 return ` ${mark} ${task.shortid}${active} ${clip(task.title || '', 36)} ${state}`
392 })
393 const behind = list.filter(task => task.pending.length > 0).length
394 const tail = behind > 0 ? `\nRun /ksync sync all to sync ${behind} task${behind === 1 ? '' : 's'}.` : ''
395 return `Karea tasks this session:\n${lines.join('\n')}${tail}`
396}
397
398export type KareaCommand =
399 | { verb: 'pick'; ref: string }
400 | { verb: 'status' | 'off' | 'pane' | 'recap' | 'review' | 'help' }
401 | { verb: 'sync'; isAll: boolean }
402
403export function parseCommand(args: string): KareaCommand {
404 const [verb = '', ...rest] = args.trim().split(/\s+/)
405 const lower = verb.toLowerCase()
406 if (lower === 'pick' && rest[0]) return { verb: 'pick', ref: rest[0] }
407 if (isShortId(verb)) return { verb: 'pick', ref: verb }
408 if (lower === 'sync') return { verb: 'sync', isAll: rest[0]?.toLowerCase() === 'all' }
409 if (lower === '' || lower === 'status') return { verb: 'status' }
410 if (lower === 'off' || lower === 'pane' || lower === 'recap' || lower === 'review') return { verb: lower }
411 return { verb: 'help' }
412}
413
414export const HELP = [
415 '/ksync pick KA123 make a task the active one (also: /ksync KA123)',
416 '/ksync status show the active task and what is not synced yet',
417 '/ksync sync ask Claude to write this session\'s progress to the task',
418 '/ksync sync all the same for every task touched in this session',
419 '/ksync review set the active task to review',
420 '/ksync pane open the task pane',
421 '/ksync recap list every task touched in this session',
422 '/ksync off stop tracking a task',
423].join('\n')
424
425export type ViewedTask = { id: string; shortid: string; title: string; status: string; notes: string[] }
426export type ContextLine = { title: string; mode: string; stamp: string }
427export type Snapshot = { status: string; notes: string[]; context: Record<string, string> }
428
429export function mcpText(result: { content?: readonly { type: string; text?: string }[]; isError?: boolean }): string {
430 return (result.content ?? []).map(block => block.text ?? '').join('\n')
431}
432
433export function parseViewTask(text: string): ViewedTask | undefined {
434 const id = text.match(/^ID:\s*([0-9a-f-]{36})\s*$/m)?.[1]
435 if (!id) return undefined
436 const notes: string[] = []
437 let inNotes = false
438 for (const line of text.split('\n')) {
439 if (/^Notes:\s*$/.test(line)) {
440 inNotes = true
441 continue
442 }
443 if (inNotes && /^\s{2}\S/.test(line)) notes.push(line.trim())
444 else if (inNotes && line.trim() !== '' && !/^\s/.test(line)) inNotes = false
445 }
446 return {
447 id,
448 shortid: text.match(/^Short ID:\s*(\S+)\s*$/m)?.[1] ?? '',
449 title: text.match(/^\*\*(.+)\*\*\s*$/m)?.[1] ?? '',
450 status: (text.match(/^Status:\s*([a-z][a-z _]*?)\s*(?:\(|·|$)/m)?.[1] ?? '').replace(/ /g, '_'),
451 notes,
452 }
453}
454
455const CONTEXT_HEAD = /^## (.+?)\s+\[(\w+), updated (\d{4}-\d{2}-\d{2} \d{2}:\d{2})/
456
457export function parseContextText(text: string): ContextLine[] {
458 const entries: ContextLine[] = []
459 for (const line of text.split('\n')) {
460 const match = line.match(CONTEXT_HEAD)
461 if (match?.[1] && match[2] && match[3]) entries.push({ title: match[1], mode: match[2], stamp: match[3] })
462 }
463 return entries
464}
465
466export function contextAgesOf(entries: readonly ContextLine[]): ContextAge[] {
467 return entries.map(entry => ({
468 title: entry.title,
469 updatedAt: Date.parse(`${entry.stamp.replace(' ', 'T')}Z`),
470 lastEditedMode: entry.mode,
471 }))
472}
473
474export function snapshotOf(task: ViewedTask, entries: readonly ContextLine[]): Snapshot {
475 return {
476 status: task.status,
477 notes: task.notes,
478 context: Object.fromEntries(entries.map(entry => [entry.title, `${entry.mode}|${entry.stamp}`])),
479 }
480}
481
482function noteBody(note: string): string {
483 return note.replace(/^\d{2} \w{3} \d{4}, \d{2}:\d{2} \([^)]*\):\s*/, '')
484}
485
486export function diffSnapshots(before: Snapshot, after: Snapshot): string[] {
487 const lines: string[] = []
488 if (before.status !== '' && after.status !== before.status) lines.push(`status ${before.status} → ${after.status}`)
489 for (const note of after.notes.filter(note => !before.notes.includes(note))) {
490 lines.push(`new note: "${clip(noteBody(note), 40)}"`)
491 }
492 for (const [title, value] of Object.entries(after.context)) {
493 if (before.context[title] === value || !value.startsWith('user|')) continue
494 lines.push(`context "${title}" edited`)
495 }
496 return lines
497}
498
499// karea_task_changes (KA748) answers for one task over the last 1 to 60 minutes.
500export const CHANGES_MAX_MINUTES = 60
501
502export type TaskChange = { at: number; stamp: string; text: string; by: string; mode: string }
503
504// Minutes to ask for: the time since the last check, plus one for clock drift. Undefined when the gap is
505// longer than the server can answer for, so the caller re-reads the task instead.
506export function changesWindow(lastCheckAt: number, now: number): number | undefined {
507 if (lastCheckAt <= 0) return undefined
508 const minutes = Math.ceil((now - lastCheckAt) / 60000)
509 if (minutes > CHANGES_MAX_MINUTES) return undefined
510 return Math.min(CHANGES_MAX_MINUTES, Math.max(1, minutes + 1))
511}
512
513// A server from before karea_task_changes refuses the action by name.
514export function isUnknownAction(text: string | undefined): boolean {
515 return /Unknown action|is not a Karea action|is an action of /.test(text ?? '')
516}
517
518const CHANGE_ROW = /^- (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.+) · by (.+) \(([\w-]+)\)\s*$/
519
520// Rows look like "- 2026-10-07T11:17:53Z status: open -> blocked · by Alex (user)", newest first.
521export function parseTaskChanges(text: string): TaskChange[] {
522 const rows: TaskChange[] = []
523 for (const line of text.split('\n')) {
524 const match = line.match(CHANGE_ROW)
525 if (!match?.[1] || !match[2] || !match[3] || !match[4]) continue
526 rows.push({ at: Date.parse(match[1]), stamp: match[1], text: match[2], by: match[3], mode: match[4] })
527 }
528 return rows
529}
530
531export function changeKey(row: TaskChange): string {
532 return `${row.stamp} ${row.text} ${row.by} ${row.mode}`
533}
534
535const QUIET_LABELS = new Set(['closed by', 'custom status', 'mcp hint clicked at', 'recap generated', 'email overdue'])
536
537// Only what a person changed, in the browser (user) or through Karea AI (ai). The session's own writes come
538// back as mcp and are left out, like Jira and system rows.
539export function personChanges(rows: readonly TaskChange[], since: number, seen: readonly string[]): TaskChange[] {
540 return rows
541 .filter(row => PERSON_MODES.has(row.mode) && row.at > since && !seen.includes(changeKey(row)))
542 .filter(row => !QUIET_LABELS.has(row.text.split(':')[0]?.trim().toLowerCase() ?? ''))
543 .sort((a, b) => a.at - b.at)
544}
545
546export function taskChangeLine(row: TaskChange): string {
547 const note = row.text.match(/^Note (added|edited): "(.*)"$/)
548 if (note) return `${note[1] === 'added' ? 'new note' : 'note edited'}: "${clip(note[2] ?? '', 40)}" (${row.by})`
549 const entry = row.text.match(/^Context entry (created|edited): "(.*)"$/)
550 if (entry) return `context "${entry[2] ?? ''}" ${entry[1] === 'created' ? 'added' : 'edited'} (${row.by})`
551 const field = row.text.match(/^([^:]+): (.+?) -> (.+)$/)
552 if (field) return `${field[1]} ${clip(field[2] ?? '', 20)} → ${clip(field[3] ?? '', 40)} (${row.by})`
553 return `${clip(row.text, 60)} (${row.by})`
554}
555
556// The status the newest status row moved the task to, if any.
557export function statusFromChanges(rows: readonly TaskChange[]): string | undefined {
558 const newest = [...rows].sort((a, b) => b.at - a.at).find(row => /^status: /.test(row.text))
559 return newest?.text.match(/-> ([a-z_]+)$/)?.[1]
560}
561
562// KA759: Karea refuses to close a task with open subtasks until it is told what to do with them.
563export type SubtaskRefusal = { title: string; count: number; subtasks: string[] }
564
565export function subtaskRefusal(text: string | undefined): SubtaskRefusal | undefined {
566 const match = text?.match(/Cannot close "(.+?)" yet: it has (\d+) open subtasks?:\n((?:[ \t]+.*\n?)*)/)
567 if (!match?.[1] || !match[2]) return undefined
568 const subtasks = (match[3] ?? '')
569 .split('\n')
570 .map(line => line.trim().replace(/^- /, ''))
571 .filter(line => line !== '' && !line.startsWith('...'))
572 return { title: match[1], count: Number(match[2]), subtasks }
573}
574
575export const CLOSE_ALL = 'Close them all'
576export const CLOSE_ONLY = 'Close only this task'
577export const KEEP_OPEN = 'Keep it open'
578
579export function subtaskAsk(refusal: SubtaskRefusal): string {
580 const shown = refusal.subtasks.slice(0, 4).join('; ')
581 const more = refusal.count > 4 ? ` and ${refusal.count - 4} more` : ''
582 const them = refusal.count === 1 ? 'it' : 'them'
583 return `Karea did not close "${clip(refusal.title, 50)}": it has ${refusal.count} open subtask${refusal.count === 1 ? '' : 's'} (${shown}${more}). Close ${them} too?`
584}
585
586export function subtaskAnswerContext(answer: string | undefined, action: string, tool: string): string {
587 const call = `${tool} with action ${action} again, with the same params plus`
588 if (answer === CLOSE_ALL) return `Karea refused the close because of open subtasks. The user chose to close them all: call ${call} closeSubtasks: true.`
589 if (answer === CLOSE_ONLY) return `Karea refused the close because of open subtasks. The user chose to close only this task: call ${call} closeSubtasks: false.`
590 if (answer === KEEP_OPEN) return 'Karea refused the close because of open subtasks, and the user chose to keep the task open. Do not close it; carry on with the rest of the sync.'
591 return `Karea refused the close because of open subtasks. Ask the user (one AskUserQuestion) whether to close them all or only this task, then call ${call} closeSubtasks true or false to match the answer.`
592}
593
594export const OPEN_DECISION_TURNS = 40
595export type OpenDecision = { text: string; age: number }
596
597// Decisions raised by the developer but not settled yet, so a "yes, go" many messages later still counts.
598export function trackOpenDecisions(open: readonly OpenDecision[], reply: string): OpenDecision[] {
599 const json = reply.match(/\{[\s\S]*\}/)?.[0]
600 let data: { decisions?: unknown } = {}
601 try {
602 data = json ? (JSON.parse(json) as { decisions?: unknown }) : {}
603 } catch {
604 data = {}
605 }
606 const decisions = Array.isArray(data.decisions)
607 ? data.decisions.filter((item): item is Record<string, unknown> => item !== null && typeof item === 'object')
608 : []
609 const settledTexts = decisions
610 .filter(item => item.settled === true && typeof item.text === 'string')
611 .map(item => String(item.text).toLowerCase())
612 const resolves = (open: OpenDecision) =>
613 decisions.some(item => item.resolves === open.text) || settledTexts.includes(open.text.toLowerCase())
614 const fresh = decisions
615 .filter(
616 item =>
617 item.madeBy === 'developer' &&
618 item.settled === false &&
619 OUTCOME_KINDS.has(String(item.kind)) &&
620 typeof item.text === 'string' &&
621 item.text.trim() !== '',
622 )
623 .map(item => ({ text: clip(String(item.text), 80), age: 0 }))
624 const kept = open
625 .filter(item => !resolves(item))
626 .map(item => ({ ...item, age: item.age + 1 }))
627 .filter(item => item.age <= OPEN_DECISION_TURNS)
628 const merged = [...kept.filter(item => !fresh.some(f => f.text === item.text)), ...fresh]
629 return merged.slice(-5)
630}
631
632export type Meeting = { id: string; startAt: number; title: string; project: string }
633
634// karea_list_meetings text: "- 2026-10-08 13:00 - Title [Project] (id: <uuid>)"; times are the user's local time.
635export function parseMeetings(text: string): Meeting[] {
636 const meetings: Meeting[] = []
637 for (const line of text.split('\n')) {
638 const match = line.match(/^- (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) - (.+?) \(id: ([0-9a-f-]{36})\)\s*$/)
639 if (!match) continue
640 const [, y, mo, d, h, mi, rest = '', id = ''] = match
641 const project = rest.match(/\[([^\]]+)\]\s*$/)?.[1] ?? ''
642 const title = rest.replace(/\s*\[[^\]]+\]\s*$/, '').trim()
643 meetings.push({ id, startAt: new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi)).getTime(), title, project })
644 }
645 return meetings.sort((a, b) => a.startAt - b.startAt)
646}
647
648// The next occurrence that has not started yet of the meeting the user named; same project first.
649export function pickMeeting(meetings: readonly Meeting[], name: string, project: string, now: number): Meeting | undefined {
650 const wanted = name.trim().toLowerCase()
651 if (wanted === '') return undefined
652 const upcoming = meetings.filter(m => m.startAt > now && m.title.toLowerCase().includes(wanted))
653 const sameProject = upcoming.filter(m => project !== '' && m.project.toLowerCase() === project.trim().toLowerCase())
654 return sameProject[0] ?? upcoming[0]
655}
656
657export function meetingLabel(meeting: Meeting): string {
658 const at = new Date(meeting.startAt)
659 const day = at.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
660 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
661 return `${clip(meeting.title, 40)} (${day} ${time})`
662}
663
664export function questionIds(text: string | undefined): { id: string; shortid: string } | undefined {
665 const id = text?.match(/^ID:\s*([0-9a-f-]{36})\s*$/m)?.[1]
666 if (!id) return undefined
667 return { id, shortid: text?.match(/^Short ID:\s*(\S+)\s*$/m)?.[1] ?? 'question' }
668}
669
670export const NO_MEETING = 'No meeting, task only'
671export const ASK_MEETING = "I'm about to create this open question. Link it to a meeting, or keep it attached only to the task?"
672
673export function linkInstruction(shortid: string, questionId: string, meetingId: string, label: string): string {
674 return `Open question ${shortid} must be linked to the meeting ${label}: call karea_link_question_to_meeting with meetingId ${meetingId} and questionId ${questionId} now, then mention it in one short line.`
675}
676types/index.d.ts 42 lines1export type ActiveTask = { shortid: string; id: string; title: string; status: string }
2export type LocalEvent = { at: number; label: string; isSynced: boolean }
3export type RemoteChange = { at: number; lines: string[] }
4export type ContextAge = { title: string; updatedAt: number; lastEditedMode: string }
5export type Touched = { shortid: string; title: string; pending: string[] }
6export type ChipState = { tone: 'synced' | 'behind' | 'remote' | 'none'; mark: string; label: string }
7export type SyncSnapshot = { status: string; notes: string[]; context: Record<string, string> }
8export type HistoryRow = {
9 field: string
10 oldValue: string | null
11 newValue: string | null
12 mode: string
13 createdAt: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 karea: {
19 active: ActiveTask | null
20 pending: string[]
21 events: LocalEvent[]
22 lastSyncAt: number
23 remote: RemoteChange | null
24 watermark: number
25 contextAges: ContextAge[]
26 touched: Touched[]
27 lastPromptAt: number
28 needsReread: boolean
29 clearArmed: boolean
30 lastPrompt: string
31 chipNow: ChipState | null
32 snapshot: SyncSnapshot | null
33 turnWrote: boolean
34 earlier: { prompt: string; answer: string } | null
35 openDecisions: { text: string; age: number }[]
36 lastCheckAt: number
37 seenChanges: string[]
38 briefPending: boolean
39 }
40 }
41}
42