@praxisflux/gates 0.61.1 → 0.63.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.
@@ -10,6 +10,7 @@
10
10
  // "links": [
11
11
  // { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
12
12
  // "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
13
+ // "labels": ["paused"],
13
14
  // "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
14
15
  // ]
15
16
  // }
@@ -20,6 +21,15 @@
20
21
  // logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
21
22
  // (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
22
23
  //
24
+ // `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
25
+ // link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
26
+ // task with no labels, so an unlabelled task's link stays byte-identical to before this field
27
+ // existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
28
+ // `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
29
+ // assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
30
+ // Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
31
+ // required to emit it for an unlabelled link.
32
+ //
23
33
  // `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
24
34
  // never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
25
35
  // top-level and per-link keys round-trip: read a mirror, write it back, and every key this
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
60
70
  import { join, dirname, resolve } from "node:path";
61
71
  import { spawnSync } from "node:child_process";
62
72
  import { runAsCli } from "./cli.mjs";
73
+ import { TASK_LINE } from "./spec-derive.mjs";
63
74
 
64
75
  /** The only schema this module understands. */
65
76
  export const CURRENT_SCHEMA = 1;
@@ -115,7 +126,7 @@ export function readMirror(root) {
115
126
  }
116
127
 
117
128
  const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
118
- const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
129
+ const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
119
130
  const AC_KEYS = ["index", "checked", "text"];
120
131
 
121
132
  /** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
193
204
  seenSpecDirs.add(link.specDir);
194
205
  }
195
206
 
207
+ // labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
208
+ // mirror written before this field existed); present-but-malformed is an error naming the
209
+ // offending link.
210
+ if (link.labels !== undefined) {
211
+ if (!Array.isArray(link.labels)) {
212
+ problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
213
+ } else {
214
+ link.labels.forEach((l, k) => {
215
+ if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
216
+ });
217
+ }
218
+ }
219
+
196
220
  if (!req(link.acs, `${where}.acs`, "array")) return;
197
221
  let prev = -Infinity;
198
222
  link.acs.forEach((ac, j) => {
@@ -211,6 +235,89 @@ export function validateMirror(mirror) {
211
235
  return problems;
212
236
  }
213
237
 
238
+ /** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
239
+ * the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
240
+ * `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
241
+ * against, so a mirror-only project — no live board files to fall back on, the case a
242
+ * Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
243
+ * (spec 055 R4/AC #7). */
244
+ export function isPausedLink(link) {
245
+ return Array.isArray(link?.labels) && link.labels.includes("paused");
246
+ }
247
+
248
+ /* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
249
+ * Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
250
+ * consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
251
+ * else. Full contract: docs/board-verbs.md "R2 — the marked description block".
252
+ *
253
+ * Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
254
+ * or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
255
+ * `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
256
+ * (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
257
+ * matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
258
+ * test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
259
+ * validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
260
+ * violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
261
+ * the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
262
+ * specs/056-jira-provider/findings/phase-1-mcp-surface.md).
263
+ *
264
+ * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
+ * block renumbers.
266
+ *
267
+ * `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
268
+ * known to introduce on EVERY read (same findings file): a blank line inserted immediately after
269
+ * BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
270
+ * to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
271
+ * strips trailing whitespace from the captured text), and two trailing spaces appended to the
272
+ * END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
273
+ * indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
274
+ * from spec-derive.mjs rather than a third checkbox regex.
275
+ *
276
+ * WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
277
+ * COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
278
+ * 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
279
+ * recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
280
+ * measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
281
+ * trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
282
+ * live write of a clean marker returning exactly 2). Splice the rendered block between the
283
+ * existing markers rather than preserving the read bytes, and the growth cannot start. */
284
+
285
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
286
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
287
+
288
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
289
+ export function renderSpecPhasesBlock(items) {
290
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
291
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
292
+ }
293
+
294
+ /**
295
+ * Parse a description (or any string containing at most one block) into
296
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
297
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
298
+ * found, naming the counts.
299
+ */
300
+ export function parseSpecPhasesBlock(text) {
301
+ const s = String(text ?? "");
302
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
303
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
304
+ if (beginCount > 1 || endCount > 1)
305
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
306
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
307
+ const stop = s.indexOf(SPEC_PHASES_END);
308
+ if (start === -1 || stop === -1) return [];
309
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
310
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
311
+ const items = [];
312
+ for (const line of inner.split("\n")) {
313
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
314
+ const m = line.match(TASK_LINE);
315
+ if (!m) continue;
316
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
317
+ }
318
+ return items;
319
+ }
320
+
214
321
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
322
  *
