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
@@ -513,6 +513,159 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
513
513
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
514
514
  }
515
515
  .dsh-atb-diag-orphan-path { font-size: 11.5px; font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
516
+
517
+ /* ---------- 0.4.0 checklist ---------- */
518
+ .dsh-atb-cke { display: flex; flex-direction: column; gap: 6px; }
519
+ .dsh-atb-cke-row { display: flex; align-items: center; gap: 8px; }
520
+ .dsh-atb-cke-box { flex-shrink: 0; width: 15px; height: 15px; cursor: pointer; }
521
+ .dsh-atb-cke-text {
522
+ flex: 1; min-width: 0; font-size: 12.5px; padding: 6px 9px;
523
+ border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3));
524
+ background: var(--dsw-alias-bg-layer-1, transparent); color: inherit;
525
+ }
526
+ .dsh-atb-cke-del {
527
+ flex-shrink: 0; border: none; background: none; cursor: pointer; padding: 4px;
528
+ color: var(--dsw-alias-label-tertiary, gray); font-size: 12px; border-radius: 6px;
529
+ }
530
+ .dsh-atb-cke-del:hover { color: var(--dsw-alias-state-error-primary, #e5484d); background: rgba(229,72,77,.08); }
531
+ .dsh-atb-cke-add {
532
+ align-self: flex-start; border: 1px dashed var(--dsw-alias-border-l2, rgba(128,128,128,.4));
533
+ background: none; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer;
534
+ font-size: 11.5px; padding: 5px 12px; border-radius: 8px;
535
+ }
536
+ .dsh-atb-cke-add:hover { color: var(--dsw-alias-state-business-primary, #3e63dd); border-color: var(--dsw-alias-state-business-primary, #3e63dd); }
537
+ .dsh-atb-cke-hint { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
538
+
539
+ .dsh-atb-cl-progress { margin-left: 8px; font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-tertiary, gray); }
540
+ .dsh-atb-cl-progress[data-tone="bad"] { color: var(--dsw-alias-state-error-primary, #e5484d); font-weight: 600; }
541
+ .dsh-atb-cl-items { display: flex; flex-direction: column; gap: 5px; }
542
+ .dsh-atb-cl-item {
543
+ display: flex; align-items: baseline; gap: 9px; padding: 6px 9px; border-radius: 8px;
544
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.22));
545
+ background: var(--dsw-alias-bg-layer-1, rgba(128,128,128,.03)); cursor: pointer;
546
+ }
547
+ .dsh-atb-cl-item:hover { border-color: var(--dsw-alias-border-l2, rgba(128,128,128,.4)); }
548
+ .dsh-atb-cl-item input { flex-shrink: 0; transform: translateY(1px); cursor: pointer; }
549
+ .dsh-atb-cl-item[data-checked="true"] .dsh-atb-cl-text { text-decoration: line-through; color: var(--dsw-alias-label-tertiary, gray); }
550
+ .dsh-atb-cl-item[data-alert="true"] {
551
+ border-color: rgba(229,72,77,.45); background: rgba(229,72,77,.06);
552
+ }
553
+ .dsh-atb-cl-text { flex: 1; min-width: 0; font-size: 12.5px; word-break: break-word; }
554
+ .dsh-atb-cl-meta { flex-shrink: 0; font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); display: flex; flex-direction: column; gap: 2px; align-items: flex-end; }
555
+ .dsh-atb-cl-note { max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, inherit); }
556
+
557
+ /* ---------- 0.4.0 report ---------- */
558
+ .dsh-atb-rpt-summary { font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; margin-bottom: 8px; }
559
+ .dsh-atb-rpt-sec { margin-bottom: 8px; }
560
+ .dsh-atb-rpt-label { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); margin-bottom: 4px; }
561
+ .dsh-atb-rpt-list { margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.55; word-break: break-all; }
562
+ .dsh-atb-rpt-risk {
563
+ font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
564
+ color: var(--dsw-alias-state-warn-primary, #f5a524);
565
+ background: rgba(245,165,36,.08); border: 1px solid rgba(245,165,36,.3);
566
+ border-radius: 8px; padding: 6px 10px;
567
+ }
568
+
569
+ /* ---------- 0.4.0 diff viewer ---------- */
570
+ .dsh-atb-iso-commit { display: flex; flex-direction: column; gap: 3px; }
571
+ .dsh-atb-iso-commit-btn {
572
+ display: flex; gap: 8px; font-size: 11.5px; align-items: baseline; text-align: left;
573
+ border: none; background: none; padding: 2px 4px; margin: 0 -4px; border-radius: 6px; cursor: pointer;
574
+ color: inherit; width: fit-content; max-width: 100%;
575
+ }
576
+ .dsh-atb-iso-commit-btn:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.08)); }
577
+ .dsh-atb-iso-commit-btn code {
578
+ font-family: ui-monospace, Consolas, monospace; font-size: 10.5px;
579
+ color: var(--dsw-alias-state-business-primary, #3e63dd); flex-shrink: 0;
580
+ }
581
+ .dsh-atb-iso-commit-btn span { word-break: break-all; color: var(--dsw-alias-label-secondary, inherit); }
582
+ .dsh-atb-iso-commit[data-open="true"] > .dsh-atb-iso-commit-btn code { font-weight: 700; }
583
+ .dsh-atb-iso-dirty { display: flex; flex-direction: column; gap: 6px;
584
+ font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #e5484d);
585
+ background: rgba(229,72,77,.09); border: 1px solid rgba(229,72,77,.35);
586
+ border-radius: 8px; padding: 6px 10px; margin-bottom: 8px;
587
+ }
588
+ .dsh-atb-iso-dirty-toggle { border: none; background: none; cursor: pointer; padding: 0; text-align: left; color: inherit; font-size: inherit; }
589
+ .dsh-atb-iso-dirty-files { display: flex; flex-direction: column; gap: 2px; }
590
+ .dsh-atb-iso-dirty-file {
591
+ border: none; background: none; cursor: pointer; text-align: left; padding: 1px 2px;
592
+ font-size: 11px; color: var(--dsw-alias-label-secondary, inherit); border-radius: 4px; word-break: break-all;
593
+ }
594
+ .dsh-atb-iso-dirty-file:hover { background: rgba(128,128,128,.1); color: var(--dsw-alias-state-business-primary, #3e63dd); }
595
+ .dsh-atb-iso-dirty-file code { font-family: ui-monospace, Consolas, monospace; font-size: 10px; margin-right: 6px; }
596
+ .dsh-atb-diffview { margin-top: 6px; border-radius: 8px; overflow: hidden;
597
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25)); }
598
+ .dsh-atb-diffview-head { display: flex; align-items: center; gap: 10px; padding: 5px 10px;
599
+ background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.08)); }
600
+ .dsh-atb-diffview-title { font-family: ui-monospace, Consolas, monospace; font-size: 10.5px;
601
+ color: var(--dsw-alias-state-business-primary, #3e63dd); word-break: break-all; }
602
+ .dsh-atb-diffview-hint { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
603
+ .dsh-atb-diffview-error { padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #e5484d); }
604
+ .dsh-atb-diffview-pre {
605
+ margin: 0; padding: 8px 10px; max-height: 340px; overflow: auto;
606
+ font-family: ui-monospace, Consolas, monospace; font-size: 10.5px; line-height: 1.5;
607
+ white-space: pre; color: var(--dsw-alias-label-secondary, inherit);
608
+ }
609
+
610
+ /* ---------- 0.4.0 new-task menu + template manager + import ---------- */
611
+ .dsh-atb-newmenu { position: relative; display: inline-flex; }
612
+ .dsh-atb-newmenu-backdrop { position: fixed; inset: 0; z-index: 40; }
613
+ .dsh-atb-newmenu-list {
614
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 41; min-width: 180px;
615
+ display: flex; flex-direction: column; padding: 5px; border-radius: 10px;
616
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
617
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
618
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
619
+ }
620
+ .dsh-atb-newmenu-opt {
621
+ border: none; background: none; text-align: left; cursor: pointer; padding: 7px 10px;
622
+ font-size: 12.5px; color: inherit; border-radius: 7px; white-space: nowrap;
623
+ }
624
+ .dsh-atb-newmenu-opt:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.1)); }
625
+ .dsh-atb-newmenu-sep { height: 1px; margin: 4px 6px; background: var(--dsw-alias-border-l2, rgba(128,128,128,.25)); }
626
+
627
+ .dsh-atb-tplm { max-width: 560px; width: min(560px, 92vw); }
628
+ .dsh-atb-tplm-list { display: flex; flex-direction: column; gap: 8px; }
629
+ .dsh-atb-tplm-row {
630
+ display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px;
631
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
632
+ }
633
+ .dsh-atb-tplm-name {
634
+ flex: 0 0 160px; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
635
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3));
636
+ background: var(--dsw-alias-bg-layer-1, transparent); color: inherit;
637
+ }
638
+ .dsh-atb-tplm-meta { flex: 1; min-width: 0; font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
639
+ .dsh-atb-tplm-btns { display: flex; gap: 6px; flex-shrink: 0; }
640
+
641
+ .dsh-atb-imp { max-width: 600px; width: min(600px, 92vw); }
642
+ .dsh-atb-imp-picker { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
643
+ .dsh-atb-imp-picker input[type="file"] { font-size: 12px; }
644
+ .dsh-atb-imp-filename { font-size: 11.5px; color: var(--dsw-alias-state-business-primary, #3e63dd); word-break: break-all; }
645
+ .dsh-atb-imp-note { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); margin-bottom: 10px; }
646
+ .dsh-atb-imp-error { font-size: 12px; color: var(--dsw-alias-state-error-primary, #e5484d); margin-bottom: 8px; }
647
+ .dsh-atb-imp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
648
+ .dsh-atb-imp-stat {
649
+ display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 9px 6px; border-radius: 9px;
650
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
651
+ }
652
+ .dsh-atb-imp-stat b { font-size: 17px; font-weight: 700; }
653
+ .dsh-atb-imp-stat span { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
654
+ .dsh-atb-imp-stat[data-tone="ok"] b { color: var(--dsw-alias-state-success-primary, #30a46c); }
655
+ .dsh-atb-imp-stat[data-tone="warn"] b { color: var(--dsw-alias-state-warn-primary, #f5a524); }
656
+ .dsh-atb-imp-stat[data-tone="bad"] b { color: var(--dsw-alias-state-error-primary, #e5484d); }
657
+ .dsh-atb-imp-sec h4 { margin: 0 0 6px; font-size: 12px; }
658
+ .dsh-atb-imp-sec { margin-bottom: 10px; }
659
+ .dsh-atb-imp-list { display: flex; flex-direction: column; gap: 4px; max-height: 160px; overflow-y: auto; }
660
+ .dsh-atb-imp-row {
661
+ display: flex; align-items: center; gap: 10px; justify-content: space-between;
662
+ padding: 5px 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.2));
663
+ }
664
+ .dsh-atb-imp-row[data-tone="bad"] { border-color: rgba(229,72,77,.35); }
665
+ .dsh-atb-imp-row-title { font-size: 12px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
666
+ .dsh-atb-imp-row-status { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); flex-shrink: 0; }
667
+ .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
668
+ .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
516
669
  `
517
670
 
518
671
  let injected = false
@@ -622,9 +622,17 @@ export class ExecutionService {
622
622
  + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
623
623
  + `完成后按序交接:\n`
624
624
  + `1. taskboard_get 读取本任务,取得最新 version\n`
625
- + `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
626
- + `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
625
+ + `2. taskboard_execution_report 提交结构化执行报告(做了什么/改了哪些文件/如何验证/剩余风险;提交与评论不冲突,都会展示给验收人)\n`
626
+ + `3. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
627
+ + `4. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
627
628
  + `若无法完成:留评论说明原因,将任务移回待办 todo。`
629
+ if (task.checklist !== undefined && task.checklist.length > 0) {
630
+ const items = task.checklist
631
+ .map((item, index) => `${item.checked ? '☑' : '☐'} ${index + 1}. ${item.text}${item.note !== undefined ? `(证据: ${item.note})` : ''}`)
632
+ .join('\n')
633
+ const done = task.checklist.filter(i => i.checked).length
634
+ text += `\n本任务有验收清单(DoD,${done}/${task.checklist.length} 已完成)——按清单干活:\n${items}\n完成一项就用 taskboard_checklist(action=check,附 note 证据)勾选;未完成项会在验收时高亮,全部完成再移待验收。需要补充验收项也可用 action=add 追加。`
635
+ }
628
636
  if (prepared !== undefined) {
629
637
  if (prepared.reused === true) {
630
638
  text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
package/src/host/git.ts CHANGED
@@ -39,6 +39,39 @@ export const MAX_COMMIT_EVIDENCE = 50
39
39
  /** Evidence caps: uncommitted-change lines kept per execution record. */
40
40
  export const MAX_DIRTY_EVIDENCE = 100
41
41
 
42
+ /** Diff viewer caps (0.4.0): raw text kept per view. */
43
+ export const MAX_DIFF_BYTES = 128 * 1024
44
+
45
+ /** Diff viewer caps: lines kept per view. */
46
+ export const MAX_DIFF_LINES = 2_000
47
+
48
+ /** One capped, read-only diff view (diff viewer, 0.4.0). */
49
+ export interface DiffResult {
50
+ text: string
51
+ truncated: boolean
52
+ }
53
+
54
+ /** Cap one diff payload by bytes and lines (in order, marking truncation). */
55
+ function capDiff(out: string): DiffResult {
56
+ let text = out
57
+ let truncated = false
58
+ if (text.length > MAX_DIFF_BYTES) {
59
+ text = text.slice(0, MAX_DIFF_BYTES)
60
+ truncated = true
61
+ }
62
+ const lines = text.split('\n')
63
+ if (lines.length > MAX_DIFF_LINES) {
64
+ text = lines.slice(0, MAX_DIFF_LINES).join('\n')
65
+ truncated = true
66
+ }
67
+ return { text, truncated }
68
+ }
69
+
70
+ /** A plausible git object hash (defense against option injection). */
71
+ function isHash(hash: string): boolean {
72
+ return /^[0-9a-f]{4,64}$/i.test(hash)
73
+ }
74
+
42
75
  /** Result of one underlying exec: `ok` is exit-0, output never null. */
43
76
  export interface ExecResult { ok: boolean; stdout: string; stderr: string }
44
77
 
@@ -94,6 +127,18 @@ export interface GitFace {
94
127
  removeWorktree(root: string, worktreePath: string): Promise<void>
95
128
  /** Delete a branch; THROWS (e.g. still checked out in a worktree). */
96
129
  deleteBranch(root: string, branch: string): Promise<void>
130
+ /**
131
+ * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:
132
+ * undefined on any git failure; the payload is capped.
133
+ */
134
+ showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>
135
+ /**
136
+ * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:
137
+ * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted
138
+ * changes in a live worktree); with `baseCommit`: the range
139
+ * base..HEAD restricted to the path. Fail-soft: undefined on failure.
140
+ */
141
+ showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>
97
142
  }
98
143
 
99
144
  /**
@@ -289,5 +334,37 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
289
334
  const deleted = await heavy(['branch', '-D', branch], root)
290
335
  if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)
291
336
  }),
337
+
338
+ async showCommit(cwd, hash) {
339
+ if (!isHash(hash)) return undefined
340
+ const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)
341
+ if (!r.ok || r.stdout.trim().length === 0) return undefined
342
+ return capDiff(r.stdout)
343
+ },
344
+
345
+ async showPathDiff(cwd, path, baseCommit) {
346
+ const target = path.trim()
347
+ if (target.length === 0) return undefined
348
+ if (baseCommit !== undefined && isHash(baseCommit)) {
349
+ const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)
350
+ if (!r.ok) return undefined
351
+ if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }
352
+ return capDiff(r.stdout)
353
+ }
354
+ // Working-tree view: staged + unstaged vs HEAD.
355
+ const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)
356
+ if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)
357
+ // Untracked files never appear in `git diff` — detect one and synthesize
358
+ // its new-file patch via --no-index (which exits 1 on differences, so
359
+ // its stdout is trusted whenever it carries a diff header).
360
+ const st = await quick(['status', '--porcelain', '--', target], cwd)
361
+ if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {
362
+ const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })
363
+ if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)
364
+ return { text: `(未跟踪新文件:${target})`, truncated: false }
365
+ }
366
+ if (!r.ok) return undefined
367
+ return { text: '(该文件无差异)', truncated: false }
368
+ },
292
369
  }
293
370
  }
@@ -14,7 +14,8 @@
14
14
  export const TASKBOARD_PROTOCOL = [
15
15
  '本机已安装 dsh-taskboard 插件(DSH 任务看板):任务挂在项目(DSH workspace)上,',
16
16
  '用 taskboard_* 工具读写;人在 Web GUI 看板上实时看到同样数据。能力:查板(list/get)、',
17
- '建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)',
17
+ '建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)',
18
+ '验收清单(checklist)、执行报告(execution_report)。',
18
19
  '任务带紧急度(urgent红/normal紫/relaxed蓝)、执行方式(claim认领/scheduled定时)与可选指定模型。',
19
20
  '工作纪律:',
20
21
  '1. 开工先查板:开始工作前先 taskboard_list(按本项目过滤、status=todo),有可认领任务时按纪律认领。',
@@ -22,8 +23,9 @@ export const TASKBOARD_PROTOCOL = [
22
23
  '3. 先认领再干活:把 todo→in_progress(带 ifVersion)成功后,才开始读代码/分析实现;',
23
24
  ' 认领失败(版本冲突/项目边界不符/已被他人持有)就停止并报告,绝不循环重试或接管他人任务。',
24
25
  '4. 版本冲突只重试一次:ifVersion 冲突时重新读卡,仅当状态仍可认领且需求未变时用新版本号重试一次,再失败即停止报告。',
25
- '5. 验收交接:实现并自验后,评论记录(改动/验证结果/剩余风险),再把 in_progress→in_review。',
26
- '6. 完成须用户确认:你永远不能把任务移到 done——那是用户的确认动作;blocked=无法继续,canceled=不再继续。',
26
+ '5. 验收交接:实现并自验后,taskboard_execution_report 提交结构化报告(摘要/改动文件/自验/风险),',
27
+ ' 评论记录补充细节,再把 in_progress→in_review;任务带验收清单时用 taskboard_checklist 逐项勾选(附证据)。',
28
+ '6. 完成须用户确认:你永远不能把任务移到 done——那是用户的确认动作;清单全勾也不等于完成;blocked=无法继续,canceled=不再继续。',
27
29
  '7. backlog=未授权:backlog 任务不算批准执行,被指派也不是授权,除非用户明确要求。',
28
30
  '8. 模型与定时只读:任务的 model 与 execution 配置归创建者/用户所有,update 工具不允许你修改这两个字段。',
29
31
  '项目边界:只有属于任务所在项目的会话才能认领(todo→in_progress)或执行它。',
@@ -20,19 +20,24 @@ import {
20
20
  asStatus,
21
21
  asUrgency,
22
22
  canTransition,
23
+ checklistFromTexts,
23
24
  newCommentId,
24
25
  newTaskId,
25
26
  normalizeBody,
27
+ normalizeChecklist,
26
28
  normalizeExecution,
27
29
  normalizeModel,
28
30
  normalizePrompt,
29
31
  normalizeTitle,
30
32
  summarize,
31
33
  syncClaim,
34
+ validateLedgerImport,
32
35
  type TaskModel,
33
36
  type TaskRecord,
34
37
  } from '../shared/protocol.ts'
35
38
  import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
39
+ import type { TaskTemplate } from '../shared/api.ts'
40
+ import type { TemplateStore } from './templates.ts'
36
41
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
37
42
  import type { TaskStore } from './store.ts'
38
43
  import type { WorkspaceFace } from './tools.ts'
@@ -62,6 +67,45 @@ export interface TaskboardRoutesOptions {
62
67
  modelProviders?: () => string[] | undefined
63
68
  /** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
64
69
  git?: GitFace
70
+ /** Task-template store (0.4.0); absent → 501 on template actions. */
71
+ templates?: TemplateStore
72
+ }
73
+
74
+ /** Validate a template's task spec (routes-side, unknown → invalid_input). */
75
+ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
76
+ if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
77
+ const e = raw as Record<string, unknown>
78
+ const spec: TaskTemplate['task'] = {}
79
+ const str = (key: string): string | undefined => {
80
+ const v = e[key]
81
+ if (v === undefined) return undefined
82
+ if (typeof v !== 'string') throw new Error(`Error: invalid_input: task.${key} must be a string`)
83
+ return v
84
+ }
85
+ const title = str('title')
86
+ const description = str('description')
87
+ const prompt = str('prompt')
88
+ const urgency = str('urgency')
89
+ const isolation = str('isolation')
90
+ const presetId = str('presetId')
91
+ if (title !== undefined) spec.title = normalizeTitle(title)
92
+ if (description !== undefined) spec.description = description
93
+ if (prompt !== undefined) spec.prompt = normalizePrompt(prompt)
94
+ if (urgency !== undefined) spec.urgency = asUrgency(urgency)
95
+ if (isolation !== undefined) spec.isolation = asIsolation(isolation)
96
+ if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
97
+ if (e.execution !== undefined) {
98
+ spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())
99
+ }
100
+ if (e.model !== undefined) spec.model = normalizeModel(e.model)
101
+ if (e.checklist !== undefined) {
102
+ if (!Array.isArray(e.checklist) || e.checklist.some(c => typeof c !== 'string')) {
103
+ throw new Error('Error: invalid_input: task.checklist must be an array of strings')
104
+ }
105
+ checklistFromTexts(e.checklist as string[]) // validates count + texts
106
+ spec.checklist = e.checklist as string[]
107
+ }
108
+ return spec
65
109
  }
66
110
 
67
111
  /** Validate a pinned model: structural check always, provider route when known. */
@@ -257,6 +301,60 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
257
301
  })
258
302
  return
259
303
  }
304
+ // Diff viewer (0.4.0): read-only git show/diff for one execution's
305
+ // commit or changed path. Prefers the live worktree (uncommitted
306
+ // view), falls back to the main repo.
307
+ const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))
308
+ if (diffMatch !== null) {
309
+ try {
310
+ if (options.git === undefined) {
311
+ const f = fail('invalid_input', 'git integration unavailable')
312
+ json(res, f.res, 501)
313
+ return
314
+ }
315
+ const task = store.get(diffMatch[1]!)
316
+ if (task === undefined) throw new Error('Error: not_found: no such task')
317
+ const execution = task.executions.find(e => e.id === url.searchParams.get('execution'))
318
+ if (execution === undefined) throw new Error('Error: not_found: no such execution')
319
+ const commit = url.searchParams.get('commit')
320
+ const filePath = url.searchParams.get('path')
321
+ const ws = workspaces.get(task.workspaceId)
322
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
323
+ const cwd = execution.worktreePath ?? ws.path
324
+ let result = commit !== null
325
+ ? await options.git.showCommit(cwd, commit)
326
+ : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : undefined
327
+ // Fallback: the worktree may be gone — commits and committed
328
+ // ranges still resolve in the main repo.
329
+ if (result === undefined && execution.worktreePath !== undefined && cwd !== ws.path) {
330
+ result = commit !== null
331
+ ? await options.git.showCommit(ws.path, commit)
332
+ : filePath !== null && execution.baseCommit !== undefined
333
+ ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit)
334
+ : undefined
335
+ }
336
+ if (result === undefined) {
337
+ throw new Error('Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)')
338
+ }
339
+ json(res, { ok: true, value: { diff: result.text, truncated: result.truncated } })
340
+ } catch (error) {
341
+ const f = toFail(error)
342
+ json(res, f.res, f.status)
343
+ }
344
+ return
345
+ }
346
+
347
+ // Templates listing (0.4.0).
348
+ if (pathname === `${ROUTE_PREFIX}/templates`) {
349
+ if (options.templates === undefined) {
350
+ const f = fail('invalid_input', 'template store unavailable')
351
+ json(res, f.res, 501)
352
+ return
353
+ }
354
+ json(res, { ok: true, value: { templates: await options.templates.list() } })
355
+ return
356
+ }
357
+
260
358
  const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
261
359
  if (taskMatch !== null) {
262
360
  const task = store.get(taskMatch[1]!)
@@ -301,6 +399,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
301
399
  const isolationRaw = str(body, 'isolation')
302
400
  const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)
303
401
  const presetId = normalizePresetId(str(body, 'presetId'))
402
+ let checklist: TaskRecord['checklist'] = undefined
403
+ if (body.checklist !== undefined) {
404
+ if (!Array.isArray(body.checklist) || body.checklist.some(c => typeof c !== 'string')) {
405
+ throw new Error('Error: invalid_input: checklist must be an array of strings')
406
+ }
407
+ const texts = (body.checklist as string[]).map(c => c.trim()).filter(c => c.length > 0)
408
+ if (texts.length > 0) checklist = checklistFromTexts(texts)
409
+ }
304
410
  const now = options.now()
305
411
  const task: TaskRecord = {
306
412
  id: newTaskId(),
@@ -315,6 +421,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
315
421
  model,
316
422
  ...(isolation !== undefined ? { isolation } : {}),
317
423
  ...(presetId !== undefined ? { presetId } : {}),
424
+ ...(checklist !== undefined ? { checklist } : {}),
318
425
  version: 1,
319
426
  createdAt: now,
320
427
  updatedAt: now,
@@ -381,6 +488,13 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
381
488
  // Preset may change any time: each run composes fresh.
382
489
  if (body.presetId === null) delete next.presetId
383
490
  else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
491
+ // Checklist (0.4.0): the GUI replaces the whole list; null clears.
492
+ if (body.checklist === null) delete next.checklist
493
+ else if (body.checklist !== undefined) {
494
+ const items = normalizeChecklist(body.checklist)
495
+ if (items.length > 0) next.checklist = items
496
+ else delete next.checklist
497
+ }
384
498
  next.version = task.version + 1
385
499
  next.updatedAt = options.now()
386
500
  next.updatedBy = { kind: 'user' }
@@ -657,6 +771,107 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
657
771
  return
658
772
  }
659
773
 
774
+ // ---------------------------------------------- POST /import/preview
775
+ // (0.4.0) Dry-run: classify every task in the uploaded ledger file
776
+ // against the live one; nothing is written.
777
+ if (pathname === `${ROUTE_PREFIX}/import/preview`) {
778
+ try {
779
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
780
+ const plan = validateLedgerImport(body, known, options.now())
781
+ json(res, {
782
+ ok: true,
783
+ value: {
784
+ plan: {
785
+ create: plan.create.map(t => ({ id: t.id, title: t.title, status: t.status })),
786
+ overwrite: plan.overwrite.map(t => ({ id: t.id, title: t.title, status: t.status })),
787
+ invalid: plan.invalid,
788
+ },
789
+ },
790
+ })
791
+ } catch (error) {
792
+ const f = toFail(error)
793
+ json(res, f.res, f.status)
794
+ }
795
+ return
796
+ }
797
+
798
+ // ------------------------------------------------------ POST /import
799
+ // (0.4.0) Commit an import. mode=merge upserts (create + overwrite by
800
+ // id); mode=replace swaps the WHOLE ledger (invalid entries dropped)
801
+ // after writing a timestamped backup of the current one.
802
+ if (pathname === `${ROUTE_PREFIX}/import`) {
803
+ try {
804
+ const mode = str(body, 'mode') === 'replace' ? 'replace' as const : 'merge' as const
805
+ const raw = body.ledger
806
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
807
+ const plan = validateLedgerImport(raw, known, options.now())
808
+ const imported = [...plan.create, ...plan.overwrite]
809
+ if (mode === 'replace' && imported.length === 0) {
810
+ throw new Error('Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换')
811
+ }
812
+ let backupFile: string | undefined
813
+ if (mode === 'replace' && store.snapshot().tasks.length > 0) {
814
+ backupFile = await store.backup()
815
+ }
816
+ let replacedTotal: number | undefined
817
+ await store.mutate('task-created', ledger => {
818
+ if (mode === 'replace') {
819
+ replacedTotal = ledger.tasks.length
820
+ ledger.tasks = structuredClone(imported)
821
+ return ledger.tasks
822
+ }
823
+ const byId = new Map(ledger.tasks.map(t => [t.id, t]))
824
+ for (const task of imported) byId.set(task.id, structuredClone(task))
825
+ ledger.tasks = [...byId.values()]
826
+ return structuredClone(imported)
827
+ })
828
+ json(res, {
829
+ ok: true,
830
+ value: {
831
+ mode,
832
+ created: plan.create.length,
833
+ overwritten: plan.overwrite.length,
834
+ ...(mode === 'replace' ? { replacedTotal } : {}),
835
+ ...(backupFile !== undefined ? { backupFile } : {}),
836
+ },
837
+ })
838
+ } catch (error) {
839
+ const f = toFail(error)
840
+ json(res, f.res, f.status)
841
+ }
842
+ return
843
+ }
844
+
845
+ // ------------------------------------------- POST /templates (+delete)
846
+ if (pathname === `${ROUTE_PREFIX}/templates` || pathname === `${ROUTE_PREFIX}/templates/delete`) {
847
+ try {
848
+ if (options.templates === undefined) {
849
+ const f = fail('invalid_input', 'template store unavailable')
850
+ json(res, f.res, 501)
851
+ return
852
+ }
853
+ if (pathname.endsWith('/delete')) {
854
+ const id = str(body, 'id') ?? ''
855
+ if (id.length === 0) throw new Error('Error: invalid_input: id required')
856
+ const deleted = await options.templates.remove(id)
857
+ json(res, { ok: true, value: { deleted } })
858
+ return
859
+ }
860
+ const name = str(body, 'name') ?? ''
861
+ if (name.trim().length === 0) throw new Error('Error: invalid_input: name required')
862
+ const template = await options.templates.upsert({
863
+ id: str(body, 'id') ?? undefined,
864
+ name,
865
+ task: normalizeTemplateSpec(body.task),
866
+ })
867
+ json(res, { ok: true, value: template }, 201)
868
+ } catch (error) {
869
+ const f = toFail(error)
870
+ json(res, f.res, f.status)
871
+ }
872
+ return
873
+ }
874
+
660
875
  res.writeHead(404)
661
876
  res.end()
662
877
  } catch (error) {
package/src/host/store.ts CHANGED
@@ -103,6 +103,19 @@ export class TaskStore {
103
103
  return () => this.subscribers.delete(fn)
104
104
  }
105
105
 
106
+ /**
107
+ * Write a timestamped backup copy of the current ledger next to the live
108
+ * file (import-replace safety, 0.4.0). Never throws the caller's flow —
109
+ * a backup failure fails the import itself.
110
+ * @returns the backup file path.
111
+ */
112
+ async backup(): Promise<string> {
113
+ await this.load()
114
+ const target = `${this.file}.backup-${Date.now()}`
115
+ await persistAtomic(target, JSON.stringify(this.ledger, null, 2))
116
+ return target
117
+ }
118
+
106
119
  /**
107
120
  * Run one mutation inside the serial queue. The mutator works on a
108
121
  * structured clone; returning `undefined` aborts with no write.