Skip to content

feat(cli): derive each package's docs directory from the registered packages - #19492

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-18965-docs-dir-resolves-per-registered-package
Sep 21, 2026
Merged

os-project-manager merged 2 commits into
mainfrom
claude/issue-18965-docs-dir-resolves-per-registered-package

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #18965

Clause-②: yes

os build now derives each package's docs directory from the packages the artifact registers, not from a fixed depth under src/ — the maintainer's ruling, decision batch #204 item 5, letter B (relayed at comment 5754492000). A (declare the convention, rename the reference fixture) and C (scan two levels) were rejected there and are not implemented here.

The first reading the ruling named: yes, packages[] is assembled before the collector runs

The ruling made this the first thing to check, and said to report rather than restructure if it came out the other way. It comes out the right way, so nothing was restructured. Measured in packages/cli/src/commands/compile.ts:

step line what it does
loadConfig 242 loads objectstack.config.ts, already composed — the fixture's own composeStacks([...], { manifest: 'preserve' }) is what produces packages[]
ObjectStackDefinitionSchema.safeParse 347 result.data.packages is the parsed, assembled array from here on
collectAndLintDocs(absolutePath, result.data) 778 the collector, handed that same array

⇒ the resolution point the ruling assumes is available at the collector, 431 lines after packages[] exists. os validate (validate.ts:560), os lint (lint.ts:954) and os dev (serve.ts:2655) reach the same seam, so all four doors move together.

What was wrong

sweepPackageDocsDirectories asked one question per direct child of src/: does src/CHILD/docs/ hold Markdown? #18962 added ownership to that loop (match the directory name against the artifact's packages) but not resolution — the one fixed level survived. So a project whose packages sit one level deeper, which is the shape this repo's own ADR-0130 D4 reference fixture examples/app-multi-package has, was invisible.

The card's repro, run through the real os build binary, before and after. The "before" leg is an ablation of this branch back to the one-level walk, with packages/cli rebuilt and ablation-dist-preflight proving the mutation reached the dist/ the bin actually loads:

BEFORE (ablated to the one-level walk; dist preflight: marker absent from all 532 built files)
  $ objectstack build            # examples/app-multi-package + src/packages/orders/docs/crm_ord_guide.md
  -> Collecting package docs (ADR-0046)... 0 collected
  exit 0
  packages[].docs -> [["com.example.multi.orders",[]],["com.example.multi.core",[]]]
     ... and no docs/uncollected-directory warning either: the sweep never looked there.

AFTER (this branch; dist preflight: marker present in packages/cli/dist/utils/collect-docs.js)
  $ objectstack build
  -> Collecting package docs (ADR-0046)... 1 collected (1 from 1 package directory)
  exit 0
  packages[].docs -> [["com.example.multi.orders",["crm_ord_guide"]],["com.example.multi.core",[]]]
  top-level docs -> []          # still the package body, never the top level (#18431 clause 1)
  marker in content -> true

Both legs restored the tree: ablation-replace reported blob == HEAD with git diff HEAD empty, and a whole-tree git status --porcelain read 0 lines afterwards.

How the directory is found, and why no second depth is pinned

A registered package carries no source path. ArtifactPackageSchema is a strictObject whose only key is manifest, and that body is AssembledPackageBodySchemaManifestSchema plus the collection keys. Neither declares the directory the package was authored in. So the only thing that can locate a package on disk is its name, and the two spellings a docs directory is matched against are unchanged: the package's id, and the last dot-separated segment of that id. ⛔ Never name (a display string, free to be re-worded); ⛔ never namespace (ADR-0130 D1 exists so N packages may share one).

The walk therefore carries no number at all. Two properties are the whole design:

  1. The recursion exists only to find a registered package, and stops at the first directory that names one. A package's own subtree is that package's source, not more packages, so a docs/ deeper inside a resolved package is not a second docs directory.
  2. A directory whose name names no package is never resolved, at any depth — so the walk can only ever add directories a package claims.

The preserved fence, held by construction rather than by a branch guarding it. With no packages[] there is nothing to search for, so there is no descent at all and a single-package stack is walked exactly one level, as it always was. The ablation measures this rather than asserting it: of the eight new cases, the ablation turned seven red and left the single-package one green — that case never depended on the recursion. All 73 pre-existing cases in the two files stayed green under the same ablation, including the pre-#18965 flat-layout pin (the ⭐ lit control) and the byte-exact single-package warning-text pin.

