dsh-taskboard 0.3.3 → 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.
- package/README.md +22 -6
- package/lib/client.js +1192 -39
- package/lib/host/execution.js +6 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +95 -2
- package/lib/host/git.js.map +1 -1
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +184 -2
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +202 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +277 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +28 -0
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +45 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +192 -8
- package/src/client/board/TaskFormModal.tsx +100 -18
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +152 -8
- package/src/client/styles.ts +153 -0
- package/src/host/execution.ts +10 -2
- package/src/host/git.ts +77 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +215 -0
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +198 -2
- package/src/index.ts +6 -0
- package/src/shared/api.ts +54 -0
- package/src/shared/protocol.ts +344 -0
- package/src/shared/version.ts +1 -1
package/src/client/styles.ts
CHANGED
|
@@ -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
|
package/src/host/execution.ts
CHANGED
|
@@ -622,9 +622,17 @@ export class ExecutionService {
|
|
|
622
622
|
+ `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
|
|
623
623
|
+ `完成后按序交接:\n`
|
|
624
624
|
+ `1. taskboard_get 读取本任务,取得最新 version\n`
|
|
625
|
-
+ `2.
|
|
626
|
-
+ `3.
|
|
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.
|
|
26
|
-
'
|
|
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)或执行它。',
|
package/src/host/routes.ts
CHANGED
|
@@ -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.
|