@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -0,0 +1,376 @@
1
+ /**
2
+ * The task board's external provider extension contract.
3
+ *
4
+ * This module is the single source of truth for how another plugin adds a
5
+ * provider surface to the task board: the cordis service name both halves
6
+ * publish, the API version they negotiate, the child seats a provider renders
7
+ * into, the host capability face a provider implements against, and the client
8
+ * capability face the board's browser half provides. It is framework-free and
9
+ * shared by the host and browser programs; the only imported SDK type is
10
+ * type-only, so the browser bundle never pulls a value out of an SDK package.
11
+ *
12
+ * @module dsh-task-board/core/extension
13
+ */
14
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
15
+ import type { ExecutionOutcome, TaskRecord, TaskStatus } from './tasks.ts'
16
+
17
+ /** Cordis service name the host and browser halves each publish. */
18
+ export const TASK_BOARD_SERVICE_NAME = 'taskBoard'
19
+
20
+ /**
21
+ * Contract version of {@link TASK_BOARD_SERVICE_NAME}. A provider declares the
22
+ * version it was built against; the board refuses (visibly) any other version
23
+ * instead of guessing at a stale shape.
24
+ */
25
+ export const TASK_BOARD_API_VERSION = 1
26
+
27
+ /** Child seat a provider registers its task-detail section into. */
28
+ export const TASK_BOARD_DETAIL_SECTION = 'task-board.detail.section'
29
+ /** Child seat a provider registers its settings section into. */
30
+ export const TASK_BOARD_SETTINGS_SECTION = 'task-board.settings.section'
31
+ /** Child seat a provider registers its card decoration into. */
32
+ export const TASK_BOARD_CARD_DECORATION = 'task-board.card.decoration'
33
+
34
+ /** Every child seat the board declares for extensions, in render order. */
35
+ export const TASK_BOARD_EXTENSION_SEATS: readonly string[] = [
36
+ TASK_BOARD_DETAIL_SECTION,
37
+ TASK_BOARD_SETTINGS_SECTION,
38
+ TASK_BOARD_CARD_DECORATION,
39
+ ]
40
+
41
+ /**
42
+ * Largest accepted extension payload, in bytes of its JSON serialization. One
43
+ * integration entry — and one action payload — may carry schema the board does
44
+ * not interpret, but never enough of it to bloat the ledger or the action
45
+ * channel.
46
+ */
47
+ export const TASK_BOARD_PAYLOAD_LIMIT_BYTES = 64 * 1024
48
+
49
+ /** Depth beyond which a payload is refused instead of walked. */
50
+ const PAYLOAD_DEPTH_LIMIT = 64
51
+
52
+ /** One opaque provider payload the board stores and forwards without interpreting. */
53
+ export type TaskBoardExtensionPayload = Record<string, unknown>
54
+
55
+ /** Whether a value is a plain JSON value (no undefined, function, symbol, bigint or cycle). */
56
+ function isJsonValue(value: unknown, depth: number): boolean {
57
+ if (depth > PAYLOAD_DEPTH_LIMIT) return false
58
+ if (value === null) return true
59
+ switch (typeof value) {
60
+ case 'string':
61
+ case 'boolean':
62
+ return true
63
+ case 'number':
64
+ return Number.isFinite(value)
65
+ // A provider's in-memory metadata legitimately clears a field by setting it
66
+ // to undefined; JSON serialization drops such a key, so the persisted
67
+ // payload is still pure JSON.
68
+ case 'undefined':
69
+ return true
70
+ case 'object':
71
+ break
72
+ default:
73
+ return false
74
+ }
75
+ if (Array.isArray(value)) return value.every(item => isJsonValue(item, depth + 1))
76
+ const prototype = Object.getPrototypeOf(value)
77
+ if (prototype !== Object.prototype && prototype !== null) return false
78
+ return Object.values(value as Record<string, unknown>).every(item => isJsonValue(item, depth + 1))
79
+ }
80
+
81
+ /**
82
+ * Validate one extension payload: a pure JSON object whose serialization fits
83
+ * {@link TASK_BOARD_PAYLOAD_LIMIT_BYTES}. Arrays, primitives and non-plain
84
+ * objects are refused; the board never interprets the keys.
85
+ * @param value - candidate payload.
86
+ * @returns whether the value is an acceptable opaque payload.
87
+ */
88
+ export function isTaskBoardExtensionPayload(value: unknown): value is TaskBoardExtensionPayload {
89
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
90
+ if (!isJsonValue(value, 0)) return false
91
+ try {
92
+ return new TextEncoder().encode(JSON.stringify(value)).length <= TASK_BOARD_PAYLOAD_LIMIT_BYTES
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /** Byte length of a value's JSON serialization, or undefined when it does not serialize. */
99
+ export function taskBoardPayloadBytes(value: unknown): number | undefined {
100
+ try {
101
+ return new TextEncoder().encode(JSON.stringify(value)).length
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Normalize a task's whole integrations container: a plain JSON object whose
109
+ * values are each an opaque provider payload within the size limit. A malformed
110
+ * container normalizes to undefined (the field is dropped), and one malformed
111
+ * entry is dropped alone so a single bad provider can never take a card — or
112
+ * another provider's data — with it.
113
+ * @param value - candidate container from the wire, the ledger, or a create input.
114
+ * @returns the cleaned container, or undefined when nothing usable remains.
115
+ */
116
+ export function normalizeTaskIntegrations(value: unknown): Record<string, TaskBoardExtensionPayload> | undefined {
117
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
118
+ const prototype = Object.getPrototypeOf(value)
119
+ if (prototype !== Object.prototype && prototype !== null) return undefined
120
+ const result: Record<string, TaskBoardExtensionPayload> = {}
121
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
122
+ if (key === '') continue
123
+ if (!isTaskBoardExtensionPayload(entry)) continue
124
+ result[key] = entry
125
+ }
126
+ return Object.keys(result).length === 0 ? undefined : result
127
+ }
128
+
129
+ /** One action a provider dispatches over the board's same-origin channel. */
130
+ export interface TaskBoardExtensionActionRequest {
131
+ /** Id of the extension the action belongs to. */
132
+ extensionId: string
133
+ /** Provider-owned action name, routed back to that extension's handleAction. */
134
+ action: string
135
+ /** Task the action targets, when it targets one. */
136
+ taskId?: string
137
+ /** Opaque provider payload, bounded like every other extension payload. */
138
+ payload?: TaskBoardExtensionPayload
139
+ }
140
+
141
+ /**
142
+ * Deliver one extension action through the board's same-origin action channel.
143
+ * The extension never opens an HTTP surface of its own.
144
+ */
145
+ export type TaskBoardExtensionDispatch = (request: TaskBoardExtensionActionRequest) => Promise<boolean>
146
+
147
+ /** Draft one provider creates a task from. */
148
+ export interface TaskBoardTaskDraft {
149
+ title: string
150
+ description: string
151
+ prompt: string
152
+ /** Column the created card opens in; absent means the board default (todo). */
153
+ status?: TaskStatus
154
+ /** Parent card id, when the provider materializes a subtask. */
155
+ parentId?: string
156
+ /**
157
+ * Workspace the execution must run in. Absent leaves the board's own
158
+ * inheritance rules in charge (the creating session's workspace for a root
159
+ * card, else the recent workspace at execution time), so a provider that has
160
+ * no reason to know better pins nothing.
161
+ */
162
+ workspaceId?: string
163
+ }
164
+
165
+ /** Options of {@link TaskBoardTasksFace.create}. */
166
+ export interface TaskBoardCreateOptions {
167
+ /** Opaque payload stored under the creating extension's id. */
168
+ payload?: TaskBoardExtensionPayload
169
+ /** Create the card off-board: it exists in the ledger but is never shown on the board. */
170
+ hidden?: boolean
171
+ }
172
+
173
+ /** One task linked to an extension, with that extension's own payload. */
174
+ export interface TaskBoardLinkedTask {
175
+ task: TaskRecord
176
+ payload: TaskBoardExtensionPayload
177
+ }
178
+
179
+ /** Task reads and writes the board grants an extension. */
180
+ export interface TaskBoardTasksFace {
181
+ /** Every task in the ledger, most recent mutation last. */
182
+ list(): readonly TaskRecord[]
183
+ /** One task by id, or undefined. */
184
+ get(taskId: string): TaskRecord | undefined
185
+ /**
186
+ * Create a task through the board's own creation gate; the payload lands
187
+ * under the creating extension's id. Throws on a refused draft.
188
+ */
189
+ create(draft: TaskBoardTaskDraft, options?: TaskBoardCreateOptions): TaskRecord
190
+ /**
191
+ * Patch a task's content. Reuses the board's own content gate: a task that
192
+ * has started executing keeps its recorded title/description/prompt.
193
+ * Throws when the card is frozen.
194
+ */
195
+ patchContent(taskId: string, patch: { title?: string; description?: string; prompt?: string }): void
196
+ /**
197
+ * Move a task through the board's existing status gates (running lock,
198
+ * permission confirmation gate). Throws on refusal.
199
+ */
200
+ setStatus(taskId: string, status: TaskStatus, initiator?: string): void
201
+ /** Every task carrying this extension's payload. */
202
+ linked(): readonly TaskBoardLinkedTask[]
203
+ }
204
+
205
+ /** Storage face for one extension's own payload on a task. */
206
+ export interface TaskBoardIntegrationFace {
207
+ /** Read this extension's payload, or undefined when the task carries none. */
208
+ read(taskId: string): TaskBoardExtensionPayload | undefined
209
+ /**
210
+ * Shallow-merge this extension's payload into the task: the given keys
211
+ * replace the stored ones, and a key explicitly set to undefined clears it.
212
+ * The board stores the result opaquely; a non-JSON or oversized payload is
213
+ * refused.
214
+ */
215
+ write(taskId: string, payload: TaskBoardExtensionPayload): void
216
+ }
217
+
218
+ /** Status-change notification. */
219
+ export interface TaskBoardStatusChangedEvent {
220
+ taskId: string
221
+ status: TaskStatus
222
+ previous: TaskStatus
223
+ initiator?: string
224
+ }
225
+
226
+ /** Execution-settlement notification. */
227
+ export interface TaskBoardExecutionSettledEvent {
228
+ taskId: string
229
+ executionId: string
230
+ outcome: ExecutionOutcome
231
+ error?: string
232
+ }
233
+
234
+ /** Task-deletion notification. */
235
+ export interface TaskBoardTaskDeletedEvent {
236
+ taskId: string
237
+ }
238
+
239
+ /**
240
+ * Event subscriptions an extension registers for. Every callback runs isolated:
241
+ * a throw is logged and never interrupts the board's own execution path.
242
+ */
243
+ export interface TaskBoardEventFace {
244
+ onStatusChanged(callback: (event: TaskBoardStatusChangedEvent) => void): () => void
245
+ onExecutionSettled(callback: (event: TaskBoardExecutionSettledEvent) => void): () => void
246
+ onTaskDeleted(callback: (event: TaskBoardTaskDeletedEvent) => void): () => void
247
+ }
248
+
249
+ /**
250
+ * Host capability face handed to one extension while it is started. The face
251
+ * lives for the extension's enabled lifetime; the board revokes it by calling
252
+ * the extension's stop().
253
+ */
254
+ export interface TaskBoardExtensionHost {
255
+ tasks: TaskBoardTasksFace
256
+ integration: TaskBoardIntegrationFace
257
+ events: TaskBoardEventFace
258
+ /**
259
+ * Publish this extension's read-only summary; it lands in the snapshot's
260
+ * extensions[id] for the extension's browser half to render.
261
+ */
262
+ publish(summary: TaskBoardExtensionPayload): void
263
+ /** Register one model-visible tool; the returned disposer unregisters it. */
264
+ registerTool(definition: ToolDefinition): () => void
265
+ }
266
+
267
+ /** One external provider contributed to the task board. */
268
+ export interface TaskBoardExtension {
269
+ /** Stable extension id; also the key of this extension's integration payload. */
270
+ readonly id: string
271
+ /** Contract version this extension was built against. */
272
+ readonly apiVersion: number
273
+ /** Volatile per-extension switch; absent means enabled. Read at use time. */
274
+ enabled?(): boolean
275
+ /** Acquire the capability face and start any provider-side work. */
276
+ start?(host: TaskBoardExtensionHost): void
277
+ /** Release provider-side work. Never clears stored data. */
278
+ stop?(): void
279
+ /** Route one dispatched action; the returned value goes back to the dispatcher. */
280
+ handleAction?(request: TaskBoardExtensionActionRequest): unknown | Promise<unknown>
281
+ }
282
+
283
+ /** Board mirror an extension's browser half observes. */
284
+ export interface TaskBoardClientMirror {
285
+ /** Whether the board's master switch currently runs the extension faces. */
286
+ enabled: boolean
287
+ /** Tasks the board currently holds. */
288
+ tasks: readonly TaskRecord[]
289
+ /** Published extension summaries, keyed by extension id (opaque to the board). */
290
+ extensions: Readonly<Record<string, unknown>>
291
+ }
292
+
293
+ /** Whether a task should be shown on the board; false hides it. */
294
+ export type TaskBoardVisibilityPredicate = (task: TaskRecord) => boolean
295
+
296
+ /**
297
+ * Client capability face the board's browser half provides. A provider's
298
+ * browser half resolves it from the shared cordis service name; it never
299
+ * value-imports the board package.
300
+ */
301
+ export interface TaskBoardClientFace {
302
+ /** Deliver one action through the board's same-origin action channel. */
303
+ dispatch(request: TaskBoardExtensionActionRequest): Promise<boolean>
304
+ /** Register one card visibility predicate; the returned disposer removes it. */
305
+ registerVisibility(predicate: TaskBoardVisibilityPredicate): () => void
306
+ /** Subscribe to the board mirror. */
307
+ subscribe(callback: (mirror: TaskBoardClientMirror) => void): () => void
308
+ /** Read the current board mirror. */
309
+ snapshot(): TaskBoardClientMirror
310
+ }
311
+
312
+ /** Owner props of the task-detail seat. */
313
+ export interface TaskBoardDetailSectionProps {
314
+ task: TaskRecord
315
+ dispatch: TaskBoardExtensionDispatch
316
+ }
317
+
318
+ /** Owner props of the settings seat. */
319
+ export interface TaskBoardSettingsSectionProps {
320
+ dispatch: TaskBoardExtensionDispatch
321
+ }
322
+
323
+ /** Owner props of the card-decoration seat. */
324
+ export interface TaskBoardCardDecorationProps {
325
+ task: TaskRecord
326
+ }
327
+
328
+ /**
329
+ * Host-half service face published under {@link TASK_BOARD_SERVICE_NAME}. A
330
+ * provider package's host half resolves this from the shared cordis service
331
+ * and registers its extension; the board's ledger never crosses this boundary.
332
+ */
333
+ export interface TaskBoardHostFace {
334
+ /**
335
+ * Admit one provider extension. Idempotent by extension id; the returned
336
+ * disposer releases it without clearing stored data.
337
+ */
338
+ registerExtension(extension: TaskBoardExtension): () => void
339
+ /** Whether the board currently runs a given extension (board x extension). */
340
+ isExtensionEnabled(extensionId: string): boolean
341
+ }
342
+
343
+ declare module '@deepseek-ai/cordis' {
344
+ interface Context {
345
+ /**
346
+ * The board's extension service. The host half publishes
347
+ * {@link TaskBoardHostFace}; the browser half publishes
348
+ * {@link TaskBoardClientFace} under the same name. The two halves never run
349
+ * in one process, so the browser half resolves its face through
350
+ * {@link resolveTaskBoardClientFace} and this shared declaration names the
351
+ * host face that registration-side consumers type against.
352
+ */
353
+ taskBoard: TaskBoardHostFace
354
+ }
355
+ }
356
+
357
+ /** The narrow context seat {@link resolveTaskBoardClientFace} needs. */
358
+ export interface TaskBoardClientContextSeat {
359
+ get(name: string): unknown
360
+ }
361
+
362
+ /**
363
+ * Resolve the browser half's client capability face by service name. Returns
364
+ * undefined when the board is not loaded or the service is not served, so a
365
+ * provider's browser half can degrade instead of throwing.
366
+ * @param ctx - client context.
367
+ * @returns the client face, or undefined.
368
+ */
369
+ export function resolveTaskBoardClientFace(ctx: TaskBoardClientContextSeat): TaskBoardClientFace | undefined {
370
+ try {
371
+ const face = ctx.get(TASK_BOARD_SERVICE_NAME) as TaskBoardClientFace | undefined
372
+ return face !== undefined && typeof (face as { dispatch?: unknown }).dispatch === 'function' ? face : undefined
373
+ } catch {
374
+ return undefined
375
+ }
376
+ }
@@ -48,8 +48,8 @@ const SECTION_NAMES: Record<SectionKey, string> = {
48
48
  /**
49
49
  * Sensitive patterns, each matched globally over every field body:
50
50
  * PEM private key blocks (whole block collapses to one marker), Bearer
51
- * credentials, OpenAI sk-, GitHub ghp_, GitLab glpat-, Slack xox* tokens,
52
- * and AWS access key ids.
51
+ * credentials, OpenAI sk-, `ghp_`-shaped provider tokens, GitLab glpat-,
52
+ * Slack xox* tokens, and AWS access key ids.
53
53
  */
54
54
  const SENSITIVE_PATTERNS: RegExp[] = [
55
55
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
@@ -40,7 +40,11 @@ export const HANDOVER_MAX_TOTAL_BYTES = 8 * 1024
40
40
  /** Per target-id (workspace/preset) byte cap. */
41
41
  export const HANDOVER_MAX_TARGET_BYTES = 256
42
42
 
43
- /** The board's notion of the deployment session-default permission (fail-safe default). */
43
+ /**
44
+ * Fail-safe baseline for the confirmation gate: what it falls back to when the
45
+ * deployment pins no baseline of its own and the Host's default permission
46
+ * preset cannot be read. Every elevation above it stays gated.
47
+ */
44
48
  export const DEFAULT_SESSION_PERMISSION: TaskPermission = 'read-only'
45
49
 
46
50
  /** Permission elevation rank (higher = more authority). */
@@ -105,6 +109,17 @@ export function exceedsSessionDefault(permission: TaskPermission | undefined, se
105
109
  return (PERMISSION_RANK.get(permission) ?? -1) > (PERMISSION_RANK.get(sessionDefault) ?? -1)
106
110
  }
107
111
 
112
+ /**
113
+ * Whether a session running at `holder` already carries `permission`: the
114
+ * permission is absent, or its rank does not exceed the holder's. A teammate
115
+ * inherits the Lead session's permission and cannot be narrowed below it, so a
116
+ * subtask's own pin is carried by a team run exactly when this holds, and a pin
117
+ * it does not carry would be silently dropped instead.
118
+ */
119
+ export function permissionCarriedBy(holder: TaskPermission, permission: TaskPermission | undefined): boolean {
120
+ return !exceedsSessionDefault(permission, holder)
121
+ }
122
+
108
123
  /**
109
124
  * The confirmation-gate predicate: an elevated permission without a human
110
125
  * confirmation stamp. Manual run/rerun and cron must refuse such a card.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Issue-form vocabulary the card excerpt recognizes.
3
+ *
4
+ * Issue forms from an issue tracker (and the zh templates this family's repositories use)
5
+ * write the same section headings and empty-field placeholders on every issue.
6
+ * The patterns are data about those forms, not UI copy, so they live in the
7
+ * shared core rather than in a locale dictionary; the browser half imports them.
8
+ *
9
+ * @module @linxin666/dsh-client-ui-task-board/core/issue-form
10
+ */
11
+
12
+ /** Section headings whose body is the issue's own summary (zh and en forms). */
13
+ export const SUMMARY_HEADING = /^(?:摘要|概要|概述|简介|描述|问题描述|问题|现象|summary|description|overview|problem|what happened)$/i
14
+
15
+ /** Values an issue form writes for an empty field. */
16
+ export const EMPTY_FIELD = /^(?:_?no response_?|n\/a|none|无|暂无)$/i
17
+
18
+ /** A trailing colon (ASCII or full-width) after a heading. */
19
+ export const HEADING_COLON = /[:\uFF1A]\s*$/
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Roster-poll cadence: the one recurring Host timer the board holds.
3
+ *
4
+ * The cadence is a user setting (seconds), so the bounds and the seconds to
5
+ * milliseconds conversion live here rather than in either half: the settings
6
+ * card validates a draft against the same range the Host clamps a hand-edited
7
+ * configuration to, and a programmatic mount that passes no cadence gets the
8
+ * default.
9
+ *
10
+ * @module dsh-task-board/core/poll-cadence
11
+ */
12
+
13
+ /** Cadence used when neither the settings document nor the mount names one. */
14
+ export const DEFAULT_SESSION_POLL_SECONDS = 5
15
+
16
+ /** Shortest accepted cadence: a faster poll only burns CPU on the Host. */
17
+ export const SESSION_POLL_MIN_SECONDS = 1
18
+
19
+ /** Longest accepted cadence, five minutes. */
20
+ export const SESSION_POLL_MAX_SECONDS = 300
21
+
22
+ /**
23
+ * Clamp a configured cadence into the supported range. A value the schema
24
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
25
+ * the board polling: an out-of-range setting never disables the timer.
26
+ * @param seconds - configured cadence in seconds, or undefined for the default.
27
+ * @returns the effective cadence in seconds.
28
+ */
29
+ export function normalizeSessionPollSeconds(seconds: number | undefined): number {
30
+ if (seconds === undefined || !Number.isFinite(seconds)) return DEFAULT_SESSION_POLL_SECONDS
31
+ return Math.min(Math.max(Math.trunc(seconds), SESSION_POLL_MIN_SECONDS), SESSION_POLL_MAX_SECONDS)
32
+ }
33
+
34
+ /**
35
+ * The cadence in the milliseconds the Host timer face takes.
36
+ * @param seconds - configured cadence in seconds, or undefined for the default.
37
+ * @returns the effective cadence in milliseconds.
38
+ */
39
+ export function sessionPollMs(seconds: number | undefined): number {
40
+ return normalizeSessionPollSeconds(seconds) * 1_000
41
+ }
package/src/core/store.ts CHANGED
@@ -10,11 +10,13 @@
10
10
  * localStorage backend.
11
11
  */
12
12
  import { isValidCron, isValidTimeZone } from './schedule.ts'
13
- import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
13
+ import { SCHEDULE_STOP_REASONS, isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type ScheduleStopReason, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
14
  import { isExecutionOutcome } from './subtask.ts'
15
15
  import type { TaskHandover } from './handover.ts'
16
16
  import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
17
17
  import { sanitizeHandover } from './handover.ts'
18
+ import { normalizeTaskIntegrations } from './extension.ts'
19
+ import { normalizeVerification } from './verification.ts'
18
20
 
19
21
  /** Persistence seam for the task ledger. */
20
22
  export interface TaskStore {
@@ -67,8 +69,11 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
67
69
  if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
68
70
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
69
71
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
72
+ if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
73
+ if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
70
74
  if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
71
75
  if (record.goalRun !== undefined && typeof record.goalRun !== 'boolean') return false
76
+ if (record.skipVerification !== undefined && typeof record.skipVerification !== 'boolean') return false
72
77
  if (!Array.isArray(record.executions)) return false
73
78
  for (const execution of record.executions) {
74
79
  if (typeof execution !== 'object' || execution === null) return false
@@ -82,6 +87,13 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
82
87
  if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
83
88
  if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
84
89
  if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
90
+ // An external-outcome marker is optional in BOTH directions: a ledger
91
+ // written before the field existed carries none, and a non-boolean value
92
+ // is dropped by the repair below rather than trusted as provenance.
93
+ if (entry.external !== undefined && typeof entry.external !== 'boolean') return false
94
+ // The acceptance block is repaired like every other optional field: an
95
+ // unusable one is dropped, which can only make the board MORE strict.
96
+ if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
85
97
  }
86
98
  return true
87
99
  }
@@ -96,29 +108,73 @@ function normalizeStatus(status: unknown): TaskStatus {
96
108
  return isTaskStatus(status) ? status : 'todo'
97
109
  }
98
110
 
111
+ /** A persisted ms timestamp, kept only when it is a usable finite number. */
112
+ function optionalInstant(value: unknown): number | undefined {
113
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
114
+ }
115
+
116
+ /** A persisted positive-integer run cap, or undefined for unlimited/repair. */
117
+ function optionalRunCap(value: unknown): number | undefined {
118
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined
119
+ }
120
+
121
+ /** A persisted stop reason, kept only when it names a reason this build knows. */
122
+ function optionalStopReason(value: unknown): ScheduleStopReason | undefined {
123
+ return typeof value === 'string' && SCHEDULE_STOP_REASONS.includes(value as ScheduleStopReason)
124
+ ? value as ScheduleStopReason
125
+ : undefined
126
+ }
127
+
99
128
  /**
100
- * Repair a persisted schedule rule: drop rules without a usable cron string,
101
- * coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
102
- * when missing (a fresh recompute or the next tick fixes them).
129
+ * Repair a persisted schedule rule. Both modes are repaired field by field and
130
+ * a rule that carries no usable plan is dropped (the task row survives):
131
+ * a recurring rule without a well-formed 5-field cron, or a one-shot without a
132
+ * finite planned instant. A rule written before `mode` existed is a cron rule.
103
133
  */
104
134
  function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
105
135
  if (typeof schedule !== 'object' || schedule === null) return undefined
106
136
  const rule = schedule as Record<string, unknown>
137
+ // A stored zone survives only when this runtime can resolve it; an unusable
138
+ // name is dropped (the rule then follows the Host zone) instead of being
139
+ // kept to fail every later resolve.
140
+ const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
141
+ const runCountValue = rule.runCount
142
+ const runCount = typeof runCountValue === 'number' && Number.isInteger(runCountValue) && runCountValue >= 0
143
+ ? runCountValue
144
+ : 0
145
+ const endedAt = optionalInstant(rule.endedAt)
146
+ const endedReason = optionalStopReason(rule.endedReason)
147
+ const skippedAt = optionalInstant(rule.skippedAt)
148
+ const skippedReason = optionalStopReason(rule.skippedReason)
149
+ const common = {
150
+ enabled: rule.enabled === true,
151
+ ...(timeZone === undefined ? {} : { timeZone }),
152
+ nextRunAt: optionalInstant(rule.nextRunAt),
153
+ lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
154
+ runCount,
155
+ ...(endedAt === undefined ? {} : { endedAt }),
156
+ ...(endedReason === undefined ? {} : { endedReason }),
157
+ ...(skippedAt === undefined ? {} : { skippedAt }),
158
+ ...(skippedReason === undefined ? {} : { skippedReason }),
159
+ }
160
+ if (rule.mode === 'once') {
161
+ const at = optionalInstant(rule.at)
162
+ // A one-shot with no instant can never fire; drop it rather than keep a
163
+ // rule the scheduler would have to interpret.
164
+ if (at === undefined) return undefined
165
+ return { ...common, mode: 'once', at }
166
+ }
107
167
  // Reject (drop) a schedule whose cron is not a well-formed 5-field
108
168
  // expression: a malformed rule would otherwise linger as a never-firing
109
169
  // schedule instead of being dropped for later repair.
110
170
  if (typeof rule.cron !== 'string') return undefined
111
171
  if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
112
- // A stored zone survives only when this runtime can resolve it; an unusable
113
- // name is dropped (the rule then follows the Host zone) instead of being
114
- // kept to fail every later resolve.
115
- const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
172
+ const maxRuns = optionalRunCap(rule.maxRuns)
116
173
  return {
117
- enabled: rule.enabled === true,
174
+ ...common,
175
+ mode: 'cron',
118
176
  cron: rule.cron,
119
- ...(timeZone === undefined ? {} : { timeZone }),
120
- nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
121
- lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
177
+ ...(maxRuns === undefined ? {} : { maxRuns }),
122
178
  }
123
179
  }
124
180
 
@@ -199,6 +255,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
199
255
  runGroupId: normalizeTargetId(execution.runGroupId),
200
256
  ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : undefined,
201
257
  ownError: typeof execution.ownError === 'string' ? execution.ownError : undefined,
258
+ // A malformed acceptance block is dropped rather than dropping the
259
+ // execution record: the board then treats that run as unverified.
260
+ verification: normalizeVerification(execution.verification),
261
+ external: execution.external === true ? true : undefined,
202
262
  }))
203
263
  // Execution targets are normalized like the schedule: blank strings
204
264
  // clear the pin and unknown permission strings from a future version
@@ -208,9 +268,12 @@ export function parseLedger(raw: string | null): TaskRecord[] {
208
268
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
209
269
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
210
270
  task.reuseSession = row.reuseSession === true ? true : undefined
211
- // The goal opt-in is stored only when it is OFF: absent means the default
212
- // (start each run with /goal), so a hand-edited true normalizes back to it.
213
- task.goalRun = row.goalRun === false ? false : undefined
271
+ // The goal opt-in is stored only when it is ON: absent means the default
272
+ // (one plain turn per run), so a hand-edited false normalizes back to it.
273
+ task.goalRun = row.goalRun === true ? true : undefined
274
+ // The acceptance opt-out is stored only when it is ON: absent inherits the
275
+ // board-wide switch, so a hand-edited false normalizes back to inherit.
276
+ task.skipVerification = row.skipVerification === true ? true : undefined
214
277
  task.freeze = normalizeFreeze(row.freeze)
215
278
  task.handover = normalizeHandover(row.handover)
216
279
  // Tags are repaired field by field like the schedule: a malformed entry is
@@ -218,6 +281,8 @@ export function parseLedger(raw: string | null): TaskRecord[] {
218
281
  // dropping the task row.
219
282
  task.tags = normalizeTags(row.tags)
220
283
  task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
284
+ task.integrations = normalizeTaskIntegrations(row.integrations)
285
+ task.hidden = row.hidden === true ? true : undefined
221
286
  tasks.push(task)
222
287
  }
223
288
  return tasks