@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/index.ts CHANGED
@@ -19,15 +19,20 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
19
19
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
20
20
  import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts'
21
21
  import { parseTaskDraft, TaskParseError } from './host-ai.ts'
22
- import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
22
+ import { TASK_PERMISSIONS, isTaskPermission, type TaskPermission } from './core/tasks.ts'
23
23
  import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
24
24
  import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
25
- import { buildTaskBoardTools } from './host/agent-tools.ts'
25
+ import { buildTaskBoardTools, TASK_BOARD_TOOL_NAMES } from './host/agent-tools.ts'
26
+ import { PLUGIN_TOOL_SECTION_ORDERS, visibleToolText } from './tool-surface.ts'
26
27
  import { makeTaskBoardRoutes } from './host-routes.ts'
28
+ import { TASK_BOARD_SERVICE_NAME, type TaskBoardExtension } from './core/extension.ts'
27
29
  import { mountOnce } from './mount-once.ts'
30
+ import { createGoalVerificationGate, type GoalFace } from './host/verification-gate.ts'
31
+ import { normalizeCatalog, type ModelCatalogView, type VerificationSettings } from './core/verification.ts'
32
+ import { probeWorkspaceChanges } from './host/workspace-evidence.ts'
28
33
 
29
- /** Order of the announcement section within the tool-guidance band. */
30
- const SECTION_ORDER = 200
34
+ /** Order of the announcement section within the shared tool-guidance band. */
35
+ const SECTION_ORDER = PLUGIN_TOOL_SECTION_ORDERS['task-board']
31
36
 
32
37
  /** Default environment variable holding the authenticated proxy token. */
33
38
  export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
@@ -68,9 +73,12 @@ export interface Config {
68
73
  /** Environment variable whose value the authenticated proxy injects upstream. */
69
74
  proxyTokenEnv?: string
70
75
  /**
71
- * The deployment's session-default permission. A card whose effective
72
- * permission (handover bundle or pin) is above this value requires a human
73
- * confirmation before it may run; cron refuses unconfirmed cards.
76
+ * An explicit baseline for the permission confirmation gate. Left unset, the
77
+ * board follows the Host's own default permission preset — the value new DSH
78
+ * sessions start at — and falls back to `read-only` when the deployment
79
+ * serves no permission catalog. A card whose effective permission (handover
80
+ * bundle or pin) is above the baseline requires a human confirmation before
81
+ * it may run; cron refuses unconfirmed cards.
74
82
  */
75
83
  sessionDefaultPermission?: TaskPermission
76
84
  /**
@@ -86,6 +94,31 @@ export interface Config {
86
94
  * only team-mode runs use it.
87
95
  */
88
96
  teamProvider?: string
97
+ /**
98
+ * Goal acceptance for executions this board starts (default ON). When on,
99
+ * update_goal(action: complete) inside a task execution is refused until an
100
+ * acceptance pass is recorded for that execution: the three coding criteria,
101
+ * a 0.65 threshold, two rounds per criterion with the A/B slots swapped, and
102
+ * at most two acceptances per execution — the first failure returns its
103
+ * findings to the fixing agent, the second ends the cycle. The switch only
104
+ * affects goal-form executions of the board; plain chat and a task pinned to
105
+ * goalRun: false are untouched. When a separate third-party verifier also
106
+ * runs its own automatic acceptance, both judges score independently: this
107
+ * one gates completion, the other only steers, and no public interface lets
108
+ * the two share a verdict.
109
+ */
110
+ goalVerification?: Volatile<boolean>
111
+ /**
112
+ * Judge model for goal acceptance as provider/model. Blank inherits the HOST's
113
+ * own model catalog default — never the card's pinned execution model.
114
+ */
115
+ goalVerificationModel?: Volatile<string>
116
+ /**
117
+ * Reasoning effort for the judge. Blank inherits the host default level; an
118
+ * effort the resolved model does not declare is dropped instead of being
119
+ * sent, and the fallback to the model's own default is reported.
120
+ */
121
+ goalVerificationReasoningEffort?: Volatile<string>
89
122
  }
90
123
 
