@yeaft/webchat-agent 0.1.479 → 0.1.481

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/crew-i18n.js CHANGED
@@ -133,7 +133,11 @@ ${isDevTeam ? '3' : '2'}. **任务完成** - 所有任务已完成,给出完
133
133
  // dev team
134
134
  developer: (dm) => `代码完成后,必须同时发两个 ROUTE 块分别交给审查者和测试者(缺一不可)。
135
135
  UI/交互方案不确定时找设计师确认。需求不明确时找决策者 "${dm}" 确认。
136
- **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 代码无法进入审查流程。**`,
136
+ **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 代码无法进入审查流程。**
137
+
138
+ > 🚨 **合并与发布 — Dev 严禁自打 tag**
139
+ > 不得执行 \`git tag\` 或 push \`v*\` / \`release-*\` 到 origin,除非用户明确要求 release。
140
+ > tag 由 PM/reviewer 在 main 分支上执行。详见 \`.crew/CLAUDE.md\` "代码合并与发布规则" 和 \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules"。`,
137
141
  reviewer_dev: (dm) => `审查完成后:评分 ≥ 9分通过,ROUTE 给对应的测试者(如 rev-1 → test-1)告知审查通过、可以开始测试。评分 < 9分打回给开发者修改。
138
142
  遇到架构层面的问题找决策者 "${dm}" 讨论。
139
143
  **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 审查结果无法传递。**`,
@@ -188,7 +192,15 @@ UI/交互方案不确定时找设计师确认。需求不明确时找决策者 "
188
192
  - **Dev 严禁自己打 tag** — tag 只能由 PM 或 reviewer 合并后打
189
193
  - **Dev 完成后必须 ROUTE 给 reviewer** — 不 ROUTE 等于没完成,消息无法传递
190
194
  - **即使是小修复(一行改动),也必须走 PR + review 流程**
191
- - **合并和打 tag 由 reviewer 或 PM 执行**,dev 不自己合并`,
195
+ - **合并和打 tag 由 reviewer 或 PM 执行**,dev 不自己合并
196
+
197
+ ## Tagging Checklist(任何角色 tag 前必读)
198
+ 1. **分支检查**:当前分支必须是 main(\`git branch --show-current\` == \`main\`),非 main 分支一律不得 tag
199
+ 2. **Conventional commits**:commit message 使用 \`feat:\` / \`fix:\` / \`perf:\` 等规范前缀
200
+ 3. **不得自打 tag**:除非用户明确要求 release,否则 dev/reviewer/tester 角色不得执行 \`git tag\`
201
+ 4. **Tag 前确认 origin/main**:被 tag 的 commit 必须已经在 \`origin/main\` 上(\`git merge-base --is-ancestor <sha> origin/main\`)
202
+
203
+ 完整规则与 2026-04-17 违规案例见 \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules"。`,
192
204
  taskSplitRules: '# 任务拆分原则',
193
205
  taskSplitRulesContent: `- **不相干的修改必须分给不同的 dev,产出独立的 PR**。严禁把两个不相关的 bug fix / feature 放在同一个 PR 中。
194
206
  - 即使两个改动看似相关(如同一个 bug 的两个方面),如果它们改的文件不同、逻辑独立,也应该拆成独立 task + 独立 PR。
@@ -393,7 +405,11 @@ Execute stop-losses immediately when triggered and notify the decision maker. Re
393
405
  // dev team
394
406
  developer: (dm) => `After code is complete, you must send two ROUTE blocks simultaneously to reviewer and tester (both required).
395
407
  Check with designer for UI/interaction questions. Check with decision maker "${dm}" for unclear requirements.
396
- **⚠️ Your reply MUST end with ROUTE blocks. Not sending a ROUTE = code cannot enter the review process.**`,
408
+ **⚠️ Your reply MUST end with ROUTE blocks. Not sending a ROUTE = code cannot enter the review process.**
409
+
410
+ > 🚨 **Merge & Release — Dev MUST NOT self-tag**
411
+ > Do NOT run \`git tag\` or push \`v*\` / \`release-*\` to origin unless the user explicitly requested a release.
412
+ > Tags are created by PM/reviewer on the \`main\` branch only. See \`.crew/CLAUDE.md\` "Code Merge & Release Rules" and \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules" for details.`,
397
413
  reviewer_dev: (dm) => `After review: score >= 9 passes, ROUTE to the corresponding tester (e.g., rev-1 → test-1) to notify review passed and testing can begin. Score < 9: send back to developer with issues.
398
414
  Discuss architecture-level concerns with decision maker "${dm}".
399
415
  **⚠️ Your reply MUST end with a ROUTE block. Not sending a ROUTE = review results cannot be passed on.**`,
