[US-407] fix: a skill's nested references/ installs inside it — bounded flatten + link rebase + config wiring - #411
Conversation
Verdict
PR: [#411] · Author: @rucka · Reviewer: independent reviewer (Claude Opus 5) · Date: 2026-07-31 · Story: [US-407] · Type: bug Classification matrix — per dimension
Tier = max(assessed) = yellow, confirming the story's refinement-time AssessmentsSecurity — Input validationVerdict: yellow — the only new input is Details
Security — Output handlingVerdict: yellow — the preserved tail is written into a filesystem path without containment checks. Details
Security — AuthenticationVerdict: green — no authentication surface in the diff. DetailsPure path/string transforms plus local filesystem copy. No credentials, tokens, or remote calls. Security — AuthorizationVerdict: green — no access-control surface in the diff. DetailsNo authorization decision is made or changed. Write targets remain the registry's configured targets. Security — Introduced vulnerabilitiesVerdict: yellow — [1 introduced (not exploitable via any current call path), 0 pre-existing] Details
No red introduced finding, so the introduced-red-security rule (#227/AC4) does not fire. The CHANGES-REQUESTED verdict comes from the unmet AC and the missing ADR, not from security. CostVerdict: Details
Architecture (Coupling)Verdict: yellow — one concept, two implementations, and only one of them learned the new rule. Details
DetailsFindings by severityCritical (must fix before merge) none Major (should fix before merge)
Minor (consider)
Questions
Positive feedback
Functionality & requirements (AC coverage)Story #407 is an unrefined (Todo) tech-debt story with no Given-When-Then AC; its Story Statement + "Likely In Scope" + DoD are the effective criteria.
Testing & quality gates
Adoption compliance
Tech debtSurfaced, never blocking on debt grounds alone:
Documentation
Performance & deployment
Independence note: reviewed from story #407, the PR diff/description, and the branch code only, in a detached throwaway worktree at |
…ly (review round 2) flattenDepth assumes every entry sits at the same depth; `.skills/next` is a ONE-segment entry beside 39 two-segment ones, so `next/references` had the shape of a real entry and would install as the sibling `pair-next-references` — the whole defect back for that skill, blast radius including AGENTS.md via the skill-name map. Unrepresentable, so copyDirectoryWithTransforms now rejects the shape (a dir shallower than flattenDepth that holds files directly AND owns a sub-dir) before copying anything. Category vs entry told apart by "holds files directly" — no SKILL.md knowledge, per ADR-020's coupling argument. Also from the review: - link-rewriter: most-specific moved-dir match now runs BEFORE the own-dir rebase, so a forward link into a sub-dir that moved elsewhere is rewritten too (own-dir first left `./references/deep.md` dead under an unbounded flatten) - mirror cleanup descends into a bounded entry, so a `references/` removed from the source stops staying installed forever; unbounded path unchanged, gated - naming-transforms: typed IO_ERROR instead of bare Error; depth validated before the empty-path early return; `isValidFlattenDepth` shared with the CLI boundary so the two enforcement points cannot drift - resolveAbsoluteTarget JSDoc: three stages, not two - ADR-020 Trade-offs + nested-sub-documents.md (both copies): the exclusion Refs: #407, PR #411
…es re-anchored, wrappers deduped Guard (pre-push-gate-composition.ts): - expansion tolerates runner flags and npm/yarn: `pnpm -s format`, `pnpm -w format`, `npm run format` all reached the write-mode formatter with a green guard, because the captured "script name" was the flag and the body was never scanned. - offender list made symmetric per tool: bin alias / .sh entrypoint / raw CLI write flag (`prettier-fix`, `markdownlint-fix`, `prettier --write`, `markdownlint --fix`) — the prettier `.sh` form used to walk straight past a list that named markdownlint's. - guard-present check requires RUNNING it (`referencesScript`), so `echo gate:composition` no longer satisfies it. - docstring: offenders come back in the offender list's order, not the gate's. Wrappers: - ignore assembly extracted per tool (`bin/_ignore-args.sh`, `bin/_ignore-file.sh`); check and fix now share one source of the invariant instead of four copies. - prettier args assembled positionally, so a repo path containing a space no longer word-splits into a bogus pattern (prettier exited 2 on every push for that contributor). - root .gitignore re-anchored to the cwd for markdownlint (`_reanchor-gitignore.awk`): it resolves patterns against the cwd, git against the ignore file's dir, so a path-anchored entry (`apps/website/gen/`) would have blocked every push from that package. - cwd/git-root de-dup compares canonical paths (`pwd -P`), same structure in both tools. Coverage + caching: - new smoke test `format-ignore-delegation.sh` (both tools, check and fix, gitignored vs not, plus a path with a space) — verified RED against the pre-fix wrappers; wired into the CI list. - turbo.json `globalDependencies`: the ignore sources are inputs of cacheable tasks, so a .gitignore edit no longer replays a stale PASS/FAIL. ADL: Context now cites the incidents actually observed (PRs #388, #408, #411 — three in two days, with story+branch each) and records the re-anchoring, the shared helpers, the smoke coverage and the globalDependencies. Refs: #394
This comment has been minimized.
This comment has been minimized.
… installs inside it Test-first, per the project's bug workflow: the failing cases were written and verified RED (`process-review-references` instead of `process-review/references`) before any code changed. `flattenPath` replaced EVERY separator with a hyphen, so a skill's nested dir became a sibling pseudo-skill: `process/review/references/deep.md` installed at `pair-process-review-references/deep.md`. The first skill to use the standard Agent-Skills `references/` progressive-disclosure layout would install unusable. `flattenDepth` bounds flattening to the registry's ENTRY granularity: the first N segments are joined, deeper ones stay a real sub-path. The skills registry's entries are two segments (`process/review`), so a third is content OF that skill. Omitted ⇒ every separator is flattened, exactly as before — no other registry changes behaviour, which is why this is not a blanket fix to flattenPath. Threaded through SyncOptions -> TransformOpts -> transformPath, so the copy pipeline honours it rather than only the pure path function. - 8 unit cases on flattenPath/transformPath, including a REGRESSION WITNESS that pins the old unbounded behaviour (a bounded-depth bug must not silently become the default for everyone else) - 1 end-to-end case through copyPathOps: the nested dir lands inside the skill, NOT as a sibling, and the skill's forward link needs no rewrite because both files move together HALF THE STORY, AND IT SAYS SO. #407's AC asks for working links in BOTH directions. The sub-doc's back-link `../SKILL.md` is still mangled to `../../../source/process/review/SKILL.md`: `rebaseWithinMovedDir` only rebases targets INSIDE the file's own directory, and `../SKILL.md` points at the PARENT, so it falls through to the source-root fallback. Fixing it means anchoring the rebase at the moved ENTRY rather than at the file's directory — a signature change to the rewriter and its callers, deliberately not rushed. Recorded as an `it.todo` naming the exact cause, not left as a silent gap. `pnpm quality-gate` green. 641 tests in content-ops, 49 files. Refs #407 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate's prettier:fix rewrote apps/pair-cli/src/commands/kb-info/version-check*.test.ts — pre-existing drift on main, nothing to do with this story — and `git add -A` caught them. Reverted to origin/main: that drift belongs to #394 (pre-push gate should run the formatters in write-mode), not to a flatten fix. Refs #407 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (link half) Completes #407. Test-first: both cases were written and verified RED before the rewriter changed. `rebaseWithinMovedDir` only rebased targets INSIDE the file's own directory. A sub-doc's link UP to its skill (`../SKILL.md`) points at the PARENT, so it fell through to the source-root fallback and came out as `../../../source/process/review/SKILL.md` — a path back into the dataset layout, dead in an install. `movedDirs` carries every directory this copy moved, built once per batch and threaded rewriteLinksAfterTransform -> rewriteLinksInFile -> computeNewHref -> resolveAbsoluteTarget. A target outside the file's own directory is now rebased through whichever moved directory contains it, most specific first. Absent, the behaviour is unchanged. THE DEFECT WAS NOT LIMITED TO NESTED references/. The second test proves it on the UNBOUNDED flatten that ships today: a sub-doc becoming a sibling had its back-link re-rooted into `source/` too. #407 described a latent defect; it is partly a live one. Live instance found by the mirror-equality guard, which flagged a skill I never touched: `.claude/skills/pair-process-plan-epics/SKILL.md` shipped `[map-subdomains](../../../.skills/capability/map-subdomains/SKILL.md)` — a `.skills/` path that does not exist in an installed project. The corrected transform emits `../pair-capability-map-subdomains/SKILL.md`, pointing at the installed sibling. Regenerated: one line, one file. `computeNewHref` exceeded the complexity ceiling once `movedDirs` was threaded; split via `splitRewritableHref` rather than raising the limit. Caught by the gate, not by vitest — which transpiles without type-checking, and had also let an un-extended inline param type through as green. `pnpm quality-gate` green. content-ops 644 tests / 49 files; the mirror guard 54. Closes #407 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…und 1) Bounded flatten made a skill's `references/` a real sub-path, so it reached `buildSkillNameMap`/`buildSkillLinkPathMap`/the frontmatter sync as if it were a skill dir: `references` got registered as a SKILL NAME mapped to one arbitrary skill's sub-dir (last writer wins on dir iteration order), and the `/references` token in an UNRELATED skill's body was rewritten to it. Test-first: 4 cases verified RED first. `isRegistryEntryPath` is the one predicate — flattenDepth IS the entry granularity, so anything deeper is content. Absent depth ⇒ every dir is an entry, the pre-#407 behaviour unchanged. Also, per review: - `flattenPath` throws on a non-positive-integer depth (0/-1/1.5 used to degrade silently, in the direction that reintroduces the bug) and refuses a `.`/`..` segment in the preserved tail (`process/review/../../../../etc` joined onto the dest root resolved to `/etc`; the unbounded form was traversal-safe by construction). - one name for the concept: `maxDepth` -> `flattenDepth` everywhere. - `buildSkillNameMap`/`buildSkillLinkPathMap` take the full `TransformOpts` (the narrow declared type lied about reading `flattenDepth`); `TransformOpts` now lives next to `transformPath` and copy-types re-exports it. - `rebaseWithinMovedDirs` picks the longest match in one pass instead of re-sorting per link; stale `rebaseWithinMovedDir` scope paragraph rewritten for the two-stage resolution; misplaced `computeNewHref` JSDoc moved back onto it. Refs #407
…iew round 1) The fix was unreachable for the product: `normalizeRegistryConfig` never read `raw['flattenDepth']`, `RegistryConfig` never declared it, `buildCopyOptions` never set it — so update/install/package always ran unbounded and a nested `references/` still installed as the sibling `pair-process-review-references/`. `"flattenDepth": 2` in config.json was silently dropped. - resolver: `RegistryConfig.flattenDepth`, carried through uncoerced. - validation: `validateFlattenDepthField` — positive integer, and rejected without `flatten: true`. A JSON typo now fails loudly instead of degrading back into the defect. - operations: `buildCopyOptions` forwards it. - config.json: `flattenDepth: 2` on the `skills` registry (its entries are `<category>/<name>`). - skill-md-mirror: `SKILL_COPY_OPTS` + module docstring re-pointed at the corrected mapping (the story lists this as in scope); the pin test now asserts flattenDepth against config.json too. Unchanged output for every current skill — no dataset skill dir is deeper than two segments. - skills-guide-mirror: reuses the single `SKILL_COPY_OPTS` instead of re-typing the knobs, so its `collectSkillDirs` (ANY dir holding a file) cannot register a `references/` dir as a skill. CLI-level coverage through `pair update`: nested dir installs inside the skill with both links intact; without the option the same dataset still produces the pre-#407 sibling layout; a `flattenDepth: 0` config is rejected. Refs #407
…onvention The story flags this as ADR-worthy and CLAUDE.md makes it a HALT condition: the reasoning lived only in the PR body and code comments while ADR-005 still documents flatten as FULL flattening because "AI tools expect skills in flat directory structures". ADR-020 records the numeric-depth form chosen and, on the record, the three alternatives rejected: registry-wide depth-1 (wrong for a two-segment entry), `preserveNested: true` (a flag that still has to infer the entry), and "the dir containing SKILL.md is the entry" — the semantically sharpest option, rejected on COUPLING: it would leak skills-domain knowledge into a transform four non-skill registries share, and make the installed path depend on file discovery rather than declared config. Also records the opt-in default, the duplicated-number trade-off, and the entry-vs-content corollary. ADR-005 amended in place (status, Key Design Choice #2, the Flatten/prefix rationale bullet, the config listing) so it no longer reads as authority for the old semantic. DoD's documentation item: `nested-sub-documents.md` states the authoring convention — a skill may ship a nested `references/`, how it installs, that relative links work in both directions, and that a sub-doc is content (no prefix, no `name:` sync, not invocable). Written generic/portable per that directory's Scope note, so it cites no ADR or issue back; dataset source + root mirror. Closes #407
…ly (review round 2) flattenDepth assumes every entry sits at the same depth; `.skills/next` is a ONE-segment entry beside 39 two-segment ones, so `next/references` had the shape of a real entry and would install as the sibling `pair-next-references` — the whole defect back for that skill, blast radius including AGENTS.md via the skill-name map. Unrepresentable, so copyDirectoryWithTransforms now rejects the shape (a dir shallower than flattenDepth that holds files directly AND owns a sub-dir) before copying anything. Category vs entry told apart by "holds files directly" — no SKILL.md knowledge, per ADR-020's coupling argument. Also from the review: - link-rewriter: most-specific moved-dir match now runs BEFORE the own-dir rebase, so a forward link into a sub-dir that moved elsewhere is rewritten too (own-dir first left `./references/deep.md` dead under an unbounded flatten) - mirror cleanup descends into a bounded entry, so a `references/` removed from the source stops staying installed forever; unbounded path unchanged, gated - naming-transforms: typed IO_ERROR instead of bare Error; depth validated before the empty-path early return; `isValidFlattenDepth` shared with the CLI boundary so the two enforcement points cannot drift - resolveAbsoluteTarget JSDoc: three stages, not two - ADR-020 Trade-offs + nested-sub-documents.md (both copies): the exclusion Refs: #407, PR #411
… close the depth-1 traversal hole Two findings from the round-3 review, both real defects this PR introduced. MAJOR — the round-2 guard was asymmetric. It rejected an entry SHALLOWER than flattenDepth and let the deeper case through silently: `capability/sub/foo/SKILL.md` installed at `pair-capability-sub/foo/SKILL.md` — a pseudo-entry directory with NO SKILL.md at its root, invisible to the skill loader. Two further defects followed silently because isRegistryEntryPath reports false for it: the frontmatter `name:` stayed unsynced, and no entry reached skillNameMap, leaving a `/foo` reference in an unrelated skill dangling. Injection-verified: with the new guard disabled the test resolves instead of rejecting, and skillNameMap comes out with ONE entry instead of two. This was a REGRESSION, not an uncovered edge — full flattening produced a perfectly usable `pair-capability-sub-foo/` for the same source. Neither CI gate catches it: skills:conformance only walks <cat>/<sub>/SKILL.md so the file is never read, and the #384 mirror guard derives the installed path from the same transformPath, so it compares equal and passes. `validateNoDeepEntry` uses the shape data already collected, with no SKILL.md knowledge (ADR-020's coupling argument): a directory deeper than flattenDepth is content IFF its nearest ancestor at that depth also holds files directly — that ancestor is the entry owning it. So `process/review/references` still passes (its depth-2 ancestor holds files) while `capability/sub/foo` fails (`capability/sub` holds none). Both cases are tested. MINOR — the traversal guard checked only the preserved tail, and the argument that the head is safe ("every separator becomes a hyphen") fails at flattenDepth 1, where the head is a single un-joined segment: `flattenPath('../evil', 1)` returned `'../evil'`. The bounded form was therefore strictly LESS traversal-safe than the unbounded one it replaces, in exactly the dimension it advertises as hardened. Now every segment is validated, including in the `segments.length <= flattenDepth` branch. Not reachable through the CLI today, which is why it was a hole rather than a live bug. Reverted a cosmetic rewording of the guard's message ("keep" for "preserve") that broke two pre-existing tests — not worth the churn. `pnpm quality-gate` green. content-ops 62 naming-transform cases, 28 copy cases. Refs #407 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…transform `main` went red the moment #387 and #406 were both on it, and neither PR was wrong. #387 (brainstorm's new `resume.md` / `parametrization.md` siblings) was green before #406 widened the mirror-equality guard beyond `SKILL.md`; #406 was green before those siblings existed. Merging them in EITHER order produces this failure, so no single PR's CI could have caught it. The cause is mine: I hand-ported those siblings to the root mirror in #387 instead of regenerating them through the real transform. - `parametrization.md` — a missing blank line before `## $domain-placed`. - `resume.md` — SUBSTANTIVE, not cosmetic. The hand-port applied the skill-reference rewrite to a FILE NAME, shipping the handoff key as `.pair/working/pair-process-brainstorm-<root-id | theme-slug>.md` where the dataset says `.pair/working/brainstorm-<root-id | theme-slug>.md`. The real transform does not rewrite it — it is a working-file name, not a skill reference — so the shipped mirror documented a different handoff key than its authoring source. A resume reading one and a write using the other never meet. - `SKILL.md` + the dataset twin — emphasis style. Surfaced only while fixing the above, and its cause is the defect #394 fixes: the gate's `mdlint:fix` runs in WRITE mode, so my own gate run rewrote the DATASET's emphasis markers, leaving the mirror behind. The manual gate passed because it was reformatting as it ran; the pre-push hook then compared the rewritten dataset against the untouched mirror. Kept the gate's formatting (it is this repo's authority) and realigned the mirror. Third time today a hand-ported mirror drifted from the transform (#408 by one `./`, #411 likewise). The lesson is applied on the open branches: regenerate, never hand-port. `pnpm quality-gate` green — knowledge-hub 715 tests, mirror guard 82. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y it can prove Finishes the rebase onto the merged #406. Two fixture properties turned out to need OPPOSITE entry depths, which is why a single fixture could not carry both. - The **link-depth rewrite** is only observable with a ONE-segment entry: `demo/` sits 2 levels below the dataset root while `pair-demo/` sits 3 below the mirror root, so the link up must be recomputed. With a two-segment entry both sides are 3 levels and the rewriter correctly leaves the link untouched — a degenerate case that proves nothing. I moved this fixture to two segments earlier in the rebase and silently destroyed the assertion's purpose; it is back on one segment, with the reason stated in the test. - The **nested `references/`** case needs a TWO-segment entry, because a one-segment entry owning a sub-directory is refused outright by the shallow-entry guard — and an entry deeper than the flatten depth by the guard added in round 3. So it gets its own `catalog/nested` entry. Also inverted the two assertions that pinned the OLD flatten mapping. They said so by name — "as the pipeline does today (#407)" — and were written knowing this PR would invert them: the nested sub-dir is now expected INSIDE its skill, with the sibling shape asserted unreachable. The rebase conflict itself was in the same file: #406 added `behavior` to the registry-pin test while this PR added `flattenDepth`. Both kept. `pnpm quality-gate` green — mirror suite 78, content-ops 649. Refs #407
37015e3 to
bf76aa6
Compare
…ated + enforced Review round 4 on PR #411 (11 findings, all resolved, none escalated). - website reference: flattenDepth row, bounded-flatten section, skills example (+ stale behavior mirror->overwrite there and in ADR-005 #5) - guard rule documented AS ENFORCED (a category dir with a file hits it too), remediation names that way out, child depth no longer mis-interpolated - buildTransformOpts drops flattenDepth when flatten is false — one source of truth for "bounded?", no unbounded transform + bounded classification - traversal guard applied by SHAPE: a single segment ('..') is refused in BOTH forms, so bounded is neither less nor more safe than unbounded - skills:conformance fails on a SKILL.md below the entry depth (authoring rule 1 of nested-sub-documents was stated but unenforced) - .pair/llms.txt regenerated (nested-sub-documents + pre-existing coupling-balance) - nested mirror cleanup labelled forward-compat, ACCEPTED RESIDUAL in ADR-020 - layout validators extracted to copy/layout-validation.ts - misattributed test comment moved onto its real subject; stale #407 caveats in skill-md-mirror corrected Refs #407
…pth, guard asymmetry) - too-deep error: second way out qualified as content-only; an entrypoint inside content installs as content, pinned by test (consumers have no conformance gate) - website Bounded Flattening: mixed depth is workable — shallower entry aborts only once it owns a sub-dir; managing-ai-artifacts examples gain flattenDepth: 2 - flattenPath docstring/ADR-020: real invariant is 'each form checked where its own output can carry a live ..' — not equal strictness; tail check is stricter deliberately, do not 'restore symmetry' - nested-sub-documents: heading + lead reworded to the enforced rule, new rule 5 (link a sub-doc only from within its skill — cross-registry maps entrypoints only) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-first: the guard was written and verified RED against the repo's own package.json before the gate changed. The gate ran `prettier:fix` and `mdlint:fix` REPO-WIDE in write mode, and the gate is the pre-push hook. The decisive problem is not noise, it is uselessness: at pre-push THE COMMITS ALREADY EXIST, so a write-mode formatter rewrites the working tree and cannot fix what is being pushed. Its output goes nowhere unless the author notices and amends — so the author either sweeps unrelated reformats into the next commit or pushes with `--no-verify`, and once bypassing is routine the hook asserts nothing. Observed three times in two days: #388 was pushed with --no-verify after the hook reformatted two unrelated pair-cli test files; the same two files were swept into #411 by a `git add -A` and had to be reverted; and they were excluded by hand from #408. - `format` — the explicit fix command (was the gate's write-mode step) - `format:check` — what the gate runs now - `gate:composition` — a tested module that reads the root package.json and fails if a write-mode formatter is ever put back. The regression is a one-word edit away and its symptom looks like author error rather than tooling behaviour, which is why a comment would not have been enough. - `**/.source/` prettier-ignored: generated by fumadocs-mdx at postinstall and already gitignored. In write mode the gate silently rewrote it on every push; in check mode it would have BLOCKED every push. A build artifact must not be able to do either. Includes the one-time sweep check mode requires: the two pair-cli version-check test files are formatted here. Sweeping unrelated files in the PR that forbids sweeping them is deliberate — the drift has to be cleared once for check mode to be viable, and after this the gate cannot produce such sweeps. Guard logic in a tested module under packages/dev-tools/src/quality-gates/ with a thin CLI entrypoint, per ADL 2026-07-13. The offender list is explicit rather than a `/:fix/` pattern: `lint:fix` is an eslint autofix, a different concern, and is asserted NOT to trip the guard. `pnpm quality-gate` green, and it now prints "✓ pre-push gate composition: check-mode only". Closes #394 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es re-anchored, wrappers deduped Guard (pre-push-gate-composition.ts): - expansion tolerates runner flags and npm/yarn: `pnpm -s format`, `pnpm -w format`, `npm run format` all reached the write-mode formatter with a green guard, because the captured "script name" was the flag and the body was never scanned. - offender list made symmetric per tool: bin alias / .sh entrypoint / raw CLI write flag (`prettier-fix`, `markdownlint-fix`, `prettier --write`, `markdownlint --fix`) — the prettier `.sh` form used to walk straight past a list that named markdownlint's. - guard-present check requires RUNNING it (`referencesScript`), so `echo gate:composition` no longer satisfies it. - docstring: offenders come back in the offender list's order, not the gate's. Wrappers: - ignore assembly extracted per tool (`bin/_ignore-args.sh`, `bin/_ignore-file.sh`); check and fix now share one source of the invariant instead of four copies. - prettier args assembled positionally, so a repo path containing a space no longer word-splits into a bogus pattern (prettier exited 2 on every push for that contributor). - root .gitignore re-anchored to the cwd for markdownlint (`_reanchor-gitignore.awk`): it resolves patterns against the cwd, git against the ignore file's dir, so a path-anchored entry (`apps/website/gen/`) would have blocked every push from that package. - cwd/git-root de-dup compares canonical paths (`pwd -P`), same structure in both tools. Coverage + caching: - new smoke test `format-ignore-delegation.sh` (both tools, check and fix, gitignored vs not, plus a path with a space) — verified RED against the pre-fix wrappers; wired into the CI list. - turbo.json `globalDependencies`: the ignore sources are inputs of cacheable tasks, so a .gitignore edit no longer replays a stale PASS/FAIL. ADL: Context now cites the incidents actually observed (PRs #388, #408, #411 — three in two days, with story+branch each) and records the re-anchoring, the shared helpers, the smoke coverage and the globalDependencies. Refs: #394
Escalating to human — non-convergence, round 6Re-review of head Rounds so far (all in
|
…docs/ADR wording - resolver: '!== undefined' so an explicit null is rejected by name instead of silently restoring the unbounded flatten (+ test). - adr-020: the depth lead-in claimed every deviation aborts; neither guard fires on depth alone. - skill-md-mirror docstring: drop the pre-#407 unbounded example stated in the present tense. - layout-validation docstring: name the real audience of the qualified remedy. - configuration.mdx: flattenDepth in the validation list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-first: the guard was written and verified RED against the repo's own package.json before the gate changed. The gate ran `prettier:fix` and `mdlint:fix` REPO-WIDE in write mode, and the gate is the pre-push hook. The decisive problem is not noise, it is uselessness: at pre-push THE COMMITS ALREADY EXIST, so a write-mode formatter rewrites the working tree and cannot fix what is being pushed. Its output goes nowhere unless the author notices and amends — so the author either sweeps unrelated reformats into the next commit or pushes with `--no-verify`, and once bypassing is routine the hook asserts nothing. Observed three times in two days: #388 was pushed with --no-verify after the hook reformatted two unrelated pair-cli test files; the same two files were swept into #411 by a `git add -A` and had to be reverted; and they were excluded by hand from #408. - `format` — the explicit fix command (was the gate's write-mode step) - `format:check` — what the gate runs now - `gate:composition` — a tested module that reads the root package.json and fails if a write-mode formatter is ever put back. The regression is a one-word edit away and its symptom looks like author error rather than tooling behaviour, which is why a comment would not have been enough. - `**/.source/` prettier-ignored: generated by fumadocs-mdx at postinstall and already gitignored. In write mode the gate silently rewrote it on every push; in check mode it would have BLOCKED every push. A build artifact must not be able to do either. Includes the one-time sweep check mode requires: the two pair-cli version-check test files are formatted here. Sweeping unrelated files in the PR that forbids sweeping them is deliberate — the drift has to be cleared once for check mode to be viable, and after this the gate cannot produce such sweeps. Guard logic in a tested module under packages/dev-tools/src/quality-gates/ with a thin CLI entrypoint, per ADL 2026-07-13. The offender list is explicit rather than a `/:fix/` pattern: `lint:fix` is an eslint autofix, a different concern, and is asserted NOT to trip the guard. `pnpm quality-gate` green, and it now prints "✓ pre-push gate composition: check-mode only". Closes #394 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es re-anchored, wrappers deduped Guard (pre-push-gate-composition.ts): - expansion tolerates runner flags and npm/yarn: `pnpm -s format`, `pnpm -w format`, `npm run format` all reached the write-mode formatter with a green guard, because the captured "script name" was the flag and the body was never scanned. - offender list made symmetric per tool: bin alias / .sh entrypoint / raw CLI write flag (`prettier-fix`, `markdownlint-fix`, `prettier --write`, `markdownlint --fix`) — the prettier `.sh` form used to walk straight past a list that named markdownlint's. - guard-present check requires RUNNING it (`referencesScript`), so `echo gate:composition` no longer satisfies it. - docstring: offenders come back in the offender list's order, not the gate's. Wrappers: - ignore assembly extracted per tool (`bin/_ignore-args.sh`, `bin/_ignore-file.sh`); check and fix now share one source of the invariant instead of four copies. - prettier args assembled positionally, so a repo path containing a space no longer word-splits into a bogus pattern (prettier exited 2 on every push for that contributor). - root .gitignore re-anchored to the cwd for markdownlint (`_reanchor-gitignore.awk`): it resolves patterns against the cwd, git against the ignore file's dir, so a path-anchored entry (`apps/website/gen/`) would have blocked every push from that package. - cwd/git-root de-dup compares canonical paths (`pwd -P`), same structure in both tools. Coverage + caching: - new smoke test `format-ignore-delegation.sh` (both tools, check and fix, gitignored vs not, plus a path with a space) — verified RED against the pre-fix wrappers; wired into the CI list. - turbo.json `globalDependencies`: the ignore sources are inputs of cacheable tasks, so a .gitignore edit no longer replays a stale PASS/FAIL. ADL: Context now cites the incidents actually observed (PRs #388, #408, #411 — three in two days, with story+branch each) and records the re-anchoring, the shared helpers, the smoke coverage and the globalDependencies. Refs: #394
Remediation — round 4
Head:
Round-3 carry-overs, re-checked rather than assumed: |
A 'git add -A' during conflict resolution staged operations.ts with markers still in it. Both hunks are wanted: flattenDepth (from #411, now on main) and exclude. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-first: the guard was written and verified RED against the repo's own package.json before the gate changed. The gate ran `prettier:fix` and `mdlint:fix` REPO-WIDE in write mode, and the gate is the pre-push hook. The decisive problem is not noise, it is uselessness: at pre-push THE COMMITS ALREADY EXIST, so a write-mode formatter rewrites the working tree and cannot fix what is being pushed. Its output goes nowhere unless the author notices and amends — so the author either sweeps unrelated reformats into the next commit or pushes with `--no-verify`, and once bypassing is routine the hook asserts nothing. Observed three times in two days: #388 was pushed with --no-verify after the hook reformatted two unrelated pair-cli test files; the same two files were swept into #411 by a `git add -A` and had to be reverted; and they were excluded by hand from #408. - `format` — the explicit fix command (was the gate's write-mode step) - `format:check` — what the gate runs now - `gate:composition` — a tested module that reads the root package.json and fails if a write-mode formatter is ever put back. The regression is a one-word edit away and its symptom looks like author error rather than tooling behaviour, which is why a comment would not have been enough. - `**/.source/` prettier-ignored: generated by fumadocs-mdx at postinstall and already gitignored. In write mode the gate silently rewrote it on every push; in check mode it would have BLOCKED every push. A build artifact must not be able to do either. Includes the one-time sweep check mode requires: the two pair-cli version-check test files are formatted here. Sweeping unrelated files in the PR that forbids sweeping them is deliberate — the drift has to be cleared once for check mode to be viable, and after this the gate cannot produce such sweeps. Guard logic in a tested module under packages/dev-tools/src/quality-gates/ with a thin CLI entrypoint, per ADL 2026-07-13. The offender list is explicit rather than a `/:fix/` pattern: `lint:fix` is an eslint autofix, a different concern, and is asserted NOT to trip the guard. `pnpm quality-gate` green, and it now prints "✓ pre-push gate composition: check-mode only". Closes #394 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es re-anchored, wrappers deduped Guard (pre-push-gate-composition.ts): - expansion tolerates runner flags and npm/yarn: `pnpm -s format`, `pnpm -w format`, `npm run format` all reached the write-mode formatter with a green guard, because the captured "script name" was the flag and the body was never scanned. - offender list made symmetric per tool: bin alias / .sh entrypoint / raw CLI write flag (`prettier-fix`, `markdownlint-fix`, `prettier --write`, `markdownlint --fix`) — the prettier `.sh` form used to walk straight past a list that named markdownlint's. - guard-present check requires RUNNING it (`referencesScript`), so `echo gate:composition` no longer satisfies it. - docstring: offenders come back in the offender list's order, not the gate's. Wrappers: - ignore assembly extracted per tool (`bin/_ignore-args.sh`, `bin/_ignore-file.sh`); check and fix now share one source of the invariant instead of four copies. - prettier args assembled positionally, so a repo path containing a space no longer word-splits into a bogus pattern (prettier exited 2 on every push for that contributor). - root .gitignore re-anchored to the cwd for markdownlint (`_reanchor-gitignore.awk`): it resolves patterns against the cwd, git against the ignore file's dir, so a path-anchored entry (`apps/website/gen/`) would have blocked every push from that package. - cwd/git-root de-dup compares canonical paths (`pwd -P`), same structure in both tools. Coverage + caching: - new smoke test `format-ignore-delegation.sh` (both tools, check and fix, gitignored vs not, plus a path with a space) — verified RED against the pre-fix wrappers; wired into the CI list. - turbo.json `globalDependencies`: the ignore sources are inputs of cacheable tasks, so a .gitignore edit no longer replays a stale PASS/FAIL. ADL: Context now cites the incidents actually observed (PRs #388, #408, #411 — three in two days, with story+branch each) and records the re-anchoring, the shared helpers, the smoke coverage and the globalDependencies. Refs: #394
…context can be wrong Two gaps the maintainer pointed at. 1. Running on an EXISTING project was not a distinct case. It is: the value of pair's adoption files there is that they describe the project as it is, so installing blank templates silently is the wrong default. The preflight now classifies greenfield vs brownfield and, for brownfield, states the fact that makes the choice safe (the adoption registry installs with 'add' behaviour, so existing decisions are never overwritten) and ASKS: install only, or install and adopt — the second handing off to /pair-process-bootstrap, which owns the checklist and arrives with the install. No answer -> install only. 2. Where the context comes from is a real concern, so the weak spots are written down: .pair/llms.txt has NO gate against the KB it indexes (issue #416, filed from #411's review), so it is treated as a table of contents — an unresolvable path means walk .pair/knowledge/ and report the index stale, never guess. Plus --help is silent about conventions, and this file can be older than both the CLI and the KB, which win over it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Information
Story: #407 · Type: Bug fix · Priority: Medium (P2) · Assignee: @rucka
Classification:
risk:yellow·cost:green· coupling not assessed (excluded from the max, D21)Matrix — per dimension (review-time, D22)
tech/risk-matrix.md)pair update; latent today/pair-capability-assess-couplingnot run in this passTier = max(yellow, yellow, yellow, green) =
risk:yellow, equal to the label already applied. Cost is computed but not projected as a tag (tech/risk-matrix.md→Active: risk). Confidence: high (diff read in full).Derived acceptance criteria are recorded on #407 (the story skipped Draft→Ready — see the note there).
Summary
What Changed
flattenPathreplaced every directory separator with a hyphen, so a skill's nested directory installed as a sibling pseudo-skill:process/review/references/deep.mdlanded atpair-process-review-references/deep.md, outside the skill, with both of its relative links dead. The first skill to use the standard Agent-Skillsreferences/progressive-disclosure layout would install unusable.Three layers, all in this PR:
flattenDepthbounds flattening to the registry's entry granularity: the first N segments are joined, deeper ones stay a real sub-path. The skills registry's entries are two segments (process/review), so a third segment is content of that skill.../SKILL.mdpoints at its parent, which also moved; before this it fell through to the source-root fallback and came out as../../../source/process/review/SKILL.md, a path back into the dataset layout.config.json, validated, and threadedRegistryConfig→SyncOptions→TransformOpts→transformPath, sopair update/install/packageactually honour it."flattenDepth": 2is declared on theskillsregistry.Why This Change
The layout is not hypothetical: the third-party
agent-browserskill already shipsreferences/*.md, and #313's direction is progressive disclosure for the pair corpus. The defect is latent only because no dataset skill uses it yet — and #384's mirror guard would otherwise cement the broken layout as the expected mirror while telling the developer to runpair update.Story Context
As a maintainer installing the Pair skills I want
pair updateto install a skill's nestedreferences/inside that skill's directory, with working relative links So that the first skill using progressive disclosure installs usable.DoD coverage (#407's Definition of Done Expectations): functionality implemented ✅ · automated tests incl. an end-to-end nested-
references/fixture through the copy pipeline and throughpair update✅ · documentation updated (skill-authoring convention) ✅ · mirror guard (#384) green and its derivation re-pointed at the corrected mapping ✅.Changes Made
Implementation Details
Why not simply fix
flattenPathfor everyone: flattening every separator is correct for a registry whose entries are single-segment. Making the bounded form the default would silently change behaviour for the four other asset registries, none of which has a defect. Hence an opt-in depth, plus a regression witness test pinning the unbounded result so it cannot drift.prefixPathalready prefixed only the top-level segment, so a preserved sub-path lands under the prefixed entry with no extra work:process-review/references→pair-process-review/references.Entry vs. content. Once flatten is bounded, a sub-directory below the entry appears in the copy's directory mapping next to real entries. It is content, not a skill, so
isRegistryEntryPathexcludes it from the skill-name map, the skill-link-path map and the frontmattername:sync. Without that,referencesis registered as a skill name mapped to one arbitrary skill's sub-dir and the/referencestoken in an unrelated skill's body is rewritten to it — a cross-skill corruption decided by directory iteration order.Fails loudly, in both boundaries.
flattenDepthis validated as a positive integer at the CLI boundary andflattenPaththrows on an invalid value, because the pre-existing silent degradation went in the direction that reintroduces the bug (0/-1full-flattened;1.5flattened nothing).flattenPathalso refuses a./..segment in the preserved tail: the unbounded form was traversal-safe by construction, a preserved tail joined onto the destination root is not.One exclusion, stated and enforced (review round 2).
flattenDepthassumes every entry sits at the SAME depth, and.skills/does not:next/is a one-segment entry beside 39 two-segment ones, sonext/referenceswould have the shape of a real entry and install as the siblingpair-next-references/— the whole defect back for that one skill, with the skill-name map leakingreferencesintoAGENTS.mdand.pair/knowledgetoo. The shape is unrepresentable, not merely unhandled, socopyDirectoryWithTransformsrejects it before copying anything (a directory shallower thanflattenDepththat holds files directly AND owns a sub-directory). A category directory is told apart from an entry by "holds files directly" — noSKILL.mdknowledge, per ADR-020's coupling argument. Recorded as an ADR-020 trade-off and as the one exclusion innested-sub-documents.md.Most specific move wins (review round 2).
resolveAbsoluteTargetnow asksrebaseWithinMovedDirsBEFORE the own-directory rebase.movedDirscontains the file's own directory, so the coarser stage was subsumed; asking it first let the less specific match win and left a FORWARD link into a sub-directory that moved elsewhere (./references/deep.mdunder an unbounded flatten) pointing at nothing. Both directions are now asserted in the same test.Mirror cleanup follows the sub-path (review round 2). Top-level-only cleanup was correct while one source sub-directory mapped to one top-level target directory; under a bounded flatten it maps to a sub-PATH, so a
references/removed from the source stayed installed forever. Cleanup descends into a bounded entry, gated onflattenDepthbeing present so the unbounded path is byte-for-byte unchanged.One name, one home. The option is
flattenDepthend to end;TransformOptsmoved next totransformPath(its only consumer) andcopy/copy-types.tsre-exports it, sobuildSkillNameMap/buildSkillLinkPathMapcan declare the full type instead of a narrower one that lied about reading the depth.Files Changed
Transform + copy pipeline (
packages/content-ops)ops/naming-transforms.ts(flattenDepth,isRegistryEntryPath,isValidFlattenDepth— the predicate now shared with the CLI boundary —,TransformOpts, the two assertions, thrown as the layer's typedIO_ERROR),ops/link-rewriter.ts(movedDirs+rebaseWithinMovedDirs, longest-match single pass; two doc corrections),ops/copy/copy-directory-transforms.ts(buildTransformOpts,isEntryDir),ops/copy/copy-types.ts(re-export),ops/SyncOptions.ts,index.ts,ops/skill-reference-rewriter.tsops/copy/layout-validation.ts— the three source-layout validators +collectDirShapes, extracted from the copy module (pure shape analysis, changes for a different reason)ops/naming-transforms.test.ts(+bounded-depth, invalid-depth, traversal incl. the single-segment symmetry,isRegistryEntryPath),ops/copy/copy-directory-transforms.test.ts(+5 end-to-end incl. the two-skills cross-rewrite case),ops/skill-reference-rewriter.test.ts(+2 map-scoping cases)CLI wiring (
apps/pair-cli)config.json("flattenDepth": 2onskills),src/registry/resolver.ts,src/registry/validation.ts(validateFlattenDepthField),src/registry/operations.ts(buildCopyOptions)src/registry/{resolver,validation,operations}.test.ts,src/commands/update/handler.test.ts(+3 CLI-level cases throughpair update)Mirror guards (
packages/knowledge-hub)src/tools/skill-md-mirror.ts(SKILL_COPY_OPTS+ module docstring re-pointed at the corrected mapping — in scope per the story),src/tools/skills-guide-mirror.ts(reuses that single constant instead of re-typing the knobs),src/tools/skill-md-mirror.test.ts(pin test now coversflattenDepth),src/tools/skills-conformance-check.ts(+ its test — new entrypoint-depth check)Decision + documentation
.pair/adoption/tech/adr/adr-020-bounded-flatten-depth-entry-granularity.md;skill-conventions/nested-sub-documents.md(dataset source + root mirror).pair/adoption/tech/adr/adr-005-skills-infrastructure.md(amended; itsbehavior: "mirror"corrected to"overwrite"),skill-conventions/README.md(index row, both copies),.pair/llms.txt(regenerated),apps/website/content/docs/reference/{configuration,skill-management}.mdxRegenerated mirror
.claude/skills/pair-process-plan-epics/SKILL.md— one link, regenerated output of the corrected rewriter (plan-epics → map-subdomains now rebases through the moved sibling instead of re-rooting). Not a hand edit.Testing
Test Coverage
Test-first, per the project's bug workflow. Every behavioural change was written RED first and verified RED against branch code before the fix:
process-review-referenceswhereprocess-review/referenceswas expected);../../../source/process/review/SKILL.mdwhere../SKILL.mdwas expected);references/dir), confirmed to produce the cross-skill rewrite and the path-as-namefrontmatter before the fix.Round 2 added, same discipline:
next+next/references+ a second skill whose body contains/references) — RED as a mis-install atpair-next-references/, now a loud rejection, plus the positive case (a one-segment entry with no sub-dir installs beside two-segment ones, the real.skills/shape today);./references/deep.md;IO_ERRORname, andisValidFlattenDepthas a unit.Coverage shape: unit on
flattenPath/transformPath/isRegistryEntryPath(incl. the unbounded regression witness and the invalid/traversal rejections); end-to-end throughcopyPathOps; CLI-level throughhandleUpdateCommand— with the negative case pinned too (noflattenDepth⇒ the same dataset still installs the pre-#407 sibling layout, so the option is provably the thing doing the work).Test Results
Stated without absolute test counts on purpose: they go stale on the next commit and the body is the merge-gate evidence artifact (round-4 finding). Reproduce with
pnpm quality-gate.Quality Assurance
Review Areas
flattenDepth: 2the right expression of "entry granularity"? A numeric depth is generic but positional — it encodes a fact about the registry's layout in the caller and must be updated in step if a category level is ever added or removed. The sharper alternative ("the directory containingSKILL.mdis the entry") was rejected on coupling, not correctness. The full argument, withpreserveNestedand registry-wide depth-1 as the other two rejected options, is on record in ADR-020 rather than in this body.skills, unbounded elsewhere). Deliberate and pinned by test; recorded as a Trade-off in ADR-020.markdownlint/prettiernote:apps/pair-cli/src/commands/kb-info/version-check*.test.tscarry pre-existing prettier drift onmain. The repo-wide pre-push formatter touches them on every push; they are deliberately not in this diff.Documentation
Documentation Updates
.pair/adoption/tech/adr/adr-020-bounded-flatten-depth-entry-granularity.md— new, the bounded-flatten decision with the four options considered; amends ADR-005 §Key Design Choices Collaborative Knowledge Base #2 and its Flatten/prefix rationale bullet, which are edited in place so ADR-005 no longer reads as authority for the superseded semantic. Trade-offs record the two limitations the model carries: the duplicated depth number, and that every entry must sit at the declared depth — with the too-shallow rule stated AS ENFORCED (it covers a category directory with a file of its own, not only a registry-root skill) and the nested-mirror-cleanup descent recorded as an ACCEPTED RESIDUAL (forward-compatibility: it needsbehavior: 'mirror', andskillsisoverwrite).skill-conventions/nested-sub-documents.md— new, in BOTH copies (.pair/knowledge/…and itspackages/knowledge-hub/dataset/source, byte-identical per the mirror-equality guard): the layout, how it installs, that relative links work in both directions, that a sub-doc is content (no prefix, noname:sync, not invocable), and the two exclusions (too shallow — incl. a category directory with a file of its own — and too deep, with the exact error text). Indexed by a row in that directory'sREADME.md, both copies.resolveAbsoluteTarget, the scope note onrebaseWithinMovedDir, and the mirror-cleanup granularity docstring were all corrected to match the post-change behaviour rather than the pre-tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407 one.apps/website/content/docs/reference/configuration.mdx— aflattenDepthrow in the Registry Fields table, a new Bounded Flattening subsection under Transform Configuration (bounded vs. unbounded mapping for the same source, plus the entry/content and fail-loudly rules), and"flattenDepth": 2in the Skills Registry Example, whose stale"behavior": "mirror"is corrected to"overwrite"to matchconfig.json. Alsoreference/skill-management.mdx— the flatten step, its code snippet and the naming table now show the bounded form. Round-4 finding: this is the maintained public reference for the registry schema, so an external KB author authoring<category>/<name>entries with areferences/sub-dir could not discover the remedy. The earlier "n/a — registry config, described in ADR-020" was wrong: an ADR under.pair/adoption/tech/is internal decision history, not user documentation..pair/llms.txtregenerated for the new guideline (and the pre-existingcoupling-balance.mdgap it exposed). Uncovered drift class: nothing gates.pair/llms.txt—docs:stalenesscovers skills/commands only, and the mirror-equality guard has no dataset source for a generated file. Two independent misses suggest a gate story of its own — filed as tech-debt: no gate covers .pair/llms.txt drift — the generated KB index goes stale silently #416, not fixed here.Knowledge Sharing
adr-018is claimed by two open branches (PR state flow (gate≠review) + pair review as required check #234, Code host separate from PM tool (WoW override) — GitHub + Linear reference case #236);019is left free for whichever renumbers.skill-conventions/nested-sub-documents.md— a skill at the standard<category>/<skill-name>depth may ship a nestedreferences/, how it installs, that relative links work in both directions (so the author must not pre-compensate), that a sub-doc is content (no prefix, noname:sync, not invocable), and the two exclusions (too shallow / too deep). Authoring rule 1 ("only the entry directory holdsSKILL.md") is now enforced byskills:conformance, not just stated. Written generic/portable per that directory's Scope note, so the cross-reference runs one way only, from ADR-020.Dependencies & Related Work
'flattens a nested sub-dir into its own prefixed dir, as the pipeline does today (#407)'and'rewrites a nested reference file too (flattened into its own prefixed dir)'. They were written knowing tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407 would invert them. Once both PRs are onmain, those two assertions must be updated in the cascade rebase — they are not conflicts (different files), so nothing flags them automatically. Recorded here because file-level disjointness analysis does not catch a behavioural collision.content-opsbut onlyfile-system-service.tsand the in-memory-fs test utils — no overlap withops/.Closes #407
🤖 Generated with Claude Code
Round 3 (
018fb287,bf76aa60) — two defects this PR had introduced[Major] The layout guard was asymmetric. Round 2 rejected an entry shallower than
flattenDepthand let the deeper case through silently:capability/sub/foo/SKILL.mdinstalled atpair-capability-sub/foo/SKILL.md— a pseudo-entry directory with noSKILL.mdat its root, invisible to the skill loader. Two further defects followed silently, becauseisRegistryEntryPathreports false for it: the frontmattername:stayed unsynced, and no entry reachedskillNameMap, leaving a/fooreference in an unrelated skill dangling.This was a regression this PR introduced, not an uncovered edge — full flattening produced a perfectly usable
pair-capability-sub-foo/for the same source. And neither CI gate catches it:skills:conformanceonly walks<cat>/<sub>/SKILL.mdso the file is never read, and the #384 mirror guard derives the installed path from the sametransformPath, so it compares equal and passes.validateNoDeepEntryuses the shape data already collected, with noSKILL.mdknowledge (ADR-020's coupling argument): a directory deeper thanflattenDepthis content iff its nearest ancestor at that depth also holds files directly. Soprocess/review/referencesstill passes;capability/sub/foofails. Injection-verified — with the guard disabled the test resolves instead of rejecting, andskillNameMapcomes out with one entry instead of two.[Minor] The bounded form was less traversal-safe than the unbounded one it replaces. The guard checked only the preserved tail, and the argument that the head is safe ("every separator becomes a hyphen") fails at
flattenDepth === 1, where the head is a single un-joined segment:flattenPath('../evil', 1)returned'../evil'. Every segment is now validated. Not reachable through the CLI today, which is why it was a hole rather than a live bug.Fixture split — one entry depth per property it can prove
Discovered while rebasing onto the merged #406, and worth recording because it is easy to get backwards:
demo/is 2 levels below the dataset root,pair-demo/is 3 below the mirror root). With a two-segment entry both sides are 3 levels, the rewriter correctly leaves the link alone, and the assertion proves nothing. I moved that fixture to two segments mid-rebase and silently destroyed its purpose; it is back on one, with the reason in the test.references/case needs a two-segment entry, because a one-segment entry owning a sub-directory is refused by the shallow-entry guard. It has its owncatalog/nestedentry.Also inverted #406's two assertions that pinned the old mapping. They said so by name — "as the pipeline does today (#407)" — and were written knowing this PR would invert them.
Verification:
pnpm quality-gategreen at that head (counts deliberately not quoted — see Test Results).Round 4 (
8b357176) — the public reference, and the rule as enforced11 findings (1 Major + 8 Minor + 2 Questions), all resolved, none escalated.
[Major]
flattenDepthshipped undocumented outside this repo. The website'sreference/configuration.mdxdocuments every other registry field and never mentioned it, its Transform Configuration explained flattening as unbounded only, and its Skills Registry Example diverged fromconfig.json. All three fixed (plus the stalebehaviorvalue there and in ADR-005 §5, whichskill-md-mirror.tspin-tests asoverwrite), andreference/skill-management.mdx— which reproduced the oldflattenPath— brought in step.The guard's rule is now documented as it is ENFORCED, not as the case that motivated it.
validateNoShallowEntryWithSubdirrejects any directory shallower thanflattenDepththat holds files directly and owns a sub-directory — which includes a category directory with a README, not only a registry-root skill. That is intentional (entry-vs-category is decided by "holds files directly", so noSKILL.mdknowledge enters a transform four registries share) but was under-documented and its remediation gave wrong advice for that shape. ADR-020 andnested-sub-documents.mdnow state the broader rule, the error message names the extra way out (…or move/remove the file(s) held directly by '<dir>') and no longer interpolatesflattenDepthas the child's depth, and a test pins the category-file shape so the rule cannot silently narrow.Two silent-divergence classes closed, both of the kind this PR elsewhere fails loudly on:
{ flatten: false, flattenDepth: 2 }applied an unbounded path transform with bounded entry classification (areferences/silently dropped from the skill-name map and the frontmatter sync). Unreachable via the CLI, reachable through the exportedcopyDirectoryWithTransforms;buildTransformOptsnow drops the depth whenflattenis false — one source of truth for "bounded?" — pinned by a test.skills:conformancenow fails on anySKILL.mdbelow the entry depth.process/review/references/SKILL.mdis correctly-shaped content for the copy-time guards (telling it apart there needs the marker-file knowledge ADR-020 refuses), so it installed silently non-invocable — authoring rule 1 of the new convention was stated but unenforced. Static corpus knowledge is the right layer;ENTRY_DEPTHis pinned toSKILL_COPY_OPTS.flattenDepthby test.Traversal guard is now symmetric by SHAPE. The docstring claimed the unbounded form is safe by construction ("every separator becomes a hyphen") — true only with a separator:
flattenPath('..')returned'..'. Round 3 made the bounded form stricter than the unbounded default four registries use; the guard now applies to any single-segment result in both forms, so neither is LESS safe than the other (they are deliberately not equally strict — a preserved tail is live where a hyphenated segment is inert; round-5 correction). Not reachable through the pipeline (dirname(relative(...))never yields./..) — defensive depth and doc accuracy.Answered (Questions):
behavior: 'mirror'andskillsisoverwrite. Relabelled in code as forward-compatibility (it no longer reads as fixing a live defect) and recorded as an ACCEPTED RESIDUAL in ADR-020: areferences/deleted from the dataset stays installed, consistent with the pre-existingoverwritesemantics for a whole skill directory. AC6 is met as written for a mirror registry.Also:
.pair/llms.txtregenerated (uncovered drift class flagged above); the DR-1 suggestion taken —collectDirShapes+ the three validators extracted toops/copy/layout-validation.ts(copy module back to ~400 lines, one summary sentence); the misattributed test comment moved onto the back-link test it actually describes; the stale "#407 installs it wrongly" caveats inskill-md-mirror.tscorrected (they contradicted the shipped behaviour after the #384 rebase).Round 5 (
109b18a6) — documentation says what the code enforces6 findings (5 Minor + 1 Questions), all resolved, none escalated. Documentation/message wording only — one new test, no behaviour change.
The too-deep error's second way out is now qualified.
…or give '<ancestor>' files of its ownmade the guard pass by reclassifying the offender as CONTENT — which, for a registry whose entries carry an entrypoint file, produces exactly the non-invocable install the same message cites as the reason to reject. In this reposkills:conformancecatches the follow-on shape; a@pair/content-opsconsumer has no such gate, so the message now says…IF '<dir>' is meant to be CONTENT of it — note that an entrypoint file inside content installs as content, not as an entry, i.e. with exactly the symptoms above, pinned by a test so it cannot regress to the bare advice. (layout-validation.ts,copy-directory-transforms.test.ts)"Every entry must sit at that same depth" was an overstatement of the enforced rule — and this repo is the counter-example:
.skills/nextis a one-segment entry beside 39 two-segment ones underflattenDepth: 2and installs fine; a shallower entry aborts the copy only once it also owns a sub-directory. The website's Bounded Flattening bullet (the terse one — it contradicted its own trailing clause) now states the enforced rule and says a mixed-depth corpus is workable, so an external KB author with that shape does not conclude the option is unusable for them.nested-sub-documents.md's heading and lead reworded in step, both copies. (configuration.mdx,nested-sub-documents.md×2)The
flattenPathdocstring's symmetry claim was half false. "Never MORE strict than the unbounded form" is contradicted by the code on purpose: with a preserved tail every segment is validated, soflattenPath('a/../b', 1)throws while the unbounded call returns the inert'a-..-b', and a..among joined entry segments stays unchecked in both. Restated as the real invariant — each form is checked exactly where its own output could carry a live./.., so neither is less safe — with an explicit "do not restore symmetry by weakening the tail check", which is precisely how round 3's hole was born. ADR-020's Benefits bullet and the test comment corrected in step. (naming-transforms.ts+ test,adr-020)The tutorial's team
config.jsonexamples now declareflattenDepth: 2.tutorials/managing-ai-artifacts.mdxis the copy-paste path for an org authoring its own KB package and teaches the<category>/<name>layout; the examples were correct only until a skill ships areferences/. Both examples updated (correct for the tree shown, future-proof) plus one sentence linking Bounded Flattening. (managing-ai-artifacts.mdx)Answered (Questions) — a sub-document must not be linked from OUTSIDE its skill.
buildSkillLinkPathMapmaps entrypoints only, and this PR chains it onto the non-skill registries, so a KB/AGENTS.mdlink to../.skills/<cat>/<name>/references/deep.mdwould be left pointing into the uninstalled source tree. Unreachable today; the convention now states it as authoring rule 5, with per-file mappings named as the follow-up if a direct deep link ever becomes necessary. No code change — confirming the documented contract, in the direction the convention had not covered.Also: the Test Results
prettier:checkline reworded to what reproduces (two pre-existing drifted files onmainfail in@pair/pair-cli; the rootquality-gaterunsprettier:fix, which is why a local gate run reads green), and the round-4 "neither less nor more safe" phrasing above corrected to match the invariant.