@andypai/agent-kanban 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,18 +62,19 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
62
62
 
63
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
64
64
 
65
- | Variable | Default | Description |
66
- | ------------------ | ------------- | ------------------------------------------------------------------------ |
67
- | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
- | `LINEAR_API_KEY` | | Required when `KANBAN_PROVIDER=linear` |
70
- | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
71
- | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
72
- | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
73
- | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
74
- | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
75
- | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
76
- | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
65
+ | Variable | Default | Description |
66
+ | ------------------------- | ------------- | ------------------------------------------------------------------------ |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
+ | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
70
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
71
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
72
+ | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
73
+ | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
74
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
75
+ | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
76
+ | `JIRA_BOARD_ID` | | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
77
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
77
78
 
78
79
  Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
79
80
 
@@ -127,8 +128,8 @@ surface.
127
128
 
128
129
  Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
129
130
 
130
- Task comments are currently exposed through the REST API and dashboard task
131
- detail flows rather than dedicated `kanban comment ...` CLI commands.
131
+ Task comments are exposed through the CLI, REST API, MCP, and dashboard task
132
+ detail flows.
132
133
 
133
134
  In Linear and Jira modes, webhooks update the cache immediately when configured,
134
135
  and the normal poll loop still runs as a fallback so missed deliveries and
@@ -192,6 +193,14 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
192
193
  | `--project <name>` | New project |
193
194
  | `-m <json>` | New metadata |
194
195
 
196
+ ### comment
197
+
198
+ | Command | Description |
199
+ | ----------------------------------------------------- | --------------------- |
200
+ | `kanban comment list <task-id>` | List task comments |
201
+ | `kanban comment add <task-id> <body>` | Create a task comment |
202
+ | `kanban comment update <task-id> <comment-id> <body>` | Update a task comment |
203
+
195
204
  ### column
196
205
 
197
206
  | Command | Description |
@@ -293,8 +302,9 @@ Starts a Bun HTTP server with:
293
302
  - **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
294
303
 
295
304
  In `serve` mode, remote providers now warm once on startup and continue syncing
296
- in the background every 30 seconds. Full reconciliation is still handled by the
297
- provider-specific logic on top of that steady cadence.
305
+ in the background every `KANBAN_SYNC_INTERVAL_MS` milliseconds. Full
306
+ reconciliation is still handled by the provider-specific logic on top of that
307
+ steady cadence.
298
308
 
299
309
  Comment routes:
300
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
5
5
  "homepage": "https://github.com/abpai/agent-kanban#readme",
6
6
  "repository": {
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
7
- import type { Task } from '../types'
7
+ import type { Task, TaskComment } from '../types'
8
8
  import { parseServeArgs, run } from '../index'
9
9
 
10
10
  async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
@@ -234,6 +234,28 @@ describe('run', () => {
234
234
  })
235
235
  })
236
236
 
237
+ test('runs comment commands through the real CLI path', async () => {
238
+ await withTempDb(async (dbPath) => {
239
+ const task = expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'Comment target']))
240
+
241
+ const created = expectOk<TaskComment>(
242
+ await run(['--db', dbPath, 'comment', 'add', task.id, 'hello', 'from', 'cli']),
243
+ )
244
+ expect(created.task_id).toBe(task.id)
245
+ expect(created.body).toBe('hello from cli')
246
+
247
+ const listed = expectOk<TaskComment[]>(
248
+ await run(['--db', dbPath, 'comment', 'list', task.id]),
249
+ )
250
+ expect(listed.map((comment) => comment.id)).toEqual([created.id])
251
+
252
+ const updated = expectOk<TaskComment>(
253
+ await run(['--db', dbPath, 'comment', 'update', task.id, created.id, 'edited', 'comment']),
254
+ )
255
+ expect(updated.body).toBe('edited comment')
256
+ })
257
+ })
258
+
237
259
  test('raises CLI errors for missing task arguments', async () => {
238
260
  await withTempDb(async (dbPath) => {
239
261
  const missingTitle = await expectKanbanError(
@@ -249,4 +271,28 @@ describe('run', () => {
249
271
  expect(missingId.message).toContain('Task ID is required')
250
272
  })
251
273
  })
274
+
275
+ test('raises CLI errors for missing comment arguments', async () => {
276
+ await withTempDb(async (dbPath) => {
277
+ const missingListId = await expectKanbanError(
278
+ run(['--db', dbPath, 'comment', 'list']),
279
+ ErrorCode.MISSING_ARGUMENT,
280
+ )
281
+ expect(missingListId.message).toContain('Task ID is required')
282
+
283
+ const missingAddBody = await expectKanbanError(
284
+ run(['--db', dbPath, 'comment', 'add', 't_1']),
285
+ ErrorCode.MISSING_ARGUMENT,
286
+ )
287
+ expect(missingAddBody.message).toContain('kanban comment add <task-id> <body>')
288
+
289
+ const missingUpdateBody = await expectKanbanError(
290
+ run(['--db', dbPath, 'comment', 'update', 't_1', 'c_1']),
291
+ ErrorCode.MISSING_ARGUMENT,
292
+ )
293
+ expect(missingUpdateBody.message).toContain(
294
+ 'kanban comment update <task-id> <comment-id> <body>',
295
+ )
296
+ })
297
+ })
252
298
  })
@@ -5,6 +5,7 @@ import {
5
5
  type AdfBulletListNode,
6
6
  type AdfCodeBlockNode,
7
7
  type AdfDocument,
8
+ type AdfExpandNode,
8
9
  type AdfInlineNode,
9
10
  type AdfParagraphNode,
10
11
  } from '../providers/jira-adf'
@@ -432,3 +433,141 @@ describe('plainTextToAdf / adfToPlainText', () => {
432
433
  expect(adfToPlainText(doc)).toBe(input)
433
434
  })
434
435
  })
