@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

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 (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +23 -2
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -3,9 +3,9 @@ import { createHash } from 'node:crypto'
3
3
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
- import { isValidCron, nextRunAtMs } from './core/schedule.ts'
6
+ import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
7
7
  import { isTaskRecord, parseLedger } from './core/store.ts'
8
- import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, hasOpenExecution, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import {
10
10
  DEFAULT_SUBTASK_DEPTH,
11
11
  cascadeTargets,
@@ -20,9 +20,11 @@ import { applyCreateTask } from './core/use-cases/task-create.ts'
20
20
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
21
21
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
22
22
  import { applySetParent } from './core/use-cases/task-parent.ts'
23
+ import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
23
24
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
24
- import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
25
- import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
25
+ import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
26
+ import type { ExecutionVerification } from './core/verification.ts'
27
+ import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
26
28
 
27
29
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
28
30
  importedSources?: string[]
@@ -57,7 +59,7 @@ function bindingRefusalMessage(
57
59
  surface: 'run' | 'schedule',
58
60
  ): string {
59
61
  if (refusal.kind === 'subtask-pin') {
60
- return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
62
+ return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session and inherits its permission, which that pin exceeds; clear the card's permission or run the tree without Agent Team`
61
63
  }
62
64
  if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
63
65
  if (refusal.kind === 'root') {
@@ -84,12 +86,21 @@ export interface OpenExecutionReference {
84
86
  readonly executionId: string
85
87
  readonly sessionId: string | undefined
86
88
  readonly startedAt: number
89
+ /**
90
+ * True for a member of a team-mode run other than its Lead. A teammate is a
91
+ * durable member of the Team: it stays alive after its turn ends, so the
92
+ * roster may keep reporting its session as running, and the turn it completed
93
+ * is the only verdict it exposes.
94
+ */
95
+ readonly teamMember: boolean
87
96
  }
88
97
 
89
98
  /** Minimal value copy used by the Host scheduler. */
90
99
  export interface DueScheduleReference {
91
100
  readonly taskId: string
92
101
  readonly cron: string
102
+ /** Zone this rule's wall clock is read in. */
103
+ readonly timeZone: string
93
104
  readonly nextRunAt: number
94
105
  }
95
106
 
@@ -106,15 +117,16 @@ interface CachedRequest {
106
117
  }
107
118
 
108
119
  function timeZone(): string {
109
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'
120
+ return resolveHostTimeZone()
110
121
  }
111
122
 
112
- function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
113
- return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
123
+ /** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
124
+ function scheduleZone(schedule: { timeZone?: string }): string {
125
+ return schedule.timeZone ?? timeZone()
114
126
  }
115
127
 
116
- function hasOpenExecution(task: TaskRecord): boolean {
117
- return task.executions.some(execution => execution.endedAt === undefined)
128
+ function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
129
+ return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
118
130
  }
119
131
 
120
132
  /**
@@ -327,7 +339,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
327
339
  const previous = byId.get(entry.id)
328
340
  byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
329
341
  }
330
- const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
342
+ // Acceptance evidence is the one field "betterExecution" cannot choose: a
343
+ // browser half that also wrote this execution carries no attempts, and taking
344
+ // its copy would erase the Host's verdict. The copy with the most recorded
345
+ // attempts wins, and one execution's verdict is never copied onto another.
346
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
347
+ if (entry.verification !== undefined) return entry
348
+ const richer = [...a.executions, ...b.executions]
349
+ .filter(other => other.id === entry.id && other.verification !== undefined)
350
+ .sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
351
+ return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
352
+ })
331
353
  return { ...newer, executions: retainRecentExecutions(executions) }
332
354
  }
333
355
 
@@ -381,6 +403,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
381
403
  schedule: {
382
404
  enabled: false,
383
405
  cron: schedule.cron,
406
+ ...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
384
407
  nextRunAt: undefined,
385
408
  lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
386
409
  ? schedule.lastTriggeredAt
@@ -401,8 +424,18 @@ export class HostTaskLedger {
401
424
  /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
402
425
  readonly schedulerFile: string
403
426
 
404
- /** Session-default permission the confirmation gate compares against. */
405
- readonly sessionDefaultPermission: TaskPermission
427
+ /** Resolves the baseline the confirmation gate compares a binding against. */
428
+ private readonly sessionDefault: () => TaskPermission
429
+
430
+ /**
431
+ * Session-default permission the confirmation gate compares against. Read
432
+ * through the resolver on every use, so a baseline that follows the Host's
433
+ * own default preset tracks a Settings change made while the board runs.
434
+ */
435
+ get sessionDefaultPermission(): TaskPermission {
436
+ return this.sessionDefault()
437
+ }
438
+
406
439
  /**
407
440
  * Deployment subtask depth limit (1..3): the lineage gate every write obeys.
408
441
  * The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
@@ -410,8 +443,10 @@ export class HostTaskLedger {
410
443
  */
411
444
  private depthLimit: number
412
445
 
413
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
414
- this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
446
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
447
+ // Resolved before the load/repair passes below: they can evaluate the gate.
448
+ const baseline = options.sessionDefaultPermission
449
+ this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
415
450
  this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
416
451
  mkdirSync(dir, { recursive: true })
417
452
  this.file = join(dir, 'ledger-v2.json')
@@ -426,6 +461,10 @@ export class HostTaskLedger {
426
461
  }
427
462
  this.repairSchedules(true)
428
463
  this.reconcileInterruptedStarts()
464
+ // Boot recovery also folds every already-decided run: a team run whose
465
+ // Lead recorded its verdict in an earlier process, and a lineage whose
466
+ // last settle never reached its parent, both leave the running column here.
467
+ this.finalizeReadyRuns(false)
429
468
  // Persist a freshly generated ledger identity and any recovery error
430
469
  // immediately, even when there are no tasks to trigger a later action.
431
470
  this.commit(false)
@@ -488,6 +527,15 @@ export class HostTaskLedger {
488
527
  */
489
528
  runtimeView(): LedgerRuntimeView {
490
529
  let armedSchedules = 0
530
+ // Run groups opened by a team-mode card: every other member of those groups
531
+ // runs as a teammate inside that card's Lead session.
532
+ const teamGroups = new Set<string>()
533
+ for (const task of this.document.tasks) {
534
+ if (task.teamRun !== true) continue
535
+ for (const execution of task.executions) {
536
+ if (execution.runGroupId !== undefined) teamGroups.add(execution.runGroupId)
537
+ }
538
+ }
491
539
  const openExecutions: OpenExecutionReference[] = []
492
540
  for (const task of this.document.tasks) {
493
541
  if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
@@ -501,6 +549,9 @@ export class HostTaskLedger {
501
549
  executionId: execution.id,
502
550
  sessionId: execution.sessionId,
503
551
  startedAt: execution.startedAt,
552
+ teamMember: task.teamRun !== true
553
+ && execution.runGroupId !== undefined
554
+ && teamGroups.has(execution.runGroupId),
504
555
  })
505
556
  }
506
557
  }
@@ -543,7 +594,7 @@ export class HostTaskLedger {
543
594
  if (task.archivedAt !== undefined) continue
544
595
  const schedule = task.schedule
545
596
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
546
- due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
597
+ due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
547
598
  }
548
599
  return due
549
600
  }
@@ -613,7 +664,7 @@ export class HostTaskLedger {
613
664
  rollForward()
614
665
  return []
615
666
  }
616
- if (task.status === 'running' || hasOpenExecution(task)) {
667
+ if (hasOpenExecution(task)) {
617
668
  rollForward()
618
669
  return []
619
670
  }
@@ -626,7 +677,7 @@ export class HostTaskLedger {
626
677
  const schedule = task.schedule
627
678
  if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
628
679
  changed = true
629
- return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
680
+ return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
630
681
  })
631
682
  if (changed) this.commit()
632
683
  }
@@ -681,20 +732,48 @@ export class HostTaskLedger {
681
732
  })
682
733
  changed = true
683
734
  }
