dsh-taskboard 0.5.0 → 0.5.2

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.
Files changed (50) hide show
  1. package/README.md +25 -1
  2. package/lib/client.js +242 -174
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +180 -109
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +124 -93
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +23 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +3 -2
  25. package/src/client/api.ts +19 -9
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/TaskBoard.tsx +7 -38
  28. package/src/client/board/TaskCard.tsx +3 -5
  29. package/src/client/board/TaskDetail.tsx +30 -21
  30. package/src/client/board/TaskFormModal.tsx +30 -23
  31. package/src/client/board/format.ts +26 -0
  32. package/src/client/board/labels.ts +44 -0
  33. package/src/client/board-mount.tsx +9 -6
  34. package/src/client/controller.ts +60 -13
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +16 -5
  37. package/src/client/styles.ts +5 -3
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +227 -126
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +180 -123
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +1 -1
  48. package/src/shared/protocol.ts +35 -1
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
package/src/host/tools.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  syncClaim,
47
47
  type Actor,
48
48
  type ChecklistItem,
49
+ type TaskLedger,
49
50
  type TaskModel,
50
51
  type TaskRecord,
51
52
  } from '../shared/protocol.ts'
@@ -124,7 +125,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
124
125
  } else {
125
126
  lines.push('执行记录: 无')
126
127
  }
127
- const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : 'user'
128
+ const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : t.updatedBy.kind === 'system' ? 'system' : 'user'
128
129
  lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`)
129
130
  return lines.join('\n')
130
131
  }
@@ -140,8 +141,10 @@ export const ERR = {
140
141
  invalidInput: 'invalid_input',
141
142
  } as const
142
143
 
143
- /** Tool failure: an Error whose message starts with a stable code. */
144
- class ToolError extends Error {
144
+ /** Tool failure: an Error whose message starts with a stable code. The code
145
+ * is also carried structurally so the routes layer can map failures without
146
+ * re-parsing messages (review P2). */
147
+ export class ToolError extends Error {
145
148
  constructor(readonly code: string, detail: string) {
146
149
  super(`Error: ${code}: ${detail}`)
147
150
  }
@@ -223,6 +226,20 @@ function versionGuard(task: TaskRecord, ifVersion: number | undefined): void {
223
226
  }
224
227
  }
225
228
 
229
+ /**
230
+ * Find a live (non-trashed) task INSIDE a mutator (R1: every guard must run
231
+ * on the fresh draft the serial queue hands us, never on a pre-read clone —
232
+ * a pre-read can pass its version check and then blind-overwrite a task that
233
+ * changed while the caller awaited). Throws not_found for missing/trashed.
234
+ */
235
+ function liveTaskAt(ledger: TaskLedger, id: string): { index: number; task: TaskRecord } {
236
+ const index = ledger.tasks.findIndex(t => t.id === id)
237
+ if (index < 0) throw new ToolError(ERR.notFound, `no task ${id}`)
238
+ const task = ledger.tasks[index]!
239
+ if (task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${id}`)
240
+ return { index, task }
241
+ }
242
+
226
243
  /** Re-throw with a stable code; non-ToolErrors become invalid_input. */
