Skip to content

[US-407] fix: a skill's nested references/ installs inside it — bounded flatten + link rebase + config wiring - #411

Merged
rucka merged 12 commits into
mainfrom
chore/US-407-preserve-nested-references-subdir
Jul 31, 2026
Merged

[US-407] fix: a skill's nested references/ installs inside it — bounded flatten + link rebase + config wiring#411
rucka merged 12 commits into
mainfrom
chore/US-407-preserve-nested-references-subdir

Conversation

@rucka

@rucka rucka commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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)
Dimension Tier Source Note
Service/domain criticality yellow KB default (no Criticality Table in tech/risk-matrix.md) the install pipeline is how the product is delivered, but it is not a runtime service
Change/diff risk yellow diff footprint ~500 lines across 3 packages, in a transform shared by 5 asset registries; behaviour change is opt-in and pinned by a regression witness
Business impact yellow supporting subdomain (skills distribution) a mis-install breaks skill loading for every consumer of pair update; latent today
Security relevance green path heuristic + round-1 review no auth/secrets/network path; the change ADDS a traversal guard on the preserved tail
Coupling balance not assessed /pair-capability-assess-coupling not run in this pass excluded from the tier max (D21); the coupling argument for the chosen option is in ADR-020

Tier = 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.mdActive: 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

flattenPath replaced every directory separator with a hyphen, so a skill's nested directory installed as a sibling pseudo-skill: process/review/references/deep.md landed at pair-process-review-references/deep.md, outside the skill, with both of its relative links dead. The first skill to use the standard Agent-Skills references/ progressive-disclosure layout would install unusable.

Three layers, all in this PR:

  1. PlacementflattenDepth 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 segment is content of that skill.
  2. Links — the link rewriter rebases a target through any directory the same copy moved, not only the file's own. A sub-doc's ../SKILL.md points 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.
  3. Wiring — the option is read from config.json, validated, and threaded RegistryConfigSyncOptionsTransformOptstransformPath, so pair update/install/package actually honour it. "flattenDepth": 2 is declared on the skills registry.

Why This Change

