@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
package/src/core/tasks.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * unit-testable in isolation.
6
6
  */
7
7
  import type { FreezeSnapshot } from './freeze-snapshot.ts'
8
+ import type { ExecutionVerification } from './verification.ts'
8
9
  import type { TaskHandover, TaskHandoverInput } from './handover.ts'
10
+ import { normalizeTaskIntegrations, type TaskBoardExtensionPayload } from './extension.ts'
9
11
 
10
12
  /** Task lifecycle status, one per kanban column. */
11
13
  export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
@@ -55,6 +57,14 @@ export interface ExecutionRecord {
55
57
  ownResult?: ExecutionOutcome
56
58
  /** Human failure text that arrived with {@link ownResult}. */
57
59
  ownError?: string
60
+ /**
61
+ * Goal acceptance state of this execution (task-board goal verification).
62
+ * Fixed when the execution starts and never rewritten from later settings:
63
+ * the contract is frozen here, the attempts accrue here, and the settlement
64
+ * rule reads the pass record here. Absent on an execution that started
65
+ * before the feature existed, which keeps it on the historical verdict.
66
+ */
67
+ verification?: ExecutionVerification
58
68
  }
59
69
 
60
70
  /**
@@ -87,6 +97,13 @@ export interface ScheduleRule {
87
97
  enabled: boolean
88
98
  /** 5-field cron expression: `分 时 日 月 周`. */
89
99
  cron: string
100
+ /**
101
+ * IANA zone the cron wall clock is read in (issue #1722). Absent means the
102
+ * Host process zone, which is what a rule written before zones were
103
+ * persisted keeps using; the Host stamps its own zone at ledger migration so
104
+ * a later `TZ` change cannot silently move an existing rule.
105
+ */
106
+ timeZone?: string
90
107
  /** Next due instant (ms epoch); maintained by the scheduler/controller. */
91
108
  nextRunAt: number | undefined
92
109
  /** Instant of the latest scheduled trigger (ms epoch). */
@@ -278,6 +295,30 @@ export interface TaskRecord {
278
295
  * and a subtask's own permission pin cannot be honored in this mode.
279
296
  */
280
297
  teamRun?: boolean
298
+ /**
299
+ * Start every execution of this task with dsh's built-in `/goal` command,
300
+ * armed with the composed execution prompt as its objective. The session then
301
+ * keeps working automatic continuation rounds until the agent marks the goal
302
+ * complete, and this board settles the execution on the goal's own end
303
+ * instead of at the first turn end. Absent means ON — the option is checked
304
+ * by default, so only an explicit `false` runs the task as a single plain
305
+ * turn. A session whose `/goal` command is refused still runs the prompt.
306
+ */
307
+ goalRun?: boolean
308
+ /**
309
+ * Run this task's goal executions WITHOUT the board's acceptance gate.
310
+ *
311
+ * Absent means INHERIT: the execution's applicability is decided by the
312
+ * board-wide `goalVerification` switch frozen at start, exactly as a card
313
+ * that never touched this option behaves. Only an explicit `true` opts the
314
+ * card out, and the execution records `applicability: 'disabled'` so the
315
+ * report says plainly that nothing certified this run.
316
+ *
317
+ * The opt-out is per execution, not per run: flipping it never rewrites the
318
+ * verdict rule of an execution already open, and a rerun or a scheduled
319
+ * occurrence reads the card's current value when it freezes its contract.
320
+ */
321
+ skipVerification?: boolean
281
322
  /**
282
323
  * Frozen context snapshot for a continuation card; absent on plain tasks.
283
324
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -309,6 +350,20 @@ export interface TaskRecord {
309
350
  * absent means on-board.
310
351
  */
311
352
  archivedAt?: number
353
+ /**
354
+ * Opaque external-provider metadata, keyed by extension id; optional and
355
+ * additive so existing tasks require no ledger migration. The board stores
356
+ * and forwards it without interpreting the keys — each provider owns the
357
+ * shape and validation of its own entry.
358
+ */
359
+ integrations?: Record<string, TaskBoardExtensionPayload>
360
+ /**
361
+ * Whether the card is held off the board: it exists in the ledger (and its
362
+ * execution history is intact) but no column ever renders it. Providers
363
+ * create hidden cards for work they materialize but do not want displayed;
364
+ * absent means on-board.
365
+ */
366
+ hidden?: boolean
312
367
  }
