@praxisflux/gates 0.61.1 → 0.62.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,78 @@ 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 two normalizations a live Jira write→read round-trip is known
268
+ * 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) and 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). Reuses TASK_LINE from spec-derive.mjs
272
+ * rather than a third checkbox regex. */
273
+
274
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
276
+
277
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
278
+ export function renderSpecPhasesBlock(items) {
279
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
280
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
281
+ }
282
+
283
+ /**
284
+ * Parse a description (or any string containing at most one block) into
285
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
286
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
287
+ * found, naming the counts.
288
+ */
289
+ export function parseSpecPhasesBlock(text) {
290
+ const s = String(text ?? "");
291
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
292
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
293
+ if (beginCount > 1 || endCount > 1)
294
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
295
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
296
+ const stop = s.indexOf(SPEC_PHASES_END);
297
+ if (start === -1 || stop === -1) return [];
298
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
299
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
300
+ const items = [];
301
+ for (const line of inner.split("\n")) {
302
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
303
+ const m = line.match(TASK_LINE);
304
+ if (!m) continue;
305
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
306
+ }
307
+ return items;
308
+ }
309
+
214
310
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
311
  *
216
312
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
219
315
 
220
316
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
317
 
318
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
319
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
320
+ * Returns [] when absent, empty, or unparseable — never throws. */
321
+ function parseFrontmatterLabels(fm) {
322
+ const m = fm.match(/^labels:(.*)$/m);
323
+ if (!m) return [];
324
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
325
+ const rest = m[1].trim();
326
+ if (rest) {
327
+ const inline = rest.match(/^\[(.*)\]$/);
328
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
329
+ }
330
+ const items = [];
331
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
332
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
333
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
334
+ if (!li) break;
335
+ items.push(strip(li[1]));
336
+ }
337
+ return items;
338
+ }
339
+
222
340
  /**
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.
341
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
342
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
343
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
344
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
345
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
346
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
347
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
348
+ * 052-055 all state it must pass unmodified).
227
349
  */
228
350
  export function parseLinkedTask(raw) {
229
351
  const text = String(raw ?? "");
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
240
362
  if (block)
241
363
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
364
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
365
+ const labels = parseFrontmatterLabels(fm[1]);
366
+ const linked = { id, status, specDir: marker[1], acs };
367
+ if (labels.length) linked.labels = labels;
368
+ return linked;
244
369
  }
245
370
 
246
371
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
259
384
  }
260
385
 
261
386
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
387
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
388
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
389
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
390
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
391
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
392
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
393
+ ));
265
394
  }
266
395
 
267
396
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -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,78 @@ 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 two normalizations a live Jira write→read round-trip is known
268
+ * 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) and 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). Reuses TASK_LINE from spec-derive.mjs
272
+ * rather than a third checkbox regex. */
273
+
274
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
276
+
277
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
278
+ export function renderSpecPhasesBlock(items) {
279
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
280
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
281
+ }
282
+
283
+ /**
284
+ * Parse a description (or any string containing at most one block) into
285
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
286
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
287
+ * found, naming the counts.
288
+ */
289
+ export function parseSpecPhasesBlock(text) {
290
+ const s = String(text ?? "");
291
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
292
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
293
+ if (beginCount > 1 || endCount > 1)
294
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
295
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
296
+ const stop = s.indexOf(SPEC_PHASES_END);
297
+ if (start === -1 || stop === -1) return [];
298
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
299
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
300
+ const items = [];
301
+ for (const line of inner.split("\n")) {
302
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
303
+ const m = line.match(TASK_LINE);
304
+ if (!m) continue;
305
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
306
+ }
307
+ return items;
308
+ }
309
+
214
310
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
311
  *
216
312
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
219
315
 
220
316
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
317
 
