@yeaft/webchat-agent 0.1.616 → 0.1.618
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/connection/message-router.js +5 -5
- package/package.json +1 -1
- package/unify/compact/orchestrator.js +1 -1
- package/unify/{task-message.js → feature-message.js} +38 -38
- package/unify/features/store.js +583 -0
- package/unify/{tasks → features}/summary.js +54 -85
- package/unify/memory/dream-shard.js +15 -15
- package/unify/memory/recall-r6.js +2 -2
- package/unify/memory/schema.js +7 -7
- package/unify/memory/scope-tree.js +6 -6
- package/unify/pipeline/dispatcher.js +7 -7
- package/unify/router/intent-classifier.js +25 -25
- package/unify/session.js +3 -3
- package/unify/threads/store.js +18 -18
- package/unify/tools/{task-tools.js → feature-tools.js} +180 -175
- package/unify/tools/index.js +21 -21
- package/unify/tools/thread-tools.js +29 -108
- package/unify/web-bridge.js +61 -61
- package/unify/memory/migrate-r5-to-r6.js +0 -611
- package/unify/migration/detect.js +0 -176
- package/unify/migration/map-fields.js +0 -293
- package/unify/migration/v0-to-v1.js +0 -588
- package/unify/tasks/store.js +0 -652
package/unify/tasks/store.js
DELETED
|
@@ -1,652 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* store.js — File-system backed TaskStore for Yeaft Unify.
|
|
3
|
-
*
|
|
4
|
-
* Persists tasks to ~/.yeaft/tasks/ with one folder per task.
|
|
5
|
-
* Layout:
|
|
6
|
-
* ~/.yeaft/tasks/
|
|
7
|
-
* index.md — Task index (auto-generated overview)
|
|
8
|
-
* plan.md — Global plan text
|
|
9
|
-
* task-abc12345/ — One folder per task
|
|
10
|
-
* task.md — Task metadata (YAML frontmatter + description)
|
|
11
|
-
* progress.md — Progress log (append-only)
|
|
12
|
-
* memory.md — Task-specific context/notes
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
|
|
16
|
-
import { join } from 'path';
|
|
17
|
-
|
|
18
|
-
// ─── YAML Frontmatter helpers ────────────────────────────────
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Serialize a task object to YAML frontmatter + body for task.md.
|
|
22
|
-
* @param {object} task
|
|
23
|
-
* @returns {string}
|
|
24
|
-
*/
|
|
25
|
-
function serializeTask(task) {
|
|
26
|
-
const fm = [
|
|
27
|
-
'---',
|
|
28
|
-
`id: ${task.id}`,
|
|
29
|
-
`title: ${task.title}`,
|
|
30
|
-
`status: ${task.status}`,
|
|
31
|
-
`priority: ${task.priority || 'medium'}`,
|
|
32
|
-
];
|
|
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}`);
|
|
38
|
-
if (task.parentId) fm.push(`parentId: ${task.parentId}`);
|
|
39
|
-
if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
|
|
40
|
-
// task-334n — multi-VP collaboration protocol fields.
|
|
41
|
-
// initiator: VP id that created the task (fallback target for ACL / reminder).
|
|
42
|
-
// members: explicit VP roster for the task (supersedes group roster when set).
|
|
43
|
-
// groupId: the group this task belongs to (null for legacy / standalone).
|
|
44
|
-
if (task.initiator) fm.push(`initiator: ${task.initiator}`);
|
|
45
|
-
if (Array.isArray(task.members) && task.members.length) {
|
|
46
|
-
fm.push(`members: [${task.members.join(', ')}]`);
|
|
47
|
-
}
|
|
48
|
-
if (task.groupId) fm.push(`groupId: ${task.groupId}`);
|
|
49
|
-
if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
|
|
50
|
-
if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
|
|
51
|
-
|
|
52
|
-
fm.push('---');
|
|
53
|
-
fm.push('');
|
|
54
|
-
|
|
55
|
-
// Body: description + result
|
|
56
|
-
const parts = [];
|
|
57
|
-
if (task.description) parts.push(task.description);
|
|
58
|
-
if (task.result) {
|
|
59
|
-
parts.push('');
|
|
60
|
-
parts.push('## Result');
|
|
61
|
-
parts.push(task.result);
|
|
62
|
-
}
|
|
63
|
-
fm.push(parts.join('\n'));
|
|
64
|
-
|
|
65
|
-
return fm.join('\n');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Parse a task.md file (YAML frontmatter + body) into a task object.
|
|
70
|
-
* @param {string} raw — File contents
|
|
71
|
-
* @returns {object|null}
|
|
72
|
-
*/
|
|
73
|
-
function parseTask(raw) {
|
|
74
|
-
if (!raw || !raw.startsWith('---')) return null;
|
|
75
|
-
|
|
76
|
-
const endIdx = raw.indexOf('---', 3);
|
|
77
|
-
if (endIdx === -1) return null;
|
|
78
|
-
|
|
79
|
-
const frontmatter = raw.slice(3, endIdx).trim();
|
|
80
|
-
const body = raw.slice(endIdx + 3).trim();
|
|
81
|
-
|
|
82
|
-
const task = {};
|
|
83
|
-
|
|
84
|
-
for (const line of frontmatter.split('\n')) {
|
|
85
|
-
const colonIdx = line.indexOf(':');
|
|
86
|
-
if (colonIdx === -1) continue;
|
|
87
|
-
const key = line.slice(0, colonIdx).trim();
|
|
88
|
-
const val = line.slice(colonIdx + 1).trim();
|
|
89
|
-
if (!key) continue;
|
|
90
|
-
|
|
91
|
-
if (key === 'createdAt' || key === 'updatedAt') {
|
|
92
|
-
task[key] = parseInt(val, 10) || 0;
|
|
93
|
-
} else if (key === 'members') {
|
|
94
|
-
// task-334n — members: [vp-a, vp-b, ...]
|
|
95
|
-
task.members = val
|
|
96
|
-
.replace(/^\[|\]$/g, '')
|
|
97
|
-
.split(',')
|
|
98
|
-
.map((s) => s.trim())
|
|
99
|
-
.filter(Boolean);
|
|
100
|
-
} else {
|
|
101
|
-
task[key] = val;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!task.id) return null;
|
|
106
|
-
|
|
107
|
-
// Parse body: description and result
|
|
108
|
-
const resultIdx = body.indexOf('## Result');
|
|
109
|
-
if (resultIdx !== -1) {
|
|
110
|
-
task.description = body.slice(0, resultIdx).trim();
|
|
111
|
-
task.result = body.slice(resultIdx + '## Result'.length).trim();
|
|
112
|
-
} else {
|
|
113
|
-
task.description = body;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Normalize parentId / parentTaskId (design §5 canonical is parentTaskId).
|
|
117
|
-
// If only legacy parentId is present, promote it to parentTaskId so
|
|
118
|
-
// anything that reads the canonical field sees a value. Null/"null"
|
|
119
|
-
// strings become real null.
|
|
120
|
-
if (!task.parentId || task.parentId === 'null') task.parentId = null;
|
|
121
|
-
if (!task.parentTaskId || task.parentTaskId === 'null') task.parentTaskId = null;
|
|
122
|
-
if (!task.parentTaskId && task.parentId) task.parentTaskId = task.parentId;
|
|
123
|
-
if (!task.parentId && task.parentTaskId) task.parentId = task.parentTaskId;
|
|
124
|
-
|
|
125
|
-
// primaryThreadId: per design §5 clarification (task-299 Q4), null means
|
|
126
|
-
// "unbound / orphan" — it does NOT implicitly equal 'main'. Keep null as null.
|
|
127
|
-
if (!task.primaryThreadId || task.primaryThreadId === 'null') {
|
|
128
|
-
task.primaryThreadId = null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return task;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── Index generation ────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Generate index.md content from all tasks.
|
|
138
|
-
* @param {Map<string, object>} tasks
|
|
139
|
-
* @returns {string}
|
|
140
|
-
*/
|
|
141
|
-
function generateIndex(tasks) {
|
|
142
|
-
const now = new Date().toISOString();
|
|
143
|
-
const lines = [
|
|
144
|
-
'---',
|
|
145
|
-
`totalTasks: ${tasks.size}`,
|
|
146
|
-
`lastUpdated: ${now}`,
|
|
147
|
-
'---',
|
|
148
|
-
'# Task Index',
|
|
149
|
-
'',
|
|
150
|
-
'| ID | Title | Status | Priority | Updated |',
|
|
151
|
-
'|----|-------|--------|----------|---------|',
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
// Sort: in_progress first, then pending, then others
|
|
155
|
-
const ORDER = { in_progress: 0, pending: 1, blocked: 2, completed: 3, cancelled: 4 };
|
|
156
|
-
const sorted = [...tasks.values()].sort(
|
|
157
|
-
(a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
for (const t of sorted) {
|
|
161
|
-
const date = t.updatedAt ? new Date(t.updatedAt).toISOString().slice(0, 10) : '-';
|
|
162
|
-
lines.push(`| ${t.id} | ${t.title} | ${t.status} | ${t.priority || 'medium'} | ${date} |`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return lines.join('\n') + '\n';
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ─── Progress log helpers ────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Format a progress entry for appending to progress.md.
|
|
172
|
-
* @param {string} note
|
|
173
|
-
* @param {object} [meta]
|
|
174
|
-
* @returns {string}
|
|
175
|
-
*/
|
|
176
|
-
function formatProgressEntry(note, meta = {}) {
|
|
177
|
-
const now = new Date();
|
|
178
|
-
const ts = `${now.toISOString().slice(0, 10)} ${now.toISOString().slice(11, 16)}`;
|
|
179
|
-
const lines = [`## ${ts}`];
|
|
180
|
-
lines.push(`- ${note}`);
|
|
181
|
-
if (meta.status) lines.push(`- Status: ${meta.status}`);
|
|
182
|
-
if (meta.result) lines.push(`- Result: ${meta.result}`);
|
|
183
|
-
lines.push('');
|
|
184
|
-
return lines.join('\n');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ─── TaskStore ───────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
export class TaskStore {
|
|
190
|
-
/** @type {string} */
|
|
191
|
-
#dir;
|
|
192
|
-
/** @type {string} */
|
|
193
|
-
#indexPath;
|
|
194
|
-
/** @type {string} */
|
|
195
|
-
#planPath;
|
|
196
|
-
/** @type {Map<string, object>} */
|
|
197
|
-
#tasks;
|
|
198
|
-
/** @type {boolean} */
|
|
199
|
-
#readOnly;
|
|
200
|
-
/** @type {Array<(evt:any)=>void>} */
|
|
201
|
-
#listeners;
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* @param {string} yeaftDir — Base ~/.yeaft directory
|
|
205
|
-
* @param {{ readOnly?: boolean }} [opts]
|
|
206
|
-
*/
|
|
207
|
-
constructor(yeaftDir, opts = {}) {
|
|
208
|
-
this.#dir = join(yeaftDir, 'tasks');
|
|
209
|
-
this.#indexPath = join(this.#dir, 'index.md');
|
|
210
|
-
this.#planPath = join(this.#dir, 'plan.md');
|
|
211
|
-
this.#tasks = new Map();
|
|
212
|
-
this.#readOnly = opts.readOnly || false;
|
|
213
|
-
this.#listeners = [];
|
|
214
|
-
|
|
215
|
-
// Ensure base directory exists
|
|
216
|
-
if (!this.#readOnly) {
|
|
217
|
-
if (!existsSync(this.#dir)) {
|
|
218
|
-
try {
|
|
219
|
-
mkdirSync(this.#dir, { recursive: true });
|
|
220
|
-
} catch {
|
|
221
|
-
this.#readOnly = true;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Load existing tasks from disk
|
|
227
|
-
this.#loadAll();
|
|
228
|
-
|
|
229
|
-
// task-299 (Q3, rework): run a one-shot backfill that promotes legacy
|
|
230
|
-
// `parentId` to the canonical `parentTaskId` field (design §5). A
|
|
231
|
-
// marker file (.migrations/parentTaskId) records completion so the
|
|
232
|
-
// migration is skipped on subsequent boots and is idempotent.
|
|
233
|
-
this.#migrateParentTaskId();
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Number of tasks in the store. */
|
|
237
|
-
get size() {
|
|
238
|
-
return this.#tasks.size;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Create a new task. Creates its folder with task.md, progress.md, memory.md.
|
|
243
|
-
* @param {object} task — Must have .id, .title, .status
|
|
244
|
-
* @returns {object} The created task
|
|
245
|
-
*/
|
|
246
|
-
create(task) {
|
|
247
|
-
this.#tasks.set(task.id, task);
|
|
248
|
-
|
|
249
|
-
if (!this.#readOnly) {
|
|
250
|
-
const taskDir = join(this.#dir, task.id);
|
|
251
|
-
try {
|
|
252
|
-
mkdirSync(taskDir, { recursive: true });
|
|
253
|
-
writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
|
|
254
|
-
writeFileSync(join(taskDir, 'progress.md'), '# Progress Log\n\n', 'utf8');
|
|
255
|
-
writeFileSync(join(taskDir, 'memory.md'), '# Task Memory\n', 'utf8');
|
|
256
|
-
this.#appendProgressInternal(task.id, `Created task: ${task.title}`, { status: 'pending' });
|
|
257
|
-
this.#updateIndex();
|
|
258
|
-
} catch {
|
|
259
|
-
// Best-effort write
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return task;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Update an existing task.
|
|
268
|
-
* @param {string} id
|
|
269
|
-
* @param {object} updates
|
|
270
|
-
* @returns {object|null} Updated task, or null if not found
|
|
271
|
-
*/
|
|
272
|
-
update(id, updates) {
|
|
273
|
-
const task = this.#tasks.get(id);
|
|
274
|
-
if (!task) return null;
|
|
275
|
-
|
|
276
|
-
const oldStatus = task.status;
|
|
277
|
-
Object.assign(task, updates, { updatedAt: Date.now() });
|
|
278
|
-
|
|
279
|
-
if (!this.#readOnly) {
|
|
280
|
-
try {
|
|
281
|
-
const taskDir = join(this.#dir, id);
|
|
282
|
-
writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
|
|
283
|
-
|
|
284
|
-
// Log progress on status change
|
|
285
|
-
if (updates.status && updates.status !== oldStatus) {
|
|
286
|
-
this.#appendProgressInternal(id, `Status changed: ${oldStatus} → ${updates.status}`, updates);
|
|
287
|
-
}
|
|
288
|
-
this.#updateIndex();
|
|
289
|
-
} catch {
|
|
290
|
-
// Best-effort
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return task;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* task-334n — add a VP member to a task's collaboration roster.
|
|
299
|
-
* Idempotent: adding an existing member is a no-op (no event emitted).
|
|
300
|
-
* Returns `{ task, added: boolean }`.
|
|
301
|
-
*
|
|
302
|
-
* If an `onEvent` callback was passed at construction time, emits a
|
|
303
|
-
* `task_member_added` event synchronously after the write:
|
|
304
|
-
* { type: 'task_member_added', taskId, vpId, addedBy, members, ts }
|
|
305
|
-
*
|
|
306
|
-
* @param {string} id — task id
|
|
307
|
-
* @param {string} vpId — VP being added
|
|
308
|
-
* @param {{ addedBy?: string }} [opts] — provenance: who triggered the add
|
|
309
|
-
*/
|
|
310
|
-
addMember(id, vpId, opts) {
|
|
311
|
-
const task = this.#tasks.get(id);
|
|
312
|
-
if (!task) return { task: null, added: false };
|
|
313
|
-
if (!vpId || typeof vpId !== 'string') {
|
|
314
|
-
throw new Error('addMember: vpId required (string)');
|
|
315
|
-
}
|
|
316
|
-
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
317
|
-
if (members.includes(vpId)) {
|
|
318
|
-
return { task, added: false };
|
|
319
|
-
}
|
|
320
|
-
members.push(vpId);
|
|
321
|
-
this.update(id, { members });
|
|
322
|
-
const addedBy = opts?.addedBy || null;
|
|
323
|
-
this.#emit({
|
|
324
|
-
type: 'task_member_added',
|
|
325
|
-
taskId: id,
|
|
326
|
-
vpId,
|
|
327
|
-
addedBy,
|
|
328
|
-
members: members.slice(),
|
|
329
|
-
ts: Date.now(),
|
|
330
|
-
});
|
|
331
|
-
return { task: this.#tasks.get(id), added: true };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* task-334n — remove a VP member from a task.
|
|
336
|
-
* Idempotent: removing a non-member is a no-op (no event emitted).
|
|
337
|
-
* Returns `{ task, removed: boolean }`.
|
|
338
|
-
* Emits `task_member_removed` on successful removal.
|
|
339
|
-
*/
|
|
340
|
-
removeMember(id, vpId) {
|
|
341
|
-
const task = this.#tasks.get(id);
|
|
342
|
-
if (!task) return { task: null, removed: false };
|
|
343
|
-
if (!vpId || typeof vpId !== 'string') {
|
|
344
|
-
throw new Error('removeMember: vpId required (string)');
|
|
345
|
-
}
|
|
346
|
-
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
347
|
-
const idx = members.indexOf(vpId);
|
|
348
|
-
if (idx === -1) return { task, removed: false };
|
|
349
|
-
members.splice(idx, 1);
|
|
350
|
-
this.update(id, { members });
|
|
351
|
-
this.#emit({
|
|
352
|
-
type: 'task_member_removed',
|
|
353
|
-
taskId: id,
|
|
354
|
-
vpId,
|
|
355
|
-
members: members.slice(),
|
|
356
|
-
ts: Date.now(),
|
|
357
|
-
});
|
|
358
|
-
return { task: this.#tasks.get(id), removed: true };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* task-334n §Δ27.3 ACL — true iff `vpId` may read `otherTaskId`'s
|
|
363
|
-
* memory/summary. Pass grants when:
|
|
364
|
-
* - both tasks share the same non-null groupId, OR
|
|
365
|
-
* - members sets intersect on at least one vpId
|
|
366
|
-
* Fail-closed: missing task, missing groupId match, no intersection → false.
|
|
367
|
-
*
|
|
368
|
-
* @param {string} currentTaskId — task the caller is running in
|
|
369
|
-
* @param {string} otherTaskId — task whose data the caller wants to read
|
|
370
|
-
* @param {string} [vpId] — caller's vp id; if set, must also be
|
|
371
|
-
* a member of currentTaskId (prevents stranger elevating via URL probe)
|
|
372
|
-
* @returns {boolean}
|
|
373
|
-
*/
|
|
374
|
-
canAccessRelated(currentTaskId, otherTaskId, vpId) {
|
|
375
|
-
if (!currentTaskId || !otherTaskId || currentTaskId === otherTaskId) {
|
|
376
|
-
return false;
|
|
377
|
-
}
|
|
378
|
-
const cur = this.#tasks.get(currentTaskId);
|
|
379
|
-
const other = this.#tasks.get(otherTaskId);
|
|
380
|
-
if (!cur || !other) return false;
|
|
381
|
-
|
|
382
|
-
// If caller claims a vpId, they must be a member of the current task or
|
|
383
|
-
// its initiator. Otherwise this is a cross-context read — fail-closed.
|
|
384
|
-
if (vpId) {
|
|
385
|
-
const curMembers = Array.isArray(cur.members) ? cur.members : [];
|
|
386
|
-
const isInsider = curMembers.includes(vpId) || cur.initiator === vpId;
|
|
387
|
-
if (!isInsider) return false;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Same-group rule.
|
|
391
|
-
if (cur.groupId && other.groupId && cur.groupId === other.groupId) {
|
|
392
|
-
return true;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Members-intersection rule.
|
|
396
|
-
const a = Array.isArray(cur.members) ? cur.members : [];
|
|
397
|
-
const b = Array.isArray(other.members) ? other.members : [];
|
|
398
|
-
if (a.length === 0 || b.length === 0) return false;
|
|
399
|
-
const bSet = new Set(b);
|
|
400
|
-
for (const v of a) if (bSet.has(v)) return true;
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** Register an event listener (task-334n member events). */
|
|
405
|
-
onEvent(fn) {
|
|
406
|
-
if (typeof fn === 'function') this.#listeners.push(fn);
|
|
407
|
-
return () => {
|
|
408
|
-
const i = this.#listeners.indexOf(fn);
|
|
409
|
-
if (i >= 0) this.#listeners.splice(i, 1);
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
#emit(evt) {
|
|
414
|
-
for (const fn of this.#listeners) {
|
|
415
|
-
try { fn(evt); } catch { /* listener failures must not corrupt store */ }
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Get a task by ID.
|
|
421
|
-
* @param {string} id
|
|
422
|
-
* @returns {object|null}
|
|
423
|
-
*/
|
|
424
|
-
get(id) {
|
|
425
|
-
return this.#tasks.get(id) || null;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* List tasks with optional filters.
|
|
430
|
-
* @param {{ status?: string, priority?: string }} [filter]
|
|
431
|
-
* @returns {object[]}
|
|
432
|
-
*/
|
|
433
|
-
list(filter) {
|
|
434
|
-
let results = [...this.#tasks.values()];
|
|
435
|
-
if (filter?.status) results = results.filter(t => t.status === filter.status);
|
|
436
|
-
if (filter?.priority) results = results.filter(t => t.priority === filter.priority);
|
|
437
|
-
return results;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Return tasks grouped as a tree. Tasks with no parent (parentTaskId == null)
|
|
442
|
-
* are "roots"; each non-root task becomes a child of its parent.
|
|
443
|
-
*
|
|
444
|
-
* Used by task-298/task-300 to render task hierarchies. Critical for the
|
|
445
|
-
* Q3 migration test: before the backfill, old tasks stored only `parentId`
|
|
446
|
-
* and `tree()` would see every task as a root.
|
|
447
|
-
*
|
|
448
|
-
* @returns {{ roots: object[], orphans: object[] }}
|
|
449
|
-
* - roots : tasks whose parentTaskId is null
|
|
450
|
-
* - orphans : tasks whose parentTaskId points to an id that no longer exists
|
|
451
|
-
*/
|
|
452
|
-
tree() {
|
|
453
|
-
const all = [...this.#tasks.values()];
|
|
454
|
-
const byId = new Map(all.map(t => [t.id, t]));
|
|
455
|
-
const roots = [];
|
|
456
|
-
const orphans = [];
|
|
457
|
-
for (const t of all) {
|
|
458
|
-
if (!t.parentTaskId) roots.push(t);
|
|
459
|
-
else if (!byId.has(t.parentTaskId)) orphans.push(t);
|
|
460
|
-
}
|
|
461
|
-
return { roots, orphans };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Get progress log for a task.
|
|
466
|
-
* @param {string} id
|
|
467
|
-
* @returns {string}
|
|
468
|
-
*/
|
|
469
|
-
getProgress(id) {
|
|
470
|
-
const path = join(this.#dir, id, 'progress.md');
|
|
471
|
-
try {
|
|
472
|
-
if (existsSync(path)) return readFileSync(path, 'utf8');
|
|
473
|
-
} catch { /* */ }
|
|
474
|
-
return '';
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Append a progress note to a task's progress log.
|
|
479
|
-
* @param {string} id
|
|
480
|
-
* @param {string} note
|
|
481
|
-
* @param {object} [meta]
|
|
482
|
-
*/
|
|
483
|
-
appendProgress(id, note, meta = {}) {
|
|
484
|
-
if (!this.#tasks.has(id)) return;
|
|
485
|
-
this.#appendProgressInternal(id, note, meta);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Get memory content for a task.
|
|
490
|
-
* @param {string} id
|
|
491
|
-
* @returns {string}
|
|
492
|
-
*/
|
|
493
|
-
getMemory(id) {
|
|
494
|
-
const path = join(this.#dir, id, 'memory.md');
|
|
495
|
-
try {
|
|
496
|
-
if (existsSync(path)) return readFileSync(path, 'utf8');
|
|
497
|
-
} catch { /* */ }
|
|
498
|
-
return '';
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Update memory content for a task.
|
|
503
|
-
* @param {string} id
|
|
504
|
-
* @param {string} content
|
|
505
|
-
*/
|
|
506
|
-
updateMemory(id, content) {
|
|
507
|
-
if (this.#readOnly || !this.#tasks.has(id)) return;
|
|
508
|
-
try {
|
|
509
|
-
writeFileSync(join(this.#dir, id, 'memory.md'), content, 'utf8');
|
|
510
|
-
} catch { /* */ }
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Get current plan text.
|
|
515
|
-
* @returns {string}
|
|
516
|
-
*/
|
|
517
|
-
getPlan() {
|
|
518
|
-
try {
|
|
519
|
-
if (existsSync(this.#planPath)) return readFileSync(this.#planPath, 'utf8');
|
|
520
|
-
} catch { /* */ }
|
|
521
|
-
return '';
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Set plan text.
|
|
526
|
-
* @param {string} text
|
|
527
|
-
*/
|
|
528
|
-
setPlan(text) {
|
|
529
|
-
if (this.#readOnly) return;
|
|
530
|
-
try {
|
|
531
|
-
writeFileSync(this.#planPath, text, 'utf8');
|
|
532
|
-
} catch { /* */ }
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// ─── Internal methods ──────────────────────────────────────
|
|
536
|
-
|
|
537
|
-
/** Load all task folders from disk. */
|
|
538
|
-
#loadAll() {
|
|
539
|
-
if (!existsSync(this.#dir)) return;
|
|
540
|
-
let entries;
|
|
541
|
-
try {
|
|
542
|
-
entries = readdirSync(this.#dir, { withFileTypes: true });
|
|
543
|
-
} catch {
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
for (const entry of entries) {
|
|
548
|
-
if (!entry.isDirectory() || !entry.name.startsWith('task-')) continue;
|
|
549
|
-
const taskMdPath = join(this.#dir, entry.name, 'task.md');
|
|
550
|
-
try {
|
|
551
|
-
if (!existsSync(taskMdPath)) continue;
|
|
552
|
-
const raw = readFileSync(taskMdPath, 'utf8');
|
|
553
|
-
const task = parseTask(raw);
|
|
554
|
-
if (task && task.id) {
|
|
555
|
-
this.#tasks.set(task.id, task);
|
|
556
|
-
}
|
|
557
|
-
} catch {
|
|
558
|
-
// Skip corrupt task folders
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** Append to a task's progress.md. */
|
|
564
|
-
#appendProgressInternal(id, note, meta) {
|
|
565
|
-
if (this.#readOnly) return;
|
|
566
|
-
const path = join(this.#dir, id, 'progress.md');
|
|
567
|
-
try {
|
|
568
|
-
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '# Progress Log\n\n';
|
|
569
|
-
writeFileSync(path, existing + formatProgressEntry(note, meta), 'utf8');
|
|
570
|
-
} catch { /* */ }
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** Regenerate index.md from all tasks. */
|
|
574
|
-
#updateIndex() {
|
|
575
|
-
if (this.#readOnly) return;
|
|
576
|
-
try {
|
|
577
|
-
writeFileSync(this.#indexPath, generateIndex(this.#tasks), 'utf8');
|
|
578
|
-
} catch { /* */ }
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* task-299 Q3 migration — one-shot backfill from legacy `parentId` to
|
|
583
|
-
* canonical `parentTaskId` (design §5).
|
|
584
|
-
*
|
|
585
|
-
* Behaviour:
|
|
586
|
-
* - Reads .migrations/parentTaskId meta marker. If present, returns
|
|
587
|
-
* immediately (skips). → guarantees idempotency on repeated boots.
|
|
588
|
-
* - Otherwise: for every loaded task, if parentId is set but
|
|
589
|
-
* parentTaskId is not, copy parentId → parentTaskId and rewrite
|
|
590
|
-
* task.md so the new field survives future loads.
|
|
591
|
-
* - Writes the meta marker on success. Read-only mode is a no-op.
|
|
592
|
-
*
|
|
593
|
-
* Exposed publicly as `migrateParentTaskId()` so tests can re-run it.
|
|
594
|
-
*
|
|
595
|
-
* @returns {{ ran: boolean, migratedCount: number }}
|
|
596
|
-
*/
|
|
597
|
-
migrateParentTaskId() {
|
|
598
|
-
return this.#migrateParentTaskId();
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
#migrateParentTaskId() {
|
|
602
|
-
if (this.#readOnly) return { ran: false, migratedCount: 0 };
|
|
603
|
-
|
|
604
|
-
const markerDir = join(this.#dir, '.migrations');
|
|
605
|
-
const markerPath = join(markerDir, 'parentTaskId');
|
|
606
|
-
|
|
607
|
-
try {
|
|
608
|
-
if (existsSync(markerPath)) return { ran: false, migratedCount: 0 };
|
|
609
|
-
} catch {
|
|
610
|
-
// If existsSync throws (pathological FS), proceed cautiously — the
|
|
611
|
-
// migration itself is idempotent on per-task level.
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
let migratedCount = 0;
|
|
615
|
-
for (const task of this.#tasks.values()) {
|
|
616
|
-
// parseTask() already promotes parentId → parentTaskId in memory,
|
|
617
|
-
// but the on-disk YAML still lacks the canonical field for old
|
|
618
|
-
// tasks. Rewriting guarantees future loads see parentTaskId and
|
|
619
|
-
// makes the migration visible.
|
|
620
|
-
if (task.parentId && !task.__parentTaskIdWritten) {
|
|
621
|
-
// Ensure the canonical field is set (parseTask normalised this,
|
|
622
|
-
// but handle the edge case where parseTask wasn't used).
|
|
623
|
-
if (!task.parentTaskId) task.parentTaskId = task.parentId;
|
|
624
|
-
const taskDir = join(this.#dir, task.id);
|
|
625
|
-
try {
|
|
626
|
-
writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
|
|
627
|
-
task.__parentTaskIdWritten = true;
|
|
628
|
-
migratedCount += 1;
|
|
629
|
-
} catch {
|
|
630
|
-
// Best-effort; marker is only written if the pass completes.
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
try {
|
|
636
|
-
mkdirSync(markerDir, { recursive: true });
|
|
637
|
-
writeFileSync(
|
|
638
|
-
markerPath,
|
|
639
|
-
`migrated: ${new Date().toISOString()}\ncount: ${migratedCount}\n`,
|
|
640
|
-
'utf8',
|
|
641
|
-
);
|
|
642
|
-
} catch {
|
|
643
|
-
// Without the marker the migration may re-run; since it's idempotent
|
|
644
|
-
// that is acceptable but not ideal. Log silently.
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return { ran: true, migratedCount };
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Exported for testing
|
|
652
|
-
export { serializeTask as _serializeTask, parseTask as _parseTask };
|