@smartmemory/compose 0.1.34-beta → 0.1.36-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.
Files changed (71) hide show
  1. package/README.md +42 -0
  2. package/bin/compose.js +16 -0
  3. package/dist/assets/{App-DMCO9aNs.js → App-DO9nGI18.js} +8 -8
  4. package/dist/assets/{arc-DsXb95RZ.js → arc-DrI_lR89.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-BaBYippI.js → architectureDiagram-3BPJPVTR-DyS14rP-.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-HwB_eL3_.js → blockDiagram-GPEHLZMM-elTUtlnM.js} +1 -1
  7. package/dist/assets/{c4Diagram-AAUBKEIU-CPSXghc8.js → c4Diagram-AAUBKEIU-CJ_8nsiM.js} +1 -1
  8. package/dist/assets/channel-Cda02Ntm.js +1 -0
  9. package/dist/assets/{chunk-2J33WTMH-CCN3bc9J.js → chunk-2J33WTMH-BllJC9rQ.js} +1 -1
  10. package/dist/assets/{chunk-4BX2VUAB-DuqFxpoV.js → chunk-4BX2VUAB-f3EEUpDr.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-DT20mkDV.js → chunk-55IACEB6-CgbUlP7I.js} +1 -1
  12. package/dist/assets/{chunk-727SXJPM-ByMH6Qvp.js → chunk-727SXJPM-D9zRfRSY.js} +1 -1
  13. package/dist/assets/{chunk-AQP2D5EJ-CLgYtOHw.js → chunk-AQP2D5EJ-Bjp1xaJ8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-BXWmTsAA.js → chunk-FMBD7UC4-Dyvhvbkr.js} +1 -1
  15. package/dist/assets/{chunk-ND2GUHAM-C2WgVbpE.js → chunk-ND2GUHAM-D4dMBzz5.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-DFuQRJeh.js → chunk-QZHKN3VN-CVhvkQt1.js} +1 -1
  17. package/dist/assets/classDiagram-4FO5ZUOK-BykFns4A.js +1 -0
  18. package/dist/assets/classDiagram-v2-Q7XG4LA2-BykFns4A.js +1 -0
  19. package/dist/assets/{cose-bilkent-S5V4N54A-CdyvK5N2.js → cose-bilkent-S5V4N54A-CylcCFl3.js} +1 -1
  20. package/dist/assets/{dagre-BM42HDAG-Drkta_n5.js → dagre-BM42HDAG-Dd1Ny0g8.js} +1 -1
  21. package/dist/assets/{diagram-2AECGRRQ-BRiBkuu5.js → diagram-2AECGRRQ-tVs4SxwR.js} +1 -1
  22. package/dist/assets/{diagram-5GNKFQAL-IrSBDK26.js → diagram-5GNKFQAL-UC1fUNjx.js} +1 -1
  23. package/dist/assets/{diagram-KO2AKTUF-BUktYepH.js → diagram-KO2AKTUF-Cxhb85Xt.js} +1 -1
  24. package/dist/assets/{diagram-LMA3HP47-B5erGOiF.js → diagram-LMA3HP47-DJMx1YAp.js} +1 -1
  25. package/dist/assets/{diagram-OG6HWLK6-5KoSfwod.js → diagram-OG6HWLK6-k_cRur_6.js} +1 -1
  26. package/dist/assets/{erDiagram-TEJ5UH35-CXSf-i6t.js → erDiagram-TEJ5UH35-ClfbMYdQ.js} +1 -1
  27. package/dist/assets/{flowDiagram-I6XJVG4X-DiwEgd9q.js → flowDiagram-I6XJVG4X-B9KvjWpm.js} +1 -1
  28. package/dist/assets/{ganttDiagram-6RSMTGT7-zQ94YEl2.js → ganttDiagram-6RSMTGT7-CCqvcQcy.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-PVQCEYII-CWNWantF.js → gitGraphDiagram-PVQCEYII-DN9gJgDY.js} +1 -1
  30. package/dist/assets/{graph-DPbJeZyN.js → graph-uO5hwVZK.js} +1 -1
  31. package/dist/assets/{index-CyFM4bTc.js → index-CtjbBZuo.js} +3 -3
  32. package/dist/assets/index-Dh2rRpBR.css +1 -0
  33. package/dist/assets/{infoDiagram-5YYISTIA-BcnrgEm6.js → infoDiagram-5YYISTIA-BZjN-ULc.js} +1 -1
  34. package/dist/assets/{ishikawaDiagram-YF4QCWOH-BRzURsJQ.js → ishikawaDiagram-YF4QCWOH-Ck1kwRqz.js} +1 -1
  35. package/dist/assets/{journeyDiagram-JHISSGLW-CdwMwMPo.js → journeyDiagram-JHISSGLW-CHKyH4p5.js} +1 -1
  36. package/dist/assets/{kanban-definition-UN3LZRKU-Difj4Zd-.js → kanban-definition-UN3LZRKU-Bq4CuVJj.js} +1 -1
  37. package/dist/assets/{linear-CKwgBFBW.js → linear-h6UanLnU.js} +1 -1
  38. package/dist/assets/{mindmap-definition-RKZ34NQL-C2aCD1L4.js → mindmap-definition-RKZ34NQL-IQcATrSw.js} +1 -1
  39. package/dist/assets/mobile-BwduHUEq.js +17 -0
  40. package/dist/assets/{pieDiagram-4H26LBE5-C9qGrfV0.js → pieDiagram-4H26LBE5-B7cw_oKH.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-W4KKPZXB-COA0Z2JV.js → quadrantDiagram-W4KKPZXB-CqiNInwZ.js} +1 -1
  42. package/dist/assets/{requirementDiagram-4Y6WPE33-BJCU8yFE.js → requirementDiagram-4Y6WPE33-CynhQVI1.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-5OEKKPKP-vYzK7FJ6.js → sankeyDiagram-5OEKKPKP-aFS3y10_.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-3UESZ5HK-Bkh_RWpN.js → sequenceDiagram-3UESZ5HK-CTvZcil0.js} +1 -1
  45. package/dist/assets/{stateDiagram-AJRCARHV-BlUsbYTW.js → stateDiagram-AJRCARHV-DYXgroJe.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-BHNVJYJU-BVQEXVG-.js +1 -0
  47. package/dist/assets/{timeline-definition-PNZ67QCA-DuYEZwxg.js → timeline-definition-PNZ67QCA-BtN3u7JC.js} +1 -1
  48. package/dist/assets/{vennDiagram-CIIHVFJN-D9a3Q3Ni.js → vennDiagram-CIIHVFJN-DsssmbTq.js} +1 -1
  49. package/dist/assets/{wardley-L42UT6IY-h2fQnc_J.js → wardley-L42UT6IY-BXD_QFvV.js} +1 -1
  50. package/dist/assets/{wardleyDiagram-YWT4CUSO-C-_dzSY5.js → wardleyDiagram-YWT4CUSO-CgZmwLSB.js} +1 -1
  51. package/dist/assets/{xychartDiagram-2RQKCTM6-CKxMIB7j.js → xychartDiagram-2RQKCTM6-BmiRpk6f.js} +1 -1
  52. package/dist/index.html +3 -3
  53. package/lib/build.js +60 -13
  54. package/lib/changelog-writer.js +111 -83
  55. package/lib/completion-writer.js +26 -9
  56. package/lib/feature-writer.js +62 -38
  57. package/lib/roadmap-gen.js +41 -14
  58. package/lib/tracker/cli.js +31 -0
  59. package/lib/tracker/factory.js +93 -0
  60. package/lib/tracker/github-api.js +115 -0
  61. package/lib/tracker/github-provider.js +641 -0
  62. package/lib/tracker/local-provider.js +202 -0
  63. package/lib/tracker/provider.js +40 -0
  64. package/lib/tracker/sync-engine.js +131 -0
  65. package/package.json +3 -2
  66. package/dist/assets/channel-TOlxWxU-.js +0 -1
  67. package/dist/assets/classDiagram-4FO5ZUOK-DZsvwI1V.js +0 -1
  68. package/dist/assets/classDiagram-v2-Q7XG4LA2-DZsvwI1V.js +0 -1
  69. package/dist/assets/index-CHkeTiSt.css +0 -1
  70. package/dist/assets/mobile-CsuriFuT.js +0 -17
  71. package/dist/assets/stateDiagram-v2-BHNVJYJU-_AUWPuja.js +0 -1
@@ -0,0 +1,641 @@
1
+ import { mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { TrackerProvider, TrackerConfigError, CAP } from './provider.js';
4
+ import { GitHubApi } from './github-api.js';
5
+ import { OpLog, Cache, ConflictLedger, Reconciler } from './sync-engine.js';
6
+ import { generateRoadmapFromBase } from '../roadmap-gen.js';
7
+ import { spliceChangelog } from '../changelog-writer.js';
8
+
9
+ const META_RE = /<!--compose-feature\n([\s\S]*?)\n-->/;
10
+ function encodeBody(obj) {
11
+ return `${obj.description ?? ''}\n\n<!--compose-feature\n${JSON.stringify(obj, null, 2)}\n-->`;
12
+ }
13
+ function decodeBody(body) {
14
+ const m = META_RE.exec(body ?? '');
15
+ if (!m) return null;
16
+ try { return JSON.parse(m[1]); } catch { return null; }
17
+ }
18
+
19
+ const TERMINAL_STATUSES = new Set(['COMPLETE', 'KILLED', 'SUPERSEDED']);
20
+
21
+ export class GitHubProvider extends TrackerProvider {
22
+ name() { return 'github'; }
23
+ capabilities() { return new Set([CAP.FEATURES, CAP.EVENTS, CAP.ROADMAP, CAP.CHANGELOG]); }
24
+
25
+ async init(cwd, cfg) {
26
+ this.cwd = cwd;
27
+ this.cfg = cfg;
28
+ const dataDir = join(cwd, '.compose/data');
29
+ mkdirSync(dataDir, { recursive: true });
30
+ this.api = new GitHubApi(cfg, cfg._transport ?? null);
31
+ this.log = new OpLog(dataDir);
32
+ this.cache = new Cache(dataDir);
33
+ this.idmap = new Cache(join(dataDir, 'idmap'));
34
+ this._dataDir = dataDir;
35
+ this._locks = new Map();
36
+ this._projectMeta = null; // memoized once per process: { projectId, fieldId, optionsByName }
37
+ this.reconciler = new Reconciler({
38
+ log: this.log,
39
+ cache: this.cache,
40
+ dir: dataDir,
41
+ apply: (op) => this._applyOp(op),
42
+ });
43
+
44
+ // Probe 1: verify token can reach the repo (catches missing `repo` scope or wrong repo).
45
+ const repoResp = await this.api.getRepo();
46
+ if (repoResp.status !== 200) {
47
+ throw new TrackerConfigError(
48
+ `GitHub repo "${cfg.repo}" not accessible — token missing \`repo\` scope or repo not found`,
49
+ { missingScope: 'repo', status: repoResp.status },
50
+ );
51
+ }
52
+
53
+ // Probe 2: if projectNumber is configured, verify Projects v2 access.
54
+ if (cfg.projectNumber) {
55
+ const accessErr = await this._probeProjectsAccess();
56
+ if (accessErr) {
57
+ throw new TrackerConfigError(
58
+ `GitHub Projects v2 (project #${cfg.projectNumber}) not accessible — token missing \`project\` scope`,
59
+ { missingScope: 'project', projectNumber: cfg.projectNumber },
60
+ );
61
+ }
62
+ }
63
+
64
+ return this;
65
+ }
66
+
67
+ // Probe Projects v2 access during init. Returns a truthy error string when access is denied,
68
+ // null when access is fine. This is separate from _resolveProjectMeta so it never caches.
69
+ async _probeProjectsAccess() {
70
+ const projectNumber = this.cfg.projectNumber;
71
+ const owner = this.cfg.repo.split('/')[0];
72
+ const query = `
73
+ query($owner: String!, $number: Int!) {
74
+ ${owner}: repositoryOwner(login: $owner) {
75
+ projectV2(number: $number) { id }
76
+ }
77
+ }
78
+ `;
79
+ const { errors } = await this.api.graphql(query, { owner, number: projectNumber });
80
+ if (errors?.length) {
81
+ // Distinguish actual access errors from "project not found" — both cause a probe failure.
82
+ return errors[0]?.message ?? 'access denied';
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // Per-code serialisation: each code gets its own promise chain so concurrent
88
+ // creates for different codes run in parallel while the same code is FIFO.
89
+ _lock(code, fn) {
90
+ const prev = this._locks.get(code) ?? Promise.resolve();
91
+ const next = prev.then(fn, fn);
92
+ this._locks.set(code, next.catch(() => {}));
93
+ return next;
94
+ }
95
+
96
+ async getFeature(code) {
97
+ return this.cache.get(code);
98
+ }
99
+
100
+ async listFeatures() {
101
+ const store = await this.cache.all();
102
+ return Object.values(store)
103
+ .map(e => e.value)
104
+ .sort((a, b) =>
105
+ (a.position ?? 0) - (b.position ?? 0) ||
106
+ String(a.code).localeCompare(String(b.code))
107
+ );
108
+ }
109
+
110
+ async createFeature(code, obj) {
111
+ return this._lock(code, async () => {
112
+ // Idempotent: if already in cache, return it.
113
+ const existing = await this.cache.get(code);
114
+ if (existing) return existing;
115
+ await this.cache.put(code, obj, { version: null, pending: true });
116
+ await this.cache.markPending(code);
117
+ await this.log.append({ op: 'createFeature', code, payload: obj, baseVersion: null });
118
+ await this.reconciler.flush();
119
+ return this.cache.get(code);
120
+ });
121
+ }
122
+
123
+ async putFeature(code, obj) {
124
+ return this._lock(code, async () => {
125
+ const cur = await this.cache.get(code);
126
+ if (cur && obj.status && obj.status !== cur.status) {
127
+ throw new Error(`putFeature: status delta not allowed; use setStatus`);
128
+ }
129
+ await this.cache.put(code, obj, { pending: true });
130
+ await this.cache.markPending(code);
131
+ await this.log.append({
132
+ op: 'putFeature',
133
+ code,
134
+ payload: obj,
135
+ baseVersion: await this.cache.version(code),
136
+ });
137
+ await this.reconciler.flush();
138
+ return this.cache.get(code);
139
+ });
140
+ }
141
+
142
+ // Raw write that allows status change (used by setStatus / policy layers).
143
+ async persistFeatureRaw(code, obj) {
144
+ return this._lock(code, async () => {
145
+ await this.cache.put(code, obj, { pending: true });
146
+ await this.cache.markPending(code);
147
+ await this.log.append({
148
+ op: 'persistFeatureRaw',
149
+ code,
150
+ payload: obj,
151
+ baseVersion: await this.cache.version(code),
152
+ });
153
+ await this.reconciler.flush();
154
+ return this.cache.get(code);
155
+ });
156
+ }
157
+
158
+ async setStatus(code, to, meta = {}) {
159
+ return this._lock(code, async () => {
160
+ const cur = await this.cache.get(code);
161
+ if (!cur) throw new Error(`setStatus: feature "${code}" not found`);
162
+ const event = {
163
+ type: 'status',
164
+ from: cur.status,
165
+ to,
166
+ ts: Date.now(),
167
+ by: meta.by ?? meta.reason ?? 'agent',
168
+ };
169
+ await this.cache.put(code, { ...cur, status: to }, { pending: true });
170
+ await this.cache.markPending(code);
171
+ await this.log.append({
172
+ op: 'setStatus',
173
+ code,
174
+ payload: { to, event },
175
+ baseVersion: await this.cache.version(code),
176
+ });
177
+ await this.reconciler.flush();
178
+ return this.cache.get(code);
179
+ });
180
+ }
181
+
182
+ async recordCompletion(code, rec) {
183
+ // commit_sha is required for replay-safe dedup
184
+ if (!rec.commit_sha) throw new Error('recordCompletion: commit_sha is required');
185
+ return this._lock(code, async () => {
186
+ const cur = await this.cache.get(code);
187
+ if (!cur) throw new Error(`recordCompletion: feature "${code}" not found`);
188
+ const completions = cur.completions ?? [];
189
+ // Dedup by commit_sha in cache path (per-code lock makes concurrent calls sequential)
190
+ if (completions.some(c => c.commit_sha === rec.commit_sha)) {
191
+ return this.cache.get(code);
192
+ }
193
+ const completion = { ...rec };
194
+ const next = { ...cur, completions: [...completions, completion] };
195
+ await this.cache.put(code, next, { pending: true });
196
+ await this.cache.markPending(code);
197
+ await this.log.append({
198
+ op: 'recordCompletion',
199
+ code,
200
+ payload: { completion },
201
+ baseVersion: await this.cache.version(code),
202
+ });
203
+ await this.reconciler.flush();
204
+ return this.cache.get(code);
205
+ });
206
+ }
207
+
208
+ async appendEvent(code, event) {
209
+ const id = await this._resolveIssueId(code);
210
+ await this._postEvent(id.issueNumber, event);
211
+
212
+ // Mirror status changes to Projects v2 (best-effort, non-fatal).
213
+ // The set_feature_status writer emits { tool: 'set_feature_status', from, to }.
214
+ const newStatus = event.to ?? (event.type === 'status' ? event.to : undefined);
215
+ if ((event.tool === 'set_feature_status' || event.type === 'status') && newStatus) {
216
+ try {
217
+ const issue = await this.api.getIssue(id.issueNumber);
218
+ await this._mirrorProjectV2Status(issue, newStatus);
219
+ } catch (err) {
220
+ // Non-fatal: Projects v2 is a mirror only.
221
+ console.warn('[tracker] appendEvent: Projects v2 mirror error (non-fatal):', err?.message);
222
+ }
223
+ }
224
+ }
225
+
226
+ async _postEvent(issueNumber, event) {
227
+ await this.api.addIssueComment(issueNumber, `<!--compose-event ${JSON.stringify(event)}-->`);
228
+ }
229
+
230
+ async readEvents(code) {
231
+ let id;
232
+ try {
233
+ id = await this._resolveIssueId(code);
234
+ } catch {
235
+ return [];
236
+ }
237
+ const EVENT_RE = /^<!--compose-event ([\s\S]*?)-->$/;
238
+ const comments = await this.api.listIssueComments(id.issueNumber);
239
+ const events = [];
240
+ for (const comment of comments) {
241
+ const m = EVENT_RE.exec((comment.body ?? '').trim());
242
+ if (!m) continue;
243
+ try {
244
+ events.push(JSON.parse(m[1]));
245
+ } catch {
246
+ // skip malformed
247
+ }
248
+ }
249
+ return events;
250
+ }
251
+
252
+ async health() {
253
+ const pendingOps = (await this.log.pending()).length;
254
+ const ledger = new ConflictLedger(this._dataDir);
255
+ const conflicts = (await ledger.all()).length;
256
+
257
+ // mixedSources: CAP entities NOT supported by this provider that the factory routes locally.
258
+ // GitHubProvider supports FEATURES, EVENTS, ROADMAP, CHANGELOG.
259
+ // JOURNAL and VISION always fall back to local.
260
+ const mixedSources = [CAP.JOURNAL, CAP.VISION]
261
+ .filter(cap => !this.capabilities().has(cap))
262
+ .map(cap => cap.toLowerCase());
263
+
264
+ return {
265
+ ok: true,
266
+ provider: 'github',
267
+ canonical: 'github',
268
+ pendingOps,
269
+ conflicts,
270
+ mixedSources,
271
+ };
272
+ }
273
+
274
+ async sync() {
275
+ const before = (await this.log.pending()).length;
276
+ const quarantinedBefore = (await this.log.quarantined()).length;
277
+ await this.reconciler.flush();
278
+ const after = (await this.log.pending()).length;
279
+ const quarantinedAfter = (await this.log.quarantined()).length;
280
+ const newlyQuarantined = quarantinedAfter - quarantinedBefore;
281
+ const drained = (before - after) - newlyQuarantined; // only truly-resolved ops
282
+ return { drained, quarantined: quarantinedAfter, pending: after };
283
+ }
284
+
285
+ // Resolve Projects v2 metadata once per process instance. Returns null if
286
+ // projectNumber is not configured (Projects v2 mirror is skipped silently).
287
+ async _resolveProjectMeta() {
288
+ if (this._projectMeta !== null) return this._projectMeta;
289
+
290
+ const projectNumber = this.cfg.projectNumber;
291
+ if (!projectNumber) {
292
+ this._projectMeta = undefined; // cache absence so we don't retry
293
+ return undefined;
294
+ }
295
+
296
+ const owner = this.cfg.repo.split('/')[0];
297
+ const query = `
298
+ query($owner: String!, $number: Int!) {
299
+ ${owner}: repositoryOwner(login: $owner) {
300
+ projectV2(number: $number) {
301
+ id
302
+ field(name: "Status") {
303
+ ... on ProjectV2SingleSelectField {
304
+ id
305
+ options { id name }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ `;
312
+ const { data, errors } = await this.api.graphql(query, { owner, number: projectNumber });
313
+ if (errors?.length) {
314
+ console.warn('[tracker] Projects v2 metadata lookup failed:', errors);
315
+ this._projectMeta = undefined;
316
+ return undefined;
317
+ }
318
+ const proj = data?.[owner]?.projectV2;
319
+ if (!proj) {
320
+ console.warn('[tracker] Projects v2: project not found for number', projectNumber);
321
+ this._projectMeta = undefined;
322
+ return undefined;
323
+ }
324
+ const optionsByName = {};
325
+ for (const opt of (proj.field?.options ?? [])) {
326
+ optionsByName[opt.name] = opt.id;
327
+ }
328
+ this._projectMeta = { projectId: proj.id, fieldId: proj.field?.id, optionsByName };
329
+ return this._projectMeta;
330
+ }
331
+
332
+ // Best-effort Projects v2 status mirror. NEVER throws — label+body+state are source of truth.
333
+ async _mirrorProjectV2Status(issue, statusValue) {
334
+ try {
335
+ const meta = await this._resolveProjectMeta();
336
+ if (!meta) return; // not configured or lookup failed
337
+
338
+ const optionId = meta.optionsByName[statusValue];
339
+ if (!optionId) {
340
+ console.warn(`[tracker] Projects v2: no option for status "${statusValue}" — skipping mirror`);
341
+ return;
342
+ }
343
+
344
+ // Add issue to the project (idempotent on GitHub's side) and get the item id.
345
+ const addQuery = `
346
+ mutation($projectId: ID!, $contentId: ID!) {
347
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
348
+ item { id }
349
+ }
350
+ }
351
+ `;
352
+ const addResp = await this.api.graphql(addQuery, {
353
+ projectId: meta.projectId,
354
+ contentId: issue.node_id,
355
+ });
356
+ if (addResp.errors?.length) {
357
+ console.warn('[tracker] Projects v2 addProjectV2ItemById failed:', addResp.errors);
358
+ return;
359
+ }
360
+ const itemId = addResp.data?.addProjectV2ItemById?.item?.id;
361
+ if (!itemId) {
362
+ console.warn('[tracker] Projects v2: could not resolve item id for issue', issue.number);
363
+ return;
364
+ }
365
+
366
+ // Update the Status single-select field.
367
+ const updateQuery = `
368
+ mutation($input: UpdateProjectV2ItemFieldValueInput!) {
369
+ updateProjectV2ItemFieldValue(input: $input) {
370
+ projectV2Item { id }
371
+ }
372
+ }
373
+ `;
374
+ const updateResp = await this.api.graphql(updateQuery, {
375
+ input: {
376
+ projectId: meta.projectId,
377
+ itemId,
378
+ fieldId: meta.fieldId,
379
+ value: { singleSelectOptionId: optionId },
380
+ },
381
+ });
382
+ if (updateResp.errors?.length) {
383
+ console.warn('[tracker] Projects v2 updateProjectV2ItemFieldValue failed:', updateResp.errors);
384
+ }
385
+ } catch (err) {
386
+ // Projects v2 is a mirror; non-fatal
387
+ console.warn('[tracker] Projects v2 mirror error (non-fatal):', err?.message);
388
+ }
389
+ }
390
+
391
+ // Resolve the idmap entry for a code. If missing (e.g. createFeature op was
392
+ // quarantined, or idmap was wiped), recover by searching existing issues.
393
+ async _resolveIssueId(code) {
394
+ let id = await this.idmap.get(code);
395
+ if (id?.issueNumber) return id;
396
+
397
+ // Recovery: search all compose-feature issues and find the one for this code.
398
+ const issues = await this.api.searchFeatureIssues();
399
+ const match = issues.find(issue => {
400
+ const decoded = decodeBody(issue.body);
401
+ if (decoded?.code === code) return true;
402
+ // Fallback: title prefix match for issues written before decodeBody was available.
403
+ return issue.title?.startsWith(`[${code}] `);
404
+ });
405
+
406
+ if (match) {
407
+ const entry = { issueNumber: match.number, nodeId: match.node_id };
408
+ await this.idmap.put(code, entry, { version: match.updated_at });
409
+ return entry;
410
+ }
411
+
412
+ throw new Error(
413
+ `github _applyOp: no issue mapping for "${code}" (create not yet reconciled and no matching issue found)`
414
+ );
415
+ }
416
+
417
+ async _applyOp(op) {
418
+ if (op.op === 'createFeature') {
419
+ const issue = await this.api.createIssue({
420
+ title: `[${op.code}] ${op.payload.description ?? ''}`,
421
+ body: encodeBody(op.payload),
422
+ labels: ['compose-feature', `status:${op.payload.status}`],
423
+ });
424
+ await this.idmap.put(
425
+ op.code,
426
+ { issueNumber: issue.number, nodeId: issue.node_id },
427
+ { version: issue.updated_at },
428
+ );
429
+ return { version: issue.updated_at };
430
+ }
431
+
432
+ if (op.op === 'putFeature' || op.op === 'persistFeatureRaw') {
433
+ const id = await this._resolveIssueId(op.code);
434
+ const issue = await this.api.getIssue(id.issueNumber);
435
+ if (op.baseVersion && issue.updated_at !== op.baseVersion) {
436
+ const e = new Error('stale');
437
+ e.casMismatch = { remoteVersion: issue.updated_at };
438
+ throw e;
439
+ }
440
+ const next = op.payload;
441
+ const updated = await this.api.updateIssue(id.issueNumber, {
442
+ body: encodeBody(next),
443
+ labels: ['compose-feature', `status:${next.status}`],
444
+ state: TERMINAL_STATUSES.has(next.status) ? 'closed' : 'open',
445
+ });
446
+ return { version: updated.updated_at };
447
+ }
448
+
449
+ if (op.op === 'setStatus') {
450
+ const id = await this._resolveIssueId(op.code);
451
+ const issue = await this.api.getIssue(id.issueNumber);
452
+ if (op.baseVersion && issue.updated_at !== op.baseVersion) {
453
+ const e = new Error('stale');
454
+ e.casMismatch = { remoteVersion: issue.updated_at };
455
+ throw e;
456
+ }
457
+ const next = { ...decodeBody(issue.body), status: op.payload.to };
458
+ const updated = await this.api.updateIssue(id.issueNumber, {
459
+ body: encodeBody(next),
460
+ labels: ['compose-feature', `status:${next.status}`],
461
+ state: TERMINAL_STATUSES.has(next.status) ? 'closed' : 'open',
462
+ });
463
+ // Post status event as a compose-event comment (source of truth for readEvents)
464
+ await this._postEvent(id.issueNumber, op.payload.event);
465
+ // Best-effort Projects v2 mirror — pass the full issue object (has node_id)
466
+ await this._mirrorProjectV2Status(issue, op.payload.to);
467
+ return { version: updated.updated_at };
468
+ }
469
+
470
+ if (op.op === 'recordCompletion') {
471
+ const id = await this._resolveIssueId(op.code);
472
+ const issue = await this.api.getIssue(id.issueNumber);
473
+ if (op.baseVersion && issue.updated_at !== op.baseVersion) {
474
+ const e = new Error('stale');
475
+ e.casMismatch = { remoteVersion: issue.updated_at };
476
+ throw e;
477
+ }
478
+ const decoded = decodeBody(issue.body) ?? {};
479
+ const existingCompletions = decoded.completions ?? [];
480
+ const sha = op.payload.completion.commit_sha;
481
+ // Idempotent: skip if already persisted (reconcile replay safety, unconditional on sha)
482
+ if (!existingCompletions.some(c => c.commit_sha === sha)) {
483
+ decoded.completions = [...existingCompletions, op.payload.completion];
484
+ }
485
+ const updated = await this.api.updateIssue(id.issueNumber, {
486
+ body: encodeBody(decoded),
487
+ labels: issue.labels?.map(l => l.name ?? l) ?? [],
488
+ });
489
+ // Post completion event comment
490
+ await this._postEvent(id.issueNumber, {
491
+ type: 'completion',
492
+ commit_sha: sha,
493
+ ts: Date.now(),
494
+ });
495
+ return { version: updated.updated_at };
496
+ }
497
+
498
+ throw new Error(`_applyOp: unknown op ${op.op}`);
499
+ }
500
+
501
+ /**
502
+ * Render the roadmap by fetching the remote ROADMAP.md as the merge base,
503
+ * merging the current feature list into it, and committing the result back
504
+ * via the Contents API with optimistic-lock (SHA-based). On SHA conflict,
505
+ * refetches and retries once.
506
+ *
507
+ * Returns the roadmapPath string (consistent with LocalFileProvider.renderRoadmap
508
+ * which returns the path via writeRoadmap).
509
+ */
510
+ async renderRoadmap() {
511
+ const roadmapPath = this.cfg.github?.roadmapPath ?? this.cfg.roadmapPath ?? 'ROADMAP.md';
512
+ const branch = this.cfg.github?.branch ?? this.cfg.branch ?? 'main';
513
+
514
+ const doRender = async () => {
515
+ const { text, sha } = await this.api.getContents(roadmapPath, branch);
516
+ const features = await this.listFeatures();
517
+ const merged = generateRoadmapFromBase(text, features, { cwd: this.cwd });
518
+ await this.api.putContents(roadmapPath, merged, {
519
+ sha,
520
+ branch,
521
+ message: 'chore(tracker): roadmap',
522
+ });
523
+ };
524
+
525
+ try {
526
+ await doRender();
527
+ } catch (e) {
528
+ if (e.shaConflict) {
529
+ // Refetch new base and retry once
530
+ await doRender();
531
+ } else {
532
+ throw e;
533
+ }
534
+ }
535
+
536
+ return roadmapPath;
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Low-level changelog primitives (FIX A)
541
+ // Called by changelog-writer.js addChangelogEntry via getChangelog/putChangelog.
542
+ // ---------------------------------------------------------------------------
543
+
544
+ /**
545
+ * Fetch the current CHANGELOG.md text from the remote repo.
546
+ * Returns '' if the file does not yet exist (404 → empty seed).
547
+ */
548
+ async getChangelog() {
549
+ const changelogPath = this.cfg.github?.changelogPath ?? this.cfg.changelogPath ?? 'CHANGELOG.md';
550
+ const branch = this.cfg.github?.branch ?? this.cfg.branch ?? 'main';
551
+ try {
552
+ const { text } = await this.api.getContents(changelogPath, branch);
553
+ return text;
554
+ } catch (e) {
555
+ // 404 = file doesn't exist yet; return empty string so writers create it.
556
+ if (e.status === 404 || e.message?.includes('404') || e.message?.includes('Not Found')) {
557
+ return '';
558
+ }
559
+ throw e;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Write the full CHANGELOG.md text to the remote repo.
565
+ * Re-fetches the current SHA inside putChangelog so the caller's
566
+ * get→splice→put sequence works correctly (the sha is always fresh at
567
+ * write time). On SHA conflict (409), refetches and retries once.
568
+ */
569
+ async putChangelog(text) {
570
+ const changelogPath = this.cfg.github?.changelogPath ?? this.cfg.changelogPath ?? 'CHANGELOG.md';
571
+ const branch = this.cfg.github?.branch ?? this.cfg.branch ?? 'main';
572
+ const message = `docs(changelog): update`;
573
+
574
+ const doWrite = async () => {
575
+ // Fetch current SHA for optimistic-lock. 404 → no sha (new file).
576
+ let sha;
577
+ try {
578
+ const current = await this.api.getContents(changelogPath, branch);
579
+ sha = current.sha;
580
+ } catch (e) {
581
+ if (e.status === 404 || e.message?.includes('404') || e.message?.includes('Not Found')) {
582
+ sha = undefined;
583
+ } else {
584
+ throw e;
585
+ }
586
+ }
587
+ await this.api.putContents(changelogPath, text, { sha, branch, message });
588
+ };
589
+
590
+ try {
591
+ await doWrite();
592
+ } catch (e) {
593
+ if (e.shaConflict) {
594
+ // Refetch SHA and retry once.
595
+ await doWrite();
596
+ } else {
597
+ throw e;
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Append a changelog entry to the remote CHANGELOG.md via the Contents API
604
+ * with optimistic-lock (SHA-based). On SHA conflict, refetches and retries once.
605
+ * If the entry is idempotent (already present), returns without writing.
606
+ *
607
+ * NOTE: The production path (addChangelogEntry in changelog-writer.js) calls
608
+ * getChangelog()+putChangelog() directly. appendChangelog() is kept for
609
+ * conformance suite callers that invoke the composite op directly on the provider.
610
+ */
611
+ async appendChangelog(entry) {
612
+ const changelogPath = this.cfg.github?.changelogPath ?? this.cfg.changelogPath ?? 'CHANGELOG.md';
613
+ const branch = this.cfg.github?.branch ?? this.cfg.branch ?? 'main';
614
+
615
+ const doAppend = async () => {
616
+ const { text, sha } = await this.api.getContents(changelogPath, branch);
617
+ const { content, idempotent } = spliceChangelog(text, entry);
618
+ if (idempotent) return { idempotent: true };
619
+ await this.api.putContents(changelogPath, content, {
620
+ sha,
621
+ branch,
622
+ message: `docs(changelog): ${entry.code ?? ''}`,
623
+ });
624
+ return { idempotent: false };
625
+ };
626
+
627
+ let result;
628
+ try {
629
+ result = await doAppend();
630
+ } catch (e) {
631
+ if (e.shaConflict) {
632
+ // Refetch new base and retry once
633
+ result = await doAppend();
634
+ } else {
635
+ throw e;
636
+ }
637
+ }
638
+
639
+ return result;
640
+ }
641
+ }