dsh-taskboard 0.6.6 → 0.7.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.
Files changed (64) hide show
  1. package/README.md +305 -288
  2. package/lib/client.js +985 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/assets.js +139 -0
  6. package/lib/host/assets.js.map +1 -0
  7. package/lib/host/execution.js +3 -0
  8. package/lib/host/execution.js.map +1 -1
  9. package/lib/host/locale.js +17 -0
  10. package/lib/host/locale.js.map +1 -0
  11. package/lib/host/routes.js +132 -6
  12. package/lib/host/routes.js.map +1 -1
  13. package/lib/host/scheduler.js +2 -0
  14. package/lib/host/scheduler.js.map +1 -1
  15. package/lib/host/session-sync.js +4 -1
  16. package/lib/host/session-sync.js.map +1 -1
  17. package/lib/host/storage-queue.js +14 -0
  18. package/lib/host/storage-queue.js.map +1 -0
  19. package/lib/host/storage.js +249 -0
  20. package/lib/host/storage.js.map +1 -0
  21. package/lib/host/store.js +34 -7
  22. package/lib/host/store.js.map +1 -1
  23. package/lib/host/templates.js +73 -113
  24. package/lib/host/templates.js.map +1 -1
  25. package/lib/host/tools.js +23 -8
  26. package/lib/host/tools.js.map +1 -1
  27. package/lib/index.js +83 -27
  28. package/lib/index.js.map +1 -1
  29. package/lib/shared/api.js.map +1 -1
  30. package/lib/shared/builtin-templates.js +155 -0
  31. package/lib/shared/builtin-templates.js.map +1 -0
  32. package/lib/shared/protocol.js +39 -2
  33. package/lib/shared/protocol.js.map +1 -1
  34. package/package.json +90 -89
  35. package/src/client/api.ts +35 -1
  36. package/src/client/board/SettingsModal.tsx +67 -4
  37. package/src/client/board/SlashPromptInput.tsx +80 -1
  38. package/src/client/board/TaskBoard.tsx +16 -11
  39. package/src/client/board/TaskDetail.tsx +186 -17
  40. package/src/client/board/TaskFormModal.tsx +8 -5
  41. package/src/client/board/TemplateManager.tsx +22 -10
  42. package/src/client/controller.ts +67 -5
  43. package/src/client/i18n/en.ts +35 -3
  44. package/src/client/i18n/templates.ts +25 -0
  45. package/src/client/i18n/zh.ts +35 -3
  46. package/src/client/image-insert.ts +29 -0
  47. package/src/client/styles.ts +35 -2
  48. package/src/host/archive-sessions.ts +18 -0
  49. package/src/host/assets.ts +120 -0
  50. package/src/host/execution.ts +4 -1
  51. package/src/host/locale.ts +44 -0
  52. package/src/host/routes.ts +132 -7
  53. package/src/host/scheduler.ts +2 -0
  54. package/src/host/session-sync.ts +4 -1
  55. package/src/host/storage-queue.ts +10 -0
  56. package/src/host/storage.ts +212 -0
  57. package/src/host/store.ts +40 -12
  58. package/src/host/templates.ts +38 -66
  59. package/src/host/tools.ts +28 -11
  60. package/src/index.ts +88 -33
  61. package/src/shared/api.ts +32 -3
  62. package/src/shared/builtin-templates.ts +153 -0
  63. package/src/shared/protocol.ts +64 -0
  64. package/src/shared/version.ts +9 -9
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS } from "../shared/builtin-templates.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  //#region src/host/templates.ts
3
4
  /**
@@ -10,81 +11,16 @@ import { readFile } from "node:fs/promises";
10
11
  *
11
12
  * @module dsh-taskboard/host/templates
12
13
  */
