@timekast/factory 1.19.0 → 1.21.0

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,706 @@
1
+ /**
2
+ * `factory backlog push` — day-2 mirror of the LOCAL backlog to backlog.timekast.mx.
3
+ *
4
+ * Reads the repo's `project/backlog/*` epics + issues and pushes them, in CASCADE,
5
+ * to the central client-facing tracker: épicas → features (projected from each
6
+ * issue's `Refs (discovery)` `FT-XX` tokens) → issues (a `story` or a `task` per the
7
+ * `> **Board:**` field, default `story`). Milestones become releases (adopt-by-name,
8
+ * D12). It CONSUMES the BSYNC-002 REST client (`backlog-api.ts`) + the BSYNC-004
9
+ * parser/mapping (`backlog-parse.ts` / `backlog-mapping.ts`) — this file only
10
+ * orchestrates; it re-implements neither the HTTP nor the field mapping.
11
+ *
12
+ * READ-ONLY BY DEFAULT: with no `BACKLOG_API_KEY` / `BACKLOG_PROJECT_ID` in
13
+ * `.env.local`, it prints a note and exits 0 — the push NEVER blocks `/backlog`.
14
+ *
15
+ * IDEMPOTENCY (smoke Hallazgo 3): a re-POST of an existing client-supplied `id`
16
+ * returns `500` on the live API, NOT the documented `409` — so a status code is an
17
+ * UNRELIABLE "already exists" signal. The push therefore PRE-CHECKS existence with a
18
+ * single `getSyncState` read and skips what already exists; the `409` handling below
19
+ * is only a belt-and-suspenders for a race / a server that later fixes the code.
20
+ * Determinism (the stamped `Backlog UUID`, or the v5 fallback keyed on the Issue ID)
21
+ * is what actually guarantees a re-run never duplicates.
22
+ *
23
+ * FLAGS:
24
+ * --reconcile PATCH the element's status when it already exists (vs skip).
25
+ * --stamp Allow un-stamped issues/epics through AND write their UUID back to
26
+ * the local markdown (invoked by tk-backlog Phase 8, BSYNC-008).
27
+ * --backfill Legacy campaign: same allow-through + write-back as --stamp, for a
28
+ * one-by-one pass over a pre-BSYNC-007 backlog with no stamped UUIDs.
29
+ * --source-lang <lang> Source language of the pushed text (default `es`).
30
+ *
31
+ * The `is_factory` guard (D9 — the Factory never pushes its own backlog) lives in the
32
+ * tk-backlog Phase 8 caller (BSYNC-008), NOT here: this command pushes whatever
33
+ * backlog dir it is pointed at.
34
+ */
35
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
36
+ import path from 'node:path';
37
+ import { assignToRelease, BacklogApiError, createEpic, createFeature, createRelease, createStory, createTask, getSyncState, listBacklogReleases, patchElementStatus, } from '../lib/backlog-api.js';
38
+ import { mapPriority, mapStatusToRemote, mapStoryPoints, resolveBacklogCredentials, } from '../lib/backlog-mapping.js';
39
+ import { deterministicElementUuid, matchBlockquoteField, parseBacklogUuid, parseBoard, parseEpic, parseIssue, parseIssueId, parseStatus, parseTitle, } from '../lib/backlog-parse.js';
40
+ export function parseBacklogPushFlags(argv) {
41
+ const langFlag = argv.find((a) => a.startsWith('--source-lang='));
42
+ const langIdx = argv.indexOf('--source-lang');
43
+ const sourceLang = (langFlag ? langFlag.split('=')[1] : langIdx >= 0 ? argv[langIdx + 1] : undefined) || 'es';
44
+ return {
45
+ reconcile: argv.includes('--reconcile'),
46
+ stamp: argv.includes('--stamp'),
47
+ backfill: argv.includes('--backfill'),
48
+ sourceLang,
49
+ };
50
+ }
51
+ /** Read every epic + issue markdown under each `project/backlog/{version}/` of `rootDir`. */
52
+ export function collectBacklog(rootDir) {
53
+ const base = path.join(rootDir, 'project', 'backlog');
54
+ const epics = [];
55
+ const issues = [];
56
+ if (!existsSync(base))
57
+ return { epics, issues };
58
+ const readMd = (dir, sink) => {
59
+ if (!existsSync(dir))
60
+ return;
61
+ for (const name of readdirSync(dir).sort()) {
62
+ if (!name.endsWith('.md'))
63
+ continue;
64
+ const filePath = path.join(dir, name);
65
+ try {
66
+ sink.push({ path: filePath, content: readFileSync(filePath, 'utf8') });
67
+ }
68
+ catch {
69
+ // A single unreadable file must not abort the whole collection.
70
+ }
71
+ }
72
+ };
73
+ for (const version of readdirSync(base).sort()) {
74
+ const vdir = path.join(base, version);
75
+ let isDir = false;
76
+ try {
77
+ isDir = statSync(vdir).isDirectory();
78
+ }
79
+ catch {
80
+ isDir = false;
81
+ }
82
+ if (!isDir)
83
+ continue;
84
+ readMd(path.join(vdir, 'epics'), epics);
85
+ readMd(path.join(vdir, 'issues'), issues);
86
+ }
87
+ return { epics, issues };
88
+ }
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // Parse — epics + issues into a cascade plan
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+ /** The `EPIC-NN` token is the stable join key between an epic file and its issues. */
93
+ const EPIC_TOKEN_RE = /EPIC-\d+/;
94
+ /** Feature refs (`FT-01`, `FT-AUTH`, …) inside an issue's `Refs (discovery)` line. */
95
+ const FT_TOKEN_RE = /FT-[A-Za-z0-9]+/g;
96
+ function epicTokenOf(text) {
97
+ const match = text?.match(EPIC_TOKEN_RE);
98
+ return match ? match[0] : undefined;
99
+ }
100
+ /** The domain prefix of an Issue ID (`BSYNC-005` → `BSYNC`); the epic's humanPrefix. */
101
+ function domainOf(issueId) {
102
+ if (!issueId)
103
+ return undefined;
104
+ const stripped = issueId.replace(/-\d+$/, '');
105
+ return (stripped || issueId).toUpperCase();
106
+ }
107
+ /** Most-frequent value (the epic's modal issue domain → humanPrefix — D10). */
108
+ function modal(values) {
109
+ const counts = new Map();
110
+ for (const value of values)
111
+ counts.set(value, (counts.get(value) ?? 0) + 1);
112
+ let best;
113
+ let bestCount = -1;
114
+ for (const [value, count] of counts) {
115
+ if (count > bestCount) {
116
+ best = value;
117
+ bestCount = count;
118
+ }
119
+ }
120
+ return best;
121
+ }
122
+ function parseEpicNode(file) {
123
+ const fullId = parseIssueId(file.content) ?? path.basename(file.path, '.md');
124
+ const token = epicTokenOf(fullId) ?? epicTokenOf(path.basename(file.path)) ?? fullId;
125
+ const title = parseTitle(file.content) ?? token;
126
+ const milestone = matchBlockquoteField(file.content, 'Milestone');
127
+ const backlogUuid = parseBacklogUuid(file.content);
128
+ const uuid = backlogUuid ?? deterministicElementUuid(fullId);
129
+ return { file, token, fullId, title, milestone, backlogUuid, uuid, humanPrefix: 'EPIC' };
130
+ }
131
+ function parseIssueNode(file) {
132
+ const parsed = parseIssue(file.content);
133
+ const epicToken = epicTokenOf(parseEpic(file.content));
134
+ const refsLine = matchBlockquoteField(file.content, 'Refs (discovery)');
135
+ const featureTokens = refsLine
136
+ ? Array.from(new Set(Array.from(refsLine.matchAll(FT_TOKEN_RE), (m) => m[0])))
137
+ : [];
138
+ return { file, parsed, epicToken, featureTokens };
139
+ }
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+ // Stamp write-back (--stamp / --backfill)
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ /**
144
+ * Insert (or replace) the `> **Backlog UUID:** <uuid>` blockquote line so a re-run
145
+ * reads the same stamped id — the durable half of idempotency (§8). Idempotent: a
146
+ * file already carrying the same uuid is returned unchanged. Placed after the
147
+ * `> **Status:**` line (present in every issue/epic header).
148
+ */
149
+ export function stampBacklogUuid(content, uuid) {
150
+ const line = `> **Backlog UUID:** ${uuid}`;
151
+ const existing = /[>-] \*\*Backlog UUID:\*\*[^\n]*/;
152
+ if (existing.test(content)) {
153
+ return content.replace(existing, line);
154
+ }
155
+ const statusLine = content.match(/[>-] \*\*Status:\*\*[^\n]*\n/);
156
+ if (statusLine) {
157
+ return content.replace(statusLine[0], `${statusLine[0]}${line}\n`);
158
+ }
159
+ // Fallback: right after the H1.
160
+ return content.replace(/^(#[^\n]*\n)/, `$1${line}\n`);
161
+ }
162
+ /** Adopt an existing release by name (D12 — releases take no client id). */
163
+ function adoptReleaseByName(releases, name) {
164
+ const target = name.trim().toLowerCase();
165
+ for (const release of releases) {
166
+ const es = release.name?.['es']?.value;
167
+ if (typeof es === 'string' && es.trim().toLowerCase() === target)
168
+ return release.id;
169
+ }
170
+ return undefined;
171
+ }
172
+ /** Read the whole `sync-state` (paginated) into the existence pre-check set. */
173
+ async function loadSyncState(apiKey, projectId, api, existing, items) {
174
+ const pageSize = 100;
175
+ let page = 1;
176
+ let total = Number.POSITIVE_INFINITY;
177
+ let seen = 0;
178
+ while (seen < total) {
179
+ const state = await getSyncState(apiKey, projectId, { page, pageSize }, api);
180
+ total = state.total;
181
+ for (const item of state.items) {
182
+ existing.add(item.id);
183
+ items.set(item.id, item);
184
+ }
185
+ seen += state.items.length;
186
+ if (state.items.length === 0)
187
+ break;
188
+ page += 1;
189
+ }
190
+ }
191
+ /**
192
+ * Push the LOCAL backlog to backlog.timekast.mx in cascade. Read-only degradation:
193
+ * no credentials → note + return (exit 0). Never throws for a per-element failure —
194
+ * it records it and continues (a `500`/`409`/network blip on one element must not
195
+ * abort the run — §4).
196
+ */
197
+ export async function runBacklogPush(flags, rootDir = process.cwd(), deps = {}) {
198
+ const creds = (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir)))(rootDir);
199
+ if (creds.status === 'not-configured') {
200
+ console.log(`ℹ️ Push al backlog central omitido: ${creds.reason}`);
201
+ console.log(' (read-only por defecto — el push nunca bloquea `/backlog`.)');
202
+ return;
203
+ }
204
+ const { apiKey, projectId } = creds;
205
+ const api = deps.api ?? {};
206
+ const writeFile = deps.writeFile ?? ((p, c) => writeFileSync(p, c, 'utf8'));
207
+ const writeBack = flags.stamp || flags.backfill;
208
+ const allowUnstamped = flags.stamp || flags.backfill;
209
+ // 1. Parse the local backlog.
210
+ const collected = (deps.collect ?? collectBacklog)(rootDir);
211
+ const issueNodes = collected.issues.map(parseIssueNode);
212
+ const epicNodes = collected.epics.map(parseEpicNode);
213
+ // Gate: skip un-stamped issues unless --stamp/--backfill (avoid spamming the
214
+ // central board with a whole legacy backlog on every `/backlog` run — §7).
215
+ const pushableIssues = issueNodes.filter((n) => n.parsed.backlogUuid !== undefined || allowUnstamped);
216
+ const skippedNoUuid = issueNodes.length - pushableIssues.length;
217
+ // Epics to push: only those a pushable issue hangs off (no orphan epics).
218
+ const pushableEpicTokens = new Set(pushableIssues.map((n) => n.epicToken).filter((t) => Boolean(t)));
219
+ const epicsToPush = epicNodes.filter((e) => pushableEpicTokens.has(e.token));
220
+ const epicByToken = new Map(epicsToPush.map((e) => [e.token, e]));
221
+ for (const epic of epicsToPush) {
222
+ const domains = pushableIssues
223
+ .filter((n) => n.epicToken === epic.token)
224
+ .map((n) => domainOf(n.parsed.issueId))
225
+ .filter((d) => Boolean(d));
226
+ epic.humanPrefix = modal(domains) ?? epic.humanPrefix;
227
+ }
228
+ // Features projected from each pushable issue's FT refs (§4; story sin FT → no
229
+ // feature is invented — the token set is simply empty for that issue).
230
+ const featureByToken = new Map();
231
+ for (const n of pushableIssues) {
232
+ for (const ft of n.featureTokens) {
233
+ if (featureByToken.has(ft))
234
+ continue;
235
+ const epicUuid = n.epicToken ? epicByToken.get(n.epicToken)?.uuid : undefined;
236
+ featureByToken.set(ft, { token: ft, uuid: deterministicElementUuid(ft), epicUuid });
237
+ }
238
+ }
239
+ // 2. Existence pre-check (the reliable idempotency signal, NOT the status code).
240
+ const existing = new Set();
241
+ const existingItems = new Map();
242
+ try {
243
+ await loadSyncState(apiKey, projectId, api, existing, existingItems);
244
+ }
245
+ catch {
246
+ console.warn('⚠ No pude leer `sync-state` (pre-check) — el push continúa best-effort.');
247
+ }
248
+ const created = new Set(existing);
249
+ const summary = { created: 0, reconciled: 0, skippedExisting: 0, failed: 0 };
250
+ const recordFail = (error) => {
251
+ summary.failed += 1;
252
+ const detail = error instanceof BacklogApiError ? `HTTP ${error.status}` : 'error';
253
+ console.warn(`⚠ Un elemento no se pudo pushear (${detail}) — el run continúa.`);
254
+ };
255
+ const stampFile = (file, uuid) => {
256
+ const next = stampBacklogUuid(file.content, uuid);
257
+ if (next !== file.content) {
258
+ writeFile(file.path, next);
259
+ file.content = next;
260
+ }
261
+ };
262
+ /** Create one element, honoring pre-check skip / --reconcile / 409-tolerance. */
263
+ const pushCreate = async (opts) => {
264
+ const { uuid, elementType } = opts;
265
+ const reconcile = async () => {
266
+ if (opts.reconcileStatus === undefined) {
267
+ summary.skippedExisting += 1;
268
+ return;
269
+ }
270
+ try {
271
+ await patchElementStatus(apiKey, projectId, elementType, uuid, opts.reconcileStatus, api);
272
+ summary.reconciled += 1;
273
+ }
274
+ catch (error) {
275
+ recordFail(error);
276
+ }
277
+ };
278
+ if (created.has(uuid)) {
279
+ if (flags.reconcile)
280
+ await reconcile();
281
+ else
282
+ summary.skippedExisting += 1;
283
+ return;
284
+ }
285
+ try {
286
+ await opts.create();
287
+ created.add(uuid);
288
+ summary.created += 1;
289
+ if (writeBack && opts.stampBack)
290
+ stampFile(opts.stampBack, uuid);
291
+ }
292
+ catch (error) {
293
+ // 409 = already exists (belt-and-suspenders; the live API returns 500 on
294
+ // collision — see file header — which is why the pre-check above is primary).
295
+ if (error instanceof BacklogApiError && error.status === 409) {
296
+ created.add(uuid);
297
+ if (flags.reconcile)
298
+ await reconcile();
299
+ else
300
+ summary.skippedExisting += 1;
301
+ }
302
+ else {
303
+ recordFail(error);
304
+ }
305
+ }
306
+ };
307
+ // 3. Cascade: épicas → features → issues.
308
+ for (const epic of epicsToPush) {
309
+ await pushCreate({
310
+ uuid: epic.uuid,
311
+ elementType: 'epic',
312
+ create: () => createEpic(apiKey, projectId, {
313
+ id: epic.uuid,
314
+ name: { es: epic.title },
315
+ humanPrefix: epic.humanPrefix,
316
+ sourceLang: flags.sourceLang,
317
+ }, api),
318
+ stampBack: epic.backlogUuid === undefined ? epic.file : undefined,
319
+ });
320
+ }
321
+ for (const feature of featureByToken.values()) {
322
+ await pushCreate({
323
+ uuid: feature.uuid,
324
+ elementType: 'feature',
325
+ create: () => createFeature(apiKey, projectId, {
326
+ id: feature.uuid,
327
+ name: { es: feature.token },
328
+ ...(feature.epicUuid ? { epicId: feature.epicUuid } : {}),
329
+ sourceLang: flags.sourceLang,
330
+ }, api),
331
+ });
332
+ }
333
+ for (const n of pushableIssues) {
334
+ const epic = n.epicToken ? epicByToken.get(n.epicToken) : undefined;
335
+ const featureUuid = n.featureTokens.length
336
+ ? featureByToken.get(n.featureTokens[0])?.uuid
337
+ : undefined;
338
+ const priority = mapPriority(n.parsed.priority);
339
+ const points = mapStoryPoints(n.parsed.storyPoints);
340
+ const title = { es: n.parsed.title ?? n.parsed.issueId ?? 'Sin título' };
341
+ const stampBack = n.parsed.backlogUuid === undefined ? n.file : undefined;
342
+ const reconcileStatus = mapStatusToRemote(n.parsed.status);
343
+ if (n.parsed.board === 'task') {
344
+ const parent = featureUuid
345
+ ? { type: 'feature', id: featureUuid }
346
+ : epic
347
+ ? { type: 'epic', id: epic.uuid }
348
+ : undefined;
349
+ if (!parent) {
350
+ summary.failed += 1;
351
+ console.warn(`⚠ ${n.parsed.issueId ?? n.file.path}: task sin épica ni feature padre — omitido.`);
352
+ continue;
353
+ }
354
+ const body = {
355
+ id: n.parsed.uuid,
356
+ title,
357
+ parent,
358
+ ...(priority ? { priority } : {}),
359
+ ...(points !== null ? { points } : {}),
360
+ sourceLang: flags.sourceLang,
361
+ };
362
+ await pushCreate({
363
+ uuid: n.parsed.uuid,
364
+ elementType: 'task',
365
+ create: () => createTask(apiKey, projectId, body, api),
366
+ reconcileStatus,
367
+ stampBack,
368
+ });
369
+ }
370
+ else {
371
+ // `story` (the default) — `bug` folds here too (no createBug primitive; it is
372
+ // an out-of-scope edge, story is the safe default per parseBoard).
373
+ const body = {
374
+ id: n.parsed.uuid,
375
+ title,
376
+ ...(epic ? { epicId: epic.uuid } : {}),
377
+ ...(featureUuid ? { featureId: featureUuid } : {}),
378
+ ...(n.parsed.moscow ? { moscow: n.parsed.moscow } : {}),
379
+ ...(priority ? { priority } : {}),
380
+ ...(points !== null ? { points } : {}),
381
+ sourceLang: flags.sourceLang,
382
+ };
383
+ await pushCreate({
384
+ uuid: n.parsed.uuid,
385
+ elementType: 'story',
386
+ create: () => createStory(apiKey, projectId, body, api),
387
+ reconcileStatus,
388
+ stampBack,
389
+ });
390
+ }
391
+ }
392
+ // 4. Releases (D12): one per unique `Milestone:`, adopt-by-name, assign ONLY epics
393
+ // (features/stories inherit; assigning a child 422s — smoke Hallazgo 5).
394
+ const milestones = new Map();
395
+ for (const epic of epicsToPush) {
396
+ if (!epic.milestone)
397
+ continue;
398
+ const list = milestones.get(epic.milestone) ?? [];
399
+ list.push(epic);
400
+ milestones.set(epic.milestone, list);
401
+ }
402
+ if (milestones.size > 0) {
403
+ let releases = [];
404
+ try {
405
+ releases = await listBacklogReleases(apiKey, projectId, api);
406
+ }
407
+ catch (error) {
408
+ recordFail(error);
409
+ }
410
+ for (const [name, epics] of milestones) {
411
+ let releaseId = adoptReleaseByName(releases, name);
412
+ if (releaseId) {
413
+ summary.skippedExisting += 1;
414
+ }
415
+ else {
416
+ try {
417
+ releaseId = (await createRelease(apiKey, projectId, { name: { es: name }, sourceLang: flags.sourceLang }, api)).id;
418
+ summary.created += 1;
419
+ }
420
+ catch (error) {
421
+ recordFail(error);
422
+ continue;
423
+ }
424
+ }
425
+ for (const epic of epics) {
426
+ try {
427
+ await assignToRelease(apiKey, releaseId, 'epic', epic.uuid, api);
428
+ }
429
+ catch (error) {
430
+ // 422 = the epic (or an ancestor) is already assigned — tolerated.
431
+ if (!(error instanceof BacklogApiError && error.status === 422))
432
+ recordFail(error);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ // 5. Plain es-MX summary + the staff-curation note (visibility is server-side —
438
+ // smoke Hallazgo 8; pushed elements arrive invisible until staff curates once).
439
+ console.log(`✔ Push al backlog central: ${summary.created} creados, ${summary.reconciled} reconciliados, ` +
440
+ `${summary.skippedExisting} ya existían` +
441
+ (summary.failed ? `, ${summary.failed} con error (el run continuó)` : '') +
442
+ '.');
443
+ if (skippedNoUuid > 0) {
444
+ console.log(`ℹ️ ${skippedNoUuid} issue(s) sin \`Backlog UUID\` — omitidos. ` +
445
+ 'Usa `--stamp` o `--backfill` para incluirlos y estamparlos.');
446
+ }
447
+ console.log('ℹ️ Los elementos llegan invisibles al board del cliente — el staff los hace visibles la primera vez (curación).');
448
+ }
449
+ // ─────────────────────────────────────────────────────────────────────────────
450
+ // `factory backlog sync` — point status-sync + self-healing heartbeat (BSYNC-006)
451
+ // ─────────────────────────────────────────────────────────────────────────────
452
+ /**
453
+ * `factory backlog sync` — the day-2 STATUS sync + self-healing heartbeat (D11).
454
+ * It runs a bounded, fire-and-forget pass over backlog.timekast.mx from the
455
+ * workflows (`/backlog`, `/implement`, BSYNC-010) — NEVER a blocking gate. Same
456
+ * "best-effort observability, NUNCA un gate; su fallo no aborta el flujo" principle
457
+ * `tk-deploy §7.5.6` already establishes for the dist-release verifier: a dead
458
+ * network, a failed PATCH, or a slow API cuts clean and lets the caller continue.
459
+ *
460
+ * TWO things happen under ONE 5s deadline (never blocks the caller past 5s):
461
+ * 1. POINT SYNC (skipped by `--heal-only`): `--issue <ID> --status <s>` /
462
+ * `--epic <ID> --status <s>` resolves the element's STAMPED `Backlog UUID`
463
+ * from the local markdown and PATCHes the remote status. A legacy element with
464
+ * no stamped UUID is skipped-with-a-note (never crashes — §8).
465
+ * 2. SELF-HEALING HEARTBEAT: exactly ONE `GET /sync-state` (a heartbeat, NOT
466
+ * polling), diffed `{ id → status }` remote-vs-local, repairing detected drift
467
+ * via PATCH — capped at 10 repairs per invocation (never "repairs the world" in
468
+ * one run; the rest wait for the next heartbeat). A repair PATCH that fails does
469
+ * NOT abort the batch; the next invocation retries that element.
470
+ *
471
+ * It CONSUMES the BSYNC-002 client (`getSyncState` / `patchElementStatus`) + the
472
+ * BSYNC-004 parser/mapping — it re-implements neither the HTTP nor the field mapping.
473
+ */
474
+ /** Overall deadline (ms) for one `sync` invocation — a slow API must never block the caller. */
475
+ const SYNC_TIMEOUT_MS = 5_000;
476
+ /** Max repairs per heartbeat — bounds the work so one run never "repairs the world". */
477
+ const HEAL_MAX_REPAIRS = 10;
478
+ /** Single-heartbeat page size — one `GET /sync-state`, not paginated polling. */
479
+ const HEAL_PAGE_SIZE = 100;
480
+ /** The 6 remote work states the API accepts (a `--status` value may already be one). */
481
+ const REMOTE_STATUSES = [
482
+ 'todo',
483
+ 'in_progress',
484
+ 'done',
485
+ 'blocked',
486
+ 'deferred',
487
+ 'wont_do',
488
+ ];
489
+ /** The 6 local buckets a `--status` value may use instead (mapped to remote). */
490
+ const LOCAL_STATUS_KEYS = [
491
+ 'todo',
492
+ 'in-progress',
493
+ 'done',
494
+ 'wont-do',
495
+ 'postponed',
496
+ 'blocked',
497
+ ];
498
+ /**
499
+ * Resolve a raw `--status` value to a remote {@link BacklogStatus}. Accepts BOTH the
500
+ * remote enum form (`in_progress`) and the local bucket form (`in-progress`) so the
501
+ * caller (a workflow or a dev) can pass either; an unknown value → `undefined` (the
502
+ * point sync then skips rather than PATCH a bogus status).
503
+ */
504
+ function resolveStatusFlag(raw) {
505
+ if (!raw)
506
+ return undefined;
507
+ const value = raw.trim().toLowerCase();
508
+ if (REMOTE_STATUSES.includes(value))
509
+ return value;
510
+ if (LOCAL_STATUS_KEYS.includes(value)) {
511
+ return mapStatusToRemote(value);
512
+ }
513
+ return undefined;
514
+ }
515
+ /** Read a `--flag <value>` or `--flag=<value>` from argv; `undefined` if absent/bare. */
516
+ function readFlagValue(argv, name) {
517
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
518
+ if (eq)
519
+ return eq.slice(name.length + 1) || undefined;
520
+ const idx = argv.indexOf(name);
521
+ if (idx >= 0) {
522
+ const next = argv[idx + 1];
523
+ if (next !== undefined && !next.startsWith('--'))
524
+ return next;
525
+ }
526
+ return undefined;
527
+ }
528
+ /** Parse the `sync` argv into {@link BacklogSyncFlags}. `--issue` wins over `--epic`. */
529
+ export function parseBacklogSyncFlags(argv) {
530
+ const issueId = readFlagValue(argv, '--issue');
531
+ const epicId = readFlagValue(argv, '--epic');
532
+ const status = readFlagValue(argv, '--status');
533
+ const healOnly = argv.includes('--heal-only');
534
+ const target = issueId
535
+ ? { kind: 'issue', id: issueId }
536
+ : epicId
537
+ ? { kind: 'epic', id: epicId }
538
+ : undefined;
539
+ return { target, status, healOnly };
540
+ }
541
+ /** The epic token (`EPIC-01`) an epic file carries, from its Issue ID / filename. */
542
+ function epicIdOf(file) {
543
+ const fullId = parseIssueId(file.content) ?? path.basename(file.path, '.md');
544
+ const token = epicTokenOf(fullId) ?? epicTokenOf(path.basename(file.path));
545
+ return { fullId, token };
546
+ }
547
+ /** Locate the local file for a point-sync target. `undefined` if no such element. */
548
+ function findTargetFile(target, collected) {
549
+ const wanted = target.id.toUpperCase();
550
+ if (target.kind === 'issue') {
551
+ return collected.issues.find((f) => parseIssueId(f.content)?.toUpperCase() === wanted);
552
+ }
553
+ return collected.epics.find((f) => {
554
+ const { fullId, token } = epicIdOf(f);
555
+ return token === wanted || fullId.toUpperCase() === wanted;
556
+ });
557
+ }
558
+ /**
559
+ * PATCH one element's remote status from its STAMPED local UUID. A legacy element
560
+ * with no stamped `Backlog UUID` is skipped-with-a-note (§8); a failed PATCH is
561
+ * logged (the self-healing retries it) but never thrown — the run continues.
562
+ */
563
+ async function runPointSync(flags, collected, apiKey, projectId, api) {
564
+ if (!flags.target)
565
+ return;
566
+ const status = resolveStatusFlag(flags.status);
567
+ if (!status) {
568
+ console.warn(`⚠ Sync puntual omitido: \`--status\` ausente o inválido (${flags.status ?? '—'}).`);
569
+ return;
570
+ }
571
+ const { kind, id } = flags.target;
572
+ const file = findTargetFile(flags.target, collected);
573
+ if (!file) {
574
+ console.warn(`⚠ Sync puntual omitido: no encontré ${kind} \`${id}\` en el backlog local.`);
575
+ return;
576
+ }
577
+ // Require the STAMPED UUID (not the deterministic fallback): a legacy element
578
+ // without it is skipped-with-a-note rather than PATCHed against a guessed id.
579
+ const backlogUuid = parseBacklogUuid(file.content);
580
+ if (!backlogUuid) {
581
+ console.log(`ℹ️ ${id} sin \`Backlog UUID\` — omitido (elemento legacy sin estampar).`);
582
+ return;
583
+ }
584
+ const elementType = kind === 'epic' ? 'epic' : parseBoard(file.content);
585
+ try {
586
+ await patchElementStatus(apiKey, projectId, elementType, backlogUuid, status, api);
587
+ console.log(`✔ Sync puntual: ${id} → ${status} en el backlog central.`);
588
+ }
589
+ catch (error) {
590
+ const detail = error instanceof BacklogApiError ? `HTTP ${error.status}` : 'error';
591
+ console.warn(`⚠ Sync puntual de ${id} falló (${detail}) — el self-healing lo reintentará.`);
592
+ }
593
+ }
594
+ /**
595
+ * One self-healing heartbeat: a SINGLE `GET /sync-state`, diffed against the local
596
+ * SSOT, repairing status drift via PATCH — capped at {@link HEAL_MAX_REPAIRS}. Only
597
+ * status-bearing remote elements (story/task/bug — epic/feature carry `status: null`
598
+ * rollups) are diffable; only locally-stamped elements are matched (an un-stamped
599
+ * legacy issue is skipped, never crashes). A failed repair PATCH does not abort the
600
+ * batch — the next heartbeat retries that element.
601
+ */
602
+ async function runHealing(collected, apiKey, projectId, api) {
603
+ const summary = { repaired: 0, failed: 0, skippedNoUuid: 0 };
604
+ let remote;
605
+ try {
606
+ remote = await getSyncState(apiKey, projectId, { page: 1, pageSize: HEAL_PAGE_SIZE }, api);
607
+ }
608
+ catch {
609
+ console.warn('⚠ No pude leer `sync-state` (heartbeat) — el self-healing se omite esta corrida.');
610
+ return summary;
611
+ }
612
+ // Local SSOT keyed by the STAMPED UUID (status-bearing elements = issues).
613
+ const localByUuid = new Map();
614
+ for (const file of collected.issues) {
615
+ const uuid = parseBacklogUuid(file.content);
616
+ if (!uuid) {
617
+ summary.skippedNoUuid += 1;
618
+ continue;
619
+ }
620
+ localByUuid.set(uuid, mapStatusToRemote(parseStatus(file.content)));
621
+ }
622
+ let attempts = 0;
623
+ for (const item of remote.items) {
624
+ if (attempts >= HEAL_MAX_REPAIRS)
625
+ break; // cap: the 11th drifted element waits for next run
626
+ if (item.status === null)
627
+ continue; // epic/feature rollup — no status axis to heal
628
+ const localStatus = localByUuid.get(item.id);
629
+ if (localStatus === undefined)
630
+ continue; // remote element we don't track locally
631
+ if (localStatus === item.status)
632
+ continue; // in sync — no-op
633
+ attempts += 1; // count the repair ATTEMPT toward the cap (bounds the network work)
634
+ try {
635
+ await patchElementStatus(apiKey, projectId, item.type, item.id, localStatus, api);
636
+ summary.repaired += 1;
637
+ }
638
+ catch {
639
+ // A single failed PATCH does not abort the batch — the next heartbeat retries it.
640
+ summary.failed += 1;
641
+ }
642
+ }
643
+ return summary;
644
+ }
645
+ /**
646
+ * Run `work` under a hard `ms` deadline. Returns `true` if it completed, `false` if
647
+ * the deadline fired first (the caller narrates the cut). A `work` rejection is
648
+ * treated as completion (point-sync/healing already swallow their own errors) so a
649
+ * late rejection never surfaces as an unhandled rejection. The default timer is
650
+ * `unref`'d + cleared so a completed run never lingers waiting on the deadline.
651
+ */
652
+ async function runWithDeadline(ms, work, deadlineSleep) {
653
+ let timer;
654
+ const deadline = deadlineSleep
655
+ ? deadlineSleep(ms).then(() => 'timeout')
656
+ : new Promise((resolve) => {
657
+ timer = setTimeout(() => resolve('timeout'), ms);
658
+ if (typeof timer.unref === 'function')
659
+ timer.unref();
660
+ });
661
+ const done = work().then(() => 'done', () => 'done');
662
+ const outcome = await Promise.race([done, deadline]);
663
+ if (timer)
664
+ clearTimeout(timer);
665
+ return outcome === 'done';
666
+ }
667
+ /**
668
+ * Orchestrate `factory backlog sync`. Read-only degradation: no credentials → note +
669
+ * return (exit 0 — the sync NEVER blocks the workflow). The point sync (unless
670
+ * `--heal-only`) + the self-healing heartbeat both run under a single 5s deadline;
671
+ * on timeout the command cuts clean and the caller continues (fire-and-forget, §4).
672
+ */
673
+ export async function runBacklogSync(flags, rootDir = process.cwd(), deps = {}) {
674
+ const creds = (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir)))(rootDir);
675
+ if (creds.status === 'not-configured') {
676
+ console.log(`ℹ️ Sync al backlog central omitido: ${creds.reason}`);
677
+ console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
678
+ return;
679
+ }
680
+ const { apiKey, projectId } = creds;
681
+ const api = deps.api ?? {};
682
+ const collected = (deps.collect ?? collectBacklog)(rootDir);
683
+ let heal = { repaired: 0, failed: 0, skippedNoUuid: 0 };
684
+ const work = async () => {
685
+ if (!flags.healOnly) {
686
+ await runPointSync(flags, collected, apiKey, projectId, api);
687
+ }
688
+ heal = await runHealing(collected, apiKey, projectId, api);
689
+ };
690
+ const completed = await runWithDeadline(SYNC_TIMEOUT_MS, work, deps.deadlineSleep);
691
+ if (!completed) {
692
+ console.warn(`⚠ Sync al backlog central cortado a los ${SYNC_TIMEOUT_MS / 1000}s — el workflow continúa sin esperar.`);
693
+ return;
694
+ }
695
+ if (heal.repaired || heal.failed) {
696
+ console.log(`✔ Self-healing: ${heal.repaired} reparado(s)` +
697
+ (heal.failed ? `, ${heal.failed} con error (reintenta la próxima corrida)` : '') +
698
+ '.');
699
+ }
700
+ else {
701
+ console.log('✔ Self-healing: sin drift (todo en sync).');
702
+ }
703
+ if (heal.skippedNoUuid > 0) {
704
+ console.log(`ℹ️ ${heal.skippedNoUuid} issue(s) sin \`Backlog UUID\` — fuera del heartbeat.`);
705
+ }
706
+ }