227
244
  function fail(error: unknown): never {
228
245
  if (error instanceof ToolError) throw error
@@ -249,7 +266,7 @@ export interface ToolContextFace {
249
266
  }
250
267
 
251
268
  /**
252
- * Register all eight tools.
269
+ * Register all ten tools.
253
270
  * @param ctx - a context exposing `tools.register`.
254
271
  * @param deps - store + workspaces + clock.
255
272
  * @returns dispose functions, one per tool.
@@ -415,8 +432,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
415
432
  }
416
433
  const urgency = asUrgency(args.urgency)
417
434
  const status = args.status === undefined ? 'todo' as const : asStatus(args.status)
418
- if (status === 'done' || status === 'archived') {
419
- throw new ToolError(ERR.invalidTransition, 'a new task cannot start as done/archived')
435
+ if (status !== 'backlog' && status !== 'todo') {
436
+ throw new ToolError(ERR.invalidTransition, 'a new task must start as backlog or todo (in_progress requires claiming the task)')
420
437
  }
421
438
  const execution = normalizeExecution(args.execution ?? {}, deps.now())
422
439
  const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
@@ -425,7 +442,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
425
442
  // existing tasks.
426
443
  const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
427
444
  const presetId = args.presetId?.trim() || undefined
428
- const checklist = args.checklist !== undefined ? checklistFromTexts(args.checklist) : undefined
445
+ // T9: match the GUI create route trim and drop blank lines instead
446
+ // of failing the whole call over one empty string.
447
+ const checklistTexts = args.checklist?.map(c => c.trim()).filter(c => c.length > 0)
448
+ const checklist = checklistTexts !== undefined && checklistTexts.length > 0 ? checklistFromTexts(checklistTexts) : undefined
429
449
  const now = deps.now()
430
450
  const task: TaskRecord = {
431
451
  id: newTaskId(),
@@ -492,25 +512,27 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
492
512
  }, exec: unknown) {
493
513
  try {
494
514
  const { actor } = caller(exec as ToolRunContext)
495
- const task = store.get(args.id)
496
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
497
- versionGuard(task, args.ifVersion)
498
- if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
499
- const next: TaskRecord = structuredClone(task)
500
- if (args.title !== undefined) next.title = normalizeTitle(args.title)
501
- if (args.description !== undefined) next.description = args.description.trim()
502
- if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
503
- if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
504
- if (args.blocked !== undefined) next.blocked = args.blocked
505
- next.version = task.version + 1
506
- next.updatedAt = deps.now()
507
- next.updatedBy = actor
515
+ // R1: lookup + version guard + write run inside the serial-queue
516
+ // mutation, on the fresh draft a pre-read clone could pass its
517
+ // version check and then blind-overwrite a concurrent writer.
518
+ let next: TaskRecord | undefined
508
519
  await store.mutate('task-updated', ledger => {
509
- const i = ledger.tasks.findIndex(t => t.id === args.id)
510
- ledger.tasks[i] = next
520
+ const { index, task } = liveTaskAt(ledger, args.id)
521
+ versionGuard(task, args.ifVersion)
522
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
523
+ next = structuredClone(task)
524
+ if (args.title !== undefined) next.title = normalizeTitle(args.title)
525
+ if (args.description !== undefined) next.description = args.description.trim()
526
+ if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
527
+ if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
528
+ if (args.blocked !== undefined) next.blocked = args.blocked
529
+ next.version = task.version + 1
530
+ next.updatedAt = deps.now()
531
+ next.updatedBy = actor
532
+ ledger.tasks[index] = next
511
533
  return [next]
512
534
  })
513
- return json({ task: summarize(next) })
535
+ return json({ task: summarize(next!) })
514
536
  } catch (error) { fail(error) }
515
537
  },
516
538
  })) as () => void)
@@ -540,44 +562,47 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
540
562
  try {
541
563
  const { actor } = caller(exec as ToolRunContext)
542
564
  const to = asStatus(args.status)
543
- const task = store.get(args.id)
544
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
545
- versionGuard(task, args.ifVersion)
565
+ // Claim boundary (policy gate): resolving the caller's workspace is
566
+ // async, so it happens BEFORE the mutation the comparison runs INSIDE
567
+ // against the FRESH task (stronger than the old stale-clone compare).
568
+ const callerWsId = to === 'in_progress'
569
+ ? await callerWorkspace(deps, exec as ToolRunContext)
570
+ : undefined
571
+ // R1: every state guard + the write itself run inside the mutation.
572
+ let next: TaskRecord | undefined
573
+ await store.mutate('task-moved', ledger => {
574
+ const { index, task } = liveTaskAt(ledger, args.id)
575
+ versionGuard(task, args.ifVersion)
546
576
 
547
- // Code-level gate: agents never complete a task.
548
- if (to === 'done') {
549
- throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
550
- }
551
- if (!canTransition(task.status, to)) {
552
- throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
553
- }
554
- // Exclusive hold: while a task is in_progress under a session
555
- // (explicit claimedBy — an agent claim or a live execution), no other
556
- // session may move it (that would be a takeover).
557
- if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
558
- throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
559
- }
560
- // Claim boundary: the calling session must belong to the task's project.
561
- if (isClaim(task.status, to)) {
562
- const wsId = await callerWorkspace(deps, exec as ToolRunContext)
563
- if (wsId !== task.workspaceId) {
577
+ // Code-level gate: agents never complete a task.
578
+ if (to === 'done') {
579
+ throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
580
+ }
581
+ if (!canTransition(task.status, to)) {
582
+ throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
583
+ }
584
+ // Exclusive hold: while a task is in_progress under a session
585
+ // (explicit claimedBy — an agent claim or a live execution), no other
586
+ // session may move it (that would be a takeover).
587
+ if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
588
+ throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
589
+ }
590
+ // Claim boundary: the calling session must belong to the task's project.
591
+ if (isClaim(task.status, to) && callerWsId !== task.workspaceId) {
564
592
  throw new ToolError(ERR.workspaceMismatch, 'only a session inside this task\'s project may claim it')
565
593
  }
566
- }
567
- const next: TaskRecord = structuredClone(task)
568
- next.status = to
569
- next.version = task.version + 1
570
- next.updatedAt = deps.now()
571
- next.updatedBy = actor
572
- if (isClaim(task.status, to)) next.blocked = false
573
- // Record the holder on a claim; every move out of in_progress releases it.
574
- syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
575
- await store.mutate('task-moved', ledger => {
576
- const i = ledger.tasks.findIndex(t => t.id === args.id)
577
- ledger.tasks[i] = next
594
+ next = structuredClone(task)
595
+ next.status = to
596
+ next.version = task.version + 1
597
+ next.updatedAt = deps.now()
598
+ next.updatedBy = actor
599
+ if (isClaim(task.status, to)) next.blocked = false
600
+ // Record the holder on a claim; every move out of in_progress releases it.
601
+ syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
602
+ ledger.tasks[index] = next
578
603
  return [next]
579
604
  })
580
- return json({ task: summarize(next) })
605
+ return json({ task: summarize(next!) })
581
606
  } catch (error) { fail(error) }
582
607
  },
583
608
  })) as () => void)
@@ -610,8 +635,6 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
610
635
  async execute(args: { id: string; body: string }, exec: unknown) {
611
636
  try {
612
637
  const { sessionId } = caller(exec as ToolRunContext)
613
- const task = store.get(args.id)
614
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
615
638
  const comment = {
616
639
  id: newCommentId(),
617
640
  body: normalizeBody(args.body),
@@ -619,16 +642,21 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
619
642
  createdAt: deps.now(),
620
643
  threadId: sessionId,
621
644
  }
622
- const next: TaskRecord = structuredClone(task)
623
- next.comments.push(comment)
624
- next.version = task.version + 1
625
- next.updatedAt = deps.now()
645
+ // R1: find + append inside the mutation (no ifVersion by design —
646
+ // comments are append-only — but the write must not clobber a task
647
+ // that changed while we were queued).
648
+ let next: TaskRecord | undefined
626
649
  await store.mutate('comment-added', ledger => {
627
- const i = ledger.tasks.findIndex(t => t.id === args.id)
628
- ledger.tasks[i] = next
650
+ const { index, task } = liveTaskAt(ledger, args.id)
651
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
652
+ next = structuredClone(task)
653
+ next.comments.push(comment)
654
+ next.version = task.version + 1
655
+ next.updatedAt = deps.now()
656
+ ledger.tasks[index] = next
629
657
  return [next]
630
658
  })
631
- return json({ comment, task: { id: next.id, version: next.version, status: next.status } })
659
+ return json({ comment, task: { id: next!.id, version: next!.version, status: next!.status } })
632
660
  } catch (error) { fail(error) }
633
661
  },
634
662
  })) as () => void)
@@ -682,15 +710,23 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
682
710
  async execute(args: { id: string; ifVersion: number }, exec: unknown) {
683
711
  try {
684
712
  caller(exec as ToolRunContext)
685
- const task = store.get(args.id)
686
- if (task === undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
687
- versionGuard(task, args.ifVersion)
688
- const next: TaskRecord = structuredClone(task)
689
- next.trashedAt = deps.now()
690
- next.version = task.version + 1
713
+ // R1: guards inside the mutation. S5: a running execution keeps
714
+ // writing to the task (report, settlement) refuse the soft-delete
715
+ // until it is cancelled or settled. T8: clear claim residue.
716
+ let next: TaskRecord | undefined
691
717
  await store.mutate('task-deleted', ledger => {
692
- const i = ledger.tasks.findIndex(t => t.id === args.id)
693
- ledger.tasks[i] = next
718
+ const { index, task } = liveTaskAt(ledger, args.id)
719
+ versionGuard(task, args.ifVersion)
720
+ if (task.executions.some(e => e.outcome === 'running')) {
721
+ throw new ToolError(ERR.invalidInput, '任务有正在运行的执行(先在 GUI 取消或等它结束再删除)')
722
+ }
723
+ next = structuredClone(task)
724
+ next.trashedAt = deps.now()
725
+ next.version = task.version + 1
726
+ delete next.claimedBy
727
+ delete next.claimedAt
728
+ next.blocked = false
729
+ ledger.tasks[index] = next
694
730
  return [next]
695
731
  })
696
732
  return { trashed: true }
@@ -739,57 +775,57 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
739
775
  }, exec: unknown) {
740
776
  try {
741
777
  const { actor } = caller(exec as ToolRunContext)
742
- const task = store.get(args.id)
743
- if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
744
- versionGuard(task, args.ifVersion)
745
- if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
746
- const next: TaskRecord = structuredClone(task)
747
- const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
748
-
749
- if (args.action === 'add') {
750
- const texts = args.items ?? []
751
- if (texts.length === 0 || texts.length > 10) {
752
- throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
753
- }
754
- if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
755
- throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
778
+ // R1: guards + the checklist edit itself run inside the mutation.
779
+ let next: TaskRecord | undefined
780
+ await store.mutate('task-updated', ledger => {
781
+ const { index, task } = liveTaskAt(ledger, args.id)
782
+ versionGuard(task, args.ifVersion)
783
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
784
+ next = structuredClone(task)
785
+ const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
786
+
787
+ if (args.action === 'add') {
788
+ const texts = args.items ?? []
789
+ if (texts.length === 0 || texts.length > 10) {
790
+ throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
791
+ }
792
+ if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
793
+ throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
794
+ }
795
+ checklist.push(...checklistFromTexts(texts))
796
+ } else if (args.action === 'check') {
797
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
798
+ const item = checklist.find(i => i.id === args.itemId)
799
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
800
+ const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
801
+ item.checked = true
802
+ item.checkedBy = actor.sessionId
803
+ item.checkedAt = deps.now()
804
+ if (note !== undefined) item.note = note
805
+ } else if (args.action === 'uncheck') {
806
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
807
+ const item = checklist.find(i => i.id === args.itemId)
808
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
809
+ item.checked = false
810
+ delete item.checkedBy
811
+ delete item.checkedAt
812
+ delete item.note
813
+ } else {
814
+ throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
756
815
  }
757
- checklist.push(...checklistFromTexts(texts))
758
- } else if (args.action === 'check') {
759
- if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
760
- const item = checklist.find(i => i.id === args.itemId)
761
- if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
762
- const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
763
- item.checked = true
764
- item.checkedBy = actor.sessionId
765
- item.checkedAt = deps.now()
766
- if (note !== undefined) item.note = note
767
- } else if (args.action === 'uncheck') {
768
- if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
769
- const item = checklist.find(i => i.id === args.itemId)
770
- if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
771
- item.checked = false
772
- delete item.checkedBy
773
- delete item.checkedAt
774
- delete item.note
775
- } else {
776
- throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
777
- }
778
816
 
779
- if (checklist.length > 0) next.checklist = checklist
780
- else delete next.checklist
781
- next.version = task.version + 1
782
- next.updatedAt = deps.now()
783
- next.updatedBy = actor
784
- await store.mutate('task-updated', ledger => {
785
- const i = ledger.tasks.findIndex(t => t.id === args.id)
786
- ledger.tasks[i] = next
817
+ if (checklist.length > 0) next.checklist = checklist
818
+ else delete next.checklist
819
+ next.version = task.version + 1
820
+ next.updatedAt = deps.now()
821
+ next.updatedBy = actor
822
+ ledger.tasks[index] = next
787
823
  return [next]
788
824
  })
789
- const progress = next.checklist !== undefined
790
- ? { done: next.checklist.filter(i => i.checked).length, total: next.checklist.length }
825
+ const progress = next!.checklist !== undefined
826
+ ? { done: next!.checklist.filter(i => i.checked).length, total: next!.checklist.length }
791
827
  : { done: 0, total: 0 }
792
- return json({ task: { id: next.id, version: next.version }, checklist: next.checklist ?? [], ...progress })
828
+ return json({ task: { id: next!.id, version: next!.version }, checklist: next!.checklist ?? [], ...progress })
793
829
  } catch (error) { fail(error) }
794
830
  },
795
831
  })) as () => void)
@@ -800,7 +836,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
800
836
  description:
801
837
  'Submit the structured execution report for the task you are currently executing (summary / changed '
802
838
  + 'files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; '
803
- + 'a later submission overwrites the previous report. Commits and diffs are host-collected do not repeat them.',
839
+ + 'a later submission overwrites the previous report. If your run already settled, you may back-submit '
840
+ + 'onto your latest succeeded execution while you still hold the task. Commits and diffs are host-collected.',
804
841
  parameters: {
805
842
  summary: { type: 'string', required: true, description: 'What was done (1..2000 chars).' },
806
843
  changedFiles: {
@@ -842,8 +879,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
842
879
  try {
843
880
  const { sessionId } = caller(exec as ToolRunContext)
844
881
  const report = normalizeExecutionReport(args)
845
- // Locate the RUNNING execution this session owns — reports attach to
846
- // the live run, so the agent never needs to know execution ids.
882
+ // Path 1 (unchanged): attach to the RUNNING execution this session
883
+ // owns — reports ride the live run, so the agent never needs ids.
847
884
  let taskId: string | undefined
848
885
  let executionId: string | undefined
849
886
  await store.mutate('execution-recorded', ledger => {
@@ -858,8 +895,28 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
858
895
  }
859
896
  return undefined
860
897
  })
898
+ // Path 2 (review follow-up, P2): back-submit onto a session-owned
899
+ // SETTLED execution — the main conversation claims tasks directly and
900
+ // has no live run. Allowed when the session holds the task or owns
901
+ // its latest successful execution; never touches anyone else's runs.
902
+ if (taskId === undefined || executionId === undefined) {
903
+ await store.mutate('execution-recorded', ledger => {
904
+ for (const task of ledger.tasks) {
905
+ if (task.trashedAt !== undefined || task.status === 'archived') continue
906
+ const last = task.executions[task.executions.length - 1]
907
+ const owned = last !== undefined && last.sessionId === sessionId && last.outcome === 'succeeded'
908
+ const holds = task.claimedBy === sessionId && last !== undefined && last.sessionId === sessionId
909
+ if (!owned && !holds) continue
910
+ last!.report = report
911
+ taskId = task.id
912
+ executionId = last!.id
913
+ return [task]
914
+ }
915
+ return undefined
916
+ })
917
+ }
861
918
  if (taskId === undefined || executionId === undefined) {
862
- throw new ToolError(ERR.forbidden, 'no running execution belongs to this session the report can only be submitted while the taskboard execution session is still running')
919
+ throw new ToolError(ERR.forbidden, 'no running execution and no settled execution of yours to report on reports attach to your running execution, or back-submit onto your latest succeeded one while you hold the task')
863
920
  }
864
921
  return json({ taskId, executionId, report })
865
922
  } catch (error) { fail(error) }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host loader entry for dsh-taskboard.
3
3
  *
4
- * Wiring: the ledger store (one JSON file under the DSH home), the eight
4
+ * Wiring: the ledger store (one JSON file under the DSH home), the ten
5
5
  * `taskboard_*` agent tools, the agent workflow-protocol system-prompt
6
6
  * section, the /taskboard JSON+SSE routes (when a webServer is served),
7
7
  * the host execution service (fresh in-project sessions, pinned models), and
@@ -48,6 +48,12 @@ export const inject = ['tools', 'systemPrompt']
48
48
  export function apply(ctx: Context): void {
49
49
  const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
50
50
  const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))
51
+ // Eager first load: the tools and most routes read snapshot()/get() without
52
+ // triggering the lazy load, so a fresh boot used to serve an EMPTY board to
53
+ // taskboard_list/get until the scheduler catchup tick or the first
54
+ // GET /state happened to load the file (review P0). load() never throws —
55
+ // a corrupt ledger is quarantined instead.
56
+ void store.load()
51
57
  const now = () => Date.now()
52
58
  // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
53
59
  const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
@@ -173,6 +179,9 @@ export function apply(ctx: Context): void {
173
179
  const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
174
180
  scheduler.start()
175
181
  disposers.push(() => scheduler.dispose())
182
+ // Detach the settlement listener with the plugin — a hot reload must
183
+ // not leave stale services reacting to turn/end errors (review P1).
184
+ disposers.push(() => execution.dispose())
176
185
 
177
186
  return () => {
178
187
  disposeRoutes?.()
package/src/shared/api.ts CHANGED
@@ -195,6 +195,6 @@ export type SummaryResponse = { tasks: TaskSummary[] }
195
195
  /** Change frame pushed on every committed ledger mutation. */
196
196
  export type ChangeEvent = {
197
197
  revision: number
198
- kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
198
+ kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
199
199
  tasks: TaskSummary[]
200
200
  }
@@ -286,6 +286,7 @@ export function nextCronTime(match: CronMatch, from: number): number | null {
286
286
  export type Actor =
287
287
  | { kind: 'user' }
288
288
  | { kind: 'agent'; sessionId: string }
289
+ | { kind: 'system' }
289
290
 
290
291
  /** A progress/report comment on a task. */
291
292
  export type CommentRecord = {
@@ -479,6 +480,16 @@ function suffix(): string {
479
480
  return Math.random().toString(36).slice(2, 8)
480
481
  }
481
482
 
483
+ /**
484
+ * Legal task id charset (R4): `t-<base36>-<base36>` from {@link newTaskId},
485
+ * and the ONLY shape accepted from the outside (import) or used to build
486
+ * filesystem paths (worktree dirs). Ids ride into `join(ws, '.dsh-worktrees',
487
+ * id)` — a lax charset here is an arbitrary-directory delete primitive.
488
+ */
489
+ export function isValidTaskId(id: string): boolean {
490
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(id)
491
+ }
492
+
482
493
  /** Mint a task id. */
483
494
  export function newTaskId(): string {
484
495
  return `t-${Date.now().toString(36)}-${suffix()}`
@@ -811,7 +822,10 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
811
822
  const e = raw as Record<string, unknown>
812
823
  const id = typeof e.id === 'string' ? e.id.trim() : ''
813
824
  const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })
814
- if (id.length === 0 || id.length > 100) return fail('missing/invalid id')
825
+ // R4①: length alone let traversal-shaped ids (`../../x`, `..\..\x`) into
826
+ // the ledger; the charset gate is the primary defense for every downstream
827
+ // filesystem use of a task id.
828
+ if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')
815
829
  try {
816
830
  const execution = normalizeExecution(
817
831
  typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
@@ -909,6 +923,26 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
909
923
  }
910
924
  }
911
925
 
926
+ /**
927
+ * Minimal structural check for ONE ledger record at load time (S11): unlike
928
+ * {@link validateImportedTask} this REBUILDS NOTHING (cron state, ids and
929
+ * timestamps must survive a load untouched) — it only rejects entries whose
930
+ * shape would break downstream consumers, including the R4 id charset.
931
+ * @param raw - the untyped record.
932
+ */
933
+ export function isPlausibleTaskRecord(raw: unknown): boolean {
934
+ if (typeof raw !== 'object' || raw === null) return false
935
+ const t = raw as Record<string, unknown>
936
+ return typeof t.id === 'string' && isValidTaskId(t.id)
937
+ && typeof t.title === 'string' && t.title.length > 0
938
+ && typeof t.workspaceId === 'string' && t.workspaceId.length > 0
939
+ && ALL_STATUSES.includes(t.status as TaskStatus)
940
+ && typeof t.version === 'number' && Number.isFinite(t.version) && t.version >= 1
941
+ && Array.isArray(t.comments) && Array.isArray(t.executions)
942
+ && typeof t.execution === 'object' && t.execution !== null
943
+ && (t.execution as { mode?: unknown }).mode !== undefined
944
+ }
945
+
912
946
  /**
913
947
  * Validate a whole imported ledger and classify its tasks against the live
914
948
  * one (pure). Duplicate ids INSIDE the file are invalid (first wins, later
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.5.0'
9
+ export const PLUGIN_VERSION = '0.5.2'
@@ -1,8 +0,0 @@
1
- /**
2
- * Compatibility shim: the composer moved into TaskFormModal (create + edit
3
- * in one dialog). Kept so existing imports keep working.
4
- *
5
- * @module dsh-taskboard/client/board/NewTaskModal
6
- */
7
- export { TaskFormModal as NewTaskModal } from './TaskFormModal.tsx'
8
- export type { CatalogModel } from './TaskFormModal.tsx'