313
368
 
314
369
  /**
@@ -342,7 +397,7 @@ export interface NewTaskInput {
342
397
  * inherits the parent's unset execution targets.
343
398
  */
344
399
  parentId?: string
345
- /** Workspace the execution must run in; empty/absent = the recent workspace. */
400
+ /** Workspace the execution must run in; empty/absent = the creating session's workspace for a root task, else the recent workspace at execution time. */
346
401
  workspaceId?: string
347
402
  /** Agent preset the execution session must be composed from; empty/absent = deployment default. */
348
403
  mode?: string
@@ -354,12 +409,23 @@ export interface NewTaskInput {
354
409
  reuseSession?: boolean
355
410
  /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
356
411
  teamRun?: boolean
412
+ /**
413
+ * Start the execution with dsh's built-in `/goal`. Absent/true keeps the
414
+ * default (goal run); an explicit false requests a single plain turn.
415
+ */
416
+ goalRun?: boolean
417
+ /**
418
+ * Run this task's goal executions without the acceptance gate; absent
419
+ * inherits the board-wide `goalVerification` switch.
420
+ */
421
+ skipVerification?: boolean
357
422
  /**
358
423
  * Optional scheduled-run rule requested at creation time (the new-task
359
- * dialog): an enable flag plus a 5-field cron expression. The create use
360
- * case arms it only when enabled and the expression is valid.
424
+ * dialog): an enable flag, a 5-field cron expression, and the IANA zone its
425
+ * wall clock is read in (absent means the Host zone). The create use case
426
+ * arms it only when enabled and the expression is valid.
361
427
  */
362
- schedule?: { enabled: boolean; cron: string }
428
+ schedule?: { enabled: boolean; cron: string; timeZone?: string }
363
429
  /**
364
430
  * Optional frozen context snapshot (goal/progress/next, sanitized by the
365
431
  * protocol gate) turning the new task into a continuation card.
@@ -375,6 +441,12 @@ export interface NewTaskInput {
375
441
  * ahead of the execution prompt; a bare name changes nothing at run time.
376
442
  */
377
443
  tags?: TaskTag[]
444
+ /** Opaque external-provider metadata keyed by extension id. */
445
+ integrations?: Record<string, TaskBoardExtensionPayload>
446
+ /** Column the created card opens in; absent means the board default (todo). */
447
+ status?: TaskStatus
448
+ /** Create the card off-board (held in the ledger, never rendered). */
449
+ hidden?: boolean
378
450
  }
379
451
 
380
452
  /** The five kanban columns, in display order. */
@@ -386,8 +458,20 @@ export const COLUMNS: readonly { status: TaskStatus; label: string }[] = [
386
458
  { status: 'failed', label: '已失败' },
387
459
  ]
388
460
 
389
- /** Statuses a user may move a card to manually (execution states are owned by the runner). */
390
- export const MANUAL_STATUSES: readonly TaskStatus[] = ['backlog', 'todo']
461
+ /**
462
+ * Statuses a card may be moved to by hand: every column. The board UI and the
463
+ * agent tool surface share this one list, so no column can be manual on one
464
+ * surface and locked on the other.
465
+ *
466
+ * The column is a board statement, not a claim about a run. A hand-written
467
+ * `done`/`failed` declares that the work finished (or failed) outside a
468
+ * Host-run execution — human hands, an external system — and a hand-written
469
+ * `running` says the work is under way without a tracked session. None of them
470
+ * creates an {@link ExecutionRecord}, which is what keeps a declaration
471
+ * distinguishable from a recorded outcome in the card's execution history; the
472
+ * next settled run overwrites the column with the real one.
473
+ */
474
+ export const MANUAL_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'running', 'done', 'failed']
391
475
 
392
476
  /** Statuses the runner may move a card to from 'running'. */
393
477
  export const RUNNER_SETTLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
@@ -402,9 +486,35 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
402
486
  return typeof value === 'string' && (ALL_STATUSES as readonly string[]).includes(value)
403
487
  }
404
488
 
405
- /** Whether a manual move target is allowed from the given status. */
489
+ /**
490
+ * Whether the manual move `from` -> `to` is a legal *column* change: any
491
+ * column, and never the one the card already shows.
492
+ *
493
+ * Whether the card may be moved at all is a separate question. The lock an
494
+ * executing card carries belongs to its open execution, never to the column
495
+ * text, so it is {@link canMoveTask} — not this predicate — that expresses it.
496
+ */
406
497
  export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