@@ -447,7 +463,15 @@ After development is complete, send two ROUTE blocks simultaneously to ${revName
447
463
  - **Dev must never create tags** — Tags can only be created by PM or reviewer after merge
448
464
  - **Dev must ROUTE to reviewer after completion** — Not ROUTEing = work not delivered, message cannot be passed
449
465
  - **Even small fixes (single-line changes) must go through PR + review workflow**
450
- - **Merge and tagging are done by reviewer or PM** — dev does not merge themselves`,
466
+ - **Merge and tagging are done by reviewer or PM** — dev does not merge themselves
467
+
468
+ ## Tagging Checklist (required reading before any \`git tag\`)
469
+ 1. **Branch check**: current branch MUST be \`main\` (\`git branch --show-current\` == \`main\`); never tag from a feature/worktree branch
470
+ 2. **Conventional commits**: commit messages use \`feat:\` / \`fix:\` / \`perf:\` prefixes
471
+ 3. **No self-tagging**: unless the user explicitly requested a release, dev/reviewer/tester roles MUST NOT run \`git tag\`
472
+ 4. **Verify origin/main before tag**: the commit being tagged must already be on \`origin/main\` (\`git merge-base --is-ancestor <sha> origin/main\`)
473
+
474
+ Full rules and the 2026-04-17 violation precedent are in \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules".`,
451
475
  taskSplitRules: '# Task Split Principles',
452
476
  taskSplitRulesContent: `- **Unrelated changes must go to different devs with independent PRs**. Never put two unrelated bug fixes / features in the same PR.
453
477
  - Even if two changes seem related (e.g., two aspects of the same bug), if they modify different files and have independent logic, they should be split into independent tasks + independent PRs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.479",
3
+ "version": "0.1.481",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -141,14 +141,16 @@ export class Engine {
141
141
 
142
142
  /**
143
143
  * Get the list of registered tool definitions (for passing to the adapter).
144
- * Prefers ToolRegistry (mode-aware) when available, falls back to legacy #tools Map.
144
+ * Prefers ToolRegistry when available, falls back to legacy #tools Map.
145
+ *
146
+ * task-297: mode-based filtering was removed — all registered tools are
147
+ * always exposed to the LLM.
145
148
  *
146
- * @param {string} [mode]
147
149
  * @returns {import('./llm/adapter.js').UnifiedToolDef[]}
148
150
  */
149
- #getToolDefs(mode) {
151
+ #getToolDefs() {
150
152
  if (this.#toolRegistry) {
151
- return this.#toolRegistry.getToolDefs(mode || 'chat');
153
+ return this.#toolRegistry.getToolDefs();
152
154
  }
153
155
  // Legacy path: no mode filtering
154
156
  const defs = [];
@@ -181,7 +183,7 @@ export class Engine {
181
183
 
182
184
  // Get tool names from the appropriate source
183
185
  const toolNames = this.#toolRegistry
184
- ? this.#toolRegistry.getToolNames(mode || 'chat')
186
+ ? this.#toolRegistry.getToolNames()
185
187
  : Array.from(this.#tools.keys());
186
188
 
187
189
  return buildSystemPrompt({
@@ -273,8 +275,10 @@ export class Engine {
273
275
  // task-299 Phase 1: tag persisted messages with the current thread.
274
276
  // getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
275
277
  let threadId = MAIN_THREAD_ID;
278
+ let threadStore = null;
276
279
  try {
277
- threadId = getThreadStore().currentId || MAIN_THREAD_ID;
280
+ threadStore = getThreadStore();
281
+ threadId = threadStore.currentId || MAIN_THREAD_ID;
278
282
  } catch {
279
283
  // Defensive: any store failure falls back to 'main' so persistence
280
284
  // never breaks because of thread bookkeeping.
@@ -300,6 +304,18 @@ export class Engine {
300
304
  assistantMsg.toolCalls = toolCalls;
301
305
  }
302
306
  this.#conversationStore.append(assistantMsg);
307
+
308
+ // task-299 Phase 1 cached-field update: bump thread counters twice
309
+ // (once for user, once for assistant). Any exception is swallowed so
310
+ // bookkeeping never blocks the main persist path.
311
+ try {
312
+ if (threadStore) {
313
+ threadStore.noteMessage(threadId);
314
+ threadStore.noteMessage(threadId);
315
+ }
316
+ } catch {
317
+ // Non-critical; counters can be rebuilt via rebuildFromMessages().
318
+ }
303
319
  }
304
320
 
305
321
  /**
@@ -336,10 +352,16 @@ export class Engine {
336
352
  * Yields EngineEvent objects that the caller (CLI, web) can consume
337
353
  * to render output in real-time.
338
354
  *
339
- * @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
355
+ * @param {object} params
356
+ * @param {string} params.prompt - The user prompt (required, non-empty).
357
+ * @param {'dream'} [params.mode] - Optional mode flag. Since task-297 the only
358
+ * value accepted / acted on is `'dream'` (memory maintenance system prompt).
359
+ * Any other value is ignored and falls through to the unified system prompt.
360
+ * @param {Array} [params.messages] - Prior conversation messages.
361
+ * @param {AbortSignal} [params.signal] - Abort signal.
340
362
  * @yields {EngineEvent}
341
363
  */
342
- async *query({ prompt, mode = 'chat', messages = [], signal }) {
364
+ async *query({ prompt, mode, messages = [], signal }) {
343
365
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
344
366
  yield {
345
367
  type: 'error',
@@ -380,7 +402,7 @@ export class Engine {
380
402
  { role: 'user', content: prompt },
381
403
  ];
382
404
 
383
- const toolDefs = this.#getToolDefs(mode);
405
+ const toolDefs = this.#getToolDefs();
384
406
  let turnNumber = 0;
385
407
  let continueTurns = 0; // auto-continue counter
386
408
  let fullResponseText = '';
package/unify/prompts.js CHANGED
@@ -95,8 +95,7 @@ function extractLangSection(content, language) {
95
95
  /** Loaded templates — read once at module load time. */
96
96
  const RAW_TEMPLATES = {
97
97
  base: readTemplate('base.md'),
98
- modeChat: readTemplate('mode-chat.md'),
99
- modeWorker: readTemplate('mode-worker.md'),
98
+ modeUnified: readTemplate('mode-unified.md'),
100
99
  modeDream: readTemplate('mode-dream.md'),
101
100
  toolGuidance: readTemplate('tool-guidance.md'),
102
101
  };
@@ -118,9 +117,7 @@ function getTemplate(key, language) {
118
117
  const PROMPTS = {
119
118
  en: {
120
119
  identity: 'You are Yeaft, a helpful AI assistant.',
121
- mode: (mode) => `Current mode: ${mode}`,
122
120
  date: (d) => `Date: ${d}`,
123
- work: 'You are in work mode. Break tasks into steps, execute them using tools, and report progress.',
124
121
  dream: 'You are in dream mode. Reflect on past conversations and consolidate memories.',
125
122
  tools: (names) => `Available tools: ${names}`,
126
123
  memoryHeader: '## User Memory',
@@ -130,9 +127,7 @@ const PROMPTS = {
130
127
  },
131
128
  zh: {
132
129
  identity: '你是 Yeaft,一个有用的 AI 助手。',
133
- mode: (mode) => `当前模式:${mode}`,
134
130
  date: (d) => `日期:${d}`,
135
- work: '你处于工作模式。将任务分解为步骤,使用工具执行,并报告进度。',
136
131
  dream: '你处于梦境模式。回顾过去的对话,整理和巩固记忆。',
137
132
  tools: (names) => `可用工具:${names}`,
138
133
  memoryHeader: '## 用户记忆',
@@ -146,12 +141,17 @@ const PROMPTS = {
146
141
  export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
147
142
 
148
143
  /**
149
- * Build the system prompt for a given language and mode.
144
+ * Build the system prompt for a given language.
145
+ *
146
+ * task-297: chat/work mode distinction was removed. The prompt now always uses
147
+ * the unified mode template. The `mode` param is retained for backward compat
148
+ * — only `mode === 'dream'` triggers the dream-mode template (used by background
149
+ * memory maintenance); all other values fall through to unified mode.
150
150
  *
151
151
  * Prompt structure:
152
152
  * 1. Core identity (from template or fallback)
153
- * 2. Mode + date metadata
154
- * 3. Mode-specific behavioral instructions (from template or fallback)
153
+ * 2. Date metadata
154
+ * 3. Mode-specific behavioral instructions (unified, or dream)
155
155
  * 4. Tool list + tool guidance (from template)
156
156
  * 5. Skills section
157
157
  * 6. Memory section
@@ -170,7 +170,7 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
170
170
  */
171
171
  export function buildSystemPrompt({
172
172
  language = 'en',
173
- mode = 'chat',
173
+ mode,
174
174
  toolNames = [],
175
175
  memory,
176
176
  memoryInjection,
@@ -192,23 +192,20 @@ export function buildSystemPrompt({
192
192
  parts.push(lang.identity);
193
193
  }
194
194
 
195
- // ─── 2. Mode + Date Metadata ───────────────────────────
196
- parts.push(lang.mode(mode));
195
+ // ─── 2. Date Metadata ──────────────────────────────────
197
196
  parts.push(lang.date(new Date().toISOString().split('T')[0]));
198
197
 
199
198
  // ─── 3. Mode-Specific Instructions ─────────────────────
200
- if (mode === 'work') {
201
- const workerTemplate = getTemplate('modeWorker', effectiveLang);
202
- parts.push(workerTemplate || lang.work);
203
- } else if (mode === 'dream') {
199
+ // task-297: single unified mode for all normal operation.
200
+ // `dream` is retained for background memory maintenance.
201
+ if (mode === 'dream') {
204
202
  const dreamTemplate = getTemplate('modeDream', effectiveLang);
205
203
  parts.push(dreamTemplate || lang.dream);
206
- } else if (mode === 'chat') {
207
- const chatTemplate = getTemplate('modeChat', effectiveLang);
208
- if (chatTemplate) {
209
- parts.push(chatTemplate);
204
+ } else {
205
+ const unifiedTemplate = getTemplate('modeUnified', effectiveLang);
206
+ if (unifiedTemplate) {
207
+ parts.push(unifiedTemplate);
210
208
  }
211
- // No fallback needed — chat mode previously had no instructions
212
209
  }
213
210
 
214
211
  // ─── 4. Tools + Tool Guidance ──────────────────────────
@@ -31,7 +31,12 @@ function serializeTask(task) {
31
31
  `priority: ${task.priority || 'medium'}`,
32
32
  ];
33
33
 
34
+ // task-299 (Q3 rework): parentTaskId is the canonical field per design §5.
35
+ // parentId is kept as a legacy mirror for backward compat with any tool
36
+ // that still reads it; both are always in sync after migration.
37
+ if (task.parentTaskId) fm.push(`parentTaskId: ${task.parentTaskId}`);
34
38
  if (task.parentId) fm.push(`parentId: ${task.parentId}`);
39
+ if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
35
40
  if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
36
41
  if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
37
42
 
@@ -92,9 +97,19 @@ function parseTask(raw) {
92
97
  task.description = body;
93
98
  }
94
99
 
95
- // Normalize parentId
96
- if (!task.parentId || task.parentId === 'null') {
97
- task.parentId = null;
100
+ // Normalize parentId / parentTaskId (design §5 canonical is parentTaskId).
101
+ // If only legacy parentId is present, promote it to parentTaskId so
102
+ // anything that reads the canonical field sees a value. Null/"null"
103
+ // strings become real null.
104
+ if (!task.parentId || task.parentId === 'null') task.parentId = null;
105
+ if (!task.parentTaskId || task.parentTaskId === 'null') task.parentTaskId = null;
106
+ if (!task.parentTaskId && task.parentId) task.parentTaskId = task.parentId;
107
+ if (!task.parentId && task.parentTaskId) task.parentId = task.parentTaskId;
108
+
109
+ // primaryThreadId: per design §5 clarification (task-299 Q4), null means
110
+ // "unbound / orphan" — it does NOT implicitly equal 'main'. Keep null as null.
111
+ if (!task.primaryThreadId || task.primaryThreadId === 'null') {
112
+ task.primaryThreadId = null;
98
113
  }
99
114
 
100
115
  return task;
@@ -191,6 +206,12 @@ export class TaskStore {
191
206
 
192
207
  // Load existing tasks from disk
193
208
  this.#loadAll();
209
+
210
+ // task-299 (Q3, rework): run a one-shot backfill that promotes legacy
211
+ // `parentId` to the canonical `parentTaskId` field (design §5). A
212
+ // marker file (.migrations/parentTaskId) records completion so the
213
+ // migration is skipped on subsequent boots and is idempotent.
214
+ this.#migrateParentTaskId();
194
215
  }
195
216
 
196
217
  /** Number of tasks in the store. */
@@ -275,6 +296,30 @@ export class TaskStore {
275
296
  return results;
276
297
  }
277
298
 
299
+ /**
300
+ * Return tasks grouped as a tree. Tasks with no parent (parentTaskId == null)
301
+ * are "roots"; each non-root task becomes a child of its parent.
302
+ *
303
+ * Used by task-298/task-300 to render task hierarchies. Critical for the
304
+ * Q3 migration test: before the backfill, old tasks stored only `parentId`
305
+ * and `tree()` would see every task as a root.
306
+ *
307
+ * @returns {{ roots: object[], orphans: object[] }}
308
+ * - roots : tasks whose parentTaskId is null
309
+ * - orphans : tasks whose parentTaskId points to an id that no longer exists
310
+ */
311
+ tree() {
312
+ const all = [...this.#tasks.values()];
313
+ const byId = new Map(all.map(t => [t.id, t]));
314
+ const roots = [];
315
+ const orphans = [];
316
+ for (const t of all) {
317
+ if (!t.parentTaskId) roots.push(t);
318
+ else if (!byId.has(t.parentTaskId)) orphans.push(t);
319
+ }
320
+ return { roots, orphans };
321
+ }
322
+
278
323
  /**
279
324
  * Get progress log for a task.
280
325
  * @param {string} id
@@ -391,6 +436,75 @@ export class TaskStore {
391
436
  writeFileSync(this.#indexPath, generateIndex(this.#tasks), 'utf8');
392
437
  } catch { /* */ }
393
438
  }
439
+
440
+ /**
441
+ * task-299 Q3 migration — one-shot backfill from legacy `parentId` to
442
+ * canonical `parentTaskId` (design §5).
443
+ *
444
+ * Behaviour:
445
+ * - Reads .migrations/parentTaskId meta marker. If present, returns
446
+ * immediately (skips). → guarantees idempotency on repeated boots.
447
+ * - Otherwise: for every loaded task, if parentId is set but
448
+ * parentTaskId is not, copy parentId → parentTaskId and rewrite
449
+ * task.md so the new field survives future loads.
450
+ * - Writes the meta marker on success. Read-only mode is a no-op.
451
+ *
452
+ * Exposed publicly as `migrateParentTaskId()` so tests can re-run it.
453
+ *
454
+ * @returns {{ ran: boolean, migratedCount: number }}
455
+ */
456
+ migrateParentTaskId() {
457
+ return this.#migrateParentTaskId();
458
+ }
459
+
460
+ #migrateParentTaskId() {
461
+ if (this.#readOnly) return { ran: false, migratedCount: 0 };
462
+
463
+ const markerDir = join(this.#dir, '.migrations');
464
+ const markerPath = join(markerDir, 'parentTaskId');
465
+
466
+ try {
467
+ if (existsSync(markerPath)) return { ran: false, migratedCount: 0 };
468
+ } catch {
469
+ // If existsSync throws (pathological FS), proceed cautiously — the
470
+ // migration itself is idempotent on per-task level.
471
+ }
472
+
473
+ let migratedCount = 0;
474
+ for (const task of this.#tasks.values()) {
475
+ // parseTask() already promotes parentId → parentTaskId in memory,
476
+ // but the on-disk YAML still lacks the canonical field for old
477
+ // tasks. Rewriting guarantees future loads see parentTaskId and
478
+ // makes the migration visible.
479
+ if (task.parentId && !task.__parentTaskIdWritten) {
480
+ // Ensure the canonical field is set (parseTask normalised this,
481
+ // but handle the edge case where parseTask wasn't used).
482
+ if (!task.parentTaskId) task.parentTaskId = task.parentId;
483
+ const taskDir = join(this.#dir, task.id);
484
+ try {
485
+ writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
486
+ task.__parentTaskIdWritten = true;
487
+ migratedCount += 1;
488
+ } catch {
489
+ // Best-effort; marker is only written if the pass completes.
490
+ }
491
+ }
492
+ }
493
+
494
+ try {
495
+ mkdirSync(markerDir, { recursive: true });
496
+ writeFileSync(
497
+ markerPath,
498
+ `migrated: ${new Date().toISOString()}\ncount: ${migratedCount}\n`,
499
+ 'utf8',
500
+ );
501
+ } catch {
502
+ // Without the marker the migration may re-run; since it's idempotent
503
+ // that is acceptable but not ideal. Log silently.
504
+ }
505
+
506
+ return { ran: true, migratedCount };
507
+ }
394
508
  }
395
509
 
396
510
  // Exported for testing
@@ -6,8 +6,14 @@
6
6
  * filesystem layer is merged. When task-298 merges, this module will be
7
7
  * replaced (or promoted to a shim) by a file-backed store with the same API.
8
8
  *
9
+ * Cached fields (task-299 rework, prev-2 suggestion):
10
+ * - messageCount, lastMessageAt, archived are maintained incrementally via
11
+ * noteMessage()/archive()/setStatus() so that ListThreads does NOT need
12
+ * to scan every message on each call. A rebuildFromMessages(messages)
13
+ * helper exists for sanity / crash-recovery reconciliation.
14
+ *
9
15
  * Responsibilities (Phase 1):
10
- * - Maintain a map of threadId → thread metadata.
16
+ * - Maintain a map of threadId → thread metadata + cached counters.
11
17
  * - Track a "currentThreadId" marker for the engine.
12
18
  * - Maintain attachments from threadId → taskId.
13
19
  *
@@ -20,12 +26,19 @@ import { randomUUID } from 'crypto';
20
26
  /** Default / root thread id — every fresh ThreadStore has one. */
21
27
  export const MAIN_THREAD_ID = 'main';
22
28
 
29
+ /** Valid thread status values. Mirrors design doc §5. */
30
+ export const THREAD_STATUSES = ['active', 'idle', 'archived'];
31
+
23
32
  /**
24
33
  * @typedef {Object} Thread
25
34
  * @property {string} id
26
35
  * @property {string} name
27
36
  * @property {string} [goal]
28
37
  * @property {string|null} parentThreadId
38
+ * @property {'active'|'idle'|'archived'} status — cached; initial 'active'
39
+ * @property {number} messageCount — cached counter, incremented via noteMessage
40
+ * @property {number|null} lastMessageAt — cached timestamp of last noted message
41
+ * @property {boolean} archived — convenience mirror of (status === 'archived')
29
42
  * @property {number} createdAt
30
43
  * @property {number} updatedAt
31
44
  */
@@ -45,16 +58,29 @@ export class ThreadStore {
45
58
  this.#attachments = new Map();
46
59
 
47
60
  const now = Date.now();
48
- const main = {
61
+ this.#threads.set(MAIN_THREAD_ID, this.#newThreadRecord({
49
62
  id: MAIN_THREAD_ID,
50
63
  name: 'main',
51
64
  goal: '',
52
65
  parentThreadId: null,
53
66
  createdAt: now,
54
67
  updatedAt: now,
68
+ }));
69
+ this.#currentId = MAIN_THREAD_ID;
70
+ }
71
+
72
+ /** Internal: build a thread record with default cached fields. */
73
+ #newThreadRecord(base) {
74
+ return {
75
+ status: 'active',
76
+ messageCount: 0,
77
+ lastMessageAt: null,
78
+ lastActivityAt: null, // task-300 sidebar: latest of lastMessageAt/updatedAt
79
+ archived: false,
80
+ unread: 0, // task-300 sidebar: messages since last read marker
81
+ preview: '', // task-300 sidebar: short excerpt of latest content
82
+ ...base,
55
83
  };
56
- this.#threads.set(main.id, main);
57
- this.#currentId = main.id;
58
84
  }
59
85
 
60
86
  /** Get current thread id (defaults to 'main'). */
@@ -81,14 +107,14 @@ export class ThreadStore {
81
107
  }
82
108
  const id = `thr-${randomUUID().slice(0, 8)}`;
83
109
  const now = Date.now();
84
- const thread = {
110
+ const thread = this.#newThreadRecord({
85
111
  id,
86
112
  name: name.trim(),
87
113
  goal: goal || '',
88
114
  parentThreadId: parentThreadId || null,
89
115
  createdAt: now,
90
116
  updatedAt: now,
91
- };
117
+ });
92
118
  this.#threads.set(id, thread);
93
119
  return thread;
94
120
  }
@@ -121,6 +147,112 @@ export class ThreadStore {
121
147
  t.updatedAt = Date.now();
122
148
  }
123
149
 
150
+ /**
151
+ * Record that a message has been persisted on a thread. Increments the
152
+ * cached messageCount and updates lastMessageAt. Safe to call repeatedly;
153
+ * unknown threadIds are silently ignored (defense: bookkeeping must never
154
+ * block the main persist path).
155
+ *
156
+ * @param {string} threadId
157
+ * @param {number} [at=Date.now()]
158
+ */
159
+ noteMessage(threadId, at = Date.now(), opts = {}) {
160
+ const t = this.#threads.get(threadId);
161
+ if (!t) return;
162
+ t.messageCount += 1;
163
+ t.lastMessageAt = at;
164
+ t.lastActivityAt = at;
165
+ t.updatedAt = at;
166
+ // task-300 sidebar unread counter: any new message not originating from the
167
+ // user themselves counts as unread until markRead() is called. Callers may
168
+ // pass { countsAsUnread: false } (e.g. for user's own messages).
169
+ if (opts.countsAsUnread !== false) {
170
+ t.unread += 1;
171
+ }
172
+ // Short preview for sidebar hover / list (capped at 160 chars).
173
+ if (typeof opts.preview === 'string' && opts.preview.length > 0) {
174
+ const p = opts.preview.replace(/\s+/g, ' ').trim();
175
+ t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
176
+ }
177
+ // Any activity bumps archived back to active.
178
+ if (t.status === 'archived') {
179
+ t.status = 'active';
180
+ t.archived = false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Mark a thread as read — resets unread counter to 0. Safe on unknown id.
186
+ * @param {string} threadId
187
+ */
188
+ markRead(threadId) {
189
+ const t = this.#threads.get(threadId);
190
+ if (!t) return;
191
+ t.unread = 0;
192
+ }
193
+
194
+ /**
195
+ * Mark a thread archived. 'main' cannot be archived.
196
+ * @param {string} id
197
+ */
198
+ archive(id) {
199
+ const t = this.#threads.get(id);
200
+ if (!t) throw new Error(`thread not found: ${id}`);
201
+ if (id === MAIN_THREAD_ID) throw new Error('cannot archive main thread');
202
+ t.status = 'archived';
203
+ t.archived = true;
204
+ t.updatedAt = Date.now();
205
+ }
206
+
207
+ /**
208
+ * Set thread status explicitly. Must be one of THREAD_STATUSES.
209
+ * @param {string} id
210
+ * @param {'active'|'idle'|'archived'} status
211
+ */
212
+ setStatus(id, status) {
213
+ if (!THREAD_STATUSES.includes(status)) {
214
+ throw new Error(`invalid status: ${status}`);
215
+ }
216
+ const t = this.#threads.get(id);
217
+ if (!t) throw new Error(`thread not found: ${id}`);
218
+ if (id === MAIN_THREAD_ID && status === 'archived') {
219
+ throw new Error('cannot archive main thread');
220
+ }
221
+ t.status = status;
222
+ t.archived = status === 'archived';
223
+ t.updatedAt = Date.now();
224
+ }
225
+
226
+ /**
227
+ * Rebuild cached fields (messageCount/lastMessageAt) from a flat messages
228
+ * list. Used for crash recovery or as a sanity check in tests. Each
229
+ * message must have { threadId, createdAt? }; missing threadId is treated
230
+ * as MAIN_THREAD_ID (matches design doc §5 default).
231
+ *
232
+ * Counts per thread are reset to zero first to guarantee idempotency.
233
+ *
234
+ * @param {Array<{threadId?: string, createdAt?: number}>} messages
235
+ */
236
+ rebuildFromMessages(messages) {
237
+ // Reset counters
238
+ for (const t of this.#threads.values()) {
239
+ t.messageCount = 0;
240
+ t.lastMessageAt = null;
241
+ t.lastActivityAt = null;
242
+ }
243
+ for (const m of messages || []) {
244
+ const tid = m.threadId || MAIN_THREAD_ID;
245
+ const t = this.#threads.get(tid);
246
+ if (!t) continue;
247
+ t.messageCount += 1;
248
+ const ts = typeof m.createdAt === 'number' ? m.createdAt : Date.now();
249
+ if (!t.lastMessageAt || ts > t.lastMessageAt) {
250
+ t.lastMessageAt = ts;
251
+ t.lastActivityAt = ts;
252
+ }
253
+ }
254
+ }
255
+
124
256
  /**
125
257
  * Attach a task to a thread. Overwrites any existing attachment.
126
258
  * @param {string} threadId
@@ -61,8 +61,10 @@ import {
61
61
  switchThread,
62
62
  listThreads,
63
63
  attachThreadToTask,
64
+ spawnTask,
65
+ readThreadSummary,
66
+ readThreadRecent,
64
67
  } from './thread-tools.js';
65
- import { spawnTask, spawnSubtask } from './spawn-task-tools.js';
66
68
 
67
69
  // --- P2 Auxiliary tools ---
68
70
  import { jsRepl, jsReplReset } from './js-repl.js';
@@ -128,7 +130,8 @@ export const allTools = [
128
130
  listThreads,
129
131
  attachThreadToTask,
130
132
  spawnTask,
131
- spawnSubtask,
133
+ readThreadSummary,
134
+ readThreadRecent,
132
135
 
133
136
  // P2 Auxiliary
134
137
  jsRepl,
@@ -1,20 +1,14 @@
1
1
  /**
2
2
  * registry.js — Tool registration center for Yeaft Unify
3
3
  *
4
- * Manages tool registration, mode-based filtering, and execution dispatch.
4
+ * Manages tool registration and execution dispatch.
5
5
  * The engine uses this to get tool definitions for the LLM and to execute tool calls.
6
+ *
7
+ * NOTE (task-297): Mode-based filtering (chat/work) has been removed. Unify now runs as
8
+ * a single unified mode where all registered tools are available. Tool definitions may
9
+ * still carry a `modes` field, but the registry ignores it.
6
10
  */
7
11
 
8
- /**
9
- * Mode normalization map.
10
- * 'coordinator' and 'worker' inherit 'work' tools.
11
- * 'dream' has no tools by design (returns empty).
12
- */
13
- const MODE_ALIASES = {
14
- coordinator: 'work',
15
- worker: 'work',
16
- };
17
-
18
12
  export class ToolRegistry {
19
13
  /** @type {Map<string, import('./types.js').ToolDef>} */
20
14
  #tools = new Map();
@@ -69,35 +63,20 @@ export class ToolRegistry {
69
63
  }
70
64
 
71
65
  /**
72
- * Resolve a mode to its effective tool mode.
73
- * @param {string} mode
74
- * @returns {string}
75
- */
76
- static resolveMode(mode) {
77
- return MODE_ALIASES[mode] || mode;
78
- }
79
-
80
- /**
81
- * Get all tools available in a given mode.
82
- * @param {string} mode
66
+ * Get all registered tools (unfiltered).
83
67
  * @returns {import('./types.js').ToolDef[]}
84
68
  */
85
- getToolsForMode(mode) {
86
- const effectiveMode = ToolRegistry.resolveMode(mode);
87
- const result = [];
88
- for (const [, tool] of this.#tools) {
89
- if (tool.modes.includes(effectiveMode)) result.push(tool);
90
- }
91
- return result;
69
+ getAllTools() {
70
+ return Array.from(this.#tools.values());
92
71
  }
93
72
 
94
73
  /**
95
74
  * Get tool definitions for the LLM adapter.
96
- * @param {string} mode
75
+ * Returns all registered tools — mode filtering was removed in task-297.
97
76
  * @returns {{ name: string, description: string, parameters: object }[]}
98
77
  */
99
- getToolDefs(mode) {
100
- return this.getToolsForMode(mode).map(t => ({
78
+ getToolDefs() {
79
+ return this.getAllTools().map(t => ({
101
80
  name: t.name,
102
81
  description: t.description,
103
82
  parameters: t.parameters,
@@ -105,12 +84,11 @@ export class ToolRegistry {
105
84
  }
106
85
 
107
86
  /**
108
- * Get tool names available in a given mode.
109
- * @param {string} mode
87
+ * Get all registered tool names.
110
88
  * @returns {string[]}
111
89
  */
112
- getToolNames(mode) {
113
- return this.getToolsForMode(mode).map(t => t.name);
90
+ getToolNames() {
91
+ return Array.from(this.#tools.keys());
114
92
  }
115
93
 
116
94
  /**
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * thread-tools.js — Thread-spawning tools for Unify Engine (Phase 1).
3
3
  *
4
- * Phase 1 scope (task-299):
5
- * - SpawnThread — create a new thread
6
- * - SwitchThread — set the engine's currentThreadId marker
7
- * - ListThreads — list threads + current marker
8
- * - AttachThreadToTask — bind a thread to an existing task
4
+ * Phase 1 scope (task-299 rework):
5
+ * - SpawnThread — create a new thread
6
+ * - SwitchThread — set the engine's currentThreadId marker
7
+ * - ListThreads — list threads + current marker + cached stats
8
+ * - AttachThreadToTask — bind a thread to an existing task
9
+ * - SpawnTask — create a task or subtask (parent_task_id optional)
10
+ * - ReadThreadSummary — cross-reference: summary of a thread (id/name/
11
+ * status/messageCount/lastMessageAt/task)
12
+ * - ReadThreadRecent — cross-reference: last N messages of a thread
9
13
  *
10
14
  * Phase 1 uses the in-memory ThreadStore (agent/unify/threads/store.js)
11
- * so these tools can ship and be tested before task-298's file-backed
12
- * data layer merges. The tool surface is designed to remain stable when
13
- * the store is replaced.
15
+ * and the existing ConversationStore for messages. When task-298 merges,
16
+ * ThreadStore becomes file-backed with the SAME API, so these tools
17
+ * continue to work unchanged.
14
18
  */
15
19
 
16
20
  import { defineTool } from './types.js';
17
- import { getThreadStore } from '../threads/store.js';
21
+ import { randomUUID } from 'crypto';
22
+ import { getThreadStore, MAIN_THREAD_ID } from '../threads/store.js';
18
23
  import { getTaskStore } from './task-tools.js';
19
24
 
20
25
  // ─── SpawnThread ─────────────────────────────────────────
@@ -57,6 +62,7 @@ engine to the new thread — call SwitchThread to activate it.`,
57
62
  name: t.name,
58
63
  goal: t.goal,
59
64
  parentThreadId: t.parentThreadId,
65
+ status: t.status,
60
66
  },
