dsh-taskboard 0.6.6 → 0.6.7

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 (45) hide show
  1. package/README.md +301 -294
  2. package/lib/client.js +937 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/execution.js +3 -0
  6. package/lib/host/execution.js.map +1 -1
  7. package/lib/host/locale.js +17 -0
  8. package/lib/host/locale.js.map +1 -0
  9. package/lib/host/routes.js +45 -6
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/scheduler.js +2 -0
  12. package/lib/host/scheduler.js.map +1 -1
  13. package/lib/host/session-sync.js +4 -1
  14. package/lib/host/session-sync.js.map +1 -1
  15. package/lib/host/templates.js +11 -75
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +17 -4
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/builtin-templates.js +155 -0
  21. package/lib/shared/builtin-templates.js.map +1 -0
  22. package/lib/shared/protocol.js +39 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +90 -90
  25. package/src/client/api.ts +5 -1
  26. package/src/client/board/TaskBoard.tsx +16 -11
  27. package/src/client/board/TaskDetail.tsx +116 -10
  28. package/src/client/board/TemplateManager.tsx +22 -10
  29. package/src/client/controller.ts +23 -3
  30. package/src/client/i18n/en.ts +18 -0
  31. package/src/client/i18n/templates.ts +25 -0
  32. package/src/client/i18n/zh.ts +18 -0
  33. package/src/client/styles.ts +1 -1
  34. package/src/host/archive-sessions.ts +18 -0
  35. package/src/host/execution.ts +4 -1
  36. package/src/host/locale.ts +44 -0
  37. package/src/host/routes.ts +40 -7
  38. package/src/host/scheduler.ts +2 -0
  39. package/src/host/session-sync.ts +4 -1
  40. package/src/host/templates.ts +8 -59
  41. package/src/host/tools.ts +20 -7
  42. package/src/shared/api.ts +6 -3
  43. package/src/shared/builtin-templates.ts +153 -0
  44. package/src/shared/protocol.ts +64 -0
  45. package/src/shared/version.ts +9 -9
@@ -9,12 +9,12 @@
9
9
  */
10
10
  import { useEffect, useState, type ReactNode } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
