From 642ca033da691fb60dbacd5fedf333f6a502d9eb Mon Sep 17 00:00:00 2001 From: aarroyo Date: Thu, 30 Jul 2026 14:39:44 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(ci):=20one=20gap,=20one=20claim=20?= =?UTF-8?q?=E2=80=94=20the=20claim=20guard,=20advancing=20GT-639?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board row carries a STATUS but not who is working it or where, so two sessions can both read `PENDING` and both start, correctly. On 2026-07-30 that happened three times in a day. `50-validate-gap-claim` derives the claim set from OPEN PULL REQUESTS — every `GT-*` in a PR's title, body or branch name — and fails when one id is claimed by two of them, naming both with their branches and titles. DERIVED, NEVER HAND-WRITTEN, and the row asked for that on purpose: a hand-written claim goes stale in the direction that matters, because someone forgets to remove it and the next session works around a claim nobody holds. THREE CRITERIA OF FOUR, and the fourth is left open with its reason rather than stretched to fit. A claim list committed to the board would be derived from live GitHub state, so it would be stale the moment anyone opened a pull request — and GT-630's chain exists precisely to insist derived artifacts reach a fixed point. A perpetually-stale artifact inside that chain would be worse than none. Making the claim discoverable from the board needs something the board can render without committing, and that is not built. The row stays IN-PROGRESS. What it cannot see is in its own failure text, not implied: a branch with no open pull request claims nothing. Opening the PR early — draft is enough — is what makes the claim visible, which turns a convention into something with a check behind it. Anti-vacuous: a pull-request query that cannot be answered is a hard failure, not a quiet pass. A guard built because two sessions could not see each other must not stay silent when it cannot look. Zero open PRs is reported in words, because it looks identical to a broken query otherwise. Verified: 10/10 fixtures; 42-validate-guard-denominators 62 guards classified; 43-validate-guard-negative-fixtures 39/39 observed red, up from 38. Run against the real repository it reports 0 claims across 5 open PRs — checked rather than assumed: all five are Dependabot bumps naming no gap, so the zero is real and not an empty query. 08-validate-tracking (640 gaps), 01, 04, 41 within budget, 46 chain at a fixed point. Counters: 600/640, in-progress 14 -> 15, pending 22 -> 21. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-cd.yml | 11 + .harness/scripts/ci/50-validate-gap-claim.mjs | 199 ++++++++++++++++++ .../scripts/ci/50-validate-gap-claim.test.mjs | 136 ++++++++++++ .harness/scripts/lib/guard-classification.mjs | 9 + .../gaps/gap-reference-catalog.es.md | 8 +- .../gaps/gap-reference-catalog.md | 8 +- .../control-center/gaps/gap-tracking.es.md | 4 +- .../core/control-center/gaps/gap-tracking.md | 4 +- .../maturity-reconciliation.json | 4 +- 9 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 .harness/scripts/ci/50-validate-gap-claim.mjs create mode 100644 .harness/scripts/ci/50-validate-gap-claim.test.mjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4afbc715..0f96ad61 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -456,11 +456,22 @@ jobs: git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}" node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose + # GT-639: a board row carries a STATUS but not who is working it or where, so + # two sessions can both read `PENDING` and both start. On 2026-07-30 the same + # work was done twice, three times over. The claim set is DERIVED from open + # pull requests — never hand-written, which would go stale in the direction + # that matters — and an id claimed by two open PRs fails here, naming both. + - name: One gap, one claim + env: + GH_TOKEN: ${{ github.token }} + run: node .harness/scripts/ci/50-validate-gap-claim.mjs + - name: Self-tests for the governance guards run: | node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs + node --test .harness/scripts/ci/50-validate-gap-claim.test.mjs node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs diff --git a/.harness/scripts/ci/50-validate-gap-claim.mjs b/.harness/scripts/ci/50-validate-gap-claim.mjs new file mode 100644 index 00000000..744eced8 --- /dev/null +++ b/.harness/scripts/ci/50-validate-gap-claim.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +/** + * GT-639 — two sessions must not work the same gap without knowing. + * + * ## The defect + * + * On 2026-07-30 the same work was done twice, three separate times, by sessions + * that could not see each other: GT-640 (then GT-633) was fixed by a standalone + * capture script on `main` and by an independent in-spec renderer on `develop`; + * the evidence guard's parser was fixed on both branches; and a GT id was + * allocated twice. The cost was not the merge conflict — it was the duplicated + * work before anyone reached it, plus a reconciliation that introduced a defect + * of its own. + * + * A board row carries a STATUS but not who is working it or where. Two sessions + * can both read `GT-640 · PENDING` and both start, correctly. + * + * ## What it checks + * + * The claim set is DERIVED from open pull requests — never hand-written, because + * a hand-written claim is stale in the direction that matters (someone forgets to + * remove it, and the next session works around a claim nobody holds). Every open + * PR claims the `GT-*` ids that appear in its title, its body or its branch name. + * + * An id claimed by MORE THAN ONE open pull request is a contested claim, and this + * guard fails naming both claimants. + * + * ## What it deliberately does NOT do + * + * It does not write a committed claim list. Such a file would be derived from + * live GitHub state, so it would be stale the moment anyone opened a PR, and the + * repository already has a guard (46) whose whole premise is that derived + * artifacts must reach a fixed point. A perpetually-stale artifact in that chain + * would be worse than none. The live view is this check's output, on the PR where + * it matters. + * + * It also cannot see work that has no open PR. A branch someone is working on + * privately claims nothing, and the guard says so rather than implying coverage. + * + * ## Anti-vacuous pass + * + * Zero open pull requests examined is reported, not silently passed: with no PRs + * there is nothing to contest, and a run that found none because the query broke + * looks identical unless it says which happened. A query that fails outright is a + * hard failure — unable to answer is not the same as nothing to report. + * + * USAGE + * node .harness/scripts/ci/50-validate-gap-claim.mjs + * node .harness/scripts/ci/50-validate-gap-claim.mjs --json + * node .harness/scripts/ci/50-validate-gap-claim.mjs --fixture # offline + * + * EXIT CODES + * 0 no id is claimed by more than one open pull request + * 1 a contested claim, or the pull-request query could not be answered + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const GUARD = '50-validate-gap-claim'; + +// --------------------------------------------------------------------------- +// Pure core — takes pull requests as data, returns the claim map. No network. +// --------------------------------------------------------------------------- + +/** Every `GT-NNN` mentioned in a pull request's title, body or branch name. */ +export function claimedIds(pr) { + const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`; + return [...new Set(haystack.match(/GT-\d+/g) ?? [])].sort(); +} + +/** + * id -> the open pull requests claiming it. + * + * @param {Array<{number:number,title?:string,body?:string,headRefName?:string,url?:string}>} prs + * @returns {Map>} + */ +export function buildClaimMap(prs) { + const map = new Map(); + for (const pr of prs) { + for (const id of claimedIds(pr)) { + if (!map.has(id)) map.set(id, []); + map.get(id).push(pr); + } + } + return map; +} + +/** Ids claimed by more than one open pull request. */ +export function findContested(claimMap) { + return [...claimMap.entries()] + .filter(([, prs]) => prs.length > 1) + .map(([id, prs]) => ({ id, prs })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +// --------------------------------------------------------------------------- +// I/O edges +// --------------------------------------------------------------------------- + +function fail(lines) { + console.error(`\n✗ ${GUARD}: ${lines[0]}`); + for (const l of lines.slice(1)) console.error(` ${l}`); + process.exit(1); +} + +/** Open pull requests, from `gh`. Returns null when the query cannot be answered. */ +export function fetchOpenPullRequests() { + try { + const raw = execFileSync( + 'gh', + ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,body,headRefName,url'], + { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function main(argv) { + const asJson = argv.includes('--json'); + const fixtureIdx = argv.indexOf('--fixture'); + + const prs = fixtureIdx !== -1 + ? JSON.parse(fs.readFileSync(path.resolve(process.cwd(), argv[fixtureIdx + 1]), 'utf8')) + : fetchOpenPullRequests(); + + if (prs === null) { + fail([ + 'could not read the open pull requests, so no claim was checked.', + ' `gh pr list` failed — in CI that usually means GH_TOKEN is not set on the step.', + '', + ' Unable to answer is not the same as nothing to report. This guard exists', + ' because two sessions could not see each other; a version of it that stays', + ' quiet when it cannot look would reproduce exactly that.', + ]); + } + + const claimMap = buildClaimMap(prs); + const contested = findContested(claimMap); + + if (asJson) { + process.stdout.write(JSON.stringify({ + pullRequests: prs.length, + claims: Object.fromEntries([...claimMap].map(([id, list]) => [id, list.map((p) => p.number)])), + contested: contested.map((c) => ({ id: c.id, prs: c.prs.map((p) => p.number) })), + }) + '\n'); + return contested.length > 0 ? 1 : 0; + } + + console.log(`${GUARD} — one gap, one claim`); + console.log(` open pull requests .. ${prs.length}`); + console.log(` ids claimed ......... ${claimMap.size}`); + console.log(` contested ........... ${contested.length}`); + + if (prs.length === 0) { + // Said out loud: with no open PRs there is nothing to contest, which is a + // legitimate state and looks identical to a broken query unless named. + console.log('\n No pull request is open, so nothing is claimed and nothing can be contested.'); + } + + for (const [id, list] of [...claimMap].sort()) { + if (list.length === 1) console.log(` · ${id} — #${list[0].number} ${list[0].headRefName ?? ''}`); + } + + if (contested.length > 0) { + fail([ + `${contested.length} gap id(s) are claimed by more than one open pull request:`, + ...contested.flatMap((c) => [ + ` • ${c.id}`, + ...c.prs.map((p) => ` #${p.number} ${p.headRefName ?? '?'} ${p.title ?? ''}`), + ]), + '', + ' Two sessions are working the same gap. That is not a merge conflict yet, and', + ' it is much cheaper to resolve now than after both have landed: on 2026-07-30', + ' the same fix was written twice and a third session spent a full reconciliation', + ' collapsing them (GT-639).', + '', + ' Decide which pull request owns the id, and say so in the other one.', + '', + ' NOT COVERED by this check: a branch with no open pull request claims nothing.', + ' Opening the PR early — draft is enough — is what makes the claim visible.', + ]); + } + + console.log( + `\n✓ ${GUARD}: ${claimMap.size} claimed id(s) across ${prs.length} open pull request(s), none contested.`, + ); + return 0; +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) process.exit(main(process.argv.slice(2))); diff --git a/.harness/scripts/ci/50-validate-gap-claim.test.mjs b/.harness/scripts/ci/50-validate-gap-claim.test.mjs new file mode 100644 index 00000000..afa9c020 --- /dev/null +++ b/.harness/scripts/ci/50-validate-gap-claim.test.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +/** + * GT-639 — fixtures for the gap-claim guard. + * + * The case that matters is the one this repository lived through: two open pull + * requests working the same gap, neither aware of the other. If that fixture ever + * goes green the guard is decoration — which is why GT-639's last criterion asks + * for a fixture OBSERVED red rather than a guard someone believes works. + * + * The pull requests are supplied as data through `--fixture`, so nothing here + * touches the network: the guard's core is pure by construction and the I/O edge + * is one function. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { claimedIds, buildClaimMap, findContested } from './50-validate-gap-claim.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GUARD = resolve(__dirname, '50-validate-gap-claim.mjs'); + +let sandbox; +before(() => { sandbox = mkdtempSync(join(tmpdir(), 'gt639-')); }); +after(() => { if (sandbox) rmSync(sandbox, { recursive: true, force: true }); }); + +const runWith = (prs, extra = []) => { + const file = join(sandbox, `prs-${Math.abs(JSON.stringify(prs).length)}-${prs.length}.json`); + writeFileSync(file, JSON.stringify(prs)); + const r = spawnSync(process.execPath, [GUARD, '--fixture', file, ...extra], { + encoding: 'utf8', timeout: 60000, + }); + return { status: r.status, out: `${r.stdout}\n${r.stderr}` }; +}; + +describe('claimedIds', () => { + it('reads a claim from the title, the body or the branch name', () => { + assert.deepEqual(claimedIds({ title: 'fix(x): close GT-100' }), ['GT-100']); + assert.deepEqual(claimedIds({ body: 'refs GT-101 and GT-102' }), ['GT-101', 'GT-102']); + assert.deepEqual(claimedIds({ headRefName: 'feat/gt-103-thing' }), []); // lowercase is not an id + assert.deepEqual(claimedIds({ headRefName: 'docs/GT-104-rows' }), ['GT-104']); + }); + + it('does not claim the same id twice from one pull request', () => { + assert.deepEqual(claimedIds({ title: 'GT-105', body: 'GT-105 again', headRefName: 'x/GT-105' }), ['GT-105']); + }); + + it('claims nothing when a pull request names no gap', () => { + assert.deepEqual(claimedIds({ title: 'chore: tidy', body: '', headRefName: 'chore/tidy' }), []); + }); +}); + +describe('findContested', () => { + it('THE CASE: one id, two open pull requests', () => { + const contested = findContested(buildClaimMap([ + { number: 1, title: 'fix: GT-200 via a capture script' }, + { number: 2, title: 'fix: GT-200 via an in-spec renderer' }, + { number: 3, title: 'docs: GT-201' }, + ])); + assert.equal(contested.length, 1); + assert.equal(contested[0].id, 'GT-200'); + assert.deepEqual(contested[0].prs.map((p) => p.number), [1, 2]); + }); + + it('one pull request claiming many ids is not contested', () => { + assert.deepEqual(findContested(buildClaimMap([{ number: 1, title: 'GT-300 GT-301 GT-302' }])), []); + }); +}); + +describe('the guard end to end', () => { + it('THE FIXTURE: two open PRs on one gap — RED, naming both', () => { + const { status, out } = runWith([ + { number: 276, title: 'fix(standards): capture script', headRefName: 'claude/fervent', body: 'closes GT-640' }, + { number: 279, title: 'fix(standards): in-spec renderer for GT-640', headRefName: 'claude/nice-cohen', body: '' }, + ]); + assert.equal(status, 1, out); + assert.match(out, /1 gap id\(s\) are claimed by more than one open pull request/); + assert.match(out, /#276/); + assert.match(out, /#279/); + // It must say why this is worth stopping for, and what to do. + assert.match(out, /much cheaper to resolve now than after both have landed/); + assert.match(out, /Decide which pull request owns the id/); + // And it must not imply coverage it does not have. + assert.match(out, /a branch with no open pull request claims nothing/); + }); + + it('distinct gaps across many PRs are green, and each claim is listed', () => { + const { status, out } = runWith([ + { number: 10, title: 'GT-400', headRefName: 'a' }, + { number: 11, title: 'GT-401', headRefName: 'b' }, + ]); + assert.equal(status, 0, out); + assert.match(out, /ids claimed \.+ 2/); + assert.match(out, /GT-400 — #10/); + }); + + it('says out loud when there is nothing to check', () => { + // Zero open PRs is a legitimate state that looks identical to a broken query + // unless it is named. + const { status, out } = runWith([]); + assert.equal(status, 0, out); + assert.match(out, /No pull request is open, so nothing is claimed/); + }); + + it('--json reports the claim map and exits non-zero when contested', () => { + const { status, out } = runWith( + [{ number: 1, title: 'GT-500' }, { number: 2, title: 'GT-500' }], + ['--json'], + ); + assert.equal(status, 1, out); + const payload = JSON.parse(out.trim().split('\n')[0]); + assert.deepEqual(payload.contested, [{ id: 'GT-500', prs: [1, 2] }]); + }); +}); + +describe('anti-vacuous floor', () => { + it('refuses to pass when the pull-request query cannot be answered', () => { + // No --fixture, and `gh` unusable: the guard must fail rather than report a + // clean run over a list it never got. + const r = spawnSync(process.execPath, [GUARD], { + encoding: 'utf8', + timeout: 60000, + env: { ...process.env, PATH: '/nonexistent' }, + }); + assert.equal(r.status, 1, r.stdout + r.stderr); + const out = `${r.stdout}\n${r.stderr}`; + assert.match(out, /could not read the open pull requests/); + assert.match(out, /Unable to answer is not the same as nothing to report/); + }); +}); diff --git a/.harness/scripts/lib/guard-classification.mjs b/.harness/scripts/lib/guard-classification.mjs index 8b69ba19..18aa8171 100644 --- a/.harness/scripts/lib/guard-classification.mjs +++ b/.harness/scripts/lib/guard-classification.mjs @@ -40,6 +40,15 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned * deleting the check and leaving the exemption behind. */ export const SELF_GUARDED = [ + { + file: '50-validate-gap-claim.mjs', + proof: /could not read the open pull requests/, + reason: + 'GT-639 gap-claim guard; refuses to pass when the pull-request query cannot be answered — ' + + 'the guard exists because two sessions could not see each other, so one that stays quiet ' + + 'when it cannot look would reproduce the defect. Zero open PRs is reported in words rather ' + + 'than passed silently, because it looks identical to a broken query otherwise', + }, { file: '49-validate-gap-id-allocation.mjs', proof: /at least one side yielded NOTHING/, diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index 74e599a9..bf8c79ce 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -7860,14 +7860,14 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre **Title:** Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces - **Purpose:** Que un gap que ya se está trabajando sea descubrible ANTES de que una segunda sesión lo empiece, y no en el merge. -- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-640`](./gap-reference-catalog.es.md#gt-640) — registrado como GT-633 hasta la renumeración de abajo — se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-640 · PENDIENTE` (registrado como GT-633 entonces) y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese mismo id lleva en la rama base: ausente en base es id nuevo, presente con el mismo título es el mismo gap editado, presente con título DISTINTO es un número nombrando dos gaps. El título es el discriminador a propósito — "el id ya existe en base" es el caso normal de 500 y pico filas y no dice nada. Corrido contra `origin/develop` reporta **`GT-633`**: en develop es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). Dos gaps, un número, y el merge todavía no lo había sacado a la luz. **RESUELTO el 2026-07-30 renumerando la fila más nueva —la de main— a [`GT-640`](./gap-reference-catalog.es.md#gt-640)**, según el propio consejo del guard. Hacerlo a mano es el coste que esta fila existe para describir: hubo que cambiar el id en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, las referencias cruzadas de otras dos filas en los dos idiomas y tres comentarios de código — y los mensajes de commit y cuerpos de PR que nombran GT-633 no se pueden cambiar. **El guard se niega a adivinar qué lado está mal** — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos y lo dice. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene y un guard que fallara por ESO enseñaría a ignorarlo. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). **Lo que sigue sin ver, dicho y no insinuado:** un número asignado en una rama de la que nadie ha abierto PR. La salida `--verbose` lo advierte en cada id recién asignado en vez de dejar que el lector suponga una cobertura que no tiene. +- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-640`](./gap-reference-catalog.es.md#gt-640) — registrado como GT-633 hasta la renumeración de abajo — se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-640 · PENDIENTE` (registrado como GT-633 entonces) y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. **GUARD CONSTRUIDO el 2026-07-30, tres criterios de cuatro.** `50-validate-gap-claim` deriva el conjunto de reclamaciones de los PULL REQUESTS ABIERTOS — cada `GT-*` en el título, el cuerpo o el nombre de rama de un PR — y falla cuando un id lo reclaman dos, nombrando ambos con su rama. Derivado y no escrito a mano a propósito: una reclamación escrita a mano se queda rancia en la dirección que importa, porque alguien olvida quitarla y la siguiente sesión esquiva una reclamación que ya no sostiene nadie. Cableado en `Governance guards (GT-578)` con `GH_TOKEN`, y observado en rojo por `43-validate-guard-negative-fixtures` (39/39). **EL CRITERIO 1 SE DEJA ABIERTO A PROPÓSITO, y el motivo es una restricción de diseño, no un olvido:** una lista de reclamaciones commiteada en el tablero se derivaría de estado vivo de GitHub, así que estaría rancia en cuanto alguien abriera un PR — y la cadena de [`GT-630`](./gap-reference-catalog.es.md#gt-630) existe justamente para exigir que los artefactos derivados alcancen un punto fijo. Un artefacto perpetuamente rancio dentro de esa cadena sería peor que ninguno. La vista viva es la salida del propio check en el PR donde importa; hacerla descubrible desde el tablero necesita algo que el tablero pueda renderizar sin commitear, y eso no está construido. **Lo que no puede ver, dicho en su propio texto de fallo y no insinuado:** una rama sin PR abierto no reclama nada. Abrir el PR pronto —basta en borrador— es lo que hace visible la reclamación, y eso ya es una convención con un check detrás en vez de una regla escrita. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese mismo id lleva en la rama base: ausente en base es id nuevo, presente con el mismo título es el mismo gap editado, presente con título DISTINTO es un número nombrando dos gaps. El título es el discriminador a propósito — "el id ya existe en base" es el caso normal de 500 y pico filas y no dice nada. Corrido contra `origin/develop` reporta **`GT-633`**: en develop es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). Dos gaps, un número, y el merge todavía no lo había sacado a la luz. **RESUELTO el 2026-07-30 renumerando la fila más nueva —la de main— a [`GT-640`](./gap-reference-catalog.es.md#gt-640)**, según el propio consejo del guard. Hacerlo a mano es el coste que esta fila existe para describir: hubo que cambiar el id en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, las referencias cruzadas de otras dos filas en los dos idiomas y tres comentarios de código — y los mensajes de commit y cuerpos de PR que nombran GT-633 no se pueden cambiar. **El guard se niega a adivinar qué lado está mal** — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos y lo dice. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene y un guard que fallara por ESO enseñaría a ignorarlo. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). **Lo que sigue sin ver, dicho y no insinuado:** un número asignado en una rama de la que nadie ha abierto PR. La salida `--verbose` lo advierte en cada id recién asignado en vez de dejar que el lector suponga una cobertura que no tiene. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M - **Provenance:** Registrado el 2026-07-30 a partir de tres instancias observadas en un solo día, todas en el merge `develop` → `main` que las hizo visibles. Relacionado pero distinto de [`GT-638`](./gap-reference-catalog.es.md#gt-638): aquella fila trata de asignar un NOMBRE dos veces, ésta de hacer el TRABAJO dos veces. La misma carencia de coordinación produce ambas, y la colisión de id es su síntoma más barato. - **Acceptance criteria:** - [ ] Un gap que se está trabajando es descubrible desde el tablero junto con la rama o el PR que lo reclama, antes de que el trabajo aterrice. - - [ ] Una comprobación falla en pull request cuando un id `GT-*` está reclamado por más de una rama abierta, nombrando a ambos reclamantes. - - [ ] La reclamación se DERIVA de los pull requests abiertos y no se escribe a mano, para que no pueda quedarse rancia en la dirección que importa. - - [ ] Incluye una fixture negativa OBSERVADA en rojo, no sólo declarada capaz de fallar. + - [x] Una comprobación falla en pull request cuando un id `GT-*` está reclamado por más de una rama abierta, nombrando a ambos reclamantes — `50-validate-gap-claim`, cableado en `Governance guards (GT-578)`. + - [x] La reclamación se DERIVA de los pull requests abiertos y no se escribe a mano, para que no pueda quedarse rancia en la dirección que importa — título, cuerpo y nombre de rama de cada PR abierto. + - [x] Incluye fixtures negativas OBSERVADAS en rojo — 10, entre ellas dos PRs abiertos sobre un mismo gap y el suelo anti-vacuo, y `43-validate-guard-negative-fixtures` la cuenta en 39/39. #### GT-638 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index c7bd95f0..24e26320 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -7955,14 +7955,14 @@ Historical gap series tracked in the former `gap-analysis-core.md`, preserved fo **Title:** Nothing makes in-flight work visible across branches, so the same gap gets fixed twice - **Purpose:** Make a gap that is already being worked discoverable BEFORE a second session starts it, rather than at merge time. -- **Evidence:** **On 2026-07-30 the same work was done twice, three separate times, by sessions that could not see each other.** (1) [`GT-640`](./gap-reference-catalog.md#gt-640) — registered as GT-633 until the renumber below — was fixed twice: a standalone capture script landed on `main` as #276, and an independent in-spec renderer landed on `develop` as #279. Both were correct, both recaptured the same numbers — and two generators for one artifact is GT-633's own defect one level up, so a THIRD session spent a full reconciliation (#294, #295, #296, #297) collapsing them to one renderer. (2) The evidence guard's parser was fixed twice: `d1ea72a3` on `main` ("stop the evidence guard blaming the evidence for two parser defects") and `5acb29ed` on `develop` ("the ratchet was stuck by two bugs in itself"). (3) A GT id was allocated twice, forcing the renumber recorded in [`GT-638`](./gap-reference-catalog.md#gt-638). **The cost is not the merge conflict.** It is the duplicated work that happened before anyone reached the conflict, plus the reconciliation it then required — and that reconciliation introduced a defect of its own (an order-sensitive comparison that rewrote the capture date on every run, invisible for a day because both runs stamped the same date). Three duplications, one repair session, one new bug: that is the real bill. **Why the board cannot see it today:** a row carries a status (`PENDING`, `IN-PROGRESS`) but not WHO is working it or WHERE. Two sessions can both read `GT-640 · PENDING` (registered as GT-633 at the time) and both start, correctly. `08-validate-tracking` validates within one working tree by construction, so it cannot know another branch exists. **This is not a missing convention — the convention exists.** The single-driver rule for gap waves is already established practice; what is missing is any mechanism that makes it observable, so on a busy day it degrades silently and nobody learns until the merge. Fix directions: require a claim (branch or PR) on a row that moves to `IN-PROGRESS`, and derive the claim set from open PRs so it cannot go stale; then fail at PR time when one GT id is claimed by more than one open branch, naming both. +- **Evidence:** **On 2026-07-30 the same work was done twice, three separate times, by sessions that could not see each other.** (1) [`GT-640`](./gap-reference-catalog.md#gt-640) — registered as GT-633 until the renumber below — was fixed twice: a standalone capture script landed on `main` as #276, and an independent in-spec renderer landed on `develop` as #279. Both were correct, both recaptured the same numbers — and two generators for one artifact is GT-633's own defect one level up, so a THIRD session spent a full reconciliation (#294, #295, #296, #297) collapsing them to one renderer. (2) The evidence guard's parser was fixed twice: `d1ea72a3` on `main` ("stop the evidence guard blaming the evidence for two parser defects") and `5acb29ed` on `develop` ("the ratchet was stuck by two bugs in itself"). (3) A GT id was allocated twice, forcing the renumber recorded in [`GT-638`](./gap-reference-catalog.md#gt-638). **The cost is not the merge conflict.** It is the duplicated work that happened before anyone reached the conflict, plus the reconciliation it then required — and that reconciliation introduced a defect of its own (an order-sensitive comparison that rewrote the capture date on every run, invisible for a day because both runs stamped the same date). Three duplications, one repair session, one new bug: that is the real bill. **Why the board cannot see it today:** a row carries a status (`PENDING`, `IN-PROGRESS`) but not WHO is working it or WHERE. Two sessions can both read `GT-640 · PENDING` (registered as GT-633 at the time) and both start, correctly. `08-validate-tracking` validates within one working tree by construction, so it cannot know another branch exists. **This is not a missing convention — the convention exists.** The single-driver rule for gap waves is already established practice; what is missing is any mechanism that makes it observable, so on a busy day it degrades silently and nobody learns until the merge. Fix directions: require a claim (branch or PR) on a row that moves to `IN-PROGRESS`, and derive the claim set from open PRs so it cannot go stale; then fail at PR time when one GT id is claimed by more than one open branch, naming both. **GUARD BUILT 2026-07-30, three criteria of four.** `50-validate-gap-claim` derives the claim set from OPEN PULL REQUESTS — every `GT-*` in a PR's title, body or branch name — and fails when one id is claimed by two of them, naming both with their branches. Derived rather than hand-written on purpose: a hand-written claim goes stale in the direction that matters, because someone forgets to remove it and the next session works around a claim nobody holds. Wired into `Governance guards (GT-578)` with `GH_TOKEN`, and observed red by `43-validate-guard-negative-fixtures` (39/39). **CRITERION 1 IS DELIBERATELY LEFT OPEN, and the reason is a design constraint rather than an omission:** a claim list committed to the board would be derived from live GitHub state, so it would be stale the moment anyone opened a pull request — and [`GT-630`](./gap-reference-catalog.md#gt-630)'s chain exists precisely to insist that derived artifacts reach a fixed point. A perpetually-stale artifact inside that chain would be worse than none. The live view is the check's own output on the pull request where it matters; making it discoverable from the board needs something the board can render without committing, and that is not built. **What it cannot see, said in its own failure text rather than implied:** a branch with no open pull request claims nothing. Opening the PR early — draft is enough — is what makes the claim visible, and that is now a working convention with a check behind it rather than a rule written down. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M - **Provenance:** Registered 2026-07-30 from three observed instances in a single day, all of them in the `develop` → `main` merge that made them visible. Related but distinct from [`GT-638`](./gap-reference-catalog.md#gt-638): that row is about allocating a NAME twice, this one about doing the WORK twice. The same coordination gap produces both, and the id collision is the cheapest symptom of it. - **Acceptance criteria:** - [ ] A gap being worked is discoverable from the board together with the branch or PR that claims it, before the work lands. - - [ ] A check fails at pull-request time when one `GT-*` id is claimed by more than one open branch, naming both claimants. - - [ ] The claim is DERIVED from open pull requests rather than hand-written, so it cannot be stale in the direction that matters. - - [ ] It ships with a negative fixture that has been OBSERVED red, not merely declared able to fail. + - [x] A check fails at pull-request time when one `GT-*` id is claimed by more than one open branch, naming both claimants — `50-validate-gap-claim`, wired into `Governance guards (GT-578)`. + - [x] The claim is DERIVED from open pull requests rather than hand-written, so it cannot be stale in the direction that matters — title, body and branch name of every open PR. + - [x] It ships with negative fixtures that have been OBSERVED red — 10 of them, including two open PRs on one gap and the anti-vacuous floor, and `43-validate-guard-negative-fixtures` counts it at 39/39. #### GT-638 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index feef4d09..e2e16882 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -14,7 +14,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | ID | Gap | Qué significa | Ejemplo | Componente | Fase | Criticidad | Complejidad | Estado | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| | [`GT-640`](./gap-reference-catalog.es.md#gt-640) | **El guard comparaba el snapshot contra seis números copiados del propio snapshot, así que solo podía pasar — y el archivo que no estaba vigilando se estampa en 388 filas de un artefacto mayor.** `native-evaluability-snapshot.json` declaraba en su propia cabecera que era "un SNAPSHOT CAPTURADO, no la fuente de verdad". **No existía ningún script de captura.** Se mantenía a mano y derivó: fijaba `documentation-only: 129` mientras Core fijaba 136, y seguía llamando `unimplemented-native` a reglas que ya tenían handler. Su guard en `iso-5055-mapping.test.mjs` asertaba los seis conteos de clase del snapshot contra seis números escritos a mano en el test — los mismos seis literales que el snapshot ya contenía. **El valor esperado y el valor real eran copias el uno del otro**, así que la aserción se sostenía mientras el archivo no cambiara, fijara Core lo que fijara; informaba de una concordancia que nunca comprobó, lo cual es peor que no tener guard, porque la fila que protege se lee como verificada. **La deriva no se queda donde empieza.** `build-iso-5055-mapping.mjs` estampa `nativeEvaluability` en TODAS las filas del mapeo ISO/IEC 5055 desde ese archivo — 388 tras la recaptura, 381 antes — así que una sola clase rancia se blanquea en un artefacto derivado cinco veces mayor que su entrada, y sobredimensiona el backlog de handlers que es el entregable entero de [`GT-598`](./gap-reference-catalog.es.md#gt-598). El orden recaptura→reconstrucción no estaba escrito en ningún sitio, y el guard del propio mapeo no corría en **ningún workflow**. **CORREGIDO el 2026-07-29:** un script de captura que ejecuta el triage REAL de Core vía `ts-node` en vez de reimplementar la clasificación, la medición extraída del spec de jest a `test/rule-corpus-triage.ts` para que un script pueda alcanzarla — esa inalcanzabilidad es la razón de que el archivo se mantuviera a mano —, aserciones snapshot-contra-triage-fresco en ambas direcciones, un guard del job de documentación que lee los conteos que Core fija LEYENDO el spec y **lanza excepción** cuando el literal falta o cambia de forma, y ambos pasos declarados como eslabones de la cadena de artefactos derivados de [`GT-630`](./gap-reference-catalog.es.md#gt-630). **Recapturado el 2026-07-29 tras mergear el fix en la línea actual, y la deriva había crecido mientras estaba en vuelo:** `native-handler` 139 → 151, `documentation-only` 129 → 136, el backlog de handlers 60 → **48**, corpus 379 → 386, mapeo 381 → 388 filas. Doce de esos handlers los cerraron [`GT-595`](./gap-reference-catalog.es.md#gt-595) y [`GT-632`](./gap-reference-catalog.es.md#gt-632) mientras el snapshot mantenido a mano seguía reportándolos como backlog — el defecto demostrándose una vez más, sobre números que nadie escribió dos veces. **RENUMERADA GT-633 -> GT-640 el 2026-07-30**, después de que `49-validate-gap-id-allocation` detectara que `GT-633` ya nombraba otro gap en `develop` (el scaffolding de `evolith init`, `c8270e48`, registrado un día antes). Se mueve la fila más nueva, según el propio consejo del guard. | | | `Governance` | Cross | P1 | S | `COMPLETADO` | -| [`GT-639`](./gap-reference-catalog.es.md#gt-639) | **Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces.** El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse: [`GT-640`](./gap-reference-catalog.es.md#gt-640) (registrado como GT-633 entonces) arreglado por un script de captura en `main` (#276) Y por un renderer independiente dentro del spec en `develop` (#279) — los dos correctos, los dos recapturando los mismos números, y dos generadores para un artefacto es el defecto del propio GT-640 un nivel más arriba, así que una tercera sesión gastó una reconciliación entera en dejar uno; el parser del guard de evidencias arreglado dos veces (`d1ea72a3` en main, `5acb29ed` en develop); y un id de GT asignado dos veces, forzando la renumeración que hay detrás de [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge** — es el trabajo duplicado antes de que nadie llegara a él, más la reconciliación, que introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible un día porque ambas estampaban la misma fecha). Una fila lleva estado pero no QUIÉN la trabaja ni DÓNDE, así que dos sesiones pueden leer ambas `PENDIENTE` y empezar ambas, correctamente. **La convención de un solo driver ya existe; lo que falta es un mecanismo que la haga observable**, así que en un día cargado se degrada en silencio. | | | `Governance` | Cross | P2 | M | `PENDIENTE` | +| [`GT-639`](./gap-reference-catalog.es.md#gt-639) | **Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces.** El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse: [`GT-640`](./gap-reference-catalog.es.md#gt-640) (registrado como GT-633 entonces) arreglado por un script de captura en `main` (#276) Y por un renderer independiente dentro del spec en `develop` (#279) — los dos correctos, los dos recapturando los mismos números, y dos generadores para un artefacto es el defecto del propio GT-640 un nivel más arriba, así que una tercera sesión gastó una reconciliación entera en dejar uno; el parser del guard de evidencias arreglado dos veces (`d1ea72a3` en main, `5acb29ed` en develop); y un id de GT asignado dos veces, forzando la renumeración que hay detrás de [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge** — es el trabajo duplicado antes de que nadie llegara a él, más la reconciliación, que introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible un día porque ambas estampaban la misma fecha). Una fila lleva estado pero no QUIÉN la trabaja ni DÓNDE, así que dos sesiones pueden leer ambas `PENDIENTE` y empezar ambas, correctamente. **La convención de un solo driver ya existe; lo que falta es un mecanismo que la haga observable**, así que en un día cargado se degrada en silencio. **GUARD CONSTRUIDO el 2026-07-30 — tres criterios de cuatro.** `50-validate-gap-claim` deriva las reclamaciones de los PULL REQUESTS ABIERTOS (cada `GT-*` en título, cuerpo o nombre de rama) y falla cuando un id lo reclaman dos, nombrando ambos con su rama. Derivado y no a mano a propósito: una reclamación escrita a mano se queda rancia en la dirección que importa. Cableado en `Governance guards (GT-578)` con `GH_TOKEN`; observado en rojo por `43-validate-guard-negative-fixtures` (39/39). **El criterio 1 sigue abierto a propósito:** una lista commiteada en el tablero derivaría de estado vivo de GitHub y estaría rancia en cuanto alguien abriera un PR — y la cadena de [`GT-630`](./gap-reference-catalog.es.md#gt-630) existe para exigir que los derivados alcancen un punto fijo, así que uno perpetuamente rancio sería peor que ninguno. Tampoco ve una rama sin PR abierto, y lo dice en su propio texto de fallo. | | | `Governance` | Cross | P2 | M | `EN-PROGRESO` | | [`GT-638`](./gap-reference-catalog.es.md#gt-638) | **El tablero no tiene asignador de ids, así que dos ramas paralelas reparten el mismo número GT.** Un id se elige leyendo el `GT-*` más alto de la rama en la que uno está, y nada lo contrasta con otra rama — así que dos sesiones en paralelo asignan el mismo número y una se entera en el merge. Ya pasó: `8449af3d` en `develop` dice *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; esa fila y [`GT-634`](./gap-reference-catalog.es.md#gt-634) en `main` tomaron el mismo id con horas de diferencia, desde sesiones que no podían verse. **La renumeración es manual y con pérdidas**, que cuesta más que el choque: un id vive en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, referencias cruzadas en dos idiomas, mensajes de commit y cuerpos de PR — y sólo los tres primeros son comprobables mecánicamente. `08-validate-tracking` no puede ayudar por construcción: valida dentro de un único árbol de trabajo, y las ramas quedan fuera de su mundo. Tampoco es un desliz aislado — en el merge `develop` → `main` del 2026-07-30 el mismo patrón de duplicación convergente aparece tres veces: GT-640 (entonces GT-633) arreglado dos veces, el parser del guard de evidencias arreglado dos veces, este id asignado dos veces. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese id lleva en la rama base; un título distinto para el mismo id es un número nombrando dos gaps. Contra `origin/develop` reportó **`GT-633`**: allí es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main era la fila del guard tautológico (`c3221276`, 2026-07-30), renumerada después a `GT-640`. El merge no lo había sacado a la luz. Se niega a adivinar qué lado está mal — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). Sigue sin ver un id asignado en una rama sin PR abierto, y su salida `--verbose` lo advierte en cada id nuevo. | | | `Governance` | Cross | P2 | S | `COMPLETADO` | | [`GT-637`](./gap-reference-catalog.es.md#gt-637) | **El ratchet de referencias muertas de GT-578 estaba atascado en 305 por dos bugs reales en el propio GUARD, no por el tamaño del backlog de registros que decía medir.** Al cerrar el PR de `GT-633` apareció un fallo en `Governance guards (GT-578)` y, al investigarlo, un refactor sin commitear y sin terminar que ya estaba en el árbol de trabajo: `resolveCommand` llamaba a `npmScriptOperand(...)`, una función que nunca se definió, así que el guard crasheaba con `ReferenceError` en cada invocación — incluso en modo reporte plano. Reparar esa función (restaurar su comportamiento y luego corregirlo) bajó el conteo de 309 a 78, lo que destapó el segundo bug: el corpus usa `npm run --workspace