From 95d769b8f75cafc517362332716da242c9047e0e Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Mon, 31 Aug 2026 15:44:39 +0200 Subject: [PATCH 1/3] issue_82: Invoke the Convergence Check from the participating skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gate nobody invokes is documentation. Three skills own the moments it applies to, and each now names the moment and defers everything else. - implement-issue-workflow runs it before declaring a pull request ready and records the result in the PR body. A result other than converged or converged with recorded waivers joins the existing warning signs that stop automatic integration — the list already collects reasons not to merge, so the gate belongs in it rather than beside it. - pr-review checks the reported result against the canonical definition. A result claimed without its evidence, a finding without a disposition, or a blocker without its kind is a review finding; a missing result is reported rather than supplied from the review, because a reviewer producing the result they are meant to check is not a check. - architecture-impact runs it before work is treated as complete. Impact analysis establishes what a change touches; the gate asks whether the artifacts still agree afterwards. None of the three restates a question, a result state, or an evidence rule. A second copy of a rule inside a gate is the drift the gate exists to detect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01USXp58FoRppFK6FUht8K7u --- skills/architecture-impact/SKILL.md | 6 ++++++ skills/implement-issue-workflow/SKILL.md | 10 ++++++++-- skills/pr-review/SKILL.md | 19 ++++++++++++++----- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/skills/architecture-impact/SKILL.md b/skills/architecture-impact/SKILL.md index 28f15af..fd65ecc 100644 --- a/skills/architecture-impact/SKILL.md +++ b/skills/architecture-impact/SKILL.md @@ -82,6 +82,10 @@ source of truth when these rules evolve. 15. Run the relevant validators, generators, render checks, tests, or manual checks. Report any unavailable verification and remaining open human decisions. +16. Before the work is treated as complete, run `../convergence-check/SKILL.md`. + Impact analysis establishes what the change touches; the gate asks whether + the request, the specification, the architecture record, the implementation + and the delivery metadata still agree afterwards. ## Required Reading @@ -197,3 +201,5 @@ Read these files when the feature or review touches the corresponding scope: risks, quality scenarios, PRs, and real sub-issues where supported? - Are validators, generators, tests, and render checks run or explicitly noted as not run? +- Does the change carry a Convergence Check result, with every finding given a + disposition and every blocker its kind, per `../convergence-check/SKILL.md`? diff --git a/skills/implement-issue-workflow/SKILL.md b/skills/implement-issue-workflow/SKILL.md index 3a3e4df..e746797 100644 --- a/skills/implement-issue-workflow/SKILL.md +++ b/skills/implement-issue-workflow/SKILL.md @@ -63,6 +63,10 @@ If useful work was already done on `main`, stash or otherwise preserve only that 3. Push the `issue_` branch to the remote repository. 4. Open a pull request against `main`. 5. Include the issue link, implementation summary, and verification results in the pull request body. +6. Before declaring the pull request ready, run `../convergence-check/SKILL.md` + and record its result and finding dispositions in the pull request body. That + skill owns the questions, the result states, and what counts as evidence; + this workflow only says when to run it. ## Address PR Comments @@ -88,8 +92,10 @@ repository policy requires merge commits. Do not integrate or clean up branches automatically when there are warning signs, such as failed or unknown checks, unresolved merge state, a diverged -local branch, unpushed local commits, uncertain PR state, or unclear branch -ownership. Report the situation and let the developer decide. +local branch, unpushed local commits, uncertain PR state, unclear branch +ownership, or a Convergence Check result that is anything other than converged +or converged with recorded waivers. Report the situation and let the developer +decide. ## After PR Integration diff --git a/skills/pr-review/SKILL.md b/skills/pr-review/SKILL.md index 4d5a96a..b396e6b 100644 --- a/skills/pr-review/SKILL.md +++ b/skills/pr-review/SKILL.md @@ -33,12 +33,18 @@ review workflow needs a stricter rule. markings stay aligned. 6. Run relevant validators, tests, linters, render checks, or targeted commands when available and reasonable for the changed files. -7. Prioritize defects, behavioral regressions, broken contracts, missing +7. Check the Convergence Check result the pull request reports, per + `../convergence-check/SKILL.md`. A result claimed without the evidence behind + it, a finding left without a disposition, or a blocker reported without its + kind is itself a review finding. When the pull request reports no result and + the change went through a feature or implementation workflow, say so rather + than supplying one from the review. +8. Prioritize defects, behavioral regressions, broken contracts, missing verification, unsafe assumptions, stale links, and traceability problems. -8. Write findings first, ordered by severity. Keep summaries secondary. -9. Prefer precise file and line references for actionable findings. -10. Separate blocking findings from non-blocking suggestions and questions. -11. If no actionable findings are found, say so clearly and still report any +9. Write findings first, ordered by severity. Keep summaries secondary. +10. Prefer precise file and line references for actionable findings. +11. Separate blocking findings from non-blocking suggestions and questions. +12. If no actionable findings are found, say so clearly and still report any residual risk or verification that could not be performed. ## Review Focus @@ -73,6 +79,9 @@ Read these files when the PR touches the corresponding scope: PRs, or refactoring PRs. - `../traceability-review/SKILL.md` before reviewing relation metadata or traceability changes. +- `../convergence-check/SKILL.md` before judging a reported convergence result, + so the review checks it against the canonical definition rather than a + remembered one. - `../adr/SKILL.md`, `../quality-scenario/SKILL.md`, or `../risk/SKILL.md` before reviewing those artifact types. - `../../adapters/github-copilot/README.md` when GitHub Copilot performs the From 70670634e871b1b5112135ea2fdb7cd15ef89b5f Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Mon, 31 Aug 2026 16:11:41 +0200 Subject: [PATCH 2/3] issue_82: Run the gate before integration, and specify the wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #85 found two connected problems. The check ran too early. The step sat before "declaring the pull request ready", while the canonical skill puts the authoritative run before mergeability and says plainly that an early pass is not a result. Review comments and later commits change the state it inspected, and PR Integration only read the recorded result without asking whether it was still current. The run at PR-open time is now explicitly provisional. Address PR Comments voids a recorded result when new commits land, and PR Integration runs the authoritative check against the commit that would be integrated. A result that does not cover the current head joins the warning signs, because a stale result is not a result. The second finding was mine to own: this change alters observable agent behaviour — agents must run and record the gate, and must not integrate on certain results — so reporting Behaviour as "not applicable" was wrong. The skills are the behaviour contracts here. What can be specified now is, and it is bridged: skill-to-skill references must resolve, the three callers must reach the gate, and its result-state definitions and question structure must live in exactly one file. A mutation probe confirms the guard fails when a reference is broken. Writing that guard sharpened a distinction the prose had left implicit. Naming a result state elsewhere is legitimate — implement-issue-workflow says which results stop integration, which is its own policy in the gate's vocabulary. Defining one elsewhere is the copy that drifts, so the test targets the definition, not the word. What remains unverifiable here is whether an agent obeys prose at all. That is #65, and it is reported as an unavailable blocker rather than waived. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01USXp58FoRppFK6FUht8K7u --- README.md | 6 +- build.sh | 2 +- features/skill-wiring.feature | 38 +++++++ skills/implement-issue-workflow/SKILL.md | 25 +++-- test/skill-wiring.test.mjs | 120 +++++++++++++++++++++++ 5 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 features/skill-wiring.feature create mode 100644 test/skill-wiring.test.mjs diff --git a/README.md b/README.md index fdedca8..99e7280 100644 --- a/README.md +++ b/README.md @@ -474,8 +474,10 @@ node --test test/build-agent-adapters.test.mjs \ test/build-agent-adapters-template.test.mjs \ test/build-sh-template.test.mjs \ test/install-skills.test.mjs \ - test/journal-config.test.mjs # adapter generator + build.sh + + test/journal-config.test.mjs \ + test/skill-wiring.test.mjs # adapter generator + build.sh + # skill installer + journal binding + # + skill-to-skill wiring ``` The container-based render scripts (`build.sh` itself and @@ -539,7 +541,7 @@ example to pin a digest) with `DOCS_TOOLBOX_IMAGE`. | `generate` | Validate, then generate derived fragments/indexes | `ruby scripts/validate-metamodel.rb --generate` | | `test` | Run all tests (Ruby units, Ruby CLI, JS adapter) | see [Tests](#tests) | | `test-ruby` | Ruby validator/generator unit and CLI tests | `ruby -Itest test/validate_metamodel_test.rb` and `ruby -Itest test/validate_metamodel_cli_test.rb` | -| `test-js` | JS adapter generator + build.sh template tests | `node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs` | +| `test-js` | JS adapter generator + build.sh template tests | `node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs test/skill-wiring.test.mjs` | | `adapters` | Regenerate agent adapters from skills | `node scripts/build-agent-adapters.js` | | `check-adapters` | Fail if the generated adapters are stale | `node scripts/check-agent-adapters.js` | | `build` | Generate fragments and render architecture HTML | see below | diff --git a/build.sh b/build.sh index a5b31a4..2a1a561 100755 --- a/build.sh +++ b/build.sh @@ -87,7 +87,7 @@ run_local_test_ruby() { } run_local_test_js() { - node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs + node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs test/skill-wiring.test.mjs } run_local_test() { diff --git a/features/skill-wiring.feature b/features/skill-wiring.feature new file mode 100644 index 0000000..25c655b --- /dev/null +++ b/features/skill-wiring.feature @@ -0,0 +1,38 @@ +# Living documentation for the wiring between canonical skills. +# Bridged to: test/skill-wiring.test.mjs (node:test, classic runner, no native +# BDD). Each scenario maps to one test named after the scenario title, with +# Given/When/Then comment anchors inside the test body. Traceability is a +# reviewer-verifiable convention, not a build-enforced link. +# +# Skills are prose contracts, so what an agent *does* with them cannot be +# verified here — that is the conformance and evaluation layer of #65. What can +# be verified is the wiring those contracts depend on: that a skill delegating +# to another one still points at a file that exists, and that a rule delegated +# away has not been copied back. Both fail loudly when someone edits one skill +# and forgets the other. + +Feature: Skill wiring + As a toolkit maintainer + I want delegation between canonical skills to stay intact + So that a skill that defers a rule still reaches the skill that owns it + + Scenario: Every skill-to-skill reference resolves + Given the canonical skills under skills/ + When their relative references to other SKILL.md files are resolved + Then every referenced file exists + + Scenario: The skills that own a gate moment reach the Convergence Check + Given architecture-impact, implement-issue-workflow and pr-review + When each is inspected for the canonical gate + Then all three reference skills/convergence-check/SKILL.md + + Scenario: The Convergence Check result states live in exactly one skill + Given the canonical skills under skills/ + When they are searched for a definition of the gate's result states + Then only the Convergence Check itself defines them + # Naming a state is allowed; defining it elsewhere is the copy that drifts. + + Scenario: The Convergence Check questions are not copied into a caller + Given the canonical skills under skills/ + When they are searched for the gate's seven-question structure + Then only the Convergence Check itself carries it diff --git a/skills/implement-issue-workflow/SKILL.md b/skills/implement-issue-workflow/SKILL.md index e746797..4c7db1d 100644 --- a/skills/implement-issue-workflow/SKILL.md +++ b/skills/implement-issue-workflow/SKILL.md @@ -63,10 +63,12 @@ If useful work was already done on `main`, stash or otherwise preserve only that 3. Push the `issue_` branch to the remote repository. 4. Open a pull request against `main`. 5. Include the issue link, implementation summary, and verification results in the pull request body. -6. Before declaring the pull request ready, run `../convergence-check/SKILL.md` - and record its result and finding dispositions in the pull request body. That - skill owns the questions, the result states, and what counts as evidence; - this workflow only says when to run it. +6. Optionally run `../convergence-check/SKILL.md` now and record its result and + finding dispositions in the pull request body. Mark it **provisional**: the + authoritative run happens before integration, because review comments and + later commits change the state it inspects. That skill owns the questions, + the result states, and what counts as evidence; this workflow only says when + to run it. ## Address PR Comments @@ -74,6 +76,9 @@ If useful work was already done on `main`, stash or otherwise preserve only that 2. Group fixes into useful commits by intent. 3. Push the new commits to the existing PR branch. 4. Re-run and report relevant verification. +5. A recorded Convergence Check result is void once new commits land. Re-run + `../convergence-check/SKILL.md` and replace the recorded result rather than + amending it — a result describes one state of the branch, not the branch. ## PR Integration @@ -90,12 +95,18 @@ unless the user explicitly requests squash integration or repository policy requires it. Use merge commits only when the user explicitly requests them or repository policy requires merge commits. +The authoritative Convergence Check runs here, immediately before the +integration decision, against the commit that would be integrated. A result +recorded earlier is evidence about an earlier state: if any commit landed after +it, it is stale, and a stale result is not a result. Re-run +`../convergence-check/SKILL.md` and record the outcome before deciding. + Do not integrate or clean up branches automatically when there are warning signs, such as failed or unknown checks, unresolved merge state, a diverged local branch, unpushed local commits, uncertain PR state, unclear branch -ownership, or a Convergence Check result that is anything other than converged -or converged with recorded waivers. Report the situation and let the developer -decide. +ownership, a Convergence Check result that is anything other than converged or +converged with recorded waivers, or a result that does not cover the current +head commit. Report the situation and let the developer decide. ## After PR Integration diff --git a/test/skill-wiring.test.mjs b/test/skill-wiring.test.mjs new file mode 100644 index 0000000..547d1a8 --- /dev/null +++ b/test/skill-wiring.test.mjs @@ -0,0 +1,120 @@ +// Behaviour specification for the wiring between canonical skills. +// +// Skills are prose contracts. Whether an agent obeys one is not verifiable from +// this repository — that is the conformance and evaluation layer tracked in #65. +// What is verifiable, and what breaks silently without a guard, is the wiring +// underneath: a skill that defers a rule must still point at the file that owns +// it, and a rule that was deferred must not have been copied back. +// +// Both checks fail on a real regression: deleting a reference, renaming a skill +// directory, or pasting the canonical rules into a caller. +// +// Bridged from: features/skill-wiring.feature + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const skillsDir = path.join(repoRoot, "skills"); + +// Every SKILL.md in the tree, at any nesting depth. +function skillFiles(dir = skillsDir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return skillFiles(full); + return entry.name === "SKILL.md" ? [full] : []; + }); +} + +function read(file) { + return fs.readFileSync(file, "utf8"); +} + +function rel(file) { + return path.relative(repoRoot, file); +} + +// Relative references to other skills, as they are written in the prose: +// `..//SKILL.md` inside backticks. +function skillReferences(file) { + const text = read(file); + const matches = text.match(/`(\.\.\/[A-Za-z0-9_./-]*SKILL\.md)`/g) || []; + return matches.map((m) => m.slice(1, -1)); +} + +test("Every skill-to-skill reference resolves", () => { + // Given the canonical skills under skills/ + const files = skillFiles(); + assert.ok(files.length > 0, "no skills found"); + + // When their relative references to other SKILL.md files are resolved + const broken = []; + let checked = 0; + for (const file of files) { + for (const reference of skillReferences(file)) { + checked += 1; + const target = path.resolve(path.dirname(file), reference); + if (!fs.existsSync(target)) broken.push(`${rel(file)} -> ${reference}`); + } + } + + // Then every referenced file exists + assert.ok(checked > 0, "no skill-to-skill references found to check"); + assert.deepEqual(broken, []); +}); + +test("The skills that own a gate moment reach the Convergence Check", () => { + // Given architecture-impact, implement-issue-workflow and pr-review + const callers = ["architecture-impact", "implement-issue-workflow", "pr-review"]; + + // When each is inspected for the canonical gate + const missing = callers.filter((name) => { + const file = path.join(skillsDir, name, "SKILL.md"); + return !skillReferences(file).includes("../convergence-check/SKILL.md"); + }); + + // Then all three reference skills/convergence-check/SKILL.md + assert.deepEqual(missing, []); +}); + +// The gate's own rule, applied to the gate: a second copy of a rule inside a +// caller is exactly the drift the gate exists to detect. These two tests are +// what stops that copy from being made quietly. +const canonical = path.join(skillsDir, "convergence-check", "SKILL.md"); + +function skillsContaining(pattern) { + return skillFiles() + .filter((file) => pattern.test(read(file))) + .map(rel); +} + +test("The Convergence Check result states live in exactly one skill", () => { + // Given the canonical skills under skills/ + // When they are searched for the gate's result vocabulary + // + // Naming a result state is allowed and often clearer: implement-issue-workflow + // says which results stop integration, which is its own policy expressed in + // the gate's vocabulary. What must not spread is the *definition* — the table + // row that says what a state means. That is the copy which drifts. + const carriers = skillsContaining( + /^\|\s*\*\*Converged with recorded waivers\*\*\s*\|/im, + ); + + // Then only the Convergence Check itself defines it + assert.deepEqual(carriers, [rel(canonical)]); +}); + +test("The Convergence Check questions are not copied into a caller", () => { + // Given the canonical skills under skills/ + // When they are searched for the gate's seven-question structure + const carriers = skillsContaining(/Every question must be able to fail/i); + + // Then only the Convergence Check itself carries it + assert.deepEqual(carriers, [rel(canonical)]); +}); From 34f9c0df4f7fb63afaaabfc831fca9964de3dd07 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Mon, 31 Aug 2026 16:32:32 +0200 Subject: [PATCH 3/3] issue_82: Check the whole structure, and stop blocking on the medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #85, three findings, all correct. The two uniqueness guards claimed more than they checked. One grepped a single heading, so all seven questions could have been copied without it; the other checked one of four result states. Both now read what to look for from the canonical file rather than hard-coding it, so renaming a state or a question keeps the guard pointed at the right thing instead of quietly guarding nothing. Mutation probes confirm each fails on the exact copy it claims to prevent: a pasted "Blocked" definition, and four question headings without their section heading. The third finding was a mistake in how I applied the gate, and it exposed a gap in the gate itself. I reported the missing execution layer for prose contracts as an unavailable blocker on this change. It is not: it would appear identically on every change to any prose skill, so blocking on it would stop all such work until #65 lands while saying nothing about the change in front of it. The canonical skill now draws that line. A blocker is evidence this change owes and cannot produce; a limitation of the medium or the tooling is a residual risk, which question 6 already requires reporting together with the follow-up that would remove it. The test is whether the same blocker would appear on every change of this kind — a gate that can never be passed is not a gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01USXp58FoRppFK6FUht8K7u --- features/skill-wiring.feature | 21 ++++---- skills/convergence-check/SKILL.md | 20 ++++++++ test/skill-wiring.test.mjs | 85 +++++++++++++++++++++++++------ 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/features/skill-wiring.feature b/features/skill-wiring.feature index 25c655b..fe5b9f5 100644 --- a/features/skill-wiring.feature +++ b/features/skill-wiring.feature @@ -26,13 +26,16 @@ Feature: Skill wiring When each is inspected for the canonical gate Then all three reference skills/convergence-check/SKILL.md - Scenario: The Convergence Check result states live in exactly one skill - Given the canonical skills under skills/ - When they are searched for a definition of the gate's result states - Then only the Convergence Check itself defines them - # Naming a state is allowed; defining it elsewhere is the copy that drifts. + Scenario: No caller defines any of the Convergence Check result states + Given the four result states read from the Convergence Check itself + When every other skill is searched for a definition of any of them + Then none defines one + # Naming a state is allowed and often clearer; defining it elsewhere is the + # copy that drifts. All four are checked, not just one. - Scenario: The Convergence Check questions are not copied into a caller - Given the canonical skills under skills/ - When they are searched for the gate's seven-question structure - Then only the Convergence Check itself carries it + Scenario: No caller carries the Convergence Check question structure + Given the seven question titles read from the Convergence Check itself + When every other skill is searched for those titles as headings + Then none carries two or more of them + # The section heading alone would not catch a copy made without it, so the + # question titles themselves are what is checked. diff --git a/skills/convergence-check/SKILL.md b/skills/convergence-check/SKILL.md index 1515272..05996e8 100644 --- a/skills/convergence-check/SKILL.md +++ b/skills/convergence-check/SKILL.md @@ -165,6 +165,26 @@ waivers, or not converged. An unavailable blocker does not resolve by waiting, and reporting it as pending sends someone to wait for something that will not arrive. +### A standing limitation is not this change's blocker + +A blocker is about **this** change: evidence this change owes and cannot +produce. + +A limitation that would appear identically on every change of its kind is not a +blocker. No execution layer for prose contracts, no environment to run a +particular check in, a tool that does not exist yet — these are properties of +the medium or the tooling, not gaps in the change under review. They are +**residual risks**, and question 6 already requires reporting them, together +with the follow-up that would remove them. + +The test is one question: *would this same blocker appear on every change of +this kind?* If yes, blocking on it stops all such work indefinitely while +changing nothing about the change in front of you — and a gate that can never +be passed is not a gate, it is a stop. + +Report the limitation, name the follow-up, and answer the question on the +evidence that **can** be produced here. + ### A waiver is not a way out of a blocker A waiver is a human decision about **proportionality** — "this is too small to diff --git a/test/skill-wiring.test.mjs b/test/skill-wiring.test.mjs index 547d1a8..ad394b8 100644 --- a/test/skill-wiring.test.mjs +++ b/test/skill-wiring.test.mjs @@ -94,27 +94,80 @@ function skillsContaining(pattern) { .map(rel); } +// Both checks read the canonical file for what to look for, rather than +// hard-coding it. Rename a result state or a question and the guard follows; +// hard-coded copies would quietly stop guarding anything. +function canonicalResultStates() { + const section = read(canonical).split("## Result")[1].split("###")[0]; + return [...section.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gim)].map((m) => + m[1].trim(), + ); +} + +function canonicalQuestionTitles() { + const section = read(canonical) + .split("## The seven questions")[1] + .split("\n## ")[0]; + return [...section.matchAll(/^###\s+\d+\.\s+(.+?)\s*$/gim)].map((m) => m[1]); +} + +function escapeForRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + test("The Convergence Check result states live in exactly one skill", () => { - // Given the canonical skills under skills/ - // When they are searched for the gate's result vocabulary + // Given the canonical skills under skills/, and the four result states read + // from the Convergence Check itself + const states = canonicalResultStates(); + assert.equal(states.length, 4, `expected four result states, got ${states.join(", ")}`); + + // When they are searched for a definition of any of those states // - // Naming a result state is allowed and often clearer: implement-issue-workflow - // says which results stop integration, which is its own policy expressed in - // the gate's vocabulary. What must not spread is the *definition* — the table - // row that says what a state means. That is the copy which drifts. - const carriers = skillsContaining( - /^\|\s*\*\*Converged with recorded waivers\*\*\s*\|/im, - ); + // Naming a state elsewhere is allowed and often clearer: implement-issue-workflow + // says which results stop integration, which is its own policy expressed in the + // gate's vocabulary. What must not spread is the *definition* — the table row + // saying what a state means. That is the copy that drifts. + const offenders = states.flatMap((state) => { + const row = new RegExp(`^\\|\\s*\\*\\*${escapeForRegex(state)}\\*\\*\\s*\\|`, "im"); + return skillsContaining(row) + .filter((file) => file !== rel(canonical)) + .map((file) => `${file} defines "${state}"`); + }); - // Then only the Convergence Check itself defines it - assert.deepEqual(carriers, [rel(canonical)]); + // Then only the Convergence Check itself defines them + assert.deepEqual(offenders, []); }); test("The Convergence Check questions are not copied into a caller", () => { - // Given the canonical skills under skills/ - // When they are searched for the gate's seven-question structure - const carriers = skillsContaining(/Every question must be able to fail/i); + // Given the canonical skills under skills/, and the seven question titles read + // from the Convergence Check itself + const titles = canonicalQuestionTitles(); + assert.equal(titles.length, 7, `expected seven questions, got ${titles.length}`); - // Then only the Convergence Check itself carries it - assert.deepEqual(carriers, [rel(canonical)]); + // When every other skill is searched for those titles as headings + // + // A caller could copy the questions without the section heading above them, + // so the heading alone is not what is checked. Two or more of the canonical + // titles appearing as headings in one file is the copy; a single shared word + // such as "Traceability" is not. + const offenders = skillFiles() + .filter((file) => file !== canonical) + .map((file) => { + const text = read(file); + const hits = titles.filter((title) => + new RegExp(`^#+\\s+(\\d+\\.\\s+)?${escapeForRegex(title)}\\s*$`, "im").test(text), + ); + return { file: rel(file), hits }; + }) + .filter((entry) => entry.hits.length >= 2) + .map((entry) => `${entry.file} carries ${entry.hits.length} question headings`); + + // Then only the Convergence Check itself carries the structure + assert.deepEqual(offenders, []); + // ... and the canonical file still has all seven, so the guard has something + // to compare against. + const canonicalHits = titles.filter((title) => + new RegExp(`^###\\s+\\d+\\.\\s+${escapeForRegex(title)}\\s*$`, "im").test(read(canonical)), + ); + assert.equal(canonicalHits.length, 7); });