61
67
  message: `Thread created: ${t.name} (${t.id})`,
62
68
  });
@@ -107,7 +113,12 @@ thread.`,
107
113
 
108
114
  export const listThreads = defineTool({
109
115
  name: 'ListThreads',
110
- description: `List all threads and the engine's current thread marker.`,
116
+ description: `List all threads with cached status / messageCount / lastMessageAt.
117
+
118
+ Returns the data needed by the Phase 2 sidebar (task-300): each entry
119
+ exposes id, name, goal, parentThreadId, status ('active'|'idle'|'archived'),
120
+ messageCount, lastMessageAt, archived, attachedTaskId. Reads only cached
121
+ fields — does not scan messages.`,
111
122
  parameters: { type: 'object', properties: {} },
112
123
  modes: ['work'],
113
124
  isConcurrencySafe: () => true,
@@ -119,6 +130,13 @@ export const listThreads = defineTool({
119
130
  name: t.name,
120
131
  goal: t.goal,
121
132
  parentThreadId: t.parentThreadId,
133
+ status: t.status,
134
+ messageCount: t.messageCount,
135
+ lastMessageAt: t.lastMessageAt,
136
+ lastActivityAt: t.lastActivityAt ?? t.lastMessageAt,
137
+ archived: t.archived,
138
+ unread: t.unread || 0,
139
+ preview: t.preview || '',
122
140
  attachedTaskId: store.attachedTask(t.id),
123
141
  }));