The two decisions the dispatch asked me to make and justify

1 — the src/docs/ only sentence at collect-docs.ts uncollectedDocsMessage: left byte-identical, deliberately. Read with its own docblock in front of me, as asked. That string is emitted from exactly one branch — refs.length === 0, a stack that declares no packages[] — and for that stack letter B resolves no package directory at all, so src/docs/ really is the only place its docs are read from. The sentence is not false for any stack that can reach it. A stack with packages gets the other pair of messages, which name the packages the directory was matched against and claim no fixed path. The decision is recorded in the docblock so the next reader does not re-litigate it. This is also what pin 3 requires: the single-package warning is byte-for-byte what it was, and a pre-existing test asserts the exact sentence.

2 — the two-level layout is built in the test's own fixture, ⛔ not added to examples/app-multi-package. The reference fixture has no docs/ directory and never had one (7 files at 32b5831c4e; git log --diff-filter=AD over its docs paths returns nothing; lit control: examples/ holds 256 files, 11 under a /docs/ path, so the probe can see docs directories there). Two reasons, both recorded in the test block's docblock:

  • Several measurements quote that fixture's contents exactly — artifact-packages.ts sizes the per-package de-duplication residue on it, build-json-advisory-parity.e2e.test.ts reads its artifact — so giving it docs changes what all of them read, to buy what the unit cases already prove with per-file marker strings.
  • ⛔ And I did not pin that fixture's on-disk shape from the test either. Such a pin fails the day someone flattens the fixture to src/PKG/, which after this card is harmless in both directions — it would pin a property this fix deliberately stops being load-bearing, so it could only ever produce false red.

The fixture's measured layout is recorded in the test docblock as the reading the synthetic layout reproduces. The repro above is the compensating evidence: it runs the real os build against the real fixture.

One new refusal, and why it is a refusal

Depth-free resolution makes a new ambiguity reachable: one package answering to two doc-bearing directories (src/core/docs and src/packages/core/docs in one tree). Both are reported and neither is collected — the same answer this collector already gives when one directory names two packages. ⛔ It is not merged and ⛔ not silently halved: attachPackageDocs keys its sets by package index through a Map, so collecting both would drop one without a word — this card's own defect, re-created one layer up.

Pins

# pin where
1 two-level src/packages/PKG/docs/ collected and attributed to the right package, with pedigree collects the ADR-0130 D4 two-level layout…
2 ⭐ flat src/PKG/docs/ collected exactly as today, beside a two-level one, each with its own marker ⭐ lit control: the flat layout is collected exactly as before… + the pre-existing #18431 flat pins
3 ⛔ no packages[] ⇒ walked one level, nothing deeper reported; warning text byte-exact ⛔ single-package regression… + the pre-existing exact-sentence pin
4 a directory matching no package, and one matching more than one, keep their distinct answers at the new depth a directory naming NO package…, an AMBIGUOUS directory name…
+ one package, two docs directories: refused, never merged, never dropped ⛔ ONE package answering to TWO docs directories…
+ the walk stops at a package root ⛔ stops at the package root…

Verification

what result
pnpm --filter @objectstack/cli exec vitest run --project unit 222 files / 3141 tests, 0 failed
pnpm --filter @objectstack/cli typecheck exit 0 (tsc --noEmit + check:test-typecheck; debt ledger unmoved: 3 files / 28 errors / 6 pinned signatures)
pnpm --filter '@objectstack/cli^...' build exit 0
reverse verification (ablation, unit) 7 of 8 new cases red, the single-package one green by construction, 73 pre-existing green; restore proved blob == HEAD, git diff HEAD empty
reverse verification (ablation, e2e through the bin) dist preflight both ways; 0 collected before, 1 collected after; whole-tree porcelain 0 lines after
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every derived family run 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN — reconciled with --ran, each line carrying its own exit code
pnpm lint (eslint . --no-inline-config, the whole repo — ⛔ not a narrowing) exit 0 in 85s, at 4afa1e4669

Four derived families first answered exit 3check:dual-build-cjs-loads, check:i18n, check:i18n-coverage, check:i18n-walk-parity — every one of them PREREQUISITE NOT MET, i.e. NOT MEASURED, never a finding. All four were re-run to exit 0 once the workspace was built, and the reconciliation above carries those codes.

packages/cli integration tier is declared to CI: this diff touches no integration-layer file, no spawn entry point (bin/, test/helpers/serve-process.ts) and no driver/kernel startup path.

