@smartmemory/compose 0.1.4-beta → 0.1.6-beta

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.
@@ -0,0 +1,585 @@
1
+ /**
2
+ * feature-writer.js — typed writers for ROADMAP / feature.json mutations.
3
+ *
4
+ * First sub-ticket of COMP-MCP-FEATURE-MGMT (COMP-MCP-ROADMAP-WRITER).
5
+ *
6
+ * Three operations:
7
+ * addRoadmapEntry(cwd, args) — register a new feature, regenerate ROADMAP
8
+ * setFeatureStatus(cwd, args) — flip status with transition policy enforcement
9
+ * roadmapDiff(cwd, args) — read the audit log for a window
10
+ *
11
+ * All writes go through feature.json (canonical) + writeRoadmap()
12
+ * (regenerates ROADMAP.md). Mutations append to the feature-events.jsonl
13
+ * audit log. Idempotency keys protect against retries.
14
+ *
15
+ * No HTTP, no transport awareness — pure data + IO so the same writers can
16
+ * be called from MCP tools, the CLI, or future REST routes.
17
+ */
18
+
19
+ import { existsSync, realpathSync, statSync } from 'fs';
20
+ import { resolve, normalize, sep, basename, dirname } from 'path';
21
+
22
+ import { readFeature, writeFeature, listFeatures, updateFeature } from './feature-json.js';
23
+ const _listFeatures = listFeatures;
24
+ import { writeRoadmap } from './roadmap-gen.js';
25
+ import { appendEvent, readEvents } from './feature-events.js';
26
+ import { checkOrInsert } from './idempotency.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Status / transition policy
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const STATUSES = new Set([
33
+ 'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
34
+ 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED',
35
+ ]);
36
+
37
+ // COMPLETE -> SUPERSEDED is force-only (per design Decision 6); not in the
38
+ // normal transitions list. Force flag bypasses the policy and is recorded in
39
+ // audit.
40
+ const TRANSITIONS = {
41
+ PLANNED: ['IN_PROGRESS', 'KILLED', 'PARKED'],
42
+ IN_PROGRESS: ['PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED'],
43
+ PARTIAL: ['IN_PROGRESS', 'COMPLETE', 'KILLED'],
44
+ COMPLETE: [],
45
+ BLOCKED: ['IN_PROGRESS', 'KILLED', 'PARKED'],
46
+ PARKED: ['PLANNED', 'KILLED'],
47
+ KILLED: [],
48
+ SUPERSEDED: [],
49
+ };
50
+
51
+ const COMPLEXITIES = new Set(['S', 'M', 'L', 'XL']);
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
58
+
59
+ function validateCode(code) {
60
+ if (typeof code !== 'string' || !FEATURE_CODE_RE.test(code)) {
61
+ throw new Error(`feature-writer: invalid feature code "${code}" — must match ${FEATURE_CODE_RE}`);
62
+ }
63
+ }
64
+
65
+ function maybeIdempotent(args, fn) {
66
+ if (args.idempotency_key) {
67
+ return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
68
+ }
69
+ return Promise.resolve().then(fn);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // addRoadmapEntry
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * @param {string} cwd
78
+ * @param {object} args
79
+ * @param {string} args.code
80
+ * @param {string} args.description
81
+ * @param {string} args.phase
82
+ * @param {string} [args.complexity]
83
+ * @param {string} [args.status]
84
+ * @param {number} [args.position]
85
+ * @param {string} [args.parent]
86
+ * @param {string[]} [args.tags]
87
+ * @param {string} [args.idempotency_key]
88
+ */
89
+ export async function addRoadmapEntry(cwd, args) {
90
+ validateCode(args.code);
91
+ if (!args.description) throw new Error('feature-writer: description is required');
92
+ if (!args.phase) throw new Error('feature-writer: phase is required');
93
+ if (args.complexity && !COMPLEXITIES.has(args.complexity)) {
94
+ throw new Error(`feature-writer: invalid complexity "${args.complexity}"`);
95
+ }
96
+ const status = args.status ?? 'PLANNED';
97
+ if (!STATUSES.has(status)) {
98
+ throw new Error(`feature-writer: invalid status "${status}"`);
99
+ }
100
+
101
+ return maybeIdempotent({ ...args, cwd }, () => {
102
+ const existing = readFeature(cwd, args.code);
103
+ if (existing) {
104
+ throw new Error(`feature-writer: feature "${args.code}" already exists`);
105
+ }
106
+
107
+ const today = new Date().toISOString().slice(0, 10);
108
+ const feature = {
109
+ code: args.code,
110
+ description: args.description,
111
+ status,
112
+ phase: args.phase,
113
+ created: today,
114
+ updated: today,
115
+ };
116
+ if (args.complexity) feature.complexity = args.complexity;
117
+ feature.position = args.position !== undefined
118
+ ? args.position
119
+ : nextPositionInPhase(cwd, args.phase);
120
+ if (args.parent) feature.parent = args.parent;
121
+ if (args.tags && args.tags.length) feature.tags = args.tags;
122
+
123
+ writeFeature(cwd, feature);
124
+ let roadmapPath;
125
+ try {
126
+ roadmapPath = writeRoadmap(cwd);
127
+ } catch (err) {
128
+ throw partialWriteError(
129
+ `add_roadmap_entry: feature.json for "${args.code}" was written but ROADMAP.md regeneration failed. ` +
130
+ `Recover with \`compose roadmap generate\`.`,
131
+ err,
132
+ );
133
+ }
134
+
135
+ safeAppendEvent(cwd, {
136
+ tool: 'add_roadmap_entry',
137
+ code: args.code,
138
+ to: status,
139
+ phase: args.phase,
140
+ idempotency_key: args.idempotency_key,
141
+ });
142
+
143
+ return {
144
+ code: args.code,
145
+ phase: args.phase,
146
+ position: feature.position,
147
+ roadmap_path: roadmapPath,
148
+ };
149
+ });
150
+ }
151
+
152
+ // Default position for a new feature: max existing position in the same
153
+ // phase, plus 1. Falls back to 1 when the phase is empty.
154
+ function nextPositionInPhase(cwd, phase) {
155
+ const peers = _listFeatures(cwd).filter(f => f.phase === phase);
156
+ if (peers.length === 0) return 1;
157
+ const maxPos = peers.reduce((m, f) => {
158
+ const p = typeof f.position === 'number' ? f.position : 0;
159
+ return p > m ? p : m;
160
+ }, 0);
161
+ return maxPos + 1;
162
+ }
163
+
164
+ // Wrap a mid-flight failure (feature.json committed, ROADMAP.md regen
165
+ // failed) in a typed envelope so MCP callers can distinguish committed vs
166
+ // uncommitted state. The wrapper at server/compose-mcp.js serializes
167
+ // err.cause as `Caused by [CODE]: message`, so the underlying writeRoadmap
168
+ // error stays observable across the MCP boundary.
169
+ function partialWriteError(message, cause) {
170
+ const err = new Error(message);
171
+ err.code = 'ROADMAP_PARTIAL_WRITE';
172
+ if (cause) err.cause = cause;
173
+ return err;
174
+ }
175
+
176
+ // Audit-log writes are best-effort: a failed append must NOT roll back a
177
+ // committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
178
+ // and continue.
179
+ function safeAppendEvent(cwd, event) {
180
+ try {
181
+ appendEvent(cwd, event);
182
+ } catch (err) {
183
+ // eslint-disable-next-line no-console
184
+ console.warn(`[feature-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // setFeatureStatus
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * @param {string} cwd
194
+ * @param {object} args
195
+ * @param {string} args.code
196
+ * @param {string} args.status
197
+ * @param {string} [args.reason]
198
+ * @param {string} [args.commit_sha]
199
+ * @param {boolean} [args.force]
200
+ * @param {string} [args.idempotency_key]
201
+ */
202
+ export async function setFeatureStatus(cwd, args) {
203
+ validateCode(args.code);
204
+ if (!STATUSES.has(args.status)) {
205
+ throw new Error(`feature-writer: invalid status "${args.status}" — must be one of ${[...STATUSES].join(', ')}`);
206
+ }
207
+
208
+ return maybeIdempotent({ ...args, cwd }, () => {
209
+ const feature = readFeature(cwd, args.code);
210
+ if (!feature) {
211
+ throw new Error(`feature-writer: feature "${args.code}" not found`);
212
+ }
213
+
214
+ const from = feature.status;
215
+ const to = args.status;
216
+
217
+ if (from === to) {
218
+ return { code: args.code, from, to, ts: new Date().toISOString(), noop: true };
219
+ }
220
+
221
+ const allowed = TRANSITIONS[from] ?? [];
222
+ if (!allowed.includes(to) && !args.force) {
223
+ throw new Error(
224
+ `feature-writer: invalid transition for ${args.code}: ${from} → ${to}. ` +
225
+ `Allowed from ${from}: [${allowed.join(', ') || 'none'}]. ` +
226
+ `Pass force: true to override.`
227
+ );
228
+ }
229
+
230
+ const updates = { status: to };
231
+ if (args.commit_sha) updates.commit_sha = args.commit_sha;
232
+ updateFeature(cwd, args.code, updates);
233
+ try {
234
+ writeRoadmap(cwd);
235
+ } catch (err) {
236
+ throw partialWriteError(
237
+ `set_feature_status: feature.json for "${args.code}" was updated (${from} → ${to}) but ROADMAP.md regeneration failed. ` +
238
+ `Recover with \`compose roadmap generate\`.`,
239
+ err,
240
+ );
241
+ }
242
+
243
+ const event = {
244
+ tool: 'set_feature_status',
245
+ code: args.code,
246
+ from,
247
+ to,
248
+ idempotency_key: args.idempotency_key,
249
+ };
250
+ if (args.reason) event.reason = args.reason;
251
+ if (args.commit_sha) event.commit_sha = args.commit_sha;
252
+ if (args.force && !allowed.includes(to)) event.forced = true;
253
+ safeAppendEvent(cwd, event);
254
+
255
+ return { code: args.code, from, to, ts: new Date().toISOString() };
256
+ });
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // roadmapDiff
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * @param {string} cwd
265
+ * @param {object} [args]
266
+ * @param {string|number|Date} [args.since='24h']
267
+ * @param {string} [args.feature_code]
268
+ * @param {string} [args.tool]
269
+ */
270
+ export function roadmapDiff(cwd, args = {}) {
271
+ const since = args.since ?? '24h';
272
+ const events = readEvents(cwd, {
273
+ since,
274
+ code: args.feature_code,
275
+ tool: args.tool,
276
+ });
277
+
278
+ const added = [];
279
+ const status_changed = [];
280
+ for (const e of events) {
281
+ if (e.tool === 'add_roadmap_entry' && e.code) {
282
+ added.push(e.code);
283
+ }
284
+ if (e.tool === 'set_feature_status' && e.code && e.from !== e.to) {
285
+ status_changed.push({ code: e.code, from: e.from, to: e.to });
286
+ }
287
+ }
288
+
289
+ return { events, added, status_changed };
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Linker — COMP-MCP-ARTIFACT-LINKER
294
+ // ---------------------------------------------------------------------------
295
+
296
+ const LINK_KINDS = new Set([
297
+ 'surfaced_by', 'blocks', 'depends_on',
298
+ 'follow_up', 'supersedes', 'related',
299
+ ]);
300
+
301
+ const CANONICAL_ARTIFACT_NAMES = new Set([
302
+ 'design.md', 'prd.md', 'architecture.md',
303
+ 'blueprint.md', 'plan.md', 'report.md',
304
+ ]);
305
+
306
+ function validateRepoPath(cwd, path) {
307
+ if (typeof path !== 'string' || path.length === 0) {
308
+ throw new Error('feature-writer: path must be a non-empty string');
309
+ }
310
+ if (path.startsWith('/') || path.startsWith('~')) {
311
+ throw new Error(`feature-writer: path must be repo-relative, got "${path}"`);
312
+ }
313
+ const normalized = normalize(path);
314
+ if (normalized.split(sep).includes('..')) {
315
+ throw new Error(`feature-writer: path must not contain ".." after normalization, got "${path}"`);
316
+ }
317
+ const realCwd = realpathSync(cwd);
318
+ const resolved = resolve(realCwd, normalized);
319
+ if (!resolved.startsWith(realCwd + sep) && resolved !== realCwd) {
320
+ throw new Error(`feature-writer: path "${path}" resolves outside cwd`);
321
+ }
322
+ if (!existsSync(resolved)) {
323
+ throw new Error(`feature-writer: path "${path}" does not exist`);
324
+ }
325
+ // Resolve symlinks AFTER existence check and verify the real target also
326
+ // lives under cwd. This blocks repo-internal symlinks that escape (e.g.
327
+ // docs/features/FOO/leak -> /etc/passwd). Mirrors the symlink hardening
328
+ // in server/artifact-manager.js.
329
+ const realResolved = realpathSync(resolved);
330
+ if (!realResolved.startsWith(realCwd + sep) && realResolved !== realCwd) {
331
+ throw new Error(`feature-writer: path "${path}" symlinks outside cwd`);
332
+ }
333
+ if (!statSync(realResolved).isFile()) {
334
+ throw new Error(`feature-writer: path "${path}" must point at a file (got directory or other)`);
335
+ }
336
+ return normalized;
337
+ }
338
+
339
+ function rejectCanonicalArtifact(featureCode, normalizedPath) {
340
+ // Reject paths like docs/features/<CODE>/design.md, prd.md, etc.
341
+ const file = basename(normalizedPath);
342
+ if (!CANONICAL_ARTIFACT_NAMES.has(file)) return;
343
+ // The canonical files live under the feature folder. If this path points
344
+ // inside the feature's own folder, refuse — those are auto-discovered.
345
+ const parent = dirname(normalizedPath);
346
+ if (parent.endsWith(`docs/features/${featureCode}`)) {
347
+ throw new Error(
348
+ `feature-writer: "${file}" inside the feature folder is a canonical artifact; ` +
349
+ `it is auto-discovered by assess_feature_artifacts and should not be linked explicitly.`
350
+ );
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Register a non-canonical artifact (snapshot, journal, finding, etc.) on a
356
+ * feature. Canonical artifacts (design.md, plan.md, ...) are auto-discovered
357
+ * by ArtifactManager and rejected here.
358
+ *
359
+ * @param {string} cwd
360
+ * @param {object} args
361
+ * @param {string} args.feature_code
362
+ * @param {string} args.artifact_type
363
+ * @param {string} args.path - repo-relative
364
+ * @param {string} [args.status]
365
+ * @param {boolean} [args.force]
366
+ * @param {string} [args.idempotency_key]
367
+ */
368
+ export async function linkArtifact(cwd, args) {
369
+ validateCode(args.feature_code);
370
+ if (!args.artifact_type || typeof args.artifact_type !== 'string') {
371
+ throw new Error('feature-writer: artifact_type is required (non-empty string)');
372
+ }
373
+ const normalizedPath = validateRepoPath(cwd, args.path);
374
+ rejectCanonicalArtifact(args.feature_code, normalizedPath);
375
+
376
+ return maybeIdempotent({ ...args, cwd }, () => {
377
+ const feature = readFeature(cwd, args.feature_code);
378
+ if (!feature) {
379
+ throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
380
+ }
381
+
382
+ const artifacts = Array.isArray(feature.artifacts) ? [...feature.artifacts] : [];
383
+ const matchIdx = artifacts.findIndex(
384
+ a => a.type === args.artifact_type && a.path === normalizedPath
385
+ );
386
+
387
+ if (matchIdx !== -1 && !args.force) {
388
+ return {
389
+ feature_code: args.feature_code,
390
+ artifact_type: args.artifact_type,
391
+ path: normalizedPath,
392
+ noop: true,
393
+ };
394
+ }
395
+
396
+ const entry = { type: args.artifact_type, path: normalizedPath };
397
+ if (args.status) entry.status = args.status;
398
+
399
+ if (matchIdx !== -1) artifacts[matchIdx] = entry;
400
+ else artifacts.push(entry);
401
+
402
+ updateFeature(cwd, args.feature_code, { artifacts });
403
+
404
+ safeAppendEvent(cwd, {
405
+ tool: 'link_artifact',
406
+ code: args.feature_code,
407
+ artifact_type: args.artifact_type,
408
+ path: normalizedPath,
409
+ forced: matchIdx !== -1 ? true : undefined,
410
+ idempotency_key: args.idempotency_key,
411
+ });
412
+
413
+ return {
414
+ feature_code: args.feature_code,
415
+ artifact_type: args.artifact_type,
416
+ path: normalizedPath,
417
+ };
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Register a typed cross-feature link.
423
+ *
424
+ * @param {string} cwd
425
+ * @param {object} args
426
+ * @param {string} args.from_code
427
+ * @param {string} args.to_code
428
+ * @param {string} args.kind - one of LINK_KINDS
429
+ * @param {string} [args.note]
430
+ * @param {boolean} [args.force]
431
+ * @param {string} [args.idempotency_key]
432
+ */
433
+ export async function linkFeatures(cwd, args) {
434
+ validateCode(args.from_code);
435
+ validateCode(args.to_code);
436
+ if (args.from_code === args.to_code) {
437
+ throw new Error(`feature-writer: cannot link a feature to itself ("${args.from_code}")`);
438
+ }
439
+ if (!LINK_KINDS.has(args.kind)) {
440
+ throw new Error(
441
+ `feature-writer: invalid link kind "${args.kind}". ` +
442
+ `Allowed: ${[...LINK_KINDS].join(', ')}`
443
+ );
444
+ }
445
+
446
+ return maybeIdempotent({ ...args, cwd }, () => {
447
+ const feature = readFeature(cwd, args.from_code);
448
+ if (!feature) {
449
+ throw new Error(`feature-writer: feature "${args.from_code}" not found`);
450
+ }
451
+
452
+ const links = Array.isArray(feature.links) ? [...feature.links] : [];
453
+ const matchIdx = links.findIndex(
454
+ l => l.kind === args.kind && l.to_code === args.to_code
455
+ );
456
+
457
+ if (matchIdx !== -1 && !args.force) {
458
+ return { from_code: args.from_code, to_code: args.to_code, kind: args.kind, noop: true };
459
+ }
460
+
461
+ const entry = { kind: args.kind, to_code: args.to_code };
462
+ if (args.note) entry.note = args.note;
463
+
464
+ if (matchIdx !== -1) links[matchIdx] = entry;
465
+ else links.push(entry);
466
+
467
+ updateFeature(cwd, args.from_code, { links });
468
+
469
+ safeAppendEvent(cwd, {
470
+ tool: 'link_features',
471
+ code: args.from_code,
472
+ to_code: args.to_code,
473
+ kind: args.kind,
474
+ note: args.note,
475
+ forced: matchIdx !== -1 ? true : undefined,
476
+ idempotency_key: args.idempotency_key,
477
+ });
478
+
479
+ return { from_code: args.from_code, to_code: args.to_code, kind: args.kind };
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Read both canonical and linked artifacts for a feature in one call.
485
+ *
486
+ * Canonical artifacts (design.md/prd.md/architecture.md/blueprint.md/
487
+ * plan.md/report.md inside the feature folder) come from ArtifactManager
488
+ * via a dynamic import (kept out of the static import graph because lib/
489
+ * is consumed by stdio MCP code paths and we don't want to pay
490
+ * server/-side load costs unless the caller asks).
491
+ *
492
+ * Linked artifacts come from feature.json's artifacts[]; each is stamped
493
+ * with a current existence check.
494
+ *
495
+ * @param {string} cwd
496
+ * @param {object} args
497
+ * @param {string} args.feature_code
498
+ */
499
+ export async function getFeatureArtifacts(cwd, args) {
500
+ validateCode(args.feature_code);
501
+ const feature = readFeature(cwd, args.feature_code);
502
+ if (!feature) {
503
+ throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
504
+ }
505
+
506
+ const realCwd = realpathSync(cwd);
507
+ const linked = (feature.artifacts ?? []).map(a => ({
508
+ type: a.type,
509
+ path: a.path,
510
+ status: a.status,
511
+ exists: existsSync(resolve(realCwd, a.path)),
512
+ }));
513
+
514
+ let canonical = null;
515
+ try {
516
+ const { ArtifactManager } = await import('../server/artifact-manager.js');
517
+ const featureRoot = resolve(realCwd, 'docs', 'features');
518
+ if (existsSync(featureRoot)) {
519
+ const manager = new ArtifactManager(featureRoot);
520
+ canonical = manager.assess(args.feature_code);
521
+ }
522
+ } catch (err) {
523
+ // Don't fail the whole read if ArtifactManager isn't available — surface
524
+ // the issue via canonical: null and a one-line note.
525
+ canonical = { error: err.message };
526
+ }
527
+
528
+ return { feature_code: args.feature_code, canonical, linked };
529
+ }
530
+
531
+ /**
532
+ * Read outgoing and/or incoming links for a feature. Outgoing reads from
533
+ * the source feature's links[]; incoming iterates listFeatures and finds
534
+ * entries that target the requested code.
535
+ *
536
+ * @param {string} cwd
537
+ * @param {object} args
538
+ * @param {string} args.feature_code
539
+ * @param {'outgoing'|'incoming'|'both'} [args.direction='both']
540
+ * @param {string} [args.kind]
541
+ */
542
+ export function getFeatureLinks(cwd, args) {
543
+ validateCode(args.feature_code);
544
+ const direction = args.direction ?? 'both';
545
+ if (!['outgoing', 'incoming', 'both'].includes(direction)) {
546
+ throw new Error(
547
+ `feature-writer: invalid direction "${direction}". Allowed: outgoing, incoming, both.`
548
+ );
549
+ }
550
+ const kind = args.kind;
551
+
552
+ const out = { feature_code: args.feature_code };
553
+
554
+ if (direction === 'outgoing' || direction === 'both') {
555
+ const feature = readFeature(cwd, args.feature_code);
556
+ if (!feature) {
557
+ throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
558
+ }
559
+ out.outgoing = (feature.links ?? [])
560
+ .filter(l => !kind || l.kind === kind)
561
+ .map(l => ({ kind: l.kind, to_code: l.to_code, note: l.note }));
562
+ }
563
+
564
+ if (direction === 'incoming' || direction === 'both') {
565
+ const all = _listFeatures(cwd);
566
+ const incoming = [];
567
+ for (const f of all) {
568
+ if (f.code === args.feature_code) continue;
569
+ for (const l of (f.links ?? [])) {
570
+ if (l.to_code !== args.feature_code) continue;
571
+ if (kind && l.kind !== kind) continue;
572
+ incoming.push({ kind: l.kind, from_code: f.code, note: l.note });
573
+ }
574
+ }
575
+ out.incoming = incoming;
576
+ }
577
+
578
+ return out;
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Exports for tests / introspection
583
+ // ---------------------------------------------------------------------------
584
+
585
+ export const _internals = { TRANSITIONS, STATUSES, COMPLEXITIES, LINK_KINDS };