124
142
  return JSON.stringify(
@@ -159,9 +177,6 @@ the same thread.`,
159
177
  if (!task_id) return JSON.stringify({ error: 'task_id is required' });
160
178
 
161
179
  const taskStore = getTaskStore();
162
- // If the task store is initialized, validate the task exists; if not
163
- // initialized (e.g. Phase 1 unit tests running before session bootstrap),
164
- // skip the task existence check — we still enforce thread existence.
165
180
  if (taskStore) {
166
181
  const task = taskStore.get(task_id);
167
182
  if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
@@ -181,3 +196,175 @@ the same thread.`,
181
196
  }
182
197
  },
183
198
  });
199
+
200
+ // ─── SpawnTask (merged — subtask via parent_task_id) ────
201
+
202
+ /**
203
+ * SpawnTask replaces both the old SpawnTask and the separate SpawnSubtask:
204
+ * pass `parent_task_id` when you want a subtask, omit it for top-level.
205
+ * Per prev-1 rework note: eliminating two tools with the same effect.
206
+ */
207
+ export const spawnTask = defineTool({
208
+ name: 'SpawnTask',
209
+ description: `Spawn a new task. Pass parent_task_id to create a subtask.
210
+
211
+ When parent_task_id is omitted → top-level task.
212
+ When parent_task_id is provided → subtask under that parent (parent must exist).
213
+ This replaces the deprecated SpawnSubtask tool.`,
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ title: { type: 'string' },
218
+ description: { type: 'string' },
219
+ priority: {
220
+ type: 'string',
221
+ enum: ['low', 'medium', 'high', 'critical'],
222
+ },
223
+ parent_task_id: {
224
+ type: 'string',
225
+ description: 'Optional parent task id; when present, a subtask is created',
226
+ },
227
+ },
228
+ required: ['title'],
229
+ },
230
+ modes: ['work'],
231
+ isConcurrencySafe: () => false,
232
+ isReadOnly: () => false,
233
+ async execute(input) {
234
+ const store = getTaskStore();
235
+ if (!store) {
236
+ return JSON.stringify({ error: 'Task store not initialized. Session may still be loading.' });
237
+ }
238
+ const { title, description = '', priority = 'medium', parent_task_id } = input || {};
239
+ if (!title) return JSON.stringify({ error: 'title is required' });
240
+
241
+ if (parent_task_id) {
242
+ const parent = store.get(parent_task_id);
243
+ if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
244
+ }
245
+
246
+ const id = `task-${randomUUID().slice(0, 8)}`;
247
+ const now = Date.now();
248
+ store.create({
249
+ id,
250
+ title,
251
+ description,
252
+ priority,
253
+ status: 'pending',
254
+ parentId: parent_task_id || null,
255
+ parentTaskId: parent_task_id || null, // design §5 canonical field; kept in sync with parentId
256
+ createdAt: now,
257
+ updatedAt: now,
258
+ });
259
+ return JSON.stringify({
260
+ success: true,
261
+ task: {
262
+ id,
263
+ title,
264
+ priority,
265
+ status: 'pending',
266
+ parentTaskId: parent_task_id || null,
267
+ },
268
+ message: parent_task_id
269
+ ? `Subtask spawned: ${title} (${id}) under ${parent_task_id}`
270
+ : `Task spawned: ${title} (${id})`,
271
+ });
272
+ },
273
+ });
274
+
275
+ // ─── ReadThreadSummary (cross-reference, design §6 Q5) ──
276
+
277
+ export const readThreadSummary = defineTool({
278
+ name: 'ReadThreadSummary',
279
+ description: `Return a one-shot summary of a thread: id, name, goal, status,
280
+ messageCount, lastMessageAt, parentThreadId, attachedTaskId.
281
+
282
+ Use this to cross-reference work on another thread without switching.`,
283
+ parameters: {
284
+ type: 'object',
285
+ properties: {
286
+ thread_id: { type: 'string' },
287
+ },
288
+ required: ['thread_id'],
289
+ },
290
+ modes: ['work'],
291
+ isConcurrencySafe: () => true,
292
+ isReadOnly: () => true,
293
+ async execute(input) {
294
+ const { thread_id } = input || {};
295
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
296
+ const store = getThreadStore();
297
+ const t = store.get(thread_id);
298
+ if (!t) return JSON.stringify({ error: `Thread not found: ${thread_id}` });
299
+ return JSON.stringify(
300
+ {
301
+ id: t.id,
302
+ name: t.name,
303
+ goal: t.goal,
304
+ status: t.status,
305
+ archived: t.archived,
306
+ messageCount: t.messageCount,
307
+ lastMessageAt: t.lastMessageAt,
308
+ parentThreadId: t.parentThreadId,
309
+ attachedTaskId: store.attachedTask(t.id),
310
+ createdAt: t.createdAt,
311
+ updatedAt: t.updatedAt,
312
+ },
313
+ null,
314
+ 2,
315
+ );
316
+ },
317
+ });
318
+
319
+ // ─── ReadThreadRecent (cross-reference, design §6 Q5) ───
320
+
321
+ export const readThreadRecent = defineTool({
322
+ name: 'ReadThreadRecent',
323
+ description: `Return the last N messages on a specific thread.
324
+
325
+ Requires an engine ConversationStore in context (ctx.conversationStore).
326
+ Reads conversation history and filters by threadId. Default N=20, max 200.
327
+ Use this to review another thread's recent activity without switching.`,
328
+ parameters: {
329
+ type: 'object',
330
+ properties: {
331
+ thread_id: { type: 'string' },
332
+ limit: { type: 'number', description: 'Max messages to return (default 20, max 200)' },
333
+ },
334
+ required: ['thread_id'],
335
+ },
336
+ modes: ['work'],
337
+ isConcurrencySafe: () => true,
338
+ isReadOnly: () => true,
339
+ async execute(input, ctx) {
340
+ const { thread_id, limit } = input || {};
341
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
342
+ const store = getThreadStore();
343
+ if (!store.has(thread_id)) {
344
+ return JSON.stringify({ error: `Thread not found: ${thread_id}` });
345
+ }
346
+ const conv = ctx?.conversationStore;
347
+ if (!conv || typeof conv.loadRecent !== 'function') {
348
+ return JSON.stringify({
349
+ error: 'conversation store unavailable in tool context',
350
+ });
351
+ }
352
+ const cap = Math.max(1, Math.min(Number(limit) || 20, 200));
353
+ // Over-fetch then filter by thread, so N still applies post-filter.
354
+ const raw = conv.loadRecent(cap * 4);
355
+ const filtered = raw
356
+ .filter(m => (m.threadId || MAIN_THREAD_ID) === thread_id)
357
+ .slice(-cap)
358
+ .map(m => ({
359
+ role: m.role,
360
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
361
+ createdAt: m.createdAt || null,
362
+ threadId: m.threadId || MAIN_THREAD_ID,
363
+ }));
364
+ return JSON.stringify(
365
+ { threadId: thread_id, count: filtered.length, messages: filtered },
366
+ null,
367
+ 2,
368
+ );
369
+ },
370
+ });
@@ -24,7 +24,9 @@
24
24
  * @property {string} description — LLM-facing description
