Skip to content

fix(create-objectstack): install the skills bundle for one agent so a scaffolded project commits it once - #16401

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-16331-skills-single-copy
Sep 6, 2026
Merged

os-sam merged 2 commits into
mainfrom
claude/issue-16331-skills-single-copy

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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:

command result on disk
--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 --copy 56 destination directories, 33.8 MB

Then, with the template's .gitignore in place, git init && git add -A && git commit, then git clone into a clean directory:

shape staged SKILL.md staged symlinks fresh clone
--all, nothing ignored (today) 22 11 readable, but the bundle is committed twice
--all, ignore .agents/ + agent/ (Route A, naive) 0 11 11 dangling symlinks, 0 readable SKILL.md
--all, ignore agent/ only (Route A, viable) 11 11 readable on POSIX; on a core.symlinks=false clone the 11 links become ordinary files whose whole content is ../../.agents/skills/NAME
--skill '*' --agent claude-code -y (this PR) 11 0 readable everywhere

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 _gitignore deny-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.ts already 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, and git clone into a clean directory:

staged total          : 62
staged SKILL.md paths : 11
staged symlinks       : 0
untracked after add   : 0
staged roots          : .claude .dockerignore .github .gitignore AGENTS.md Dockerfile
                        README.md docker-compose.yml objectstack.config.ts
                        objectstack.manifest.json package.json pnpm-workspace.yaml
                        skills-lock.json src tsconfig.json

CLONE into a clean directory
  cloned SKILL.md files : 11
  dangling symlinks     : 0
  .claude/ total        : 604,102 bytes
  ./.claude/skills/objectstack-ai/SKILL.md        21,866 bytes
  ./.claude/skills/objectstack-api/SKILL.md       18,535 bytes
  ./.claude/skills/objectstack-automation/SKILL.md 50,043 bytes

No .agents/, no agent/, 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 — runs SKILLS_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:
      AI Skills:
        Installed for claude-code → .claude/skills/ — commit it with your project.
        Using a different agent? Add it one runtime at a time:
          npx skills add objectstack-ai/objectstack/skills --skill '*' --agent codex -y
          (`npx skills add --help` lists every agent name)
    
  • src/scaffold-skills-single-copy.test.ts (new) — the acceptance assertion the card asked for, counting staged SKILL.md paths. It runs the real CLI through tsx under 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 --all row and requires the same assertions to fail.
  • src/template-consistency.test.ts — the /skills subpath 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 --all with the reason.
  • Changeset: create-objectstack minor — Route B changes what gets installed, per the card.

_gitignore is 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. typecheck clean, and tsc --listFiles confirms both new files are in the program (a typecheck that misses the test files is not coverage).
  • Ablation (from a committed implementation, on-disk mutation proved by blob hash, restored and re-verified against the HEAD blob): reverting SKILLS_INSTALL_COMMAND to --all turns 9 of 14 cases red, with the defect's own signature — staged roots ['.agents','agent'], 6 staged SKILL.md instead of 3, 3 symlinks. Restore leg: git diff HEAD empty, blob hash identical to HEAD.
  • Gates: node scripts/pm/dispatch-gates.mjs --ran reports 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) and check:published-files.
  • pnpm lint — the full repo-wide eslint . --no-inline-config, exit 0 in 100s at 604dc4d2. Not a narrowing.
  • packages/cli is in turbo ls --affected (it declares create-objectstack as a dependency): closure built, --project unit green — 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.js and .d.ts sha256 unchanged when the file is reverted to origin/main and rebuilt).

Three gate results needed a note rather than a re-run: check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3) because the workspace closure was unbuilt — both green once it was built. check:type-check-debt then 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-test could not reach its pinned fixture commit on a shallow checkout, and is green after git 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/** and skills/** 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), and content/docs/releases/**.


Generated by Claude Code

… 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
@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 4 changed file(s) yielded no anchor (packages/create-objectstack/README.md, packages/create-objectstack/src/created-summary.ts, packages/create-objectstack/src/templates/AGENTS.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/create-objectstack/README.md, packages/create-objectstack/src/created-summary.ts, packages/create-objectstack/src/templates/AGENTS.md, …) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 9 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 3890244554ce94651dfdd7c1cb9fc1e1c19377abpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c47b6a6f495bd9262f8bd6ca26283f4dc372547 — the merge of head 604dc4d28785917f18d7db7aefd9da1602f5b451 into base 3890244554ce94651dfdd7c1cb9fc1e1c19377ab, 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 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@os-sam
os-sam marked this pull request as ready for review September 6, 2026 18:52
@os-sam
os-sam added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit fd75728 Sep 6, 2026
37 checks passed
@os-sam
os-sam deleted the claude/issue-16331-skills-single-copy branch September 6, 2026 20:36
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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]>
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/l tests tooling

Projects

None yet

2 participants