216
323
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +326,37 @@ export function validateMirror(mirror) {
219
326
 
220
327
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
328
 
329
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
330
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
331
+ * Returns [] when absent, empty, or unparseable — never throws. */
332
+ function parseFrontmatterLabels(fm) {
333
+ const m = fm.match(/^labels:(.*)$/m);
334
+ if (!m) return [];
335
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
336
+ const rest = m[1].trim();
337
+ if (rest) {
338
+ const inline = rest.match(/^\[(.*)\]$/);
339
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
340
+ }
341
+ const items = [];
342
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
343
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
344
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
345
+ if (!li) break;
346
+ items.push(strip(li[1]));
347
+ }
348
+ return items;
349
+ }
350
+
222
351
  /**
223
- * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
224
- * null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
225
- * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
226
- * still read-only; the plan command needs them to compute reconciling edits.
352
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
353
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
354
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
355
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
356
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
357
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
358
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
359
+ * 052-055 all state it must pass unmodified).
227
360
  */
228
361
  export function parseLinkedTask(raw) {
229
362
  const text = String(raw ?? "");
@@ -240,7 +373,10 @@ export function parseLinkedTask(raw) {
240
373
  if (block)
241
374
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
375
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
376
+ const labels = parseFrontmatterLabels(fm[1]);
377
+ const linked = { id, status, specDir: marker[1], acs };
378
+ if (labels.length) linked.labels = labels;
379
+ return linked;
244
380
  }
245
381
 
246
382
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +395,13 @@ export function findLinkedTasks(root) {
259
395
  }
260
396
 
261
397
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
398
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
399
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
400
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
401
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
402
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
403
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
404
+ ));
265
405
  }
266
406
 
267
407
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -273,6 +413,12 @@ export function projectBacklog(root) {
273
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
274
414
  export const providers = {
275
415
  backlog: { requiresSync: false, project: projectBacklog },
416
+ // spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
417
+ // provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
418
+ // is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
419
+ // missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
420
+ // anywhere: the shape of this entry carries the distinction.
421
+ jira: { requiresSync: true, project: null },
276
422
  };
277
423
 
278
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -333,10 +479,65 @@ export function validateBoardConfig(config) {
333
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
334
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
335
481
  problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
482
+ if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
483
+ problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
484
+ // The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
485
+ // Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
486
+ // canonical WRITE target per bridge status, so two bridge statuses sharing a site status
487
+ // makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
488
+ // many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
489
+ // exempt. Silently picking a winner would make verdicts depend on key order.
490
+ if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
491
+ const seen = new Map();
492
+ for (const [bridge, site] of Object.entries(sub.statusMap)) {
493
+ if (typeof site !== "string") continue;
494
+ const prior = seen.get(site);
495
+ if (prior !== undefined)
496
+ problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
497
+ else seen.set(site, bridge);
498
+ }
499
+ }
336
500
  }
337
501
  return problems;
338
502
  }
339
503
 
504
+ /* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
505
+ *
506
+ * Two fields, because the two directions have genuinely different shapes and one field cannot
507
+ * honestly carry both:
508
+ *
509
+ * bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
510
+ * site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
511
+ *
512
+ * The live workflow that forced this had FIFTEEN statuses against the bridge's three
513
+ * (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
514
+ * many-to-one, while the write direction must pick exactly one target per bridge status.
515
+ * See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
516
+ *
517
+ * Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
518
+ * host with no map at all behaves exactly as it did before either field existed. */
519
+
520
+ /** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
521
+ export function toSiteStatus(bridgeStatus, config = {}) {
522
+ return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
523
+ }
524
+
525
+ /** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
526
+ * many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
527
+ * `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
528
+ * caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
529
+ * this function guessing one. */
530
+ export function toBridgeStatus(siteStatus, config = {}) {
531
+ const read = config?.statusReadMap;
532
+ if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
533
+ return read[siteStatus];
534
+ const write = config?.statusMap;
535
+ if (write && typeof write === "object" && !Array.isArray(write)) {
536
+ for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
537
+ }
538
+ return siteStatus;
539
+ }
540
+
340
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
341
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
342
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
61
  // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
62
62
  // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
63
63
  // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
64
- // (AC #1: name the phase, the box, and the failing gate).
65
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
64
+ // (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
65
+ // lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
66
+ // third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
67
+ // captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
68
+ export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
66
69
 
