dsh-taskboard 0.2.2 → 0.4.0

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 (43) hide show
  1. package/README.md +62 -6
  2. package/lib/client.js +1839 -74
  3. package/lib/host/execution.js +199 -54
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +327 -0
  6. package/lib/host/git.js.map +1 -0
  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 +435 -5
  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 +217 -3
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +23 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +286 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +74 -74
  23. package/src/client/api.ts +47 -3
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +118 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +360 -4
  28. package/src/client/board/TaskFormModal.tsx +193 -12
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +238 -11
  31. package/src/client/index.ts +18 -1
  32. package/src/client/styles.ts +198 -0
  33. package/src/host/execution.ts +301 -67
  34. package/src/host/git.ts +370 -0
  35. package/src/host/protocol-text.ts +5 -3
  36. package/src/host/routes.ts +483 -5
  37. package/src/host/store.ts +13 -0
  38. package/src/host/templates.ts +143 -0
  39. package/src/host/tools.ts +215 -2
  40. package/src/index.ts +30 -1
  41. package/src/shared/api.ts +89 -3
  42. package/src/shared/protocol.ts +408 -0
  43. package/src/shared/version.ts +1 -1
@@ -102,6 +102,29 @@ export const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {
102
102
  // Execution
103
103
  // ---------------------------------------------------------------------------
104
104
 
105
+ /**
106
+ * Per-task code isolation mode (0.3.0).
107
+ * - `worktree`: each execution runs in a fresh `git worktree` on a dedicated
108
+ * task branch (`task/<标题>+<taskId>`) under `<workspace>/.dsh-worktrees/`.
109
+ * - `none`: run in the workspace directory as before, zero git interaction.
110
+ * Omitted = the default `worktree`; non-git projects auto-degrade at run
111
+ * time (the execution record carries an `isolationNote` explaining why).
112
+ */
113
+ export type IsolationMode = 'worktree' | 'none'
114
+
115
+ /** Validate an isolation value. */
116
+ export function asIsolation(raw: string): IsolationMode {
117
+ if (raw !== 'worktree' && raw !== 'none') {
118
+ throw new Error("isolation must be 'worktree' or 'none'")
119
+ }
120
+ return raw
121
+ }
122
+
123
+ /** Resolve a task's effective isolation (omitted → the worktree default). */
124
+ export function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {
125
+ return task.isolation === undefined ? 'worktree' : task.isolation
126
+ }
127
+
105
128
  /** How a task may run. */
106
129
  export type ExecutionMode = 'claim' | 'scheduled'
107
130
 
@@ -237,6 +260,41 @@ export type CommentRecord = {
237
260
  threadId?: string
238
261
  }
239
262
 
263
+ /** One commit produced by an isolated execution (hash + subject). */
264
+ export type CommitInfo = { hash: string; subject: string }
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
+
240
298
  /** One execution attempt of a task. */
241
299
  export type ExecutionRecord = {
242
300
  id: string
@@ -248,6 +306,32 @@ export type ExecutionRecord = {
248
306
  endedAt?: number
249
307
  outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'
250
308
  error?: string
309
+ /** Code isolation actually used (`none` also covers degraded worktree runs). */
310
+ isolation?: IsolationMode
311
+ /** Why worktree isolation degraded to running in the original directory. */
312
+ isolationNote?: string
313
+ /** The task branch this execution worked on (worktree runs only). */
314
+ branch?: string
315
+ /** Absolute path of the dedicated worktree (worktree runs only). */
316
+ worktreePath?: string
317
+ /** HEAD of the task branch before the execution started. */
318
+ baseCommit?: string
319
+ /** HEAD at settlement. */
320
+ headCommit?: string
321
+ /** Commits between baseCommit and headCommit (hash + subject; capped at 50, newest first). */
322
+ commits?: CommitInfo[]
323
+ /** Total commits before the evidence cap (equals commits.length when under it). */
324
+ commitsTotal?: number
325
+ /** Uncommitted changes present at settlement (`git status --porcelain` lines; capped at 100). */
326
+ dirtyFiles?: string[]
327
+ /** Total uncommitted lines before the evidence cap. */
328
+ dirtyFilesTotal?: number
329
+ /** Aggregate diff stat between baseCommit and headCommit. */
330
+ diffStat?: string
331
+ /** How many files differ between baseCommit and headCommit. */
332
+ changedFiles?: number
333
+ /** The agent's structured report, submitted via taskboard_execution_report. */
334
+ report?: ExecutionReport
251
335
  }
252
336
 
253
337
  /** The per-model override a task may carry; absent = session default model. */
@@ -271,6 +355,26 @@ export type TaskRecord = {
271
355
  blocked: boolean
272
356
  execution: ExecutionConfig
273
357
  model?: TaskModel
358
+ /** Code isolation for executions (omitted = the worktree default; see {@link IsolationMode}). */
359
+ isolation?: IsolationMode
360
+ /**
361
+ * The agent preset execution sessions are composed from (omitted = the
362
+ * deployment default preset). Recorded on the session header and mounted
363
+ * via the presets service at creation — this is what hands the session its
364
+ * tool set. Editable any time (each run composes fresh).
365
+ */
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[]
373
+ /**
374
+ * The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).
375
+ * Renaming the task afterwards never changes it (history preservation).
376
+ */
377
+ branch?: string
274
378
  /**
275
379
  * The session currently holding the in-progress claim (explicit claim or a
276
380
  * live execution). Present only while `status === 'in_progress'`: any move
@@ -344,6 +448,11 @@ export function newCommentId(): string {
344
448
  return `c-${Date.now().toString(36)}-${suffix()}`
345
449
  }
346
450
 
451
+ /** Mint a checklist item id. */
452
+ export function newChecklistItemId(): string {
453
+ return `k-${Date.now().toString(36)}-${suffix()}`
454
+ }
455
+
347
456
  /** Mint an execution id. */
348
457
  export function newExecutionId(): string {
349
458
  return `e-${Date.now().toString(36)}-${suffix()}`
@@ -498,6 +607,301 @@ export function normalizeModel(raw: unknown): TaskModel {
498
607
  return { provider: p, model: m }
499
608
  }
500
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
+
501
905
  /**
502
906
  * Compact list-projection of a task (token-friendly for `taskboard_list`).
503
907
  * @param task - the task.
@@ -516,6 +920,8 @@ export type TaskSummary = {
516
920
  claimOwner?: string
517
921
  commentCount: number
518
922
  lastExecutionOutcome?: ExecutionRecord['outcome']
923
+ /** Checklist progress (present only when the task has a checklist). */
924
+ checklist?: { done: number; total: number }
519
925
  trashed: boolean
520
926
  }
521
927
 
@@ -525,6 +931,7 @@ export type TaskSummary = {
525
931
  */
526
932
  export function summarize(task: TaskRecord): TaskSummary {
527
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
528
935
  return {
529
936
  id: task.id,
530
937
  title: task.title,
@@ -539,6 +946,7 @@ export function summarize(task: TaskRecord): TaskSummary {
539
946
  claimOwner: isClaimedBy(task),
540
947
  commentCount: task.comments.length,
541
948
  lastExecutionOutcome: last?.outcome,
949
+ ...(checklist !== undefined ? { checklist } : {}),
542
950
  trashed: task.trashedAt !== undefined,
543
951
  }
544
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.2.2'
9
+ export const PLUGIN_VERSION = '0.4.0'