25
25
  * @property {object} parameters — JSON Schema for input
26
26
  * @property {(input: object, ctx?: ToolContext) => Promise<string>} execute — execution function
27
- * @property {string[]} modes — which modes can use this tool: ['chat', 'work']
27
+ * @property {string[]} [modes]@deprecated since task-297. Legacy mode filter (['chat', 'work']).
28
+ * Unify no longer has mode distinction; the ToolRegistry ignores this field and exposes every
29
+ * registered tool to the engine. Retained only so existing tool definitions keep loading.
28
30
  * @property {(input?: object) => boolean} [isConcurrencySafe] — can run in parallel?
29
31
  * @property {(input?: object) => boolean} [isReadOnly] — read-only operation?
30
32
  * @property {(input?: object) => boolean} [isDestructive] — destructive operation?
@@ -38,7 +40,7 @@
38
40
  * description: string,
39
41
  * parameters: object,
40
42
  * execute: (input: object, ctx?: ToolContext) => Promise<string>,
41
- * modes?: string[],
43
+ * modes?: string[], // @deprecated since task-297 — ignored by ToolRegistry
42
44
  * isConcurrencySafe?: (input?: object) => boolean,
43
45
  * isReadOnly?: (input?: object) => boolean,
44
46
  * isDestructive?: (input?: object) => boolean,