- import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
- import { canTransition, checklistProgress } from '../../shared/protocol.ts'
12
+ import type { CommentRecord, ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
+ import { canTransition, checklistProgress, taskAssociatedSessionIds } from '../../shared/protocol.ts'
14
14
  import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './format.ts'
16
16
  import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
17
- import { useT } from '../i18n/runtime.ts'
17
+ import { useT, type Translate } from '../i18n/runtime.ts'
18
18
 
19
19
  /** Statuses a user may move this task to, per the state machine. */
20
20
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -28,6 +28,26 @@ function shortId(id: string | undefined): string {
28
28
  return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
29
29
  }
30
30
 
31
+ /**
32
+ * Render a comment body, localizing host-generated system messages (0.6.4).
33
+ * System comments carry a `systemKey` (+ flat params, or structured per-repo
34
+ * rows for the multi-repo merge summary); user/agent comments render raw.
35
+ */
36
+ export function commentBody(t: Translate, c: CommentRecord): string {
37
+ if (c.systemKey === undefined) return c.body
38
+ if (c.systemRows !== undefined) {
39
+ const summary = c.systemRows
40
+ .map(r => {
41
+ const label = r.repo === '' ? t('iso.repo.root') : r.repo
42
+ const mark = r.outcome === 'merged' ? '✓' : r.outcome === 'noop' ? '⟲' : '✗'
43
+ return r.outcome === 'failed' && r.error !== undefined ? `${label} ${mark} ${r.error}` : `${label} ${mark}`
44
+ })
45
+ .join(' · ')
46
+ return t(c.systemKey, { summary })
47
+ }
48
+ return t(c.systemKey, c.systemParams)
49
+ }
50
+
31
51
  /** Execution duration between start and end. */
32
52
  function duration(startedAt: number | undefined, endedAt: number | undefined): string {
33
53
  if (startedAt === undefined || endedAt === undefined) return ''
@@ -497,6 +517,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
497
517
  const [confirmDone, setConfirmDone] = useState(false)
498
518
  const [confirmPurge, setConfirmPurge] = useState(false)
499
519
  const [confirmCancel, setConfirmCancel] = useState(false)
520
+ const [confirmArchive, setConfirmArchive] = useState(false)
500
521
  // Top action buttons (duplicate / save-as-template / run / reuse-run)
501
522
  // share one in-flight guard: a double click used to fire duplicate runs or
502
523
  // copies while the first round-trip was still pending (review P0).
@@ -510,6 +531,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
510
531
  const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
511
532
  const sessionExecution = [...task.executions].reverse().find(e => e.sessionId !== undefined)
512
533
  const targetSessionId = runningExecution?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
534
+ const associatedSessions = taskAssociatedSessionIds(task)
535
+ const archiveState = controller.getSnapshot()
536
+ const archiveResult = archiveState.sessionArchive?.taskId === task.id ? archiveState.sessionArchive.result : undefined
513
537
 
514
538
  /** Fire one top action under the shared busy guard; re-enable on settle. */
515
539
  const runAction = (action: () => Promise<unknown>): void => {
@@ -680,10 +704,21 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
680
704
 
681
705
  <ChecklistBlock task={task} controller={controller} />
682
706
 
707
+ {archiveResult !== undefined && (
708
+ <div role="status" className="dsh-atb-confirm">
709
+ <span>{t('detail.move.archiveResult', { n: archiveResult.archived.length })}</span>
710
+ {archiveResult.failed.map(item => <span key={item.sessionId}>{item.sessionId}: {item.error}</span>)}
711
+ {archiveResult.unsupported.length > 0 && <span>{t('detail.move.archiveUnsupported')} {archiveResult.unsupported.join(', ')}</span>}
712
+ </div>
713
+ )}
714
+ {task.status === 'archived' && associatedSessions.length > 0 && archiveState.archiveSessionsSupported && (
715
+ <button type="button" className="dsh-atb-btn" disabled={actionBusy} onClick={() => runAction(() => controller.retryArchiveSessions(task.id))}>{t('detail.move.archiveRetry')}</button>
716
+ )}
683
717
  <div className="dsh-atb-detail-actions">
684
718
  <div className="dsh-atb-movebtns">
685
- {moveTargets(task).map(to => to === 'done'
686
- ? (confirmDone
719
+ {moveTargets(task).map(to => {
720
+ if (to === 'done') {
721
+ return confirmDone
687
722
  ? (
688
723
  <span key={to} className="dsh-atb-confirm">
689
724
  <span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
@@ -693,12 +728,83 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
693
728
  <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>{t('shared.cancel')}</button>
694
729
  </span>
695
730
  )
696
- : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>)
697
- : (
698
- <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
731
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => { setConfirmDone(true); setConfirmArchive(false) }}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>
732
+ }
733
+ if (to === 'archived') {
734
+ if (confirmArchive) {
735
+ return (
736
+ <span key={to} className="dsh-atb-confirm">
737
+ <span className="dsh-atb-confirm-label">
738
+ {associatedSessions.length === 1
739
+ ? t('detail.move.confirmArchiveSessionWithId', { id: shortId(associatedSessions[0]) })
740
+ : associatedSessions.length > 1
741
+ ? t('detail.move.confirmArchiveSessionCount', { n: associatedSessions.length })
742
+ : t('detail.move.confirmArchive')}
743
+ </span>
744
+ {associatedSessions.length > 0 && <span className="dsh-atb-confirm-label">{associatedSessions.join(', ')}</span>}
745
+ {associatedSessions.length > 0 ? (
746
+ <>
747
+ <button
748
+ type="button"
749
+ className="dsh-atb-btn"
750
+ disabled={!archiveState.archiveSessionsSupported || actionBusy}
751
+ title={!archiveState.archiveSessionsSupported ? t('detail.move.archiveUnsupported') : undefined}
752
+ onClick={() => {
753
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: true }))
754
+ setConfirmArchive(false)
755
+ }}
756
+ >
757
+ {t('detail.move.archiveWithSession')}
758
+ </button>
759
+ <button
760
+ type="button"
761
+ className="dsh-atb-btn"
762
+ onClick={() => {
763
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: false }))
764
+ setConfirmArchive(false)
765
+ }}
766
+ >
767
+ {t('detail.move.archiveCardOnly')}
768
+ </button>
769
+ </>
770
+ ) : (
771
+ <button
772
+ type="button"
773
+ className="dsh-atb-btn"
774
+ data-primary="true"
775
+ onClick={() => {
776
+ void controller.move(task.id, task.version, 'archived')
777
+ setConfirmArchive(false)
778
+ }}
779
+ >
780
+ {t('detail.move.confirm')}
781
+ </button>
782
+ )}
783
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmArchive(false)}>{t('shared.cancel')}</button>
784
+ </span>
785
+ )
786
+ }
787
+ return (
788
+ <button
789
+ key={to}
790
+ type="button"
791
+ className="dsh-atb-movebtn"
792
+ data-to={to}
793
+ onClick={() => {
794
+ setConfirmArchive(true)
795
+ setConfirmDone(false)
796
+ }}
797
+ >
699
798
  {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
700
799
  </button>
701
- ))}
800
+ )
801
+ }
802
+ return (
803
+ <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
804
+ {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
805
+ </button>
806
+ )
807
+ })}
702
808
  <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
