@yeaft/webchat-agent 0.1.763 → 0.1.766

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.
@@ -1,583 +0,0 @@
1
- /**
2
- * store.js — File-system backed FeatureStore for Yeaft Unify.
3
- *
4
- * Persists features to ~/.yeaft/features/ with one folder per feature.
5
- * Layout:
6
- * ~/.yeaft/features/
7
- * index.md — Feature index (auto-generated overview)
8
- * plan.md — Global plan text
9
- * feat-abc12345/ — One folder per feature
10
- * feature.md — Feature metadata (YAML frontmatter + description)
11
- * progress.md — Progress log (append-only)
12
- * memory.md — Feature-specific context/notes
13
- *
14
- * NOTE (PR-1a refactor): renamed from TaskStore. Per project policy
15
- * (no aliases, no ambiguity), object field names were unified:
16
- * parentTaskId → parentFeatureId (canonical parent ref)
17
- * relatedTaskIds → relatedFeatureIds
18
- * primaryThreadId → primaryThreadId (unchanged — thread system uses its own naming)
19
- * members / initiator / groupId (unchanged — group concept survives)
20
- *
21
- * Legacy `parentId` mirror field (Q3 backfill from task-299) was dropped
22
- * because no production data carried only the legacy field — the rework
23
- * landed before any users adopted Unify features at scale. Same reason:
24
- * the one-shot `#migrateParentTaskId` boot migration is removed.
25
- *
26
- * Member/event names: `task_member_added` → `feature_member_added`,
27
- * `task_member_removed` → `feature_member_removed`.
28
- */
29
-
30
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
31
- import { join } from 'path';
32
-
33
- // ─── YAML Frontmatter helpers ────────────────────────────────
34
-
35
- /**
36
- * Serialize a feature object to YAML frontmatter + body for feature.md.
37
- * @param {object} feature
38
- * @returns {string}
39
- */
40
- function serializeFeature(feature) {
41
- const fm = [
42
- '---',
43
- `id: ${feature.id}`,
44
- `title: ${feature.title}`,
45
- `status: ${feature.status}`,
46
- `priority: ${feature.priority || 'medium'}`,
47
- ];
48
-
49
- // Canonical parent ref. Single field — no legacy mirror.
50
- if (feature.parentFeatureId) fm.push(`parentFeatureId: ${feature.parentFeatureId}`);
51
- if (feature.primaryThreadId) fm.push(`primaryThreadId: ${feature.primaryThreadId}`);
52
- // Multi-VP collaboration protocol (R6, formerly task-334n):
53
- // initiator — VP id that created the feature (fallback target for ACL).
54
- // members — explicit VP roster for the feature.
55
- // groupId — the group this feature belongs to (null for standalone).
56
- if (feature.initiator) fm.push(`initiator: ${feature.initiator}`);
57
- if (Array.isArray(feature.members) && feature.members.length) {
58
- fm.push(`members: [${feature.members.join(', ')}]`);
59
- }
60
- if (feature.groupId) fm.push(`groupId: ${feature.groupId}`);
61
- if (Array.isArray(feature.relatedFeatureIds) && feature.relatedFeatureIds.length) {
62
- fm.push(`relatedFeatureIds: [${feature.relatedFeatureIds.join(', ')}]`);
63
- }
64
- if (feature.createdAt) fm.push(`createdAt: ${feature.createdAt}`);
65
- if (feature.updatedAt) fm.push(`updatedAt: ${feature.updatedAt}`);
66
-
67
- fm.push('---');
68
- fm.push('');
69
-
70
- // Body: description + result
71
- const parts = [];
72
- if (feature.description) parts.push(feature.description);
73
- if (feature.result) {
74
- parts.push('');
75
- parts.push('## Result');
76
- parts.push(feature.result);
77
- }
78
- fm.push(parts.join('\n'));
79
-
80
- return fm.join('\n');
81
- }
82
-
83
- /**
84
- * Parse a feature.md file (YAML frontmatter + body) into a feature object.
85
- * @param {string} raw — File contents
86
- * @returns {object|null}
87
- */
88
- function parseFeature(raw) {
89
- if (!raw || !raw.startsWith('---')) return null;
90
-
91
- const endIdx = raw.indexOf('---', 3);
92
- if (endIdx === -1) return null;
93
-
94
- const frontmatter = raw.slice(3, endIdx).trim();
95
- const body = raw.slice(endIdx + 3).trim();
96
-
97
- const feature = {};
98
-
99
- for (const line of frontmatter.split('\n')) {
100
- const colonIdx = line.indexOf(':');
101
- if (colonIdx === -1) continue;
102
- const key = line.slice(0, colonIdx).trim();
103
- const val = line.slice(colonIdx + 1).trim();
104
- if (!key) continue;
105
-
106
- if (key === 'createdAt' || key === 'updatedAt') {
107
- feature[key] = parseInt(val, 10) || 0;
108
- } else if (key === 'members' || key === 'relatedFeatureIds') {
109
- feature[key] = val
110
- .replace(/^\[|\]$/g, '')
111
- .split(',')
112
- .map((s) => s.trim())
113
- .filter(Boolean);
114
- } else {
115
- feature[key] = val;
116
- }
117
- }
118
-
119
- if (!feature.id) return null;
120
-
121
- // Parse body: description and result
122
- const resultIdx = body.indexOf('## Result');
123
- if (resultIdx !== -1) {
124
- feature.description = body.slice(0, resultIdx).trim();
125
- feature.result = body.slice(resultIdx + '## Result'.length).trim();
126
- } else {
127
- feature.description = body;
128
- }
129
-
130
- // Normalize parentFeatureId. Null/"null" strings become real null.
131
- if (!feature.parentFeatureId || feature.parentFeatureId === 'null') {
132
- feature.parentFeatureId = null;
133
- }
134
-
135
- // primaryThreadId: null means "unbound / orphan" — do NOT default to 'main'.
136
- if (!feature.primaryThreadId || feature.primaryThreadId === 'null') {
137
- feature.primaryThreadId = null;
138
- }
139
-
140
- return feature;
141
- }
142
-
143
- // ─── Index generation ────────────────────────────────────────
144
-
145
- /**
146
- * Generate index.md content from all features.
147
- * @param {Map<string, object>} features
148
- * @returns {string}
149
- */
150
- function generateIndex(features) {
151
- const now = new Date().toISOString();
152
- const lines = [
153
- '---',
154
- `totalFeatures: ${features.size}`,
155
- `lastUpdated: ${now}`,
156
- '---',
157
- '# Feature Index',
158
- '',
159
- '| ID | Title | Status | Priority | Updated |',
160
- '|----|-------|--------|----------|---------|',
161
- ];
162
-
163
- // Sort: in_progress first, then pending, then others
164
- const ORDER = { in_progress: 0, pending: 1, blocked: 2, completed: 3, cancelled: 4 };
165
- const sorted = [...features.values()].sort(
166
- (a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)
167
- );
168
-
169
- for (const f of sorted) {
170
- const date = f.updatedAt ? new Date(f.updatedAt).toISOString().slice(0, 10) : '-';
171
- lines.push(`| ${f.id} | ${f.title} | ${f.status} | ${f.priority || 'medium'} | ${date} |`);
172
- }
173
-
174
- return lines.join('\n') + '\n';
175
- }
176
-
177
- // ─── Progress log helpers ────────────────────────────────────
178
-
179
- /**
180
- * Format a progress entry for appending to progress.md.
181
- * @param {string} note
182
- * @param {object} [meta]
183
- * @returns {string}
184
- */
185
- function formatProgressEntry(note, meta = {}) {
186
- const now = new Date();
187
- const ts = `${now.toISOString().slice(0, 10)} ${now.toISOString().slice(11, 16)}`;
188
- const lines = [`## ${ts}`];
189
- lines.push(`- ${note}`);
190
- if (meta.status) lines.push(`- Status: ${meta.status}`);
191
- if (meta.result) lines.push(`- Result: ${meta.result}`);
192
- lines.push('');
193
- return lines.join('\n');
194
- }
195
-
196
- // ─── FeatureStore ────────────────────────────────────────────
197
-
198
- export class FeatureStore {
199
- /** @type {string} */
200
- #dir;
201
- /** @type {string} */
202
- #indexPath;
203
- /** @type {string} */
204
- #planPath;
205
- /** @type {Map<string, object>} */
206
- #features;
207
- /** @type {boolean} */
208
- #readOnly;
209
- /** @type {Array<(evt:any)=>void>} */
210
- #listeners;
211
-
212
- /**
213
- * @param {string} yeaftDir — Base ~/.yeaft directory
214
- * @param {{ readOnly?: boolean }} [opts]
215
- */
216
- constructor(yeaftDir, opts = {}) {
217
- this.#dir = join(yeaftDir, 'features');
218
- this.#indexPath = join(this.#dir, 'index.md');
219
- this.#planPath = join(this.#dir, 'plan.md');
220
- this.#features = new Map();
221
- this.#readOnly = opts.readOnly || false;
222
- this.#listeners = [];
223
-
224
- // Ensure base directory exists
225
- if (!this.#readOnly) {
226
- if (!existsSync(this.#dir)) {
227
- try {
228
- mkdirSync(this.#dir, { recursive: true });
229
- } catch {
230
- this.#readOnly = true;
231
- }
232
- }
233
- }
234
-
235
- // Load existing features from disk
236
- this.#loadAll();
237
- }
238
-
239
- /** Number of features in the store. */
240
- get size() {
241
- return this.#features.size;
242
- }
243
-
244
- /**
245
- * Create a new feature. Creates its folder with feature.md, progress.md, memory.md.
246
- * @param {object} feature — Must have .id, .title, .status
247
- * @returns {object} The created feature
248
- */
249
- create(feature) {
250
- this.#features.set(feature.id, feature);
251
-
252
- if (!this.#readOnly) {
253
- const featureDir = join(this.#dir, feature.id);
254
- try {
255
- mkdirSync(featureDir, { recursive: true });
256
- writeFileSync(join(featureDir, 'feature.md'), serializeFeature(feature), 'utf8');
257
- writeFileSync(join(featureDir, 'progress.md'), '# Progress Log\n\n', 'utf8');
258
- writeFileSync(join(featureDir, 'memory.md'), '# Feature Memory\n', 'utf8');
259
- this.#appendProgressInternal(feature.id, `Created feature: ${feature.title}`, { status: 'pending' });
260
- this.#updateIndex();
261
- } catch {
262
- // Best-effort write
263
- }
264
- }
265
-
266
- return feature;
267
- }
268
-
269
- /**
270
- * Update an existing feature.
271
- * @param {string} id
272
- * @param {object} updates
273
- * @returns {object|null} Updated feature, or null if not found
274
- */
275
- update(id, updates) {
276
- const feature = this.#features.get(id);
277
- if (!feature) return null;
278
-
279
- const oldStatus = feature.status;
280
- Object.assign(feature, updates, { updatedAt: Date.now() });
281
-
282
- if (!this.#readOnly) {
283
- try {
284
- const featureDir = join(this.#dir, id);
285
- writeFileSync(join(featureDir, 'feature.md'), serializeFeature(feature), 'utf8');
286
-
287
- // Log progress on status change
288
- if (updates.status && updates.status !== oldStatus) {
289
- this.#appendProgressInternal(id, `Status changed: ${oldStatus} → ${updates.status}`, updates);
290
- }
291
- this.#updateIndex();
292
- } catch {
293
- // Best-effort
294
- }
295
- }
296
-
297
- return feature;
298
- }
299
-
300
- /**
301
- * Add a VP member to a feature's collaboration roster.
302
- * Idempotent: adding an existing member is a no-op (no event emitted).
303
- * Returns `{ feature, added: boolean }`.
304
- *
305
- * If an `onEvent` callback was registered, emits a `feature_member_added`
306
- * event synchronously after the write:
307
- * { type: 'feature_member_added', featureId, vpId, addedBy, members, ts }
308
- *
309
- * @param {string} id — feature id
310
- * @param {string} vpId — VP being added
311
- * @param {{ addedBy?: string }} [opts] — provenance: who triggered the add
312
- */
313
- addMember(id, vpId, opts) {
314
- const feature = this.#features.get(id);
315
- if (!feature) return { feature: null, added: false };
316
- if (!vpId || typeof vpId !== 'string') {
317
- throw new Error('addMember: vpId required (string)');
318
- }
319
- const members = Array.isArray(feature.members) ? feature.members.slice() : [];
320
- if (members.includes(vpId)) {
321
- return { feature, added: false };
322
- }
323
- members.push(vpId);
324
- this.update(id, { members });
325
- const addedBy = opts?.addedBy || null;
326
- this.#emit({
327
- type: 'feature_member_added',
328
- featureId: id,
329
- vpId,
330
- addedBy,
331
- members: members.slice(),
332
- ts: Date.now(),
333
- });
334
- return { feature: this.#features.get(id), added: true };
335
- }
336
-
337
- /**
338
- * Remove a VP member from a feature.
339
- * Idempotent: removing a non-member is a no-op (no event emitted).
340
- * Returns `{ feature, removed: boolean }`.
341
- * Emits `feature_member_removed` on successful removal.
342
- */
343
- removeMember(id, vpId) {
344
- const feature = this.#features.get(id);
345
- if (!feature) return { feature: null, removed: false };
346
- if (!vpId || typeof vpId !== 'string') {
347
- throw new Error('removeMember: vpId required (string)');
348
- }
349
- const members = Array.isArray(feature.members) ? feature.members.slice() : [];
350
- const idx = members.indexOf(vpId);
351
- if (idx === -1) return { feature, removed: false };
352
- members.splice(idx, 1);
353
- this.update(id, { members });
354
- this.#emit({
355
- type: 'feature_member_removed',
356
- featureId: id,
357
- vpId,
358
- members: members.slice(),
359
- ts: Date.now(),
360
- });
361
- return { feature: this.#features.get(id), removed: true };
362
- }
363
-
364
- /**
365
- * ACL — true iff `vpId` may read `otherFeatureId`'s memory/summary.
366
- * Pass grants when:
367
- * - both features share the same non-null groupId, OR
368
- * - members sets intersect on at least one vpId
369
- * Fail-closed: missing feature, missing groupId match, no intersection → false.
370
- *
371
- * @param {string} currentFeatureId — feature the caller is running in
372
- * @param {string} otherFeatureId — feature whose data the caller wants to read
373
- * @param {string} [vpId] — caller's vp id; if set, must also be
374
- * a member of currentFeatureId (prevents stranger elevating via URL probe)
375
- * @returns {boolean}
376
- */
377
- canAccessRelated(currentFeatureId, otherFeatureId, vpId) {
378
- if (!currentFeatureId || !otherFeatureId || currentFeatureId === otherFeatureId) {
379
- return false;
380
- }
381
- const cur = this.#features.get(currentFeatureId);
382
- const other = this.#features.get(otherFeatureId);
383
- if (!cur || !other) return false;
384
-
385
- // If caller claims a vpId, they must be a member of the current feature
386
- // or its initiator. Otherwise this is a cross-context read — fail-closed.
387
- if (vpId) {
388
- const curMembers = Array.isArray(cur.members) ? cur.members : [];
389
- const isInsider = curMembers.includes(vpId) || cur.initiator === vpId;
390
- if (!isInsider) return false;
391
- }
392
-
393
- // Same-group rule.
394
- if (cur.groupId && other.groupId && cur.groupId === other.groupId) {
395
- return true;
396
- }
397
-
398
- // Members-intersection rule.
399
- const a = Array.isArray(cur.members) ? cur.members : [];
400
- const b = Array.isArray(other.members) ? other.members : [];
401
- if (a.length === 0 || b.length === 0) return false;
402
- const bSet = new Set(b);
403
- for (const v of a) if (bSet.has(v)) return true;
404
- return false;
405
- }
406
-
407
- /** Register an event listener (member events). */
408
- onEvent(fn) {
409
- if (typeof fn === 'function') this.#listeners.push(fn);
410
- return () => {
411
- const i = this.#listeners.indexOf(fn);
412
- if (i >= 0) this.#listeners.splice(i, 1);
413
- };
414
- }
415
-
416
- #emit(evt) {
417
- for (const fn of this.#listeners) {
418
- try { fn(evt); } catch { /* listener failures must not corrupt store */ }
419
- }
420
- }
421
-
422
- /**
423
- * Get a feature by ID.
424
- * @param {string} id
425
- * @returns {object|null}
426
- */
427
- get(id) {
428
- return this.#features.get(id) || null;
429
- }
430
-
431
- /**
432
- * List features with optional filters.
433
- * @param {{ status?: string, priority?: string }} [filter]
434
- * @returns {object[]}
435
- */
436
- list(filter) {
437
- let results = [...this.#features.values()];
438
- if (filter?.status) results = results.filter(f => f.status === filter.status);
439
- if (filter?.priority) results = results.filter(f => f.priority === filter.priority);
440
- return results;
441
- }
442
-
443
- /**
444
- * Return features grouped as a tree. Features with no parent
445
- * (parentFeatureId == null) are "roots"; each non-root becomes a child
446
- * of its parent.
447
- *
448
- * @returns {{ roots: object[], orphans: object[] }}
449
- * - roots : features whose parentFeatureId is null
450
- * - orphans : features whose parentFeatureId points to a missing id
451
- */
452
- tree() {
453
- const all = [...this.#features.values()];
454
- const byId = new Map(all.map(f => [f.id, f]));
455
- const roots = [];
456
- const orphans = [];
457
- for (const f of all) {
458
- if (!f.parentFeatureId) roots.push(f);
459
- else if (!byId.has(f.parentFeatureId)) orphans.push(f);
460
- }
461
- return { roots, orphans };
462
- }
463
-
464
- /**
465
- * Get progress log for a feature.
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 feature's progress log.
479
- * @param {string} id
480
- * @param {string} note
481
- * @param {object} [meta]
482
- */
483
- appendProgress(id, note, meta = {}) {
484
- if (!this.#features.has(id)) return;
485
- this.#appendProgressInternal(id, note, meta);
486
- }
487
-
488
- /**
489
- * Get memory content for a feature.
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 feature.
503
- * @param {string} id
504
- * @param {string} content
505
- */
506
- updateMemory(id, content) {
507
- if (this.#readOnly || !this.#features.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 feature 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('feat-')) continue;
549
- const featureMdPath = join(this.#dir, entry.name, 'feature.md');
550
- try {
551
- if (!existsSync(featureMdPath)) continue;
552
- const raw = readFileSync(featureMdPath, 'utf8');
553
- const feature = parseFeature(raw);
554
- if (feature && feature.id) {
555
- this.#features.set(feature.id, feature);
556
- }
557
- } catch {
558
- // Skip corrupt feature folders
559
- }
560
- }
561
- }
562
-
563
- /** Append to a feature'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 features. */
574
- #updateIndex() {
575
- if (this.#readOnly) return;
576
- try {
577
- writeFileSync(this.#indexPath, generateIndex(this.#features), 'utf8');
578
- } catch { /* */ }
579
- }
580
- }
581
-
582
- // Exported for testing
583
- export { serializeFeature as _serializeFeature, parseFeature as _parseFeature };