@linxin666/dsh-client-ui-task-board 0.3.21 → 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.
@@ -5,9 +5,13 @@
5
5
  * are controlled components.
6
6
  */
7
7
  import type { ReactNode } from 'react'
8
+ import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, type TaskTag } from '../../core/tasks.ts'
8
9
  import { t } from '../locales.ts'
9
10
  import css from '../board.module.css'
10
11
 
12
+ /** DOM id shared by the tag-name inputs and their datalist (one board at a time). */
13
+ const TAG_NAME_LIST_ID = 'dsh-task-board-tag-names'
14
+
11
15
  /** Modal overlay: closes on backdrop press, submits through the form. */
12
16
  export function ModalShell({
13
17
  ariaLabel,
@@ -109,3 +113,91 @@ export function TaskContentFields({
109
113
  </>
110
114
  )
111
115
  }
116
+
117
+ /**
118
+ * Task labels (issue #1521): one row per label holding the badge name and an
119
+ * optional execution hint. The name inputs offer the labels already used on the
120
+ * board through a datalist, and picking one adopts its hint when the row has
121
+ * none — so a business line is defined once and reused by every later task.
122
+ */
123
+ export function TaskTagFields({
124
+ tags,
125
+ knownTags,
126
+ onChange,
127
+ }: {
128
+ tags: TaskTag[]
129
+ /** Labels already carried elsewhere on the board. */
130
+ knownTags: TaskTag[]
131
+ onChange: (tags: TaskTag[]) => void
132
+ }) {
133
+ const update = (index: number, patch: Partial<TaskTag>): void => {
134
+ onChange(tags.map((tag, position) => (position === index ? { ...tag, ...patch } : tag)))
135
+ }
136
+
137
+ return (
138
+ <div className={css.field}>
139
+ <span className={css.fieldLabel}>{t('new.tags')}</span>
140
+ <span className={css.fieldHint}>{t('new.tagsHint')}</span>
141
+ {tags.map((tag, index) => (
142
+ <div className={css.tagRow} key={index}>
143
+ <input
144
+ className={css.input}
145
+ list={TAG_NAME_LIST_ID}
146
+ value={tag.name}
147
+ maxLength={TAG_NAME_MAX_LENGTH}
148
+ placeholder={t('new.tagNamePlaceholder')}
149
+ aria-label={t('new.tagName')}
150
+ onChange={(event) => {
151
+ const name = event.target.value
152
+ const known = knownTags.find(candidate => candidate.name === name)
153
+ const adoptsHint = known?.promptPrefix !== undefined && (tag.promptPrefix ?? '').trim() === ''
154
+ update(index, adoptsHint ? { name, promptPrefix: known.promptPrefix } : { name })
155
+ }}
156
+ />
157
+ <input
158
+ className={css.input}
159
+ value={tag.promptPrefix ?? ''}
160
+ maxLength={TAG_PROMPT_MAX_LENGTH}
161
+ placeholder={t('new.tagPromptPlaceholder')}
162
+ aria-label={t('new.tagPrompt')}
163
+ onChange={event => { update(index, { promptPrefix: event.target.value }) }}
164
+ />
165
+ <button
166
+ type="button"
167
+ className={css.ghostButton}
168
+ aria-label={t('new.tagRemove', { name: tag.name })}
169
+ onClick={() => { onChange(tags.filter((_, position) => position !== index)) }}
170
+ >
171
+ ×
172
+ </button>
173
+ </div>
174
+ ))}
175
+ <datalist id={TAG_NAME_LIST_ID}>
176
+ {knownTags.map(tag => <option key={tag.name} value={tag.name} />)}
177
+ </datalist>
178
+ <button
179
+ type="button"
180
+ className={css.ghostButton + ' ' + css.tagAddButton}
181
+ disabled={tags.length >= TASK_TAG_LIMIT}
182
+ onClick={() => { onChange([...tags, { name: '' }]) }}
183
+ >
184
+ + {t('new.tagAdd')}
185
+ </button>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Drop blank rows and trim what survives, so the wire always carries a valid
192
+ * tag list (the protocol rejects a list with a blank name or an empty array).
193
+ */
194
+ export function cleanTags(tags: readonly TaskTag[]): TaskTag[] {
195
+ const cleaned: TaskTag[] = []
196
+ for (const tag of tags) {
197
+ const name = tag.name.trim()
198
+ if (name === '') continue
199
+ const promptPrefix = tag.promptPrefix?.trim()
200
+ cleaned.push(promptPrefix === undefined || promptPrefix === '' ? { name } : { name, promptPrefix })
201
+ }
202
+ return cleaned
203
+ }
@@ -1121,3 +1121,122 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
1121
1121
  animation: none;
1122
1122
  }
1123
1123
  }
1124
+
1125
+ /* --- task tags (issue #1521) ---------------------------------------------------
1126
+ * A badge takes its tone from a fixed six-colour palette chosen by hashing the
1127
+ * name, so one label always paints the same way and nothing per-tag has to be
1128
+ * stored. The fill is translucent and the text keeps the theme's own primary
1129
+ * label colour, so badges stay readable on light, dark, and every skin without
1130
+ * depending on a token a skin may not define.
1131
+ * ----------------------------------------------------------------------------- */
1132
+
1133
+ .cardTags {
1134
+ display: flex;
1135
+ flex-wrap: wrap;
1136
+ gap: 4px;
1137
+ }
1138
+
1139
+ .cardTag {
1140
+ max-width: 100%;
1141
+ padding: 0 7px;
1142
+ overflow: hidden;
1143
+ border: 1px solid var(--dsh-task-tag-border);
1144
+ border-radius: 999px;
1145
+ background: var(--dsh-task-tag-fill);
1146
+ color: var(--dsw-alias-label-primary);
1147
+ font-size: 10px;
1148
+ line-height: 16px;
1149
+ text-overflow: ellipsis;
1150
+ white-space: nowrap;
1151
+ }
1152
+
1153
+ .tagFilter {
1154
+ display: flex;
1155
+ flex-wrap: wrap;
1156
+ align-items: center;
1157
+ gap: 6px;
1158
+ margin: 0 0 10px;
1159
+ }
1160
+
1161
+ .tagFilterLabel {
1162
+ color: var(--dsw-alias-label-tertiary);
1163
+ font-size: 11px;
1164
+ }
1165
+
1166
+ .tagChip {
1167
+ padding: 1px 9px;
1168
+ border: 1px solid var(--dsh-task-tag-border);
1169
+ border-radius: 999px;
1170
+ background: transparent;
1171
+ color: var(--dsw-alias-label-secondary);
1172
+ cursor: pointer;
1173
+ font-family: inherit;
1174
+ font-size: 11px;
1175
+ line-height: 18px;
1176
+ }
1177
+
1178
+ .tagChip[data-active='true'] {
1179
+ background: var(--dsh-task-tag-fill);
1180
+ color: var(--dsw-alias-label-primary);
1181
+ }
1182
+
1183
+ .cardTag[data-tag-tone='0'],
1184
+ .tagChip[data-tag-tone='0'] {
1185
+ --dsh-task-tag-fill: #4e93e82e;
1186
+ --dsh-task-tag-border: #4e93e866;
1187
+ }
1188
+
1189
+ .cardTag[data-tag-tone='1'],
1190
+ .tagChip[data-tag-tone='1'] {
1191
+ --dsh-task-tag-fill: #2ea36a2e;
1192
+ --dsh-task-tag-border: #2ea36a66;
1193
+ }
1194
+
1195
+ .cardTag[data-tag-tone='2'],
1196
+ .tagChip[data-tag-tone='2'] {
1197
+ --dsh-task-tag-fill: #d08a2a2e;
1198
+ --dsh-task-tag-border: #d08a2a66;
1199
+ }
1200
+
1201
+ .cardTag[data-tag-tone='3'],
1202
+ .tagChip[data-tag-tone='3'] {
1203
+ --dsh-task-tag-fill: #b456c82e;
1204
+ --dsh-task-tag-border: #b456c866;
1205
+ }
1206
+
1207
+ .cardTag[data-tag-tone='4'],
1208
+ .tagChip[data-tag-tone='4'] {
1209
+ --dsh-task-tag-fill: #cf5f7a2e;
1210
+ --dsh-task-tag-border: #cf5f7a66;
1211
+ }
1212
+
1213
+ .cardTag[data-tag-tone='5'],
1214
+ .tagChip[data-tag-tone='5'] {
1215
+ --dsh-task-tag-fill: #4a9fb52e;
1216
+ --dsh-task-tag-border: #4a9fb566;
1217
+ }
1218
+
1219
+ .fieldHint {
1220
+ color: var(--dsw-alias-label-tertiary);
1221
+ font-size: 11px;
1222
+ line-height: 1.4;
1223
+ }
1224
+
1225
+ .tagRow {
1226
+ display: flex;
1227
+ align-items: center;
1228
+ gap: 6px;
1229
+ }
1230
+
1231
+ .tagRow .input {
1232
+ flex: 1 1 0;
1233
+ min-width: 0;
1234
+ }
1235
+
1236
+ .tagRow .ghostButton {
1237
+ flex: none;
1238
+ }
1239
+
1240
+ .tagAddButton {
1241
+ align-self: flex-start;
1242
+ }
@@ -55,6 +55,17 @@ export const zh = {
55
55
  'detail.handover': '交接包',
56
56
  'detail.handover.bundledAt': '打包于 {time}',
57
57
  'detail.handover.references': '引用',
58
+ 'new.tags': '标签(可选)',
59
+ 'new.tagsHint': '标签用于卡片徽章与顶栏筛选;填了「执行提示」的行会在每次执行前注入 Prompt,留空则只作展示。',
60
+ 'new.tagName': '标签名',
61
+ 'new.tagNamePlaceholder': '例如:工作',
62
+ 'new.tagPrompt': '执行提示(可选)',
63
+ 'new.tagPromptPlaceholder': '例如:产物归档到 02-工作/',
64
+ 'new.tagAdd': '添加标签',
65
+ 'new.tagRemove': '移除标签 {name}',
66
+ 'board.tagFilter': '按标签筛选',
67
+ 'board.tagFilterClear': '清除标签筛选',
68
+ 'board.tagEmpty': '没有同时带这些标签的任务',
58
69
  'detail.permissionPending': '权限待确认:本卡有效权限({permission})高于会话默认,手动执行与定时调度都会被拒绝,需人工确认后才能运行。',
59
70
  'detail.permissionConfirm': '确认权限绑定',
60
71
  'detail.permissionConfirmed': '已确认权限绑定 · {time}',
@@ -209,6 +220,17 @@ export const en: Record<keyof typeof zh, string> = {
209
220
  'detail.handover': 'Handover bundle',
210
221
  'detail.handover.bundledAt': 'Bundled at {time}',
211
222
  'detail.handover.references': 'References',
223
+ 'new.tags': 'Tags (optional)',
224
+ 'new.tagsHint': 'Tags drive the card badges and the board filter; a row with an execution hint is injected ahead of the prompt on every run, an empty one is display-only.',
225
+ 'new.tagName': 'Tag name',
226
+ 'new.tagNamePlaceholder': 'e.g. Work',
227
+ 'new.tagPrompt': 'Execution hint (optional)',
228
+ 'new.tagPromptPlaceholder': 'e.g. Archive artefacts under 02-work/',
229
+ 'new.tagAdd': 'Add tag',
230
+ 'new.tagRemove': 'Remove tag {name}',
231
+ 'board.tagFilter': 'Filter by tag',
232
+ 'board.tagFilterClear': 'Clear tag filter',
233
+ 'board.tagEmpty': 'No task carries all of these tags',
212
234
  'detail.permissionPending': 'Permission pending confirmation: this card\'s effective permission ({permission}) is above the session default; manual runs and cron are refused until a human confirms.',
213
235
  'detail.permissionConfirm': 'Confirm permission binding',
214
236
  'detail.permissionConfirmed': 'Permission confirmed · {time}',
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
@@ -223,6 +328,11 @@ export interface NewTaskInput {
223
328
  * sanitized by the protocol gate) attached at creation.
224
329
  */
225
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[]
226
336
  }
227
337
 
228
338
  /** The five kanban columns, in display order. */
@@ -281,6 +391,7 @@ export function freezeOf(
281
391
 
282
392
  /** Create a task from user input. */
283
393
  export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
394
+ const tags = normalizeTags(input.tags)
284
395
  return {
285
396
  id,
286
397
  title: input.title.trim(),
@@ -297,6 +408,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
297
408
  reuseSession: input.reuseSession === true ? true : undefined,
298
409
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
299
410
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
411
+ ...(tags === undefined ? {} : { tags }),
300
412
  }
301
413
  }
302
414
 
@@ -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.