13
- /** The built-in templates seeded when the side file does not exist yet. */
14
- const BUILTIN_TEMPLATES = [
15
- {
16
- id: "tpl-feature",
17
- name: "新增功能",
18
- task: {
19
- title: "新增:",
20
- prompt: [
21
- "实现以上新功能并按序交接:",
22
- "1. 明确需求边界与验收标准,列出实现要点",
23
- "2. 实现功能(含类型定义与错误处理)",
24
- "3. 补充测试(单测/回归)",
25
- "4. 运行相关测试套件确认通过"
26
- ].join("\n"),
27
- urgency: "normal",
28
- checklist: [
29
- "实现要点已明确(需求边界与验收标准)",
30
- "功能已实现并补充测试",
31
- "相关测试套件通过"
32
- ]
33
- }
34
- },
35
- {
36
- id: "tpl-bugfix",
37
- name: "Bug 修复",
38
- task: {
39
- title: "修复:",
40
- prompt: [
41
- "修复以上问题并按序交接:",
42
- "1. 复现问题(写最小复现步骤或测试)",
43
- "2. 定位根因,说明为什么会发生",
44
- "3. 修复并补回归测试",
45
- "4. 运行相关测试套件确认无回归"
46
- ].join("\n"),
47
- urgency: "urgent",
48
- checklist: [
49
- "已复现并定位根因",
50
- "修复已提交到任务分支",
51
- "回归测试通过"
52
- ]
53
- }
54
- },
55
- {
56
- id: "tpl-release",
57
- name: "发布检查",
58
- task: {
59
- title: "发布:",
60
- prompt: "执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。",
61
- urgency: "normal",
62
- checklist: [
63
- "版本号已更新(package.json 与版本常量同步)",
64
- "构建通过",
65
- "全部测试通过",
66
- "变更记录已写"
67
- ]
68
- }
69
- },
70
- {
71
- id: "tpl-patrol",
72
- name: "例行巡检",
73
- task: {
74
- title: "巡检:",
75
- prompt: [
76
- "例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。",
77
- "发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。",
78
- "输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。"
79
- ].join("\n"),
80
- urgency: "relaxed",
81
- execution: {
82
- mode: "scheduled",
83
- cron: "0 9 * * 1"
84
- }
85
- }
86
- }
87
- ];
14
+ /**
15
+ * The built-in templates seeded when the side file does not exist yet.
16
+ * Seeded from the shared zh content (the side file is plain data; the client
17
+ * resolves the active locale at render time — see shared/builtin-templates.ts).
18
+ */
19
+ const BUILTIN_TEMPLATES = BUILTIN_TEMPLATE_IDS.map((id) => ({
20
+ id,
21
+ name: BUILTIN_TEMPLATE_CONTENT.zh[id].name,
22
+ task: BUILTIN_TEMPLATE_CONTENT.zh[id].task
23
+ }));
88
24
  /** Mint a template id. */
