@smartmemory/compose 0.1.34-beta → 0.1.35-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/README.md CHANGED
@@ -82,6 +82,48 @@ Check what you're running:
82
82
  compose --version
83
83
  ```
84
84
 
85
+ ## Tracker providers
86
+
87
+ Compose can persist feature data to different backends via the `tracker` block in `.compose/compose.json`.
88
+
89
+ **Default (local) — zero configuration required:**
90
+
91
+ ```json
92
+ { "tracker": { "provider": "local" } }
93
+ ```
94
+
95
+ `local` is the default when no `tracker` block is present. All writes go to the filesystem exactly as before — no behavior change.
96
+
97
+ **GitHub provider:**
98
+
99
+ ```json
100
+ {
101
+ "tracker": {
102
+ "provider": "github",
103
+ "github": {
104
+ "repo": "owner/repo",
105
+ "projectNumber": 42,
106
+ "branch": "main",
107
+ "roadmapPath": "ROADMAP.md",
108
+ "changelogPath": "CHANGELOG.md",
109
+ "cacheTtlSeconds": 300,
110
+ "auth": { "tokenEnv": "GITHUB_TOKEN" }
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ The GitHub provider syncs features to **Issues** (one per feature), **Projects v2** (`Status` custom field), and **Contents API** (roadmap + changelog files). Requires a token in the named env var (or `gh auth login` fallback) with `repo` and `project` scopes.
117
+
118
+ CLI verbs:
119
+
120
+ ```bash
121
+ compose tracker status # show provider health + pending op-log + conflict ledger
122
+ compose tracker sync # reconcile op-log against remote provider
123
+ ```
124
+
125
+ See [docs/configuration.md](docs/configuration.md) for the full `tracker` config reference.
126
+
85
127
  ## Documentation
86
128
 
87
129
  Topic-scoped reference:
package/bin/compose.js CHANGED
@@ -2674,6 +2674,22 @@ if (cmd === 'build') {
2674
2674
  process.exit(1)
2675
2675
  }
2676
2676
 
2677
+ } else if (cmd === 'tracker') {
2678
+ // ---------------------------------------------------------------------------
2679
+ // compose tracker status — print provider name, canonical, pendingOps, conflicts, mixedSources
2680
+ // compose tracker sync — flush op-log and report drained/quarantined counts
2681
+ // COMP-TRACKER-PROVIDER T18
2682
+ // ---------------------------------------------------------------------------
2683
+ try {
2684
+ const { runTrackerCli } = await import('../lib/tracker/cli.js')
2685
+ const result = await runTrackerCli(process.cwd(), args)
2686
+ console.log(result.output)
2687
+ if (result.exitCode !== 0) process.exit(result.exitCode)
2688
+ } catch (err) {
2689
+ console.error(`tracker: ${err.message}`)
2690
+ process.exit(1)
2691
+ }
2692
+
2677
2693
  } else {
2678
2694
  console.error(`Unknown command: ${cmd}`)
2679
2695
  process.exit(1)
package/lib/build.js CHANGED
@@ -31,8 +31,16 @@ import { emitSections as emitPlanSections, appendTrailers as appendSectionTraile
31
31
  import { SECTIONS_DIR } from './constants.js';
32
32
 
33
33
  import YAML from 'yaml';
34
- import { updateFeature, readFeature, writeFeature } from './feature-json.js';
34
+ // feature-json direct imports removed mutations now go through TrackerProvider (T9)
35
35
  import { loadFeaturesDir } from './project-paths.js';
36
+
37
+ // Lazy provider accessor — avoids circular import risk (factory → local-provider
38
+ // does NOT import build.js, so a static import is safe, but lazy is used for
39
+ // consistency with the pattern established in T7/T8 and to avoid any future risk).
40
+ async function getBuildProvider(cwd) {
41
+ const { providerFor } = await import('./tracker/factory.js');
42
+ return providerFor(cwd);
43
+ }
36
44
  import { evaluatePolicy } from '../server/policy-evaluator.js';
37
45
  import { runTriage, isTriageStale } from './triage.js';
38
46
  import { shouldRunCrossModel, LENS_DEFINITIONS } from './review-lenses.js';
@@ -622,18 +630,22 @@ export async function runBuild(featureCode, opts = {}) {
622
630
  // - opts.template is explicitly set (user chose a specific template)
623
631
  // ---------------------------------------------------------------------------
624
632
  let buildProfile = null;
633
+ let _buildTierLabel = '?'; // for skip_reason label in spec YAML mutation below
625
634
  // Bug mode skips pre-build triage entirely — triage is feature-shaped
626
635
  // (writes feature.json, profile selection per feature complexity tiers).
627
636
  if (!isBugMode && !opts.skipTriage && !opts.template) {
628
- let cachedFeature = readFeature(cwd, featureCode, featuresDir);
637
+ const _buildProvider = await getBuildProvider(cwd);
638
+ let cachedFeature = await _buildProvider.getFeature(featureCode);
629
639
  if (cachedFeature?.profile && !isTriageStale(cwd, featureCode, featuresDir)) {
630
640
  // Reuse cached profile
631
641
  buildProfile = cachedFeature.profile;
632
- console.log(`[triage] Using cached profile (tier ${cachedFeature.complexity ?? '?'}): ${JSON.stringify(buildProfile)}`);
642
+ _buildTierLabel = cachedFeature.complexity ?? '?';
643
+ console.log(`[triage] Using cached profile (tier ${_buildTierLabel}): ${JSON.stringify(buildProfile)}`);
633
644
  } else {
634
645
  // Run fresh triage
635
646
  const triageResult = await runTriage(featureCode, { cwd, featuresDir });
636
647
  buildProfile = triageResult.profile;
648
+ _buildTierLabel = String(triageResult.tier);
637
649
  console.log(`[triage] Tier ${triageResult.tier}: ${triageResult.rationale}`);
638
650
  console.log(`[triage] Profile: ${JSON.stringify(buildProfile)}`);
639
651
 
@@ -641,20 +653,23 @@ export async function runBuild(featureCode, opts = {}) {
641
653
  if (!cachedFeature) {
642
654
  // Create feature.json — feature folder exists but json was missing
643
655
  const featureDesc = opts.description ?? featureCode;
644
- writeFeature(cwd, {
656
+ cachedFeature = await _buildProvider.createFeature(featureCode, {
645
657
  code: featureCode,
646
658
  description: featureDesc,
647
659
  status: 'PLANNED',
648
660
  complexity: String(triageResult.tier),
649
661
  profile: buildProfile,
650
662
  triageTimestamp,
651
- }, featuresDir);
663
+ });
652
664
  } else {
653
- updateFeature(cwd, featureCode, {
665
+ // Profile/complexity cache update — no status change. Spread current
666
+ // feature so putFeature receives the full object (it overwrites, not merges).
667
+ cachedFeature = await _buildProvider.putFeature(featureCode, {
668
+ ...cachedFeature,
654
669
  complexity: String(triageResult.tier),
655
670
  profile: buildProfile,
656
671
  triageTimestamp,
657
- }, featuresDir);
672
+ });
658
673
  }
659
674
  }
660
675
  }
@@ -689,9 +704,8 @@ export async function runBuild(featureCode, opts = {}) {
689
704
  delete step.skip_reason;
690
705
  } else if (buildProfile[needsKey] === false) {
691
706
  // Disable step — mark as unconditionally skipped
692
- const tier = readFeature(cwd, featureCode, featuresDir)?.complexity ?? '?';
693
707
  step.skip_if = 'true';
694
- step.skip_reason = `Skipped by triage (tier ${tier})`;
708
+ step.skip_reason = `Skipped by triage (tier ${_buildTierLabel})`;
695
709
  }
696
710
  }
697
711
  specYaml = YAML.stringify(specObj);
@@ -752,7 +766,16 @@ export async function runBuild(featureCode, opts = {}) {
752
766
  // Update feature.json status to IN_PROGRESS (feature mode only;
753
767
  // bug mode does not use feature.json).
754
768
  if (!isBugMode) {
755
- updateFeature(cwd, featureCode, { status: 'IN_PROGRESS' }, featuresDir);
769
+ const _bp = await getBuildProvider(cwd);
770
+ // Guard: feature.json may not exist if triage was skipped AND no prior
771
+ // createFeature ran (e.g. test harnesses that only create the folder).
772
+ // Original updateFeature silently no-oped when feature was missing.
773
+ // Use persistFeatureRaw (not setStatus) — raw write with no transition policy,
774
+ // no events, no renderRoadmap. Matches original updateFeature semantics exactly.
775
+ const _feat = await _bp.getFeature(featureCode);
776
+ if (_feat) {
777
+ await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'IN_PROGRESS' });
778
+ }
756
779
  }
757
780
 
758
781
  // Hoisted for finally-block visibility
@@ -1831,7 +1854,15 @@ export async function runBuild(featureCode, opts = {}) {
1831
1854
  // COMP-QA: persist filesChanged so `compose qa-scope` can read them post-build.
1832
1855
  // Bug mode skips feature-json — bugs don't have feature.json (COMP-FIX-HARD T4).
1833
1856
  if (!isBugMode) {
1834
- updateFeature(cwd, featureCode, { status: 'COMPLETE', filesChanged: context.filesChanged ?? [] }, featuresDir);
1857
+ const _bp = await getBuildProvider(cwd);
1858
+ // Guard: feature.json may not exist when triage was skipped (test harnesses).
1859
+ // Original updateFeature silently no-oped when feature was missing.
1860
+ // Single atomic raw write (status + filesChanged together) — restores original
1861
+ // updateFeature atomicity. persistFeatureRaw: no policy, no events, no roadmap.
1862
+ const _feat = await _bp.getFeature(featureCode);
1863
+ if (_feat) {
1864
+ await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'COMPLETE', filesChanged: context.filesChanged ?? [] });
1865
+ }
1835
1866
  }
1836
1867
  const termState = readActiveBuild(dataDir);
1837
1868
  if (termState) {
@@ -1842,7 +1873,15 @@ export async function runBuild(featureCode, opts = {}) {
1842
1873
  buildStatus = 'killed';
1843
1874
  console.log('\nBuild killed.');
1844
1875
  await visionWriter.updateItemStatus(itemId, 'killed');
1845
- if (!isBugMode) updateFeature(cwd, featureCode, { status: 'PLANNED' }, featuresDir);
1876
+ if (!isBugMode) {
1877
+ const _bp = await getBuildProvider(cwd);
1878
+ const _feat = await _bp.getFeature(featureCode);
1879
+ if (_feat) {
1880
+ // Raw write back to PLANNED — no transition policy, no events, no renderRoadmap.
1881
+ // Matches original updateFeature semantics; keeps teardown side-effect-free.
1882
+ await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'PLANNED' });
1883
+ }
1884
+ }
1846
1885
  const termState = readActiveBuild(dataDir);
1847
1886
  if (termState) {
1848
1887
  writeActiveBuild(dataDir, { ...termState, status: 'aborted', completedAt: new Date().toISOString() });
@@ -1851,7 +1890,15 @@ export async function runBuild(featureCode, opts = {}) {
1851
1890
  // Ship failure or other explicit failure — write terminal state
1852
1891
  console.log('\nBuild failed.');
1853
1892
  await visionWriter.updateItemStatus(itemId, 'failed');
1854
- if (!isBugMode) updateFeature(cwd, featureCode, { status: 'PLANNED' }, featuresDir);
1893
+ if (!isBugMode) {
1894
+ const _bp = await getBuildProvider(cwd);
1895
+ const _feat = await _bp.getFeature(featureCode);
1896
+ if (_feat) {
1897
+ // Raw write back to PLANNED — no transition policy, no events, no renderRoadmap.
1898
+ // Matches original updateFeature semantics; keeps teardown side-effect-free.
1899
+ await _bp.persistFeatureRaw(featureCode, { ..._feat, status: 'PLANNED' });
1900
+ }
1901
+ }
1855
1902
  const termState = readActiveBuild(dataDir);
1856
1903
  if (termState) {
1857
1904
  writeActiveBuild(dataDir, { ...termState, status: 'failed', completedAt: new Date().toISOString() });
@@ -20,14 +20,19 @@
20
20
  * CLI, or future REST routes.
21
21
  */
22
22
 
23
- import {
24
- readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync,
25
- } from 'node:fs';
26
- import { join, dirname } from 'node:path';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
27
25
 
28
- import { appendEvent, normalizeSince } from './feature-events.js';
26
+ import { normalizeSince } from './feature-events.js';
29
27
  import { checkOrInsert } from './idempotency.js';
30
28
 
29
+ // providerFor is imported lazily to avoid the load-time cycle:
30
+ // factory.js → local-provider.js → changelog-writer.js
31
+ async function getProvider(cwd) {
32
+ const { providerFor } = await import('./tracker/factory.js');
33
+ return providerFor(cwd);
34
+ }
35
+
31
36
  // ---------------------------------------------------------------------------
32
37
  // Validation helpers
33
38
  // ---------------------------------------------------------------------------
@@ -304,9 +309,13 @@ function readChangelogText(cwd) {
304
309
  return readFileSync(p, 'utf-8');
305
310
  }
306
311
 
307
- function safeAppendEvent(cwd, event) {
312
+ // Routes through provider.appendEvent so GitHubProvider can post
313
+ // <!--compose-event--> comments. LocalFileProvider delegates to
314
+ // feature-events.js#appendEvent producing byte-identical output.
315
+ async function safeAppendEvent(cwd, event) {
308
316
  try {
309
- appendEvent(cwd, event);
317
+ const provider = await getProvider(cwd);
318
+ await provider.appendEvent(event.code, event);
310
319
  } catch (err) {
311
320
  // eslint-disable-next-line no-console
312
321
  console.warn(`[changelog-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
@@ -320,17 +329,86 @@ function maybeIdempotent(args, fn) {
320
329
  return Promise.resolve().then(fn);
321
330
  }
322
331
 
323
- function atomicWrite(cwd, content) {
324
- const p = changelogPath(cwd);
325
- const tmp = p + '.tmp';
326
- mkdirSync(dirname(p), { recursive: true });
327
- try {
328
- writeFileSync(tmp, content);
329
- renameSync(tmp, p);
330
- } catch (err) {
331
- try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* ignore */ }
332
- throw err;
332
+ /**
333
+ * spliceChangelog(currentText, entry) — pure string-in / string-out transform.
334
+ *
335
+ * Parses `currentText`, applies the mutation described by `entry` (same shape
336
+ * as the `args` to addChangelogEntry), and returns the new file content.
337
+ *
338
+ * Exported so future providers (e.g. GitHubProvider) can fetch a remote blob,
339
+ * pass it here, then PUT the result back — without touching the local FS.
340
+ *
341
+ * Throws formatError if the file exists but lacks the `# Changelog` header.
342
+ * Returns { content, insertedAtLine, surface, action } — callers write content.
343
+ */
344
+ export function spliceChangelog(currentText, entry) {
345
+ const text = currentText;
346
+ if (text.length > 0 && !H1_RE.test(text.split('\n')[0] ?? '')) {
347
+ throw formatError('first line must be "# Changelog" (line 1)');
348
+ }
349
+
350
+ const parsed = parseChangelog(text);
351
+
352
+ // Find existing entry across all matching surfaces.
353
+ const matchingSurfaces = parsed.surfaces.filter(s => s.label === entry.date_or_version);
354
+ let existingSurface = null;
355
+ let existingEntry = null;
356
+ for (const s of matchingSurfaces) {
357
+ const e = s.entries.find(en => en.code === entry.code);
358
+ if (e) { existingSurface = s; existingEntry = e; break; }
359
+ }
360
+
361
+ const rendered = renderEntry({
362
+ code: entry.code,
363
+ summary: entry.summary,
364
+ body: entry.body,
365
+ sections: entry.sections,
366
+ });
367
+
368
+ let action;
369
+ let chosenSurfaceLabel = entry.date_or_version;
370
+ let chosenSurfaceStartLine = -1;
371
+
372
+ if (existingEntry && !entry.force) {
373
+ // Storage-level idempotent no-op — caller decides what to do with this.
374
+ return {
375
+ content: null,
376
+ insertedAtLine: existingEntry.startLine,
377
+ surface: existingSurface.label,
378
+ idempotent: true,
379
+ };
333
380
  }
381
+
382
+ if (existingEntry && entry.force) {
383
+ action = { kind: 'replace', entry: existingEntry, code: entry.code };
384
+ chosenSurfaceLabel = existingSurface.label;
385
+ chosenSurfaceStartLine = existingSurface.startLine;
386
+ } else if (matchingSurfaces.length > 0) {
387
+ const surface = matchingSurfaces[0];
388
+ action = { kind: 'append-to-surface', surface, code: entry.code };
389
+ chosenSurfaceLabel = surface.label;
390
+ chosenSurfaceStartLine = surface.startLine;
391
+ } else if (text.length === 0) {
392
+ action = { kind: 'new-file', code: entry.code };
393
+ chosenSurfaceStartLine = 3;
394
+ } else {
395
+ action = { kind: 'new-surface', code: entry.code };
396
+ }
397
+
398
+ const { content, insertedAtLine } = buildNextContent(text, parsed, action, rendered, entry.date_or_version);
399
+
400
+ // For new-surface, recompute chosenSurfaceStartLine from output.
401
+ if (action.kind === 'new-surface' || action.kind === 'new-file') {
402
+ const outLines = content.split('\n');
403
+ for (let i = 0; i < outLines.length; i++) {
404
+ if (outLines[i] === `## ${entry.date_or_version}`) {
405
+ chosenSurfaceStartLine = i + 1;
406
+ break;
407
+ }
408
+ }
409
+ }
410
+
411
+ return { content, insertedAtLine, surface: chosenSurfaceLabel, idempotent: false, action, chosenSurfaceStartLine };
334
412
  }
335
413
 
336
414
  /**
@@ -507,91 +585,41 @@ export async function addChangelogEntry(cwd, args) {
507
585
  validateSummary(args.summary);
508
586
  validateSections(args.sections);
509
587
 
510
- return maybeIdempotent({ ...args, cwd }, () => {
511
- const text = readChangelogText(cwd);
512
- if (text.length > 0 && !H1_RE.test(text.split('\n')[0] ?? '')) {
513
- throw formatError('first line must be "# Changelog" (line 1)');
514
- }
588
+ return maybeIdempotent({ ...args, cwd }, async () => {
589
+ // Route through provider low-level primitives (getChangelog / putChangelog).
590
+ // Do NOT call provider.appendChangelog that's LocalFileProvider→addChangelogEntry = recursion.
591
+ const provider = await getProvider(cwd);
592
+ const text = await provider.getChangelog();
515
593
 
516
- const parsed = parseChangelog(text);
517
-
518
- // Find existing entry across all matching surfaces.
519
- const matchingSurfaces = parsed.surfaces.filter(s => s.label === args.date_or_version);
520
- let existingSurface = null;
521
- let existingEntry = null;
522
- for (const s of matchingSurfaces) {
523
- const e = s.entries.find(en => en.code === args.code);
524
- if (e) { existingSurface = s; existingEntry = e; break; }
525
- }
594
+ const spliced = spliceChangelog(text, args);
526
595
 
527
- const rendered = renderEntry({
528
- code: args.code,
529
- summary: args.summary,
530
- body: args.body,
531
- sections: args.sections,
532
- });
533
-
534
- let action;
535
- let chosenSurfaceLabel = args.date_or_version;
536
- let chosenSurfaceStartLine = -1;
537
-
538
- if (existingEntry && !args.force) {
596
+ if (spliced.idempotent) {
539
597
  // Storage-level idempotent no-op: no file write, no audit event
540
598
  // (Decision 2 of design). Caller-supplied idempotency_key replays are
541
599
  // handled separately by the maybeIdempotent wrapper above.
542
600
  return {
543
- inserted_at: existingEntry.startLine,
601
+ inserted_at: spliced.insertedAtLine,
544
602
  idempotent: true,
545
- surface: existingSurface.label,
603
+ surface: spliced.surface,
546
604
  };
547
605
  }
548
606
 
549
- if (existingEntry && args.force) {
550
- action = { kind: 'replace', entry: existingEntry, code: args.code };
551
- chosenSurfaceLabel = existingSurface.label;
552
- chosenSurfaceStartLine = existingSurface.startLine;
553
- } else if (matchingSurfaces.length > 0) {
554
- // No existing entry; insert into first (topmost) matching surface.
555
- const surface = matchingSurfaces[0];
556
- action = { kind: 'append-to-surface', surface, code: args.code };
557
- chosenSurfaceLabel = surface.label;
558
- chosenSurfaceStartLine = surface.startLine;
559
- } else if (text.length === 0) {
560
- action = { kind: 'new-file', code: args.code };
561
- chosenSurfaceStartLine = 3; // line of new surface heading after H1 + blank
562
- } else {
563
- action = { kind: 'new-surface', code: args.code };
564
- }
565
-
566
- const { content, insertedAtLine } = buildNextContent(text, parsed, action, rendered, args.date_or_version);
567
-
568
- // For new-surface, recompute chosenSurfaceStartLine from output.
569
- if (action.kind === 'new-surface' || action.kind === 'new-file') {
570
- const outLines = content.split('\n');
571
- for (let i = 0; i < outLines.length; i++) {
572
- if (outLines[i] === `## ${args.date_or_version}`) {
573
- chosenSurfaceStartLine = i + 1;
574
- break;
575
- }
576
- }
577
- }
578
-
579
- atomicWrite(cwd, content);
607
+ await provider.putChangelog(spliced.content);
580
608
 
581
609
  const event = {
582
610
  tool: 'add_changelog_entry',
583
611
  code: args.code,
584
- surface_label: chosenSurfaceLabel,
585
- surface_start_line: chosenSurfaceStartLine,
612
+ surface_label: spliced.surface,
613
+ surface_start_line: spliced.chosenSurfaceStartLine,
586
614
  };
587
615
  if (args.idempotency_key) event.idempotency_key = args.idempotency_key;
588
- if (action.kind === 'replace') event.force = true;
589
- safeAppendEvent(cwd, event);
616
+ if (spliced.action?.kind === 'replace') event.force = true;
617
+ await safeAppendEvent(cwd, event);
590
618
 
591
619
  return {
592
- inserted_at: insertedAtLine,
620
+ inserted_at: spliced.insertedAtLine,
593
621
  idempotent: false,
594
- surface: chosenSurfaceLabel,
622
+ surface: spliced.surface,
595
623
  };
596
624
  });
597
625
  }
@@ -24,12 +24,19 @@
24
24
  import { mkdirSync, rmSync, statSync } from 'fs';
25
25
  import { join, dirname, posix } from 'path';
26
26
 
27
- import { readFeature, updateFeature, listFeatures } from './feature-json.js';
27
+ import { readFeature, listFeatures } from './feature-json.js';
28
28
  import { loadFeaturesDir } from './project-paths.js';
29
- import { appendEvent, normalizeSince } from './feature-events.js';
29
+ import { normalizeSince } from './feature-events.js';
30
30
  import { checkOrInsert } from './idempotency.js';
31
31
  import { FEATURE_CODE_RE_STRICT as FEATURE_CODE_RE } from './feature-code.js';
32
32
 
33
+ // providerFor is imported lazily to avoid the load-time cycle:
34
+ // factory.js → local-provider.js → completion-writer.js
35
+ async function getProvider(cwd) {
36
+ const { providerFor } = await import('./tracker/factory.js');
37
+ return providerFor(cwd);
38
+ }
39
+
33
40
  // ---------------------------------------------------------------------------
34
41
  // Constants + regexes
35
42
  // ---------------------------------------------------------------------------
@@ -206,11 +213,17 @@ function maybeIdempotent(args, fn) {
206
213
  // ---------------------------------------------------------------------------
207
214
  // safeAppendEvent — best-effort; failed append must NOT roll back a committed
208
215
  // mutation (per sibling-writer convention).
216
+ //
217
+ // Routes through provider.appendEvent so GitHubProvider can post
218
+ // <!--compose-event--> comments. LocalFileProvider delegates to
219
+ // feature-events.js#appendEvent producing byte-identical output.
209
220
  // ---------------------------------------------------------------------------
210
221
 
211
- function safeAppendEvent(cwd, event) {
222
+ async function safeAppendEvent(cwd, event) {
212
223
  try {
213
- appendEvent(cwd, event);
224
+ const { providerFor } = await import('./tracker/factory.js');
225
+ const provider = await providerFor(cwd);
226
+ await provider.appendEvent(event.code, event);
214
227
  } catch (err) {
215
228
  // eslint-disable-next-line no-console
216
229
  console.warn(
@@ -252,14 +265,16 @@ export async function recordCompletion(cwd, args) {
252
265
  // 3. Compute completion_id
253
266
  const completion_id = `${feature_code}:${commit_sha}`;
254
267
 
255
- const featuresDir = loadFeaturesDir(cwd);
256
268
  // 4. Wrap in maybeIdempotent for caller-key path
257
269
  return maybeIdempotent({ ...args, cwd }, async () => {
270
+ // Acquire provider once per operation (resolves factory, no per-call overhead)
271
+ const provider = await getProvider(cwd);
272
+
258
273
  // 5a. Acquire per-feature advisory lock (Decision 10)
259
274
  const release = await acquireFeatureLock(cwd, feature_code);
260
275
  try {
261
- // 5b. Read feature
262
- const feature = readFeature(cwd, feature_code, featuresDir);
276
+ // 5b. Read feature via provider (low-level primitive — no recursion risk)
277
+ const feature = await provider.getFeature(feature_code);
263
278
  if (!feature) throw notFoundError(feature_code);
264
279
 
265
280
  // 5c. Snapshot completions array
@@ -303,7 +318,9 @@ export async function recordCompletion(cwd, args) {
303
318
  }
304
319
 
305
320
  // 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
306
- updateFeature(cwd, feature_code, { completions }, featuresDir);
321
+ // Use persistFeatureRaw (policy-free, no status-delta guard) completion-writer
322
+ // owns its own ordering/locking and only mutates the completions array, not status.
323
+ await provider.persistFeatureRaw(feature_code, { ...feature, completions });
307
324
 
308
325
  // 5i. Status flip (default on)
309
326
  const set_status = args.set_status !== false;
@@ -357,7 +374,7 @@ export async function recordCompletion(cwd, args) {
357
374
  };
358
375
  if (args.force) auditEvent.force = args.force;
359
376
  if (args.idempotency_key) auditEvent.idempotency_key = args.idempotency_key;
360
- safeAppendEvent(cwd, auditEvent);
377
+ await safeAppendEvent(cwd, auditEvent);
361
378
 
362
379
  // 5l. Return
363
380
  return {