735
+ // A team run is governed by its Lead: the Lead's own outcome closes whatever
736
+ // the Team still holds open, so the tree can never wait on a teammate that
737
+ // will never report one.
738
+ if (task.teamRun === true
739
+ && this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true
684
740
  if (this.settleCascade(taskId, groupId, now)) changed = true
685
741
  if (changed) this.commit()
686
742
  }
687
743
 
688
744
  attachSession(taskId: string, executionId: string, sessionId: string): void {
689
745
  const now = this.now()
690
- this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
691
- ...task,
746
+ const task = this.document.tasks.find(item => item.id === taskId)
747
+ // A run the Lead's verdict already closed keeps its record: attaching a
748
+ // session to a settled execution would rewrite history the Host no longer
749
+ // observes (a teammate spawn that resolved after its Team was closed).
750
+ if (task?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) return
751
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
752
+ ...item,
692
753
  updatedAt: now,
693
- executions: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
754
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
694
755
  })
695
756
  this.commit()
696
757
  }
697
758
 
759
+ allTasks(): readonly TaskRecord[] {
760
+ return this.document.tasks
761
+ }
762
+
763
+ getTask(id: string): TaskRecord | undefined {
764
+ return this.document.tasks.find(item => item.id === id)
765
+ }
766
+
767
+ saveTaskRecord(task: TaskRecord): void {
768
+ const exists = this.document.tasks.some(item => item.id === task.id)
769
+ if (exists) {
770
+ this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
771
+ } else {
772
+ this.document.tasks = [...this.document.tasks, task]
773
+ }
774
+ this.commit()
775
+ }
776
+
698
777
  private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
