@smartmemory/compose 0.1.5-beta → 0.1.7-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.1.5-beta",
3
+ "version": "0.1.7-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -8,6 +8,10 @@
8
8
  "bin": {
9
9
  "compose": "./bin/compose.js"
10
10
  },
11
+ "exports": {
12
+ "./mcp": "./server/compose-mcp.js",
13
+ "./package.json": "./package.json"
14
+ },
11
15
  "scripts": {
12
16
  "dev": "node server/supervisor.js 2>&1 | tee /tmp/compose-server.log",
13
17
  "dev:server": "node server/supervisor.js",
@@ -216,6 +216,86 @@ export async function toolRoadmapDiff(args) {
216
216
  return roadmapDiff(getTargetRoot(), args);
217
217
  }
218
218
 
219
+ export async function toolLinkArtifact(args) {
220
+ const { linkArtifact } = await import('../lib/feature-writer.js');
221
+ return linkArtifact(getTargetRoot(), args);
222
+ }
223
+
224
+ export async function toolLinkFeatures(args) {
225
+ const { linkFeatures } = await import('../lib/feature-writer.js');
226
+ return linkFeatures(getTargetRoot(), args);
227
+ }
228
+
229
+ export async function toolGetFeatureArtifacts(args) {
230
+ const { getFeatureArtifacts } = await import('../lib/feature-writer.js');
231
+ return getFeatureArtifacts(getTargetRoot(), args);
232
+ }
233
+
234
+ export async function toolGetFeatureLinks(args) {
235
+ const { getFeatureLinks } = await import('../lib/feature-writer.js');
236
+ return getFeatureLinks(getTargetRoot(), args);
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Changelog writer — COMP-MCP-CHANGELOG-WRITER
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export async function toolAddChangelogEntry(args) {
244
+ const { addChangelogEntry } = await import('../lib/changelog-writer.js');
245
+ return addChangelogEntry(getTargetRoot(), args);
246
+ }
247
+
248
+ export async function toolGetChangelogEntries(args) {
249
+ const { getChangelogEntries } = await import('../lib/changelog-writer.js');
250
+ return getChangelogEntries(getTargetRoot(), args);
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Journal writer — COMP-MCP-JOURNAL-WRITER
255
+ // ---------------------------------------------------------------------------
256
+
257
+ export async function toolWriteJournalEntry(args) {
258
+ const { writeJournalEntry } = await import('../lib/journal-writer.js');
259
+ return writeJournalEntry(getTargetRoot(), args);
260
+ }
261
+
262
+ export async function toolGetJournalEntries(args) {
263
+ const { getJournalEntries } = await import('../lib/journal-writer.js');
264
+ return getJournalEntries(getTargetRoot(), args);
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Completion writer — COMP-MCP-COMPLETION
269
+ // ---------------------------------------------------------------------------
270
+
271
+ export async function toolRecordCompletion(args) {
272
+ const { recordCompletion } = await import('../lib/completion-writer.js');
273
+ return recordCompletion(getTargetRoot(), args);
274
+ }
275
+
276
+ export async function toolGetCompletions(args) {
277
+ const { getCompletions } = await import('../lib/completion-writer.js');
278
+ return getCompletions(getTargetRoot(), args);
279
+ }
280
+
281
+ export async function toolValidateFeature(args = {}) {
282
+ const { validateFeature } = await import('../lib/feature-validator.js');
283
+ const { feature_code, external_prefixes, feature_json_mode } = args;
284
+ return validateFeature(getTargetRoot(), feature_code, {
285
+ externalPrefixes: external_prefixes,
286
+ featureJsonMode: feature_json_mode,
287
+ });
288
+ }
289
+
290
+ export async function toolValidateProject(args = {}) {
291
+ const { validateProject } = await import('../lib/feature-validator.js');
292
+ const { external_prefixes, feature_json_mode } = args;
293
+ return validateProject(getTargetRoot(), {
294
+ externalPrefixes: external_prefixes,
295
+ featureJsonMode: feature_json_mode,
296
+ });
297
+ }
298
+
219
299
  export async function toolBindSession({ featureCode }) {
220
300
  const postData = JSON.stringify({ featureCode });
221
301
  return new Promise((resolve, reject) => {
@@ -46,6 +46,18 @@ import {
46
46
  toolAddRoadmapEntry,
47
47
  toolSetFeatureStatus,
48
48
  toolRoadmapDiff,
49
+ toolLinkArtifact,
50
+ toolLinkFeatures,
51
+ toolGetFeatureArtifacts,
52
+ toolGetFeatureLinks,
53
+ toolAddChangelogEntry,
54
+ toolGetChangelogEntries,
55
+ toolWriteJournalEntry,
56
+ toolGetJournalEntries,
57
+ toolRecordCompletion,
58
+ toolGetCompletions,
59
+ toolValidateFeature,
60
+ toolValidateProject,
49
61
  } from './compose-mcp-tools.js';
50
62
 
51
63
  // ---------------------------------------------------------------------------
@@ -312,6 +324,212 @@ const TOOLS = [
312
324
  },
313
325
  },
314
326
  },
327
+ {
328
+ name: 'validate_feature',
329
+ description: 'Cross-check a single feature against ROADMAP, vision-state, feature.json, folder contents, linked artifacts, and cross-references. Returns structured findings with severity (error/warning/info). FEATURE_NOT_FOUND emitted as a finding (not thrown) when the code matches strict regex but exists in no source.',
330
+ inputSchema: {
331
+ type: 'object',
332
+ required: ['feature_code'],
333
+ properties: {
334
+ feature_code: { type: 'string', description: 'Strict feature code, e.g. "COMP-MCP-VALIDATE"' },
335
+ external_prefixes: { type: 'array', items: { type: 'string' }, description: 'Code prefixes (e.g. ["STRAT-"]) treated as external; downgrades ORPHAN_FOLDER to info' },
336
+ feature_json_mode: { type: 'boolean', description: 'Default true. Set false to skip feature.json comparisons in legacy projects.' },
337
+ },
338
+ },
339
+ },
340
+ {
341
+ name: 'validate_project',
342
+ description: 'Run validate_feature for every code in vision-state, ROADMAP, and folders, plus cross-cutting checks (orphan folders, dangling cross-refs, CHANGELOG references, journal index drift). Returns the union of all findings.',
343
+ inputSchema: {
344
+ type: 'object',
345
+ properties: {
346
+ external_prefixes: { type: 'array', items: { type: 'string' } },
347
+ feature_json_mode: { type: 'boolean' },
348
+ },
349
+ },
350
+ },
351
+
352
+ // -------------------------------------------------------------------------
353
+ // Linker — COMP-MCP-ARTIFACT-LINKER
354
+ // -------------------------------------------------------------------------
355
+ {
356
+ name: 'link_artifact',
357
+ description: 'Register a non-canonical artifact (snapshot, journal entry, finding, etc.) on a feature. Canonical artifacts (design.md, plan.md, …) inside the feature folder are auto-discovered and rejected here. Stores in feature.json artifacts[]; dedups on (type, path); appends an audit event (best-effort).',
358
+ inputSchema: {
359
+ type: 'object',
360
+ required: ['feature_code', 'artifact_type', 'path'],
361
+ properties: {
362
+ feature_code: { type: 'string' },
363
+ artifact_type: { type: 'string', description: 'e.g. "journal", "snapshot", "finding", "report-supplement", "link", "external"' },
364
+ path: { type: 'string', description: 'Repo-relative path. Must exist; cannot contain ".." after normalization.' },
365
+ status: { type: 'string', enum: ['current', 'superseded', 'historical'] },
366
+ force: { type: 'boolean', description: 'Overwrite an existing entry with the same (type, path)' },
367
+ idempotency_key: { type: 'string' },
368
+ },
369
+ },
370
+ },
371
+ {
372
+ name: 'link_features',
373
+ description: 'Register a typed cross-feature relationship. Stores on the source feature; query the inverse via get_feature_links(direction:"incoming"). Closed enum on kind; self-links rejected; dedups on (kind, to_code).',
374
+ inputSchema: {
375
+ type: 'object',
376
+ required: ['from_code', 'to_code', 'kind'],
377
+ properties: {
378
+ from_code: { type: 'string' },
379
+ to_code: { type: 'string', description: 'Target feature code. Need not exist yet (you can link to a code you are about to create).' },
380
+ kind: { type: 'string', enum: ['surfaced_by', 'blocks', 'depends_on', 'follow_up', 'supersedes', 'related'] },
381
+ note: { type: 'string' },
382
+ force: { type: 'boolean' },
383
+ idempotency_key: { type: 'string' },
384
+ },
385
+ },
386
+ },
387
+ {
388
+ name: 'get_feature_artifacts',
389
+ description: 'Read both canonical (auto-discovered: design.md, plan.md, …) and linked (snapshots, journals, findings) artifacts for a feature in one call. Each linked entry includes a current existence check.',
390
+ inputSchema: {
391
+ type: 'object',
392
+ required: ['feature_code'],
393
+ properties: {
394
+ feature_code: { type: 'string' },
395
+ },
396
+ },
397
+ },
398
+ {
399
+ name: 'get_feature_links',
400
+ description: 'Read outgoing and/or incoming feature links. Default returns both directions; filter by kind if needed.',
401
+ inputSchema: {
402
+ type: 'object',
403
+ required: ['feature_code'],
404
+ properties: {
405
+ feature_code: { type: 'string' },
406
+ direction: { type: 'string', enum: ['outgoing', 'incoming', 'both'] },
407
+ kind: { type: 'string' },
408
+ },
409
+ },
410
+ },
411
+
412
+ // -------------------------------------------------------------------------
413
+ // Changelog writer — COMP-MCP-CHANGELOG-WRITER
414
+ // -------------------------------------------------------------------------
415
+ {
416
+ name: 'add_changelog_entry',
417
+ description: 'Insert (or replace, with force: true) a typed entry in compose/CHANGELOG.md. Idempotent on (date_or_version, code) at storage level; optional caller-supplied idempotency_key for retry safety. Audit-log append is best-effort. Use this instead of editing CHANGELOG.md by hand.',
418
+ inputSchema: {
419
+ type: 'object',
420
+ required: ['date_or_version', 'code', 'summary'],
421
+ properties: {
422
+ date_or_version: { type: 'string', description: 'ISO date "YYYY-MM-DD" or semver "vX.Y.Z"' },
423
+ code: { type: 'string', description: 'Feature code (e.g. "COMP-FOO-1"). Uppercase A-Z, digits, dashes; cannot start or end with a dash.' },
424
+ summary: { type: 'string', description: 'One-line summary; renders as the "— summary" tail of the entry header.' },
425
+ body: { type: 'string', description: 'Free paragraphs between header and labeled subsections.' },
426
+ sections: {
427
+ type: 'object',
428
+ description: 'Optional labeled subsections; emitted in fixed order Added → Changed → Fixed → Snapshot.',
429
+ properties: {
430
+ added: { type: 'array', items: { type: 'string' } },
431
+ changed: { type: 'array', items: { type: 'string' } },
432
+ fixed: { type: 'array', items: { type: 'string' } },
433
+ snapshot: { type: 'array', items: { type: 'string' } },
434
+ },
435
+ additionalProperties: false,
436
+ },
437
+ force: { type: 'boolean', description: 'If true and an entry with the same (date_or_version, code) exists, replace it in place.' },
438
+ idempotency_key: { type: 'string', description: 'Optional caller-supplied key. Same key replays return the cached result without re-mutating.' },
439
+ },
440
+ },
441
+ },
442
+ {
443
+ name: 'get_changelog_entries',
444
+ description: 'Read parsed entries from compose/CHANGELOG.md. Filter by code (exact) or since (shorthand "24h"/"7d"/"30m" or ISO date — date-only; version surfaces always pass through).',
445
+ inputSchema: {
446
+ type: 'object',
447
+ properties: {
448
+ since: { type: 'string', description: 'Window: shorthand like "24h"/"7d"/"30m" or ISO date. Date-only filter; version surfaces are always returned.' },
449
+ code: { type: 'string' },
450
+ limit: { type: 'number', description: 'Default 50; capped at 500.' },
451
+ },
452
+ },
453
+ },
454
+
455
+ // -------------------------------------------------------------------------
456
+ // Journal writer — COMP-MCP-JOURNAL-WRITER
457
+ // -------------------------------------------------------------------------
458
+ {
459
+ name: 'write_journal_entry',
460
+ description: 'Write a typed entry to compose/docs/journal/ with auto-numbered global session and inserted index row. Idempotent on (date, slug) at storage level; optional caller idempotency_key for retry safety. Audit-log append is best-effort.',
461
+ inputSchema: {
462
+ type: 'object',
463
+ required: ['date', 'slug', 'sections', 'summary_for_index'],
464
+ properties: {
465
+ date: { type: 'string', description: 'ISO date "YYYY-MM-DD".' },
466
+ slug: { type: 'string', description: 'Kebab-case slug for the filename, e.g. "mcp-journal-writer".' },
467
+ sections: {
468
+ type: 'object',
469
+ required: ['what_happened', 'what_we_built', 'what_we_learned', 'open_threads'],
470
+ properties: {
471
+ what_happened: { type: 'string' },
472
+ what_we_built: { type: 'string' },
473
+ what_we_learned: { type: 'string' },
474
+ open_threads: { type: 'string' },
475
+ },
476
+ additionalProperties: false,
477
+ },
478
+ summary_for_index: { type: 'string', description: 'Single-line summary for the README index row. No newlines, no "|".' },
479
+ feature_code: { type: 'string', description: 'Optional feature code stamped in entry frontmatter.' },
480
+ closing_line: { type: 'string', description: 'Optional final italicized one-liner.' },
481
+ force: { type: 'boolean', description: 'If true and an entry with the same (date, slug) exists, overwrite in place.' },
482
+ idempotency_key: { type: 'string', description: 'Optional caller-supplied key. Same key replays return the cached result without re-mutating.' },
483
+ },
484
+ },
485
+ },
486
+ {
487
+ name: 'get_journal_entries',
488
+ description: 'Read parsed entries from compose/docs/journal/. Filter by feature_code (exact), session (exact), or since (shorthand "24h"/"7d"/"30m" or ISO date).',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ since: { type: 'string' },
493
+ feature_code: { type: 'string' },
494
+ session: { type: 'number' },
495
+ limit: { type: 'number', description: 'Default 50; capped at 500.' },
496
+ },
497
+ },
498
+ },
499
+ // -------------------------------------------------------------------------
500
+ // Completion writer — COMP-MCP-COMPLETION
501
+ // -------------------------------------------------------------------------
502
+ {
503
+ name: 'record_completion',
504
+ description: 'Record a completion bound to a commit SHA. Stores in feature.json completions[]; idempotent on (feature_code, commit_sha); when set_status:true (default) also flips status to COMPLETE via set_feature_status. Audit append best-effort. Status-flip failure rethrows as STATUS_FLIP_AFTER_COMPLETION_RECORDED with err.cause; the completion record is still persisted.',
505
+ inputSchema: {
506
+ type: 'object',
507
+ required: ['feature_code', 'commit_sha', 'tests_pass', 'files_changed'],
508
+ properties: {
509
+ feature_code: { type: 'string' },
510
+ commit_sha: { type: 'string', description: 'Full 40-char hex SHA (Decision 9). Short prefixes are rejected on write. Stored verbatim; commit_sha_short is derived for display only.' },
511
+ tests_pass: { type: 'boolean' },
512
+ files_changed: { type: 'array', items: { type: 'string' } },
513
+ notes: { type: 'string' },
514
+ set_status: { type: 'boolean', description: 'Default true. When true, flips status to COMPLETE via set_feature_status.' },
515
+ force: { type: 'boolean', description: 'If true and a record with the same (feature_code, commit_sha) exists, replace it in place.' },
516
+ idempotency_key: { type: 'string' },
517
+ },
518
+ },
519
+ },
520
+ {
521
+ name: 'get_completions',
522
+ description: 'Read completion records from feature.json files. Filter by feature_code (exact), commit_sha (short or full prefix), or since (shorthand or ISO date).',
523
+ inputSchema: {
524
+ type: 'object',
525
+ properties: {
526
+ feature_code: { type: 'string' },
527
+ commit_sha: { type: 'string' },
528
+ since: { type: 'string' },
529
+ limit: { type: 'number', description: 'Default 50; capped at 500.' },
530
+ },
531
+ },
532
+ },
315
533
  ];
316
534
 
317
535
  // ---------------------------------------------------------------------------
@@ -352,6 +570,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
352
570
  case 'add_roadmap_entry': result = await toolAddRoadmapEntry(args); break;
353
571
  case 'set_feature_status': result = await toolSetFeatureStatus(args); break;
354
572
  case 'roadmap_diff': result = await toolRoadmapDiff(args); break;
573
+ case 'link_artifact': result = await toolLinkArtifact(args); break;
574
+ case 'link_features': result = await toolLinkFeatures(args); break;
575
+ case 'get_feature_artifacts': result = await toolGetFeatureArtifacts(args); break;
576
+ case 'get_feature_links': result = await toolGetFeatureLinks(args); break;
577
+ case 'add_changelog_entry': result = await toolAddChangelogEntry(args); break;
578
+ case 'get_changelog_entries': result = await toolGetChangelogEntries(args); break;
579
+ case 'write_journal_entry': result = await toolWriteJournalEntry(args); break;
580
+ case 'get_journal_entries': result = await toolGetJournalEntries(args); break;
581
+ case 'record_completion': result = await toolRecordCompletion(args); break;
582
+ case 'get_completions': result = await toolGetCompletions(args); break;
583
+ case 'validate_feature': result = await toolValidateFeature(args); break;
584
+ case 'validate_project': result = await toolValidateProject(args); break;
355
585
  // agent_run removed — STRAT-DEDUP-AGENTRUN v1. Use mcp__stratum__stratum_agent_run.
356
586
  default:
357
587
  return {
@@ -363,8 +593,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
363
593
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
364
594
  };
365
595
  } catch (err) {
596
+ // Surface typed error codes (e.g. INVALID_INPUT, CHANGELOG_FORMAT) when
597
+ // tools attach them, so MCP callers can branch deterministically. Plain
598
+ // errors fall back to the original "Error: <message>" shape.
599
+ // When err.cause is an Error-shaped object, append it so callers can
600
+ // distinguish partial-write sub-errors (e.g. rollback succeeded vs failed).
601
+ let text = err && err.code
602
+ ? `Error [${err.code}]: ${err.message}`
603
+ : `Error: ${err.message}`;
604
+ if (err && err.cause && typeof err.cause.message === 'string') {
605
+ text += err.cause.code
606
+ ? `\n Caused by [${err.cause.code}]: ${err.cause.message}`
607
+ : `\n Caused by: ${err.cause.message}`;
608
+ }
366
609
  return {
367
- content: [{ type: 'text', text: `Error: ${err.message}` }],
610
+ content: [{ type: 'text', text }],
368
611
  isError: true,
369
612
  };
370
613
  }
@@ -5,26 +5,43 @@ import Ajv from 'ajv';
5
5
  import addFormats from 'ajv-formats';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
- const SCHEMA_PATH = resolve(__dirname, '../contracts/comp-obs-contract.schema.json');
8
+ const DEFAULT_SCHEMA_PATH = resolve(__dirname, '../contracts/comp-obs-contract.schema.json');
9
9
 
10
- let cached = null;
10
+ // Per-path cache. Each entry: { schema, ajv }.
11
+ const cache = new Map();
11
12
 
12
- function load() {
13
- if (cached) return cached;
14
- const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
13
+ function load(schemaPath = DEFAULT_SCHEMA_PATH) {
14
+ if (cache.has(schemaPath)) return cache.get(schemaPath);
15
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf8'));
15
16
  const ajv = new Ajv({ strict: false, allErrors: true });
16
17
  addFormats(ajv);
17
18
  ajv.addSchema(schema);
18
- cached = { schema, ajv };
19
- return cached;
19
+ const entry = { schema, ajv };
20
+ cache.set(schemaPath, entry);
21
+ return entry;
22
+ }
23
+
24
+ /**
25
+ * Load (and cache) a schema by absolute path. Returns `{ schema, ajv }`.
26
+ * Used by code that wants to compile arbitrary `$ref`s against the schema
27
+ * without going through the SchemaValidator class.
28
+ */
29
+ export function loadSchema(schemaPath) {
30
+ return load(schemaPath);
20
31
  }
21
32
 
22
33
  export class SchemaValidator {
23
- constructor() {
24
- const { schema, ajv } = load();
34
+ /**
35
+ * @param {string} [schemaPath] Absolute path to a JSON Schema file.
36
+ * Default is the comp-obs-contract schema (back-compat for existing
37
+ * callers that pass no args).
38
+ */
39
+ constructor(schemaPath = DEFAULT_SCHEMA_PATH) {
40
+ const { schema, ajv } = load(schemaPath);
25
41
  this.schema = schema;
26
42
  this.ajv = ajv;
27
43
  this._validators = new Map();
44
+ this._rootValidator = null;
28
45
  }
29
46
 
30
47
  _getValidator(defName) {
@@ -39,11 +56,35 @@ export class SchemaValidator {
39
56
  return v;
40
57
  }
41
58
 
59
+ /**
60
+ * Validate `obj` against `schema.definitions[defName]`. Used by the
61
+ * comp-obs-contract code paths.
62
+ */
42
63
  validate(defName, obj) {
43
64
  const v = this._getValidator(defName);
44
65
  const valid = v(obj);
45
66
  return { valid: !!valid, errors: valid ? [] : (v.errors || []) };
46
67
  }
68
+
69
+ /**
70
+ * Validate `obj` against the schema's root (no $defs/$ref indirection).
71
+ * Used by feature-json / vision-state / roadmap-row schemas, which are
72
+ * top-level shapes without nested definitions.
73
+ */
74
+ validateRoot(obj) {
75
+ if (!this._rootValidator) {
76
+ // ajv.getSchema by $id returns the root validator if the schema was
77
+ // added via addSchema (which load() does).
78
+ let v = this.schema.$id ? this.ajv.getSchema(this.schema.$id) : null;
79
+ if (!v) v = this.ajv.compile(this.schema);
80
+ this._rootValidator = v;
81
+ }
82
+ const v = this._rootValidator;
83
+ const valid = v(obj);
84
+ return { valid: !!valid, errors: valid ? [] : (v.errors || []) };
85
+ }
47
86
  }
48
87
 
88
+ // Back-compat export — points at the comp-obs schema version. Existing
89
+ // callers consuming SCHEMA_VERSION continue to work unchanged.
49
90
  export const SCHEMA_VERSION = load().schema.version;