@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.
- package/codebase-to-course/lib/board-mirror.mjs +209 -8
- package/codebase-to-course/lib/spec-derive.mjs +5 -2
- package/grounding-wiki/lib/board-mirror.mjs +209 -8
- package/grounding-wiki/lib/spec-derive.mjs +5 -2
- package/lib/board-mirror.mjs +209 -8
- package/lib/spec-derive.mjs +5 -2
- package/package.json +1 -1
- package/spec-bridge/gates/bridge.mjs +70 -0
- package/spec-bridge/lib/board-mirror.mjs +209 -8
- package/spec-bridge/lib/spec-derive.mjs +5 -2
|
@@ -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
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
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
|
-
|
|
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 }) => (
|
|
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
|
-
|
|
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
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
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
|
-
|
|
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 }) => (
|
|
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
|
-
|
|
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) {
|