dsh-taskboard 0.3.3 → 0.4.1

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 (44) hide show
  1. package/README.md +27 -6
  2. package/lib/client.js +1201 -42
  3. package/lib/host/execution.js +6 -1
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +95 -2
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +184 -2
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +202 -2
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +7 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +277 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/api.ts +28 -0
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +45 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +192 -8
  28. package/src/client/board/TaskFormModal.tsx +100 -18
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/board-mount.tsx +8 -0
  31. package/src/client/controller.ts +152 -8
  32. package/src/client/sidebar-entry.ts +6 -3
  33. package/src/client/styles.ts +153 -0
  34. package/src/host/execution.ts +10 -2
  35. package/src/host/git.ts +77 -0
  36. package/src/host/protocol-text.ts +5 -3
  37. package/src/host/routes.ts +215 -0
  38. package/src/host/store.ts +13 -0
  39. package/src/host/templates.ts +143 -0
  40. package/src/host/tools.ts +198 -2
  41. package/src/index.ts +6 -0
  42. package/src/shared/api.ts +54 -0
  43. package/src/shared/protocol.ts +344 -0
  44. package/src/shared/version.ts +1 -1
@@ -263,6 +263,38 @@ export type CommentRecord = {
263
263
  /** One commit produced by an isolated execution (hash + subject). */
264
264
  export type CommitInfo = { hash: string; subject: string }
265
265
 
266
+ /**
267
+ * The structured execution report an agent submits at handoff (0.4.0).
268
+ * Commits/dirty/diff facts are host-collected git evidence — the report
269
+ * covers the BUSINESS side the host cannot see.
270
+ */
271
+ export type ExecutionReport = {
272
+ /** What was done (1..2000 chars, required). */
273
+ summary: string
274
+ /** Files the agent changed (paths, ≤50 × 300 chars). */
275
+ changedFiles: string[]
276
+ /** How the work was self-verified (≤50 × 300 chars). */
277
+ checks: string[]
278
+ /** Produced artifacts worth reviewing (≤30 × 300 chars). */
279
+ artifacts: string[]
280
+ /** Known remaining risks / follow-ups (≤2000 chars, '' allowed). */
281
+ risk: string
282
+ }
283
+
284
+ /** One Definition-of-Done checklist item (0.4.0). */
285
+ export type ChecklistItem = {
286
+ id: string
287
+ /** What must be true for acceptance (1..200 chars). */
288
+ text: string
289
+ checked: boolean
290
+ /** Who checked it: an agent session id, or 'user' for GUI toggles. */
291
+ checkedBy?: string
292
+ /** When it was checked (epoch ms). */
293
+ checkedAt?: number
294
+ /** Evidence note attached when checking (≤400 chars). */
295
+ note?: string
296
+ }
297
+
266
298
  /** One execution attempt of a task. */
267
299
  export type ExecutionRecord = {
268
300
  id: string
@@ -298,6 +330,8 @@ export type ExecutionRecord = {
298
330
  diffStat?: string
299
331
  /** How many files differ between baseCommit and headCommit. */
300
332
  changedFiles?: number
333
+ /** The agent's structured report, submitted via taskboard_execution_report. */
334
+ report?: ExecutionReport
301
335
  }
302
336
 
303
337
  /** The per-model override a task may carry; absent = session default model. */
@@ -330,6 +364,12 @@ export type TaskRecord = {
330
364
  * tool set. Editable any time (each run composes fresh).
331
365
  */
332
366
  presetId?: string
367
+ /**
368
+ * Definition-of-Done acceptance checklist (0.4.0). Agents may append items
369
+ * and check/uncheck them (with evidence); the GUI may edit the whole list.
370
+ * Unchecked items highlight at review time; done stays user-only.
371
+ */
372
+ checklist?: ChecklistItem[]
333
373
  /**
334
374
  * The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).
335
375
  * Renaming the task afterwards never changes it (history preservation).
@@ -408,6 +448,11 @@ export function newCommentId(): string {
408
448
  return `c-${Date.now().toString(36)}-${suffix()}`
409
449
  }
410
450
 
451
+ /** Mint a checklist item id. */
452
+ export function newChecklistItemId(): string {
453
+ return `k-${Date.now().toString(36)}-${suffix()}`
454
+ }
455
+
411
456
  /** Mint an execution id. */
412
457
  export function newExecutionId(): string {
413
458
  return `e-${Date.now().toString(36)}-${suffix()}`
@@ -562,6 +607,301 @@ export function normalizeModel(raw: unknown): TaskModel {
562
607
  return { provider: p, model: m }
563
608
  }
564
609
 
610
+ // ---------------------------------------------------------------------------
611
+ // checklist + report validation (0.4.0)
612
+ // ---------------------------------------------------------------------------
613
+
614
+ /** Checklist size cap per task. */
615
+ export const MAX_CHECKLIST_ITEMS = 30
616
+
617
+ /** Checklist item text cap (chars). */
618
+ export const MAX_CHECKLIST_TEXT = 200
619
+
620
+ /**
621
+ * Validate and normalize one checklist text line: trimmed, 1..200 chars.
622
+ * @param raw - the raw text.
623
+ * @throws when empty or too long.
624
+ */
625
+ export function normalizeChecklistText(raw: string): string {
626
+ const t = raw.trim()
627
+ if (t.length === 0 || t.length > MAX_CHECKLIST_TEXT) {
628
+ throw new Error(`checklist item text must be 1..${MAX_CHECKLIST_TEXT} characters`)
629
+ }
630
+ return t
631
+ }
632
+
633
+ /**
634
+ * Build a fresh unchecked checklist from plain text lines (create route /
635
+ * templates / tool adds).
636
+ * @param texts - the item texts (validated individually).
637
+ */
638
+ export function checklistFromTexts(texts: readonly string[]): ChecklistItem[] {
639
+ const items = texts.map(text => ({ id: newChecklistItemId(), text: normalizeChecklistText(text), checked: false }))
640
+ if (items.length > MAX_CHECKLIST_ITEMS) {
641
+ throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)
642
+ }
643
+ return items
644
+ }
645
+
646
+ /**
647
+ * Validate and normalize a full checklist array (GUI update route, import):
648
+ * missing ids are minted, text is checked, checked flags must be booleans,
649
+ * checkedBy/checkedAt are kept only on checked items.
650
+ * @param raw - untyped array from the wire.
651
+ * @throws with a readable reason on any invalid entry.
652
+ */
653
+ export function normalizeChecklist(raw: unknown): ChecklistItem[] {
654
+ if (!Array.isArray(raw)) throw new Error('checklist must be an array')
655
+ if (raw.length > MAX_CHECKLIST_ITEMS) {
656
+ throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)
657
+ }
658
+ return raw.map((entry): ChecklistItem => {
659
+ if (typeof entry !== 'object' || entry === null) throw new Error('checklist item must be an object')
660
+ const e = entry as Record<string, unknown>
661
+ const text = normalizeChecklistText(typeof e.text === 'string' ? e.text : '')
662
+ const id = typeof e.id === 'string' && e.id.trim().length > 0 ? e.id.trim() : newChecklistItemId()
663
+ const checked = e.checked === true
664
+ const checkedBy = typeof e.checkedBy === 'string' ? e.checkedBy.trim().slice(0, 100) : undefined
665
+ const checkedAt = typeof e.checkedAt === 'number' && Number.isFinite(e.checkedAt) ? e.checkedAt : undefined
666
+ const note = typeof e.note === 'string' && e.note.trim().length > 0 ? e.note.trim().slice(0, 400) : undefined
667
+ if (!checked) return { id, text, checked: false }
668
+ return {
669
+ id,
670
+ text,
671
+ checked: true,
672
+ ...(checkedBy !== undefined && checkedBy.length > 0 ? { checkedBy } : {}),
673
+ ...(checkedAt !== undefined ? { checkedAt } : {}),
674
+ ...(note !== undefined ? { note } : {}),
675
+ }
676
+ })
677
+ }
678
+
679
+ /** Checklist progress: how many items are checked (absent checklist → 0/0). */
680
+ export function checklistProgress(task: Pick<TaskRecord, 'checklist'>): { done: number; total: number } {
681
+ const items = task.checklist ?? []
682
+ return { done: items.filter(i => i.checked).length, total: items.length }
683
+ }
684
+
685
+ /** Report string-list caps. */
686
+ const REPORT_LIST_CAPS = { changedFiles: 50, checks: 50, artifacts: 30 } as const
687
+
688
+ /** Per-entry cap for report lists (chars). */
689
+ const REPORT_ENTRY_MAX = 300
690
+
691
+ /** Validate one report string list: strings trimmed 1..300 chars. */
692
+ function normalizeReportList(raw: unknown, field: keyof typeof REPORT_LIST_CAPS): string[] {
693
+ if (raw === undefined) return []
694
+ if (!Array.isArray(raw)) throw new Error(`report.${field} must be an array of strings`)
695
+ const out = raw.map(entry => {
696
+ if (typeof entry !== 'string') throw new Error(`report.${field} must be an array of strings`)
697
+ const t = entry.trim()
698
+ if (t.length === 0 || t.length > REPORT_ENTRY_MAX) {
699
+ throw new Error(`report.${field} entries must be 1..${REPORT_ENTRY_MAX} characters`)
700
+ }
701
+ return t
702
+ })
703
+ if (out.length > REPORT_LIST_CAPS[field]) {
704
+ throw new Error(`report.${field} may hold at most ${REPORT_LIST_CAPS[field]} entries`)
705
+ }
706
+ return out
707
+ }
708
+
709
+ /**
710
+ * Validate and normalize a structured execution report.
711
+ * @param raw - untyped tool/route input.
712
+ * @throws with a readable reason on any invalid field.
713
+ */
714
+ export function normalizeExecutionReport(raw: unknown): ExecutionReport {
715
+ if (typeof raw !== 'object' || raw === null) throw new Error('report must be an object')
716
+ const e = raw as Record<string, unknown>
717
+ const summary = typeof e.summary === 'string' ? e.summary.trim() : ''
718
+ if (summary.length === 0 || summary.length > 2000) {
719
+ throw new Error('report.summary must be 1..2000 characters')
720
+ }
721
+ const risk = typeof e.risk === 'string' ? e.risk.trim().slice(0, 2000) : ''
722
+ return {
723
+ summary,
724
+ changedFiles: normalizeReportList(e.changedFiles, 'changedFiles'),
725
+ checks: normalizeReportList(e.checks, 'checks'),
726
+ artifacts: normalizeReportList(e.artifacts, 'artifacts'),
727
+ risk,
728
+ }
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // ledger import validation (0.4.0)
733
+ // ---------------------------------------------------------------------------
734
+
735
+ /** Result classifying every task in an import file against the live ledger. */
736
+ export type ImportPlan = {
737
+ /** Structurally valid tasks whose ids are new (merge adds them). */
738
+ create: TaskRecord[]
739
+ /** Structurally valid tasks whose ids already exist (merge replaces them). */
740
+ overwrite: TaskRecord[]
741
+ /** Invalid entries with a human-readable reason (never imported). */
742
+ invalid: Array<{ id?: string; reason: string }>
743
+ }
744
+
745
+ /** One unknown-value read helper: string fields with defaults. */
746
+ function strOr(raw: Record<string, unknown>, key: string, fallback: string): string {
747
+ const v = raw[key]
748
+ return typeof v === 'string' ? v : fallback
749
+ }
750
+
751
+ /** One unknown-value read helper: finite numbers with defaults. */
752
+ function numOr(raw: Record<string, unknown>, key: string, fallback: number): number {
753
+ const v = raw[key]
754
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback
755
+ }
756
+
757
+ /**
758
+ * Validate ONE imported task record (pure): rebuilds it field by field with
759
+ * the normal validators, minting missing ids and re-arming cron. Executions
760
+ * left `running` by the exporting machine are marked failed — their
761
+ * settlement watchers died there and can never settle here.
762
+ * @param raw - the untyped record.
763
+ * @param now - current epoch ms (defaults for timestamps).
764
+ * @returns the rebuilt record, or a rejection reason.
765
+ */
766
+ export function validateImportedTask(raw: unknown, now: number): { ok: true; task: TaskRecord } | { ok: false; reason: string } {
767
+ if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }
768
+ const e = raw as Record<string, unknown>
769
+ const id = typeof e.id === 'string' ? e.id.trim() : ''
770
+ const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })
771
+ if (id.length === 0 || id.length > 100) return fail('missing/invalid id')
772
+ try {
773
+ const execution = normalizeExecution(
774
+ typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
775
+ now,
776
+ )
777
+ const comments: CommentRecord[] = []
778
+ if (Array.isArray(e.comments)) {
779
+ for (const c of e.comments) {
780
+ if (typeof c !== 'object' || c === null) return fail('invalid comment entry')
781
+ const ce = c as Record<string, unknown>
782
+ const body = typeof ce.body === 'string' ? ce.body : ''
783
+ if (body.trim().length === 0 || body.length > 4000) return fail('invalid comment body')
784
+ comments.push({
785
+ id: typeof ce.id === 'string' && ce.id.length > 0 ? ce.id : newCommentId(),
786
+ body,
787
+ version: numOr(ce, 'version', 1),
788
+ createdAt: numOr(ce, 'createdAt', now),
789
+ ...(typeof ce.threadId === 'string' ? { threadId: ce.threadId } : {}),
790
+ })
791
+ }
792
+ } else return fail('comments must be an array')
793
+ const executions: ExecutionRecord[] = []
794
+ if (Array.isArray(e.executions)) {
795
+ for (const x of e.executions) {
796
+ if (typeof x !== 'object' || x === null) return fail('invalid execution entry')
797
+ const xe = x as Record<string, unknown>
798
+ const trigger = xe.trigger === 'scheduled' ? 'scheduled' : 'manual'
799
+ const outcomeRaw = xe.outcome
800
+ if (outcomeRaw !== 'running' && outcomeRaw !== 'succeeded' && outcomeRaw !== 'failed' && outcomeRaw !== 'cancelled') {
801
+ return fail('invalid execution outcome')
802
+ }
803
+ // A running execution from the exporting machine can never settle
804
+ // here — import it as failed with the reason recorded.
805
+ const outcome = outcomeRaw === 'running' ? 'failed' as const : outcomeRaw
806
+ executions.push({
807
+ id: typeof xe.id === 'string' && xe.id.length > 0 ? xe.id : newExecutionId(),
808
+ ...(typeof xe.sessionId === 'string' ? { sessionId: xe.sessionId } : {}),
809
+ trigger,
810
+ ...(typeof xe.startedAt === 'number' ? { startedAt: xe.startedAt } : {}),
811
+ ...(typeof xe.endedAt === 'number' ? { endedAt: xe.endedAt } : {}),
812
+ outcome,
813
+ ...(outcomeRaw === 'running' ? { error: 'imported while still running (settlement watcher died with the exporting host)' } : (typeof xe.error === 'string' ? { error: xe.error } : {})),
814
+ ...(typeof xe.isolation === 'string' && (xe.isolation === 'worktree' || xe.isolation === 'none') ? { isolation: xe.isolation } : {}),
815
+ ...(typeof xe.isolationNote === 'string' ? { isolationNote: xe.isolationNote } : {}),
816
+ ...(typeof xe.branch === 'string' ? { branch: xe.branch } : {}),
817
+ ...(typeof xe.worktreePath === 'string' ? { worktreePath: xe.worktreePath } : {}),
818
+ ...(typeof xe.baseCommit === 'string' ? { baseCommit: xe.baseCommit } : {}),
819
+ ...(typeof xe.headCommit === 'string' ? { headCommit: xe.headCommit } : {}),
820
+ ...(Array.isArray(xe.commits) ? { commits: xe.commits.filter((c): c is CommitInfo =>
821
+ typeof c === 'object' && c !== null && typeof (c as CommitInfo).hash === 'string' && typeof (c as CommitInfo).subject === 'string') } : {}),
822
+ ...(typeof xe.commitsTotal === 'number' ? { commitsTotal: xe.commitsTotal } : {}),
823
+ ...(Array.isArray(xe.dirtyFiles) ? { dirtyFiles: xe.dirtyFiles.filter((l): l is string => typeof l === 'string') } : {}),
824
+ ...(typeof xe.dirtyFilesTotal === 'number' ? { dirtyFilesTotal: xe.dirtyFilesTotal } : {}),
825
+ ...(typeof xe.diffStat === 'string' ? { diffStat: xe.diffStat } : {}),
826
+ ...(typeof xe.changedFiles === 'number' ? { changedFiles: xe.changedFiles } : {}),
827
+ ...(typeof xe.report === 'object' && xe.report !== null ? { report: normalizeExecutionReport(xe.report) } : {}),
828
+ })
829
+ }
830
+ } else return fail('executions must be an array')
831
+ const status = asStatus(strOr(e, 'status', 'todo'))
832
+ const actorOf = (v: unknown): Actor => (typeof v === 'object' && v !== null && (v as Actor).kind === 'agent' && typeof (v as { sessionId?: unknown }).sessionId === 'string'
833
+ ? { kind: 'agent', sessionId: (v as { sessionId: string }).sessionId }
834
+ : { kind: 'user' })
835
+ const task: TaskRecord = {
836
+ id,
837
+ title: normalizeTitle(strOr(e, 'title', '')),
838
+ description: strOr(e, 'description', '').trim(),
839
+ prompt: normalizePrompt(strOr(e, 'prompt', '')),
840
+ workspaceId: strOr(e, 'workspaceId', ''),
841
+ urgency: asUrgency(strOr(e, 'urgency', 'normal')),
842
+ status,
843
+ blocked: e.blocked === true,
844
+ execution,
845
+ ...(typeof e.model === 'object' && e.model !== null ? { model: normalizeModel(e.model) } : {}),
846
+ ...(typeof e.isolation === 'string' && (e.isolation === 'worktree' || e.isolation === 'none') ? { isolation: e.isolation } : {}),
847
+ ...(typeof e.presetId === 'string' && e.presetId.trim().length > 0 ? { presetId: e.presetId.trim() } : {}),
848
+ ...(Array.isArray(e.checklist) ? { checklist: normalizeChecklist(e.checklist) } : {}),
849
+ ...(typeof e.branch === 'string' ? { branch: e.branch } : {}),
850
+ ...(status === 'in_progress' && typeof e.claimedBy === 'string' ? { claimedBy: e.claimedBy } : {}),
851
+ ...(status === 'in_progress' && typeof e.claimedAt === 'number' ? { claimedAt: e.claimedAt } : {}),
852
+ version: Math.max(1, Math.trunc(numOr(e, 'version', 1))),
853
+ createdAt: numOr(e, 'createdAt', now),
854
+ updatedAt: numOr(e, 'updatedAt', now),
855
+ createdBy: actorOf(e.createdBy),
856
+ updatedBy: actorOf(e.updatedBy),
857
+ comments,
858
+ executions,
859
+ ...(typeof e.executionsPruned === 'number' ? { executionsPruned: e.executionsPruned } : {}),
860
+ ...(typeof e.trashedAt === 'number' ? { trashedAt: e.trashedAt } : {}),
861
+ }
862
+ if (task.workspaceId.length === 0) return fail('missing workspaceId')
863
+ return { ok: true, task }
864
+ } catch (error) {
865
+ return fail(error instanceof Error ? error.message : String(error))
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Validate a whole imported ledger and classify its tasks against the live
871
+ * one (pure). Duplicate ids INSIDE the file are invalid (first wins, later
872
+ * copies reported); schemaVersion must match {@link LEDGER_SCHEMA_VERSION}.
873
+ * @param raw - the parsed import file.
874
+ * @param knownIds - live ledger task ids.
875
+ * @param now - current epoch ms.
876
+ * @throws when the file is not a ledger or the schemaVersion is unsupported.
877
+ */
878
+ export function validateLedgerImport(raw: unknown, knownIds: ReadonlySet<string>, now: number): ImportPlan {
879
+ if (typeof raw !== 'object' || raw === null) throw new Error('导入文件不是 JSON 对象')
880
+ const e = raw as Record<string, unknown>
881
+ if (e.schemaVersion !== LEDGER_SCHEMA_VERSION) {
882
+ throw new Error(`不支持的 schemaVersion ${String(e.schemaVersion)}(当前支持 ${LEDGER_SCHEMA_VERSION})`)
883
+ }
884
+ if (!Array.isArray(e.tasks)) throw new Error('导入文件的 tasks 不是数组')
885
+ const plan: ImportPlan = { create: [], overwrite: [], invalid: [] }
886
+ const seen = new Set<string>()
887
+ for (const entry of e.tasks) {
888
+ const id = typeof (entry as { id?: unknown })?.id === 'string' ? (entry as { id: string }).id : undefined
889
+ const result = validateImportedTask(entry, now)
890
+ if (!result.ok) {
891
+ plan.invalid.push({ ...(id !== undefined ? { id } : {}), reason: result.reason })
892
+ continue
893
+ }
894
+ if (seen.has(result.task.id)) {
895
+ plan.invalid.push({ id: result.task.id, reason: '文件内重复 id' })
896
+ continue
897
+ }
898
+ seen.add(result.task.id)
899
+ if (knownIds.has(result.task.id)) plan.overwrite.push(result.task)
900
+ else plan.create.push(result.task)
901
+ }
902
+ return plan
903
+ }
904
+
565
905
  /**
566
906
  * Compact list-projection of a task (token-friendly for `taskboard_list`).
567
907
  * @param task - the task.
@@ -580,6 +920,8 @@ export type TaskSummary = {
580
920
  claimOwner?: string
581
921
  commentCount: number
582
922
  lastExecutionOutcome?: ExecutionRecord['outcome']
923
+ /** Checklist progress (present only when the task has a checklist). */
924
+ checklist?: { done: number; total: number }
583
925
  trashed: boolean
584
926
  }
585
927
 
@@ -589,6 +931,7 @@ export type TaskSummary = {
589
931
  */
590
932
  export function summarize(task: TaskRecord): TaskSummary {
591
933
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
934
+ const checklist = task.checklist !== undefined && task.checklist.length > 0 ? checklistProgress(task) : undefined
592
935
  return {
593
936
  id: task.id,
594
937
  title: task.title,
@@ -603,6 +946,7 @@ export function summarize(task: TaskRecord): TaskSummary {
603
946
  claimOwner: isClaimedBy(task),
604
947
  commentCount: task.comments.length,
605
948
  lastExecutionOutcome: last?.outcome,
949
+ ...(checklist !== undefined ? { checklist } : {}),
606
950
  trashed: task.trashedAt !== undefined,
607
951
  }
608
952
  }
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.3.3'
9
+ export const PLUGIN_VERSION = '0.4.1'