@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.
@@ -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
+ }