From abd5e2e8685b6092e6adba879452c7067c3465f5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:10:08 +0530 Subject: [PATCH 1/7] chore: remove the automated pre-merge review cycle The owner reviews every PR themselves, inline or with an agent of their own choosing. Sessions no longer spawn reviewer subagents, run multi-round self-review loops, or post self-authored review objects. The webjs-start-work skill now ends at a reviewable PR (Definition of done, full suites, gh pr ready) and acting on the owner's review comments; the route-skills code-review directive injects an inline-only guard instead of the cycle, and the review-loop-exit guard test goes with the cycle it guarded. --- .claude/hooks/route-skills.sh | 20 +- .claude/skills/webjs-start-work/SKILL.md | 204 ++------------- test/hooks/review-loop-exit.test.mjs | 308 ----------------------- 3 files changed, 34 insertions(+), 498 deletions(-) delete mode 100644 test/hooks/review-loop-exit.test.mjs diff --git a/.claude/hooks/route-skills.sh b/.claude/hooks/route-skills.sh index e749fc9f2..03775ae92 100755 --- a/.claude/hooks/route-skills.sh +++ b/.claude/hooks/route-skills.sh @@ -192,22 +192,18 @@ if printf '%s' "$lc" | grep -q 'instagram' \ add_match "webjs-instagram-post: the request is to publish to the WebJs Instagram account. Invoke the webjs-instagram-post skill. Every post is SEO-only, so ALWAYS create a fresh branded image plus a keyword-rich caption, host the JPEG at a public HTTPS URL, and CONFIRM the image and caption with the user before the public publish. Never print or commit the access token." fi -# --- code-review: review the diff before a PR is ready ------------------ +# --- code-review: the owner reviews PRs; inline-only when asked --------- # Triggers: review the PR/diff/branch/changes, code review, look it over -# for bugs. Reviewing every change before it is marked ready is a standing -# expectation, and a review is the CYCLE the webjs-start-work skill -# defines: one reviewer over the whole diff, a minor-or-must-fix call, and -# at most one delta round on the fixes, escalating to delta rounds plus a -# final whole-diff review only when the change shows it needs them. This -# directive and the skill must not resolve a case differently, which they -# once did for the final fix-check; test/hooks/review-loop-exit.test.mjs -# asserts the shared wording of the rules that decide when the cycle ends. -# code-review is a built-in Claude Code skill (no in-repo SKILL.md, so the -# portability test that guards project skills does not cover it). +# for bugs. The owner reviews every PR themselves before merge, inline or +# with an agent of their own choosing. There is NO automated pre-merge +# review cycle and NO reviewer subagent, so review phrasings inject the +# inline-only guard below rather than a cycle. code-review is a built-in +# Claude Code skill (no in-repo SKILL.md, so the portability test that +# guards project skills does not cover it). if has '(review|audit) (the |my |this )?(pr|diff|branch|change|changes|code|commit)' \ || has 'code ?review' \ || has '(review|look) .{0,20}(over )?for (bug|issue|correctness|regression)'; then - add_match "code-review: the request is to review code. Invoke the code-review skill (it reviews the diff for correctness bugs plus reuse and simplification). A review of a PR runs the webjs-start-work review cycle, which is ONE fresh reviewer over the whole diff, never a fleet, and which defaults to its FAST shape because every round costs about 10 minutes and the owner should not have to ask for a short cycle. Fast shape: round 1 reads the whole diff; only a MUST-FIX fix buys a round and it buys exactly ONE, delta-scoped to those fix commits alone; a round whose fixes were all minor ends the cycle instead; then stop. A clean or minor-only round 1 therefore finishes with ONE review, which is the intended common case. Speed comes from running fewer rounds, never from lowering the bar inside a round. Escalate to the THOROUGH shape when the owner asks for a thorough or full review, or round 1 produced two or more must-fix findings or the one delta round produced any, or the diff touches the serializer, SSR or action dispatch, auth or session, the client router, or the elision analyser. Thorough shape: each later round is delta-scoped to the previous round's fix commits, and the first round that produces no fixes, whether it found nothing must-fix or everything it found was rejected or deferred, buys a FINAL review over the whole diff again. The code-review skill's findings feed that cycle as auxiliary input, not as a round of it. Every reviewer is spawned with the Agent tool as subagent_type general-purpose, model opus (Opus 5, never fable), run_in_background true, and isolation worktree. Judge each finding MINOR or MUST-FIX by SURFACE, never by importance: must-fix when it touches source, a test's ability to observe the defect it claims to cover, or a factual claim about runtime behavior in docs; minor for wording, naming, comment style, and nits about the review artifacts; when it could go either way it is must-fix. Only a FIX buys another round, since only a fix changed the branch, and a delta chain that keeps producing fixes stops after the fifth delta round, unfinished, rather than continuing; a rejection buys one refuter instead, and a deferral and a minor finding buy nothing. After the final review, a must-fix finding is fixed and gets ONE delta check of that fix alone; the cycle ends when nothing must-fix is left open, meaning the check came back with nothing or there was no fix to check because every must-fix finding the final review raised was rejected or deferred; a check that does find something must-fix gets that fixed and one more check of the same shape, and only if that one also finds something must-fix do you stop and report the PR unfinished. Do not file follow-up issues for review findings that are out of scope; report them to the owner, who decides, and fold a small same-file tweak into the PR. Never report the PR ready off a round that found something must-fix." + add_match "code-review: the request is to review code. Invoke the code-review skill (it reviews the diff for correctness bugs plus reuse and simplification) and perform the review YOURSELF, inline in this session. NEVER spawn a reviewer subagent and NEVER run a multi-round review cycle. One inline read, findings reported to the owner in the conversation, and the owner decides what gets fixed. The owner reviews every PR themselves before merge, so your job is to hand them a reviewable PR, not to review it for them." fi # --- verify: prove the change works by running the app ------------------ diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index bff754d51..6a63cea67 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -108,7 +108,7 @@ The user's request typically names an issue by number (e.g. `#112`) or by descri If `ITEM_ID` is empty, step 2 did not find the card. Go back and add it rather than passing an empty `--id`, which fails. -6. **Open a DRAFT PR immediately, BEFORE writing any code.** This is the single most important ordering rule and it is NOT optional: the PR is opened at the START of the work, not the end. The whole point of the PR is to be the durable, append-only record of the change AS IT HAPPENS: every per-logical-unit commit lands on it, every design-rationale / decision / follow-up context comment is posted to it the moment that discussion happens, and every review round is posted to it. NONE of that is possible if the PR does not exist yet, which is exactly the failure a late `gh pr create` causes. So open it now, empty branch and all (the branch was already pushed in step 4). +6. **Open a DRAFT PR immediately, BEFORE writing any code.** This is the single most important ordering rule and it is NOT optional: the PR is opened at the START of the work, not the end. The whole point of the PR is to be the durable, append-only record of the change AS IT HAPPENS: every per-logical-unit commit lands on it, every design-rationale / decision / follow-up context comment is posted to it the moment that discussion happens, and the owner's review lands on it. NONE of that is possible if the PR does not exist yet, which is exactly the failure a late `gh pr create` causes. So open it now, empty branch and all (the branch was already pushed in step 4). Push one trivial initial commit if the branch has no commits yet (GitHub refuses a PR with no diff between head and base); the cleanest is to defer this step to immediately after the FIRST real commit, but never later than that. Open it as a DRAFT so it is clearly not yet ready to merge: @@ -123,13 +123,13 @@ The user's request typically names an issue by number (e.g. `#112`) or by descri The title MUST carry a conventional-commit prefix from the first moment (feat/fix/perf/breaking appear in the changelog; chore/docs/test/refactor do not), because a single-commit PR squashes on the COMMIT subject and a multi-commit PR on the TITLE. Refine the title/body as the change takes shape; the draft is a living document. Capture the issue URL/number for `Closes #` (already in the body). - From here on, the PR exists, so: commit per logical unit and push after each (the commits stream onto the PR); post design-rationale / decision / follow-up context comments to the PR as those discussions happen (do not hoard them for the end); and run every review round ON the PR. The PR is marked **ready for review** (`gh pr ready `) only at the very end, AFTER the Definition of done is satisfied and the review cycle has finished (the final whole-diff review, and the fix-check if it had one). Opening late and dumping everything at the end is the anti-pattern this step exists to kill. + From here on, the PR exists, so: commit per logical unit and push after each (the commits stream onto the PR); post design-rationale / decision / follow-up context comments to the PR as those discussions happen (do not hoard them for the end). The PR is marked **ready for review** (`gh pr ready `) only at the very end, AFTER the Definition of done is satisfied. From there the owner reviews it. Opening late and dumping everything at the end is the anti-pattern this step exists to kill. 7. **Report back briefly.** One short message to the user: issue title + number, new branch name, draft PR URL, "project card moved to In progress". Then continue with the actual work the user asked for. ## Definition of done (MUST be satisfied BEFORE marking the draft PR ready for review) -The PR is already open as a draft (step 6). "Done" here means the gate to flip it from draft to **ready for review** (`gh pr ready `), NOT the gate to create it. Everything below must be addressed, and the review cycle must have finished, before that flip. +The PR is already open as a draft (step 6). "Done" here means the gate to flip it from draft to **ready for review** (`gh pr ready `), NOT the gate to create it. Everything below must be addressed before that flip. **Bun parity is part of the task, not an afterthought.** webjs runs on Node 24+ AND Bun (#508). If the change touches a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors), then BEFORE you mark the PR ready you MUST (1) run the Bun matrix and report it green (`node scripts/run-bun-tests.js` plus the touched `test/bun/*.mjs` under `bun`), and (2) add or update a `test/bun/.mjs` cross-runtime assertion for the surface. This is enforced: `.claude/hooks/require-bun-parity-with-runtime-src.sh` BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test (escape hatch `WEBJS_BUN_VERIFIED=1` only when an existing Bun script already covers it AND you ran it). Treat the parity, not just the Node result, as the bar. @@ -145,7 +145,7 @@ Doc drift is the #1 way a framework rots. Documentation MUST stay in sync with c - **Do NOT neuter a guard by `sed`-rewriting the source to a sentinel like `''`.** Shell-quoted escapes land as a literal control byte (a NUL/0x01) inside the file, which renders like a space in an editor but breaks the comparison and makes `grep` treat the file as binary (silent empty matches). Verify any byte-level edit with `od -c` on the changed line and `tr -d '\000' | wc -c` for stray NULs. Prefer the Edit tool (toggle the guard, run, toggle back) or `git stash`/`git stash pop` of the committed source over `sed` for this. The clean loop: commit fix+test, run test green, `git stash push -- ` (or Edit out the guard), run test red, `git stash pop` (or Edit the guard back), run test green again. The test having gone red in the middle is the proof. - **A counterfactual CLAIM decays.** "Reverting X reds Y" is true of a commit, not a branch: a later commit touching the same mechanism can make it false while every test stays green (a review-loop fix once made an older test non-discriminating exactly this way). So date the claim to the commit it was proven at, and when a later commit touches that mechanism, re-run the toggle and restate or correct the claim. This applies to mid-cycle fix commits too (see the speed rule in the review cycle). + **A counterfactual CLAIM decays.** "Reverting X reds Y" is true of a commit, not a branch: a later commit touching the same mechanism can make it false while every test stays green (a later fix commit once made an older test non-discriminating exactly this way). So date the claim to the commit it was proven at, and when a later commit touches that mechanism, re-run the toggle and restate or correct the claim. This applies to fix commits made in response to review feedback too. - **Integration** (server-level through `createRequestHandler`, SSR pipeline, scaffolds): behaviour across modules without a browser. - **Browser** (`*/test/**/browser/*.test.js`, run via `npm run test:browser` / `wtr`): anything touching hydration, client render, DOM, slots, the client router, custom-element upgrade. - **E2E** (`test/e2e/e2e.test.mjs`, run via `WEBJS_E2E=1`): full-stack behaviour observable only in a real browser against the running blog example, including **network probes** (was a module fetched or not), navigation, and streaming. @@ -242,69 +242,37 @@ If you find yourself writing "N/A" for every item except tests, that is a smell. If a PR ships without ANY of those touches and the change is user-visible, the PR is incomplete; do not mark it ready for review (leave it draft until the surfaces are addressed). -## Anatomy of a complete PR: four things, always +## Anatomy of a complete PR: three things, always -A finished PR is not just a diff. It carries four artifacts, and the PR is considered incomplete until all four exist. Treat this as the standing definition of a complete PR, applied automatically on every one: +A finished PR is not just a diff. It carries three artifacts, and the PR is considered incomplete until all three exist. Treat this as the standing definition of a complete PR, applied automatically on every one: 1. **A meaningful, conventional-commit-prefixed title.** The title MUST start with a conventional-commit type so the changelog is generated automatically: `feat:` for a new user-facing capability, `fix:` for a bug fix, `perf:` for a performance improvement, `breaking:` (or a `!` like `feat!:`) for a breaking change, and `chore:` / `docs:` / `test:` / `refactor:` for changes that should NOT appear in the changelog. After the prefix, be imperative, specific, what-and-why, under ~72 chars total. Example: `fix: shared rich values round-trip through the RPC serializer`, not `Fix serializer` or the issue number alone. **Why this matters (do not skip it):** PRs are squash-merged, so the PR TITLE becomes the squash commit subject on `main`, and `scripts/backfill-changelog.js` (run by the pre-commit hook on a version bump) extracts changelog entries by matching that subject against `^(feat|fix|breaking|perf)(scope)?!?:` and reads the commit BODY (the PR description) for the entry text. A non-prefixed title (e.g. `De-flake the prefetch e2e...`) produces ZERO changelog entries, which forces a hand-written changelog at release time, which is wrong. NEVER hand-write `changelog//.md`: fix the PR title/body instead so the automation produces it. If you ever find yourself about to hand-write a changelog, stop and correct the merged PR titles (or the release's source commits) so they are conventional-commit prefixed. 2. **A meaningful body.** `Closes #` near the top, a summary, what changed and why, the deliberately-excluded decisions, a test plan, and the docs surfaces touched (per the Definition of done above). This is the architectural narrative of the change. Because the squash commit body IS this PR description, write the first paragraph so it reads as the changelog entry text (the generator uses it), then continue with the rest. 3. **Context comments.** The reasoning from the working conversation that the diff and body do not capture, posted on the PR as the discussion happens (see "Capture significant design discussion as PR comments" below). The PR is the durable memory; the chat transcript is not. -4. **Review comments: a summary AND per-code-line comments.** Every review (each round of the review cycle and any manual review) posts a summary review plus an inline comment on each finding's `file:line` (see "Every PR review is posted ON the PR" below). +All three are written in the owner's voice (first person, plain, no AI/agent framing) and free of AGENTS.md invariant 11 banned glyphs. The no-machinery-tells rule binds the context comments; the PR BODY is the one place machinery evidence is REQUIRED content (the test plan and the dogfood results the Definition of done demands), so reporting it there is not a tell. The sections below specify the mechanics for item 3. -All four are written in the owner's voice (first person, plain, no AI/agent framing) and free of AGENTS.md invariant 11 banned glyphs. The no-machinery-tells rule binds the review and context comments; the PR BODY is the one place machinery evidence is REQUIRED content (the test plan and the dogfood results the Definition of done demands), so reporting it there is not a tell. The sections below specify the mechanics for items 3 and 4. +**Header every standalone comment with a short, meaningful bold heading** so a future reader (human or AI) knows what the comment is and what it is about before reading it. Put the heading on its own first line as bold markdown, blank line, then the body. Write the heading to fit THIS comment, do not pick from a fixed list. A good heading names the kind of comment and its topic, e.g. `**Design rationale: why analysis moved off boot, and what it costs**`, `**Decision: kept the derived gate over a declared allowlist**`, `**Follow-up: aliased-expose 404 filed as #N**`. A bare category word like `Context` or `Review` is the floor, not the goal; prefer a heading that also says the subject, so a reader scanning the PR's comment list can tell the boot-rationale note from the elision note without opening either. **Threaded replies inside the owner's review comments do NOT need a heading** because the thread already classifies them. Keep those terse. The heading rule is for standalone, top-level comments (the PR body in item 2 is exempt, since it has its own `## Summary` structure). -**Header every standalone comment with a short, meaningful bold heading** so a future reader (human or AI) knows what the comment is and what it is about before reading it. Put the heading on its own first line as bold markdown, blank line, then the body. Write the heading to fit THIS comment, do not pick from a fixed list. A good heading names the kind of comment and its topic, e.g. `**Design rationale: why analysis moved off boot, and what it costs**`, `**Review: lazy-boot model holds, one real bug**`, `**Decision: kept the derived gate over a declared allowlist**`, `**Follow-up: aliased-expose 404 filed as #N**`. A bare category word like `Context` or `Review` is the floor, not the goal; prefer a heading that also says the subject, so a reader scanning the PR's comment list can tell the boot-rationale note from the elision-review note without opening either. **Per-line inline review comments do NOT need a heading** because their `file:line` anchor already classifies them as review; keep those terse. The heading rule is for standalone, top-level comments (the PR body in item 2 is exempt, since it has its own `## Summary` structure). +## Review: the owner reviews every PR -## Pre-merge review cycle (MUST run before reporting "ready for merge") +There is NO automated pre-merge review cycle in this workflow. Do not spawn reviewer subagents, do not run multi-round self-review loops, and do not post self-authored review objects to the PR. The review belongs to the owner (vivek7405), who reviews every PR themselves, inline or with an agent of their own choosing, once it is flipped to ready. Your job ends at handing them a reviewable PR and then acting on what their review finds. -Saying "ready for merge" before the review cycle completes is the single biggest source of low-quality PRs. The recurring pattern to AVOID: claim ready-for-merge, the user requests a review, find issues, fix them, claim ready-for-merge again, repeat 4-5 times before a review comes back clean. The cure is to run that cycle internally BEFORE the first "ready" signal. The user should only hear "ready to merge" after the cycle has finished AND the suites it deferred have run. +Before flipping to ready, run everything the Definition of done demands: the full suites for every layer the change touches (full Node, browser, e2e, the Bun matrix, the two-app dogfood boot check). Launch them as parallel background tasks in one batch and collect EVERY result before reporting, because a task you forget to collect is a silently skipped layer. Then `gh pr ready ` and report back that the PR awaits the owner's review. Never report the PR ready with failing or unrun suites. -### Every PR review is posted ON the PR (summary + per-line comments) +**If the owner explicitly asks you to review something, do it yourself, inline in this session** (the built-in code-review skill fits), and report the findings in the conversation. Never delegate that review to a subagent, and never expand it into rounds. One read, findings reported, the owner decides what gets fixed. -This applies to EVERY review of a PR: each round of the review cycle below, AND any time the user asks you to "review the PR" manually. A review that lives only in your chat reply is not a review the PR carries. For every review you perform, post BOTH: +### Acting on the owner's review comments -1. **A summary review comment** stating what you reviewed and the overall outcome (which surface, what you found, or that it is clean). This is what you leave at the "Finish your review" step. -2. **A per-line inline comment for each finding**, anchored at `file:line` on the diff. Each states the PROBLEM only, the way a reviewer flags it before anyone has fixed it. Do NOT bake the resolution into the finding (ending a finding with "...Fixed." is wrong). The resolution is recorded separately, as a threaded reply, in the programmer half below. Post the won't-fix and false-positive findings as inline comments too, so the concern sits on the exact line; their reply carries the reason they are left as is. +When the owner leaves review comments on the PR, work through each one following GitHub's real flow (fix, reply in the thread, resolve): -**Both go in ONE review object, via the reviews API, never as plain issue comments.** The summary and all its inline comments are submitted together with a single `POST /pulls//reviews` (the `--input review.json` call below). That is what makes GitHub render them as a grouped unit: the summary plus a `reviewed these changes - N comments` trail of the per-line comments beneath it. A review observation posted with `gh pr comment` (an issue comment) instead lands as a standalone box with NO trail, visually identical to a general comment, and disconnected from its inline notes. So: review content (summaries AND observations, every round) goes through the reviews API; `gh pr comment` issue comments are reserved for NON-review context (the design-rationale, decision, and follow-up notes from the section further down). Do not scatter review remarks across loose issue comments. If you catch yourself about to `gh pr comment` something that is really a review observation, fold it into the review summary instead. (Note: GitHub's mobile app tints every comment you author a light blue because of the `Author`/`Member` badge; that tint is author-association, NOT a review marker, so it is not a reliable signal. The reliable signal that something is a review is the `reviewed these changes` trail, which only a review object has.) - -**Voice: write every PR comment as the repo owner (vivek7405) would write it.** First person, plain, the way a person reviews code. The whole review trail (summary AND inline comments) must read as if the owner typed it, not as a bot reporting a procedure. This is non-negotiable and applies to every PR review, forever, not just the one in front of you. - -Hard rules: - -- **No AI/agent framing.** Never refer to yourself as an AI or agent, never say "self-review", never number the rounds ("Round 2", "round 3 of the loop"), never say "you requested a manual re-review" or otherwise narrate the review process. -- **No machinery tells.** A human reviewer does NOT mention CI status ("CI is green", "all 5 gates pass"), test counts ("96 tests pass"), or meta-scaffolding ("Went over the X, Y, Z paths. Comments inline."). CI state lives in the checks UI, not in prose; the inline comments are obviously inline. Drop all of it. -- **Inline findings are terse and state the problem, not the fix.** Point at what is wrong on that line, the way a reviewer flags it. "`expose as exp` won't match this, so the route 404s." / "Says it scans on boot, but this is lazy now." / "A same-mtime, same-size recreate could still serve a stale parse, does that need handling?" The fix and won't-fix reasons go in the threaded reply, never in the finding itself. -- **Reference commits as clickable links, not bare SHAs.** GitHub does NOT auto-link a SHA inside a backtick code span, so `` `5fd02dc` `` renders as dead text. Always write a markdown link: `[`5fd02dc`](https://github.com/webjsdev/webjs/commit/5fd02dc)` (the short SHA resolves fine in the URL). Same for any commit referenced in a summary, reply, or context comment, e.g. "Fixed in [``](https://github.com/webjsdev/webjs/commit/).". A reviewer wants to click straight to the diff. -- **The summary may go broad.** Because the per-line comments carry the specifics, the summary is the place for an opinionated, architecture-level take: what the change does well, what you would keep an eye on, the one thing that actually matters. Still first person and plain, just not restricted to pointing at one line. Think of how you would brief a teammate on the PR in three or four sentences. - -The test for any comment: if it reads like a person who owns this repo wrote it offhand, it passes. If it reads like a status report or a tool's output, rewrite it. - -### Follow the real review flow: reviewer, then programmer, both roles - -GitHub's manual flow is: **Start a review**, add inline comments, **Finish your review**, leave a summary, **Submit review**. Then the author **fixes** each comment, **replies in the thread** that it is fixed, and **resolves** the thread. The reviewer and the programmer are the same person here, but that does NOT collapse the two roles into one comment. Reproduce the whole flow over the API every time, both halves. - -**Reviewer half (one review object).** Submit the summary plus all inline findings together with a single `POST /pulls//reviews`. That one call is Start-review + add-comments + Finish + Submit. Findings state the problem, not the fix. - -```sh -gh api -X POST repos/webjsdev/webjs/pulls//reviews --input review.json -# review.json: { "commit_id": "", "event": "COMMENT", -# "body": "", -# "comments": [ { "path": "", "line": , "side": "RIGHT", "body": "" } ] } -``` - -Use `event: "COMMENT"` (GitHub forbids APPROVE / REQUEST_CHANGES on your own PR). Each inline `line` must be a line that is in the PR diff (a changed or added line), or the API rejects the whole review; if a finding sits on an unchanged line outside the diff, note it path-level in the summary. Verify with `gh api repos/webjsdev/webjs/pulls//comments`. - -**Programmer half (after the review is submitted).** For each finding: - -1. **Fix it** on the branch (commit + push), or decide it is a won't-fix. -2. **Reply in the comment's thread** with the resolution. This is the "reply that it is fixed" step, not an edit of the finding: +1. **Fix it** on the branch (commit + push), or make the case in the thread for leaving it as is and let the owner decide. +2. **Reply in the comment's thread** with the resolution: ```sh gh api -X POST repos/webjsdev/webjs/pulls//comments//replies \ --input reply.json # reply.json: { "body": "Fixed in [``](https://github.com/webjsdev/webjs/commit/)." } ``` -3. **Resolve the thread** once it is concluded (fixed, or won't-fix-with-reason). Threads resolve ONLY via GraphQL `resolveReviewThread`; REST cannot do it: +3. **Resolve the thread** once its finding is fixed. Leave a debated or won't-fix thread OPEN for the owner to resolve, since the concern is theirs. Threads resolve ONLY via GraphQL `resolveReviewThread` (REST cannot do it), one of the two sanctioned GraphQL uses in `.claude/gh-budget.md`: ```sh # list unresolved review-thread node IDs gh api graphql -f query='query{repository(owner:"webjsdev",name:"webjs"){pullRequest(number:){reviewThreads(first:50){nodes{id isResolved}}}}}' \ @@ -313,9 +281,7 @@ Use `event: "COMMENT"` (GitHub forbids APPROVE / REQUEST_CHANGES on your own PR) gh api graphql -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' -f t= ``` -**Every round repeats the whole flow.** Each round of the review cycle, and each manual re-review the user asks for, is a NEW review object: a fresh `POST /pulls//reviews` carrying that round's summary and findings, followed by fix + reply + resolve for that round's threads. Never append a later round's findings into an earlier round's review, and never edit a prior finding to say it is fixed (reply instead). A round that REVIEWED and found nothing still posts a short summary review saying it is clean, with no inline comments. A round whose reviewer did not review (see the liveness rules) posts nothing, because there is no round to summarize and a clean review object on the PR would be a lie. - -Banned prose glyphs (AGENTS.md invariant 11) apply to every comment, reply, and summary body, so keep them clean. +**Voice: write every PR comment and reply as the repo owner (vivek7405) would write it.** First person, plain, the way a person talks about their own code. No AI/agent framing, no machinery tells (CI status, test counts, meta-scaffolding). Reference commits as clickable markdown links, never bare SHAs in code spans, because GitHub does not auto-link a SHA inside a backtick span. Write [`5fd02dc`](https://github.com/webjsdev/webjs/commit/5fd02dc). Banned prose glyphs (AGENTS.md invariant 11) apply to every comment and reply. ### Capture significant design discussion as PR comments (standing, automatic) @@ -323,135 +289,20 @@ Beyond review findings, proactively record the *reasoning* behind a PR as commen **Trigger (automatic, not on request):** whenever a conversation about an open PR produces a non-obvious design decision, a rejected alternative, a tradeoff accepted with eyes open, or context the diff alone does not explain, post it as a PR comment AS THE DISCUSSION HAPPENS. Use `gh pr comment --body-file /tmp/pr-comment.md` for cross-cutting narrative, or an inline `file:line` comment when it pertains to specific code. Same voice as review comments: first person, plain, owner's voice, no AI/agent framing, no machinery tells, no banned glyphs. -**This runs continuously across the PR's whole life, not once.** Because the PR opens as a draft at the START (step 6), there is a place to post from the first commit onward, so keep adding context throughout: when a mid-work investigation changes the approach, when a reviewer finding is resolved a particular way, when an edge case is discovered, when something is deliberately deferred. The acceptance test is concrete: a future AI agent (or the owner) who opens ONLY this PR, with zero access to this chat, should find every non-obvious "why" already written on it. If reconstructing the reasoning would require the chat transcript, a context comment is missing. Do not save it all for a single end-of-work dump; that recreates the exact gap the early-draft-PR rule exists to close. +**This runs continuously across the PR's whole life, not once.** Because the PR opens as a draft at the START (step 6), there is a place to post from the first commit onward, so keep adding context throughout: when a mid-work investigation changes the approach, when the owner's review finding is resolved a particular way, when an edge case is discovered, when something is deliberately deferred. The acceptance test is concrete: a future AI agent (or the owner) who opens ONLY this PR, with zero access to this chat, should find every non-obvious "why" already written on it. If reconstructing the reasoning would require the chat transcript, a context comment is missing. Do not save it all for a single end-of-work dump; that recreates the exact gap the early-draft-PR rule exists to close. **What's worth capturing (judgement, not a checklist):** why an approach won over a credible alternative; an experiment tried and reverted, with the reason; a tradeoff accepted knowingly (a cold-start cost, a known-small race window left in, a documented edge case); a constraint or invariant discovered mid-work; anything you would want explained if you returned to the PR with no memory of the conversation. Skip the trivial: routine fixes, mechanical edits, anything the diff already makes obvious. The bar is "would a future agent be missing important context without this", not "log everything". When the PR body already covers a decision, a short comment is fine or skip it; do not duplicate the whole body into a comment. -**A PR carries exactly two kinds of content: the code change and the review rounds.** Everything on it (body, commits, review summaries, inline findings, context comments) must be meaningful data about one or the other. Session and harness machinery is NOT PR content and must never be posted there. Concretely, keep OFF the PR: a subagent that could not be spawned or died, a tool that errored or was declined, a retry, an interruption, how many turns something took, and above all your own process mistakes in running the PR (a stale body you then fixed, a mirror you forgot to sync, a mis-posted comment). Those are conversation, not record. The test: would this still matter to someone reading the PR in a year who has no idea which agent or session produced it? A design decision passes. A rejected alternative passes. A review finding passes. "My reviewer spawn was declined and I retried" does not, and neither does "I got this wrong earlier in the PR and then corrected it", which reads as noise around a diff that already shows the correction. Fix the mistake and move on; do not narrate it onto the PR. - -### How the cycle works - -The draft PR is already open (step 6), so reviews post to it from the first round. Do NOT mark it ready for review or report "ready for merge" yet. - -**FAST is the default shape.** A reviewer spawn costs roughly 10 minutes of wall clock, so every round is a real, measurable tax on the PR, and the owner should never have to ask for a short cycle. The default therefore buys the SECOND read only when there is evidence the change needs it, and the thorough shape below is what that evidence escalates to. Speed is bought by running fewer rounds, NEVER by lowering the bar inside a round: the minor / must-fix call is unchanged, still by surface, still fail-open to must-fix. - -**The fast cycle (default):** - -1. **Round 1: ONE fresh reviewer over the WHOLE diff.** The same shape for every PR, whatever paths it touches. No fleet, no lenses, no jury, no per-diff tier choice. -2. **Judge each finding MINOR or MUST-FIX** (rule below), then fix, reject, or defer it (the three dispositions below). Apply every minor finding; they buy nothing. A rejection buys one REFUTER, a deferral buys nothing. -3. **Only a MUST-FIX fix buys a round, and it buys exactly ONE:** a delta-scoped reviewer whose QUESTION is those fix commits alone. A round whose fixes were ALL minor ends the cycle instead, because wording and naming edits cannot introduce the class of defect a round exists to catch. -4. **Then STOP.** The cycle ends when nothing must-fix is left OPEN. If that one delta round itself finds something must-fix, fix it, and escalate to the thorough cycle rather than chaining further: a fix that produces another must-fix finding is the evidence the escalation ladder is asking for. - -So a clean or minor-only round 1 finishes the cycle with ONE review. That is the intended common case, not a shortcut. - -**Escalate to the THOROUGH cycle when ANY of these hold**, checked at the end of round 1 and again after a delta round: - -- the owner asks for a thorough, full, or deep review; -- round 1 produced TWO OR MORE must-fix findings, or a delta round produced any, which says the change is not yet understood; -- the diff touches a surface where a miss is expensive: the serializer, SSR or action dispatch, auth or session, the client router, or the elision analyser. - -**The thorough cycle (on escalation):** steps 1 and 2 as above, except that the one-delta-round limit lifts, so each later round is delta-scoped to the previous round's fix commits and the chain continues while its rounds keep producing fixes. Then: - -4. **The first round that produces no fixes buys the FINAL review: ONE fresh reviewer over the WHOLE diff again**, on the head the fixes produced. Two different jobs are being done here. After delta rounds, it is the first read of the finished change rather than of a fragment. After a clean round, when it is the same diff at the same head, it is a second independent read, which is the check a single-reviewer cycle owes in place of the fleet's second opinion. Either way a clean round is not the end of the THOROUGH cycle. This is. **A delta chain that keeps producing fixes stops after the FIFTH DELTA ROUND**, unfinished, reported per its own entry in Failure handling rather than continued: a change whose repairs keep breaking something is signal about the change, not about the review, and the measured 18-round loop that motivated this spent its last four rounds catching regressions its own fixes had introduced. -5. **Judge the final review the same way, then STOP.** Its fixes get ONE delta-scoped check of those fix commits alone; its rejections get a refuter; its deferrals and minor findings are recorded and applied. The cycle ends when nothing must-fix is left OPEN, which means the check came back with nothing, or there was no fix to check because every must-fix finding it raised was rejected or deferred. A check that does find something must-fix gets that fixed and ONE more check of the same shape, and if that one does too, stop and report the PR unfinished. The final review is never re-run and the delta rounds are never re-entered. - -**Do not file follow-up issues for what a review turns up.** A finding that is real but out of scope goes in the reply to the owner, who decides whether it becomes tracked work. A small tweak in a file the PR already touches is folded into the PR instead. This is the standing no-proactive-follow-up rule, and it applies to review findings like anything else. - -**Minor or must-fix.** MUST-FIX when the finding touches what ships: source, a test's ability to OBSERVE the defect it claims to cover (a tautological assertion that stays green with the bug present counts), or a factual claim about runtime behavior in docs (a stated default, limit, or condition list). MINOR is everything else: wording, naming, comment style, PR-body counts, nits about the review artifacts. Judge by SURFACE, never by importance, because importance is judged by the same agent that wants to stop. When it could go either way, it is must-fix. - -**Every reviewer is the same spawn:** the Agent tool, `subagent_type: "general-purpose"`, `model: "opus"` (Opus 5, always, no other model anywhere in the cycle), `run_in_background: true`, `isolation: "worktree"`, carrying the prompt template at the end of this section. Reviewers are fresh and ONE-SHOT: a reviewer that carries context across rounds re-derives its own conclusions, which is the blind spot a fresh one exists to avoid. - -**Give the reviewer the diff and nothing else.** The PR diff, the PR title and body, the touched files, and the rule files it judges against (`AGENTS.md`, `CONVENTIONS.md`). NEVER prior PR comments or reviews, and never a growing list of already-handled findings: on #1159 the comment payload alone reached 171 KB by the fifth round. An unbiased reviewer is the point, and a duplicate finding costs you a second of reading. - -**Do not tell it what to look for.** The prompt sets the SCOPE (which diff) and nothing else: no list of defect classes, no "specifically check for X and Y", no ranking of what matters, however sure you are about where the risk sits. A checklist narrows a fresh reviewer to what you already suspect, which is the bias it exists to escape, and everything outside your list becomes what it does not look at. The one exception is a REFUTER, whose whole job is the single claim it is handed. Naming the touched files is scope; naming the bugs to hunt is steering. - -**Keep the cycle fast.** After a fix, run only the test file(s) covering the line you changed, with the counterfactual toggle the Definition of done mandates (a fix can make an older test non-discriminating without failing it). The e2e, full Node, browser, and Bun suites and the two-app dogfood check run ONCE, after the cycle ends. CI is not read during the cycle at all, and not at the end of it either. It is read once, at merge, under the merge gate below. Both rules change WHEN work happens, never WHETHER. - -**Do not restore what this replaced.** This cycle used to run a 16-agent fleet with a scout, parallel lenses, and a jury, pick round 1's shape by a path check, sort findings into two tiers, cap itself at five rounds, and poll a file to watch each spawn. Almost all of it is gone on purpose: termination is mostly structural now (only a fix buys a round, delta rounds narrow the question, the minor call stops wording from buying rounds, the final review plus one fix-check is a hard end). Reviews are async, so the harness completion notification is the signal, with at most an optional background progress check that never kills anything. The one exception to the removals is the round cap, which came back in a narrower form, because structure alone cannot bound a chain where every fix produces the next round's finding: it now bounds ONLY that case, at five delta rounds, instead of counting every round of the cycle. +**A PR carries exactly two kinds of content: the code change and its review.** Everything on it (body, commits, context comments, review replies) must be meaningful data about one or the other. Session and harness machinery is NOT PR content and must never be posted there. Concretely, keep OFF the PR: a subagent that could not be spawned or died, a tool that errored or was declined, a retry, an interruption, how many turns something took, and above all your own process mistakes in running the PR (a stale body you then fixed, a mirror you forgot to sync, a mis-posted comment). Those are conversation, not record. The test: would this still matter to someone reading the PR in a year who has no idea which agent or session produced it? A design decision passes. A rejected alternative passes. A review finding passes. "A subagent spawn was declined and I retried" does not, and neither does "I got this wrong earlier in the PR and then corrected it", which reads as noise around a diff that already shows the correction. Fix the mistake and move on; do not narrate it onto the PR. -The two-review minimum went the same way, and this is the part most likely to be "restored" by mistake. It was introduced on the reasoning that a lone reviewer's miss is the cost of dropping the fleet and the final pass is what covers it. That reasoning was right about the risk and wrong about the price: it charged EVERY PR a second 10 minute round to cover a miss that mostly matters on a few surfaces, and in practice the second read on a small single-surface diff came back clean or minor. So the final review is now bought by evidence rather than owed by default, through the escalation ladder above, which keeps the protection where it pays. Do NOT reinstate an unconditional final review or an unconditional two-review floor; if the ladder is letting real defects through, tighten a TRIGGER, which is the knob that was built for it. +### Merge is gated on green CI, enforced at the branch level, not by trust -Each round must: +A PR must not merge until all CI checks pass. `main` branch protection requires the five `ci.yml` checks (Conventions, Unit+integration, Browser, E2E, Build) before any merge. If `gh api repos/webjsdev/webjs/branches/main/protection` shows `required_status_checks: null`, run `bash scripts/protect-main.sh` once (needs repo admin) to restore it. Do not work around a red or pending check. Wait for green, and fix whatever is red before merging. -1. **Spawn the round's reviewer** per the spawn spec, and act on the harness completion notification when it arrives. Do not badger it for results and do not re-read its message hoping for a different answer. - - **Liveness.** Only two things are evidence a reviewer is alive: the harness status, and byte growth on its transcript when the output file is one. Its own prose never is (one signed off with "I'm partway through the careful pass" while its status read `killed`). - - **A dead spawn is not a round.** Declined, errored, killed, or empty means nothing ran. It does not count toward the cycle and does not advance it. - - **Waiting is not blocking.** The spawn is async precisely so the turn stays free, so never wait on it with a foreground `sleep`: that stalls everything and hands back exactly what the async spawn bought. Keep working, or, if there is genuinely nothing else to do, do NOTHING and let the completion notification arrive. A blocking probe is worse than no probe. - - **A progress check is optional, runs in the BACKGROUND, and never kills.** Rising bytes on the spawn's output path (`stat -c %s`, following the symlink) prove it is working, so leave it alone however long it has run. Nothing proves the opposite: a file that never grows may simply be a stub the harness does not write to, and elapsed time never proves a stall, since reviewers here routinely run 5 to 10 minutes while working normally. So there is no AUTOMATIC re-spawn trigger short of a killed or errored harness status; never wire one to a flat file or a timer. Giving up applies ONLY where there is no growth to see (a flat file, a stub-backed spawn), and "none to see" means you PROBED and saw none, never that you did not look, so the probe is optional only while you are content to wait and required before you give up. There it is a DELIBERATE call, weighing how long it has been against what a restart costs, and that judgement is the one place elapsed time legitimately counts. A transcript that is still growing is never abandoned, whatever the clock says. Giving up means STOPPING it (`TaskStop`) and re-spawning, so there is never a second reviewer in flight, no question of which return is the round, and no late result to reconcile. Never read the transcript's contents: it is large enough to swamp your own context, and a subagent's words are not evidence either way. - - **A reviewer that returns without reviewing is also not a round.** Anything that is not a finding list or the literal `CLEAN` is a non-review, including "I could not fetch the diff", a refusal, or an answer to another question. The absence of findings is not a clean round. A REFUTER is the one exception, since its job is not to review: it answers `REFUTED` or `STANDS`, and either is a complete result. - - **Re-spawn rather than asking.** Spawn it again, varying the approach after a few identical failures. Never stop mid-cycle to report a failed spawn or hand back a half-finished cycle. - - **NEVER substitute an inline self-review.** Reviewing your own work re-derives the assumptions that produced the bug; that downgrade already shipped three real bugs through a PR two inline passes had called clean. Only a reviewer that cannot be produced at all stops the cycle, reported once at the end and kept out of the PR (a spawn that could not run is session tooling, not a fact about the change). - - **Working-tree safety.** Every worktree shares ONE `.git`, so a reviewer's git write reaches this session's checkout (one ran `git checkout main` mid-cycle and the local checkout regressed). - - `isolation: "worktree"` on every spawn, so a stray checkout cannot move the files under this session. - - The read-only git prohibition in the prompt, which covers the shared refs and config that isolation cannot. - - After EACH spawn resolves, before acting on findings, check the repo: `git rev-parse --is-inside-work-tree` is `true` and `git config --get core.bare` is NOT `true` (spawning isolated reviewers has flipped it; repair with `git config core.bare false`, then `git worktree prune`, plus `git worktree remove -f -f .claude/worktrees/agent-*` for a locked leftover, never touching worktrees outside `.claude/worktrees/`). In the task's worktree, HEAD is still the feature branch and `git status` is clean; in the primary, HEAD is `main` (`git checkout -f main` if it moved). Run it after failed spawns too, since a spawn that died after creating its worktree is the likeliest leaker. GitHub is unaffected either way; this only repairs the local repo. - -2. **For each finding, do exactly ONE of three things.** There is no fourth, and "mention it and move on" is not one. A REFUTER, a fresh spawn told to DISPROVE a claim (does it reproduce in the code as written, is the behavior intended, is it already guarded or tested somewhere the finder did not look), has two uses here: BEFORE acting on a must-fix finding whose fix would be expensive or behavior-changing, and AFTER rejecting any must-fix finding, since a rejection is your own unadjudicated judgment and one cheap spawn is what adjudicates it. That second use is what a rejection buys instead of a whole round, and it terminates: one spawn per rejection, never a refuter of a refuter. A refuted finding is a rejection carrying the refuter's reason. On the post-rejection use only, a refuter that answers `STANDS` has contradicted your rejection, so the rejection does not hold: fix the finding, or defer it if it is genuinely out of scope. That is one of the three dispositions arriving late, not a fourth. In a delta round the fix it usually produces buys its round like any other; on the final review it joins that phase's single fix-check instead, since the final review is never re-run. (On the pre-action gate there is no rejection yet, so `STANDS` there simply means the finding is real and you act on it.) The two uses fail differently when no refuter can be produced: on the pre-action gate, act on the finding as real, since the gate is an optimization and must fail toward the finding being genuine; on the post-rejection adjudication, keep the rejection but record it as UNREFUTED on its thread and carry it into the end-of-cycle report, so the user can second-guess it. Trivial or obviously-real findings skip the first use. - - **Fix it** on the branch (commit + push to update the PR), OR - - **Reject it** with a one-sentence reason, stated to the user and recorded on the finding's thread. Rejection has to be defensible ("flagged as a security issue, but this runs server-side only and never sees user input"), not hand-waved. OR - - **Defer it** when it is genuine but out of scope (a pre-existing bug, an unrelated hygiene problem, a separate feature). A finding on code this PR adds or changes is NEVER out of scope, whatever its size. Deferral is not a way to drop a finding: record it on the thread, and carry it into the end-of-cycle report, where the USER decides what gets filed (the cycle never files a follow-up issue on its own; invoke `webjs-file-issue` only on their go-ahead). **The final summary review also carries a deferral ledger**, one line per deferral with its reason, so a cold reader sees them all without walking the threads. When unsure, fix it here. - - - **Record every finding ON THE PR**, through the mechanics in `### Every PR review is posted ON the PR` and `### Follow the real review flow`, which are authoritative: one review object per round carrying the summary plus every inline `file:line` finding, each stating the problem only, with the disposition (`fixed in ` / `rejected because ` / `deferred as out of scope because `) in a threaded reply, then the thread resolved. Post rejections and false positives too, so the reasoning is auditable. A round that found nothing posts a short summary saying so. Build the review JSON with a real serializer, never by interpolating into a shell string: a review on #1115 lost every code reference to shell command substitution and had to be reposted. - -**When the cycle FINISHES, run everything it deferred:** the full suites for every layer the change touches (e2e, Node, browser, Bun matrix, the two-app dogfood boot check, per the Definition of done). Launch them as parallel background tasks in one batch and collect EVERY result before reporting: a task you forget to collect is a silently skipped layer. A cycle that STOPPED unfinished runs none of this and says so in the report, because these gate the flip to ready for review and that flip is not happening. - -**A fix is never the end.** A fix changes the branch, so the changed branch needs its own round; that is what the delta rounds are, and why a round with no fixes still buys the final review. Never report "fixed it" or "ready to merge" off a round that found something must-fix, however obviously correct the fix looks. On #1159 three consecutive rounds each found problems introduced by the previous round's fix, which is what a re-used reviewer, already invested in that fix, is worst at seeing. - -**A standalone "review the PR" request IS this cycle, not a one-shot.** Re-enter at round 1 over the whole diff however many times the PR has been reviewed before, since the ask itself says the existing trail is not trusted. It also overrides the trivial-change skip below: when the user asks for a review, they get one. Then fix, reject, or defer, run the delta rounds, run the final review, and only then report back. - -### When to skip the cycle - -Skip only for PRs that change a single line of trivially-correct content (a doc typo, a renamed local variable, a one-token config bump). Anything that touches logic, public surface, the build, the importmap, security-relevant code, or multiple files goes through the cycle without exception. A bias toward running it is correct; a bias toward skipping it is the exact failure mode this rule exists to prevent. - -### Reporting after the cycle - -After the final review (and its fix-check, if it had one) and the deferred suites, report exactly this shape: - -> PR # is up at . Reviewed it over rounds plus a final pass over the whole diff; nothing must-fix is left open. Issues found and fixed: . Out-of-scope findings, recorded on the PR and awaiting your call on filing: . Ready to merge. - -When anything was deferred, expand each one right there (the finding, its one-sentence reason, its thread), and END WITH A DIRECT QUESTION, on its own line, asking whether to file follow-up issues and which ones. Filing happens only on the user's answer; silence is never consent. - -**Only a round where a fresh subagent actually REVIEWED counts toward ``.** A declined, errored, killed, or empty spawn produced no round, and neither did one that returned without reviewing, so neither can be the final review nor part of the total. An inline pass of your own is not a round at all. A cycle whose reviewer never reviewed has run ZERO rounds, and the honest report is that the review is blocked and why, not a count and not "ready to merge". - -If you cannot honestly say the final review left nothing must-fix open, you cannot say "ready to merge". Mention any finding you rejected as a false positive so the user can second-guess it. Every finding must be accounted for here as fixed, rejected-with-reason, or deferred, and must also appear on the PR, so the report and the PR agree; a deferred finding missing from its thread, this report, or the ledger is a dropped finding. - -**Merge is gated on green CI, enforced at the branch level, not by trust.** A PR must not merge until all CI checks pass. `main` branch protection requires the five `ci.yml` checks (Conventions, Unit+integration, Browser, E2E, Build) before any merge; if `gh api repos/webjsdev/webjs/branches/main/protection` shows `required_status_checks: null`, run `bash scripts/protect-main.sh` once (needs repo admin) to restore it. Do not work around a red or pending check. Wait for green, and fix whatever is red before merging. - -**This is the ONLY place CI is read, on purpose. Do not add one back to the end of the review cycle.** An end-of-cycle read was removed because it was redundant against this gate, which reads EVERY check and fixes what is red before merging, so a failure gets caught here whatever the ready-to-merge report claimed, and reading it earlier only parks the finished cycle on a multi-minute CI run. Be precise about what enforces that, because the two halves are not equally strong. Branch protection MECHANICALLY refuses the merge for the required contexts only, and `ci.yml` defines roughly twice as many jobs as `main` requires, so the rest are held by the INSTRUCTION above to read every check rather than by anything that can refuse a merge. Removing the end-of-cycle read did not weaken that, since the removed read was an instruction too and branch protection covered the same subset before and after. It does mean a green REQUIRED set is not the same as green CI, so read `gh pr checks` in full rather than trusting the merge button to have judged for you. What that costs is worth stating plainly, because it looks like a gap. Every `ci.yml` job with no counterpart in the deferred local suites now fails for the first time at merge rather than before the ready signal. That is a CLASS, not a list. Its membership moves as jobs are added and as the local suites grow to cover them, so derive it when you need it, by reading `.github/workflows/ci.yml` against the deferred set named above. Do NOT write the membership down here. - -That instruction is load-bearing and was expensive to learn. Three attempts to write the membership into this paragraph were each wrong, some by naming a job the local suites already cover, some by reading complete while omitting one they do not, and every one of them was caught by review rather than by the author. The reason the error keeps recurring is structural: deciding membership means holding `ci.yml` and the deferred set side by side and checking a non-obvious local counterpart for each job, which is a derivation, and a derivation frozen into prose is wrong the moment either side moves. So derive it, and do not leave the answer here for the next reader to trust. - -The class is real and not small, and what it costs is the same work done later rather than work skipped, which is the trade that was chosen. It is a real cost rather than a free one. The local suites the Definition of done demands still run at the end of the cycle and still gate the flip to ready for review, so only the CI read moved. +CI is read ONCE, at merge, never in a mid-work sleep loop. Read `gh pr checks` in full rather than trusting the merge button to have judged for you, because `ci.yml` defines roughly twice as many jobs as `main` requires, so the non-required ones are held by this instruction rather than by anything that can refuse a merge. **NEVER use `gh pr merge --admin` to bypass a FAILING check.** `--admin` skips ALL branch-protection gates, not only the review requirement, so a red check merges silently and lands broken code on `main`. This has happened (a Unit-test failure was admin-merged, breaking `main`). It is acceptable ONLY to bypass a required-review gate on a PR whose CI is confirmed all-green, so re-run `gh pr checks ` first and confirm EVERY check reads `pass` (a `BLOCKED` state can mean review-required OR a failing check, so never assume which). -### Subagent prompt template - -**The template fetches the diff and metadata over REST on purpose.** The porcelain equivalents go through GraphQL, and this template is pasted into EVERY reviewer in every round, so it was the single largest consumer of that budget in this skill. REST is a separate budget and returns the same bytes. Both are reads, so the read-only git constraint in the template is unaffected. See `.claude/gh-budget.md`. - -One template serves every reviewer in the cycle: round 1, each delta round, the final whole-diff review, the final review's fix-check, and a refuter. Only the question in its numbered step 5 changes. - -``` -Review PR # (branch ``) at https://github.com/webjsdev/webjs/pull/ for anything genuinely wrong with it, judged against the project's AGENTS.md and CONVENTIONS.md (root + per-package). - -HARD CONSTRAINT, read first: you are running against a repository the main session is actively using, and every worktree of it shares ONE `.git` directory, so a git write here reaches the main session's checkout even from an isolated worktree. You are a READ-ONLY reviewer. Do NOT run any command that changes git branch, HEAD, the index, or the working tree: no `git checkout`, `git switch`, `git reset`, `git restore`, `git stash`, `git pull`, `git fetch` that moves refs, `git merge`, `git rebase`, `git clean`, `git branch -f`, or `git worktree`. Any of these silently corrupts the main session's checkout (it moved HEAD off the branch and looked like lost work, and a stray worktree op once flipped the shared repo's `core.bare` to `true`). You do NOT need to switch branches to review. Use `gh api repos///pulls/ -H "Accept: application/vnd.github.diff"` for the diff and `gh api repos///pulls/` for metadata, and read any file at its PR-branch state with `gh api repos///contents/?ref= --jq .content | base64 -d`. All of those read from GitHub, so they work whether or not the branch exists locally, which matters because a PR you were asked to review may not be checked out here at all. If the branch does happen to be the one checked out, reading files in place is fine too. The only git you may run is read-only inspection (`git log`, `git show`, `git diff` WITHOUT changing state, `git status`, `git blame`). If you think you need to change git state to do the review, you are wrong; report what you found instead. - -You start with no prior context on this PR. Steps: - -1. Run `gh api repos/webjsdev/webjs/pulls/ -H "Accept: application/vnd.github.diff"` to see the full diff. -2. Run `gh api repos/webjsdev/webjs/pulls/ --jq '.title, .body'` to see what the author claims it does. -3. Read every file the diff touches in its current state (not just the diff hunks) so you see edits in context. -4. Read root AGENTS.md, the per-package AGENTS.md for each touched package, and CONVENTIONS.md if a scaffolded template was touched. -5. The question for this round is a SCOPE, not a checklist: .> Review it as a whole and report whatever is actually wrong. - -Report findings as a numbered list with file:line references. Problems only. No suggestions, no nits about style if the rule isn't enforceable. If you find nothing genuinely wrong, say exactly `CLEAN` on its own line and stop. Do not pad with "looks good overall" or summaries. (A REFUTER answers differently, since it is judging one claim rather than reviewing: say exactly `REFUTED` or `STANDS` on its own line, then one or two sentences of reason, and ignore the finding-list and `CLEAN` contract entirely.) - -If you CANNOT review (you could not fetch the diff, you have no access to the repo or PR, the branch does not resolve), say exactly `BLOCKED` on its own line followed by one line naming what you are missing. Do NOT report `CLEAN` in that case: `CLEAN` means you looked and found nothing, and reporting it for a review you could not perform is the single worst outcome here, because it ends the cycle on a review that never happened. -``` - ## After a merge: decide on a version bump, automatically After ANY PR that lands a user-facing change (a `feat` / `fix` / `perf` / `breaking` to a published package: `core`, `server`, `cli`, `ui`, `intellisense`, `mcp`; `intellisense` lives at `packages/editors/intellisense`, the rest at `packages/`) merges into `main`, assess whether a release bump is owed and open a release PR WITHOUT being asked. The user should not have to ask "do we need to bump versions?". Docs-only / chore / scaffold-doc changes do NOT bump on their own; they ride to the next functional bump. @@ -477,7 +328,7 @@ If a package has qualifying commits since its last `changelog//.md 4. `npm install --package-lock-only` and stage `package-lock.json` (a desync reds CI's `npm ci`). 5. Changelog: the pre-commit hook runs `scripts/backfill-changelog.js`, which parses `^(feat|fix|perf|breaking):` from commit subjects in the package's tree. **Squash-merge subjects are PR titles with no conventional prefix, so the generator finds nothing and the hook fails.** Hand-write `changelog//.md` (match an existing file's frontmatter: `package`, `version`, `date`, `commit_count`; sections ordered Breaking, Features, Performance, Fixes; entries link the PR and the squash commit) and stage it; then the commit passes. 6. Open the release PR. Note in the body that merging it adds the `changelog/**.md` files to `main`, which triggers `release.yml` to `npm publish` and cut GitHub Releases (idempotent). -7. Run the review cycle on it too (a release publishes to npm; a wrong bump level, missed package, or inaccurate changelog is worth catching). Merge is still user-gated. +7. Flip it ready for the owner's review like any other PR (a release publishes to npm, so a wrong bump level, missed package, or inaccurate changelog is worth their eyes). Merge is still user-gated. ### Then: make sure the deployed Railway services actually picked it up @@ -496,7 +347,7 @@ A merge updates `main` and npm, but the two in-repo apps deployed to Railway (`e - Opens the PR as a DRAFT at the START (step 6), not at the end. It is NOT created late once all the work is done. At draft-create time: - The body MUST include `Closes #` near the top so merging auto-closes the issue and the project card auto-moves to Done. If the work turns out to only partially address the issue, use a plain `#N` reference, not `Closes`. - The PR MUST be assigned to vivek7405 (`gh pr create ... --assignee vivek7405`). Matches the project's per-issue-owner convention. - - It stays a draft until the Definition of done is satisfied and the review cycle has finished; then `gh pr ready ` flips it to ready for review. + - It stays a draft until the Definition of done is satisfied, then `gh pr ready ` flips it to ready for the owner's review. - Does not make commits FOR you. Subsequent work follows the standard webjs git workflow (commit per logical unit, push after each, run tests before committing); those commits stream onto the already-open PR. - Does not merge. Merging is always user-approved per the project's git rules. @@ -504,8 +355,5 @@ A merge updates `main` and npm, but the two in-repo apps deployed to Railway (`e - If the TASK worktree's `git status` is dirty at start (a prior session died mid-work in it): stop and ask the user to commit, stash, or abandon that work. Never silently lose changes. A dirty PRIMARY checkout is not a blocker and not yours to fix; the worktree cuts from `origin/main` regardless. - If the issue is already in `In progress` (someone else's work, or a prior branch left open): report this and ask the user whether to continue on the existing branch, branch off a fresh main, or pick a different issue. -- If the TASK worktree regressed mid-loop (its HEAD detached or off the feature branch, work seemingly "gone"): a review subagent mutated shared git state. In the PRIMARY, HEAD on `main` is the healthy state, not a regression. Do NOT panic or redo work. The local feature-branch ref and `origin/` still point at the latest commit (every logical unit was pushed). Recover with `git -C checkout ` (anchored: run bare from the primary it would succeed and park the PRIMARY on the feature branch, since a detached worktree no longer holds it); confirm with `git log --oneline origin/main..HEAD` and `git status` clean. The PR on GitHub was never affected (the GitHub-reading reviewer still saw correct content), so no re-push or force-push is needed. -- If the FIFTH delta round still produces fixes: commit them, then stop instead of running a sixth. What makes this stop unfinished is not open findings, since that round's findings were fixed, but that its FIXES are on the branch unreviewed, so say exactly that. Report the rounds run, those unreviewed fixes, anything rejected or deferred along the way, and your read on why the fixes keep breaking something. Withhold the flip to ready for review; the branch, the commits, and the card all stay exactly as they are, and the deferred suites do not run, since they gate a flip that is not happening. -- If the final review's fix-check keeps surfacing must-fix findings (two of them in a row): stop there per the last step of the cycle, withhold the flip to ready for review, and report the rounds run, the open findings, and your read on why the fixes keep breaking something. The branch, the commits, and the card all stay exactly as they are. -- If a round's reviewer cannot be produced (a spawn declined at the permission prompt, an internal error, a killed task, a reviewer you stopped and re-spawned, or a return that is neither a finding list nor `CLEAN`): the round did not happen. Re-spawn it, varying the approach after a few identical failures, and do NOT stop mid-cycle to report the failure or ask how to proceed: recovering costs seconds and interrupting costs the cycle its momentum. Only a reviewer that cannot be produced at all blocks the cycle; then withhold the flip to ready for review and say once, at the end, that the review did not run and why. Keep that out of the PR body and PR comments, since a spawn that could not run is session tooling rather than a fact about the change. Do NOT review it yourself inline and count that as the round; an inline pass is what let three real bugs through a supposedly clean PR. The branch, the commits, and the card all stay exactly as they are. Full rules in the liveness block of the review cycle. +- If the TASK worktree regressed mid-loop (its HEAD detached or off the feature branch, work seemingly "gone"): a subagent mutated shared git state. In the PRIMARY, HEAD on `main` is the healthy state, not a regression. Do NOT panic or redo work. The local feature-branch ref and `origin/` still point at the latest commit (every logical unit was pushed). Recover with `git -C checkout ` (anchored: run bare from the primary it would succeed and park the PRIMARY on the feature branch, since a detached worktree no longer holds it); confirm with `git log --oneline origin/main..HEAD` and `git status` clean. The PR on GitHub was never affected (anything reading the PR from GitHub still saw correct content), so no re-push or force-push is needed. - If the `gh project item-edit` call fails (auth scope, missing field): report the failure clearly and offer to do the move manually via the web UI. The branch creation still stands. diff --git a/test/hooks/review-loop-exit.test.mjs b/test/hooks/review-loop-exit.test.mjs deleted file mode 100644 index b4529c5a2..000000000 --- a/test/hooks/review-loop-exit.test.mjs +++ /dev/null @@ -1,308 +0,0 @@ -// Guards the pre-merge review cycle across the two files that carry it: the -// webjs-start-work skill (the normative rules) and the skill-routing hook -// (the injected review directive, which once kept an OLD exit condition -// after the skill had moved on, steering standalone reviews back into a -// shape the skill no longer described). -// -// These are static assertions over committed text on purpose: the cycle is -// prose executed by agents, so the counterfactual for "the cycle silently -// reverted" is the text no longer carrying its load-bearing anchors. Each -// assertion fails if its hunk is reverted or typo-drifted. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const repo = resolve(here, '../..'); -const skill = readFileSync(resolve(repo, '.claude/skills/webjs-start-work/SKILL.md'), 'utf8'); -const hook = readFileSync(resolve(repo, '.claude/hooks/route-skills.sh'), 'utf8'); - -test('the skill prescribes ONE reviewer, never a fleet', () => { - // Round 1 is a single reviewer over the whole diff, with no path-based - // tier choice deciding its shape. - assert.match(skill, /Round 1: ONE fresh reviewer over the WHOLE diff/); - assert.match(skill, /No fleet, no lenses, no jury, no per-diff tier choice/); - // Later rounds narrow the QUESTION to the fixes, not the evidence. The - // fast default allows exactly one such round; the escalated cycle chains - // them. Both phrasings must keep saying the question is the fix commits. - assert.match(skill, /delta-scoped reviewer whose QUESTION is those fix commits alone/); - assert.match(skill, /each later round is delta-scoped to the previous round's fix commits/); - // Structure alone cannot bound a chain where every fix produces the next - // round's finding, which is the case the deleted budget was written for. - assert.match(skill, /\*\*A delta chain that keeps producing fixes stops after the FIFTH DELTA ROUND\*\*/); - // The stop needs a reporting shape of its own: the reporting section - // covers only the converged path. - assert.match(skill, /If the FIFTH delta round still produces fixes/); - // What makes this stop unfinished is unreviewed fixes, not open findings, - // which is the opposite of its sibling stop and the thing a report copied - // from that sibling gets wrong. - assert.match(skill, /but that its FIXES are on the branch unreviewed, so say exactly that/); - // And the do-not-restore paragraph must admit the cap came back rather - // than claiming termination is entirely structural. - assert.match(skill, /The one exception to the removals is the round cap, which came back in a narrower form/); - // The final whole-diff pass is what a clean delta round buys, and it is - // the reason a clean round is not by itself the end. - assert.match(skill, /buys the FINAL review: ONE fresh reviewer over the WHOLE diff again/); - assert.match(skill, /Either way a clean round is not the end of the THOROUGH cycle/); - // The final review's findings end in a fix plus ONE delta check of that - // fix, which does not re-open the cycle. - assert.match(skill, /Its fixes get ONE delta-scoped check of those fix commits alone/); - assert.match(skill, /The cycle ends when nothing must-fix is left OPEN/); - // The same two words for the same disposition in every rule that names - // it, since "refuted or deferred" silently dropped the unrefutable - // rejection the refuter-unavailable rule creates. - assert.match(skill, /every must-fix finding it raised was rejected or deferred/); - assert.match(skill, /The final review is never re-run and the delta rounds are never re-entered/); - // A fix-check that finds something gets ONE more of the same shape, then - // the cycle stops unfinished rather than looping. - assert.match(skill, /ONE more check of the same shape, and if that one does too, stop and report the PR unfinished/); - // NOTE: a clean round 1 no longer buys the final review unconditionally. - // That floor is now bought by the escalation ladder, which the FAST - // default test below owns; asserting it here too would contradict it. -}); - -test('the skill pins every reviewer to Opus, async, and worktree-isolated', () => { - assert.match(skill, /`model: "opus"` \(Opus 5, always, no other model anywhere in the cycle\)/); - assert.match(skill, /`run_in_background: true`/); - assert.match(skill, /`isolation: "worktree"`/); - assert.match(skill, /`subagent_type: "general-purpose"`/); - // No reviewer anywhere in the skill is pinned to another model family. - assert.ok(!/fable/i.test(skill), 'a reviewer was pinned back to fable'); -}); - -test('FAST is the default cycle, and the thorough one is bought by evidence', () => { - // The owner should never have to ask for a short cycle. A reviewer spawn - // costs about 10 minutes, so an unconditional second round taxes every PR - // to cover a miss that mostly matters on a few surfaces. - assert.match(skill, /\*\*FAST is the default shape\.\*\*/); - assert.match(skill, /a clean or minor-only round 1 finishes the cycle with ONE review/i); - // Speed must come from fewer ROUNDS, never from a softer bar inside one. - // Reclassifying findings as minor is the cheat this forbids. - assert.match(skill, /Speed is bought by running fewer rounds, NEVER by lowering the bar inside a round/); - - // All three escalation triggers, since dropping any one silently widens - // the fast path over changes that were meant to get the second read. - assert.match(skill, /the owner asks for a thorough, full, or deep review/); - assert.match(skill, /TWO OR MORE must-fix findings/); - assert.match(skill, /the serializer, SSR or action dispatch, auth or session, the client router, or the elision analyser/); - - // The final review still EXISTS; it is conditional, not deleted. - assert.match(skill, /buys the FINAL review: ONE fresh reviewer over the WHOLE diff again/); - // And the five-delta cap survives on the escalated path. - assert.match(skill, /\*\*A delta chain that keeps producing fixes stops after the FIFTH DELTA ROUND\*\*/); - - // A review finding that is out of scope is reported, not filed. - assert.match(skill, /\*\*Do not file follow-up issues for what a review turns up\.\*\*/); - - // The do-not-restore note must record WHY the two-review floor went, or - // the next agent reads the missing final review as a regression to fix. - assert.match(skill, /Do NOT reinstate an unconditional final review or an unconditional two-review floor/); - assert.ok( - !/every PR gets at least two reviews/.test(skill), - 'the unconditional two-review floor is back in the skill', - ); - - // The hook mirrors the skill, so the default must match on both sides. - assert.match(hook, /defaults to its FAST shape/); - assert.match(hook, /Escalate to the THOROUGH shape/); - assert.match(hook, /Do not file follow-up issues for review findings that are out of scope/); -}); - -test('the minor / must-fix call is by surface, not by importance', () => { - assert.match(skill, /\*\*Minor or must-fix\.\*\*/); - // The three must-fix surfaces, including the tautological-test case that - // an importance test would wrongly eject. - assert.match(skill, /ability to OBSERVE the defect it claims to cover/); - assert.match(skill, /factual claim about runtime behavior in docs/); - assert.match(skill, /Judge by SURFACE, never by importance/); - // Doubt resolves toward keeping the cycle open. - assert.match(skill, /When it could go either way, it is must-fix/); - // Only a FIX buys a round. A rejection produces no fix commits, so a - // round would re-pose the same question over an unchanged head to a - // reviewer that is never told what was already handled, which is how the - // pre-final loop lost its bound when the round budget went. The refuter - // is what adjudicates a rejection, and it terminates in one spawn. - // Under the fast default it is specifically a MUST-FIX fix, since a - // minor-only round cannot introduce the class of defect a round catches. - assert.match(skill, /\*\*Only a MUST-FIX fix buys a round, and it buys exactly ONE:\*\*/); - assert.match(skill, /A rejection buys one REFUTER, a deferral buys nothing/); - assert.match(skill, /what a rejection buys instead of a whole round, and it terminates: one spawn per rejection, never a refuter of a refuter/); - // A refuter has two verdicts and the cycle must define both, or a finding - // whose rejection was contradicted ends with no disposition at all. - assert.match(skill, /On the post-rejection use only, a refuter that answers `STANDS` has contradicted your rejection/); - // The pre-action gate has no rejection to overturn, so STANDS means the - // finding is real, and a final-phase fix cannot buy a round that phase - // forbids. - assert.match(skill, /On the pre-action gate there is no rejection yet/); - assert.match(skill, /on the final review it joins that phase's single fix-check instead/); - // The two uses fail in opposite directions when no refuter can be spawned. - assert.match(skill, /on the pre-action gate, act on the finding as real/); - assert.match(skill, /keep the rejection but record it as UNREFUTED/); -}); - -test('the removed machinery stays removed, with the reason recorded', () => { - // The paragraph that tells a future reader the omissions were deliberate. - assert.match(skill, /Do not restore what this replaced/); - assert.match(skill, /Almost all of it is gone on purpose: termination is mostly structural now/); - // The fleet workflow itself is gone from the repo. - assert.ok(!existsSync(resolve(repo, '.claude/workflows/deep-review.js')), 'the deep-review fleet workflow is back'); - // None of the removed mechanisms may re-enter, as a rule or as vocabulary. - // The do-not-restore paragraph describes them WITHOUT these words ("two - // tiers", "a 5-round budget", "poll a file"), so the expected count is - // zero and any occurrence is a re-introduction rather than a mention. - for (const [label, re] of [ - ['the substantive/prose tier vocabulary', /substantive[^.]{0,40}(tier|prose)|(tier|prose)[^.]{0,40}substantive/i], - ['the round budget', /round budget|over budget/i], - ['the polling watchdog', /watchdog/i], - ]) { - assert.ok(!re.test(skill), `${label} is back in the skill`); - } -}); - -test('CI is read only at the merge gate, never at the end of the cycle', () => { - // The third removal in this section, pinned the same way as the fleet and - // the two-review floor above: the note that records WHY it went, plus the - // counterfactual that the read itself has not crept back. - assert.match(skill, /\*\*This is the ONLY place CI is read, on purpose\. Do not add one back to the end of the review cycle\.\*\*/); - // The cost it accepts must stay stated, or the next reader takes the - // removal for an oversight and restores the read to "fix" it. It is stated - // as an open CLASS derived from ci.yml, never a written-down membership. - // Three attempts at writing that membership down were each wrong, in one - // direction or the other, so the instruction not to enumerate is itself - // the load-bearing part and is pinned alongside the class phrasing. - assert.match(skill, /Every `ci\.yml` job with no counterpart in the deferred local suites/); - assert.match(skill, /That is a CLASS, not a list/); - assert.match(skill, /Do NOT write the membership down here/); - - // The four instructions that USED to make the cycle wait on CI. Each is - // gone, and a revert of the hunk that removed it puts its phrasing back. - for (const [label, re] of [ - ['the ready-to-merge condition', /suites it deferred have run AND CI has been read green/], - ['the keep-the-cycle-fast rule', /Never wait on CI between rounds/], - ['the end-of-cycle batch', /and only now read CI|plus a background CI watch/], - ['the report preamble', /the deferred suites, and the CI read, report exactly/], - ]) { - assert.ok(!re.test(skill), `${label} tells the cycle to read CI again`); - } - - // The gate leans on reading every check, NOT on branch protection alone, - // which mechanically covers only the required subset. Overstating that was - // the removal's original justification and it was false for exactly the - // jobs it was justifying the cost of. - assert.match(skill, /Branch protection MECHANICALLY refuses the merge for the required contexts only/); - assert.match(skill, /a green REQUIRED set is not the same as green CI/); - - // The gate the removal leans on has to stay strict, since it is now the - // only CI checkpoint there is. - assert.match(skill, /\*\*Merge is gated on green CI, enforced at the branch level, not by trust\.\*\*/); - assert.match(skill, /\*\*NEVER use `gh pr merge --admin` to bypass a FAILING check\.\*\*/); -}); - -test('the cycle keeps the guarantees the trim was not allowed to touch', () => { - // A fix is never the end: the delta round after a fix is what the whole - // cycle exists to force. - assert.match(skill, /A fix changes the branch, so the changed branch needs its own round/); - // The pre-fix wording said "Fixing (or rejecting) a must-fix finding - // changes the branch", which both restates the reversed rule and is not - // true of a rejection. It must not survive anywhere in the file. - assert.ok(!/Fixing \(or rejecting\)/.test(skill), 'the reversed rejection-buys-a-round rule is back'); - // A dead or non-reviewing spawn is not a round, and an inline pass is - // never a substitute for one. - assert.match(skill, /A dead spawn is not a round/); - // Waiting must not block the turn, which is the whole point of spawning - // the reviewer in the background, and the optional progress check must - // never kill on a timer or on a flat file (elapsed time says nothing: a - // reviewer here runs 5 to 10 minutes while working normally, and a file - // that never grows may simply be a stub the harness does not write to). - assert.match(skill, /\*\*Waiting is not blocking\.\*\*/); - assert.match(skill, /never wait on it with a foreground `sleep`/); - assert.match(skill, /\*\*A progress check is optional, runs in the BACKGROUND, and never kills\.\*\*/); - assert.match(skill, /elapsed time never proves a stall/); - assert.match(skill, /never wire one to a flat file or a timer/); - // There is no automatic trigger beyond a dead status, so giving up is a - // deliberate call, and that call is the one place elapsed time legitimately - // counts. It is scoped to the state with nothing to read, it requires - // having actually probed, and it stops the reviewer rather than leaving a - // second one in flight. - assert.match(skill, /Giving up applies ONLY where there is no growth to see/); - assert.match(skill, /that judgement is the one place elapsed time legitimately counts/); - // The give-up clause must not reach the one state that HAS a positive - // signal, or it contradicts "leave it alone however long it has run". - assert.match(skill, /A transcript that is still growing is never abandoned, whatever the clock says/); - // An abandoned reviewer was produced and may still return, so it is not a - // failed spawn, and its late findings are read rather than discarded. - // "No growth to see" must mean you looked, or never probing re-authorizes - // abandoning a reviewer that is in fact working. - assert.match(skill, /means you PROBED and saw none, never that you did not look/); - // Giving up stops the reviewer, so no second one is ever left in flight to - // return late findings the cycle has no phase or review object to absorb. - assert.match(skill, /Giving up means STOPPING it \(`TaskStop`\) and re-spawning/); - assert.ok(!/Take whichever returns first as the round/.test(skill), 'the two-reviewers-in-flight case is back'); - // Step 1 must not ban polling outright while the bullet below sanctions a - // background size probe of the same spawn. - assert.ok(!/Do not poll it and do not re-read its message/.test(skill), 'step 1 bans the probe the progress check sanctions'); - assert.match(skill, /Do not badger it for results/); - // And the do-not-restore paragraph must not claim a replacement mechanism - // richer than what is actually there, which is how a polling watchdog - // grew back once already. - assert.match(skill, /the harness completion notification is the signal, with at most an optional background progress check that never kills anything/); - // The liveness sentence must not claim the harness status is the ONLY - // signal while the progress check reads byte growth as one. - assert.match(skill, /Only two things are evidence a reviewer is alive/); - assert.match(skill, /Never read the transcript.s contents/); - assert.match(skill, /A reviewer that returns without reviewing is also not a round/); - assert.match(skill, /NEVER substitute an inline self-review/); - // The literal sentinels the cycle reads a reviewer's answer by. - assert.match(skill, /say exactly `CLEAN` on its own line and stop/); - assert.match(skill, /say exactly `BLOCKED` on its own line/); - // The template must not keep offering the no-fix-commit delta round that - // cycle step 3 replaced with the final review, since following it re-opens - // the unbounded path. - assert.ok(!/delta round following a round with no fix commits/.test(skill), 'the removed no-fix delta round is back in the prompt template'); - // Working-tree safety: isolation, the read-only git prohibition, and the - // per-spawn repo-health check that catches a leaked worktree. - assert.match(skill, /\*\*Working-tree safety\.\*\*/); - assert.match(skill, /You are a READ-ONLY reviewer/); - assert.match(skill, /After EACH spawn resolves, before acting on findings, check the repo/); - // Reviewers stay starved of prior review context. - assert.match(skill, /NEVER prior PR comments or reviews/); - // The prompt sets the scope and nothing else. A defect-class checklist - // narrows the reviewer to what the author already suspects, which is the - // bias a fresh reviewer exists to escape. - assert.match(skill, /\*\*Do not tell it what to look for\.\*\*/); - assert.match(skill, /no list of defect classes, no "specifically check for X and Y"/); - assert.match(skill, /The question for this round is a SCOPE, not a checklist/); - // The three dispositions and the deferral ledger survive. - assert.match(skill, /the cycle never files a follow-up issue on its own/); - assert.match(skill, /final summary review also carries a deferral ledger/); - assert.match(skill, /END WITH A DIRECT QUESTION/); -}); - -test('the routed review directive states the same cycle as the skill', () => { - // The directive must not describe a fleet or a tiered round 1. - assert.ok(!/deep-review/.test(hook), 'route-skills.sh still routes round 1 to the deep-review fleet'); - assert.ok(!/round budget|OVER BUDGET/i.test(hook), 'route-skills.sh reverted to the round budget'); - // The shape, in lockstep with the skill. - assert.match(hook, /ONE fresh reviewer over the whole diff, never a fleet/); - assert.match(hook, /each later round is delta-scoped/); - assert.match(hook, /the first round that produces no fixes, whether it found nothing must-fix or everything it found was rejected or deferred, buys a FINAL review over the whole diff again/); - assert.match(hook, /model opus \(Opus 5, never fable\)/); - assert.match(hook, /isolation worktree/); - // The judgment rule, including its fail-open direction. - assert.match(hook, /MINOR or MUST-FIX by SURFACE, never by importance/); - assert.match(hook, /when it could go either way it is must-fix/); - assert.match(hook, /ONE delta check of that fix alone/); - assert.match(hook, /Never report the PR ready off a round that found something must-fix/); - // The directive must resolve the fix-check case the SAME way the skill - // does, and must not both end the cycle and forbid reporting it. - assert.match(hook, /the cycle ends when nothing must-fix is left open, meaning the check came back with nothing or there was no fix to check because every must-fix finding the final review raised was rejected or deferred/); - assert.match(hook, /a check that does find something must-fix gets that fixed and one more check of the same shape, and only if that one also finds something must-fix do you stop and report the PR unfinished/); - assert.match(hook, /Only a FIX buys another round/); - assert.match(hook, /a delta chain that keeps producing fixes stops after the fifth delta round, unfinished, rather than continuing/); - // The code-review skill's own findings are input to the cycle, not a - // round of it. - assert.match(hook, /auxiliary input, not as a round of it/); -}); From e14434a6d83880e8ffcdb2e9dc1f01511c49637e Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:15:37 +0530 Subject: [PATCH 2/7] feat: add the agent-agnostic pr-review skill When the owner asks for a PR review, the agent now reviews inline and posts the review through the GitHub review API the way a human reviews on the dashboard: one review object carrying a summary plus line-anchored comments, highlighting ranges via start_line/line and offering suggestion blocks where a concrete replacement is obvious. The reviewer only reviews: it never fixes findings, never resolves threads, never waits on or reports CI, and is never delegated to a subagent. The skill is written harness-agnostically (plain gh/REST, no harness-specific tools) at .claude/skills/pr-review and exposed cross-agent via the .agents/skills/ symlink, following the existing convention. route-skills now routes review phrases to it, and the portability test guards it like any other project skill. --- .agents/skills/pr-review | 1 + .claude/hooks/route-skills.sh | 18 +-- .claude/skills/pr-review/SKILL.md | 166 +++++++++++++++++++++++ .claude/skills/webjs-start-work/SKILL.md | 2 +- test/hooks/route-skills.test.mjs | 13 +- 5 files changed, 185 insertions(+), 15 deletions(-) create mode 120000 .agents/skills/pr-review create mode 100644 .claude/skills/pr-review/SKILL.md diff --git a/.agents/skills/pr-review b/.agents/skills/pr-review new file mode 120000 index 000000000..c0f54c5c7 --- /dev/null +++ b/.agents/skills/pr-review @@ -0,0 +1 @@ +../../.claude/skills/pr-review \ No newline at end of file diff --git a/.claude/hooks/route-skills.sh b/.claude/hooks/route-skills.sh index 03775ae92..5611d6414 100755 --- a/.claude/hooks/route-skills.sh +++ b/.claude/hooks/route-skills.sh @@ -192,18 +192,20 @@ if printf '%s' "$lc" | grep -q 'instagram' \ add_match "webjs-instagram-post: the request is to publish to the WebJs Instagram account. Invoke the webjs-instagram-post skill. Every post is SEO-only, so ALWAYS create a fresh branded image plus a keyword-rich caption, host the JPEG at a public HTTPS URL, and CONFIRM the image and caption with the user before the public publish. Never print or commit the access token." fi -# --- code-review: the owner reviews PRs; inline-only when asked --------- +# --- pr-review: review a PR inline, posted via the GitHub API ----------- # Triggers: review the PR/diff/branch/changes, code review, look it over -# for bugs. The owner reviews every PR themselves before merge, inline or -# with an agent of their own choosing. There is NO automated pre-merge -# review cycle and NO reviewer subagent, so review phrasings inject the -# inline-only guard below rather than a cycle. code-review is a built-in -# Claude Code skill (no in-repo SKILL.md, so the portability test that -# guards project skills does not cover it). +# for bugs. The owner reviews every PR themselves before merge; when they +# ask THIS agent for a review, the pr-review skill runs it like a human +# reviewer working over the GitHub API instead of the dashboard: one +# inline read, one posted review object (summary plus line-anchored +# comments with suggestion blocks). Review only: no reviewer subagent, +# no multi-round cycle, no fixing findings, no waiting on CI. The skill +# is agent-agnostic (plain gh/REST) and committed at +# .claude/skills/pr-review, exposed cross-agent via .agents/skills/. if has '(review|audit) (the |my |this )?(pr|diff|branch|change|changes|code|commit)' \ || has 'code ?review' \ || has '(review|look) .{0,20}(over )?for (bug|issue|correctness|regression)'; then - add_match "code-review: the request is to review code. Invoke the code-review skill (it reviews the diff for correctness bugs plus reuse and simplification) and perform the review YOURSELF, inline in this session. NEVER spawn a reviewer subagent and NEVER run a multi-round review cycle. One inline read, findings reported to the owner in the conversation, and the owner decides what gets fixed. The owner reviews every PR themselves before merge, so your job is to hand them a reviewable PR, not to review it for them." + add_match "pr-review: the request is to review code. Invoke the pr-review skill and perform the review YOURSELF, inline in this session. NEVER spawn a reviewer subagent and NEVER run a multi-round review cycle. When the target is a pull request, post the review through the GitHub review API as ONE review object, a summary plus line-anchored comments that highlight the code to fix and carry suggestion blocks where a concrete replacement is obvious, exactly as the skill specifies. The reviewer ONLY reviews: it does not fix findings, does not resolve threads, and never waits on or reports CI. For a local diff with no PR, review inline and report the findings in the conversation instead. The owner decides what gets fixed, and fixing is separate work on a separate ask." fi # --- verify: prove the change works by running the app ------------------ diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 000000000..761a7dbec --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,166 @@ +--- +name: pr-review +description: Review a GitHub pull request the way a human reviewer would, entirely inline, and post the review through the GitHub review API as ONE review object, a summary plus line-anchored comments that highlight the code to fix and carry suggestion blocks where a concrete replacement is obvious. Trigger whenever the user asks to review a PR ("review the PR", "review #123", "look over this pull request", "review the branch/changes" when the branch has an open PR). The reviewer ONLY reviews. It never fixes findings, never waits on or reports CI, never resolves threads, and never delegates to a subagent or another agent. +when_to_use: | + Examples that should trigger this skill: + "review the PR" + "review #1453" + "can you review this pull request" + "review my changes" (when the branch has an open PR) + "look over the diff for bugs" (when the diff is a PR) + Do NOT trigger for: fixing review findings (that is normal branch work, + a separate task from the review), responding to someone else's review + comments, merging, or reviewing a local uncommitted diff with no PR + (review that inline and report the findings in the conversation). +--- + +# Review a pull request (inline, posted via the GitHub API) + +Review a PR like a human reviewer who happens to use the GitHub API +instead of the dashboard. Same output a person would produce on +github.com: one submitted review carrying a summary and inline comments +anchored to the exact lines, with GitHub suggestion blocks where the fix +is concrete enough to spell out. + +This skill is agent-agnostic on purpose. Everything below is plain `gh` +CLI (or raw REST with any HTTP client and a token), no harness-specific +tools, so it works the same from any agent or harness that can run shell +commands. + +## The contract (read first, all four are hard rules) + +1. **Inline.** The agent that received the ask performs the review + itself, in the same session. Never spawn a reviewer subagent, a + fleet, or a background task for it, and never run a multi-round + review cycle. One read over the whole diff, one posted review, done. +2. **Review only.** The reviewer never fixes what it finds: no commits, + no pushes, no code edits, no resolving of threads. Findings are the + deliverable. Fixing them is separate work for whoever owns the + branch, on a separate ask. +3. **No CI.** Never wait on, read, or report CI or check status. Checks + are the merge gate's business, not the reviewer's, and a review that + stalls on a pending check has failed its one job of being fast + feedback on the code. +4. **Read-only on git.** Reviewing needs no checkout. Do not run any + git command that changes branch, HEAD, the index, or the working + tree (`checkout`, `switch`, `reset`, `restore`, `stash`, `merge`, + `rebase`, `clean`). Read-only inspection (`git log`, `git show`, + `git diff`, `git status`, `git blame`) is fine. + +## Steps + +### 1. Resolve the PR + +An explicit `#N` wins. Otherwise resolve the current branch's open PR +over REST (owner/repo from the git remote): + +```sh +BRANCH=$(git branch --show-current) +gh api "repos///pulls?head=:$BRANCH&state=open" \ + --jq '.[0].number' +``` + +If nothing resolves, ask the user which PR they mean rather than +guessing. + +### 2. Fetch what a reviewer reads + +All over REST (in repos that budget GraphQL, these cost nothing there): + +```sh +gh api repos///pulls/ --jq '.title, .body, .head.sha, .user.login' +gh api repos///pulls/ -H "Accept: application/vnd.github.diff" +``` + +Then read every touched file in its PR-head state, not just the hunks, +so each edit is judged in context. Read locally if the branch happens to +be checked out, else through the contents API: + +```sh +gh api "repos///contents/?ref=" --jq .content | base64 -d +``` + +Capture `head.sha` from the first call. The review is posted against it +(`commit_id`), which pins every comment to the exact code you read. + +### 3. Review the whole diff, yourself + +Judge the change against what it claims to do (title + body) and the +project's own stated rules (root `AGENTS.md`, `CONVENTIONS.md`, +per-package variants, when present). Correctness first: wrong behavior, +broken edge cases, a test that cannot observe the defect it claims to +cover, security problems, a doc stating something the code does not do. +Problems, not style nits, and no checklist narrowing: read the diff and +report whatever is actually wrong. A finding states the problem the way +a reviewer flags it, not the fix baked in as if already applied. + +### 4. Compose ONE review object + +The summary plus every inline comment go in a single review, which is +what makes GitHub render them as a grouped unit with the +"reviewed these changes" trail. Shape: + +```json +{ + "commit_id": "", + "event": "COMMENT", + "body": "", + "comments": [ + { "path": "src/x.js", "line": 42, "side": "RIGHT", + "body": "" }, + { "path": "src/y.js", "start_line": 10, "start_side": "RIGHT", + "line": 14, "side": "RIGHT", + "body": "" } + ] +} +``` + +- **Highlight a range** with `start_line` + `line` (both `side: RIGHT` + for added code, `LEFT` for deleted). A single-line comment omits + `start_line`. +- **Suggest the fix** where a concrete, self-contained replacement + exists, using a GitHub suggestion block in the comment body. The + block replaces EXACTLY the commented line range, so it must contain + the full replacement for those lines, correctly indented: + + ````markdown + This drops id 0 too. Strict-compare against null instead: + + ```suggestion + if (id !== null) { + ``` + ```` + + The user can then apply it with one click. Suggest only on lines the + diff adds or keeps (`side: RIGHT`), never on pure deletions, and skip + the suggestion when the real fix is bigger than the commented range; + state the problem and sketch the fix in prose instead. +- Every `line` must be part of the PR diff (a changed line or nearby + hunk context), or the API rejects the whole review. A finding on an + untouched line goes path-level into the summary body. +- Build the JSON with a real serializer (`jq -n`, python), never by + interpolating into a shell string, which mangles quotes and code + spans. + +**Verdict.** `event: "COMMENT"` on a PR you (the account posting) +authored, since GitHub forbids `APPROVE` / `REQUEST_CHANGES` on your +own PR, and that is the common case when the agent reviews the account +owner's work. On someone else's PR, pick the honest verdict: +`APPROVE`, `COMMENT`, or `REQUEST_CHANGES`. + +**Voice.** First person, plain, the way a person reviews code. Terse +inline findings, a summary that may go broad (what the change does +well, the one thing that matters). No AI or agent framing, no process +narration, no machinery tells (test counts, check status). + +### 5. Post it and report back + +```sh +gh api -X POST repos///pulls//reviews --input review.json +``` + +A clean review still posts: a short summary saying it is clean, with no +inline comments. Then tell the user the outcome in one or two +sentences, with the review's URL and the finding count. Stop there. No +fixing, no thread resolution, no follow-up issues, no re-review unless +they ask again. diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index 6a63cea67..7a64429d8 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -260,7 +260,7 @@ There is NO automated pre-merge review cycle in this workflow. Do not spawn revi Before flipping to ready, run everything the Definition of done demands: the full suites for every layer the change touches (full Node, browser, e2e, the Bun matrix, the two-app dogfood boot check). Launch them as parallel background tasks in one batch and collect EVERY result before reporting, because a task you forget to collect is a silently skipped layer. Then `gh pr ready ` and report back that the PR awaits the owner's review. Never report the PR ready with failing or unrun suites. -**If the owner explicitly asks you to review something, do it yourself, inline in this session** (the built-in code-review skill fits), and report the findings in the conversation. Never delegate that review to a subagent, and never expand it into rounds. One read, findings reported, the owner decides what gets fixed. +**If the owner explicitly asks you to review a PR, do it yourself, inline in this session, per the `pr-review` skill** (`.claude/skills/pr-review`, exposed cross-agent at `.agents/skills/pr-review`): one read over the whole diff, posted to the PR through the GitHub review API as one review object, a summary plus line-anchored comments with suggestion blocks. The reviewer ONLY reviews. It never fixes the findings, never resolves threads, never waits on or reports CI, and is never delegated to a subagent or expanded into rounds. The owner decides what gets fixed, and fixing is separate branch work on a separate ask. ### Acting on the owner's review comments diff --git a/test/hooks/route-skills.test.mjs b/test/hooks/route-skills.test.mjs index 6ad2d55a5..e4519bd29 100644 --- a/test/hooks/route-skills.test.mjs +++ b/test/hooks/route-skills.test.mjs @@ -178,7 +178,7 @@ test('webjs-instagram-post stays quiet without instagram intent', () => { assert.ok(!routed(ctx, 'webjs-instagram-post'), 'non-instagram publish must not route to instagram'); }); -test('code-review routes on review phrases', () => { +test('pr-review routes on review phrases', () => { for (const p of [ 'review the PR', 'review my changes', @@ -187,7 +187,7 @@ test('code-review routes on review phrases', () => { 'review the branch', ]) { const { ctx } = run(p); - assert.ok(routed(ctx, 'code-review'), `expected code-review route for: ${p}`); + assert.ok(routed(ctx, 'pr-review'), `expected pr-review route for: ${p}`); } }); @@ -234,13 +234,14 @@ test('every skill the hook can route to is committed in-repo (no dangling refere // The hook names skills it routes to; each PROJECT skill MUST have a // committed `.claude/skills//SKILL.md`, or a fresh clone routes a // prompt at a skill that does not exist (the #543 portability bug). The - // regex matches only project-skill names (webjs-* and use-railway); - // built-in Claude Code skills the hook also routes (code-review, verify) - // ship with the CLI for everyone, so they are intentionally exempt. + // regex matches only project-skill names (webjs-*, use-railway, + // pr-review); the built-in Claude Code skill the hook also routes + // (verify) ships with the CLI for everyone, so it is intentionally + // exempt. // Extract the project-skill names from the hook source and assert each is // present in the repo. const hookSrc = readFileSync(HOOK, 'utf8'); - const names = [...new Set((hookSrc.match(/\b(?:webjs-[a-z-]+|use-railway)\b/g) || []))]; + const names = [...new Set((hookSrc.match(/\b(?:webjs-[a-z-]+|use-railway|pr-review)\b/g) || []))]; assert.ok(names.length >= 4, `expected the hook to reference its skills; found ${names.join(', ')}`); for (const name of names) { const skillFile = resolve(REPO, '.claude/skills', name, 'SKILL.md'); From 3b318aa892defd77e24d34f14a0de0d5f9f50e51 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:36:40 +0530 Subject: [PATCH 3/7] fix: address review findings on the pr-review skill Three findings from the review on this PR. The paragraph closing the complete-PR list lost its blank line when the review-comments item was removed above it, so it rendered as a lazy continuation inside the context-comments bullet. The skill read touched files at the base repo by branch name, which 404s on a fork PR because that branch does not exist there. It now addresses the head repo at the head sha, which covers both cases and pins the read to the commit the review is posted against. The routing test only proved a skill named pr-review was reached, so nothing guarded what the injected directive says. review-loop-exit had done that job for the old cycle and caught drift once. The new assertions pin the no-subagent, no-rounds, review-only, and no-CI rules across both the directive and the skill, and a counterfactual confirms they red when the rule is removed. --- .claude/skills/pr-review/SKILL.md | 15 ++++++++--- .claude/skills/webjs-start-work/SKILL.md | 1 + test/hooks/route-skills.test.mjs | 33 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 761a7dbec..6b8c201cb 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -68,7 +68,8 @@ guessing. All over REST (in repos that budget GraphQL, these cost nothing there): ```sh -gh api repos///pulls/ --jq '.title, .body, .head.sha, .user.login' +gh api repos///pulls/ \ + --jq '.title, .body, .head.sha, .head.repo.full_name, .user.login' gh api repos///pulls/ -H "Accept: application/vnd.github.diff" ``` @@ -77,11 +78,17 @@ so each edit is judged in context. Read locally if the branch happens to be checked out, else through the contents API: ```sh -gh api "repos///contents/?ref=" --jq .content | base64 -d +gh api "repos//contents/?ref=" --jq .content | base64 -d ``` -Capture `head.sha` from the first call. The review is posted against it -(`commit_id`), which pins every comment to the exact code you read. +Address the HEAD repo at the HEAD sha, not the base repo at a branch +name. A fork PR's branch does not exist in the base repo, so a +branch-name ref there 404s, and a sha pins every read to the exact +commit the review is posted against. Both values come from the first +call (`.head.repo.full_name`, `.head.sha`); on a same-repo PR the head +repo IS `/`, so one form covers both cases. + +Capture `head.sha` from the first call for `commit_id` too. ### 3. Review the whole diff, yourself diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index 7a64429d8..065d403e8 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -250,6 +250,7 @@ A finished PR is not just a diff. It carries three artifacts, and the PR is cons **Why this matters (do not skip it):** PRs are squash-merged, so the PR TITLE becomes the squash commit subject on `main`, and `scripts/backfill-changelog.js` (run by the pre-commit hook on a version bump) extracts changelog entries by matching that subject against `^(feat|fix|breaking|perf)(scope)?!?:` and reads the commit BODY (the PR description) for the entry text. A non-prefixed title (e.g. `De-flake the prefetch e2e...`) produces ZERO changelog entries, which forces a hand-written changelog at release time, which is wrong. NEVER hand-write `changelog//.md`: fix the PR title/body instead so the automation produces it. If you ever find yourself about to hand-write a changelog, stop and correct the merged PR titles (or the release's source commits) so they are conventional-commit prefixed. 2. **A meaningful body.** `Closes #` near the top, a summary, what changed and why, the deliberately-excluded decisions, a test plan, and the docs surfaces touched (per the Definition of done above). This is the architectural narrative of the change. Because the squash commit body IS this PR description, write the first paragraph so it reads as the changelog entry text (the generator uses it), then continue with the rest. 3. **Context comments.** The reasoning from the working conversation that the diff and body do not capture, posted on the PR as the discussion happens (see "Capture significant design discussion as PR comments" below). The PR is the durable memory; the chat transcript is not. + All three are written in the owner's voice (first person, plain, no AI/agent framing) and free of AGENTS.md invariant 11 banned glyphs. The no-machinery-tells rule binds the context comments; the PR BODY is the one place machinery evidence is REQUIRED content (the test plan and the dogfood results the Definition of done demands), so reporting it there is not a tell. The sections below specify the mechanics for item 3. **Header every standalone comment with a short, meaningful bold heading** so a future reader (human or AI) knows what the comment is and what it is about before reading it. Put the heading on its own first line as bold markdown, blank line, then the body. Write the heading to fit THIS comment, do not pick from a fixed list. A good heading names the kind of comment and its topic, e.g. `**Design rationale: why analysis moved off boot, and what it costs**`, `**Decision: kept the derived gate over a declared allowlist**`, `**Follow-up: aliased-expose 404 filed as #N**`. A bare category word like `Context` or `Review` is the floor, not the goal; prefer a heading that also says the subject, so a reader scanning the PR's comment list can tell the boot-rationale note from the elision note without opening either. **Threaded replies inside the owner's review comments do NOT need a heading** because the thread already classifies them. Keep those terse. The heading rule is for standalone, top-level comments (the PR body in item 2 is exempt, since it has its own `## Summary` structure). diff --git a/test/hooks/route-skills.test.mjs b/test/hooks/route-skills.test.mjs index e4519bd29..fa5ba6685 100644 --- a/test/hooks/route-skills.test.mjs +++ b/test/hooks/route-skills.test.mjs @@ -191,6 +191,39 @@ test('pr-review routes on review phrases', () => { } }); +// The routing test above only proves a skill NAMED pr-review is reached. It +// says nothing about what the injected directive tells the model to DO, and +// the rules below are the whole point of the skill: reviews happen inline, +// the reviewer only reviews, and it never blocks on CI. Nothing else in the +// suite would notice if a round count, a reviewer subagent, or a CI wait +// were put back, so both carriers of those rules are pinned here. +// +// This replaces test/hooks/review-loop-exit.test.mjs, which guarded the +// previous review cycle's load-bearing wording across the same two files and +// caught them drifting apart once. +test('the pr-review directive and skill both keep the review contract', () => { + const { ctx } = run('review the PR'); + const directive = ctx.split('\n').find((l) => l.startsWith('- pr-review:')); + assert.ok(directive, 'expected a pr-review directive line'); + const skill = readFileSync(resolve(REPO, '.claude/skills/pr-review/SKILL.md'), 'utf8'); + + // Inline, never delegated: a subagent reviewer is what this replaced. + assert.match(directive, /NEVER spawn a reviewer subagent/); + assert.match(skill, /Never spawn a reviewer subagent/i); + // One read, not a cycle. A round count coming back is the regression. + assert.match(directive, /NEVER run a multi-round review cycle/); + assert.match(skill, /never run a multi-round\s+review cycle/i); + // Review only: fixing the findings is the author's job, on a separate ask. + assert.match(directive, /does not fix findings/); + assert.match(skill, /\*\*Review only\.\*\* The reviewer never fixes what it finds/); + // Never blocks on CI, which is the merge gate's business. + assert.match(directive, /never waits on or reports CI/); + assert.match(skill, /\*\*No CI\.\*\* Never wait on, read, or report CI/); + // The output is a real GitHub review object, not a chat-only reply. + assert.match(directive, /post the review through the GitHub review API as ONE review object/); + assert.match(skill, /pulls\/\/reviews/); +}); + test('verify routes on verify / dogfood phrases', () => { for (const p of [ 'verify the fix works', From 5186889d1e54151ee55cd5839e0f0f69c3970469 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:36:55 +0530 Subject: [PATCH 4/7] chore: stop shipping a review-process mandate in scaffolded apps WebJs is opinionated about the code (the conventions, `webjs check`, the test layers) and has no business dictating how another team reviews a pull request. The scaffold did anyway: every generated app carried a mandatory multi-round self-review loop in its agent rules, plus a PR checklist item citing a Pre-merge self-review loop section of CONVENTIONS.md that the scaffold does not ship, so the reference was dangling on top of being unwanted. Both are gone, replaced by a note that the review process is the team's own call. The stale mirrors in examples/blog go with them. The monorepo keeps its own `pr-review` skill. It is not shipped, because create.js copies exactly one skill by hardcoded path, and the new scaffold test pins that: a generated app of either template ships the `webjs` skill and nothing else, and its agent rules mandate no review process. Nothing structural enforced this before, so a change that copied the skills directory wholesale would have shipped our workflow into every app silently. --- examples/blog/.agents/rules/workflow.md | 17 ++-- examples/blog/.cursorrules | 16 ++-- examples/blog/.github/copilot-instructions.md | 20 ++--- .../cli/templates/.agents/rules/workflow.md | 7 +- .../.github/pull_request_template.md | 4 - test/scaffolds/scaffold-agent-skills.test.js | 82 +++++++++++++++++++ 6 files changed, 103 insertions(+), 43 deletions(-) create mode 100644 test/scaffolds/scaffold-agent-skills.test.js diff --git a/examples/blog/.agents/rules/workflow.md b/examples/blog/.agents/rules/workflow.md index d3f7c7c1d..26f7e0b62 100644 --- a/examples/blog/.agents/rules/workflow.md +++ b/examples/blog/.agents/rules/workflow.md @@ -53,19 +53,12 @@ Every code change must include: "N/A because " in the PR body. Docs land on the same PR as the code, never as a follow-up. 4. Convention check: `webjs check` must pass. -5. Pre-merge self-review loop. Before saying the PR is ready for merge, run - fresh-context review rounds until one round finds zero issues. Antigravity - primitive: open a new Cascade thread or a fresh side-panel session for - each round so the reviewer has no prior context on the implementation - decisions. Minimum two rounds; rotate focus each round. Skip the loop - only for one-line trivial changes; skipping on a change that touches - logic, public surface, build, security, or multiple files is the exact - failure mode the loop exists to prevent. The full rule, prompt template, - and reporting contract live in the **Pre-merge self-review loop** section - of CONVENTIONS.md. -The user should never have to ask for tests, documentation, or the -self-review loop. +How a PR gets REVIEWED is deliberately not specified here. Use whatever your +team already does. WebJs has opinions about the code (the conventions above, +`webjs check`, the test layers) and none about your review process. + +The user should never have to ask for tests or documentation. ## Git rules diff --git a/examples/blog/.cursorrules b/examples/blog/.cursorrules index c67687766..c1bbb5b50 100644 --- a/examples/blog/.cursorrules +++ b/examples/blog/.cursorrules @@ -57,18 +57,12 @@ Every code change must include: "N/A because " in the PR body. Docs land on the same PR as the code, never as a follow-up. 4. Convention check: `webjs check` must pass. -5. Pre-merge self-review loop. Before saying the PR is ready for merge, run - fresh-context review rounds until one round finds zero issues. Cursor - primitive: open a NEW composer tab and prompt the review there so the - reviewer has no prior context on your decisions. Minimum two rounds; - rotate focus each round. Skip the loop only for one-line trivial - changes; skipping on a change that touches logic, public surface, build, - security, or multiple files is the exact failure mode the loop exists - to prevent. The full rule, prompt template, and reporting contract live - in the **Pre-merge self-review loop** section of CONVENTIONS.md. -The user should never have to ask for tests, documentation, or the -self-review loop. +How a PR gets REVIEWED is deliberately not specified here. Use whatever your +team already does. WebJs has opinions about the code (the conventions above, +`webjs check`, the test layers) and none about your review process. + +The user should never have to ask for tests or documentation. ## Git rules diff --git a/examples/blog/.github/copilot-instructions.md b/examples/blog/.github/copilot-instructions.md index 56b0659db..03edfdbd5 100644 --- a/examples/blog/.github/copilot-instructions.md +++ b/examples/blog/.github/copilot-instructions.md @@ -53,19 +53,13 @@ Every code change must include: "N/A because " in the PR body. Docs land on the same PR as the code, never as a follow-up. 4. Convention check: `webjs check` must pass. -5. Pre-merge self-review loop. Before saying the PR is ready for merge, - run fresh-context review rounds until one round finds zero issues. - Copilot primitive: open a NEW chat session (reset the side panel) for - each round so the reviewer has no prior context on the implementation - decisions. Minimum two rounds; rotate focus each round. Skip the loop - only for one-line trivial changes; skipping on a change that touches - logic, public surface, build, security, or multiple files is the exact - failure mode the loop exists to prevent. The full rule, prompt - template, and reporting contract live in the **Pre-merge self-review - loop** section of CONVENTIONS.md. - -The user should never have to ask for tests, documentation, or the -self-review loop. The commit-per-logical-unit rule lives under "Git rules" + +How a PR gets REVIEWED is deliberately not specified here. Use whatever your +team already does. WebJs has opinions about the code (the conventions above, +`webjs check`, the test layers) and none about your review process. + +The user should never have to ask for tests or documentation. The +commit-per-logical-unit rule lives under "Git rules" below, not here, since it governs how work is grouped rather than what each change must include. diff --git a/packages/cli/templates/.agents/rules/workflow.md b/packages/cli/templates/.agents/rules/workflow.md index 384685f8d..3cfbf8b4e 100644 --- a/packages/cli/templates/.agents/rules/workflow.md +++ b/packages/cli/templates/.agents/rules/workflow.md @@ -59,9 +59,10 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs. tsconfig), either of which would 500 the app at runtime. Everything else it reports is a warning that cannot fail the build. Widen or narrow the gate in `package.json` rather than in the workflow. -5. Pre-merge self-review: before saying a PR is ready, run fresh-context review - rounds until one round finds zero issues (minimum two rounds, rotate focus). - Skip only for a one-line trivial change. + +How a PR gets REVIEWED is deliberately not specified here. Use whatever your +team already does. WebJs has opinions about the code (the conventions above, +`webjs check`, the test layers) and none about your review process. ## Git rules diff --git a/packages/cli/templates/.github/pull_request_template.md b/packages/cli/templates/.github/pull_request_template.md index 808394f48..c698a389e 100644 --- a/packages/cli/templates/.github/pull_request_template.md +++ b/packages/cli/templates/.github/pull_request_template.md @@ -33,7 +33,3 @@ in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance. there. - [ ] **Scaffold scripts / codegen** (if the project has any). Updated when the change affects what new instances generate. -- [ ] **Pre-merge self-review loop.** Ran N rounds; last round clean. - Skip only for one-line trivial changes. See the **Pre-merge - self-review loop** section in [`CONVENTIONS.md`](../CONVENTIONS.md) - for the prompt template and reporting contract. diff --git a/test/scaffolds/scaffold-agent-skills.test.js b/test/scaffolds/scaffold-agent-skills.test.js new file mode 100644 index 000000000..4909656eb --- /dev/null +++ b/test/scaffolds/scaffold-agent-skills.test.js @@ -0,0 +1,82 @@ +/** + * Guards the boundary between the monorepo's OWN agent skills and what a + * generated app ships. + * + * The framework keeps a set of workflow skills for working on WebJs itself + * (`.claude/skills/*`, exposed cross-agent through `.agents/skills/*` + * symlinks). Exactly ONE of them, `webjs`, is a teaching surface for app + * authors and belongs in a generated app. Everything else is monorepo process: + * how we file issues, how we sync docs, how we review our own PRs. Shipping + * any of it would push our process onto a team that already has its own, and + * `pr-review` is the live example, since how a team reviews pull requests is + * theirs to decide. + * + * Nothing structural enforces that today: `create.js` copies the skill by a + * hardcoded path, so a future change that copies the skills DIRECTORY instead + * would ship the lot silently, and the app would look fine. These assertions + * are that enforcement. + * + * They also pin the reverse direction: `webjs` must actually arrive, since the + * skill is the only teaching surface that survives `npm run gallery:clear`, so + * an app missing it has no reference at all once the gallery is stripped. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { scaffoldApp } from '../../packages/cli/lib/create.js'; + +// The one skill an app author needs. Anything else under .agents/skills/ in a +// generated app is monorepo process that leaked. +const APP_SKILLS = ['webjs']; + +for (const template of ['full-stack', 'api']) { + test(`${template} scaffold ships the webjs skill and no monorepo process skills`, async () => { + const cwd = await mkdtemp(join(tmpdir(), 'webjs-scaffold-skills-')); + try { + await scaffoldApp('demo', cwd, { template, install: false }); + const skillsDir = join(cwd, 'demo', '.agents', 'skills'); + assert.ok(existsSync(skillsDir), '.agents/skills/ must exist in a generated app'); + + const shipped = readdirSync(skillsDir).sort(); + assert.deepEqual(shipped, APP_SKILLS, + `a generated app must ship exactly ${APP_SKILLS.join(', ')}; found ${shipped.join(', ') || '(none)'}`); + + // The skill has to be the real thing, not an empty directory. + assert.ok(existsSync(join(skillsDir, 'webjs', 'SKILL.md')), 'the `webjs` skill ships its SKILL.md'); + assert.ok(existsSync(join(skillsDir, 'webjs', 'references')), 'the `webjs` skill ships its references/'); + + // Named explicitly so the failure message says WHY, rather than only + // that a directory listing changed. + for (const monorepoOnly of ['pr-review', 'webjs-start-work', 'webjs-file-issue', 'webjs-doc-sync']) { + assert.ok(!existsSync(join(skillsDir, monorepoOnly)), + `${monorepoOnly} is a monorepo workflow skill and must not ship to a generated app`); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +} + +test('the generated agent rules do not mandate a review process', async () => { + // WebJs is opinionated about the code (conventions, `webjs check`, the test + // layers) and deliberately silent on how a team reviews a PR. The scaffold + // used to ship a mandatory multi-round self-review loop plus a PR-template + // checkbox pointing at a CONVENTIONS.md section that did not exist. + const cwd = await mkdtemp(join(tmpdir(), 'webjs-scaffold-skills-')); + try { + await scaffoldApp('demo', cwd, { template: 'full-stack', install: false }); + for (const rel of ['.agents/rules/workflow.md', '.github/pull_request_template.md']) { + const p = join(cwd, 'demo', rel); + if (!existsSync(p)) continue; + const src = await readFile(p, 'utf8'); + assert.doesNotMatch(src, /self-review/i, `${rel} must not mandate a self-review loop`); + assert.doesNotMatch(src, /fresh-context review/i, `${rel} must not mandate review rounds`); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); From b61ad8ba0fb1374b9c574450de44cf06548789ce Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 19:58:12 +0530 Subject: [PATCH 5/7] feat: ship one agent surface in scaffolded apps, AGENTS.md plus .agents A generated app no longer carries per-agent rule files or vendor tool config. `AGENTS.md`, the open standard Cursor, opencode, Antigravity and the Copilot coding agent read natively, is the entry point, and it routes to `.agents/` for everything else: the teaching skill and the workflow rules. Removed from the scaffold: `CLAUDE.md`, `CONVENTIONS.md`, `.claude.json`, `.claude/settings.json`, and the nine `.claude/hooks/*` scripts. #1368 had already dropped `GEMINI.md` and the Copilot bridge, so this finishes the job on one principle: how another team runs its tools is that team's call, and a framework has no business shipping its own tool config into their repo. Nothing that actually protects an app was lost, because the enforcement that mattered was already agent-agnostic or is now written down. `check-server-imports` duplicated `webjs check`'s no-server-import-in-browser-module rule, which CI runs, and its own header said so. `guard-branch-context` duplicated `.hooks/pre-commit`, which blocks commits on main at the git level and therefore binds every agent, editor, and human rather than one. The test, commit-cadence and worktree hooks restate rules `.agents/rules/workflow.md` already carries. `block-prose-punctuation` enforced OUR house style, including our brand casing, which a user's app has no reason to inherit. Two gaps were real and are covered in `.agents/` rather than dropped. `block-raw-htmlelement` had no `webjs check` equivalent, so the skill's components reference now teaches why a component extends WebComponent and never raw HTMLElement, naming all four failure modes and saying plainly that no rule catches it. And `.claude.json` was what wired the read-only `@webjsdev/mcp` server, so the scaffold's AGENTS.md now tells the reader to register it in whatever MCP config their agent uses. The monorepo keeps its own `.claude/` alongside `.agents/`, since that is our tooling and is never scaffolded. Root AGENTS.md now spells out the two audiences, which had been conflated. --- .agents/rules/workflow.md | 1 + .agents/skills/webjs/references/components.md | 12 + AGENTS.md | 10 +- README.md | 4 +- packages/cli/AGENTS.md | 23 +- packages/cli/README.md | 4 +- packages/cli/bin/webjs.js | 2 +- packages/cli/lib/create.js | 36 +-- packages/cli/lib/runtime-rewrite.js | 2 +- packages/cli/templates/.claude.json | 14 - .../.claude/hooks/block-prose-punctuation.sh | 278 ------------------ .../.claude/hooks/block-raw-htmlelement.sh | 83 ------ .../.claude/hooks/check-server-imports.mjs | 86 ------ .../.claude/hooks/check-server-imports.sh | 26 -- .../.claude/hooks/cleanup-merged-worktree.sh | 129 -------- .../.claude/hooks/commit-before-stop.sh | 52 ---- .../.claude/hooks/guard-branch-context.sh | 39 --- .../.claude/hooks/nudge-uncommitted.sh | 46 --- .../.claude/hooks/require-tests-with-src.sh | 103 ------- packages/cli/templates/.claude/settings.json | 81 ----- .../.github/pull_request_template.md | 6 +- packages/cli/templates/AGENTS.md | 8 +- packages/cli/templates/CLAUDE.md | 23 -- packages/cli/templates/CONVENTIONS.md | 36 --- packages/cli/templates/gitignore | 14 +- test/hooks/block-prose-punctuation.test.mjs | 36 ++- test/hooks/check-server-imports.test.mjs | 56 ---- test/hooks/require-tests-with-src.test.mjs | 169 ----------- test/scaffolds/scaffold-agent-skills.test.js | 19 ++ test/scaffolds/scaffold-integration.test.js | 88 ++---- test/scaffolds/scaffold-runtime.test.js | 2 +- .../scaffold-template-validation.test.js | 4 +- website/app/docs/ai-first/page.ts | 2 +- website/app/docs/conventions/page.ts | 9 +- 34 files changed, 150 insertions(+), 1353 deletions(-) delete mode 100644 packages/cli/templates/.claude.json delete mode 100755 packages/cli/templates/.claude/hooks/block-prose-punctuation.sh delete mode 100755 packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh delete mode 100644 packages/cli/templates/.claude/hooks/check-server-imports.mjs delete mode 100755 packages/cli/templates/.claude/hooks/check-server-imports.sh delete mode 100755 packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh delete mode 100755 packages/cli/templates/.claude/hooks/commit-before-stop.sh delete mode 100644 packages/cli/templates/.claude/hooks/guard-branch-context.sh delete mode 100755 packages/cli/templates/.claude/hooks/nudge-uncommitted.sh delete mode 100755 packages/cli/templates/.claude/hooks/require-tests-with-src.sh delete mode 100644 packages/cli/templates/.claude/settings.json delete mode 100644 packages/cli/templates/CLAUDE.md delete mode 100644 packages/cli/templates/CONVENTIONS.md delete mode 100644 test/hooks/check-server-imports.test.mjs delete mode 100644 test/hooks/require-tests-with-src.test.mjs diff --git a/.agents/rules/workflow.md b/.agents/rules/workflow.md index 58c0b0a57..48f01e000 100644 --- a/.agents/rules/workflow.md +++ b/.agents/rules/workflow.md @@ -58,6 +58,7 @@ These project-level rules govern all operations inside this workspace. Antigravi - `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. + - `pr-review`: Trigger when asked to review a PR. The review runs INLINE in the session that was asked, never in a subagent and never as a multi-round cycle, and posts through the GitHub review API as one review object (a summary plus line-anchored comments with suggestion blocks). It only reviews: it does not fix findings, resolve threads, or wait on CI. - `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. diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 5ec88276c..4770c6560 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -3,6 +3,7 @@ ## What This Covers - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on ``, ARIA derived in `render()` +- Why every component extends `WebComponent` and never raw `HTMLElement` - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`) - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch` - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips @@ -139,6 +140,17 @@ The first version ships three modules instead of one: itself, plus ` **The exception, and it is a real one.** Markup that is static *today* but is the thing a near-term behaviour will read is fine to keep, because the alternative is a component that reaches outward for it later, which is what rule 1 forbids. Judge the behaviour you are building, not one you are speculating about. When those genuinely collide, ownership wins over bytes: a coherent component that ships a little extra markup beats a split feature that a selector holds together. +## Extend `WebComponent`, never raw `HTMLElement` + +Every custom element in a WebJs app extends the framework base class, through the factory (`extends WebComponent({ ... })`) when it declares reactive properties or bare `extends WebComponent` when it does not. A plain `class X extends HTMLElement` is valid DOM and broken WebJs, in four ways that all fail quietly rather than loudly: + +- It is **invisible to the elision analyser**, so it ships unconditionally, defeats display-only elision for itself, and keeps any page or layout importing it from being import-only. +- It gets **no SSR**. The renderer instantiates and renders `WebComponent` subclasses on the server; a raw element contributes nothing to the first paint. +- It has **no reactive properties and no lifecycle**, so no `render()` re-runs, no `willUpdate`, no `updated`, and no attribute coercion. +- It pushes its DOM work into `connectedCallback`, which is **client-only**, so the content does not exist with JS off. That is a progressive-enhancement bug, and progressive enhancement is the default architecture. + +No `webjs check` rule catches this today, so nothing fails when you write it. Treat it as a rule you hold yourself: if a file calls `customElements.define`, the class it defines extends `WebComponent`. + ## Reactive properties: the base-class factory Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`). diff --git a/AGENTS.md b/AGENTS.md index 12655a2f6..d8dc72fd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,13 @@ 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), 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`). +**WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source**: `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`. + +Be careful to distinguish TWO audiences here, because they now differ. + +**A SCAFFOLDED APP ships `AGENTS.md` plus `.agents/` and nothing else.** No `CLAUDE.md`, no `.cursorrules`, no `.gemini/`, no `.claude/` settings or hooks. Per-agent bridges were removed progressively (#1368 dropped `GEMINI.md` and `.github/copilot-instructions.md`; the rest went with the review-cycle removal) on one principle: how another team runs its tools is that team's call, and a framework has no business shipping its own tool config into their repo. Everything that genuinely protects an app is enforced agent-agnostically instead, by `webjs check` (which CI runs) and the git `.hooks/pre-commit`. Do NOT add a per-agent file back to `packages/cli/templates/`; `test/scaffolds/scaffold-agent-skills.test.js` fails if you do. If a rule matters enough to enforce, it belongs in the skill or in a `webjs check` rule. + +**THIS MONOREPO keeps its own `.claude/` alongside `.agents/`**, because it is our repo and our tooling: the skills under `.claude/skills/` (symlinked into `.agents/skills/` for cross-agent use), the enforcement hooks under `.claude/hooks/`, and a `CLAUDE.md` bridge. None of it is scaffolded. ### Before starting ANY work: verify and sync the branch @@ -132,7 +138,7 @@ verification commands in `references/module-structure.md`. Every code change MUST include, automatically: 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). -2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). +2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/`, whose agent surface is `AGENTS.md` plus `.agents/`), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. 4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). diff --git a/README.md b/README.md index 5c33fff35..a73507575 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ examples/ gallery/ # the feature gallery every scaffolded app ships, as a live app website/ # landing site AND the documentation at /docs and gallery at /ui AGENTS.md # AI-agent contract for the framework -CLAUDE.md # Claude Code quick-reference +CLAUDE.md # Claude Code bridge (monorepo only, not scaffolded) ``` ## Local development @@ -317,7 +317,7 @@ tests. Key features: - **Core:** Signals (`signal`, `computed`, `effect`, `batch`, TC39 Stage 1 shape) as the default state primitive, with WebComponent's built-in SignalWatcher auto-tracking `.get()` reads inside `render()`. Reactive properties via the declare-free base-class factory `extends WebComponent({ count: Number })` (the `prop()` helper carries options like `reflect` / `state` / `attribute` / `default`), reserved for HTML attribute round-trip (a direct `static properties` block throws at runtime, flagged by the `no-static-properties` rule, and a class-field initializer on a factory prop is caught by `reactive-props-no-class-field`). Full lit-API parity: ReactiveController hooks (`hostConnected`, `hostDisconnected`, `hostUpdate`, `hostUpdated`) and lifecycle (`shouldUpdate`, `willUpdate`, `update`, `updated`, `firstUpdated`, `updateComplete`), 12 directives (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` + `createRef`, `cache`, `until`, `asyncAppend`, `asyncReplace`, `watch`). SSR with DSD (opt-in) + light-DOM hydration (default), light-DOM `` projection (framework-driven, same API as shadow DOM), fine-grained client renderer, `Suspense()`, client router with `composedPath()` for shadow DOM, mixed-attribute interpolation, MutationObserver upgrade safety net. - **Data:** Server actions with webjs's built-in serializer (`Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, reference cycles all survive the wire). Two-marker server-file convention: `.server.{js,ts}` for path-level source-protection (browser imports get a throw-at-load stub), `'use server'` for RPC registration (file is also browser-callable). REST over HTTP via a `route.ts` (or the `route()` adapter) with an optional `validate` config export. `json()` + `richFetch()` for content-negotiated APIs. `cache()` for server-side query caching with TTL + `invalidate()`. `WEBJS_PUBLIC_*` env vars injected into `window.process.env` at SSR (no build step, no transform). - **Server:** File router with `page.ts`, `layout.ts`, `route.ts`, `error.ts`, `loading.ts`, `not-found.ts`, `middleware.ts`, metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `opengraph-image`), per-segment middleware, `rateLimit()`, WebSockets (`WS` export + `connectWS()` + `broadcast()`), CSRF, gzip / brotli compression, HTTP/2, 103 Early Hints, modulepreload hints, health probes, graceful shutdown on `SIGTERM`, `Session` class with `SessionStorage` (cookie or store-backed), NextAuth-style `createAuth()` (Credentials, Google, GitHub), single pluggable cache store (in-memory by default, swap to Redis with one `setStore()` call shared by auth, sessions, caching, and rate limiting). -- **DX:** Node 24+ or Bun runtime (run a Bun app with `bun --bun run dev` / `start`, and the CLI hot-reloads via `node --watch` on Node and `bun --hot` on Bun), with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (or `amaro` on Bun, byte-identical), zero build, position-preserving, no sourcemap. Non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) fails with a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. Vendor (`node_modules`) packages resolve through importmap to jspm.io URLs at runtime; the WebJs server doesn't bundle them. `webjs vendor pin` writes resolved URLs to `.webjs/vendor/importmap.json` for deterministic deploys; `webjs vendor pin --download` additionally vendors bundle bytes for offline-capable production. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `no-static-properties`, `reactive-props-no-class-field`, `erasable-typescript-only`, `no-non-erasable-typescript`, `shell-in-non-root-layout`, and more (run `webjs check --rules` to enumerate). Single cross-agent source: `AGENTS.md` (read natively by Cursor, opencode, Antigravity, and the Copilot coding agent) plus the shipped skill `.agents/skills/webjs/SKILL.md` and the workflow rules in `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a thin bridge pointing at it (`CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`), and Claude Code adds a `.claude/settings.json` PreToolUse hook guarding edits on `main`. Live reload in dev (`fs.watch` + SSE). `@webjsdev/intellisense` is the standalone editor-only piece (no Lit dependency): its own `` html`…` `` template parser drives go-to-definition on tags / attributes / CSS classes, binding-aware completions, value/binding diagnostics, and hover, all gated by the file's import graph. The `webjs` VS Code / Cursor / Windsurf extension bundles it. Not required for the framework to run. +- **DX:** Node 24+ or Bun runtime (run a Bun app with `bun --bun run dev` / `start`, and the CLI hot-reloads via `node --watch` on Node and `bun --hot` on Bun), with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (or `amaro` on Bun, byte-identical), zero build, position-preserving, no sourcemap. Non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) fails with a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. Vendor (`node_modules`) packages resolve through importmap to jspm.io URLs at runtime; the WebJs server doesn't bundle them. `webjs vendor pin` writes resolved URLs to `.webjs/vendor/importmap.json` for deterministic deploys; `webjs vendor pin --download` additionally vendors bundle bytes for offline-capable production. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `no-static-properties`, `reactive-props-no-class-field`, `erasable-typescript-only`, `no-non-erasable-typescript`, `shell-in-non-root-layout`, and more (run `webjs check --rules` to enumerate). Single cross-agent source: `AGENTS.md` (read natively by Cursor, opencode, Antigravity, and the Copilot coding agent) plus the shipped skill `.agents/skills/webjs/SKILL.md` and the workflow rules in `.agents/rules/workflow.md`. A scaffolded app ships NO per-agent rule files and no vendor tool config, so it does not carry one team's tool choices into another's repo; the rules that protect the app are enforced agent-agnostically by `webjs check` and a git `.hooks/pre-commit`. Live reload in dev (`fs.watch` + SSE). `@webjsdev/intellisense` is the standalone editor-only piece (no Lit dependency): its own `` html`…` `` template parser drives go-to-definition on tags / attributes / CSS classes, binding-aware completions, value/binding diagnostics, and hover, all gated by the file's import graph. The `webjs` VS Code / Cursor / Windsurf extension bundles it. Not required for the framework to run. - **Release:** Per-package per-version changelog under `changelog//.md`, auto-generated on the same commit that bumps a `package.json` `version` field (universal pre-commit hook). The `.github/workflows/release.yml` workflow watches for new changelog files on `main` and dual-publishes to npm (`npm publish --workspace=@webjsdev/`) and GitHub Releases (`gh release create @`), both idempotent so re-runs pick up where they left off. Free for public repos via `NPM_TOKEN` + the auto-provisioned `GITHUB_TOKEN`. ## License diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c915c383f..35f2d9362 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -156,11 +156,12 @@ lib/ `test/runtime-rewrite/`. templates/ Verbatim files copied into every new app. {{APP_NAME}} placeholder is substituted at - copy time. The AGENTS.md / CLAUDE.md / - CONVENTIONS.md / .cursorrules / .agents/rules/workflow.md - / .github/copilot-instructions.md / .editorconfig - / .env.example / .claude.json / .claude/hooks/ - all live here. AGENTS.md ALSO gets a template-specific + copy time. AGENTS.md / .agents/rules/workflow.md + / .editorconfig / .env.example / .hooks/pre-commit + all live here. There are NO per-agent rule files + and no vendor tool config: AGENTS.md is the open + standard agents read natively, and it routes to + .agents/, which is the whole agent surface. AGENTS.md ALSO gets a template-specific {{PLAYBOOK}} injection: create.js substitutes the full-stack or api build playbook from templates/partials/agents-playbook-{fullstack,api}.md @@ -235,9 +236,9 @@ verbatim. `webjs start`, #550/#725), so a `db:generate`'d migration applies on the next boot with no manual `db:migrate`. Apps must NEVER use JSON files for persistence. This is a project - convention (documented in the scaffold's CONVENTIONS.md). + convention (documented in the scaffold's AGENTS.md). 4. **Template files are verbatim copies** with `{{APP_NAME}}` substitution. - When editing `templates/AGENTS.md`, `templates/CONVENTIONS.md`, or `.agents/skills/webjs/`, remember they ship + When editing `templates/AGENTS.md` or `.agents/skills/webjs/`, remember they ship into every scaffolded app. Write for the audience of an AI agent working inside a freshly-scaffolded WebJs project. `templates/AGENTS.md` is the one exception to "verbatim": it carries a `{{PLAYBOOK}}` marker @@ -246,8 +247,12 @@ verbatim. never ships UI-only guidance. Keep the shared meta-rules (required context-gathering, strict typing, data) in `AGENTS.md` itself and only the template-divergent build steps in the partials. -5. **`templates/CLAUDE.md` uses Claude Code's `@import` syntax** - (`@AGENTS.md`, `@CONVENTIONS.md`). See https://code.claude.com/docs/en/claude-md.md#import-additional-files +5. **The scaffold ships ONE agent surface: `AGENTS.md` plus `.agents/`.** + No `CLAUDE.md`, no `.cursorrules`, no `.gemini/`, no `.claude/` hooks or + settings. How a team runs its tools is the team's call, and the rules that + actually protect an app are enforced agent-agnostically: `webjs check` + (which CI runs) and `.hooks/pre-commit`. Do not add a per-agent bridge + back; if a rule matters, it belongs in the skill or in a check rule. ## Tests diff --git a/packages/cli/README.md b/packages/cli/README.md index d02535236..b9c621cc2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -68,8 +68,8 @@ the CLI gives you `webjs ui` automatically. See The scaffold seeds opinionated defaults so AI agents produce consistent code: -- `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth) -- `.agents/rules/workflow.md` & `.claude/` protective hooks +- `AGENTS.md` + `.agents/skills/webjs/` (single cross-agent source of truth, no per-agent rule files) +- `.agents/rules/workflow.md` & a git `.hooks/pre-commit` - `test//` (with optional `browser/` / `e2e/` subfolders per kind) with example tests - Tailwind CSS via CLI (no browser runtime at build time) - TypeScript, `.editorconfig`, `.gitignore` diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 3e50c168e..d4034b02b 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -716,7 +716,7 @@ async function main() { console.log(' security leak, a reactive prop that silently stops'); console.log(' re-rendering, or a build/type-strip failure. They always'); console.log(' run. Project conventions (layout, style, process) are'); - console.log(' guidance in CONVENTIONS.md, not rules here.\n'); + console.log(' guidance in AGENTS.md and .agents/, not rules here.\n'); for (const r of RULES) { console.log(` ${r.name.padEnd(30)} ${r.description}`); } diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 532635a6b..a773ff251 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -6,7 +6,7 @@ * - modules/ skeleton * - components/ with a theme toggle * - test/unit/ and test/e2e/ with example tests - * - CONVENTIONS.md, AGENTS.md, CLAUDE.md + * - AGENTS.md plus .agents/ (the one cross-agent guidance surface) * - package.json with WebJs deps + test scripts * - tsconfig.json for editor support */ @@ -623,27 +623,17 @@ export async function scaffoldApp(name, cwd, opts = {}) { exclude: ['node_modules', '.webjs/vendor', 'db/migrations'], }, null, 2) + '\n'); - // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) --- + // --- Templates (AGENTS.md, .agents/, test files, git hook) --- const templateFiles = [ - // Single cross-agent source: AGENTS.md points at .agents/skills/webjs/; the - // .agents/rules workflow rules and the Claude enforcement hooks back it up. + // ONE agent surface, no per-agent rule files. AGENTS.md is the open + // standard agents read natively, and it points at .agents/skills/webjs/ + // (the guidance) and .agents/rules/workflow.md (the workflow). A generated + // app gets no CLAUDE.md, no .cursorrules, no vendor hook config: how a team + // runs its tools is theirs to decide, and the rules that actually protect + // the app are enforced agent-agnostically by `webjs check` and CI. 'AGENTS.md', - 'CONVENTIONS.md', '.agents/rules/workflow.md', - 'CLAUDE.md', - // Claude Code config + the protective enforcement hooks (no design ceremony). - '.claude.json', - '.claude/settings.json', - '.claude/hooks/block-prose-punctuation.sh', - '.claude/hooks/block-raw-htmlelement.sh', - '.claude/hooks/guard-branch-context.sh', - '.claude/hooks/nudge-uncommitted.sh', - '.claude/hooks/commit-before-stop.sh', - '.claude/hooks/cleanup-merged-worktree.sh', - '.claude/hooks/require-tests-with-src.sh', - '.claude/hooks/check-server-imports.sh', - '.claude/hooks/check-server-imports.mjs', // Git pre-commit hook (blocks commits directly to main). '.hooks/pre-commit', // Starter tests under the feature-folder layout. @@ -673,7 +663,7 @@ export async function scaffoldApp(name, cwd, opts = {}) { // rewrites; the three infra files get their file-specific transform. On Node, // every file is copied byte-identical (the map is empty). const PROSE_REWRITE = new Set([ - 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md', + 'AGENTS.md', '.agents/rules/workflow.md', 'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts', ]); @@ -741,12 +731,10 @@ export async function scaffoldApp(name, cwd, opts = {}) { } } - // Make the Claude enforcement hooks + the git pre-commit executable. + // Make the git pre-commit hook executable. It is the one piece of + // enforcement the scaffold ships, and it is git-level rather than + // agent-level, so it binds every agent, editor, and human equally. const { chmod } = await import('node:fs/promises'); - for (const hook of ['block-prose-punctuation.sh', 'block-raw-htmlelement.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh', 'commit-before-stop.sh', 'cleanup-merged-worktree.sh', 'require-tests-with-src.sh', 'check-server-imports.sh']) { - const hookPath = join(appDir, '.claude', 'hooks', hook); - if (existsSync(hookPath)) await chmod(hookPath, 0o755); - } const preCommitPath = join(appDir, '.hooks', 'pre-commit'); if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755); diff --git a/packages/cli/lib/runtime-rewrite.js b/packages/cli/lib/runtime-rewrite.js index 1b104c771..944192263 100644 --- a/packages/cli/lib/runtime-rewrite.js +++ b/packages/cli/lib/runtime-rewrite.js @@ -7,7 +7,7 @@ * pure string transforms so they unit-test without touching the filesystem. * * Why a transform and not a second set of template files: the agent-config - * markdown (AGENTS.md / CONVENTIONS.md / .cursorrules / ...) plus the deploy + * markdown (AGENTS.md / .agents/rules/workflow.md) plus the deploy * files (Dockerfile / ci.yml) are long and change often; a parallel bun copy * would silently drift from the node original. A transform keeps the node * template canonical and the bun output a deterministic function of it. diff --git a/packages/cli/templates/.claude.json b/packages/cli/templates/.claude.json deleted file mode 100644 index cd0ee4a37..000000000 --- a/packages/cli/templates/.claude.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "mcpServers": { - "playwright": { - "type": "stdio", - "command": "npx", - "args": ["@playwright/mcp@latest"] - }, - "webjs": { - "type": "stdio", - "command": "npx", - "args": ["@webjsdev/mcp"] - } - } -} diff --git a/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh b/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh deleted file mode 100755 index 58d83613c..000000000 --- a/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bash -# -# PreToolUse hook: block prose-punctuation patterns the webjs convention bans. -# -# Catches four classes of new content in tool calls: -# -# 1. U+2014 em-dash, anywhere. -# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown -# lines, headings, blockquotes, a JSON "description" / "title" / -# "displayName" string value, and a column-0 YAML front-matter -# description: / title: / displayName: line). Math expressions in code like -# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged. -# 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2. -# JS / CSS statement terminators (`;\n`) are NOT flagged. -# 4. Code-shaped left-hand side immediately followed by a colon and prose: -# - `foo():` (markdown code-LHS in docs) -# - `:` (custom-element tag with hyphen) -# - Inline comment `// foo(): description` -# -# Why this exists: see AGENTS.md "Invariants", item 11. These patterns -# confuse AI agents that try to parse the prose as TypeScript / shorthand- -# method / object-literal syntax, and trip humans reading API docs. -# -# Covers two tool-call paths: -# * Write / Edit / MultiEdit / NotebookEdit. The hook inspects the NEW -# content fields of the tool payload. Existing glyphs in old_string -# are not flagged: you can still Edit a line that contains one to -# remove it. -# * Bash. The hook inspects the command string, which catches commit -# messages (`git commit -m "..."`), heredocs, echo / printf, and any -# other prose typed at the shell. - -set -euo pipefail - -payload=$(cat) - -# Pull every field where prose might land. `// empty` keeps missing -# fields silent; `[]?` keeps array iteration safe when absent. -new_content=$(printf '%s' "$payload" | jq -r ' - (.tool_input.content // empty), - (.tool_input.new_string // empty), - (.tool_input.new_source // empty), - (.tool_input.command // empty), - (.tool_input.edits[]?.new_string // empty) -' 2>/dev/null || true) - -if [ -z "$new_content" ]; then - exit 0 -fi - -# Every match below reads from a here-string, never a pipe. `grep -q` exits on -# the first match, which closes a pipe under `printf`, and with `set -o pipefail` -# that SIGPIPE became the pipeline status, so the rule silently skipped on any -# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB). - -# --- 1. U+2014 em-dash -------------------------------------------------- -if grep -q $'\xe2\x80\x94' <<< "$new_content"; then - cat >&2 <<'EOF' -BLOCKED: em-dash (U+2014) detected in this tool call. - -webjs bans em-dashes repo-wide. Replace every U+2014 character with -a period, comma, colon (on a plain-noun LHS), parentheses, or -restructured sentence. Do NOT replace it with " - " or " ; " or a -trailing colon on code: those are also banned. See rule 2 / 3 / 4 -below for the alternatives. - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -# --- 2. Pause-hyphen " - " in PROSE contexts ---------------------------- -# Only flag lines whose context is clearly prose: -# - Markdown lines starting with `#`, `>`, `*`, plain text outside code -# fences (heuristic: line has no `=`, `{`, or `(...)` math) -# - JSDoc / block comment lines starting with `*` -# - Single-line comments starting with `//` -# -# Math expressions like `Math.abs(a - b)` or `arr.length - 1` are NOT -# flagged because they appear in code lines (not comments) with code -# context. The hook trades some false negatives in prose for zero false -# positives in code-heavy diffs. - -block_pause_hyphen=0 - -# Comment-line " - " pause: line starts with `//` or ` *` (JSDoc/block) or -# `*` (markdown bold-start would have a letter after, distinguishable), -# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines -# like `// foo - bar`, ` * foo - bar`, `* foo - bar`. -if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -# Markdown heading " - " pause: line starts with `#` followed by prose -# and ` - ` pattern. -if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -# Markdown blockquote " - " pause: line starts with `>` followed by prose -# and ` - ` pattern. (Single `>` blockquote, not table.) -if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -# HTML / markdown