318
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
319
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
320
+ * Returns [] when absent, empty, or unparseable — never throws. */
321
+ function parseFrontmatterLabels(fm) {
322
+ const m = fm.match(/^labels:(.*)$/m);
323
+ if (!m) return [];
324
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
325
+ const rest = m[1].trim();
326
+ if (rest) {
327
+ const inline = rest.match(/^\[(.*)\]$/);
328
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
329
+ }
330
+ const items = [];
331
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
332
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
333
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
334
+ if (!li) break;
335
+ items.push(strip(li[1]));
336
+ }
337
+ return items;
338
+ }
339
+
222
340
  /**
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.
341
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
342
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
343
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
344
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
345
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
346
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
347
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
348
+ * 052-055 all state it must pass unmodified).
227
349
  */
228
350
  export function parseLinkedTask(raw) {
229
351
  const text = String(raw ?? "");
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
240
362
  if (block)
241
363
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
364
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
365
+ const labels = parseFrontmatterLabels(fm[1]);
366
+ const linked = { id, status, specDir: marker[1], acs };
367
+ if (labels.length) linked.labels = labels;
368
+ return linked;
244
369
  }
245
370
 
246
371
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
259
384
  }
260
385
 
261
386
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
387
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
388
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
389
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
390
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
391
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
392
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
393
+ ));
265
394
  }
266
395
 
267
396
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -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,78 @@ 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 two normalizations a live Jira write→read round-trip is known
268
+ * 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) and 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). Reuses TASK_LINE from spec-derive.mjs
272
+ * rather than a third checkbox regex. */
273
+
274
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
276
+
277
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
278
+ export function renderSpecPhasesBlock(items) {
279
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
280
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
281
+ }
282
+
283
+ /**
284
+ * Parse a description (or any string containing at most one block) into
285
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
286
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
287
+ * found, naming the counts.
288
+ */
289
+ export function parseSpecPhasesBlock(text) {
290
+ const s = String(text ?? "");
291
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
292
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
293
+ if (beginCount > 1 || endCount > 1)
294
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
295
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
296
+ const stop = s.indexOf(SPEC_PHASES_END);
297
+ if (start === -1 || stop === -1) return [];
298
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
299
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
300
+ const items = [];
301
+ for (const line of inner.split("\n")) {
302
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
303
+ const m = line.match(TASK_LINE);
304
+ if (!m) continue;
305
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
306
+ }
307
+ return items;
308
+ }
309
+
214
310
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
311
  *
216
312
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
219
315
 
220
316
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
317
 
318
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
319
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
320
+ * Returns [] when absent, empty, or unparseable — never throws. */
321
+ function parseFrontmatterLabels(fm) {
322
+ const m = fm.match(/^labels:(.*)$/m);
323
+ if (!m) return [];
324
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
325
+ const rest = m[1].trim();
326
+ if (rest) {
327
+ const inline = rest.match(/^\[(.*)\]$/);
328
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
329
+ }
330
+ const items = [];
331
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
332
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
333
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
334
+ if (!li) break;
335
+ items.push(strip(li[1]));
336
+ }
337
+ return items;
338
+ }
339
+
222
340
  /**
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.
341
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
342
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
343
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
344
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
345
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
346
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
347
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
348
+ * 052-055 all state it must pass unmodified).
227
349
  */
228
350
  export function parseLinkedTask(raw) {
229
351
  const text = String(raw ?? "");
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
240
362
  if (block)
241
363
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
364
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
365
+ const labels = parseFrontmatterLabels(fm[1]);
366
+ const linked = { id, status, specDir: marker[1], acs };
367
+ if (labels.length) linked.labels = labels;
368
+ return linked;
244
369
  }
245
370
 
246
371
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
259
384
  }
260
385
 
261
386
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
387
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
388
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
389
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
390
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
391
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
392
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
393
+ ));
265
394
  }
266
395
 
267
396
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.61.1",
3
+ "version": "0.62.0",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -779,6 +779,76 @@ export function renderBacklog(id, intents) {
779
779
  return cmds;
780
780
  }
