@praxisflux/gates 0.62.0 → 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.
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
264
264
  * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
265
  * block renumbers.
266
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
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
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. */
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. */
273
284
 
274
285
  const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
286
  const SPEC_PHASES_END = "<!-- spec-phases END -->";
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
402
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
403
414
  export const providers = {
404
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 },
405
422
  };
406
423
 
407
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
462
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
463
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
464
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
+ }
465
500
  }
466
501
  return problems;
467
502
  }
468
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
+
469
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
470
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
471
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
264
264
  * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
265
  * block renumbers.
266
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
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
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. */
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. */
273
284
 
274
285
  const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
286
  const SPEC_PHASES_END = "<!-- spec-phases END -->";
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
402
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
403
414
  export const providers = {
404
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 },
405
422
  };
406
423
 
407
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
462
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
463
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
464
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
+ }
465
500
  }
466
501
  return problems;
467
502
  }
468
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
+
469
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
470
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
471
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
264
264
  * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
265
  * block renumbers.
266
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
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
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. */
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. */
273
284
 
274
285
  const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
286
  const SPEC_PHASES_END = "<!-- spec-phases END -->";
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
402
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
403
414
  export const providers = {
404
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 },
405
422
  };
406
423
 
407
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
462
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
463
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
464
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
+ }
465
500
  }
466
501
  return problems;
467
502
  }
468
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
+
469
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
470
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
471
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.62.0",
3
+ "version": "0.63.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": {
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
264
264
  * Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
265
265
  * block renumbers.
266
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
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
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. */
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. */
273
284
 
274
285
  const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
275
286
  const SPEC_PHASES_END = "<!-- spec-phases END -->";
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
402
413
  * `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
403
414
  export const providers = {
404
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 },
405
422
  };
406
423
 
407
424
  /* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
462
479
  if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
463
480
  if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
464
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
+ }
465
500
  }
466
501
  return problems;
467
502
  }
468
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
+
469
541
  /** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
470
542
  * matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
471
543
  * array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`