67
70
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
68
71
  function phaseName(heading) {
@@ -10,6 +10,7 @@
10
10
  // "links": [
11
11
  // { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
12
12
  // "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
13
+ // "labels": ["paused"],
13
14
  // "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
14
15
  // ]
15
16
  // }
@@ -20,6 +21,15 @@
20
21
  // logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
21
22
  // (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
22
23
  //
24
+ // `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
25
+ // link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
26
+ // task with no labels, so an unlabelled task's link stays byte-identical to before this field
27
+ // existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
28
+ // `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
29
+ // assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
30
+ // Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
31
+ // required to emit it for an unlabelled link.
32
+ //
23
33
  // `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
24
34
  // never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
25
35
  // top-level and per-link keys round-trip: read a mirror, write it back, and every key this
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
60
70
  import { join, dirname, resolve } from "node:path";
61
71
  import { spawnSync } from "node:child_process";
62
72
  import { runAsCli } from "./cli.mjs";
73
+ import { TASK_LINE } from "./spec-derive.mjs";
63
74
 
64
75
  /** The only schema this module understands. */
65
76
  export const CURRENT_SCHEMA = 1;
@@ -115,7 +126,7 @@ export function readMirror(root) {
115
126
  }
116
127
 
117
128
  const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
118
- const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
129
+ const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
119
130
  const AC_KEYS = ["index", "checked", "text"];
120
131
 
121
132
  /** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
193
204
  seenSpecDirs.add(link.specDir);
194
205
  }
195
206
 
207
+ // labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
208
+ // mirror written before this field existed); present-but-malformed is an error naming the
209
+ // offending link.
210
+ if (link.labels !== undefined) {
211
+ if (!Array.isArray(link.labels)) {
212
+ problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
213
+ } else {
214
+ link.labels.forEach((l, k) => {
215
+ if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
216
+ });
217
+ }
218
+ }
219
+
196
220
  if (!req(link.acs, `${where}.acs`, "array")) return;
197
221
  let prev = -Infinity;
198
222
  link.acs.forEach((ac, j) => {
@@ -211,6 +235,89 @@ export function validateMirror(mirror) {
211
235
  return problems;
212
236
  }
213
237
 
238
+ /** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
239
+ * the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
240
+ * `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
241
+ * against, so a mirror-only project — no live board files to fall back on, the case a
242
+ * Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
243
+ * (spec 055 R4/AC #7). */
244
+ export function isPausedLink(link) {
245
+ return Array.isArray(link?.labels) && link.labels.includes("paused");
246
+ }
247
+
248
+ /* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
249
+ * Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
250
+ * consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
251
+ * else. Full contract: docs/board-verbs.md "R2 — the marked description block".
252
+ *
253
+ * Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
254
+ * or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
255
+ * `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
256
+ * (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
257
+ * matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
258
+ * test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
259
+ * validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
260
+ * violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
261
+ * the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
262
+ * specs/056-jira-provider/findings/phase-1-mcp-surface.md).
263
+ *
264
+ * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
+ * block renumbers.
266
+ *
267
+ * `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
268
+ * known to introduce on EVERY read (same findings file): a blank line inserted immediately after
269
+ * BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
270
+ * to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
271
+ * strips trailing whitespace from the captured text), and two trailing spaces appended to the
272
+ * END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
273
+ * indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
274
+ * from spec-derive.mjs rather than a third checkbox regex.
275
+ *
276
+ * WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
277
+ * COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
278
+ * 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
279
+ * recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
280
+ * measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
281
+ * trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
282
+ * live write of a clean marker returning exactly 2). Splice the rendered block between the
283
+ * existing markers rather than preserving the read bytes, and the growth cannot start. */
284
+
285
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
286
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
287
+
288
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
289
+ export function renderSpecPhasesBlock(items) {
290
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
291
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
292
+ }
293
+
294
+ /**
295
+ * Parse a description (or any string containing at most one block) into
296
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
297
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
298
+ * found, naming the counts.
299
+ */
300
+ export function parseSpecPhasesBlock(text) {
301
+ const s = String(text ?? "");
302
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
303
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
304
+ if (beginCount > 1 || endCount > 1)
305
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
306
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
307
+ const stop = s.indexOf(SPEC_PHASES_END);
308
+ if (start === -1 || stop === -1) return [];
309
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
310
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
311
+ const items = [];
312
+ for (const line of inner.split("\n")) {
313
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
314
+ const m = line.match(TASK_LINE);
315
+ if (!m) continue;
316
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
317
+ }
318
+ return items;
319
+ }
320
+
214
321
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
322
  *
216
323
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +326,37 @@ export function validateMirror(mirror) {
219
326
 
220
327
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
328
 
329
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
330
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
331
+ * Returns [] when absent, empty, or unparseable — never throws. */
332
+ function parseFrontmatterLabels(fm) {
333
+ const m = fm.match(/^labels:(.*)$/m);
334
+ if (!m) return [];
335
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
336
+ const rest = m[1].trim();
337
+ if (rest) {
338
+ const inline = rest.match(/^\[(.*)\]$/);
339
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
340
+ }
341
+ const items = [];
342
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
343
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
344
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
345
+ if (!li) break;
346
+ items.push(strip(li[1]));
347
+ }
348
+ return items;
349
+ }
350
+
222
351
  /**
223
- * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
224
- * null for anything else (no marker, unreadable, or not a task file). `acs` is the task's
225
- * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
226
- * still read-only; the plan command needs them to compute reconciling edits.
352
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
353
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
354
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
355
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
356
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
357
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
358
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
359
+ * 052-055 all state it must pass unmodified).
227
360
  */
228
361
  export function parseLinkedTask(raw) {
229
362
  const text = String(raw ?? "");
@@ -240,7 +373,10 @@ export function parseLinkedTask(raw) {
240
373
  if (block)
241
374
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
375
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
376
+ const labels = parseFrontmatterLabels(fm[1]);
377
+ const linked = { id, status, specDir: marker[1], acs };
378
+ if (labels.length) linked.labels = labels;
379
+ return linked;
244
380
  }
245
381
 
246
382
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +395,13 @@ export function findLinkedTasks(root) {
259
395
  }
260
396
 
261
397
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
398
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
399
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
400
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
401
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
402
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
403
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
404
+ ));
265
405
  }