89
25
  function newTemplateId() {
90
26
  return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -94,11 +30,26 @@ function newTemplateId() {
94
30
  * writes are rare, human-paced GUI operations; last-write-wins is fine).
95
31
  */
96
32
  var TemplateStore = class {
97
- file;
33
+ storageQueue;
98
34
  templates;
99
35
  loaded = false;
36
+ file;
100
37
  /** @param file - absolute side-file path (next to the ledger). */
101
- constructor(file) {
38
+ constructor(file, storageQueue) {
39
+ this.storageQueue = storageQueue;
40
+ this.file = file;
41
+ }
42
+ /** Current absolute template-file path. */
43
+ location() {
44
+ return this.file;
45
+ }
46
+ /** Persist the loaded template set to another file without switching. */
47
+ async writeCopy(file) {
48
+ await this.ensure();
49
+ await this.persist(this.templates ?? [], file);
50
+ }
51
+ /** Switch future writes after a prepared migration commits. */
52
+ setLocation(file) {
102
53
  this.file = file;
103
54
  }
104
55
  /** Load once; a missing file seeds the built-ins; a corrupt file resets. */
@@ -127,11 +78,11 @@ var TemplateStore = class {
127
78
  this.loaded = true;
128
79
  }
129
80
  /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
130
- async persist(templates) {
81
+ async persist(templates, file = this.file) {
131
82
  const { mkdir, open, rename } = await import("node:fs/promises");
132
83
  const { dirname, join } = await import("node:path");
133
- await mkdir(dirname(this.file), { recursive: true });
134
- const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`);
84
+ await mkdir(dirname(file), { recursive: true });
85
+ const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
135
86
  const fh = await open(temp, "w");
136
87
  try {
137
88
  await fh.writeFile(JSON.stringify({ templates }, null, 2), "utf8");
@@ -139,52 +90,61 @@ var TemplateStore = class {
139
90
  } finally {
140
91
  await fh.close();
141
92
  }
142
- await rename(temp, this.file);
93
+ await rename(temp, file);
143
94
  }
144
95
  /** All templates (oldest first). */
145
96
  async list() {
146
- await this.ensure();
147
- return (this.templates ?? []).slice();
97
+ const run = async () => {
98
+ await this.ensure();
99
+ return (this.templates ?? []).slice();
100
+ };
101
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
148
102
  }
149
103
  /**
150
104
  * Create or replace a template by id (a body without id creates).
151
105
  * @returns the stored template.
152
106
  */
153
107
  async upsert(input) {
154
- await this.ensure();
155
- const templates = this.templates ?? [];
156
- const name = input.name.trim();
157
- if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
158
- const now = Date.now();
159
- const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
160
- if (existing?.builtin === true) throw new Error("内置模板不可覆盖;可删除后另建,或以新名称存为新模板");
161
- const stored = existing !== void 0 ? {
162
- ...existing,
163
- name,
164
- task: input.task,
165
- updatedAt: now
166
- } : {
167
- id: input.id ?? newTemplateId(),
168
- name,
169
- task: input.task,
170
- createdAt: now,
171
- updatedAt: now
108
+ const run = async () => {
109
+ await this.ensure();
110
+ const templates = this.templates ?? [];
111
+ const name = input.name.trim();
112
+ if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
113
+ const now = Date.now();
114
+ const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
115
+ if (existing?.builtin === true) throw new Error("内置模板不可覆盖;可删除后另建,或以新名称存为新模板");
116
+ const stored = existing !== void 0 ? {
117
+ ...existing,
118
+ name,
119
+ task: input.task,
120
+ updatedAt: now
121
+ } : {
122
+ id: input.id ?? newTemplateId(),
123
+ name,
124
+ task: input.task,
125
+ createdAt: now,
126
+ updatedAt: now
127
+ };
128
+ const index = existing !== void 0 ? templates.indexOf(existing) : -1;
129
+ if (index >= 0) templates[index] = stored;
130
+ else templates.push(stored);
131
+ await this.persist(templates);
132
+ return stored;
172
133
  };
173
- const index = existing !== void 0 ? templates.indexOf(existing) : -1;
174
- if (index >= 0) templates[index] = stored;
175
- else templates.push(stored);
176
- await this.persist(templates);
177
- return stored;
134
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
178
135
  }
179
136
  /** Delete a template by id; returns whether it existed. */
180
137
  async remove(id) {
181
- await this.ensure();
182
- const templates = this.templates ?? [];
183
- const index = templates.findIndex((t) => t.id === id);
184
- if (index < 0) return false;
185
- templates.splice(index, 1);
186
- await this.persist(templates);
187
- return true;
138
+ const run = async () => {
139
+ await this.ensure();
140
+ const templates = this.templates ?? [];
141
+ const index = templates.findIndex((t) => t.id === id);
142
+ if (index < 0) return false;
143
+ templates.splice(index, 1);
144
+ await this.persist(templates);
145
+ return true;
146
+ };
147
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
188
148
  }
189
149
  };
190
150
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\n\n/** The built-in templates seeded when the side file does not exist yet. */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [\n {\n id: 'tpl-feature',\n name: '新增功能',\n task: {\n title: '新增:',\n prompt: [\n '实现以上新功能并按序交接:',\n '1. 明确需求边界与验收标准,列出实现要点',\n '2. 实现功能(含类型定义与错误处理)',\n '3. 补充测试(单测/回归)',\n '4. 运行相关测试套件确认通过',\n ].join('\\n'),\n urgency: 'normal',\n checklist: ['实现要点已明确(需求边界与验收标准)', '功能已实现并补充测试', '相关测试套件通过'],\n },\n },\n {\n id: 'tpl-bugfix',\n name: 'Bug 修复',\n task: {\n title: '修复:',\n prompt: [\n '修复以上问题并按序交接:',\n '1. 复现问题(写最小复现步骤或测试)',\n '2. 定位根因,说明为什么会发生',\n '3. 修复并补回归测试',\n '4. 运行相关测试套件确认无回归',\n ].join('\\n'),\n urgency: 'urgent',\n checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],\n },\n },\n {\n id: 'tpl-release',\n name: '发布检查',\n task: {\n title: '发布:',\n prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',\n urgency: 'normal',\n checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],\n },\n },\n {\n id: 'tpl-patrol',\n name: '例行巡检',\n task: {\n title: '巡检:',\n prompt: [\n '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',\n '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',\n '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',\n ].join('\\n'),\n urgency: 'relaxed',\n execution: { mode: 'scheduled', cron: '0 9 * * 1' },\n },\n },\n]\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(private readonly file: string) {}\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */\n private async persist(templates: TaskTemplate[]): Promise<void> {\n const { mkdir, open, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(this.file), { recursive: true })\n const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, this.file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n // T12: built-ins are factory content — editable only by delete + recreate\n // (deleting stays allowed), never silently overwritten in place.\n if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\nimport { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'\nimport type { StorageQueue } from './storage-queue.ts'\n\n/**\n * The built-in templates seeded when the side file does not exist yet.\n * Seeded from the shared zh content (the side file is plain data; the client\n * resolves the active locale at render time — see shared/builtin-templates.ts).\n */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: BuiltinTemplateId; name: string; task: TaskTemplate['task'] }> =\n BUILTIN_TEMPLATE_IDS.map(id => ({ id, name: BUILTIN_TEMPLATE_CONTENT.zh[id].name, task: BUILTIN_TEMPLATE_CONTENT.zh[id].task }))\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n private file: string\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(file: string, private readonly storageQueue?: StorageQueue) { this.file = file }\n\n /** Current absolute template-file path. */\n location(): string { return this.file }\n\n /** Persist the loaded template set to another file without switching. */\n async writeCopy(file: string): Promise<void> {\n await this.ensure()\n await this.persist(this.templates ?? [], file)\n }\n\n /** Switch future writes after a prepared migration commits. */\n setLocation(file: string): void { this.file = file }\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */\n private async persist(templates: TaskTemplate[], file = this.file): Promise<void> {\n const { mkdir, open, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n const run = async (): Promise<TaskTemplate[]> => {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n const run = async (): Promise<TaskTemplate> => {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n // T12: built-ins are factory content — editable only by delete + recreate\n // (deleting stays allowed), never silently overwritten in place.\n if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n const run = async (): Promise<boolean> => {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n}\n"],"mappings":"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"}
package/lib/host/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, effectivePrompt, isClaim, isClaimedBy, newCommentId, newTaskId, normalizeBody, normalizeExecution, normalizeExecutionReport, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim } from "../shared/protocol.js";
2
2
  import { defineTool } from "./sdk.js";
3
+ import { archiveTaskSessions } from "./archive-sessions.js";
3
4
  //#region src/host/tools.ts
4
5
  /** Render side: one compact task line (id/status/version are load-bearing). */
5
6
  function taskLine(t) {
@@ -59,6 +60,7 @@ function taskDetail(t) {
59
60
  }
60
61
  /** Stable error codes surfaced at the head of tool error messages. */
61
62
  const ERR = {
63
+ notReady: "taskboard_not_ready",
62
64
  notFound: "not_found",
63
65
  versionConflict: "version_conflict",
64
66
  workspaceMismatch: "workspace_mismatch",
@@ -96,7 +98,8 @@ function workspaceFace(registry) {
96
98
  id: ws.id,
97
99
  path: ws.path,
98
100
  title: ws.title
99
- }))
101
+ })),
102
+ ...typeof registry.archiveSession === "function" ? { archiveSession: (sessionId) => registry.archiveSession(sessionId) } : {}
100
103
  };
101
104
  }
102
105
  /** Validate a pinned model: structural check always, provider route when known. */
@@ -167,16 +170,17 @@ function registerTaskboardTools(ctx, deps) {
167
170
  const disposers = [];
168
171
  const { store, workspaces } = deps;
169
172
  const register = (tool) => {
170
- if (process.env.ATB_TRACE === "1" && typeof tool.execute === "function") {
173
+ if (typeof tool.execute === "function") {
171
174
  const orig = tool.execute;
172
175
  tool.execute = async (args, exec) => {
173
- console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300));
176
+ await deps.ready?.();
177
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300));
174
178
  try {
175
179
  const result = await orig(args, exec);
176
- console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300));
180
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300));
177
181
  return result;
178
182
  } catch (error) {
179
- console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400));
183
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400));
180
184
  throw error;
181
185
  }
182
186
  };
@@ -490,15 +494,20 @@ function registerTaskboardTools(ctx, deps) {
490
494
  type: "number",
491
495
  required: true,
492
496
  description: "Task version you read; fails on mismatch."
497
+ },
498
+ archiveSessions: {
499
+ type: "boolean",
500
+ description: "When moving to archived: whether to archive associated execution sessions as well. Defaults to false."
493
501
  }
494
502
  },
495
503
  output: {
496
504
  schema: JSON_OUT,
497
505
  render: (_args, value) => {
498
- const t = value.task;
506
+ const v = value;
507
+ const t = v.task;
499
508
  return [{
500
509
  type: "text",
501
- text: t === void 0 ? "移动失败。" : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。`
510
+ text: t === void 0 ? "移动失败。" : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。${v.sessionArchive === void 0 ? "" : ` 会话归档结果:${JSON.stringify(v.sessionArchive)}`}`
502
511
  }];
503
512
  }
504
513
  },
@@ -508,9 +517,11 @@ function registerTaskboardTools(ctx, deps) {
508
517
  const to = asStatus(args.status);
509
518
  const callerWsId = to === "in_progress" ? await callerWorkspace(deps, exec) : void 0;
510
519
  let next;
520
+ let beforeTask;
511
521
  await store.mutate("task-moved", (ledger) => {
512
522
  const { index, task } = liveTaskAt(ledger, args.id);
513
523
  versionGuard(task, args.ifVersion);
524
+ beforeTask = task;
514
525
  if (to === "done") throw new ToolError(ERR.forbidden, "moving a task to done requires explicit user confirmation (GUI); agents cannot do it");
515
526
  if (!canTransition(task.status, to)) throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`);
516
527
  if (task.status === "in_progress" && task.claimedBy !== void 0 && task.claimedBy !== actor.sessionId) throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`);
@@ -525,7 +536,11 @@ function registerTaskboardTools(ctx, deps) {
525
536
  ledger.tasks[index] = next;
526
537
  return [next];
527
538
  });
528
- return json({ task: summarize(next) });
539
+ const sessionArchive = to === "archived" && args.archiveSessions === true ? await archiveTaskSessions(beforeTask ?? next, deps.workspaces.archiveSession) : void 0;
540
+ return json({
541
+ task: summarize(next),
542
+ ...sessionArchive !== void 0 ? { sessionArchive } : {}
543
+ });
529
544
  } catch (error) {
530
545
  fail(error);
531
546
  }