@linxin666/dsh-client-ui-task-board 0.4.4 → 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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  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 +4 -1
  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/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
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,18 +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
4
  import { isValidTimeZone } from './core/schedule.ts'
5
5
  import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
6
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'
7
9
 
8
10
  /** Freeze payload carried by create/update actions after the gate (redacted in place). */
9
11
  type FreezePayload = FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
10
12
 
11
- export const TASK_BOARD_SCHEMA_VERSION = 4 as const
12
- /** Ledger documents written before v4; loaded once and migrated on startup. */
13
- export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 3 as const
14
- /** Ledger documents written before v3; migrated through the v3 normalization too. */
15
- export const TASK_BOARD_OLDER_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
+ ]
16
31
  export const TASK_BOARD_API_PREFIX = '/api/task-board'
17
32
 
18
33
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
@@ -53,6 +68,25 @@ export interface TaskBoardSnapshot {
53
68
  * into team execution (Team Lead session plus one teammate per subtask).
54
69
  */
55
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
56
90
  }
57
91
 
58
92
  /** SSE event frame: revision/scheduler/power only, never the task list. */
@@ -110,6 +144,10 @@ export type TaskBoardAction =
110
144
  | { kind: 'rerun'; taskId: string }
111
145
  | { kind: 'confirm-permission'; taskId: string }
112
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 }
113
151
 
114
152
  export interface TaskBoardActionEnvelope {
115
153
  requestId: string
@@ -154,6 +192,7 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
154
192
  // must carry a well-formed list or none at all, so a hand-edited export
155
193
  // cannot smuggle a malformed tag past the gate.
156
194
  if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
195
+ if (value.integrations !== undefined && normalizeTaskIntegrations(value.integrations) === undefined) return false
157
196
  if (value.schedule !== undefined) {
158
197
  const schedule = record(value.schedule)
159
198
  if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
@@ -172,6 +211,10 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
172
211
  if (execution.initiatedBy !== undefined && typeof execution.initiatedBy !== 'string') return false
173
212
  if (execution.frozenBy !== undefined && typeof execution.frozenBy !== 'string') return false
174
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
175
218
  }
176
219
  }
177
220
  return true
@@ -200,6 +243,8 @@ function importedTask(value: unknown): TaskRecord | undefined {
200
243
  ...(execution.initiatedBy === undefined ? {} : { initiatedBy: execution.initiatedBy }),
201
244
  ...(execution.frozenAt === undefined ? {} : { frozenAt: execution.frozenAt }),
202
245
  ...(execution.frozenBy === undefined ? {} : { frozenBy: execution.frozenBy }),
246
+ // 安全门(对抗场景 d):验收报告是 Host 的判定,不接受 import 携带——
247
+ // 一条伪造的通过记录会让导入的执行被当成已验收。
203
248
  })),
