@linxin666/dsh-client-ui-task-board 0.3.20 → 0.3.22

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 (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -0
  3. package/README.zh.md +5 -0
  4. package/lib/client.js +467 -67
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +97 -13
  7. package/lib/types/client/board/EditTaskModal.d.ts +1 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +8 -1
  9. package/lib/types/client/board/TaskForm.d.ts +18 -0
  10. package/lib/types/client/body-mutations.d.ts +45 -0
  11. package/lib/types/client/locales.d.ts +11 -0
  12. package/lib/types/client/panel-mount-core.d.ts +1 -1
  13. package/lib/types/client/sidebar-entry-core.d.ts +0 -15
  14. package/lib/types/core/controller.d.ts +2 -3
  15. package/lib/types/core/tasks.d.ts +66 -1
  16. package/lib/types/core/use-cases/task-archive.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-update.d.ts +8 -1
  18. package/package.json +1 -1
  19. package/src/client/board/EditTaskModal.tsx +16 -3
  20. package/src/client/board/NewTaskModal.tsx +9 -2
  21. package/src/client/board/TaskBoard.tsx +61 -6
  22. package/src/client/board/TaskCard.tsx +17 -1
  23. package/src/client/board/TaskDetail.tsx +4 -2
  24. package/src/client/board/TaskForm.tsx +92 -0
  25. package/src/client/board.module.css +119 -0
  26. package/src/client/body-mutations.ts +133 -0
  27. package/src/client/index.ts +52 -20
  28. package/src/client/locales.ts +22 -0
  29. package/src/client/panel-mount-core.ts +21 -13
  30. package/src/client/sidebar-entry-core.ts +10 -7
  31. package/src/core/controller.ts +2 -3
  32. package/src/core/schedule.ts +4 -1
  33. package/src/core/store.ts +5 -1
  34. package/src/core/tasks.ts +119 -2
  35. package/src/core/use-cases/task-archive.ts +6 -6
  36. package/src/core/use-cases/task-update.ts +12 -2
  37. package/src/host-runner.ts +24 -1
  38. package/src/protocol.ts +11 -3
@@ -20,10 +20,11 @@
20
20
  * (shared/client/sidebar-entry-core.ts, synced copy).
21
21
  */
22
22
  import { createRoot, type Root } from 'react-dom/client'
23
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
23
24
 
24
25
  /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
25
26
  export interface CenterPanelMountOptions {
26
- /** Render the panel React tree into a root (initial mount, remount, locale refresh). */
27
+ /** Render the panel tree (first open, remount while open, locale refresh). */
27
28
  render: (root: Root) => void
28
29
  /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
29
30
  viewDatasetKey: string
@@ -79,31 +80,38 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
79
80
  } catch { /* locale service absent: the panel follows its next natural re-render */ }
80
81
 
81
82
  const ensure = (): void => {
82
- if (container !== undefined) {
83
- if (container.isConnected) return
83
+ if (container !== undefined && !container.isConnected) {
84
84
  // The conversation pane was replaced; drop the stale tree and remount.
85
85
  root?.unmount()
86
86
  root = undefined
87
87
  container.remove()
88
88
  container = undefined
89
89
  }
90
- const column = conversationColumn()
91
- if (column === undefined) return
92
- container = document.createElement('div')
93
- container.dataset[options.viewDatasetKey] = ''
94
- container.dataset.dshPlugin = options.pluginName
95
- container.className = options.viewClassName
96
- column.appendChild(container)
90
+ if (container === undefined) {
91
+ const column = conversationColumn()
92
+ if (column === undefined) return
93
+ container = document.createElement('div')
94
+ container.dataset[options.viewDatasetKey] = ''
95
+ container.dataset.dshPlugin = options.pluginName
96
+ container.className = options.viewClassName
97
+ column.appendChild(container)
98
+ }
99
+ // Keep a visited tree mounted so drafts and terminal sessions survive
100
+ // close/reopen; an unused panel needs neither a React root nor effects.
101
+ if (root !== undefined || !options.isOpen()) return
97
102
  root = createRoot(container)
98
103
  options.render(root)
99
104
  }
100
105
 
101
106
  // The frame mounts after boot settlement; watch for the column's arrival.
102
- const waitObserver = new MutationObserver(() => { ensure() })
103
- waitObserver.observe(document.body, { childList: true, subtree: true })
107
+ // The observation is the page-wide hub (shared/client/body-mutations.ts):
108
+ // every family panel used to hold its own document.body subtree observer, so
109
+ // the per-mutation cost grew with the number of installed plugins.
110
+ const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
104
111
 
105
112
  const applyActive = (): void => {
106
113
  if (options.isOpen()) {
114
+ ensure()
107
115
  // Single-occupant center column: opening this panel must evict the
108
116
  // sibling panel, both its html attribute and its controller state,
109
117
  // otherwise the two panels' visibility rules fight and the second
@@ -135,7 +143,7 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
135
143
  return () => {
136
144
  document.removeEventListener('click', onClickSidebarRow, true)
137
145
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
138
- waitObserver.disconnect()
146
+ unsubscribeBody()
139
147
  unsubscribe()
140
148
  unsubscribeLocale?.()
141
149
  document.documentElement.removeAttribute(options.activeAttribute)
@@ -14,6 +14,7 @@
14
14
  * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
15
  * edit the shared source and re-run the sync instead of editing a copy.
16
16
  */
17
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
17
18
 
18
19
  /** Per-package configuration for one sidebar entry row. */
19
20
  export interface SidebarEntryOptions {
@@ -188,13 +189,15 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
188
189
 
189
190
  // Body-level watcher retained as the "whole rebuild" fallback: when the shell
190
191
  // tears down the whole sidebar pane, the root observer is gone with it and
191
- // only this body observation can notice the new pane mounting. It is no
192
- // longer disconnected after placement; the placed-and-still-mounted case
192
+ // only this body observation can notice the new pane mounting. It stays
193
+ // subscribed after placement; the placed-and-still-mounted case
193
194
  // short-circuits through the cheap document.body.contains(entry) check, so
194
- // unrelated app mutations (e.g. chat streaming) cost one contains check
195
- // instead of churning the full re-query.
196
- const waitObserver = new MutationObserver(() => { tryPlace() })
197
- waitObserver.observe(document.body, { childList: true, subtree: true })
195
+ // unrelated app mutations (e.g. chat streaming) cost one contains check per
196
+ // frame instead of churning the full re-query. The body observation itself is
197
+ // the page-wide hub (shared/client/body-mutations.ts): every family plugin
198
+ // used to hold its own body subtree observer, so the per-mutation cost grew
199
+ // with the number of installed plugins; the hub keeps exactly one.
200
+ const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
198
201
 
199
202
  // Self-heal: if a React re-render displaces the row, re-insert it in the
200
203
  // same frame (microtask before paint -> no visible flicker).
@@ -225,7 +228,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
225
228
  tryPlace()
226
229
 
227
230
  return () => {
228
- waitObserver.disconnect()
231
+ unsubscribeBody()
229
232
  rootObserver.disconnect()
230
233
  unsubscribeRefresh?.()
231
234
  unsubscribeActive?.()
@@ -327,9 +327,8 @@ export class BoardController {
327
327
  }
328
328
 
329
329
  /**
330
- * Archive a settled task (done/failed). Running or on-board-unsettled
331
- * tasks are refused so the runner keeps exclusive ownership of their
332
- * lifecycle.
330
+ * Archive a task from any status but `running`, whose lifecycle the runner
331
+ * keeps exclusive ownership of until it settles.
333
332
  * @returns true when applied.
334
333
  */
335
334
  archiveTask(id: string): boolean {
@@ -179,7 +179,10 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
179
179
  high = Number(b)
180
180
  } else if (isDigits(range)) {
181
181
  low = Number(range)
182
- high = Number(range)
182
+ // Standard cron: a bare value carrying a step runs from that value to the
183
+ // field maximum (minutes 5/15 are 5,20,35,50), while a bare value alone is
184
+ // exactly itself (#1493).
185
+ high = stepRaw === undefined ? low : max
183
186
  } else {
184
187
  return false
185
188
  }
package/src/core/store.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * localStorage backend.
11
11
  */
12
12
  import { isValidCron } from './schedule.ts'
13
- import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
13
+ import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
14
  import type { TaskHandover } from './handover.ts'
15
15
  import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
16
16
  import { sanitizeHandover } from './handover.ts'
@@ -189,6 +189,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
189
189
  task.reuseSession = row.reuseSession === true ? true : undefined
190
190
  task.freeze = normalizeFreeze(row.freeze)
191
191
  task.handover = normalizeHandover(row.handover)
192
+ // Tags are repaired field by field like the schedule: a malformed entry is
193
+ // dropped, and a list that repairs to nothing clears the field instead of
194
+ // dropping the task row.
195
+ task.tags = normalizeTags(row.tags)
192
196
  task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
193
197
  tasks.push(task)
194
198
  }
package/src/core/tasks.ts CHANGED
@@ -94,6 +94,105 @@ export interface TaskFreeze extends FreezeSnapshot {
94
94
  frozenBy?: string
95
95
  }
96
96
 
97
+ /**
98
+ * One task label (issue #1521). The name is the badge and the tag-filter key;
99
+ * the optional prompt line rides the execution prompt, so a label may be
100
+ * display-only (no prefix) or carry an instruction (business-line routing,
101
+ * output directory, house style) that every run of the task inherits.
102
+ */
103
+ export interface TaskTag {
104
+ /** Display name; trimmed, non-empty, unique within the task. */
105
+ name: string
106
+ /**
107
+ * Prompt line injected ahead of the execution prompt. Absent (or blank
108
+ * after trimming) keeps the tag display-only: it never touches the prompt,
109
+ * so a label with no prefix has zero execution side effects.
110
+ */
111
+ promptPrefix?: string
112
+ }
113
+
114
+ /** Maximum number of tags carried by one task. */
115
+ export const TASK_TAG_LIMIT = 8
116
+ /** Maximum length of a tag name. */
117
+ export const TAG_NAME_MAX_LENGTH = 32
118
+ /** Maximum length of a tag's injected prompt line. */
119
+ export const TAG_PROMPT_MAX_LENGTH = 200
120
+
121
+ /** Whether an unknown value is a well-formed tag (strict: the wire gate). */
122
+ export function isTaskTag(value: unknown): value is TaskTag {
123
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
124
+ const tag = value as Record<string, unknown>
125
+ if (Object.keys(tag).some(key => key !== 'name' && key !== 'promptPrefix')) return false
126
+ if (typeof tag.name !== 'string') return false
127
+ const name = tag.name.trim()
128
+ if (name === '' || name.length > TAG_NAME_MAX_LENGTH) return false
129
+ if (tag.promptPrefix !== undefined && typeof tag.promptPrefix !== 'string') return false
130
+ return tag.promptPrefix === undefined || (tag.promptPrefix as string).trim().length <= TAG_PROMPT_MAX_LENGTH
131
+ }
132
+
133
+ /**
134
+ * Whether an unknown value is a well-formed tag list (strict: the wire gate).
135
+ * An empty list is rejected — clearing tags is expressed by omitting the field
136
+ * (create) or by an explicit null (update), never by an empty array.
137
+ */
138
+ export function isTaskTagList(value: unknown): value is TaskTag[] {
139
+ return Array.isArray(value) && value.length > 0 && value.length <= TASK_TAG_LIMIT && value.every(isTaskTag)
140
+ }
141
+
142
+ /**
143
+ * Repair a persisted tag list: keep the well-formed entries, trim, drop
144
+ * blanks and repeats, cap the count, and collapse a blank prompt line to
145
+ * "display-only". Returns undefined when nothing usable remains, so the caller
146
+ * clears the field instead of storing an empty array.
147
+ */
148
+ export function normalizeTags(value: unknown): TaskTag[] | undefined {
149
+ if (!Array.isArray(value)) return undefined
150
+ const tags: TaskTag[] = []
151
+ const seen = new Set<string>()
152
+ for (const entry of value) {
153
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
154
+ const row = entry as Record<string, unknown>
155
+ if (typeof row.name !== 'string') continue
156
+ const name = row.name.trim()
157
+ if (name === '' || name.length > TAG_NAME_MAX_LENGTH || seen.has(name)) continue
158
+ const raw = typeof row.promptPrefix === 'string' ? row.promptPrefix.trim() : ''
159
+ const promptPrefix = raw === '' ? undefined : raw.slice(0, TAG_PROMPT_MAX_LENGTH)
160
+ seen.add(name)
161
+ tags.push(promptPrefix === undefined ? { name } : { name, promptPrefix })
162
+ if (tags.length >= TASK_TAG_LIMIT) break
163
+ }
164
+ return tags.length === 0 ? undefined : tags
165
+ }
166
+
167
+ /**
168
+ * Stable palette slot (0..5) for a tag name. The same label always lands on the
169
+ * same tone, so a badge needs no stored colour and two tasks sharing a label
170
+ * cannot disagree about it.
171
+ */
172
+ export function tagTone(name: string): number {
173
+ let hash = 0
174
+ for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
175
+ return hash % 6
176
+ }
177
+
178
+ /**
179
+ * Union of the labels already carried by `tasks`, first occurrence wins (the
180
+ * oldest task's hint is the one offered). Feeds the editor's name datalist and
181
+ * the board's tag filter, so a label defined once can be reused everywhere.
182
+ */
183
+ export function collectKnownTags(tasks: readonly TaskRecord[]): TaskTag[] {
184
+ const known: TaskTag[] = []
185
+ const seen = new Set<string>()
186
+ for (const task of tasks) {
187
+ for (const tag of task.tags ?? []) {
188
+ if (seen.has(tag.name)) continue
189
+ seen.add(tag.name)
190
+ known.push(tag)
191
+ }
192
+ }
193
+ return known
194
+ }
195
+
97
196
  /** One task on the board. */
98
197
  export interface TaskRecord {
99
198
  /** Stable task id (uuid). */
@@ -158,6 +257,12 @@ export interface TaskRecord {
158
257
  * bundle's triplet overrides the legacy pin fields at execution time.
159
258
  */
160
259
  handover?: TaskHandover
260
+ /**
261
+ * Task labels (issue #1521): optional, additive, and absent on every task
262
+ * created before the field existed. A tag whose `promptPrefix` is set is
263
+ * prepended to the execution prompt; a bare name is display and filter only.
264
+ */
265
+ tags?: TaskTag[]
161
266
  /**
162
267
  * Human confirmation stamp for an above-default effective permission
163
268
  * (ms epoch). Absent while the binding awaits confirmation; any permission
@@ -172,8 +277,13 @@ export interface TaskRecord {
172
277
  archivedAt?: number
173
278
  }
174
279
 
175
- /** Statuses a settled task may be archived from. */
176
- export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
280
+ /**
281
+ * Statuses a task may be archived from: every status but `running`, whose
282
+ * execution the runner still owns until it settles. A settled-only gate made
283
+ * the duplicate-and-archive flow a silent no-op for scheduled tasks, which
284
+ * return to `todo` after every successful run (issue #1447).
285
+ */
286
+ export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'done', 'failed']
177
287
 
178
288
 
179
289
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
@@ -218,6 +328,11 @@ export interface NewTaskInput {
218
328
  * sanitized by the protocol gate) attached at creation.
219
329
  */
220
330
  handover?: TaskHandoverInput
331
+ /**
332
+ * Optional task labels. A tag with a non-blank `promptPrefix` is injected
333
+ * ahead of the execution prompt; a bare name changes nothing at run time.
334
+ */
335
+ tags?: TaskTag[]
221
336
  }
222
337
 
223
338
  /** The five kanban columns, in display order. */
@@ -276,6 +391,7 @@ export function freezeOf(
276
391
 
277
392
  /** Create a task from user input. */
278
393
  export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
394
+ const tags = normalizeTags(input.tags)
279
395
  return {
280
396
  id,
281
397
  title: input.title.trim(),
@@ -292,6 +408,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
292
408
  reuseSession: input.reuseSession === true ? true : undefined,
293
409
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
294
410
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
411
+ ...(tags === undefined ? {} : { tags }),
295
412
  }
296
413
  }
297
414
 
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Archive/restore task use case: move a settled (done/failed) task off the
3
- * main board and back. The task keeps its status, execution history, and
2
+ * Archive/restore task use case: move a task off the main board and back,
3
+ * whatever its status but `running`. The task keeps its status, execution history, and
4
4
  * transcript references, while archiving disarms any schedule so it cannot
5
5
  * create more execution records until the user restores and re-enables it.
6
6
  */
@@ -16,10 +16,10 @@ export interface ArchiveTaskResult {
16
16
  }
17
17
 
18
18
  /**
19
- * Archive one task: only settled statuses (done/failed) can be archived;
20
- * a running or not-yet-settled task stays on the board (its runner still
21
- * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
22
- * are a no-op.
19
+ * Archive one task: only a `running` task stays on the board (its runner
20
+ * still owns its lifecycle until the execution settles); every other status
21
+ * can be archived. Archiving disarms a schedule; already-archived tasks are
22
+ * a no-op.
23
23
  */
24
24
  export function applyArchiveTask(
25
25
  tasks: readonly TaskRecord[],
@@ -8,7 +8,7 @@
8
8
  * back to the runtime default); an unknown permission string is ignored so
9
9
  * stale UI can never persist a value the execution service rejects.
10
10
  */
11
- import { freezeOf, isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
11
+ import { freezeOf, isTaskPermission, normalizeTags, normalizeTargetId, type TaskRecord, type TaskPermission, type TaskTag } from '../tasks.ts'
12
12
  import type { FreezeSnapshot } from '../freeze-snapshot.ts'
13
13
  import type { TaskHandoverInput } from '../handover.ts'
14
14
 
@@ -20,6 +20,13 @@ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' |
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
23
+ /**
24
+ * Replaces the task's labels (issue #1521). Unlike the content fields, tags
25
+ * stay editable after the first run: they classify the task and shape the
26
+ * NEXT execution prompt, they are not the record of what already ran. An
27
+ * explicit null (or a list that normalizes to nothing) clears them.
28
+ */
29
+ tags?: TaskTag[] | null
23
30
  }
24
31
 
25
32
  /** The fields that edit the task's content (what the user reads and what the
@@ -68,7 +75,7 @@ export function applyUpdateTask(
68
75
  ): readonly TaskRecord[] {
69
76
  return tasks.map(task => {
70
77
  if (task.id !== id) return task
71
- const { freeze: freezePatch, handover: handoverPatch, ...rest } = patch
78
+ const { freeze: freezePatch, handover: handoverPatch, tags: tagsPatch, ...rest } = patch
72
79
  const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
73
80
  const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
74
81
  const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
@@ -86,6 +93,9 @@ export function applyUpdateTask(
86
93
  next.freeze = freezePatch == null ? undefined : freezeOf(freezePatch, now)
87
94
  // Handover follows the same null-clears convention, restamping bundledAt.
88
95
  next.handover = handoverPatch == null ? undefined : { ...handoverPatch, bundledAt: now }
96
+ // Tags use the same convention: a present array replaces the set (after
97
+ // repair), an explicit null or a fully-invalid list clears it.
98
+ if ('tags' in patch) next.tags = tagsPatch == null ? undefined : normalizeTags(tagsPatch)
89
99
  // The permission confirmation binds the exact permission value: a change
90
100
  // of the pinned permission or of the handover bundle re-arms the gate
91
101
  // (blocking confirm-then-swap escalation). handover was destructured out
@@ -110,9 +110,15 @@ function escapeProvenanceDelimiter(value: string): string {
110
110
  export function promptText(task: TaskRecord): string {
111
111
  const body = task.prompt !== '' ? task.prompt : task.title
112
112
  const handover = task.handover
113
- const preamble = handover === undefined || handover.references.length === 0
113
+ const handoverPreamble = handover === undefined || handover.references.length === 0
114
114
  ? undefined
115
115
  : `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
116
+ // Tag prompts come first: they are the run's standing context (business line,
117
+ // output location), the handover preamble is a per-card note, and the task
118
+ // body is the instruction itself.
119
+ const tagPreamble = tagPromptPreamble(task)
120
+ const preambles = [tagPreamble, handoverPreamble].filter((part): part is string => part !== undefined)
121
+ const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
116
122
  const freeze = task.freeze
117
123
  if (freeze === undefined) {
118
124
  return preamble === undefined ? body : `${preamble}\n\n${body}`
@@ -122,6 +128,23 @@ export function promptText(task: TaskRecord): string {
122
128
  return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
123
129
  }
124
130
 
131
+ /**
132
+ * Build the tag section of the execution prompt (issue #1521). Only tags with
133
+ * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
134
+ * (or which has no tags at all) yields undefined and the prompt is byte-for-byte
135
+ * what it was before the feature.
136
+ */
137
+ function tagPromptPreamble(task: TaskRecord): string | undefined {
138
+ const lines: string[] = []
139
+ for (const tag of task.tags ?? []) {
140
+ const prefix = tag.promptPrefix?.trim()
141
+ if (prefix === undefined || prefix === '') continue
142
+ lines.push(`- [${tag.name}] ${escapeProvenanceDelimiter(prefix)}`)
143
+ }
144
+ if (lines.length === 0) return undefined
145
+ return `标签提示(任务看板标签,每次执行前注入):\n${lines.join('\n')}`
146
+ }
147
+
125
148
  function isErrorTurnEnd(data: unknown): boolean {
126
149
  if (typeof data !== 'object' || data === null) return false
127
150
  const reason = (data as { reason?: unknown }).reason
package/src/protocol.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
2
- import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
2
+ import { 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 { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
5
5
  import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
@@ -101,6 +101,10 @@ function optionalFiniteNumber(value: unknown): boolean {
101
101
  }
102
102
 
103
103
  function validImportedKnownFields(value: Record<string, unknown>): boolean {
104
+ // Tags are validated (not repaired) on the import path: an imported task
105
+ // must carry a well-formed list or none at all, so a hand-edited export
106
+ // cannot smuggle a malformed tag past the gate.
107
+ if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
104
108
  if (value.schedule !== undefined) {
105
109
  const schedule = record(value.schedule)
106
110
  if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
@@ -161,6 +165,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
161
165
  ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
162
166
  ...(task.freeze === undefined ? {} : { freeze: task.freeze }),
163
167
  ...(task.handover === undefined ? {} : { handover: task.handover }),
168
+ ...(task.tags === undefined ? {} : { tags: task.tags }),
164
169
  // 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
165
170
  // 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
166
171
  }
@@ -194,11 +199,12 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
194
199
 
195
200
  function createInput(value: unknown): value is NewTaskInput {
196
201
  const input = record(value)
197
- if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
202
+ if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
198
203
  if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
199
204
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
200
205
  if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
201
206
  if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
207
+ if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
202
208
  if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
203
209
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
204
210
  if (input.schedule !== undefined) {
@@ -211,13 +217,15 @@ function createInput(value: unknown): value is NewTaskInput {
211
217
 
212
218
  function updatePatch(value: unknown): boolean {
213
219
  const patch = record(value)
214
- if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
220
+ if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
215
221
  // null (or false) clears the reuse opt-in; only a real boolean is accepted.
216
222
  if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
217
223
  for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
218
224
  if (!optionalString(patch[key])) return false
219
225
  }
220
226
  if (patch.permission !== undefined && !isTaskPermission(patch.permission)) return false
227
+ // null clears the label set; a present array must be a well-formed list.
228
+ if (patch.tags !== undefined && patch.tags !== null && !isTaskTagList(patch.tags)) return false
221
229
  // null clears the snapshot; an object must pass the freeze gate.
222
230
  if (patch.freeze !== undefined && patch.freeze !== null && freezePayload(patch.freeze) === undefined) return false
223
231
  // Same convention for the handover bundle.