@smartmemory/compose 0.1.5-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.
@@ -16,8 +16,10 @@
16
16
  * be called from MCP tools, the CLI, or future REST routes.
17
17
  */
18
18
 
19
+ import { existsSync, realpathSync, statSync } from 'fs';
20
+ import { resolve, normalize, sep, basename, dirname } from 'path';
21
+
19
22
  import { readFeature, writeFeature, listFeatures, updateFeature } from './feature-json.js';
20
- // eslint-disable-next-line no-unused-vars
21
23
  const _listFeatures = listFeatures;
22
24
  import { writeRoadmap } from './roadmap-gen.js';
23
25
  import { appendEvent, readEvents } from './feature-events.js';
@@ -119,7 +121,16 @@ export async function addRoadmapEntry(cwd, args) {
119
121
  if (args.tags && args.tags.length) feature.tags = args.tags;
120
122
 
121
123
  writeFeature(cwd, feature);
122
- const roadmapPath = writeRoadmap(cwd);
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
+ }
123
134
 
124
135
  safeAppendEvent(cwd, {
125
136
  tool: 'add_roadmap_entry',
@@ -150,6 +161,18 @@ function nextPositionInPhase(cwd, phase) {
150
161
  return maxPos + 1;
151
162
  }
152
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
+
153
176
  // Audit-log writes are best-effort: a failed append must NOT roll back a
154
177
  // committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
155
178
  // and continue.
@@ -207,7 +230,15 @@ export async function setFeatureStatus(cwd, args) {
207
230
  const updates = { status: to };
208
231
  if (args.commit_sha) updates.commit_sha = args.commit_sha;
209
232
  updateFeature(cwd, args.code, updates);
210
- writeRoadmap(cwd);
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
+ }
211
242
 
212
243
  const event = {
213
244
  tool: 'set_feature_status',
@@ -258,8 +289,297 @@ export function roadmapDiff(cwd, args = {}) {
258
289
  return { events, added, status_changed };
259
290
  }
260
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
+
261
581
  // ---------------------------------------------------------------------------
262
582
  // Exports for tests / introspection
263
583
  // ---------------------------------------------------------------------------
264
584
 
265
- export const _internals = { TRANSITIONS, STATUSES, COMPLEXITIES };
585
+ export const _internals = { TRANSITIONS, STATUSES, COMPLEXITIES, LINK_KINDS };