fix(create-objectstack): install the skills bundle for one agent so a scaffolded project commits it once - #16401
Conversation
… scaffolded project commits it once `skills add … --all` is shorthand for `--skill '*' --agent '*' -y`, and the `--agent '*'` half wrote the same 11-skill catalog to three destinations: `.agents/` (46 real files), `agent/` (46 real files, identical bodies) and `.claude/` (11 symlinks into `.agents/`). The template `.gitignore` excluded none of it, so a new project's first `git add -A` staged 22 `SKILL.md` paths plus 11 symlinks — measured against [email protected]. The command now names one agent: `--skill '*' --agent claude-code -y`, which writes 46 real files to `.claude/skills/` and nothing else. Staged once, no symlinks, and a clone of that commit reads on every platform. Route A (keep `--all`, deny-list the duplicates) was built and cloned rather than reasoned about: ignoring `.agents/` leaves a cloner 11 dangling symlinks and zero readable SKILL.md; ignoring only `agent/` works on POSIX but commits symlinks that a `core.symlinks=false` clone materialises as plain files; and `--all --copy` fans out to 56 directories totalling 33.8 MB. A deny-list is also the wrong shape — the destination set belongs to the skills CLI and moves with its releases. The command and the printed hint now come from one builder in `src/skills-install.ts`, and the closing summary always prints an AI Skills block naming where the bundle landed plus the one-liner for other runtimes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
…per-agent install form `templates/AGENTS.md` and the blank template README both told a project's future readers to run the bare `npx skills add objectstack-ai/objectstack/skills`, which prompts for agents interactively — picking "all" there re-creates the triplicate this card removes, inside the user's own repository. Both now name the single-agent form, say where the committed copy lives, and refuse `--all` with the reason. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
📓 Docs Drift Check10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3c47b6a6f495bd9262f8bd6ca26283f4dc372547 && git checkout 3c47b6a6f495bd9262f8bd6ca26283f4dc372547
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3890244554ce94651dfdd7c1cb9fc1e1c19377ab 604dc4d28785917f18d7db7aefd9da1602f5b451 && git checkout -B drift-repro 3890244554ce94651dfdd7c1cb9fc1e1c19377ab && git merge --no-ff 604dc4d28785917f18d7db7aefd9da1602f5b451
node scripts/docs-audit/affected-docs.mjs --json 3890244554ce94651dfdd7c1cb9fc1e1c19377ab |
…lder actually runs (objectstack-ai#17868) Clause-②: no Closes objectstack-ai#16400 ## What was false Four `content/docs/**` pages recommended installing the AI skills bundle with `npx skills add objectstack-ai/objectstack/skills --all`, and **two of them stated that flag as what the scaffolder runs**. That second half was already false in the tree, not about to become false: the scaffolder moved to a single named agent when PR objectstack-ai#16401 landed (2026-09-06T20:36:10Z), so the two getting-started lines described a command `create-objectstack` no longer issues. ## What it actually runs today — re-measured, not cited Read from `packages/create-objectstack/src/skills-install.ts` on this branch's base (`c88fa2ccdc`), by evaluating the constants rather than eyeballing the template literal: ``` SKILLS_INSTALL_COMMAND npx -y skills add objectstack-ai/objectstack/skills --skill '*' --agent claude-code -y skillsInstallHint(default) npx skills add objectstack-ai/objectstack/skills --skill '*' --agent claude-code -y skillsInstallHint('codex') npx skills add objectstack-ai/objectstack/skills --skill '*' --agent codex -y DEFAULT_SKILLS_DIR .claude/skills/ ``` `index.ts:542` passes `SKILLS_INSTALL_COMMAND` to `execSync`, and lines 594-601 print `skillsInstallHint(...)` in the closing summary — so "one agent, one directory, and the command is printed for any other runtime" is the behaviour, not just the constant. A check in the PR: all **7** command literals left in the four pages are compared programmatically against the value that module composes. 0 mismatches. ## Carriers — 4 of the card's 5 surfaces are this PR's The card named five surfaces. Triage split on the landing path, not on the sentence kind: 「拆后本卡的文件面只剩 `content/docs/**`,按车道表归 `domain:devx`」. | surface | kind | state | |:---|:---|:---| | `getting-started/your-first-project.mdx` 45, 286 | states what the scaffolder runs + re-run instruction | fixed here | | `getting-started/build-with-claude-code.mdx` 57, 381 | same pair | fixed here | | `ai/skills-reference.mdx` 27, 30, **33** | manual recommendation + two prose claims | fixed here | | `ai/skills.mdx` 35, **38** | manual recommendation + prose claim | fixed here | | `skills/README.md` 9 | governed surface | not ours — already moved to the per-agent form by PR objectstack-ai#16806, verified absent from this base | The two bolded sites were not on the card's or the skills seat's line list; `skills-reference.mdx:33` and `skills.mdx:38` attribute the bundle-versioning and idempotence properties to `--all` specifically, so leaving them would have kept the flag as the recommended form in prose after the commands changed. ## Fix shape, and why not a pointer The brief asked whether to keep a corrected literal or point at the single source of truth (`skills-install.ts`), as this shift's objectstack-ai#16200 did. Split by reader: - **Where the reader must type the command** (all four pages' install and re-run instructions) the literal stays, corrected. A docs reader cannot resolve a pointer into a TypeScript module in our monorepo — that page is not on the docs site and they cannot run it. Pointing there would remove the fact instead of keeping it true, which is the vague-sentence failure in a different costume. - **Where the reader does not need the value**, the pointer shape is used: `skills-reference.mdx`'s "new projects" paragraph now states the observable properties (one runtime, `.claude/skills/`, once) and points at the live instrument the reader actually holds — *the scaffolder's own closing summary prints the exact command it ran*. That paragraph previously named no command, so this keeps the number of decaying copies at what it was rather than adding an eighth. `--all` is not deleted: it is a real CLI option, and triage's instruction was that the idempotence claim is true and must not be removed. It survives on the reference page as a labelled multi-runtime opt-in that names its cost (three destinations: real copies in both `.agents/` and `agent/`, plus `.claude/` symlinks), with the distinction that makes the idempotence claim useful — re-running is idempotent *per destination*, and it is the destination count that grows. ## Reverse-read *Does anywhere else state the scaffolder's behaviour, and does this diff make any standing sentence false?* - `content/docs/deployment/cli.mdx` 29, 93, 94 — "installs the AI skills bundle + `AGENTS.md`". True, names no command, no decay. **Unchanged.** - `content/docs/getting-started/how-ai-development-works.mdx:45`, `getting-started/index.mdx` 26, 161 — same shape. **Unchanged.** - `getting-started/build-with-claude-code.mdx:70` — a console transcript quoting `→ Installing AI skills for your coding agent...`, byte-equal to `index.ts`'s `printStep`. True. `pnpm check:docs-transcript-drift` measured it green on the final text. **Unchanged.** - `getting-started/build-with-claude-code.mdx:26` (mermaid) and `your-first-project.mdx:272` — "installs the skills bundle", no command. **Unchanged.** - `packages/create-objectstack/README.md:60`, `src/templates/AGENTS.md:82`, `src/templates/blank/README.md:148`, `skills/README.md:9` — all four already carry the per-agent form. This diff agrees with them; none becomes false. - `packages/create-objectstack/src/template-consistency.test.ts:597-602` — "the boundary is the `/skills` SUBPATH, and it did not move when the scaffolder stopped passing `--all`". Directional and historical, still true, and this diff keeps the subpath on every literal. - **Zero** results for: any other `content/docs` page naming a `skills add` command; any test or gate pinning `--all` in the docs (none exists — see the acceptance note below); any generated block containing an install command (`build-skill-docs.ts` emits none; `skills-reference.mdx`'s generated region starts at line 38, below every site edited). One sentence outside this card's file surface **is** made false by this diff, reported rather than fixed: `.github/workflows/scaffold-e2e.yml:229-231` says in a comment "The scaffolder/docs command is `skills add …/objectstack/skills --all`". The scaffolder half was already stale; the docs half becomes stale here. The probe below it deliberately uses `--all --copy` and is correct for its purpose (set-equality against the curated catalog), so only the comment is wrong. Left alone because triage split this card's file surface to `content/docs/**` and editing a workflow would add a verification surface this PR does not otherwise touch. ## 验收备注 - **No gate pins the docs against the scaffolder's command.** Triage flagged building one as a bonus and explicitly told the claiming seat not to expand the PR into `scripts/` for it — so the programmatic literal comparison above was run as a one-off in this PR and is not committed. Noted, not filed: the next surface to state this command has nothing mechanical to catch it, and the docs-drift audit states it structurally cannot see this class. - `docs/qa/platform-checklist/areas/cli.json:384` uses `--all --copy` as a skills-boundary probe. Not a statement of scaffolder behaviour; unaffected. - `.claude/skills/dogfood-verification/SKILL.md:13` notes that the CLI's `--all` implies `--skill '*'`. True CLI semantics, governed surface, untouched. ## Verification Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (change set taken by the script from the merge base, not from a hand-written diff): **43 families, 43 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0** — reconciled back through `--ran` with an exit code recorded per family, so the zero is derived rather than claimed. Four of them first refused with `PREREQUISITE NOT MET` (exit 3, not a finding); `@objectstack/lint`, `@objectstack/formula` and `@objectstack/client-react` were built and all four then exited 0. Named readings, all on the final text: ``` pnpm --filter @objectstack/spec check:docs ✅ 222 generated files in sync with packages/spec pnpm check:docs-transcript-drift ✓ 4 declared transcript value(s) across 402 page(s) under content/docs/ equal what the registry derives today pnpm check:doc-authoring ✓ 44 published skill files clean pnpm check:corpus-claim-drift OK, no new claim sites beside a pinned spelling pnpm check:role-word OK, no new occurrences of the reserved word pnpm check:nul-bytes exit 0 ``` Heavy steps ran through `scripts/pm/os-verify-lock.sh` (slot `issue-16400`): `pnpm install` `VERDICT command-exit 0`, `@objectstack/spec build` `VERDICT command-exit 0`, the three-package turbo build `VERDICT command-exit 0`. **`pnpm lint` — a declared narrowing, measured in three parts.** The repo-scoped run is CI's; the reading here is that this diff is outside eslint's population entirely. (1) Population from eslint's own config, not a guess: `new ESLint().isPathIgnored(...)` returns `true` for the changed `.mdx` files. (2) File count from `--format json` over exactly the four changed files: 4 files read, 0 errors, and each of the 4 warnings is `File ignored because no matching configuration was supplied`. (3) Invariance: this repo runs one `eslint.config.mjs` which never enables type-aware linting for any file (`eslint.config.mjs:325-332`; no `parserOptions.project`), so nothing in this diff can move the verdict on a file it does not touch. Union run against final `HEAD` `0612e8bfe8`. **Changeset: skipped, measured.** No package's `files[]` names `content/docs` — checked across every `packages/*/package.json` carrying a `files` array, zero hits. The only consumer of this tree is `apps/docs` (`@objectstack/docs`, `private: true`, no `files`). Positive control: the corrected command string does appear in published surfaces (`packages/create-objectstack/README.md`, `src/templates/AGENTS.md`, `src/templates/blank/README.md`) — none of which this diff touches. Nothing shipped moves, so the `skip-changeset` label is applied rather than a body sentence. **`Clause-②: no`** — the diff is four `.mdx` prose files: no schema key, no closed-set member, no published export, no registry entry. `pnpm check:pm-widening-tells` exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ Co-authored-by: Claude <[email protected]>
Fixes #16331
Route B, chosen on measurement
The card offered two routes and made the choice an in-card decision. Route B — install for one agent runtime by default and print the one-liner for the others — was taken, and every alternative was built and cloned rather than argued about.
Measured against
[email protected]and this repo's 11-skill catalog, each into an empty directory:--all(=--skill '*' --agent '*' -y).agents/46 real files 604,102 B ·agent/46 real files 602,682 B ·.claude/11 symlinks into.agents/--skill '*' --agent claude-code -y.claude/46 real files 604,102 B, and nothing else--skill '*' --agent universal -y.agents/46 real files 604,102 B, and nothing else--all --copyThen, with the template's
.gitignorein place,git init && git add -A && git commit, thengit cloneinto a clean directory:SKILL.md--all, nothing ignored (today)--all, ignore.agents/+agent/(Route A, naive)SKILL.md--all, ignoreagent/only (Route A, viable)core.symlinks=falseclone the 11 links become ordinary files whose whole content is../../.agents/skills/NAME--skill '*' --agent claude-code -y(this PR)Route A's naive shape is the trap the triage comment predicted, and it is reproduced above by actually cloning, not by reasoning. Route A's viable shape does satisfy the card's acceptance on POSIX, and it was rejected for two reasons: it commits symlinks that git-for-Windows' default checkout materialises as stray text files, and — the structural one — a
_gitignoredeny-list is the wrong shape regardless of which paths it names. This package does not choose the destination set; the skills CLI does, and it moves with that package's releases.created-summary.tsalready documents the same property for the same reason. An ignore list has to chase it silently, in the direction that re-commits duplicates. Naming our own destination is explicit composition instead of default magic.The cost is the multi-runtime default, and it is paid in the open (see the new closing block below).
Acceptance, verified end to end
A real scaffold with the real skills CLI (only the package manager was stubbed; an install is irrelevant to the skills bundle), then
git add -A, commit, andgit cloneinto a clean directory:No
.agents/, noagent/, nothing left untracked.What changed
src/skills-install.ts(new) — one source for the command. The scaffolder runs it and the closing summary prints the same builder's output with a different agent name; a drifted pair is how a project acquires the second copy. The header carries the measurement table above.src/index.ts— runsSKILLS_INSTALL_COMMAND; the closing summary now prints an AI Skills block on every path, not only the skipped one. A single-agent install leaves every other runtime uninstalled, and an absent capability has to say so with the remedy:src/scaffold-skills-single-copy.test.ts(new) — the acceptance assertion the card asked for, counting stagedSKILL.mdpaths. It runs the real CLI throughtsxunder a stubbed PATH; the skills CLI is stubbed from the measured destination map, and the stub refuses an argv it has no measured row for (exit 3) so a command nobody measured fails loudly instead of passing against an invented layout. A vacuity block replays the--allrow and requires the same assertions to fail.src/template-consistency.test.ts— the/skillssubpath boundary is unchanged and still enforced; the pin now asserts the value the scaffolder runs rather than the source text that happens to hold it, and a second case keeps a hand-written duplicate invocation from creeping back in.src/created-summary.ts,README.md,src/templates/AGENTS.md,src/templates/blank/README.md— prose. The two template files told a project's future readers to run the bare interactive form; picking "all" there re-creates the triplicate inside the user's own repository, so both now name the per-agent form and refuse--allwith the reason.create-objectstackminor — Route B changes what gets installed, per the card._gitignoreis deliberately unchanged: with one destination there is nothing to exclude, and a deny-list for directories we no longer create is dead text.Verification
pnpm --filter create-objectstack test— 16 files, 203 passed.typecheckclean, andtsc --listFilesconfirms both new files are in the program (atypecheckthat misses the test files is not coverage).HEADblob): revertingSKILLS_INSTALL_COMMANDto--allturns 9 of 14 cases red, with the defect's own signature — staged roots['.agents','agent'], 6 stagedSKILL.mdinstead of 3, 3 symlinks. Restore leg:git diff HEADempty, blob hash identical toHEAD.node scripts/pm/dispatch-gates.mjs --ranreports 55 derived families, 55 run, 0 NOT-MEASURED, 0 UNRUN — including the two template families this path set pulls in (check:template-manifests,check:template-version-sync) andcheck:published-files.pnpm lint— the full repo-wideeslint . --no-inline-config, exit 0 in 100s at604dc4d2. Not a narrowing.packages/cliis inturbo ls --affected(it declarescreate-objectstackas a dependency): closure built,--project unitgreen — 181 files, 2438 passed. The other six affected packages reach this diff only through the one published entry point,create-objectstack/created-summary, and that entry point is byte-identical across the diff (dist/created-summary.jsand.d.tssha256 unchanged when the file is reverted toorigin/mainand rebuilt).Three gate results needed a note rather than a re-run:
check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3) because the workspace closure was unbuilt — both green once it was built.check:type-check-debtthen OOM'd under the container's default--max-old-space-size=4096, which is tighter than the 6144 ceiling the gate pins for itself; green at its own ceiling.check-plugin-teardown-shape --self-testcould not reach its pinned fixture commit on a shallow checkout, and is green aftergit fetch origin 621a4876.Out of scope, filed
#16400 — five documentation surfaces still tell readers to install with
--all, and two of them describe what the scaffolder does, so they go stale with this PR.content/docs/**andskills/**are outside this card's file surface and pull in a different gate family (skills/**is governed). Following those instructions in a git repository reproduces the triplication independently of the scaffolder, so closing this card does not close that.Not touched, per the card: the CI workflow (#16330, landed as
4998efa7),packages/cli/src/commands/init.ts(#16350), the scaffolded-project SyntaxError (#16373), andcontent/docs/releases/**.Generated by Claude Code