699
778
  const now = this.now()
700
779
  let runs: OpenedRun[] | undefined
@@ -717,17 +796,22 @@ export class HostTaskLedger {
717
796
  : `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
718
797
  this.repairSchedules(true, false)
719
798
  this.reconcileInterruptedStarts(false)
799
+ // An imported document may carry a decided-but-open run; fold it in the
800
+ // same action (apply() commits once at the end).
801
+ this.finalizeReadyRuns(false)
720
802
  break
721
803
  }
722
804
  case 'create': {
723
805
  if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
724
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
806
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
807
+ || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
808
+ || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
725
809
  throw new Error('invalid schedule')
726
810
  }
727
811
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
728
812
  ? action.input
729
813
  : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
730
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
814
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
731
815
  if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
732
816
  this.document.tasks = [...result.tasks]
733
817
  break
@@ -755,7 +839,7 @@ export class HostTaskLedger {
755
839
  case 'delete': {
756
840
  const task = this.document.tasks.find(task => task.id === action.taskId)
757
841
  if (task === undefined) throw new Error('task not found')
758
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
842
+ if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
759
843
  // Subtasks keep their link: deleting the parent would leave dangling
760
844
  // children, so the user detaches or deletes them explicitly first.
761
845
  if (this.document.tasks.some(item => item.parentId === action.taskId)) {
@@ -774,7 +858,10 @@ export class HostTaskLedger {
774
858
  const task = this.document.tasks.find(item => item.id === action.taskId)
775
859
  if (task === undefined) throw new Error('task not found')
776
860
  if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
777
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved')
861
+ // The lock belongs to an open execution, not to the column: a card
862
+ // parked in 'running' by hand has no session and stays movable, while
863
+ // a card the runner is executing cannot be moved out from under it.
864
+ if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
778
865
  if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
779
866
  this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
780
867
  break
@@ -786,6 +873,34 @@ export class HostTaskLedger {
786
873
  this.document.tasks = [...result.tasks]
787
874
  break
788
875
  }
876
+ case 'settle': {
877
+ const task = this.document.tasks.find(item => item.id === action.taskId)
878
+ if (task === undefined) throw new Error('task not found')
879
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
880
+ if (!task.executions.some(entry => entry.endedAt === undefined)) throw new Error('task has no open execution')
881
+ // Force-close the executions this card governs. The verdict is
882
+ // 'cancelled', never 'succeeded': a card whose execution produced no
883
+ // evidence of a completed turn must not be recorded as finished work,
884
+ // and a cancelled settlement returns the card to the todo column so the
885
+ // operator can run it again. Nothing else clears a running card: move,
886
+ // archive and delete all refuse one, which is what made a stuck card
887
+ // unrecoverable from the board.
888
+ const reason = 'execution closed manually by '
889
+ + (initiator === undefined || initiator === '' ? 'the operator' : initiator)
890
+ + ': no verdict was recorded'
891
+ for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) {
892
+ for (const execution of member.executions) {
893
+ if (execution.endedAt !== undefined) continue
894
+ this.document.tasks = this.document.tasks.map(item => item.id !== member.id
895
+ ? item
896
+ : settleExecution(item, execution.id, 'cancelled', now, reason))
897
+ }
898
+ }
899
+ // Fold whatever became ready in the same action: an ancestor whose last
900
+ // member this closed leaves the running column with it.
901
+ this.finalizeReadyRuns(false)
902
+ break
903
+ }
789
904
  case 'restore': {
790
905
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
791
906
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
@@ -802,10 +917,22 @@ export class HostTaskLedger {
802
917
  : item)
803
918
  break
804
919
  }
920
+ case 'rename-tag': {
921
+ const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
922
+ if (result.error !== undefined) throw new Error(result.error)
923
+ if (result.changed) this.document.tasks = [...result.tasks]
924
+ break
925
+ }
926
+ case 'delete-tag': {
927
+ const result = applyDeleteTag(this.document.tasks, action.name, now)
928
+ if (result.error !== undefined) throw new Error(result.error)
929
+ if (result.changed) this.document.tasks = [...result.tasks]
930
+ break
931
+ }
805
932
  case 'set-schedule': {
806
933
  const task = this.document.tasks.find(task => task.id === action.taskId)
807
934
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
808
- const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
935
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone())
809
936
  if (!result.applied) throw new Error('invalid schedule')
810
937
  this.document.tasks = [...result.tasks]
811
938
  break
@@ -814,7 +941,7 @@ export class HostTaskLedger {
814
941
  case 'run': {
815
942
  const task = this.document.tasks.find(item => item.id === action.taskId)
816
943
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
817
- if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
944
+ if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
818
945
  // The confirmation gate judges the RESOLVED binding: a subtask that
819
946
  // inherits an elevated permission from its parent is exactly as
820
947
  // unconfirmed as the parent would be without its own stamp.
@@ -835,10 +962,12 @@ export class HostTaskLedger {
835
962
  *
836
963
  * A plain cascade launches one session per participant, so every participant
837
964
  * carries its own resolved binding and each one gates the run. A team run
838
- * launches only the Lead session: the Lead's binding gates it, while a
839
- * subtask's OWN above-default pin cannot be applied to a teammate and is
840
- * refused instead of being silently dropped (an inherited binding is the
841
- * Lead's own and stays allowed once the Lead is confirmed).
965
+ * launches only the Lead session: the Lead's binding gates it, and a
966
+ * teammate inherits that session's permission, so a subtask's OWN pin is
967
+ * refused exactly when the Lead session does not already carry it — the
968
+ * teammate could never be given that authority, and dropping the pin
969
+ * silently would misreport the work (an inherited binding is the Lead's own
970
+ * and stays allowed once the Lead is confirmed).
842
971
  * @param root - the task being run.
843
972
  * @returns the first refusal, or undefined when the run may start.
844
973
  */
@@ -849,12 +978,17 @@ export class HostTaskLedger {
849
978
  if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
850
979
  return { kind: 'root', title: lead.title }
851
980
  }
852
- // The raw record carries the subtask's OWN binding: the resolved
853
- // participant above already folded the inherited one into its permission.
981
+ // The Lead session runs at the Lead's own binding, or at the deployment
982
+ // default when it pins none; a teammate inherits that permission and
983
+ // cannot be narrowed below it, so it carries a subtask pin when it is
984
+ // already at least as wide. The raw record carries the subtask's OWN
985
+ // binding: the resolved participant above already folded an inherited one
986
+ // into its permission.
987
+ const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
854
988
  const pinned = participants.find(participant => {
855
989
  if (participant.id === root.id) return false
856
990
  const raw = this.document.tasks.find(task => task.id === participant.id)
857
- return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
991
+ return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
858
992
  })
859
993
  return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
860
994
  }
@@ -873,14 +1007,13 @@ export class HostTaskLedger {
873
1007
  return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
874
1008
  .map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
875
1009
  .filter(participant => participant.archivedAt === undefined
876
- && participant.status !== 'running'
877
1010
  && !hasOpenExecution(participant))
878
1011
  }
879
1012
 
880
1013
  /** Whether a task or any of its subtasks still has an open execution. */
881
1014
  private subtreeHasOpenExecution(id: string): boolean {
882
1015
  return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
883
- .some(task => task.status === 'running' || hasOpenExecution(task))
1016
+ .some(task => hasOpenExecution(task))
884
1017
  }
885
1018
 
886
1019
  /**
@@ -905,7 +1038,7 @@ export class HostTaskLedger {
905
1038
  const runs: OpenedRun[] = []
906
1039
  for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
907
1040
  if (task.archivedAt !== undefined) continue
908
- if (task.status === 'running' || hasOpenExecution(task)) continue
1041
+ if (hasOpenExecution(task)) continue
909
1042
  const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
910
1043
  const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
911
1044
  started.set(task.id, opened.task)
@@ -959,6 +1092,95 @@ export class HostTaskLedger {
959
1092
  return changed
960
1093
  }
961
1094
 
1095
+ /**
1096
+ * Close every run that is already decided, and fold every lineage whose
1097
+ * members are all settled. The Host poll calls this on every tick, so a run
1098
+ * can no longer be left in the running column by a settle nobody observed:
1099
+ *
1100
+ * - A team run is governed by its Lead. Once the Lead's own outcome is
1101
+ * recorded, its still-open members are settled with that verdict. A
1102
+ * teammate is a durable member of the Team and may never report a turn of
1103
+ * its own, and without this the whole lineage waits on it forever.
1104
+ * - A cascade parent whose members are all settled is finalized here even
1105
+ * when the settle that completed the tree happened in another process, or
1106
+ * was interrupted between its child write and its parent write.
1107
+ *
1108
+ * Idempotent: a run that is already folded is left untouched, so a caller may
1109
+ * invoke it on every tick.
1110
+ * @param persist - false to leave the document for the caller to commit.
1111
+ * @returns whether the document changed.
1112
+ */
1113
+ finalizeReadyRuns(persist = true): boolean {
1114
+ const now = this.now()
1115
+ let changed = false
1116
+ const groups = new Set<string>()
1117
+ for (const task of this.document.tasks) {
1118
+ for (const execution of task.executions) {
1119
+ if (execution.endedAt === undefined && execution.runGroupId !== undefined) groups.add(execution.runGroupId)
1120
+ }
1121
+ }
1122
+ for (const groupId of groups) {
1123
+ const lead = this.document.tasks.find(task => task.teamRun === true
1124
+ && task.executions.some(entry => entry.runGroupId === groupId))
1125
+ const verdict = lead === undefined ? undefined : this.leadVerdict(lead, groupId)
1126
+ if (lead !== undefined && verdict !== undefined
1127
+ && this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true
1128
+ // Fold the lineage: a member may itself be a parent of a deeper member.
1129
+ for (const task of this.document.tasks) {
1130
+ if (this.settleCascade(task.id, groupId, now)) changed = true
1131
+ }
1132
+ }
1133
+ if (changed && persist) this.commit()
1134
+ return changed
1135
+ }
1136
+
1137
+ /**
1138
+ * The verdict a team run's Lead has already recorded: its own turn outcome
1139
+ * once that turn ended, or the folded outcome once its card settled.
1140
+ */
1141
+ private leadVerdict(lead: TaskRecord, groupId: string): { result: ExecutionOutcome; error: string | undefined } | undefined {
1142
+ const execution = lead.executions.find(entry => entry.runGroupId === groupId)
1143
+ const result = execution?.ownResult ?? execution?.result
1144
+ if (result === undefined) return undefined
1145
+ return { result, error: execution?.ownError ?? execution?.error }
1146
+ }
1147
+
1148
+ /**
1149
+ * Settle every still-open member of a team run with the Lead's verdict.
1150
+ * A teammate that already reported its own outcome keeps it: the ordinary
1151
+ * fold still lets a failure dominate.
1152
+ * @param leadId - the Lead card's task id (never settled here).
1153
+ * @param groupId - the team run's group.
1154
+ * @param verdict - the Lead's outcome, inherited by members that never reported.
1155
+ * @param error - the Lead's failure text, inherited with a failed verdict.
1156
+ * @param now - clock instant (ms epoch).
1157
+ * @returns whether the document changed.
1158
+ */
1159
+ private closeTeamMembers(leadId: string, groupId: string, verdict: ExecutionOutcome, error: string | undefined, now: number): boolean {
1160
+ const members: string[] = []
1161
+ let recorded = false
1162
+ this.document.tasks = this.document.tasks.map(task => {
1163
+ if (task.id === leadId) return task
1164
+ const execution = openGroupExecution(task, groupId)
1165
+ if (execution === undefined) return task
1166
+ members.push(task.id)
1167
+ if (execution.ownResult !== undefined) return task
1168
+ recorded = true
1169
+ return {
1170
+ ...task,
1171
+ updatedAt: now,
1172
+ executions: task.executions.map(entry => entry.id === execution.id
1173
+ ? { ...entry, ownResult: verdict, ownError: error }
1174
+ : entry),
1175
+ }
1176
+ })
1177
+ let changed = recorded
1178
+ for (const memberId of members) {
1179
+ if (this.settleCascade(memberId, groupId, now)) changed = true
1180
+ }
1181
+ return changed
1182
+ }
1183
+
962
1184
  private repairSchedules(skipPast: boolean, persist = true): void {
963
1185
  const now = this.now()
964
1186
  let changed = false
@@ -966,7 +1188,7 @@ export class HostTaskLedger {
966
1188
  const schedule = task.schedule
967
1189
  if (schedule === undefined || !schedule.enabled) return task
968
1190
  if (!skipPast && schedule.nextRunAt !== undefined) return task
969
- const next = nextRunAtMs(schedule.cron, now)
1191
+ const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
970
1192
  if (next === undefined) {
971
1193
  changed = true
972
1194
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
@@ -984,7 +1206,6 @@ export class HostTaskLedger {
984
1206
  let changed = false
985
1207
  const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
986
1208
  this.document.tasks = this.document.tasks.map(task => {
987
- if (task.status !== 'running') return task
988
1209
  const execution = task.executions.at(-1)
989
1210
  if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
990
1211
  changed = true
@@ -1003,35 +1224,105 @@ export class HostTaskLedger {
1003
1224
  }
1004
1225
 
1005
1226
  /**
1006
- * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
1007
- * migration reuses the v3 normalization, but it first proves every task
1008
- * row is structurally valid: a v2 document that would silently drop or
1009
- * coerce rows fails loudly instead (no quarantined-empty restart).
1227
+ * Field-preserving migration of an older document. It first proves every task
1228
+ * row is structurally valid, so a document that would silently drop or coerce
1229
+ * rows fails loudly instead (no quarantined-empty restart), and then reuses
1230
+ * the current normalization.
1231
+ *
1232
+ * v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
1233
+ * whatever zone the Host process happened to report, so the migration stamps
1234
+ * that zone onto every enabled rule: the trigger instant is preserved exactly
1235
+ * (the stored `nextRunAt` already encodes the old zone), but the rule stops
1236
+ * following a later `TZ` change, which is what made an existing schedule
1237
+ * silently move when the Host's zone changed.
1238
+ *
1239
+ * v5 adds the per-execution acceptance block. The migration deliberately does
1240
+ * NOT stamp one: an execution opened before v5 keeps no contract, so it
1241
+ * settles on its own historical verdict and no in-flight run is retroactively
1242
+ * judged by a gate that was never armed for it.
1010
1243
  */
1011
1244
  private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
1012
1245
  if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
1013
- throw new Error('v2 document contains structurally invalid task rows')
1246
+ throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
1247
+ }
1248
+ // Every row passed `isTaskRecord` above, so the stamped rows are still
1249
+ // task records; the cast only re-narrows the unknown-typed on-disk array.
1250
+ const rows = (parsed.tasks as readonly unknown[]).map((value) => {
1251
+ const row = value as { schedule?: unknown }
1252
+ const schedule = row.schedule
1253
+ if (typeof schedule !== 'object' || schedule === null) return value
1254
+ if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
1255
+ return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
1256
+ })
1257
+ return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
1258
+ }
1259
+
1260
+ /**
1261
+ * Find the still-open execution that ran in one session, with its task.
1262
+ *
1263
+ * The completion gate keys on the session id its tool call arrives from: the
1264
+ * execution record is the only object binding a session to a card, and an
1265
+ * already-settled execution is deliberately not returned (a session the board
1266
+ * no longer owns must not be gated).
1267
+ * @param sessionId - the DSH session the tool call runs in.
1268
+ * @returns the open execution and its task, or undefined.
1269
+ */
1270
+ findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
1271
+ if (sessionId === '') return undefined
1272
+ for (const task of this.document.tasks) {
1273
+ for (const execution of task.executions) {
1274
+ if (execution.endedAt !== undefined) continue
1275
+ if (execution.sessionId === sessionId) return { task, execution }
1276
+ }
1014
1277
  }
1015
- return this.normalizeDocument(parsed)
1278
+ return undefined
1279
+ }
1280
+
1281
+ /**
1282
+ * Replace one execution's acceptance block (creating it when absent).
1283
+ *
1284
+ * A no-op when the serialized value is unchanged, so a coarse caller that
1285
+ * re-writes the same state never bumps the ledger revision. A settled
1286
+ * execution keeps its record: the report of a finished cycle must survive,
1287
+ * and only the board's own settlement moves the card.
1288
+ * @param taskId - the task owning the execution.
1289
+ * @param executionId - the execution to update.
1290
+ * @param verification - the new block.
1291
+ * @returns true when the ledger changed.
1292
+ */
1293
+ setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
1294
+ const now = this.now()
1295
+ const task = this.document.tasks.find(item => item.id === taskId)
1296
+ const execution = task?.executions.find(entry => entry.id === executionId)
1297
+ if (task === undefined || execution === undefined) return false
1298
+ if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
1299
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
1300
+ ...item,
1301
+ updatedAt: now,
1302
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
1303
+ })
1304
+ this.commit()
1305
+ return true
1016
1306
  }
1017
1307
 
1018
1308
  private load(dir: string): LedgerDocument {
1019
1309
  const existed = existsSync(this.file)
1020
- // schemaVersion stays unknown-typed here: on-disk documents may be v2
1021
- // (legacy), v3, or any future/invalid value the branches below sort out.
1310
+ // schemaVersion stays unknown-typed here: on-disk documents may be v2 or
1311
+ // v3 (legacy), v4, or any future/invalid value the branches below sort out.
1022
1312
  let parsed: ParsedLedgerDocument
1023
1313
  try {
1024
1314
  parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
1025
1315
  } catch (error) {
1026
1316
  return this.recoverCorrupt(dir, existed, error)
1027
1317
  }
1028
- if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION) {
1318
+ if (typeof parsed.schemaVersion === 'number'
1319
+ && TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
1029
1320
  try {
1030
1321
  return this.migrateLegacyDocument(parsed)
1031
1322
  } catch (error) {
1032
- // Migration failure is explicit: the original v2 file stays in place
1323
+ // Migration failure is explicit: the original file stays in place
1033
1324
  // for manual recovery and the ledger refuses to start (fail closed).
1034
- throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
1325
+ throw new Error(`ledger v${String(parsed.schemaVersion)} to v${TASK_BOARD_SCHEMA_VERSION} migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
1035
1326
  }
1036
1327
  }
1037
1328
  try {
@@ -1246,4 +1537,4 @@ export class HostTaskLedger {
1246
1537
  }
1247
1538
  throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
1248
1539
  }
1249
- }
1540
+ }