@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.
- package/bin/compose.js +279 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/lib/build.js +31 -3
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +465 -0
- package/lib/feature-events.js +114 -0
- package/lib/feature-writer.js +585 -0
- package/lib/idempotency.js +138 -0
- package/lib/journal-writer.js +928 -0
- package/lib/roadmap-parser.js +3 -1
- package/lib/sections.js +188 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +82 -0
- package/server/compose-mcp.js +273 -1
|
@@ -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 };
|