91
124
  /**
@@ -95,22 +128,57 @@ export interface Config {
95
128
  export const DEFAULT_TEAM_PROVIDER = 'spawn'
96
129
 
97
130
  /**
98
- * The schema is left to inference rather than annotated with `z<Config>`: a
99
- * volatile field's parsed output is a `Volatile` reference while its accepted
100
- * input stays the plain value, so the two sides no longer share one shape and
101
- * the annotation would reject the schema the Host must be given.
131
+ * Profile-patch shape of {@link Config}: what the Host validates the row's
132
+ * config against, before the schema turns volatile fields into live references
133
+ * and applies defaults. Declared separately because the two sides no longer
134
+ * share one shape, so the schema is annotated `z<ConfigInput, Config>` — the
135
+ * same split `dsh-git-graph` uses. The annotation is also what keeps the
136
+ * emitted declaration portable: an unannotated schema holding a nested object
137
+ * array infers a type that cannot be named without reaching into a transitive
138
+ * dependency's own types.
102
139
  */
103
- export const Config = z.object({
140
+ export interface ConfigInput {
141
+ /** Announce the board in the system prompt. */
142
+ announceToAgent?: boolean
143
+ /** Master switch for the plugin. */
144
+ enabled?: boolean
145
+ /** Prevent idle system sleep while sessions run or schedules are armed. */
146
+ preventIdleSleep?: boolean
147
+ /** Canonical reverse-proxy Host authorities. */
148
+ trustedProxyHosts?: string[]
149
+ /** Environment variable whose value the authenticated proxy injects. */
150
+ proxyTokenEnv?: string
151
+ /** Explicit baseline for the permission confirmation gate; unset follows the Host default. */
152
+ sessionDefaultPermission?: TaskPermission
153
+ /** Subtask depth limit, 1..3. */
154
+ maxSubtaskDepth?: number
155
+ /** Continuable-subagent provider the Agent Teams service composes a teammate from. */
156
+ teamProvider?: string
157
+ /** Goal acceptance switch. */
158
+ goalVerification?: boolean
159
+ /** Judge model route for goal acceptance; blank inherits the host default. */
160
+ goalVerificationModel?: string
161
+ /** Judge reasoning effort; blank inherits the host default. */
162
+ goalVerificationReasoningEffort?: string
163
+ }
164
+
165
+ export const Config: z<ConfigInput, Config> = z.object({
104
166
  announceToAgent: z.boolean().default(false).volatile(),
105
167
  enabled: z.boolean().default(true).volatile(),
106
168
  preventIdleSleep: z.boolean().default(false).volatile(),
107
169
  trustedProxyHosts: z.array(z.string()).default([]),
108
170
  proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
109
- sessionDefaultPermission: z.union(TASK_PERMISSIONS).default(DEFAULT_SESSION_PERMISSION),
171
+ sessionDefaultPermission: z.union(TASK_PERMISSIONS),
110
172
  maxSubtaskDepth: z.number().min(SUBTASK_DEPTH_MIN).max(SUBTASK_DEPTH_MAX).default(DEFAULT_SUBTASK_DEPTH).volatile(),
111
173
  teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
174
+ goalVerification: z.boolean().default(true).volatile(),
175
+ goalVerificationModel: z.string().default('').volatile(),
176
+ goalVerificationReasoningEffort: z.string().default('').volatile(),
112
177
  })
113
178
 
179
+ /** Schema default for the goal-acceptance switch, re-read for hand-built contexts. */
180
+ export const DEFAULT_GOAL_VERIFICATION = true
181
+
114
182
  declare module '@deepseek-ai/cordis' {
115
183
  interface Events {
116
184
  /**
@@ -160,6 +228,13 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
160
228
  /** The registry face the agent tools register into. */
161
229
  interface AgentToolRegistry {
162
230
  register(definition: ToolDefinition): () => void
231
+ /** Scoped lookup; present on the real registry, absent on a stub that only registers. */
232
+ get?(name: string, scope?: unknown): unknown
233
+ }
234
+
235
+ /** The lookup half of a resolved registry, when it exposes one. */
236
+ function lookupOf(registry: AgentToolRegistry | undefined): { get: (name: string, scope?: unknown) => unknown } | undefined {
237
+ return registry !== undefined && typeof registry.get === 'function' ? { get: registry.get.bind(registry) } : undefined
163
238
  }
164
239
 
165
240
  /**
@@ -203,6 +278,33 @@ export function resolveHostTimers(ctx: Context): HostTimerFace | undefined {
203
278
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
204
279
  const DEFAULT_ANNOUNCE = false
205
280
 
281
+ /** The subset of the official permission-preset service the board reads. */
282
+ interface PermissionPresetsFace {
283
+ catalog(): { defaultPreset?: unknown }
284
+ }
285
+
286
+ /**
287
+ * The Host's own default permission preset, mapped into the board's vocabulary.
288
+ *
289
+ * This is the value new DSH sessions start at (the deployment's `permission`
290
+ * settings row), so a board that follows it treats an ordinary card exactly as
291
+ * the Host would. Absent when the deployment serves no permission catalog, or
292
+ * its default is a current-session identity (`auto`) that names no fixed
293
+ * sandbox mode; the caller then keeps the fail-safe baseline.
294
+ * @param ctx - the plugin context.
295
+ * @returns the Host default as a board permission, or undefined when unreadable.
296
+ */
297
+ export function resolveHostDefaultPermission(ctx: Context): TaskPermission | undefined {
298
+ try {
299
+ const presets = ctx.get('permissionPresets') as PermissionPresetsFace | undefined
300
+ if (presets === undefined || typeof presets.catalog !== 'function') return undefined
301
+ const preset = presets.catalog()?.defaultPreset
302
+ return isTaskPermission(preset) ? preset : undefined
303
+ } catch {
304
+ return undefined
305
+ }
306
+ }
307
+
206
308
  /**
207
309
  * How long one teammate may take to reach its durable active or failed edge.
208
310
  * Agent Teams provisioning normally resolves in seconds; the bound exists so a
@@ -317,11 +419,35 @@ function applyImpl(ctx: Context, config?: Config): void {
317
419
  const preventIdleSleep = (): boolean => readConfigField(config?.preventIdleSleep, false)
318
420
  /** Current subtask depth limit (read live: the settings card edits it in place). */
319
421
  const maxSubtaskDepth = (): number => readConfigField(config?.maxSubtaskDepth, DEFAULT_SUBTASK_DEPTH)
422
+ /**
423
+ * The baseline the permission confirmation gate judges against: the row's own
424
+ * `sessionDefaultPermission` when the deployment pins one, otherwise the Host's
425
+ * default preset. Read per call through the ledger, so a Host Settings change
426
+ * reaches the running board without remounting the row; the fail-safe default
427
+ * still applies when no catalog is available.
428
+ */
429
+ const sessionDefaultPermission = (): TaskPermission =>
430
+ config?.sessionDefaultPermission ?? resolveHostDefaultPermission(ctx) ?? DEFAULT_SESSION_PERMISSION
320
431
 
432
+ /** Live acceptance settings the contract of each new execution freezes. */
433
+ const verificationSettings = (): VerificationSettings => ({
434
+ enabled: readConfigField(config?.goalVerification, DEFAULT_GOAL_VERIFICATION),
435
+ model: readConfigField(config?.goalVerificationModel, ''),
436
+ reasoningEffort: readConfigField(config?.goalVerificationReasoningEffort, ''),
437
+ })
438
+ // The catalog reader needs the service, and the service needs the reader;
439
+ // the reader only runs after start(), so a late holder is enough.
440
+ let hostForCatalog: TaskBoardHostService | undefined
441
+ const verificationCatalog = async (): Promise<ModelCatalogView | undefined> => {
442
+ const runner = hostForCatalog?.runner
443
+ return runner === undefined ? undefined : normalizeCatalog(await runner.modelCatalog())
444
+ }
321
445
  const host = new TaskBoardHostService(ctx.typertGateway, {
322
446
  workspaceRegistry: ctx.workspaceRegistry,
323
- sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
447
+ sessionDefaultPermission,
324
448
  maxSubtaskDepth: maxSubtaskDepth(),
449
+ verificationSettings,
450
+ verificationCatalog,
325
451
  timers: resolveHostTimers(ctx),
326
452
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
327
453
  commandDispatcher: {
@@ -332,22 +458,41 @@ function applyImpl(ctx: Context, config?: Config): void {
332
458
  },
333
459
  },
334
460
  })
461
+ hostForCatalog = host
335
462
  // Configuration before start(): a disabled row must not take the first
336
463
  // scheduler tick, which would roll schedules the board is not running.
337
464
  host.setConfiguration(enabled(), preventIdleSleep())
338
465
  host.start()
339
466
 
467
+ // External providers. The board publishes its registration service so any
468
+ // provider package can resolve it and admit itself; the board itself knows
469
+ // no provider vocabulary. A capture-only test context implements no service
470
+ // registry and the board still serves its own surfaces.
471
+ if (typeof (ctx as { provide?: unknown }).provide === 'function') {
472
+ ctx.provide(TASK_BOARD_SERVICE_NAME, {
473
+ registerExtension: (extension: TaskBoardExtension) => host.registerExtension(extension),
474
+ isExtensionEnabled: (extensionId: string) => host.extensions.isActive(extensionId),
475
+ })
476
+ }
477
+
340
478
  // Agent tools: the same Host ledger the browser drives, so any session can
341
479
  // list, create, link, run and settle board work. Registration follows the
342
480
  // master switch (a disabled board answers no tool call), and the mount
343
- // effect below owns the disposers.
481
+ // effect below owns the disposers. Provider tools register through the
482
+ // extension registry, which follows the same gate and rebinds when the tool
483
+ // registry appears late.
344
484
  let disposeTools: (() => void) | undefined
345
485
  const setToolsEnabled = (active: boolean): void => {
346
486
  if (!active) {
487
+ // Releasing is the whole job on the way down. Rebinding the extension
488
+ // registry here would re-register every provider tool, and the teardown
489
+ // path runs this while the fiber is already unloading, so each of those
490
+ // re-registrations is a request the host can only refuse.
347
491
  disposeTools?.()
348
492
  disposeTools = undefined
349
493
  return
350
494
  }
495
+ host.extensions.setToolRegistry(() => resolveToolRegistry(ctx))
351
496
  if (disposeTools !== undefined) return
352
497
  const registry = resolveToolRegistry(ctx)
353
498
  if (registry === undefined) return
@@ -364,6 +509,9 @@ function applyImpl(ctx: Context, config?: Config): void {
364
509
  if (typeof scopedInject === 'function') {
365
510
  scopedInject.call(ctx, ['tools'], () => {
366
511
  setToolsEnabled(enabled())
512
+ // The extension registry follows the same rebinding: a registry that
513
+ // appears (or is replaced) after a provider started adopts its tools.
514
+ host.extensions.setToolRegistry(() => resolveToolRegistry(ctx))
367
515
  // Cordis unloads and re-runs this callback when the injected service's
368
516
  // provider fiber changes, and the old registry dies with its provider.
369
517
  // Releasing the guard here is what lets the callback register into the
@@ -378,6 +526,42 @@ function applyImpl(ctx: Context, config?: Config): void {
378
526
  ctx.effect(() => {
379
527
  const disposers: Array<() => void> = []
380
528
  try {
529
+ // The goal acceptance gate: the OFFICIAL tool pre-execution lifecycle,
530
+ // installed before the tool body runs, so a completion claim cannot take
531
+ // effect without a matching acceptance pass record.
532
+ const gate = createGoalVerificationGate({
533
+ ledger: host.ledger,
534
+ llm: () => resolveLlmRuntime(ctx),
535
+ goals: () => {
536
+ try {
537
+ return ctx.get('goals') as GoalFace | undefined
538
+ } catch {
539
+ return undefined
540
+ }
541
+ },
542
+ // The host's own observation of what a turn changed on disk, when this
543
+ // deployment records it; the acceptance degrades to the trajectory alone
544
+ // when it does not.
545
+ workspaceChanges: () => probeWorkspaceChanges(ctx),
546
+ logger: {
547
+ warn: (message: string, ...rest: unknown[]) => {
548
+ const logger = (ctx as { logger?: { warn?: (...args: unknown[]) => void } }).logger
549
+ if (typeof logger?.warn === 'function') logger.warn(message, ...rest)
550
+ else console.warn(message, ...rest)
551
+ },
552
+ },
553
+ })
554
+ // A capture-only context may implement no listener registry (it returns
555
+ // no disposer), so the handle is pushed only when it really is one.
556
+ const offGate = ctx.on('tools/pre-execute', async (exec, next) => {
557
+ const decision = await gate({ name: exec.name, arguments: exec.arguments, agent: exec.agent, signal: exec.signal })
558
+ return decision ?? next()
559
+ })
560
+ if (typeof offGate === 'function') disposers.push(offGate)
561
+ else if (typeof (offGate as { dispose?: unknown } | undefined)?.dispose === 'function') {
562
+ const handle = offGate as { dispose(): void }
563
+ disposers.push(() => { handle.dispose() })
564
+ }
381
565
  const routes = makeTaskBoardRoutes(host, resolveProxyAccess(config), {
382
566
  parseTask: async (request, signal) => {
383
567
  const llm = resolveLlmRuntime(ctx)
@@ -426,7 +610,11 @@ function applyImpl(ctx: Context, config?: Config): void {
426
610
  disposeSection = ctx.systemPrompt.section({
427
611
  name: 'plugin:task-board',
428
612
  order: SECTION_ORDER,
429
- text: TASK_BOARD_GUIDANCE,
613
+ // The announcement names the task_board_* tools, so it renders only while
614
+ // at least one of them is reachable: a runtime that serves no tool
615
+ // registry, or a restriction that withholds them, must not leave guidance
616
+ // telling the model to call tools this session cannot see.
617
+ text: visibleToolText(lookupOf(resolveToolRegistry(ctx)), TASK_BOARD_TOOL_NAMES, TASK_BOARD_GUIDANCE),
430
618
  })
431
619
  }
432
620
 
package/src/protocol.ts CHANGED
@@ -1,15 +1,33 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
2
- import { isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
2
+ import { TAG_NAME_MAX_LENGTH, isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
3
3
  import { parseLedger } from './core/store.ts'
4
+ import { isValidTimeZone } from './core/schedule.ts'
4
5
  import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
5
6
  import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
7
+ import { isTaskBoardExtensionPayload, normalizeTaskIntegrations, type TaskBoardExtensionPayload } from './core/extension.ts'
8
+ import { normalizeVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
6
9
 
7
10
  /** Freeze payload carried by create/update actions after the gate (redacted in place). */
8
11
  type FreezePayload = FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
9
12
 
10
- export const TASK_BOARD_SCHEMA_VERSION = 3 as const
11
- /** Ledger documents written before v3; loaded once and migrated on startup. */
12
- export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 2 as const
13
+ export const TASK_BOARD_SCHEMA_VERSION = 5 as const
14
+ /** Ledger documents written before v5; loaded once and migrated on startup. */
15
+ export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 4 as const
16
+ /** Ledger documents written before v4; migrated through the v4 normalization too. */
17
+ export const TASK_BOARD_OLDER_SCHEMA_VERSION = 3 as const
18
+ /** Ledger documents written before v3; migrated through every later normalization too. */
19
+ export const TASK_BOARD_OLDEST_SCHEMA_VERSION = 2 as const
20
+ /**
21
+ * Every older generation the loader migrates in place. v5 only ADDS the
22
+ * per-execution acceptance block, so the migration is the normalization pass
23
+ * that re-validates each row; a document from any listed generation upgrades
24
+ * losslessly.
25
+ */
26
+ export const TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS: readonly number[] = [
27
+ TASK_BOARD_LEGACY_SCHEMA_VERSION,
28
+ TASK_BOARD_OLDER_SCHEMA_VERSION,
29
+ TASK_BOARD_OLDEST_SCHEMA_VERSION,
30
+ ]
13
31
  export const TASK_BOARD_API_PREFIX = '/api/task-board'
14
32
 
15
33
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
@@ -50,6 +68,25 @@ export interface TaskBoardSnapshot {
50
68
  * into team execution (Team Lead session plus one teammate per subtask).
51
69
  */
52
70
  teamRunAvailable?: boolean
71
+ /**
72
+ * Read-only summaries published by running extensions, keyed by extension id.
73
+ * The board forwards them verbatim; it never interprets their shape.
74
+ */
75
+ extensions?: Record<string, unknown>
76
+ }
77
+
78
+ /**
79
+ * Body of `GET {TASK_BOARD_API_PREFIX}/verification`: the acceptance settings the
80
+ * card edits, the contract they resolve to right now, and the host model catalog
81
+ * the model and reasoning choices come from. Served through the same loopback /
82
+ * authenticated-proxy guard as the rest of the board API, and it carries no
83
+ * credential material — model routes and level ids only.
84
+ */
85
+ export interface TaskBoardVerificationOptions {
86
+ settings: VerificationSettings
87
+ /** The RESOLVED contract, so the card can show what the next execution freezes. */
88
+ contract: VerificationContract
89
+ catalog: ModelCatalogView
53
90
  }
54
91
 
55
92
  /** SSE event frame: revision/scheduler/power only, never the task list. */
@@ -101,11 +138,16 @@ export type TaskBoardAction =
101
138
  | { kind: 'move'; taskId: string; status: TaskStatus }
102
139
  | { kind: 'archive'; taskId: string }
103
140
  | { kind: 'restore'; taskId: string }
104
- | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string } }
141
+ | { kind: 'settle'; taskId: string }
142
+ | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string; timeZone?: string | null } }
105
143
  | { kind: 'run'; taskId: string }
106
144
  | { kind: 'rerun'; taskId: string }
107
145
  | { kind: 'confirm-permission'; taskId: string }
108
146
  | { kind: 'set-parent'; taskId: string; parentId: string | null }
147
+ /** Ledger-wide label management; see core/use-cases/task-tag.ts. */
148
+ | { kind: 'rename-tag'; from: string; to: string }
149
+ | { kind: 'delete-tag'; name: string }
150
+ | { kind: 'extension-action'; extensionId: string; action: string; taskId?: string; payload?: TaskBoardExtensionPayload }
109
151
 
110
152
  export interface TaskBoardActionEnvelope {
111
153
  requestId: string
@@ -150,10 +192,13 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
150
192
  // must carry a well-formed list or none at all, so a hand-edited export
151
193
  // cannot smuggle a malformed tag past the gate.
152
194
  if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
195
+ if (value.integrations !== undefined && normalizeTaskIntegrations(value.integrations) === undefined) return false
153
196
  if (value.schedule !== undefined) {
154
197
  const schedule = record(value.schedule)
155
198
  if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
156
199
  if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
200
+ if (schedule.timeZone !== undefined
201
+ && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
157
202
  }
158
203
  if (value.executions !== undefined) {
159
204
  if (!Array.isArray(value.executions)) return false
@@ -166,6 +211,10 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
166
211
  if (execution.initiatedBy !== undefined && typeof execution.initiatedBy !== 'string') return false
167
212
  if (execution.frozenBy !== undefined && typeof execution.frozenBy !== 'string') return false
168
213
  if (execution.frozenAt !== undefined && typeof execution.frozenAt !== 'number') return false
214
+ // A well-formed acceptance block may be imported for inspection, but the
215
+ // import path DROPS it (see importedTask): a fabricated pass record must
216
+ // never let an imported execution settle as verified.
217
+ if (execution.verification !== undefined && normalizeVerification(execution.verification) === undefined) return false
169
218
  }
170
219
  }
171
220
  return true
@@ -194,11 +243,14 @@ function importedTask(value: unknown): TaskRecord | undefined {
194
243
  ...(execution.initiatedBy === undefined ? {} : { initiatedBy: execution.initiatedBy }),
195
244
  ...(execution.frozenAt === undefined ? {} : { frozenAt: execution.frozenAt }),
196
245
  ...(execution.frozenBy === undefined ? {} : { frozenBy: execution.frozenBy }),
246
+ // 安全门(对抗场景 d):验收报告是 Host 的判定,不接受 import 携带——
247
+ // 一条伪造的通过记录会让导入的执行被当成已验收。
197
248
  })),
198
249
  ...(task.schedule === undefined ? {} : {
199
250
  schedule: {
200
251
  enabled: task.schedule.enabled,
201
252
  cron: task.schedule.cron,
253
+ ...(task.schedule.timeZone === undefined ? {} : { timeZone: task.schedule.timeZone }),
202
254
  nextRunAt: task.schedule.nextRunAt,
203
255
  lastTriggeredAt: task.schedule.lastTriggeredAt,
204
256
  },
@@ -210,10 +262,17 @@ function importedTask(value: unknown): TaskRecord | undefined {
210
262
  ...(task.mode === undefined ? {} : { mode: task.mode }),
211
263
  ...(task.permission === undefined ? {} : { permission: task.permission }),
212
264
  ...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
265
+ ...(task.goalRun === undefined ? {} : { goalRun: task.goalRun }),
266
+ // The per-card acceptance opt-out rides the import like the /goal opt-in:
267
+ // it is a user-chosen execution preference on their own board, not a
268
+ // verdict. The per-execution `verification` block is still stripped below, so
269
+ // no imported card can arrive already accepted.
270
+ ...(task.skipVerification === undefined ? {} : { skipVerification: task.skipVerification }),
213
271
  ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
214
272
  ...(task.freeze === undefined ? {} : { freeze: task.freeze }),
215
273
  ...(task.handover === undefined ? {} : { handover: task.handover }),
216
274
  ...(task.tags === undefined ? {} : { tags: task.tags }),
275
+ ...(task.integrations === undefined ? {} : { integrations: task.integrations }),
217
276
  // 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
218
277
  // 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
219
278
  }
@@ -247,30 +306,41 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
247
306
 
248
307
  function createInput(value: unknown): value is NewTaskInput {
249
308
  const input = record(value)
250
- if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
309
+ if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'skipVerification', 'tags', 'integrations'])) return false
251
310
  if (input.parentId !== undefined && (typeof input.parentId !== 'string' || input.parentId.trim() === '')) return false
252
311
  if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
253
312
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
254
313
  if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
255
314
  if (input.teamRun !== undefined && typeof input.teamRun !== 'boolean') return false
315
+ if (input.goalRun !== undefined && typeof input.goalRun !== 'boolean') return false
316
+ if (input.skipVerification !== undefined && typeof input.skipVerification !== 'boolean') return false
256
317
  if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
257
318
  if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
319
+ if (input.integrations !== undefined && normalizeTaskIntegrations(input.integrations) === undefined) return false
258
320
  if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
259
321
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
260
322
  if (input.schedule !== undefined) {
261
323
  const schedule = record(input.schedule)
262
- if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
324
+ if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron', 'timeZone'])) return false
263
325
  if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
326
+ if (schedule.timeZone !== undefined
327
+ && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
264
328
  }
265
329
  return true
266
330
  }
267
331
 
268
332
  function updatePatch(value: unknown): boolean {
269
333
  const patch = record(value)
270
- if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
334
+ if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'skipVerification', 'tags'])) return false
271
335
  // null (or false) clears the reuse opt-in; only a real boolean is accepted.
272
336
  if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
273
337
  if (patch.teamRun !== undefined && patch.teamRun !== null && typeof patch.teamRun !== 'boolean') return false
338
+ // The goal opt-in is tri-state: null/true return the card to its default
339
+ // (goal run), false pins a single plain turn.
340
+ if (patch.goalRun !== undefined && patch.goalRun !== null && typeof patch.goalRun !== 'boolean') return false
341
+ // The acceptance opt-out is the mirror tri-state: null/false return the card
342
+ // to inheriting the board-wide switch, true pins it out of the gate.
343
+ if (patch.skipVerification !== undefined && patch.skipVerification !== null && typeof patch.skipVerification !== 'boolean') return false
274
344
  for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
275
345
  if (!optionalString(patch[key])) return false
276
346
  }
@@ -283,12 +353,25 @@ function updatePatch(value: unknown): boolean {
283
353
  return patch.handover === undefined || patch.handover === null || handoverPayload(patch.handover) !== undefined
284
354
  }
285
355
 
356
+ /**
357
+ * A label name on the wire: a non-empty trimmed string within the tag cap. The
358
+ * trim/merge/duplicate rules belong to the use case; this gate only keeps a
359
+ * malformed name off the ledger.
360
+ */
361
+ function isTagName(value: unknown): value is string {
362
+ return typeof value === 'string' && value.trim() !== '' && value.trim().length <= TAG_NAME_MAX_LENGTH
363
+ }
364
+
286
365
  function schedulePatch(value: unknown): boolean {
287
366
  const patch = record(value)
288
367
  return patch !== undefined
289
- && exactKeys(patch, ['enabled', 'cron'])
368
+ && exactKeys(patch, ['enabled', 'cron', 'timeZone'])
290
369
  && (patch.enabled === undefined || typeof patch.enabled === 'boolean')
291
370
  && (patch.cron === undefined || typeof patch.cron === 'string')
371
+ // `null` clears the stored zone back to the Host zone; an unknown name is
372
+ // rejected here so the Host never has to guess at an unusable zone.
373
+ && (patch.timeZone === undefined || patch.timeZone === null
374
+ || (typeof patch.timeZone === 'string' && isValidTimeZone(patch.timeZone)))
292
375
  }
293
376
 
294
377
  export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
@@ -350,6 +433,16 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
350
433
  if (parentId !== null && (typeof parentId !== 'string' || parentId.trim() === '')) return undefined
351
434
  return { requestId: envelope.requestId, action: { kind: 'set-parent', taskId, parentId } }
352
435
  }
436
+ case 'rename-tag': {
437
+ if (!exactKeys(action, ['kind', 'from', 'to'])) return undefined
438
+ if (!isTagName(action.from) || !isTagName(action.to)) return undefined
439
+ return { requestId: envelope.requestId, action: { kind: 'rename-tag', from: action.from, to: action.to } }
440
+ }
441
+ case 'delete-tag': {
442
+ if (!exactKeys(action, ['kind', 'name'])) return undefined
443
+ if (!isTagName(action.name)) return undefined
444
+ return { requestId: envelope.requestId, action: { kind: 'delete-tag', name: action.name } }
445
+ }
353
446
  case 'set-schedule':
354
447
  if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
355
448
  return taskId !== undefined && schedulePatch(action.patch)
@@ -360,10 +453,30 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
360
453
  return taskId !== undefined && isTaskStatus(action.status)
361
454
  ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'move' }> }
362
455
  : undefined
456
+ case 'extension-action': {
457
+ // Structural gate only: whether the id names a registered, enabled
458
+ // extension is decided by the host registry, which owns that state.
459
+ if (!exactKeys(action, ['kind', 'extensionId', 'action', 'taskId', 'payload'])) return undefined
460
+ if (typeof action.extensionId !== 'string' || action.extensionId.trim() === '' || action.extensionId.length > 128) return undefined
461
+ if (typeof action.action !== 'string' || action.action.trim() === '' || action.action.length > 128) return undefined
462
+ if (action.taskId !== undefined && (typeof action.taskId !== 'string' || action.taskId === '')) return undefined
463
+ if (action.payload !== undefined && !isTaskBoardExtensionPayload(action.payload)) return undefined
464
+ return {
465
+ requestId: envelope.requestId,
466
+ action: {
467
+ kind: 'extension-action',
468
+ extensionId: action.extensionId,
469
+ action: action.action,
470
+ ...(action.taskId === undefined ? {} : { taskId: action.taskId }),
471
+ ...(action.payload === undefined ? {} : { payload: action.payload }),
472
+ },
473
+ }
474
+ }
363
475
  case 'confirm-permission':
364
476
  case 'delete':
365
477
  case 'archive':
366
478
  case 'restore':
479
+ case 'settle':
367
480
  case 'run':
368
481
  case 'rerun':
369
482
  if (!exactKeys(action, ['kind', 'taskId'])) return undefined