204
249
  ...(task.schedule === undefined ? {} : {
205
250
  schedule: {
@@ -218,10 +263,16 @@ function importedTask(value: unknown): TaskRecord | undefined {
218
263
  ...(task.permission === undefined ? {} : { permission: task.permission }),
219
264
  ...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
220
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 }),
221
271
  ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
222
272
  ...(task.freeze === undefined ? {} : { freeze: task.freeze }),
223
273
  ...(task.handover === undefined ? {} : { handover: task.handover }),
224
274
  ...(task.tags === undefined ? {} : { tags: task.tags }),
275
+ ...(task.integrations === undefined ? {} : { integrations: task.integrations }),
225
276
  // 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
226
277
  // 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
227
278
  }
@@ -255,15 +306,17 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
255
306
 
256
307
  function createInput(value: unknown): value is NewTaskInput {
257
308
  const input = record(value)
258
- if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', '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
259
310
  if (input.parentId !== undefined && (typeof input.parentId !== 'string' || input.parentId.trim() === '')) return false
260
311
  if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
261
312
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
262
313
  if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
263
314
  if (input.teamRun !== undefined && typeof input.teamRun !== 'boolean') return false
264
315
  if (input.goalRun !== undefined && typeof input.goalRun !== 'boolean') return false
316
+ if (input.skipVerification !== undefined && typeof input.skipVerification !== 'boolean') return false
265
317
  if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
266
318
  if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
319
+ if (input.integrations !== undefined && normalizeTaskIntegrations(input.integrations) === undefined) return false
267
320
  if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
268
321
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
269
322
  if (input.schedule !== undefined) {
@@ -278,13 +331,16 @@ function createInput(value: unknown): value is NewTaskInput {
278
331
 
279
332
  function updatePatch(value: unknown): boolean {
280
333
  const patch = record(value)
281
- if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', '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
282
335
  // null (or false) clears the reuse opt-in; only a real boolean is accepted.
283
336
  if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
284
337
  if (patch.teamRun !== undefined && patch.teamRun !== null && typeof patch.teamRun !== 'boolean') return false
285
338
  // The goal opt-in is tri-state: null/true return the card to its default
286
339
  // (goal run), false pins a single plain turn.
287
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
288
344
  for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
289
345
  if (!optionalString(patch[key])) return false
290
346
  }
@@ -297,6 +353,15 @@ function updatePatch(value: unknown): boolean {
297
353
  return patch.handover === undefined || patch.handover === null || handoverPayload(patch.handover) !== undefined
298
354
  }
299
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
+
300
365
  function schedulePatch(value: unknown): boolean {
301
366
  const patch = record(value)
302
367
  return patch !== undefined
@@ -368,6 +433,16 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
368
433
  if (parentId !== null && (typeof parentId !== 'string' || parentId.trim() === '')) return undefined
369
434
  return { requestId: envelope.requestId, action: { kind: 'set-parent', taskId, parentId } }
370
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
+ }
371
446
  case 'set-schedule':
372
447
  if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
373
448
  return taskId !== undefined && schedulePatch(action.patch)
@@ -378,6 +453,25 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
378
453
  return taskId !== undefined && isTaskStatus(action.status)
379
454
  ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'move' }> }
380
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
+ }
381
475
  case 'confirm-permission':
382
476
  case 'delete':
383
477
  case 'archive':
@@ -0,0 +1,95 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/tool-surface.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Family-shared tool-surface conventions for the packages that register
4
+ * model-facing tools.
5
+ *
6
+ * One home for the prompt-section placement and the visibility gate this
7
+ * family previously restated per package.
8
+ *
9
+ * Modelled on the official tool packages:
10
+ * - `@deepseek-ai/dsh-tool-subagent` renders its section through
11
+ * `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
12
+ * disappears together with a restricted tool.
13
+ * - 21 of the 22 official tool packages register their tools GLOBALLY and gate
14
+ * them with a setting, not per agent. Only the Agent Teams tool package —
15
+ * whose tools belong to one membership — installs into each `agent.ctx`.
16
+ * This family follows the majority: registration stays global so the
17
+ * registry's per-scope `restrict()` mask (which filters inherited global
18
+ * tools and REJECTS scope-local names) can still withhold these tools.
19
+ *
20
+ * Structural types only: this copy lands in packages that do not all depend on
21
+ * the same SDK service packages.
22
+ */
23
+
24
+ /**
25
+ * Prompt-section order for each package's tool guidance.
26
+ *
27
+ * These sit in the family band between the deployment persona and the SDK's
28
+ * `PLAN_POLICY` (500). Values are owned here rather than written per package so
29
+ * the host plugins whose guidance shares one prompt cannot drift into an
30
+ * accidental tie.
31
+ */
32
+ export const PLUGIN_TOOL_SECTION_ORDERS = {
33
+ ssh: 150,
34
+ 'task-board': 200,
35
+ } as const
36
+
37
+ /** One plugin id keyed by {@link PLUGIN_TOOL_SECTION_ORDERS}. */
38
+ export type PluginToolSectionId = keyof typeof PLUGIN_TOOL_SECTION_ORDERS
39
+
40
+ /**
41
+ * Order of an external provider extension's own guidance section, right after
42
+ * the band of the plugin it extends.
43
+ *
44
+ * An extension names itself, not the plugin it plugs into, so its order stays
45
+ * out of {@link PLUGIN_TOOL_SECTION_ORDERS}: every package's copy of this module
46
+ * then carries no provider id. The slot still has one owner and cannot collide
47
+ * with a host plugin's band.
48
+ */
49
+ export const EXTENSION_TOOL_SECTION_ORDER = 210
50
+
51
+ /**
52
+ * The scoped tool lookup a section provider needs: the registry read for one
53
+ * assembly's scope. Matches `ctx.tools.get(name, scope)`.
54
+ */
55
+ export interface ScopedToolLookup {
56
+ get(name: string, scope?: unknown): unknown
57
+ }
58
+
59
+ /** One assembly context carrying the scope the section is rendered for. */
60
+ export interface SectionScopeContext {
61
+ scope?: unknown
62
+ }
63
+
64
+ /**
65
+ * Build a section provider that renders \`text\` only while at least one of
66
+ * \`toolNames\` is visible to the rendering scope.
67
+ *
68
+ * The native convention: a tool that a restriction, a preset, or a missing
69
+ * registry removed must not leave guidance behind telling the model to call it.
70
+ * An unreadable registry renders the text unchanged — guidance that outlives a
71
+ * momentary read failure is recoverable; guidance for a tool that is gone is
72
+ * not.
73
+ * @param tools - the scoped tool registry, when the deployment serves one.
74
+ * @param toolNames - registered names the guidance describes.
75
+ * @param text - the guidance rendered while they are reachable.
76
+ * @returns the section text provider.
77
+ */
78
+ export function visibleToolText(
79
+ tools: ScopedToolLookup | undefined,
80
+ toolNames: readonly string[],
81
+ text: string,
82
+ ): (context: SectionScopeContext) => string {
83
+ if (tools === undefined) return () => text
84
+ return (context: SectionScopeContext) => {
85
+ for (const name of toolNames) {
86
+ try {
87
+ if (tools.get(name, context.scope) !== undefined) return text
88
+ } catch {
89
+ // A refused lookup is not proof the tool is gone; keep the guidance.
90
+ return text
91
+ }
92
+ }
93
+ return ''
94
+ }
95
+ }