Changeset

@objectstack/cli minor, measured rather than assumed. packages/cli is a published package and src/utils/collect-docs.ts ships inside it, so skip-changeset is refused; and Clause-②: yes takes at least minor. ⛔ No @objectstack/spec changeset: nothing in packages/spec changed and nothing needed to — packages[].manifest.docs was already declared, which is what #18962 measured.

Why Clause-②: yes, stated here rather than inherited. Clause ② is directional: widening the accept set triggers it, pulling code back to the declared contract does not. os build now accepts a source layout it previously read nothing from, so what an author may write and have collected grows. ⛔ Nothing narrows — every tree that built green still builds green, with the same docs[] and the same warnings. The same declaration, on the same collector, is the precedent: PR #18962 (card #18431) landed Clause-②: yes with a @objectstack/cli minor.

Acceptance notes

  • The module header's "Absence" bullet was already stale before this card, and is corrected here because this change rewrites that exact sentence. It read "a src/PKG/docs/ directory one level down is NEVER collected" — false since df0c856e01 (feat(cli): read package docs from each package directory of an ADR-0130 layout #18962) made such a directory collectable when it names a package. ⛔ Not filed: it is the docblock of the function this PR changes, and leaving it while restating the contract beside it is not an option.
  • Markdown under a docs/ nested deeper INSIDE a resolved package (src/packages/orders/components/docs/x.md) is still not collected and still not warned about. Unchanged in both directions — the one-level sweep never reached it either — and it is the convention working as designed rather than a defect: the package's docs directory is the one at its root. Pinned as ⛔ stops at the package root… so the boundary is a decision on the record. Noted, not filed; ⛔ carrier: none — no queued PR or person touches this path, and no layout in this repo has that shape.
  • No governed surface is touched: the diff is packages/cli/src and one changeset. ⛔ No docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md.
  • ⛔ No labels were written by this branch. The dispatch permitted exactly one, skip-changeset, and only if my own measurement refused a changeset; it did not.

Generated by Claude Code

…packages

`os build` derived a package's docs directory from a fixed one level under
`src/`, so the ADR-0130 D4 reference layout (`src/packages/<pkg>/docs/`) was
dropped silently — exit 0, nothing printed, not even the
`docs/uncollected-directory` warning, because the sweep never looked there.

The sweep now finds each package's directory by the names the artifact's
`packages[]` answers to, at no fixed depth: the recursion exists only to find a
registered package, stops at one, and does not happen at all when there are
none — so a single-package stack is still walked exactly one level.

Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-authored-by: Claude <noreply@anthropic.com>
…ectory

Clause-②: yes — `os build` accepts a source layout it previously read nothing
from, so what an author may write and have collected widens.

Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via node_modules (literal, a string literal in packageDirectoryCandidates))
  • content/docs/deployment/cli.mdx (via node_modules (literal, a string literal in packageDirectoryCandidates))
  • content/docs/upgrading.mdx (via node_modules (literal, a string literal in packageDirectoryCandidates))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4045781faff6ef4811b7438aee009be750d7cddepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2d2ecc4b712ceb4955c693348847eed42414d52a — the merge of head 4afa1e46697c2daf085ca54cc27cd024e5b253e4 into base 4045781faff6ef4811b7438aee009be750d7cdde, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d2ecc4b712ceb4955c693348847eed42414d52a && git checkout 2d2ecc4b712ceb4955c693348847eed42414d52a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4045781faff6ef4811b7438aee009be750d7cdde 4afa1e46697c2daf085ca54cc27cd024e5b253e4 && git checkout -B drift-repro 4045781faff6ef4811b7438aee009be750d7cdde && git merge --no-ff 4afa1e46697c2daf085ca54cc27cd024e5b253e4

node scripts/docs-audit/affected-docs.mjs --json 4045781faff6ef4811b7438aee009be750d7cdde

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4045781faff6ef4811b7438aee009be750d7cdde → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Two CI failure notices arrived — both name a dead head, and one is already green on the live one

Recorded so nobody chases them. ⛔ No re-run was spent and none is warranted.

notice head it names that head is status on the CURRENT head 4afa1e4669
TypeScript Type Check failed 097806adc4 superseded ⏳ not reported yet — ⛔ so it is neither green nor red
Check Changeset failed 097806adc4 superseded success

Why Check Changeset was red there, and why that is not a defect