,

  • , body " - " pause: line contains a -# closing HTML tag from a prose context, then prose-style ` - `. -if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -# JSON prose-value " - " pause: a string assignment whose KEY is one of the -# three prose-bearing keys this project's JSON uses. Scoping to the key is what -# keeps this off semver ranges, script commands, urls, paths and globs, every -# one of which lives under a different key. Shape, not file path: the Bash -# payload carries no file_path, so a heredoc writing a manifest is covered too. -if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no -# leading whitespace, which is what confines it to document front matter: every -# nested YAML mapping is indented, including the workflow-input `description:` -# values in .github/workflows/release.yml. -if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then - block_pause_hyphen=1 -fi - -if [ "$block_pause_hyphen" = "1" ]; then - cat >&2 <<'EOF' -BLOCKED: pause-hyphen " - " detected in a prose context. - -webjs bans plain hyphens used as pause-punctuation in prose. Rewrite -the sentence with a period, comma, colon (on a plain-noun LHS), or -restructured phrasing. - - Bad: // Foo - bar - Good: // Foo, with bar - Good: // Foo. Bar. - - Bad:
  • Foo - bar.
  • - Good:
  • Foo, with bar.
  • - - Bad: "description": "A library - for things" - Good: "description": "A library for things" - -Plain hyphens are still fine in compound words (`AI-first`), CLI -flags (`--http2`), filenames, ranges, and math expressions in code -(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the -` < word > - < word > ` pause-pattern in prose contexts (comments, -markdown headings, blockquotes, HTML prose tags, and a JSON or -front-matter description / title / displayName value). - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -# --- 3. Pause-semicolon " ; " in PROSE contexts ------------------------- -# Same prose-context guard as #2. -block_pause_semicolon=0 - -if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -# JSON prose-value " ; " pause, same three keys as rule 2. -if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -# YAML front-matter " ; " pause, column-0 anchored like rule 2. -if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then - block_pause_semicolon=1 -fi - -if [ "$block_pause_semicolon" = "1" ]; then - cat >&2 <<'EOF' -BLOCKED: pause-semicolon " ; " detected in a prose context. - -webjs bans semicolons used as pause-punctuation in prose. Rewrite as -two sentences (period) or with a conjunction (", and", ", but", ", so"). - - Bad: // Forms work ; links work too. - Good: // Forms work. Links work too. - Good: // Forms work, and links work too. - - Bad: "description": "Forms work ; links work too." - Good: "description": "Forms work. Links work too." - -Semicolons stay fine inside code (JS statement terminators, CSS -declarations) since those are not flagged. Only the space-surrounded -form is banned, so an ordinary English semicolon is untouched. - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -# --- 4a. foo(): prose --------------------------------------- -# Markdown / HTML definition list with code-call followed by colon and -# lowercase prose. The `):` shape is unambiguous: this is markdown, -# not code, AND the inner code ends in `()` so the colon visually parses -# as a return-type annotation. -if grep -qE '\):[[:space:]][a-z]' <<< "$new_content"; then - cat >&2 <<'EOF' -BLOCKED: code-LHS colon-then-prose detected ("foo(): ..."). - -webjs bans `foo(): ` because the colon visually -parses as a TypeScript return-type annotation. Rewrite verb-led. - - Bad: repeat(): keyed list directive - Good: repeat() is the keyed list directive - Good: startServer() creates an HTTP(S) server - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -# --- 4b. Custom-element-tag : prose ------------------------------ -# HTML reserves hyphenated tag names for custom elements (W3C spec), so -# `:` is unambiguous prose, never JSX / TS / CSS. -if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then - cat >&2 <<'EOF' -BLOCKED: custom-element-tag colon-then-prose detected (": ..."). - -webjs bans `: ` in comments and docs. Rewrite verb-led. - - Bad: // : owns open state, focus trap, escape, scroll lock. - Good: // owns open state, focus trap, escape, scroll lock. - Bad: // : the centered panel. - Good: // is the centered panel. - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -# --- 4c. Inline / JSDoc comment "foo(): prose" -------------------------- -# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and -# lowercase prose. Avoids TS return-type annotations because those never -# appear inside comment lines. -if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then - cat >&2 <<'EOF' -BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ..."). - -webjs bans `xyz(): ` inside comments and JSDoc. Rewrite verb-led. - - Bad: // firstUpdated(): once, on the first render only - Good: // firstUpdated() runs once, on the first render only - Bad: // closest(): null if the click wasn't inside a frame - Good: // closest() returns null when the click wasn't inside a frame - -Rule: AGENTS.md, Invariants section, item 11. -Hook: .claude/hooks/block-prose-punctuation.sh. -EOF - exit 2 -fi - -exit 0 diff --git a/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh b/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh deleted file mode 100755 index f54015dd8..000000000 --- a/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Guardrail: a webjs custom element must extend the framework's WebComponent -# base class, never raw HTMLElement. -# -# Why: a raw `extends HTMLElement` custom element is invisible to the webjs -# elision analyser (it ships unconditionally, defeats display-only elision, and -# keeps any importing page/layout from being import-only), it bypasses the -# SSR / lifecycle / reactive-prop machinery, and it usually applies its DOM work -# in connectedCallback (client-only), a progressive-enhancement bug. -# -# Scope: fires ONLY when the edited file lives in a webjs project (a package.json -# up the tree depends on @webjsdev/*), so vanilla-JS projects are never touched. -# Exempts framework source (packages/, node_modules/), since the framework -# legitimately defines WebComponent and the SSR-inert / -stream / -# -suspense primitives on raw HTMLElement. Honours an explicit escape-hatch -# marker `webjs-allow-htmlelement: ` for the rare native-API case -# WebComponent cannot express (a form-associated element via ElementInternals, -# a customized built-in via `extends HTMLButtonElement`, etc.). -# -# PreToolUse contract: exit 0 = allow, exit 2 = block (message on stderr). -# -# NOTE: No em-dashes, spaces around hyphens as pauses, or semicolons as pauses -# are allowed in comments per project rules. -set -euo pipefail - -input=$(cat) -fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') -[ -z "$fp" ] && exit 0 -case "$fp" in - *.ts|*.tsx|*.js|*.jsx|*.mts|*.mjs) ;; - *) exit 0 ;; -esac - -# The text being written: Write -> .content, Edit -> .new_string, MultiEdit -> .edits[]?.new_string. -content=$(printf '%s' "$input" | jq -r '(.tool_input.content // empty), (.tool_input.new_string // empty), (.tool_input.edits[]?.new_string // empty)') -[ -z "$content" ] && exit 0 - -# Only a class that extends raw HTMLElement is the target (not `typeof -# HTMLElement` guards, not `instanceof HTMLElement`, not another base). -printf '%s' "$content" \ - | grep -Eq 'class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]+extends[[:space:]]+HTMLElement([[:space:]{]|$)' \ - || exit 0 - -# Explicit, acknowledged exception. -printf '%s' "$content" | grep -qi 'webjs-allow-htmlelement' && exit 0 - -# Framework source / installed deps are never app components. -case "$fp" in - */packages/*|*/node_modules/*|packages/*|node_modules/*) exit 0 ;; -esac - -# Webjs context: a package.json up the tree references @webjsdev/* (a webjs app -# or the framework repo). Outside a webjs project this hook is a no-op. -dir=$(CDPATH= cd -- "$(dirname -- "$fp")" 2>/dev/null && pwd || dirname -- "$fp") -is_webjs=0 -while [ -n "$dir" ] && [ "$dir" != "/" ]; do - if [ -f "$dir/package.json" ] && grep -q '@webjsdev/' "$dir/package.json" 2>/dev/null; then - is_webjs=1 - break - fi - dir=$(dirname -- "$dir") -done -[ "$is_webjs" -eq 0 ] && exit 0 - -cat >&2 <<'MSG' -BLOCKED: a webjs custom element must extend the WebComponent base class, not raw HTMLElement. - - import { WebComponent } from '@webjsdev/core'; - class MyThing extends WebComponent { - render() { return html`...`; } - } - MyThing.register('my-thing'); - -A display-only element (just host classes / static markup) can set its classes -in the constructor (runs at SSR, so it is progressive-enhancement-safe) and -stays elidable, so it ships zero JS. A raw `extends HTMLElement` element cannot -be elided, defeats import-only routes, and applies its work client-only. - -If WebComponent genuinely cannot express this (a rare native-API edge case), -add a marker comment containing `webjs-allow-htmlelement: ` to the file -to acknowledge the exception, and this guardrail will allow it. -MSG -exit 2 diff --git a/packages/cli/templates/.claude/hooks/check-server-imports.mjs b/packages/cli/templates/.claude/hooks/check-server-imports.mjs deleted file mode 100644 index 21843d5c2..000000000 --- a/packages/cli/templates/.claude/hooks/check-server-imports.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// Node walker for check-server-imports.sh (#804). Reads the PreToolUse JSON -// payload (arg or stdin), extracts the file being edited and its proposed -// content, and warns when a browser-facing app module adds an import of a -// server-only `.server.*` utility (no `'use server'`). WARN by default; a -// clean edit prints nothing and exits 0. -import { readFileSync, existsSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; - -function readPayload() { - const arg = process.argv[2]; - if (arg && arg.trim().startsWith('{')) return arg; - try { return readFileSync(0, 'utf8'); } catch { return ''; } -} - -let data; -try { data = JSON.parse(readPayload() || '{}'); } catch { process.exit(0); } -const input = data.tool_input || {}; -const filePath = input.file_path || input.filePath || ''; -if (!filePath) process.exit(0); - -// Only browser-facing app modules matter. A `.server.*` file (the boundary) or a -// route.ts / middleware.ts (never shipped) is allowed to import server code. -const rel = filePath.replace(/\\/g, '/'); -const isAppModule = /\/(app|components|modules|lib)\/.*\.(ts|js|mts|mjs)$/.test('/' + rel) || /(^|\/)(app|components|modules|lib)\//.test(rel); -if (!isAppModule) process.exit(0); -if (/\.server\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0); -if (/(^|\/)(route|middleware)\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0); - -// Proposed content: Write has `content`; Edit has `new_string`; else read disk. -let content = input.content ?? input.new_string ?? ''; -if (!content && existsSync(filePath)) { try { content = readFileSync(filePath, 'utf8'); } catch { /* ignore */ } } -if (!content) process.exit(0); - -// Find the app root (walks up for a package.json with a `#*` imports map or a db/ dir). -function findAppRoot(start) { - let dir = dirname(resolve(start)); - for (let i = 0; i < 8; i++) { - if (existsSync(join(dir, 'package.json')) && (existsSync(join(dir, 'app')) || existsSync(join(dir, 'db')))) return dir; - const up = dirname(dir); - if (up === dir) break; - dir = up; - } - return dirname(resolve(start)); -} -const appRoot = findAppRoot(filePath); - -// Collect import specifiers, skipping `import type` (erased by the stripper). -const specs = []; -const re = /(?:^|\n)\s*import\s+(type\s+)?[^;'"]*?from\s*['"]([^'"]+)['"]/g; -let m; -while ((m = re.exec(content))) { if (!m[1]) specs.push(m[2]); } - -function resolveSpec(spec) { - if (spec.startsWith('#')) return join(appRoot, spec.slice(1).replace(/^\//, '')); - if (spec.startsWith('.')) return resolve(dirname(filePath), spec); - return null; // bare npm specifier -} - -const offenders = []; -for (const spec of specs) { - if (!/\.server\.(ts|js|mts|mjs)$/.test(spec)) continue; - const abs = resolveSpec(spec); - if (!abs || !existsSync(abs)) continue; - let src = ''; - try { src = readFileSync(abs, 'utf8'); } catch { continue; } - const head = src.split('\n').slice(0, 5).join('\n'); - const hasUseServer = /^\s*(['"])use server\1\s*;?\s*$/m.test(head); - if (!hasUseServer) offenders.push(spec); -} - -if (offenders.length === 0) process.exit(0); - -const msg = - `A browser-facing module (${rel}) imports a server-only utility: ${offenders.join(', ')}. ` + - `A .server.{ts,js} file with NO 'use server' directive throws at load in the browser, ` + - `so this would crash the page (webjs check flags it as no-server-import-in-browser-module). ` + - `Fix: add 'use server' to make it an RPC action, or reach it from a 'use server' action / route.ts / ` + - `middleware.ts, or share only a type via 'import type'. See the skill's references/data-and-actions.md.`; - -if (process.env.WEBJS_SERVER_IMPORT_GATE === 'block') { - process.stderr.write(`BLOCKED: ${msg}\n`); - process.exit(2); -} -// WARN: surface as additionalContext, allow the edit. -process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }) + '\n'); -process.exit(0); diff --git a/packages/cli/templates/.claude/hooks/check-server-imports.sh b/packages/cli/templates/.claude/hooks/check-server-imports.sh deleted file mode 100755 index 850be7349..000000000 --- a/packages/cli/templates/.claude/hooks/check-server-imports.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# -# PreToolUse hook (scaffolded by `webjs create`): WARN, at write time, when an -# Edit/Write to a browser-facing app module (a page / layout / component under -# app/ or components/ or modules/, NOT a `.server.*` file) adds an import of a -# server-only `.server.{ts,js}` utility (a `.server.*` file with NO `'use server'` -# directive). In the browser that import resolves to a throw-at-load stub, so the -# module crashes the moment it loads. This is the #804 first-pass iteration loop: -# `webjs check`'s `no-server-import-in-browser-module` catches it AFTER the file -# lands; this hook surfaces it BEFORE, so the agent never writes the wrong shape. -# -# WARN, not block (the convention-vs-check principle in this app's AGENTS.md): -# `webjs check` is the authoritative gate, and a pre-edit static peek cannot see -# the full elision verdict, so a hard block could false-positive on a display-only -# page the framework would elide. So this emits a loud reminder and allows the -# edit. Set WEBJS_SERVER_IMPORT_GATE=block to hard-block instead; set -# WEBJS_NO_SERVER_IMPORT_GATE=1 to skip. -# -# A `'use server'` action import is fine (it becomes a working RPC stub), and a -# `import type { ... } from './x.server.ts'` is fine (the stripper erases it), so -# both are ignored. - -[ "$WEBJS_NO_SERVER_IMPORT_GATE" = "1" ] && exit 0 - -payload="$(cat)" -node "$(dirname "$0")/check-server-imports.mjs" "$payload" diff --git a/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh b/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh deleted file mode 100755 index aef86b6ef..000000000 --- a/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# -# Claude Code PostToolUse hook (matcher: Bash). -# -# After a `gh pr merge`, sweep the repo's git worktrees and REMOVE the ones -# whose work has already landed, so a merged branch's worktree does not leak. -# Accumulated stale worktrees (a session that merged but never cleaned up, or -# crashed mid-task) are exactly what this closes: the webjs-start-work skill -# already says "after the PR merges, git worktree remove", but as guidance it -# gets skipped, so this makes the cleanup deterministic. -# -# CONSERVATIVE BY DESIGN. A worktree is removed ONLY when ALL hold: -# * it is a LINKED worktree, not the primary checkout; -# * it is NOT the current directory (you cannot remove the one you are in); -# * its branch is not main/master; -# * its branch is MERGED (an ancestor of the base ref, OR a merged GitHub PR -# for that head branch, which is how squash-merges are detected); -# * its working tree is CLEAN apart from untracked node_modules / .webjs. -# Anything with uncommitted or unpushed-looking work is KEPT and reported, so -# the hook can never destroy in-flight work. -# -# It never blocks the tool (always exits 0) and reports what it did back to the -# model via hookSpecificOutput.additionalContext. Disable with -# WEBJS_NO_WORKTREE_CLEANUP=1. -# -# Rule: AGENTS.md "One task per git worktree" + the webjs-start-work skill. - -set -uo pipefail - -# Read the whole payload first so we always honour the hook contract. -payload=$(cat 2>/dev/null || true) - -if [ "${WEBJS_NO_WORKTREE_CLEANUP:-}" = "1" ]; then exit 0; fi - -cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true) -if [ -z "$cmd" ]; then exit 0; fi - -# Only act after a `gh pr merge` (whole word, not `gh pr merge-queue` typos etc.). -if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])gh pr merge([^[:alnum:]-]|$)'; then - exit 0 -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi - -# The base ref merged branches land on. Prefer origin/main; fall back to a -# local main/master (the test harness has no remote). -base="" -for ref in origin/main origin/master main master; do - if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then base="$ref"; break; fi -done -[ -z "$base" ] && exit 0 - -here=$(git rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD") -# The primary worktree is the first entry of `git worktree list`. -primary=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') - -is_merged() { - local br="$1" - # Ancestor of the base ref (fast-forward / rebase merges, and the real - # merges the test harness makes). - if git merge-base --is-ancestor "refs/heads/$br" "$base" 2>/dev/null; then return 0; fi - # A merged GitHub PR for this head branch (squash merges, which are NOT an - # ancestor of base). Network; skipped when gh is absent or unauthenticated. - if command -v gh >/dev/null 2>&1; then - local n - n=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null || true) - [ -n "$n" ] && return 0 - fi - return 1 -} - -# Clean = nothing in `git status` except untracked node_modules / .webjs caches. -is_clean() { - local wt="$1" dirty - dirty=$(git -C "$wt" status --porcelain 2>/dev/null \ - | grep -vE '(^|/)(node_modules|\.webjs)(/|$)' || true) - [ -z "$dirty" ] -} - -removed=() -kept=() - -# Parse worktree path + branch pairs. -wt="" -while IFS= read -r line; do - case "$line" in - worktree\ *) wt="${line#worktree }" ;; - branch\ *) - br="${line#branch refs/heads/}" - # Skip the primary checkout and main/master lines. - if [ "$wt" = "$primary" ] || [ "$br" = "main" ] || [ "$br" = "master" ]; then wt=""; continue; fi - # Never remove the worktree we are currently in. - if [ "$wt" = "$here" ]; then - kept+=("$wt (current directory; cd out then \`git worktree remove\`)") - wt=""; continue - fi - if ! is_clean "$wt"; then - kept+=("$wt (uncommitted changes)"); wt=""; continue - fi - if ! is_merged "$br"; then - kept+=("$wt (branch $br not merged yet)"); wt=""; continue - fi - if git worktree remove --force "$wt" >/dev/null 2>&1; then - removed+=("$wt ($br)") - else - kept+=("$wt (git worktree remove failed)") - fi - wt="" ;; - "") wt="" ;; - esac -done < <(git worktree list --porcelain 2>/dev/null) - -git worktree prune >/dev/null 2>&1 || true - -# Report nothing if there was nothing to do. -if [ "${#removed[@]}" -eq 0 ] && [ "${#kept[@]}" -eq 0 ]; then exit 0; fi - -msg="Worktree cleanup after \`gh pr merge\`:" -for r in "${removed[@]:-}"; do [ -n "$r" ] && msg="$msg"$'\n'" removed $r (merged, clean)"; done -for k in "${kept[@]:-}"; do [ -n "$k" ] && msg="$msg"$'\n'" kept $k"; done - -jq -n --arg ctx "$msg" '{ - hookSpecificOutput: { - hookEventName: "PostToolUse", - additionalContext: $ctx - } -}' 2>/dev/null || true - -exit 0 diff --git a/packages/cli/templates/.claude/hooks/commit-before-stop.sh b/packages/cli/templates/.claude/hooks/commit-before-stop.sh deleted file mode 100755 index 125dc1b07..000000000 --- a/packages/cli/templates/.claude/hooks/commit-before-stop.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# -# Claude Code Stop hook. -# -# The commit-per-logical-unit rule (CLAUDE.md + AGENTS.md "Git workflow") is -# easy for an agent to defer to "the end", and then the end arrives with the -# whole feature done and ZERO commits, which is the worst outcome: git history, -# the user's revert and cherry-pick safety net, is empty. The PostToolUse -# `nudge-uncommitted.sh` reminds DURING work but is only a soft context nudge an -# agent can ignore. This Stop hook is the backstop at the END of a turn: if you -# try to finish with a pile of uncommitted work on a feature branch, it blocks -# the stop once and tells you to commit the completed unit first. -# -# Loop-safe: when `stop_hook_active` is already true (this hook fired and the -# agent is continuing because of it), it does NOT block again, so it nags at -# most once per stop and can never trap the agent in a loop. -# -# Skipped on main/master (you must not commit there anyway) and outside a git -# work tree. Threshold via WEBJS_COMMIT_STOP_THRESHOLD (default 2). Disable -# entirely with WEBJS_NO_COMMIT_STOP=1. - -set -uo pipefail - -payload=$(cat 2>/dev/null || true) - -if [ "${WEBJS_NO_COMMIT_STOP:-}" = "1" ]; then exit 0; fi - -# Loop guard: if we already blocked once this stop-cycle, let the agent stop. -active=$(printf '%s' "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false) -if [ "$active" = "true" ]; then exit 0; fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi - -branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -if [ -z "$branch" ] || [ "$branch" = "main" ] || [ "$branch" = "master" ]; then exit 0; fi - -threshold="${WEBJS_COMMIT_STOP_THRESHOLD:-2}" - -# Count real changes: tracked modifications + staged + untracked, minus the -# noise the agent should never commit (node_modules, the sqlite db, caches). -changed=$(git status --porcelain 2>/dev/null \ - | grep -vE '(^|/)(node_modules|\.webjs)(/|$)|dev\.db($|-journal)' \ - | grep -c . || true) - -if [ -z "$changed" ] || [ "$changed" -lt "$threshold" ]; then exit 0; fi - -reason="You are ending the turn with ${changed} uncommitted changes on '${branch}'. This project OVERRIDES Claude Code's never-commit default: commit per logical unit (see CLAUDE.md and AGENTS.md \"Git workflow\"). Before you stop, group the completed work into a meaningful commit ('git add' the related files, 'git commit' with an imperative subject under 72 chars) and push. If the work is genuinely mid-change and not yet a coherent unit, commit what IS complete, or explain in your final message why it cannot be committed yet. To relax this backstop set WEBJS_COMMIT_STOP_THRESHOLD, or disable it with WEBJS_NO_COMMIT_STOP=1." - -jq -n --arg r "$reason" '{decision: "block", reason: $r}' 2>/dev/null \ - || printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs . 2>/dev/null || echo '""')" - -exit 0 diff --git a/packages/cli/templates/.claude/hooks/guard-branch-context.sh b/packages/cli/templates/.claude/hooks/guard-branch-context.sh deleted file mode 100644 index cda51ef93..000000000 --- a/packages/cli/templates/.claude/hooks/guard-branch-context.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# -# guard-branch-context.sh - Claude Code PreToolUse hook -# -# Rules: -# - On main/master → ask (agent should create a feature branch first) -# - On any other branch → allow (feature branches are free to edit) -# - Bypass mode → allow everything - -INPUT=$(cat /dev/stdin) - -# Bypass mode - full autonomy -SETTINGS="$HOME/.claude/settings.json" -if [ -f "$SETTINGS" ]; then - BYPASS=$(jq -r '.skipDangerousModePermissionPrompt // false' "$SETTINGS" 2>/dev/null) - if [ "$BYPASS" = "true" ]; then - exit 0 - fi -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - exit 0 -fi - -BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -[ -z "$BRANCH" ] && exit 0 - -if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then - jq -n --arg reason "You are on '$BRANCH'. Create a feature branch first (git checkout -b feature/), or approve to edit on '$BRANCH'." '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "ask", - permissionDecisionReason: $reason - } - }' - exit 0 -fi - -exit 0 diff --git a/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh b/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh deleted file mode 100755 index e3a170c42..000000000 --- a/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# -# Claude Code PostToolUse hook. -# -# After each Edit, Write, MultiEdit, or NotebookEdit, counts -# uncommitted changes in the working tree. When the count -# crosses a threshold (default 4, override with the -# WEBJS_COMMIT_NUDGE_THRESHOLD env var), injects a reminder -# into the model's context via hookSpecificOutput. -# -# Soft nudge. Does NOT block the edit. The goal is to keep -# the agent honest about the "commit per logical unit" rule, -# not to interrupt valid work. -# -# Skipped on main/master and outside a git work tree. - -set -e - -THRESHOLD="${WEBJS_COMMIT_NUDGE_THRESHOLD:-4}" - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - exit 0 -fi - -BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then - exit 0 -fi - -# Read stdin so we don't break Claude Code's hook contract. -cat /dev/stdin >/dev/null 2>&1 || true - -CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') - -if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then - exit 0 -fi - -REASON="You have ${CHANGED} uncommitted changes on '${BRANCH}'. The webjs convention is small, focused commits per logical unit (one feature, one fix, one rename, one doc rewrite). Before continuing with more edits, group the current changes into a meaningful commit. See AGENTS.md \"Git workflow\" for the rule and the rationale. To raise the threshold for this hook in long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD." - -jq -n --arg ctx "$REASON" '{ - hookSpecificOutput: { - hookEventName: "PostToolUse", - additionalContext: $ctx - } -}' diff --git a/packages/cli/templates/.claude/hooks/require-tests-with-src.sh b/packages/cli/templates/.claude/hooks/require-tests-with-src.sh deleted file mode 100755 index d57517603..000000000 --- a/packages/cli/templates/.claude/hooks/require-tests-with-src.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# -# PreToolUse hook (scaffolded by `webjs create`): WARN on a `git commit` -# that adds or changes application code without any accompanying test. -# -# webjs is AI-first, and "every change ships with a test" is the right -# default. But it is a CONVENTION, not a correctness check: a sensible -# app can legitimately want a test-less commit (a spike, a vendored -# file, a pure refactor). The convention-vs-check principle in this -# app's AGENTS.md says guidance like this WARNS, it -# does not hard-block by default. So this hook surfaces a loud reminder -# and lets the commit proceed. -# -# What a hook CANNOT do: judge WHICH test layer a change needs (a unit -# test vs a browser/e2e test is a judgement call). So it nudges toward -# the floor (some real test should accompany app code) and reminds you -# to add browser/e2e coverage for interactive surfaces. The actual test -# suite runs in CI (.github/workflows/ci.yml), which is the real gate. -# -# Scope: fires only on `git commit`. Inspects the STAGED diff. -# -# Behavior when the staged diff changes app code (app/, modules/, -# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*): -# - Default: WARN via additionalContext, then allow the commit (exit 0). -# - WEBJS_TEST_GATE=block: restore the old hard floor (print BLOCKED, -# exit 2), for a project that wants the strict gate. Set it in -# .claude/settings.json env, your shell, or CI. -# - WEBJS_NO_TEST_GATE=1: skip entirely (no warn, no block), for a -# genuine non-code commit (docs, config). -# -# Bypass (humans, emergencies): git commit --no-verify. - -set -euo pipefail - -if [ "${WEBJS_NO_TEST_GATE:-}" = "1" ]; then - exit 0 -fi - -payload=$(cat) -cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true) -if [ -z "$cmd" ]; then exit 0; fi -# Match `git commit` as a whole word so sibling subcommands -# (git commit-graph, git commit-tree) and string mentions do not trip it. -if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])git commit([^[:alnum:]-]|$)'; then - exit 0 -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi - -staged=$(git diff --cached --name-only 2>/dev/null || true) -if [ -z "$staged" ]; then exit 0; fi - -# App code lives under app/, modules/, components/, lib/. A `.server.*` -# file is still app code. Match source extensions only (skip .css, .md). -app_code=$(printf '%s\n' "$staged" \ - | grep -E '^(app|modules|components|lib)/.*\.([mc]?[jt]sx?)$' || true) -if [ -z "$app_code" ]; then exit 0; fi - -test_staged=$(printf '%s\n' "$staged" \ - | grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true) - -if [ -z "$test_staged" ]; then - # Hard-mode opt-in: restore the old block when the project asks for it. - if [ "${WEBJS_TEST_GATE:-}" = "block" ] || [ "${WEBJS_TEST_GATE:-}" = "hard" ]; then - cat >&2 <<'EOF' -BLOCKED: this commit changes app code but stages no test. - -You staged application code (app/, modules/, components/, lib/) with no -accompanying test. Every change ships with a test. Add or update the test -that proves the new behaviour, then `git add` it. - -Pick the layer the change needs (a unit test is not always enough): - - logic / actions / queries / utils -> a unit test - - a component, hydration, a server action called from the client, the - router, anything interactive -> a browser or e2e test that asserts the - real behaviour in a browser, not just the function in isolation. - -See `webjs test` and the testing guide. Genuine non-code commit (docs, -config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1. Hard mode is -on because WEBJS_TEST_GATE=block is set; unset it to fall back to a warning. - -Hook: .claude/hooks/require-tests-with-src.sh -EOF - exit 2 - fi - - # Default: warn loudly via additionalContext, then allow the commit. - # A missing test for app code subsumes the interactive-component - # reminder, so emit this warning alone and skip that reminder below. - jq -n --arg ctx "Heads up: this commit stages app code (app/, modules/, components/, lib/) with no test. Every change should ship with a test (it is a convention, not a hard gate). Pick the layer the change needs: a unit test for logic/actions/queries/utils, and a browser or e2e test for a component, hydration, the client router, or a server action called from the client. The suite runs in CI regardless. To enforce a hard block locally, set WEBJS_TEST_GATE=block. To silence this for a genuine non-code commit, set WEBJS_NO_TEST_GATE=1." '{ - hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx } - }' - exit 0 -fi - -# Reminder for interactive surfaces: a unit test alone rarely covers them. -interactive=$(printf '%s\n' "$app_code" | grep -E '^components/|/components/' || true) -if [ -n "$interactive" ]; then - jq -n --arg ctx "Reminder: this commit changes component code. A unit test alone usually is not enough for an interactive component; add a browser test (webjs test --browser) that asserts the rendered/hydrated behaviour." '{ - hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx } - }' -fi -exit 0 diff --git a/packages/cli/templates/.claude/settings.json b/packages/cli/templates/.claude/settings.json deleted file mode 100644 index 6f71bc42e..000000000 --- a/packages/cli/templates/.claude/settings.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/block-raw-htmlelement.sh" - } - ] - }, - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/check-server-imports.sh" - } - ] - }, - { - "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/block-prose-punctuation.sh" - } - ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/guard-branch-context.sh" - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/require-tests-with-src.sh" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/nudge-uncommitted.sh" - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/cleanup-merged-worktree.sh" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/commit-before-stop.sh" - } - ] - } - ] - } -} diff --git a/packages/cli/templates/.github/pull_request_template.md b/packages/cli/templates/.github/pull_request_template.md index c698a389e..2942c9d18 100644 --- a/packages/cli/templates/.github/pull_request_template.md +++ b/packages/cli/templates/.github/pull_request_template.md @@ -16,14 +16,14 @@ a codebase rots. Walk every markdown file in the project (`git ls-files '*.md'`) and ask whether this PR changed behaviour, surface, or invariants it describes. For each row below, write `Updated ` or `N/A because `. Reviewers should reject the PR if this section -is left as the template default. See the **Definition of done** section -in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance. +is left as the template default. The workflow rules are in +[`.agents/rules/workflow.md`](../.agents/rules/workflow.md). - [ ] **Tests.** Unit coverage for logic. Real-browser coverage for user-facing behaviour. - [ ] **Every markdown file in the project** that describes the changed surface. Common cases (non-exhaustive): `AGENTS.md` (root - + nested), `CONVENTIONS.md`, `README.md` (root + nested), + + nested), `README.md` (root + nested), `CHANGELOG.md`, `docs/**/*.md`, `.agents/skills/webjs/**/*.md`, `.github/*.md`. The rule is generative: if a markdown file in this project mentions a thing this PR changed, it gets touched diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 7da873cae..1aca20672 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -21,7 +21,13 @@ This is what separates a working app from a broken one. playbook below says what ships and the exact order to follow. The workflow rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them too. -3. **Read the framework source for exact contracts.** WebJs is 100% buildless +3. **Wire the WebJs MCP server into your agent (optional, recommended).** It + is read-only and version-matched to the app: `list_routes`, `list_actions`, + `list_components`, `list_elision`, `check`, `ui`, plus a docs and recipes + layer. Run it with `npx @webjsdev/mcp`, registered in whatever MCP config + your agent uses. This app ships no agent-specific config, so nothing is + wired for you. +4. **Read the framework source for exact contracts.** WebJs is 100% buildless native ES modules, so the source you run IS the source you read. When you need a precise API signature or behavior, open the package source under `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`). diff --git a/packages/cli/templates/CLAUDE.md b/packages/cli/templates/CLAUDE.md deleted file mode 100644 index 2264bdc4f..000000000 --- a/packages/cli/templates/CLAUDE.md +++ /dev/null @@ -1,23 +0,0 @@ -@AGENTS.md - -# Committing per logical unit (this OVERRIDES Claude Code's default) - -Claude Code's built-in default is to NEVER commit unless the user explicitly -asks. **For this project that default does NOT apply.** Commit and push per -logical unit (one feature, one fix, one rename, one doc rewrite) as soon as it -is complete, WITHOUT being asked. Do not save all the work for one commit at the -end. A finished implementation with zero commits is a mistake here, because git -history is the user's revert and cherry-pick safety net. - -- After each completed unit whose tests pass, `git add` the related files and - `git commit` with an imperative subject under 72 chars, then push. If 5+ files - span more than one concern, you already waited too long. -- Never commit to `main`. Work on a feature branch (the - `.claude/hooks/guard-branch-context.sh` hook enforces this). -- No AI-attribution trailers (`Co-Authored-By`, `Generated by`). - -See AGENTS.md "Git workflow" for the full contract. Two hooks back this up: the -`.claude/hooks/nudge-uncommitted.sh` PostToolUse hook reminds you while -uncommitted changes pile up during work, and the -`.claude/hooks/commit-before-stop.sh` Stop hook stops you from ending a turn -with a pile of uncommitted work still on a feature branch. diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md deleted file mode 100644 index 576116c75..000000000 --- a/packages/cli/templates/CONVENTIONS.md +++ /dev/null @@ -1,36 +0,0 @@ -# Conventions for {{APP_NAME}} - -The conventions for building a WebJs app live in the agent skill. **Read -`AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (it routes to focused -references under `.agents/skills/webjs/references/`, loaded on demand). This file -is the short version. - -## The essentials - -- **`app/` is routing only.** Only routing files live there (page, layout, route, - middleware, metadata routes). Feature logic goes in `modules//` - (`actions/`, `queries/`, `components/`, `utils/`); shared UI primitives go in - top-level `components/`; browser-safe helpers in `lib/utils/`. -- **Server-only code goes behind `.server.ts`.** Reach it from a page or component - through a `'use server'` action, never by importing a server-only utility - directly into browser-bound code. -- **Use the wired-up database (Drizzle).** Define real models in - `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`. - Never persist to a JSON file, an in-memory array or Map, or localStorage. -- **The scaffold ships a showcase to learn from.** A full-stack app ships a UI - feature gallery (`app/features/`, `app/examples/todo`); the api template ships - a backend-features showcase (`app/api/features/`), with logic in `modules/`. - When you build a real app, study the parts that match your task (the skill - teaches the same and survives the clear), run `npm run gallery:clear` to shed - the showcase, then grow the app in place. `AGENTS.md` has the full - template-specific playbook. -- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs - from an `interface`, routing files from `PageProps` / `LayoutProps`. Never - `any`, and never `unknown` where a real type exists. -- **Progressive enhancement is the default.** Pages render as HTML, `
    ` - navigates, a `
    ` submits, all with JavaScript off; opt into - interactivity per behaviour inside a component. -- **Commit per logical unit** as soon as it is complete, and never push to `main`. - -Everything else (the module architecture, the `ActionResult` envelope, styling, -testing, the client router, optimistic UI) is in the skill's references. diff --git a/packages/cli/templates/gitignore b/packages/cli/templates/gitignore index f2950b7de..3834b840b 100644 --- a/packages/cli/templates/gitignore +++ b/packages/cli/templates/gitignore @@ -59,10 +59,10 @@ Thumbs.db # test artifacts coverage/ -# AI assistants: local session state, scheduled-task locks, etc. -# Repo-shared config (settings.json + hooks scripts) stays tracked so -# every contributor and agent gets the same PreToolUse rules. -.claude/* -!.claude/settings.json -!.claude/hooks/ -!.claude/hooks/** +# AI assistants: local session state, scheduled-task locks, etc. The +# scaffold ships no agent config of its own (AGENTS.md and .agents/ are +# the guidance, and both are tracked), so whatever your tools write here +# is yours and stays local. +.claude/ +.cursor/ +.gemini/ diff --git a/test/hooks/block-prose-punctuation.test.mjs b/test/hooks/block-prose-punctuation.test.mjs index 4bc0d9bbb..2fb88ec31 100644 --- a/test/hooks/block-prose-punctuation.test.mjs +++ b/test/hooks/block-prose-punctuation.test.mjs @@ -280,20 +280,30 @@ test('no tracked JSON or front-matter prose value carries a banned pause', () => } }); -// --- Drift guard: all three copies of the hook stay in step --- - -test('the scaffold and dogfood hook copies carry the same rules', () => { - const scaffold = resolve(REPO_ROOT, 'packages/cli/templates/.claude/hooks/block-prose-punctuation.sh'); - const blog = resolve(REPO_ROOT, 'examples/blog/.claude/hooks/block-prose-punctuation.sh'); - const scaffoldSrc = readFileSync(scaffold, 'utf8'); - assert.equal(scaffoldSrc, readFileSync(blog, 'utf8'), 'the two copies are byte-identical'); +// --- Drift guard: the dogfood copy keeps the rules it carries --- +// +// There used to be THREE copies of this hook: this repo's, the scaffold's, +// and the blog dogfood app's, and the guard asserted the scaffold and blog +// copies were byte-identical. The scaffold ships no agent config at all now +// (these prose rules are OUR house style, and a generated app has no reason +// to inherit them), so that pairing is gone and only two copies remain. +// +// They are deliberately NOT compared byte-for-byte: the repo copy carries a +// fifth rule (brand casing) the blog copy predates. What must hold is that the +// blog copy still carries the prose-key patterns it does implement, and that +// NEITHER copy reintroduces the SIGPIPE bug, where a `grep -q` behind a pipe +// silently skips its rule once the payload outgrows the pipe buffer. + +test('the dogfood hook copy keeps its prose rules and the SIGPIPE fix', () => { + const blogSrc = readFileSync(resolve(REPO_ROOT, 'examples/blog/.claude/hooks/block-prose-punctuation.sh'), 'utf8'); + const repoSrc = readFileSync(HOOK, 'utf8'); + + const blogKeyPatterns = [...blogSrc.matchAll(/^if grep -qE '(.*)' <<< "\$new_content"; then$/gm)] + .map((m) => m[1]) + .filter((p) => p.includes('(description|title|displayName)')); + assert.equal(blogKeyPatterns.length, 4, 'blog copy carries the four prose-key patterns'); - for (const pattern of proseKeyPatterns()) { - assert.ok(scaffoldSrc.includes(pattern), `copy is missing a prose-key pattern: ${pattern}`); - } - // The SIGPIPE fix must hold in every copy: a `grep -q` behind a pipe silently - // skips its rule once the payload outgrows the pipe buffer. - for (const [label, src] of [['repo', readFileSync(HOOK, 'utf8')], ['scaffold', scaffoldSrc]]) { + for (const [label, src] of [['repo', repoSrc], ['blog', blogSrc]]) { assert.equal( /^if printf .*\| grep -q/m.test(src), false, diff --git a/test/hooks/check-server-imports.test.mjs b/test/hooks/check-server-imports.test.mjs deleted file mode 100644 index be73221de..000000000 --- a/test/hooks/check-server-imports.test.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// The scaffolded check-server-imports hook (#804): WARN at write time when a -// browser-facing module imports a server-only `.server.*` utility (no -// 'use server'), silent for a 'use server' action or an `import type`. -const HOOK = join( - dirname(fileURLToPath(import.meta.url)), - '..', '..', - 'packages/cli/templates/.claude/hooks/check-server-imports.mjs', -); - -function run(app, filePath, content) { - const payload = JSON.stringify({ tool_input: { file_path: filePath, content } }); - return execFileSync('node', [HOOK, payload], { encoding: 'utf8' }); -} - -function makeApp() { - const dir = mkdtempSync(join(tmpdir(), 'webjs-hook-')); - mkdirSync(join(dir, 'db'), { recursive: true }); - mkdirSync(join(dir, 'modules', 'todos', 'components'), { recursive: true }); - writeFileSync(join(dir, 'package.json'), '{}'); - writeFileSync(join(dir, 'db', 'queries.server.ts'), 'export function q(){return 1}\n'); - writeFileSync(join(dir, 'modules', 'todos', 'actions.server.ts'), "'use server';\nexport async function a(){return 1}\n"); - return dir; -} - -test('WARNs when a component imports a server-only utility (#804)', () => { - const dir = makeApp(); - try { - const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import { q } from '#db/queries.server.ts';\nexport class X {}"); - assert.match(out, /server-only utility/, 'warns about the server-only import'); - assert.match(out, /additionalContext/, 'emits a PreToolUse additionalContext warning'); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('silent when the imported .server.ts is a use-server action (#804)', () => { - const dir = makeApp(); - try { - const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import { a } from '#modules/todos/actions.server.ts';\nexport class X {}"); - assert.equal(out.trim(), '', 'a use-server RPC action import is fine, no warning'); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('silent for an import type from a server file (#804)', () => { - const dir = makeApp(); - try { - const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import type { T } from '#db/queries.server.ts';\nexport class X {}"); - assert.equal(out.trim(), '', 'a type-only import is erased by the stripper, no warning'); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); diff --git a/test/hooks/require-tests-with-src.test.mjs b/test/hooks/require-tests-with-src.test.mjs deleted file mode 100644 index 814d2965a..000000000 --- a/test/hooks/require-tests-with-src.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -// Tests for the SCAFFOLDED require-tests-with-src PreToolUse hook -// (packages/cli/templates/.claude/hooks/require-tests-with-src.sh, the -// one shipped into every user app by `webjs create`). The hook reads a -// tool-call payload on stdin and, for a `git commit` that stages app -// code (app/, modules/, components/, lib/) without a test, WARNS by -// default (exit 0 + an additionalContext message) and HARD-BLOCKS -// (exit 2) only when WEBJS_TEST_GATE=block opts in. -// -// Each case builds a throwaway git repo, stages a specific shape of -// change, and feeds the hook the commit payload, asserting the exit -// code and (where relevant) the emitted stdout. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// The scaffolded hook (templates/), NOT the framework's own self-gate. -const HOOK = resolve( - dirname(fileURLToPath(import.meta.url)), - '../../packages/cli/templates/.claude/hooks/require-tests-with-src.sh', -); - -/** Init a throwaway app-shaped repo with one committed baseline file. */ -function makeRepo() { - const dir = mkdtempSync(join(tmpdir(), 'webjs-app-testgate-')); - const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); - git('init', '-q'); - git('config', 'user.email', 't@t'); - git('config', 'user.name', 't'); - mkdirSync(join(dir, 'modules/posts'), { recursive: true }); - mkdirSync(join(dir, 'components'), { recursive: true }); - mkdirSync(join(dir, 'test/posts'), { recursive: true }); - writeFileSync(join(dir, 'README.md'), 'docs\n'); - git('add', '-A'); - git('commit', '-qm', 'init'); - return { dir, git }; -} - -/** Run the hook in `dir` with a commit payload and the given env. */ -function runHook(dir, env = {}) { - const r = spawnSync('bash', [HOOK], { - cwd: dir, - input: JSON.stringify({ tool_input: { command: 'git commit -m x' } }), - env: { ...process.env, ...env }, - encoding: 'utf8', - }); - return r; // { status, stdout, stderr } -} - -test('default: app code staged with no test WARNS and allows the commit', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); - git('add', 'modules/posts/create.server.ts'); - const r = runHook(dir); - assert.equal(r.status, 0, 'warn mode must allow the commit (exit 0)'); - // The warning rides additionalContext (the JSON the hook prints to stdout). - assert.match(r.stdout, /additionalContext/); - assert.match(r.stdout, /stages app code .* with no test/); - assert.match(r.stdout, /WEBJS_TEST_GATE=block/); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('WEBJS_TEST_GATE=block: app code staged with no test BLOCKS (exit 2)', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); - git('add', 'modules/posts/create.server.ts'); - const r = runHook(dir, { WEBJS_TEST_GATE: 'block' }); - assert.equal(r.status, 2, 'hard mode must block (exit 2)'); - assert.match(r.stderr, /BLOCKED: this commit changes app code but stages no test/); - // Counterfactual: the SAME input is allowed without the env opt-in. - assert.equal(runHook(dir).status, 0); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('WEBJS_TEST_GATE=hard is an accepted alias for block', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); - git('add', 'modules/posts/create.server.ts'); - assert.equal(runHook(dir, { WEBJS_TEST_GATE: 'hard' }).status, 2); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('app code staged WITH a test: exit 0, no test-gate warning', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); - writeFileSync(join(dir, 'test/posts/create.test.ts'), 'test\n'); - git('add', '-A'); - const r = runHook(dir); - assert.equal(r.status, 0); - // The no-test warning must not fire when a test is present. - assert.doesNotMatch(r.stdout, /with no test/); - // Same under the hard env (a present test passes regardless). - assert.equal(runHook(dir, { WEBJS_TEST_GATE: 'block' }).status, 0); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('component change WITH a test still warns to add browser coverage', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'components/widget.ts'), 'export const x = 1\n'); - writeFileSync(join(dir, 'test/posts/widget.test.ts'), 'test\n'); - git('add', '-A'); - const r = runHook(dir); - assert.equal(r.status, 0); - // The interactive-component reminder fires (a unit test alone is not enough). - assert.match(r.stdout, /component code/); - assert.match(r.stdout, /browser test/); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('component change with NO test warns about the missing test (subsumes the reminder)', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'components/widget.ts'), 'export const x = 1\n'); - git('add', 'components/widget.ts'); - const r = runHook(dir); - assert.equal(r.status, 0); - // A missing test subsumes the component reminder: one warning, valid JSON. - assert.match(r.stdout, /with no test/); - assert.doesNotMatch(r.stdout, /Reminder: this commit changes component code/); - assert.doesNotThrow(() => JSON.parse(r.stdout), 'the emitted JSON must be a single valid object'); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('allows a docs-only commit (no app code touched)', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'README.md'), 'more docs\n'); - git('add', 'README.md'); - const r = runHook(dir); - assert.equal(r.status, 0); - assert.equal(r.stdout.trim(), '', 'no warning for a non-app-code commit'); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('WEBJS_NO_TEST_GATE=1 skips the gate entirely (no warn, no block)', () => { - const { dir, git } = makeRepo(); - try { - writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); - git('add', 'modules/posts/create.server.ts'); - const r = runHook(dir, { WEBJS_NO_TEST_GATE: '1' }); - assert.equal(r.status, 0); - assert.equal(r.stdout.trim(), '', 'skip env must emit nothing'); - // It even overrides the hard gate. - const r2 = runHook(dir, { WEBJS_NO_TEST_GATE: '1', WEBJS_TEST_GATE: 'block' }); - assert.equal(r2.status, 0); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); - -test('does not fire on a non-commit git command', () => { - const { dir } = makeRepo(); - try { - const r = spawnSync('bash', [HOOK], { - cwd: dir, - input: JSON.stringify({ tool_input: { command: 'git status' } }), - encoding: 'utf8', - }); - assert.equal(r.status, 0); - assert.equal(r.stdout.trim(), ''); - } finally { rmSync(dir, { recursive: true, force: true }); } -}); diff --git a/test/scaffolds/scaffold-agent-skills.test.js b/test/scaffolds/scaffold-agent-skills.test.js index 4909656eb..f1c1138b4 100644 --- a/test/scaffolds/scaffold-agent-skills.test.js +++ b/test/scaffolds/scaffold-agent-skills.test.js @@ -55,6 +55,25 @@ for (const template of ['full-stack', 'api']) { assert.ok(!existsSync(join(skillsDir, monorepoOnly)), `${monorepoOnly} is a monorepo workflow skill and must not ship to a generated app`); } + + // And no per-agent rule files or vendor tool config at all. AGENTS.md + // plus .agents/ is the whole agent surface, so the app does not carry + // one team's tool choices into another's repo. The monorepo keeps its + // own .claude/; none of it is scaffolded. + const appDir = join(cwd, 'demo'); + for (const perAgent of [ + 'CLAUDE.md', 'CONVENTIONS.md', '.claude', '.claude.json', + '.cursorrules', '.cursor', 'GEMINI.md', '.gemini', '.opencode', + '.github/copilot-instructions.md', '.windsurfrules', + ]) { + assert.ok(!existsSync(join(appDir, perAgent)), + `${perAgent} is per-agent config and must not ship to a generated app`); + } + // AGENTS.md is what makes the single surface reachable: nothing reads + // .agents/ on its own, so it must route there. + const agentsMd = await readFile(join(appDir, 'AGENTS.md'), 'utf8'); + assert.match(agentsMd, /\.agents\/skills\/webjs/, 'AGENTS.md routes to the skill'); + assert.match(agentsMd, /\.agents\/rules\/workflow\.md/, 'AGENTS.md routes to the workflow rules'); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index de4cc67df..d32d328bd 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -86,21 +86,25 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(existsSync(join(appDir, 'package.json'))); assert.ok(existsSync(join(appDir, 'tsconfig.json'))); - // Single cross-agent source (AGENTS.md + the one skill + the .agents workflow - // rules), CONVENTIONS.md, CLAUDE.md bridge, and Claude protective hooks. - for (const f of ['AGENTS.md', '.agents/skills/webjs/SKILL.md', '.agents/rules/workflow.md', 'CLAUDE.md', 'CONVENTIONS.md', '.claude/settings.json', '.editorconfig']) { + // ONE agent surface: AGENTS.md (the open standard agents read natively) + // pointing at .agents/, which carries the skill and the workflow rules. + for (const f of ['AGENTS.md', '.agents/skills/webjs/SKILL.md', '.agents/rules/workflow.md', '.editorconfig']) { assert.ok(existsSync(join(appDir, f)), `${f} should exist`); } - // Per-agent files and design-distinctness ceremony removed. - for (const f of ['GEMINI.md', '.github/copilot-instructions.md', '.cursorrules', 'LAYOUT-REFERENCE.md', '.claude/hooks/design-review-before-stop.sh', '.claude/skills/webjs-design-review']) { + // NO per-agent rule files or vendor tool config. How a team runs its + // tools is the team's call, and the rules that protect the app are + // enforced agent-agnostically by `webjs check`, CI, and .hooks/pre-commit. + for (const f of [ + 'CLAUDE.md', 'CONVENTIONS.md', '.claude', '.claude.json', '.claude/settings.json', + 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules', '.gemini', '.opencode', + 'LAYOUT-REFERENCE.md', + ]) { assert.ok(!existsSync(join(appDir, f)), `${f} should NOT exist in the scaffold`); } - // Thin bridges (pointers to AGENTS.md / the skill). - for (const f of ['CLAUDE.md', 'CONVENTIONS.md']) { - const src = readFileSync(join(appDir, f), 'utf8'); - assert.ok(src.length < 2200, `${f} is a thin bridge, not a full rule duplicate`); - assert.match(src, /AGENTS\.md|\.agents\/skills\/webjs/, `${f} points at AGENTS.md or the skill`); - } + // AGENTS.md must actually route to the skill, since nothing else does. + const agentsMd = readFileSync(join(appDir, 'AGENTS.md'), 'utf8'); + assert.match(agentsMd, /\.agents\/skills\/webjs/, 'AGENTS.md points at the skill'); + assert.match(agentsMd, /\.agents\/rules\/workflow\.md/, 'AGENTS.md points at the workflow rules'); // #271: the opt-in progressive-enhancement service worker + its offline // fallback ship into the UI scaffold (full-stack; api has no UI), @@ -229,41 +233,16 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(!/from '(\.\.\/){2,}/.test(src), `${f.slice(appDir.length)} must not keep a deep relative import`); } - // The require-tests hook still reaches the scaffolded app for Claude - // Code: the hook file is copied and the Claude settings wire it into - // PreToolUse. (The tool-agnostic test gate has moved to CI, see below.) - assert.ok(existsSync(join(appDir, '.claude/hooks/require-tests-with-src.sh')), - 'require-tests hook is scaffolded'); - const claudeSettings = JSON.parse( - readFileSync(join(appDir, '.claude/settings.json'), 'utf8'), - ); - const preCommands = (claudeSettings.hooks?.PreToolUse ?? []) - .flatMap((g) => g.hooks.map((h) => h.command)); - assert.ok( - preCommands.includes('.claude/hooks/require-tests-with-src.sh'), - 'settings.json wires the require-tests hook into PreToolUse', - ); - - // Commit enforcement for Claude Code: CLAUDE.md overrides Claude Code's - // never-commit default, a Stop hook backstops end-of-turn, and a - // PostToolUse hook removes merged worktrees after `gh pr merge`. - const claudeMd = readFileSync(join(appDir, 'CLAUDE.md'), 'utf8'); - assert.match(claudeMd, /OVERRIDES Claude Code/i, - 'CLAUDE.md overrides Claude Code\'s never-commit default'); - for (const h of ['commit-before-stop.sh', 'cleanup-merged-worktree.sh']) { - assert.ok(existsSync(join(appDir, '.claude/hooks', h)), `${h} is scaffolded`); + // The scaffold ships NO agent-specific enforcement hooks. What used to be + // a set of Claude PreToolUse/Stop hooks is now covered agent-agnostically, + // which is the point: it binds every agent rather than one. + // - a server-only import in a browser module -> `webjs check`'s + // no-server-import-in-browser-module rule, run in CI + // - committing on main -> .hooks/pre-commit, a git-level block + // - tests with source, commit cadence -> .agents/rules/workflow.md + for (const f of ['.claude', '.claude.json']) { + assert.ok(!existsSync(join(appDir, f)), `${f} must not ship`); } - const stopCommands = (claudeSettings.hooks?.Stop ?? []) - .flatMap((g) => g.hooks.map((h) => h.command)); - assert.ok(stopCommands.includes('.claude/hooks/commit-before-stop.sh'), - 'settings.json wires commit-before-stop into Stop'); - const postCommands = (claudeSettings.hooks?.PostToolUse ?? []) - .flatMap((g) => g.hooks.map((h) => h.command)); - assert.ok(postCommands.includes('.claude/hooks/cleanup-merged-worktree.sh'), - 'settings.json wires cleanup-merged-worktree into PostToolUse'); - - // The design-review ceremony (a skill + a UserPromptSubmit router + a Stop - // hook) was retired in #969; only the protective hooks above ship now. // The local pre-commit hook is lightweight: it blocks commits to main // and nothing else. The test/convention gate runs in CI, not locally, @@ -672,7 +651,7 @@ test('scaffoldApp: template placeholder substitution in copied files', async () const appDir = join(cwd, 'PlaceholderTest'); // Walk a few template-copied files and verify {{APP_NAME}} was replaced. - const filesToCheck = ['AGENTS.md', 'CONVENTIONS.md', 'CLAUDE.md']; + const filesToCheck = ['AGENTS.md', '.agents/rules/workflow.md']; for (const f of filesToCheck) { const p = join(appDir, f); if (!existsSync(p)) continue; @@ -803,18 +782,13 @@ test('scaffoldApp: AGENTS.md build playbook is template-specific (#1076)', async assert.doesNotMatch(api, /Build components for interactivity|WebComponent/, 'api: no component/reactive-props guidance'); - // The sibling agent-doc surfaces (CONVENTIONS.md, .agents/rules/workflow.md) - // ship into BOTH apps, so they must be template-neutral: no opt-out phrasing, - // and they must acknowledge the api showcase rather than only the UI gallery. - const apiConv = readFileSync(join(cwd, 'api-app', 'CONVENTIONS.md'), 'utf8'); + // The workflow rules ship into BOTH apps, so they must be template-neutral: + // no opt-out phrasing, and they must acknowledge the api showcase rather + // than only the UI gallery. const apiFlow = readFileSync(join(cwd, 'api-app', '.agents/rules/workflow.md'), 'utf8'); - for (const [label, md] of [['CONVENTIONS.md', apiConv], ['workflow.md', apiFlow]]) { - assert.doesNotMatch(md, /only while exploring|do not have to read|only (if|when) a task needs/i, - `api ${label}: no opt-out phrasing`); - } - for (const [label, md] of [['CONVENTIONS.md', apiConv], ['workflow.md', apiFlow]]) { - assert.match(md, /app\/api\/features/, `api ${label}: acknowledges the api showcase`); - } + assert.doesNotMatch(apiFlow, /only while exploring|do not have to read|only (if|when) a task needs/i, + 'api workflow.md: no opt-out phrasing'); + assert.match(apiFlow, /app\/api\/features/, 'api workflow.md: acknowledges the api showcase'); } finally { restore(); await rm(cwd, { recursive: true, force: true }); diff --git a/test/scaffolds/scaffold-runtime.test.js b/test/scaffolds/scaffold-runtime.test.js index b097c28a2..cc9e61897 100644 --- a/test/scaffolds/scaffold-runtime.test.js +++ b/test/scaffolds/scaffold-runtime.test.js @@ -124,7 +124,7 @@ test('bun scaffold: agent-config markdown shows bun commands, no npm commands', try { await scaffoldApp('bunapp', cwd, { template: 'full-stack', runtime: 'bun' }); const appDir = join(cwd, 'bunapp'); - for (const f of ['AGENTS.md', 'CLAUDE.md', '.agents/rules/workflow.md']) { + for (const f of ['AGENTS.md', '.agents/rules/workflow.md']) { const md = read(appDir, f); assert.doesNotMatch(md, /\bnpm run /, `${f} should not contain "npm run"`); assert.doesNotMatch(md, /\bnpx /, `${f} should not contain "npx "`); diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index efe089bdb..25fa16797 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -256,8 +256,8 @@ test('no scaffold template ships a conformance-error action="" or formaction=""' await walk(path); continue; } - // Markdown IS in scope: `webjs create` ships AGENTS.md, CONVENTIONS.md, - // and the agent rule files into every generated app, and those are what + // Markdown IS in scope: `webjs create` ships AGENTS.md and the + // .agents/ guidance into every generated app, and those are what // TEACH the idiom. The one exclusion is the generated skill bundle under // `.agents/skills/`, the single place prose legitimately writes the // attribute while explaining why not to use it. (`prepack` copies it in diff --git a/website/app/docs/ai-first/page.ts b/website/app/docs/ai-first/page.ts index 729b9fd43..9d48092b7 100644 --- a/website/app/docs/ai-first/page.ts +++ b/website/app/docs/ai-first/page.ts @@ -113,7 +113,7 @@ export async function createPost(
  • AGENTS.md, read natively by Cursor, opencode, Antigravity, and the Copilot coding agent
  • .agents/skills/webjs/SKILL.md, the shipped routing skill carrying the framework context
  • .agents/rules/workflow.md, the git, test, and review workflow rules
  • -
  • Thin bridges for tools that do not read AGENTS.md natively: CLAUDE.md (Claude Code), GEMINI.md (Gemini CLI), and .github/copilot-instructions.md (Copilot in VS Code), each pointing at AGENTS.md
  • +
  • No per-agent rule files. AGENTS.md plus .agents/ is the whole agent surface, so a scaffolded app does not carry one team's tool choices into another's repo
  • Every agent gets the same rules from that one source: check the branch before coding, sync with parent before starting, auto-generate tests, auto-update docs, ask before merging (with delete/keep prompt), no AI attribution in commits.

    diff --git a/website/app/docs/conventions/page.ts b/website/app/docs/conventions/page.ts index 5852eba43..aa3291170 100644 --- a/website/app/docs/conventions/page.ts +++ b/website/app/docs/conventions/page.ts @@ -133,16 +133,13 @@ After merging, should <branch> be deleted or kept? AGENTS.mdAll agents (Cursor, opencode, Antigravity, Copilot coding agent read it natively)Framework API, conventions, recipes (the source of truth) .agents/skills/webjs/SKILL.mdAll agentsThe shipped routing skill carrying the framework context and project conventions (guidance, customizable in the prose) .agents/rules/workflow.mdAll agentsGit, test, and review workflow rules - CLAUDE.mdClaude CodeThin bridge pointing at AGENTS.md - GEMINI.mdGemini CLIThin bridge pointing at AGENTS.md - .claude/settings.jsonClaude CodePreToolUse hook guarding git merge/push to main - .github/copilot-instructions.mdCopilot in VS CodeThin bridge pointing at AGENTS.md + .hooks/pre-commitAll (via git)Blocks commits directly on main, at the git level rather than per agent .github/pull_request_template.mdAll (via GitHub)PR checklist: tests, docs, convention check .editorconfigAll editorsConsistent indent/encoding/line endings -

    All config files encode the same rules: the framework conventions, git workflow, and quality expectations. Each is formatted for its target agent's native config format.

    +

    There are no per-agent rule files. A scaffolded app ships AGENTS.md, which Cursor, opencode, Antigravity, and the Copilot coding agent read natively, and it routes to .agents/ for everything else. No CLAUDE.md, no .cursorrules, no vendor hook config: how your team runs its tools is your call, and the rules that protect the app are enforced agent-agnostically by webjs check and the git pre-commit hook.

    Autonomous Mode

    When an agent runs in sandbox or bypass-permissions mode, it follows these defaults instead of asking questions:

    @@ -181,7 +178,7 @@ cd my-app && npm run dev
  • .agents/skills/webjs/SKILL.md: the shipped routing skill with the framework context and editable project conventions
  • .agents/rules/workflow.md: git, test, and review workflow rules
  • AGENTS.md: full framework API reference
  • -
  • Thin bridges pointing at AGENTS.md (CLAUDE.md, GEMINI.md, .github/copilot-instructions.md)
  • +
  • No per-agent rule files: AGENTS.md plus .agents/ is the whole agent surface
  • .editorconfig for consistent formatting
  • package.json with scripts (dev, build, start, test, check, db:migrate, db:generate, db:studio)
  • From 5cac470fbf5d14f0456732960b085d1e9629f7bd Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 20:28:19 +0530 Subject: [PATCH 6/7] Revert "feat: ship one agent surface in scaffolded apps, AGENTS.md plus .agents" This reverts commit ef5bab9a999191976f6a71837a771488cfd7d7d6. --- .agents/rules/workflow.md | 1 - .agents/skills/webjs/references/components.md | 12 - AGENTS.md | 10 +- README.md | 4 +- packages/cli/AGENTS.md | 23 +- packages/cli/README.md | 4 +- packages/cli/bin/webjs.js | 2 +- packages/cli/lib/create.js | 36 ++- packages/cli/lib/runtime-rewrite.js | 2 +- packages/cli/templates/.claude.json | 14 + .../.claude/hooks/block-prose-punctuation.sh | 278 ++++++++++++++++++ .../.claude/hooks/block-raw-htmlelement.sh | 83 ++++++ .../.claude/hooks/check-server-imports.mjs | 86 ++++++ .../.claude/hooks/check-server-imports.sh | 26 ++ .../.claude/hooks/cleanup-merged-worktree.sh | 129 ++++++++ .../.claude/hooks/commit-before-stop.sh | 52 ++++ .../.claude/hooks/guard-branch-context.sh | 39 +++ .../.claude/hooks/nudge-uncommitted.sh | 46 +++ .../.claude/hooks/require-tests-with-src.sh | 103 +++++++ packages/cli/templates/.claude/settings.json | 81 +++++ .../.github/pull_request_template.md | 6 +- packages/cli/templates/AGENTS.md | 8 +- packages/cli/templates/CLAUDE.md | 23 ++ packages/cli/templates/CONVENTIONS.md | 36 +++ packages/cli/templates/gitignore | 14 +- test/hooks/block-prose-punctuation.test.mjs | 36 +-- test/hooks/check-server-imports.test.mjs | 56 ++++ test/hooks/require-tests-with-src.test.mjs | 169 +++++++++++ test/scaffolds/scaffold-agent-skills.test.js | 19 -- test/scaffolds/scaffold-integration.test.js | 88 ++++-- test/scaffolds/scaffold-runtime.test.js | 2 +- .../scaffold-template-validation.test.js | 4 +- website/app/docs/ai-first/page.ts | 2 +- website/app/docs/conventions/page.ts | 9 +- 34 files changed, 1353 insertions(+), 150 deletions(-) create mode 100644 packages/cli/templates/.claude.json create mode 100755 packages/cli/templates/.claude/hooks/block-prose-punctuation.sh create mode 100755 packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh create mode 100644 packages/cli/templates/.claude/hooks/check-server-imports.mjs create mode 100755 packages/cli/templates/.claude/hooks/check-server-imports.sh create mode 100755 packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh create mode 100755 packages/cli/templates/.claude/hooks/commit-before-stop.sh create mode 100644 packages/cli/templates/.claude/hooks/guard-branch-context.sh create mode 100755 packages/cli/templates/.claude/hooks/nudge-uncommitted.sh create mode 100755 packages/cli/templates/.claude/hooks/require-tests-with-src.sh create mode 100644 packages/cli/templates/.claude/settings.json create mode 100644 packages/cli/templates/CLAUDE.md create mode 100644 packages/cli/templates/CONVENTIONS.md create mode 100644 test/hooks/check-server-imports.test.mjs create mode 100644 test/hooks/require-tests-with-src.test.mjs diff --git a/.agents/rules/workflow.md b/.agents/rules/workflow.md index 48f01e000..58c0b0a57 100644 --- a/.agents/rules/workflow.md +++ b/.agents/rules/workflow.md @@ -58,7 +58,6 @@ These project-level rules govern all operations inside this workspace. Antigravi - `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. - - `pr-review`: Trigger when asked to review a PR. The review runs INLINE in the session that was asked, never in a subagent and never as a multi-round cycle, and posts through the GitHub review API as one review object (a summary plus line-anchored comments with suggestion blocks). It only reviews: it does not fix findings, resolve threads, or wait on CI. - `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. diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 4770c6560..5ec88276c 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -3,7 +3,6 @@ ## What This Covers - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on ``, ARIA derived in `render()` -- Why every component extends `WebComponent` and never raw `HTMLElement` - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`) - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch` - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips @@ -140,17 +139,6 @@ The first version ships three modules instead of one: itself, plus ` **The exception, and it is a real one.** Markup that is static *today* but is the thing a near-term behaviour will read is fine to keep, because the alternative is a component that reaches outward for it later, which is what rule 1 forbids. Judge the behaviour you are building, not one you are speculating about. When those genuinely collide, ownership wins over bytes: a coherent component that ships a little extra markup beats a split feature that a selector holds together. -## Extend `WebComponent`, never raw `HTMLElement` - -Every custom element in a WebJs app extends the framework base class, through the factory (`extends WebComponent({ ... })`) when it declares reactive properties or bare `extends WebComponent` when it does not. A plain `class X extends HTMLElement` is valid DOM and broken WebJs, in four ways that all fail quietly rather than loudly: - -- It is **invisible to the elision analyser**, so it ships unconditionally, defeats display-only elision for itself, and keeps any page or layout importing it from being import-only. -- It gets **no SSR**. The renderer instantiates and renders `WebComponent` subclasses on the server; a raw element contributes nothing to the first paint. -- It has **no reactive properties and no lifecycle**, so no `render()` re-runs, no `willUpdate`, no `updated`, and no attribute coercion. -- It pushes its DOM work into `connectedCallback`, which is **client-only**, so the content does not exist with JS off. That is a progressive-enhancement bug, and progressive enhancement is the default architecture. - -No `webjs check` rule catches this today, so nothing fails when you write it. Treat it as a rule you hold yourself: if a file calls `customElements.define`, the class it defines extends `WebComponent`. - ## Reactive properties: the base-class factory Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`). diff --git a/AGENTS.md b/AGENTS.md index d8dc72fd0..12655a2f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,13 +34,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**: `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`. - -Be careful to distinguish TWO audiences here, because they now differ. - -**A SCAFFOLDED APP ships `AGENTS.md` plus `.agents/` and nothing else.** No `CLAUDE.md`, no `.cursorrules`, no `.gemini/`, no `.claude/` settings or hooks. Per-agent bridges were removed progressively (#1368 dropped `GEMINI.md` and `.github/copilot-instructions.md`; the rest went with the review-cycle removal) on one principle: how another team runs its tools is that team's call, and a framework has no business shipping its own tool config into their repo. Everything that genuinely protects an app is enforced agent-agnostically instead, by `webjs check` (which CI runs) and the git `.hooks/pre-commit`. Do NOT add a per-agent file back to `packages/cli/templates/`; `test/scaffolds/scaffold-agent-skills.test.js` fails if you do. If a rule matters enough to enforce, it belongs in the skill or in a `webjs check` rule. - -**THIS MONOREPO keeps its own `.claude/` alongside `.agents/`**, because it is our repo and our tooling: the skills under `.claude/skills/` (symlinked into `.agents/skills/` for cross-agent use), the enforcement hooks under `.claude/hooks/`, and a `CLAUDE.md` bridge. None of it is scaffolded. +**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 @@ -138,7 +132,7 @@ verification commands in `references/module-structure.md`. Every code change MUST include, automatically: 1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`). -2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/`, whose agent surface is `AGENTS.md` plus `.agents/`), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). +2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`). 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`. 4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257). diff --git a/README.md b/README.md index a73507575..5c33fff35 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ examples/ gallery/ # the feature gallery every scaffolded app ships, as a live app website/ # landing site AND the documentation at /docs and gallery at /ui AGENTS.md # AI-agent contract for the framework -CLAUDE.md # Claude Code bridge (monorepo only, not scaffolded) +CLAUDE.md # Claude Code quick-reference ``` ## Local development @@ -317,7 +317,7 @@ tests. Key features: - **Core:** Signals (`signal`, `computed`, `effect`, `batch`, TC39 Stage 1 shape) as the default state primitive, with WebComponent's built-in SignalWatcher auto-tracking `.get()` reads inside `render()`. Reactive properties via the declare-free base-class factory `extends WebComponent({ count: Number })` (the `prop()` helper carries options like `reflect` / `state` / `attribute` / `default`), reserved for HTML attribute round-trip (a direct `static properties` block throws at runtime, flagged by the `no-static-properties` rule, and a class-field initializer on a factory prop is caught by `reactive-props-no-class-field`). Full lit-API parity: ReactiveController hooks (`hostConnected`, `hostDisconnected`, `hostUpdate`, `hostUpdated`) and lifecycle (`shouldUpdate`, `willUpdate`, `update`, `updated`, `firstUpdated`, `updateComplete`), 12 directives (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` + `createRef`, `cache`, `until`, `asyncAppend`, `asyncReplace`, `watch`). SSR with DSD (opt-in) + light-DOM hydration (default), light-DOM `` projection (framework-driven, same API as shadow DOM), fine-grained client renderer, `Suspense()`, client router with `composedPath()` for shadow DOM, mixed-attribute interpolation, MutationObserver upgrade safety net. - **Data:** Server actions with webjs's built-in serializer (`Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, reference cycles all survive the wire). Two-marker server-file convention: `.server.{js,ts}` for path-level source-protection (browser imports get a throw-at-load stub), `'use server'` for RPC registration (file is also browser-callable). REST over HTTP via a `route.ts` (or the `route()` adapter) with an optional `validate` config export. `json()` + `richFetch()` for content-negotiated APIs. `cache()` for server-side query caching with TTL + `invalidate()`. `WEBJS_PUBLIC_*` env vars injected into `window.process.env` at SSR (no build step, no transform). - **Server:** File router with `page.ts`, `layout.ts`, `route.ts`, `error.ts`, `loading.ts`, `not-found.ts`, `middleware.ts`, metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `opengraph-image`), per-segment middleware, `rateLimit()`, WebSockets (`WS` export + `connectWS()` + `broadcast()`), CSRF, gzip / brotli compression, HTTP/2, 103 Early Hints, modulepreload hints, health probes, graceful shutdown on `SIGTERM`, `Session` class with `SessionStorage` (cookie or store-backed), NextAuth-style `createAuth()` (Credentials, Google, GitHub), single pluggable cache store (in-memory by default, swap to Redis with one `setStore()` call shared by auth, sessions, caching, and rate limiting). -- **DX:** Node 24+ or Bun runtime (run a Bun app with `bun --bun run dev` / `start`, and the CLI hot-reloads via `node --watch` on Node and `bun --hot` on Bun), with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (or `amaro` on Bun, byte-identical), zero build, position-preserving, no sourcemap. Non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) fails with a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. Vendor (`node_modules`) packages resolve through importmap to jspm.io URLs at runtime; the WebJs server doesn't bundle them. `webjs vendor pin` writes resolved URLs to `.webjs/vendor/importmap.json` for deterministic deploys; `webjs vendor pin --download` additionally vendors bundle bytes for offline-capable production. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `no-static-properties`, `reactive-props-no-class-field`, `erasable-typescript-only`, `no-non-erasable-typescript`, `shell-in-non-root-layout`, and more (run `webjs check --rules` to enumerate). Single cross-agent source: `AGENTS.md` (read natively by Cursor, opencode, Antigravity, and the Copilot coding agent) plus the shipped skill `.agents/skills/webjs/SKILL.md` and the workflow rules in `.agents/rules/workflow.md`. A scaffolded app ships NO per-agent rule files and no vendor tool config, so it does not carry one team's tool choices into another's repo; the rules that protect the app are enforced agent-agnostically by `webjs check` and a git `.hooks/pre-commit`. Live reload in dev (`fs.watch` + SSE). `@webjsdev/intellisense` is the standalone editor-only piece (no Lit dependency): its own `` html`…` `` template parser drives go-to-definition on tags / attributes / CSS classes, binding-aware completions, value/binding diagnostics, and hover, all gated by the file's import graph. The `webjs` VS Code / Cursor / Windsurf extension bundles it. Not required for the framework to run. +- **DX:** Node 24+ or Bun runtime (run a Bun app with `bun --bun run dev` / `start`, and the CLI hot-reloads via `node --watch` on Node and `bun --hot` on Bun), with the dev server stripping TypeScript via Node's built-in `module.stripTypeScriptTypes` (or `amaro` on Bun, byte-identical), zero build, position-preserving, no sourcemap. Non-erasable TS (enums, value-carrying namespaces, constructor parameter properties, legacy decorators) fails with a 500 pointing at the `no-non-erasable-typescript` lint rule. WebJs is buildless end-to-end and has no bundler fallback. Vendor (`node_modules`) packages resolve through importmap to jspm.io URLs at runtime; the WebJs server doesn't bundle them. `webjs vendor pin` writes resolved URLs to `.webjs/vendor/importmap.json` for deterministic deploys; `webjs vendor pin --download` additionally vendors bundle bytes for offline-capable production. `webjs check` lint covers `use-server-needs-extension`, `no-server-env-in-components`, `no-static-properties`, `reactive-props-no-class-field`, `erasable-typescript-only`, `no-non-erasable-typescript`, `shell-in-non-root-layout`, and more (run `webjs check --rules` to enumerate). Single cross-agent source: `AGENTS.md` (read natively by Cursor, opencode, Antigravity, and the Copilot coding agent) plus the shipped skill `.agents/skills/webjs/SKILL.md` and the workflow rules in `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a thin bridge pointing at it (`CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`), and Claude Code adds a `.claude/settings.json` PreToolUse hook guarding edits on `main`. Live reload in dev (`fs.watch` + SSE). `@webjsdev/intellisense` is the standalone editor-only piece (no Lit dependency): its own `` html`…` `` template parser drives go-to-definition on tags / attributes / CSS classes, binding-aware completions, value/binding diagnostics, and hover, all gated by the file's import graph. The `webjs` VS Code / Cursor / Windsurf extension bundles it. Not required for the framework to run. - **Release:** Per-package per-version changelog under `changelog//.md`, auto-generated on the same commit that bumps a `package.json` `version` field (universal pre-commit hook). The `.github/workflows/release.yml` workflow watches for new changelog files on `main` and dual-publishes to npm (`npm publish --workspace=@webjsdev/`) and GitHub Releases (`gh release create @`), both idempotent so re-runs pick up where they left off. Free for public repos via `NPM_TOKEN` + the auto-provisioned `GITHUB_TOKEN`. ## License diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 35f2d9362..c915c383f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -156,12 +156,11 @@ lib/ `test/runtime-rewrite/`. templates/ Verbatim files copied into every new app. {{APP_NAME}} placeholder is substituted at - copy time. AGENTS.md / .agents/rules/workflow.md - / .editorconfig / .env.example / .hooks/pre-commit - all live here. There are NO per-agent rule files - and no vendor tool config: AGENTS.md is the open - standard agents read natively, and it routes to - .agents/, which is the whole agent surface. AGENTS.md ALSO gets a template-specific + copy time. The AGENTS.md / CLAUDE.md / + CONVENTIONS.md / .cursorrules / .agents/rules/workflow.md + / .github/copilot-instructions.md / .editorconfig + / .env.example / .claude.json / .claude/hooks/ + all live here. AGENTS.md ALSO gets a template-specific {{PLAYBOOK}} injection: create.js substitutes the full-stack or api build playbook from templates/partials/agents-playbook-{fullstack,api}.md @@ -236,9 +235,9 @@ verbatim. `webjs start`, #550/#725), so a `db:generate`'d migration applies on the next boot with no manual `db:migrate`. Apps must NEVER use JSON files for persistence. This is a project - convention (documented in the scaffold's AGENTS.md). + convention (documented in the scaffold's CONVENTIONS.md). 4. **Template files are verbatim copies** with `{{APP_NAME}}` substitution. - When editing `templates/AGENTS.md` or `.agents/skills/webjs/`, remember they ship + When editing `templates/AGENTS.md`, `templates/CONVENTIONS.md`, or `.agents/skills/webjs/`, remember they ship into every scaffolded app. Write for the audience of an AI agent working inside a freshly-scaffolded WebJs project. `templates/AGENTS.md` is the one exception to "verbatim": it carries a `{{PLAYBOOK}}` marker @@ -247,12 +246,8 @@ verbatim. never ships UI-only guidance. Keep the shared meta-rules (required context-gathering, strict typing, data) in `AGENTS.md` itself and only the template-divergent build steps in the partials. -5. **The scaffold ships ONE agent surface: `AGENTS.md` plus `.agents/`.** - No `CLAUDE.md`, no `.cursorrules`, no `.gemini/`, no `.claude/` hooks or - settings. How a team runs its tools is the team's call, and the rules that - actually protect an app are enforced agent-agnostically: `webjs check` - (which CI runs) and `.hooks/pre-commit`. Do not add a per-agent bridge - back; if a rule matters, it belongs in the skill or in a check rule. +5. **`templates/CLAUDE.md` uses Claude Code's `@import` syntax** + (`@AGENTS.md`, `@CONVENTIONS.md`). See https://code.claude.com/docs/en/claude-md.md#import-additional-files ## Tests diff --git a/packages/cli/README.md b/packages/cli/README.md index b9c621cc2..d02535236 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -68,8 +68,8 @@ the CLI gives you `webjs ui` automatically. See The scaffold seeds opinionated defaults so AI agents produce consistent code: -- `AGENTS.md` + `.agents/skills/webjs/` (single cross-agent source of truth, no per-agent rule files) -- `.agents/rules/workflow.md` & a git `.hooks/pre-commit` +- `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth) +- `.agents/rules/workflow.md` & `.claude/` protective hooks - `test//` (with optional `browser/` / `e2e/` subfolders per kind) with example tests - Tailwind CSS via CLI (no browser runtime at build time) - TypeScript, `.editorconfig`, `.gitignore` diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index d4034b02b..3e50c168e 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -716,7 +716,7 @@ async function main() { console.log(' security leak, a reactive prop that silently stops'); console.log(' re-rendering, or a build/type-strip failure. They always'); console.log(' run. Project conventions (layout, style, process) are'); - console.log(' guidance in AGENTS.md and .agents/, not rules here.\n'); + console.log(' guidance in CONVENTIONS.md, not rules here.\n'); for (const r of RULES) { console.log(` ${r.name.padEnd(30)} ${r.description}`); } diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index a773ff251..532635a6b 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -6,7 +6,7 @@ * - modules/ skeleton * - components/ with a theme toggle * - test/unit/ and test/e2e/ with example tests - * - AGENTS.md plus .agents/ (the one cross-agent guidance surface) + * - CONVENTIONS.md, AGENTS.md, CLAUDE.md * - package.json with WebJs deps + test scripts * - tsconfig.json for editor support */ @@ -623,17 +623,27 @@ export async function scaffoldApp(name, cwd, opts = {}) { exclude: ['node_modules', '.webjs/vendor', 'db/migrations'], }, null, 2) + '\n'); - // --- Templates (AGENTS.md, .agents/, test files, git hook) --- + // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) --- const templateFiles = [ - // ONE agent surface, no per-agent rule files. AGENTS.md is the open - // standard agents read natively, and it points at .agents/skills/webjs/ - // (the guidance) and .agents/rules/workflow.md (the workflow). A generated - // app gets no CLAUDE.md, no .cursorrules, no vendor hook config: how a team - // runs its tools is theirs to decide, and the rules that actually protect - // the app are enforced agent-agnostically by `webjs check` and CI. + // Single cross-agent source: AGENTS.md points at .agents/skills/webjs/; the + // .agents/rules workflow rules and the Claude enforcement hooks back it up. 'AGENTS.md', + 'CONVENTIONS.md', '.agents/rules/workflow.md', + 'CLAUDE.md', + // Claude Code config + the protective enforcement hooks (no design ceremony). + '.claude.json', + '.claude/settings.json', + '.claude/hooks/block-prose-punctuation.sh', + '.claude/hooks/block-raw-htmlelement.sh', + '.claude/hooks/guard-branch-context.sh', + '.claude/hooks/nudge-uncommitted.sh', + '.claude/hooks/commit-before-stop.sh', + '.claude/hooks/cleanup-merged-worktree.sh', + '.claude/hooks/require-tests-with-src.sh', + '.claude/hooks/check-server-imports.sh', + '.claude/hooks/check-server-imports.mjs', // Git pre-commit hook (blocks commits directly to main). '.hooks/pre-commit', // Starter tests under the feature-folder layout. @@ -663,7 +673,7 @@ export async function scaffoldApp(name, cwd, opts = {}) { // rewrites; the three infra files get their file-specific transform. On Node, // every file is copied byte-identical (the map is empty). const PROSE_REWRITE = new Set([ - 'AGENTS.md', + 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md', '.agents/rules/workflow.md', 'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts', ]); @@ -731,10 +741,12 @@ export async function scaffoldApp(name, cwd, opts = {}) { } } - // Make the git pre-commit hook executable. It is the one piece of - // enforcement the scaffold ships, and it is git-level rather than - // agent-level, so it binds every agent, editor, and human equally. + // Make the Claude enforcement hooks + the git pre-commit executable. const { chmod } = await import('node:fs/promises'); + for (const hook of ['block-prose-punctuation.sh', 'block-raw-htmlelement.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh', 'commit-before-stop.sh', 'cleanup-merged-worktree.sh', 'require-tests-with-src.sh', 'check-server-imports.sh']) { + const hookPath = join(appDir, '.claude', 'hooks', hook); + if (existsSync(hookPath)) await chmod(hookPath, 0o755); + } const preCommitPath = join(appDir, '.hooks', 'pre-commit'); if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755); diff --git a/packages/cli/lib/runtime-rewrite.js b/packages/cli/lib/runtime-rewrite.js index 944192263..1b104c771 100644 --- a/packages/cli/lib/runtime-rewrite.js +++ b/packages/cli/lib/runtime-rewrite.js @@ -7,7 +7,7 @@ * pure string transforms so they unit-test without touching the filesystem. * * Why a transform and not a second set of template files: the agent-config - * markdown (AGENTS.md / .agents/rules/workflow.md) plus the deploy + * markdown (AGENTS.md / CONVENTIONS.md / .cursorrules / ...) plus the deploy * files (Dockerfile / ci.yml) are long and change often; a parallel bun copy * would silently drift from the node original. A transform keeps the node * template canonical and the bun output a deterministic function of it. diff --git a/packages/cli/templates/.claude.json b/packages/cli/templates/.claude.json new file mode 100644 index 000000000..cd0ee4a37 --- /dev/null +++ b/packages/cli/templates/.claude.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["@playwright/mcp@latest"] + }, + "webjs": { + "type": "stdio", + "command": "npx", + "args": ["@webjsdev/mcp"] + } + } +} diff --git a/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh b/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh new file mode 100755 index 000000000..58d83613c --- /dev/null +++ b/packages/cli/templates/.claude/hooks/block-prose-punctuation.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# +# PreToolUse hook: block prose-punctuation patterns the webjs convention bans. +# +# Catches four classes of new content in tool calls: +# +# 1. U+2014 em-dash, anywhere. +# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown +# lines, headings, blockquotes, a JSON "description" / "title" / +# "displayName" string value, and a column-0 YAML front-matter +# description: / title: / displayName: line). Math expressions in code like +# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged. +# 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2. +# JS / CSS statement terminators (`;\n`) are NOT flagged. +# 4. Code-shaped left-hand side immediately followed by a colon and prose: +# - `foo():` (markdown code-LHS in docs) +# - `:` (custom-element tag with hyphen) +# - Inline comment `// foo(): description` +# +# Why this exists: see AGENTS.md "Invariants", item 11. These patterns +# confuse AI agents that try to parse the prose as TypeScript / shorthand- +# method / object-literal syntax, and trip humans reading API docs. +# +# Covers two tool-call paths: +# * Write / Edit / MultiEdit / NotebookEdit. The hook inspects the NEW +# content fields of the tool payload. Existing glyphs in old_string +# are not flagged: you can still Edit a line that contains one to +# remove it. +# * Bash. The hook inspects the command string, which catches commit +# messages (`git commit -m "..."`), heredocs, echo / printf, and any +# other prose typed at the shell. + +set -euo pipefail + +payload=$(cat) + +# Pull every field where prose might land. `// empty` keeps missing +# fields silent; `[]?` keeps array iteration safe when absent. +new_content=$(printf '%s' "$payload" | jq -r ' + (.tool_input.content // empty), + (.tool_input.new_string // empty), + (.tool_input.new_source // empty), + (.tool_input.command // empty), + (.tool_input.edits[]?.new_string // empty) +' 2>/dev/null || true) + +if [ -z "$new_content" ]; then + exit 0 +fi + +# Every match below reads from a here-string, never a pipe. `grep -q` exits on +# the first match, which closes a pipe under `printf`, and with `set -o pipefail` +# that SIGPIPE became the pipeline status, so the rule silently skipped on any +# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB). + +# --- 1. U+2014 em-dash -------------------------------------------------- +if grep -q $'\xe2\x80\x94' <<< "$new_content"; then + cat >&2 <<'EOF' +BLOCKED: em-dash (U+2014) detected in this tool call. + +webjs bans em-dashes repo-wide. Replace every U+2014 character with +a period, comma, colon (on a plain-noun LHS), parentheses, or +restructured sentence. Do NOT replace it with " - " or " ; " or a +trailing colon on code: those are also banned. See rule 2 / 3 / 4 +below for the alternatives. + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +# --- 2. Pause-hyphen " - " in PROSE contexts ---------------------------- +# Only flag lines whose context is clearly prose: +# - Markdown lines starting with `#`, `>`, `*`, plain text outside code +# fences (heuristic: line has no `=`, `{`, or `(...)` math) +# - JSDoc / block comment lines starting with `*` +# - Single-line comments starting with `//` +# +# Math expressions like `Math.abs(a - b)` or `arr.length - 1` are NOT +# flagged because they appear in code lines (not comments) with code +# context. The hook trades some false negatives in prose for zero false +# positives in code-heavy diffs. + +block_pause_hyphen=0 + +# Comment-line " - " pause: line starts with `//` or ` *` (JSDoc/block) or +# `*` (markdown bold-start would have a letter after, distinguishable), +# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines +# like `// foo - bar`, ` * foo - bar`, `* foo - bar`. +if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +# Markdown heading " - " pause: line starts with `#` followed by prose +# and ` - ` pattern. +if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +# Markdown blockquote " - " pause: line starts with `>` followed by prose +# and ` - ` pattern. (Single `>` blockquote, not table.) +if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +# HTML / markdown

    ,

  • , body " - " pause: line contains a +# closing HTML tag from a prose context, then prose-style ` - `. +if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +# JSON prose-value " - " pause: a string assignment whose KEY is one of the +# three prose-bearing keys this project's JSON uses. Scoping to the key is what +# keeps this off semver ranges, script commands, urls, paths and globs, every +# one of which lives under a different key. Shape, not file path: the Bash +# payload carries no file_path, so a heredoc writing a manifest is covered too. +if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no +# leading whitespace, which is what confines it to document front matter: every +# nested YAML mapping is indented, including the workflow-input `description:` +# values in .github/workflows/release.yml. +if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then + block_pause_hyphen=1 +fi + +if [ "$block_pause_hyphen" = "1" ]; then + cat >&2 <<'EOF' +BLOCKED: pause-hyphen " - " detected in a prose context. + +webjs bans plain hyphens used as pause-punctuation in prose. Rewrite +the sentence with a period, comma, colon (on a plain-noun LHS), or +restructured phrasing. + + Bad: // Foo - bar + Good: // Foo, with bar + Good: // Foo. Bar. + + Bad:
  • Foo - bar.
  • + Good:
  • Foo, with bar.
  • + + Bad: "description": "A library - for things" + Good: "description": "A library for things" + +Plain hyphens are still fine in compound words (`AI-first`), CLI +flags (`--http2`), filenames, ranges, and math expressions in code +(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the +` < word > - < word > ` pause-pattern in prose contexts (comments, +markdown headings, blockquotes, HTML prose tags, and a JSON or +front-matter description / title / displayName value). + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +# --- 3. Pause-semicolon " ; " in PROSE contexts ------------------------- +# Same prose-context guard as #2. +block_pause_semicolon=0 + +if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +# JSON prose-value " ; " pause, same three keys as rule 2. +if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +# YAML front-matter " ; " pause, column-0 anchored like rule 2. +if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then + block_pause_semicolon=1 +fi + +if [ "$block_pause_semicolon" = "1" ]; then + cat >&2 <<'EOF' +BLOCKED: pause-semicolon " ; " detected in a prose context. + +webjs bans semicolons used as pause-punctuation in prose. Rewrite as +two sentences (period) or with a conjunction (", and", ", but", ", so"). + + Bad: // Forms work ; links work too. + Good: // Forms work. Links work too. + Good: // Forms work, and links work too. + + Bad: "description": "Forms work ; links work too." + Good: "description": "Forms work. Links work too." + +Semicolons stay fine inside code (JS statement terminators, CSS +declarations) since those are not flagged. Only the space-surrounded +form is banned, so an ordinary English semicolon is untouched. + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +# --- 4a. foo(): prose --------------------------------------- +# Markdown / HTML definition list with code-call followed by colon and +# lowercase prose. The `):` shape is unambiguous: this is markdown, +# not code, AND the inner code ends in `()` so the colon visually parses +# as a return-type annotation. +if grep -qE '\):[[:space:]][a-z]' <<< "$new_content"; then + cat >&2 <<'EOF' +BLOCKED: code-LHS colon-then-prose detected ("foo(): ..."). + +webjs bans `foo(): ` because the colon visually +parses as a TypeScript return-type annotation. Rewrite verb-led. + + Bad: repeat(): keyed list directive + Good: repeat() is the keyed list directive + Good: startServer() creates an HTTP(S) server + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +# --- 4b. Custom-element-tag : prose ------------------------------ +# HTML reserves hyphenated tag names for custom elements (W3C spec), so +# `:` is unambiguous prose, never JSX / TS / CSS. +if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then + cat >&2 <<'EOF' +BLOCKED: custom-element-tag colon-then-prose detected (": ..."). + +webjs bans `: ` in comments and docs. Rewrite verb-led. + + Bad: // : owns open state, focus trap, escape, scroll lock. + Good: // owns open state, focus trap, escape, scroll lock. + Bad: // : the centered panel. + Good: // is the centered panel. + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +# --- 4c. Inline / JSDoc comment "foo(): prose" -------------------------- +# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and +# lowercase prose. Avoids TS return-type annotations because those never +# appear inside comment lines. +if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then + cat >&2 <<'EOF' +BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ..."). + +webjs bans `xyz(): ` inside comments and JSDoc. Rewrite verb-led. + + Bad: // firstUpdated(): once, on the first render only + Good: // firstUpdated() runs once, on the first render only + Bad: // closest(): null if the click wasn't inside a frame + Good: // closest() returns null when the click wasn't inside a frame + +Rule: AGENTS.md, Invariants section, item 11. +Hook: .claude/hooks/block-prose-punctuation.sh. +EOF + exit 2 +fi + +exit 0 diff --git a/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh b/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh new file mode 100755 index 000000000..f54015dd8 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Guardrail: a webjs custom element must extend the framework's WebComponent +# base class, never raw HTMLElement. +# +# Why: a raw `extends HTMLElement` custom element is invisible to the webjs +# elision analyser (it ships unconditionally, defeats display-only elision, and +# keeps any importing page/layout from being import-only), it bypasses the +# SSR / lifecycle / reactive-prop machinery, and it usually applies its DOM work +# in connectedCallback (client-only), a progressive-enhancement bug. +# +# Scope: fires ONLY when the edited file lives in a webjs project (a package.json +# up the tree depends on @webjsdev/*), so vanilla-JS projects are never touched. +# Exempts framework source (packages/, node_modules/), since the framework +# legitimately defines WebComponent and the SSR-inert / -stream / +# -suspense primitives on raw HTMLElement. Honours an explicit escape-hatch +# marker `webjs-allow-htmlelement: ` for the rare native-API case +# WebComponent cannot express (a form-associated element via ElementInternals, +# a customized built-in via `extends HTMLButtonElement`, etc.). +# +# PreToolUse contract: exit 0 = allow, exit 2 = block (message on stderr). +# +# NOTE: No em-dashes, spaces around hyphens as pauses, or semicolons as pauses +# are allowed in comments per project rules. +set -euo pipefail + +input=$(cat) +fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +[ -z "$fp" ] && exit 0 +case "$fp" in + *.ts|*.tsx|*.js|*.jsx|*.mts|*.mjs) ;; + *) exit 0 ;; +esac + +# The text being written: Write -> .content, Edit -> .new_string, MultiEdit -> .edits[]?.new_string. +content=$(printf '%s' "$input" | jq -r '(.tool_input.content // empty), (.tool_input.new_string // empty), (.tool_input.edits[]?.new_string // empty)') +[ -z "$content" ] && exit 0 + +# Only a class that extends raw HTMLElement is the target (not `typeof +# HTMLElement` guards, not `instanceof HTMLElement`, not another base). +printf '%s' "$content" \ + | grep -Eq 'class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]+extends[[:space:]]+HTMLElement([[:space:]{]|$)' \ + || exit 0 + +# Explicit, acknowledged exception. +printf '%s' "$content" | grep -qi 'webjs-allow-htmlelement' && exit 0 + +# Framework source / installed deps are never app components. +case "$fp" in + */packages/*|*/node_modules/*|packages/*|node_modules/*) exit 0 ;; +esac + +# Webjs context: a package.json up the tree references @webjsdev/* (a webjs app +# or the framework repo). Outside a webjs project this hook is a no-op. +dir=$(CDPATH= cd -- "$(dirname -- "$fp")" 2>/dev/null && pwd || dirname -- "$fp") +is_webjs=0 +while [ -n "$dir" ] && [ "$dir" != "/" ]; do + if [ -f "$dir/package.json" ] && grep -q '@webjsdev/' "$dir/package.json" 2>/dev/null; then + is_webjs=1 + break + fi + dir=$(dirname -- "$dir") +done +[ "$is_webjs" -eq 0 ] && exit 0 + +cat >&2 <<'MSG' +BLOCKED: a webjs custom element must extend the WebComponent base class, not raw HTMLElement. + + import { WebComponent } from '@webjsdev/core'; + class MyThing extends WebComponent { + render() { return html`...`; } + } + MyThing.register('my-thing'); + +A display-only element (just host classes / static markup) can set its classes +in the constructor (runs at SSR, so it is progressive-enhancement-safe) and +stays elidable, so it ships zero JS. A raw `extends HTMLElement` element cannot +be elided, defeats import-only routes, and applies its work client-only. + +If WebComponent genuinely cannot express this (a rare native-API edge case), +add a marker comment containing `webjs-allow-htmlelement: ` to the file +to acknowledge the exception, and this guardrail will allow it. +MSG +exit 2 diff --git a/packages/cli/templates/.claude/hooks/check-server-imports.mjs b/packages/cli/templates/.claude/hooks/check-server-imports.mjs new file mode 100644 index 000000000..21843d5c2 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/check-server-imports.mjs @@ -0,0 +1,86 @@ +// Node walker for check-server-imports.sh (#804). Reads the PreToolUse JSON +// payload (arg or stdin), extracts the file being edited and its proposed +// content, and warns when a browser-facing app module adds an import of a +// server-only `.server.*` utility (no `'use server'`). WARN by default; a +// clean edit prints nothing and exits 0. +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; + +function readPayload() { + const arg = process.argv[2]; + if (arg && arg.trim().startsWith('{')) return arg; + try { return readFileSync(0, 'utf8'); } catch { return ''; } +} + +let data; +try { data = JSON.parse(readPayload() || '{}'); } catch { process.exit(0); } +const input = data.tool_input || {}; +const filePath = input.file_path || input.filePath || ''; +if (!filePath) process.exit(0); + +// Only browser-facing app modules matter. A `.server.*` file (the boundary) or a +// route.ts / middleware.ts (never shipped) is allowed to import server code. +const rel = filePath.replace(/\\/g, '/'); +const isAppModule = /\/(app|components|modules|lib)\/.*\.(ts|js|mts|mjs)$/.test('/' + rel) || /(^|\/)(app|components|modules|lib)\//.test(rel); +if (!isAppModule) process.exit(0); +if (/\.server\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0); +if (/(^|\/)(route|middleware)\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0); + +// Proposed content: Write has `content`; Edit has `new_string`; else read disk. +let content = input.content ?? input.new_string ?? ''; +if (!content && existsSync(filePath)) { try { content = readFileSync(filePath, 'utf8'); } catch { /* ignore */ } } +if (!content) process.exit(0); + +// Find the app root (walks up for a package.json with a `#*` imports map or a db/ dir). +function findAppRoot(start) { + let dir = dirname(resolve(start)); + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, 'package.json')) && (existsSync(join(dir, 'app')) || existsSync(join(dir, 'db')))) return dir; + const up = dirname(dir); + if (up === dir) break; + dir = up; + } + return dirname(resolve(start)); +} +const appRoot = findAppRoot(filePath); + +// Collect import specifiers, skipping `import type` (erased by the stripper). +const specs = []; +const re = /(?:^|\n)\s*import\s+(type\s+)?[^;'"]*?from\s*['"]([^'"]+)['"]/g; +let m; +while ((m = re.exec(content))) { if (!m[1]) specs.push(m[2]); } + +function resolveSpec(spec) { + if (spec.startsWith('#')) return join(appRoot, spec.slice(1).replace(/^\//, '')); + if (spec.startsWith('.')) return resolve(dirname(filePath), spec); + return null; // bare npm specifier +} + +const offenders = []; +for (const spec of specs) { + if (!/\.server\.(ts|js|mts|mjs)$/.test(spec)) continue; + const abs = resolveSpec(spec); + if (!abs || !existsSync(abs)) continue; + let src = ''; + try { src = readFileSync(abs, 'utf8'); } catch { continue; } + const head = src.split('\n').slice(0, 5).join('\n'); + const hasUseServer = /^\s*(['"])use server\1\s*;?\s*$/m.test(head); + if (!hasUseServer) offenders.push(spec); +} + +if (offenders.length === 0) process.exit(0); + +const msg = + `A browser-facing module (${rel}) imports a server-only utility: ${offenders.join(', ')}. ` + + `A .server.{ts,js} file with NO 'use server' directive throws at load in the browser, ` + + `so this would crash the page (webjs check flags it as no-server-import-in-browser-module). ` + + `Fix: add 'use server' to make it an RPC action, or reach it from a 'use server' action / route.ts / ` + + `middleware.ts, or share only a type via 'import type'. See the skill's references/data-and-actions.md.`; + +if (process.env.WEBJS_SERVER_IMPORT_GATE === 'block') { + process.stderr.write(`BLOCKED: ${msg}\n`); + process.exit(2); +} +// WARN: surface as additionalContext, allow the edit. +process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }) + '\n'); +process.exit(0); diff --git a/packages/cli/templates/.claude/hooks/check-server-imports.sh b/packages/cli/templates/.claude/hooks/check-server-imports.sh new file mode 100755 index 000000000..850be7349 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/check-server-imports.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# PreToolUse hook (scaffolded by `webjs create`): WARN, at write time, when an +# Edit/Write to a browser-facing app module (a page / layout / component under +# app/ or components/ or modules/, NOT a `.server.*` file) adds an import of a +# server-only `.server.{ts,js}` utility (a `.server.*` file with NO `'use server'` +# directive). In the browser that import resolves to a throw-at-load stub, so the +# module crashes the moment it loads. This is the #804 first-pass iteration loop: +# `webjs check`'s `no-server-import-in-browser-module` catches it AFTER the file +# lands; this hook surfaces it BEFORE, so the agent never writes the wrong shape. +# +# WARN, not block (the convention-vs-check principle in this app's AGENTS.md): +# `webjs check` is the authoritative gate, and a pre-edit static peek cannot see +# the full elision verdict, so a hard block could false-positive on a display-only +# page the framework would elide. So this emits a loud reminder and allows the +# edit. Set WEBJS_SERVER_IMPORT_GATE=block to hard-block instead; set +# WEBJS_NO_SERVER_IMPORT_GATE=1 to skip. +# +# A `'use server'` action import is fine (it becomes a working RPC stub), and a +# `import type { ... } from './x.server.ts'` is fine (the stripper erases it), so +# both are ignored. + +[ "$WEBJS_NO_SERVER_IMPORT_GATE" = "1" ] && exit 0 + +payload="$(cat)" +node "$(dirname "$0")/check-server-imports.mjs" "$payload" diff --git a/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh b/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh new file mode 100755 index 000000000..aef86b6ef --- /dev/null +++ b/packages/cli/templates/.claude/hooks/cleanup-merged-worktree.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# Claude Code PostToolUse hook (matcher: Bash). +# +# After a `gh pr merge`, sweep the repo's git worktrees and REMOVE the ones +# whose work has already landed, so a merged branch's worktree does not leak. +# Accumulated stale worktrees (a session that merged but never cleaned up, or +# crashed mid-task) are exactly what this closes: the webjs-start-work skill +# already says "after the PR merges, git worktree remove", but as guidance it +# gets skipped, so this makes the cleanup deterministic. +# +# CONSERVATIVE BY DESIGN. A worktree is removed ONLY when ALL hold: +# * it is a LINKED worktree, not the primary checkout; +# * it is NOT the current directory (you cannot remove the one you are in); +# * its branch is not main/master; +# * its branch is MERGED (an ancestor of the base ref, OR a merged GitHub PR +# for that head branch, which is how squash-merges are detected); +# * its working tree is CLEAN apart from untracked node_modules / .webjs. +# Anything with uncommitted or unpushed-looking work is KEPT and reported, so +# the hook can never destroy in-flight work. +# +# It never blocks the tool (always exits 0) and reports what it did back to the +# model via hookSpecificOutput.additionalContext. Disable with +# WEBJS_NO_WORKTREE_CLEANUP=1. +# +# Rule: AGENTS.md "One task per git worktree" + the webjs-start-work skill. + +set -uo pipefail + +# Read the whole payload first so we always honour the hook contract. +payload=$(cat 2>/dev/null || true) + +if [ "${WEBJS_NO_WORKTREE_CLEANUP:-}" = "1" ]; then exit 0; fi + +cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +if [ -z "$cmd" ]; then exit 0; fi + +# Only act after a `gh pr merge` (whole word, not `gh pr merge-queue` typos etc.). +if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])gh pr merge([^[:alnum:]-]|$)'; then + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi + +# The base ref merged branches land on. Prefer origin/main; fall back to a +# local main/master (the test harness has no remote). +base="" +for ref in origin/main origin/master main master; do + if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then base="$ref"; break; fi +done +[ -z "$base" ] && exit 0 + +here=$(git rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD") +# The primary worktree is the first entry of `git worktree list`. +primary=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') + +is_merged() { + local br="$1" + # Ancestor of the base ref (fast-forward / rebase merges, and the real + # merges the test harness makes). + if git merge-base --is-ancestor "refs/heads/$br" "$base" 2>/dev/null; then return 0; fi + # A merged GitHub PR for this head branch (squash merges, which are NOT an + # ancestor of base). Network; skipped when gh is absent or unauthenticated. + if command -v gh >/dev/null 2>&1; then + local n + n=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null || true) + [ -n "$n" ] && return 0 + fi + return 1 +} + +# Clean = nothing in `git status` except untracked node_modules / .webjs caches. +is_clean() { + local wt="$1" dirty + dirty=$(git -C "$wt" status --porcelain 2>/dev/null \ + | grep -vE '(^|/)(node_modules|\.webjs)(/|$)' || true) + [ -z "$dirty" ] +} + +removed=() +kept=() + +# Parse worktree path + branch pairs. +wt="" +while IFS= read -r line; do + case "$line" in + worktree\ *) wt="${line#worktree }" ;; + branch\ *) + br="${line#branch refs/heads/}" + # Skip the primary checkout and main/master lines. + if [ "$wt" = "$primary" ] || [ "$br" = "main" ] || [ "$br" = "master" ]; then wt=""; continue; fi + # Never remove the worktree we are currently in. + if [ "$wt" = "$here" ]; then + kept+=("$wt (current directory; cd out then \`git worktree remove\`)") + wt=""; continue + fi + if ! is_clean "$wt"; then + kept+=("$wt (uncommitted changes)"); wt=""; continue + fi + if ! is_merged "$br"; then + kept+=("$wt (branch $br not merged yet)"); wt=""; continue + fi + if git worktree remove --force "$wt" >/dev/null 2>&1; then + removed+=("$wt ($br)") + else + kept+=("$wt (git worktree remove failed)") + fi + wt="" ;; + "") wt="" ;; + esac +done < <(git worktree list --porcelain 2>/dev/null) + +git worktree prune >/dev/null 2>&1 || true + +# Report nothing if there was nothing to do. +if [ "${#removed[@]}" -eq 0 ] && [ "${#kept[@]}" -eq 0 ]; then exit 0; fi + +msg="Worktree cleanup after \`gh pr merge\`:" +for r in "${removed[@]:-}"; do [ -n "$r" ] && msg="$msg"$'\n'" removed $r (merged, clean)"; done +for k in "${kept[@]:-}"; do [ -n "$k" ] && msg="$msg"$'\n'" kept $k"; done + +jq -n --arg ctx "$msg" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $ctx + } +}' 2>/dev/null || true + +exit 0 diff --git a/packages/cli/templates/.claude/hooks/commit-before-stop.sh b/packages/cli/templates/.claude/hooks/commit-before-stop.sh new file mode 100755 index 000000000..125dc1b07 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/commit-before-stop.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Claude Code Stop hook. +# +# The commit-per-logical-unit rule (CLAUDE.md + AGENTS.md "Git workflow") is +# easy for an agent to defer to "the end", and then the end arrives with the +# whole feature done and ZERO commits, which is the worst outcome: git history, +# the user's revert and cherry-pick safety net, is empty. The PostToolUse +# `nudge-uncommitted.sh` reminds DURING work but is only a soft context nudge an +# agent can ignore. This Stop hook is the backstop at the END of a turn: if you +# try to finish with a pile of uncommitted work on a feature branch, it blocks +# the stop once and tells you to commit the completed unit first. +# +# Loop-safe: when `stop_hook_active` is already true (this hook fired and the +# agent is continuing because of it), it does NOT block again, so it nags at +# most once per stop and can never trap the agent in a loop. +# +# Skipped on main/master (you must not commit there anyway) and outside a git +# work tree. Threshold via WEBJS_COMMIT_STOP_THRESHOLD (default 2). Disable +# entirely with WEBJS_NO_COMMIT_STOP=1. + +set -uo pipefail + +payload=$(cat 2>/dev/null || true) + +if [ "${WEBJS_NO_COMMIT_STOP:-}" = "1" ]; then exit 0; fi + +# Loop guard: if we already blocked once this stop-cycle, let the agent stop. +active=$(printf '%s' "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false) +if [ "$active" = "true" ]; then exit 0; fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi + +branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +if [ -z "$branch" ] || [ "$branch" = "main" ] || [ "$branch" = "master" ]; then exit 0; fi + +threshold="${WEBJS_COMMIT_STOP_THRESHOLD:-2}" + +# Count real changes: tracked modifications + staged + untracked, minus the +# noise the agent should never commit (node_modules, the sqlite db, caches). +changed=$(git status --porcelain 2>/dev/null \ + | grep -vE '(^|/)(node_modules|\.webjs)(/|$)|dev\.db($|-journal)' \ + | grep -c . || true) + +if [ -z "$changed" ] || [ "$changed" -lt "$threshold" ]; then exit 0; fi + +reason="You are ending the turn with ${changed} uncommitted changes on '${branch}'. This project OVERRIDES Claude Code's never-commit default: commit per logical unit (see CLAUDE.md and AGENTS.md \"Git workflow\"). Before you stop, group the completed work into a meaningful commit ('git add' the related files, 'git commit' with an imperative subject under 72 chars) and push. If the work is genuinely mid-change and not yet a coherent unit, commit what IS complete, or explain in your final message why it cannot be committed yet. To relax this backstop set WEBJS_COMMIT_STOP_THRESHOLD, or disable it with WEBJS_NO_COMMIT_STOP=1." + +jq -n --arg r "$reason" '{decision: "block", reason: $r}' 2>/dev/null \ + || printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs . 2>/dev/null || echo '""')" + +exit 0 diff --git a/packages/cli/templates/.claude/hooks/guard-branch-context.sh b/packages/cli/templates/.claude/hooks/guard-branch-context.sh new file mode 100644 index 000000000..cda51ef93 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/guard-branch-context.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# +# guard-branch-context.sh - Claude Code PreToolUse hook +# +# Rules: +# - On main/master → ask (agent should create a feature branch first) +# - On any other branch → allow (feature branches are free to edit) +# - Bypass mode → allow everything + +INPUT=$(cat /dev/stdin) + +# Bypass mode - full autonomy +SETTINGS="$HOME/.claude/settings.json" +if [ -f "$SETTINGS" ]; then + BYPASS=$(jq -r '.skipDangerousModePermissionPrompt // false' "$SETTINGS" 2>/dev/null) + if [ "$BYPASS" = "true" ]; then + exit 0 + fi +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + exit 0 +fi + +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +[ -z "$BRANCH" ] && exit 0 + +if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then + jq -n --arg reason "You are on '$BRANCH'. Create a feature branch first (git checkout -b feature/), or approve to edit on '$BRANCH'." '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: $reason + } + }' + exit 0 +fi + +exit 0 diff --git a/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh b/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh new file mode 100755 index 000000000..e3a170c42 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/nudge-uncommitted.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Claude Code PostToolUse hook. +# +# After each Edit, Write, MultiEdit, or NotebookEdit, counts +# uncommitted changes in the working tree. When the count +# crosses a threshold (default 4, override with the +# WEBJS_COMMIT_NUDGE_THRESHOLD env var), injects a reminder +# into the model's context via hookSpecificOutput. +# +# Soft nudge. Does NOT block the edit. The goal is to keep +# the agent honest about the "commit per logical unit" rule, +# not to interrupt valid work. +# +# Skipped on main/master and outside a git work tree. + +set -e + +THRESHOLD="${WEBJS_COMMIT_NUDGE_THRESHOLD:-4}" + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + exit 0 +fi + +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then + exit 0 +fi + +# Read stdin so we don't break Claude Code's hook contract. +cat /dev/stdin >/dev/null 2>&1 || true + +CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') + +if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then + exit 0 +fi + +REASON="You have ${CHANGED} uncommitted changes on '${BRANCH}'. The webjs convention is small, focused commits per logical unit (one feature, one fix, one rename, one doc rewrite). Before continuing with more edits, group the current changes into a meaningful commit. See AGENTS.md \"Git workflow\" for the rule and the rationale. To raise the threshold for this hook in long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD." + +jq -n --arg ctx "$REASON" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $ctx + } +}' diff --git a/packages/cli/templates/.claude/hooks/require-tests-with-src.sh b/packages/cli/templates/.claude/hooks/require-tests-with-src.sh new file mode 100755 index 000000000..d57517603 --- /dev/null +++ b/packages/cli/templates/.claude/hooks/require-tests-with-src.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# PreToolUse hook (scaffolded by `webjs create`): WARN on a `git commit` +# that adds or changes application code without any accompanying test. +# +# webjs is AI-first, and "every change ships with a test" is the right +# default. But it is a CONVENTION, not a correctness check: a sensible +# app can legitimately want a test-less commit (a spike, a vendored +# file, a pure refactor). The convention-vs-check principle in this +# app's AGENTS.md says guidance like this WARNS, it +# does not hard-block by default. So this hook surfaces a loud reminder +# and lets the commit proceed. +# +# What a hook CANNOT do: judge WHICH test layer a change needs (a unit +# test vs a browser/e2e test is a judgement call). So it nudges toward +# the floor (some real test should accompany app code) and reminds you +# to add browser/e2e coverage for interactive surfaces. The actual test +# suite runs in CI (.github/workflows/ci.yml), which is the real gate. +# +# Scope: fires only on `git commit`. Inspects the STAGED diff. +# +# Behavior when the staged diff changes app code (app/, modules/, +# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*): +# - Default: WARN via additionalContext, then allow the commit (exit 0). +# - WEBJS_TEST_GATE=block: restore the old hard floor (print BLOCKED, +# exit 2), for a project that wants the strict gate. Set it in +# .claude/settings.json env, your shell, or CI. +# - WEBJS_NO_TEST_GATE=1: skip entirely (no warn, no block), for a +# genuine non-code commit (docs, config). +# +# Bypass (humans, emergencies): git commit --no-verify. + +set -euo pipefail + +if [ "${WEBJS_NO_TEST_GATE:-}" = "1" ]; then + exit 0 +fi + +payload=$(cat) +cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +if [ -z "$cmd" ]; then exit 0; fi +# Match `git commit` as a whole word so sibling subcommands +# (git commit-graph, git commit-tree) and string mentions do not trip it. +if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])git commit([^[:alnum:]-]|$)'; then + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi + +staged=$(git diff --cached --name-only 2>/dev/null || true) +if [ -z "$staged" ]; then exit 0; fi + +# App code lives under app/, modules/, components/, lib/. A `.server.*` +# file is still app code. Match source extensions only (skip .css, .md). +app_code=$(printf '%s\n' "$staged" \ + | grep -E '^(app|modules|components|lib)/.*\.([mc]?[jt]sx?)$' || true) +if [ -z "$app_code" ]; then exit 0; fi + +test_staged=$(printf '%s\n' "$staged" \ + | grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true) + +if [ -z "$test_staged" ]; then + # Hard-mode opt-in: restore the old block when the project asks for it. + if [ "${WEBJS_TEST_GATE:-}" = "block" ] || [ "${WEBJS_TEST_GATE:-}" = "hard" ]; then + cat >&2 <<'EOF' +BLOCKED: this commit changes app code but stages no test. + +You staged application code (app/, modules/, components/, lib/) with no +accompanying test. Every change ships with a test. Add or update the test +that proves the new behaviour, then `git add` it. + +Pick the layer the change needs (a unit test is not always enough): + - logic / actions / queries / utils -> a unit test + - a component, hydration, a server action called from the client, the + router, anything interactive -> a browser or e2e test that asserts the + real behaviour in a browser, not just the function in isolation. + +See `webjs test` and the testing guide. Genuine non-code commit (docs, +config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1. Hard mode is +on because WEBJS_TEST_GATE=block is set; unset it to fall back to a warning. + +Hook: .claude/hooks/require-tests-with-src.sh +EOF + exit 2 + fi + + # Default: warn loudly via additionalContext, then allow the commit. + # A missing test for app code subsumes the interactive-component + # reminder, so emit this warning alone and skip that reminder below. + jq -n --arg ctx "Heads up: this commit stages app code (app/, modules/, components/, lib/) with no test. Every change should ship with a test (it is a convention, not a hard gate). Pick the layer the change needs: a unit test for logic/actions/queries/utils, and a browser or e2e test for a component, hydration, the client router, or a server action called from the client. The suite runs in CI regardless. To enforce a hard block locally, set WEBJS_TEST_GATE=block. To silence this for a genuine non-code commit, set WEBJS_NO_TEST_GATE=1." '{ + hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx } + }' + exit 0 +fi + +# Reminder for interactive surfaces: a unit test alone rarely covers them. +interactive=$(printf '%s\n' "$app_code" | grep -E '^components/|/components/' || true) +if [ -n "$interactive" ]; then + jq -n --arg ctx "Reminder: this commit changes component code. A unit test alone usually is not enough for an interactive component; add a browser test (webjs test --browser) that asserts the rendered/hydrated behaviour." '{ + hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx } + }' +fi +exit 0 diff --git a/packages/cli/templates/.claude/settings.json b/packages/cli/templates/.claude/settings.json new file mode 100644 index 000000000..6f71bc42e --- /dev/null +++ b/packages/cli/templates/.claude/settings.json @@ -0,0 +1,81 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/block-raw-htmlelement.sh" + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/check-server-imports.sh" + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/block-prose-punctuation.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/guard-branch-context.sh" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/require-tests-with-src.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/nudge-uncommitted.sh" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/cleanup-merged-worktree.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/commit-before-stop.sh" + } + ] + } + ] + } +} diff --git a/packages/cli/templates/.github/pull_request_template.md b/packages/cli/templates/.github/pull_request_template.md index 2942c9d18..c698a389e 100644 --- a/packages/cli/templates/.github/pull_request_template.md +++ b/packages/cli/templates/.github/pull_request_template.md @@ -16,14 +16,14 @@ a codebase rots. Walk every markdown file in the project (`git ls-files '*.md'`) and ask whether this PR changed behaviour, surface, or invariants it describes. For each row below, write `Updated ` or `N/A because `. Reviewers should reject the PR if this section -is left as the template default. The workflow rules are in -[`.agents/rules/workflow.md`](../.agents/rules/workflow.md). +is left as the template default. See the **Definition of done** section +in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance. - [ ] **Tests.** Unit coverage for logic. Real-browser coverage for user-facing behaviour. - [ ] **Every markdown file in the project** that describes the changed surface. Common cases (non-exhaustive): `AGENTS.md` (root - + nested), `README.md` (root + nested), + + nested), `CONVENTIONS.md`, `README.md` (root + nested), `CHANGELOG.md`, `docs/**/*.md`, `.agents/skills/webjs/**/*.md`, `.github/*.md`. The rule is generative: if a markdown file in this project mentions a thing this PR changed, it gets touched diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 1aca20672..7da873cae 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -21,13 +21,7 @@ This is what separates a working app from a broken one. playbook below says what ships and the exact order to follow. The workflow rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them too. -3. **Wire the WebJs MCP server into your agent (optional, recommended).** It - is read-only and version-matched to the app: `list_routes`, `list_actions`, - `list_components`, `list_elision`, `check`, `ui`, plus a docs and recipes - layer. Run it with `npx @webjsdev/mcp`, registered in whatever MCP config - your agent uses. This app ships no agent-specific config, so nothing is - wired for you. -4. **Read the framework source for exact contracts.** WebJs is 100% buildless +3. **Read the framework source for exact contracts.** WebJs is 100% buildless native ES modules, so the source you run IS the source you read. When you need a precise API signature or behavior, open the package source under `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`). diff --git a/packages/cli/templates/CLAUDE.md b/packages/cli/templates/CLAUDE.md new file mode 100644 index 000000000..2264bdc4f --- /dev/null +++ b/packages/cli/templates/CLAUDE.md @@ -0,0 +1,23 @@ +@AGENTS.md + +# Committing per logical unit (this OVERRIDES Claude Code's default) + +Claude Code's built-in default is to NEVER commit unless the user explicitly +asks. **For this project that default does NOT apply.** Commit and push per +logical unit (one feature, one fix, one rename, one doc rewrite) as soon as it +is complete, WITHOUT being asked. Do not save all the work for one commit at the +end. A finished implementation with zero commits is a mistake here, because git +history is the user's revert and cherry-pick safety net. + +- After each completed unit whose tests pass, `git add` the related files and + `git commit` with an imperative subject under 72 chars, then push. If 5+ files + span more than one concern, you already waited too long. +- Never commit to `main`. Work on a feature branch (the + `.claude/hooks/guard-branch-context.sh` hook enforces this). +- No AI-attribution trailers (`Co-Authored-By`, `Generated by`). + +See AGENTS.md "Git workflow" for the full contract. Two hooks back this up: the +`.claude/hooks/nudge-uncommitted.sh` PostToolUse hook reminds you while +uncommitted changes pile up during work, and the +`.claude/hooks/commit-before-stop.sh` Stop hook stops you from ending a turn +with a pile of uncommitted work still on a feature branch. diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md new file mode 100644 index 000000000..576116c75 --- /dev/null +++ b/packages/cli/templates/CONVENTIONS.md @@ -0,0 +1,36 @@ +# Conventions for {{APP_NAME}} + +The conventions for building a WebJs app live in the agent skill. **Read +`AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (it routes to focused +references under `.agents/skills/webjs/references/`, loaded on demand). This file +is the short version. + +## The essentials + +- **`app/` is routing only.** Only routing files live there (page, layout, route, + middleware, metadata routes). Feature logic goes in `modules//` + (`actions/`, `queries/`, `components/`, `utils/`); shared UI primitives go in + top-level `components/`; browser-safe helpers in `lib/utils/`. +- **Server-only code goes behind `.server.ts`.** Reach it from a page or component + through a `'use server'` action, never by importing a server-only utility + directly into browser-bound code. +- **Use the wired-up database (Drizzle).** Define real models in + `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`. + Never persist to a JSON file, an in-memory array or Map, or localStorage. +- **The scaffold ships a showcase to learn from.** A full-stack app ships a UI + feature gallery (`app/features/`, `app/examples/todo`); the api template ships + a backend-features showcase (`app/api/features/`), with logic in `modules/`. + When you build a real app, study the parts that match your task (the skill + teaches the same and survives the clear), run `npm run gallery:clear` to shed + the showcase, then grow the app in place. `AGENTS.md` has the full + template-specific playbook. +- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs + from an `interface`, routing files from `PageProps` / `LayoutProps`. Never + `any`, and never `unknown` where a real type exists. +- **Progressive enhancement is the default.** Pages render as HTML, `
    ` + navigates, a `` submits, all with JavaScript off; opt into + interactivity per behaviour inside a component. +- **Commit per logical unit** as soon as it is complete, and never push to `main`. + +Everything else (the module architecture, the `ActionResult` envelope, styling, +testing, the client router, optimistic UI) is in the skill's references. diff --git a/packages/cli/templates/gitignore b/packages/cli/templates/gitignore index 3834b840b..f2950b7de 100644 --- a/packages/cli/templates/gitignore +++ b/packages/cli/templates/gitignore @@ -59,10 +59,10 @@ Thumbs.db # test artifacts coverage/ -# AI assistants: local session state, scheduled-task locks, etc. The -# scaffold ships no agent config of its own (AGENTS.md and .agents/ are -# the guidance, and both are tracked), so whatever your tools write here -# is yours and stays local. -.claude/ -.cursor/ -.gemini/ +# AI assistants: local session state, scheduled-task locks, etc. +# Repo-shared config (settings.json + hooks scripts) stays tracked so +# every contributor and agent gets the same PreToolUse rules. +.claude/* +!.claude/settings.json +!.claude/hooks/ +!.claude/hooks/** diff --git a/test/hooks/block-prose-punctuation.test.mjs b/test/hooks/block-prose-punctuation.test.mjs index 2fb88ec31..4bc0d9bbb 100644 --- a/test/hooks/block-prose-punctuation.test.mjs +++ b/test/hooks/block-prose-punctuation.test.mjs @@ -280,30 +280,20 @@ test('no tracked JSON or front-matter prose value carries a banned pause', () => } }); -// --- Drift guard: the dogfood copy keeps the rules it carries --- -// -// There used to be THREE copies of this hook: this repo's, the scaffold's, -// and the blog dogfood app's, and the guard asserted the scaffold and blog -// copies were byte-identical. The scaffold ships no agent config at all now -// (these prose rules are OUR house style, and a generated app has no reason -// to inherit them), so that pairing is gone and only two copies remain. -// -// They are deliberately NOT compared byte-for-byte: the repo copy carries a -// fifth rule (brand casing) the blog copy predates. What must hold is that the -// blog copy still carries the prose-key patterns it does implement, and that -// NEITHER copy reintroduces the SIGPIPE bug, where a `grep -q` behind a pipe -// silently skips its rule once the payload outgrows the pipe buffer. - -test('the dogfood hook copy keeps its prose rules and the SIGPIPE fix', () => { - const blogSrc = readFileSync(resolve(REPO_ROOT, 'examples/blog/.claude/hooks/block-prose-punctuation.sh'), 'utf8'); - const repoSrc = readFileSync(HOOK, 'utf8'); - - const blogKeyPatterns = [...blogSrc.matchAll(/^if grep -qE '(.*)' <<< "\$new_content"; then$/gm)] - .map((m) => m[1]) - .filter((p) => p.includes('(description|title|displayName)')); - assert.equal(blogKeyPatterns.length, 4, 'blog copy carries the four prose-key patterns'); +// --- Drift guard: all three copies of the hook stay in step --- + +test('the scaffold and dogfood hook copies carry the same rules', () => { + const scaffold = resolve(REPO_ROOT, 'packages/cli/templates/.claude/hooks/block-prose-punctuation.sh'); + const blog = resolve(REPO_ROOT, 'examples/blog/.claude/hooks/block-prose-punctuation.sh'); + const scaffoldSrc = readFileSync(scaffold, 'utf8'); + assert.equal(scaffoldSrc, readFileSync(blog, 'utf8'), 'the two copies are byte-identical'); - for (const [label, src] of [['repo', repoSrc], ['blog', blogSrc]]) { + for (const pattern of proseKeyPatterns()) { + assert.ok(scaffoldSrc.includes(pattern), `copy is missing a prose-key pattern: ${pattern}`); + } + // The SIGPIPE fix must hold in every copy: a `grep -q` behind a pipe silently + // skips its rule once the payload outgrows the pipe buffer. + for (const [label, src] of [['repo', readFileSync(HOOK, 'utf8')], ['scaffold', scaffoldSrc]]) { assert.equal( /^if printf .*\| grep -q/m.test(src), false, diff --git a/test/hooks/check-server-imports.test.mjs b/test/hooks/check-server-imports.test.mjs new file mode 100644 index 000000000..be73221de --- /dev/null +++ b/test/hooks/check-server-imports.test.mjs @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The scaffolded check-server-imports hook (#804): WARN at write time when a +// browser-facing module imports a server-only `.server.*` utility (no +// 'use server'), silent for a 'use server' action or an `import type`. +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', '..', + 'packages/cli/templates/.claude/hooks/check-server-imports.mjs', +); + +function run(app, filePath, content) { + const payload = JSON.stringify({ tool_input: { file_path: filePath, content } }); + return execFileSync('node', [HOOK, payload], { encoding: 'utf8' }); +} + +function makeApp() { + const dir = mkdtempSync(join(tmpdir(), 'webjs-hook-')); + mkdirSync(join(dir, 'db'), { recursive: true }); + mkdirSync(join(dir, 'modules', 'todos', 'components'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), '{}'); + writeFileSync(join(dir, 'db', 'queries.server.ts'), 'export function q(){return 1}\n'); + writeFileSync(join(dir, 'modules', 'todos', 'actions.server.ts'), "'use server';\nexport async function a(){return 1}\n"); + return dir; +} + +test('WARNs when a component imports a server-only utility (#804)', () => { + const dir = makeApp(); + try { + const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import { q } from '#db/queries.server.ts';\nexport class X {}"); + assert.match(out, /server-only utility/, 'warns about the server-only import'); + assert.match(out, /additionalContext/, 'emits a PreToolUse additionalContext warning'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('silent when the imported .server.ts is a use-server action (#804)', () => { + const dir = makeApp(); + try { + const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import { a } from '#modules/todos/actions.server.ts';\nexport class X {}"); + assert.equal(out.trim(), '', 'a use-server RPC action import is fine, no warning'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('silent for an import type from a server file (#804)', () => { + const dir = makeApp(); + try { + const out = run(dir, join(dir, 'modules/todos/components/list.ts'), "import type { T } from '#db/queries.server.ts';\nexport class X {}"); + assert.equal(out.trim(), '', 'a type-only import is erased by the stripper, no warning'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/hooks/require-tests-with-src.test.mjs b/test/hooks/require-tests-with-src.test.mjs new file mode 100644 index 000000000..814d2965a --- /dev/null +++ b/test/hooks/require-tests-with-src.test.mjs @@ -0,0 +1,169 @@ +// Tests for the SCAFFOLDED require-tests-with-src PreToolUse hook +// (packages/cli/templates/.claude/hooks/require-tests-with-src.sh, the +// one shipped into every user app by `webjs create`). The hook reads a +// tool-call payload on stdin and, for a `git commit` that stages app +// code (app/, modules/, components/, lib/) without a test, WARNS by +// default (exit 0 + an additionalContext message) and HARD-BLOCKS +// (exit 2) only when WEBJS_TEST_GATE=block opts in. +// +// Each case builds a throwaway git repo, stages a specific shape of +// change, and feeds the hook the commit payload, asserting the exit +// code and (where relevant) the emitted stdout. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The scaffolded hook (templates/), NOT the framework's own self-gate. +const HOOK = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../packages/cli/templates/.claude/hooks/require-tests-with-src.sh', +); + +/** Init a throwaway app-shaped repo with one committed baseline file. */ +function makeRepo() { + const dir = mkdtempSync(join(tmpdir(), 'webjs-app-testgate-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + git('init', '-q'); + git('config', 'user.email', 't@t'); + git('config', 'user.name', 't'); + mkdirSync(join(dir, 'modules/posts'), { recursive: true }); + mkdirSync(join(dir, 'components'), { recursive: true }); + mkdirSync(join(dir, 'test/posts'), { recursive: true }); + writeFileSync(join(dir, 'README.md'), 'docs\n'); + git('add', '-A'); + git('commit', '-qm', 'init'); + return { dir, git }; +} + +/** Run the hook in `dir` with a commit payload and the given env. */ +function runHook(dir, env = {}) { + const r = spawnSync('bash', [HOOK], { + cwd: dir, + input: JSON.stringify({ tool_input: { command: 'git commit -m x' } }), + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + return r; // { status, stdout, stderr } +} + +test('default: app code staged with no test WARNS and allows the commit', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); + git('add', 'modules/posts/create.server.ts'); + const r = runHook(dir); + assert.equal(r.status, 0, 'warn mode must allow the commit (exit 0)'); + // The warning rides additionalContext (the JSON the hook prints to stdout). + assert.match(r.stdout, /additionalContext/); + assert.match(r.stdout, /stages app code .* with no test/); + assert.match(r.stdout, /WEBJS_TEST_GATE=block/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('WEBJS_TEST_GATE=block: app code staged with no test BLOCKS (exit 2)', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); + git('add', 'modules/posts/create.server.ts'); + const r = runHook(dir, { WEBJS_TEST_GATE: 'block' }); + assert.equal(r.status, 2, 'hard mode must block (exit 2)'); + assert.match(r.stderr, /BLOCKED: this commit changes app code but stages no test/); + // Counterfactual: the SAME input is allowed without the env opt-in. + assert.equal(runHook(dir).status, 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('WEBJS_TEST_GATE=hard is an accepted alias for block', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); + git('add', 'modules/posts/create.server.ts'); + assert.equal(runHook(dir, { WEBJS_TEST_GATE: 'hard' }).status, 2); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('app code staged WITH a test: exit 0, no test-gate warning', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); + writeFileSync(join(dir, 'test/posts/create.test.ts'), 'test\n'); + git('add', '-A'); + const r = runHook(dir); + assert.equal(r.status, 0); + // The no-test warning must not fire when a test is present. + assert.doesNotMatch(r.stdout, /with no test/); + // Same under the hard env (a present test passes regardless). + assert.equal(runHook(dir, { WEBJS_TEST_GATE: 'block' }).status, 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('component change WITH a test still warns to add browser coverage', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'components/widget.ts'), 'export const x = 1\n'); + writeFileSync(join(dir, 'test/posts/widget.test.ts'), 'test\n'); + git('add', '-A'); + const r = runHook(dir); + assert.equal(r.status, 0); + // The interactive-component reminder fires (a unit test alone is not enough). + assert.match(r.stdout, /component code/); + assert.match(r.stdout, /browser test/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('component change with NO test warns about the missing test (subsumes the reminder)', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'components/widget.ts'), 'export const x = 1\n'); + git('add', 'components/widget.ts'); + const r = runHook(dir); + assert.equal(r.status, 0); + // A missing test subsumes the component reminder: one warning, valid JSON. + assert.match(r.stdout, /with no test/); + assert.doesNotMatch(r.stdout, /Reminder: this commit changes component code/); + assert.doesNotThrow(() => JSON.parse(r.stdout), 'the emitted JSON must be a single valid object'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('allows a docs-only commit (no app code touched)', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'README.md'), 'more docs\n'); + git('add', 'README.md'); + const r = runHook(dir); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), '', 'no warning for a non-app-code commit'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('WEBJS_NO_TEST_GATE=1 skips the gate entirely (no warn, no block)', () => { + const { dir, git } = makeRepo(); + try { + writeFileSync(join(dir, 'modules/posts/create.server.ts'), 'export const x = 1\n'); + git('add', 'modules/posts/create.server.ts'); + const r = runHook(dir, { WEBJS_NO_TEST_GATE: '1' }); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), '', 'skip env must emit nothing'); + // It even overrides the hard gate. + const r2 = runHook(dir, { WEBJS_NO_TEST_GATE: '1', WEBJS_TEST_GATE: 'block' }); + assert.equal(r2.status, 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('does not fire on a non-commit git command', () => { + const { dir } = makeRepo(); + try { + const r = spawnSync('bash', [HOOK], { + cwd: dir, + input: JSON.stringify({ tool_input: { command: 'git status' } }), + encoding: 'utf8', + }); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/scaffolds/scaffold-agent-skills.test.js b/test/scaffolds/scaffold-agent-skills.test.js index f1c1138b4..4909656eb 100644 --- a/test/scaffolds/scaffold-agent-skills.test.js +++ b/test/scaffolds/scaffold-agent-skills.test.js @@ -55,25 +55,6 @@ for (const template of ['full-stack', 'api']) { assert.ok(!existsSync(join(skillsDir, monorepoOnly)), `${monorepoOnly} is a monorepo workflow skill and must not ship to a generated app`); } - - // And no per-agent rule files or vendor tool config at all. AGENTS.md - // plus .agents/ is the whole agent surface, so the app does not carry - // one team's tool choices into another's repo. The monorepo keeps its - // own .claude/; none of it is scaffolded. - const appDir = join(cwd, 'demo'); - for (const perAgent of [ - 'CLAUDE.md', 'CONVENTIONS.md', '.claude', '.claude.json', - '.cursorrules', '.cursor', 'GEMINI.md', '.gemini', '.opencode', - '.github/copilot-instructions.md', '.windsurfrules', - ]) { - assert.ok(!existsSync(join(appDir, perAgent)), - `${perAgent} is per-agent config and must not ship to a generated app`); - } - // AGENTS.md is what makes the single surface reachable: nothing reads - // .agents/ on its own, so it must route there. - const agentsMd = await readFile(join(appDir, 'AGENTS.md'), 'utf8'); - assert.match(agentsMd, /\.agents\/skills\/webjs/, 'AGENTS.md routes to the skill'); - assert.match(agentsMd, /\.agents\/rules\/workflow\.md/, 'AGENTS.md routes to the workflow rules'); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index d32d328bd..de4cc67df 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -86,25 +86,21 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(existsSync(join(appDir, 'package.json'))); assert.ok(existsSync(join(appDir, 'tsconfig.json'))); - // ONE agent surface: AGENTS.md (the open standard agents read natively) - // pointing at .agents/, which carries the skill and the workflow rules. - for (const f of ['AGENTS.md', '.agents/skills/webjs/SKILL.md', '.agents/rules/workflow.md', '.editorconfig']) { + // Single cross-agent source (AGENTS.md + the one skill + the .agents workflow + // rules), CONVENTIONS.md, CLAUDE.md bridge, and Claude protective hooks. + for (const f of ['AGENTS.md', '.agents/skills/webjs/SKILL.md', '.agents/rules/workflow.md', 'CLAUDE.md', 'CONVENTIONS.md', '.claude/settings.json', '.editorconfig']) { assert.ok(existsSync(join(appDir, f)), `${f} should exist`); } - // NO per-agent rule files or vendor tool config. How a team runs its - // tools is the team's call, and the rules that protect the app are - // enforced agent-agnostically by `webjs check`, CI, and .hooks/pre-commit. - for (const f of [ - 'CLAUDE.md', 'CONVENTIONS.md', '.claude', '.claude.json', '.claude/settings.json', - 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules', '.gemini', '.opencode', - 'LAYOUT-REFERENCE.md', - ]) { + // Per-agent files and design-distinctness ceremony removed. + for (const f of ['GEMINI.md', '.github/copilot-instructions.md', '.cursorrules', 'LAYOUT-REFERENCE.md', '.claude/hooks/design-review-before-stop.sh', '.claude/skills/webjs-design-review']) { assert.ok(!existsSync(join(appDir, f)), `${f} should NOT exist in the scaffold`); } - // AGENTS.md must actually route to the skill, since nothing else does. - const agentsMd = readFileSync(join(appDir, 'AGENTS.md'), 'utf8'); - assert.match(agentsMd, /\.agents\/skills\/webjs/, 'AGENTS.md points at the skill'); - assert.match(agentsMd, /\.agents\/rules\/workflow\.md/, 'AGENTS.md points at the workflow rules'); + // Thin bridges (pointers to AGENTS.md / the skill). + for (const f of ['CLAUDE.md', 'CONVENTIONS.md']) { + const src = readFileSync(join(appDir, f), 'utf8'); + assert.ok(src.length < 2200, `${f} is a thin bridge, not a full rule duplicate`); + assert.match(src, /AGENTS\.md|\.agents\/skills\/webjs/, `${f} points at AGENTS.md or the skill`); + } // #271: the opt-in progressive-enhancement service worker + its offline // fallback ship into the UI scaffold (full-stack; api has no UI), @@ -233,16 +229,41 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(!/from '(\.\.\/){2,}/.test(src), `${f.slice(appDir.length)} must not keep a deep relative import`); } - // The scaffold ships NO agent-specific enforcement hooks. What used to be - // a set of Claude PreToolUse/Stop hooks is now covered agent-agnostically, - // which is the point: it binds every agent rather than one. - // - a server-only import in a browser module -> `webjs check`'s - // no-server-import-in-browser-module rule, run in CI - // - committing on main -> .hooks/pre-commit, a git-level block - // - tests with source, commit cadence -> .agents/rules/workflow.md - for (const f of ['.claude', '.claude.json']) { - assert.ok(!existsSync(join(appDir, f)), `${f} must not ship`); + // The require-tests hook still reaches the scaffolded app for Claude + // Code: the hook file is copied and the Claude settings wire it into + // PreToolUse. (The tool-agnostic test gate has moved to CI, see below.) + assert.ok(existsSync(join(appDir, '.claude/hooks/require-tests-with-src.sh')), + 'require-tests hook is scaffolded'); + const claudeSettings = JSON.parse( + readFileSync(join(appDir, '.claude/settings.json'), 'utf8'), + ); + const preCommands = (claudeSettings.hooks?.PreToolUse ?? []) + .flatMap((g) => g.hooks.map((h) => h.command)); + assert.ok( + preCommands.includes('.claude/hooks/require-tests-with-src.sh'), + 'settings.json wires the require-tests hook into PreToolUse', + ); + + // Commit enforcement for Claude Code: CLAUDE.md overrides Claude Code's + // never-commit default, a Stop hook backstops end-of-turn, and a + // PostToolUse hook removes merged worktrees after `gh pr merge`. + const claudeMd = readFileSync(join(appDir, 'CLAUDE.md'), 'utf8'); + assert.match(claudeMd, /OVERRIDES Claude Code/i, + 'CLAUDE.md overrides Claude Code\'s never-commit default'); + for (const h of ['commit-before-stop.sh', 'cleanup-merged-worktree.sh']) { + assert.ok(existsSync(join(appDir, '.claude/hooks', h)), `${h} is scaffolded`); } + const stopCommands = (claudeSettings.hooks?.Stop ?? []) + .flatMap((g) => g.hooks.map((h) => h.command)); + assert.ok(stopCommands.includes('.claude/hooks/commit-before-stop.sh'), + 'settings.json wires commit-before-stop into Stop'); + const postCommands = (claudeSettings.hooks?.PostToolUse ?? []) + .flatMap((g) => g.hooks.map((h) => h.command)); + assert.ok(postCommands.includes('.claude/hooks/cleanup-merged-worktree.sh'), + 'settings.json wires cleanup-merged-worktree into PostToolUse'); + + // The design-review ceremony (a skill + a UserPromptSubmit router + a Stop + // hook) was retired in #969; only the protective hooks above ship now. // The local pre-commit hook is lightweight: it blocks commits to main // and nothing else. The test/convention gate runs in CI, not locally, @@ -651,7 +672,7 @@ test('scaffoldApp: template placeholder substitution in copied files', async () const appDir = join(cwd, 'PlaceholderTest'); // Walk a few template-copied files and verify {{APP_NAME}} was replaced. - const filesToCheck = ['AGENTS.md', '.agents/rules/workflow.md']; + const filesToCheck = ['AGENTS.md', 'CONVENTIONS.md', 'CLAUDE.md']; for (const f of filesToCheck) { const p = join(appDir, f); if (!existsSync(p)) continue; @@ -782,13 +803,18 @@ test('scaffoldApp: AGENTS.md build playbook is template-specific (#1076)', async assert.doesNotMatch(api, /Build components for interactivity|WebComponent/, 'api: no component/reactive-props guidance'); - // The workflow rules ship into BOTH apps, so they must be template-neutral: - // no opt-out phrasing, and they must acknowledge the api showcase rather - // than only the UI gallery. + // The sibling agent-doc surfaces (CONVENTIONS.md, .agents/rules/workflow.md) + // ship into BOTH apps, so they must be template-neutral: no opt-out phrasing, + // and they must acknowledge the api showcase rather than only the UI gallery. + const apiConv = readFileSync(join(cwd, 'api-app', 'CONVENTIONS.md'), 'utf8'); const apiFlow = readFileSync(join(cwd, 'api-app', '.agents/rules/workflow.md'), 'utf8'); - assert.doesNotMatch(apiFlow, /only while exploring|do not have to read|only (if|when) a task needs/i, - 'api workflow.md: no opt-out phrasing'); - assert.match(apiFlow, /app\/api\/features/, 'api workflow.md: acknowledges the api showcase'); + for (const [label, md] of [['CONVENTIONS.md', apiConv], ['workflow.md', apiFlow]]) { + assert.doesNotMatch(md, /only while exploring|do not have to read|only (if|when) a task needs/i, + `api ${label}: no opt-out phrasing`); + } + for (const [label, md] of [['CONVENTIONS.md', apiConv], ['workflow.md', apiFlow]]) { + assert.match(md, /app\/api\/features/, `api ${label}: acknowledges the api showcase`); + } } finally { restore(); await rm(cwd, { recursive: true, force: true }); diff --git a/test/scaffolds/scaffold-runtime.test.js b/test/scaffolds/scaffold-runtime.test.js index cc9e61897..b097c28a2 100644 --- a/test/scaffolds/scaffold-runtime.test.js +++ b/test/scaffolds/scaffold-runtime.test.js @@ -124,7 +124,7 @@ test('bun scaffold: agent-config markdown shows bun commands, no npm commands', try { await scaffoldApp('bunapp', cwd, { template: 'full-stack', runtime: 'bun' }); const appDir = join(cwd, 'bunapp'); - for (const f of ['AGENTS.md', '.agents/rules/workflow.md']) { + for (const f of ['AGENTS.md', 'CLAUDE.md', '.agents/rules/workflow.md']) { const md = read(appDir, f); assert.doesNotMatch(md, /\bnpm run /, `${f} should not contain "npm run"`); assert.doesNotMatch(md, /\bnpx /, `${f} should not contain "npx "`); diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index 25fa16797..efe089bdb 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -256,8 +256,8 @@ test('no scaffold template ships a conformance-error action="" or formaction=""' await walk(path); continue; } - // Markdown IS in scope: `webjs create` ships AGENTS.md and the - // .agents/ guidance into every generated app, and those are what + // Markdown IS in scope: `webjs create` ships AGENTS.md, CONVENTIONS.md, + // and the agent rule files into every generated app, and those are what // TEACH the idiom. The one exclusion is the generated skill bundle under // `.agents/skills/`, the single place prose legitimately writes the // attribute while explaining why not to use it. (`prepack` copies it in diff --git a/website/app/docs/ai-first/page.ts b/website/app/docs/ai-first/page.ts index 9d48092b7..729b9fd43 100644 --- a/website/app/docs/ai-first/page.ts +++ b/website/app/docs/ai-first/page.ts @@ -113,7 +113,7 @@ export async function createPost(
  • AGENTS.md, read natively by Cursor, opencode, Antigravity, and the Copilot coding agent
  • .agents/skills/webjs/SKILL.md, the shipped routing skill carrying the framework context
  • .agents/rules/workflow.md, the git, test, and review workflow rules
  • -
  • No per-agent rule files. AGENTS.md plus .agents/ is the whole agent surface, so a scaffolded app does not carry one team's tool choices into another's repo
  • +
  • Thin bridges for tools that do not read AGENTS.md natively: CLAUDE.md (Claude Code), GEMINI.md (Gemini CLI), and .github/copilot-instructions.md (Copilot in VS Code), each pointing at AGENTS.md
  • Every agent gets the same rules from that one source: check the branch before coding, sync with parent before starting, auto-generate tests, auto-update docs, ask before merging (with delete/keep prompt), no AI attribution in commits.

    diff --git a/website/app/docs/conventions/page.ts b/website/app/docs/conventions/page.ts index aa3291170..5852eba43 100644 --- a/website/app/docs/conventions/page.ts +++ b/website/app/docs/conventions/page.ts @@ -133,13 +133,16 @@ After merging, should <branch> be deleted or kept? AGENTS.mdAll agents (Cursor, opencode, Antigravity, Copilot coding agent read it natively)Framework API, conventions, recipes (the source of truth) .agents/skills/webjs/SKILL.mdAll agentsThe shipped routing skill carrying the framework context and project conventions (guidance, customizable in the prose) .agents/rules/workflow.mdAll agentsGit, test, and review workflow rules - .hooks/pre-commitAll (via git)Blocks commits directly on main, at the git level rather than per agent + CLAUDE.mdClaude CodeThin bridge pointing at AGENTS.md + GEMINI.mdGemini CLIThin bridge pointing at AGENTS.md + .claude/settings.jsonClaude CodePreToolUse hook guarding git merge/push to main + .github/copilot-instructions.mdCopilot in VS CodeThin bridge pointing at AGENTS.md .github/pull_request_template.mdAll (via GitHub)PR checklist: tests, docs, convention check .editorconfigAll editorsConsistent indent/encoding/line endings -

    There are no per-agent rule files. A scaffolded app ships AGENTS.md, which Cursor, opencode, Antigravity, and the Copilot coding agent read natively, and it routes to .agents/ for everything else. No CLAUDE.md, no .cursorrules, no vendor hook config: how your team runs its tools is your call, and the rules that protect the app are enforced agent-agnostically by webjs check and the git pre-commit hook.

    +

    All config files encode the same rules: the framework conventions, git workflow, and quality expectations. Each is formatted for its target agent's native config format.

    Autonomous Mode

    When an agent runs in sandbox or bypass-permissions mode, it follows these defaults instead of asking questions:

    @@ -178,7 +181,7 @@ cd my-app && npm run dev
  • .agents/skills/webjs/SKILL.md: the shipped routing skill with the framework context and editable project conventions
  • .agents/rules/workflow.md: git, test, and review workflow rules
  • AGENTS.md: full framework API reference
  • -
  • No per-agent rule files: AGENTS.md plus .agents/ is the whole agent surface
  • +
  • Thin bridges pointing at AGENTS.md (CLAUDE.md, GEMINI.md, .github/copilot-instructions.md)
  • .editorconfig for consistent formatting
  • package.json with scripts (dev, build, start, test, check, db:migrate, db:generate, db:studio)
  • From 7eab48ea542fac4b8700e20c4e56e200ead09835 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 20:29:00 +0530 Subject: [PATCH 7/7] fix: list the pr-review skill in the workspace rules Every skill symlinked into `.agents/skills/` must have an entry in `.agents/rules/workflow.md`, and `test/repo-health/agent-skill-parity.test.mjs` enforces both directions. The entry was missing from the commit that added the skill. --- .agents/rules/workflow.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/rules/workflow.md b/.agents/rules/workflow.md index 58c0b0a57..48f01e000 100644 --- a/.agents/rules/workflow.md +++ b/.agents/rules/workflow.md @@ -58,6 +58,7 @@ These project-level rules govern all operations inside this workspace. Antigravi - `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. + - `pr-review`: Trigger when asked to review a PR. The review runs INLINE in the session that was asked, never in a subagent and never as a multi-round cycle, and posts through the GitHub review API as one review object (a summary plus line-anchored comments with suggestion blocks). It only reviews: it does not fix findings, resolve threads, or wait on CI. - `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.