703
809
  {task.blocked ? t('detail.blocked.unmark') : t('detail.blocked.mark')}
704
810
  </button>
@@ -730,7 +836,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
730
836
  <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
731
837
  <span>{fmtTime(c.createdAt)}</span>
732
838
  </div>
733
- <div className="dsh-atb-bubble-body">{c.body}</div>
839
+ <div className="dsh-atb-bubble-body">{commentBody(t, c)}</div>
734
840
  </div>
735
841
  </div>
736
842
  ))}
@@ -7,8 +7,12 @@
7
7
  */
8
8
  import { useState } from 'react'
9
9
  import type { BoardController } from '../controller.ts'
10
+ import type { TaskTemplate } from '../../shared/api.ts'
11
+ import type { Urgency } from '../../shared/protocol.ts'
10
12
  import { useAlert } from './AlertModal.tsx'
13
+ import { URGENCY_KEYS } from './labels.ts'
11
14
  import { useT } from '../i18n/runtime.ts'
15
+ import { localizeBuiltinName, localizeBuiltinTask } from '../i18n/templates.ts'
12
16
 
13
17
  /**
14
18
  * The template manager modal.
@@ -23,12 +27,20 @@ export function TemplateManager({ controller }: { controller: BoardController })
23
27
 
24
28
  const close = (): void => controller.closeTemplateManager()
25
29
 
26
- const nameOf = (id: string, fallback: string): string => edits[id] ?? fallback
30
+ /** The name a row currently shows: an in-flight rename edit, else the localized name. */
31
+ const nameOf = (tpl: TaskTemplate): string => edits[tpl.id] ?? localizeBuiltinName(tpl)
32
+
33
+ /** Localized urgency label for the meta line ('' when the template pins none). */
34
+ const urgencyLabel = (urgency: string | undefined): string => {
35
+ if (urgency === undefined) return ''
36
+ const key = URGENCY_KEYS[urgency as Urgency]
37
+ return key !== undefined ? ` · ${t(key)}` : ` · ${urgency}`
38
+ }
27
39
 
28
40
  /** Save one template's rename. */