The round's own report records three pushes: a routing probe, the implementation commit, and the changeset commit last. 097806adc4 is the implementation commit — it did not yet carry .changeset/18965-docs-dir-resolves-per-registered-package.md, which is present on the branch now. ⇒ a changeset gate red on a head that predates the changeset is the gate working, not a finding.

⚠️ A third notice — the docs-drift comment being edited in place — is the corroborating evidence that the head moved: its first run computed on a merge of 097806adc4, its second on a merge of 4afa1e4669, same comment id.

What is still owed before this can land

TypeScript Type Check has not reported on 4afa1e4669. A check that has not run is ⛔ not a pass, and a failure on a dead head is ⛔ not this head's state — both directions of the same rule. 12 checks are still running; latest-per-name currently reads RED: NONE.

The carrier stays hung and nothing is cleared until that check reports green on this head. Then: a provenance comment, the dual needs:contract-review carrier stripped in one stroke, check-clause2-carriers.mjs --pair 19492 re-run as the post-strip gate, then ready and arm.

Docs drift — read, and no sweep

The drift check named 3 pages, all "via node_modules (literal, a string literal in packageDirectoryCandidates)". A string literal named node_modules in a helper is not an assertion those pages make about this change — skills-reference.mdx, deployment/cli.mdx and upgrading.mdx mention node_modules about other things entirely. ⇒ a weak anchor, no sweep. ⚠️ It did surface something useful though: packageDirectoryCandidates is in this diff, which the first file listing did not show — the second push widened the surface, so the grading read will be taken fresh rather than carried over.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

ACCEPT — and the provenance for stripping the dual carrier

Head judged 4afa1e4669, merge-base 32b5831c4e⚠️ both printed on purpose. Read at 2026-09-21T04:22Z.

CI, all three layers, on the head being judged

layer reading
check RUNS, latest-per-name — the required-check gate 29 success · 5 skipped · 0 red, 0 running. ⭐ TypeScript Type Check = success
check SUITESwhat a human sees 11 success · 2 skipped · 4 queued — no failure suite
legacy combined status success (Vercel)