436
+
437
+ describe('jira-adf expand block', () => {
438
+ test('write: expand wrapping a fenced code block produces nested ADF', () => {
439
+ const input =
440
+ '::: expand title="garage-baton (machine-readable)"\n' +
441
+ '```garage-baton\n' +
442
+ '{"v":1}\n' +
443
+ '```\n' +
444
+ ':::'
445
+ const doc = plainTextToAdf(input)
446
+ expect(doc.content).toHaveLength(1)
447
+ const expand = doc.content[0] as AdfExpandNode
448
+ expect(expand.type).toBe('expand')
449
+ expect(expand.attrs?.title).toBe('garage-baton (machine-readable)')
450
+ expect(expand.content).toHaveLength(1)
451
+ const code = expand.content[0] as AdfCodeBlockNode
452
+ expect(code.type).toBe('codeBlock')
453
+ expect(code.attrs?.language).toBe('garage-baton')
454
+ expect(code.content?.[0]).toEqual({ type: 'text', text: '{"v":1}' })
455
+ })
456
+
457
+ test('read: ADF expand wrapping a codeBlock renders back to the wire format', () => {
458
+ const doc: AdfDocument = {
459
+ version: 1,
460
+ type: 'doc',
461
+ content: [
462
+ {
463
+ type: 'expand',
464
+ attrs: { title: 'garage-baton (machine-readable)' },
465
+ content: [
466
+ {
467
+ type: 'codeBlock',
468
+ attrs: { language: 'garage-baton' },
469
+ content: [{ type: 'text', text: '{"v":1}' }],
470
+ },
471
+ ],
472
+ },
473
+ ],
474
+ }
475
+ expect(adfToPlainText(doc)).toBe(
476
+ '::: expand title="garage-baton (machine-readable)"\n' +
477
+ '```garage-baton\n' +
478
+ '{"v":1}\n' +
479
+ '```\n' +
480
+ ':::',
481
+ )
482
+ })
483
+
484
+ test('write: bare ::: expand emits empty title', () => {
485
+ const input = '::: expand\nbody paragraph\n:::'
486
+ const doc = plainTextToAdf(input)
487
+ const expand = doc.content[0] as AdfExpandNode
488
+ expect(expand.type).toBe('expand')
489
+ expect(expand.attrs?.title).toBe('')
490
+ expect(expand.content[0]?.type).toBe('paragraph')
491
+ })
492
+
493
+ test('read: bare ::: expand renders without title attribute', () => {
494
+ const doc: AdfDocument = {
495
+ version: 1,
496
+ type: 'doc',
497
+ content: [
498
+ {
499
+ type: 'expand',
500
+ attrs: { title: '' },
501
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
502
+ },
503
+ ],
504
+ }
505
+ expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
506
+ })
507
+
508
+ test('read: expand without attrs renders as a bare expand block', () => {
509
+ const doc: AdfDocument = {
510
+ version: 1,
511
+ type: 'doc',
512
+ content: [
513
+ {
514
+ type: 'expand',
515
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
516
+ },
517
+ ],
518
+ }
519
+ expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
520
+ })
521
+
522
+ test('write: unterminated ::: expand falls through to paragraph', () => {
523
+ const input = '::: expand title="oops"\nstray text without close'
524
+ const doc = plainTextToAdf(input)
525
+ // Both lines collapse into a single paragraph; no expand node.
526
+ expect(doc.content.every((b) => b.type !== 'expand')).toBe(true)
527
+ expect(doc.content[0]?.type).toBe('paragraph')
528
+ })
529
+
530
+ test('round-trip: typed-comment-shaped string is byte-stable', () => {
531
+ const input =
532
+ 'garage-triage: **Accepted** — abpai/garage-band\n' +
533
+ '\n' +
534
+ 'Increment current_count by 1.\n' +
535
+ '\n' +
536
+ '**Next:** Handing off to planner.\n' +
537
+ '\n' +
538
+ '::: expand title="garage-baton (machine-readable)"\n' +
539
+ '```garage-baton\n' +
540
+ '{"v":1,"accepted":true}\n' +
541
+ '```\n' +
542
+ ':::'
543
+ expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
544
+ })
545
+
546
+ test('round-trip: nested paragraph and bullet list inside expand survive', () => {
547
+ const input =
548
+ '::: expand title="details"\n' +
549
+ 'first paragraph\n' +
550
+ '\n' +
551
+ '- bullet one\n' +
552
+ '- bullet two\n' +
553
+ ':::'
554
+ expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
555
+ })
556
+
557
+ test('write: expand title preserves escaped quote', () => {
558
+ const input = '::: expand title="quoted \\"title\\""\nx\n:::'
559
+ const doc = plainTextToAdf(input)
560
+ const expand = doc.content[0] as AdfExpandNode
561
+ expect(expand.attrs?.title).toBe('quoted "title"')
562
+ // Round-trip emits the same escaping.
563
+ expect(adfToPlainText(doc)).toBe(input)
564
+ })
565
+
566
+ test('write: text followed by ::: expand on the next line is two blocks', () => {
567
+ const input = 'leading paragraph\n' + '::: expand title="x"\n' + 'inside\n' + ':::'
568
+ const doc = plainTextToAdf(input)
569
+ expect(doc.content).toHaveLength(2)
570
+ expect(doc.content[0]?.type).toBe('paragraph')
571
+ expect(doc.content[1]?.type).toBe('expand')
572
+ })
573
+ })
@@ -240,6 +240,28 @@ describe('JiraProvider read path', () => {
240
240
  }
241
241
  })