@@ -29,9 +29,6 @@ const QUERY_TIMEOUT_MS = 120_000;
29
29
  /** Virtual conversationId for the Unify session */
30
30
  let unifyConversationId = null;
31
31
 
32
- /** Current query mode: 'chat' or 'work' */
33
- let currentMode = 'chat';
34
-
35
32
  /** Accumulated conversation messages for context continuity across queries.
36
33
  * Each entry is { role: 'user'|'assistant', content: string|Array }.
37
34
  * Cleared on session reset or consolidation. */
@@ -79,14 +76,16 @@ function sendUnifyEvent(event) {
79
76
  * Handle a unify_chat message from the web UI.
80
77
  *
81
78
  * @param {{ prompt: string, mode?: string, userId?: string, username?: string }} msg
79
+ * NOTE: `mode` is deprecated (task-297) — Unify now runs in a single unified mode.
80
+ * If present, a warning is logged and the field is ignored.
82
81
  */
83
82
  export async function handleUnifyChat(msg) {
84
83
  const { prompt, mode } = msg;
85
84
  if (!prompt?.trim()) return;
86
85
 
87
- // Update mode if provided
88
- if (mode === 'chat' || mode === 'work') {
89
- currentMode = mode;
86
+ // Deprecation warning — task-297 removed chat/work mode distinction
87
+ if (mode !== undefined && mode !== null) {
88
+ console.warn('[Unify] unify_chat.mode is deprecated and ignored — Unify now runs in a single unified mode.');
90
89
  }
91
90
 
92
91
  try {
@@ -150,7 +149,6 @@ export async function handleUnifyChat(msg) {
150
149
  // ─── Stream Engine events → claude_output format ──
151
150
  for await (const event of session.engine.query({
152
151
  prompt,
153
- mode: currentMode,
154
152
  messages: conversationMessages,
155
153
  signal: currentAbort.signal,
156
154
  })) {
@@ -410,12 +408,12 @@ export async function handleUnifyChat(msg) {
410
408
 
411
409
  /**
412
410
  * Handle mode switch from the web UI.
413
- * @param {{ mode: 'chat' | 'work' }} msg
411
+ * DEPRECATED (task-297): Unify no longer has chat/work mode distinction.
412
+ * Retained as a no-op with warning for backward compatibility.
413
+ * @param {{ mode?: string }} _msg
414
414
  */
415
- export function handleUnifyModeSwitch(msg) {
416
- if (msg.mode === 'chat' || msg.mode === 'work') {
417
- currentMode = msg.mode;
418
- }
415
+ export function handleUnifyModeSwitch(_msg) {
416
+ console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
419
417
  }
420
418
 
421
419
  /**
@@ -522,7 +520,6 @@ export async function resetUnifySession() {
522
520
  session = null;
523
521
  }
524
522
  unifyConversationId = null;
525
- currentMode = 'chat';
526
523
  conversationMessages = [];
527
524
 
528
525
  // Re-initialize session immediately so frontend gets updated config
@@ -1,125 +0,0 @@
1
- /**
2
- * spawn-task-tools.js — Thin semantic-sugar wrappers over TaskCreate.
3
- *
4
- * Phase 1 of task-299: expose `SpawnTask` and `SpawnSubtask` as first-class
5
- * tool names so the LLM can "spawn" work items in the same vocabulary it
6
- * uses to spawn threads. Both delegate to the existing TaskStore (shared
7
- * with TaskCreate), so there is a single source of truth for task data.
8
- */
9
-
10
- import { defineTool } from './types.js';
11
- import { randomUUID } from 'crypto';
12
- import { getTaskStore } from './task-tools.js';
13
-
14
- function requireStore() {
15
- const store = getTaskStore();
16
- if (!store) return { error: 'Task store not initialized. Session may still be loading.' };
17
- return { store };
18
- }
19
-
20
- function newTaskId() {
21
- return `task-${randomUUID().slice(0, 8)}`;
22
- }
23
-
24
- // ─── SpawnTask ──────────────────────────────────────────
25
-
26
- export const spawnTask = defineTool({
27
- name: 'SpawnTask',
28
- description: `Spawn a new top-level task.
29
-
30
- Equivalent to TaskCreate without a parent. Provided as a first-class
31
- name so tasks and threads can be "spawned" with a uniform vocabulary.`,
32
- parameters: {
33
- type: 'object',
34
- properties: {
35
- title: { type: 'string' },
36
- description: { type: 'string' },
37
- priority: {
38
- type: 'string',
39
- enum: ['low', 'medium', 'high', 'critical'],
40
- },
41
- },
42
- required: ['title'],
43
- },
44
- modes: ['work'],
45
- isConcurrencySafe: () => false,
46
- isReadOnly: () => false,
47
- async execute(input) {
48
- const got = requireStore();
49
- if (got.error) return JSON.stringify({ error: got.error });
50
- const { title, description = '', priority = 'medium' } = input || {};
51
- if (!title) return JSON.stringify({ error: 'title is required' });
52
-
53
- const id = newTaskId();
54
- const now = Date.now();
55
- got.store.create({
56
- id,
57
- title,
58
- description,
59
- priority,
60
- status: 'pending',
61
- parentId: null,
62
- createdAt: now,
63
- updatedAt: now,
64
- });
65
- return JSON.stringify({
66
- success: true,
67
- task: { id, title, priority, status: 'pending' },
68
- message: `Task spawned: ${title} (${id})`,
69
- });
70
- },
71
- });
72
-
73
- // ─── SpawnSubtask ───────────────────────────────────────
74
-
75
- export const spawnSubtask = defineTool({
76
- name: 'SpawnSubtask',
77
- description: `Spawn a subtask under an existing parent task.
78
-
79
- Requires parent_task_id. The parent must exist. Use for breaking a
80
- larger task into executable pieces.`,
81
- parameters: {
82
- type: 'object',
83
- properties: {
84
- parent_task_id: { type: 'string' },
85
- title: { type: 'string' },
86
- description: { type: 'string' },
87
- priority: {
88
- type: 'string',
89
- enum: ['low', 'medium', 'high', 'critical'],
90
- },
91
- },
92
- required: ['parent_task_id', 'title'],
93
- },
94
- modes: ['work'],
95
- isConcurrencySafe: () => false,
96
- isReadOnly: () => false,
97
- async execute(input) {
98
- const got = requireStore();
99
- if (got.error) return JSON.stringify({ error: got.error });
100
- const { parent_task_id, title, description = '', priority = 'medium' } = input || {};
101
- if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
102
- if (!title) return JSON.stringify({ error: 'title is required' });
103
-
104
- const parent = got.store.get(parent_task_id);
105
- if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
106
-
107
- const id = newTaskId();
108
- const now = Date.now();
109
- got.store.create({
110
- id,
111
- title,
112
- description: description || `Subtask of: ${parent.title}`,
113
- priority,
114
- status: 'pending',
115
- parentId: parent_task_id,
116
- createdAt: now,
117
- updatedAt: now,
118
- });
119
- return JSON.stringify({
120
- success: true,
121
- task: { id, title, priority, status: 'pending', parentId: parent_task_id },
122
- message: `Subtask spawned: ${title} (${id}) under ${parent_task_id}`,
123
- });
124
- },
125
- });