@yeaft/webchat-agent 0.1.480 → 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 +28 -4
- package/package.json +1 -1
- package/unify/engine.js +22 -2
- package/unify/tasks/store.js +117 -3
- package/unify/threads/store.js +138 -6
- package/unify/tools/index.js +5 -2
- package/unify/tools/thread-tools.js +200 -13
- package/unify/tools/types.js +4 -2
- package/unify/tools/spawn-task-tools.js +0 -125
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
package/unify/engine.js
CHANGED
|
@@ -275,8 +275,10 @@ export class Engine {
|
|
|
275
275
|
// task-299 Phase 1: tag persisted messages with the current thread.
|
|
276
276
|
// getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
|
|
277
277
|
let threadId = MAIN_THREAD_ID;
|
|
278
|
+
let threadStore = null;
|
|
278
279
|
try {
|
|
279
|
-
|
|
280
|
+
threadStore = getThreadStore();
|
|
281
|
+
threadId = threadStore.currentId || MAIN_THREAD_ID;
|
|
280
282
|
} catch {
|
|
281
283
|
// Defensive: any store failure falls back to 'main' so persistence
|
|
282
284
|
// never breaks because of thread bookkeeping.
|
|
@@ -302,6 +304,18 @@ export class Engine {
|
|
|
302
304
|
assistantMsg.toolCalls = toolCalls;
|
|
303
305
|
}
|
|
304
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
|
+
}
|
|
305
319
|
}
|
|
306
320
|
|
|
307
321
|
/**
|
|
@@ -338,7 +352,13 @@ export class Engine {
|
|
|
338
352
|
* Yields EngineEvent objects that the caller (CLI, web) can consume
|
|
339
353
|
* to render output in real-time.
|
|
340
354
|
*
|
|
341
|
-
* @param {
|
|
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.
|
|
342
362
|
* @yields {EngineEvent}
|
|
343
363
|
*/
|
|
344
364
|
async *query({ prompt, mode, messages = [], signal }) {
|
package/unify/tasks/store.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
package/unify/threads/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/unify/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
133
|
+
readThreadSummary,
|
|
134
|
+
readThreadRecent,
|
|
132
135
|
|
|
133
136
|
// P2 Auxiliary
|
|
134
137
|
jsRepl,
|
|
@@ -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
|
|
6
|
-
* - SwitchThread
|
|
7
|
-
* - ListThreads
|
|
8
|
-
* - AttachThreadToTask
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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
|
|
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
|
+
});
|
package/unify/tools/types.js
CHANGED
|
@@ -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 —
|
|
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,
|
|
@@ -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
|
-
});
|