242
242
 
243
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
244
+ const { fn, calls } = jiraFetchStub(standardRoutes({}))
245
+ globalThis.fetch = fn
246
+ const client = new JiraClient({
247
+ baseUrl: baseConfig.baseUrl,
248
+ email: baseConfig.email,
249
+ apiToken: baseConfig.apiToken,
250
+ })
251
+ const provider = new JiraProvider(db, { ...baseConfig, pollingSyncIntervalMs: 5_000 }, client)
252
+ saveJiraSyncMeta(db, {
253
+ projectKey: 'ENG',
254
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
255
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
256
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
257
+ })
258
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
259
+
260
+ await provider.listTasks()
261
+
262
+ expect(calls.some((call) => call.url.includes('/rest/api/3/search/jql'))).toBe(true)
263
+ })
264
+
243
265
  test('sync delta JQL is exactly project = KEY AND updated >= "<ts>" ORDER BY updated ASC', async () => {
244
266
  const capturedJql: string[] = []
245
267
  // First sync: one issue returned so lastIssueUpdatedAt is set.
@@ -485,4 +485,81 @@ describe('LinearProvider sync', () => {
485
485
 
486
486
  expect(issueQueries).toBe(1)
487
487
  })
488
+
489
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
490
+ let issueQueries = 0
491
+
492
+ saveSyncMeta(db, {
493
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
494
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
495
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
496
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
497
+ })
498
+
499
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
500
+ const body = JSON.parse(String(init?.body)) as {
501
+ query: string
502
+ variables: Record<string, unknown>
503
+ }
504
+
505
+ if (body.query.includes('query TeamSnapshot')) {
506
+ return new Response(
507
+ JSON.stringify({
508
+ data: {
509
+ team: {
510
+ id: 'team-1',
511
+ key: 'R2P',
512
+ name: 'R2pi',
513
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
514
+ },
515
+ },
516
+ }),
517
+ { status: 200, headers: { 'content-type': 'application/json' } },
518
+ )
519
+ }
520
+
521
+ if (body.query.includes('query Users')) {
522
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
523
+ status: 200,
524
+ headers: { 'content-type': 'application/json' },
525
+ })
526
+ }
527
+
528
+ if (body.query.includes('query Projects')) {
529
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
530
+ status: 200,
531
+ headers: { 'content-type': 'application/json' },
532
+ })
533
+ }
534
+
535
+ if (body.query.includes('query Issues')) {
536
+ issueQueries += 1
537
+ return new Response(
538
+ JSON.stringify({
539
+ data: {
540
+ issues: {
541
+ nodes: [],
542
+ pageInfo: { hasNextPage: false, endCursor: null },
543
+ },
544
+ },
545
+ }),
546
+ { status: 200, headers: { 'content-type': 'application/json' } },
547
+ )
548
+ }
549
+
550
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
551
+ }) as unknown as typeof fetch
552
+
553
+ const originalDateNow = Date.now
554
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
555
+
556
+ try {
557
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test', 5_000)
558
+ await provider.getBoard()
559
+ } finally {
560
+ Date.now = originalDateNow
561
+ }
562
+
563
+ expect(issueQueries).toBe(1)
564
+ })
488
565
  })
