@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.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 (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
package/src/core/tasks.ts CHANGED
@@ -37,8 +37,23 @@ export interface ExecutionRecord {
37
37
  * Session id of the DSH session that issued the run/rerun action (issue #6
38
38
  * audit origin). Client-asserted, not a trust boundary; absent when the run
39
39
  * was triggered by cron (source unknown).
40
+ *
41
+ * On an EXTERNAL record (issue #1826) this names the agent that completed the
42
+ * work outside the Host instead, which is what makes the history readable.
40
43
  */
41
44
  initiatedBy?: string
45
+ /**
46
+ * True when this outcome was recorded from outside the Host by
47
+ * `record-external-outcome` rather than observed from a DSH session
48
+ * (issue #1826).
49
+ *
50
+ * The board never manufactures one of these itself: every Host-side path
51
+ * (`run`, `settle`, cascade folding, restart recovery) writes an execution
52
+ * without it, so `external` is the single field separating work this Host ran
53
+ * from work an outside agent reported. Such a record never carries a
54
+ * `sessionId` and never claims a verdict the Host observed.
55
+ */
56
+ external?: boolean
42
57
  /** Freeze instant captured from the card snapshot when the run opened. */
43
58
  frozenAt?: number
44
59
  /** Freeze source session captured from the card snapshot when the run opened. */
@@ -88,17 +103,74 @@ export function retainRecentExecutions(executions: readonly ExecutionRecord[]):
88
103
  return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
89
104
  }
90
105
 
106
+ /**
107
+ * How a rule produces its triggers: a recurring cron rule, or one single
108
+ * planned instant. The distinction is stored rather than inferred so a
109
+ * one-shot is never represented as a date-restricted cron expression.
110
+ */
111
+ export type ScheduleMode = 'cron' | 'once'
112
+
113
+ /**
114
+ * Why a due occurrence produced no run, or why a rule stopped on its own.
115
+ *
116
+ * These are stable codes, not display text: the board renders them through its
117
+ * locale dictionary, so the ledger never carries a translated string.
118
+ */
119
+ export type ScheduleStopReason =
120
+ /** The single planned instant produced its run. */
121
+ | 'fired'
122
+ /** The recurring rule reached its run budget. */
123
+ | 'limit'
124
+ /** The plan has no reachable instant any more (an impossible cron). */
125
+ | 'no-target'
126
+ /** The instant passed while the board was not running through it. */
127
+ | 'missed'
128
+ /** The task already had an open execution, so this occurrence was skipped. */
129
+ | 'busy'
130
+ /** An above-default permission was still unconfirmed, so this occurrence was skipped. */
131
+ | 'permission'
132
+ /**
133
+ * A scheduled launch failed before any session existed, so the occurrence
134
+ * created no execution and its run was refunded rather than spent.
135
+ */
136
+ | 'launch-failed'
137
+
138
+ /** Every stop reason this build understands; the repair and wire gates share it. */
139
+ export const SCHEDULE_STOP_REASONS: readonly ScheduleStopReason[] = [
140
+ 'fired', 'limit', 'no-target', 'missed', 'busy', 'permission', 'launch-failed',
141
+ ]
142
+
91
143
  /**
92
144
  * A scheduled-run rule attached to a task. The Host scheduler triggers the
93
145
  * task when `nextRunAt` is due and persists the rule in the Host ledger.
146
+ *
147
+ * A rule is either `cron` (the historical shape: a 5-field expression with an
148
+ * explicit IANA zone, optionally capped by `maxRuns`) or `once` (one planned
149
+ * instant, `at`, with an implicit budget of one run). Both modes count the
150
+ * executions the scheduler actually started in `runCount`; a rule that reaches
151
+ * its budget stops itself in the ledger (`enabled: false` + `endedReason`)
152
+ * instead of relying on the execution agent to disable it.
94
153
  */
95
154
  export interface ScheduleRule {
96
155
  /** Whether the schedule is armed. */
97
156
  enabled: boolean
98
- /** 5-field cron expression: `分 时 日 月 周`. */
99
- cron: string
100
157
  /**
101
- * IANA zone the cron wall clock is read in (issue #1722). Absent means the
158
+ * Which kind of plan this rule carries. A rule loaded from a ledger written
159
+ * before the field existed normalizes to `cron` (its `cron` is the plan).
160
+ */
161
+ mode: ScheduleMode
162
+ /**
163
+ * 5-field cron expression: `分 时 日 月 周`. Present on a `cron` rule and
164
+ * absent on a `once` rule.
165
+ */
166
+ cron?: string
167
+ /**
168
+ * The instant a `once` rule executes (ms epoch). Kept after the run so the
169
+ * detail view can still show the planned time once the rule has ended.
170
+ */
171
+ at?: number
172
+ /**
173
+ * IANA zone the wall clock is read in (issue #1722). Absent means the
102
174
  * Host process zone, which is what a rule written before zones were
103
175
  * persisted keeps using; the Host stamps its own zone at ledger migration so
104
176
  * a later `TZ` change cannot silently move an existing rule.
@@ -108,6 +180,62 @@ export interface ScheduleRule {
108
180
  nextRunAt: number | undefined
109
181
  /** Instant of the latest scheduled trigger (ms epoch). */
110
182
  lastTriggeredAt: number | undefined
183
+ /**
184
+ * Scheduled executions the Host has created under this rule. This is the
185
+ * run budget's counter: it counts executions that were successfully opened
186
+ * (whatever their later outcome), never completions, and a manual run never
187
+ * touches it. A skipped occurrence (busy / permission / missed / no target)
188
+ * creates no execution and therefore consumes nothing.
189
+ */
190
+ runCount: number
191
+ /**
192
+ * Maximum scheduled executions for a `cron` rule; absent means unlimited
193
+ * (the historical behavior). A `once` rule has an implicit budget of one and
194
+ * stores no `maxRuns`.
195
+ */
196
+ maxRuns?: number
197
+ /** Instant the rule stopped itself (ms epoch); absent while it may still fire. */
198
+ endedAt?: number
199
+ /** Why the rule stopped itself; present with `endedAt`. */
200
+ endedReason?: ScheduleStopReason
201
+ /** Instant of the latest occurrence that produced no run. */
202
+ skippedAt?: number
203
+ /** Why that occurrence produced no run; present with `skippedAt`. */
204
+ skippedReason?: ScheduleStopReason
205
+ }
206
+
207
+ /**
208
+ * The rule's total run budget: one for a one-shot, the stored cap for a
209
+ * recurring rule, and undefined when the rule is unlimited.
210
+ * @param rule - the schedule rule.
211
+ * @returns the budget, or undefined for an uncapped rule.
212
+ */
213
+ export function scheduleRunBudget(rule: ScheduleRule): number | undefined {
214
+ if (rule.mode === 'once') return 1
215
+ return rule.maxRuns
216
+ }
217
+
218
+ /**
219
+ * Whether a rule has no runs left. The scheduler refuses to open another
220
+ * execution for an exhausted rule, which is what makes the budget terminal
221
+ * regardless of restarts or repeated timer fires.
222
+ * @param rule - the schedule rule.
223
+ * @returns true when the run budget is spent.
224
+ */
225
+ export function scheduleExhausted(rule: ScheduleRule): boolean {
226
+ const budget = scheduleRunBudget(rule)
227
+ return budget !== undefined && rule.runCount >= budget
228
+ }
229
+
230
+ /**
231
+ * The instant a rule plans to fire next, for display, whatever its mode: the
232
+ * planned `at` of a one-shot, or the stored `nextRunAt` of a recurring rule.
233
+ * @param rule - the schedule rule.
234
+ * @returns the planned instant, or undefined when the rule carries none.
235
+ */
236
+ export function schedulePlannedAt(rule: ScheduleRule): number | undefined {
237
+ if (rule.mode === 'once') return rule.at
238
+ return rule.nextRunAt
111
239
  }
112
240
 
113
241
  /**
@@ -278,6 +406,15 @@ export interface TaskRecord {
278
406
  * absent falls back to the host default (agent-default-model).
279
407
  */
280
408
  model?: string
409
+ /**
410
+ * Reasoning effort pinned for that execution route (an adapter-owned id such
411
+ * as "high"). Absent (or blank) means INHERIT: the session runs at the
412
+ * model's own default level, exactly as a card written before the field
413
+ * existed does. The launch sends the value only when the target model's
414
+ * adapter declares that exact id, so switching a card to a model without
415
+ * that level never pushes an invalid selection (see core/reasoning-effort.ts).
416
+ */
417
+ reasoningEffort?: string
281
418
  /**
282
419
  * Whether later executions continue in the previous execution's session
283
420
  * (issue #1419) instead of minting a fresh conversation per run. Absent or
@@ -300,9 +437,11 @@ export interface TaskRecord {
300
437
  * armed with the composed execution prompt as its objective. The session then
301
438
  * keeps working automatic continuation rounds until the agent marks the goal
302
439
  * 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.
440
+ * instead of at the first turn end. Absent means OFF — the option is
441
+ * unchecked by default, so only an explicit `true` joins the goal run and
442
+ * every card that never touched it (including every card written before the
443
+ * field existed) runs one plain turn. A session whose `/goal` command is
444
+ * refused still runs the prompt.
306
445
  */
307
446
  goalRun?: boolean
308
447
  /**
@@ -405,13 +544,23 @@ export interface NewTaskInput {
405
544
  permission?: TaskPermission
406
545
  /** Optional pinned model for the execution session; absent = host default. */
407
546
  model?: string
547
+ /** Optional reasoning effort for that model route; empty/absent = the model's own default. */
548
+ reasoningEffort?: string
549
+ /**
550
+ * Opt in to the deployment's new-card defaults: a target this input leaves
551
+ * out is filled from the configured default (agent preset, permission, model
552
+ * and the level bound to that model). Absent means the create stores exactly
553
+ * what it was given, which is what the extension provider's own create path
554
+ * and the legacy import rely on.
555
+ */
556
+ applyCardDefaults?: boolean
408
557
  /** Reuse the previous execution's session for later runs (issue #1419). */
409
558
  reuseSession?: boolean
410
559
  /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
411
560
  teamRun?: boolean
412
561
  /**
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.
562
+ * Start the execution with dsh's built-in `/goal`. Absent means the default
563
+ * (one plain turn); only an explicit true joins the goal run.
415
564
  */
416
565
  goalRun?: boolean
417
566
  /**
@@ -420,12 +569,22 @@ export interface NewTaskInput {
420
569
  */
421
570
  skipVerification?: boolean
422
571
  /**
423
- * Optional scheduled-run rule requested at creation time (the new-task
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.
572
+ * Optional scheduling rule requested at creation time (the new-task
573
+ * dialog): an enable flag, the kind of plan (a recurring 5-field cron
574
+ * expression, or one planned instant for a one-shot), an optional run cap,
575
+ * and the IANA zone its wall clock is read in (absent means the Host zone).
576
+ * The create use case arms it only when enabled and the plan is valid.
427
577
  */
428
- schedule?: { enabled: boolean; cron: string; timeZone?: string }
578
+ schedule?: {
579
+ enabled: boolean
580
+ mode?: ScheduleMode
581
+ cron?: string
582
+ /** Planned instant of a one-shot rule (ms epoch). */
583
+ at?: number
584
+ timeZone?: string
585
+ /** Run cap of a recurring rule; absent means unlimited. */
586
+ maxRuns?: number
587
+ }
429
588
  /**
430
589
  * Optional frozen context snapshot (goal/progress/next, sanitized by the
431
590
  * protocol gate) turning the new task into a continuation card.
@@ -559,12 +718,13 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
559
718
  mode: normalizeTargetId(input.mode),
560
719
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
561
720
  model: normalizeTargetId(input.model),
721
+ reasoningEffort: normalizeTargetId(input.reasoningEffort),
562
722
  reuseSession: input.reuseSession === true ? true : undefined,
563
723
  teamRun: input.teamRun === true ? true : undefined,
564
- // Default ON: only an explicit false is stored, so a card that never
724
+ // Default OFF: only an explicit true is stored, so a card that never
565
725
  // 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,
726
+ // keeps running one plain turn.
727
+ goalRun: input.goalRun === true ? true : undefined,
568
728
  // Default OFF: only an explicit true is stored, so a card that never
569
729
  // touched the option inherits the board-wide acceptance switch exactly as
570
730
  // every card written before the field existed does.
@@ -582,35 +742,38 @@ export function withStatus(task: TaskRecord, status: TaskStatus, now: number): T
582
742
  return { ...task, status, updatedAt: now }
583
743
  }
584
744
 
745
+ /** Every field a schedule patch may carry; the rest of the rule is carried over. */
746
+ const SCHEDULE_PATCH_KEYS = [
747
+ 'enabled', 'mode', 'cron', 'at', 'timeZone', 'nextRunAt', 'lastTriggeredAt',
748
+ 'runCount', 'maxRuns', 'endedAt', 'endedReason', 'skippedAt', 'skippedReason',
749
+ ] as const satisfies ReadonlyArray<keyof ScheduleRule>
750
+
585
751
  /**
586
752
  * Merge a schedule patch into a task's schedule rule (creating it when
587
753
  * absent), with a fresh updatedAt. Keys present in the patch overwrite the
588
754
  * current value — including explicit `undefined`, which clears a field (used
589
- * to disarm `nextRunAt`); absent keys keep their current value.
755
+ * to disarm `nextRunAt` or to drop an ended/skip record); absent keys keep
756
+ * their current value.
590
757
  */
591
758
  export function withSchedule(
592
759
  task: TaskRecord,
593
760
  patch: Partial<ScheduleRule>,
594
761
  now: number,
595
762
  ): TaskRecord {
596
- const current = task.schedule
597
- const schedule: ScheduleRule = {
598
- enabled: current?.enabled ?? false,
599
- cron: current?.cron ?? '',
600
- ...(current?.timeZone === undefined ? {} : { timeZone: current.timeZone }),
601
- nextRunAt: current?.nextRunAt,
602
- lastTriggeredAt: current?.lastTriggeredAt,
603
- }
604
- if ('enabled' in patch) schedule.enabled = patch.enabled ?? false
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
763
+ const schedule: ScheduleRule = task.schedule === undefined
764
+ ? { enabled: false, mode: 'cron', nextRunAt: undefined, lastTriggeredAt: undefined, runCount: 0 }
765
+ : { ...task.schedule }
766
+ const fields = schedule as unknown as Record<string, unknown>
767
+ const next = patch as unknown as Record<string, unknown>
768
+ for (const key of SCHEDULE_PATCH_KEYS) {
769
+ if (!(key in patch)) continue
770
+ if (next[key] === undefined) delete fields[key]
771
+ else fields[key] = next[key]
611
772
  }
612
- if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
613
- if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
773
+ // The three non-optional fields can never be cleared into an unusable rule.
774
+ if (schedule.enabled !== true) schedule.enabled = false
775
+ if (schedule.mode !== 'once') schedule.mode = 'cron'
776
+ if (!Number.isFinite(schedule.runCount) || schedule.runCount < 0) schedule.runCount = 0
614
777
  return { ...task, updatedAt: now, schedule }
615
778
  }
616
779
 
@@ -654,6 +817,21 @@ export function startExecution(
654
817
  }
655
818
  }
656
819
 
820
+ /**
821
+ * The column a settled outcome puts a card in. The ONE derivation shared by
822
+ * every settlement path (issue #1826): a Host-run execution and an outcome an
823
+ * outside agent reported both land in the same column from the same rule, so
824
+ * the column never becomes a second, independent piece of state.
825
+ @param task - the card being settled.
826
+ @param outcome - the settled outcome.
827
+ @returns the status the card shows afterwards.
828
+ */
829
+ export function settledStatus(task: TaskRecord, outcome: ExecutionOutcome): TaskStatus {
830
+ if (outcome === 'succeeded') return task.schedule?.enabled === true ? 'todo' : 'done'
831
+ if (outcome === 'failed') return 'failed'
832
+ return task.status === 'running' ? 'todo' : task.status
833
+ }
834
+
657
835
  /**
658
836
  * Settle a running execution: record the outcome and move the task into the
659
837
  * matching column. No-op (returns the input task) when the execution is not
@@ -673,11 +851,7 @@ export function settleExecution(
673
851
  const settled: ExecutionRecord = { ...execution, endedAt: now, result: outcome, error }
674
852
  const executions = [...task.executions]
675
853
  executions[index] = settled
676
- const status: TaskStatus = outcome === 'succeeded'
677
- ? (task.schedule?.enabled ? 'todo' : 'done')
678
- : outcome === 'failed' ? 'failed'
679
- : task.status === 'running' ? 'todo' : task.status
680
- return { ...task, status, updatedAt: now, executions }
854
+ return { ...task, status: settledStatus(task, outcome), updatedAt: now, executions }
681
855
  }
682
856
 
683
857
  /** A settled-execution summary string for the detail view. */
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
8
8
  import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
9
- import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
9
+ import { resolveCardTargets, type CardDefaults } from '../card-defaults.ts'
10
+ import { createTask, isTaskPermission, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
10
11
 
11
12
  /** Result of a create transition: the new task (when accepted) + the next ledger. */
12
13
  export interface CreateTaskResult {
@@ -23,16 +24,20 @@ export interface CreateTaskResult {
23
24
  * appended ledger, or the unchanged ledger when the title is blank or the
24
25
  * requested parent link is refused.
25
26
  *
26
- * A subtask inherits the execution targets its parent already pins (workspace,
27
- * agent preset, permission, model) unless the form supplied its own; the
28
- * parent human permission confirmation travels with an inherited permission,
29
- * so a subtask of an already-confirmed elevated card is runnable at once.
27
+ * The execution targets resolve through {@link resolveCardTargets}: what the
28
+ * caller sent wins, then the parent's own pins, then - only when the caller
29
+ * opted in with `applyCardDefaults` - the deployment's configured new-card
30
+ * defaults. Without the flag the resolution sees no defaults at all, which is
31
+ * exactly the inline parent inheritance this path has always applied. The
32
+ * workspace is not part of that resolution: a subtask still takes the parent's
33
+ * workspace, and the defaults never name one.
30
34
  * @param tasks - current ledger.
31
35
  * @param input - raw user input (title/description/prompt + optional schedule/parent).
32
36
  * @param now - clock instant (ms epoch).
33
37
  * @param id - minted task id.
34
38
  * @param maxSubtaskDepth - deployment subtask depth limit.
35
39
  * @param hostTimeZone - zone a rule that stores no zone of its own follows.
40
+ * @param cardDefaults - the deployment's new-card defaults, read live at creation.
36
41
  */
37
42
  export function applyCreateTask(
38
43
  tasks: readonly TaskRecord[],
@@ -41,6 +46,7 @@ export function applyCreateTask(
41
46
  id: string,
42
47
  maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
43
48
  hostTimeZone: string = resolveHostTimeZone(),
49
+ cardDefaults?: CardDefaults,
44
50
  ): CreateTaskResult {
45
51
  if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
46
52
  const limit = normalizeSubtaskDepth(maxSubtaskDepth)
@@ -53,36 +59,62 @@ export function applyCreateTask(
53
59
  return { task: undefined, tasks, error: `subtask depth limit (${limit}) exceeded` }
54
60
  }
55
61
  }
56
- // A subtask copies the execution targets its parent pins so the card shows
57
- // what it will run with — EXCEPT the permission. A copied permission would
58
- // also have to copy the parent's human confirmation to stay runnable, and the
59
- // child would keep both after being detached: one confirmed parent would mint
60
- // unbounded confirmed elevated roots. The permission is resolved live from the
61
- // attached ancestors at launch instead (see core/subtask.ts), so detaching
62
- // removes the inheritance by construction.
63
- const effective: NewTaskInput = parent === undefined ? input : {
62
+ // The execution targets resolve through the one shared rule: the caller's own
63
+ // choice, then the parent's own pins (never its permission, see
64
+ // core/card-defaults.ts), then the deployment's new-card defaults — and only
65
+ // when the caller asked for them, so a create that never opts in stores
66
+ // exactly what it sent, as it always has.
67
+ const seed = resolveCardTargets(
68
+ input.applyCardDefaults === true ? cardDefaults : undefined,
69
+ parent === undefined ? undefined : { kind: 'parent', task: parent },
70
+ { mode: input.mode, permission: input.permission, model: input.model, reasoningEffort: input.reasoningEffort },
71
+ )
72
+ const effective: NewTaskInput = {
64
73
  ...input,
65
- workspaceId: input.workspaceId ?? parent.workspaceId,
66
- mode: input.mode ?? parent.mode,
67
- model: input.model ?? parent.model,
74
+ workspaceId: input.workspaceId ?? parent?.workspaceId,
75
+ mode: seed.mode,
76
+ permission: isTaskPermission(seed.permission) ? seed.permission : undefined,
77
+ model: seed.model,
78
+ reasoningEffort: seed.reasoningEffort,
68
79
  }
69
80
  let task = createTask(effective, now, id)
70
- // Arm the requested schedule (new-task dialog): only an enabled rule with
71
- // a valid cron is applied; blank, invalid, or disabled requests leave the
72
- // task unscheduled.
81
+ // Arm the requested schedule (new-task dialog). The Host wire gate already
82
+ // rejects a malformed enabled plan; this arm re-checks it so a direct caller
83
+ // (a test or an embedder) cannot persist an unusable rule either.
73
84
  const requested = input.schedule
74
- if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
75
- const cron = requested.cron.trim()
85
+ if (requested?.enabled === true) {
76
86
  const requestedZone = requested.timeZone
77
87
  // An unusable zone is refused rather than silently reinterpreted as the
78
88
  // Host zone: the caller asked for a specific wall clock.
79
89
  if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
80
90
  return { task: undefined, tasks, error: 'invalid schedule time zone' }
81
91
  }
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)
92
+ if (requested.mode === 'once') {
93
+ const at = requested.at
94
+ if (at === undefined || !Number.isInteger(at) || at <= now) {
95
+ return { task: undefined, tasks, error: 'invalid schedule instant' }
96
+ }
97
+ task = withSchedule(task, {
98
+ enabled: true,
99
+ mode: 'once',
100
+ at,
101
+ timeZone: requestedZone,
102
+ nextRunAt: at,
103
+ runCount: 0,
104
+ }, now)
105
+ } else {
106
+ const cron = (requested.cron ?? '').trim()
107
+ if (cron !== '' && isValidCron(cron)) {
108
+ const maxRuns = requested.maxRuns
109
+ if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns < 1)) {
110
+ return { task: undefined, tasks, error: 'invalid schedule run cap' }
111
+ }
112
+ const zone = requestedZone ?? hostTimeZone
113
+ const nextRunAt = nextRunAtMs(cron, now, zone)
114
+ if (nextRunAt !== undefined) {
115
+ task = withSchedule(task, { enabled: true, mode: 'cron', cron, maxRuns, timeZone: requestedZone, nextRunAt, runCount: 0 }, now)
116
+ }
117
+ }
86
118
  }
87
119
  }
88
120
  return { task, tasks: [...tasks, task] }