@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
|
|
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)
|
|
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)
|
|
272
|
-
*
|
|
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
|
|
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)
|
|
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)
|
|
272
|
-
*
|
|
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/lib/board-mirror.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
272
|
-
*
|
|
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
|
@@ -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
|
|
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)
|
|
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)
|
|
272
|
-
*
|
|
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`
|