781
781
 
782
+ /**
783
+ * Render one task's intents as ordered Jira MCP call descriptions (spec 055 R6): `renderBacklog`'s
784
+ * sibling for the `jira` provider. Each entry is `{ tool, args, why }` — `tool` a bare MCP tool
785
+ * name, unprefixed (the connector-specific prefix these Atlassian tools carry, and the actual
786
+ * dispatch, are spec 056's skill's job, not this module's), `args` a plain object, `why` the
787
+ * human-legible reason (feeds the sync skill's progress note). PURE: no MCP call, no network, no
788
+ * remote fetch of any kind — this is what keeps `lib/` (and this gate) network-free
789
+ * (docs/design/board-provider-seam.md invariant 4).
790
+ *
791
+ * `config` is `.board.json`'s `jira` sub-object (spec 054); only `statusMap` is consulted.
792
+ *
793
+ * The AC-block collapse: the Backlog renderer above issues one command PER acRemove/acAdd/
794
+ * acCheck/acUncheck entry, because Backlog's AC list has a partial-edit CLI. Jira's marked
795
+ * description block (docs/board-verbs.md R2) has no partial-edit form — it is replaced
796
+ * wholesale — so every ac* array here folds into exactly ONE `editJiraIssue` call, whatever the
797
+ * mix of adds/removes/checks/unchecks. That call's `args` carries the RAW diff, not a
798
+ * fully-resolved description string: resolving it against the block's CURRENT text needs the
799
+ * live issue (read via `getJiraIssue`, parsed with `parseSpecPhasesBlock`, the diff applied,
800
+ * re-rendered with `renderSpecPhasesBlock` — both `lib/board-mirror.mjs`), which needs live data
801
+ * this pure function neither has nor is allowed to fetch. That resolution is spec 056's skill's
802
+ * job — this function only describes the diff to make, in order; executing (and resolving) it
803
+ * is not this module's job.
804
+ */
805
+ export function renderJira(id, intents, config = {}) {
806
+ const statusMap = config?.statusMap ?? {};
807
+ const calls = [];
808
+
809
+ if (intents.statusTo) {
810
+ const target = statusMap[intents.statusTo] ?? intents.statusTo;
811
+ if (intents.statusTo === "Done" && intents.finalSummary) {
812
+ // board:final's jira column: comment the summary, THEN transition — so the summary is on
813
+ // record before the issue moves, mirroring Backlog's combined `-s Done --final-summary`.
814
+ calls.push({
815
+ tool: "addOrEditJiraIssueComment",
816
+ args: { issueIdOrKey: id, commentBody: intents.finalSummary },
817
+ why: intents.finalSummary,
818
+ });
819
+ }
820
+ calls.push({
821
+ tool: "transitionJiraIssue",
822
+ args: { issueIdOrKey: id, status: target },
823
+ why: `status ${intents.statusFrom} -> ${intents.statusTo}`,
824
+ });
825
+ }
826
+
827
+ const acChanged = intents.acRemove.length > 0 || intents.acAdd.length > 0
828
+ || intents.acCheck.length > 0 || intents.acUncheck.length > 0;
829
+ if (acChanged) {
830
+ calls.push({
831
+ tool: "editJiraIssue",
832
+ args: {
833
+ issueIdOrKey: id,
834
+ acRemove: intents.acRemove, acAdd: intents.acAdd,
835
+ acCheck: intents.acCheck, acUncheck: intents.acUncheck,
836
+ },
837
+ why: intents.note ?? "phase ACs reconciled",
838
+ });
839
+ }
840
+
841
+ if (intents.note) {
842
+ calls.push({
843
+ tool: "addOrEditJiraIssueComment",
844
+ args: { issueIdOrKey: id, commentBody: intents.note },
845
+ why: intents.note,
846
+ });
847
+ }
848
+
849
+ return calls;
850
+ }
851
+
782
852
  /**
783
853
  * Backward-compatible single-shot planner: `planIntents` then `renderBacklog` in one call.
784
854
  * Every call site inside this module now goes through the two halves directly; this wrapper
@@ -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,78 @@ 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 two normalizations a live Jira write→read round-trip is known
268
+ * 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) and 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). Reuses TASK_LINE from spec-derive.mjs
272
+ * rather than a third checkbox regex. */
273
+
274
+ const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
+ const SPEC_PHASES_END = "<!-- spec-phases END -->";
276
+
277
+ /** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
278
+ export function renderSpecPhasesBlock(items) {
279
+ const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
280
+ return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
281
+ }
282
+
283
+ /**
284
+ * Parse a description (or any string containing at most one block) into
285
+ * [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
286
+ * an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
287
+ * found, naming the counts.
288
+ */
289
+ export function parseSpecPhasesBlock(text) {
290
+ const s = String(text ?? "");
291
+ const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
292
+ const endCount = s.split(SPEC_PHASES_END).length - 1;
293
+ if (beginCount > 1 || endCount > 1)
294
+ throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
295
+ const start = s.indexOf(SPEC_PHASES_BEGIN);
296
+ const stop = s.indexOf(SPEC_PHASES_END);
297
+ if (start === -1 || stop === -1) return [];
298
+ if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
299
+ const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
300
+ const items = [];
301
+ for (const line of inner.split("\n")) {
302
+ if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
303
+ const m = line.match(TASK_LINE);
304
+ if (!m) continue;
305
+ items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
306
+ }
307
+ return items;
308
+ }
309
+
214
310
  /* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
215
311
  *
216
312
  * Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
219
315
 
220
316
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
221
317
 
318
+ /** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
319
+ * form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
320
+ * Returns [] when absent, empty, or unparseable — never throws. */
321
+ function parseFrontmatterLabels(fm) {
322
+ const m = fm.match(/^labels:(.*)$/m);
323
+ if (!m) return [];
324
+ const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
325
+ const rest = m[1].trim();
326
+ if (rest) {
327
+ const inline = rest.match(/^\[(.*)\]$/);
328
+ return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
329
+ }
330
+ const items = [];
331
+ for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
332
+ if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
333
+ const li = line.match(/^\s*-\s*(.+?)\s*$/);
334
+ if (!li) break;
335
+ items.push(strip(li[1]));
336
+ }
337
+ return items;
338
+ }
339
+
222
340
  /**
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.
341
+ * Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
342
+ * for anything else (no marker, unreadable, or not a task file). `acs` is the task's
343
+ * acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
344
+ * read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
345
+ * R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
346
+ * unlabelled task's return value stays byte-identical to before this field existed (this is
347
+ * load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
348
+ * 052-055 all state it must pass unmodified).
227
349
  */
228
350
  export function parseLinkedTask(raw) {
229
351
  const text = String(raw ?? "");
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
240
362
  if (block)
241
363
  for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
242
364
  acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
243
- return { id, status, specDir: marker[1], acs };
365
+ const labels = parseFrontmatterLabels(fm[1]);
366
+ const linked = { id, status, specDir: marker[1], acs };
367
+ if (labels.length) linked.labels = labels;
368
+ return linked;
244
369
  }
245
370
 
246
371
  /** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
259
384
  }
260
385
 
261
386
  /** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
262
- * exactly the mirror's per-link fields (drops `file`). */
387
+ * exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
388
+ * through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
389
+ * extra logic to keep an unlabelled projection byte-identical to before this field existed. */
263
390
  export function projectBacklog(root) {
264
- return findLinkedTasks(root).map(({ id, status, specDir, acs }) => ({ id, status, specDir, acs }));
391
+ return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
392
+ labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
393
+ ));
265
394
  }
266
395
 
267
396
  /** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
@@ -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) {