@yeaft/webchat-agent 0.1.763 → 0.1.767
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/bin/yeaft-stats.js +165 -0
- package/connection/message-router.js +6 -9
- package/package.json +3 -2
- package/unify/cli.js +16 -0
- package/unify/dream-v2/prompts/index.js +4 -2
- package/unify/dream-v2/session-wiring.js +5 -4
- package/unify/dream-v2/triage.js +7 -10
- package/unify/engine.js +42 -88
- package/unify/groups/pre-flow.js +5 -5
- package/unify/prompts.js +35 -202
- package/unify/session.js +18 -3
- package/unify/stats/format.js +31 -0
- package/unify/stats/tool-usage.js +324 -0
- package/unify/sub-agent/runner.js +4 -19
- package/unify/templates/plan-instruction.md +42 -0
- package/unify/templates/tool-guidance.md +41 -0
- package/unify/tools/index.js +13 -22
- package/unify/tools/start-plan.js +133 -0
- package/unify/tools/todo-write.js +125 -0
- package/unify/vp/vp-crud.js +1 -0
- package/unify/vp/vp-loader.js +2 -1
- package/unify/vp/vp-store.js +8 -0
- package/unify/web-bridge.js +93 -145
- package/unify/features/store.js +0 -583
- package/unify/features/summary.js +0 -250
- package/unify/tools/feature-tools.js +0 -713
package/unify/features/store.js
DELETED
|
@@ -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 };
|