⚠️ Both earlier failure notices named 097806adc4, a superseded head, and are answered at 5755… on this PR: Check Changeset was red there because that commit predated the changeset (the round's third push added it) and reads success here; TypeScript Type Check had simply not reported on this head and now has.

The carrier comes off, and here is the reading that authorises it

domain:cli owes no contract review of record. Read from the instrument and its landing commit, ⛔ not from a docblock and ⛔ not from memory:

  • node scripts/pm/check-clause2-carriers.mjs --pair 19492exit 0, and no C6 row is printed at allreviewOfRecord reads not-owed for this pair.
  • e15870f7a0 (2026-09-18T23:51Z), the commit that landed the rule, says it in its subject: "key the clause-② contract review by lane — spec and skills owe it on every round, other lanes owe none".
  • laneOwesReview's own text agrees: "owes the review at tier on EVERY round those two lanes deliver."

⇒ the needs:contract-review dual carrier, hung when this seat declared Clause-②: yes, is stripped from both limbs in one stroke against this reading and this head. --pair 19492 is re-run afterwards as the post-strip gate.

⛔ This seat's earlier correction said the review was "in-seat" here — that was order error #10, an obligation invented from a partial reading, and it is corrected on the card at the comment above.

Independent verification — re-derived, ⛔ not read off the report

⚠️ The first file listing this seat took was stale: the round's second push widened the surface (packageDirectoryCandidates appeared after it). The listing below was taken fresh.

claim probe reading
file surface git diff --stat 3 files, +327/−36 — changeset · new test · collect-docs.ts
⛔ the reference fixture was NOT edited --name-only -- examples/ 0 files
the src/docs/ only emitted string is byte-identical md5 of the return line 236ab821841c on both refs, 1 line each
⭐ control — a line the diff did change module header's one level down is NEVER bullet 1 → 0

⚠️ The third row needed opening rather than counting. A first probe over every line carrying the phrase gave different hashes — because the diff adds a docblock line at :318 (sentence below ("read from src/docs/ only") is false for every stack B newly …) recording why the string stays. The emitted template literal itself is untouched. Fifth impostor-shaped reading this shift, caught the same way: open the hit.

What the round decided, and why each is right

The ruling's first reading is answered: packages[] IS assembled before the collector runscompile.ts parses it at :347 and hands it to collectAndLintDocs at :778. ⇒ nothing was restructured, which is what the ruling required.

The preserved fence holds by construction, not by a guard. With no packages[] there is nothing to search for, so there is no descent at all. ⭐ And that is measured, not asserted: in the ablation, 7 of 8 new cases went red while the single-package regression pin stayed GREEN — the one case whose green is the claim.

The two decisions the order left open, both taken with reasons this seat accepts:

  1. The src/docs/ only sentence stays byte-identical, because it is emitted only from the refs.length === 0 branch — the one place where it remains exactly true. The reasoning is now in the docblock instead of being lost.
  2. The two-level layout lives in the test's own fixture, ⛔ not in examples/app-multi-package — because other tests quote that fixture's contents exactly. ⭐ And the round declined to pin the fixture's on-disk shape from the test, because such a pin would fail the day someone flattens it, which this very fix makes harmless. That is the better instinct, and it is the kind of thing an order cannot specify in advance.

One branch added beyond the ruling, and it earns its place: one package answering to two doc-bearing directories is refused rather than merged, because attachPackageDocs keys its sets by package index and would otherwise drop one silently. A silent drop is the exact defect class this card exists about.

Also corrected in passing: the module header's Absence bullet was already stale before this card — it claimed src/PKG/docs/ is "NEVER collected", false since df0c856e01. Fixed here rather than filed, because this change rewrites that sentence's subject.

Gates

Clause-②: yes, declared by this seat on a correction comment and readable in the fixed spelling. Changeset @objectstack/cli minor, measured. dispatch-gates --commands: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN — the four that first answered exit 3 were PREREQUISITE NOT MET and re-ran to 0 once the workspace was built, and those are the codes reconciled. Whole-repo pnpm lint run in full, exit 0.

Stripping the carrier, then readying and arming.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4afa1e46697c2daf085ca54cc27cd024e5b253e4

Isolated at-tier reviewer spawned by the domain:cli seat under ruling #208 R5 (record 5755284987 on #19491; relayed for this pair at 5755819775 on #18965): inputs are the diff at this head and card #18965; facet ③ is read off the head's check-runs from the GitHub API; ⛔ no gate was re-run locally, nothing was pushed, no label was written. The seat's ACCEPT at 5755353982 was read as one reader's opinion, not adopted. Written 2026-09-21T06:09Z.

① Derived judgments

  1. What the contract now says. A package's docs directory is the docs/ inside the first directory under src/, at any depth, whose basename is the package's id or the last dot-segment of that id (docsPackageRefs, unchanged); the search (packageDirectoryCandidates) descends only through directories that name no package, never into node_modules, and stops at a package root. Flat src/PKG/docs/ and the ADR-0130 D4 fixture's src/packages/PKG/docs/ are one rule with no depth constant — letter B of batch 🔗 Broken links detected in documentation #204 item 5 as ruled (5754492000); A (rename the fixture) and C (a second depth) are not implemented, verified on the file list (0 files under examples/) and on the walk (no numeric depth anywhere).
  2. The preserved fence holds by construction, verified in the code, not from the report. The walk recurses only when the directory names no package AND the artifact registers at least one (owners.length === 0, refs non-empty), so with no packages[] it lists the children of src/ once, sorted by localeCompare, and stops — the same candidate set, order and rel strings as origin/main. The refs.length === 0 branch and the uncollectedDocsMessage template literal are byte-identical (the diff adds only a docblock paragraph above it). The new single-package pin plus the pre-existing exact-sentence pin cover it; the 7-of-8-red / single-package-green ablation is the dev's own measurement and is consistent with this reading.
  3. The ruling's first reading is answered in the tree. All four doors hand the parsed artifact to the collector — compile.ts:778, validate.ts:560, lint.ts:954, serve.ts:2656 — so packages[] exists before resolution and nothing was restructured.
  4. The one branch beyond the ruling's letter is correct. One package answering to two doc-bearing directories is refused at warning, both named, neither collected, counted over the whole candidate set before any collection so the answer is walk-order independent. Premise verified: attachPackageDocs builds a Map keyed by set.index over the sets it is handed, so a second set for one index would be dropped without a word. Refuse-and-name mirrors the existing one-directory-two-packages refusal and Prime Directive Add comprehensive test suite for Zod schema validation #12.
  5. The diagnostic's reach widened past the ruling's literal sentence, and that is fine. The ruling said the warning covers Markdown under any registered package; the implementation also warns on any DIR/docs/*.md the search reaches in a multi-package tree (a directory naming no package, at any depth), which was silent below level one. Warning severity, so no tree that built green turns red; disclosed in the changeset (「now at whatever depth they are found」). Recorded for the noise it may add to multi-package trees that keep unrelated docs/ folders deep under src/.
  6. One exposure inherent to name-based, depth-free resolution — the ruling's own design, not a defect of this diff. A deep non-package directory whose basename equals a package tail (say src/shared/core/docs/*.md beside package com.example.multi.core) is now that package's docs when no other location claims it, and is then linted — a stray .md there can fail docs/filename or docs/namespace-prefix. A registered package carries no source path (ArtifactPackageSchema is a strict object whose one key is manifest), so no collector can do better than the name; the mitigations available (stop at package roots, skip node_modules, refuse two locations) are all in the diff. Level one already carries the same exposure since feat(cli): read package docs from each package directory of an ADR-0130 layout #18962.
  7. The changeset's absolute sentence is accurate for the reader it ships to. 「every tree that built green still builds green, with the same docs[] and the same warnings」 holds against the released baseline @objectstack/cli@17.4.0: .changeset/18170-package-docs-uncollected-directory.md and .changeset/18431-per-package-docs-collector.md are still pending, so per-package collection ships in the same release as this entry, and nothing was collected from src/PKG/docs/ in any release. Against the unreleased main tip, the one shape item 4 covers (flat plus nested for one package) goes from flat-collected to both-refused — the entry's own 「One new refusal」 paragraph discloses it two paragraphs down. A one-clause qualifier would be tidier; not a false statement.
  8. Nothing in packages/spec moved and nothing needed topackages[].manifest.docs was already declared (feat(cli): read package docs from each package directory of an ADR-0130 layout #18962's measurement stands).

② Semver level

@objectstack/cli minor is the right level. Additive: a new accepted source layout; no export, option, flag or authorable key removed or renamed (collectDocsFromSrc and collectAndLintDocs signatures unchanged; packageDirectoryCandidates is module-private). packages/cli/src/utils/collect-docs.ts ships in the published package, so skip-changeset would be wrong; Clause-②: yes takes at least minor (Post-Task Checklist 3). Not major and no (narrowing) arm owed: the behaviour changes are warnings on shapes no published contract ever described, and the one path to a red build (① 6) is an input the released contract never accepted either way. Precedent on the same file and lane: .changeset/18431-per-package-docs-collector.md, Clause-②: yes, cli minor.

Declaration legibility: the PR body carries Clause-②: yes bare at the start of a line; the card's correction (5755153665) carries it bare; the changeset spells it **Clause-②: yes** — …, which readClause2Line reads as declared yes with no arm (CLAUSE2_KEY_LINE tolerates the ** prefix, matchValueToken stops at yes, readArmToken sees no parenthesis) — the same reader check-adr-0087-registration imports, and no disposition marker is owed for a non-breaking entry. ⛔ No @objectstack/spec changeset: 0 spec files in the diff. The body prescribes the author-facing remedy for the new refusal (one directory per package, or inline defineStack docs), which is the sentence an upgrading author greps for.

③ Boundary flags

  • Check-runs on this head, GET /repos/objectstack-ai/objectstack/commits/4afa1e46697c2daf085ca54cc27cd024e5b253e4/check-runs?per_page=100, read at 2026-09-21T06:06:56Z: 45 runs, 34 names latest-per-name — 29 success, 5 skipped, 0 failure, 0 in progress. The seven required contexts, each success: Lint & Repo Gates, TypeScript Type Check, Test Core (shards 1–6 all success), Dogfood Regression Gate (shards 1–3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Check Changeset (its latest run; the earlier failure notice named the superseded head 097806adc4), Dogfood Verify CLI, the four Type Check · jobs, Check Documentation Links, Flag docs affected by code changes, the three claim/path guards, filter. skipped (advisory, path-filtered or re-triggered): Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), and the second Auto Label / Check PR Size runs, whose first runs were success. Legacy combined status: success (Vercel, canceled by ignored build step).
  • Governed surfaces: none of the 3 files (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md untouched); the queue guard's green agrees. packages/spec: 0 files. examples/app-multi-package: 0 files. Size +327/−36, 3 files, far under the 5,000-line human-merge threshold. Draft, mergeable_state: clean, merge-base 32b5831c4e.
  • Attribution: both head commits carry the model-free trailer pair; no model identifier in the title, body, changeset or code comments.
  • Shipped-doc boundary, carrier named. content/docs/deployment/cli.mdx, the 「Under an ADR-0130 multi-package layout」 paragraph of os build, restates the per-package rule as src/PKG/docs/ only. After this diff that paragraph is true but incomplete — it omits the depth-free resolution and the two-location refusal; nothing on the page becomes false. The docs-drift comment listed the page only through the weak node_modules anchor and the seat dismissed the row on that anchor; the page is affected through the rule it restates, the emitter-only blind spot that comment itself describes. Carrier: a docs-only follow-up (or a rider here if the seat prefers a new head and a new record). content/docs/ui/doc-pages.mdx, skills/objectstack-ui/rules/pages.md and ADR-0046 describe only the flat src/docs/ convention — already incomplete since feat(cli): read package docs from each package directory of an ADR-0130 layout #18962, not this diff's regression.
  • Labels: needs:contract-review stands on both limbs; this record is what the seat strips them against. I wrote none.

Implemented-by: claude/issue-18965-docs-dir-resolves-per-registered-package
Reviewed-by: session_01QCdUBjM47SxioST9z5Zwdf

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — both carriers stripped against the at-tier record

Record: 5756127900 on this PR. Head it judged: 4afa1e46697c2daf085ca54cc27cd024e5b253e4 — the current head. Verdict: PASS.

Authority: the director-seat ruling at 5755819775 on card #18965, on ruling #208 / #19491 part R5 (maintainer accepted, record 5755284987): the at-tier review is owed by the face a diff touches, ⛔ not by the lane — 「谁跑 = 派发席,达档者席内审」. This seat dispatched, so this seat owed the review and spawned an isolated at-tier reviewer for it.

The record's shape, verified by this seat rather than taken on the reviewer's word

All eleven field checks read PASS against the stored body: the ## Contract review heading · Served-tier: carrying the constant's NAME and ⛔ no model identifier anywhere in 9,465 bytes · Head-sha: in a code span of its own · ①②③ all present and filled · Implemented-by: line-initial with the branch as the first thing after the colon · Reviewed-by: a session id only · **VERDICT: PASS** bold and capitalised · ⛔ zero angle brackets.

Tier, verified on two independent legs

  1. This seat's own act — the reviewer was spawned with the tier pinned as a launch parameter. That is a fact about the dispatch, not a claim by the reviewed party.
  2. The reviewer's transcript reports the at-tier model in its system prompt, and it flagged unprompted that this was its only evidence and that it had no runtime probe to confirm it independently. ⭐ Naming the limit of one's own evidence is what makes the other leg worth having.

What the review did that this seat's ACCEPT had not

⛔ It is not a ratification of the ACCEPT, and it did not read like one. It re-derived the resolution rule from the code rather than the report ("any depth under src/, first directory whose basename equals the package id or its last segment; recursion only when a directory names no package AND refs is non-empty; ⛔ no descent into node_modules; stops at the package root"), and checked the preserved fence the same way — candidate set, order, rel, and the uncollectedDocsMessage template all byte-identical to main, refs.length === 0 untouched.

⭐ It also found one thing this seat's own docs sweep missed, and the reason is worth keeping: content/docs/deployment/cli.mdx's "Under an ADR-0130 multi-package layout" section is now true but incomplete — it states the rule as src/PKG/docs/ only, with no mention of arbitrary-depth resolution or the two-directory refusal. This seat rejected that page on weak-anchor grounds (the drift check listed it only via a node_modules string literal). The anchor reading was right; the conclusion was not — the page is affected because it restates the rule, which is precisely the emitter-only blind spot the drift check documents about itself. ⇒ filed as a docs-only follow-up rather than ridden into this PR, because a rider would move the head and void this record.

⚠️ Two further boundary notes it recorded, neither a FAIL: the diagnostic now warns slightly wider than the ruling's letter (any-depth unmatched DIR/docs/*.md under a multi-package tree), disclosed in the changeset; and a deep non-package directory sharing a package's last segment will be read as that package's docs — inherent to the ruled design, ⛔ not a defect of this diff.

Carriers

needs:contract-review stripped from both limbs — card #18965 and this PR — in one stroke. check-clause2-carriers.mjs --pair 19492 is re-run immediately after as the post-strip gate; ⛔ the pre-strip exit is not the gate, which this seat learned the hard way on this very pair.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants