@co0ontty/wand 4.60.0 → 4.61.0

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.
package/dist/storage.js CHANGED
@@ -34,6 +34,19 @@ export function parseWandTaskAgent(raw) {
34
34
  return null;
35
35
  return { provider, model: model.trim(), thinkingEffort };
36
36
  }
37
+ /** `wand_milestones` 行 → 领域对象;名字必须非空,脏行直接跳过。 */
38
+ function mapWandMilestoneRow(row) {
39
+ const name = typeof row.name === "string" ? row.name.trim() : "";
40
+ if (!name)
41
+ return null;
42
+ return {
43
+ id: String(row.id),
44
+ name,
45
+ dueDate: typeof row.due_date === "string" && row.due_date ? row.due_date : null,
46
+ createdAt: String(row.created_at),
47
+ updatedAt: String(row.updated_at),
48
+ };
49
+ }
37
50
  const SESSION_OPTIONS_SCHEMA_VERSION = 1;
38
51
  function isRecord(value) {
39
52
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -330,6 +343,7 @@ function mapWorkspaceTaskRow(row) {
330
343
  name: row.name,
331
344
  worktree: mapWorkspaceTaskWorktree(row.worktree_json),
332
345
  ...(cwd ? { cwd } : {}),
346
+ milestoneId: typeof row.milestone_id === "string" && row.milestone_id ? row.milestone_id : null,
333
347
  layout: mapWorkspaceTaskLayout(row.layout_json),
334
348
  status: (row.status === "done" ? "done" : "active"),
335
349
  createdAt: row.created_at,
@@ -606,6 +620,7 @@ const INIT_SQL = `
606
620
  cwd TEXT NOT NULL,
607
621
  status TEXT NOT NULL,
608
622
  task_id TEXT,
623
+ milestone_id TEXT,
609
624
  base_ref TEXT,
610
625
  shared_directories TEXT NOT NULL DEFAULT '[]',
611
626
  copy_paths TEXT NOT NULL DEFAULT '[]',
@@ -686,6 +701,7 @@ const INIT_SQL = `
686
701
  layout_json TEXT,
687
702
  status TEXT NOT NULL DEFAULT 'active',
688
703
  cwd TEXT,
704
+ milestone_id TEXT,
689
705
  created_at TEXT NOT NULL,
690
706
  last_opened_at TEXT,
691
707
  layout_revision INTEGER NOT NULL DEFAULT 0,
@@ -706,6 +722,15 @@ const INIT_SQL = `
706
722
  );
707
723
  CREATE INDEX IF NOT EXISTS idx_github_issue_bindings_session ON github_issue_bindings(session_id);
708
724
 
725
+ CREATE TABLE IF NOT EXISTS wand_milestones (
726
+ id TEXT PRIMARY KEY,
727
+ name TEXT NOT NULL,
728
+ due_date TEXT,
729
+ created_at TEXT NOT NULL,
730
+ updated_at TEXT NOT NULL
731
+ );
732
+ CREATE INDEX IF NOT EXISTS idx_wand_milestones_created ON wand_milestones(created_at DESC);
733
+
709
734
  CREATE TABLE IF NOT EXISTS wand_tasks (
710
735
  id TEXT PRIMARY KEY,
711
736
  identifier TEXT,
@@ -716,6 +741,7 @@ const INIT_SQL = `
716
741
  priority TEXT NOT NULL DEFAULT 'none',
717
742
  labels_json TEXT NOT NULL DEFAULT '[]',
718
743
  due_date TEXT,
744
+ milestone_id TEXT,
719
745
  sort_order INTEGER NOT NULL DEFAULT 0,
720
746
  agent_json TEXT,
721
747
  workspace_task_id TEXT,
@@ -750,6 +776,9 @@ function ensureWandTaskSchema(db) {
750
776
  // 标题来源:'user' = 用户手写,'auto' = 由描述自动生成。只加列,历史行保持 NULL(读取时视为 user)。
751
777
  if (columns.length > 0 && !names.has("title_source"))
752
778
  db.exec("ALTER TABLE wand_tasks ADD COLUMN title_source TEXT");
779
+ // 里程碑:只加列,历史行保持 NULL;里程碑本体在 wand_milestones(INIT_SQL 建表)。
780
+ if (columns.length > 0 && !names.has("milestone_id"))
781
+ db.exec("ALTER TABLE wand_tasks ADD COLUMN milestone_id TEXT");
753
782
  if (columns.length > 0) {
754
783
  // Index creation must wait until the column exists on legacy databases.
755
784
  // INIT_SQL cannot create it: CREATE TABLE IF NOT EXISTS is a no-op on old
@@ -774,6 +803,10 @@ function ensureWorkspaceSchema(db) {
774
803
  if (taskColumns.length > 0 && !taskNames.has("layout_revision")) {
775
804
  db.exec("ALTER TABLE workspace_tasks ADD COLUMN layout_revision INTEGER NOT NULL DEFAULT 0");
776
805
  }
806
+ // 侧栏任务也记里程碑:建任务时把它带到看板卡片上,命名任务与未命名任务保持一致。
807
+ if (taskColumns.length > 0 && !taskNames.has("milestone_id")) {
808
+ db.exec("ALTER TABLE workspace_tasks ADD COLUMN milestone_id TEXT");
809
+ }
777
810
  }
778
811
  function ensureConnectorSchema(db) {
779
812
  // The table is created by INIT_SQL for new databases; this repairs an
@@ -809,6 +842,10 @@ export function ensureDatabaseFile(dbPath) {
809
842
  if (missionColumns.length > 0 && !missionColumns.some((column) => column.name === "task_id")) {
810
843
  db.exec("ALTER TABLE missions ADD COLUMN task_id TEXT");
811
844
  }
845
+ // 并行任务的里程碑与看板共用同一份列表;只加列,历史行保持 NULL。
846
+ if (missionColumns.length > 0 && !missionColumns.some((column) => column.name === "milestone_id")) {
847
+ db.exec("ALTER TABLE missions ADD COLUMN milestone_id TEXT");
848
+ }
812
849
  }
813
850
  db.close();
814
851
  chmodSync(dbPath, 0o600);
@@ -1056,17 +1093,18 @@ export class WandStorage {
1056
1093
  }));
1057
1094
  }
1058
1095
  // ── Workspace tasks(任务 = 命名 + 独立 worktree + 一组标签)──
1096
+ /** 侧栏任务顺序:新创建在前。GET /api/tasks 按此顺序填充目录组。 */
1059
1097
  listWorkspaceTasks(workspaceId) {
1060
1098
  const rows = this.db
1061
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at, layout_revision
1099
+ .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at, layout_revision
1062
1100
  FROM workspace_tasks WHERE workspace_id = ?
1063
- ORDER BY created_at DESC, id DESC`)
1101
+ ORDER BY created_at DESC, rowid DESC`)
1064
1102
  .all(workspaceId);
1065
1103
  return rows.map(mapWorkspaceTaskRow);
1066
1104
  }
1067
1105
  getWorkspaceTask(id) {
1068
1106
  const row = this.db
1069
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at, layout_revision
1107
+ .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at, layout_revision
1070
1108
  FROM workspace_tasks WHERE id = ?`)
1071
1109
  .get(id);
1072
1110
  return row ? mapWorkspaceTaskRow(row) : null;
@@ -1076,16 +1114,18 @@ export class WandStorage {
1076
1114
  const createdAt = nowIso();
1077
1115
  const status = input.status ?? "active";
1078
1116
  const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : null;
1117
+ const milestoneId = typeof input.milestoneId === "string" && input.milestoneId ? input.milestoneId : null;
1079
1118
  this.db
1080
- .prepare(`INSERT INTO workspace_tasks (id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at)
1081
- VALUES (?, ?, ?, ?, NULL, ?, ?, ?, NULL)`)
1082
- .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, status, cwd, createdAt);
1119
+ .prepare(`INSERT INTO workspace_tasks (id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at)
1120
+ VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL)`)
1121
+ .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, status, cwd, milestoneId, createdAt);
1083
1122
  return {
1084
1123
  id,
1085
1124
  workspaceId: input.workspaceId,
1086
1125
  name: input.name,
1087
1126
  worktree: input.worktree ?? null,
1088
1127
  ...(cwd ? { cwd } : {}),
1128
+ milestoneId,
1089
1129
  layout: null,
1090
1130
  status,
1091
1131
  createdAt,
@@ -1107,6 +1147,10 @@ export class WandStorage {
1107
1147
  assignments.push("worktree_json = ?");
1108
1148
  values.push(patch.worktree ? JSON.stringify(patch.worktree) : null);
1109
1149
  }
1150
+ if (patch.milestoneId !== undefined) {
1151
+ assignments.push("milestone_id = ?");
1152
+ values.push(patch.milestoneId || null);
1153
+ }
1110
1154
  if (assignments.length === 0)
1111
1155
  return;
1112
1156
  this.db.prepare(`UPDATE workspace_tasks SET ${assignments.join(", ")} WHERE id = ?`).run(...values, id);
@@ -1149,16 +1193,24 @@ export class WandStorage {
1149
1193
  COALESCE(MAX(layout_revision), 0) AS revision
1150
1194
  FROM workspace_tasks`).get();
1151
1195
  const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
1196
+ // 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
1197
+ const names = this.db.prepare(`SELECT
1198
+ COALESCE((SELECT GROUP_CONCAT(id || ':' || name || ':' || cwd, '|')
1199
+ FROM (SELECT id, name, cwd FROM workspaces ORDER BY id)), '') AS workspaceNames,
1200
+ COALESCE((SELECT GROUP_CONCAT(path || ':' || name || ':' || updated_at, '|')
1201
+ FROM (SELECT path, name, updated_at FROM session_directory_names ORDER BY path)), '') AS directoryNames`).get();
1152
1202
  return JSON.stringify({
1153
1203
  sessions,
1154
1204
  tasks,
1155
1205
  workspaces,
1206
+ names,
1156
1207
  });
1157
1208
  }
1209
+ /** GET /api/wand-tasks 的顺序:新创建在前。客户端按此顺序渲染,不再本地排序。 */
1158
1210
  listWandTasks(workspaceId) {
1159
- const rows = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1211
+ const rows = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at
1160
1212
  FROM wand_tasks ${workspaceId === undefined ? "" : "WHERE workspace_id IS ?"}
1161
- ORDER BY status, sort_order, updated_at DESC`).all(...(workspaceId === undefined ? [] : [workspaceId]));
1213
+ ORDER BY created_at DESC, rowid DESC`).all(...(workspaceId === undefined ? [] : [workspaceId]));
1162
1214
  return rows.map((row) => this.mapWandTaskRow(row));
1163
1215
  }
1164
1216
  mapWandTaskRow(row) {
@@ -1170,10 +1222,11 @@ export class WandStorage {
1170
1222
  title: String(row.title),
1171
1223
  titleSource: row.title_source === "auto" ? "auto" : "user",
1172
1224
  description: String(row.description ?? ""),
1173
- status: row.status === "doing" || row.status === "done" ? row.status : "todo",
1225
+ status: row.status === "doing" || row.status === "done" || row.status === "archived" ? row.status : "todo",
1174
1226
  priority: row.priority === "low" || row.priority === "medium" || row.priority === "high" || row.priority === "urgent" ? row.priority : "none",
1175
1227
  labels: (() => { const parsed = safeJsonParse(String(row.labels_json ?? "[]")); return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; })(),
1176
1228
  dueDate: typeof row.due_date === "string" ? row.due_date : null,
1229
+ milestoneId: typeof row.milestone_id === "string" && row.milestone_id ? row.milestone_id : null,
1177
1230
  sortOrder: Number(row.sort_order) || 0,
1178
1231
  agent: parseWandTaskAgent(row.agent_json),
1179
1232
  createdAt: String(row.created_at),
@@ -1184,12 +1237,12 @@ export class WandStorage {
1184
1237
  return this.listWandTasks().find((task) => task.id === id) ?? null;
1185
1238
  }
1186
1239
  getWandTaskByWorkspaceTaskId(workspaceTaskId) {
1187
- const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1240
+ const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at
1188
1241
  FROM wand_tasks WHERE workspace_task_id = ? ORDER BY updated_at DESC LIMIT 1`).get(workspaceTaskId);
1189
1242
  return row ? this.mapWandTaskRow(row) : null;
1190
1243
  }
1191
1244
  findUnlinkedWandTask(workspaceId, title) {
1192
- const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1245
+ const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at
1193
1246
  FROM wand_tasks
1194
1247
  WHERE workspace_id IS ? AND title = ? AND (workspace_task_id IS NULL OR workspace_task_id = '')
1195
1248
  ORDER BY updated_at DESC LIMIT 1`).get(workspaceId, title);
@@ -1203,7 +1256,7 @@ export class WandStorage {
1203
1256
  const max = this.db.prepare("SELECT COALESCE(MAX(sort_order), -1) AS value FROM wand_tasks WHERE workspace_id IS ? AND status = ?").get(input.workspaceId ?? null, status);
1204
1257
  const numberRow = this.db.prepare("SELECT COALESCE(MAX(CAST(substr(identifier, 6) AS INTEGER)), 0) AS value FROM wand_tasks WHERE identifier GLOB 'TASK-[0-9]*'").get();
1205
1258
  const identifier = `TASK-${(numberRow?.value ?? 0) + 1}`;
1206
- this.db.prepare(`INSERT INTO wand_tasks (id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, identifier, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.title, input.titleSource ?? "user", input.description ?? "", status, priority, JSON.stringify(input.labels ?? []), input.dueDate ?? null, (max?.value ?? -1) + 1, input.agent ? JSON.stringify(input.agent) : null, now, now);
1259
+ this.db.prepare(`INSERT INTO wand_tasks (id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, identifier, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.title, input.titleSource ?? "user", input.description ?? "", status, priority, JSON.stringify(input.labels ?? []), input.dueDate ?? null, input.milestoneId ?? null, (max?.value ?? -1) + 1, input.agent ? JSON.stringify(input.agent) : null, now, now);
1207
1260
  return this.getWandTask(id);
1208
1261
  }
1209
1262
  updateWandTask(id, patch) {
@@ -1211,10 +1264,51 @@ export class WandStorage {
1211
1264
  if (!current)
1212
1265
  return null;
1213
1266
  const next = { ...current, ...patch, updatedAt: nowIso() };
1214
- this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, title = ?, title_source = ?, description = ?, status = ?, priority = ?, labels_json = ?, due_date = ?, sort_order = ?, agent_json = ?, updated_at = ? WHERE id = ?`).run(next.workspaceId, next.workspaceTaskId, next.title, next.titleSource, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, id);
1267
+ this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, title = ?, title_source = ?, description = ?, status = ?, priority = ?, labels_json = ?, due_date = ?, milestone_id = ?, sort_order = ?, agent_json = ?, updated_at = ? WHERE id = ?`).run(next.workspaceId, next.workspaceTaskId, next.title, next.titleSource, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.milestoneId ?? null, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, id);
1215
1268
  return next;
1216
1269
  }
1217
1270
  deleteWandTask(id) { this.db.prepare("DELETE FROM wand_tasks WHERE id = ?").run(id); }
1271
+ // ── 里程碑(跨项目全局列表;任务只存 milestoneId)──
1272
+ listWandMilestones() {
1273
+ const rows = this.db.prepare(`SELECT id, name, due_date, created_at, updated_at FROM wand_milestones ORDER BY created_at DESC, rowid DESC`).all();
1274
+ return rows.map(mapWandMilestoneRow).filter((item) => item !== null);
1275
+ }
1276
+ getWandMilestone(id) {
1277
+ const row = this.db.prepare(`SELECT id, name, due_date, created_at, updated_at FROM wand_milestones WHERE id = ?`).get(id);
1278
+ return row ? mapWandMilestoneRow(row) : null;
1279
+ }
1280
+ createWandMilestone(input) {
1281
+ const id = crypto.randomUUID();
1282
+ const now = nowIso();
1283
+ this.db.prepare(`INSERT INTO wand_milestones (id, name, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run(id, input.name, input.dueDate ?? null, now, now);
1284
+ return this.getWandMilestone(id);
1285
+ }
1286
+ updateWandMilestone(id, patch) {
1287
+ const current = this.getWandMilestone(id);
1288
+ if (!current)
1289
+ return null;
1290
+ const next = {
1291
+ name: patch.name ?? current.name,
1292
+ dueDate: patch.dueDate === undefined ? current.dueDate : patch.dueDate,
1293
+ updatedAt: nowIso(),
1294
+ };
1295
+ this.db.prepare(`UPDATE wand_milestones SET name = ?, due_date = ?, updated_at = ? WHERE id = ?`).run(next.name, next.dueDate, next.updatedAt, id);
1296
+ return this.getWandMilestone(id);
1297
+ }
1298
+ /** 删除里程碑只解绑任务(milestone_id 置空),绝不删除任务。 */
1299
+ deleteWandMilestone(id) {
1300
+ const removed = this.db.prepare("DELETE FROM wand_milestones WHERE id = ?").run(id);
1301
+ if (Number(removed.changes) === 0)
1302
+ return false;
1303
+ this.db.prepare("UPDATE wand_tasks SET milestone_id = NULL WHERE milestone_id = ?").run(id);
1304
+ this.db.prepare("UPDATE workspace_tasks SET milestone_id = NULL WHERE milestone_id = ?").run(id);
1305
+ return true;
1306
+ }
1307
+ /** 各里程碑下的任务数(看板卡片),用于下拉里的数量提示。 */
1308
+ countWandTasksByMilestone() {
1309
+ const rows = this.db.prepare(`SELECT milestone_id, COUNT(*) AS count FROM wand_tasks WHERE milestone_id IS NOT NULL GROUP BY milestone_id`).all();
1310
+ return Object.fromEntries(rows.map((row) => [row.milestone_id, Number(row.count) || 0]));
1311
+ }
1218
1312
  listWandTaskSessionIds(taskId) {
1219
1313
  const rows = this.db.prepare("SELECT session_id FROM wand_task_sessions WHERE task_id = ? ORDER BY rowid ASC, session_id ASC").all(taskId);
1220
1314
  return rows.map((row) => row.session_id);
@@ -1498,13 +1592,14 @@ export class WandStorage {
1498
1592
  // ============ Missions ============
1499
1593
  saveMission(mission) {
1500
1594
  this.db.prepare(`INSERT INTO missions (
1501
- id, title, prompt, cwd, status, base_ref, shared_directories, copy_paths, created_at, updated_at, task_id
1502
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1595
+ id, title, prompt, cwd, status, base_ref, shared_directories, copy_paths, created_at, updated_at, task_id, milestone_id
1596
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1503
1597
  ON CONFLICT(id) DO UPDATE SET
1504
1598
  title = excluded.title, prompt = excluded.prompt, cwd = excluded.cwd,
1505
1599
  status = excluded.status, base_ref = excluded.base_ref,
1506
1600
  shared_directories = excluded.shared_directories, copy_paths = excluded.copy_paths,
1507
- updated_at = excluded.updated_at, task_id = excluded.task_id`).run(mission.id, mission.title, mission.prompt, mission.cwd, mission.status, mission.worktree.baseRef ?? null, JSON.stringify(mission.worktree.sharedDirectories ?? []), JSON.stringify(mission.worktree.copyPaths ?? []), mission.createdAt, mission.updatedAt, mission.taskId ?? null);
1601
+ updated_at = excluded.updated_at, task_id = excluded.task_id,
1602
+ milestone_id = excluded.milestone_id`).run(mission.id, mission.title, mission.prompt, mission.cwd, mission.status, mission.worktree.baseRef ?? null, JSON.stringify(mission.worktree.sharedDirectories ?? []), JSON.stringify(mission.worktree.copyPaths ?? []), mission.createdAt, mission.updatedAt, mission.taskId ?? null, mission.milestoneId ?? null);
1508
1603
  }
1509
1604
  getMission(id) {
1510
1605
  const row = this.db.prepare("SELECT * FROM missions WHERE id = ?").get(id);
@@ -1688,6 +1783,7 @@ function mapMissionRow(row) {
1688
1783
  copyPaths: safeJsonParse(typeof row.copy_paths === "string" ? row.copy_paths : null) ?? [],
1689
1784
  },
1690
1785
  taskId: typeof row.task_id === "string" ? row.task_id : null,
1786
+ milestoneId: typeof row.milestone_id === "string" && row.milestone_id ? row.milestone_id : null,
1691
1787
  createdAt: String(row.created_at),
1692
1788
  updatedAt: String(row.updated_at),
1693
1789
  };
@@ -283,6 +283,27 @@ function buildIncrementalStructuredPayload(snapshot, cardDefaults) {
283
283
  messageCount: messages.length,
284
284
  };
285
285
  }
286
+ function isoNow() {
287
+ return new Date().toISOString();
288
+ }
289
+ function upsertAssistantMessage(messages, turn, complete = false) {
290
+ const msgs = [...(messages ?? [])];
291
+ const last = msgs[msgs.length - 1];
292
+ const createdAt = (last?.role === "assistant" ? last.createdAt : undefined) ?? turn.createdAt ?? isoNow();
293
+ const next = {
294
+ ...turn,
295
+ createdAt,
296
+ };
297
+ if (complete)
298
+ next.completedAt = isoNow();
299
+ else if (last?.role === "assistant" && last.completedAt)
300
+ next.completedAt = last.completedAt;
301
+ if (last?.role === "assistant")
302
+ msgs[msgs.length - 1] = next;
303
+ else
304
+ msgs.push(next);
305
+ return msgs;
306
+ }
286
307
  export class StructuredSessionManager {
287
308
  storage;
288
309
  config;
@@ -570,11 +591,7 @@ export class StructuredSessionManager {
570
591
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
571
592
  usage: turnState.usage,
572
593
  };
573
- const messages = [...(current.messages ?? [])];
574
- if (messages[messages.length - 1]?.role === "assistant")
575
- messages[messages.length - 1] = turn;
576
- else
577
- messages.push(turn);
594
+ const messages = upsertAssistantMessage(current.messages, turn);
578
595
  const patched = {
579
596
  ...current,
580
597
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -1111,9 +1128,11 @@ export class StructuredSessionManager {
1111
1128
  // child 是被 SIGTERM 主动 kill 的,正在等用户回答)。如果有,把这次的输入打包
1112
1129
  // 成 tool_result 注入到 messages,让 UI 把卡片渲染为 answered。
1113
1130
  const pendingAsk = findUnpairedAskUserQuestion(session.messages ?? []);
1131
+ const userTurnAt = isoNow();
1114
1132
  const userTurn = pendingAsk
1115
1133
  ? {
1116
1134
  role: "user",
1135
+ createdAt: userTurnAt,
1117
1136
  content: [
1118
1137
  {
1119
1138
  type: "tool_result",
@@ -1125,6 +1144,7 @@ export class StructuredSessionManager {
1125
1144
  }
1126
1145
  : {
1127
1146
  role: "user",
1147
+ createdAt: userTurnAt,
1128
1148
  content: [{ type: "text", text: prompt }],
1129
1149
  };
1130
1150
  const requestId = randomUUID();
@@ -1827,11 +1847,7 @@ export class StructuredSessionManager {
1827
1847
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1828
1848
  usage: turnState.usage,
1829
1849
  };
1830
- const messages = [...(current.messages ?? [])];
1831
- if (messages[messages.length - 1]?.role === "assistant")
1832
- messages[messages.length - 1] = turn;
1833
- else
1834
- messages.push(turn);
1850
+ const messages = upsertAssistantMessage(current.messages, turn);
1835
1851
  const patched = {
1836
1852
  ...current,
1837
1853
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -1986,11 +2002,7 @@ export class StructuredSessionManager {
1986
2002
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1987
2003
  usage: turnState.usage,
1988
2004
  };
1989
- const messages = [...(current.messages ?? [])];
1990
- if (messages.at(-1)?.role === "assistant")
1991
- messages[messages.length - 1] = turn;
1992
- else
1993
- messages.push(turn);
2005
+ const messages = upsertAssistantMessage(current.messages, turn);
1994
2006
  const patched = {
1995
2007
  ...current,
1996
2008
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -2133,11 +2145,7 @@ export class StructuredSessionManager {
2133
2145
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2134
2146
  usage: turnState.usage,
2135
2147
  };
2136
- const messages = [...(current.messages ?? [])];
2137
- if (messages[messages.length - 1]?.role === "assistant")
2138
- messages[messages.length - 1] = turn;
2139
- else
2140
- messages.push(turn);
2148
+ const messages = upsertAssistantMessage(current.messages, turn);
2141
2149
  const patched = {
2142
2150
  ...current,
2143
2151
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -2300,17 +2308,14 @@ export class StructuredSessionManager {
2300
2308
  if (!current)
2301
2309
  return;
2302
2310
  const hasAssistantContent = turnState.blocks.length > 0 || !!turnState.result;
2303
- const messages = [...(current.messages ?? [])];
2311
+ let messages = [...(current.messages ?? [])];
2304
2312
  if (hasAssistantContent) {
2305
2313
  const turn = {
2306
2314
  role: "assistant",
2307
2315
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2308
2316
  usage: turnState.usage,
2309
2317
  };
2310
- if (messages[messages.length - 1]?.role === "assistant")
2311
- messages[messages.length - 1] = turn;
2312
- else
2313
- messages.push(turn);
2318
+ messages = upsertAssistantMessage(current.messages, turn);
2314
2319
  }
2315
2320
  const patched = {
2316
2321
  ...current,
@@ -2666,12 +2671,7 @@ export class StructuredSessionManager {
2666
2671
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2667
2672
  usage: turnState.usage,
2668
2673
  };
2669
- const msgs = [...(current.messages ?? [])];
2670
- const lastMsg = msgs[msgs.length - 1];
2671
- if (lastMsg && lastMsg.role === "assistant")
2672
- msgs[msgs.length - 1] = inProgressTurn;
2673
- else
2674
- msgs.push(inProgressTurn);
2674
+ const msgs = upsertAssistantMessage(current.messages, inProgressTurn);
2675
2675
  const patched = {
2676
2676
  ...current,
2677
2677
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -3009,13 +3009,7 @@ export class StructuredSessionManager {
3009
3009
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
3010
3010
  usage: turnState.usage,
3011
3011
  };
3012
- const msgs = [...(current.messages ?? [])];
3013
- const lastMsg = msgs[msgs.length - 1];
3014
- if (lastMsg && lastMsg.role === "assistant")
3015
- msgs[msgs.length - 1] = assistantTurn;
3016
- else
3017
- msgs.push(assistantTurn);
3018
- return msgs;
3012
+ return upsertAssistantMessage(current.messages, assistantTurn, true);
3019
3013
  }
3020
3014
  resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt) {
3021
3015
  if (interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId))
@@ -3066,12 +3060,7 @@ export class StructuredSessionManager {
3066
3060
  role: "assistant",
3067
3061
  content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
3068
3062
  };
3069
- const msgs = [...(current.messages ?? [])];
3070
- const lastMsg = msgs[msgs.length - 1];
3071
- if (lastMsg && lastMsg.role === "assistant")
3072
- msgs[msgs.length - 1] = failureTurn;
3073
- else
3074
- msgs.push(failureTurn);
3063
+ const msgs = upsertAssistantMessage(current.messages, failureTurn, true);
3075
3064
  return {
3076
3065
  ...current,
3077
3066
  status: "failed",
@@ -1,4 +1,6 @@
1
- export type WandTaskStatus = "todo" | "doing" | "done";
1
+ export type WandTaskStatus = "todo" | "doing" | "done" | "archived";
2
+ /** 已确认或已归档:不再算未完成,也不会被未分组会话同步重新打开。 */
3
+ export declare function isClosedWandTaskStatus(status: WandTaskStatus): boolean;
2
4
  export type WandTaskPriority = "none" | "low" | "medium" | "high" | "urgent";
3
5
  /** 任务派发时选择的 CLI 工具;空串表示尚未指定。 */
4
6
  export type WandTaskAgentProvider = "claude" | "codex" | "opencode" | "grok" | "qoder" | "pi";
@@ -13,6 +15,20 @@ export interface WandTaskAgent {
13
15
  }
14
16
  /** 任务标题来源;标题是可选字段,留空时由服务端按描述自动生成。 */
15
17
  export type WandTaskTitleSource = "user" | "auto";
18
+ /** 里程碑名上限;所有任务面板的新增入口共用同一约束。 */
19
+ export declare const WAND_MILESTONE_NAME_MAX_LENGTH = 60;
20
+ /**
21
+ * 里程碑:跨项目的全局列表(不是每个目录一份),创建任务时从中选择或新增。
22
+ * 任务只保存 milestoneId,删除里程碑时只解绑任务、不删除任务。
23
+ */
24
+ export interface WandTaskMilestone {
25
+ id: string;
26
+ name: string;
27
+ /** YYYY-MM-DD,可为空。 */
28
+ dueDate: string | null;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ }
16
32
  export interface WandTask {
17
33
  id: string;
18
34
  workspaceId: string | null;
@@ -27,6 +43,8 @@ export interface WandTask {
27
43
  priority: WandTaskPriority;
28
44
  labels: string[];
29
45
  dueDate: string | null;
46
+ /** 所属里程碑;null 表示未归入任何里程碑。 */
47
+ milestoneId: string | null;
30
48
  sortOrder: number;
31
49
  /** 该任务绑定的执行 Agent;null 表示还没指定。 */
32
50
  agent: WandTaskAgent | null;
@@ -1 +1,6 @@
1
- export {};
1
+ /** 已确认或已归档:不再算未完成,也不会被未分组会话同步重新打开。 */
2
+ export function isClosedWandTaskStatus(status) {
3
+ return status === "done" || status === "archived";
4
+ }
5
+ /** 里程碑名上限;所有任务面板的新增入口共用同一约束。 */
6
+ export const WAND_MILESTONE_NAME_MAX_LENGTH = 60;
package/dist/types.d.ts CHANGED
@@ -495,6 +495,10 @@ export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResult
495
495
  export interface ConversationTurn {
496
496
  role: "user" | "assistant";
497
497
  content: ContentBlock[];
498
+ /** ISO time when this turn was first recorded (user send / assistant start). */
499
+ createdAt?: string;
500
+ /** ISO time when an assistant turn finished streaming. */
501
+ completedAt?: string;
498
502
  /** Optional usage metadata when available from the underlying tool. */
499
503
  usage?: {
500
504
  inputTokens?: number;
@@ -691,6 +695,8 @@ export interface WorkspaceTask {
691
695
  worktree: WorkspaceTaskWorktree | null;
692
696
  /** Optional directory override; empty/undefined falls back to the workspace cwd. */
693
697
  cwd?: string;
698
+ /** 归属里程碑(全局列表);null = 未归入。建任务时同步到看板卡片。 */
699
+ milestoneId?: string | null;
694
700
  /** 该任务的工作窗口 Tabs;每个窗口内部可含一棵分屏树。 */
695
701
  layout: TaskWindowLayout | null;
696
702
  status: WorkspaceTaskStatus;
@@ -1,5 +1,5 @@
1
1
  import type { WandStorage } from "./storage.js";
2
- import type { WandTask } from "./task-types.js";
2
+ import { type WandTask } from "./task-types.js";
3
3
  import type { SessionSnapshot, Workspace, WorkspaceTask } from "./types.js";
4
4
  export declare function isUnnamedWorkspaceTaskName(name: string): boolean;
5
5
  /** 从未分组会话的标题 / 描述 / 首条用户消息里抽出看板标题。 */
@@ -11,7 +11,9 @@ export declare function ensureBoardTaskForWorkspaceTask(storage: WandStorage, ta
11
11
  * 幂等,打开看板列表时调用。
12
12
  */
13
13
  export declare function syncUngroupedSessionsToBoard(storage: WandStorage): void;
14
- /** 看板归档:卡片进入已完成,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
14
+ /** 看板确认/归档时,把关联的侧栏工作任务标成 done(不删会话)。 */
15
+ export declare function syncClosedBoardTask(storage: WandStorage, task: WandTask): void;
16
+ /** 看板归档:卡片进入归档目录,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
15
17
  export declare function archiveBoardTask(storage: WandStorage, id: string): WandTask | null;
16
- /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成。 */
18
+ /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成;已归档的保持归档。 */
17
19
  export declare function archiveBoardTaskForWorkspaceTask(storage: WandStorage, workspaceTaskId: string): void;
@@ -1,4 +1,5 @@
1
1
  import { provisionalTaskTitleFromDescription } from "./task-title.js";
2
+ import { isClosedWandTaskStatus } from "./task-types.js";
2
3
  const UNNAMED_TASK_NAME = "未命名任务";
3
4
  function boardDescriptionFor(task, workspace) {
4
5
  const lines = [`项目:${workspace.name}`];
@@ -92,7 +93,7 @@ function promoteUngroupedCard(storage, card, patch) {
92
93
  title: nextTitle,
93
94
  titleSource: nextTitle !== card.title ? "auto" : card.titleSource,
94
95
  description: nextDescription,
95
- status: card.status === "done" ? "done" : "doing",
96
+ status: isClosedWandTaskStatus(card.status) ? card.status : "doing",
96
97
  workspaceId: patch.workspaceId !== undefined ? patch.workspaceId : card.workspaceId,
97
98
  workspaceTaskId: patch.workspaceTaskId !== undefined ? patch.workspaceTaskId : card.workspaceTaskId,
98
99
  }) ?? card;
@@ -110,6 +111,7 @@ export function ensureBoardTaskForWorkspaceTask(storage, task, workspace) {
110
111
  return storage.updateWandTask(reusable.id, {
111
112
  workspaceTaskId: task.id,
112
113
  workspaceId: workspace.id,
114
+ ...(task.milestoneId ? { milestoneId: task.milestoneId } : {}),
113
115
  }) ?? reusable;
114
116
  }
115
117
  return storage.createWandTask({
@@ -118,6 +120,7 @@ export function ensureBoardTaskForWorkspaceTask(storage, task, workspace) {
118
120
  title: task.name,
119
121
  description: boardDescriptionFor(task, workspace),
120
122
  status: task.status === "done" ? "done" : "todo",
123
+ milestoneId: task.milestoneId ?? null,
121
124
  });
122
125
  }
123
126
  /**
@@ -147,6 +150,8 @@ export function syncUngroupedSessionsToBoard(storage) {
147
150
  titleSource: "auto",
148
151
  description,
149
152
  status: "doing",
153
+ // 未命名任务建立板卡时才补上:建任务时选的里程碑不能丢。
154
+ milestoneId: task.milestoneId ?? null,
150
155
  });
151
156
  boardTasks.push(card);
152
157
  }
@@ -157,6 +162,9 @@ export function syncUngroupedSessionsToBoard(storage) {
157
162
  workspaceId: workspace.id,
158
163
  workspaceTaskId: task.id,
159
164
  });
165
+ if (!card.milestoneId && task.milestoneId) {
166
+ card = storage.updateWandTask(card.id, { milestoneId: task.milestoneId }) ?? card;
167
+ }
160
168
  }
161
169
  bindSessions(storage, card.id, sessions.map((session) => session.id));
162
170
  for (const session of sessions)
@@ -182,7 +190,7 @@ export function syncUngroupedSessionsToBoard(storage) {
182
190
  });
183
191
  boardTasks.push(card);
184
192
  }
185
- else if (card.status !== "done") {
193
+ else if (!isClosedWandTaskStatus(card.status)) {
186
194
  card = promoteUngroupedCard(storage, card, {
187
195
  title,
188
196
  description: boardDescriptionFromSession(session),
@@ -193,24 +201,34 @@ export function syncUngroupedSessionsToBoard(storage) {
193
201
  bound.add(session.id);
194
202
  }
195
203
  }
196
- /** 看板归档:卡片进入已完成,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
204
+ function closeLinkedWorkspaceTask(storage, workspaceTaskId) {
205
+ if (!workspaceTaskId)
206
+ return;
207
+ const workspaceTask = storage.getWorkspaceTask(workspaceTaskId);
208
+ if (workspaceTask && workspaceTask.status !== "done") {
209
+ storage.updateWorkspaceTask(workspaceTask.id, { status: "done" });
210
+ }
211
+ }
212
+ /** 看板确认/归档时,把关联的侧栏工作任务标成 done(不删会话)。 */
213
+ export function syncClosedBoardTask(storage, task) {
214
+ if (!isClosedWandTaskStatus(task.status))
215
+ return;
216
+ closeLinkedWorkspaceTask(storage, task.workspaceTaskId);
217
+ }
218
+ /** 看板归档:卡片进入归档目录,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
197
219
  export function archiveBoardTask(storage, id) {
198
220
  const current = storage.getWandTask(id);
199
221
  if (!current)
200
222
  return null;
201
- const archived = storage.updateWandTask(id, { status: "done" });
202
- if (current.workspaceTaskId) {
203
- const workspaceTask = storage.getWorkspaceTask(current.workspaceTaskId);
204
- if (workspaceTask && workspaceTask.status !== "done") {
205
- storage.updateWorkspaceTask(workspaceTask.id, { status: "done" });
206
- }
207
- }
223
+ const archived = storage.updateWandTask(id, { status: "archived" });
224
+ if (archived)
225
+ closeLinkedWorkspaceTask(storage, archived.workspaceTaskId);
208
226
  return archived;
209
227
  }
210
- /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成。 */
228
+ /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成;已归档的保持归档。 */
211
229
  export function archiveBoardTaskForWorkspaceTask(storage, workspaceTaskId) {
212
230
  const linked = storage.getWandTaskByWorkspaceTaskId(workspaceTaskId);
213
- if (!linked || linked.status === "done")
231
+ if (!linked || isClosedWandTaskStatus(linked.status))
214
232
  return;
215
233
  storage.updateWandTask(linked.id, { status: "done" });
216
234
  }