407
- return from !== 'running' && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
498
+ return from !== to && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
499
+ }
500
+
501
+ /**
502
+ * Whether the board may move `task` to `to` by hand: the card is on-board,
503
+ * holds no open execution, and the target is a legal column change. The ledger,
504
+ * the detail view's status buttons and the board's drop handler all decide
505
+ * through this one predicate, so no two surfaces can disagree about a card.
506
+ */
507
+ export function canMoveTask(task: TaskRecord, to: TaskStatus): boolean {
508
+ return task.archivedAt === undefined && !hasOpenExecution(task) && canMoveManually(task.status, to)
509
+ }
510
+
511
+ /**
512
+ * Whether the task still has an execution the runner has not settled. This —
513
+ * not the `running` column — is what "the runner owns this card" means: a card
514
+ * parked in `running` by hand has no session and stays fully movable.
515
+ */
516
+ export function hasOpenExecution(task: TaskRecord): boolean {
517
+ return task.executions.some(execution => execution.endedAt === undefined)
408
518
  }
409
519
 
410
520
  /** Normalize one optional execution-target string: trim; blank collapses to undefined. */
@@ -434,13 +544,14 @@ export function freezeOf(
434
544
  /** Create a task from user input. */
435
545
  export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
436
546
  const tags = normalizeTags(input.tags)
547
+ const integrations = normalizeTaskIntegrations(input.integrations)
437
548
  return {
438
549
  id,
439
550
  title: input.title.trim(),
440
551
  description: input.description.trim(),
441
552
  prompt: input.prompt.trim(),
442
553
  parentId: normalizeTargetId(input.parentId),
443
- status: 'todo',
554
+ status: input.status ?? 'todo',
444
555
  createdAt: now,
445
556
  updatedAt: now,
446
557
  executions: [],
@@ -449,10 +560,20 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
449
560
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
450
561
  model: normalizeTargetId(input.model),
451
562
  reuseSession: input.reuseSession === true ? true : undefined,
452
- teamRun: input.teamRun === true ? true : undefined,
563
+ teamRun: input.teamRun === true ? true : undefined,
564
+ // Default ON: only an explicit false is stored, so a card that never
565
+ // touched the option (and every card written before the field existed)
566
+ // keeps starting its runs with /goal.
567
+ goalRun: input.goalRun === false ? false : undefined,
568
+ // Default OFF: only an explicit true is stored, so a card that never
569
+ // touched the option inherits the board-wide acceptance switch exactly as
570
+ // every card written before the field existed does.
571
+ skipVerification: input.skipVerification === true ? true : undefined,
453
572
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
454
573
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
455
574
  ...(tags === undefined ? {} : { tags }),
575
+ ...(integrations === undefined ? {} : { integrations }),
576
+ ...(input.hidden === true ? { hidden: true } : {}),
456
577
  }
457
578
  }
458
579
 