29
41
  const save = (id: string, name: string): void => {
30
42
  const template = state.templates.find(t => t.id === id)
31
- if (template === undefined || name === template.name) return
43
+ if (template === undefined || name === localizeBuiltinName(template)) return
32
44
  void controller.upsertTemplate({ id, name, task: template.task }).then(ok => {
33
45
  if (ok) {
34
46
  setEdits(prev => { const next = { ...prev }; delete next[id]; return next })
@@ -57,31 +69,31 @@ export function TemplateManager({ controller }: { controller: BoardController })
57
69
  <div key={tpl.id} className="dsh-atb-tplm-row">
58
70
  <input
59
71
  className="dsh-atb-tplm-name"
60
- value={nameOf(tpl.id, tpl.name)}
72
+ value={nameOf(tpl)}
61
73
  maxLength={60}
62
74
  spellCheck={false}
63
- aria-label={t('tpl.name.aria', { name: tpl.name })}
75
+ aria-label={t('tpl.name.aria', { name: localizeBuiltinName(tpl) })}
64
76
  onChange={e => setEdits(prev => ({ ...prev, [tpl.id]: e.target.value }))}
65
77
  onKeyDown={e => {
66
- if (e.key === 'Enter') save(tpl.id, nameOf(tpl.id, tpl.name))
78
+ if (e.key === 'Enter') save(tpl.id, nameOf(tpl))
67
79
  }}
68
80
  />
69
81
  <span
70
82
  className="dsh-atb-tplm-meta"
71
- title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${tpl.task.urgency !== undefined ? ` · ${tpl.task.urgency}` : ''}${tpl.task.permission !== undefined ? ` · ${t('shared.permission')}: ${tpl.task.permission}` : ''}`}
83
+ title={`${tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}${tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}${urgencyLabel(tpl.task.urgency)}${tpl.task.permission !== undefined ? ` · ${t('shared.permission')}: ${tpl.task.permission}` : ''}`}
72
84
  >
73
85
  {tpl.builtin === true ? t('tpl.builtin') : t('tpl.custom')}
74
86
  {tpl.task.checklist !== undefined && tpl.task.checklist.length > 0 ? t('tpl.meta.checklist', { n: tpl.task.checklist.length }) : ''}
75
- {tpl.task.urgency !== undefined ? ` · ${tpl.task.urgency}` : ''}
87
+ {urgencyLabel(tpl.task.urgency)}
76
88
  {tpl.task.permission !== undefined && tpl.task.permission !== 'workspace-write' ? ` · ${tpl.task.permission === 'read-only' ? t('tpl.meta.permReadOnly') : t('tpl.meta.permFull')}` : ''}
77
89
  </span>
78
90
  <span className="dsh-atb-tplm-btns">
79
91
  <button
80
92
  type="button"
81
93
  className="dsh-atb-btn"
82
- disabled={nameOf(tpl.id, tpl.name) === tpl.name || nameOf(tpl.id, tpl.name).trim().length === 0}
94
+ disabled={nameOf(tpl) === localizeBuiltinName(tpl) || nameOf(tpl).trim().length === 0}
83
95
  title={t('tpl.rename.title')}
84
- onClick={() => save(tpl.id, nameOf(tpl.id, tpl.name))}
96
+ onClick={() => save(tpl.id, nameOf(tpl))}
85
97
  >
86
98
  {t('tpl.rename.button')}
87
99
  </button>
@@ -91,7 +103,7 @@ export function TemplateManager({ controller }: { controller: BoardController })
91
103
  title={t('tpl.use.title')}
92
104
  onClick={() => {
93
105
  close()
94
- controller.newFromTemplate(tpl.task)
106
+ controller.newFromTemplate(localizeBuiltinTask(tpl))
95
107
  }}
96
108
  >
97
109
  {t('tpl.use.button')}
@@ -1,3 +1,4 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
1
2
  /**
2
3
  * The board controller: framework-free state holder the React views render
3
4
  * from. Owns the ledger snapshot, workspace listing, view state (open,
@@ -47,6 +48,8 @@ function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortB
47
48
 
48
49
  /** Controller snapshot the views render. */
49
50
  export interface ControllerState {
51
+ archiveSessionsSupported?: boolean
52
+ sessionArchive?: { taskId: string; result: SessionArchiveResult }
50
53
  boardOpen: boolean
51
54
  ledger: TaskLedger
52
55
  workspaces: WorkspaceView[]
@@ -185,7 +188,7 @@ export class BoardController {
185
188
  if (this.state.selectedId !== undefined) {
186
189
  selected = ledger.tasks.find(t => t.id === this.state.selectedId)
187
190
  }
188
- this.setState({ ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
191
+ this.setState({ archiveSessionsSupported: ledger.capabilities?.archiveSessions === true, ledger, workspaces, error: undefined, selectedId: selected === undefined ? undefined : this.state.selectedId })
189
192
  if (this.seenRevision === undefined || ledger.revision >= this.seenRevision) break
190
193
  }
191
194
  } catch (error) {
@@ -422,10 +425,27 @@ export class BoardController {
422
425
  }
423
426
  }
424
427
 
428
+ /** Retry session archiving without repeating the task's terminal transition. */
429
+ async retryArchiveSessions(id: string): Promise<void> {
430
+ try {
431
+ if (this.client.archiveSessions === undefined) return
432
+ const result = await this.client.archiveSessions(id)
433
+ this.setState({ sessionArchive: { taskId: id, result } })
434
+ await this.refresh()
435
+ } catch (error) {
436
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
437
+ }
438
+ }
439
+
425
440
  /** Move a task (user surface: done allowed). */
426
- async move(id: string, ifVersion: number, status: string): Promise<void> {
441
+ async move(id: string, ifVersion: number, status: string, options?: { archiveSessions?: boolean }): Promise<void> {
427
442
  try {
428
- await this.client.move(id, { ifVersion, status })
443
+ const result = await this.client.move(id, {
444
+ ifVersion,
445
+ status,
446
+ ...(options?.archiveSessions !== undefined ? { archiveSessions: options.archiveSessions } : {}),
447
+ })
448
+ if (result.sessionArchive !== undefined) this.setState({ sessionArchive: { taskId: id, result: result.sessionArchive } })
429
449
  await this.refresh()
430
450
  } catch (error) {
431
451
  this.setState({ error: error instanceof Error ? error.message : String(error) })
@@ -213,6 +213,14 @@ export const en: TaskboardDict = {
213
213
  'detail.move.to': 'Move to → {status}',
214
214
  'detail.move.confirmDoneUnchecked': '{n} checklist items are still unchecked — confirm done?',
215
215
  'detail.move.confirmDone': 'Confirm done?',
216
+ 'detail.move.confirmArchive': 'Confirm archive this task?',
217
+ 'detail.move.confirmArchiveSessionWithId': 'Archive execution session {id} together?',
218
+ 'detail.move.confirmArchiveSessionCount': 'Archive {n} execution sessions together?',
219
+ 'detail.move.archiveWithSession': 'With session',
220
+ 'detail.move.archiveUnsupported': 'Session archiving is unavailable on this host',
221
+ 'detail.move.archiveResult': 'Card archived; {n} execution sessions archived. Failed sessions, if any:',
222
+ 'detail.move.archiveRetry': 'Archive / retry execution sessions',
223
+ 'detail.move.archiveCardOnly': 'Card only',
216
224
  'detail.move.confirm': 'Confirm',
217
225
  'detail.blocked.unmark': '✓ Unblock',
218
226
  'detail.blocked.mark': '⛔ Mark blocked',
@@ -456,4 +464,14 @@ export const en: TaskboardDict = {
456
464
  'slash.skill.source-driven-development': 'Design from authoritative docs and source code',
457
465
  'slash.skill.spec-driven-development': 'Write clear technical specs before coding',
458
466
  'slash.skill.using-agent-skills': 'Discover and invoke agent skills dynamically',
467
+
468
+ // ── host-generated system comments (0.6.4; localized at render) ────
469
+ 'sys.execFailed': '[System] Execution failed: {error}; the task was returned to todo.',
470
+ 'sys.endedWithComment': '[System] The execution session ended with comments but was not moved to in review; the system moved it to in review automatically.',
471
+ 'sys.endedNoHandoff': '[System] The execution session ended without a protocol handoff (no comments, not moved to in review); the system moved it to in review automatically — review it, then send back or accept.',
472
+ 'sys.sessionError': '[System] Session execution error: {error}; the task was returned to todo.',
473
+ 'sys.sessionDone': '[System] The session finished; automatically moved to in review.',
474
+ 'sys.cronDead': '[System] The cron expression {cron} has no trigger time within 4 years; scheduling disabled — fix the cron and re-enable.',
475
+ 'sys.mergeSingle': '[System] Branch {branch} merged into the main worktree (--no-ff).',
476
+ 'sys.mergeMulti': '[System] Branches merged per repo (--no-ff): {summary}',
459
477
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Client-side localization of built-in task templates (0.6.4). Built-in
3
+ * templates are seeded host-side with zh fallback text; this module resolves
4
+ * the ACTIVE locale's content (name + task spec) so the new-task dropdown,
5
+ * the template manager, and the create-form prefill follow the GUI language
6
+ * live. Custom templates (no `builtin` flag) and unknown built-in ids pass
7
+ * through untouched.
8
+ *
9
+ * @module dsh-taskboard/client/i18n/templates
10
+ */
11
+ import type { TaskTemplate, TaskTemplateSpec } from '../../shared/api.ts'
12
+ import { builtinTemplateContent } from '../../shared/builtin-templates.ts'
13
+ import { localeStore } from './runtime.ts'
14
+
15
+ /** The localized display name for a template (stored name for custom ones). */
16
+ export function localizeBuiltinName(tpl: Pick<TaskTemplate, 'id' | 'name' | 'builtin'>): string {
17
+ if (tpl.builtin !== true) return tpl.name
18
+ return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.name ?? tpl.name
19
+ }
20
+
21
+ /** The localized task spec for a template (stored spec for custom ones). */
22
+ export function localizeBuiltinTask(tpl: Pick<TaskTemplate, 'id' | 'task' | 'builtin'>): TaskTemplateSpec {
23
+ if (tpl.builtin !== true) return tpl.task
24
+ return builtinTemplateContent(tpl.id, localeStore.getSnapshot().active)?.task ?? tpl.task
25
+ }
@@ -215,6 +215,14 @@ export const zh = {
215
215
  'detail.move.to': '移至→{status}',
216
216
  'detail.move.confirmDoneUnchecked': '仍有 {n} 项清单未勾选,确认完成?',
217
217
  'detail.move.confirmDone': '确认完成?',
218
+ 'detail.move.confirmArchive': '确认归档该任务?',
219
+ 'detail.move.confirmArchiveSessionWithId': '是否连同执行会话 {id} 一同归档?',
220
+ 'detail.move.confirmArchiveSessionCount': '是否连同 {n} 个执行会话一同归档?',
221
+ 'detail.move.archiveWithSession': '连同会话归档',
222
+ 'detail.move.archiveUnsupported': '当前宿主不支持会话归档',
223
+ 'detail.move.archiveResult': '卡片已归档;执行会话归档成功 {n} 个。失败会话如下(如有):',
224
+ 'detail.move.archiveRetry': '归档 / 重试归档执行会话',
225
+ 'detail.move.archiveCardOnly': '仅归档卡片',
218
226
  'detail.move.confirm': '确认',
219
227
  'detail.blocked.unmark': '✓ 解除受阻',
220
228
  'detail.blocked.mark': '⛔ 标记受阻',
@@ -458,6 +466,16 @@ export const zh = {
458
466
  'slash.skill.source-driven-development': '基于权威官方文档与源码进行设计实现',
459
467
  'slash.skill.spec-driven-development': '在编码前制定清晰的技术规范',
460
468
  'slash.skill.using-agent-skills': '发现并动态调用智能体各项专业技能',
469
+
470
+ // ── host-generated system comments (0.6.4; localized at render) ────
471
+ 'sys.execFailed': '[系统] 执行失败:{error};任务已退回待办。',
472
+ 'sys.endedWithComment': '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。',
473
+ 'sys.endedNoHandoff': '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。',
474
+ 'sys.sessionError': '[系统] 会话执行异常:{error};任务已退回待办。',
475
+ 'sys.sessionDone': '[系统] 会话执行完毕,已自动进入待验收。',
476
+ 'sys.cronDead': '[系统] 定时表达式 {cron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。',
477
+ 'sys.mergeSingle': '[系统] 分支 {branch} 已合并到主工作区(--no-ff)。',
478
+ 'sys.mergeMulti': '[系统] 分支已按仓库合并(--no-ff):{summary}',
461
479
  } as const
462
480
 
463
481
  /** The dictionary shape en.ts must match key-for-key. */
@@ -365,7 +365,7 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
365
365
  .dsh-atb-movebtn[data-to="canceled"], .dsh-atb-movebtn[data-to="archived"] { opacity: .75; }
366
366
  .dsh-atb-movebtn[data-to="blocked"] { border-color: rgba(229,72,77,.45); }
367
367
  .dsh-atb-movebtn[data-to="blocked"]:hover { background: rgba(229,72,77,.1); }
368
- .dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; }
368
+ .dsh-atb-confirm { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
369
369
  .dsh-atb-confirm-label { font-size: 11.5px; color: var(--dsw-text-secondary, gray); }
370
370
 
371
371
  .dsh-atb-section { font-size: 13px; display: flex; flex-direction: column; gap: 7px; }
@@ -0,0 +1,18 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
2
+ import { taskAssociatedSessionIds, type TaskRecord } from '../shared/protocol.ts'
3
+
4
+ /** Idempotent best-effort archiving with explicit per-session outcomes. */
5
+ export async function archiveTaskSessions(task: TaskRecord, archive?: (id: string) => Promise<void>): Promise<SessionArchiveResult> {
6
+ const sessionIds = taskAssociatedSessionIds(task)
7
+ if (archive === undefined) return { archived: [], failed: [], unsupported: sessionIds }
8
+ const result: SessionArchiveResult = { archived: [], failed: [], unsupported: [] }
9
+ for (const sessionId of sessionIds) {
10
+ try {
11
+ await archive(sessionId)
12
+ result.archived.push(sessionId)
13
+ } catch (error) {
14
+ result.failed.push({ sessionId, error: error instanceof Error ? error.message : String(error) })
15
+ }
16
+ }
17
+ return result
18
+ }
@@ -72,7 +72,7 @@ export interface ExecutionWorkspaceFace {
72
72
 
73
73
  /** Narrow event-bus face for settlement listening. */
74
74
  export interface EventsFace {
75
- onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void): () => void
75
+ onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void | Promise<void>): () => void
76
76
  }
77
77
 
78
78
  /** Everything the execution service needs. */
@@ -293,6 +293,8 @@ export class ExecutionService {
293
293
  task.comments.push({
294
294
  id: newCommentId(),
295
295
  body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
296
+ systemKey: 'sys.execFailed',
297
+ systemParams: { error: message.slice(0, 300) },
296
298
  version: 1,
297
299
  createdAt: this.deps.now(),
298
300
  })
@@ -598,6 +600,7 @@ export class ExecutionService {
598
600
  body: normalizeBody(commented
599
601
  ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
600
602
  : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
603
+ systemKey: commented ? 'sys.endedWithComment' : 'sys.endedNoHandoff',
601
604
  version: 1,
602
605
  createdAt: now,
603
606
  })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Host-side locale reading for the few user-facing HOST log lines (the
3
+ * gitignore suggestion). Everything else the user sees is localized by the
4
+ * GUI (client i18n dictionaries), and host-written ledger comments carry a
5
+ * `systemKey` that the GUI localizes at render — so this module is only for
6
+ * the raw `console.*` lines the host emits itself.
7
+ *
8
+ * Source: the DSH locale plugin persists the explicit language choice as
9
+ * `locale.preference` in `$DSH_HOME/settings.yaml` (loopback pages) and
10
+ * exposes it through the settings service. When the preference is absent the
11
+ * browser delegates, which the host cannot see — so we fall back to `en`,
12
+ * matching the client's own fallback (zh only when something asked for it).
13
+ *
14
+ * @module dsh-taskboard/host/locale
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+
18
+ /** The two locales the taskboard ships. */
19
+ export type HostLocale = 'zh' | 'en'
20
+
21
+ /** Narrow settings-service face this module consumes (stringly-typed soft access). */
22
+ interface SettingsFace {
23
+ get?: (ns: string) => unknown
24
+ }
25
+
26
+ /** The DSH locale settings section (`preference` carries the explicit choice). */
27
+ interface LocaleSettings {
28
+ preference?: unknown
29
+ }
30
+
31
+ /**
32
+ * Read the active GUI locale as a host-side hint. Absent / malformed settings
33
+ * (or no settings service in scope) fall back to `en` and never throw — a
34
+ * cosmetic log line must not break route boot.
35
+ */
36
+ export function activeHostLocale(ctx: Context): HostLocale {
37
+ try {
38
+ const settings = ctx.get('settings') as SettingsFace | undefined
39
+ const locale = settings?.get?.('locale') as LocaleSettings | undefined
40
+ return locale?.preference === 'zh' ? 'zh' : 'en'
41
+ } catch {
42
+ return 'en'
43
+ }
44
+ }
@@ -1,3 +1,4 @@
1
+ import { archiveTaskSessions } from './archive-sessions.ts'
1
2
  /**
2
3
  * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the
3
4
  * GUI's human operations (create/update/move/comment/delete — actor `user`,
@@ -40,10 +41,12 @@ import {
40
41
  type TaskLedger,
41
42
  type TaskModel,
42
43
  type TaskRecord,
44
+ type SystemCommentRow,
43
45
  } from '../shared/protocol.ts'
44
46
  import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
45
47
  import { removeMirror, repoMainPath } from './isolation.ts'
46
48
  import { createRepoScanner, type RepoScanner } from './repos.ts'
49
+ import { activeHostLocale } from './locale.ts'
47
50
  import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
48
51
  import type { TemplateStore } from './templates.ts'
49
52
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
@@ -316,10 +319,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
316
319
  } catch { /* fail-soft → false */ }
317
320
  // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
318
321
  // worktree directory, once per workspace per host run. Root repos only.
322
+ // The line is localized from the DSH locale preference (see host/locale.ts).
319
323
  if (rootRepo && !gitHinted.has(path)) {
320
324
  gitHinted.add(path)
321
325
  if (await gitignoreMissing(path)) {
322
- console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
326
+ const file = `${path}/.gitignore`
327
+ const hint = activeHostLocale(ctx) === 'zh'
328
+ ? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`
329
+ : `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`
330
+ console.info(`[dsh-taskboard] ${hint}`)
323
331
  }
324
332
  }
325
333
  // The nested scan always runs: repoCount needs it even when the root
@@ -372,7 +380,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
372
380
  if (req.method === 'GET') {
373
381
  if (pathname === `${ROUTE_PREFIX}/state`) {
374
382
  await store.load()
375
- json(res, { ok: true, value: store.snapshot() })
383
+ json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
376
384
  return
377
385
  }
378
386
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
@@ -615,6 +623,12 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
615
623
  try {
616
624
  const task = store.get(id)
617
625
  if (task === undefined) throw new Error('Error: not_found: no such task')
626
+ if (action === 'archive-sessions') {
627
+ if (task.trashedAt !== undefined || task.status !== 'archived') throw new Error('Error: invalid_transition: only archived live tasks can retry session archiving')
628
+ const result = await archiveTaskSessions(task, workspaces.archiveSession)
629
+ json(res, { ok: true, value: result })
630
+ return
631
+ }
618
632
  if (action === 'update') {
619
633
  const ifVersion = num(body, 'ifVersion')
620
634
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
@@ -678,13 +692,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
678
692
  if (action === 'move') {
679
693
  const ifVersion = num(body, 'ifVersion')
680
694
  const status = str(body, 'status') ?? ''
695
+ const archiveSessions = body.archiveSessions === true
681
696
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
682
697
  const to = asStatus(status)
683
698
  let next: TaskRecord | undefined
699
+ let beforeTask: TaskRecord | undefined
684
700
  await store.mutate('task-moved', ledger => {
685
701
  const { index, task } = liveTaskAt(ledger, id)
686
702
  if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
687
703
  if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
704
+ beforeTask = task
688
705
  next = structuredClone(task)
689
706
  next.status = to
690
707
  next.version = task.version + 1
@@ -696,7 +713,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
696
713
  ledger.tasks[index] = next
697
714
  return [next]
698
715
  })
699
- json(res, { ok: true, value: summarize(next!) })
716
+ const sessionArchive = to === 'archived' && archiveSessions
717
+ ? await archiveTaskSessions(beforeTask ?? next!, workspaces.archiveSession)
718
+ : undefined
719
+ json(res, { ok: true, value: { ...summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) } })
700
720
  return
701
721
  }
702
722
  if (action === 'reject') {
@@ -909,11 +929,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
909
929
  }
910
930
  // R1: the git merges above are slow — re-find the FRESH task inside
911
931
  // the mutation so a concurrent comment is never overwritten.
912
- const pushComment = (body: string): Promise<void> =>
932
+ const pushComment = (body: string, system?: { key: string; params?: Record<string, string>; rows?: SystemCommentRow[] }): Promise<void> =>
913
933
  store.mutate('comment-added', ledger => {
914
934
  const { index, task: fresh } = liveTaskAt(ledger, id)
915
935
  const next = structuredClone(fresh)
916
- next.comments.push({ id: newCommentId(), body: normalizeBody(body), version: 1, createdAt: options.now() })
936
+ next.comments.push({
937
+ id: newCommentId(),
938
+ body: normalizeBody(body),
939
+ ...(system !== undefined ? {
940
+ systemKey: system.key,
941
+ ...(system.params !== undefined ? { systemParams: system.params } : {}),
942
+ ...(system.rows !== undefined ? { systemRows: system.rows } : {}),
943
+ } : {}),
944
+ version: 1,
945
+ createdAt: options.now(),
946
+ })
917
947
  next.version = fresh.version + 1
918
948
  next.updatedAt = options.now()
919
949
  ledger.tasks[index] = next
@@ -930,7 +960,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
930
960
  if (root.outcome === 'failed') {
931
961
  throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
932
962
  }
933
- await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`)
963
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, { key: 'sys.mergeSingle', params: { branch: root.branch } })
934
964
  json(res, { ok: true, value: { merged: true, branch: root.branch } })
935
965
  return
936
966
  }
@@ -942,7 +972,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
942
972
  ? `${labelOf(r.repo)} ✓ 已合并`
943
973
  : r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
944
974
  .join(';')
945
- await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`)
975
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`, {
976
+ key: 'sys.mergeMulti',
977
+ rows: results.map(r => ({ repo: r.repo, outcome: r.outcome, ...(r.error !== undefined ? { error: r.error.slice(0, 150) } : {}) })),
978
+ })
946
979
  json(res, {
947
980
  ok: true,
948
981
  value: {