The layout is not hypothetical: the third-party agent-browser skill already ships references/*.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 run pair update.

Story Context

As a maintainer installing the Pair skills I want pair update to install a skill's nested references/ 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 through pair 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 flattenPath for 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.

  • prefixPath already prefixed only the top-level segment, so a preserved sub-path lands under the prefixed entry with no extra work: process-review/referencespair-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 isRegistryEntryPath excludes it from the skill-name map, the skill-link-path map and the frontmatter name: sync. Without that, references is registered as a skill name mapped to one arbitrary skill's sub-dir and the /references token in an unrelated skill's body is rewritten to it — a cross-skill corruption decided by directory iteration order.

  • Fails loudly, in both boundaries. flattenDepth is validated as a positive integer at the CLI boundary and flattenPath throws on an invalid value, because the pre-existing silent degradation went in the direction that reintroduces the bug (0/-1 full-flattened; 1.5 flattened nothing). flattenPath also 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). flattenDepth assumes every entry sits at the SAME depth, and .skills/ does not: next/ is a one-segment entry beside 39 two-segment ones, so next/references would have the shape of a real entry and install as the sibling pair-next-references/ — the whole defect back for that one skill, with the skill-name map leaking references into AGENTS.md and .pair/knowledge too. The shape is unrepresentable, not merely unhandled, so copyDirectoryWithTransforms rejects it before copying anything (a directory shallower than flattenDepth that holds files directly AND owns a sub-directory). A category directory is told apart from an entry by "holds files directly" — no SKILL.md knowledge, per ADR-020's coupling argument. Recorded as an ADR-020 trade-off and as the one exclusion in nested-sub-documents.md.

  • Most specific move wins (review round 2). resolveAbsoluteTarget now asks rebaseWithinMovedDirs BEFORE the own-directory rebase. movedDirs contains 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.md under 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 on flattenDepth being present so the unbounded path is byte-for-byte unchanged.

  • One name, one home. The option is flattenDepth end to end; TransformOpts moved next to transformPath (its only consumer) and copy/copy-types.ts re-exports it, so buildSkillNameMap/buildSkillLinkPathMap can declare the full type instead of a narrower one that lied about reading the depth.

Files Changed

Transform + copy pipeline (packages/content-ops)

  • Modified: ops/naming-transforms.ts (flattenDepth, isRegistryEntryPath, isValidFlattenDepth — the predicate now shared with the CLI boundary —, TransformOpts, the two assertions, thrown as the layer's typed IO_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.ts
  • Added: ops/copy/layout-validation.ts — the three source-layout validators + collectDirShapes, extracted from the copy module (pure shape analysis, changes for a different reason)
  • Tests: 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)

  • Modified: config.json ("flattenDepth": 2 on skills), src/registry/resolver.ts, src/registry/validation.ts (validateFlattenDepthField), src/registry/operations.ts (buildCopyOptions)
  • Tests: src/registry/{resolver,validation,operations}.test.ts, src/commands/update/handler.test.ts (+3 CLI-level cases through pair update)

Mirror guards (packages/knowledge-hub)

  • Modified: 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 covers flattenDepth), src/tools/skills-conformance-check.ts (+ its test — new entrypoint-depth check)

Decision + documentation

  • Added: .pair/adoption/tech/adr/adr-020-bounded-flatten-depth-entry-granularity.md; skill-conventions/nested-sub-documents.md (dataset source + root mirror)
  • Modified: .pair/adoption/tech/adr/adr-005-skills-infrastructure.md (amended; its behavior: "mirror" corrected to "overwrite"), skill-conventions/README.md (index row, both copies), .pair/llms.txt (regenerated), apps/website/content/docs/reference/{configuration,skill-management}.mdx

Regenerated mirror

  • Modified: .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:

  • the placement cases (process-review-references where process-review/references was expected);
  • the back-link case (../../../source/process/review/SKILL.md where ../SKILL.md was expected);
  • the four entry-vs-content cases — reproduced from the reviewer's exact dataset (two skills each owning a references/ dir), confirmed to produce the cross-skill rewrite and the path-as-name frontmatter before the fix.

Round 2 added, same discipline:

  • the shallow-entry reproduction (next + next/references + a second skill whose body contains /references) — RED as a mis-install at pair-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);
  • the FORWARD link under an unbounded flatten, added to the existing sibling test — RED as ./references/deep.md;
  • mirror + bounded idempotency across three runs, with the nested source dir removed before the third;
  • the depth assertion for an empty path, the typed IO_ERROR name, and isValidFlattenDepth as a unit.

Coverage shape: unit on flattenPath/transformPath/isRegistryEntryPath (incl. the unbounded regression witness and the invalid/traversal rejections); end-to-end through copyPathOps; CLI-level through handleUpdateCommand — with the negative case pinned too (no flattenDepth ⇒ 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.

@pair/content-ops     ✅ all green (49 test files, 0 todo)
@pair/pair-cli        ✅ all green (72 test files)
@pair/knowledge-hub   ✅ all green (22 test files — mirror + skills-guide guards)
ts:check / lint       ✅ clean (all three packages)
prettier:check        ✅ clean on every file in this diff (two pre-existing drifted files on `main` still fail in `@pair/pair-cli` — see the note under Quality Assurance; the root `quality-gate` runs `prettier:fix`, which is why a local gate run looks green)
hygiene:check         ✅ PASS
skills:conformance    ✅ PASS — 40 skills (now incl. entrypoint depth)
docs:staleness        ✅ PASS — 40 skills, 8 commands in sync
dup:check             ✅ exit 0 (18 pre-existing clones, none in touched files)
markdownlint          ✅ clean on every touched .md

Quality Assurance

Review Areas

  • Is flattenDepth: 2 the 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 containing SKILL.md is the entry") was rejected on coupling, not correctness. The full argument, with preserveNested and registry-wide depth-1 as the other two rejected options, is on record in ADR-020 rather than in this body.
  • Two flatten semantics now coexist (bounded for skills, unbounded elsewhere). Deliberate and pinned by test; recorded as a Trade-off in ADR-020.
  • markdownlint/prettier note: apps/pair-cli/src/commands/kb-info/version-check*.test.ts carry pre-existing prettier drift on main. The repo-wide pre-push formatter touches them on every push; they are deliberately not in this diff.

Documentation

Documentation Updates

  • Technical documentation (ADR): .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 needs behavior: 'mirror', and skills is overwrite).
  • Authoring convention (KB guideline): skill-conventions/nested-sub-documents.md — new, in BOTH copies (.pair/knowledge/… and its packages/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, no name: 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's README.md, both copies.
  • Code documentation: the three resolution stages of resolveAbsoluteTarget, the scope note on rebaseWithinMovedDir, 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.
  • User documentation (website reference): apps/website/content/docs/reference/configuration.mdx — a flattenDepth row 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": 2 in the Skills Registry Example, whose stale "behavior": "mirror" is corrected to "overwrite" to match config.json. Also reference/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 a references/ 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.
  • Generated KB index: .pair/llms.txt regenerated for the new guideline (and the pre-existing coupling-balance.md gap it exposed). Uncovered drift class: nothing gates .pair/llms.txtdocs:staleness covers 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

  • Technical decision recorded: ADR-020 (bounded flatten depth, entry granularity, opt-in) — amends ADR-005 §Key Design Choices Collaborative Knowledge Base #2 and its Flatten/prefix rationale, which documented flatten as full flattening. ADR-005 was edited in place so it no longer reads as authority for the superseded semantic. Numbering: adr-018 is 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); 019 is left free for whichever renumbers.
  • Authoring convention documented: skill-conventions/nested-sub-documents.md — a skill at the standard <category>/<skill-name> depth may ship a nested references/, 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, no name: sync, not invocable), and the two exclusions (too shallow / too deep). Authoring rule 1 ("only the entry directory holds SKILL.md") is now enforced by skills: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

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 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.

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: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. So process/review/references still passes; capability/sub/foo fails. Injection-verified — with the guard disabled the test resolves instead of rejecting, and skillNameMap comes 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:

  • The link-depth rewrite is observable only with a one-segment entry (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.
  • The nested 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 own catalog/nested entry.

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-gate green at that head (counts deliberately not quoted — see Test Results).

Round 4 (8b357176) — the public reference, and the rule as enforced

11 findings (1 Major + 8 Minor + 2 Questions), all resolved, none escalated.

[Major] flattenDepth shipped undocumented outside this repo. The website's reference/configuration.mdx documents every other registry field and never mentioned it, its Transform Configuration explained flattening as unbounded only, and its Skills Registry Example diverged from config.json. All three fixed (plus the stale behavior value there and in ADR-005 §5, which skill-md-mirror.ts pin-tests as overwrite), and reference/skill-management.mdx — which reproduced the old flattenPath — brought in step.

The guard's rule is now documented as it is ENFORCED, not as the case that motivated it. validateNoShallowEntryWithSubdir rejects any directory shallower than flattenDepth that 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 no SKILL.md knowledge enters a transform four registries share) but was under-documented and its remediation gave wrong advice for that shape. ADR-020 and nested-sub-documents.md now 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 interpolates flattenDepth as 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 (a references/ silently dropped from the skill-name map and the frontmatter sync). Unreachable via the CLI, reachable through the exported copyDirectoryWithTransforms; buildTransformOpts now drops the depth when flatten is false — one source of truth for "bounded?" — pinned by a test.
  • skills:conformance now fails on any SKILL.md below the entry depth. process/review/references/SKILL.md is 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_DEPTH is pinned to SKILL_COPY_OPTS.flattenDepth by 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):

  • Is the nested mirror cleanup live? No — it needs behavior: 'mirror' and skills is overwrite. Relabelled in code as forward-compatibility (it no longer reads as fixing a live defect) and recorded as an ACCEPTED RESIDUAL in ADR-020: a references/ deleted from the dataset stays installed, consistent with the pre-existing overwrite semantics for a whole skill directory. AC6 is met as written for a mirror registry.
  • Is the unenforced authoring rule 1 separable? It was closed here instead — a few lines in the gate, and it is the same silent-hole class round 3 closed for the neighbouring shape.

Also: .pair/llms.txt regenerated (uncovered drift class flagged above); the DR-1 suggestion taken — collectDirShapes + the three validators extracted to ops/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 in skill-md-mirror.ts corrected (they contradicted the shipped behaviour after the #384 rebase).

Round 5 (109b18a6) — documentation says what the code enforces

6 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 own made 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 repo skills:conformance catches the follow-on shape; a @pair/content-ops consumer 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/next is a one-segment entry beside 39 two-segment ones under flattenDepth: 2 and 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 flattenPath docstring'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, so flattenPath('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.json examples now declare flattenDepth: 2. tutorials/managing-ai-artifacts.mdx is 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 a references/. 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. buildSkillLinkPathMap maps entrypoints only, and this PR chains it onto the non-skill registries, so a KB/AGENTS.md link to ../.skills/<cat>/<name>/references/deep.md would 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:check line reworded to what reproduces (two pre-existing drifted files on main fail in @pair/pair-cli; the root quality-gate runs prettier: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.

@rucka rucka added tech-debt Tracked technical debt (living backlog, R7.2 — never blocks a PR) risk:yellow Classification: medium risk tier labels Jul 31, 2026
@rucka rucka self-assigned this Jul 31, 2026
@rucka

rucka commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict

risk:yellow · cost:greenCHANGES-REQUESTED — the transform is fixed and verified, but flattenDepth never reaches the copy pipeline from configuration, so pair update still mis-installs a nested references/ — while commit 724ea0d says Closes #407.

PR: [#411] · Author: @rucka · Reviewer: independent reviewer (Claude Opus 5) · Date: 2026-07-31 · Story: [US-407] · Type: bug

Classification matrix — per dimension
Dimension Tier Source Note
Service/domain criticality yellow install/distribution pipeline content-ops/ops is shared by every asset registry
Change/diff risk yellow 8 files, +255/-13 opt-in param + one behaviour change in the shared link rewriter
Business impact yellow supporting subdomain (install artifacts) latent for placement; the link-rewrite half fixed a live dead link
Security relevance yellow manual read (/pair-capability-assess-security not run separately) preserved .. segment can escape the destination root — unreachable today (finding 5)
Coupling balance yellow manual read (/pair-capability-assess-coupling not run separately) a second flatten implementation lives in pair-cli/registry/layout.ts and didn't learn the rule

Tier = max(assessed) = yellow, confirming the story's refinement-time risk:yellow (no raise, no drift note). Cost = highest detected signal = green. Review value is a floor (D17): confirm or raise, never lower.

Assessments

Security — Input validation

Verdict: yellow — the only new input is flattenDepth, and it is unvalidated at every boundary.

Details
  • flattenDepth is typed number with no runtime validation. apps/pair-cli/src/registry/validation.ts has validators for flatten and prefix but none for flattenDepth (and rejects no unknown keys), so a config typo passes silently.
  • flattenPath silently degrades on 0, negatives and fractions instead of erroring — see finding 6.
  • No network/user input is touched by the diff; all path segments originate from a filesystem walk (collectFiles).

Security — Output handling

Verdict: yellow — the preserved tail is written into a filesystem path without containment checks.

Details
  • flattenPath('process/review/../../../../etc', 2)process-review/../../../../etc; posix.join('/dest/target', …) (as copy-directory-transforms.ts:93 does) → /etc. Verified by direct execution against branch code.
  • The pre-change unbounded form was traversal-safe by construction. Not reachable from collectFiles today (no real directory is named ..), but flattenPath/transformPath are public exports (packages/content-ops/src/index.ts:58). Finding 5.

Security — Authentication

Verdict: green — no authentication surface in the diff.

Details

Pure path/string transforms plus local filesystem copy. No credentials, tokens, or remote calls.

Security — Authorization

Verdict: green — no access-control surface in the diff.

Details

No authorization decision is made or changed. Write targets remain the registry's configured targets.

Security — Introduced vulnerabilities

Verdict: yellow — [1 introduced (not exploitable via any current call path), 0 pre-existing]

Details
Severity Category File:location Introduced / pre-existing Recommendation
P3 A01 Path traversal (defence-in-depth) packages/content-ops/src/ops/naming-transforms.ts:32-38 introduced Reject/normalize .. in the preserved tail; assert containment

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.

Cost

Verdict: cost:green — no cost signal in the diff.

Details
Signal Class Provider Note
Managed service / infra change green n/a none — local filesystem copy only
AI/LLM invocation green n/a none added
CI minutes green GitHub +11 unit tests in an existing suite (~ms)

Architecture (Coupling)

Verdict: yellow — one concept, two implementations, and only one of them learned the new rule.

Details
  • packages/content-ops/src/ops/naming-transforms.ts (flattenPath/transformPath) is the source of truth the copy pipeline and the tech-debt: extend mirror-equality guard to per-skill SKILL.md pairs (all 36 skills) #352 mirror guard both compose — good.
  • apps/pair-cli/src/registry/layout.ts:105 applyPrefixFlatten (and the reverse transformTargetToSource, line ~277) is an independent re-implementation of flatten/prefix used for expected-target-name computation and target→source mapping. It has no notion of depth, so once flattenDepth is actually enabled the two disagree for any nested path. Integration strength: contract/shared-knowledge; distance: same repo, same team; volatility: low. Balance is tolerable, but the duplication is what makes finding 1 easy to get half-right.
  • The movedDirs threading (rewriteLinksAfterTransformrewriteLinksInFilecomputeNewHrefresolveAbsoluteTarget) adds an optional parameter across four layers of one module — acceptable, built once per batch, and inert when absent.

Details

Findings by severity

Critical (must fix before merge)

none

Major (should fix before merge)

  • apps/pair-cli/src/registry/resolver.ts:60-72 + apps/pair-cli/src/registry/operations.ts:150-162 + apps/pair-cli/config.json:31-46flattenDepth never reaches the copy pipeline from configuration. normalizeRegistryConfig does not read raw['flattenDepth'], RegistryConfig does not declare it, and buildCopyOptions does not set it — so pair update / install / package always run with flattenDepth: undefined, i.e. unbounded flatten, i.e. the nested dir still installs as the sibling pair-process-review-references/. Adding "flattenDepth": 2 to config.json today would be silently dropped (no validator, no unknown-key rejection). Story tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407's statement — "pair update install a skill's nested references/ inside that skill's installed directory" — is therefore not met at the only layer the story names, while commit 724ea0d carries Closes #407. The PR's own stated reason for deferring the wiring ("turning it on while back-links are broken would trade one broken layout for another") no longer holds: the back-link half is in this same PR. Fix: read + validate flattenDepth (positive integer) in resolver.ts/validation.ts, add it to RegistryConfig and buildCopyOptions, declare "flattenDepth": 2 on the skills registry, and update SKILL_COPY_OPTS + module docstring in packages/knowledge-hub/src/tools/skill-md-mirror.ts:38 in the same change (the story lists this re-pointing as in scope) — plus one CLI-level fixture through update/package, as the story's scope asks. If you judge the wiring large enough for its own story, file it, retitle this PR, and drop Closes #407 from the commit — do not merge a Closes on an unmet AC.
  • packages/content-ops/src/ops/skill-reference-rewriter.ts:180-193 (+:236-249) — a nested content directory is registered as a skill name, corrupting installed content on exactly the layout this PR enables. Reproduced against branch code: dataset process/review/{SKILL.md,references/deep.md} + capability/grill/{SKILL.md,references/deep.md} with flattenDepth: 2 yields skillNameMap = [["references","pair-capability-grill/references"],["review","pair-process-review"],["grill","pair-capability-grill"]], and the `/references` token inside pair-process-review's installed SKILL.md is rewritten to `/pair-capability-grill/references` — a cross-skill rewrite, last-writer-wins on directory iteration order. Related: copy-directory-transforms.ts:144-151 runs syncFrontmatter({ from: 'references', to: 'pair-process-review/references' }) on the sub-doc, so a sub-doc with name: frontmatter gets a path as its name. Fix: in buildSkillNameMap/buildSkillLinkPathMap (and the frontmatter sync) skip any originalSubDir deeper than the entry granularity — those are content, not skills — and add a test with two skills each owning a references/ dir.
  • packages/content-ops/src/ops/naming-transforms.ts:11-38 — missing ADR/ADL — story tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407's Open Questions state the choice ("redefine flatten registry-wide (depth-1 only) or gain an explicit flattenDepth/preserveNested knob") is ADR-worthy, and .pair/adoption/tech/adr/adr-005-skills-infrastructure.md §Key Design Choices Collaborative Knowledge Base #2 / §Rationale documents flatten as full flattening because "AI tools expect skills in flat directory structures". This PR changes that semantic for the skills registry and records the reasoning only in the PR body and code comments; no ADR or ADL exists (grep flatten over adoption/tech/adr hits only ADR-005; there is no adl/ directory). Per CLAUDE.md ("architectural/project decisions MUST be recorded as ADR or ADL") and /pair-process-review Step 2.3, a new technical decision without an ADR is a HALT/CHANGES-REQUESTED condition. Fix: /pair-capability-record-decision — amend or supersede ADR-005, recording the numeric-depth form chosen, the alternatives rejected (preserveNested, "the dir containing SKILL.md is the entry"), and the opt-in default.
  • PR description + title (#411) — materially contradicts head 724ea0d. The title still says "(placement half)"; the body's "⚠️ This PR delivers half the story, deliberately" section states the back-link "is still mangled to ../../../source/process/review/SKILL.md", that it is "recorded as an it.todo", and that "Merging this PR does not close tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407". None of that is true of the pushed head: the back-link is fixed (copy-directory-transforms.test.ts "keeps a nested sub-doc back-link pointing at its own skill (tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407)"), grep -rn 'it\.todo' packages/content-ops/src returns nothing, and commit 3 says Closes #407. "Files Changed" omits link-rewriter.ts (+61/-6, the largest behavioural change) and .claude/skills/pair-process-plan-epics/SKILL.md; "Test Results" says 641 tests / 1 todo where commit 3 says 644 / 0. The PR body is the review artifact of record — a reviewer, or a later resumed session, draws false conclusions about what shipped. Fix: rewrite the description against head per pr-template.md, fix the title, and make the closure semantics match the delivered scope.

Minor (consider)

  • packages/content-ops/src/ops/naming-transforms.ts:32-38 — a .. segment beyond maxDepth is preserved verbatim, so the result can escape the destination root: flattenPath('process/review/../../../../etc', 2)process-review/../../../../etc, and posix.join('/dest/target', …)/etc (verified). The unbounded form was traversal-safe by construction. Not reachable from collectFiles today, but flattenPath/transformPath are public exports (index.ts:58). Reject/normalize ./.. in the preserved tail, or assert join(destPath, transformed) stays under destPath, with a test.
  • packages/content-ops/src/ops/naming-transforms.ts:31 — invalid depths degrade silently, in the direction that reintroduces the bug: 0process-review-references (full flatten), -1 → same, 1.5a/b/c/d (no flattening at all). A config typo therefore fails silently. Validate at the CLI boundary (positive integer) and/or throw on a non-positive-integer maxDepth; add cases for 0, negative, fractional.
  • packages/content-ops/src/ops/link-rewriter.ts:79-84 — the "Known scope boundary" paragraph on rebaseWithinMovedDir is now wrong: a target in a different transformed sibling dir no longer "still falls through to reRootTarget" (it goes through the new rebaseWithinMovedDirs first), and "not needed by any skill in the corpus today" is falsified by this very PR (plan-epics → map-subdomains). Update it to describe the two-stage resolution.
  • packages/content-ops/src/ops/link-rewriter.ts:156-172 — the JSDoc "Computes the new href for a relative link after the file has moved. Returns null if the link should not be rewritten…" now sits immediately above splitRewritableHref, which has its own JSDoc right below it, while computeNewHref (line 174) is left undocumented. Two stacked doc blocks, the first describing a different function. Move it back onto computeNewHref.
  • packages/content-ops/src/ops/link-rewriter.ts:143-154rebaseWithinMovedDirs copies and re-sorts movedDirs on every link of every file (O(F·L·D log D)), although the array is already built once per batch at line 306. Sort once in rewriteLinksAfterTransform and pass the sorted array.
  • packages/content-ops/src/ops/naming-transforms.ts:27 vs :62 — one concept, two names: the option is flattenDepth in SyncOptions/TransformOpts/transformPath, the parameter is maxDepth in flattenPath, and it is positional (flattenPath('process/review/references', 2)) while the sibling transformPath takes an options object — a bare magic number at call sites. Name it flattenDepth throughout, or document the alias once.
  • packages/content-ops/src/ops/skill-reference-rewriter.ts:181 and :240buildSkillNameMap/buildSkillLinkPathMap declare transformOpts: { flatten?: boolean; prefix?: string } yet forward it to transformPath, which now also reads flattenDepth. It works only because the caller happens to pass the wider TransformOpts object through unchanged; the declared type lies about what the function consumes, and any refactor that destructures and rebuilds the object silently drops the depth. Type both as TransformOpts.
  • Docs — .pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/README.md (absent change) — story tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407's DoD requires "Documentation updated (skill-authoring convention: nested references/ supported)". Nothing under skill-conventions/ (nor ADR-005) states that a skill may ship a nested references/ sub-dir or how it installs, so the convention this PR exists to enable is undiscoverable to a skill author. Add a short section, cross-referenced from ADR-005, once the wiring lands.

Questions

  • packages/content-ops/src/ops/naming-transforms.ts:62 — is flattenDepth: 2 the right expression of "entry granularity"? The PR flags this as worth challenging, and I agree it is the open design point: a numeric depth encodes a registry-layout fact in the caller and silently mis-flattens if a category level is ever added or removed. Were preserveNested: true (flatten only the entry) or "the directory containing SKILL.md is the entry" considered and rejected on record? Please capture the answer in the ADR from the third Major finding.
  • Cross-PR (behavioural collision with [US-384] chore: mirror-equality guard widened to every dataset skill artifact #406, still OPEN)[US-384] chore: mirror-equality guard widened to every dataset skill artifact #406 carries two tests asserting today's mapping by name ('flattens a nested sub-dir into its own prefixed dir, as the pipeline does today (#407)', 'rewrites a nested reference file too (flattened into its own prefixed dir)'); its nested back-link assertion changes under this PR's link-rewriter fix regardless of flattenDepth. The two PRs are file-disjoint, so nothing will flag it. Nothing to change in this PR — noted so the human merge gate sequences the two and updates [US-384] chore: mirror-equality guard widened to every dataset skill artifact #406's assertions in the cascade rebase. (Resolves after merge.)
Positive feedback
  • Test-first, and the tests are diagnostic rather than decorative. The "regression witness" pinning the unbounded default is the right instinct for a shared transform, and the sibling case asserts the back-link is rewritten (../pair-process-review/SKILL.md), not merely left alone — which is what makes the second commit's claim ("the defect was not limited to nested references/") checkable.
  • The link-rewriter half fixes a live, shipped defect, and I verified the regeneration is complete. Replaying the branch's copyDirectoryWithTransforms over the real packages/knowledge-hub/dataset/.skills tree reproduces every installed .md under .claude/skills/ byte-for-byte — SKILL.md files and the sub-docs (merge-and-cascade.md, degradation-levels.md, post-review-merge.md, assess-orchestration.md) that the tech-debt: extend mirror-equality guard to per-skill SKILL.md pairs (all 36 skills) #352 guard deliberately does not assert. So pair-process-plan-epics' ../../../.skills/capability/map-subdomains/SKILL.md really was a dead path in installed projects, ../pair-capability-map-subdomains/SKILL.md is the correct replacement, and nothing else in the corpus is left stale.
  • The change leans on existing invariants instead of duplicating them: prefixPath's top-level-only prefixing and cleanupStaleTransformedEntries' transformedDir.split('/')[0] both remain correct under the new sub-path form, and the code says so rather than re-deriving it.
  • movedDirs is built once per batch and inert when absent — the opt-in shape keeps every other asset registry on exactly the old code path, which is the right call for a transform shared by all of them.
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.

  • Nested dir installs inside the skill — at library level (copyPathOps with flattenDepth: 2), with a passing end-to-end test.
  • Forward link works (./references/deep.md) — no rewrite needed, asserted.
  • Back-link works (../SKILL.md) — fixed and asserted, in both the "moved together" and "became a sibling" directions.
  • "pair update … installs … inside that skill's installed directory"not met: the option is unreachable from config.json/pair-cli (Major 1). The user-facing behaviour of pair update for placement is byte-identical to before.
  • In scope: fixtures/tests "through install / update / package" — only through the content-ops library, not the CLI.
  • In scope: re-point the mirror guard's documented derivation (skill-md-mirror.ts) — not done (consistent with the config not being wired, so it is the same gap).
  • DoD: documentation updated (skill-authoring convention) — not done.
  • DoD: mirror guard still green — verified independently against branch code (all installed artifacts match).
  • Error handling: unchanged paths keep their fallbacks; rebaseWithinMovedDirs returns null so existing behaviour survives.
  • Edge cases covered by tests: entry-depth path, shallower-than-depth path, more than one level below the entry, unbounded default. Missing: 0/negative/fractional depth (Minor 6), two skills each with a references/ dir (Major 2).
Testing & quality gates
  • Adequate unit coverage on the pure transform (8 new cases) and one end-to-end case per direction through copyPathOps (3 new cases).
  • Edge + error scenarios: invalid depth values untested; the skill-name-map / frontmatter-sync interaction with a nested dir untested (and broken — Major 2); no CLI-layer test, which is exactly where the wiring gap hides (Major 1).
  • Quality gates (run independently in a detached review worktree): content-ops vitest 626/626 tests pass; tsc --noEmit -p packages/content-ops clean. Two content-ops suites (http/download-manager, http/resume-manager) failed to load in my worktree for lack of a built dist — an artifact of my read-only setup, not of the branch. Mirror-equality re-derived from branch code: no drift across all installed .md. I could not run the full pnpm quality-gate (no install in the review worktree); the PR's green claim is plausible, but its stated counts (641 tests / 1 todo) do not match head (644 / 0) — see Major 4.
Adoption compliance
  • Degradation level: 2/pair-capability-verify-adoption treated as available in-repo, /pair-capability-assess-stack not needed (no new dependency; package.json untouched).
  • Dependencies match tech-stack.md — no new dependency, no version change.
  • Patterns: the change respects architecture.md's tool-package boundary (transform logic stays in content-ops), but leaves the duplicate flatten implementation in pair-cli/registry/layout.ts unaware of the new rule — see the Coupling assessment.
  • ADRs present for new technical decisions — NO. ADR-005's documented flatten semantics are modified without an ADR/ADL, and the story itself flags the question as ADR-worthy. This is the process HALT condition of Step 2.3 (Major 3).
Tech debt

Surfaced, never blocking on debt grounds alone:

  1. Duplicate flatten/prefix knowledgeapps/pair-cli/src/registry/layout.ts applyPrefixFlatten + transformTargetToSource vs content-ops/naming-transforms. Impact: high (a correctness divergence the moment flattenDepth is enabled); effort: medium. Worth deliberate promotion via /pair-capability-write-issue --label tech-debt if not folded into the wiring fix.
  2. Mirror guard covers only SKILL.md (tech-debt: extend mirror-equality guard to all root skill artifacts (sub-docs + references) #384/[US-384] chore: mirror-equality guard widened to every dataset skill artifact #406 in flight) — my probe shows sub-docs are in fact byte-equal today, so widening the guard would lock in a property that already holds. Already tracked.
  3. flattenPath positional numeric parameter vs transformPath's options object — small API inconsistency (Minor 10).
Documentation
Performance & deployment
  • No hot-path regression of consequence: link rewriting gains one extra pass over movedDirs per unresolved link; the per-link re-sort is wasteful but bounded (~42 entries for the skills registry) — Minor 9.
  • Deployment/rollback: pure opt-in. With flattenDepth omitted (today's config) behaviour is byte-identical for placement, and the link-rewriter change is a strict improvement whose full output I verified against the committed corpus. Rollback = revert; no migration, no state.

Independence note: reviewed from story #407, the PR diff/description, and the branch code only, in a detached throwaway worktree at origin/chore/US-407-preserve-nested-references-subdir (724ea0d). .pair/working/ was not read. Findings 2, 5, 6 and the mirror-equality verification were reproduced by executing branch code, not inferred.

@rucka rucka changed the title [US-407] fix: bound flatten depth — a skill's nested references/ installs inside it (placement half) [US-407] fix: a skill's nested references/ installs inside it — bounded flatten + link rebase + config wiring Jul 31, 2026
rucka added a commit that referenced this pull request Jul 31, 2026
…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
rucka added a commit that referenced this pull request Jul 31, 2026
…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
@rucka

This comment has been minimized.

rucka and others added 8 commits July 31, 2026 16:33
… 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>
rucka added a commit that referenced this pull request Jul 31, 2026
…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
…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>
@github-actions
github-actions Bot temporarily deployed to Website Preview July 31, 2026 17:35 Inactive
rucka added a commit that referenced this pull request Jul 31, 2026
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>
rucka added a commit that referenced this pull request Jul 31, 2026
…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
@rucka

rucka commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

In reply to the first review.

Escalating to human — non-convergence, round 6

Re-review of head 109b18a6 (no new commit since round 5) surfaced 6 further findings, all Minor. This flush supersedes the round-3 escalate-flush comment (now marked outdated/minimized) and summarizes the full cycle to date.

Rounds so far (all in .pair/working/reviews/407.md)

  • Round 1 — 13 findings (4 Major + 8 Minor + 1 Questions), all resolved, none escalated.
  • Round 2 — 9 findings (1 Major + 8 Minor), all resolved, none escalated.
  • Round 3 — 7 findings (1 Major + 5 Minor + 1 Questions), escalated to human (design question on the shape-inference guard). Subsequently fixed out of band.
  • Round 4 — 11 findings (1 Major + 8 Minor + 2 Questions), all resolved, none escalated.
  • Round 5 — 6 findings (5 Minor + 1 Questions), all resolved, none escalated.
  • Round 6 (this one) — 6 further findings surfaced on re-review of the SAME head 109b18a6, all Minor. Escalating rather than auto-fixing per this run's instruction.

Still-open actionable findings (round 6)

  1. [Minor] packages/knowledge-hub/src/tools/skill-md-mirror.ts:196-202 (installedArtifactPath docstring, paragraph 2) — still asserts the superseded UNBOUNDED semantic in the present tense with a worked example (… → pair-process-review-references/deep.md), directly contradicted by the very next paragraph's #407-bounded result for the same source path. Verified: transformPath(dir, SKILL_COPY_OPTS) with flattenDepth: 2 returns pair-process-review/references, not the flattened form. Missed by the round-4 grep sweep because the paragraph never says "tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407".
    Recommendation: merge the two paragraphs, or mark the unbounded example as historical (e.g. "Before tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407 … Since tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407 the mapping is BOUNDED: …"). Docs only.

  2. [Minor] apps/pair-cli/src/registry/resolver.ts:81-84 (normalizeRegistryConfig) — raw['flattenDepth'] != null && {...} drops an explicit JSON null before it reaches validateFlattenDepthField, so flattenDepth: null normalizes to "unbounded" and pair update silently reinstalls the tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken) #407 sibling-dir defect for a registry meant to be bounded. 0/-1/1.5/'2' are all correctly rejected — null is the one hole.
    Recommendation: raw['flattenDepth'] !== undefined && {...}; add a null test case to resolver.test.ts/validation.test.ts.

  3. [Minor] PR [US-407] fix: a skill's nested references/ installs inside it — bounded flatten + link rebase + config wiring #411 body → ## Documentation → "Generated KB index" bullet (line 137) — defers the .pair/llms.txt drift-detection gap without citing tech-debt: no gate covers .pair/llms.txt drift — the generated KB index goes stale silently #416, which was opened at 17:22 UTC (before the round-5 commit at 17:34 UTC) and describes this exact gap.
    Recommendation: cite tech-debt: no gate covers .pair/llms.txt drift — the generated KB index goes stale silently #416 in the bullet.

  4. [Minor] .pair/adoption/tech/adr/adr-020-bounded-flatten-depth-entry-granularity.md:70 (Trade-offs bold lead-in) — "Every entry must sit at the declared depth; both deviations abort the copy" is the last surviving copy of the absolute/overstated wording; round 5 reworded the two sibling docs (website bullet, nested-sub-documents.md) to the enforced (narrower) rule but left the ADR of record stating the false absolute form.
    Recommendation: align with the two reworded copies, e.g. "A deviation from the declared depth aborts the copy once it becomes ambiguous."

  5. [Minor] apps/website/content/docs/reference/configuration.mdx:174-181 (Validation → What Gets Validated) — doesn't list the new flattenDepth validation rule (positive integer; requires flatten: true) among the enumerated transform-config checks. Discoverability only — the rule is stated two sections up in Registry Fields — but the round-4 Major on this same page was about exactly this class of omission.
    Recommendation: extend the existing transform bullet to mention flattenDepth.

  6. [Minor] packages/content-ops/src/ops/copy/layout-validation.ts:157-163 (validateNoDeepEntry docstring, round-5 addition) — misattributes its audience as "a consumer of this package has no such gate", but @pair/content-ops is "private": true with no external consumers; the real reachable audience is a project whose own config.json declares a bounded registry, copied by the published pair CLI (apps/pair-cli).
    Recommendation: reword to name the real audience.

None of these individually rise to a Major/design-level disagreement; escalating per this run's framing (non-convergence risk) rather than auto-fixing.

Convention for continuation

Any further rework or re-review on this story — including manual, out-of-band rounds — should be appended to the working log .pair/working/reviews/407.md, NOT posted as a new standalone PR comment. The next orchestrated run on this story continues the same cycle; its convergence will synthesize ONE final remediation comment and minimize this flush (and any other intermediate comments, excluding the first-review comment above).

Note: this working log is an UNTRACKED file living only in the persistent authoring worktree ../pair-worktrees/407. That worktree must be PRESERVED until merge — if it is pruned/recreated the audit trail of rounds 1-6 is lost (this flush and the first-review comment remain on the PR and still prevent a duplicate first review on the next run, but the per-finding detail would not be recoverable).

Not merged. Human decision requested on how to proceed (bundle-fix vs. continue escalating).

…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>
@github-actions
github-actions Bot temporarily deployed to Website Preview July 31, 2026 18:37 Inactive
rucka added a commit that referenced this pull request Jul 31, 2026
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>
rucka added a commit that referenced this pull request Jul 31, 2026
…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
@rucka

rucka commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation — round 4

Open findings: 0 of the six raised this round. Two of the round-3 carry-overs are also gone (see below); the rest are unchanged and were explicitly out of scope for this round.

Head: 7db5d1da. Gate: 22 + 12 tasks green.

# Finding What changed
1 installedArtifactPath docstring described the pre-#407 unbounded mapping in the present tense The stale example is removed rather than re-tensed: the docstring now states the bounded behaviour only, so there is no sentence a reader can mistake for current.
2 flattenDepth: null was silently dropped instead of rejected !== undefined (was != null), so an explicit null reaches validateFlattenDepthField and is rejected by name instead of degrading into the unbounded flatten this story removed. New test asserts the key survives normalization and the value stays null.
3 The KB-index bullet flagged a drift class without citing the issue Cites #416 (tech-debt: no gate covers .pair/llms.txt drift), which exists and is open.
4 ADR-020's lead-in claimed every depth deviation aborts the copy Reworded to match what the two guards actually do: a deviation aborts once the shape becomes ambiguous, and neither guard fires on depth alone — each needs its second condition, which the sub-bullets state. The duplicated trailing clause is gone.
5 flattenDepth missing from the docs' validation list Added to the existing transform bullet: a positive integer, and only alongside flatten: true.
6 The docstring's "consumer of this package" named the wrong audience Reworded: a project whose own config.json declares a bounded registry has no such gate, because skills:conformance is corpus-specific to this repository rather than part of the published package.

Round-3 carry-overs, re-checked rather than assumed: .pair/llms.txt does now carry the nested-sub-documents.md row, and ADR-020 no longer has an open Question about normalizing next into a category. The remaining three (the guard message's depth interpolation, adr-005's behavior wording, the DR-1 module-split suggestion) are untouched — they were listed as not-new for this round and none of them changes behaviour.

@rucka
rucka merged commit 9bc4744 into main Jul 31, 2026
3 checks passed
rucka added a commit that referenced this pull request Aug 1, 2026
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>
rucka added a commit that referenced this pull request Aug 1, 2026
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>
rucka added a commit that referenced this pull request Aug 1, 2026
…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
rucka added a commit that referenced this pull request Aug 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:yellow Classification: medium risk tier tech-debt Tracked technical debt (living backlog, R7.2 — never blocks a PR)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tech-debt: pair update mis-installs a skill's nested references/ subdir (flattened out of the skill, both links broken)

1 participant