SLOPSHOPPER

karea

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…

newpanebandspinnerguardcommand
v0.4.2MITupdated 2026-10-12starecz/karea-agentic/plugins/karea
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · karea
│ ┃ karea-task ✕ › fix the failing auth test and add an audit log call │ ┃ No active Karea task. Run /ksync pick KA123. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ksync │ ⎿ karea: No active Karea task. Run /ksync pick KA123. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ○ no Karea task · /ksync pick

Draws

Pane · karea-task
No active Karea task. Run /ksync pick KA123.
Prompt hint
⟨Claude Code's own drawing⟩ ○ no Karea task · /ksync pick
README

karea-agentic

Karea for Claude Code: mods, skills and plugins.

Install

/plugin marketplace add starecz/karea-agentic
/plugin install karea@karea-agentic

Plugins

PluginWhat it does
kareaShows 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.

How karea works

  • Active task. /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.
  • Changed in Karea. While you are active it asks Karea what changed on the task since its last check (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.
  • Open subtasks. If closing the task is refused because it has open subtasks, you are asked whether to close them too, and Claude gets your answer.

Develop

claude plugin validate plugins/karea
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/karea

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 1010 lines
1import { 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}
1010
hooks/rules.ts 676 lines
1import 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}
676
types/index.d.ts 42 lines
1export 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