@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.
- package/bin/compose.js +279 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +465 -0
- package/lib/feature-writer.js +324 -4
- package/lib/journal-writer.js +928 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +62 -0
- package/server/compose-mcp.js +216 -1
package/lib/feature-writer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|