266
406
 
267
407
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -273,6 +413,12 @@ export function projectBacklog(root) {
273
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
274
414
  export const providers = {
275
415
  backlog: { requiresSync: false, project: projectBacklog },
416
+ // spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
417
+ // provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
418
+ // is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
419
+ // missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
420
+ // anywhere: the shape of this entry carries the distinction.
421
+ jira: { requiresSync: true, project: null },
276
422
  };
277
423
 
278
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -333,10 +479,65 @@ export function validateBoardConfig(config) {
333
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
334
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
335
481
  problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
482
+ if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
483
+ problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
484
+ // The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
485
+ // Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
486
+ // canonical WRITE target per bridge status, so two bridge statuses sharing a site status
487
+ // makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
488
+ // many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
489
+ // exempt. Silently picking a winner would make verdicts depend on key order.
490
+ if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
491
+ const seen = new Map();
492
+ for (const [bridge, site] of Object.entries(sub.statusMap)) {
493
+ if (typeof site !== "string") continue;
494
+ const prior = seen.get(site);
495
+ if (prior !== undefined)
496
+ problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
497
+ else seen.set(site, bridge);
498
+ }
499
+ }
336
500
  }
337
501
  return problems;
338
502
  }
339
503
 
504
+ /* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
505
+ *
506
+ * Two fields, because the two directions have genuinely different shapes and one field cannot
507
+ * honestly carry both:
508
+ *
509
+ * bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
510
+ * site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
511
+ *
512
+ * The live workflow that forced this had FIFTEEN statuses against the bridge's three
513
+ * (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
514
+ * many-to-one, while the write direction must pick exactly one target per bridge status.
515
+ * See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
516
+ *
517
+ * Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
518
+ * host with no map at all behaves exactly as it did before either field existed. */
519
+
520
+ /** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
521
+ export function toSiteStatus(bridgeStatus, config = {}) {
522
+ return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
523
+ }
524
+
525
+ /** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
526
+ * many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
527
+ * `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
528
+ * caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
529
+ * this function guessing one. */
530
+ export function toBridgeStatus(siteStatus, config = {}) {
531
+ const read = config?.statusReadMap;
532
+ if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
533
+ return read[siteStatus];
534
+ const write = config?.statusMap;
535
+ if (write && typeof write === "object" && !Array.isArray(write)) {
536
+ for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
537
+ }
538
+ return siteStatus;
539
+ }
540
+
340
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
341
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
342
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
61
  // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
62
62
  // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
63
63
  // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
64
- // (AC #1: name the phase, the box, and the failing gate).
65
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
64
+ // (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
65
+ // lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
66
+ // third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
67
+ // captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
68
+ export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
66
69
 
67
70
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
68
71
  function phaseName(heading) {