@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,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* completion-writer.js — typed writer for per-feature completion records.
|
|
3
|
+
*
|
|
4
|
+
* Sub-ticket #5 of COMP-MCP-FEATURE-MGMT (COMP-MCP-COMPLETION).
|
|
5
|
+
*
|
|
6
|
+
* Two operations:
|
|
7
|
+
* recordCompletion(cwd, args) — append (or replace) a completion record on feature.json
|
|
8
|
+
* getCompletions(cwd, opts) — read + filter completion records across features
|
|
9
|
+
*
|
|
10
|
+
* Storage: feature.json completions[] (append-mostly, oldest-first on disk).
|
|
11
|
+
* Idempotency: storage-level dedup on completion_id = <feature_code>:<commit_sha>;
|
|
12
|
+
* optional caller-supplied idempotency_key via checkOrInsert.
|
|
13
|
+
* Concurrency: per-feature advisory lock at
|
|
14
|
+
* <cwd>/.compose/data/locks/feature-<feature_code>.lock
|
|
15
|
+
* (Decision 10; mirrors acquireLock pattern from lib/idempotency.js:42).
|
|
16
|
+
*
|
|
17
|
+
* setFeatureStatus is lazy-imported inside the writer to avoid circular load.
|
|
18
|
+
* (feature-writer.js must NOT import completion-writer.js — that would create
|
|
19
|
+
* a cycle. The lazy import here is intentional and must stay inside the function.)
|
|
20
|
+
*
|
|
21
|
+
* No HTTP, no transport awareness.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, rmSync, statSync } from 'fs';
|
|
25
|
+
import { join, dirname, posix } from 'path';
|
|
26
|
+
|
|
27
|
+
import { readFeature, updateFeature, listFeatures } from './feature-json.js';
|
|
28
|
+
import { appendEvent, normalizeSince } from './feature-events.js';
|
|
29
|
+
import { checkOrInsert } from './idempotency.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Constants + regexes
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
|
|
36
|
+
const SHA_RE = /^[0-9a-f]{40}$/i; // FULL SHA only (Decision 9). Case-insensitive input; normalize to lowercase.
|
|
37
|
+
const SHORT_LEN = 8; // Display only — never the dedup key.
|
|
38
|
+
const DEFAULT_LIMIT = 50;
|
|
39
|
+
const MAX_LIMIT = 500;
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Lock helpers (mirrors idempotency.js:42)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
46
|
+
const LOCK_RETRY_MS = 25;
|
|
47
|
+
|
|
48
|
+
function featureLockFile(cwd, featureCode) {
|
|
49
|
+
return join(cwd, '.compose', 'data', 'locks', `feature-${featureCode}.lock`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureLockDir(lockPath) {
|
|
53
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function acquireFeatureLock(cwd, featureCode) {
|
|
57
|
+
const lockPath = featureLockFile(cwd, featureCode);
|
|
58
|
+
ensureLockDir(lockPath);
|
|
59
|
+
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
while (true) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(lockPath);
|
|
64
|
+
return () => {
|
|
65
|
+
try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
66
|
+
};
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code !== 'EEXIST') throw err;
|
|
69
|
+
// Stale lock recovery
|
|
70
|
+
try {
|
|
71
|
+
const st = statSync(lockPath);
|
|
72
|
+
if (Date.now() - st.mtimeMs > LOCK_TIMEOUT_MS) {
|
|
73
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
} catch { /* stat raced; loop and retry */ }
|
|
77
|
+
|
|
78
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
79
|
+
throw new Error(`completion-writer: feature lock timeout after ${LOCK_TIMEOUT_MS}ms: ${lockPath}`);
|
|
80
|
+
}
|
|
81
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Typed-error helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function inputError(message) {
|
|
91
|
+
const e = new Error(message);
|
|
92
|
+
e.code = 'INVALID_INPUT';
|
|
93
|
+
return e;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function notFoundError(code) {
|
|
97
|
+
const e = new Error(`completion-writer: feature "${code}" not found`);
|
|
98
|
+
e.code = 'FEATURE_NOT_FOUND';
|
|
99
|
+
return e;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function statusFlipError(message, cause) {
|
|
103
|
+
const e = new Error(message);
|
|
104
|
+
e.code = 'STATUS_FLIP_AFTER_COMPLETION_RECORDED';
|
|
105
|
+
if (cause) e.cause = cause;
|
|
106
|
+
return e;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Path validation
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function validateRepoRelativePath(p) {
|
|
114
|
+
if (typeof p !== 'string' || p.length === 0) {
|
|
115
|
+
throw inputError('files_changed: each entry must be a non-empty string');
|
|
116
|
+
}
|
|
117
|
+
if (p.includes('\0')) {
|
|
118
|
+
throw inputError(`files_changed: entry contains NUL byte: "${p}"`);
|
|
119
|
+
}
|
|
120
|
+
if (p.startsWith('/')) {
|
|
121
|
+
throw inputError(`files_changed: absolute paths not allowed: "${p}"`);
|
|
122
|
+
}
|
|
123
|
+
if (p.includes('\\')) {
|
|
124
|
+
throw inputError(`files_changed: use POSIX separators (no backslashes): "${p}"`);
|
|
125
|
+
}
|
|
126
|
+
const normalized = posix.normalize(p);
|
|
127
|
+
if (normalized !== p) {
|
|
128
|
+
throw inputError(`files_changed: path "${p}" must already be normalized (got "${normalized}")`);
|
|
129
|
+
}
|
|
130
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
131
|
+
throw inputError(`files_changed: ".." escape rejected: "${p}"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Validation
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function validate(args) {
|
|
140
|
+
// feature_code
|
|
141
|
+
if (typeof args.feature_code !== 'string' || !FEATURE_CODE_RE.test(args.feature_code)) {
|
|
142
|
+
throw inputError(
|
|
143
|
+
`completion-writer: invalid feature_code "${args.feature_code}" — must match ${FEATURE_CODE_RE}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// commit_sha — full 40-char hex required (Decision 9)
|
|
148
|
+
if (typeof args.commit_sha !== 'string' || args.commit_sha.trim().length === 0) {
|
|
149
|
+
throw inputError('completion-writer: commit_sha is required (non-empty string)');
|
|
150
|
+
}
|
|
151
|
+
const trimmedSha = args.commit_sha.trim();
|
|
152
|
+
if (!SHA_RE.test(trimmedSha)) {
|
|
153
|
+
throw inputError(
|
|
154
|
+
`completion-writer: commit_sha must be a full 40-char hex SHA (Decision 9). ` +
|
|
155
|
+
`Got "${trimmedSha}" (length ${trimmedSha.length}). Short prefixes are rejected on write.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// tests_pass — strict boolean
|
|
160
|
+
if (typeof args.tests_pass !== 'boolean') {
|
|
161
|
+
throw inputError(
|
|
162
|
+
`completion-writer: tests_pass must be a boolean, got ${typeof args.tests_pass} "${args.tests_pass}"`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// files_changed — array of normalized repo-relative paths
|
|
167
|
+
if (!Array.isArray(args.files_changed)) {
|
|
168
|
+
throw inputError('completion-writer: files_changed must be an array');
|
|
169
|
+
}
|
|
170
|
+
for (const p of args.files_changed) {
|
|
171
|
+
validateRepoRelativePath(p);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// notes — if present, non-empty string, no NUL
|
|
175
|
+
if (args.notes !== undefined && args.notes !== null) {
|
|
176
|
+
if (typeof args.notes !== 'string') {
|
|
177
|
+
throw inputError('completion-writer: notes must be a string');
|
|
178
|
+
}
|
|
179
|
+
if (args.notes.includes('\0')) {
|
|
180
|
+
throw inputError('completion-writer: notes must not contain NUL bytes');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// set_status — boolean if present
|
|
185
|
+
if (args.set_status !== undefined && typeof args.set_status !== 'boolean') {
|
|
186
|
+
throw inputError('completion-writer: set_status must be a boolean');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// force — boolean if present
|
|
190
|
+
if (args.force !== undefined && typeof args.force !== 'boolean') {
|
|
191
|
+
throw inputError('completion-writer: force must be a boolean');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// maybeIdempotent helper (mirrors feature-writer.js pattern)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function maybeIdempotent(args, fn) {
|
|
200
|
+
if (args.idempotency_key) {
|
|
201
|
+
return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
|
|
202
|
+
}
|
|
203
|
+
return Promise.resolve().then(fn);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// safeAppendEvent — best-effort; failed append must NOT roll back a committed
|
|
208
|
+
// mutation (per sibling-writer convention).
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function safeAppendEvent(cwd, event) {
|
|
212
|
+
try {
|
|
213
|
+
appendEvent(cwd, event);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
// eslint-disable-next-line no-console
|
|
216
|
+
console.warn(
|
|
217
|
+
`[completion-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// recordCompletion
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Record a completion bound to a commit SHA on feature.json.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} cwd
|
|
230
|
+
* @param {object} args
|
|
231
|
+
* @param {string} args.feature_code
|
|
232
|
+
* @param {string} args.commit_sha Full 40-char hex SHA (Decision 9)
|
|
233
|
+
* @param {boolean} args.tests_pass
|
|
234
|
+
* @param {string[]} args.files_changed Repo-relative normalized POSIX paths
|
|
235
|
+
* @param {string} [args.notes]
|
|
236
|
+
* @param {boolean} [args.set_status] Default true — flip status to COMPLETE
|
|
237
|
+
* @param {boolean} [args.force] Replace existing same-(code,sha) record
|
|
238
|
+
* @param {string} [args.idempotency_key]
|
|
239
|
+
*
|
|
240
|
+
* @returns {{ feature_code, completion_id, commit_sha, commit_sha_short,
|
|
241
|
+
* status_changed, status_flip_partial, idempotent, recorded_at }}
|
|
242
|
+
*/
|
|
243
|
+
export async function recordCompletion(cwd, args) {
|
|
244
|
+
// 1. Validate
|
|
245
|
+
validate(args);
|
|
246
|
+
|
|
247
|
+
// 2. Normalize SHA
|
|
248
|
+
const commit_sha = args.commit_sha.trim().toLowerCase();
|
|
249
|
+
const commit_sha_short = commit_sha.slice(0, SHORT_LEN);
|
|
250
|
+
const feature_code = args.feature_code;
|
|
251
|
+
|
|
252
|
+
// 3. Compute completion_id
|
|
253
|
+
const completion_id = `${feature_code}:${commit_sha}`;
|
|
254
|
+
|
|
255
|
+
// 4. Wrap in maybeIdempotent for caller-key path
|
|
256
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
257
|
+
// 5a. Acquire per-feature advisory lock (Decision 10)
|
|
258
|
+
const release = await acquireFeatureLock(cwd, feature_code);
|
|
259
|
+
try {
|
|
260
|
+
// 5b. Read feature
|
|
261
|
+
const feature = readFeature(cwd, feature_code);
|
|
262
|
+
if (!feature) throw notFoundError(feature_code);
|
|
263
|
+
|
|
264
|
+
// 5c. Snapshot completions array
|
|
265
|
+
const completions = Array.isArray(feature.completions) ? [...feature.completions] : [];
|
|
266
|
+
|
|
267
|
+
// 5d. Find existing index
|
|
268
|
+
const idx = completions.findIndex(c => c.completion_id === completion_id);
|
|
269
|
+
|
|
270
|
+
// 5e. Idempotent no-op
|
|
271
|
+
if (idx !== -1 && !args.force) {
|
|
272
|
+
return {
|
|
273
|
+
feature_code,
|
|
274
|
+
completion_id,
|
|
275
|
+
commit_sha,
|
|
276
|
+
commit_sha_short,
|
|
277
|
+
status_changed: null,
|
|
278
|
+
status_flip_partial: false,
|
|
279
|
+
idempotent: true,
|
|
280
|
+
recorded_at: completions[idx].recorded_at,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 5f. Build record
|
|
285
|
+
const record = {
|
|
286
|
+
completion_id,
|
|
287
|
+
feature_code, // stamped at write time (Decision 11)
|
|
288
|
+
commit_sha,
|
|
289
|
+
commit_sha_short,
|
|
290
|
+
tests_pass: args.tests_pass,
|
|
291
|
+
files_changed: [...args.files_changed],
|
|
292
|
+
recorded_at: new Date().toISOString(),
|
|
293
|
+
recorded_by: process.env.COMPOSE_ACTOR || 'mcp:agent',
|
|
294
|
+
};
|
|
295
|
+
if (args.notes) record.notes = args.notes;
|
|
296
|
+
|
|
297
|
+
// 5g. Replace or append
|
|
298
|
+
if (idx !== -1) {
|
|
299
|
+
completions[idx] = record;
|
|
300
|
+
} else {
|
|
301
|
+
completions.push(record);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
|
|
305
|
+
updateFeature(cwd, feature_code, { completions });
|
|
306
|
+
|
|
307
|
+
// 5i. Status flip (default on)
|
|
308
|
+
const set_status = args.set_status !== false;
|
|
309
|
+
let status_changed = null;
|
|
310
|
+
|
|
311
|
+
if (set_status && feature.status !== 'COMPLETE') {
|
|
312
|
+
const fromStatus = feature.status;
|
|
313
|
+
// Terminal states (KILLED, SUPERSEDED) have no valid outgoing transitions.
|
|
314
|
+
// We deliberately do NOT force for terminal states so that the transition
|
|
315
|
+
// policy enforcement fires and produces the STATUS_FLIP_AFTER_COMPLETION_RECORDED
|
|
316
|
+
// error (test #11 / Decision 4). For non-terminal states we pass force: true
|
|
317
|
+
// so that intermediate-state features (e.g. PLANNED → COMPLETE) succeed without
|
|
318
|
+
// requiring callers to manually walk through IN_PROGRESS first.
|
|
319
|
+
const TERMINAL_STATES = new Set(['KILLED', 'SUPERSEDED']);
|
|
320
|
+
const flipForce = !TERMINAL_STATES.has(fromStatus);
|
|
321
|
+
try {
|
|
322
|
+
// Lazy-import setFeatureStatus to avoid circular load
|
|
323
|
+
// (completion-writer.js must not be statically imported by feature-writer.js)
|
|
324
|
+
const { setFeatureStatus } = await import('./feature-writer.js');
|
|
325
|
+
await setFeatureStatus(cwd, {
|
|
326
|
+
code: feature_code,
|
|
327
|
+
status: 'COMPLETE',
|
|
328
|
+
commit_sha,
|
|
329
|
+
reason: 'record_completion',
|
|
330
|
+
force: flipForce,
|
|
331
|
+
});
|
|
332
|
+
status_changed = { from: fromStatus, to: 'COMPLETE' };
|
|
333
|
+
} catch (flipErr) {
|
|
334
|
+
// Both failure sub-cases (transition rejected AND ROADMAP_PARTIAL_WRITE) rethrow
|
|
335
|
+
// as STATUS_FLIP_AFTER_COMPLETION_RECORDED. The completion record IS persisted (step h).
|
|
336
|
+
throw statusFlipError(
|
|
337
|
+
flipErr.code === 'ROADMAP_PARTIAL_WRITE'
|
|
338
|
+
? `completion-writer: completion recorded for "${feature_code}" but ROADMAP regen failed after status flip. ` +
|
|
339
|
+
`This is the ROADMAP_PARTIAL_WRITE subcase (Decision 4). ` +
|
|
340
|
+
`Recover with \`compose roadmap generate\`.`
|
|
341
|
+
: `completion-writer: completion recorded for "${feature_code}" but status flip to COMPLETE failed. ` +
|
|
342
|
+
`err.cause carries the underlying transition error.`,
|
|
343
|
+
flipErr,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 5j. Audit event (not appended for idempotent no-ops — they return early at 5e)
|
|
349
|
+
const auditEvent = {
|
|
350
|
+
tool: 'record_completion',
|
|
351
|
+
code: feature_code,
|
|
352
|
+
completion_id,
|
|
353
|
+
commit_sha,
|
|
354
|
+
tests_pass: args.tests_pass,
|
|
355
|
+
set_status,
|
|
356
|
+
};
|
|
357
|
+
if (args.force) auditEvent.force = args.force;
|
|
358
|
+
if (args.idempotency_key) auditEvent.idempotency_key = args.idempotency_key;
|
|
359
|
+
safeAppendEvent(cwd, auditEvent);
|
|
360
|
+
|
|
361
|
+
// 5l. Return
|
|
362
|
+
return {
|
|
363
|
+
feature_code,
|
|
364
|
+
completion_id,
|
|
365
|
+
commit_sha,
|
|
366
|
+
commit_sha_short,
|
|
367
|
+
status_changed,
|
|
368
|
+
status_flip_partial: false,
|
|
369
|
+
idempotent: false,
|
|
370
|
+
recorded_at: record.recorded_at,
|
|
371
|
+
};
|
|
372
|
+
} finally {
|
|
373
|
+
release();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// getCompletions
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Read completion records across features, with optional filtering.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} cwd
|
|
386
|
+
* @param {object} [opts]
|
|
387
|
+
* @param {string} [opts.feature_code] Exact feature code filter
|
|
388
|
+
* @param {string} [opts.commit_sha] Full or short-prefix SHA filter (permissive at read time)
|
|
389
|
+
* @param {string} [opts.since] Shorthand "7d"/"24h" or ISO date
|
|
390
|
+
* @param {number} [opts.limit] Default 50, max 500
|
|
391
|
+
*
|
|
392
|
+
* @returns {{ completions: Array, count: number }}
|
|
393
|
+
*/
|
|
394
|
+
export function getCompletions(cwd, opts = {}) {
|
|
395
|
+
// 1. Parse filters
|
|
396
|
+
const sinceMs = opts.since ? normalizeSince(opts.since) : null;
|
|
397
|
+
let limit = typeof opts.limit === 'number' ? opts.limit : DEFAULT_LIMIT;
|
|
398
|
+
if (limit < 0) limit = 0;
|
|
399
|
+
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
|
400
|
+
|
|
401
|
+
// 2. Gather candidate features
|
|
402
|
+
let features;
|
|
403
|
+
if (opts.feature_code) {
|
|
404
|
+
const f = readFeature(cwd, opts.feature_code);
|
|
405
|
+
features = f ? [f] : [];
|
|
406
|
+
} else {
|
|
407
|
+
features = listFeatures(cwd);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 3. Flatten all completions[] arrays
|
|
411
|
+
// Normalize always-present nullable fields so readers get a uniform shape
|
|
412
|
+
// regardless of hand-edited or legacy records (Decision 11).
|
|
413
|
+
const all = [];
|
|
414
|
+
for (const feature of features) {
|
|
415
|
+
if (!Array.isArray(feature.completions)) continue;
|
|
416
|
+
for (const rec of feature.completions) {
|
|
417
|
+
const normalized = { ...rec };
|
|
418
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, 'feature_code')) {
|
|
419
|
+
normalized.feature_code = null;
|
|
420
|
+
}
|
|
421
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, 'commit_sha_short')) {
|
|
422
|
+
normalized.commit_sha_short = null;
|
|
423
|
+
}
|
|
424
|
+
all.push(normalized);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 4. Filter
|
|
429
|
+
let filtered = all;
|
|
430
|
+
|
|
431
|
+
if (opts.commit_sha) {
|
|
432
|
+
const filterSha = opts.commit_sha.trim().toLowerCase();
|
|
433
|
+
if (filterSha.length > 0 && filterSha.length < 4) {
|
|
434
|
+
const err = new Error(
|
|
435
|
+
`commit_sha prefix too short: got ${filterSha.length} char(s), minimum is 4`
|
|
436
|
+
);
|
|
437
|
+
err.code = 'INVALID_INPUT';
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
filtered = filtered.filter(rec => {
|
|
441
|
+
if (typeof rec.commit_sha !== 'string') return false;
|
|
442
|
+
return rec.commit_sha === filterSha || rec.commit_sha.startsWith(filterSha);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (sinceMs !== null) {
|
|
447
|
+
filtered = filtered.filter(rec => {
|
|
448
|
+
const ms = Date.parse(rec.recorded_at);
|
|
449
|
+
return !isNaN(ms) && ms >= sinceMs;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 5. Sort desc by recorded_at
|
|
454
|
+
filtered.sort((a, b) => {
|
|
455
|
+
const ta = Date.parse(a.recorded_at) || 0;
|
|
456
|
+
const tb = Date.parse(b.recorded_at) || 0;
|
|
457
|
+
return tb - ta;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// 6. Truncate
|
|
461
|
+
const completions = filtered.slice(0, limit);
|
|
462
|
+
|
|
463
|
+
// 7. Return — feature_code from the record itself (Decision 11; null if absent)
|
|
464
|
+
return { completions, count: completions.length };
|
|
465
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feature-events.js — append-only audit log for feature-management mutations.
|
|
3
|
+
*
|
|
4
|
+
* One JSONL row per mutation. Filed by COMP-MCP-FEATURE-MGMT writers
|
|
5
|
+
* (add_roadmap_entry, set_feature_status, etc.) and read by `roadmap_diff`
|
|
6
|
+
* plus future `validate_feature`.
|
|
7
|
+
*
|
|
8
|
+
* File: <cwd>/.compose/data/feature-events.jsonl
|
|
9
|
+
*
|
|
10
|
+
* Event row shape (additive — extra fields are allowed and preserved by
|
|
11
|
+
* readers, so individual writers can attach context-specific metadata):
|
|
12
|
+
* {
|
|
13
|
+
* ts: ISO string,
|
|
14
|
+
* tool: 'add_roadmap_entry' | 'set_feature_status' | ...,
|
|
15
|
+
* code?: string,
|
|
16
|
+
* from?: string, // status transitions
|
|
17
|
+
* to?: string,
|
|
18
|
+
* reason?: string,
|
|
19
|
+
* actor: string, // process.env.COMPOSE_ACTOR || 'mcp:agent'
|
|
20
|
+
* idempotency_key?: string,
|
|
21
|
+
* ...
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
26
|
+
import { join, dirname } from 'path';
|
|
27
|
+
|
|
28
|
+
function eventsFile(cwd) {
|
|
29
|
+
return join(cwd, '.compose', 'data', 'feature-events.jsonl');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function actor() {
|
|
33
|
+
return process.env.COMPOSE_ACTOR || 'mcp:agent';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Append an event to the audit log. Caller supplies tool + payload; ts and
|
|
38
|
+
* actor are stamped here.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @param {object} event - must include `tool`; other fields are passed through
|
|
42
|
+
* @returns {object} the row that was written (with ts + actor stamped)
|
|
43
|
+
*/
|
|
44
|
+
export function appendEvent(cwd, event) {
|
|
45
|
+
if (!event || typeof event.tool !== 'string' || !event.tool) {
|
|
46
|
+
throw new Error('feature-events.appendEvent: event.tool is required');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const path = eventsFile(cwd);
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
|
|
52
|
+
const row = {
|
|
53
|
+
ts: new Date().toISOString(),
|
|
54
|
+
actor: actor(),
|
|
55
|
+
...event,
|
|
56
|
+
};
|
|
57
|
+
appendFileSync(path, JSON.stringify(row) + '\n');
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read events from the audit log, optionally filtered.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} cwd
|
|
65
|
+
* @param {object} [opts]
|
|
66
|
+
* @param {string|number|Date} [opts.since] - ISO date, ms since epoch, Date,
|
|
67
|
+
* or shorthand '24h' / '7d'. Default: read all.
|
|
68
|
+
* @param {string} [opts.code] - filter to events with `code === <value>`
|
|
69
|
+
* @param {string} [opts.tool] - filter to events with `tool === <value>`
|
|
70
|
+
* @returns {Array<object>}
|
|
71
|
+
*/
|
|
72
|
+
export function readEvents(cwd, opts = {}) {
|
|
73
|
+
const path = eventsFile(cwd);
|
|
74
|
+
if (!existsSync(path)) return [];
|
|
75
|
+
|
|
76
|
+
const sinceMs = normalizeSince(opts.since);
|
|
77
|
+
const text = readFileSync(path, 'utf-8');
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const line of text.split('\n')) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
let row;
|
|
82
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
83
|
+
if (sinceMs !== null) {
|
|
84
|
+
const ts = Date.parse(row.ts);
|
|
85
|
+
if (Number.isNaN(ts) || ts < sinceMs) continue;
|
|
86
|
+
}
|
|
87
|
+
if (opts.code && row.code !== opts.code) continue;
|
|
88
|
+
if (opts.tool && row.tool !== opts.tool) continue;
|
|
89
|
+
out.push(row);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a since value to milliseconds-since-epoch, or return null for
|
|
96
|
+
* "no filter".
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeSince(since) {
|
|
99
|
+
if (since === undefined || since === null) return null;
|
|
100
|
+
if (since instanceof Date) return since.getTime();
|
|
101
|
+
if (typeof since === 'number') return since;
|
|
102
|
+
if (typeof since !== 'string') return null;
|
|
103
|
+
|
|
104
|
+
// Shorthand: "24h" | "7d" | "30m"
|
|
105
|
+
const m = since.match(/^(\d+)([hdm])$/);
|
|
106
|
+
if (m) {
|
|
107
|
+
const n = parseInt(m[1], 10);
|
|
108
|
+
const mult = m[2] === 'h' ? 3600_000 : m[2] === 'd' ? 86_400_000 : 60_000;
|
|
109
|
+
return Date.now() - n * mult;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parsed = Date.parse(since);
|
|
113
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
114
|
+
}
|