@@ -36,4 +36,21 @@ describe('formatOutput', () => {
36
36
  const output = formatOutput(result, true)
37
37
  expect(output).toBe('Board initialized.')
38
38
  })
39
+
40
+ test('formats comments in pretty mode', () => {
41
+ const output = formatOutput(
42
+ success([
43
+ {
44
+ id: 'c_1',
45
+ task_id: 't_1',
46
+ body: 'hello from cli',
47
+ author: 'alice',
48
+ created_at: '2026-04-26T00:00:00.000Z',
49
+ updated_at: '2026-04-26T00:00:00.000Z',
50
+ },
51
+ ]),
52
+ true,
53
+ )
54
+ expect(output).toBe(' c_1 @alice hello from cli')
55
+ })
39
56
  })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
+ import {
4
+ DEFAULT_POLLING_SYNC_INTERVAL_MS,
5
+ MIN_POLLING_SYNC_INTERVAL_MS,
6
+ resolvePollingSyncIntervalMs,
7
+ } from '../sync-config'
8
+
9
+ describe('sync config', () => {
10
+ test('defaults when unset or blank', () => {
11
+ expect(resolvePollingSyncIntervalMs(undefined)).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
12
+ expect(resolvePollingSyncIntervalMs(' ')).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
13
+ })
14
+
15
+ test('accepts an integer millisecond interval', () => {
16
+ expect(resolvePollingSyncIntervalMs('5000')).toBe(5_000)
17
+ })
18
+
19
+ test('rejects invalid or too-aggressive intervals', () => {
20
+ for (const raw of ['999', '5s', '1000.5', '0']) {
21
+ let err: unknown
22
+ try {
23
+ resolvePollingSyncIntervalMs(raw)
24
+ } catch (caught) {
25
+ err = caught
26
+ }
27
+ expect(err).toBeInstanceOf(KanbanError)
28
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_CONFIG)
29
+ expect((err as Error).message).toContain(String(MIN_POLLING_SYNC_INTERVAL_MS))
30
+ }
31
+ })
32
+ })
package/src/errors.ts CHANGED
@@ -9,6 +9,7 @@ export const ErrorCode = {
9
9
  INVALID_PRIORITY: 'INVALID_PRIORITY',
10
10
  INVALID_METADATA: 'INVALID_METADATA',
11
11
  INVALID_POSITION: 'INVALID_POSITION',
12
+ INVALID_CONFIG: 'INVALID_CONFIG',
12
13
  CONFLICT: 'CONFLICT',
13
14
  MISSING_ARGUMENT: 'MISSING_ARGUMENT',
14
15
  UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
package/src/index.ts CHANGED
@@ -139,6 +139,45 @@ async function routeTask(
139
139
  }
140
140
  }
141
141
 
142
+ async function routeComment(
143
+ provider: ReturnType<typeof createProvider>,
144
+ action: string | undefined,
145
+ positionals: string[],
146
+ ): Promise<CliOutput> {
147
+ switch (action) {
148
+ case 'list': {
149
+ const id = positionals[2]
150
+ if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
151
+ return success(await provider.listComments(id))
152
+ }
153
+ case 'add': {
154
+ const id = positionals[2]
155
+ const body = positionals.slice(3).join(' ')
156
+ if (!id || !body) {
157
+ throw new KanbanError(
158
+ ErrorCode.MISSING_ARGUMENT,
159
+ 'Usage: kanban comment add <task-id> <body>',
160
+ )
161
+ }
162
+ return success(await provider.comment(id, body))
163
+ }
164
+ case 'update': {
165
+ const id = positionals[2]
166
+ const commentId = positionals[3]
167
+ const body = positionals.slice(4).join(' ')
168
+ if (!id || !commentId || !body) {
169
+ throw new KanbanError(
170
+ ErrorCode.MISSING_ARGUMENT,
171
+ 'Usage: kanban comment update <task-id> <comment-id> <body>',
172
+ )
173
+ }
174
+ return success(await provider.updateComment(id, commentId, body))
175
+ }
176
+ default:
177
+ throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown comment command '${action}'`)
178
+ }
179
+ }
180
+
142
181
  function routeColumn(
143
182
  db: Database,
144
183
  providerType: string,
@@ -298,6 +337,9 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
298
337
  case 'task':
299
338
  output = await routeTask(provider, action, positionals, values)
300
339
  break
340
+ case 'comment':
341
+ output = await routeComment(provider, action, positionals)
342
+ break
301
343
  case 'column':
302
344
  output = routeColumn(db, provider.type, action, positionals, values)
303
345
  break
@@ -335,6 +377,11 @@ Commands:
335
377
  task assign <id> <user> Assign task
336
378
  task prioritize <id> <lvl> Set priority
337
379
 
380
+ comment list <task-id> List comments on a task
381
+ comment add <task-id> <body> Create a comment
382
+ comment update <task-id> <comment-id> <body>
383
+ Update a comment
384
+
338
385
  column add <name> Add column [--position n] [--color hex]
339
386
  column list List columns
340
387
  column rename <id> <name> Rename column
package/src/mcp/errors.ts CHANGED
@@ -39,6 +39,7 @@ function providerError(code: ErrorCodeValue): TrackerMcpErrorCode {
39
39
  case ErrorCode.INVALID_METADATA:
40
40
  case ErrorCode.INVALID_POSITION:
41
41
  case ErrorCode.INVALID_PRIORITY:
42
+ case ErrorCode.INVALID_CONFIG:
42
43
  case ErrorCode.MISSING_ARGUMENT:
43
44
  case ErrorCode.UNSUPPORTED_OPERATION:
44
45
  case ErrorCode.CONFLICT:
package/src/output.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CliOutput, BoardView, Task, TaskWithColumn, Column } from './types'
1
+ import type { CliOutput, BoardView, Task, TaskWithColumn, TaskComment, Column } from './types'
2
2
 
3
3
  export function success<T>(data: T): CliOutput<T> {
4
4
  return { ok: true, data }
@@ -25,12 +25,16 @@ function formatPrettyData(data: unknown): string {
25
25
  if (Array.isArray(data)) {
26
26
  if (data.length === 0) return 'No items found.'
27
27
  if ('column_id' in data[0]) return data.map(formatTaskLine).join('\n')
28
+ if ('task_id' in data[0]) return data.map(formatCommentLine).join('\n')
28
29
  if ('position' in data[0]) return data.map(formatColumnLine).join('\n')
29
30
  return JSON.stringify(data, null, 2)
30
31
  }
31
32
  if (data && typeof data === 'object' && 'column_id' in data) {
32
33
  return formatTaskDetail(data as TaskWithColumn)
33
34
  }
35
+ if (data && typeof data === 'object' && 'task_id' in data) {
36
+ return formatCommentDetail(data as TaskComment)
37
+ }
34
38
  if (data && typeof data === 'object' && 'moved' in data) {
35
39
  return `Moved ${(data as { moved: number }).moved} task(s).`
36
40
  }
@@ -79,6 +83,23 @@ function formatTaskDetail(task: Task): string {
79
83
  return lines.join('\n')
80
84
  }
81
85
 
86
+ function formatCommentLine(comment: TaskComment): string {
87
+ const author = comment.author ? ` @${comment.author}` : ''
88
+ return ` ${comment.id}${author} ${comment.body}`
89
+ }
90
+
91
+ function formatCommentDetail(comment: TaskComment): string {
92
+ const lines = [
93
+ `Comment: ${comment.id}`,
94
+ `Task: ${comment.task_id}`,
95
+ ...(comment.author ? [`Author: ${comment.author}`] : []),
96
+ `Body: ${comment.body}`,
97
+ `Created: ${comment.created_at}`,
98
+ `Updated: ${comment.updated_at}`,
99
+ ]
100
+ return lines.join('\n')
101
+ }
102
+
82
103
  function formatColumnLine(col: Column): string {
83
104
  const color = col.color ? ` (${col.color})` : ''
84
105
  return ` ${col.position}. ${col.name}${color} [${col.id}]`
@@ -5,6 +5,7 @@ import { JiraProvider } from './jira'
5
5
  import { LinearProvider } from './linear'
6
6
  import { LocalProvider } from './local'
7
7
  import type { KanbanProvider } from './types'
8
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
8
9
 
9
10
  export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
10
11
  const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
@@ -17,7 +18,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
17
18
  'LINEAR_API_KEY and LINEAR_TEAM_ID are required when KANBAN_PROVIDER=linear',
18
19
  )
19
20
  }
20
- return new LinearProvider(db, teamId!, apiKey!)
21
+ return new LinearProvider(db, teamId!, apiKey!, resolvePollingSyncIntervalMs())
21
22
  }
22
23
 
23
24
  if (providerType === 'jira') {
@@ -45,6 +46,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
45
46
  projectKey: projectKey!,
46
47
  boardId: Number.isFinite(boardId) ? boardId : undefined,
47
48
  defaultIssueType,
49
+ pollingSyncIntervalMs: resolvePollingSyncIntervalMs(),
48
50
  })
49
51
  }
50
52
 
@@ -68,6 +68,12 @@ export interface AdfHeadingNode {
68
68
  content?: AdfInlineNode[]
69
69
  }
70
70
 
71
+ export interface AdfExpandNode {
72
+ type: 'expand'
73
+ attrs?: { title?: string }
74
+ content: AdfBlockNode[]
75
+ }
76
+
71
77
  export interface AdfUnknownBlockNode {
72
78
  type: string
73
79
  [key: string]: unknown
@@ -79,6 +85,7 @@ export type AdfBlockNode =
79
85
  | AdfOrderedListNode
80
86
  | AdfCodeBlockNode
81
87
  | AdfHeadingNode
88
+ | AdfExpandNode
82
89
  | AdfUnknownBlockNode
83
90
 
84
91
  // Public AdfNode union covers every node shape this module recognizes.
@@ -90,6 +97,18 @@ const ORDERED_MARKER = /^(\d+)\. (.*)$/
90
97
  // no whitespace before it. Fence must occupy the whole line.
91
98
  const FENCE_OPEN = /^```([^\s`]*)$/
92
99
  const FENCE_CLOSE = /^```$/
100
+ // Expand wrapper: `::: expand` or `::: expand title="..."`. Quotes and
101
+ // backslashes in the title are escaped with a leading `\`.
102
+ const EXPAND_OPEN = /^::: expand(?: title="((?:\\.|[^"\\])*)")?$/
103
+ const EXPAND_CLOSE = /^:::$/
104
+
105
+ function unescapeTitle(raw: string): string {
106
+ return raw.replace(/\\(.)/g, '$1')
107
+ }
108
+
109
+ function escapeTitle(value: string): string {
110
+ return value.replace(/(["\\])/g, '\\$1')
111
+ }
93
112
 
94
113
  function paragraphFromText(text: string): AdfParagraphNode {
95
114
  if (text.length === 0) {
@@ -145,13 +164,26 @@ export function plainTextToAdf(text: string): AdfDocument {
145
164
  if (text.length === 0) {
146
165
  return { version: 1, type: 'doc', content: [] }
147
166
  }
148
-
149
167
  const lines = text.split('\n')
150
- const blocks: AdfBlockNode[] = []
168
+ const { blocks } = parseBlocks(lines, 0, () => false)
169
+ return { version: 1, type: 'doc', content: blocks }
170
+ }
171
+
172
+ interface ParseResult {
173
+ blocks: AdfBlockNode[]
174
+ // Index just past the last consumed line. When `stop` matches, this points
175
+ // at the stop line itself (the caller is responsible for stepping past it).
176
+ endIndex: number
177
+ }
151
178
 
152
- let i = 0
179
+ function parseBlocks(lines: string[], start: number, stop: (line: string) => boolean): ParseResult {
180
+ const blocks: AdfBlockNode[] = []
181
+ let i = start
153
182
  while (i < lines.length) {
154
183
  const line = lines[i] ?? ''
184
+ if (stop(line)) {
185
+ return { blocks, endIndex: i }
186
+ }
155
187
 
156
188
  // Blank line separates blocks — consume and move on.
157
189
  if (line === '') {
@@ -159,6 +191,19 @@ export function plainTextToAdf(text: string): AdfDocument {
159
191
  continue
160
192
  }
161
193
 
194
+ // Expand wrapper.
195
+ const expandOpen = line.match(EXPAND_OPEN)
196
+ if (expandOpen) {
197
+ const title = unescapeTitle(expandOpen[1] ?? '')
198
+ const inner = parseBlocks(lines, i + 1, (l) => EXPAND_CLOSE.test(l))
199
+ if (inner.endIndex < lines.length) {
200
+ blocks.push({ type: 'expand', attrs: { title }, content: inner.blocks })
201
+ i = inner.endIndex + 1
202
+ continue
203
+ }
204
+ // Unterminated `::: expand` — fall through to paragraph.
205
+ }
206
+
162
207
  // Fenced code block.
163
208
  const fenceOpen = line.match(FENCE_OPEN)
164
209
  if (fenceOpen) {
@@ -227,16 +272,19 @@ export function plainTextToAdf(text: string): AdfDocument {
227
272
  continue
228
273
  }
229
274
 
230
- // Paragraph: consume until blank line or a block-starting line.
275
+ // Paragraph: consume until blank line, stop predicate, or a
276
+ // block-starting line.
231
277
  const paragraphLines: string[] = [line]
232
278
  i += 1
233
279
  while (i < lines.length) {
234
280
  const current = lines[i] ?? ''
235
281
  if (
236
282
  current === '' ||
283
+ stop(current) ||
237
284
  BULLET_MARKER.test(current) ||
238
285
  ORDERED_MARKER.test(current) ||
239
- FENCE_OPEN.test(current)
286
+ FENCE_OPEN.test(current) ||
287
+ EXPAND_OPEN.test(current)
240
288
  ) {
241
289
  break
242
290
  }
@@ -246,7 +294,7 @@ export function plainTextToAdf(text: string): AdfDocument {
246
294
  blocks.push(paragraphFromText(paragraphLines.join('\n')))
247
295
  }
248
296
 
249
- return { version: 1, type: 'doc', content: blocks }
297
+ return { blocks, endIndex: i }
250
298
  }
251
299
 
252
300
  function inlineText(
@@ -357,6 +405,14 @@ function renderBlock(node: AdfBlockNode): string | null {
357
405
  }
358
406
  case 'heading':
359
407
  return inlineText((node as AdfHeadingNode).content)
408
+ case 'expand': {
409
+ const expand = node as AdfExpandNode
410
+ const title = expand.attrs?.title ?? ''
411
+ const open = title.length > 0 ? `::: expand title="${escapeTitle(title)}"` : '::: expand'
412
+ const inner = renderBlocks(expand.content)
413
+ const body = inner.length > 0 ? `${open}\n${inner}\n:::` : `${open}\n:::`
414
+ return body
415
+ }
360
416
  case 'blockCard':
361
417
  case 'embedCard': {
362
418
  const url = readCardUrl(node)
@@ -368,12 +424,16 @@ function renderBlock(node: AdfBlockNode): string | null {
368
424
  }
369
425
  }
370
426
 
371
- export function adfToPlainText(doc: AdfDocument): string {
372
- const rendered: string[] = []
373
- for (const block of doc.content) {
427
+ function renderBlocks(nodes: AdfBlockNode[]): string {
428
+ const out: string[] = []
429
+ for (const block of nodes) {
374
430
  const text = renderBlock(block)
375
431
  if (text === null) continue
376
- rendered.push(text)
432
+ out.push(text)
377
433
  }
378
- return rendered.join('\n\n')
434
+ return out.join('\n\n')
435
+ }
436
+
437
+ export function adfToPlainText(doc: AdfDocument): string {
438
+ return renderBlocks(doc.content)
379
439
  }
@@ -49,8 +49,8 @@ import type {
49
49
  TaskListFilters,
50
50
  UpdateTaskInput,
51
51
  } from './types'
52
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
52
53
 
53
- const SYNC_INTERVAL_MS = 30_000
54
54
  const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
55
55
 
56
56
  function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
@@ -78,11 +78,13 @@ export interface JiraProviderConfig {
78
78
  projectKey: string
79
79
  boardId?: number
80
80
  defaultIssueType?: string
81
+ pollingSyncIntervalMs?: number
81
82
  }
82
83
 
83
84
  export class JiraProvider implements KanbanProvider {
84
85
  readonly type = 'jira' as const
85
86
  private readonly client: JiraClient
87
+ private readonly pollingSyncIntervalMs: number
86
88
 
87
89
  constructor(
88
90
  private readonly db: Database,
@@ -90,6 +92,7 @@ export class JiraProvider implements KanbanProvider {
90
92
  client?: JiraClient,
91
93
  ) {
92
94
  initJiraCacheSchema(db)
95
+ this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? resolvePollingSyncIntervalMs()
93
96
  this.client =
94
97
  client ??
95
98
  new JiraClient({
@@ -103,7 +106,7 @@ export class JiraProvider implements KanbanProvider {
103
106
  const meta = loadJiraSyncMeta(this.db)
104
107
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
105
108
  const now = Date.now()
106
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
107
110
  const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
108
111
 
109
112
  // 1. Resolve project.
@@ -41,8 +41,8 @@ import type {
41
41
  TaskListFilters,
42
42
  UpdateTaskInput,
43
43
  } from './types'
44
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
44
45
 
45
- const SYNC_INTERVAL_MS = 30_000
46
46
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
47
47
 
48
48
  function parseTimestamp(value: string | null | undefined): number {
@@ -81,6 +81,7 @@ export class LinearProvider implements KanbanProvider {
81
81
  private readonly db: Database,
82
82
  private readonly teamId: string,
83
83
  apiKey: string,
84
+ private readonly pollingSyncIntervalMs = resolvePollingSyncIntervalMs(),
84
85
  ) {
85
86
  initLinearCacheSchema(db)
86
87
  this.client = new LinearClient(apiKey)
@@ -105,7 +106,7 @@ export class LinearProvider implements KanbanProvider {
105
106
  const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
106
107
  const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
107
108
  const now = Date.now()
108
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
109
110
 
110
111
  const shouldFullSync =
111
112
  force ||
package/src/server.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
  import { handleRequest } from './api'
4
4
  import type { ServerWebSocket } from 'bun'
5
5
  import type { KanbanProvider } from './providers/types'
6
+ import { resolvePollingSyncIntervalMs } from './sync-config'
6
7
 
7
8
  const wsClients = new Set<ServerWebSocket<unknown>>()
8
9
  const CORS_HEADERS = {
@@ -10,8 +11,6 @@ const CORS_HEADERS = {
10
11
  'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
11
12
  'Access-Control-Allow-Headers': 'Content-Type',
12
13
  }
13
- const DEFAULT_BACKGROUND_SYNC_INTERVAL_MS = 30_000
14
-
15
14
  interface BackgroundSyncState {
16
15
  enabled: boolean
17
16
  inFlight: boolean
@@ -64,7 +63,7 @@ export function startServer(
64
63
  ): StartedServer {
65
64
  const distDir = join(import.meta.dir, '..', 'ui', 'dist')
66
65
  const hasStatic = existsSync(distDir)
67
- const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_BACKGROUND_SYNC_INTERVAL_MS
66
+ const syncIntervalMs = opts.syncIntervalMs ?? resolvePollingSyncIntervalMs()
68
67
  const syncCache = provider.syncCache?.bind(provider)
69
68
  const getSyncStatus = provider.getSyncStatus?.bind(provider)
70
69
  const backgroundSync: BackgroundSyncState = {
@@ -0,0 +1,18 @@
1
+ import { ErrorCode, KanbanError } from './errors'
2
+
3
+ export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
4
+ export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
5
+
6
+ export function resolvePollingSyncIntervalMs(raw = process.env['KANBAN_SYNC_INTERVAL_MS']): number {
7
+ const value = raw?.trim()
8
+ if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
9
+
10
+ const parsed = Number(value)
11
+ if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
12
+ throw new KanbanError(
13
+ ErrorCode.INVALID_CONFIG,
14
+ `KANBAN_SYNC_INTERVAL_MS must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
15
+ )
16
+ }
17
+ return parsed
18
+ }