@@ -476,11 +597,18 @@ export function withSchedule(
476
597
  const schedule: ScheduleRule = {
477
598
  enabled: current?.enabled ?? false,
478
599
  cron: current?.cron ?? '',
600
+ ...(current?.timeZone === undefined ? {} : { timeZone: current.timeZone }),
479
601
  nextRunAt: current?.nextRunAt,
480
602
  lastTriggeredAt: current?.lastTriggeredAt,
481
603
  }
482
604
  if ('enabled' in patch) schedule.enabled = patch.enabled ?? false
483
605
  if ('cron' in patch) schedule.cron = patch.cron ?? ''
606
+ // An explicit `undefined` drops the stored zone (back to the Host zone);
607
+ // an absent key keeps it.
608
+ if ('timeZone' in patch) {
609
+ if (patch.timeZone === undefined) delete schedule.timeZone
610
+ else schedule.timeZone = patch.timeZone
611
+ }
484
612
  if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
485
613
  if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
486
614
  return { ...task, updatedAt: now, schedule }
@@ -4,7 +4,7 @@
4
4
  * (no persistence or notify - the controller orchestrates those), so it is
5
5
  * unit-testable without any runtime face.
6
6
  */
7
- import { isValidCron, nextRunAtMs } from '../schedule.ts'
7
+ import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
8
8
  import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
9
9
  import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
10
10
 
@@ -32,6 +32,7 @@ export interface CreateTaskResult {
32
32
  * @param now - clock instant (ms epoch).
33
33
  * @param id - minted task id.
34
34
  * @param maxSubtaskDepth - deployment subtask depth limit.
35
+ * @param hostTimeZone - zone a rule that stores no zone of its own follows.
35
36
  */
36
37
  export function applyCreateTask(
37
38
  tasks: readonly TaskRecord[],
@@ -39,6 +40,7 @@ export function applyCreateTask(
39
40
  now: number,
40
41
  id: string,
41
42
  maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
43
+ hostTimeZone: string = resolveHostTimeZone(),
42
44
  ): CreateTaskResult {
43
45
  if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
44
46
  const limit = normalizeSubtaskDepth(maxSubtaskDepth)
@@ -71,7 +73,17 @@ export function applyCreateTask(
71
73
  const requested = input.schedule
72
74
  if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
73
75
  const cron = requested.cron.trim()
74
- task = withSchedule(task, { enabled: true, cron, nextRunAt: nextRunAtMs(cron, now) }, now)
76
+ const requestedZone = requested.timeZone
77
+ // An unusable zone is refused rather than silently reinterpreted as the
78
+ // Host zone: the caller asked for a specific wall clock.
79
+ if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
80
+ return { task: undefined, tasks, error: 'invalid schedule time zone' }
81
+ }
82
+ const zone = requestedZone ?? hostTimeZone
83
+ const nextRunAt = nextRunAtMs(cron, now, zone)
84
+ if (nextRunAt !== undefined) {
85
+ task = withSchedule(task, { enabled: true, cron, timeZone: requestedZone, nextRunAt }, now)
86
+ }
75
87
  }
76
88
  return { task, tasks: [...tasks, task] }
77
89
  }
@@ -49,8 +49,9 @@ export function applySetParent(
49
49
  if (task.archivedAt !== undefined) return { tasks, applied: false, error: 'archived task is read-only' }
50
50
  // A task with an open execution may be a participant of a running cascade.
51
51
  // Moving it would break that group's parent walk and leave the parent card
52
- // in the running column forever, so the link is frozen until it settles.
53
- if (task.status === 'running' || task.executions.some(execution => execution.endedAt === undefined)) {
52
+ // in the running column forever, so the link is frozen until it settles. The
53
+ // lock is the open execution: a card parked in 'running' by hand links freely.
54
+ if (task.executions.some(execution => execution.endedAt === undefined)) {
54
55
  return { tasks, applied: false, error: 'running task cannot be re-parented' }
55
56
  }
56
57
  const check = checkParentLink(tasks, id, parentId, normalizeSubtaskDepth(maxSubtaskDepth))
@@ -4,13 +4,15 @@
4
4
  * orchestrates those). Validation and next-run computation live here, sharing
5
5
  * the core cron parser (schedule.ts) and the withSchedule transition.
6
6
  */
7
- import { isValidCron, nextRunAtMs } from '../schedule.ts'
7
+ import { isValidCron, isValidTimeZone, nextRunAtMs } from '../schedule.ts'
8
8
  import { withSchedule, type TaskRecord } from '../tasks.ts'
9
9
 
10
10
  /** Fields the schedule use case may change on a rule. */
11
11
  export interface SetSchedulePatch {
12
12
  enabled?: boolean
13
13
  cron?: string
14
+ /** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
15
+ timeZone?: string | null
14
16
  }
15
17
 
16
18
  /** Result of arming/disarming a rule. */
@@ -22,31 +24,40 @@ export interface SetScheduleResult {
22
24
  }
23
25
 
24
26
  /**
25
- * Set an on-board task's schedule rule. A blank or invalid cron, or an
26
- * archived task, is rejected (state untouched); an enabled rule computes the
27
- * next run instant immediately, a disabled one carries no next-run instant.
27
+ * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
28
+ * archived task, or an unusable zone is rejected (state untouched); an enabled
29
+ * rule computes the next run instant immediately in the rule's own zone, a
30
+ * disabled one carries no next-run instant.
28
31
  * @param tasks - current ledger.
29
32
  * @param id - the task to schedule.
30
33
  * @param patch - rule fields to change (absent fields keep their current value).
31
34
  * @param now - clock instant (ms epoch).
35
+ * @param hostTimeZone - zone a rule with no stored zone follows.
32
36
  */
33
37
  export function applySetSchedule(
34
38
  tasks: readonly TaskRecord[],
35
39
  id: string,
36
40
  patch: SetSchedulePatch,
37
41
  now: number,
42
+ hostTimeZone: string,
38
43
  ): SetScheduleResult {
39
44
  const task = tasks.find(candidate => candidate.id === id)
40
45
  if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
41
46
  const current = task.schedule
42
47
  const cron = (patch.cron ?? current?.cron ?? '').trim()
43
48
  if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
49
+ // An explicit null clears the stored zone; an absent key keeps it.
50
+ const requestedZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
51
+ if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) return { tasks, applied: false }
44
52
  const enabled = patch.enabled ?? current?.enabled ?? false
45
- const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
53
+ const effectiveZone = requestedZone ?? hostTimeZone
54
+ const nextRunAt = enabled ? nextRunAtMs(cron, now, effectiveZone) : undefined
46
55
  if (enabled && nextRunAt === undefined) return { tasks, applied: false }
47
56
  return {
48
57
  tasks: tasks.map(candidate =>
49
- candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
58
+ candidate.id === id
59
+ ? withSchedule(candidate, { enabled, cron, timeZone: requestedZone ?? undefined, nextRunAt }, now)
60
+ : candidate),
50
61
  applied: true,
51
62
  }
52
63
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Label-management use cases (issue: labels could not be cleaned up).
3
+ *
4
+ * A label lives on every task that carries it, so renaming or removing one is a
5
+ * ledger-wide edit, not a task edit: the board's label row is the union of the
6
+ * labels in use, and without these two transitions a label created by an
7
+ * afternoon experiment stayed on the board forever.
8
+ *
9
+ * Both transitions are pure ledger rewrites (no persistence, no notify — the
10
+ * controller and the Host ledger orchestrate those), and both are idempotent:
11
+ * a label that no task carries changes nothing and reports no error for the
12
+ * delete path (the desired end state already holds), while a rename from an
13
+ * unknown label is refused so a stale tab cannot silently do nothing.
14
+ *
15
+ * Renaming onto a name that is already in use MERGES the two: a task carrying
16
+ * both keeps the row it already had (with its execution hint) and loses the
17
+ * duplicate, which is the only reading that cannot lose a hint the user typed.
18
+ */
19
+
20
+ import { TAG_NAME_MAX_LENGTH, collectKnownTags, normalizeTags, type TaskRecord, type TaskTag } from '../tasks.ts'
21
+
22
+ /** Outcome of one ledger-wide label edit. */
23
+ export interface TagEditResult {
24
+ /** The ledger after the edit, or the input unchanged when nothing applied. */
25
+ tasks: readonly TaskRecord[]
26
+ /** Whether any task actually changed (drives whether the caller persists). */
27
+ changed: boolean
28
+ /** Refusal reason; the ledger is unchanged when set. */
29
+ error?: string
30
+ }
31
+
32
+ /** Trim a label name the way creation does. */
33
+ function normalizeName(value: string): string {
34
+ return value.trim()
35
+ }
36
+
37
+ /** Whether any task in the ledger carries this label. */
38
+ function carries(tasks: readonly TaskRecord[], name: string): boolean {
39
+ return collectKnownTags(tasks).some(tag => tag.name === name)
40
+ }
41
+
42
+ /**
43
+ * Rename one label everywhere, merging into an existing label of the target
44
+ * name. Every touched task gets a fresh updatedAt; untouched tasks keep theirs.
45
+ * @param tasks - current ledger.
46
+ * @param from - the label to rename.
47
+ * @param to - the new name (an existing name merges the two labels).
48
+ * @param now - clock instant (ms epoch).
49
+ * @returns the rewritten ledger, or a refusal.
50
+ */
51
+ export function applyRenameTag(
52
+ tasks: readonly TaskRecord[],
53
+ from: string,
54
+ to: string,
55
+ now: number,
56
+ ): TagEditResult {
57
+ const source = normalizeName(from)
58
+ const target = normalizeName(to)
59
+ if (source === '') return { tasks, changed: false, error: 'a label name is required' }
60
+ if (target === '') return { tasks, changed: false, error: 'a label name is required' }
61
+ if (target.length > TAG_NAME_MAX_LENGTH) return { tasks, changed: false, error: 'label name is too long' }
62
+ if (source === target) return { tasks, changed: false }
63
+ if (!carries(tasks, source)) return { tasks, changed: false, error: 'label not found' }
64
+ let changed = false
65
+ const next = tasks.map(task => {
66
+ const tags = task.tags
67
+ if (tags === undefined || !tags.some(tag => tag.name === source)) return task
68
+ changed = true
69
+ // A task that already carried BOTH names keeps the target's own row — the
70
+ // target is the label that survives, so its execution hint stays the one
71
+ // that applies. A task carrying only the source keeps that row's hint under
72
+ // the new name.
73
+ const hasTarget = tags.some(tag => tag.name === target)
74
+ const merged: TaskTag[] = []
75
+ for (const tag of tags) {
76
+ if (hasTarget && tag.name === source) continue
77
+ const name = tag.name === source ? target : tag.name
78
+ if (merged.some(entry => entry.name === name)) continue
79
+ merged.push(name === tag.name ? tag : { ...tag, name })
80
+ }
81
+ return { ...task, tags: normalizeTags(merged), updatedAt: now }
82
+ })
83
+ return { tasks: changed ? next : tasks, changed }
84
+ }
85
+
86
+ /**
87
+ * Remove one label from every task that carries it. Archived cards are included:
88
+ * the board's label row draws from the whole ledger, so a label left on an
89
+ * archived card would come straight back into the filter.
90
+ * @param tasks - current ledger.
91
+ * @param name - the label to remove.
92
+ * @param now - clock instant (ms epoch).
93
+ * @returns the rewritten ledger.
94
+ */
95
+ export function applyDeleteTag(
96
+ tasks: readonly TaskRecord[],
97
+ name: string,
98
+ now: number,
99
+ ): TagEditResult {
100
+ const target = normalizeName(name)
101
+ if (target === '') return { tasks, changed: false, error: 'a label name is required' }
102
+ let changed = false
103
+ const next = tasks.map(task => {
104
+ const tags = task.tags
105
+ if (tags === undefined || !tags.some(tag => tag.name === target)) return task
106
+ changed = true
107
+ return { ...task, tags: normalizeTags(tags.filter(tag => tag.name !== target)), updatedAt: now }
108
+ })
109
+ return { tasks: changed ? next : tasks, changed }
110
+ }
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun' | 'skipVerification'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -43,11 +43,13 @@ export function hasContentPatch(patch: TaskUpdatePatch): boolean {
43
43
 
44
44
  /**
45
45
  * Whether a task's content may still be edited: the task must be on-board
46
- * (not archived) and must never have started executing. Fail-closed: a
47
- * running, settled, or cancelled-before-launch task keeps its content fixed.
46
+ * (not archived) and must never have started executing. Fail-closed: a task
47
+ * with any execution record — running, settled, or cancelled before launch —
48
+ * keeps its content fixed. The run, not the column, is the trigger: a card
49
+ * parked in 'running' by hand has nothing recorded yet and stays editable.
48
50
  */
49
51
  export function canEditTaskContent(task: TaskRecord): boolean {
50
- return task.archivedAt === undefined && task.status !== 'running' && task.executions.length === 0
52
+ return task.archivedAt === undefined && task.executions.length === 0
51
53
  }
52
54
 
53
55
  /** Keep an unknown permission string from entering the ledger. */
@@ -108,6 +110,13 @@ export function applyUpdateTask(
108
110
  if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
109
111
  // Team execution is a boolean opt-in with the same clear-by-false rule.
110
112
  if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
113
+ // The /goal opt-in is default-ON: true (or an explicit null) returns the
114
+ // card to the default and stores nothing, only false pins a plain turn.
115
+ if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
116
+ // The acceptance opt-out is the mirror image: default OFF, so only true
117
+ // pins a card out of the gate; false (or an explicit null) returns it to
118
+ // inheriting the board-wide switch and stores nothing.
119
+ if ('skipVerification' in patch) next.skipVerification = patch.skipVerification === true ? true : undefined
111
120
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
112
121
  if (mode !== undefined || 'mode' in patch) next.mode = mode
113
122
  if (permission !== undefined || 'permission' in patch) next.permission = permission