diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md deleted file mode 100644 index 568d537c6..000000000 --- a/.agents/AGENTS.md +++ /dev/null @@ -1,56 +0,0 @@ -# Translated Claude Code rules for Antigravity - -These project-level rules are translated from the user's global Claude configuration (`~/.claude/CLAUDE.md` and `~/.claude/hooks/*`). They govern all operations inside this workspace. - -## Commit Per Logical Unit, Do Not Batch -- **Commit per logical unit as soon as it is complete.** -- **Push after each commit.** -- Do not batch unrelated changes into one end-of-session commit. -- A logical unit is one feature, one fix, one rename, one doc rewrite. -- Checkpoint commit triggers: - - A bug fix that produced green tests. - - A new feature that has tests passing. - - A rename or refactor that is mechanically complete. - - A doc rewrite that touches a coherent surface. - - Anything that finishes a tracked task on the TaskList. -- **Heuristic:** If more than 5 unstaged files span more than one logical concern, stop and commit the already-complete units before continuing. - -## Concise by Default -- Answer directly. Skip preamble, skip restating the question, skip exhaustive analysis when a short answer suffices. -- For most questions, two to four sentences is the right length. -- If unsure whether the question needs a long or short answer, default short and offer to expand. - -## Branch, Merge, and Push Guards -- **Never push to `main` / `master`.** Always push to the feature branch and create a PR via `gh pr create`. -- **Never edit or write code on `main` / `master`.** If on `main`/`master`, **STOP** and create a feature branch first (`git checkout -b feature/`). -- When merging to `main`, the workflow is `gh pr create` -> confirm -> `gh pr merge`. Never do a local `git merge` + push. -- **Merge Approval:** Never merge without permission. Ask exactly: "Ready to merge `` into ``? After merging, should `` be deleted or kept?" and wait for both answers. -- **Command Guard:** Ask the user before running any `git merge` command or `git push` targeting `main`/`master`. - -## Commit Integrity -- **No AI attribution in commits.** Never add `Co-Authored-By: Claude`, `Generated by AI`, `AI-assisted`, or similar trailers / prefixes. -- **Imperative-mood commit subjects under 72 chars.** The body explains the reason for the change, not the diff. -- **Run tests before committing** when the project has them. Respect pre-commit hooks, do not pass `--no-verify`. - -## Concurrent Agent Worktree Isolation -- **One task per git worktree when agents may run concurrently.** If more than one agent (or more than one in-flight task) might touch a repo at the same time, do NOT share one working directory. -- Give each task its own worktree: - ```sh - git worktree add -b ../- origin/main - cd ../- - ``` - And clean it up after merge: - ```sh - git worktree remove ../- - ``` -- Before committing in a shared checkout, confirm `git branch --show-current` is still the branch you created. If it moved, switch to a worktree. - -## Custom Skills Usage -- The following skills have been translated and are available in `.agents/skills/`: - - `webjs-start-work`: Trigger when starting a tracked issue on the webjs project board. - - `webjs-doc-sync`: Trigger when documenting new public surfaces or finding doc gaps/drift. - - `webjs-file-issue`: Trigger to file a grounded issue on the board. - - `webjs-list-todos`: Trigger to check the list of TODOs. - - `webjs-research-record`: Trigger to search and record findings. - - `use-railway`: Trigger when interacting with Railway deploys. -- Always use the `view_file` tool on the matched skill's `SKILL.md` before executing its tasks. diff --git a/.agents/rules/workflow.md b/.agents/rules/workflow.md new file mode 100644 index 000000000..58c0b0a57 --- /dev/null +++ b/.agents/rules/workflow.md @@ -0,0 +1,81 @@ +# Workspace rules for agents that read `.agents/rules/` + +These project-level rules govern all operations inside this workspace. Antigravity loads every markdown file in `.agents/rules/`; the repo-root `AGENTS.md` remains the full contract and this file does not replace it. + +## Commit Per Logical Unit, Do Not Batch +- **Commit per logical unit as soon as it is complete.** +- **Push after each commit.** +- Do not batch unrelated changes into one end-of-session commit. +- A logical unit is one feature, one fix, one rename, one doc rewrite. +- Checkpoint commit triggers: + - A bug fix that produced green tests. + - A new feature that has tests passing. + - A rename or refactor that is mechanically complete. + - A doc rewrite that touches a coherent surface. + - Anything that finishes a tracked task on the TaskList. +- **Heuristic:** If more than 5 unstaged files span more than one logical concern, stop and commit the already-complete units before continuing. + +## Concise by Default +- Answer directly. Skip preamble, skip restating the question, skip exhaustive analysis when a short answer suffices. +- For most questions, two to four sentences is the right length. +- If unsure whether the question needs a long or short answer, default short and offer to expand. + +## Branch, Merge, and Push Guards +- **Never push to `main` / `master`.** Always push to the feature branch and create a PR via `gh pr create`. +- **Never edit or write code on `main` / `master`.** If on `main`/`master`, **STOP** and create a feature branch first (`git checkout -b feature/`). +- When merging to `main`, the workflow is `gh pr create` -> confirm -> `gh pr merge`. Never do a local `git merge` + push. +- **Merge Approval:** Never merge without permission. Ask exactly: "Ready to merge `` into ``? After merging, should `` be deleted or kept?" and wait for both answers. +- **Command Guard:** Ask the user before running any `git merge` command or `git push` targeting `main`/`master`. + +## Commit Integrity +- **No AI attribution in commits.** Never add `Co-Authored-By: Claude`, `Generated by AI`, `AI-assisted`, or similar trailers / prefixes. +- **Imperative-mood commit subjects under 72 chars.** The body explains the reason for the change, not the diff. +- **Run tests before committing** when the project has them. Respect pre-commit hooks, do not pass `--no-verify`. + +## One Task Per Git Worktree +- **One task per git worktree, ALWAYS.** There is no lone-agent exception: this repo is worked by multiple agents at once, and "no other agent is active right now" is unverifiable mid-task, since another session can start any minute. Never share one working directory across tasks. +- Two agents in one checkout collide: a `git checkout` in one moves `HEAD` under the other, so the next commit lands on the WRONG branch. Git enforces one branch per worktree, which is what makes the collision impossible. +- Give each task its own worktree: + ```sh + git worktree add -b ../- origin/main + cd ../- + ``` + And clean it up after merge: + ```sh + git worktree remove ../- + ``` +- Before every commit, confirm `git branch --show-current` is still the branch you created. If it moved, you are colliding with another session, so stop and move the work into its own worktree. +- The primary checkout stays an untouched mirror of `main`. Do all work in the task's worktree, never there. + +## Custom Skills Usage +- These skills are symlinked into `.agents/skills/` from `.claude/skills/`, so both engines load one copy. Every entry here must have a matching symlink, and every symlink must have an entry here; `test/repo-health/agent-skill-parity.test.mjs` enforces both directions. + - `webjs-start-work`: Trigger when starting a tracked issue on the WebJs project board. + - `webjs-ready-for-dev`: Trigger when planning tracked issues into an implementable shape with verified plans. + - `webjs-file-issue`: Trigger to file a grounded issue on the board. + - `webjs-list-todos`: Trigger to check the list of TODOs. + - `webjs-research-record`: Trigger to search and record findings. + - `webjs-doc-sync`: Trigger when documenting new public surfaces or finding doc gaps or drift. + - `webjs-scaffold-sync`: Trigger when changing the CLI generators, the scaffold templates, or the agent teaching skill. + - `webjs-blog-write`: Trigger when writing, drafting, or editing a WebJs blog post under `blog/`. + - `webjs-instagram-post`: Trigger when publishing an SEO post to the WebJs Instagram account. + - `use-railway`: Trigger when interacting with Railway deploys. +- The framework teaching skill at `.agents/skills/webjs/` is a real directory rather than a symlink, and is the reference for building WebJs apps rather than a workflow trigger. +- Always use the `view_file` tool on the matched skill's `SKILL.md` before executing its tasks. + +## Enforcement gates + +Everything under `.claude/hooks/` fires only inside Claude Code, and that is more than the blocking gates: seven `PreToolUse` hooks that can refuse a tool call, one `UserPromptSubmit` skill router, and three `PostToolUse` hooks. None of it runs here. Two gates bind every agent regardless of engine, and neither is optional. + +- `.hooks/pre-commit` runs on every commit. It blocks a direct commit to `main` or `master` and blocks a published-library version bump on any branch that is not `chore/release-*`. Never pass `git commit --no-verify`. +- `.github/workflows/ci.yml` is the test gate. Branch protection blocks a merge until the five REQUIRED checks pass: `Conventions (webjs check)`, `Unit + integration (node --test)`, `Browser (web-test-runner / Playwright)`, `E2E (Puppeteer against the blog example)`, and `Build (@webjsdev/core dist)`. The workflow defines more jobs than those five, the Bun matrix among them, so a green required set is not the same as green CI. Read every check rather than trusting the merge button to have judged for you. `.hooks/pre-commit` deliberately does not run the suite because CI does. + +Because the tool-call gates do not fire here, self-check the workflow they enforce before every commit. Root `AGENTS.md` carries the full contract under Code workflow, and each gate has its own trigger conditions, so read the rules there rather than assuming a given change trips all of them. What those gates would otherwise have caught: + +- Tests for every layer the change touches, staged with the change. +- The doc surfaces that change with it. +- A scaffold surface, when a feature under `packages/core`, `packages/server`, or `packages/cli` source changes what `webjs create` generates. +- A `test/bun/**` cross-runtime assertion, when the change touches a runtime-sensitive surface (the serializer, the listener and request path, SSR, action or CSRF dispatch, streams, `node:crypto`, the TypeScript stripper, auth, session, or cors). +- In an APP component, extending `WebComponent(...)` rather than raw `HTMLElement`. Framework source under `packages/` is exempt. +- One task per worktree, and invariant 11 on prose punctuation plus `WebJs` brand casing. These two bind every task and every edit. + +The first four have a documented opt-out for a change that genuinely does not need them (`WEBJS_NO_TEST_GATE`, `WEBJS_NO_DOC_GATE`, `WEBJS_NO_SCAFFOLD_GATE`, `WEBJS_BUN_VERIFIED`), and root `AGENTS.md` states when each applies. Reaching for one to skip work the change actually needs is the misuse they exist despite. diff --git a/.agents/skills/webjs-instagram-post b/.agents/skills/webjs-instagram-post new file mode 120000 index 000000000..a296ad42e --- /dev/null +++ b/.agents/skills/webjs-instagram-post @@ -0,0 +1 @@ +../../.claude/skills/webjs-instagram-post \ No newline at end of file diff --git a/.agents/skills/webjs-ready-for-dev b/.agents/skills/webjs-ready-for-dev new file mode 120000 index 000000000..3d0f8a007 --- /dev/null +++ b/.agents/skills/webjs-ready-for-dev @@ -0,0 +1 @@ +../../.claude/skills/webjs-ready-for-dev \ No newline at end of file diff --git a/.agents/skills/webjs-scaffold-sync b/.agents/skills/webjs-scaffold-sync new file mode 120000 index 000000000..d1fffdd19 --- /dev/null +++ b/.agents/skills/webjs-scaffold-sync @@ -0,0 +1 @@ +../../.claude/skills/webjs-scaffold-sync \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 90aebc3e2..93f0a759d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ itself): commands, repo-health git config, changelog flow, dev error overlay. ## AI-driven development: guardrails for all agents -**WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI, `.github/copilot-instructions.md` for Copilot in VS Code), never a duplicated rule set. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`). +**WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code), never a duplicated rule set. The scaffold shipped `GEMINI.md` and `.github/copilot-instructions.md` as further bridges until #1368 removed them, so `CLAUDE.md` is the only one today. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`). ### Before starting ANY work: verify and sync the branch diff --git a/blog/ai-first-is-plumbing.md b/blog/ai-first-is-plumbing.md index 1efda0908..2dcb67ed2 100644 --- a/blog/ai-first-is-plumbing.md +++ b/blog/ai-first-is-plumbing.md @@ -21,14 +21,13 @@ Every scaffolded WebJs app (the starter project `webjs create` generates for you AGENTS.md agent contract (this is the load-bearing one) CONVENTIONS.md project-specific overridable conventions CLAUDE.md Claude Code import file (points at AGENTS.md) -.cursorrules Cursor rules (same content, different format) +.agents/skills/webjs/ the teaching skill, loaded on demand .agents/rules/workflow.md Antigravity (Google) workspace rules -.github/copilot-instructions.md GitHub Copilot .github/pull_request_template.md PR template (also AI-readable) .editorconfig text-tool consistency ``` -The trick is that all of them say the same thing. AGENTS.md is the source of truth; CLAUDE.md is just `@AGENTS.md` (Claude Code's import syntax). Cursor and Antigravity (formerly Windsurf) use their own formats that load equivalent content. The PR template carries the convention checklist into every code review. +The trick is that all of them say the same thing, and that there are fewer of them than there used to be. AGENTS.md is the source of truth. CLAUDE.md is just `@AGENTS.md` (Claude Code's import syntax), and it is the only bridge file left, because Cursor, opencode, Antigravity, and the Copilot coding agent all read AGENTS.md natively now. The PR template carries the convention checklist into every code review. Most agents read whichever file matches their tool first. AGENTS.md is the cross-tool standard ([emerging spec, FYI](https://agents.md/)). Every WebJs scaffold ships it. @@ -68,13 +67,12 @@ The lint is intentionally narrow. Every rule catches something that is wrong to - `.claude/hooks/block-prose-punctuation.sh` (blocks em-dashes, pause-semicolons, and other patterns that come from training data but don't fit our docs) - `.claude/hooks/guard-branch-context.sh` (intercepts Edit/Write when the agent is on main, forces a feature branch) - `.claude/hooks/nudge-uncommitted.sh` (reminds the agent to commit when uncommitted-file count crosses a threshold) -- `.gemini/hooks/nudge-uncommitted.sh` (same threshold logic, Gemini CLI format) -- `.cursor/hooks/nudge-uncommitted.sh` (same, Cursor 1.7+ format) -- `.opencode/plugins/nudge-uncommitted.ts` (same, OpenCode plugin format) +- `.claude/hooks/require-tests-with-src.sh` (warns when source is staged with no test beside it) +- `.claude/hooks/check-server-imports.sh` (catches a server-only import reaching a module that ships to the browser) -Each hook is a small shell script (or TS plugin for OpenCode). They fire on the agent's tool-call events. They are advisory for everything except the branch-guard, which actively blocks edits when on main. +Each hook is a small shell script. They fire on the agent's tool-call events. They are advisory for everything except the branch-guard, which actively blocks edits when on main. -The interesting bit is that the framework ships hooks for multiple agents in the same scaffold. The agent picks the one matching its tool; the others are inert. +These are Claude-only, and that is now a deliberate choice rather than an accident of what got written first. The scaffold used to carry the same nudge logic in Gemini, Cursor, and opencode formats too. Keeping four copies of one rule in four config dialects turned out to cost more than it bought, so the scaffold dropped them and kept the two enforcement layers that bind every agent regardless of tool: the pre-commit hook above, and CI. # WEBJS_PUBLIC_* environment shim @@ -106,7 +104,9 @@ What is exciting is watching an agent take the framework as a given. No "where d # What I am still figuring out -The hooks fragment across tools. Every new agent CLI (Cline, Codex, Factory Droid, Aider, etc.) wants its own hook format. We can ship the same content in each format via the scaffold, but maintaining six near-identical files is brittle. The longer-term answer is for AGENTS.md to become the universal contract (which is happening, slowly) and the per-tool hooks to read from it. +The hooks fragment across tools. Every new agent CLI (Cline, Codex, Factory Droid, Aider, etc.) wants its own hook format, and for a while the scaffold tried to keep up by shipping the same content in each one. That was a mistake, and I have since deleted those copies. Six near-identical files drift, and a drifted rule file is worse than a missing one, because an agent reads it and believes it. The bet now is that AGENTS.md becomes the universal contract, which is largely how it has played out, and that anything genuinely protective lives at a layer every tool has to pass through anyway. A pre-commit hook and a CI job do not care which agent wrote the code. + +What I have not solved is enforcement for a tool that is not Claude Code. The blocking tool-call gates only fire there, so an agent in another editor gets the contract and the commit-time gates but not the live ones that catch a mistake as it is typed. The other thing is the AGENTS.md size budget. We are at ~40k characters and growing. Each new feature adds a recipe, an invariant, or a doc-link. Agents have token windows that get pricey above ~50k. We are about to need a "load this section on demand" mechanism. The skill at `.agents/skills/webjs/` (SKILL.md plus its `references/`) is the start of that pattern: detail references that load only when relevant. diff --git a/blog/stop-ai-agents-breaking-your-code.md b/blog/stop-ai-agents-breaking-your-code.md index 2e2d27a6c..ffb00ceeb 100644 --- a/blog/stop-ai-agents-breaking-your-code.md +++ b/blog/stop-ai-agents-breaking-your-code.md @@ -15,18 +15,17 @@ I wrote about the philosophy of this in an earlier post ("AI-first is plumbing, # The rules go where the agent will actually read them -An agent reads whichever instruction file matches its tool. So WebJs ships all of them, and they all say the same thing. +An agent reads whichever instruction file matches its tool. Most of them now agree on one, so WebJs writes the contract once and bridges only where a tool still needs it. ``` AGENTS.md the contract (source of truth) CONVENTIONS.md project conventions, overridable CLAUDE.md Claude Code (imports AGENTS.md) -.cursorrules Cursor -.agents/rules/workflow.md Antigravity -.github/copilot-instructions.md GitHub Copilot +.agents/rules/workflow.md Antigravity workspace rules +.agents/skills/webjs/ the teaching skill, loaded on demand ``` -Plus a `.claude/settings.json` wiring up hooks, a PR template, and an `.editorconfig`. The point is not the file count. The point is that whichever agent you happen to be driving, it lands in the project and finds the same conventions in its own native format. There is no "the agent didn't know" excuse, because the knowledge is in the file the agent reads first. +Plus a `.claude/settings.json` wiring up hooks, a PR template, and an `.editorconfig`. The point is not the file count, and the count has gone down: Cursor, opencode, and Copilot's coding agent all read AGENTS.md directly, so the separate rule files they each used to need are gone. The point is that whichever agent you happen to be driving, it lands in the project and finds the same conventions. There is no "the agent didn't know" excuse, because the knowledge is in the file the agent reads first. But a document is advisory. An agent can read a rule and still forget it three tool-calls later. That is why the load-bearing enforcement is not the docs. It is the hooks. diff --git a/blog/why-webjs.md b/blog/why-webjs.md index a84798e70..9092d67bc 100644 --- a/blog/why-webjs.md +++ b/blog/why-webjs.md @@ -65,7 +65,7 @@ What that translates to: - **Conventions are enforced by tooling.** `webjs check` lints the rules that can be checked mechanically. The pre-commit hook refuses commits that break tests, refuses commits to `main`, auto-generates changelog entries on version bumps. The framework guards itself in the seams where mistakes happen, not in a doc page. -- **AGENTS.md is the contract.** Every scaffolded WebJs app ships with `AGENTS.md` at the root: file conventions, public API, framework invariants, recipes, and a "deliberately deferred" list so the agent does not try to add a bundler. The same content lands as `CLAUDE.md`, `.cursorrules`, `.agents/rules/workflow.md` (Antigravity), `.github/copilot-instructions.md`. One source of truth, every major coding agent reads it. +- **AGENTS.md is the contract.** Every scaffolded WebJs app ships with `AGENTS.md` at the root: file conventions, public API, framework invariants, recipes, and a "deliberately deferred" list so the agent does not try to add a bundler. Cursor, opencode, Antigravity, and Copilot's coding agent read that file natively, and Claude Code gets a one-line `CLAUDE.md` pointing at it. One source of truth, every major coding agent reads it. - **No build step means console parity.** When DevTools shows an error at `app/posts/[slug]/page.ts:42:8`, the agent opens that exact path and jumps to that exact line. The file on disk is what the runtime sees. diff --git a/framework-dev.md b/framework-dev.md index a77ee4f25..59074553b 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -130,6 +130,29 @@ It is conservative. A worktree is removed ONLY when it is a linked (non-primary) The fix only repairs the LOCAL checkout. Commits and branches are always safe on GitHub regardless. +### Agent config in this repo: `.claude/` is canonical, `.agents/` mirrors it + +Workflow skills live once at `.claude/skills//SKILL.md`. Antigravity reads +`/.agents/skills//SKILL.md`, so each one is mirrored as a +RELATIVE symlink `.agents/skills/ -> ../../.claude/skills/`. Adding a +skill means adding the directory, the symlink, and a bullet in +`.agents/rules/workflow.md`; `test/repo-health/agent-skill-parity.test.mjs` +fails when the three drift apart. Create the link with `ln -s` so git records +mode `120000`, and keep the target relative, or +`test/repo-health/no-committed-symlinks.test.mjs` rejects it. + +Two entries under `.agents/skills/` are not mirrors. `webjs/` is the real +committed teaching skill (`scripts/sync-scaffold-skill.mjs` bundles it into the +CLI at prepack), and `omarchy` is a machine-local absolute symlink kept +untracked by `.gitignore:98`. + +Lifecycle HOOKS stay Claude-only on purpose. Antigravity supports a workspace +`.agents/hooks.json`, but its blocking protocol (stdout `{"decision":"deny"}`), +its context-injection shape (`injectSteps`), and its tool vocabulary all differ +from Claude Code's, so a mirror is a protocol port rather than a config copy, and +a generated copy would be the duplicated rule set root `AGENTS.md` rules out. The +gates that bind every agent are `.hooks/pre-commit` and CI (#1372). + --- ### Scaffold teaching-coverage gate (`gallery-coverage.test.js`) diff --git a/test/repo-health/agent-skill-parity.test.mjs b/test/repo-health/agent-skill-parity.test.mjs new file mode 100644 index 000000000..c0ff90a0f --- /dev/null +++ b/test/repo-health/agent-skill-parity.test.mjs @@ -0,0 +1,123 @@ +/** + * The Claude skill set, the Antigravity symlink set, and the documented list + * must agree. + * + * Skills live once at `.claude/skills//SKILL.md` and are mirrored into + * `.agents/skills/` as relative symlinks, because Antigravity reads + * `/.agents/skills//SKILL.md`. Nothing kept the two in + * step, so three skills shipped with no symlink and a fourth was symlinked but + * never documented (#1372). Every new skill reopens that gap, so assert it. + * + * Both sets are read from `git ls-files`, not from the filesystem, so the + * machine-local `.agents/skills/omarchy` link (untracked, .gitignore:98) is + * invisible by construction and needs no special case. The one tracked + * exception is `.agents/skills/webjs/`, a real directory holding the framework + * teaching skill, which has no `.claude/skills` counterpart. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const RULES_DOC = '.agents/rules/workflow.md'; +/** Tracked entries under .agents/skills/ that are directories, not mirrors. */ +const REAL_DIRS = new Set(['webjs']); + +const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }); + +/** Skill names with a committed `.claude/skills//SKILL.md`. */ +function claudeSkillNames() { + return [ + ...new Set( + git('ls-files', '.claude/skills') + .split('\n') + .filter((p) => p.endsWith('/SKILL.md')) + .map((p) => p.split('/')[2]), + ), + ].sort(); +} + +/** Map of `.agents/skills/` symlink name to its stored target string. */ +function agentsSkillLinks() { + const map = new Map(); + for (const line of git('ls-files', '-s', '.agents/skills').split('\n')) { + // Mode 120000 is git's symlink mode; the blob content IS the target string. + if (!line.startsWith('120000 ')) continue; + const path = line.split('\t').slice(1).join('\t'); + const oid = line.split(' ')[1]; + map.set(path.slice('.agents/skills/'.length), git('cat-file', '-p', oid).trim()); + } + return map; +} + +test('every committed .claude skill is mirrored into .agents/skills', () => { + const links = agentsSkillLinks(); + const missing = claudeSkillNames().filter((n) => !links.has(n)); + assert.deepEqual( + missing, + [], + `no .agents/skills symlink for: ${missing.join(', ')}. ` + + `Add it with: cd .agents/skills && ln -s ../../.claude/skills/ `, + ); +}); + +test('every .agents/skills symlink is relative and names its own skill', () => { + const bad = []; + for (const [name, target] of agentsSkillLinks()) { + const want = `../../.claude/skills/${name}`; + if (target !== want) bad.push(`${name} -> ${target} (expected ${want})`); + } + assert.deepEqual(bad, [], `a mirror symlink has the wrong target:\n ${bad.join('\n ')}`); +}); + +test('no .agents/skills symlink dangles', () => { + const tracked = new Set(git('ls-files', '.claude/skills').split('\n').filter(Boolean)); + const dangling = [...agentsSkillLinks().keys()].filter( + (n) => !tracked.has(`.claude/skills/${n}/SKILL.md`), + ); + assert.deepEqual(dangling, [], `symlink with no committed target: ${dangling.join(', ')}`); +}); + +test('the workspace rules file documents exactly the mirrored skills', () => { + const section = readFileSync(resolve(repoRoot, RULES_DOC), 'utf8').split( + '## Custom Skills Usage', + )[1]; + assert.ok(section, `${RULES_DOC} must keep its "## Custom Skills Usage" section`); + const listed = new Set([...section.matchAll(/^\s*-\s+`([a-z0-9-]+)`:/gm)].map((m) => m[1])); + const linked = new Set(agentsSkillLinks().keys()); + const undocumented = [...linked].filter((n) => !listed.has(n)).sort(); + const phantom = [...listed].filter((n) => !linked.has(n) && !REAL_DIRS.has(n)).sort(); + assert.deepEqual( + undocumented, + [], + `symlinked but not listed in ${RULES_DOC}: ${undocumented.join(', ')}`, + ); + assert.deepEqual( + phantom, + [], + `listed in ${RULES_DOC} but not symlinked: ${phantom.join(', ')}`, + ); +}); + +test('the framework teaching skill stays a real committed directory', () => { + const modes = git('ls-files', '-s', '.agents/skills/webjs') + .split('\n') + .filter(Boolean) + .map((l) => l.split(' ')[0]); + assert.ok(modes.length > 0, '.agents/skills/webjs must stay committed'); + assert.ok( + !modes.includes('120000'), + '.agents/skills/webjs is the canonical teaching skill and must not become a symlink', + ); +}); + +test('the machine-local omarchy link stays untracked', () => { + assert.equal( + git('ls-files', '.agents/skills/omarchy').trim(), + '', + 'omarchy is a machine-local absolute symlink and must stay untracked (.gitignore:98)', + ); +}); diff --git a/test/repo-health/no-committed-symlinks.test.mjs b/test/repo-health/no-committed-symlinks.test.mjs index 7cf16eb9f..e8fa28016 100644 --- a/test/repo-health/no-committed-symlinks.test.mjs +++ b/test/repo-health/no-committed-symlinks.test.mjs @@ -34,13 +34,18 @@ test('no tracked symlink points outside the repo', () => { const links = out .split('\n') .filter((l) => l.startsWith('120000 ')) - .map((l) => l.split('\t').slice(1).join('\t')) - .filter(Boolean); + .map((l) => ({ oid: l.split(' ')[1], path: l.split('\t').slice(1).join('\t') })) + .filter((l) => l.path); const escaping = []; - for (const path of links) { - // The blob content of a symlink IS its target string. - const target = execFileSync('git', ['cat-file', '-p', `HEAD:${path}`], { + for (const { oid, path } of links) { + // The blob content of a symlink IS its target string. Read it by OID rather + // than as `HEAD:`, so a STAGED but not yet committed link is checked + // too: `ls-files -s` above enumerates the index, and resolving against HEAD + // mixed two snapshots, throwing a raw `fatal: path ... exists on disk, but + // not in 'HEAD'` at exactly the moment someone adds a symlink and needs + // this guard to judge it (#1372). + const target = execFileSync('git', ['cat-file', '-p', oid], { cwd: repoRoot, encoding: 'utf8', }).trim();