@andypai/agent-kanban 0.3.1 → 0.3.3

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
@@ -127,8 +127,8 @@ surface.
127
127
 
128
128
  Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
129
129
 
130
- Task comments are currently exposed through the REST API and dashboard task
131
- detail flows rather than dedicated `kanban comment ...` CLI commands.
130
+ Task comments are exposed through the CLI, REST API, MCP, and dashboard task
131
+ detail flows.
132
132
 
133
133
  In Linear and Jira modes, webhooks update the cache immediately when configured,
134
134
  and the normal poll loop still runs as a fallback so missed deliveries and
@@ -192,6 +192,14 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
192
192
  | `--project <name>` | New project |
193
193
  | `-m <json>` | New metadata |
194
194
 
195
+ ### comment
196
+
197
+ | Command | Description |
198
+ | ----------------------------------------------------- | --------------------- |
199
+ | `kanban comment list <task-id>` | List task comments |
200
+ | `kanban comment add <task-id> <body>` | Create a task comment |
201
+ | `kanban comment update <task-id> <comment-id> <body>` | Update a task comment |
202
+
195
203
  ### column
196
204
 
197
205
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
  })
@@ -1,5 +1,32 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { adfToPlainText, plainTextToAdf, type AdfDocument } from '../providers/jira-adf'
2
+ import {
3
+ adfToPlainText,
4
+ plainTextToAdf,
5
+ type AdfBulletListNode,
6
+ type AdfCodeBlockNode,
7
+ type AdfDocument,
8
+ type AdfExpandNode,
9
+ type AdfInlineNode,
10
+ type AdfParagraphNode,
11
+ } from '../providers/jira-adf'
12
+
13
+ function firstParagraphContent(doc: AdfDocument): AdfInlineNode[] {
14
+ const paragraph = doc.content[0] as AdfParagraphNode
15
+ expect(paragraph.type).toBe('paragraph')
16
+ return paragraph.content ?? []
17
+ }
18
+
19
+ function firstCodeBlock(doc: AdfDocument): AdfCodeBlockNode {
20
+ const code = doc.content[0] as AdfCodeBlockNode
21
+ expect(code.type).toBe('codeBlock')
22
+ return code
23
+ }
24
+
25
+ function firstBulletList(doc: AdfDocument): AdfBulletListNode {
26
+ const list = doc.content[0] as AdfBulletListNode
27
+ expect(list.type).toBe('bulletList')
28
+ return list
29
+ }
3
30
 
4
31
  describe('plainTextToAdf / adfToPlainText', () => {
5
32
  test('empty doc round-trip', () => {
@@ -154,7 +181,7 @@ describe('plainTextToAdf / adfToPlainText', () => {
154
181
  expect(adfToPlainText(doc)).toBe('before\n\nafter')
155
182
  })
156
183
 
157
- test('inline text marks are stripped on read', () => {
184
+ test('strong + link marks survive on read as markdown', () => {
158
185
  const doc: AdfDocument = {
159
186
  version: 1,
160
187
  type: 'doc',
@@ -163,12 +190,240 @@ describe('plainTextToAdf / adfToPlainText', () => {
163
190
  type: 'paragraph',
164
191
  content: [
165
192
  { type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
166
- { type: 'text', text: ' normal' },
193
+ { type: 'text', text: ' ' },
194
+ {
195
+ type: 'text',
196
+ text: 'click',
197
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
198
+ },
199
+ ],
200
+ },
201
+ ],
202
+ }
203
+ expect(adfToPlainText(doc)).toBe('**bold** [click](https://example.com)')
204
+ })
205
+
206
+ test('write path emits **bold** as a strong mark', () => {
207
+ const input = 'hello **world**'
208
+ const doc = plainTextToAdf(input)
209
+ expect(firstParagraphContent(doc)).toEqual([
210
+ { type: 'text', text: 'hello ' },
211
+ { type: 'text', text: 'world', marks: [{ type: 'strong' }] },
212
+ ])
213
+ expect(adfToPlainText(doc)).toBe(input)
214
+ })
215
+
216
+ test('write path emits [label](https://...) as a link mark', () => {
217
+ const input = 'see [docs](https://example.com/x)'
218
+ const doc = plainTextToAdf(input)
219
+ expect(firstParagraphContent(doc)).toEqual([
220
+ { type: 'text', text: 'see ' },
221
+ {
222
+ type: 'text',
223
+ text: 'docs',
224
+ marks: [{ type: 'link', attrs: { href: 'https://example.com/x' } }],
225
+ },
226
+ ])
227
+ expect(adfToPlainText(doc)).toBe(input)
228
+ })
229
+
230
+ test('write path emits side-by-side bold and link as adjacent text nodes', () => {
231
+ const input = '**PR opened** — [repo#1](https://github.com/o/r/pull/1)'
232
+ const doc = plainTextToAdf(input)
233
+ expect(firstParagraphContent(doc)).toEqual([
234
+ { type: 'text', text: 'PR opened', marks: [{ type: 'strong' }] },
235
+ { type: 'text', text: ' — ' },
236
+ {
237
+ type: 'text',
238
+ text: 'repo#1',
239
+ marks: [{ type: 'link', attrs: { href: 'https://github.com/o/r/pull/1' } }],
240
+ },
241
+ ])
242
+ expect(adfToPlainText(doc)).toBe(input)
243
+ })
244
+
245
+ test('write path bolds bullet item field-name prefix', () => {
246
+ const input = '- **Marker:** drift:HUMAN REVIEW:516652'
247
+ const doc = plainTextToAdf(input)
248
+ const list = firstBulletList(doc)
249
+ const itemParagraph = list.content[0]!.content[0] as AdfParagraphNode
250
+ const itemContent = itemParagraph.content ?? []
251
+ expect(itemContent).toEqual([
252
+ { type: 'text', text: 'Marker:', marks: [{ type: 'strong' }] },
253
+ { type: 'text', text: ' drift:HUMAN REVIEW:516652' },
254
+ ])
255
+ expect(adfToPlainText(doc)).toBe(input)
256
+ })
257
+
258
+ test('inline tokenizer does NOT run inside fenced code blocks', () => {
259
+ const input = '```ts\nconst s = "**not bold** [x](https://e.com)"\n```'
260
+ const doc = plainTextToAdf(input)
261
+ const code = firstCodeBlock(doc)
262
+ const codeContent = code.content ?? []
263
+ expect(codeContent).toHaveLength(1)
264
+ expect(codeContent[0]?.text).toBe('const s = "**not bold** [x](https://e.com)"')
265
+ expect(codeContent[0]?.marks).toBeUndefined()
266
+ expect(adfToPlainText(doc)).toBe(input)
267
+ })
268
+
269
+ test('read: code block text marks stay literal', () => {
270
+ const doc: AdfDocument = {
271
+ version: 1,
272
+ type: 'doc',
273
+ content: [
274
+ {
275
+ type: 'codeBlock',
276
+ content: [
277
+ { type: 'text', text: '**not bold**', marks: [{ type: 'strong' }] },
278
+ {
279
+ type: 'text',
280
+ text: '\nhttps://example.com',
281
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
282
+ },
283
+ ],
284
+ },
285
+ ],
286
+ }
287
+ expect(adfToPlainText(doc)).toBe('```\n**not bold**\nhttps://example.com\n```')
288
+ })
289
+
290
+ test('non-http link target is left as literal text (no link mark)', () => {
291
+ const input = 'see [docs](mailto:dev@example.com)'
292
+ const doc = plainTextToAdf(input)
293
+ expect(firstParagraphContent(doc)).toEqual([{ type: 'text', text: input }])
294
+ })
295
+
296
+ test('read: paragraph containing inlineCard emits the URL inline', () => {
297
+ const doc: AdfDocument = {
298
+ version: 1,
299
+ type: 'doc',
300
+ content: [
301
+ {
302
+ type: 'paragraph',
303
+ content: [
304
+ { type: 'text', text: 'Repo: ' },
305
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
306
+ ],
307
+ },
308
+ ],
309
+ }
310
+ expect(adfToPlainText(doc)).toBe('Repo: https://github.com/abpai/garage-band')
311
+ })
312
+
313
+ test('read: split strong field label keeps colon inside markdown', () => {
314
+ const doc: AdfDocument = {
315
+ version: 1,
316
+ type: 'doc',
317
+ content: [
318
+ {
319
+ type: 'paragraph',
320
+ content: [
321
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
322
+ { type: 'text', text: ': ' },
323
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
324
+ ],
325
+ },
326
+ ],
327
+ }
328
+ expect(adfToPlainText(doc)).toBe('**Repo:** https://github.com/abpai/garage-band')
329
+ })
330
+
331
+ test('read: blockCard renders as a standalone block with the URL', () => {
332
+ const doc: AdfDocument = {
333
+ version: 1,
334
+ type: 'doc',
335
+ content: [
336
+ { type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
337
+ { type: 'blockCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
338
+ { type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
339
+ ],
340
+ }
341
+ expect(adfToPlainText(doc)).toBe('before\n\nhttps://github.com/abpai/garage-band\n\nafter')
342
+ })
343
+
344
+ test('read: embedCard also emits its URL', () => {
345
+ const doc: AdfDocument = {
346
+ version: 1,
347
+ type: 'doc',
348
+ content: [{ type: 'embedCard', attrs: { url: 'https://example.com/embed' } }],
349
+ }
350
+ expect(adfToPlainText(doc)).toBe('https://example.com/embed')
351
+ })
352
+
353
+ test('read: DXS-12-shaped description preserves smart-link Repo URL', () => {
354
+ // Captured shape from a real Jira description where a user pasted a URL
355
+ // and Jira auto-converted it to an inlineCard. Before the fix,
356
+ // adfToPlainText returned "Repo: " with the URL silently dropped.
357
+ const doc: AdfDocument = {
358
+ version: 1,
359
+ type: 'doc',
360
+ content: [
361
+ {
362
+ type: 'paragraph',
363
+ content: [
364
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
365
+ { type: 'text', text: ': ' },
366
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
367
+ ],
368
+ },
369
+ {
370
+ type: 'paragraph',
371
+ content: [{ type: 'text', text: 'Please make one minimal change.' }],
372
+ },
373
+ {
374
+ type: 'heading',
375
+ attrs: { level: 3 },
376
+ content: [{ type: 'text', text: 'Acceptance criteria:' }],
377
+ },
378
+ {
379
+ type: 'bulletList',
380
+ content: [
381
+ {
382
+ type: 'listItem',
383
+ content: [
384
+ {
385
+ type: 'paragraph',
386
+ content: [{ type: 'text', text: 'Change only SMOKE_TEST_TASK.md.' }],
387
+ },
388
+ ],
389
+ },
390
+ {
391
+ type: 'listItem',
392
+ content: [
393
+ {
394
+ type: 'paragraph',
395
+ content: [{ type: 'text', text: 'Increment current_count by exactly one.' }],
396
+ },
397
+ ],
398
+ },
399
+ ],
400
+ },
401
+ ],
402
+ }
403
+ const out = adfToPlainText(doc)
404
+ expect(out).toContain('Repo:')
405
+ expect(out).toContain('https://github.com/abpai/garage-band')
406
+ // Repo line carries the URL — agents grepping for the URL recover it.
407
+ const repoLine = out.split('\n').find((l) => l.includes('Repo:'))
408
+ expect(repoLine).toBe('**Repo:** https://github.com/abpai/garage-band')
409
+ })
410
+
411
+ test('read: hardBreak between text runs emits newline', () => {
412
+ const doc: AdfDocument = {
413
+ version: 1,
414
+ type: 'doc',
415
+ content: [
416
+ {
417
+ type: 'paragraph',
418
+ content: [
419
+ { type: 'text', text: 'first line' },
420
+ { type: 'hardBreak' },
421
+ { type: 'text', text: 'second line' },
167
422
  ],
168
423
  },
169
424
  ],
170
425
  }
171
- expect(adfToPlainText(doc)).toBe('bold normal')
426
+ expect(adfToPlainText(doc)).toBe('first line\nsecond line')
172
427
  })
173
428
 
174
429
  test('bullet item containing digits-dot substring is still a bullet', () => {
@@ -178,3 +433,141 @@ describe('plainTextToAdf / adfToPlainText', () => {
178
433
  expect(adfToPlainText(doc)).toBe(input)
179
434
  })
180
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
+ })
@@ -67,13 +67,14 @@ function makeIssue(opts: {
67
67
  updated?: string
68
68
  summary?: string
69
69
  assignee?: { accountId: string; displayName: string } | null
70
+ description?: unknown
70
71
  }): Record<string, unknown> {
71
72
  return {
72
73
  id: opts.id,
73
74
  key: opts.key,
74
75
  fields: {
75
76
  summary: opts.summary ?? opts.key,
76
- description: null,
77
+ description: opts.description ?? null,
77
78
  status: { id: opts.statusId, name: 'Status ' + opts.statusId },
78
79
  issuetype: { id: '10000', name: 'Bug' },
79
80
  priority: { id: '2', name: 'High' },
@@ -429,6 +430,51 @@ describe('JiraProvider read path', () => {
429
430
  expect(byPrefixed.externalRef).toBe('ENG-1')
430
431
  })
431
432
 
433
+ test('description ADF with an inlineCard smart-link preserves the URL through getTask', async () => {
434
+ // Regression for the DXS-12 shape: a user pastes
435
+ // `Repo: https://github.com/abpai/garage-band` in the description, Jira
436
+ // auto-converts the URL to an inlineCard, and the previous
437
+ // adfToPlainText silently dropped attrs.url.
438
+ const description = {
439
+ version: 1,
440
+ type: 'doc',
441
+ content: [
442
+ {
443
+ type: 'paragraph',
444
+ content: [
445
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
446
+ { type: 'text', text: ': ' },
447
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
448
+ ],
449
+ },
450
+ {
451
+ type: 'paragraph',
452
+ content: [{ type: 'text', text: 'Increment SMOKE_TEST_TASK.md by 1.' }],
453
+ },
454
+ ],
455
+ }
456
+ const searchHandler: StubHandler = () =>
457
+ jsonResponse({
458
+ startAt: 0,
459
+ maxResults: 100,
460
+ total: 1,
461
+ issues: [
462
+ makeIssue({
463
+ id: '20001',
464
+ key: 'ENG-12',
465
+ statusId: '10001',
466
+ description,
467
+ }),
468
+ ],
469
+ })
470
+ const { provider } = makeProvider(standardRoutes({ searchHandler }))
471
+ await provider.getBoard()
472
+ const task = await provider.getTask('ENG-12')
473
+ expect(task.description).toContain('Repo:')
474
+ expect(task.description).toContain('https://github.com/abpai/garage-band')
475
+ expect(task.description.split('\n')[0]).toBe('**Repo:** https://github.com/abpai/garage-band')
476
+ })
477
+
432
478
  test('getBootstrap returns provider=jira and the adapted BoardConfig', async () => {
433
479
  const { provider } = makeProvider(standardRoutes({}))
434
480
  const result = await provider.getBootstrap()
@@ -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
  })
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/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}]`
@@ -2,14 +2,14 @@
2
2
  //
3
3
  // This module is intentionally dependency-free: no imports from the rest of the
4
4
  // provider stack, no bun:sqlite, no fetch. It models only the subset of ADF
5
- // that agent-kanban round-trips through plain text:
5
+ // that agent-kanban writes, plus a few Jira nodes it must preserve on read:
6
6
  //
7
- // doc > { paragraph | bulletList | orderedList | codeBlock | heading }
8
- // paragraph / heading / codeBlock > text(inline)
7
+ // doc > { paragraph | bulletList | orderedList | codeBlock | heading | card }
8
+ // paragraph / heading / codeBlock > text(inline) | inlineCard | hardBreak
9
9
  // bulletList / orderedList > listItem > paragraph > text
10
10
  //
11
- // Unknown node types are tolerated on the read path (skipped silently) and
12
- // never emitted on the write path.
11
+ // Other unknown node types are tolerated on the read path (skipped silently)
12
+ // and never emitted on the write path.
13
13
 
14
14
  export interface AdfDocument {
15
15
  version: 1
@@ -17,10 +17,15 @@ export interface AdfDocument {
17
17
  content: AdfBlockNode[]
18
18
  }
19
19
 
20
+ export interface AdfMark {
21
+ type: string
22
+ attrs?: Record<string, unknown>
23
+ }
24
+
20
25
  export interface AdfTextNode {
21
26
  type: 'text'
22
27
  text: string
23
- marks?: unknown[]
28
+ marks?: AdfMark[]
24
29
  }
25
30
 
26
31
  export interface AdfUnknownInlineNode {
@@ -63,6 +68,12 @@ export interface AdfHeadingNode {
63
68
  content?: AdfInlineNode[]
64
69
  }
65
70
 
71
+ export interface AdfExpandNode {
72
+ type: 'expand'
73
+ attrs?: { title?: string }
74
+ content: AdfBlockNode[]
75
+ }
76
+
66
77
  export interface AdfUnknownBlockNode {
67
78
  type: string
68
79
  [key: string]: unknown
@@ -74,6 +85,7 @@ export type AdfBlockNode =
74
85
  | AdfOrderedListNode
75
86
  | AdfCodeBlockNode
76
87
  | AdfHeadingNode
88
+ | AdfExpandNode
77
89
  | AdfUnknownBlockNode
78
90
 
79
91
  // Public AdfNode union covers every node shape this module recognizes.
@@ -85,6 +97,18 @@ const ORDERED_MARKER = /^(\d+)\. (.*)$/
85
97
  // no whitespace before it. Fence must occupy the whole line.
86
98
  const FENCE_OPEN = /^```([^\s`]*)$/
87
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
+ }
88
112
 
89
113
  function paragraphFromText(text: string): AdfParagraphNode {
90
114
  if (text.length === 0) {
@@ -92,7 +116,7 @@ function paragraphFromText(text: string): AdfParagraphNode {
92
116
  }
93
117
  return {
94
118
  type: 'paragraph',
95
- content: [{ type: 'text', text }],
119
+ content: tokenizeInline(text),
96
120
  }
97
121
  }
98
122
 
@@ -103,17 +127,63 @@ function listItemFromText(text: string): AdfListItemNode {
103
127
  }
104
128
  }
105
129
 
130
+ const INLINE_MARK = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g
131
+
132
+ function tokenizeInline(text: string): AdfTextNode[] {
133
+ const out: AdfTextNode[] = []
134
+ INLINE_MARK.lastIndex = 0
135
+ let cursor = 0
136
+ for (const match of text.matchAll(INLINE_MARK)) {
137
+ const start = match.index ?? 0
138
+ if (start > cursor) {
139
+ out.push({ type: 'text', text: text.slice(cursor, start) })
140
+ }
141
+ const boldText = match[1]
142
+ if (boldText !== undefined) {
143
+ out.push({
144
+ type: 'text',
145
+ text: boldText,
146
+ marks: [{ type: 'strong' }],
147
+ })
148
+ } else {
149
+ out.push({
150
+ type: 'text',
151
+ text: match[2]!,
152
+ marks: [{ type: 'link', attrs: { href: match[3]! } }],
153
+ })
154
+ }
155
+ cursor = start + match[0].length
156
+ }
157
+ if (cursor < text.length) {
158
+ out.push({ type: 'text', text: text.slice(cursor) })
159
+ }
160
+ return out
161
+ }
162
+
106
163
  export function plainTextToAdf(text: string): AdfDocument {
107
164
  if (text.length === 0) {
108
165
  return { version: 1, type: 'doc', content: [] }
109
166
  }
110
-
111
167
  const lines = text.split('\n')
112
- const blocks: AdfBlockNode[] = []
168
+ const { blocks } = parseBlocks(lines, 0, () => false)
169
+ return { version: 1, type: 'doc', content: blocks }
170
+ }
113
171
 
114
- let i = 0
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
+ }
178
+
179
+ function parseBlocks(lines: string[], start: number, stop: (line: string) => boolean): ParseResult {
180
+ const blocks: AdfBlockNode[] = []
181
+ let i = start
115
182
  while (i < lines.length) {
116
183
  const line = lines[i] ?? ''
184
+ if (stop(line)) {
185
+ return { blocks, endIndex: i }
186
+ }
117
187
 
118
188
  // Blank line separates blocks — consume and move on.
119
189
  if (line === '') {
@@ -121,6 +191,19 @@ export function plainTextToAdf(text: string): AdfDocument {
121
191
  continue
122
192
  }
123
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
+
124
207
  // Fenced code block.
125
208
  const fenceOpen = line.match(FENCE_OPEN)
126
209
  if (fenceOpen) {
@@ -189,16 +272,19 @@ export function plainTextToAdf(text: string): AdfDocument {
189
272
  continue
190
273
  }
191
274
 
192
- // Paragraph: consume until blank line or a block-starting line.
275
+ // Paragraph: consume until blank line, stop predicate, or a
276
+ // block-starting line.
193
277
  const paragraphLines: string[] = [line]
194
278
  i += 1
195
279
  while (i < lines.length) {
196
280
  const current = lines[i] ?? ''
197
281
  if (
198
282
  current === '' ||
283
+ stop(current) ||
199
284
  BULLET_MARKER.test(current) ||
200
285
  ORDERED_MARKER.test(current) ||
201
- FENCE_OPEN.test(current)
286
+ FENCE_OPEN.test(current) ||
287
+ EXPAND_OPEN.test(current)
202
288
  ) {
203
289
  break
204
290
  }
@@ -208,21 +294,82 @@ export function plainTextToAdf(text: string): AdfDocument {
208
294
  blocks.push(paragraphFromText(paragraphLines.join('\n')))
209
295
  }
210
296
 
211
- return { version: 1, type: 'doc', content: blocks }
297
+ return { blocks, endIndex: i }
212
298
  }
213
299
 
214
- function inlineText(nodes: AdfInlineNode[] | undefined): string {
300
+ function inlineText(
301
+ nodes: AdfInlineNode[] | undefined,
302
+ opts: { renderMarks?: boolean } = {},
303
+ ): string {
215
304
  if (!nodes) return ''
305
+ const renderMarks = opts.renderMarks ?? true
216
306
  let out = ''
217
- for (const node of nodes) {
307
+ for (let i = 0; i < nodes.length; i += 1) {
308
+ const node = nodes[i]!
218
309
  if (node.type === 'text') {
219
- out += (node as AdfTextNode).text
310
+ const textNode = node as AdfTextNode
311
+ if (!renderMarks) {
312
+ out += textNode.text
313
+ continue
314
+ }
315
+ const nextNode = nodes[i + 1]
316
+ const labelColon = readPlainTextLeadingColon(nextNode)
317
+ if (labelColon && hasMark(textNode, 'strong')) {
318
+ out += renderTextNode({ ...textNode, text: `${textNode.text}:` })
319
+ out += labelColon.remainder
320
+ i += 1
321
+ continue
322
+ }
323
+ out += renderTextNode(textNode)
324
+ continue
220
325
  }
221
- // Unknown inline nodes (mentions, emoji, hardBreak, etc.) are skipped.
326
+ if (node.type === 'inlineCard') {
327
+ const url = readCardUrl(node)
328
+ if (url) out += url
329
+ continue
330
+ }
331
+ if (node.type === 'hardBreak') {
332
+ out += '\n'
333
+ continue
334
+ }
335
+ // Other unknown inline nodes (mentions, emoji, etc.) are skipped.
222
336
  }
223
337
  return out
224
338
  }
225
339
 
340
+ function hasMark(node: AdfTextNode, type: string): boolean {
341
+ return node.marks?.some((m) => m.type === type) ?? false
342
+ }
343
+
344
+ function readPlainTextLeadingColon(node: AdfInlineNode | undefined): { remainder: string } | null {
345
+ if (!node || node.type !== 'text') return null
346
+ const textNode = node as AdfTextNode
347
+ if (textNode.marks && textNode.marks.length > 0) return null
348
+ if (!textNode.text.startsWith(':')) return null
349
+ return { remainder: textNode.text.slice(1) }
350
+ }
351
+
352
+ function renderTextNode(node: AdfTextNode): string {
353
+ if (!node.marks || node.marks.length === 0) return node.text
354
+ const link = node.marks.find((m) => m.type === 'link')
355
+ let out = node.text
356
+ if (link) {
357
+ const href = link.attrs?.['href']
358
+ if (typeof href === 'string' && href.length > 0) out = `[${out}](${href})`
359
+ }
360
+ if (hasMark(node, 'strong')) {
361
+ out = `**${out}**`
362
+ }
363
+ return out
364
+ }
365
+
366
+ function readCardUrl(node: AdfInlineNode | AdfBlockNode): string | null {
367
+ const attrs = (node as { attrs?: Record<string, unknown> }).attrs
368
+ if (!attrs) return null
369
+ const url = attrs['url']
370
+ return typeof url === 'string' && url.length > 0 ? url : null
371
+ }
372
+
226
373
  function listItemInnerText(item: AdfListItemNode): string {
227
374
  // Each list item wraps a paragraph (or nested blocks). We flatten to the
228
375
  // first paragraph's inline text, which is all the write path produces.
@@ -252,24 +399,41 @@ function renderBlock(node: AdfBlockNode): string | null {
252
399
  case 'codeBlock': {
253
400
  const code = node as AdfCodeBlockNode
254
401
  const language = code.attrs?.language ?? ''
255
- const body = inlineText(code.content)
402
+ const body = inlineText(code.content, { renderMarks: false })
256
403
  const fence = language.length > 0 ? `\`\`\`${language}` : '```'
257
404
  return `${fence}\n${body}\n\`\`\``
258
405
  }
259
406
  case 'heading':
260
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
+ }
416
+ case 'blockCard':
417
+ case 'embedCard': {
418
+ const url = readCardUrl(node)
419
+ return url ?? null
420
+ }
261
421
  default:
262
422
  // Unknown block node — skip entirely, never throw.
263
423
  return null
264
424
  }
265
425
  }
266
426
 
267
- export function adfToPlainText(doc: AdfDocument): string {
268
- const rendered: string[] = []
269
- for (const block of doc.content) {
427
+ function renderBlocks(nodes: AdfBlockNode[]): string {
428
+ const out: string[] = []
429
+ for (const block of nodes) {
270
430
  const text = renderBlock(block)
271
431
  if (text === null) continue
272
- rendered.push(text)
432
+ out.push(text)
273
433
  }
274
- 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)
275
439
  }