From f6a64855088d27fd3b7553476d163c01655f994c Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 24 Jul 2026 11:21:59 +0200 Subject: [PATCH 1/8] [#352] test: mirror-equality guard for per-skill SKILL.md (data-driven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-driven it.each guard asserting every root .claude/skills//SKILL.md is byte-for-byte the real 'pair update' copy-pipeline transform of its dataset source. Composes the FULL pipeline (copyDirectoryWithTransforms, flatten+prefix 'pair') over InMemoryFileSystemService — no parallel transform re-implementation, so a real pipeline bug fails the guard. - Directional (dataset -> root): iterates 39 dataset skill dirs; root-only agent-browser (no dataset source) is ignored, not drift. - Covers all 4 drift classes via drift-injection over a synthetic mini dataset: frontmatter name: sync, relative-link-depth bump (../../ -> ../../../), /command skill-reference rewrite, and missing-mirror hard fail. - No hardcoded skill count (AC5): new dataset skill covered with no test edit. - Gate/assertion helper (assertRootSkillMdMatches) in a tested production module per the gate-tooling-in-tested-modules ADL. Refs: #352 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tools/skill-md-mirror.test.ts | 141 ++++++++++++++++ .../src/tools/skill-md-mirror.ts | 157 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 packages/knowledge-hub/src/tools/skill-md-mirror.test.ts create mode 100644 packages/knowledge-hub/src/tools/skill-md-mirror.ts diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts new file mode 100644 index 00000000..a3e1d9e1 --- /dev/null +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { existsSync, readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { + readSkillsDatasetFromDisk, + datasetSkillDirs, + installedSkillDir, + buildInstalledSkillMd, + assertRootSkillMdMatches, + type DatasetTree, +} from './skill-md-mirror' + +// packages/knowledge-hub/src/tools -> repo root +const REPO_ROOT = join(__dirname, '..', '..', '..', '..') +const DATASET_SKILLS = join(REPO_ROOT, 'packages/knowledge-hub/dataset/.skills') +const ROOT_CLAUDE_SKILLS = join(REPO_ROOT, '.claude/skills') + +const rootMirrorContent = (prefixed: string): string | undefined => { + const p = join(ROOT_CLAUDE_SKILLS, prefixed, 'SKILL.md') + return existsSync(p) ? readFileSync(p, 'utf-8') : undefined +} + +/** + * Data-driven mirror-equality guard: for EVERY skill dir in the dataset, + * asserts the root `.claude/skills//SKILL.md` is byte-for-byte the + * real `pair update` copy-pipeline transform of its dataset source. The case + * list is derived from the dataset at collection time, so a newly added skill + * is covered automatically with no test edit (AC5) — never a hardcoded count. + */ +describe('per-skill SKILL.md dataset -> root mirror equality (data-driven)', () => { + const tree = readSkillsDatasetFromDisk(DATASET_SKILLS) + const skillDirs = datasetSkillDirs(tree) + let installed: Map + + beforeAll(async () => { + installed = await buildInstalledSkillMd(tree) + }) + + it('discovers dataset skill dirs directly from disk (no hardcoded count)', () => { + expect(skillDirs.length).toBeGreaterThan(0) + expect(skillDirs).toContain('capability/verify-quality') + expect(skillDirs).toContain('process/refine-story') + expect(skillDirs).toContain('next') + }) + + it.each(skillDirs)( + 'root mirror SKILL.md for %s equals the real copy-pipeline transform', + datasetDir => { + const prefixed = installedSkillDir(datasetDir) + const expected = installed.get(prefixed) + expect(expected, `pipeline produced no SKILL.md for ${prefixed}`).toBeDefined() + + // Throws (AC4 missing / AC2+AC3 drift) or passes. The assertion helper + // lives in the production module so this guard and the drift-injection + // tests below exercise the same code path. + expect(() => + assertRootSkillMdMatches(prefixed, expected!, rootMirrorContent(prefixed)), + ).not.toThrow() + }, + ) +}) + +/** + * Directional (dataset -> root): the guard iterates dataset skill dirs, so a + * root-only skill with no dataset source (e.g. `agent-browser`) is never + * asserted and is NOT treated as drift. + */ +describe('directional guard ignores root-only skills with no dataset source', () => { + it('agent-browser is present in the root mirror but absent from the dataset', () => { + const rootDirs = readdirSync(ROOT_CLAUDE_SKILLS, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + expect(rootDirs).toContain('agent-browser') + + const tree = readSkillsDatasetFromDisk(DATASET_SKILLS) + const installedFromDataset = datasetSkillDirs(tree).map(installedSkillDir) + expect(installedFromDataset).not.toContain('agent-browser') + }) +}) + +/** + * Drift-injection: proves the guard FAILS on each drift class the copy + * transform covers, then PASSES once reconciled. A synthetic mini dataset is + * run through the SAME real pipeline; the transformed output is then corrupted + * to simulate a stale root mirror, and `assertRootSkillMdMatches` must throw. + * + * The mini fixture is authored so the real transform performs all three + * content rewrites: frontmatter `name:` sync, relative-link-depth bump + * (`../../` -> `../../../`, triggered by the depth-shifting bare `demo` skill), + * and the `/command` skill-reference rewrite. + */ +describe('drift-injection: guard fails on each drift class, passes when reconciled', () => { + const MINI: DatasetTree = { + 'demo/SKILL.md': + '---\nname: demo\ndescription: "d"\n---\n\n# demo\n\nSee [foo](../../.pair/foo.md). Compose /verify-quality here.\n', + 'capability/verify-quality/SKILL.md': '---\nname: verify-quality\n---\n\n# vq\n', + } + let expected: string + + beforeAll(async () => { + const installed = await buildInstalledSkillMd(MINI) + expected = installed.get('pair-demo')! + }) + + it('the real transform performs all three content rewrites (fixture is meaningful)', () => { + // frontmatter name: sync + expect(expected).toContain('name: pair-demo') + expect(expected).not.toContain('name: demo\n') + // relative-link-depth bump (../../ -> ../../../) + expect(expected).toContain('](../../../.pair/foo.md)') + expect(expected).not.toContain('](../../.pair/foo.md)') + // /command skill-reference rewrite + expect(expected).toContain('/pair-capability-verify-quality') + expect(expected).not.toContain(' /verify-quality ') + }) + + it('passes when the root mirror equals the real transform (reconciled)', () => { + expect(() => assertRootSkillMdMatches('pair-demo', expected, expected)).not.toThrow() + }) + + it('FAILS on frontmatter name: drift (AC2)', () => { + const drifted = expected.replace('name: pair-demo', 'name: demo') + expect(() => assertRootSkillMdMatches('pair-demo', expected, drifted)).toThrow(/drifted/) + }) + + it('FAILS on relative-link-depth drift (AC3)', () => { + const drifted = expected.replace('](../../../.pair/foo.md)', '](../../.pair/foo.md)') + expect(() => assertRootSkillMdMatches('pair-demo', expected, drifted)).toThrow(/drifted/) + }) + + it('FAILS on /command skill-reference drift (AC3)', () => { + const drifted = expected.replace('/pair-capability-verify-quality', '/verify-quality') + expect(() => assertRootSkillMdMatches('pair-demo', expected, drifted)).toThrow(/drifted/) + }) + + it('FAILS loudly when the root mirror is missing (AC4)', () => { + expect(() => assertRootSkillMdMatches('pair-demo', expected, undefined)).toThrow( + /missing[\s\S]*pair update/, + ) + }) +}) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts new file mode 100644 index 00000000..efc24665 --- /dev/null +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -0,0 +1,157 @@ +/** + * Mirror-equality helpers for per-skill `SKILL.md` files. + * + * Every root `.claude/skills//SKILL.md` is GENERATED from its + * canonical dataset source `packages/knowledge-hub/dataset/.skills///SKILL.md` + * by `pair update`. Rather than re-implement that transform, these helpers run + * the REAL copy pipeline — `copyDirectoryWithTransforms` with the exact + * `{ flatten: true, prefix: 'pair' }` options `apps/pair-cli/config.json` + * declares for the `skills` registry — over an in-memory clone of the dataset, + * so a bug in the real pipeline FAILS the guard instead of being masked. + * + * The composed transform covers all four drift classes the guard must catch: + * dir-rename (`transformPath`), frontmatter `name:` sync (`syncFrontmatter`), + * relative-link-depth rewrite (`rewriteLinksAfterTransform`, the `../../`→`../../../` + * bump that the depth-shifting bare `next` skill triggers), and the `/command` + * skill-reference rewrite (`rewriteSkillReferencesInFiles`). + * + * Directional (dataset → root): the map is keyed by dataset skill dirs, so a + * root-only skill with no dataset source (e.g. `agent-browser`) is never + * asserted — it is not drift. + */ +import { readdirSync, readFileSync } from 'fs' +import { join, relative, sep } from 'path' +import { + InMemoryFileSystemService, + copyDirectoryWithTransforms, + transformPath, + defaultSyncOptions, +} from '@pair/content-ops' + +/** The exact naming-transform options the `skills` registry uses in config.json. */ +export const SKILL_COPY_OPTS = { flatten: true, prefix: 'pair' } as const + +const SKILL_FILE = 'SKILL.md' + +// Virtual (in-memory) dataset layout mirroring the real registry paths: +// datasetRoot/.skills -> datasetRoot/.claude/skills, same as `pair update`. +const VIRTUAL_DATASET_ROOT = '/ds' +const VIRTUAL_SRC = `${VIRTUAL_DATASET_ROOT}/.skills` +const VIRTUAL_DEST = `${VIRTUAL_DATASET_ROOT}/.claude/skills` + +/** Posix-relative path under `.skills/` → file content. */ +export type DatasetTree = Record + +/** + * Reads the on-disk dataset `.skills` tree into a posix-keyed content map. + * Keys are paths relative to `skillsDir` (e.g. `capability/verify-quality/SKILL.md`, + * `next/SKILL.md`), so the map is a portable snapshot of the dataset source. + */ +export function readSkillsDatasetFromDisk(skillsDir: string): DatasetTree { + const tree: DatasetTree = {} + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else { + const rel = relative(skillsDir, full).split(sep).join('/') + tree[rel] = readFileSync(full, 'utf-8') + } + } + } + + walk(skillsDir) + return tree +} + +/** + * The dataset skill directories — every posix dir that directly contains a + * `SKILL.md` (`capability/`, `process/`, or the bare `next`), + * sorted for stable `it.each` ordering. Data-driven: adding a skill to the + * dataset extends this list with no test edit (AC5). + */ +export function datasetSkillDirs(tree: DatasetTree): string[] { + const dirs = new Set() + for (const rel of Object.keys(tree)) { + if (rel.endsWith(`/${SKILL_FILE}`)) { + dirs.add(rel.slice(0, -(`/${SKILL_FILE}`.length))) + } + } + return [...dirs].sort() +} + +/** + * Installed (prefixed) directory name for a dataset skill dir, via the real + * `transformPath` with the registry's flatten/prefix options + * (`capability/verify-quality` → `pair-capability-verify-quality`, + * `next` → `pair-next`). + */ +export function installedSkillDir(datasetSkillDir: string): string { + return transformPath(datasetSkillDir, SKILL_COPY_OPTS) +} + +/** + * Runs the REAL `pair update` copy pipeline over an in-memory clone of the + * dataset and returns `installedPrefixedDir → transformed SKILL.md content` + * for every dataset skill dir. No parallel transform logic — a bug in the + * production pipeline surfaces here. + */ +export async function buildInstalledSkillMd(tree: DatasetTree): Promise> { + const initial: Record = {} + for (const [rel, content] of Object.entries(tree)) { + initial[`${VIRTUAL_SRC}/${rel}`] = content + } + + const fileService = new InMemoryFileSystemService(initial, '/', '/') + await copyDirectoryWithTransforms({ + fileService, + srcPath: VIRTUAL_SRC, + destPath: VIRTUAL_DEST, + source: '.skills', + target: '.claude/skills', + datasetRoot: VIRTUAL_DATASET_ROOT, + // Same SyncOptions the `skills` registry resolves to: default sync options + // (behavior 'overwrite') with the registry's flatten/prefix applied. + options: { ...defaultSyncOptions(), ...SKILL_COPY_OPTS }, + }) + + const installed = new Map() + for (const datasetDir of datasetSkillDirs(tree)) { + const prefixed = installedSkillDir(datasetDir) + installed.set(prefixed, await fileService.readFile(`${VIRTUAL_DEST}/${prefixed}/${SKILL_FILE}`)) + } + return installed +} + +/** + * Asserts the root mirror `SKILL.md` equals the real pipeline transform. + * Throws LOUDLY — naming the skill and giving the `pair update` regenerate + * hint — when the mirror is missing (AC4) or has drifted (AC2/AC3). This is + * the guard's assertion helper, kept in a tested production module (per the + * "gate & tooling code in tested modules" ADL) so both the real on-disk guard + * and the drift-injection tests drive the same code path. + * + * `actual` is `undefined` iff the root mirror file does not exist. + */ +export function assertRootSkillMdMatches( + prefixed: string, + expected: string, + actual: string | undefined, +): void { + if (actual === undefined) { + throw new Error( + `Root mirror SKILL.md missing for skill '${prefixed}': ` + + `.claude/skills/${prefixed}/SKILL.md does not exist. Run 'pair update' to regenerate it.`, + ) + } + if (actual !== expected) { + throw new Error( + `Root mirror SKILL.md for skill '${prefixed}' has drifted from its dataset source ` + + `transform. Run 'pair update' to regenerate .claude/skills/${prefixed}/SKILL.md.\n` + + `--- expected (dataset → real transform) ---\n${expected}\n` + + `--- actual (root mirror on disk) ---\n${actual}`, + ) + } +} From 5f8289e8279daa21a1069b82206ffe6ef9da3eee Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 24 Jul 2026 12:19:57 +0200 Subject: [PATCH 2/8] =?UTF-8?q?[#352]=20test:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drive=20reRootTarget=20branch=20+=20assert=20drift?= =?UTF-8?q?=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed virtual dataset at deep source path (packages/knowledge-hub/dataset/.skills) so sourceContentRoot is non-trivial and the link-rewriter reRootTarget branch runs - strengthen frontmatter-drift test to assert error names the skill + shows expected-vs-actual (AC2), so a regression dropping either fails the test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tools/skill-md-mirror.test.ts | 24 +++++++++++++++++- .../src/tools/skill-md-mirror.ts | 25 ++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index a3e1d9e1..ffc4d547 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -20,6 +20,16 @@ const rootMirrorContent = (prefixed: string): string | undefined => { return existsSync(p) ? readFileSync(p, 'utf-8') : undefined } +/** Runs `fn`, expecting it to throw, and returns the thrown Error's message. */ +const captureThrownMessage = (fn: () => void): string => { + try { + fn() + } catch (err) { + return (err as Error).message + } + throw new Error('expected the assertion to throw, but it did not') +} + /** * Data-driven mirror-equality guard: for EVERY skill dir in the dataset, * asserts the root `.claude/skills//SKILL.md` is byte-for-byte the @@ -118,9 +128,21 @@ describe('drift-injection: guard fails on each drift class, passes when reconcil expect(() => assertRootSkillMdMatches('pair-demo', expected, expected)).not.toThrow() }) - it('FAILS on frontmatter name: drift (AC2)', () => { + it('FAILS on frontmatter name: drift (AC2), naming the skill + showing expected vs actual', () => { const drifted = expected.replace('name: pair-demo', 'name: demo') expect(() => assertRootSkillMdMatches('pair-demo', expected, drifted)).toThrow(/drifted/) + + // AC2 explicitly requires the failure to NAME the offending skill and SHOW + // expected-vs-actual content — assert the message carries all of it, so a + // regression that dropped the skill name or the diff dump would fail here. + const message = captureThrownMessage(() => + assertRootSkillMdMatches('pair-demo', expected, drifted), + ) + expect(message).toMatch(/pair-demo/) // names the offending skill + expect(message).toContain('--- expected') // labelled expected dump + expect(message).toContain('--- actual') // labelled actual dump + expect(message).toContain('name: pair-demo') // expected content present + expect(message).toContain('name: demo') // drifted actual content present }) it('FAILS on relative-link-depth drift (AC3)', () => { diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index efc24665..af2f26ea 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -33,11 +33,21 @@ export const SKILL_COPY_OPTS = { flatten: true, prefix: 'pair' } as const const SKILL_FILE = 'SKILL.md' -// Virtual (in-memory) dataset layout mirroring the real registry paths: -// datasetRoot/.skills -> datasetRoot/.claude/skills, same as `pair update`. +// Virtual (in-memory) dataset layout that FAITHFULLY mirrors the real +// `pair update` skills-registry paths, not just a convenient shallow layout. +// The real run uses datasetRoot = baseTarget = repo root and a DEEP source +// (`packages/knowledge-hub/dataset/.skills`), so its `sourceContentRoot` +// (= dirname of the source-relative path) is non-trivial and the link +// rewriter's `reRootTarget` branch executes. Seeding the source at the same +// deep path here (rather than a shallow `/ds/.skills`, whose sourceContentRoot +// collapses to '.') means the guard actually DRIVES that re-rooting branch, so +// a pipeline bug isolated to `reRootTarget`/`sourceContentRoot` re-rooting also +// fails the guard. const VIRTUAL_DATASET_ROOT = '/ds' -const VIRTUAL_SRC = `${VIRTUAL_DATASET_ROOT}/.skills` -const VIRTUAL_DEST = `${VIRTUAL_DATASET_ROOT}/.claude/skills` +const VIRTUAL_SOURCE_REL = 'packages/knowledge-hub/dataset/.skills' +const VIRTUAL_TARGET_REL = '.claude/skills' +const VIRTUAL_SRC = `${VIRTUAL_DATASET_ROOT}/${VIRTUAL_SOURCE_REL}` +const VIRTUAL_DEST = `${VIRTUAL_DATASET_ROOT}/${VIRTUAL_TARGET_REL}` /** Posix-relative path under `.skills/` → file content. */ export type DatasetTree = Record @@ -109,8 +119,11 @@ export async function buildInstalledSkillMd(tree: DatasetTree): Promise Date: Fri, 24 Jul 2026 12:36:04 +0200 Subject: [PATCH 3/8] [#352] style: drop redundant parens for prettier-clean slice arg Addresses review: prettier:check now clean on skill-md-mirror.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/knowledge-hub/src/tools/skill-md-mirror.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index af2f26ea..cd000624 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -86,7 +86,7 @@ export function datasetSkillDirs(tree: DatasetTree): string[] { const dirs = new Set() for (const rel of Object.keys(tree)) { if (rel.endsWith(`/${SKILL_FILE}`)) { - dirs.add(rel.slice(0, -(`/${SKILL_FILE}`.length))) + dirs.add(rel.slice(0, -`/${SKILL_FILE}`.length)) } } return [...dirs].sort() From 215e8d5ae7978cebd882d41f6b659762c7fc5c04 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 24 Jul 2026 16:54:11 +0200 Subject: [PATCH 4/8] =?UTF-8?q?[#352]=20test:=20address=20review=20round?= =?UTF-8?q?=202=20=E2=80=94=20compact=20diff=20on=20drift=20+=20scope=20no?= =?UTF-8?q?tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finding 2 (noisy failure output): drift message now carries a compact line-level diff (diffSkillMd, LCS-based, context-collapsed) instead of dumping both files in full; unit-covered for change/insert/delete/elision. - finding 3 (parallel enumerators): doc comment on datasetSkillDirs explains why it is preferred over reusing collectSkillDirs (in-memory, SKILL.md-only). - finding 1 (guard scope): in-code SCOPE note documents SKILL.md-only is by-design; follow-up tech-debt story #384 tracks extending to all artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tools/skill-md-mirror.test.ts | 55 ++++++++++- .../src/tools/skill-md-mirror.ts | 96 ++++++++++++++++++- 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index ffc4d547..c1755a7d 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -7,6 +7,7 @@ import { installedSkillDir, buildInstalledSkillMd, assertRootSkillMdMatches, + diffSkillMd, type DatasetTree, } from './skill-md-mirror' @@ -88,6 +89,52 @@ describe('directional guard ignores root-only skills with no dataset source', () }) }) +/** + * `diffSkillMd` unit coverage: the compact line-level diff behind the drift + * failure message (finding-2 remediation — replaces the full two-file dump). + * Exercises each edit class directly so a large SKILL.md failure stays readable. + */ +describe('diffSkillMd — compact line-level diff', () => { + it('shows a changed line as a - expected / + actual pair with surrounding context', () => { + const expected = 'a\nb\nc\nd\ne' + const actual = 'a\nb\nX\nd\ne' + const out = diffSkillMd(expected, actual) + expect(out).toContain('-c') + expect(out).toContain('+X') + expect(out).toContain(' b') // unchanged context line kept + expect(out).toContain(' d') + }) + + it('shows pure insertions as + lines (actual longer than expected)', () => { + const out = diffSkillMd('a\nb', 'a\nb\nc\nd') + expect(out).toContain('+c') + expect(out).toContain('+d') + expect(out).not.toContain('-') + }) + + it('shows pure deletions as - lines (expected longer than actual)', () => { + const out = diffSkillMd('a\nb\nc\nd', 'a\nb') + expect(out).toContain('-c') + expect(out).toContain('-d') + expect(out).not.toContain('+') + }) + + it('collapses long runs of unchanged lines with an ellipsis marker', () => { + const many = Array.from({ length: 30 }, (_, i) => `line${i}`).join('\n') + const drifted = many.replace('line15', 'CHANGED') + const out = diffSkillMd(many, drifted) + expect(out).toContain('-line15') + expect(out).toContain('+CHANGED') + expect(out).toContain('…') // distant unchanged lines elided + expect(out).not.toContain('line0\n') // far-from-change context dropped + }) + + it('returns no diff lines for identical input', () => { + // identical input has no changed lines, so nothing is kept — only elision. + expect(diffSkillMd('a\nb\nc', 'a\nb\nc').replace(/…/g, '').trim()).toBe('') + }) +}) + /** * Drift-injection: proves the guard FAILS on each drift class the copy * transform covers, then PASSES once reconciled. A synthetic mini dataset is @@ -139,10 +186,10 @@ describe('drift-injection: guard fails on each drift class, passes when reconcil assertRootSkillMdMatches('pair-demo', expected, drifted), ) expect(message).toMatch(/pair-demo/) // names the offending skill - expect(message).toContain('--- expected') // labelled expected dump - expect(message).toContain('--- actual') // labelled actual dump - expect(message).toContain('name: pair-demo') // expected content present - expect(message).toContain('name: demo') // drifted actual content present + expect(message).toContain('--- expected') // labelled expected side of the diff + expect(message).toContain('+++ actual') // labelled actual side of the diff + expect(message).toContain('-name: pair-demo') // expected content shown as a removed diff line + expect(message).toContain('+name: demo') // drifted actual content shown as an added diff line }) it('FAILS on relative-link-depth drift (AC3)', () => { diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index cd000624..56ca8b36 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -18,6 +18,12 @@ * Directional (dataset → root): the map is keyed by dataset skill dirs, so a * root-only skill with no dataset source (e.g. `agent-browser`) is never * asserted — it is not drift. + * + * SCOPE (by design, per #352): the guard asserts equality for each skill's + * `SKILL.md` ONLY. Other artifacts a skill dir contributes through the same + * `pair update` transform — sub-docs (e.g. process-review's `merge-and-cascade.md`) + * and `references/*` — are NOT asserted here; extending the invariant to all + * root skill artifacts is tracked as follow-up tech-debt story #384. */ import { readdirSync, readFileSync } from 'fs' import { join, relative, sep } from 'path' @@ -81,6 +87,12 @@ export function readSkillsDatasetFromDisk(skillsDir: string): DatasetTree { * `SKILL.md` (`capability/`, `process/`, or the bare `next`), * sorted for stable `it.each` ordering. Data-driven: adding a skill to the * dataset extends this list with no test edit (AC5). + * + * Deliberately NOT reusing `collectSkillDirs` from the sibling + * `skills-guide-mirror.ts`: that one re-walks the disk and returns ANY dir + * holding at least one file, whereas this one enumerates only SKILL.md-bearing + * dirs off the already-read in-memory `tree` — no second disk walk, and a + * `references/`-only subdir is never mistaken for a skill's own directory. */ export function datasetSkillDirs(tree: DatasetTree): string[] { const dirs = new Set() @@ -138,6 +150,81 @@ export async function buildInstalledSkillMd(tree: DatasetTree): Promise new Array(n + 1).fill(0)) + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!) + } + } + + const edits: DiffEdit[] = [] + let i = 0 + let j = 0 + while (i < m && j < n) { + // i < m and j < n guarantee both are defined (noUncheckedIndexedAccess). + const ai = a[i]! + const bj = b[j]! + if (ai === bj) { + edits.push({ tag: ' ', line: ai }) + i++ + j++ + } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { + edits.push({ tag: '-', line: ai }) + i++ + } else { + edits.push({ tag: '+', line: bj }) + j++ + } + } + for (const line of a.slice(i)) edits.push({ tag: '-', line }) + for (const line of b.slice(j)) edits.push({ tag: '+', line }) + return edits +} + +/** + * Compact line-level diff of `expected` vs `actual`, showing only the changed + * lines with a little surrounding context rather than dumping both files in + * full — keeps a drift failure readable for a large SKILL.md instead of + * flooding CI with two complete copies. `-` lines are `expected` (dataset → + * real transform), `+` lines are `actual` (root mirror on disk); collapsed + * runs of unchanged context are shown as ` …`. + */ +export function diffSkillMd(expected: string, actual: string): string { + const edits = lineEditScript(expected.split('\n'), actual.split('\n')) + + // Keep only changed lines plus a little context around each; collapse the rest. + const CONTEXT = 2 + const keep = new Array(edits.length).fill(false) + edits.forEach((e, idx) => { + if (e.tag === ' ') return + for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(edits.length - 1, idx + CONTEXT); k++) { + keep[k] = true + } + }) + + const out: string[] = [] + let elided = false + edits.forEach((e, idx) => { + if (keep[idx]) { + out.push(`${e.tag}${e.line}`) + elided = false + } else if (!elided) { + out.push(' …') + elided = true + } + }) + return out.join('\n') +} + /** * Asserts the root mirror `SKILL.md` equals the real pipeline transform. * Throws LOUDLY — naming the skill and giving the `pair update` regenerate @@ -146,6 +233,10 @@ export async function buildInstalledSkillMd(tree: DatasetTree): Promise Date: Fri, 24 Jul 2026 16:56:03 +0200 Subject: [PATCH 5/8] [#352] style: prettier-wrap lcs assignment line (>printWidth) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/knowledge-hub/src/tools/skill-md-mirror.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index 56ca8b36..f4f5c8a6 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -162,7 +162,8 @@ function lineEditScript(a: string[], b: string[]): DiffEdit[] { const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) for (let i = m - 1; i >= 0; i--) { for (let j = n - 1; j >= 0; j--) { - lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!) + lcs[i]![j] = + a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!) } } From 01b9601255cfe1f5f05954a62187c2f14438b2cc Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 24 Jul 2026 18:00:44 +0200 Subject: [PATCH 6/8] [#352] test: pin SKILL_COPY_OPTS to pair-cli config; exercise directional guard - new test asserts SKILL_COPY_OPTS.flatten/prefix + source match apps/pair-cli/config.json asset_registries.skills, so a registry change fails here (correctly attributed) instead of the guard silently computing the wrong root path - directional test now exercises the guard's dataset-derived expected-set (datasetSkillDirs+installedSkillDir+buildInstalledSkillMd) proving root-only skills are structurally never enumerated, replacing the environment-fact assertion Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tools/skill-md-mirror.test.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index c1755a7d..5a98a49b 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -8,6 +8,7 @@ import { buildInstalledSkillMd, assertRootSkillMdMatches, diffSkillMd, + SKILL_COPY_OPTS, type DatasetTree, } from './skill-md-mirror' @@ -77,15 +78,54 @@ describe('per-skill SKILL.md dataset -> root mirror equality (data-driven)', () * asserted and is NOT treated as drift. */ describe('directional guard ignores root-only skills with no dataset source', () => { - it('agent-browser is present in the root mirror but absent from the dataset', () => { + it('the guard enumerates ONLY the dataset-derived expected set, never a root-only skill', async () => { + // Synthetic dataset that deliberately does NOT contain `agent-browser` (a real root-only skill). + const tree: DatasetTree = { + 'capability/verify-quality/SKILL.md': '---\nname: verify-quality\n---\n\n# vq\n', + 'next/SKILL.md': '---\nname: next\n---\n\n# next\n', + } + // Exercise the guard's actual expected-set construction (not filesystem facts): + const expectedDirs = datasetSkillDirs(tree).map(installedSkillDir) + const installed = await buildInstalledSkillMd(tree) + + // The guard will assert on EXACTLY these dataset-derived, prefixed dirs — the iteration + // domain is dataset -> root, so a root-only skill name is structurally never checked. + expect(expectedDirs.slice().sort()).toEqual([...installed.keys()].sort()) + expect(expectedDirs).toContain('pair-capability-verify-quality') + expect(expectedDirs).not.toContain('agent-browser') + expect([...installed.keys()]).not.toContain('agent-browser') + + // The real root DOES carry agent-browser, yet the dataset-derived expected set above never + // enumerates it (proving direction) while dataset skills DO mirror into the root. const rootDirs = readdirSync(ROOT_CLAUDE_SKILLS, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) expect(rootDirs).toContain('agent-browser') + expect( + datasetSkillDirs(readSkillsDatasetFromDisk(DATASET_SKILLS)).map(installedSkillDir), + ).not.toContain('agent-browser') + }) +}) - const tree = readSkillsDatasetFromDisk(DATASET_SKILLS) - const installedFromDataset = datasetSkillDirs(tree).map(installedSkillDir) - expect(installedFromDataset).not.toContain('agent-browser') +/** + * SKILL_COPY_OPTS is a local copy of the `skills` asset-registry knobs in + * apps/pair-cli/config.json (what `pair update` actually uses). Pin it so a + * registry change (e.g. prefix `pair` -> `p`) fails HERE — correctly attributed + * — instead of the guard silently computing the wrong root path and blaming the + * mirror / `pair update` (finding: hardcoded duplication of the config). + */ +describe('SKILL_COPY_OPTS stays pinned to the pair-cli skills registry', () => { + it('flatten/prefix/source match apps/pair-cli/config.json asset_registries.skills', () => { + const config = JSON.parse( + readFileSync(join(REPO_ROOT, 'apps/pair-cli/config.json'), 'utf-8'), + ) as { + asset_registries: { skills: { source: string; flatten: boolean; prefix: string } } + } + const registry = config.asset_registries.skills + expect(SKILL_COPY_OPTS.flatten).toBe(registry.flatten) + expect(SKILL_COPY_OPTS.prefix).toBe(registry.prefix) + // the guard reads the dataset from the registry's declared source dir (.skills) + expect(DATASET_SKILLS.endsWith(registry.source)).toBe(true) }) }) From c3d697a29b36e674a8005c1c24cbc75382eedc00 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 25 Jul 2026 10:43:39 +0200 Subject: [PATCH 7/8] [#352] fix: reconcile 3 drifted SKILL.md mirrors the new guard caught on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real catch of this PR's guard, on the rebase onto main: 3 root mirrors were not byte-equal to realTransform(dataset), drift left by #263 (PR #381). Resolved per case, not blindly regenerated: - classify + review (root stale, dataset right): `/assess-coupling` was left unprefixed in the root mirrors — regenerated via the real pipeline, root now carries `/pair-capability-assess-coupling`. - assess-coupling `[N unbalanced+volatile]` (root right, dataset stale): the review fix e8537bb had been applied to root only; regenerating would have REGRESSED the content (tolerable = unbalanced + low volatility is retained, so "+volatile" is wrong). Ported into the dataset instead. - assess-coupling fenced ```text blocks (neither side reproducible): the reference rewriter does not enter fences, so root's prefixed `/pair-process-review` / `/pair-capability-write-issue` could never be produced from the dataset. Rephrased prefix-neutral in the dataset ("the review process", "the write-issue pipeline") — the dataset must not hardcode an install-time prefix. Mirrors regenerated with buildInstalledSkillMd (the same code path `pair update` runs), not hand-edited. Guard: 54/54 pass. Co-Authored-By: Claude --- .../skills/pair-capability-assess-coupling/SKILL.md | 6 +++--- .claude/skills/pair-capability-classify/SKILL.md | 10 +++++----- .claude/skills/pair-process-review/SKILL.md | 2 +- .../.skills/capability/assess-coupling/SKILL.md | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/skills/pair-capability-assess-coupling/SKILL.md b/.claude/skills/pair-capability-assess-coupling/SKILL.md index f12a5249..3aec40bb 100644 --- a/.claude/skills/pair-capability-assess-coupling/SKILL.md +++ b/.claude/skills/pair-capability-assess-coupling/SKILL.md @@ -36,7 +36,7 @@ The model is resolved through the standard **Adoption > KB default** layering; t KB guideline is the sole home of the criteria (D37): 1. **KB default** — [coupling-balance.md](../../../.pair/knowledge/guidelines/architecture/design-patterns/coupling-balance.md): strength levels (intrusive > functional > model > contract), the distance axes, essential-vs-accidental volatility, the balance rule, severity, DDD mapping, test implications. -2. **Volatility inputs (adoption)** — [adoption/product/subdomain/](../../../.pair/adoption/product/subdomain/) (subdomain classification → essential volatility: core = high, supporting = medium, generic = low) and [adoption/tech/boundedcontext/](../../../.pair/adoption/tech/boundedcontext/) (context boundaries → distance). Read when present; **asked or estimated** when absent (no-DDD degradation, never HALT — consistent with #246). +2. **Volatility inputs (adoption)** — [adoption/product/subdomain/](../../../.pair/adoption/product/subdomain) (subdomain classification → essential volatility: core = high, supporting = medium, generic = low) and [adoption/tech/boundedcontext/](../../../.pair/adoption/tech/boundedcontext) (context boundaries → distance). Read when present; **asked or estimated** when absent (no-DDD degradation, never HALT — consistent with #246). 3. **Project delta (adoption)** — `tech/risk-matrix.md` `## Overrides` may tune severity thresholds; absent ⇒ KB defaults apply completely (D21). A missing/malformed adoption file is treated as absent: warn and fall back (never HALT on adoption absence). @@ -115,7 +115,7 @@ For each relationship, assign **all three** per the guideline: ### Diff Scope ```text -COUPLING ASSESSMENT (composed by /pair-process-review — no files written): +COUPLING ASSESSMENT (composed by the review process — no files written): ├── Scope: Diff ├── Verdict: [balanced | tolerable | significant | critical] — [1-line summary] ├── Findings: [N total — N critical, N significant, N tolerable] @@ -139,7 +139,7 @@ COUPLING AUDIT COMPLETE: ├── Mapped: [N integrations] ├── Flagged: [N unbalanced — N critical, N significant, N tolerable] ├── Report: [.pair/working/reports/architecture/ — written] -├── Tech-debt: [N tuples handed to caller for /pair-capability-write-issue (tech-debt label, #224)] +├── Tech-debt: [N tuples handed to caller for the write-issue pipeline (tech-debt label, #224)] └── Basis: [KB model | + subdomain/boundedcontext adoption | volatility estimated (no DDD)] ``` diff --git a/.claude/skills/pair-capability-classify/SKILL.md b/.claude/skills/pair-capability-classify/SKILL.md index 40394fd7..84336322 100644 --- a/.claude/skills/pair-capability-classify/SKILL.md +++ b/.claude/skills/pair-capability-classify/SKILL.md @@ -12,7 +12,7 @@ Compile the classification matrix for a card or PR by **applying the [quality mo Two invocation contexts, one model: - **refinement** — evidence is the **story context** (declared/estimated scope, touched subdomains, cross-context integrations). Shift-left (R1.3): the matrix exists before code does. -- **review** — evidence is the **code/diff** (observed footprint, `/pair-capability-assess-security` verdict, `/assess-coupling` verdict). The review value is a **floor**: it confirms or **raises** the refinement tier, and **never lowers** it (quality-model §3.2, D17). +- **review** — evidence is the **code/diff** (observed footprint, `/pair-capability-assess-security` verdict, `/pair-capability-assess-coupling` verdict). The review value is a **floor**: it confirms or **raises** the refinement tier, and **never lowers** it (quality-model §3.2, D17). ## Arguments @@ -52,9 +52,9 @@ Every rule resolves through the quality model's cascade — **Argument (`$overri Per `$context`, collect the source for each dimension (quality-model §3.1, "Source" column): - **refinement** — from the story body: declared scope (change/diff-risk proxy), the subdomain classification of what it touches (business impact), touched-path heuristics (security relevance), and — if `subdomain/`/`boundedcontext/` artifacts exist — the volatility of touched subdomains plus the cross-context integrations the story introduces (coupling balance). -- **review** — from the diff: observed footprint (change/diff risk), the `/pair-capability-assess-security` review verdict (security relevance, quality-model §3.1), and the `/assess-coupling` verdict on the diff (coupling balance). An unreadable diff (huge/binary) falls back to file-path/service-level evidence and flags **low confidence** in the `
`. +- **review** — from the diff: observed footprint (change/diff risk), the `/pair-capability-assess-security` review verdict (security relevance, quality-model §3.1), and the `/pair-capability-assess-coupling` verdict on the diff (coupling balance). An unreadable diff (huge/binary) falls back to file-path/service-level evidence and flags **low confidence** in the `
`. -**Coupling sources absent** (no subdomain/bounded-context artifacts in refinement, `/assess-coupling` not installed in review) ⇒ the coupling dimension is reported **not assessed**, excluded from the tier max, and never blocks (quality-model §3.1, D21). +**Coupling sources absent** (no subdomain/bounded-context artifacts in refinement, `/pair-capability-assess-coupling` not installed in review) ⇒ the coupling dimension is reported **not assessed**, excluded from the tier max, and never blocks (quality-model §3.1, D21). ### Step 3: Compile the Matrix @@ -154,7 +154,7 @@ Review raw max = red (Change/diff risk, Security relevance), so `max(red, yellow **Composed by `/pair-process-refine-story`** (refinement): invoked with `$context: refinement` after the technical analysis. Returns the matrix; `/pair-process-refine-story` embeds the 1-line + `
` into the story body (D22) and lets `classify` apply/propose tags. Absent ⇒ `/pair-process-refine-story` warns and continues without a matrix (graceful degradation — never HALTs on classify's absence). -**Composed by `/pair-process-review`** (review, Phase 1): invoked with `$context: review` against the PR diff. Produces the review-time matrix (confirm-or-raise, never lower). The `/pair-capability-assess-security` verdict (Phase 2) feeds the **Security relevance** dimension and the `/assess-coupling` verdict feeds **Coupling balance** — both raise-only, consistent with the review floor. Any raise to `risk:red` is surfaced for `/pair-process-review`'s own decision step; `classify` never blocks or merges — it has no such authority. +**Composed by `/pair-process-review`** (review, Phase 1): invoked with `$context: review` against the PR diff. Produces the review-time matrix (confirm-or-raise, never lower). The `/pair-capability-assess-security` verdict (Phase 2) feeds the **Security relevance** dimension and the `/pair-capability-assess-coupling` verdict feeds **Coupling balance** — both raise-only, consistent with the review floor. Any raise to `risk:red` is surfaced for `/pair-process-review`'s own decision step; `classify` never blocks or merges — it has no such authority. **Invoked directly**: classify a single card or PR on demand; same algorithm, `$context` auto-detected. @@ -173,7 +173,7 @@ Review raw max = red (Change/diff risk, Security relevance), so `max(red, yellow See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/graceful-degradation.md) (guideline missing → minimal run, ask directly; adoption file missing → run against KB defaults; PM tool unreachable → matrix returned to caller, tagging deferred). Additional cases: - **`/pair-capability-assess-security` not available** (review): fall back to the path-heuristic security relevance (quality-model §3.1) instead of the verdict; note the fallback. -- **`/assess-coupling` not installed / no DDD artifacts**: coupling dimension "not assessed" (never a HALT). +- **`/pair-capability-assess-coupling` not installed / no DDD artifacts**: coupling dimension "not assessed" (never a HALT). - **`/pair-process-refine-story` or `/pair-process-review` absent as caller**: run standalone via direct invocation. ## Notes diff --git a/.claude/skills/pair-process-review/SKILL.md b/.claude/skills/pair-process-review/SKILL.md index dfd413d7..7275ad7a 100644 --- a/.claude/skills/pair-process-review/SKILL.md +++ b/.claude/skills/pair-process-review/SKILL.md @@ -98,7 +98,7 @@ Ask: _"Proceed with review?"_ 1. **Check**: Has `/pair-capability-classify` already run with `$context: review` on the current PR head commit? 2. **Skip**: If already run — reuse the matrix + tier, move to Phase 2. If `/pair-capability-classify` is not installed → warn (`/pair-capability-classify not installed — no review-time risk matrix`) and move to Phase 2. -3. **Act**: Compose `/pair-capability-classify` with `$context: review` against the PR diff. It applies the [quality model](../../../.pair/knowledge/guidelines/quality-assurance/quality-model.md) to the diff footprint, reads the story's refinement-time tier, and produces the review matrix as a **floor** — it confirms or **raises** the tier, and **never lowers** it (D17). The Security relevance and Coupling balance dimensions are reconciled in Phase 2 (Step 2.4) as `/pair-capability-assess-security` / `/assess-coupling` verdicts land — raise-only. +3. **Act**: Compose `/pair-capability-classify` with `$context: review` against the PR diff. It applies the [quality model](../../../.pair/knowledge/guidelines/quality-assurance/quality-model.md) to the diff footprint, reads the story's refinement-time tier, and produces the review matrix as a **floor** — it confirms or **raises** the tier, and **never lowers** it (D17). The Security relevance and Coupling balance dimensions are reconciled in Phase 2 (Step 2.4) as `/pair-capability-assess-security` / `/pair-capability-assess-coupling` verdicts land — raise-only. 4. **Verify**: The review matrix + `risk:*` tier are recorded on the PR (matrix in the body as 1 line + `
`, D22; tags applied only when a `## Tag Projection` is declared). This body matrix is the single rendered artifact — if Phase 2 raises Security relevance or Coupling balance, `/pair-process-review` updates it **in place** (Step 2.4), it is not re-emitted by `/pair-capability-classify`. A raise to `risk:red` is carried into the Step 5.2 decision. `/pair-capability-classify` HALTs only if the quality model doc (#221) is absent. ## Phase 2: Technical Review diff --git a/packages/knowledge-hub/dataset/.skills/capability/assess-coupling/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/assess-coupling/SKILL.md index 3014282a..2126ce95 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/assess-coupling/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/assess-coupling/SKILL.md @@ -115,7 +115,7 @@ For each relationship, assign **all three** per the guideline: ### Diff Scope ```text -COUPLING ASSESSMENT (composed by /review — no files written): +COUPLING ASSESSMENT (composed by the review process — no files written): ├── Scope: Diff ├── Verdict: [balanced | tolerable | significant | critical] — [1-line summary] ├── Findings: [N total — N critical, N significant, N tolerable] @@ -137,9 +137,9 @@ COUPLING ASSESSMENT (composed by /review — no files written): COUPLING AUDIT COMPLETE: ├── Scope: [whole codebase | $area] ├── Mapped: [N integrations] -├── Flagged: [N unbalanced+volatile — N critical, N significant, N tolerable] +├── Flagged: [N unbalanced — N critical, N significant, N tolerable] ├── Report: [.pair/working/reports/architecture/ — written] -├── Tech-debt: [N tuples handed to caller for /write-issue (tech-debt label, #224)] +├── Tech-debt: [N tuples handed to caller for the write-issue pipeline (tech-debt label, #224)] └── Basis: [KB model | + subdomain/boundedcontext adoption | volatility estimated (no DDD)] ``` From 883307b080a96147d1d213c8a5e72f43f67f624f Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 25 Jul 2026 10:57:07 +0200 Subject: [PATCH 8/8] [#352] fix: raise hookTimeout for the pipeline beforeAll (flaky under full turbo run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror-equality beforeAll runs the real copy pipeline over the whole dataset (temp-dir I/O per skill). Passing standalone but timing out at vitest's 10s default when the suite shares the machine with the full turbo test fan-out — the pre-push gate failed on it. Explicit 120s hookTimeout. Co-Authored-By: Claude --- packages/knowledge-hub/src/tools/skill-md-mirror.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index 5a98a49b..9cc0b6ed 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -44,9 +44,12 @@ describe('per-skill SKILL.md dataset -> root mirror equality (data-driven)', () const skillDirs = datasetSkillDirs(tree) let installed: Map + // Runs the real copy pipeline over the whole dataset (temp-dir I/O for every + // skill), so it needs more than vitest's 10s default hookTimeout — the suite + // shares the machine with the rest of the turbo test fan-out. beforeAll(async () => { installed = await buildInstalledSkillMd(tree) - }) + }, 120_000) it('discovers dataset skill dirs directly from disk (no hardcoded count)', () => { expect(skillDirs.length).toBeGreaterThan(0)